@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.
- package/.agents/notes/2026-04-04/handoff-202604032309-9e85a104.md +38 -0
- package/.turbo/turbo-build.log +1 -0
- package/.turbo/turbo-lint.log +3 -0
- package/.turbo/turbo-typecheck.log +1 -0
- package/CHANGELOG.md +12 -0
- package/README.md +213 -0
- package/dist/drizzle/index.d.ts +3 -0
- package/dist/drizzle/index.d.ts.map +1 -0
- package/dist/drizzle/index.js +2 -0
- package/dist/drizzle/index.js.map +1 -0
- package/dist/drizzle/runtime.d.ts +21 -0
- package/dist/drizzle/runtime.d.ts.map +1 -0
- package/dist/drizzle/runtime.js +458 -0
- package/dist/drizzle/runtime.js.map +1 -0
- package/dist/drizzle/schema.d.ts +15 -0
- package/dist/drizzle/schema.d.ts.map +1 -0
- package/dist/drizzle/schema.js +322 -0
- package/dist/drizzle/schema.js.map +1 -0
- package/dist/drizzle/types.d.ts +40 -0
- package/dist/drizzle/types.d.ts.map +1 -0
- package/dist/drizzle/types.js +2 -0
- package/dist/drizzle/types.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -0
- package/dist/store.d.ts +26 -0
- package/dist/store.d.ts.map +1 -0
- package/dist/store.js +192 -0
- package/dist/store.js.map +1 -0
- package/dist/types.d.ts +224 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/package.json +29 -0
- package/src/__tests__/store.test.ts +333 -0
- package/src/drizzle/__tests__/drizzle.test.ts +469 -0
- package/src/drizzle/index.ts +17 -0
- package/src/drizzle/runtime.ts +853 -0
- package/src/drizzle/schema.ts +577 -0
- package/src/drizzle/types.ts +70 -0
- package/src/index.ts +39 -0
- package/src/store.ts +367 -0
- package/src/types.ts +361 -0
- package/tsconfig.json +9 -0
- package/tsconfig.tsbuildinfo +1 -0
|
@@ -0,0 +1,853 @@
|
|
|
1
|
+
import { Database } from 'bun:sqlite';
|
|
2
|
+
import {
|
|
3
|
+
AlreadyExistsError,
|
|
4
|
+
InternalError,
|
|
5
|
+
Result,
|
|
6
|
+
ValidationError,
|
|
7
|
+
provision,
|
|
8
|
+
} from '@ontrails/core';
|
|
9
|
+
import { and, eq } from 'drizzle-orm';
|
|
10
|
+
import { drizzle } from 'drizzle-orm/bun-sqlite';
|
|
11
|
+
import type { AnySQLiteColumn, AnySQLiteTable } from 'drizzle-orm/sqlite-core';
|
|
12
|
+
import type { z } from 'zod';
|
|
13
|
+
|
|
14
|
+
import { store as defineStore } from '../store.js';
|
|
15
|
+
import type {
|
|
16
|
+
AnyStoreDefinition,
|
|
17
|
+
AnyStoreTable,
|
|
18
|
+
EntityOf,
|
|
19
|
+
FixtureInputOf,
|
|
20
|
+
InsertOf,
|
|
21
|
+
ReadOnlyStoreConnection,
|
|
22
|
+
StoreAccessMode,
|
|
23
|
+
StoreConnection,
|
|
24
|
+
StoreFieldKey,
|
|
25
|
+
StoreTablesInput,
|
|
26
|
+
UpdateOf,
|
|
27
|
+
} from '../types.js';
|
|
28
|
+
import {
|
|
29
|
+
createSqliteSchemaStatements,
|
|
30
|
+
describeField,
|
|
31
|
+
deriveDrizzleTables,
|
|
32
|
+
} from './schema.js';
|
|
33
|
+
import type {
|
|
34
|
+
ConnectDrizzleOptions,
|
|
35
|
+
DrizzleMockSeed,
|
|
36
|
+
DrizzleStoreConnection,
|
|
37
|
+
DrizzleStoreProvision,
|
|
38
|
+
DrizzleStoreSchema,
|
|
39
|
+
ReadOnlyDrizzleOptions,
|
|
40
|
+
ReadOnlyDrizzleStoreConnection,
|
|
41
|
+
} from './types.js';
|
|
42
|
+
|
|
43
|
+
const defaultProvisionId = 'store';
|
|
44
|
+
const connectionClients = new WeakMap<object, Database>();
|
|
45
|
+
|
|
46
|
+
const cloneValue = <T>(value: T): T => structuredClone(value);
|
|
47
|
+
|
|
48
|
+
const openSqliteDatabase = (url: string, readOnly: boolean): Database => {
|
|
49
|
+
const client = new Database(
|
|
50
|
+
url,
|
|
51
|
+
readOnly ? { readonly: true } : { create: true }
|
|
52
|
+
);
|
|
53
|
+
client.run('PRAGMA foreign_keys = ON');
|
|
54
|
+
|
|
55
|
+
if (!readOnly) {
|
|
56
|
+
client.run('PRAGMA journal_mode = WAL');
|
|
57
|
+
client.run('PRAGMA synchronous = NORMAL');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return client;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const asError = (error: unknown): Error =>
|
|
64
|
+
error instanceof Error ? error : new Error(String(error));
|
|
65
|
+
|
|
66
|
+
const storeTableNames = <TStore extends AnyStoreDefinition>(
|
|
67
|
+
definition: TStore
|
|
68
|
+
): readonly Extract<keyof TStore['tables'], string>[] =>
|
|
69
|
+
definition.tableNames as readonly Extract<keyof TStore['tables'], string>[];
|
|
70
|
+
|
|
71
|
+
const registerConnection = <TConnection extends object>(
|
|
72
|
+
connection: TConnection,
|
|
73
|
+
client: Database
|
|
74
|
+
): TConnection => {
|
|
75
|
+
connectionClients.set(connection, client);
|
|
76
|
+
return connection;
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const closeConnection = (connection: object): void => {
|
|
80
|
+
connectionClients.get(connection)?.close();
|
|
81
|
+
connectionClients.delete(connection);
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const mapDatabaseError = (tableName: string, error: unknown): Error => {
|
|
85
|
+
if (
|
|
86
|
+
error instanceof ValidationError ||
|
|
87
|
+
error instanceof AlreadyExistsError ||
|
|
88
|
+
error instanceof InternalError
|
|
89
|
+
) {
|
|
90
|
+
return error;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ZodError from .parse() should surface as ValidationError, not InternalError.
|
|
94
|
+
const resolved = asError(error);
|
|
95
|
+
if (resolved.name === 'ZodError') {
|
|
96
|
+
return new ValidationError(
|
|
97
|
+
`Store table "${tableName}" input failed schema validation: ${resolved.message}`,
|
|
98
|
+
{ cause: resolved }
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
if (resolved.message.includes('UNIQUE constraint failed')) {
|
|
102
|
+
return new AlreadyExistsError(
|
|
103
|
+
`Drizzle store insert for table "${tableName}" violated a uniqueness constraint`,
|
|
104
|
+
{ cause: resolved }
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (resolved.message.includes('FOREIGN KEY constraint failed')) {
|
|
109
|
+
return new ValidationError(
|
|
110
|
+
`Drizzle store insert for table "${tableName}" violated a foreign key constraint`,
|
|
111
|
+
{ cause: resolved }
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return new InternalError(
|
|
116
|
+
`Drizzle store encountered an unexpected error for table "${tableName}": ${resolved.message}`,
|
|
117
|
+
{ cause: resolved }
|
|
118
|
+
);
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
const formatIssues = (
|
|
122
|
+
issues: readonly { readonly message: string }[]
|
|
123
|
+
): string => issues.map((issue) => issue.message).join('; ');
|
|
124
|
+
|
|
125
|
+
const parseEntity = <TTable extends AnyStoreTable>(
|
|
126
|
+
table: TTable,
|
|
127
|
+
value: unknown
|
|
128
|
+
): EntityOf<TTable> => {
|
|
129
|
+
const parsed = table.schema.safeParse(value);
|
|
130
|
+
if (!parsed.success) {
|
|
131
|
+
throw new InternalError(
|
|
132
|
+
`Drizzle store for table "${table.name}" returned an entity that does not match the schema: ${formatIssues(parsed.error.issues)}`
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return parsed.data as EntityOf<TTable>;
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
const normalizeWriteInput = (
|
|
140
|
+
input: Record<string, unknown>
|
|
141
|
+
): Record<string, unknown> =>
|
|
142
|
+
Object.fromEntries(
|
|
143
|
+
Object.entries(input).filter(([, value]) => value !== undefined)
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
const baseFieldKind = <TTable extends AnyStoreTable>(
|
|
147
|
+
table: TTable,
|
|
148
|
+
field: StoreFieldKey<TTable['schema']>
|
|
149
|
+
): string =>
|
|
150
|
+
describeField(
|
|
151
|
+
field as string,
|
|
152
|
+
table.schema.shape[field as keyof typeof table.schema.shape] as z.ZodType
|
|
153
|
+
).kind;
|
|
154
|
+
|
|
155
|
+
const TIMESTAMP_FIELD_NAMES = new Set([
|
|
156
|
+
'createdAt',
|
|
157
|
+
'updatedAt',
|
|
158
|
+
'created_at',
|
|
159
|
+
'updated_at',
|
|
160
|
+
]);
|
|
161
|
+
|
|
162
|
+
const ID_FIELD_SUFFIX_RE = /[Ii]d$/;
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Synthesize a value for a generated field during insert.
|
|
166
|
+
*
|
|
167
|
+
* The connector recognizes these conventions for generated fields:
|
|
168
|
+
*
|
|
169
|
+
* - **Primary key** (`integer` type): auto-increment, left to SQLite.
|
|
170
|
+
* - **Timestamp fields** (`createdAt`, `updatedAt`, `created_at`,
|
|
171
|
+
* `updated_at`): materialized as `new Date()` (date type) or ISO 8601
|
|
172
|
+
* string (text type).
|
|
173
|
+
* - **ID-like text fields** (name ends with `Id` or `id`): filled with
|
|
174
|
+
* `Bun.randomUUIDv7()`.
|
|
175
|
+
*
|
|
176
|
+
* Any other generated `text` field that does not match a recognized convention
|
|
177
|
+
* throws a `ValidationError` — the developer must either supply a value or
|
|
178
|
+
* give the field a Zod default.
|
|
179
|
+
*
|
|
180
|
+
* All other generated field types fall through to `undefined`, letting the
|
|
181
|
+
* schema's Zod default (if any) apply during validation.
|
|
182
|
+
*/
|
|
183
|
+
const generatedTimestamp = (kind: string): unknown =>
|
|
184
|
+
kind === 'date' ? new Date() : new Date().toISOString();
|
|
185
|
+
|
|
186
|
+
const generatedTextValue = (tableName: string, fieldName: string): unknown => {
|
|
187
|
+
if (ID_FIELD_SUFFIX_RE.test(fieldName)) {
|
|
188
|
+
return Bun.randomUUIDv7();
|
|
189
|
+
}
|
|
190
|
+
throw new ValidationError(
|
|
191
|
+
`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.`
|
|
192
|
+
);
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
const generatedValueForInsert = <TTable extends AnyStoreTable>(
|
|
196
|
+
table: TTable,
|
|
197
|
+
field: StoreFieldKey<TTable['schema']>
|
|
198
|
+
): unknown => {
|
|
199
|
+
const fieldName = field as string;
|
|
200
|
+
const kind = baseFieldKind(table, field);
|
|
201
|
+
|
|
202
|
+
if (field === table.primaryKey && kind === 'integer') {
|
|
203
|
+
return undefined;
|
|
204
|
+
}
|
|
205
|
+
if (TIMESTAMP_FIELD_NAMES.has(fieldName)) {
|
|
206
|
+
return generatedTimestamp(kind);
|
|
207
|
+
}
|
|
208
|
+
if (kind === 'text') {
|
|
209
|
+
return generatedTextValue(table.name, fieldName);
|
|
210
|
+
}
|
|
211
|
+
throw new ValidationError(
|
|
212
|
+
`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.`
|
|
213
|
+
);
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
const materializeGeneratedFields = <TTable extends AnyStoreTable>(
|
|
217
|
+
table: TTable,
|
|
218
|
+
input: Record<string, unknown>
|
|
219
|
+
): Record<string, unknown> => {
|
|
220
|
+
const next = { ...input };
|
|
221
|
+
|
|
222
|
+
for (const field of table.generated) {
|
|
223
|
+
if (next[field] !== undefined) {
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const generated = generatedValueForInsert(
|
|
228
|
+
table,
|
|
229
|
+
field as StoreFieldKey<TTable['schema']>
|
|
230
|
+
);
|
|
231
|
+
if (generated !== undefined) {
|
|
232
|
+
next[field] = generated;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
return next;
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
const validateFixturePayload = <TTable extends AnyStoreTable>(
|
|
240
|
+
table: TTable,
|
|
241
|
+
input: Record<string, unknown>
|
|
242
|
+
): Record<string, unknown> => {
|
|
243
|
+
const parsed = table.fixtureSchema.safeParse(input);
|
|
244
|
+
if (!parsed.success) {
|
|
245
|
+
throw new ValidationError(
|
|
246
|
+
`Store table "${table.name}" insert payload is invalid after generated-field materialization: ${formatIssues(parsed.error.issues)}`
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
return normalizeWriteInput(parsed.data as Record<string, unknown>);
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
const applyGeneratedInsertFields = <TTable extends AnyStoreTable>(
|
|
254
|
+
table: TTable,
|
|
255
|
+
input: Record<string, unknown>
|
|
256
|
+
): Record<string, unknown> =>
|
|
257
|
+
validateFixturePayload(table, materializeGeneratedFields(table, input));
|
|
258
|
+
|
|
259
|
+
const applyGeneratedUpdateFields = <TTable extends AnyStoreTable>(
|
|
260
|
+
table: TTable,
|
|
261
|
+
input: Record<string, unknown>
|
|
262
|
+
): Record<string, unknown> => {
|
|
263
|
+
const updatedAtKey = (['updatedAt', 'updated_at'] as const).find((key) =>
|
|
264
|
+
table.generated.includes(key as StoreFieldKey<TTable['schema']>)
|
|
265
|
+
);
|
|
266
|
+
|
|
267
|
+
if (!updatedAtKey) {
|
|
268
|
+
return normalizeWriteInput(input);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const kind = baseFieldKind(
|
|
272
|
+
table,
|
|
273
|
+
updatedAtKey as StoreFieldKey<TTable['schema']>
|
|
274
|
+
);
|
|
275
|
+
|
|
276
|
+
return normalizeWriteInput({
|
|
277
|
+
...input,
|
|
278
|
+
[updatedAtKey]:
|
|
279
|
+
input[updatedAtKey] ??
|
|
280
|
+
(kind === 'date' ? new Date() : new Date().toISOString()),
|
|
281
|
+
});
|
|
282
|
+
};
|
|
283
|
+
|
|
284
|
+
interface VisitFrame {
|
|
285
|
+
readonly expanded: boolean;
|
|
286
|
+
readonly tableName: string;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const ensureNotCyclic = (
|
|
290
|
+
visiting: ReadonlySet<string>,
|
|
291
|
+
tableName: string
|
|
292
|
+
): void => {
|
|
293
|
+
if (!visiting.has(tableName)) {
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
throw new ValidationError(
|
|
298
|
+
`Store definition contains a reference cycle involving "${tableName}", which the SQLite connector cannot seed automatically`
|
|
299
|
+
);
|
|
300
|
+
};
|
|
301
|
+
|
|
302
|
+
const pushVisitDependencies = (
|
|
303
|
+
stack: VisitFrame[],
|
|
304
|
+
definition: AnyStoreDefinition,
|
|
305
|
+
visited: ReadonlySet<string>,
|
|
306
|
+
tableName: string
|
|
307
|
+
): void => {
|
|
308
|
+
const table = definition.tables[tableName];
|
|
309
|
+
if (table === undefined) {
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
for (const target of Object.values(table.references).toReversed()) {
|
|
314
|
+
if (target !== undefined && !visited.has(target)) {
|
|
315
|
+
stack.push({ expanded: false, tableName: target });
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
};
|
|
319
|
+
|
|
320
|
+
const finishVisitFrame = (
|
|
321
|
+
frame: VisitFrame,
|
|
322
|
+
visiting: Set<string>,
|
|
323
|
+
visited: Set<string>,
|
|
324
|
+
ordered: string[]
|
|
325
|
+
): void => {
|
|
326
|
+
visiting.delete(frame.tableName);
|
|
327
|
+
visited.add(frame.tableName);
|
|
328
|
+
ordered.push(frame.tableName);
|
|
329
|
+
};
|
|
330
|
+
|
|
331
|
+
const startVisitFrame = (
|
|
332
|
+
stack: VisitFrame[],
|
|
333
|
+
definition: AnyStoreDefinition,
|
|
334
|
+
visiting: Set<string>,
|
|
335
|
+
visited: Set<string>,
|
|
336
|
+
tableName: string
|
|
337
|
+
): void => {
|
|
338
|
+
ensureNotCyclic(visiting, tableName);
|
|
339
|
+
visiting.add(tableName);
|
|
340
|
+
stack.push({ expanded: true, tableName });
|
|
341
|
+
pushVisitDependencies(stack, definition, visited, tableName);
|
|
342
|
+
};
|
|
343
|
+
|
|
344
|
+
const visitTableForSeeding = (
|
|
345
|
+
definition: AnyStoreDefinition,
|
|
346
|
+
visiting: Set<string>,
|
|
347
|
+
visited: Set<string>,
|
|
348
|
+
ordered: string[],
|
|
349
|
+
tableName: string
|
|
350
|
+
): void => {
|
|
351
|
+
const stack: VisitFrame[] = [{ expanded: false, tableName }];
|
|
352
|
+
|
|
353
|
+
while (stack.length > 0) {
|
|
354
|
+
const frame = stack.pop();
|
|
355
|
+
if (frame === undefined) {
|
|
356
|
+
continue;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
if (frame.expanded) {
|
|
360
|
+
finishVisitFrame(frame, visiting, visited, ordered);
|
|
361
|
+
continue;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
if (!visited.has(frame.tableName)) {
|
|
365
|
+
startVisitFrame(stack, definition, visiting, visited, frame.tableName);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
};
|
|
369
|
+
|
|
370
|
+
const topologicalTableOrder = (
|
|
371
|
+
definition: AnyStoreDefinition
|
|
372
|
+
): readonly string[] => {
|
|
373
|
+
const visited = new Set<string>();
|
|
374
|
+
const visiting = new Set<string>();
|
|
375
|
+
const ordered: string[] = [];
|
|
376
|
+
|
|
377
|
+
for (const tableName of definition.tableNames) {
|
|
378
|
+
visitTableForSeeding(definition, visiting, visited, ordered, tableName);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
return Object.freeze(ordered);
|
|
382
|
+
};
|
|
383
|
+
|
|
384
|
+
const ensureSqliteSchema = (
|
|
385
|
+
client: Database,
|
|
386
|
+
definition: AnyStoreDefinition
|
|
387
|
+
): void => {
|
|
388
|
+
for (const statement of createSqliteSchemaStatements(definition)) {
|
|
389
|
+
client.run(statement);
|
|
390
|
+
}
|
|
391
|
+
};
|
|
392
|
+
|
|
393
|
+
const primaryKeyColumn = (
|
|
394
|
+
table: AnySQLiteTable,
|
|
395
|
+
field: string
|
|
396
|
+
): AnySQLiteColumn => table[field as keyof typeof table] as AnySQLiteColumn;
|
|
397
|
+
|
|
398
|
+
const buildFilterConditions = (
|
|
399
|
+
drizzleTable: AnySQLiteTable,
|
|
400
|
+
filters: Record<string, unknown> | undefined
|
|
401
|
+
): ReturnType<typeof eq>[] =>
|
|
402
|
+
filters === undefined
|
|
403
|
+
? []
|
|
404
|
+
: Object.entries(filters)
|
|
405
|
+
.filter(([, value]) => value !== undefined)
|
|
406
|
+
.map(([field, value]) =>
|
|
407
|
+
eq(primaryKeyColumn(drizzleTable, field), value as never)
|
|
408
|
+
);
|
|
409
|
+
|
|
410
|
+
const createReadOnlyAccessor = <
|
|
411
|
+
TStore extends AnyStoreDefinition,
|
|
412
|
+
TName extends keyof TStore['tables'] & string,
|
|
413
|
+
>(
|
|
414
|
+
definitionTable: TStore['tables'][TName],
|
|
415
|
+
drizzleTable: DrizzleStoreSchema<TStore>[TName],
|
|
416
|
+
db: ReturnType<typeof drizzle<DrizzleStoreSchema<TStore>>>
|
|
417
|
+
): ReadOnlyStoreConnection<TStore>[TName] => ({
|
|
418
|
+
get(id) {
|
|
419
|
+
try {
|
|
420
|
+
const row = db
|
|
421
|
+
.select()
|
|
422
|
+
.from(drizzleTable)
|
|
423
|
+
.where(
|
|
424
|
+
eq(
|
|
425
|
+
primaryKeyColumn(drizzleTable, definitionTable.primaryKey),
|
|
426
|
+
id as never
|
|
427
|
+
)
|
|
428
|
+
)
|
|
429
|
+
.get();
|
|
430
|
+
|
|
431
|
+
return Promise.resolve(
|
|
432
|
+
row === null || row === undefined
|
|
433
|
+
? null
|
|
434
|
+
: cloneValue(parseEntity(definitionTable, row))
|
|
435
|
+
);
|
|
436
|
+
} catch (error) {
|
|
437
|
+
return Promise.reject(mapDatabaseError(definitionTable.name, error));
|
|
438
|
+
}
|
|
439
|
+
},
|
|
440
|
+
list(filters, options) {
|
|
441
|
+
try {
|
|
442
|
+
const conditions = buildFilterConditions(
|
|
443
|
+
drizzleTable,
|
|
444
|
+
filters as Record<string, unknown> | undefined
|
|
445
|
+
);
|
|
446
|
+
const base = db.select().from(drizzleTable).$dynamic();
|
|
447
|
+
const filtered =
|
|
448
|
+
conditions.length > 0 ? base.where(and(...conditions)) : base;
|
|
449
|
+
const rows = filtered
|
|
450
|
+
.limit(options?.limit ?? -1)
|
|
451
|
+
.offset(options?.offset ?? 0)
|
|
452
|
+
.all();
|
|
453
|
+
|
|
454
|
+
return Promise.resolve(
|
|
455
|
+
rows.map((row) => cloneValue(parseEntity(definitionTable, row)))
|
|
456
|
+
);
|
|
457
|
+
} catch (error) {
|
|
458
|
+
return Promise.reject(mapDatabaseError(definitionTable.name, error));
|
|
459
|
+
}
|
|
460
|
+
},
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
const createWritableAccessor = <
|
|
464
|
+
TStore extends AnyStoreDefinition,
|
|
465
|
+
TName extends keyof TStore['tables'] & string,
|
|
466
|
+
>(
|
|
467
|
+
definitionTable: TStore['tables'][TName],
|
|
468
|
+
drizzleTable: DrizzleStoreSchema<TStore>[TName],
|
|
469
|
+
db: ReturnType<typeof drizzle<DrizzleStoreSchema<TStore>>>
|
|
470
|
+
): StoreConnection<TStore>[TName] => ({
|
|
471
|
+
...createReadOnlyAccessor(definitionTable, drizzleTable, db),
|
|
472
|
+
insert(input) {
|
|
473
|
+
try {
|
|
474
|
+
const parsed = definitionTable.insertSchema.parse(input) as InsertOf<
|
|
475
|
+
TStore['tables'][TName]
|
|
476
|
+
>;
|
|
477
|
+
const row = db
|
|
478
|
+
.insert(drizzleTable)
|
|
479
|
+
.values(
|
|
480
|
+
applyGeneratedInsertFields(
|
|
481
|
+
definitionTable,
|
|
482
|
+
parsed as Record<string, unknown>
|
|
483
|
+
) as never
|
|
484
|
+
)
|
|
485
|
+
.returning()
|
|
486
|
+
.get();
|
|
487
|
+
|
|
488
|
+
return Promise.resolve(cloneValue(parseEntity(definitionTable, row)));
|
|
489
|
+
} catch (error) {
|
|
490
|
+
return Promise.reject(mapDatabaseError(definitionTable.name, error));
|
|
491
|
+
}
|
|
492
|
+
},
|
|
493
|
+
remove(id) {
|
|
494
|
+
try {
|
|
495
|
+
const deleted = db
|
|
496
|
+
.delete(drizzleTable)
|
|
497
|
+
.where(
|
|
498
|
+
eq(
|
|
499
|
+
primaryKeyColumn(drizzleTable, definitionTable.primaryKey),
|
|
500
|
+
id as never
|
|
501
|
+
)
|
|
502
|
+
)
|
|
503
|
+
.returning({
|
|
504
|
+
deletedId: primaryKeyColumn(drizzleTable, definitionTable.primaryKey),
|
|
505
|
+
})
|
|
506
|
+
.get();
|
|
507
|
+
|
|
508
|
+
return Promise.resolve({ deleted: deleted !== undefined });
|
|
509
|
+
} catch (error) {
|
|
510
|
+
return Promise.reject(mapDatabaseError(definitionTable.name, error));
|
|
511
|
+
}
|
|
512
|
+
},
|
|
513
|
+
update(id, input) {
|
|
514
|
+
try {
|
|
515
|
+
const parsed = definitionTable.updateSchema.parse(input) as UpdateOf<
|
|
516
|
+
TStore['tables'][TName]
|
|
517
|
+
>;
|
|
518
|
+
const userFields = normalizeWriteInput(parsed as Record<string, unknown>);
|
|
519
|
+
if (Object.keys(userFields).length === 0) {
|
|
520
|
+
return Promise.reject(
|
|
521
|
+
new ValidationError(
|
|
522
|
+
`Store table "${definitionTable.name}" update requires at least one field to set.`
|
|
523
|
+
)
|
|
524
|
+
);
|
|
525
|
+
}
|
|
526
|
+
const fields = applyGeneratedUpdateFields(
|
|
527
|
+
definitionTable,
|
|
528
|
+
parsed as Record<string, unknown>
|
|
529
|
+
);
|
|
530
|
+
const row = db
|
|
531
|
+
.update(drizzleTable)
|
|
532
|
+
.set(fields as never)
|
|
533
|
+
.where(
|
|
534
|
+
eq(
|
|
535
|
+
primaryKeyColumn(drizzleTable, definitionTable.primaryKey),
|
|
536
|
+
id as never
|
|
537
|
+
)
|
|
538
|
+
)
|
|
539
|
+
.returning()
|
|
540
|
+
.get();
|
|
541
|
+
|
|
542
|
+
return Promise.resolve(
|
|
543
|
+
row === undefined ? null : cloneValue(parseEntity(definitionTable, row))
|
|
544
|
+
);
|
|
545
|
+
} catch (error) {
|
|
546
|
+
return Promise.reject(mapDatabaseError(definitionTable.name, error));
|
|
547
|
+
}
|
|
548
|
+
},
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
/** Collect non-empty fixture arrays keyed by table name. */
|
|
552
|
+
const collectFixtures = <TStore extends AnyStoreDefinition>(
|
|
553
|
+
definition: TStore,
|
|
554
|
+
seed?: DrizzleMockSeed<TStore>
|
|
555
|
+
): Map<string, readonly FixtureInputOf<AnyStoreTable>[]> => {
|
|
556
|
+
const result = new Map<string, readonly FixtureInputOf<AnyStoreTable>[]>();
|
|
557
|
+
for (const tableName of definition.tableNames) {
|
|
558
|
+
const table = definition.tables[tableName];
|
|
559
|
+
if (table === undefined) {
|
|
560
|
+
continue;
|
|
561
|
+
}
|
|
562
|
+
const fixtures =
|
|
563
|
+
(seed?.[tableName] as
|
|
564
|
+
| readonly FixtureInputOf<typeof table>[]
|
|
565
|
+
| undefined) ?? table.fixtures;
|
|
566
|
+
if (fixtures.length > 0) {
|
|
567
|
+
result.set(tableName, fixtures);
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
return result;
|
|
571
|
+
};
|
|
572
|
+
|
|
573
|
+
/** Insert fixture rows in topological order. */
|
|
574
|
+
const insertFixtureRows = <TStore extends AnyStoreDefinition>(
|
|
575
|
+
definition: TStore,
|
|
576
|
+
tables: DrizzleStoreSchema<TStore>,
|
|
577
|
+
db: ReturnType<typeof drizzle<DrizzleStoreSchema<TStore>>>,
|
|
578
|
+
fixturesByTable: Map<string, readonly FixtureInputOf<AnyStoreTable>[]>
|
|
579
|
+
): void => {
|
|
580
|
+
for (const tableName of topologicalTableOrder(definition)) {
|
|
581
|
+
const defTable = definition.tables[tableName];
|
|
582
|
+
const drizzleTable = tables[tableName];
|
|
583
|
+
const fixtures = fixturesByTable.get(tableName);
|
|
584
|
+
if (!defTable || !drizzleTable || !fixtures) {
|
|
585
|
+
continue;
|
|
586
|
+
}
|
|
587
|
+
for (const fixture of fixtures) {
|
|
588
|
+
db.insert(drizzleTable)
|
|
589
|
+
.values(
|
|
590
|
+
applyGeneratedInsertFields(
|
|
591
|
+
defTable,
|
|
592
|
+
fixture as Record<string, unknown>
|
|
593
|
+
) as never
|
|
594
|
+
)
|
|
595
|
+
.run();
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
};
|
|
599
|
+
|
|
600
|
+
const seedFixtures = <TStore extends AnyStoreDefinition>(
|
|
601
|
+
definition: TStore,
|
|
602
|
+
tables: DrizzleStoreSchema<TStore>,
|
|
603
|
+
db: ReturnType<typeof drizzle<DrizzleStoreSchema<TStore>>>,
|
|
604
|
+
seed?: DrizzleMockSeed<TStore>
|
|
605
|
+
): void => {
|
|
606
|
+
const fixturesByTable = collectFixtures(definition, seed);
|
|
607
|
+
if (fixturesByTable.size === 0) {
|
|
608
|
+
return;
|
|
609
|
+
}
|
|
610
|
+
insertFixtureRows(definition, tables, db, fixturesByTable);
|
|
611
|
+
};
|
|
612
|
+
|
|
613
|
+
const createReadOnlyConnection = <TStore extends AnyStoreDefinition>(
|
|
614
|
+
definition: TStore,
|
|
615
|
+
tables: DrizzleStoreSchema<TStore>,
|
|
616
|
+
db: ReturnType<typeof drizzle<DrizzleStoreSchema<TStore>>>,
|
|
617
|
+
client: Database
|
|
618
|
+
): ReadOnlyDrizzleStoreConnection<TStore> => {
|
|
619
|
+
const connection = {
|
|
620
|
+
async query(run) {
|
|
621
|
+
return await run({ drizzle: db, tables });
|
|
622
|
+
},
|
|
623
|
+
} as ReadOnlyDrizzleStoreConnection<TStore>;
|
|
624
|
+
|
|
625
|
+
for (const tableName of storeTableNames(definition)) {
|
|
626
|
+
const definitionTable = definition.tables[tableName];
|
|
627
|
+
const drizzleTable = tables[tableName];
|
|
628
|
+
if (definitionTable === undefined || drizzleTable === undefined) {
|
|
629
|
+
continue;
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
Object.defineProperty(connection, tableName, {
|
|
633
|
+
enumerable: true,
|
|
634
|
+
value: createReadOnlyAccessor(
|
|
635
|
+
definitionTable as TStore['tables'][typeof tableName],
|
|
636
|
+
drizzleTable,
|
|
637
|
+
db
|
|
638
|
+
),
|
|
639
|
+
});
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
return Object.freeze(
|
|
643
|
+
registerConnection(connection, client)
|
|
644
|
+
) as ReadOnlyDrizzleStoreConnection<TStore>;
|
|
645
|
+
};
|
|
646
|
+
|
|
647
|
+
const createWritableConnection = <TStore extends AnyStoreDefinition>(
|
|
648
|
+
definition: TStore,
|
|
649
|
+
tables: DrizzleStoreSchema<TStore>,
|
|
650
|
+
db: ReturnType<typeof drizzle<DrizzleStoreSchema<TStore>>>,
|
|
651
|
+
client: Database
|
|
652
|
+
): DrizzleStoreConnection<TStore> => {
|
|
653
|
+
const connection = {
|
|
654
|
+
async query(run) {
|
|
655
|
+
return await run({ drizzle: db, tables });
|
|
656
|
+
},
|
|
657
|
+
} as DrizzleStoreConnection<TStore>;
|
|
658
|
+
|
|
659
|
+
for (const tableName of storeTableNames(definition)) {
|
|
660
|
+
const definitionTable = definition.tables[tableName];
|
|
661
|
+
const drizzleTable = tables[tableName];
|
|
662
|
+
if (definitionTable === undefined || drizzleTable === undefined) {
|
|
663
|
+
continue;
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
Object.defineProperty(connection, tableName, {
|
|
667
|
+
enumerable: true,
|
|
668
|
+
value: createWritableAccessor(
|
|
669
|
+
definitionTable as TStore['tables'][typeof tableName],
|
|
670
|
+
drizzleTable,
|
|
671
|
+
db
|
|
672
|
+
),
|
|
673
|
+
});
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
return Object.freeze(
|
|
677
|
+
registerConnection(connection, client)
|
|
678
|
+
) as DrizzleStoreConnection<TStore>;
|
|
679
|
+
};
|
|
680
|
+
|
|
681
|
+
const buildProvisionShape = <
|
|
682
|
+
TStore extends AnyStoreDefinition,
|
|
683
|
+
TConnection,
|
|
684
|
+
TAccess extends StoreAccessMode,
|
|
685
|
+
>(
|
|
686
|
+
value: ReturnType<typeof provision<TConnection>>,
|
|
687
|
+
store: TStore,
|
|
688
|
+
tables: DrizzleStoreSchema<TStore>,
|
|
689
|
+
access: TAccess
|
|
690
|
+
): DrizzleStoreProvision<TStore, TConnection, TAccess> =>
|
|
691
|
+
Object.freeze({
|
|
692
|
+
...value,
|
|
693
|
+
access,
|
|
694
|
+
store,
|
|
695
|
+
tables,
|
|
696
|
+
});
|
|
697
|
+
|
|
698
|
+
const connectionHealth = (
|
|
699
|
+
connection: object
|
|
700
|
+
): Result<{ readonly ok: true }, Error> => {
|
|
701
|
+
const client = connectionClients.get(connection);
|
|
702
|
+
if (client === undefined) {
|
|
703
|
+
return Result.err(
|
|
704
|
+
new InternalError('Drizzle store connection is missing its SQLite client')
|
|
705
|
+
);
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
try {
|
|
709
|
+
client.query('SELECT 1').get();
|
|
710
|
+
return Result.ok({ ok: true });
|
|
711
|
+
} catch (error) {
|
|
712
|
+
return Result.err(asError(error));
|
|
713
|
+
}
|
|
714
|
+
};
|
|
715
|
+
|
|
716
|
+
/**
|
|
717
|
+
* Bind a store definition to a Drizzle-backed SQLite provision.
|
|
718
|
+
*
|
|
719
|
+
* The returned provision manages its own connection lifecycle. The `mock()`
|
|
720
|
+
* factory creates an in-memory SQLite database seeded with fixtures — callers
|
|
721
|
+
* who obtain a mock connection are responsible for calling `closeConnection()`
|
|
722
|
+
* when done, or letting the connection be garbage-collected (the underlying
|
|
723
|
+
* `Database` client is tracked via `WeakMap`).
|
|
724
|
+
*
|
|
725
|
+
* Note: the `search` field on `StoreTableInput` is not yet interpreted by
|
|
726
|
+
* this connector — it is reserved for future full-text search support.
|
|
727
|
+
*/
|
|
728
|
+
export const connectDrizzle = <const TStore extends AnyStoreDefinition>(
|
|
729
|
+
definition: TStore,
|
|
730
|
+
options: ConnectDrizzleOptions<TStore>
|
|
731
|
+
): DrizzleStoreProvision<
|
|
732
|
+
TStore,
|
|
733
|
+
DrizzleStoreConnection<TStore>,
|
|
734
|
+
'readwrite'
|
|
735
|
+
> => {
|
|
736
|
+
const tables = deriveDrizzleTables(definition);
|
|
737
|
+
|
|
738
|
+
return buildProvisionShape(
|
|
739
|
+
provision(options.id ?? defaultProvisionId, {
|
|
740
|
+
create: () => {
|
|
741
|
+
try {
|
|
742
|
+
const client = openSqliteDatabase(options.url, false);
|
|
743
|
+
try {
|
|
744
|
+
ensureSqliteSchema(client, definition);
|
|
745
|
+
} catch (error) {
|
|
746
|
+
client.close();
|
|
747
|
+
throw error;
|
|
748
|
+
}
|
|
749
|
+
const db = drizzle({ client, schema: tables });
|
|
750
|
+
return Result.ok(
|
|
751
|
+
createWritableConnection(definition, tables, db, client)
|
|
752
|
+
);
|
|
753
|
+
} catch (error) {
|
|
754
|
+
return Result.err(
|
|
755
|
+
new InternalError(
|
|
756
|
+
`Drizzle store failed to open database at "${options.url}": ${asError(error).message}`,
|
|
757
|
+
{ cause: asError(error) }
|
|
758
|
+
)
|
|
759
|
+
);
|
|
760
|
+
}
|
|
761
|
+
},
|
|
762
|
+
description:
|
|
763
|
+
options.description ??
|
|
764
|
+
'Drizzle-backed writable store bound from an @ontrails/store definition.',
|
|
765
|
+
dispose: (connection) => {
|
|
766
|
+
closeConnection(connection);
|
|
767
|
+
},
|
|
768
|
+
health: connectionHealth,
|
|
769
|
+
mock: () => {
|
|
770
|
+
const client = openSqliteDatabase(':memory:', false);
|
|
771
|
+
try {
|
|
772
|
+
ensureSqliteSchema(client, definition);
|
|
773
|
+
const db = drizzle({ client, schema: tables });
|
|
774
|
+
seedFixtures(definition, tables, db, options.mockSeed);
|
|
775
|
+
return createWritableConnection(definition, tables, db, client);
|
|
776
|
+
} catch (error) {
|
|
777
|
+
client.close();
|
|
778
|
+
throw error;
|
|
779
|
+
}
|
|
780
|
+
},
|
|
781
|
+
}),
|
|
782
|
+
definition,
|
|
783
|
+
tables,
|
|
784
|
+
'readwrite'
|
|
785
|
+
);
|
|
786
|
+
};
|
|
787
|
+
|
|
788
|
+
export const connectReadOnlyDrizzle = <const TStore extends AnyStoreDefinition>(
|
|
789
|
+
definition: TStore,
|
|
790
|
+
options: ReadOnlyDrizzleOptions
|
|
791
|
+
): DrizzleStoreProvision<
|
|
792
|
+
TStore,
|
|
793
|
+
ReadOnlyDrizzleStoreConnection<TStore>,
|
|
794
|
+
'readonly'
|
|
795
|
+
> => {
|
|
796
|
+
const tables = deriveDrizzleTables(definition);
|
|
797
|
+
|
|
798
|
+
return buildProvisionShape(
|
|
799
|
+
provision(options.id ?? defaultProvisionId, {
|
|
800
|
+
create: () => {
|
|
801
|
+
try {
|
|
802
|
+
const client = openSqliteDatabase(options.url, true);
|
|
803
|
+
const db = drizzle({ client, schema: tables });
|
|
804
|
+
return Result.ok(
|
|
805
|
+
createReadOnlyConnection(definition, tables, db, client)
|
|
806
|
+
);
|
|
807
|
+
} catch (error) {
|
|
808
|
+
return Result.err(
|
|
809
|
+
new InternalError(
|
|
810
|
+
`Drizzle read-only store failed to open database at "${options.url}": ${asError(error).message}`,
|
|
811
|
+
{ cause: asError(error) }
|
|
812
|
+
)
|
|
813
|
+
);
|
|
814
|
+
}
|
|
815
|
+
},
|
|
816
|
+
description:
|
|
817
|
+
options.description ??
|
|
818
|
+
'Drizzle-backed read-only store bound from an @ontrails/store definition.',
|
|
819
|
+
dispose: (connection) => {
|
|
820
|
+
closeConnection(connection);
|
|
821
|
+
},
|
|
822
|
+
health: connectionHealth,
|
|
823
|
+
}),
|
|
824
|
+
definition,
|
|
825
|
+
tables,
|
|
826
|
+
'readonly'
|
|
827
|
+
);
|
|
828
|
+
};
|
|
829
|
+
|
|
830
|
+
export const store = <const TTables extends StoreTablesInput>(
|
|
831
|
+
tables: TTables,
|
|
832
|
+
options: ConnectDrizzleOptions<ReturnType<typeof defineStore<TTables>>>
|
|
833
|
+
): DrizzleStoreProvision<
|
|
834
|
+
ReturnType<typeof defineStore<TTables>>,
|
|
835
|
+
DrizzleStoreConnection<ReturnType<typeof defineStore<TTables>>>,
|
|
836
|
+
'readwrite'
|
|
837
|
+
> => connectDrizzle(defineStore(tables), options);
|
|
838
|
+
|
|
839
|
+
export const readonlyStore = <const TTables extends StoreTablesInput>(
|
|
840
|
+
tables: TTables,
|
|
841
|
+
options: ReadOnlyDrizzleOptions
|
|
842
|
+
): DrizzleStoreProvision<
|
|
843
|
+
ReturnType<typeof defineStore<TTables>>,
|
|
844
|
+
ReadOnlyDrizzleStoreConnection<ReturnType<typeof defineStore<TTables>>>,
|
|
845
|
+
'readonly'
|
|
846
|
+
> => connectReadOnlyDrizzle(defineStore(tables), options);
|
|
847
|
+
|
|
848
|
+
export const getSchema = <TStore extends AnyStoreDefinition>(
|
|
849
|
+
binding: Pick<
|
|
850
|
+
DrizzleStoreProvision<TStore, unknown, StoreAccessMode>,
|
|
851
|
+
'tables'
|
|
852
|
+
>
|
|
853
|
+
): DrizzleStoreSchema<TStore> => binding.tables;
|