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

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 (92) hide show
  1. package/.turbo/turbo-lint.log +1 -1
  2. package/CHANGELOG.md +7 -0
  3. package/README.md +126 -61
  4. package/dist/index.d.ts +2 -2
  5. package/dist/index.d.ts.map +1 -1
  6. package/dist/index.js +1 -1
  7. package/dist/index.js.map +1 -1
  8. package/dist/internal/signal-identity.d.ts +17 -0
  9. package/dist/internal/signal-identity.d.ts.map +1 -0
  10. package/dist/internal/signal-identity.js +81 -0
  11. package/dist/internal/signal-identity.js.map +1 -0
  12. package/dist/jsonfile/index.d.ts +3 -0
  13. package/dist/jsonfile/index.d.ts.map +1 -0
  14. package/dist/jsonfile/index.js +2 -0
  15. package/dist/jsonfile/index.js.map +1 -0
  16. package/dist/jsonfile/runtime.d.ts +19 -0
  17. package/dist/jsonfile/runtime.d.ts.map +1 -0
  18. package/dist/jsonfile/runtime.js +403 -0
  19. package/dist/jsonfile/runtime.js.map +1 -0
  20. package/dist/jsonfile/types.d.ts +40 -0
  21. package/dist/jsonfile/types.d.ts.map +1 -0
  22. package/dist/{drizzle → jsonfile}/types.js.map +1 -1
  23. package/dist/store.d.ts +8 -18
  24. package/dist/store.d.ts.map +1 -1
  25. package/dist/store.js +152 -94
  26. package/dist/store.js.map +1 -1
  27. package/dist/testing.d.ts +28 -0
  28. package/dist/testing.d.ts.map +1 -0
  29. package/dist/testing.js +90 -0
  30. package/dist/testing.js.map +1 -0
  31. package/dist/trails/crud.d.ts +38 -0
  32. package/dist/trails/crud.d.ts.map +1 -0
  33. package/dist/trails/crud.js +112 -0
  34. package/dist/trails/crud.js.map +1 -0
  35. package/dist/trails/index.d.ts +7 -0
  36. package/dist/trails/index.d.ts.map +1 -0
  37. package/dist/trails/index.js +4 -0
  38. package/dist/trails/index.js.map +1 -0
  39. package/dist/trails/reconcile.d.ts +36 -0
  40. package/dist/trails/reconcile.d.ts.map +1 -0
  41. package/dist/trails/reconcile.js +142 -0
  42. package/dist/trails/reconcile.js.map +1 -0
  43. package/dist/trails/sync.d.ts +25 -0
  44. package/dist/trails/sync.d.ts.map +1 -0
  45. package/dist/trails/sync.js +96 -0
  46. package/dist/trails/sync.js.map +1 -0
  47. package/dist/trails/utils.d.ts +51 -0
  48. package/dist/trails/utils.d.ts.map +1 -0
  49. package/dist/trails/utils.js +65 -0
  50. package/dist/trails/utils.js.map +1 -0
  51. package/dist/types.d.ts +205 -35
  52. package/dist/types.d.ts.map +1 -1
  53. package/package.json +6 -9
  54. package/src/__tests__/crud.test.ts +346 -0
  55. package/src/__tests__/jsonfile.test.ts +502 -0
  56. package/src/__tests__/store.test.ts +239 -33
  57. package/src/__tests__/sync-reconcile.test.ts +646 -0
  58. package/src/index.ts +12 -8
  59. package/src/internal/signal-identity.ts +162 -0
  60. package/src/jsonfile/index.ts +6 -0
  61. package/src/jsonfile/runtime.ts +702 -0
  62. package/src/jsonfile/types.ts +50 -0
  63. package/src/store.ts +296 -134
  64. package/src/testing.ts +175 -0
  65. package/src/trails/crud.ts +355 -0
  66. package/src/trails/index.ts +10 -0
  67. package/src/trails/reconcile.ts +281 -0
  68. package/src/trails/sync.ts +251 -0
  69. package/src/trails/utils.ts +96 -0
  70. package/src/types.ts +355 -63
  71. package/tsconfig.tests.json +10 -0
  72. package/tsconfig.tsbuildinfo +1 -1
  73. package/dist/drizzle/index.d.ts +0 -3
  74. package/dist/drizzle/index.d.ts.map +0 -1
  75. package/dist/drizzle/index.js +0 -2
  76. package/dist/drizzle/index.js.map +0 -1
  77. package/dist/drizzle/runtime.d.ts +0 -21
  78. package/dist/drizzle/runtime.d.ts.map +0 -1
  79. package/dist/drizzle/runtime.js +0 -458
  80. package/dist/drizzle/runtime.js.map +0 -1
  81. package/dist/drizzle/schema.d.ts +0 -15
  82. package/dist/drizzle/schema.d.ts.map +0 -1
  83. package/dist/drizzle/schema.js +0 -322
  84. package/dist/drizzle/schema.js.map +0 -1
  85. package/dist/drizzle/types.d.ts +0 -40
  86. package/dist/drizzle/types.d.ts.map +0 -1
  87. package/src/drizzle/__tests__/drizzle.test.ts +0 -469
  88. package/src/drizzle/index.ts +0 -17
  89. package/src/drizzle/runtime.ts +0 -853
  90. package/src/drizzle/schema.ts +0 -577
  91. package/src/drizzle/types.ts +0 -70
  92. /package/dist/{drizzle → jsonfile}/types.js +0 -0
