mirror of
https://github.com/typicode/json-server.git
synced 2026-03-13 09:35:37 +08:00
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:
63
src/adapters/normalized-adapter.test.ts
Normal file
63
src/adapters/normalized-adapter.test.ts
Normal 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)
|
||||
})
|
||||
47
src/adapters/normalized-adapter.ts
Normal file
47
src/adapters/normalized-adapter.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
12
src/bin.ts
12
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<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
5
src/random-id.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { randomBytes } from 'node:crypto'
|
||||
|
||||
export function randomId(): string {
|
||||
return randomBytes(2).toString('hex')
|
||||
}
|
||||
@@ -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]],
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user