@@ -7,7 +7,7 @@ type TotalCount = number; | |||||
type DeleteResult = unknown; | type DeleteResult = unknown; | ||||
export interface DataSource<Schema = object, Query = object, ID = string> { | |||||
export interface DataSource<Schema extends object = object, Query extends object = object, ID extends string = string> { | |||||
initialize(): Promise<unknown>; | initialize(): Promise<unknown>; | ||||
getTotalCount?(query?: Query): Promise<TotalCount>; | getTotalCount?(query?: Query): Promise<TotalCount>; | ||||
getMultiple(query?: Query): Promise<Schema[]>; | getMultiple(query?: Query): Promise<Schema[]>; | ||||
@@ -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<Data extends Record<string, string>> implements DataSource<Data> { | |||||
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<Schema, CurrentName, CurrentRouteName>) { | |||||
this.path = join(this.baseDir, `${resource.state.routeName}.jsonl`); | |||||
resource.dataSource = resource.dataSource ?? this; | |||||
const originalResourceId = resource.id; | |||||
resource.id = <NewIdAttr extends string, NewIdSchema extends v.BaseSchema>(newIdAttr: NewIdAttr, params: ResourceIdConfig<NewIdSchema>) => { | |||||
originalResourceId(newIdAttr, params); | |||||
return resource as Resource<Schema, CurrentName, CurrentRouteName, NewIdAttr, NewIdSchema>; | |||||
}; | |||||
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<any>; | |||||
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<any>; | |||||
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<any>; | |||||
const newData = { | |||||
...data | |||||
} as Record<string, unknown>; | |||||
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<any>; | |||||
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<any>; | |||||
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<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<any>; | |||||
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; | |||||
} | |||||
} |
@@ -1,8 +0,0 @@ | |||||
import { describe, it, expect } from 'vitest'; | |||||
import add from '../src'; | |||||
describe('blah', () => { | |||||
it('works', () => { | |||||
expect(add(1, 1)).toEqual(2); | |||||
}); | |||||
}); |
@@ -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<Schema>, | |||||
> implements DataSource<Data> { | |||||
private path?: string; | |||||
private resource?: Resource<Schema, CurrentName, CurrentRouteName>; | |||||
data: Data[] = []; | |||||
constructor(private readonly baseDir = '') { | |||||
// noop | |||||
} | |||||
prepareResource(resource: Resource<Schema, CurrentName, CurrentRouteName>) { | |||||
this.path = join(this.baseDir, `${resource.state.routeName}.jsonl`); | |||||
resource.dataSource = resource.dataSource ?? this; | |||||
const originalResourceId = resource.id; | |||||
resource.id = <NewIdAttr extends string, NewIdSchema extends v.BaseSchema>(newIdAttr: NewIdAttr, params: ResourceIdConfig<NewIdSchema>) => { | |||||
originalResourceId(newIdAttr, params); | |||||
return resource as Resource<Schema, CurrentName, CurrentRouteName, NewIdAttr, NewIdSchema>; | |||||
}; | |||||
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<any>; | |||||
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<any> | 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<any> | undefined; | |||||
assert(typeof idConfig !== 'undefined', new ResourceIdNotDesignatedError()); | |||||
const newData = { | |||||
...data | |||||
} as Record<string, unknown>; | |||||
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<any> | 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<any> | 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<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<any> | 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; | |||||
} | |||||
} |
@@ -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<typeof schema>(); | |||||
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<v.Output<typeof schema>>; | |||||
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<typeof schema>(); | |||||
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); | |||||
}); | |||||
}); | |||||
}); | |||||
}); |
@@ -37,11 +37,11 @@ importers: | |||||
specifier: ^1.4.0 | specifier: ^1.4.0 | ||||
version: 1.5.0(@types/node@20.12.7) | version: 1.5.0(@types/node@20.12.7) | ||||
packages/data-source-file-jsonl: | |||||
packages/data-sources/file-jsonl: | |||||
dependencies: | dependencies: | ||||
'@modal-sh/yasumi': | '@modal-sh/yasumi': | ||||
specifier: '*' | specifier: '*' | ||||
version: link:../core | |||||
version: link:../../core | |||||
devDependencies: | devDependencies: | ||||
'@types/node': | '@types/node': | ||||
specifier: ^20.11.0 | specifier: ^20.11.0 | ||||
@@ -1,2 +1,2 @@ | |||||
packages: | packages: | ||||
- 'packages/*' | |||||
- 'packages/**' |