@ontrails/store 1.0.0-beta.14

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 (46) hide show
  1. package/.agents/notes/2026-04-04/handoff-202604032309-9e85a104.md +38 -0
  2. package/.turbo/turbo-build.log +1 -0
  3. package/.turbo/turbo-lint.log +3 -0
  4. package/.turbo/turbo-typecheck.log +1 -0
  5. package/CHANGELOG.md +12 -0
  6. package/README.md +213 -0
  7. package/dist/drizzle/index.d.ts +3 -0
  8. package/dist/drizzle/index.d.ts.map +1 -0
  9. package/dist/drizzle/index.js +2 -0
  10. package/dist/drizzle/index.js.map +1 -0
  11. package/dist/drizzle/runtime.d.ts +21 -0
  12. package/dist/drizzle/runtime.d.ts.map +1 -0
  13. package/dist/drizzle/runtime.js +458 -0
  14. package/dist/drizzle/runtime.js.map +1 -0
  15. package/dist/drizzle/schema.d.ts +15 -0
  16. package/dist/drizzle/schema.d.ts.map +1 -0
  17. package/dist/drizzle/schema.js +322 -0
  18. package/dist/drizzle/schema.js.map +1 -0
  19. package/dist/drizzle/types.d.ts +40 -0
  20. package/dist/drizzle/types.d.ts.map +1 -0
  21. package/dist/drizzle/types.js +2 -0
  22. package/dist/drizzle/types.js.map +1 -0
  23. package/dist/index.d.ts +3 -0
  24. package/dist/index.d.ts.map +1 -0
  25. package/dist/index.js +2 -0
  26. package/dist/index.js.map +1 -0
  27. package/dist/store.d.ts +26 -0
  28. package/dist/store.d.ts.map +1 -0
  29. package/dist/store.js +192 -0
  30. package/dist/store.js.map +1 -0
  31. package/dist/types.d.ts +224 -0
  32. package/dist/types.d.ts.map +1 -0
  33. package/dist/types.js +2 -0
  34. package/dist/types.js.map +1 -0
  35. package/package.json +29 -0
  36. package/src/__tests__/store.test.ts +333 -0
  37. package/src/drizzle/__tests__/drizzle.test.ts +469 -0
  38. package/src/drizzle/index.ts +17 -0
  39. package/src/drizzle/runtime.ts +853 -0
  40. package/src/drizzle/schema.ts +577 -0
  41. package/src/drizzle/types.ts +70 -0
  42. package/src/index.ts +39 -0
  43. package/src/store.ts +367 -0
  44. package/src/types.ts +361 -0
  45. package/tsconfig.json +9 -0
  46. package/tsconfig.tsbuildinfo +1 -0
