@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.
- package/.turbo/turbo-lint.log +1 -1
- package/CHANGELOG.md +7 -0
- package/README.md +126 -61
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/internal/signal-identity.d.ts +17 -0
- package/dist/internal/signal-identity.d.ts.map +1 -0
- package/dist/internal/signal-identity.js +81 -0
- package/dist/internal/signal-identity.js.map +1 -0
- package/dist/jsonfile/index.d.ts +3 -0
- package/dist/jsonfile/index.d.ts.map +1 -0
- package/dist/jsonfile/index.js +2 -0
- package/dist/jsonfile/index.js.map +1 -0
- package/dist/jsonfile/runtime.d.ts +19 -0
- package/dist/jsonfile/runtime.d.ts.map +1 -0
- package/dist/jsonfile/runtime.js +403 -0
- package/dist/jsonfile/runtime.js.map +1 -0
- package/dist/jsonfile/types.d.ts +40 -0
- package/dist/jsonfile/types.d.ts.map +1 -0
- package/dist/{drizzle → jsonfile}/types.js.map +1 -1
- package/dist/store.d.ts +8 -18
- package/dist/store.d.ts.map +1 -1
- package/dist/store.js +152 -94
- package/dist/store.js.map +1 -1
- package/dist/testing.d.ts +28 -0
- package/dist/testing.d.ts.map +1 -0
- package/dist/testing.js +90 -0
- package/dist/testing.js.map +1 -0
- package/dist/trails/crud.d.ts +38 -0
- package/dist/trails/crud.d.ts.map +1 -0
- package/dist/trails/crud.js +112 -0
- package/dist/trails/crud.js.map +1 -0
- package/dist/trails/index.d.ts +7 -0
- package/dist/trails/index.d.ts.map +1 -0
- package/dist/trails/index.js +4 -0
- package/dist/trails/index.js.map +1 -0
- package/dist/trails/reconcile.d.ts +36 -0
- package/dist/trails/reconcile.d.ts.map +1 -0
- package/dist/trails/reconcile.js +142 -0
- package/dist/trails/reconcile.js.map +1 -0
- package/dist/trails/sync.d.ts +25 -0
- package/dist/trails/sync.d.ts.map +1 -0
- package/dist/trails/sync.js +96 -0
- package/dist/trails/sync.js.map +1 -0
- package/dist/trails/utils.d.ts +51 -0
- package/dist/trails/utils.d.ts.map +1 -0
- package/dist/trails/utils.js +65 -0
- package/dist/trails/utils.js.map +1 -0
- package/dist/types.d.ts +205 -35
- package/dist/types.d.ts.map +1 -1
- package/package.json +6 -9
- package/src/__tests__/crud.test.ts +346 -0
- package/src/__tests__/jsonfile.test.ts +502 -0
- package/src/__tests__/store.test.ts +239 -33
- package/src/__tests__/sync-reconcile.test.ts +646 -0
- package/src/index.ts +12 -8
- package/src/internal/signal-identity.ts +162 -0
- package/src/jsonfile/index.ts +6 -0
- package/src/jsonfile/runtime.ts +702 -0
- package/src/jsonfile/types.ts +50 -0
- package/src/store.ts +296 -134
- package/src/testing.ts +175 -0
- package/src/trails/crud.ts +355 -0
- package/src/trails/index.ts +10 -0
- package/src/trails/reconcile.ts +281 -0
- package/src/trails/sync.ts +251 -0
- package/src/trails/utils.ts +96 -0
- package/src/types.ts +355 -63
- package/tsconfig.tests.json +10 -0
- package/tsconfig.tsbuildinfo +1 -1
- package/dist/drizzle/index.d.ts +0 -3
- package/dist/drizzle/index.d.ts.map +0 -1
- package/dist/drizzle/index.js +0 -2
- package/dist/drizzle/index.js.map +0 -1
- package/dist/drizzle/runtime.d.ts +0 -21
- package/dist/drizzle/runtime.d.ts.map +0 -1
- package/dist/drizzle/runtime.js +0 -458
- package/dist/drizzle/runtime.js.map +0 -1
- package/dist/drizzle/schema.d.ts +0 -15
- package/dist/drizzle/schema.d.ts.map +0 -1
- package/dist/drizzle/schema.js +0 -322
- package/dist/drizzle/schema.js.map +0 -1
- package/dist/drizzle/types.d.ts +0 -40
- package/dist/drizzle/types.d.ts.map +0 -1
- package/src/drizzle/__tests__/drizzle.test.ts +0 -469
- package/src/drizzle/index.ts +0 -17
- package/src/drizzle/runtime.ts +0 -853
- package/src/drizzle/schema.ts +0 -577
- package/src/drizzle/types.ts +0 -70
- /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
|
+
});
|