feat: auto-insert $schema when missing from DB file on startup (#1717)

* Initial plan

* Add auto-fix for missing $schema in JSON/JSON5 DB files

Co-authored-by: typicode <5502029+typicode@users.noreply.github.com>

* Delete package-lock.json

* Normalize DB adapter reads/writes

* Move adapters under src/adapters and remove service auto-fixes

* update

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: typicode <5502029+typicode@users.noreply.github.com>
Co-authored-by: typicode <typicode@gmail.com>
This commit is contained in:
Copilot
2026-02-28 01:41:54 +01:00
committed by GitHub
parent da111a2b99
commit 037609cc1d
7 changed files with 123 additions and 61 deletions

View File

@@ -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<RawData> {
#data: RawData | null
constructor(data: RawData | null) {
this.#data = data
}
async read(): Promise<RawData | null> {
return this.#data === null ? null : structuredClone(this.#data)
}
async write(data: RawData): Promise<void> {
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)
})

View File

@@ -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<string, Item[] | Item | string | undefined> & {
$schema?: string
}
export class NormalizedAdapter implements Adapter<Data> {
#adapter: Adapter<RawData>
constructor(adapter: Adapter<RawData>) {
this.#adapter = adapter
}
async read(): Promise<Data | null> {
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<void> {
await this.#adapter.write({ ...data, $schema: DEFAULT_SCHEMA_PATH })
}
}

View File

@@ -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<Data>;
let adapter: Adapter<RawData>;
if (extname(file) === ".json5") {
adapter = new DataFile<Data>(file, {
adapter = new DataFile<RawData>(file, {
parse: JSON5.parse,
stringify: JSON5.stringify,
});
} else {
adapter = new JSONFile<Data>(file);
adapter = new JSONFile<RawData>(file);
}
const observer = new Observer(adapter);
const observer = new Observer(new NormalizedAdapter(adapter));
const db = new Low<Data>(observer, {});
await db.read();

5
src/random-id.ts Normal file
View File

@@ -0,0 +1,5 @@
import { randomBytes } from 'node:crypto'
export function randomId(): string {
return randomBytes(2).toString('hex')
}

View File

@@ -56,24 +56,6 @@ beforeEach(() => {
})
})
await test('constructor', () => {
const defaultData = { posts: [{ id: '1' }, {}], object: {} } satisfies Data
const db = new Low<Data>(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]],

View File

@@ -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<string, unknown>
export type Data = Record<string, Item[] | Item>
@@ -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<string, unknown>
return Object.values(data).every((value) =>
Array.isArray(value) ? value.every(isItem) : isItem(value),
)
}
export type PaginatedItems = PaginationResult<Item>
function ensureArray(arg: string | string[] = []): string[] {
@@ -90,35 +78,10 @@ function deleteDependents(db: Low<Data>, 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<Data>
constructor(db: Low<Data>) {
fixAllItemsIds(db.data)
this.#db = db
}