@ontrails/store 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +329 -0
- package/README.md +290 -0
- package/package.json +56 -0
- package/src/adapter-support.ts +178 -0
- package/src/crud-doctrine.ts +43 -0
- package/src/index.ts +48 -0
- package/src/jsonfile/index.ts +6 -0
- package/src/jsonfile/runtime.ts +700 -0
- package/src/jsonfile/types.ts +50 -0
- package/src/store.ts +528 -0
- package/src/testing.ts +175 -0
- package/src/trails/crud.ts +423 -0
- package/src/trails/index.ts +20 -0
- package/src/trails/reconcile.ts +299 -0
- package/src/trails/sync.ts +274 -0
- package/src/trails/utils.ts +117 -0
- package/src/types.ts +654 -0
|
@@ -0,0 +1,700 @@
|
|
|
1
|
+
import { ConflictError, InternalError, Result, resource } from '@ontrails/core';
|
|
2
|
+
import type {
|
|
3
|
+
AnyStoreDefinition,
|
|
4
|
+
AnyStoreTable,
|
|
5
|
+
EntityOf,
|
|
6
|
+
FiltersOf,
|
|
7
|
+
FixtureInputOf,
|
|
8
|
+
StoreAccessor,
|
|
9
|
+
StoreIdentifierOf,
|
|
10
|
+
StoreListOptions,
|
|
11
|
+
UpsertOf,
|
|
12
|
+
} from '../types.js';
|
|
13
|
+
import { bindStoreDefinition } from '../adapter-support.js';
|
|
14
|
+
import { versionFieldName } from '../store.js';
|
|
15
|
+
|
|
16
|
+
import type {
|
|
17
|
+
JsonFileConnection,
|
|
18
|
+
JsonFileStoreOptions,
|
|
19
|
+
JsonFileStoreResource,
|
|
20
|
+
} from './types.js';
|
|
21
|
+
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
// Mutex — single-process in-memory lock
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
/* eslint-disable promise/avoid-new -- Mutex requires manual promise control */
|
|
27
|
+
const createMutexAcquire =
|
|
28
|
+
(state: { locked: boolean }, queue: (() => void)[]): (() => Promise<void>) =>
|
|
29
|
+
() => {
|
|
30
|
+
if (!state.locked) {
|
|
31
|
+
state.locked = true;
|
|
32
|
+
return Promise.resolve();
|
|
33
|
+
}
|
|
34
|
+
return new Promise<void>((resolve) => {
|
|
35
|
+
queue.push(resolve);
|
|
36
|
+
});
|
|
37
|
+
};
|
|
38
|
+
/* eslint-enable promise/avoid-new */
|
|
39
|
+
|
|
40
|
+
const createMutexRelease =
|
|
41
|
+
(state: { locked: boolean }, queue: (() => void)[]): (() => void) =>
|
|
42
|
+
() => {
|
|
43
|
+
const next = queue.shift();
|
|
44
|
+
if (next) {
|
|
45
|
+
next();
|
|
46
|
+
} else {
|
|
47
|
+
state.locked = false;
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
interface MutexHandle {
|
|
52
|
+
readonly acquire: () => Promise<void>;
|
|
53
|
+
readonly release: () => void;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const createMutex = (): MutexHandle => {
|
|
57
|
+
const state = { locked: false };
|
|
58
|
+
const queue: (() => void)[] = [];
|
|
59
|
+
return {
|
|
60
|
+
acquire: createMutexAcquire(state, queue),
|
|
61
|
+
release: createMutexRelease(state, queue),
|
|
62
|
+
};
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
// Helpers
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
|
|
69
|
+
const defaultResourceId = 'store';
|
|
70
|
+
|
|
71
|
+
const defaultGenerateIdentity = (): string => Bun.randomUUIDv7();
|
|
72
|
+
|
|
73
|
+
const jsonFilePath = (dir: string, tableName: string): string =>
|
|
74
|
+
`${dir}/${tableName}.json`;
|
|
75
|
+
|
|
76
|
+
const matchesFilters = (
|
|
77
|
+
entity: Record<string, unknown>,
|
|
78
|
+
filters: Record<string, unknown>
|
|
79
|
+
): boolean => {
|
|
80
|
+
for (const [key, value] of Object.entries(filters)) {
|
|
81
|
+
if (entity[key] !== value) {
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return true;
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
const applyPagination = <T>(
|
|
89
|
+
items: readonly T[],
|
|
90
|
+
options?: StoreListOptions
|
|
91
|
+
): readonly T[] => {
|
|
92
|
+
if (options === undefined) {
|
|
93
|
+
return items;
|
|
94
|
+
}
|
|
95
|
+
const offset = options.offset ?? 0;
|
|
96
|
+
const limit = options.limit ?? items.length;
|
|
97
|
+
return items.slice(offset, offset + limit);
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
// ---------------------------------------------------------------------------
|
|
101
|
+
// Generated field helpers (defined before use, module-level)
|
|
102
|
+
// ---------------------------------------------------------------------------
|
|
103
|
+
|
|
104
|
+
const assignIdentity = (
|
|
105
|
+
payload: Record<string, unknown>,
|
|
106
|
+
identityField: string,
|
|
107
|
+
generate: () => string
|
|
108
|
+
): void => {
|
|
109
|
+
if (payload[identityField] === undefined) {
|
|
110
|
+
payload[identityField] = generate();
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
const assignTimestamp = (
|
|
115
|
+
payload: Record<string, unknown>,
|
|
116
|
+
generatedFields: ReadonlySet<string>,
|
|
117
|
+
isNew: boolean
|
|
118
|
+
): void => {
|
|
119
|
+
if (generatedFields.has('createdAt') && isNew) {
|
|
120
|
+
payload['createdAt'] = new Date().toISOString();
|
|
121
|
+
}
|
|
122
|
+
if (generatedFields.has('updatedAt')) {
|
|
123
|
+
payload['updatedAt'] = new Date().toISOString();
|
|
124
|
+
}
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
const resolveNextVersion = (
|
|
128
|
+
existing: Record<string, unknown> | undefined
|
|
129
|
+
): number =>
|
|
130
|
+
existing === undefined ? 1 : (existing[versionFieldName] as number) + 1;
|
|
131
|
+
|
|
132
|
+
const assignVersion = (
|
|
133
|
+
payload: Record<string, unknown>,
|
|
134
|
+
isVersioned: boolean,
|
|
135
|
+
existing: Record<string, unknown> | undefined
|
|
136
|
+
): void => {
|
|
137
|
+
if (!isVersioned) {
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
payload[versionFieldName] = resolveNextVersion(existing);
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
const checkVersionConflict = (
|
|
144
|
+
tableName: string,
|
|
145
|
+
isVersioned: boolean,
|
|
146
|
+
input: Record<string, unknown>,
|
|
147
|
+
existing: Record<string, unknown>
|
|
148
|
+
): void => {
|
|
149
|
+
if (!isVersioned) {
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
const inputVersion = input[versionFieldName] as number | undefined;
|
|
153
|
+
if (inputVersion === undefined) {
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
const currentVersion = existing[versionFieldName] as number;
|
|
157
|
+
if (inputVersion !== currentVersion) {
|
|
158
|
+
throw new ConflictError(
|
|
159
|
+
`Version conflict on "${tableName}": expected ${String(inputVersion)}, actual ${String(currentVersion)}`
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
const buildUpsertPayload = (
|
|
165
|
+
input: Record<string, unknown>,
|
|
166
|
+
existing: Record<string, unknown> | undefined,
|
|
167
|
+
identityField: string,
|
|
168
|
+
generateIdentity: () => string,
|
|
169
|
+
generatedFields: ReadonlySet<string>,
|
|
170
|
+
isVersioned: boolean
|
|
171
|
+
): Record<string, unknown> => {
|
|
172
|
+
const payload = { ...input };
|
|
173
|
+
assignIdentity(payload, identityField, generateIdentity);
|
|
174
|
+
assignTimestamp(payload, generatedFields, existing === undefined);
|
|
175
|
+
assignVersion(
|
|
176
|
+
payload,
|
|
177
|
+
isVersioned,
|
|
178
|
+
existing as Record<string, unknown> | undefined
|
|
179
|
+
);
|
|
180
|
+
return payload;
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
// ---------------------------------------------------------------------------
|
|
184
|
+
// File I/O helpers
|
|
185
|
+
// ---------------------------------------------------------------------------
|
|
186
|
+
|
|
187
|
+
const loadFromDisk = async <TTable extends AnyStoreTable>(
|
|
188
|
+
path: string,
|
|
189
|
+
identityField: string,
|
|
190
|
+
index: Map<string, EntityOf<TTable>>
|
|
191
|
+
): Promise<void> => {
|
|
192
|
+
const file = Bun.file(path);
|
|
193
|
+
const exists = await file.exists();
|
|
194
|
+
if (!exists) {
|
|
195
|
+
await Bun.write(path, '[]');
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
const text = await file.text();
|
|
199
|
+
const rows = JSON.parse(text) as EntityOf<TTable>[];
|
|
200
|
+
for (const row of rows) {
|
|
201
|
+
const id = String((row as Record<string, unknown>)[identityField]);
|
|
202
|
+
index.set(id, row);
|
|
203
|
+
}
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
const flushToDisk = async <TTable extends AnyStoreTable>(
|
|
207
|
+
path: string,
|
|
208
|
+
index: Map<string, EntityOf<TTable>>
|
|
209
|
+
): Promise<void> => {
|
|
210
|
+
const rows = [...index.values()];
|
|
211
|
+
await Bun.write(path, JSON.stringify(rows, null, 2));
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
// ---------------------------------------------------------------------------
|
|
215
|
+
// Table config — groups table metadata for passing to helpers
|
|
216
|
+
// ---------------------------------------------------------------------------
|
|
217
|
+
|
|
218
|
+
interface TableConfig {
|
|
219
|
+
readonly generateIdentity: () => string;
|
|
220
|
+
readonly generatedFields: ReadonlySet<string>;
|
|
221
|
+
readonly identityField: string;
|
|
222
|
+
readonly isVersioned: boolean;
|
|
223
|
+
readonly tableName: string;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// ---------------------------------------------------------------------------
|
|
227
|
+
// Upsert core (extracted to stay under max-statements)
|
|
228
|
+
// ---------------------------------------------------------------------------
|
|
229
|
+
|
|
230
|
+
const resolveExisting = <TTable extends AnyStoreTable>(
|
|
231
|
+
raw: Record<string, unknown>,
|
|
232
|
+
identityField: string,
|
|
233
|
+
index: Map<string, EntityOf<TTable>>
|
|
234
|
+
): EntityOf<TTable> | undefined => {
|
|
235
|
+
const id = String(raw[identityField] ?? '');
|
|
236
|
+
return id ? index.get(id) : undefined;
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
const mergeAndBuild = <TTable extends AnyStoreTable>(
|
|
240
|
+
raw: Record<string, unknown>,
|
|
241
|
+
existing: EntityOf<TTable> | undefined,
|
|
242
|
+
identityField: string,
|
|
243
|
+
generateIdentity: () => string,
|
|
244
|
+
generatedFields: ReadonlySet<string>,
|
|
245
|
+
isVersioned: boolean
|
|
246
|
+
): EntityOf<TTable> => {
|
|
247
|
+
const merged = existing === undefined ? { ...raw } : { ...existing, ...raw };
|
|
248
|
+
const payload = buildUpsertPayload(
|
|
249
|
+
merged as Record<string, unknown>,
|
|
250
|
+
existing as Record<string, unknown> | undefined,
|
|
251
|
+
identityField,
|
|
252
|
+
generateIdentity,
|
|
253
|
+
generatedFields,
|
|
254
|
+
isVersioned
|
|
255
|
+
);
|
|
256
|
+
return payload as EntityOf<TTable>;
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
// Flush an upsert to disk then commit to the live index. Extracted to keep
|
|
260
|
+
// executeUpsert under the max-statements limit.
|
|
261
|
+
const flushAndCommitUpsert = async <TTable extends AnyStoreTable>(
|
|
262
|
+
entity: EntityOf<TTable>,
|
|
263
|
+
id: string,
|
|
264
|
+
index: Map<string, EntityOf<TTable>>,
|
|
265
|
+
path: string
|
|
266
|
+
): Promise<EntityOf<TTable>> => {
|
|
267
|
+
// Build the intended state, flush to disk, then update the live index.
|
|
268
|
+
// This ensures the in-memory Map stays consistent with the file even if
|
|
269
|
+
// the write fails.
|
|
270
|
+
const next = new Map([...index, [id, entity]]);
|
|
271
|
+
await flushToDisk(path, next);
|
|
272
|
+
index.set(id, entity);
|
|
273
|
+
return structuredClone(entity);
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
const executeUpsert = <TTable extends AnyStoreTable>(
|
|
277
|
+
input: UpsertOf<TTable>,
|
|
278
|
+
index: Map<string, EntityOf<TTable>>,
|
|
279
|
+
path: string,
|
|
280
|
+
cfg: TableConfig
|
|
281
|
+
): Promise<EntityOf<TTable>> => {
|
|
282
|
+
const raw = input as Record<string, unknown>;
|
|
283
|
+
const existing = resolveExisting(raw, cfg.identityField, index);
|
|
284
|
+
if (existing !== undefined) {
|
|
285
|
+
checkVersionConflict(
|
|
286
|
+
cfg.tableName,
|
|
287
|
+
cfg.isVersioned,
|
|
288
|
+
raw,
|
|
289
|
+
existing as Record<string, unknown>
|
|
290
|
+
);
|
|
291
|
+
}
|
|
292
|
+
const entity = mergeAndBuild(
|
|
293
|
+
raw,
|
|
294
|
+
existing,
|
|
295
|
+
cfg.identityField,
|
|
296
|
+
cfg.generateIdentity,
|
|
297
|
+
cfg.generatedFields,
|
|
298
|
+
cfg.isVersioned
|
|
299
|
+
);
|
|
300
|
+
const id = String((entity as Record<string, unknown>)[cfg.identityField]);
|
|
301
|
+
return flushAndCommitUpsert(entity, id, index, path);
|
|
302
|
+
};
|
|
303
|
+
|
|
304
|
+
// ---------------------------------------------------------------------------
|
|
305
|
+
// JsonFileTable — manages one table's data on disk + in memory
|
|
306
|
+
// ---------------------------------------------------------------------------
|
|
307
|
+
|
|
308
|
+
interface JsonFileTableOptions {
|
|
309
|
+
readonly dir: string;
|
|
310
|
+
readonly table: AnyStoreTable;
|
|
311
|
+
readonly generateIdentity: () => string;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const deriveTableConfig = (options: JsonFileTableOptions): TableConfig => ({
|
|
315
|
+
generateIdentity: options.generateIdentity,
|
|
316
|
+
generatedFields: new Set<string>(options.table.generated),
|
|
317
|
+
identityField: options.table.identity,
|
|
318
|
+
isVersioned: options.table.versioned,
|
|
319
|
+
tableName: options.table.name,
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
interface JsonFileTableReuseConfig {
|
|
323
|
+
readonly generateIdentity: (() => string) | undefined;
|
|
324
|
+
readonly generatedFields: readonly string[];
|
|
325
|
+
readonly identityField: string;
|
|
326
|
+
readonly isVersioned: boolean;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const usesGeneratedIdentity = (config: {
|
|
330
|
+
readonly generatedFields: readonly string[];
|
|
331
|
+
readonly identityField: string;
|
|
332
|
+
}): boolean => config.generatedFields.includes(config.identityField);
|
|
333
|
+
|
|
334
|
+
const deriveTableReuseConfig = (
|
|
335
|
+
options: JsonFileTableOptions
|
|
336
|
+
): JsonFileTableReuseConfig => {
|
|
337
|
+
const generatedFields = [...options.table.generated].toSorted();
|
|
338
|
+
return {
|
|
339
|
+
generateIdentity: usesGeneratedIdentity({
|
|
340
|
+
generatedFields,
|
|
341
|
+
identityField: options.table.identity,
|
|
342
|
+
})
|
|
343
|
+
? options.generateIdentity
|
|
344
|
+
: undefined,
|
|
345
|
+
generatedFields,
|
|
346
|
+
identityField: options.table.identity,
|
|
347
|
+
isVersioned: options.table.versioned,
|
|
348
|
+
};
|
|
349
|
+
};
|
|
350
|
+
|
|
351
|
+
const diffTableReuseConfig = (
|
|
352
|
+
cached: JsonFileTableReuseConfig,
|
|
353
|
+
incoming: JsonFileTableReuseConfig
|
|
354
|
+
): readonly string[] => {
|
|
355
|
+
const mismatches: string[] = [];
|
|
356
|
+
|
|
357
|
+
if (cached.identityField !== incoming.identityField) {
|
|
358
|
+
mismatches.push('identityField');
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
if (cached.isVersioned !== incoming.isVersioned) {
|
|
362
|
+
mismatches.push('versioned');
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
if (
|
|
366
|
+
cached.generatedFields.length !== incoming.generatedFields.length ||
|
|
367
|
+
cached.generatedFields.some(
|
|
368
|
+
(field, index) => field !== incoming.generatedFields[index]
|
|
369
|
+
)
|
|
370
|
+
) {
|
|
371
|
+
mismatches.push('generatedFields');
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
if (cached.generateIdentity !== incoming.generateIdentity) {
|
|
375
|
+
mismatches.push('generateIdentity');
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
return mismatches;
|
|
379
|
+
};
|
|
380
|
+
|
|
381
|
+
const assertCompatibleTableReuse = (
|
|
382
|
+
path: string,
|
|
383
|
+
cached: JsonFileTableReuseConfig,
|
|
384
|
+
incoming: JsonFileTableReuseConfig
|
|
385
|
+
): void => {
|
|
386
|
+
const mismatches = diffTableReuseConfig(cached, incoming);
|
|
387
|
+
if (mismatches.length === 0) {
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
throw new ConflictError(
|
|
392
|
+
`JSON file table reuse conflict for "${path}": ${mismatches.join(', ')} differ across connections. Reuse the same table config for a shared path or isolate the stores to different directories.`
|
|
393
|
+
);
|
|
394
|
+
};
|
|
395
|
+
|
|
396
|
+
const createGetAccessor =
|
|
397
|
+
<TTable extends AnyStoreTable>(
|
|
398
|
+
index: Map<string, EntityOf<TTable>>
|
|
399
|
+
): ((id: StoreIdentifierOf<TTable>) => Promise<EntityOf<TTable> | null>) =>
|
|
400
|
+
(id) => {
|
|
401
|
+
const entity = index.get(String(id));
|
|
402
|
+
return Promise.resolve(
|
|
403
|
+
entity === undefined ? null : structuredClone(entity)
|
|
404
|
+
);
|
|
405
|
+
};
|
|
406
|
+
|
|
407
|
+
const createListAccessor =
|
|
408
|
+
<TTable extends AnyStoreTable>(
|
|
409
|
+
index: Map<string, EntityOf<TTable>>
|
|
410
|
+
): ((
|
|
411
|
+
filters?: FiltersOf<TTable>,
|
|
412
|
+
opts?: StoreListOptions
|
|
413
|
+
) => Promise<readonly EntityOf<TTable>[]>) =>
|
|
414
|
+
(filters, opts) => {
|
|
415
|
+
const all = [...index.values()];
|
|
416
|
+
const filtered =
|
|
417
|
+
filters === undefined
|
|
418
|
+
? all
|
|
419
|
+
: all.filter((entity) =>
|
|
420
|
+
matchesFilters(
|
|
421
|
+
entity as Record<string, unknown>,
|
|
422
|
+
filters as Record<string, unknown>
|
|
423
|
+
)
|
|
424
|
+
);
|
|
425
|
+
return Promise.resolve(
|
|
426
|
+
applyPagination(filtered, opts).map((e) => structuredClone(e))
|
|
427
|
+
);
|
|
428
|
+
};
|
|
429
|
+
|
|
430
|
+
// Flush a remove to disk then commit to the live index. Extracted to keep
|
|
431
|
+
// the remove closure under the max-statements limit.
|
|
432
|
+
const executeRemove = async <TTable extends AnyStoreTable>(
|
|
433
|
+
key: string,
|
|
434
|
+
index: Map<string, EntityOf<TTable>>,
|
|
435
|
+
path: string
|
|
436
|
+
): Promise<{ readonly deleted: boolean }> => {
|
|
437
|
+
if (!index.has(key)) {
|
|
438
|
+
return { deleted: false };
|
|
439
|
+
}
|
|
440
|
+
// Build the intended state, flush to disk, then update the live index.
|
|
441
|
+
// This ensures the in-memory Map stays consistent with the file even if
|
|
442
|
+
// the write fails.
|
|
443
|
+
const next = new Map([...index].filter(([k]) => k !== key));
|
|
444
|
+
await flushToDisk(path, next);
|
|
445
|
+
index.delete(key);
|
|
446
|
+
return { deleted: true };
|
|
447
|
+
};
|
|
448
|
+
|
|
449
|
+
// Module-level registry: ensures all connections to the same file share one
|
|
450
|
+
// in-memory table instance. Keyed by resolved file path.
|
|
451
|
+
// Note: entries are never evicted. This is acceptable for v1 — the adapter
|
|
452
|
+
// targets single-process, short-lived servers where the number of distinct
|
|
453
|
+
// table paths is bounded by the store definition.
|
|
454
|
+
interface JsonFileTableRegistration<TTable extends AnyStoreTable> {
|
|
455
|
+
readonly accessor: LoadableTable<TTable>;
|
|
456
|
+
readonly reuseConfig: JsonFileTableReuseConfig;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
const tableRegistry = new Map<
|
|
460
|
+
string,
|
|
461
|
+
JsonFileTableRegistration<AnyStoreTable>
|
|
462
|
+
>();
|
|
463
|
+
|
|
464
|
+
type LoadableTable<TTable extends AnyStoreTable> = StoreAccessor<TTable> & {
|
|
465
|
+
readonly load: () => Promise<void>;
|
|
466
|
+
};
|
|
467
|
+
|
|
468
|
+
const buildAndRegisterTable = <TTable extends AnyStoreTable>(
|
|
469
|
+
path: string,
|
|
470
|
+
options: JsonFileTableOptions
|
|
471
|
+
): LoadableTable<TTable> => {
|
|
472
|
+
const mutex = createMutex();
|
|
473
|
+
const index = new Map<string, EntityOf<TTable>>();
|
|
474
|
+
const cfg = deriveTableConfig(options);
|
|
475
|
+
|
|
476
|
+
const upsert = async (input: UpsertOf<TTable>): Promise<EntityOf<TTable>> => {
|
|
477
|
+
await mutex.acquire();
|
|
478
|
+
try {
|
|
479
|
+
return await executeUpsert(input, index, path, cfg);
|
|
480
|
+
} finally {
|
|
481
|
+
mutex.release();
|
|
482
|
+
}
|
|
483
|
+
};
|
|
484
|
+
|
|
485
|
+
const remove = async (
|
|
486
|
+
id: StoreIdentifierOf<TTable>
|
|
487
|
+
): Promise<{ readonly deleted: boolean }> => {
|
|
488
|
+
await mutex.acquire();
|
|
489
|
+
try {
|
|
490
|
+
return await executeRemove(String(id), index, path);
|
|
491
|
+
} finally {
|
|
492
|
+
mutex.release();
|
|
493
|
+
}
|
|
494
|
+
};
|
|
495
|
+
|
|
496
|
+
const instance: LoadableTable<TTable> = {
|
|
497
|
+
get: createGetAccessor(index),
|
|
498
|
+
list: createListAccessor(index),
|
|
499
|
+
// Skip disk load when the index already has data. This prevents a
|
|
500
|
+
// second `connectJsonFile` call from overwriting in-flight writes
|
|
501
|
+
// (the mutex only guards individual operations, not the full load
|
|
502
|
+
// sequence). It also avoids re-reading stale files on reuse.
|
|
503
|
+
load: async () => {
|
|
504
|
+
if (index.size > 0) {
|
|
505
|
+
return;
|
|
506
|
+
}
|
|
507
|
+
await loadFromDisk(path, cfg.identityField, index);
|
|
508
|
+
},
|
|
509
|
+
remove,
|
|
510
|
+
upsert,
|
|
511
|
+
};
|
|
512
|
+
tableRegistry.set(path, {
|
|
513
|
+
accessor: instance,
|
|
514
|
+
reuseConfig: deriveTableReuseConfig(options),
|
|
515
|
+
});
|
|
516
|
+
return instance;
|
|
517
|
+
};
|
|
518
|
+
|
|
519
|
+
/**
|
|
520
|
+
* Return a cached or freshly-built table instance for the given path.
|
|
521
|
+
*
|
|
522
|
+
* @remarks
|
|
523
|
+
* Tables are shared per resolved file path so multiple connections stay
|
|
524
|
+
* coherent in-process. Reuse is only allowed when table-affecting config
|
|
525
|
+
* matches exactly; otherwise the adapter throws instead of silently
|
|
526
|
+
* inheriting the first connection's runtime semantics.
|
|
527
|
+
*/
|
|
528
|
+
const createJsonFileTable = <TTable extends AnyStoreTable>(
|
|
529
|
+
options: JsonFileTableOptions
|
|
530
|
+
): LoadableTable<TTable> => {
|
|
531
|
+
const path = jsonFilePath(options.dir, options.table.name);
|
|
532
|
+
const cached = tableRegistry.get(path) as
|
|
533
|
+
| JsonFileTableRegistration<TTable>
|
|
534
|
+
| undefined;
|
|
535
|
+
if (cached === undefined) {
|
|
536
|
+
return buildAndRegisterTable<TTable>(path, options);
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
assertCompatibleTableReuse(
|
|
540
|
+
path,
|
|
541
|
+
cached.reuseConfig,
|
|
542
|
+
deriveTableReuseConfig(options)
|
|
543
|
+
);
|
|
544
|
+
return cached.accessor;
|
|
545
|
+
};
|
|
546
|
+
|
|
547
|
+
// Tracks temp directories created by mock factories so `dispose` can clean
|
|
548
|
+
// them up. WeakMap ensures entries are GC'd when the connection is released.
|
|
549
|
+
const mockTmpDirs = new WeakMap<object, string>();
|
|
550
|
+
|
|
551
|
+
const collectMockFixtures = <TStore extends AnyStoreDefinition>(
|
|
552
|
+
definition: TStore,
|
|
553
|
+
seed?: JsonFileStoreOptions<TStore>['mockSeed']
|
|
554
|
+
): Map<string, readonly FixtureInputOf<AnyStoreTable>[]> => {
|
|
555
|
+
const fixturesByTable = new Map<
|
|
556
|
+
string,
|
|
557
|
+
readonly FixtureInputOf<AnyStoreTable>[]
|
|
558
|
+
>();
|
|
559
|
+
|
|
560
|
+
for (const tableName of definition.tableNames) {
|
|
561
|
+
const table = definition.tables[tableName];
|
|
562
|
+
if (table === undefined) {
|
|
563
|
+
continue;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
const fixtures =
|
|
567
|
+
(seed?.[tableName] as
|
|
568
|
+
| readonly FixtureInputOf<typeof table>[]
|
|
569
|
+
| undefined) ?? table.fixtures;
|
|
570
|
+
if (fixtures.length > 0) {
|
|
571
|
+
fixturesByTable.set(tableName, fixtures);
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
return fixturesByTable;
|
|
576
|
+
};
|
|
577
|
+
|
|
578
|
+
const seedMockTable = async (
|
|
579
|
+
accessor: StoreAccessor<AnyStoreTable>,
|
|
580
|
+
fixtures: readonly FixtureInputOf<AnyStoreTable>[]
|
|
581
|
+
): Promise<void> => {
|
|
582
|
+
for (const fixture of fixtures) {
|
|
583
|
+
await accessor.upsert(fixture as UpsertOf<AnyStoreTable>);
|
|
584
|
+
}
|
|
585
|
+
};
|
|
586
|
+
|
|
587
|
+
const seedMockConnection = async <TStore extends AnyStoreDefinition>(
|
|
588
|
+
connection: JsonFileConnection<TStore['tables']>,
|
|
589
|
+
definition: TStore,
|
|
590
|
+
seed?: JsonFileStoreOptions<TStore>['mockSeed']
|
|
591
|
+
): Promise<void> => {
|
|
592
|
+
const fixturesByTable = collectMockFixtures(definition, seed);
|
|
593
|
+
if (fixturesByTable.size === 0) {
|
|
594
|
+
return;
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
const accessors = connection as Record<string, StoreAccessor<AnyStoreTable>>;
|
|
598
|
+
for (const tableName of definition.tableNames) {
|
|
599
|
+
const accessor = accessors[tableName];
|
|
600
|
+
const fixtures = fixturesByTable.get(tableName);
|
|
601
|
+
if (accessor === undefined || fixtures === undefined) {
|
|
602
|
+
continue;
|
|
603
|
+
}
|
|
604
|
+
await seedMockTable(accessor, fixtures);
|
|
605
|
+
}
|
|
606
|
+
};
|
|
607
|
+
|
|
608
|
+
// ---------------------------------------------------------------------------
|
|
609
|
+
// Public API
|
|
610
|
+
// ---------------------------------------------------------------------------
|
|
611
|
+
|
|
612
|
+
/**
|
|
613
|
+
* Connect to a JSON-file-backed store.
|
|
614
|
+
*
|
|
615
|
+
* Creates one `<tableName>.json` file per table in the target directory.
|
|
616
|
+
* Each file holds a JSON array of entities. The entire dataset is loaded
|
|
617
|
+
* into memory on connect and flushed to disk on every write.
|
|
618
|
+
*/
|
|
619
|
+
export const connectJsonFile = async <TStore extends AnyStoreDefinition>(
|
|
620
|
+
definition: TStore,
|
|
621
|
+
options: JsonFileStoreOptions<TStore>
|
|
622
|
+
): Promise<JsonFileConnection<TStore['tables']>> => {
|
|
623
|
+
const { dir, generateIdentity = defaultGenerateIdentity } = options;
|
|
624
|
+
const connection = {} as Record<string, StoreAccessor<AnyStoreTable>>;
|
|
625
|
+
|
|
626
|
+
for (const tableName of definition.tableNames) {
|
|
627
|
+
const table = definition.tables[tableName];
|
|
628
|
+
if (table === undefined) {
|
|
629
|
+
continue;
|
|
630
|
+
}
|
|
631
|
+
const accessor = createJsonFileTable({
|
|
632
|
+
dir,
|
|
633
|
+
generateIdentity,
|
|
634
|
+
table,
|
|
635
|
+
}) as LoadableTable<AnyStoreTable>;
|
|
636
|
+
await accessor.load();
|
|
637
|
+
connection[tableName] = accessor;
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
return Object.freeze(connection) as JsonFileConnection<TStore['tables']>;
|
|
641
|
+
};
|
|
642
|
+
|
|
643
|
+
/**
|
|
644
|
+
* Create a JSON-file-backed store resource.
|
|
645
|
+
*
|
|
646
|
+
* Returns a `Resource` that can be registered in a topo and resolved from
|
|
647
|
+
* trail context via `db.from(ctx)`.
|
|
648
|
+
*/
|
|
649
|
+
export const jsonFile = <TStore extends AnyStoreDefinition>(
|
|
650
|
+
definition: TStore,
|
|
651
|
+
options: JsonFileStoreOptions<TStore>
|
|
652
|
+
): JsonFileStoreResource<TStore['tables']> => {
|
|
653
|
+
const scope = options.id ?? defaultResourceId;
|
|
654
|
+
const store = bindStoreDefinition(definition, scope) as TStore;
|
|
655
|
+
|
|
656
|
+
return resource(scope, {
|
|
657
|
+
create: async () => {
|
|
658
|
+
try {
|
|
659
|
+
return Result.ok(await connectJsonFile(store, options));
|
|
660
|
+
} catch (error) {
|
|
661
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
662
|
+
return Result.err(
|
|
663
|
+
new InternalError(
|
|
664
|
+
`JSON file store failed to initialize in "${options.dir}": ${err.message}`,
|
|
665
|
+
{ cause: err }
|
|
666
|
+
)
|
|
667
|
+
);
|
|
668
|
+
}
|
|
669
|
+
},
|
|
670
|
+
description:
|
|
671
|
+
options.description ??
|
|
672
|
+
'JSON-file-backed store bound from an @ontrails/store definition.',
|
|
673
|
+
dispose: async (connection) => {
|
|
674
|
+
const tmpDir = mockTmpDirs.get(connection as object);
|
|
675
|
+
if (tmpDir !== undefined) {
|
|
676
|
+
const { rm } = await import('node:fs/promises');
|
|
677
|
+
await rm(tmpDir, { force: true, recursive: true });
|
|
678
|
+
mockTmpDirs.delete(connection as object);
|
|
679
|
+
for (const tableName of store.tableNames) {
|
|
680
|
+
tableRegistry.delete(jsonFilePath(tmpDir, tableName));
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
},
|
|
684
|
+
meta: options.meta,
|
|
685
|
+
mock: async () => {
|
|
686
|
+
const { mkdtemp } = await import('node:fs/promises');
|
|
687
|
+
const { join } = await import('node:path');
|
|
688
|
+
const { tmpdir } = await import('node:os');
|
|
689
|
+
const tmpDir = await mkdtemp(join(tmpdir(), 'jsonfile-mock-'));
|
|
690
|
+
const connection = await connectJsonFile(store, {
|
|
691
|
+
...options,
|
|
692
|
+
dir: tmpDir,
|
|
693
|
+
});
|
|
694
|
+
await seedMockConnection(connection, store, options.mockSeed);
|
|
695
|
+
mockTmpDirs.set(connection as object, tmpDir);
|
|
696
|
+
return connection;
|
|
697
|
+
},
|
|
698
|
+
signals: store.signals,
|
|
699
|
+
});
|
|
700
|
+
};
|