@@ -0,0 +1,646 @@
1
+ import { beforeEach, describe, expect, mock, test } from 'bun:test';
2
+ import {
3
+ ConflictError,
4
+ Result,
5
+ RetryExhaustedError,
6
+ ValidationError,
7
+ createTrailContext,
8
+ executeTrail,
9
+ resource,
10
+ } from '@ontrails/core';
11
+ import { z } from 'zod';
12
+
13
+ import type {
14
+ EntityOf,
15
+ ReadOnlyStoreTableAccessor,
16
+ StoreAccessor,
17
+ UpsertOf,
18
+ } from '../index.js';
19
+ import { store } from '../index.js';
20
+ import { reconcile, sync } from '../trails/index.js';
21
+
22
+ const cloneOrNull = <T>(value: T | undefined): T | null =>
23
+ value === undefined ? null : structuredClone(value);
24
+
25
+ const noteSchema = z.object({
26
+ body: z.string(),
27
+ createdAt: z.string(),
28
+ id: z.string(),
29
+ title: z.string(),
30
+ });
31
+
32
+ const externalNoteSchema = z.object({
33
+ bodyText: z.string(),
34
+ createdAt: z.string(),
35
+ heading: z.string(),
36
+ id: z.string(),
37
+ });
38
+
39
+ const versionedNoteDefinition = store({
40
+ notes: {
41
+ fixtures: [
42
+ {
43
+ body: 'Seed body',
44
+ createdAt: '2026-04-10T12:00:00.000Z',
45
+ id: 'note-1',
46
+ title: 'Seed title',
47
+ version: 1,
48
+ },
49
+ ],
50
+ generated: ['createdAt'],
51
+ identity: 'id',
52
+ schema: noteSchema,
53
+ versioned: true,
54
+ },
55
+ });
56
+
57
+ const plainNoteDefinition = store({
58
+ notes: {
59
+ generated: ['createdAt'],
60
+ identity: 'id',
61
+ schema: noteSchema,
62
+ },
63
+ });
64
+
65
+ const sourceDefinition = store({
66
+ externalNotes: {
67
+ fixtures: [
68
+ {
69
+ bodyText: 'Copied body',
70
+ createdAt: '2026-04-10T12:00:00.000Z',
71
+ heading: 'Copied title',
72
+ id: 'external-1',
73
+ },
74
+ ],
75
+ identity: 'id',
76
+ schema: externalNoteSchema,
77
+ },
78
+ });
79
+
80
+ const targetDefinition = store({
81
+ notes: {
82
+ fixtures: [
83
+ {
84
+ body: 'Copied body',
85
+ createdAt: '2026-04-10T12:00:00.000Z',
86
+ id: 'external-1',
87
+ title: 'Copied title',
88
+ },
89
+ ],
90
+ generated: ['createdAt'],
91
+ identity: 'id',
92
+ schema: noteSchema,
93
+ },
94
+ });
95
+
96
+ type SourceTable = typeof sourceDefinition.tables.externalNotes;
97
+ type TargetTable = typeof targetDefinition.tables.notes;
98
+ type VersionedNotesTable = typeof versionedNoteDefinition.tables.notes;
99
+ type PlainNotesTable = typeof plainNoteDefinition.tables.notes;
100
+
101
+ type SourceConnection = Readonly<
102
+ Record<'externalNotes', ReadOnlyStoreTableAccessor<SourceTable>>
103
+ >;
104
+
105
+ type TargetConnection = Readonly<Record<'notes', StoreAccessor<TargetTable>>>;
106
+ type VersionedConnection = Readonly<
107
+ Record<'notes', StoreAccessor<VersionedNotesTable>>
108
+ >;
109
+ type PlainConnection = Readonly<
110
+ Record<'notes', StoreAccessor<PlainNotesTable>>
111
+ >;
112
+
113
+ const requireFixture = <T>(value: T | undefined): T => {
114
+ if (value === undefined) {
115
+ throw new Error('Expected fixture to be present');
116
+ }
117
+
118
+ return value;
119
+ };
120
+
121
+ const sourceFixture = requireFixture(
122
+ sourceDefinition.tables.externalNotes.fixtures[0]
123
+ );
124
+ const targetFixture = requireFixture(targetDefinition.tables.notes.fixtures[0]);
125
+ const versionedFixture = requireFixture(
126
+ versionedNoteDefinition.tables.notes.fixtures[0]
127
+ );
128
+
129
+ const clone = <T>(value: T): T => structuredClone(value);
130
+
131
+ const expectOk = <T>(result: Result<T, Error>): T => {
132
+ if (result.isErr()) {
133
+ throw result.error;
134
+ }
135
+
136
+ return result.value;
137
+ };
138
+
139
+ const createSourceAccessor = (): ReadOnlyStoreTableAccessor<SourceTable> => ({
140
+ get(id) {
141
+ return Promise.resolve(
142
+ id === sourceFixture.id ? clone(sourceFixture) : null
143
+ );
144
+ },
145
+ list(filters) {
146
+ const matches =
147
+ filters === undefined ||
148
+ Object.entries(filters).every(
149
+ ([field, value]) =>
150
+ sourceFixture[field as keyof typeof sourceFixture] === value
151
+ );
152
+
153
+ return Promise.resolve(matches ? [clone(sourceFixture)] : []);
154
+ },
155
+ });
156
+
157
+ const createTargetAccessor = (
158
+ records = new Map<string, EntityOf<TargetTable>>()
159
+ ): StoreAccessor<TargetTable> => ({
160
+ get(id) {
161
+ return Promise.resolve(clone(records.get(id) ?? null));
162
+ },
163
+ list(filters) {
164
+ return Promise.resolve(
165
+ [...records.values()]
166
+ .filter((entity) =>
167
+ filters === undefined
168
+ ? true
169
+ : Object.entries(filters).every(
170
+ ([field, value]) =>
171
+ entity[field as keyof EntityOf<TargetTable>] === value
172
+ )
173
+ )
174
+ .map((entity) => clone(entity))
175
+ );
176
+ },
177
+ remove(id) {
178
+ return Promise.resolve({ deleted: records.delete(id) });
179
+ },
180
+ upsert(input) {
181
+ const next = {
182
+ body: input.body ?? targetFixture.body,
183
+ createdAt: input.createdAt ?? targetFixture.createdAt,
184
+ id: input.id ?? targetFixture.id,
185
+ title: input.title ?? targetFixture.title,
186
+ } satisfies EntityOf<TargetTable>;
187
+
188
+ records.set(next.id, next);
189
+ return Promise.resolve(clone(next));
190
+ },
191
+ });
192
+
193
+ const createConflictAccessor = (
194
+ calls: UpsertOf<VersionedNotesTable>[] = []
195
+ ): StoreAccessor<VersionedNotesTable> => {
196
+ let current = clone({
197
+ ...versionedFixture,
198
+ body: 'Current body',
199
+ title: 'Current title',
200
+ version: 2,
201
+ });
202
+
203
+ return {
204
+ get(id) {
205
+ return Promise.resolve(id === current.id ? clone(current) : null);
206
+ },
207
+ list(filters) {
208
+ const matches =
209
+ filters === undefined ||
210
+ Object.entries(filters).every(
211
+ ([field, value]) => current[field as keyof typeof current] === value
212
+ );
213
+
214
+ return Promise.resolve(matches ? [clone(current)] : []);
215
+ },
216
+ remove(id) {
217
+ const deleted = id === current.id;
218
+ if (deleted) {
219
+ current = { ...current, id: '__deleted__' };
220
+ }
221
+
222
+ return Promise.resolve({ deleted });
223
+ },
224
+ upsert(input) {
225
+ calls.push(clone(input));
226
+
227
+ if (input.id === current.id && input.version !== current.version) {
228
+ throw new ConflictError(
229
+ `Version conflict for "${current.id}": expected ${String(input.version)}, found ${String(current.version)}`
230
+ );
231
+ }
232
+
233
+ current = {
234
+ ...current,
235
+ ...input,
236
+ version: current.version + 1,
237
+ };
238
+
239
+ return clone(current);
240
+ },
241
+ };
242
+ };
243
+
244
+ const sourceResource = resource<SourceConnection>('db.source', {
245
+ create: () =>
246
+ Result.ok({
247
+ externalNotes: createSourceAccessor(),
248
+ }),
249
+ mock: () => ({
250
+ externalNotes: createSourceAccessor(),
251
+ }),
252
+ });
253
+
254
+ const targetResource = resource<TargetConnection>('db.target', {
255
+ create: () =>
256
+ Result.ok({
257
+ notes: createTargetAccessor(new Map<string, EntityOf<TargetTable>>()),
258
+ }),
259
+ mock: () => ({
260
+ notes: createTargetAccessor(new Map<string, EntityOf<TargetTable>>()),
261
+ }),
262
+ });
263
+
264
+ const plainResource = resource<PlainConnection>('db.notes.plain', {
265
+ create: () =>
266
+ Result.ok({
267
+ notes:
268
+ createTargetAccessor() as unknown as StoreAccessor<PlainNotesTable>,
269
+ }),
270
+ mock: () => ({
271
+ notes: createTargetAccessor() as unknown as StoreAccessor<PlainNotesTable>,
272
+ }),
273
+ });
274
+
275
+ const createSyncContext = (records: Map<string, EntityOf<TargetTable>>) =>
276
+ createTrailContext({
277
+ extensions: {
278
+ 'db.source': {
279
+ externalNotes: createSourceAccessor(),
280
+ },
281
+ 'db.target': {
282
+ notes: createTargetAccessor(records),
283
+ },
284
+ },
285
+ });
286
+
287
+ const expectSyncShape = (
288
+ syncNote: ReturnType<
289
+ typeof sync<SourceTable, TargetTable, SourceConnection, TargetConnection>
290
+ >
291
+ ) => {
292
+ expect(syncNote.id).toBe('notes.sync');
293
+ expect(syncNote.pattern).toBe('sync');
294
+ expect(syncNote.resources).toEqual([sourceResource, targetResource]);
295
+ expect(syncNote.contours.map((candidate) => candidate.name)).toEqual([
296
+ 'externalNotes',
297
+ 'notes',
298
+ ]);
299
+ expect(syncNote.input.safeParse({ id: sourceFixture.id }).success).toBe(true);
300
+ expect(syncNote.output?.safeParse(targetFixture).success).toBe(true);
301
+ expect(syncNote.examples).toEqual([
302
+ {
303
+ expected: targetFixture,
304
+ input: { id: sourceFixture.id },
305
+ name: 'Sync notes external-1',
306
+ },
307
+ ]);
308
+ };
309
+
310
+ describe('sync()', () => {
311
+ let targetRecords: Map<string, EntityOf<TargetTable>>;
312
+
313
+ beforeEach(() => {
314
+ targetRecords = new Map<string, EntityOf<TargetTable>>();
315
+ });
316
+
317
+ test('reads from the source resource and writes to the target resource', async () => {
318
+ const syncNote = sync({
319
+ from: {
320
+ resource: sourceResource,
321
+ table: sourceDefinition.tables.externalNotes,
322
+ },
323
+ to: {
324
+ resource: targetResource,
325
+ table: targetDefinition.tables.notes,
326
+ },
327
+ transform: (entity) => ({
328
+ body: entity.bodyText,
329
+ createdAt: entity.createdAt,
330
+ id: entity.id,
331
+ title: entity.heading,
332
+ }),
333
+ });
334
+
335
+ const result = await syncNote.blaze(
336
+ { id: sourceFixture.id },
337
+ createSyncContext(targetRecords)
338
+ );
339
+
340
+ expectSyncShape(syncNote);
341
+ expect(expectOk(result)).toEqual(targetFixture);
342
+ expect(targetRecords.get(targetFixture.id)).toEqual(targetFixture);
343
+ });
344
+
345
+ test('applies the transform before writing to the target accessor', async () => {
346
+ const transform = mock((entity: EntityOf<SourceTable>) => ({
347
+ body: `${entity.bodyText} (normalized)`,
348
+ createdAt: entity.createdAt,
349
+ id: entity.id,
350
+ title: entity.heading.toUpperCase(),
351
+ }));
352
+ const syncNote = sync({
353
+ from: {
354
+ resource: sourceResource,
355
+ table: sourceDefinition.tables.externalNotes,
356
+ },
357
+ to: {
358
+ resource: targetResource,
359
+ table: targetDefinition.tables.notes,
360
+ },
361
+ transform,
362
+ });
363
+ const records = new Map<string, EntityOf<TargetTable>>();
364
+
365
+ const result = await syncNote.blaze(
366
+ { id: sourceFixture.id },
367
+ createSyncContext(records)
368
+ );
369
+
370
+ expect(transform).toHaveBeenCalledTimes(1);
371
+ expect(expectOk(result)).toEqual({
372
+ body: 'Copied body (normalized)',
373
+ createdAt: sourceFixture.createdAt,
374
+ id: sourceFixture.id,
375
+ title: 'COPIED TITLE',
376
+ });
377
+ });
378
+ });
379
+
380
+ describe('reconcile()', () => {
381
+ test('retries version conflicts with the last-write-wins strategy', async () => {
382
+ const calls: UpsertOf<VersionedNotesTable>[] = [];
383
+ const reconcileNote = reconcile({
384
+ resource: resource<VersionedConnection>('db.notes.versioned', {
385
+ create: () =>
386
+ Result.ok({
387
+ notes: createConflictAccessor(calls),
388
+ }),
389
+ mock: () => ({
390
+ notes: createConflictAccessor([]),
391
+ }),
392
+ }),
393
+ strategy: 'last-write-wins',
394
+ table: versionedNoteDefinition.tables.notes,
395
+ });
396
+
397
+ const result = await executeTrail(
398
+ reconcileNote,
399
+ {
400
+ body: 'Incoming body',
401
+ createdAt: versionedFixture.createdAt,
402
+ id: versionedFixture.id,
403
+ title: 'Incoming title',
404
+ version: 1,
405
+ },
406
+ {
407
+ ctx: {
408
+ extensions: {
409
+ 'db.notes.versioned': {
410
+ notes: createConflictAccessor(calls),
411
+ },
412
+ },
413
+ },
414
+ }
415
+ );
416
+
417
+ expect(reconcileNote.id).toBe('notes.reconcile');
418
+ expect(reconcileNote.pattern).toBe('reconcile');
419
+ expect(
420
+ reconcileNote.input.safeParse({
421
+ body: 'Incoming body',
422
+ id: versionedFixture.id,
423
+ title: 'Incoming title',
424
+ version: 1,
425
+ }).success
426
+ ).toBe(true);
427
+ expect(reconcileNote.output?.safeParse(expectOk(result)).success).toBe(
428
+ true
429
+ );
430
+ expect(calls).toEqual([
431
+ {
432
+ body: 'Incoming body',
433
+ createdAt: versionedFixture.createdAt,
434
+ id: versionedFixture.id,
435
+ title: 'Incoming title',
436
+ version: 1,
437
+ },
438
+ {
439
+ body: 'Incoming body',
440
+ createdAt: versionedFixture.createdAt,
441
+ id: versionedFixture.id,
442
+ title: 'Incoming title',
443
+ version: 2,
444
+ },
445
+ ]);
446
+ expect(expectOk(result)).toEqual({
447
+ body: 'Incoming body',
448
+ createdAt: versionedFixture.createdAt,
449
+ id: versionedFixture.id,
450
+ title: 'Incoming title',
451
+ version: 3,
452
+ });
453
+ });
454
+
455
+ test('delegates conflict resolution to a custom strategy', async () => {
456
+ const calls: UpsertOf<VersionedNotesTable>[] = [];
457
+ const strategy = mock(
458
+ ({
459
+ current,
460
+ incoming,
461
+ }: {
462
+ current: EntityOf<VersionedNotesTable>;
463
+ incoming: UpsertOf<VersionedNotesTable>;
464
+ }) => ({
465
+ ...current,
466
+ body: `${current.body} + ${incoming.body as string}`,
467
+ title: `${current.title} / resolved`,
468
+ })
469
+ );
470
+ const reconcileNote = reconcile({
471
+ resource: resource<VersionedConnection>('db.notes.strategy', {
472
+ create: () =>
473
+ Result.ok({
474
+ notes: createConflictAccessor(calls),
475
+ }),
476
+ mock: () => ({
477
+ notes: createConflictAccessor([]),
478
+ }),
479
+ }),
480
+ strategy,
481
+ table: versionedNoteDefinition.tables.notes,
482
+ });
483
+
484
+ const result = await executeTrail(
485
+ reconcileNote,
486
+ {
487
+ body: 'Incoming body',
488
+ createdAt: versionedFixture.createdAt,
489
+ id: versionedFixture.id,
490
+ title: 'Incoming title',
491
+ version: 1,
492
+ },
493
+ {
494
+ ctx: {
495
+ extensions: {
496
+ 'db.notes.strategy': {
497
+ notes: createConflictAccessor(calls),
498
+ },
499
+ },
500
+ },
501
+ }
502
+ );
503
+
504
+ expect(strategy).toHaveBeenCalledTimes(1);
505
+ expect(calls[1]).toEqual({
506
+ body: 'Current body + Incoming body',
507
+ createdAt: versionedFixture.createdAt,
508
+ id: versionedFixture.id,
509
+ title: 'Current title / resolved',
510
+ version: 2,
511
+ });
512
+ expect(expectOk(result)).toEqual({
513
+ body: 'Current body + Incoming body',
514
+ createdAt: versionedFixture.createdAt,
515
+ id: versionedFixture.id,
516
+ title: 'Current title / resolved',
517
+ version: 3,
518
+ });
519
+ });
520
+
521
+ test('rejects non-versioned tables at factory creation time', () => {
522
+ expect(() =>
523
+ reconcile({
524
+ resource: plainResource,
525
+ table: plainNoteDefinition.tables.notes,
526
+ })
527
+ ).toThrow(ValidationError);
528
+ });
529
+
530
+ test('requires `version` in the derived input schema for versioned tables', () => {
531
+ const reconcileNote = reconcile({
532
+ resource: resource<VersionedConnection>('db.notes.versioned.required', {
533
+ create: () =>
534
+ Result.ok({
535
+ notes: createConflictAccessor([]),
536
+ }),
537
+ mock: () => ({
538
+ notes: createConflictAccessor([]),
539
+ }),
540
+ }),
541
+ strategy: 'last-write-wins',
542
+ table: versionedNoteDefinition.tables.notes,
543
+ });
544
+
545
+ const withoutVersion = reconcileNote.input.safeParse({
546
+ body: 'Incoming body',
547
+ id: versionedFixture.id,
548
+ title: 'Incoming title',
549
+ });
550
+ expect(withoutVersion.success).toBe(false);
551
+
552
+ const withVersion = reconcileNote.input.safeParse({
553
+ body: 'Incoming body',
554
+ id: versionedFixture.id,
555
+ title: 'Incoming title',
556
+ version: 1,
557
+ });
558
+ expect(withVersion.success).toBe(true);
559
+ });
560
+
561
+ test('surfaces RetryExhaustedError when the retry also conflicts', async () => {
562
+ // Accessor that always throws ConflictError on upsert, mirroring a
563
+ // concurrent writer that races the retry path. The detour loop
564
+ // exhausts after one recovery attempt and wraps in
565
+ // RetryExhaustedError<ConflictError>.
566
+ const stubbornCurrent = {
567
+ body: 'Current body',
568
+ createdAt: versionedFixture.createdAt,
569
+ id: versionedFixture.id,
570
+ title: 'Current title',
571
+ version: 99,
572
+ } satisfies EntityOf<VersionedNotesTable>;
573
+
574
+ const stubbornById = new Map<string, typeof stubbornCurrent>([
575
+ [stubbornCurrent.id, stubbornCurrent],
576
+ ]);
577
+ const stubbornAccessor: StoreAccessor<VersionedNotesTable> = {
578
+ get: async (id) => {
579
+ await Promise.resolve();
580
+ return cloneOrNull(stubbornById.get(id));
581
+ },
582
+ list: async () => {
583
+ await Promise.resolve();
584
+ return [structuredClone(stubbornCurrent)];
585
+ },
586
+ remove: async () => {
587
+ await Promise.resolve();
588
+ return { deleted: false };
589
+ },
590
+ upsert: () => {
591
+ throw new ConflictError(`Version conflict for "${stubbornCurrent.id}"`);
592
+ },
593
+ };
594
+
595
+ const stubbornResource = resource<VersionedConnection>(
596
+ 'db.notes.stubborn',
597
+ {
598
+ create: () =>
599
+ Result.ok({
600
+ notes: stubbornAccessor,
601
+ }),
602
+ mock: () => ({
603
+ notes: stubbornAccessor,
604
+ }),
605
+ }
606
+ );
607
+
608
+ const reconcileNote = reconcile({
609
+ resource: stubbornResource,
610
+ strategy: 'last-write-wins',
611
+ table: versionedNoteDefinition.tables.notes,
612
+ });
613
+
614
+ const result = await executeTrail(
615
+ reconcileNote,
616
+ {
617
+ body: 'Incoming body',
618
+ createdAt: versionedFixture.createdAt,
619
+ id: versionedFixture.id,
620
+ title: 'Incoming title',
621
+ version: 1,
622
+ },
623
+ {
624
+ ctx: {
625
+ extensions: {
626
+ 'db.notes.stubborn': {
627
+ notes: stubbornAccessor,
628
+ },
629
+ },
630
+ },
631
+ }
632
+ );
633
+
634
+ const err = result.match({
635
+ err: (e) => e,
636
+ ok: () => {
637
+ throw new Error('expected reconcile to fail after retry');
638
+ },
639
+ });
640
+ expect(err).toBeInstanceOf(RetryExhaustedError);
641
+ // Inherits the conflict category for trailhead mapping.
642
+ expect((err as RetryExhaustedError).category).toBe('conflict');
643
+ // The cause is the original ConflictError.
644
+ expect(err.cause).toBeInstanceOf(ConflictError);
645
+ });
646
+ });
package/src/index.ts CHANGED
@@ -1,10 +1,4 @@
1
- export {
2
- entitySchemaOf,
3
- fixtureSchemaOf,
4
- insertSchemaOf,
5
- store,
6
- updateSchemaOf,
7
- } from './store.js';
1
+ export { store, versionFieldName } from './store.js';
8
2
  export type {
9
3
  AnyStoreDefinition,
10
4
  AnyStoreTable,
@@ -15,25 +9,35 @@ export type {
15
9
  FixturesOfInput,
16
10
  GeneratedFieldsOfInput,
17
11
  GeneratedKeysOf,
12
+ IdentityFieldOfInput,
13
+ IdentityOf,
18
14
  IndexFieldsOfInput,
15
+ IndexedFieldsOfInput,
19
16
  InsertOf,
20
- PrimaryKeyOf,
21
17
  ReadOnlyStoreConnection,
18
+ StoreAccessor,
22
19
  ReadOnlyStoreTableAccessor,
23
20
  ReferencesOfInput,
24
21
  StoreAccessMode,
25
22
  StoreConnection,
23
+ StoreConnectorOptions,
26
24
  StoreDefinition,
27
25
  StoreFieldKey,
28
26
  StoreFixtureInput,
29
27
  StoreFixtureRow,
30
28
  StoreIdentifierOf,
29
+ StoreKind,
31
30
  StoreListOptions,
31
+ StoreMockSeed,
32
32
  StoreObjectSchema,
33
+ StoreOptions,
33
34
  StoreSearchDefinition,
34
35
  StoreTable,
36
+ StoreTableSignals,
35
37
  StoreTableAccessor,
38
+ StoreTableConnection,
36
39
  StoreTableInput,
37
40
  StoreTablesInput,
41
+ UpsertOf,
38
42
  UpdateOf,
39
43
  } from './types.js';