@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.
@@ -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
+ };