diff --git a/packages/core/src/backend/data-source.ts b/packages/core/src/backend/data-source.ts index 915bda4..0c39aea 100644 --- a/packages/core/src/backend/data-source.ts +++ b/packages/core/src/backend/data-source.ts @@ -7,7 +7,7 @@ type TotalCount = number; type DeleteResult = unknown; -export interface DataSource { +export interface DataSource { initialize(): Promise; getTotalCount?(query?: Query): Promise; getMultiple(query?: Query): Promise; diff --git a/packages/data-source-file-jsonl/src/index.ts b/packages/data-source-file-jsonl/src/index.ts deleted file mode 100644 index 918f880..0000000 --- a/packages/data-source-file-jsonl/src/index.ts +++ /dev/null @@ -1,252 +0,0 @@ -import {readFile, writeFile} from 'fs/promises'; -import {join} from 'path'; -import { Resource, validation as v } from '@modal-sh/yasumi'; -import { DataSource, ResourceIdConfig } from '@modal-sh/yasumi/backend'; - -export class JsonLinesDataSource> implements DataSource { - private path?: string; - - private resource?: Resource; - - data: Data[] = []; - - constructor(private readonly baseDir = '') { - // noop - } - - prepareResource< - Schema extends v.BaseSchema = v.BaseSchema, - CurrentName extends string = string, - CurrentRouteName extends string = string - >(resource: Resource) { - this.path = join(this.baseDir, `${resource.state.routeName}.jsonl`); - resource.dataSource = resource.dataSource ?? this; - const originalResourceId = resource.id; - resource.id = (newIdAttr: NewIdAttr, params: ResourceIdConfig) => { - originalResourceId(newIdAttr, params); - return resource as Resource; - }; - this.resource = resource; - } - - async initialize() { - if (typeof this.path !== 'string') { - throw new Error('Resource not prepared.'); - } - - try { - const fileContents = await readFile(this.path, 'utf-8'); - const lines = fileContents.split('\n'); - this.data = lines.filter((l) => l.trim().length > 0).map((l) => JSON.parse(l)); - } catch (err) { - await writeFile(this.path, ''); - } - } - - async getTotalCount() { - return this.data.length; - } - - async getMultiple() { - return [...this.data]; - } - - async newId() { - const idConfig = this.resource?.state.shared.get('idConfig') as ResourceIdConfig; - if (typeof idConfig === 'undefined') { - throw new Error('Resource not prepared.'); - } - - const theNewId = await idConfig.generationStrategy(this); - return theNewId as string; - } - - async getById(idSerialized: string) { - if (typeof this.resource === 'undefined') { - throw new Error('Resource not prepared.'); - } - - if (typeof this.path !== 'string') { - throw new Error('Resource not prepared.'); - } - - const theIdAttr = this.resource.state.shared.get('idAttr'); - if (typeof theIdAttr === 'undefined') { - throw new Error('Resource not prepared.'); - } - const idAttr = theIdAttr as string; - const theIdConfigRaw = this.resource.state.shared.get('idConfig'); - if (typeof theIdConfigRaw === 'undefined') { - throw new Error('Resource not prepared.'); - } - const theIdConfig = theIdConfigRaw as ResourceIdConfig; - - const id = theIdConfig.deserialize(idSerialized); - const foundData = this.data.find((s) => s[idAttr] === id); - - if (foundData) { - return { - ...foundData - }; - } - - return null; - } - - async create(data: Data) { - if (typeof this.resource === 'undefined') { - throw new Error('Resource not prepared.'); - } - - if (typeof this.path !== 'string') { - throw new Error('Resource not prepared.'); - } - - const theIdAttr = this.resource.state.shared.get('idAttr'); - if (typeof theIdAttr === 'undefined') { - throw new Error('Resource not prepared.'); - } - const idAttr = theIdAttr as string; - const theIdConfigRaw = this.resource.state.shared.get('idConfig'); - if (typeof theIdConfigRaw === 'undefined') { - throw new Error('Resource not prepared.'); - } - const theIdConfig = theIdConfigRaw as ResourceIdConfig; - - const newData = { - ...data - } as Record; - - if (idAttr in newData) { - newData[idAttr] = theIdConfig.deserialize(newData[idAttr] as string); - } - - const newCollection = [ - ...this.data, - newData - ]; - - await writeFile(this.path, newCollection.map((d) => JSON.stringify(d)).join('\n')); - - return data as Data; - } - - async delete(idSerialized: string) { - if (typeof this.resource === 'undefined') { - throw new Error('Resource not prepared.'); - } - - if (typeof this.path !== 'string') { - throw new Error('Resource not prepared.'); - } - - const theIdAttr = this.resource.state.shared.get('idAttr'); - if (typeof theIdAttr === 'undefined') { - throw new Error('Resource not prepared.'); - } - const idAttr = theIdAttr as string; - const theIdConfigRaw = this.resource.state.shared.get('idConfig'); - if (typeof theIdConfigRaw === 'undefined') { - throw new Error('Resource not prepared.'); - } - const theIdConfig = theIdConfigRaw as ResourceIdConfig; - - const oldDataLength = this.data.length; - - const id = theIdConfig.deserialize(idSerialized); - const newData = this.data.filter((s) => !(s[idAttr] === id)); - - await writeFile(this.path, newData.map((d) => JSON.stringify(d)).join('\n')); - - return oldDataLength !== newData.length; - } - - async emplace(idSerialized: string, dataWithId: Data) { - if (typeof this.resource === 'undefined') { - throw new Error('Resource not prepared.'); - } - - if (typeof this.path !== 'string') { - throw new Error('Resource not prepared.'); - } - - const theIdAttr = this.resource.state.shared.get('idAttr'); - if (typeof theIdAttr === 'undefined') { - throw new Error('Resource not prepared.'); - } - const idAttr = theIdAttr as string; - const theIdConfigRaw = this.resource.state.shared.get('idConfig'); - if (typeof theIdConfigRaw === 'undefined') { - throw new Error('Resource not prepared.'); - } - const theIdConfig = theIdConfigRaw as ResourceIdConfig; - - const existing = await this.getById(idSerialized); - const id = theIdConfig.deserialize(idSerialized); - const { [idAttr]: idFromResource, ...data } = dataWithId; - const dataToEmplace = { - ...data, - [idAttr]: id, - } as Data; - - if (existing) { - const newData = this.data.map((d) => { - if (d[idAttr] === id) { - return dataToEmplace; - } - - return d; - }); - - await writeFile(this.path, newData.map((d) => JSON.stringify(d)).join('\n')); - - return [dataToEmplace, false] as [Data, boolean]; - } - - const newData = await this.create(dataToEmplace); - return [newData, true] as [Data, boolean]; - } - - async patch(idSerialized: string, data: Partial) { - if (typeof this.resource === 'undefined') { - throw new Error('Resource not prepared.'); - } - - if (typeof this.path !== 'string') { - throw new Error('Resource not prepared.'); - } - - const theIdAttr = this.resource.state.shared.get('idAttr'); - if (typeof theIdAttr === 'undefined') { - throw new Error('Resource not prepared.'); - } - const idAttr = theIdAttr as string; - const theIdConfigRaw = this.resource.state.shared.get('idConfig'); - if (typeof theIdConfigRaw === 'undefined') { - throw new Error('Resource not prepared.'); - } - const theIdConfig = theIdConfigRaw as ResourceIdConfig; - - const existing = await this.getById(idSerialized); - if (!existing) { - return null; - } - - const newItem = { - ...existing, - ...data, - } - - const id = theIdConfig.deserialize(idSerialized); - const newData = this.data.map((d) => { - if (d[idAttr] === id) { - return newItem; - } - - return d; - }); - - await writeFile(this.path, newData.map((d) => JSON.stringify(d)).join('\n')); - return newItem as Data; - } -} diff --git a/packages/data-source-file-jsonl/test/index.test.ts b/packages/data-source-file-jsonl/test/index.test.ts deleted file mode 100644 index 441ca94..0000000 --- a/packages/data-source-file-jsonl/test/index.test.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import add from '../src'; - -describe('blah', () => { - it('works', () => { - expect(add(1, 1)).toEqual(2); - }); -}); diff --git a/packages/data-source-file-jsonl/.gitignore b/packages/data-sources/file-jsonl/.gitignore similarity index 100% rename from packages/data-source-file-jsonl/.gitignore rename to packages/data-sources/file-jsonl/.gitignore diff --git a/packages/data-source-file-jsonl/LICENSE b/packages/data-sources/file-jsonl/LICENSE similarity index 100% rename from packages/data-source-file-jsonl/LICENSE rename to packages/data-sources/file-jsonl/LICENSE diff --git a/packages/data-source-file-jsonl/package.json b/packages/data-sources/file-jsonl/package.json similarity index 100% rename from packages/data-source-file-jsonl/package.json rename to packages/data-sources/file-jsonl/package.json diff --git a/packages/data-source-file-jsonl/pridepack.json b/packages/data-sources/file-jsonl/pridepack.json similarity index 100% rename from packages/data-source-file-jsonl/pridepack.json rename to packages/data-sources/file-jsonl/pridepack.json diff --git a/packages/data-sources/file-jsonl/src/index.ts b/packages/data-sources/file-jsonl/src/index.ts new file mode 100644 index 0000000..c39170a --- /dev/null +++ b/packages/data-sources/file-jsonl/src/index.ts @@ -0,0 +1,205 @@ +import {readFile, writeFile} from 'fs/promises'; +import {join} from 'path'; +import { Resource, validation as v } from '@modal-sh/yasumi'; +import { DataSource, ResourceIdConfig } from '@modal-sh/yasumi/backend'; +import assert from 'assert'; + +class ResourceNotPreparedError extends Error {} + +const resourceNotPreparedError = new ResourceNotPreparedError(); + +class ResourceIdNotDesignatedError extends Error {} + +export class JsonLinesDataSource< + Schema extends v.BaseSchema = v.BaseSchema, + CurrentName extends string = string, + CurrentRouteName extends string = string, + Data extends object = v.Output, +> implements DataSource { + private path?: string; + + private resource?: Resource; + + data: Data[] = []; + + constructor(private readonly baseDir = '') { + // noop + } + + prepareResource(resource: Resource) { + this.path = join(this.baseDir, `${resource.state.routeName}.jsonl`); + resource.dataSource = resource.dataSource ?? this; + const originalResourceId = resource.id; + resource.id = (newIdAttr: NewIdAttr, params: ResourceIdConfig) => { + originalResourceId(newIdAttr, params); + return resource as Resource; + }; + this.resource = resource; + } + + async initialize() { + assert(typeof this.path === 'string', resourceNotPreparedError); + + try { + const fileContents = await readFile(this.path, 'utf-8'); + const lines = fileContents.split('\n'); + this.data = lines.filter((l) => l.trim().length > 0).map((l) => JSON.parse(l)); + } catch (err) { + await writeFile(this.path, ''); + } + } + + async getTotalCount() { + return this.data.length; + } + + async getMultiple() { + return [...this.data]; + } + + async newId() { + const idConfig = this.resource?.state.shared.get('idConfig') as ResourceIdConfig; + assert(typeof idConfig !== 'undefined', resourceNotPreparedError); + const theNewId = await idConfig.generationStrategy(this); + return theNewId as string; + } + + async getById(idSerialized: string) { + assert(typeof this.resource !== 'undefined', resourceNotPreparedError); + assert(typeof this.path === 'string', resourceNotPreparedError); + + const idAttr = this.resource.state.shared.get('idAttr'); + assert(typeof idAttr === 'string', new ResourceIdNotDesignatedError()); + + const idConfig = this.resource.state.shared.get('idConfig') as ResourceIdConfig | undefined; + assert(typeof idConfig !== 'undefined', new ResourceIdNotDesignatedError()); + + const id = idConfig.deserialize(idSerialized); + const foundData = this.data.find((s) => (s as any)[idAttr] === id); + + if (foundData) { + return { + ...foundData + }; + } + + return null; + } + + async create(data: Data) { + assert(typeof this.resource !== 'undefined', resourceNotPreparedError); + assert(typeof this.path === 'string', resourceNotPreparedError); + + const idAttr = this.resource.state.shared.get('idAttr'); + assert(typeof idAttr === 'string', new ResourceIdNotDesignatedError()); + + const idConfig = this.resource.state.shared.get('idConfig') as ResourceIdConfig | undefined; + assert(typeof idConfig !== 'undefined', new ResourceIdNotDesignatedError()); + + const newData = { + ...data + } as Record; + + if (idAttr in newData) { + newData[idAttr] = idConfig.deserialize(newData[idAttr] as string); + } + + const newCollection = [ + ...this.data, + newData + ]; + + await writeFile(this.path, newCollection.map((d) => JSON.stringify(d)).join('\n')); + + return data as Data; + } + + async delete(idSerialized: string) { + assert(typeof this.resource !== 'undefined', resourceNotPreparedError); + assert(typeof this.path === 'string', resourceNotPreparedError); + + const idAttr = this.resource.state.shared.get('idAttr'); + assert(typeof idAttr === 'string', new ResourceIdNotDesignatedError()); + + const idConfig = this.resource.state.shared.get('idConfig') as ResourceIdConfig | undefined; + assert(typeof idConfig !== 'undefined', new ResourceIdNotDesignatedError()); + + const oldDataLength = this.data.length; + + const id = idConfig.deserialize(idSerialized); + const newData = this.data.filter((s) => !((s as any)[idAttr] === id)); + + await writeFile(this.path, newData.map((d) => JSON.stringify(d)).join('\n')); + + return oldDataLength !== newData.length; + } + + async emplace(idSerialized: string, dataWithId: Data) { + assert(typeof this.resource !== 'undefined', resourceNotPreparedError); + assert(typeof this.path === 'string', resourceNotPreparedError); + + const idAttr = this.resource.state.shared.get('idAttr'); + assert(typeof idAttr === 'string', new ResourceIdNotDesignatedError()); + + const idConfig = this.resource.state.shared.get('idConfig') as ResourceIdConfig | undefined; + assert(typeof idConfig !== 'undefined', new ResourceIdNotDesignatedError()); + + const existing = await this.getById(idSerialized); + const id = idConfig.deserialize(idSerialized); + const { [idAttr]: idFromResource, ...data } = (dataWithId as any); + const dataToEmplace = { + ...data, + [idAttr]: id, + } as Data; + + if (existing) { + const newData = this.data.map((d) => { + if ((d as any)[idAttr] === id) { + return dataToEmplace; + } + + return d; + }); + + await writeFile(this.path, newData.map((d) => JSON.stringify(d)).join('\n')); + + return [dataToEmplace, false] as [Data, boolean]; + } + + const newData = await this.create(dataToEmplace); + return [newData, true] as [Data, boolean]; + } + + async patch(idSerialized: string, data: Partial) { + assert(typeof this.resource !== 'undefined', resourceNotPreparedError); + assert(typeof this.path === 'string', resourceNotPreparedError); + + const idAttr = this.resource.state.shared.get('idAttr'); + assert(typeof idAttr === 'string', new ResourceIdNotDesignatedError()); + + const idConfig = this.resource.state.shared.get('idConfig') as ResourceIdConfig | undefined; + assert(typeof idConfig !== 'undefined', new ResourceIdNotDesignatedError()); + + const existing = await this.getById(idSerialized); + if (!existing) { + return null; + } + + const newItem = { + ...existing, + ...data, + } + + const id = idConfig.deserialize(idSerialized); + const newData = this.data.map((d) => { + if ((d as any)[idAttr] === id) { + return newItem; + } + + return d; + }); + + await writeFile(this.path, newData.map((d) => JSON.stringify(d)).join('\n')); + return newItem as Data; + } +} diff --git a/packages/data-sources/file-jsonl/test/index.test.ts b/packages/data-sources/file-jsonl/test/index.test.ts new file mode 100644 index 0000000..eaff939 --- /dev/null +++ b/packages/data-sources/file-jsonl/test/index.test.ts @@ -0,0 +1,128 @@ +import {describe, it, expect, vi, Mock, beforeAll, beforeEach} from 'vitest'; +import { readFile, writeFile } from 'fs/promises'; +import { JsonLinesDataSource } from '../src'; +import { resource, validation as v } from '@modal-sh/yasumi'; +import {DataSource} from '@modal-sh/yasumi/dist/types/backend'; + +vi.mock('fs/promises'); + +describe('prepareResource', () => { + beforeAll(() => { + const mockWriteFile = writeFile as Mock; + mockWriteFile.mockImplementation(() => { /* noop */ }) + }); + + it('works', () => { + const schema = v.object({}); + const r = resource(schema); + const ds = new JsonLinesDataSource(); + expect(() => ds.prepareResource(r)).not.toThrow(); + }); +}); + +describe('methods', () => { + const dummyItems = [ + { + id: 1, + name: 'foo', + }, + { + id: 2, + name: 'bar', + }, + { + id: 3, + name: 'baz', + }, + ]; + const schema = v.object({ + name: v.string(), + }); + let ds: DataSource>; + let mockGenerationStrategy; + beforeEach(() => { + mockGenerationStrategy = vi.fn(); + const r = resource(schema) + .id('id', { + generationStrategy: mockGenerationStrategy, + schema: v.any(), + serialize: (id) => id.toString(), + deserialize: (id) => Number(id.toString()), + }); + ds = new JsonLinesDataSource(); + ds.prepareResource(r); + }); + + beforeEach(() => { + const mockReadFile = readFile as Mock; + mockReadFile.mockReturnValueOnce( + dummyItems.map((i) => JSON.stringify(i)).join('\n') + ); + }); + + describe('initialize', () => { + it('works', async () => { + try { + await ds.initialize(); + } catch { + expect.fail('Could not initialize data source.'); + } + }); + }); + + describe('operations', () => { + beforeEach(async () => { + await ds.initialize(); + }); + + describe('getTotalCount', () => { + it('works', async () => { + if (typeof ds.getTotalCount !== 'function') { + return; + } + const totalCount = await ds.getTotalCount(); + expect(totalCount).toBe(dummyItems.length); + }); + }); + + describe('getMultiple', () => { + it('works', async () => { + const items = await ds.getMultiple(); + expect(items).toEqual(dummyItems); + }); + }); + + describe('getById', () => { + it('works', async () => { + const item = await ds.getById('2'); + const expected = dummyItems.find((i) => i.id === 2); + expect(item).toEqual(expected); + }); + }); + + describe('getSingle', () => { + it('works', async () => { + if (typeof ds.getSingle !== 'function') { + return; + } + const item = await ds.getSingle(); + const expected = dummyItems[0]; + expect(item).toEqual(expected); + }); + }); + + describe.todo('create'); + describe.todo('delete'); + describe.todo('emplace'); + describe.todo('patch'); + + describe('newId', () => { + it('works', async () => { + const v = 'newId'; + mockGenerationStrategy.mockResolvedValueOnce(v); + const id = await ds.newId(); + expect(id).toBe(v); + }); + }); + }); +}); diff --git a/packages/data-source-file-jsonl/tsconfig.json b/packages/data-sources/file-jsonl/tsconfig.json similarity index 100% rename from packages/data-source-file-jsonl/tsconfig.json rename to packages/data-sources/file-jsonl/tsconfig.json diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5e174e4..47ee74f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -37,11 +37,11 @@ importers: specifier: ^1.4.0 version: 1.5.0(@types/node@20.12.7) - packages/data-source-file-jsonl: + packages/data-sources/file-jsonl: dependencies: '@modal-sh/yasumi': specifier: '*' - version: link:../core + version: link:../../core devDependencies: '@types/node': specifier: ^20.11.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 18ec407..600b4bb 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,2 +1,2 @@ packages: - - 'packages/*' + - 'packages/**'