@ontrails/store 1.0.0-beta.15 → 1.0.0-beta.17

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.
Files changed (78) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/README.md +31 -15
  3. package/package.json +11 -3
  4. package/src/{internal/signal-identity.ts → adapter-support.ts} +15 -14
  5. package/src/crud-doctrine.ts +43 -0
  6. package/src/index.ts +6 -1
  7. package/src/jsonfile/runtime.ts +3 -5
  8. package/src/jsonfile/types.ts +2 -2
  9. package/src/store.ts +5 -6
  10. package/src/testing.ts +2 -2
  11. package/src/trails/crud.ts +4 -3
  12. package/src/trails/index.ts +5 -0
  13. package/src/trails/reconcile.ts +2 -2
  14. package/src/trails/utils.ts +1 -1
  15. package/src/types.ts +27 -26
  16. package/.agents/notes/2026-04-04/handoff-202604032309-9e85a104.md +0 -38
  17. package/.turbo/turbo-build.log +0 -1
  18. package/.turbo/turbo-lint.log +0 -3
  19. package/.turbo/turbo-typecheck.log +0 -1
  20. package/dist/index.d.ts +0 -3
  21. package/dist/index.d.ts.map +0 -1
  22. package/dist/index.js +0 -2
  23. package/dist/index.js.map +0 -1
  24. package/dist/internal/signal-identity.d.ts +0 -17
  25. package/dist/internal/signal-identity.d.ts.map +0 -1
  26. package/dist/internal/signal-identity.js +0 -81
  27. package/dist/internal/signal-identity.js.map +0 -1
  28. package/dist/jsonfile/index.d.ts +0 -3
  29. package/dist/jsonfile/index.d.ts.map +0 -1
  30. package/dist/jsonfile/index.js +0 -2
  31. package/dist/jsonfile/index.js.map +0 -1
  32. package/dist/jsonfile/runtime.d.ts +0 -19
  33. package/dist/jsonfile/runtime.d.ts.map +0 -1
  34. package/dist/jsonfile/runtime.js +0 -403
  35. package/dist/jsonfile/runtime.js.map +0 -1
  36. package/dist/jsonfile/types.d.ts +0 -40
  37. package/dist/jsonfile/types.d.ts.map +0 -1
  38. package/dist/jsonfile/types.js +0 -2
  39. package/dist/jsonfile/types.js.map +0 -1
  40. package/dist/store.d.ts +0 -16
  41. package/dist/store.d.ts.map +0 -1
  42. package/dist/store.js +0 -250
  43. package/dist/store.js.map +0 -1
  44. package/dist/testing.d.ts +0 -28
  45. package/dist/testing.d.ts.map +0 -1
  46. package/dist/testing.js +0 -90
  47. package/dist/testing.js.map +0 -1
  48. package/dist/trails/crud.d.ts +0 -38
  49. package/dist/trails/crud.d.ts.map +0 -1
  50. package/dist/trails/crud.js +0 -112
  51. package/dist/trails/crud.js.map +0 -1
  52. package/dist/trails/index.d.ts +0 -7
  53. package/dist/trails/index.d.ts.map +0 -1
  54. package/dist/trails/index.js +0 -4
  55. package/dist/trails/index.js.map +0 -1
  56. package/dist/trails/reconcile.d.ts +0 -36
  57. package/dist/trails/reconcile.d.ts.map +0 -1
  58. package/dist/trails/reconcile.js +0 -142
  59. package/dist/trails/reconcile.js.map +0 -1
  60. package/dist/trails/sync.d.ts +0 -25
  61. package/dist/trails/sync.d.ts.map +0 -1
  62. package/dist/trails/sync.js +0 -96
  63. package/dist/trails/sync.js.map +0 -1
  64. package/dist/trails/utils.d.ts +0 -51
  65. package/dist/trails/utils.d.ts.map +0 -1
  66. package/dist/trails/utils.js +0 -65
  67. package/dist/trails/utils.js.map +0 -1
  68. package/dist/types.d.ts +0 -394
  69. package/dist/types.d.ts.map +0 -1
  70. package/dist/types.js +0 -2
  71. package/dist/types.js.map +0 -1
  72. package/src/__tests__/crud.test.ts +0 -346
  73. package/src/__tests__/jsonfile.test.ts +0 -502
  74. package/src/__tests__/store.test.ts +0 -539
  75. package/src/__tests__/sync-reconcile.test.ts +0 -646
  76. package/tsconfig.json +0 -9
  77. package/tsconfig.tests.json +0 -10
  78. package/tsconfig.tsbuildinfo +0 -1
