@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,346 @@
1
+ import { describe, expect, mock, test } from 'bun:test';
2
+ import { Result, createTrailContext, resource, topo } from '@ontrails/core';
3
+ import { z } from 'zod';
4
+
5
+ import type { EntityOf, InsertOf, StoreAccessor } from '../index.js';
6
+ import { testAll } from '../../../testing/src/index.js';
7
+ import { store } from '../index.js';
8
+ import { crud } from '../trails/index.js';
9
+
10
+ const noteSchema = z.object({
11
+ body: z.string().default('draft'),
12
+ createdAt: z.string(),
13
+ id: z.string(),
14
+ title: z.string(),
15
+ });
16
+
17
+ const noteDefinition = store({
18
+ notes: {
19
+ fixtures: [
20
+ {
21
+ body: 'Hello, Trails',
22
+ createdAt: '2026-04-10T12:00:00.000Z',
23
+ id: 'note-1',
24
+ title: 'First note',
25
+ },
26
+ ],
27
+ generated: ['id', 'createdAt'],
28
+ identity: 'id',
29
+ schema: noteSchema,
30
+ },
31
+ });
32
+
33
+ type NotesTable = typeof noteDefinition.tables.notes;
34
+ type NotesConnection = Readonly<Record<'notes', StoreAccessor<NotesTable>>>;
35
+
36
+ const requireFixture = <T>(value: T | undefined): T => {
37
+ if (value === undefined) {
38
+ throw new Error('Expected store fixture to be present');
39
+ }
40
+
41
+ return value;
42
+ };
43
+
44
+ const noteFixture = requireFixture(noteDefinition.tables.notes.fixtures[0]);
45
+
46
+ const cloneEntity = <T>(value: T): T => structuredClone(value);
47
+
48
+ const matchesFilters = <TEntity extends Record<string, unknown>>(
49
+ entity: TEntity,
50
+ filters: Partial<TEntity> | undefined
51
+ ): boolean => {
52
+ if (filters === undefined) {
53
+ return true;
54
+ }
55
+
56
+ return Object.entries(filters).every(
57
+ ([field, value]) => entity[field as keyof TEntity] === value
58
+ );
59
+ };
60
+
61
+ const createNotesAccessor = (): StoreAccessor<NotesTable> => {
62
+ const records = new Map<string, EntityOf<NotesTable>>([
63
+ [noteFixture.id, cloneEntity(noteFixture)],
64
+ ]);
65
+
66
+ return {
67
+ get(id) {
68
+ return Promise.resolve(cloneEntity(records.get(id) ?? null));
69
+ },
70
+ list(filters) {
71
+ return Promise.resolve(
72
+ [...records.values()]
73
+ .filter((entity) => matchesFilters(entity, filters))
74
+ .map((entity) => cloneEntity(entity))
75
+ );
76
+ },
77
+ remove(id) {
78
+ return Promise.resolve({ deleted: records.delete(id) });
79
+ },
80
+ upsert(input) {
81
+ const existing =
82
+ input.id === undefined
83
+ ? records.get(noteFixture.id)
84
+ : records.get(input.id);
85
+ const next = {
86
+ ...(existing ?? noteFixture),
87
+ ...input,
88
+ createdAt: (existing ?? noteFixture).createdAt,
89
+ id: input.id ?? noteFixture.id,
90
+ } satisfies EntityOf<NotesTable>;
91
+
92
+ records.set(next.id, next);
93
+ return Promise.resolve(cloneEntity(next));
94
+ },
95
+ };
96
+ };
97
+
98
+ const notesResource = resource<NotesConnection>('db.notes', {
99
+ create: () =>
100
+ Result.ok({
101
+ notes: createNotesAccessor(),
102
+ }),
103
+ mock: () => ({
104
+ notes: createNotesAccessor(),
105
+ }),
106
+ });
107
+
108
+ const [createNote, readNote, updateNote, deleteNote, listNote] = crud(
109
+ noteDefinition.tables.notes,
110
+ notesResource
111
+ );
112
+
113
+ type CreateNoteInput = Parameters<typeof createNote.blaze>[0];
114
+ type UpdateNoteInput = Parameters<typeof updateNote.blaze>[0];
115
+
116
+ const createInputHasTitle: 'title' extends keyof CreateNoteInput
117
+ ? true
118
+ : false = true;
119
+ const createInputOmitsGeneratedId: 'id' extends keyof CreateNoteInput
120
+ ? false
121
+ : true = true;
122
+ const updateInputHasIdentity: 'id' extends keyof UpdateNoteInput
123
+ ? true
124
+ : false = true;
125
+ const updateInputHasWritableFields: 'title' extends keyof UpdateNoteInput
126
+ ? true
127
+ : false = true;
128
+
129
+ // oxlint-disable-next-line jest/require-hook -- testAll generates describe/test blocks, not setup code
130
+ testAll(
131
+ topo('store-crud-app', {
132
+ createNote,
133
+ deleteNote,
134
+ listNote,
135
+ notesResource,
136
+ readNote,
137
+ updateNote,
138
+ } as Record<string, unknown>)
139
+ );
140
+
141
+ const createCrudContext = () =>
142
+ createTrailContext({
143
+ extensions: {
144
+ 'db.notes': {
145
+ notes: createNotesAccessor(),
146
+ },
147
+ },
148
+ });
149
+
150
+ const expectCrudIds = () => {
151
+ expect(createNote.id).toBe('notes.create');
152
+ expect(readNote.id).toBe('notes.read');
153
+ expect(updateNote.id).toBe('notes.update');
154
+ expect(deleteNote.id).toBe('notes.delete');
155
+ expect(listNote.id).toBe('notes.list');
156
+ };
157
+
158
+ const expectCrudPatterns = () => {
159
+ expect(createNote.pattern).toBe('crud');
160
+ expect(readNote.pattern).toBe('crud');
161
+ expect(updateNote.pattern).toBe('crud');
162
+ expect(deleteNote.pattern).toBe('crud');
163
+ expect(listNote.pattern).toBe('crud');
164
+ };
165
+
166
+ const expectCrudInputSchemas = () => {
167
+ const createParsed = createNote.input.safeParse({
168
+ id: 'note-1',
169
+ title: 'Created note',
170
+ });
171
+
172
+ expect(createNote.input.safeParse({ title: 'Created note' }).success).toBe(
173
+ true
174
+ );
175
+ expect(createParsed.success).toBe(true);
176
+ if (createParsed.success) {
177
+ expect('id' in createParsed.data).toBe(false);
178
+ }
179
+
180
+ expect(readNote.input.safeParse({ id: 'note-1' }).success).toBe(true);
181
+ expect(
182
+ updateNote.input.safeParse({ id: 'note-1', title: 'Updated title' }).success
183
+ ).toBe(true);
184
+ };
185
+
186
+ const expectCrudOutputSchemas = () => {
187
+ expect(
188
+ createNote.output?.safeParse({ id: 'note-1', title: 'Created note' })
189
+ .success
190
+ ).toBe(false);
191
+ expect(deleteNote.output?.safeParse().success).toBe(true);
192
+ expect(
193
+ listNote.output?.safeParse([{ id: 'note-1', title: 'First note' }]).success
194
+ ).toBe(false);
195
+ expect(listNote.output?.safeParse([noteFixture]).success).toBe(true);
196
+ };
197
+
198
+ const expectOk = <T>(result: Result<T, Error>): T => {
199
+ if (result.isErr()) {
200
+ throw result.error;
201
+ }
202
+
203
+ return result.value;
204
+ };
205
+
206
+ describe('crud()', () => {
207
+ test('produces the five standard CRUD trails with derived schemas', () => {
208
+ expect(createInputHasTitle).toBe(true);
209
+ expect(createInputOmitsGeneratedId).toBe(true);
210
+ expect(updateInputHasIdentity).toBe(true);
211
+ expect(updateInputHasWritableFields).toBe(true);
212
+ expectCrudIds();
213
+ expectCrudPatterns();
214
+ expectCrudInputSchemas();
215
+ expectCrudOutputSchemas();
216
+ expect(createNote.resources).toEqual([notesResource]);
217
+ expect(createNote.contours.map((candidate) => candidate.name)).toEqual([
218
+ 'notes',
219
+ ]);
220
+ });
221
+
222
+ const createPartialCrudTrails = () => {
223
+ const partialSchema = z.object({
224
+ body: z.string().default('draft'),
225
+ createdAt: z.string(),
226
+ id: z.string(),
227
+ title: z.string(),
228
+ });
229
+
230
+ const partialDefinition = store({
231
+ notes: {
232
+ fixtures: [
233
+ {
234
+ // createdAt intentionally omitted — the store layer accepts it
235
+ // through fixtureSchema, and the contour view built by
236
+ // createTableContour must mirror that shape so contour example
237
+ // validation does not crash. The identity field stays required
238
+ // so downstream read/update/delete inputs remain sound.
239
+ id: 'note-1',
240
+ title: 'First note',
241
+ },
242
+ ],
243
+ generated: ['id', 'createdAt'],
244
+ identity: 'id',
245
+ schema: partialSchema,
246
+ },
247
+ });
248
+
249
+ return crud(partialDefinition.tables.notes, notesResource);
250
+ };
251
+
252
+ test('default blazes delegate to the store accessor contract', async () => {
253
+ const ctx = createCrudContext();
254
+
255
+ const created = expectOk(
256
+ await createNote.blaze(
257
+ { body: 'Hello, Trails', title: 'First note' },
258
+ ctx
259
+ )
260
+ );
261
+ expect(created).toEqual(noteFixture);
262
+
263
+ expect(expectOk(await readNote.blaze({ id: noteFixture.id }, ctx))).toEqual(
264
+ noteFixture
265
+ );
266
+
267
+ const updated = expectOk(
268
+ await updateNote.blaze({ id: noteFixture.id, title: 'Renamed' }, ctx)
269
+ );
270
+ expect(updated).toEqual({
271
+ ...noteFixture,
272
+ title: 'Renamed',
273
+ });
274
+
275
+ expect(expectOk(await listNote.blaze({ id: noteFixture.id }, ctx))).toEqual(
276
+ [updated]
277
+ );
278
+
279
+ expect(await deleteNote.blaze({ id: noteFixture.id }, ctx)).toEqual(
280
+ Result.ok()
281
+ );
282
+ });
283
+
284
+ test('keeps CRUD outputs strict even when fixtures omit generated fields', () => {
285
+ const [create, read, update, remove, list] = createPartialCrudTrails();
286
+
287
+ expect(create.id).toBe('notes.create');
288
+ expect(read.id).toBe('notes.read');
289
+ expect(update.id).toBe('notes.update');
290
+ expect(remove.id).toBe('notes.delete');
291
+ expect(list.id).toBe('notes.list');
292
+
293
+ // Identity input must stay required even though the contour fixture shape
294
+ // relaxes generated non-identity fields for example validation.
295
+ expect(read.input.safeParse({}).success).toBe(false);
296
+ expect(read.input.safeParse({ id: 'note-1' }).success).toBe(true);
297
+ expect(
298
+ create.output?.safeParse({ id: 'note-1', title: 'First note' }).success
299
+ ).toBe(false);
300
+ expect(
301
+ list.output?.safeParse([{ id: 'note-1', title: 'First note' }]).success
302
+ ).toBe(false);
303
+ });
304
+
305
+ test('drops output-bearing examples sourced from relaxed fixtures', () => {
306
+ const [create, read, update, remove, list] = createPartialCrudTrails();
307
+
308
+ expect(create.examples).toBeUndefined();
309
+ expect(read.examples).toBeUndefined();
310
+ expect(update.examples).toBeUndefined();
311
+ expect(list.examples).toBeUndefined();
312
+ expect(remove.examples).toEqual([
313
+ {
314
+ input: { id: 'note-1' },
315
+ name: 'Delete notes note-1',
316
+ },
317
+ ]);
318
+ });
319
+
320
+ test('accepts per-operation blaze overrides', async () => {
321
+ const createOverride = mock((input: InsertOf<NotesTable>) =>
322
+ Result.ok({
323
+ ...noteFixture,
324
+ ...input,
325
+ id: 'override-note',
326
+ })
327
+ );
328
+ const [customCreate] = crud(noteDefinition.tables.notes, notesResource, {
329
+ blaze: {
330
+ create: createOverride,
331
+ },
332
+ });
333
+
334
+ const result = await customCreate.blaze(
335
+ { title: 'Custom note' },
336
+ createCrudContext()
337
+ );
338
+
339
+ expect(createOverride).toHaveBeenCalledTimes(1);
340
+ expect(expectOk(result)).toEqual({
341
+ ...noteFixture,
342
+ id: 'override-note',
343
+ title: 'Custom note',
344
+ });
345
+ });
346
+ });