@@ -0,0 +1,469 @@
1
+ import { afterEach, describe, expect, test } from 'bun:test';
2
+ import {
3
+ AlreadyExistsError,
4
+ ValidationError,
5
+ createTrailContext,
6
+ } from '@ontrails/core';
7
+ import { mkdtempSync, rmSync } from 'node:fs';
8
+ import { tmpdir } from 'node:os';
9
+ import { join } from 'node:path';
10
+ import { z } from 'zod';
11
+
12
+ import { getSchema, readonlyStore, store } from '../index.js';
13
+
14
+ const userSchema = z.object({
15
+ email: z.string().email(),
16
+ id: z.string(),
17
+ });
18
+
19
+ const gistSchema = z.object({
20
+ createdAt: z.string(),
21
+ description: z.string().nullable().default(null),
22
+ id: z.string(),
23
+ isPublic: z.boolean().default(true),
24
+ ownerId: z.string(),
25
+ tags: z.array(z.string()).default([]),
26
+ updatedAt: z.string(),
27
+ });
28
+
29
+ const accountSchema = z.object({
30
+ id: z.string(),
31
+ name: z.string(),
32
+ });
33
+
34
+ const userTable = {
35
+ generated: ['id'],
36
+ primaryKey: 'id',
37
+ schema: userSchema,
38
+ } as const;
39
+
40
+ const gistTable = {
41
+ generated: ['id', 'createdAt', 'updatedAt'],
42
+ indexes: ['ownerId'],
43
+ primaryKey: 'id',
44
+ references: { ownerId: 'users' },
45
+ schema: gistSchema,
46
+ } as const;
47
+
48
+ const createProvisionInput = (rootDir: string) => ({
49
+ config: undefined,
50
+ cwd: rootDir,
51
+ env: {},
52
+ workspaceRoot: rootDir,
53
+ });
54
+
55
+ const expectOk = async <T>(value: PromiseLike<T> | T): Promise<T> =>
56
+ await value;
57
+
58
+ const unwrapCreated = async <T>(
59
+ value:
60
+ | PromiseLike<{
61
+ unwrap(): T;
62
+ }>
63
+ | {
64
+ unwrap(): T;
65
+ }
66
+ ): Promise<T> => {
67
+ const result = await value;
68
+ return result.unwrap();
69
+ };
70
+
71
+ const createWritableDemoStore = (rootDir: string) =>
72
+ store(
73
+ {
74
+ gists: gistTable,
75
+ users: userTable,
76
+ },
77
+ {
78
+ description: 'Writable demo store',
79
+ id: 'demo.store',
80
+ url: join(rootDir, 'demo.sqlite'),
81
+ }
82
+ );
83
+
84
+ const setupWritableDemoStore = async (rootDir: string) => {
85
+ const db = createWritableDemoStore(rootDir);
86
+ return {
87
+ created: await unwrapCreated(db.create(createProvisionInput(rootDir))),
88
+ db,
89
+ };
90
+ };
91
+
92
+ type WritableDemoStoreRuntime = Awaited<
93
+ ReturnType<typeof setupWritableDemoStore>
94
+ >['created'];
95
+
96
+ const expectWritableProvisionDefinition = (
97
+ db: ReturnType<typeof createWritableDemoStore>
98
+ ): void => {
99
+ expect(db.kind).toBe('provision');
100
+ expect(db.id).toBe('demo.store');
101
+ expect(db.access).toBe('readwrite');
102
+ expect(db.mock).toBeDefined();
103
+ expect(getSchema(db).gists).toBe(db.tables.gists);
104
+ };
105
+
106
+ const expectInsertedGist = (
107
+ gist: z.output<typeof gistSchema>,
108
+ ownerId: string
109
+ ): void => {
110
+ expect(gist).toEqual(
111
+ expect.objectContaining({
112
+ createdAt: expect.any(String),
113
+ description: null,
114
+ id: expect.any(String),
115
+ isPublic: true,
116
+ ownerId,
117
+ tags: [],
118
+ updatedAt: expect.any(String),
119
+ })
120
+ );
121
+ };
122
+
123
+ const seedWritableRecords = async (
124
+ created: WritableDemoStoreRuntime
125
+ ): Promise<{
126
+ readonly gist: z.output<typeof gistSchema>;
127
+ readonly user: z.output<typeof userSchema>;
128
+ }> => {
129
+ const user = await expectOk(
130
+ created.users.insert({ email: 'alice@example.com' })
131
+ );
132
+ expect(user.id).toEqual(expect.any(String));
133
+
134
+ const gist = await expectOk(
135
+ created.gists.insert({
136
+ ownerId: user.id,
137
+ })
138
+ );
139
+ expectInsertedGist(gist, user.id);
140
+ return { gist, user };
141
+ };
142
+
143
+ const expectStoredGist = async (
144
+ created: WritableDemoStoreRuntime,
145
+ gist: z.output<typeof gistSchema>,
146
+ ownerId: string
147
+ ): Promise<void> => {
148
+ expect(await created.gists.get(gist.id)).toEqual(gist);
149
+ expect(await created.gists.list({ ownerId })).toEqual([gist]);
150
+ };
151
+
152
+ const expectUpdatedGist = async (
153
+ created: WritableDemoStoreRuntime,
154
+ gist: z.output<typeof gistSchema>
155
+ ): Promise<void> => {
156
+ const updated = await expectOk(
157
+ created.gists.update(gist.id, {
158
+ description: 'Updated',
159
+ })
160
+ );
161
+
162
+ expect(updated).toEqual(
163
+ expect.objectContaining({
164
+ description: 'Updated',
165
+ id: gist.id,
166
+ })
167
+ );
168
+ expect(updated?.updatedAt).toEqual(expect.any(String));
169
+ expect(updated?.updatedAt).not.toBe(gist.updatedAt);
170
+ };
171
+
172
+ const expectQueryEscapeHatch = async (
173
+ created: WritableDemoStoreRuntime
174
+ ): Promise<void> => {
175
+ const rows = await created.query(({ drizzle, tables }) =>
176
+ drizzle.select().from(tables.gists).all()
177
+ );
178
+ expect(rows).toHaveLength(1);
179
+ };
180
+
181
+ const expectDeletedGist = async (
182
+ created: WritableDemoStoreRuntime,
183
+ gistId: string
184
+ ): Promise<void> => {
185
+ const deleted = await created.gists.remove(gistId);
186
+ expect(deleted).toEqual({ deleted: true });
187
+ expect(await created.gists.get(gistId)).toBeNull();
188
+ };
189
+
190
+ const expectMissingGistDelete = async (
191
+ created: WritableDemoStoreRuntime
192
+ ): Promise<void> => {
193
+ const deleted = await created.gists.remove('non-existent-id');
194
+ expect(deleted).toEqual({ deleted: false });
195
+ };
196
+
197
+ const expectWritableLifecycle = async (
198
+ created: WritableDemoStoreRuntime
199
+ ): Promise<void> => {
200
+ const { gist, user } = await seedWritableRecords(created);
201
+ await expectStoredGist(created, gist, user.id);
202
+ await expectUpdatedGist(created, gist);
203
+ await expectQueryEscapeHatch(created);
204
+ await expectDeletedGist(created, gist.id);
205
+ await expectMissingGistDelete(created);
206
+ };
207
+
208
+ const expectProvisionResolution = (
209
+ db: ReturnType<typeof createWritableDemoStore>,
210
+ created: WritableDemoStoreRuntime
211
+ ): void => {
212
+ const ctx = createTrailContext({
213
+ abortSignal: new AbortController().signal,
214
+ extensions: {
215
+ [db.id]: created,
216
+ },
217
+ requestId: 'store-drizzle',
218
+ });
219
+ expect(db.from(ctx).users).toBeDefined();
220
+ };
221
+
222
+ const createFixtureBackedStore = () =>
223
+ store(
224
+ {
225
+ gists: {
226
+ ...gistTable,
227
+ fixtures: [
228
+ {
229
+ id: 'gist-seed',
230
+ ownerId: 'user-seed',
231
+ },
232
+ ],
233
+ },
234
+ users: {
235
+ ...userTable,
236
+ fixtures: [
237
+ {
238
+ email: 'seed@example.com',
239
+ id: 'user-seed',
240
+ },
241
+ ],
242
+ },
243
+ },
244
+ {
245
+ id: 'demo.store.mock',
246
+ url: ':memory:',
247
+ }
248
+ );
249
+
250
+ const createWritableSeedStore = (url: string) =>
251
+ store(
252
+ {
253
+ users: userTable,
254
+ },
255
+ {
256
+ id: 'demo.store.seed',
257
+ url,
258
+ }
259
+ );
260
+
261
+ const seedReadonlyFixture = async (
262
+ url: string,
263
+ rootDir: string
264
+ ): Promise<z.output<typeof userSchema>> => {
265
+ const writable = createWritableSeedStore(url);
266
+ const seeded = await unwrapCreated(
267
+ writable.create(createProvisionInput(rootDir))
268
+ );
269
+ const inserted = await seeded.users.insert({ email: 'seed@example.com' });
270
+ await writable.dispose?.(seeded);
271
+ return inserted;
272
+ };
273
+
274
+ const createReadonlyUserStore = (url: string) =>
275
+ readonlyStore(
276
+ {
277
+ users: userTable,
278
+ },
279
+ {
280
+ id: 'demo.store.readonly',
281
+ url,
282
+ }
283
+ );
284
+
285
+ const setupReadonlyUserStore = async (url: string, rootDir: string) => {
286
+ const db = createReadonlyUserStore(url);
287
+ return {
288
+ created: await unwrapCreated(db.create(createProvisionInput(rootDir))),
289
+ db,
290
+ };
291
+ };
292
+
293
+ type ReadonlyUserStoreRuntime = Awaited<
294
+ ReturnType<typeof setupReadonlyUserStore>
295
+ >['created'];
296
+
297
+ const expectReadonlyReads = async (
298
+ created: ReadonlyUserStoreRuntime,
299
+ inserted: z.output<typeof userSchema>
300
+ ): Promise<void> => {
301
+ expect(await created.users.get(inserted.id)).toEqual(inserted);
302
+ expect(await created.users.list()).toEqual([inserted]);
303
+ };
304
+
305
+ const expectReadonlyWriteFailure = async (
306
+ created: ReadonlyUserStoreRuntime
307
+ ): Promise<void> => {
308
+ await expect(
309
+ created.query(({ drizzle, tables }) =>
310
+ drizzle
311
+ .insert(tables.users)
312
+ .values({ email: 'blocked@example.com', id: 'blocked' })
313
+ .run()
314
+ )
315
+ ).rejects.toThrow();
316
+ };
317
+
318
+ const createErrorStore = (rootDir: string) =>
319
+ store(
320
+ {
321
+ accounts: {
322
+ primaryKey: 'id',
323
+ schema: accountSchema,
324
+ },
325
+ gists: {
326
+ ...gistTable,
327
+ references: { ownerId: 'accounts' },
328
+ },
329
+ },
330
+ {
331
+ id: 'demo.store.errors',
332
+ url: join(rootDir, 'errors.sqlite'),
333
+ }
334
+ );
335
+
336
+ describe('@ontrails/store/drizzle', () => {
337
+ let tmpRoot: string | undefined;
338
+
339
+ afterEach(() => {
340
+ if (tmpRoot !== undefined) {
341
+ rmSync(tmpRoot, { force: true, recursive: true });
342
+ tmpRoot = undefined;
343
+ }
344
+ });
345
+
346
+ const makeRoot = (): string => {
347
+ tmpRoot = mkdtempSync(join(tmpdir(), 'store-drizzle-'));
348
+ return tmpRoot;
349
+ };
350
+
351
+ test('binds a writable provision with CRUD accessors and one escape hatch', async () => {
352
+ const rootDir = makeRoot();
353
+ const { created, db } = await setupWritableDemoStore(rootDir);
354
+ expectWritableProvisionDefinition(db);
355
+ await expectWritableLifecycle(created);
356
+ expectProvisionResolution(db, created);
357
+ await db.dispose?.(created);
358
+ });
359
+
360
+ test('creates a writable mock provision seeded from fixtures', async () => {
361
+ const db = createFixtureBackedStore();
362
+
363
+ const mock = await db.mock?.();
364
+ expect(mock).toBeDefined();
365
+ expect(await mock?.users.get('user-seed')).toEqual(
366
+ expect.objectContaining({
367
+ email: 'seed@example.com',
368
+ id: 'user-seed',
369
+ })
370
+ );
371
+ expect(await mock?.gists.get('gist-seed')).toEqual(
372
+ expect.objectContaining({
373
+ createdAt: expect.any(String),
374
+ description: null,
375
+ id: 'gist-seed',
376
+ isPublic: true,
377
+ ownerId: 'user-seed',
378
+ updatedAt: expect.any(String),
379
+ })
380
+ );
381
+ });
382
+
383
+ test('opens a read-only store without a mock and enforces writes at the database layer', async () => {
384
+ const rootDir = makeRoot();
385
+ const url = join(rootDir, 'readonly.sqlite');
386
+ const inserted = await seedReadonlyFixture(url, rootDir);
387
+ const { created, db: readOnly } = await setupReadonlyUserStore(
388
+ url,
389
+ rootDir
390
+ );
391
+ expect(readOnly.access).toBe('readonly');
392
+ expect(readOnly.mock).toBeUndefined();
393
+ await expectReadonlyReads(created, inserted);
394
+ await expectReadonlyWriteFailure(created);
395
+ await readOnly.dispose?.(created);
396
+ });
397
+
398
+ test('maps primary-key and foreign-key failures into Trails errors', async () => {
399
+ const rootDir = makeRoot();
400
+ const db = createErrorStore(rootDir);
401
+ const created = await unwrapCreated(
402
+ db.create(createProvisionInput(rootDir))
403
+ );
404
+
405
+ await created.accounts.insert({
406
+ id: 'acct-1',
407
+ name: 'Alpha',
408
+ });
409
+
410
+ await expect(
411
+ created.accounts.insert({
412
+ id: 'acct-1',
413
+ name: 'Duplicate',
414
+ })
415
+ ).rejects.toBeInstanceOf(AlreadyExistsError);
416
+
417
+ await expect(
418
+ created.gists.insert({
419
+ ownerId: 'missing-account',
420
+ })
421
+ ).rejects.toBeInstanceOf(ValidationError);
422
+
423
+ await db.dispose?.(created);
424
+ });
425
+
426
+ test('maps z.number().int() to INTEGER (Zod internals regression guard)', () => {
427
+ const intStore = store(
428
+ {
429
+ counters: {
430
+ generated: ['id'],
431
+ primaryKey: 'id',
432
+ schema: z.object({
433
+ id: z.number().int(),
434
+ value: z.number(),
435
+ }),
436
+ },
437
+ },
438
+ { url: join(makeRoot(), 'int.sqlite') }
439
+ );
440
+
441
+ const schema = getSchema(intStore);
442
+ const col = schema.counters;
443
+ expect(col).toBeDefined();
444
+
445
+ const idColumn = col.id as unknown as { columnType: string };
446
+ expect(idColumn.columnType).toBe('SQLiteInteger');
447
+ });
448
+
449
+ test('update returns null for a non-existent ID', async () => {
450
+ const db = createFixtureBackedStore();
451
+ const mock = await db.mock?.();
452
+ expect(mock).toBeDefined();
453
+
454
+ const result = await mock?.gists.update('ghost-id', {
455
+ description: 'nope',
456
+ });
457
+ expect(result).toBeNull();
458
+ });
459
+
460
+ test('update rejects empty fields even when updatedAt is generated', async () => {
461
+ const db = createFixtureBackedStore();
462
+ const mock = await db.mock?.();
463
+ expect(mock).toBeDefined();
464
+
465
+ await expect(mock?.gists.update('gist-seed', {})).rejects.toBeInstanceOf(
466
+ ValidationError
467
+ );
468
+ });
469
+ });
@@ -0,0 +1,17 @@
1
+ export {
2
+ connectDrizzle,
3
+ connectReadOnlyDrizzle,
4
+ getSchema,
5
+ readonlyStore,
6
+ store,
7
+ } from './runtime.js';
8
+ export type {
9
+ ConnectDrizzleOptions,
10
+ DrizzleMockSeed,
11
+ DrizzleQueryContext,
12
+ DrizzleStoreConnection,
13
+ DrizzleStoreProvision,
14
+ DrizzleStoreSchema,
15
+ ReadOnlyDrizzleOptions,
16
+ ReadOnlyDrizzleStoreConnection,
17
+ } from './types.js';