diff --git a/src/adapters/normalized-adapter.test.ts b/src/adapters/normalized-adapter.test.ts new file mode 100644 index 000000000..67f85a3ec --- /dev/null +++ b/src/adapters/normalized-adapter.test.ts @@ -0,0 +1,63 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import type { Adapter } from 'lowdb' + +import { DEFAULT_SCHEMA_PATH, NormalizedAdapter } from './adapters/normalized-adapter.ts' +import type { RawData } from './adapters/normalized-adapter.ts' +import type { Data } from './service.ts' + +class StubAdapter implements Adapter { + #data: RawData | null + + constructor(data: RawData | null) { + this.#data = data + } + + async read(): Promise { + return this.#data === null ? null : structuredClone(this.#data) + } + + async write(data: RawData): Promise { + this.#data = structuredClone(data) + } + + get data(): RawData | null { + return this.#data + } +} + +await test('read removes $schema and normalizes ids', async () => { + const adapter = new StubAdapter({ + $schema: './custom/schema.json', + posts: [{ id: 1 }, { title: 'missing id' }], + profile: { name: 'x' }, + }) + + const normalized = await new NormalizedAdapter(adapter).read() + assert.notEqual(normalized, null) + + if (normalized === null) { + return + } + + assert.equal(normalized['$schema'], undefined) + assert.deepEqual(normalized['profile'], { name: 'x' }) + + const posts = normalized['posts'] + assert.ok(Array.isArray(posts)) + assert.equal(posts[0]?.['id'], '1') + assert.equal(typeof posts[1]?.['id'], 'string') + assert.notEqual(posts[1]?.['id'], '') +}) + +await test('write always overwrites $schema', async () => { + const adapter = new StubAdapter(null) + const normalizedAdapter = new NormalizedAdapter(adapter) + + await normalizedAdapter.write({ posts: [{ id: '1' }] } satisfies Data) + + const data = adapter.data + assert.notEqual(data, null) + assert.equal(data?.['$schema'], DEFAULT_SCHEMA_PATH) +}) diff --git a/src/adapters/normalized-adapter.ts b/src/adapters/normalized-adapter.ts new file mode 100644 index 000000000..e2d9e7284 --- /dev/null +++ b/src/adapters/normalized-adapter.ts @@ -0,0 +1,47 @@ +import type { Adapter } from 'lowdb' + +import { randomId } from '../random-id.ts' +import type { Data, Item } from '../service.ts' + +export const DEFAULT_SCHEMA_PATH = './node_modules/json-server/schema.json' +export type RawData = Record & { + $schema?: string +} + +export class NormalizedAdapter implements Adapter { + #adapter: Adapter + + constructor(adapter: Adapter) { + this.#adapter = adapter + } + + async read(): Promise { + const data = await this.#adapter.read() + + if (data === null) { + return null + } + + delete data['$schema'] + + for (const value of Object.values(data)) { + if (Array.isArray(value)) { + for (const item of value) { + if (typeof item['id'] === 'number') { + item['id'] = item['id'].toString() + } + + if (item['id'] === undefined) { + item['id'] = randomId() + } + } + } + } + + return data as Data + } + + async write(data: Data): Promise { + await this.#adapter.write({ ...data, $schema: DEFAULT_SCHEMA_PATH }) + } +} diff --git a/src/observer.ts b/src/adapters/observer.ts similarity index 100% rename from src/observer.ts rename to src/adapters/observer.ts diff --git a/src/bin.ts b/src/bin.ts index 17d4cdd36..3cc39d83b 100644 --- a/src/bin.ts +++ b/src/bin.ts @@ -12,8 +12,10 @@ import { DataFile, JSONFile } from "lowdb/node"; import type { PackageJson } from "type-fest"; import { fileURLToPath } from "node:url"; +import { NormalizedAdapter } from "./adapters/normalized-adapter.ts"; +import type { RawData } from "./adapters/normalized-adapter.ts"; +import { Observer } from "./adapters/observer.ts"; import { createApp } from "./app.ts"; -import { Observer } from "./observer.ts"; import type { Data } from "./service.ts"; function help() { @@ -123,16 +125,16 @@ if (readFileSync(file, "utf-8").trim() === "") { } // Set up database -let adapter: Adapter; +let adapter: Adapter; if (extname(file) === ".json5") { - adapter = new DataFile(file, { + adapter = new DataFile(file, { parse: JSON5.parse, stringify: JSON5.stringify, }); } else { - adapter = new JSONFile(file); + adapter = new JSONFile(file); } -const observer = new Observer(adapter); +const observer = new Observer(new NormalizedAdapter(adapter)); const db = new Low(observer, {}); await db.read(); diff --git a/src/random-id.ts b/src/random-id.ts new file mode 100644 index 000000000..1e1112436 --- /dev/null +++ b/src/random-id.ts @@ -0,0 +1,5 @@ +import { randomBytes } from 'node:crypto' + +export function randomId(): string { + return randomBytes(2).toString('hex') +} diff --git a/src/service.test.ts b/src/service.test.ts index a1c1119a6..b83b9ff69 100644 --- a/src/service.test.ts +++ b/src/service.test.ts @@ -56,24 +56,6 @@ beforeEach(() => { }) }) -await test('constructor', () => { - const defaultData = { posts: [{ id: '1' }, {}], object: {} } satisfies Data - const db = new Low(adapter, defaultData) - new Service(db) - if (Array.isArray(db.data['posts'])) { - const id0 = db.data['posts'][0]['id'] - const id1 = db.data['posts'][1]['id'] - assert.ok( - typeof id0 === 'string' && id0 === '1', - `id should not change if already set but was: ${id0}`, - ) - assert.ok( - typeof id1 === 'string' && id1.length > 0, - `id should be a non empty string but was: ${id1}`, - ) - } -}) - await test('findById', () => { const cases: [[string, string, { _embed?: string[] | string }], unknown][] = [ [[POSTS, '1', {}], db.data?.[POSTS]?.[0]], diff --git a/src/service.ts b/src/service.ts index 2d77c1a07..9b5edd224 100644 --- a/src/service.ts +++ b/src/service.ts @@ -1,5 +1,3 @@ -import { randomBytes } from 'node:crypto' - import inflection from 'inflection' import { Low } from 'lowdb' import sortOn from 'sort-on' @@ -7,6 +5,7 @@ import type { JsonObject } from 'type-fest' import { matchesWhere } from './matches-where.ts' import { paginate, type PaginationResult } from './paginate.ts' +import { randomId } from './random-id.ts' export type Item = Record export type Data = Record @@ -15,17 +14,6 @@ export function isItem(obj: unknown): obj is Item { return typeof obj === 'object' && obj !== null && !Array.isArray(obj) } -export function isData(obj: unknown): obj is Data { - if (typeof obj !== 'object' || obj === null) { - return false - } - - const data = obj as Record - return Object.values(data).every((value) => - Array.isArray(value) ? value.every(isItem) : isItem(value), - ) -} - export type PaginatedItems = PaginationResult function ensureArray(arg: string | string[] = []): string[] { @@ -90,35 +78,10 @@ function deleteDependents(db: Low, name: string, dependents: string[]) { }) } -function randomId(): string { - return randomBytes(2).toString('hex') -} - -function fixItemsIds(items: Item[]) { - items.forEach((item) => { - if (typeof item['id'] === 'number') { - item['id'] = item['id'].toString() - } - if (item['id'] === undefined) { - item['id'] = randomId() - } - }) -} - -// Ensure all items have an id -function fixAllItemsIds(data: Data) { - Object.values(data).forEach((value) => { - if (Array.isArray(value)) { - fixItemsIds(value) - } - }) -} - export class Service { #db: Low constructor(db: Low) { - fixAllItemsIds(db.data) this.#db = db }