@ontrails/drizzle 0.2.0

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/src/runtime.ts ADDED
@@ -0,0 +1,1351 @@
1
+ import { Database } from 'bun:sqlite';
2
+ import { mkdtempSync, rmSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import {
6
+ AlreadyExistsError,
7
+ ConflictError,
8
+ InternalError,
9
+ Result,
10
+ ValidationError,
11
+ resource,
12
+ } from '@ontrails/core';
13
+ import { versionFieldName } from '@ontrails/store';
14
+ import { bindStoreDefinition } from '@ontrails/store/adapter-support';
15
+ import type {
16
+ AnyStoreDefinition,
17
+ AnyStoreTable,
18
+ EntityOf,
19
+ FixtureInputOf,
20
+ InsertOf,
21
+ ReadOnlyStoreConnection,
22
+ StoreAccessMode,
23
+ StoreFieldKey,
24
+ StoreIdentifierOf,
25
+ StoreMockSeed,
26
+ StoreTableAccessor,
27
+ StoreTableConnection,
28
+ UpsertOf,
29
+ UpdateOf,
30
+ } from '@ontrails/store';
31
+ import { and, eq, sql } from 'drizzle-orm';
32
+ import { drizzle } from 'drizzle-orm/bun-sqlite';
33
+ import type { AnySQLiteColumn, AnySQLiteTable } from 'drizzle-orm/sqlite-core';
34
+ import type { z } from 'zod';
35
+ import type { Signal, TrailContext } from '@ontrails/core';
36
+
37
+ import {
38
+ deriveDrizzleTables,
39
+ deriveFieldSpec,
40
+ deriveSqliteSchemaStatements,
41
+ } from './schema.js';
42
+ import type {
43
+ DrizzleStoreConnection,
44
+ DrizzleStoreOptions,
45
+ DrizzleStoreResource,
46
+ DrizzleStoreSchema,
47
+ ReadOnlyDrizzleStoreConnection,
48
+ } from './types.js';
49
+
50
+ const defaultResourceId = 'store';
51
+ const connectionClients = new WeakMap<object, Database>();
52
+ const connectionTempDirs = new WeakMap<object, string>();
53
+
54
+ const cloneValue = <T>(value: T): T => structuredClone(value);
55
+
56
+ const openSqliteDatabase = (url: string, readOnly: boolean): Database => {
57
+ const client = new Database(
58
+ url,
59
+ readOnly ? { readonly: true } : { create: true }
60
+ );
61
+ client.run('PRAGMA foreign_keys = ON');
62
+
63
+ if (!readOnly) {
64
+ client.run('PRAGMA journal_mode = WAL');
65
+ client.run('PRAGMA synchronous = NORMAL');
66
+ }
67
+
68
+ return client;
69
+ };
70
+
71
+ const asError = (error: unknown): Error =>
72
+ error instanceof Error ? error : new Error(String(error));
73
+
74
+ const storeTableNames = <TStore extends AnyStoreDefinition>(
75
+ definition: TStore
76
+ ): readonly Extract<keyof TStore['tables'], string>[] =>
77
+ definition.tableNames as readonly Extract<keyof TStore['tables'], string>[];
78
+
79
+ const registerConnection = <TConnection extends object>(
80
+ connection: TConnection,
81
+ client: Database,
82
+ tempDir?: string
83
+ ): TConnection => {
84
+ connectionClients.set(connection, client);
85
+ if (tempDir !== undefined) {
86
+ connectionTempDirs.set(connection, tempDir);
87
+ }
88
+ return connection;
89
+ };
90
+
91
+ const closeConnection = (connection: object): void => {
92
+ connectionClients.get(connection)?.close();
93
+ connectionClients.delete(connection);
94
+ const tempDir = connectionTempDirs.get(connection);
95
+ if (tempDir !== undefined) {
96
+ rmSync(tempDir, { force: true, recursive: true });
97
+ connectionTempDirs.delete(connection);
98
+ }
99
+ };
100
+
101
+ const createReadonlyMockTempDir = (): string =>
102
+ mkdtempSync(join(tmpdir(), 'trails-drizzle-readonly-'));
103
+
104
+ const mapDatabaseError = (tableName: string, error: unknown): Error => {
105
+ if (
106
+ error instanceof ValidationError ||
107
+ error instanceof AlreadyExistsError ||
108
+ error instanceof ConflictError ||
109
+ error instanceof InternalError
110
+ ) {
111
+ return error;
112
+ }
113
+
114
+ // ZodError from .parse() should surface as ValidationError, not InternalError.
115
+ const resolved = asError(error);
116
+ if (resolved.name === 'ZodError') {
117
+ return new ValidationError(
118
+ `Store table "${tableName}" input failed schema validation: ${resolved.message}`,
119
+ { cause: resolved }
120
+ );
121
+ }
122
+ if (resolved.message.includes('UNIQUE constraint failed')) {
123
+ return new AlreadyExistsError(
124
+ `Drizzle store insert for table "${tableName}" violated a uniqueness constraint`,
125
+ { cause: resolved }
126
+ );
127
+ }
128
+
129
+ if (resolved.message.includes('FOREIGN KEY constraint failed')) {
130
+ return new ValidationError(
131
+ `Drizzle store insert for table "${tableName}" violated a foreign key constraint`,
132
+ { cause: resolved }
133
+ );
134
+ }
135
+
136
+ return new InternalError(
137
+ `Drizzle store encountered an unexpected error for table "${tableName}": ${resolved.message}`,
138
+ { cause: resolved }
139
+ );
140
+ };
141
+
142
+ const formatIssues = (
143
+ issues: readonly { readonly message: string }[]
144
+ ): string => issues.map((issue) => issue.message).join('; ');
145
+
146
+ const parseEntity = <TTable extends AnyStoreTable>(
147
+ table: TTable,
148
+ value: unknown
149
+ ): EntityOf<TTable> => {
150
+ const parsed = table.schema.safeParse(value);
151
+ if (!parsed.success) {
152
+ throw new InternalError(
153
+ `Drizzle store for table "${table.name}" returned an entity that does not match the schema: ${formatIssues(parsed.error.issues)}`
154
+ );
155
+ }
156
+
157
+ return parsed.data as EntityOf<TTable>;
158
+ };
159
+
160
+ const normalizeWriteInput = (
161
+ input: Record<string, unknown>
162
+ ): Record<string, unknown> =>
163
+ Object.fromEntries(
164
+ Object.entries(input).filter(([, value]) => value !== undefined)
165
+ );
166
+
167
+ const baseFieldKind = <TTable extends AnyStoreTable>(
168
+ table: TTable,
169
+ field: StoreFieldKey<TTable['schema']>
170
+ ): string =>
171
+ deriveFieldSpec(
172
+ field as string,
173
+ table.schema.shape[field as keyof typeof table.schema.shape] as z.ZodType
174
+ ).kind;
175
+
176
+ const TIMESTAMP_FIELD_NAMES = new Set([
177
+ 'createdAt',
178
+ 'updatedAt',
179
+ 'created_at',
180
+ 'updated_at',
181
+ ]);
182
+
183
+ const ID_FIELD_SUFFIX_RE = /[Ii]d$/;
184
+
185
+ /**
186
+ * Returns true when the given field on `table` is the framework-managed
187
+ * version column. Used by insert and upsert paths to gate behavior that
188
+ * only applies to versioned tables, preventing silent data loss when a
189
+ * non-versioned table happens to have a user field named `version`.
190
+ */
191
+ const isVersionManagedField = <TTable extends AnyStoreTable>(
192
+ table: TTable,
193
+ fieldName: string
194
+ ): boolean => table.versioned && fieldName === versionFieldName;
195
+
196
+ /**
197
+ * Synthesize a value for a generated field during insert.
198
+ *
199
+ * The adapter recognizes these conventions for generated fields:
200
+ *
201
+ * - **Primary key** (`integer` type): auto-increment, left to SQLite.
202
+ * - **Timestamp fields** (`createdAt`, `updatedAt`, `created_at`,
203
+ * `updated_at`): materialized as `new Date()` (date type) or ISO 8601
204
+ * string (text type).
205
+ * - **ID-like text fields** (name ends with `Id` or `id`): filled with
206
+ * `Bun.randomUUIDv7()`.
207
+ *
208
+ * Any other generated `text` field that does not match a recognized convention
209
+ * throws a `ValidationError` — the developer must either supply a value or
210
+ * give the field a Zod default.
211
+ *
212
+ * All other generated field types fall through to `undefined`, letting the
213
+ * schema's Zod default (if any) apply during validation.
214
+ */
215
+ const generatedTimestamp = (kind: string): unknown =>
216
+ kind === 'date' ? new Date() : new Date().toISOString();
217
+
218
+ const generatedTextValue = (tableName: string, fieldName: string): unknown => {
219
+ if (ID_FIELD_SUFFIX_RE.test(fieldName)) {
220
+ return Bun.randomUUIDv7();
221
+ }
222
+ throw new ValidationError(
223
+ `Store table "${tableName}" has a generated text field "${fieldName}" that does not match a recognized convention (timestamp or ID field). Supply a value or add a Zod default.`
224
+ );
225
+ };
226
+
227
+ const generatedVersionValue = (tableName: string, kind: string): number => {
228
+ if (kind === 'integer') {
229
+ return 1;
230
+ }
231
+
232
+ throw new ValidationError(
233
+ `Store table "${tableName}" has a versioned "${versionFieldName}" field of type "${kind}", but the framework requires it to be an integer. Ensure the schema uses z.number().int() for the version field.`
234
+ );
235
+ };
236
+
237
+ const generatedFallbackValue = <TTable extends AnyStoreTable>(
238
+ table: TTable,
239
+ field: StoreFieldKey<TTable['schema']>,
240
+ fieldName: string,
241
+ kind: string
242
+ ): unknown => {
243
+ if (field === table.primaryKey && kind === 'integer') {
244
+ return undefined;
245
+ }
246
+ if (TIMESTAMP_FIELD_NAMES.has(fieldName)) {
247
+ return generatedTimestamp(kind);
248
+ }
249
+ if (kind === 'text') {
250
+ return generatedTextValue(table.name, fieldName);
251
+ }
252
+
253
+ throw new ValidationError(
254
+ `Store table "${table.name}" has a generated field "${fieldName}" of unrecognized type "${kind}". Only "integer" (primary key), "text", and timestamp fields are supported as generated fields. Supply a value or add a Zod default.`
255
+ );
256
+ };
257
+
258
+ const generatedValueForInsert = <TTable extends AnyStoreTable>(
259
+ table: TTable,
260
+ field: StoreFieldKey<TTable['schema']>
261
+ ): unknown => {
262
+ const fieldName = field as string;
263
+ const kind = baseFieldKind(table, field);
264
+
265
+ if (isVersionManagedField(table, fieldName)) {
266
+ return generatedVersionValue(table.name, kind);
267
+ }
268
+
269
+ return generatedFallbackValue(table, field, fieldName, kind);
270
+ };
271
+
272
+ const materializeGeneratedFields = <TTable extends AnyStoreTable>(
273
+ table: TTable,
274
+ input: Record<string, unknown>
275
+ ): Record<string, unknown> => {
276
+ const next = { ...input };
277
+
278
+ for (const field of table.generated) {
279
+ if (next[field] !== undefined) {
280
+ continue;
281
+ }
282
+
283
+ const generated = generatedValueForInsert(
284
+ table,
285
+ field as StoreFieldKey<TTable['schema']>
286
+ );
287
+ if (generated !== undefined) {
288
+ next[field] = generated;
289
+ }
290
+ }
291
+
292
+ return next;
293
+ };
294
+
295
+ const validateFixturePayload = <TTable extends AnyStoreTable>(
296
+ table: TTable,
297
+ input: Record<string, unknown>
298
+ ): Record<string, unknown> => {
299
+ const parsed = table.fixtureSchema.safeParse(input);
300
+ if (!parsed.success) {
301
+ throw new ValidationError(
302
+ `Store table "${table.name}" insert payload is invalid after generated-field materialization: ${formatIssues(parsed.error.issues)}`
303
+ );
304
+ }
305
+
306
+ return normalizeWriteInput(parsed.data as Record<string, unknown>);
307
+ };
308
+
309
+ const applyGeneratedInsertFields = <TTable extends AnyStoreTable>(
310
+ table: TTable,
311
+ input: Record<string, unknown>
312
+ ): Record<string, unknown> =>
313
+ validateFixturePayload(table, materializeGeneratedFields(table, input));
314
+
315
+ const applyGeneratedUpdateFields = <TTable extends AnyStoreTable>(
316
+ table: TTable,
317
+ input: Record<string, unknown>
318
+ ): Record<string, unknown> => {
319
+ const updatedAtKey = (['updatedAt', 'updated_at'] as const).find((key) =>
320
+ table.generated.includes(key as StoreFieldKey<TTable['schema']>)
321
+ );
322
+
323
+ if (!updatedAtKey) {
324
+ return normalizeWriteInput(input);
325
+ }
326
+
327
+ const kind = baseFieldKind(
328
+ table,
329
+ updatedAtKey as StoreFieldKey<TTable['schema']>
330
+ );
331
+
332
+ return normalizeWriteInput({
333
+ ...input,
334
+ [updatedAtKey]:
335
+ input[updatedAtKey] ??
336
+ (kind === 'date' ? new Date() : new Date().toISOString()),
337
+ });
338
+ };
339
+
340
+ const versionFromEntity = <TTable extends AnyStoreTable>(
341
+ table: TTable,
342
+ entity: EntityOf<TTable>
343
+ ): number => {
344
+ const version = (entity as Record<string, unknown>)[versionFieldName];
345
+ if (typeof version === 'number' && Number.isInteger(version) && version > 0) {
346
+ return version;
347
+ }
348
+
349
+ throw new InternalError(
350
+ `Drizzle store for table "${table.name}" returned a versioned entity without a valid integer "${versionFieldName}" field.`
351
+ );
352
+ };
353
+
354
+ const versionConflictError = (
355
+ tableName: string,
356
+ id: string | number,
357
+ expectedVersion: number,
358
+ actualVersion: number | null
359
+ ): ConflictError =>
360
+ new ConflictError(
361
+ actualVersion === null
362
+ ? `Store table "${tableName}" expected version ${expectedVersion} for "${String(id)}" but found no existing row.`
363
+ : `Store table "${tableName}" expected version ${expectedVersion} for "${String(id)}" but found ${actualVersion}.`
364
+ );
365
+
366
+ const expectedVersionFromInput = (
367
+ input: Record<string, unknown>
368
+ ): number | undefined => {
369
+ const candidate = input[versionFieldName];
370
+ return typeof candidate === 'number' &&
371
+ Number.isInteger(candidate) &&
372
+ candidate > 0
373
+ ? candidate
374
+ : undefined;
375
+ };
376
+
377
+ const requireUpdateFields = (
378
+ tableName: string,
379
+ input: Record<string, unknown>
380
+ ): Record<string, unknown> => {
381
+ const userFields = normalizeWriteInput(input);
382
+ if (Object.keys(userFields).length > 0) {
383
+ return userFields;
384
+ }
385
+
386
+ throw new ValidationError(
387
+ `Store table "${tableName}" update requires at least one field to set.`
388
+ );
389
+ };
390
+
391
+ const assertExpectedVersionMatch = <TTable extends AnyStoreTable>(
392
+ table: TTable,
393
+ id: string | number,
394
+ existing: EntityOf<TTable>,
395
+ expectedVersion?: number
396
+ ): void => {
397
+ if (!table.versioned || expectedVersion === undefined) {
398
+ return;
399
+ }
400
+
401
+ const currentVersion = versionFromEntity(table, existing);
402
+ if (currentVersion !== expectedVersion) {
403
+ throw versionConflictError(table.name, id, expectedVersion, currentVersion);
404
+ }
405
+ };
406
+
407
+ const resolveUpsertWithoutPatch = <
408
+ TTable extends AnyStoreTable,
409
+ TIdentifier extends StoreIdentifierOf<TTable>,
410
+ >(
411
+ table: TTable,
412
+ identifier: TIdentifier,
413
+ input: Record<string, unknown>,
414
+ expectedVersion: number | undefined,
415
+ readEntity: (id: TIdentifier) => EntityOf<TTable> | null,
416
+ insertEntity: (input: Record<string, unknown>) => EntityOf<TTable>
417
+ ): EntityOf<TTable> => {
418
+ const existing = readEntity(identifier);
419
+ if (existing === null) {
420
+ if (expectedVersion !== undefined) {
421
+ throw versionConflictError(
422
+ table.name,
423
+ identifier as string | number,
424
+ expectedVersion,
425
+ null
426
+ );
427
+ }
428
+
429
+ return insertEntity(input);
430
+ }
431
+
432
+ assertExpectedVersionMatch(
433
+ table,
434
+ identifier as string | number,
435
+ existing,
436
+ expectedVersion
437
+ );
438
+ return existing;
439
+ };
440
+
441
+ const resolveUpsertAfterMissingUpdate = <
442
+ TTable extends AnyStoreTable,
443
+ TIdentifier extends StoreIdentifierOf<TTable>,
444
+ >(
445
+ table: TTable,
446
+ identifier: TIdentifier,
447
+ input: Record<string, unknown>,
448
+ expectedVersion: number | undefined,
449
+ insertEntity: (input: Record<string, unknown>) => EntityOf<TTable>
450
+ ): EntityOf<TTable> => {
451
+ if (expectedVersion !== undefined) {
452
+ throw versionConflictError(
453
+ table.name,
454
+ identifier as string | number,
455
+ expectedVersion,
456
+ null
457
+ );
458
+ }
459
+
460
+ return insertEntity(input);
461
+ };
462
+
463
+ interface VisitFrame {
464
+ readonly expanded: boolean;
465
+ readonly tableName: string;
466
+ }
467
+
468
+ const ensureNotCyclic = (
469
+ visiting: ReadonlySet<string>,
470
+ tableName: string
471
+ ): void => {
472
+ if (!visiting.has(tableName)) {
473
+ return;
474
+ }
475
+
476
+ throw new ValidationError(
477
+ `Store definition contains a reference cycle involving "${tableName}", which the SQLite adapter cannot seed automatically`
478
+ );
479
+ };
480
+
481
+ const pushVisitDependencies = (
482
+ stack: VisitFrame[],
483
+ definition: AnyStoreDefinition,
484
+ visited: ReadonlySet<string>,
485
+ tableName: string
486
+ ): void => {
487
+ const table = definition.tables[tableName];
488
+ if (table === undefined) {
489
+ return;
490
+ }
491
+
492
+ for (const target of Object.values(table.references).toReversed()) {
493
+ if (target !== undefined && !visited.has(target)) {
494
+ stack.push({ expanded: false, tableName: target });
495
+ }
496
+ }
497
+ };
498
+
499
+ const finishVisitFrame = (
500
+ frame: VisitFrame,
501
+ visiting: Set<string>,
502
+ visited: Set<string>,
503
+ ordered: string[]
504
+ ): void => {
505
+ visiting.delete(frame.tableName);
506
+ visited.add(frame.tableName);
507
+ ordered.push(frame.tableName);
508
+ };
509
+
510
+ const startVisitFrame = (
511
+ stack: VisitFrame[],
512
+ definition: AnyStoreDefinition,
513
+ visiting: Set<string>,
514
+ visited: Set<string>,
515
+ tableName: string
516
+ ): void => {
517
+ ensureNotCyclic(visiting, tableName);
518
+ visiting.add(tableName);
519
+ stack.push({ expanded: true, tableName });
520
+ pushVisitDependencies(stack, definition, visited, tableName);
521
+ };
522
+
523
+ const visitTableForSeeding = (
524
+ definition: AnyStoreDefinition,
525
+ visiting: Set<string>,
526
+ visited: Set<string>,
527
+ ordered: string[],
528
+ tableName: string
529
+ ): void => {
530
+ const stack: VisitFrame[] = [{ expanded: false, tableName }];
531
+
532
+ while (stack.length > 0) {
533
+ const frame = stack.pop();
534
+ if (frame === undefined) {
535
+ continue;
536
+ }
537
+
538
+ if (frame.expanded) {
539
+ finishVisitFrame(frame, visiting, visited, ordered);
540
+ continue;
541
+ }
542
+
543
+ if (!visited.has(frame.tableName)) {
544
+ startVisitFrame(stack, definition, visiting, visited, frame.tableName);
545
+ }
546
+ }
547
+ };
548
+
549
+ const topologicalTableOrder = (
550
+ definition: AnyStoreDefinition
551
+ ): readonly string[] => {
552
+ const visited = new Set<string>();
553
+ const visiting = new Set<string>();
554
+ const ordered: string[] = [];
555
+
556
+ for (const tableName of definition.tableNames) {
557
+ visitTableForSeeding(definition, visiting, visited, ordered, tableName);
558
+ }
559
+
560
+ return Object.freeze(ordered);
561
+ };
562
+
563
+ const ensureSqliteSchema = (
564
+ client: Database,
565
+ definition: AnyStoreDefinition
566
+ ): void => {
567
+ for (const statement of deriveSqliteSchemaStatements(definition)) {
568
+ client.run(statement);
569
+ }
570
+ };
571
+
572
+ const primaryKeyColumn = (
573
+ table: AnySQLiteTable,
574
+ field: string
575
+ ): AnySQLiteColumn => table[field as keyof typeof table] as AnySQLiteColumn;
576
+
577
+ const buildFilterConditions = (
578
+ drizzleTable: AnySQLiteTable,
579
+ filters: Record<string, unknown> | undefined
580
+ ): ReturnType<typeof eq>[] =>
581
+ filters === undefined
582
+ ? []
583
+ : Object.entries(filters)
584
+ .filter(([, value]) => value !== undefined)
585
+ .map(([field, value]) =>
586
+ eq(primaryKeyColumn(drizzleTable, field), value as never)
587
+ );
588
+
589
+ const createReadOnlyAccessor = <
590
+ TStore extends AnyStoreDefinition,
591
+ TName extends keyof TStore['tables'] & string,
592
+ >(
593
+ definitionTable: TStore['tables'][TName],
594
+ drizzleTable: DrizzleStoreSchema<TStore>[TName],
595
+ db: ReturnType<typeof drizzle<DrizzleStoreSchema<TStore>>>
596
+ ): ReadOnlyStoreConnection<TStore>[TName] => ({
597
+ get(id) {
598
+ try {
599
+ const row = db
600
+ .select()
601
+ .from(drizzleTable)
602
+ .where(
603
+ eq(
604
+ primaryKeyColumn(drizzleTable, definitionTable.primaryKey),
605
+ id as never
606
+ )
607
+ )
608
+ .get();
609
+
610
+ return Promise.resolve(
611
+ row === null || row === undefined
612
+ ? null
613
+ : cloneValue(parseEntity(definitionTable, row))
614
+ );
615
+ } catch (error) {
616
+ return Promise.reject(mapDatabaseError(definitionTable.name, error));
617
+ }
618
+ },
619
+ list(filters, options) {
620
+ try {
621
+ const conditions = buildFilterConditions(
622
+ drizzleTable,
623
+ filters as Record<string, unknown> | undefined
624
+ );
625
+ const base = db.select().from(drizzleTable).$dynamic();
626
+ const filtered =
627
+ conditions.length > 0 ? base.where(and(...conditions)) : base;
628
+ const rows = filtered
629
+ .limit(options?.limit ?? -1)
630
+ .offset(options?.offset ?? 0)
631
+ .all();
632
+
633
+ return Promise.resolve(
634
+ rows.map((row) => cloneValue(parseEntity(definitionTable, row)))
635
+ );
636
+ } catch (error) {
637
+ return Promise.reject(mapDatabaseError(definitionTable.name, error));
638
+ }
639
+ },
640
+ });
641
+
642
+ const createWritableAccessor = <
643
+ TStore extends AnyStoreDefinition,
644
+ TName extends keyof TStore['tables'] & string,
645
+ >(
646
+ definitionTable: TStore['tables'][TName],
647
+ drizzleTable: DrizzleStoreSchema<TStore>[TName],
648
+ db: ReturnType<typeof drizzle<DrizzleStoreSchema<TStore>>>
649
+ ): StoreTableConnection<TStore>[TName] => {
650
+ type Table = TStore['tables'][TName];
651
+ type Identifier = StoreIdentifierOf<Table>;
652
+
653
+ const findRowById = (id: Identifier): Record<string, unknown> | undefined =>
654
+ db
655
+ .select()
656
+ .from(drizzleTable)
657
+ .where(
658
+ eq(
659
+ primaryKeyColumn(drizzleTable, definitionTable.primaryKey),
660
+ id as never
661
+ )
662
+ )
663
+ .get() as Record<string, unknown> | undefined;
664
+
665
+ const readEntity = (id: Identifier): EntityOf<Table> | null => {
666
+ const existing = findRowById(id);
667
+
668
+ return existing === undefined
669
+ ? null
670
+ : (cloneValue(parseEntity(definitionTable, existing)) as EntityOf<Table>);
671
+ };
672
+
673
+ const insertEntity = (input: Record<string, unknown>): EntityOf<Table> => {
674
+ const row = db
675
+ .insert(drizzleTable)
676
+ .values(applyGeneratedInsertFields(definitionTable, input) as never)
677
+ .returning()
678
+ .get();
679
+
680
+ return cloneValue(parseEntity(definitionTable, row)) as EntityOf<Table>;
681
+ };
682
+
683
+ const versionColumn = definitionTable.versioned
684
+ ? primaryKeyColumn(drizzleTable, versionFieldName)
685
+ : undefined;
686
+
687
+ // oxlint-disable-next-line max-statements -- atomic UPDATE ... WHERE with version guard and conflict diagnosis reads more clearly as one function
688
+ const updateEntity = (
689
+ id: Identifier,
690
+ input: Record<string, unknown>,
691
+ expectedVersion?: number
692
+ ): EntityOf<Table> | null => {
693
+ const base = applyGeneratedUpdateFields(
694
+ definitionTable,
695
+ requireUpdateFields(definitionTable.name, input)
696
+ );
697
+ // Atomic increment via SQL expression — avoids the read-then-write race
698
+ // on optimistic-concurrency updates.
699
+ const fields =
700
+ definitionTable.versioned && versionColumn !== undefined
701
+ ? { ...base, [versionFieldName]: sql`${versionColumn} + 1` }
702
+ : base;
703
+ const idColumn = primaryKeyColumn(drizzleTable, definitionTable.primaryKey);
704
+ const idCondition = eq(idColumn, id as never);
705
+ const condition =
706
+ definitionTable.versioned &&
707
+ versionColumn !== undefined &&
708
+ expectedVersion !== undefined
709
+ ? and(idCondition, eq(versionColumn, expectedVersion as never))
710
+ : idCondition;
711
+ const row = db
712
+ .update(drizzleTable)
713
+ .set(fields as never)
714
+ .where(condition)
715
+ .returning()
716
+ .get() as Record<string, unknown> | undefined;
717
+ if (row !== undefined) {
718
+ return cloneValue(parseEntity(definitionTable, row)) as EntityOf<Table>;
719
+ }
720
+ if (
721
+ !definitionTable.versioned ||
722
+ versionColumn === undefined ||
723
+ expectedVersion === undefined
724
+ ) {
725
+ return null;
726
+ }
727
+ const existing = readEntity(id);
728
+ throw versionConflictError(
729
+ definitionTable.name,
730
+ id as string | number,
731
+ expectedVersion,
732
+ existing === null ? null : versionFromEntity(definitionTable, existing)
733
+ );
734
+ };
735
+
736
+ const patchFromUpsert = (
737
+ input: Record<string, unknown>
738
+ ): Record<string, unknown> =>
739
+ Object.fromEntries(
740
+ Object.entries(input).filter(
741
+ ([field, value]) =>
742
+ field !== definitionTable.identity &&
743
+ !isVersionManagedField(definitionTable, field) &&
744
+ value !== undefined
745
+ )
746
+ );
747
+
748
+ const upsertEntity = (input: Record<string, unknown>): EntityOf<Table> => {
749
+ const identifier = input[definitionTable.identity] as
750
+ | Identifier
751
+ | undefined;
752
+ const expectedVersion = definitionTable.versioned
753
+ ? expectedVersionFromInput(input)
754
+ : undefined;
755
+
756
+ if (identifier === undefined) {
757
+ if (expectedVersion !== undefined) {
758
+ throw new ValidationError(
759
+ `Store table "${definitionTable.name}" cannot accept an expected version without an identity during upsert.`
760
+ );
761
+ }
762
+
763
+ return insertEntity(input);
764
+ }
765
+
766
+ const patch = patchFromUpsert(input);
767
+ if (Object.keys(patch).length === 0) {
768
+ return resolveUpsertWithoutPatch(
769
+ definitionTable,
770
+ identifier,
771
+ input,
772
+ expectedVersion,
773
+ readEntity,
774
+ insertEntity
775
+ );
776
+ }
777
+
778
+ return (
779
+ updateEntity(identifier, patch, expectedVersion) ??
780
+ resolveUpsertAfterMissingUpdate(
781
+ definitionTable,
782
+ identifier,
783
+ input,
784
+ expectedVersion,
785
+ insertEntity
786
+ )
787
+ );
788
+ };
789
+
790
+ return {
791
+ ...createReadOnlyAccessor(definitionTable, drizzleTable, db),
792
+ insert(input) {
793
+ try {
794
+ const parsed = definitionTable.insertSchema.parse(
795
+ input
796
+ ) as InsertOf<Table>;
797
+
798
+ return Promise.resolve(insertEntity(parsed as Record<string, unknown>));
799
+ } catch (error) {
800
+ return Promise.reject(mapDatabaseError(definitionTable.name, error));
801
+ }
802
+ },
803
+ remove(id) {
804
+ try {
805
+ const deleted = db
806
+ .delete(drizzleTable)
807
+ .where(
808
+ eq(
809
+ primaryKeyColumn(drizzleTable, definitionTable.primaryKey),
810
+ id as never
811
+ )
812
+ )
813
+ .returning({
814
+ deletedId: primaryKeyColumn(
815
+ drizzleTable,
816
+ definitionTable.primaryKey
817
+ ),
818
+ })
819
+ .get();
820
+
821
+ return Promise.resolve({ deleted: deleted !== undefined });
822
+ } catch (error) {
823
+ return Promise.reject(mapDatabaseError(definitionTable.name, error));
824
+ }
825
+ },
826
+ update(id, input) {
827
+ try {
828
+ const parsed = definitionTable.updateSchema.parse(
829
+ input
830
+ ) as UpdateOf<Table>;
831
+
832
+ return Promise.resolve(
833
+ updateEntity(id as Identifier, parsed as Record<string, unknown>)
834
+ );
835
+ } catch (error) {
836
+ return Promise.reject(mapDatabaseError(definitionTable.name, error));
837
+ }
838
+ },
839
+ upsert(input) {
840
+ try {
841
+ const parsed = definitionTable.fixtureSchema.parse(
842
+ input
843
+ ) as UpsertOf<Table>;
844
+ const normalized = normalizeWriteInput(
845
+ parsed as Record<string, unknown>
846
+ );
847
+
848
+ return Promise.resolve(upsertEntity(normalized));
849
+ } catch (error) {
850
+ return Promise.reject(mapDatabaseError(definitionTable.name, error));
851
+ }
852
+ },
853
+ };
854
+ };
855
+
856
+ /** Collect non-empty fixture arrays keyed by table name. */
857
+ const collectFixtures = <TStore extends AnyStoreDefinition>(
858
+ definition: TStore,
859
+ seed?: StoreMockSeed<TStore>
860
+ ): Map<string, readonly FixtureInputOf<AnyStoreTable>[]> => {
861
+ const result = new Map<string, readonly FixtureInputOf<AnyStoreTable>[]>();
862
+ for (const tableName of definition.tableNames) {
863
+ const table = definition.tables[tableName];
864
+ if (table === undefined) {
865
+ continue;
866
+ }
867
+ const fixtures =
868
+ (seed?.[tableName] as
869
+ | readonly FixtureInputOf<typeof table>[]
870
+ | undefined) ?? table.fixtures;
871
+ if (fixtures.length > 0) {
872
+ result.set(tableName, fixtures);
873
+ }
874
+ }
875
+ return result;
876
+ };
877
+
878
+ /** Insert fixture rows in topological order. */
879
+ const insertFixtureRows = <TStore extends AnyStoreDefinition>(
880
+ definition: TStore,
881
+ tables: DrizzleStoreSchema<TStore>,
882
+ db: ReturnType<typeof drizzle<DrizzleStoreSchema<TStore>>>,
883
+ fixturesByTable: Map<string, readonly FixtureInputOf<AnyStoreTable>[]>
884
+ ): void => {
885
+ for (const tableName of topologicalTableOrder(definition)) {
886
+ const defTable = definition.tables[tableName];
887
+ const drizzleTable = tables[tableName];
888
+ const fixtures = fixturesByTable.get(tableName);
889
+ if (!defTable || !drizzleTable || !fixtures) {
890
+ continue;
891
+ }
892
+ for (const fixture of fixtures) {
893
+ db.insert(drizzleTable)
894
+ .values(
895
+ applyGeneratedInsertFields(
896
+ defTable,
897
+ fixture as Record<string, unknown>
898
+ ) as never
899
+ )
900
+ .run();
901
+ }
902
+ }
903
+ };
904
+
905
+ const seedFixtures = <TStore extends AnyStoreDefinition>(
906
+ definition: TStore,
907
+ tables: DrizzleStoreSchema<TStore>,
908
+ db: ReturnType<typeof drizzle<DrizzleStoreSchema<TStore>>>,
909
+ seed?: StoreMockSeed<TStore>
910
+ ): void => {
911
+ const fixturesByTable = collectFixtures(definition, seed);
912
+ if (fixturesByTable.size === 0) {
913
+ return;
914
+ }
915
+ insertFixtureRows(definition, tables, db, fixturesByTable);
916
+ };
917
+
918
+ const createReadOnlyConnection = <TStore extends AnyStoreDefinition>(
919
+ definition: TStore,
920
+ tables: DrizzleStoreSchema<TStore>,
921
+ db: ReturnType<typeof drizzle<DrizzleStoreSchema<TStore>>>,
922
+ client: Database,
923
+ tempDir?: string
924
+ ): ReadOnlyDrizzleStoreConnection<TStore> => {
925
+ const connection = {
926
+ async query(run) {
927
+ return await run({ drizzle: db, tables });
928
+ },
929
+ } as ReadOnlyDrizzleStoreConnection<TStore>;
930
+
931
+ for (const tableName of storeTableNames(definition)) {
932
+ const definitionTable = definition.tables[tableName];
933
+ const drizzleTable = tables[tableName];
934
+ if (definitionTable === undefined || drizzleTable === undefined) {
935
+ continue;
936
+ }
937
+
938
+ Object.defineProperty(connection, tableName, {
939
+ enumerable: true,
940
+ value: createReadOnlyAccessor(
941
+ definitionTable as TStore['tables'][typeof tableName],
942
+ drizzleTable,
943
+ db
944
+ ),
945
+ });
946
+ }
947
+
948
+ return Object.freeze(
949
+ registerConnection(connection, client, tempDir)
950
+ ) as ReadOnlyDrizzleStoreConnection<TStore>;
951
+ };
952
+
953
+ const createWritableConnection = <TStore extends AnyStoreDefinition>(
954
+ definition: TStore,
955
+ tables: DrizzleStoreSchema<TStore>,
956
+ db: ReturnType<typeof drizzle<DrizzleStoreSchema<TStore>>>,
957
+ client: Database
958
+ ): DrizzleStoreConnection<TStore> => {
959
+ const connection = {
960
+ async query(run) {
961
+ return await run({ drizzle: db, tables });
962
+ },
963
+ } as DrizzleStoreConnection<TStore>;
964
+
965
+ for (const tableName of storeTableNames(definition)) {
966
+ const definitionTable = definition.tables[tableName];
967
+ const drizzleTable = tables[tableName];
968
+ if (definitionTable === undefined || drizzleTable === undefined) {
969
+ continue;
970
+ }
971
+
972
+ Object.defineProperty(connection, tableName, {
973
+ enumerable: true,
974
+ value: createWritableAccessor(
975
+ definitionTable as TStore['tables'][typeof tableName],
976
+ drizzleTable,
977
+ db
978
+ ),
979
+ });
980
+ }
981
+
982
+ return Object.freeze(
983
+ registerConnection(connection, client)
984
+ ) as DrizzleStoreConnection<TStore>;
985
+ };
986
+
987
+ const seedReadonlyMockDatabase = <TStore extends AnyStoreDefinition>(
988
+ definition: TStore,
989
+ tables: DrizzleStoreSchema<TStore>,
990
+ url: string,
991
+ seed?: StoreMockSeed<TStore>
992
+ ): void => {
993
+ const writableClient = openSqliteDatabase(url, false);
994
+ try {
995
+ ensureSqliteSchema(writableClient, definition);
996
+ const writableDb = drizzle({ client: writableClient, schema: tables });
997
+ seedFixtures(definition, tables, writableDb, seed);
998
+ } finally {
999
+ writableClient.close();
1000
+ }
1001
+ };
1002
+
1003
+ const openReadonlyMockConnection = <TStore extends AnyStoreDefinition>(
1004
+ definition: TStore,
1005
+ tables: DrizzleStoreSchema<TStore>,
1006
+ url: string,
1007
+ tempDir: string
1008
+ ): ReadOnlyDrizzleStoreConnection<TStore> => {
1009
+ const client = openSqliteDatabase(url, true);
1010
+ try {
1011
+ const db = drizzle({ client, schema: tables });
1012
+ return createReadOnlyConnection(definition, tables, db, client, tempDir);
1013
+ } catch (error) {
1014
+ client.close();
1015
+ throw error;
1016
+ }
1017
+ };
1018
+
1019
+ const createReadonlyMockConnection = <TStore extends AnyStoreDefinition>(
1020
+ definition: TStore,
1021
+ tables: DrizzleStoreSchema<TStore>,
1022
+ seed?: StoreMockSeed<TStore>
1023
+ ): ReadOnlyDrizzleStoreConnection<TStore> => {
1024
+ const tempDir = createReadonlyMockTempDir();
1025
+ const url = join(tempDir, 'mock.sqlite');
1026
+
1027
+ try {
1028
+ seedReadonlyMockDatabase(definition, tables, url, seed);
1029
+ return openReadonlyMockConnection(definition, tables, url, tempDir);
1030
+ } catch (error) {
1031
+ rmSync(tempDir, { force: true, recursive: true });
1032
+ throw error;
1033
+ }
1034
+ };
1035
+
1036
+ type BoundFireFn = NonNullable<TrailContext['fire']>;
1037
+
1038
+ /**
1039
+ * Best-effort signal emission after a successful DB write.
1040
+ *
1041
+ * Signal errors are caught and logged rather than re-thrown so that a
1042
+ * listener failure does not mask a successful database mutation. The
1043
+ * caller already holds the write result; surfacing a signal error here
1044
+ * would discard it and confuse error handling upstream.
1045
+ */
1046
+ const fireDerivedSignal = async <TTable extends AnyStoreTable>(
1047
+ fire: BoundFireFn,
1048
+ derivedSignal: Signal<unknown>,
1049
+ entity: EntityOf<TTable>
1050
+ ): Promise<void> => {
1051
+ try {
1052
+ await fire(derivedSignal, entity);
1053
+ } catch (error) {
1054
+ console.warn(
1055
+ `[drizzle] signal "${derivedSignal.id}" emission threw:`,
1056
+ error
1057
+ );
1058
+ }
1059
+ };
1060
+
1061
+ const inputIdentity = <TTable extends AnyStoreTable>(
1062
+ table: TTable,
1063
+ input: UpsertOf<TTable>
1064
+ ): StoreIdentifierOf<TTable> | undefined =>
1065
+ input[table.identity as keyof UpsertOf<TTable> & string] as
1066
+ | StoreIdentifierOf<TTable>
1067
+ | undefined;
1068
+
1069
+ const changedEntity = <TTable extends AnyStoreTable>(
1070
+ previous: EntityOf<TTable> | null,
1071
+ next: EntityOf<TTable> | null
1072
+ ): next is EntityOf<TTable> =>
1073
+ previous !== null && next !== null && !Bun.deepEquals(previous, next);
1074
+
1075
+ const bindWritableAccessorSignals = <TTable extends AnyStoreTable>(
1076
+ table: TTable,
1077
+ accessor: StoreTableAccessor<TTable>,
1078
+ fire: BoundFireFn
1079
+ ): StoreTableAccessor<TTable> =>
1080
+ Object.freeze({
1081
+ ...accessor,
1082
+ async insert(input: InsertOf<TTable>) {
1083
+ const created = await accessor.insert(input);
1084
+ await fireDerivedSignal(fire, table.signals.created, created);
1085
+ return created;
1086
+ },
1087
+ async remove(id: StoreIdentifierOf<TTable>) {
1088
+ // Snapshot taken before delete. May be stale under concurrent writes
1089
+ // since StoreAccessor.remove returns `{ deleted: boolean }` without a
1090
+ // post-delete returning clause. Acceptable for signal consumers that
1091
+ // tolerate eventual consistency; revisit if strict ordering is needed.
1092
+ const existing = await accessor.get(id);
1093
+ const removed = await accessor.remove(id);
1094
+ if (removed.deleted && existing !== null) {
1095
+ await fireDerivedSignal(fire, table.signals.removed, existing);
1096
+ }
1097
+ return removed;
1098
+ },
1099
+ async update(id: StoreIdentifierOf<TTable>, input: UpdateOf<TTable>) {
1100
+ if (table.versioned) {
1101
+ // Versioned tables auto-increment the version column on every write,
1102
+ // so changedEntity always detects a diff. Skip the redundant pre-read
1103
+ // and fire unconditionally on successful update.
1104
+ const updated = await accessor.update(id, input);
1105
+ if (updated !== null) {
1106
+ await fireDerivedSignal(fire, table.signals.updated, updated);
1107
+ }
1108
+ return updated;
1109
+ }
1110
+ const existing = await accessor.get(id);
1111
+ const updated = await accessor.update(id, input);
1112
+ if (changedEntity(existing, updated)) {
1113
+ await fireDerivedSignal(fire, table.signals.updated, updated);
1114
+ }
1115
+ return updated;
1116
+ },
1117
+ async upsert(input: UpsertOf<TTable>) {
1118
+ const existingId = inputIdentity(table, input);
1119
+ // NOTE: pre-read is not transactional with the write below. Under
1120
+ // concurrent deletes, `existing` may be non-null while `accessor.upsert`
1121
+ // actually inserts. `created` vs `updated` signal discrimination is
1122
+ // best-effort — matches the same caveat documented on `remove`.
1123
+ const existing =
1124
+ existingId === undefined ? null : await accessor.get(existingId);
1125
+ const written = await accessor.upsert(input);
1126
+
1127
+ if (existing === null) {
1128
+ await fireDerivedSignal(fire, table.signals.created, written);
1129
+ return written;
1130
+ }
1131
+
1132
+ if (changedEntity(existing, written)) {
1133
+ await fireDerivedSignal(fire, table.signals.updated, written);
1134
+ }
1135
+ return written;
1136
+ },
1137
+ });
1138
+
1139
+ const bindWritableConnectionSignals = <TStore extends AnyStoreDefinition>(
1140
+ definition: TStore,
1141
+ connection: DrizzleStoreConnection<TStore>,
1142
+ fire: BoundFireFn
1143
+ ): DrizzleStoreConnection<TStore> => {
1144
+ const bound = {
1145
+ query: connection.query,
1146
+ } as DrizzleStoreConnection<TStore>;
1147
+
1148
+ for (const tableName of storeTableNames(definition)) {
1149
+ const table = definition.tables[tableName];
1150
+ const accessor = connection[tableName];
1151
+ if (table === undefined || accessor === undefined) {
1152
+ continue;
1153
+ }
1154
+
1155
+ Object.defineProperty(bound, tableName, {
1156
+ enumerable: true,
1157
+ value: bindWritableAccessorSignals(
1158
+ table,
1159
+ accessor as StoreTableAccessor<typeof table>,
1160
+ fire
1161
+ ),
1162
+ });
1163
+ }
1164
+
1165
+ return Object.freeze(bound);
1166
+ };
1167
+
1168
+ const bindResourceConnection = <
1169
+ TStore extends AnyStoreDefinition,
1170
+ TConnection,
1171
+ TAccess extends StoreAccessMode,
1172
+ >(
1173
+ access: TAccess,
1174
+ definition: TStore,
1175
+ connection: TConnection,
1176
+ fire: TrailContext['fire']
1177
+ ): TConnection => {
1178
+ if (access !== 'readwrite' || fire === undefined) {
1179
+ return connection;
1180
+ }
1181
+
1182
+ return bindWritableConnectionSignals(
1183
+ definition,
1184
+ connection as DrizzleStoreConnection<TStore>,
1185
+ fire
1186
+ ) as TConnection;
1187
+ };
1188
+
1189
+ const buildResourceShape = <
1190
+ TStore extends AnyStoreDefinition,
1191
+ TConnection,
1192
+ TAccess extends StoreAccessMode,
1193
+ >(
1194
+ value: ReturnType<typeof resource<TConnection>>,
1195
+ store: TStore,
1196
+ tables: DrizzleStoreSchema<TStore>,
1197
+ access: TAccess
1198
+ ): DrizzleStoreResource<TStore, TConnection, TAccess> =>
1199
+ Object.freeze({
1200
+ ...value,
1201
+ access,
1202
+ from(ctx: TrailContext) {
1203
+ return bindResourceConnection(access, store, value.from(ctx), ctx.fire);
1204
+ },
1205
+ ...(access === 'readwrite' ? { signals: store.signals } : {}),
1206
+ store,
1207
+ tables,
1208
+ });
1209
+
1210
+ const connectionHealth = (
1211
+ connection: object
1212
+ ): Result<{ readonly ok: true }, Error> => {
1213
+ const client = connectionClients.get(connection);
1214
+ if (client === undefined) {
1215
+ return Result.err(
1216
+ new InternalError('Drizzle store connection is missing its SQLite client')
1217
+ );
1218
+ }
1219
+
1220
+ try {
1221
+ client.query('SELECT 1').get();
1222
+ return Result.ok({ ok: true });
1223
+ } catch (error) {
1224
+ return Result.err(asError(error));
1225
+ }
1226
+ };
1227
+
1228
+ /**
1229
+ * Bind a store definition to a Drizzle-backed SQLite resource.
1230
+ *
1231
+ * The returned resource manages its own connection lifecycle. The `mock()`
1232
+ * factory creates an in-memory SQLite database seeded with fixtures — callers
1233
+ * who obtain a mock connection are responsible for calling `closeConnection()`
1234
+ * when done, or letting the connection be garbage-collected (the underlying
1235
+ * `Database` client is tracked via `WeakMap`).
1236
+ *
1237
+ * Note: the `search` field on `StoreTableInput` is not yet interpreted by
1238
+ * this adapter — it is reserved for future full-text search support.
1239
+ */
1240
+ export const connectDrizzle = <const TStore extends AnyStoreDefinition>(
1241
+ definition: TStore,
1242
+ options: DrizzleStoreOptions<TStore>
1243
+ ): DrizzleStoreResource<
1244
+ TStore,
1245
+ DrizzleStoreConnection<TStore>,
1246
+ 'readwrite'
1247
+ > => {
1248
+ const scope = options.id ?? defaultResourceId;
1249
+ const store = bindStoreDefinition(definition, scope) as TStore;
1250
+ const tables = deriveDrizzleTables(store);
1251
+
1252
+ return buildResourceShape(
1253
+ resource(scope, {
1254
+ create: () => {
1255
+ try {
1256
+ const client = openSqliteDatabase(options.url, false);
1257
+ try {
1258
+ ensureSqliteSchema(client, store);
1259
+ } catch (error) {
1260
+ client.close();
1261
+ throw error;
1262
+ }
1263
+ const db = drizzle({ client, schema: tables });
1264
+ if (options.seed !== undefined) {
1265
+ try {
1266
+ seedFixtures(store, tables, db, options.seed);
1267
+ } catch (error) {
1268
+ client.close();
1269
+ throw error;
1270
+ }
1271
+ }
1272
+ return Result.ok(createWritableConnection(store, tables, db, client));
1273
+ } catch (error) {
1274
+ return Result.err(
1275
+ new InternalError(
1276
+ `Drizzle store failed to open database at "${options.url}": ${asError(error).message}`,
1277
+ { cause: asError(error) }
1278
+ )
1279
+ );
1280
+ }
1281
+ },
1282
+ description:
1283
+ options.description ??
1284
+ 'Drizzle-backed writable store bound from an @ontrails/store definition.',
1285
+ dispose: (connection) => {
1286
+ closeConnection(connection);
1287
+ },
1288
+ health: connectionHealth,
1289
+ meta: options.meta,
1290
+ mock: () => {
1291
+ const client = openSqliteDatabase(':memory:', false);
1292
+ try {
1293
+ ensureSqliteSchema(client, store);
1294
+ const db = drizzle({ client, schema: tables });
1295
+ seedFixtures(store, tables, db, options.mockSeed);
1296
+ return createWritableConnection(store, tables, db, client);
1297
+ } catch (error) {
1298
+ client.close();
1299
+ throw error;
1300
+ }
1301
+ },
1302
+ }),
1303
+ store,
1304
+ tables,
1305
+ 'readwrite'
1306
+ );
1307
+ };
1308
+
1309
+ export const connectReadOnlyDrizzle = <const TStore extends AnyStoreDefinition>(
1310
+ definition: TStore,
1311
+ options: DrizzleStoreOptions<TStore>
1312
+ ): DrizzleStoreResource<
1313
+ TStore,
1314
+ ReadOnlyDrizzleStoreConnection<TStore>,
1315
+ 'readonly'
1316
+ > => {
1317
+ const scope = options.id ?? defaultResourceId;
1318
+ const store = bindStoreDefinition(definition, scope) as TStore;
1319
+ const tables = deriveDrizzleTables(store);
1320
+
1321
+ return buildResourceShape(
1322
+ resource(scope, {
1323
+ create: () => {
1324
+ try {
1325
+ const client = openSqliteDatabase(options.url, true);
1326
+ const db = drizzle({ client, schema: tables });
1327
+ return Result.ok(createReadOnlyConnection(store, tables, db, client));
1328
+ } catch (error) {
1329
+ return Result.err(
1330
+ new InternalError(
1331
+ `Drizzle read-only store failed to open database at "${options.url}": ${asError(error).message}`,
1332
+ { cause: asError(error) }
1333
+ )
1334
+ );
1335
+ }
1336
+ },
1337
+ description:
1338
+ options.description ??
1339
+ 'Drizzle-backed read-only store bound from an @ontrails/store definition.',
1340
+ dispose: (connection) => {
1341
+ closeConnection(connection);
1342
+ },
1343
+ health: connectionHealth,
1344
+ meta: options.meta,
1345
+ mock: () => createReadonlyMockConnection(store, tables, options.mockSeed),
1346
+ }),
1347
+ store,
1348
+ tables,
1349
+ 'readonly'
1350
+ );
1351
+ };