@@ -1,502 +0,0 @@
1
- import { copyFile, mkdtemp, rm } from 'node:fs/promises';
2
- import { tmpdir } from 'node:os';
3
- import { join } from 'node:path';
4
- import { ConflictError, Result, topo, trail } from '@ontrails/core';
5
- import { store as defineStore } from '@ontrails/store';
6
- import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
7
- import { z } from 'zod';
8
-
9
- import { connectJsonFile, jsonFile } from '../jsonfile/index.js';
10
-
11
- // ---------------------------------------------------------------------------
12
- // Fixtures
13
- // ---------------------------------------------------------------------------
14
-
15
- const itemSchema = z.object({
16
- category: z.string().optional(),
17
- id: z.string(),
18
- name: z.string(),
19
- });
20
-
21
- const versionedSchema = z.object({
22
- id: z.string(),
23
- title: z.string(),
24
- });
25
-
26
- const timestampedSchema = z.object({
27
- body: z.string(),
28
- createdAt: z.string(),
29
- id: z.string(),
30
- });
31
-
32
- const fullTimestampSchema = z.object({
33
- body: z.string(),
34
- createdAt: z.string(),
35
- id: z.string(),
36
- updatedAt: z.string(),
37
- });
38
-
39
- const itemStore = defineStore({
40
- items: { identity: 'id', schema: itemSchema },
41
- });
42
-
43
- const autoIdStore = defineStore({
44
- items: { generated: ['id'] as const, identity: 'id', schema: itemSchema },
45
- });
46
-
47
- const generatedNameStore = defineStore({
48
- items: {
49
- generated: ['name'] as const,
50
- identity: 'id',
51
- schema: itemSchema,
52
- },
53
- });
54
-
55
- const fixtureStore = defineStore({
56
- items: {
57
- fixtures: [{ id: 'fixture-1', name: 'Fixture row' }],
58
- identity: 'id',
59
- schema: itemSchema,
60
- },
61
- });
62
-
63
- const versionedStore = defineStore({
64
- docs: { identity: 'id', schema: versionedSchema, versioned: true },
65
- });
66
-
67
- const timestampedStore = defineStore({
68
- notes: {
69
- generated: ['createdAt'] as const,
70
- identity: 'id',
71
- schema: timestampedSchema,
72
- },
73
- });
74
-
75
- const fullTimestampStore = defineStore({
76
- entries: {
77
- generated: ['createdAt', 'updatedAt'] as const,
78
- identity: 'id',
79
- schema: fullTimestampSchema,
80
- },
81
- });
82
-
83
- // ---------------------------------------------------------------------------
84
- // Test setup
85
- // ---------------------------------------------------------------------------
86
-
87
- let dir: string;
88
-
89
- beforeEach(async () => {
90
- dir = await mkdtemp(join(tmpdir(), 'jsonfile-test-'));
91
- });
92
-
93
- afterEach(async () => {
94
- await rm(dir, { force: true, recursive: true });
95
- });
96
-
97
- const expectConflictError = async (
98
- run: () => unknown,
99
- messagePart: string
100
- ): Promise<void> => {
101
- const outcome = await Promise.resolve()
102
- .then(run)
103
- .then(
104
- () => null,
105
- (error: unknown) => error
106
- );
107
- if (outcome === null) {
108
- throw new Error('Expected a ConflictError');
109
- }
110
- expect(outcome).toBeInstanceOf(ConflictError);
111
- expect(String(outcome)).toContain(messagePart);
112
- };
113
-
114
- /** Copy a JSON table file into a fresh directory and load it via a new connection. */
115
- const reloadAndList = async (
116
- sourceDir: string,
117
- fileName: string
118
- ): Promise<readonly z.infer<typeof itemSchema>[]> => {
119
- const reloadDir = await mkdtemp(join(tmpdir(), 'jsonfile-reload-'));
120
- try {
121
- await copyFile(join(sourceDir, fileName), join(reloadDir, fileName));
122
- const conn = await connectJsonFile(itemStore, { dir: reloadDir });
123
- return await conn.items.list();
124
- } finally {
125
- await rm(reloadDir, { force: true, recursive: true });
126
- }
127
- };
128
-
129
- const createMockConnection = async <TConnection>(
130
- factory: (() => TConnection | Promise<TConnection>) | undefined
131
- ): Promise<TConnection> => {
132
- if (factory === undefined) {
133
- throw new Error('Expected jsonfile store mock factory to be defined');
134
- }
135
-
136
- return await factory();
137
- };
138
-
139
- const stableGeneratedId = (): string => 'generated-id';
140
-
141
- // ---------------------------------------------------------------------------
142
- // Tests
143
- // ---------------------------------------------------------------------------
144
-
145
- describe('jsonfile connector', () => {
146
- describe('upsert and get', () => {
147
- test('creates new entity', async () => {
148
- const conn = await connectJsonFile(itemStore, { dir });
149
- const result = await conn.items.upsert({ id: '1', name: 'Widget' });
150
- expect(result.id).toBe('1');
151
- expect(result.name).toBe('Widget');
152
-
153
- const fetched = await conn.items.get('1');
154
- expect(fetched).toEqual(result);
155
- });
156
-
157
- test('replaces existing entity', async () => {
158
- const conn = await connectJsonFile(itemStore, { dir });
159
- await conn.items.upsert({ id: '1', name: 'Original' });
160
- const updated = await conn.items.upsert({ id: '1', name: 'Updated' });
161
- expect(updated.name).toBe('Updated');
162
-
163
- const fetched = await conn.items.get('1');
164
- expect(fetched?.name).toBe('Updated');
165
- });
166
- });
167
-
168
- describe('get', () => {
169
- test('returns null for missing entity', async () => {
170
- const conn = await connectJsonFile(itemStore, { dir });
171
- const result = await conn.items.get('nonexistent');
172
- expect(result).toBeNull();
173
- });
174
- });
175
-
176
- describe('list', () => {
177
- test('returns all entities', async () => {
178
- const conn = await connectJsonFile(itemStore, { dir });
179
- await conn.items.upsert({ id: '1', name: 'A' });
180
- await conn.items.upsert({ id: '2', name: 'B' });
181
- await conn.items.upsert({ id: '3', name: 'C' });
182
-
183
- const all = await conn.items.list();
184
- expect(all).toHaveLength(3);
185
- });
186
-
187
- test('filters by field', async () => {
188
- const conn = await connectJsonFile(itemStore, { dir });
189
- await conn.items.upsert({ category: 'x', id: '1', name: 'A' });
190
- await conn.items.upsert({ category: 'y', id: '2', name: 'B' });
191
- await conn.items.upsert({ category: 'x', id: '3', name: 'C' });
192
-
193
- const filtered = await conn.items.list({ category: 'x' });
194
- expect(filtered).toHaveLength(2);
195
- expect(filtered.every((e) => e.category === 'x')).toBe(true);
196
- });
197
-
198
- test('limit restricts number of results', async () => {
199
- const conn = await connectJsonFile(itemStore, { dir });
200
- await conn.items.upsert({ id: '1', name: 'A' });
201
- await conn.items.upsert({ id: '2', name: 'B' });
202
- await conn.items.upsert({ id: '3', name: 'C' });
203
-
204
- const limited = await conn.items.list(undefined, { limit: 2 });
205
- expect(limited).toHaveLength(2);
206
- });
207
-
208
- test('offset skips first N results', async () => {
209
- const conn = await connectJsonFile(itemStore, { dir });
210
- await conn.items.upsert({ id: '1', name: 'A' });
211
- await conn.items.upsert({ id: '2', name: 'B' });
212
- await conn.items.upsert({ id: '3', name: 'C' });
213
-
214
- const skipped = await conn.items.list(undefined, { offset: 1 });
215
- expect(skipped).toHaveLength(2);
216
- });
217
-
218
- test('limit and offset together paginate correctly', async () => {
219
- const conn = await connectJsonFile(itemStore, { dir });
220
- await conn.items.upsert({ id: '1', name: 'A' });
221
- await conn.items.upsert({ id: '2', name: 'B' });
222
- await conn.items.upsert({ id: '3', name: 'C' });
223
- await conn.items.upsert({ id: '4', name: 'D' });
224
-
225
- const page = await conn.items.list(undefined, { limit: 2, offset: 1 });
226
- expect(page).toHaveLength(2);
227
- expect(page[0]?.name).toBe('B');
228
- expect(page[1]?.name).toBe('C');
229
- });
230
- });
231
-
232
- describe('remove', () => {
233
- test('deletes existing entity', async () => {
234
- const conn = await connectJsonFile(itemStore, { dir });
235
- await conn.items.upsert({ id: '1', name: 'A' });
236
-
237
- const result = await conn.items.remove('1');
238
- expect(result.deleted).toBe(true);
239
-
240
- const fetched = await conn.items.get('1');
241
- expect(fetched).toBeNull();
242
- });
243
-
244
- test('returns false for missing entity', async () => {
245
- const conn = await connectJsonFile(itemStore, { dir });
246
- const result = await conn.items.remove('nonexistent');
247
- expect(result.deleted).toBe(false);
248
- });
249
- });
250
-
251
- describe('versioning', () => {
252
- test('version conflict on stale upsert', async () => {
253
- const conn = await connectJsonFile(versionedStore, { dir });
254
- await conn.docs.upsert({ id: '1', title: 'Draft' });
255
-
256
- await expect(
257
- conn.docs.upsert({ id: '1', title: 'Stale', version: 99 })
258
- ).rejects.toBeInstanceOf(ConflictError);
259
- });
260
-
261
- test('version auto-increments', async () => {
262
- const conn = await connectJsonFile(versionedStore, { dir });
263
- const v1 = await conn.docs.upsert({ id: '1', title: 'Draft' });
264
- expect(v1.version).toBe(1);
265
-
266
- const v2 = await conn.docs.upsert({
267
- id: '1',
268
- title: 'Revised',
269
- version: 1,
270
- });
271
- expect(v2.version).toBe(2);
272
- });
273
- });
274
-
275
- describe('persistence', () => {
276
- test('data persists to JSON file', async () => {
277
- const conn = await connectJsonFile(itemStore, { dir });
278
- await conn.items.upsert({ id: '1', name: 'Persisted' });
279
-
280
- const file = Bun.file(join(dir, 'items.json'));
281
- const raw = await file.json();
282
- expect(raw).toHaveLength(1);
283
- expect(raw[0].name).toBe('Persisted');
284
- });
285
-
286
- test('fresh connection loads data from disk', async () => {
287
- // Write data through the first connection.
288
- const conn1 = await connectJsonFile(itemStore, { dir });
289
- await conn1.items.upsert({ id: '1', name: 'First' });
290
- await conn1.items.upsert({ id: '2', name: 'Second' });
291
-
292
- // Create a second temp directory and copy the persisted JSON file into
293
- // it. Because the module-level tableRegistry is keyed by resolved file
294
- // path, pointing connectJsonFile at a new directory produces a fresh
295
- // accessor that must load from disk rather than returning a cached
296
- // in-memory instance.
297
- const all = await reloadAndList(dir, 'items.json');
298
- expect(all).toHaveLength(2);
299
- expect(all.map((e) => e.name).toSorted()).toEqual(['First', 'Second']);
300
- });
301
- });
302
-
303
- describe('table reuse', () => {
304
- test('reuses the same table accessor when the config matches', async () => {
305
- const first = await connectJsonFile(autoIdStore, {
306
- dir,
307
- generateIdentity: stableGeneratedId,
308
- });
309
- await first.items.upsert({ name: 'First' });
310
-
311
- const second = await connectJsonFile(autoIdStore, {
312
- dir,
313
- generateIdentity: stableGeneratedId,
314
- });
315
-
316
- await expect(second.items.get('generated-id')).resolves.toEqual(
317
- expect.objectContaining({
318
- id: 'generated-id',
319
- name: 'First',
320
- })
321
- );
322
- });
323
-
324
- test('throws when a shared path is reused with a different table config', async () => {
325
- await connectJsonFile(itemStore, { dir });
326
-
327
- await expectConflictError(
328
- () => connectJsonFile(generatedNameStore, { dir }),
329
- 'generatedFields'
330
- );
331
- });
332
-
333
- test('throws when a shared path is reused with a different identity generator', async () => {
334
- await connectJsonFile(autoIdStore, {
335
- dir,
336
- generateIdentity: () => 'first-id',
337
- });
338
-
339
- await expectConflictError(
340
- () =>
341
- connectJsonFile(autoIdStore, {
342
- dir,
343
- generateIdentity: () => 'second-id',
344
- }),
345
- 'generateIdentity'
346
- );
347
- });
348
- });
349
-
350
- describe('identity generation', () => {
351
- test('generates id when not provided', async () => {
352
- const conn = await connectJsonFile(autoIdStore, {
353
- dir,
354
- generateIdentity: () => 'generated-id',
355
- });
356
- const result = await conn.items.upsert({ name: 'Auto' });
357
- expect(result.id).toBe('generated-id');
358
- });
359
- });
360
-
361
- describe('generated timestamps', () => {
362
- test('assigns createdAt on new entity', async () => {
363
- const conn = await connectJsonFile(timestampedStore, { dir });
364
- const result = await conn.notes.upsert({ body: 'Hello', id: 'n1' });
365
- expect(result.createdAt).toBeDefined();
366
- expect(typeof result.createdAt).toBe('string');
367
- });
368
-
369
- test('assigns updatedAt on update', async () => {
370
- const conn = await connectJsonFile(fullTimestampStore, { dir });
371
- const created = await conn.entries.upsert({ body: 'Hello', id: 'e1' });
372
- expect(created.updatedAt).toBeDefined();
373
-
374
- // Small delay so timestamps differ
375
- await Bun.sleep(5);
376
-
377
- const updated = await conn.entries.upsert({
378
- body: 'Revised',
379
- id: 'e1',
380
- });
381
- expect(updated.updatedAt).toBeDefined();
382
- expect(updated.updatedAt).not.toBe(created.updatedAt);
383
- });
384
- });
385
-
386
- describe('mock resource seeding', () => {
387
- test('seeds mock resources from authored fixtures', async () => {
388
- const store = jsonFile(fixtureStore, { dir });
389
- expect(store.mock).toBeDefined();
390
- const mock = await createMockConnection(store.mock);
391
- expect(mock).toBeDefined();
392
- expect(await mock.items.get('fixture-1')).toEqual(
393
- expect.objectContaining({
394
- id: 'fixture-1',
395
- name: 'Fixture row',
396
- })
397
- );
398
- expect(await mock.items.list()).toHaveLength(1);
399
-
400
- await store.dispose?.(mock);
401
- });
402
-
403
- test('uses mockSeed when provided for a table', async () => {
404
- const store = jsonFile(fixtureStore, {
405
- dir,
406
- mockSeed: {
407
- items: [{ id: 'seed-1', name: 'Seed override' }],
408
- },
409
- });
410
- expect(store.mock).toBeDefined();
411
- const mock = await createMockConnection(store.mock);
412
- expect(mock).toBeDefined();
413
- expect(await mock.items.get('seed-1')).toEqual(
414
- expect.objectContaining({
415
- id: 'seed-1',
416
- name: 'Seed override',
417
- })
418
- );
419
- expect(await mock.items.get('fixture-1')).toBeNull();
420
- expect(await mock.items.list()).toHaveLength(1);
421
-
422
- await store.dispose?.(mock);
423
- });
424
-
425
- test('preserves connector metadata on the resource definition', () => {
426
- const store = jsonFile(fixtureStore, {
427
- dir,
428
- meta: { domain: 'fixtures' },
429
- });
430
-
431
- expect(store.meta).toEqual({ domain: 'fixtures' });
432
- });
433
-
434
- test('propagates caller-provided description to the resource', () => {
435
- const store = jsonFile(fixtureStore, {
436
- description: 'Custom jsonfile store description',
437
- dir,
438
- });
439
-
440
- expect(store.description).toBe('Custom jsonfile store description');
441
- });
442
-
443
- test('uses a default description when caller omits one', () => {
444
- const store = jsonFile(fixtureStore, { dir });
445
-
446
- expect(store.description).toBe(
447
- 'JSON-file-backed store bound from an @ontrails/store definition.'
448
- );
449
- });
450
- });
451
- });
452
-
453
- describe('jsonfile connector — topo integration', () => {
454
- let topoDir: string;
455
-
456
- beforeEach(async () => {
457
- topoDir = await mkdtemp(join(tmpdir(), 'jsonfile-topo-'));
458
- });
459
-
460
- afterEach(async () => {
461
- await rm(topoDir, { force: true, recursive: true });
462
- });
463
-
464
- const scopedSetup = (tmpDir: string) => {
465
- const definition = defineStore({
466
- items: { identity: 'id', schema: itemSchema },
467
- });
468
- const storeResource = jsonFile(definition, {
469
- dir: tmpDir,
470
- id: 'primary-store',
471
- });
472
- const onCreated = trail('items.on-created', {
473
- blaze: () => Result.ok({ seen: true }),
474
- description: 'Consumer that reacts to items.created on the scoped store.',
475
- input: z.object({}).passthrough(),
476
- on: [definition.tables.items.signals.created],
477
- });
478
- return { onCreated, storeResource };
479
- };
480
-
481
- test('topo construction does not throw for on: with scoped store signal', () => {
482
- const { onCreated, storeResource } = scopedSetup(topoDir);
483
- expect(() =>
484
- topo('jsonfile-scoped-store-app', {
485
- onCreated,
486
- storeResource,
487
- } as Record<string, unknown>)
488
- ).not.toThrow();
489
- });
490
-
491
- test('registers scoped signal id and late-binds trail on: reference', () => {
492
- const { onCreated, storeResource } = scopedSetup(topoDir);
493
- const graph = topo('jsonfile-scoped-store-app-2', {
494
- onCreated,
495
- storeResource,
496
- } as Record<string, unknown>);
497
- expect(graph.signals.has('primary-store:items.created')).toBe(true);
498
- expect(graph.get('items.on-created')?.on).toContain(
499
- 'primary-store:items.created'
500
- );
501
- });
502
- });