Skip to content

Commit 1076f1d

Browse files
committed
Normalize DB adapter reads/writes
1 parent be9d4bb commit 1076f1d

5 files changed

Lines changed: 148 additions & 49 deletions

File tree

src/bin.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ import type { PackageJson } from "type-fest";
1313

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

@@ -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/normalized-adapter.test.ts

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 './normalized-adapter.ts'
7+
import type { RawData } from './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/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/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.ts

Lines changed: 27 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
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'
7+
import { DEFAULT_SCHEMA_PATH } from './normalized-adapter.ts'
98
import { paginate, type PaginationResult } from './paginate.ts'
9+
import { randomId } from './random-id.ts'
1010
export type Item = Record<string, unknown>
1111

1212
export type Data = Record<string, Item[] | Item>
@@ -15,23 +15,37 @@ export function isItem(obj: unknown): obj is Item {
1515
return typeof obj === 'object' && obj !== null && !Array.isArray(obj)
1616
}
1717

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-
2918
export type PaginatedItems = PaginationResult<Item>
3019

3120
function ensureArray(arg: string | string[] = []): string[] {
3221
return Array.isArray(arg) ? arg : [arg]
3322
}
3423

24+
function fixItemsIds(items: Item[]) {
25+
items.forEach((item) => {
26+
if (typeof item['id'] === 'number') {
27+
item['id'] = item['id'].toString()
28+
}
29+
if (item['id'] === undefined) {
30+
item['id'] = randomId()
31+
}
32+
})
33+
}
34+
35+
function fixAllItemsIds(data: Data) {
36+
Object.values(data).forEach((value) => {
37+
if (Array.isArray(value)) {
38+
fixItemsIds(value)
39+
}
40+
})
41+
}
42+
43+
function fixSchema(data: Data) {
44+
if (data['$schema'] === undefined) {
45+
;(data as Record<string, unknown>)['$schema'] = DEFAULT_SCHEMA_PATH
46+
}
47+
}
48+
3549
function embed(db: Low<Data>, name: string, item: Item, related: string): Item {
3650
if (inflection.singularize(related) === related) {
3751
const relatedData = db.data[inflection.pluralize(related)] as Item[]
@@ -90,38 +104,6 @@ function deleteDependents(db: Low<Data>, name: string, dependents: string[]) {
90104
})
91105
}
92106

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-
117-
// Ensure $schema is set
118-
function fixSchema(data: Data) {
119-
if (data['$schema'] === undefined) {
120-
(data as Record<string, unknown>)['$schema'] =
121-
'./node_modules/json-server/schema.json'
122-
}
123-
}
124-
125107
export class Service {
126108
#db: Low<Data>
127109

0 commit comments

Comments
 (0)