Skip to content

Commit 037609c

Browse files
Copilottypicode
andauthored
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>
1 parent da111a2 commit 037609c

7 files changed

Lines changed: 123 additions & 61 deletions

File tree

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import assert from 'node:assert/strict'
2+
import test from 'node:test'
3+
4+
import type { Adapter } from 'lowdb'
5+
6+
import { DEFAULT_SCHEMA_PATH, NormalizedAdapter } from './adapters/normalized-adapter.ts'
7+
import type { RawData } from './adapters/normalized-adapter.ts'
8+
import type { Data } from './service.ts'
9+
10+
class StubAdapter implements Adapter<RawData> {
11+
#data: RawData | null
12+
13+
constructor(data: RawData | null) {
14+
this.#data = data
15+
}
16+
17+
async read(): Promise<RawData | null> {
18+
return this.#data === null ? null : structuredClone(this.#data)
19+
}
20+
21+
async write(data: RawData): Promise<void> {
22+
this.#data = structuredClone(data)
23+
}
24+
25+
get data(): RawData | null {
26+
return this.#data
27+
}
28+
}
29+
30+
await test('read removes $schema and normalizes ids', async () => {
31+
const adapter = new StubAdapter({
32+
$schema: './custom/schema.json',
33+
posts: [{ id: 1 }, { title: 'missing id' }],
34+
profile: { name: 'x' },
35+
})
36+
37+
const normalized = await new NormalizedAdapter(adapter).read()
38+
assert.notEqual(normalized, null)
39+
40+
if (normalized === null) {
41+
return
42+
}
43+
44+
assert.equal(normalized['$schema'], undefined)
45+
assert.deepEqual(normalized['profile'], { name: 'x' })
46+
47+
const posts = normalized['posts']
48+
assert.ok(Array.isArray(posts))
49+
assert.equal(posts[0]?.['id'], '1')
50+
assert.equal(typeof posts[1]?.['id'], 'string')
51+
assert.notEqual(posts[1]?.['id'], '')
52+
})
53+
54+
await test('write always overwrites $schema', async () => {
55+
const adapter = new StubAdapter(null)
56+
const normalizedAdapter = new NormalizedAdapter(adapter)
57+
58+
await normalizedAdapter.write({ posts: [{ id: '1' }] } satisfies Data)
59+
60+
const data = adapter.data
61+
assert.notEqual(data, null)
62+
assert.equal(data?.['$schema'], DEFAULT_SCHEMA_PATH)
63+
})

src/adapters/normalized-adapter.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import type { Adapter } from 'lowdb'
2+
3+
import { randomId } from '../random-id.ts'
4+
import type { Data, Item } from '../service.ts'
5+
6+
export const DEFAULT_SCHEMA_PATH = './node_modules/json-server/schema.json'
7+
export type RawData = Record<string, Item[] | Item | string | undefined> & {
8+
$schema?: string
9+
}
10+
11+
export class NormalizedAdapter implements Adapter<Data> {
12+
#adapter: Adapter<RawData>
13+
14+
constructor(adapter: Adapter<RawData>) {
15+
this.#adapter = adapter
16+
}
17+
18+
async read(): Promise<Data | null> {
19+
const data = await this.#adapter.read()
20+
21+
if (data === null) {
22+
return null
23+
}
24+
25+
delete data['$schema']
26+
27+
for (const value of Object.values(data)) {
28+
if (Array.isArray(value)) {
29+
for (const item of value) {
30+
if (typeof item['id'] === 'number') {
31+
item['id'] = item['id'].toString()
32+
}
33+
34+
if (item['id'] === undefined) {
35+
item['id'] = randomId()
36+
}
37+
}
38+
}
39+
}
40+
41+
return data as Data
42+
}
43+
44+
async write(data: Data): Promise<void> {
45+
await this.#adapter.write({ ...data, $schema: DEFAULT_SCHEMA_PATH })
46+
}
47+
}

src/bin.ts

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,10 @@ import { DataFile, JSONFile } from "lowdb/node";
1212
import type { PackageJson } from "type-fest";
1313

1414
import { fileURLToPath } from "node:url";
15+
import { NormalizedAdapter } from "./adapters/normalized-adapter.ts";
16+
import type { RawData } from "./adapters/normalized-adapter.ts";
17+
import { Observer } from "./adapters/observer.ts";
1518
import { createApp } from "./app.ts";
16-
import { Observer } from "./observer.ts";
1719
import type { Data } from "./service.ts";
1820

