@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/CHANGELOG.md +267 -0
- package/README.md +40 -0
- package/package.json +42 -0
- package/src/index.ts +9 -0
- package/src/runtime.ts +1351 -0
- package/src/schema.ts +590 -0
- package/src/types.ts +81 -0
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
|
+
};
|