1921
function help() {
@@ -123,16 +125,16 @@ if (readFileSync(file, "utf-8").trim() === "") {
123125
}
124126

125127
// Set up database
126-
let adapter: Adapter<Data>;
128+
let adapter: Adapter<RawData>;
127129
if (extname(file) === ".json5") {
128-
adapter = new DataFile<Data>(file, {
130+
adapter = new DataFile<RawData>(file, {
129131
parse: JSON5.parse,
130132
stringify: JSON5.stringify,
131133
});
132134
} else {
133-
adapter = new JSONFile<Data>(file);
135+
adapter = new JSONFile<RawData>(file);
134136
}
135-
const observer = new Observer(adapter);
137+
const observer = new Observer(new NormalizedAdapter(adapter));
136138

137139
const db = new Low<Data>(observer, {});
138140
await db.read();

src/random-id.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import { randomBytes } from 'node:crypto'
2+
3+
export function randomId(): string {
4+
return randomBytes(2).toString('hex')
5+
}

src/service.test.ts

Lines changed: 0 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -56,24 +56,6 @@ beforeEach(() => {
5656
})
5757
})
5858

59-
await test('constructor', () => {
60-
const defaultData = { posts: [{ id: '1' }, {}], object: {} } satisfies Data
61-
const db = new Low<Data>(adapter, defaultData)
62-
new Service(db)
63-
if (Array.isArray(db.data['posts'])) {
64-
const id0 = db.data['posts'][0]['id']
65-
const id1 = db.data['posts'][1]['id']
66-
assert.ok(
67-
typeof id0 === 'string' && id0 === '1',
68-
`id should not change if already set but was: ${id0}`,
69-
)
70-
assert.ok(
71-
typeof id1 === 'string' && id1.length > 0,
72-
`id should be a non empty string but was: ${id1}`,
73-
)
74-
}
75-
})
76-
7759
await test('findById', () => {
7860
const cases: [[string, string, { _embed?: string[] | string }], unknown][] = [
7961
[[POSTS, '1', {}], db.data?.[POSTS]?.[0]],

src/service.ts

Lines changed: 1 addition & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,11 @@
1-
import { randomBytes } from 'node:crypto'
2-
31
import inflection from 'inflection'
42
import { Low } from 'lowdb'
53
import sortOn from 'sort-on'
64
import type { JsonObject } from 'type-fest'
75

86
import { matchesWhere } from './matches-where.ts'
97
import { paginate, type PaginationResult } from './paginate.ts'
8+
import { randomId } from './random-id.ts'
109
export type Item = Record<string, unknown>
1110

1211
export type Data = Record<string, Item[] | Item>
@@ -15,17 +14,6 @@ export function isItem(obj: unknown): obj is Item {
1514
return typeof obj === 'object' && obj !== null && !Array.isArray(obj)
1615
}
1716

18-
export function isData(obj: unknown): obj is Data {
19-
if (typeof obj !== 'object' || obj === null) {
20-
return false
21-
}
22-
23-
const data = obj as Record<string, unknown>
24-
return Object.values(data).every((value) =>
25-
Array.isArray(value) ? value.every(isItem) : isItem(value),
26-
)
27-
}
28-
2917
export type PaginatedItems = PaginationResult<Item>
3018

3119
function ensureArray(arg: string | string[] = []): string[] {
@@ -90,35 +78,10 @@ function deleteDependents(db: Low<Data>, name: string, dependents: string[]) {
9078
})
9179
}
9280

93-
function randomId(): string {
94-
return randomBytes(2).toString('hex')
95-
}
96-
97-
function fixItemsIds(items: Item[]) {
98-
items.forEach((item) => {
99-
if (typeof item['id'] === 'number') {
100-
item['id'] = item['id'].toString()
101-
}
102-
if (item['id'] === undefined) {
103-
item['id'] = randomId()
104-
}
105-
})
106-
}
107-
108-
// Ensure all items have an id
109-
function fixAllItemsIds(data: Data) {
110-
Object.values(data).forEach((value) => {
111-
if (Array.isArray(value)) {
112-
fixItemsIds(value)
113-
}
114-
})
115-
}
116-
11781
export class Service {
11882
#db: Low<Data>
11983

12084
constructor(db: Low<Data>) {
121-
fixAllItemsIds(db.data)
12285
this.#db = db
12386
}
12487

0 commit comments

Comments
 (0)