@ontrails/drizzle 1.0.0-beta.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +1 -0
- package/.turbo/turbo-lint.log +3 -0
- package/.turbo/turbo-typecheck.log +1 -0
- package/CHANGELOG.md +9 -0
- package/README.md +36 -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/runtime.d.ts +17 -0
- package/dist/runtime.d.ts.map +1 -0
- package/dist/runtime.js +755 -0
- package/dist/runtime.js.map +1 -0
- package/dist/schema.d.ts +15 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/schema.js +332 -0
- package/dist/schema.js.map +1 -0
- package/dist/types.d.ts +38 -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 +24 -0
- package/src/__tests__/drizzle.test.ts +997 -0
- package/src/index.ts +9 -0
- package/src/runtime.ts +1346 -0
- package/src/schema.ts +590 -0
- package/src/types.ts +65 -0
- package/tsconfig.json +9 -0
- package/tsconfig.tests.json +10 -0
- package/tsconfig.tsbuildinfo +1 -0
package/dist/runtime.js
ADDED
|
@@ -0,0 +1,755 @@
|
|
|
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 { AlreadyExistsError, ConflictError, InternalError, Result, ValidationError, resource, } from '@ontrails/core';
|
|
6
|
+
import { versionFieldName } from '@ontrails/store';
|
|
7
|
+
import { bindStoreDefinition } from '@ontrails/store/internal/signal-identity';
|
|
8
|
+
import { and, eq, sql } from 'drizzle-orm';
|
|
9
|
+
import { drizzle } from 'drizzle-orm/bun-sqlite';
|
|
10
|
+
import { deriveDrizzleTables, deriveFieldSpec, deriveSqliteSchemaStatements, } from './schema.js';
|
|
11
|
+
const defaultResourceId = 'store';
|
|
12
|
+
const connectionClients = new WeakMap();
|
|
13
|
+
const connectionTempDirs = new WeakMap();
|
|
14
|
+
const cloneValue = (value) => structuredClone(value);
|
|
15
|
+
const openSqliteDatabase = (url, readOnly) => {
|
|
16
|
+
const client = new Database(url, readOnly ? { readonly: true } : { create: true });
|
|
17
|
+
client.run('PRAGMA foreign_keys = ON');
|
|
18
|
+
if (!readOnly) {
|
|
19
|
+
client.run('PRAGMA journal_mode = WAL');
|
|
20
|
+
client.run('PRAGMA synchronous = NORMAL');
|
|
21
|
+
}
|
|
22
|
+
return client;
|
|
23
|
+
};
|
|
24
|
+
const asError = (error) => error instanceof Error ? error : new Error(String(error));
|
|
25
|
+
const storeTableNames = (definition) => definition.tableNames;
|
|
26
|
+
const registerConnection = (connection, client, tempDir) => {
|
|
27
|
+
connectionClients.set(connection, client);
|
|
28
|
+
if (tempDir !== undefined) {
|
|
29
|
+
connectionTempDirs.set(connection, tempDir);
|
|
30
|
+
}
|
|
31
|
+
return connection;
|
|
32
|
+
};
|
|
33
|
+
const closeConnection = (connection) => {
|
|
34
|
+
connectionClients.get(connection)?.close();
|
|
35
|
+
connectionClients.delete(connection);
|
|
36
|
+
const tempDir = connectionTempDirs.get(connection);
|
|
37
|
+
if (tempDir !== undefined) {
|
|
38
|
+
rmSync(tempDir, { force: true, recursive: true });
|
|
39
|
+
connectionTempDirs.delete(connection);
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
const createReadonlyMockTempDir = () => mkdtempSync(join(tmpdir(), 'trails-drizzle-readonly-'));
|
|
43
|
+
const mapDatabaseError = (tableName, error) => {
|
|
44
|
+
if (error instanceof ValidationError ||
|
|
45
|
+
error instanceof AlreadyExistsError ||
|
|
46
|
+
error instanceof ConflictError ||
|
|
47
|
+
error instanceof InternalError) {
|
|
48
|
+
return error;
|
|
49
|
+
}
|
|
50
|
+
// ZodError from .parse() should surface as ValidationError, not InternalError.
|
|
51
|
+
const resolved = asError(error);
|
|
52
|
+
if (resolved.name === 'ZodError') {
|
|
53
|
+
return new ValidationError(`Store table "${tableName}" input failed schema validation: ${resolved.message}`, { cause: resolved });
|
|
54
|
+
}
|
|
55
|
+
if (resolved.message.includes('UNIQUE constraint failed')) {
|
|
56
|
+
return new AlreadyExistsError(`Drizzle store insert for table "${tableName}" violated a uniqueness constraint`, { cause: resolved });
|
|
57
|
+
}
|
|
58
|
+
if (resolved.message.includes('FOREIGN KEY constraint failed')) {
|
|
59
|
+
return new ValidationError(`Drizzle store insert for table "${tableName}" violated a foreign key constraint`, { cause: resolved });
|
|
60
|
+
}
|
|
61
|
+
return new InternalError(`Drizzle store encountered an unexpected error for table "${tableName}": ${resolved.message}`, { cause: resolved });
|
|
62
|
+
};
|
|
63
|
+
const formatIssues = (issues) => issues.map((issue) => issue.message).join('; ');
|
|
64
|
+
const parseEntity = (table, value) => {
|
|
65
|
+
const parsed = table.schema.safeParse(value);
|
|
66
|
+
if (!parsed.success) {
|
|
67
|
+
throw new InternalError(`Drizzle store for table "${table.name}" returned an entity that does not match the schema: ${formatIssues(parsed.error.issues)}`);
|
|
68
|
+
}
|
|
69
|
+
return parsed.data;
|
|
70
|
+
};
|
|
71
|
+
const normalizeWriteInput = (input) => Object.fromEntries(Object.entries(input).filter(([, value]) => value !== undefined));
|
|
72
|
+
const baseFieldKind = (table, field) => deriveFieldSpec(field, table.schema.shape[field]).kind;
|
|
73
|
+
const TIMESTAMP_FIELD_NAMES = new Set([
|
|
74
|
+
'createdAt',
|
|
75
|
+
'updatedAt',
|
|
76
|
+
'created_at',
|
|
77
|
+
'updated_at',
|
|
78
|
+
]);
|
|
79
|
+
const ID_FIELD_SUFFIX_RE = /[Ii]d$/;
|
|
80
|
+
/**
|
|
81
|
+
* Returns true when the given field on `table` is the framework-managed
|
|
82
|
+
* version column. Used by insert and upsert paths to gate behavior that
|
|
83
|
+
* only applies to versioned tables, preventing silent data loss when a
|
|
84
|
+
* non-versioned table happens to have a user field named `version`.
|
|
85
|
+
*/
|
|
86
|
+
const isVersionManagedField = (table, fieldName) => table.versioned && fieldName === versionFieldName;
|
|
87
|
+
/**
|
|
88
|
+
* Synthesize a value for a generated field during insert.
|
|
89
|
+
*
|
|
90
|
+
* The connector recognizes these conventions for generated fields:
|
|
91
|
+
*
|
|
92
|
+
* - **Primary key** (`integer` type): auto-increment, left to SQLite.
|
|
93
|
+
* - **Timestamp fields** (`createdAt`, `updatedAt`, `created_at`,
|
|
94
|
+
* `updated_at`): materialized as `new Date()` (date type) or ISO 8601
|
|
95
|
+
* string (text type).
|
|
96
|
+
* - **ID-like text fields** (name ends with `Id` or `id`): filled with
|
|
97
|
+
* `Bun.randomUUIDv7()`.
|
|
98
|
+
*
|
|
99
|
+
* Any other generated `text` field that does not match a recognized convention
|
|
100
|
+
* throws a `ValidationError` — the developer must either supply a value or
|
|
101
|
+
* give the field a Zod default.
|
|
102
|
+
*
|
|
103
|
+
* All other generated field types fall through to `undefined`, letting the
|
|
104
|
+
* schema's Zod default (if any) apply during validation.
|
|
105
|
+
*/
|
|
106
|
+
const generatedTimestamp = (kind) => kind === 'date' ? new Date() : new Date().toISOString();
|
|
107
|
+
const generatedTextValue = (tableName, fieldName) => {
|
|
108
|
+
if (ID_FIELD_SUFFIX_RE.test(fieldName)) {
|
|
109
|
+
return Bun.randomUUIDv7();
|
|
110
|
+
}
|
|
111
|
+
throw new ValidationError(`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.`);
|
|
112
|
+
};
|
|
113
|
+
const generatedVersionValue = (tableName, kind) => {
|
|
114
|
+
if (kind === 'integer') {
|
|
115
|
+
return 1;
|
|
116
|
+
}
|
|
117
|
+
throw new ValidationError(`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.`);
|
|
118
|
+
};
|
|
119
|
+
const generatedFallbackValue = (table, field, fieldName, kind) => {
|
|
120
|
+
if (field === table.primaryKey && kind === 'integer') {
|
|
121
|
+
return undefined;
|
|
122
|
+
}
|
|
123
|
+
if (TIMESTAMP_FIELD_NAMES.has(fieldName)) {
|
|
124
|
+
return generatedTimestamp(kind);
|
|
125
|
+
}
|
|
126
|
+
if (kind === 'text') {
|
|
127
|
+
return generatedTextValue(table.name, fieldName);
|
|
128
|
+
}
|
|
129
|
+
throw new ValidationError(`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.`);
|
|
130
|
+
};
|
|
131
|
+
const generatedValueForInsert = (table, field) => {
|
|
132
|
+
const fieldName = field;
|
|
133
|
+
const kind = baseFieldKind(table, field);
|
|
134
|
+
if (isVersionManagedField(table, fieldName)) {
|
|
135
|
+
return generatedVersionValue(table.name, kind);
|
|
136
|
+
}
|
|
137
|
+
return generatedFallbackValue(table, field, fieldName, kind);
|
|
138
|
+
};
|
|
139
|
+
const materializeGeneratedFields = (table, input) => {
|
|
140
|
+
const next = { ...input };
|
|
141
|
+
for (const field of table.generated) {
|
|
142
|
+
if (next[field] !== undefined) {
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
const generated = generatedValueForInsert(table, field);
|
|
146
|
+
if (generated !== undefined) {
|
|
147
|
+
next[field] = generated;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return next;
|
|
151
|
+
};
|
|
152
|
+
const validateFixturePayload = (table, input) => {
|
|
153
|
+
const parsed = table.fixtureSchema.safeParse(input);
|
|
154
|
+
if (!parsed.success) {
|
|
155
|
+
throw new ValidationError(`Store table "${table.name}" insert payload is invalid after generated-field materialization: ${formatIssues(parsed.error.issues)}`);
|
|
156
|
+
}
|
|
157
|
+
return normalizeWriteInput(parsed.data);
|
|
158
|
+
};
|
|
159
|
+
const applyGeneratedInsertFields = (table, input) => validateFixturePayload(table, materializeGeneratedFields(table, input));
|
|
160
|
+
const applyGeneratedUpdateFields = (table, input) => {
|
|
161
|
+
const updatedAtKey = ['updatedAt', 'updated_at'].find((key) => table.generated.includes(key));
|
|
162
|
+
if (!updatedAtKey) {
|
|
163
|
+
return normalizeWriteInput(input);
|
|
164
|
+
}
|
|
165
|
+
const kind = baseFieldKind(table, updatedAtKey);
|
|
166
|
+
return normalizeWriteInput({
|
|
167
|
+
...input,
|
|
168
|
+
[updatedAtKey]: input[updatedAtKey] ??
|
|
169
|
+
(kind === 'date' ? new Date() : new Date().toISOString()),
|
|
170
|
+
});
|
|
171
|
+
};
|
|
172
|
+
const versionFromEntity = (table, entity) => {
|
|
173
|
+
const version = entity[versionFieldName];
|
|
174
|
+
if (typeof version === 'number' && Number.isInteger(version) && version > 0) {
|
|
175
|
+
return version;
|
|
176
|
+
}
|
|
177
|
+
throw new InternalError(`Drizzle store for table "${table.name}" returned a versioned entity without a valid integer "${versionFieldName}" field.`);
|
|
178
|
+
};
|
|
179
|
+
const versionConflictError = (tableName, id, expectedVersion, actualVersion) => new ConflictError(actualVersion === null
|
|
180
|
+
? `Store table "${tableName}" expected version ${expectedVersion} for "${String(id)}" but found no existing row.`
|
|
181
|
+
: `Store table "${tableName}" expected version ${expectedVersion} for "${String(id)}" but found ${actualVersion}.`);
|
|
182
|
+
const expectedVersionFromInput = (input) => {
|
|
183
|
+
const candidate = input[versionFieldName];
|
|
184
|
+
return typeof candidate === 'number' &&
|
|
185
|
+
Number.isInteger(candidate) &&
|
|
186
|
+
candidate > 0
|
|
187
|
+
? candidate
|
|
188
|
+
: undefined;
|
|
189
|
+
};
|
|
190
|
+
const requireUpdateFields = (tableName, input) => {
|
|
191
|
+
const userFields = normalizeWriteInput(input);
|
|
192
|
+
if (Object.keys(userFields).length > 0) {
|
|
193
|
+
return userFields;
|
|
194
|
+
}
|
|
195
|
+
throw new ValidationError(`Store table "${tableName}" update requires at least one field to set.`);
|
|
196
|
+
};
|
|
197
|
+
const assertExpectedVersionMatch = (table, id, existing, expectedVersion) => {
|
|
198
|
+
if (!table.versioned || expectedVersion === undefined) {
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
const currentVersion = versionFromEntity(table, existing);
|
|
202
|
+
if (currentVersion !== expectedVersion) {
|
|
203
|
+
throw versionConflictError(table.name, id, expectedVersion, currentVersion);
|
|
204
|
+
}
|
|
205
|
+
};
|
|
206
|
+
const resolveUpsertWithoutPatch = (table, identifier, input, expectedVersion, readEntity, insertEntity) => {
|
|
207
|
+
const existing = readEntity(identifier);
|
|
208
|
+
if (existing === null) {
|
|
209
|
+
if (expectedVersion !== undefined) {
|
|
210
|
+
throw versionConflictError(table.name, identifier, expectedVersion, null);
|
|
211
|
+
}
|
|
212
|
+
return insertEntity(input);
|
|
213
|
+
}
|
|
214
|
+
assertExpectedVersionMatch(table, identifier, existing, expectedVersion);
|
|
215
|
+
return existing;
|
|
216
|
+
};
|
|
217
|
+
const resolveUpsertAfterMissingUpdate = (table, identifier, input, expectedVersion, insertEntity) => {
|
|
218
|
+
if (expectedVersion !== undefined) {
|
|
219
|
+
throw versionConflictError(table.name, identifier, expectedVersion, null);
|
|
220
|
+
}
|
|
221
|
+
return insertEntity(input);
|
|
222
|
+
};
|
|
223
|
+
const ensureNotCyclic = (visiting, tableName) => {
|
|
224
|
+
if (!visiting.has(tableName)) {
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
throw new ValidationError(`Store definition contains a reference cycle involving "${tableName}", which the SQLite connector cannot seed automatically`);
|
|
228
|
+
};
|
|
229
|
+
const pushVisitDependencies = (stack, definition, visited, tableName) => {
|
|
230
|
+
const table = definition.tables[tableName];
|
|
231
|
+
if (table === undefined) {
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
for (const target of Object.values(table.references).toReversed()) {
|
|
235
|
+
if (target !== undefined && !visited.has(target)) {
|
|
236
|
+
stack.push({ expanded: false, tableName: target });
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
};
|
|
240
|
+
const finishVisitFrame = (frame, visiting, visited, ordered) => {
|
|
241
|
+
visiting.delete(frame.tableName);
|
|
242
|
+
visited.add(frame.tableName);
|
|
243
|
+
ordered.push(frame.tableName);
|
|
244
|
+
};
|
|
245
|
+
const startVisitFrame = (stack, definition, visiting, visited, tableName) => {
|
|
246
|
+
ensureNotCyclic(visiting, tableName);
|
|
247
|
+
visiting.add(tableName);
|
|
248
|
+
stack.push({ expanded: true, tableName });
|
|
249
|
+
pushVisitDependencies(stack, definition, visited, tableName);
|
|
250
|
+
};
|
|
251
|
+
const visitTableForSeeding = (definition, visiting, visited, ordered, tableName) => {
|
|
252
|
+
const stack = [{ expanded: false, tableName }];
|
|
253
|
+
while (stack.length > 0) {
|
|
254
|
+
const frame = stack.pop();
|
|
255
|
+
if (frame === undefined) {
|
|
256
|
+
continue;
|
|
257
|
+
}
|
|
258
|
+
if (frame.expanded) {
|
|
259
|
+
finishVisitFrame(frame, visiting, visited, ordered);
|
|
260
|
+
continue;
|
|
261
|
+
}
|
|
262
|
+
if (!visited.has(frame.tableName)) {
|
|
263
|
+
startVisitFrame(stack, definition, visiting, visited, frame.tableName);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
};
|
|
267
|
+
const topologicalTableOrder = (definition) => {
|
|
268
|
+
const visited = new Set();
|
|
269
|
+
const visiting = new Set();
|
|
270
|
+
const ordered = [];
|
|
271
|
+
for (const tableName of definition.tableNames) {
|
|
272
|
+
visitTableForSeeding(definition, visiting, visited, ordered, tableName);
|
|
273
|
+
}
|
|
274
|
+
return Object.freeze(ordered);
|
|
275
|
+
};
|
|
276
|
+
const ensureSqliteSchema = (client, definition) => {
|
|
277
|
+
for (const statement of deriveSqliteSchemaStatements(definition)) {
|
|
278
|
+
client.run(statement);
|
|
279
|
+
}
|
|
280
|
+
};
|
|
281
|
+
const primaryKeyColumn = (table, field) => table[field];
|
|
282
|
+
const buildFilterConditions = (drizzleTable, filters) => filters === undefined
|
|
283
|
+
? []
|
|
284
|
+
: Object.entries(filters)
|
|
285
|
+
.filter(([, value]) => value !== undefined)
|
|
286
|
+
.map(([field, value]) => eq(primaryKeyColumn(drizzleTable, field), value));
|
|
287
|
+
const createReadOnlyAccessor = (definitionTable, drizzleTable, db) => ({
|
|
288
|
+
get(id) {
|
|
289
|
+
try {
|
|
290
|
+
const row = db
|
|
291
|
+
.select()
|
|
292
|
+
.from(drizzleTable)
|
|
293
|
+
.where(eq(primaryKeyColumn(drizzleTable, definitionTable.primaryKey), id))
|
|
294
|
+
.get();
|
|
295
|
+
return Promise.resolve(row === null || row === undefined
|
|
296
|
+
? null
|
|
297
|
+
: cloneValue(parseEntity(definitionTable, row)));
|
|
298
|
+
}
|
|
299
|
+
catch (error) {
|
|
300
|
+
return Promise.reject(mapDatabaseError(definitionTable.name, error));
|
|
301
|
+
}
|
|
302
|
+
},
|
|
303
|
+
list(filters, options) {
|
|
304
|
+
try {
|
|
305
|
+
const conditions = buildFilterConditions(drizzleTable, filters);
|
|
306
|
+
const base = db.select().from(drizzleTable).$dynamic();
|
|
307
|
+
const filtered = conditions.length > 0 ? base.where(and(...conditions)) : base;
|
|
308
|
+
const rows = filtered
|
|
309
|
+
.limit(options?.limit ?? -1)
|
|
310
|
+
.offset(options?.offset ?? 0)
|
|
311
|
+
.all();
|
|
312
|
+
return Promise.resolve(rows.map((row) => cloneValue(parseEntity(definitionTable, row))));
|
|
313
|
+
}
|
|
314
|
+
catch (error) {
|
|
315
|
+
return Promise.reject(mapDatabaseError(definitionTable.name, error));
|
|
316
|
+
}
|
|
317
|
+
},
|
|
318
|
+
});
|
|
319
|
+
const createWritableAccessor = (definitionTable, drizzleTable, db) => {
|
|
320
|
+
const findRowById = (id) => db
|
|
321
|
+
.select()
|
|
322
|
+
.from(drizzleTable)
|
|
323
|
+
.where(eq(primaryKeyColumn(drizzleTable, definitionTable.primaryKey), id))
|
|
324
|
+
.get();
|
|
325
|
+
const readEntity = (id) => {
|
|
326
|
+
const existing = findRowById(id);
|
|
327
|
+
return existing === undefined
|
|
328
|
+
? null
|
|
329
|
+
: cloneValue(parseEntity(definitionTable, existing));
|
|
330
|
+
};
|
|
331
|
+
const insertEntity = (input) => {
|
|
332
|
+
const row = db
|
|
333
|
+
.insert(drizzleTable)
|
|
334
|
+
.values(applyGeneratedInsertFields(definitionTable, input))
|
|
335
|
+
.returning()
|
|
336
|
+
.get();
|
|
337
|
+
return cloneValue(parseEntity(definitionTable, row));
|
|
338
|
+
};
|
|
339
|
+
const versionColumn = definitionTable.versioned
|
|
340
|
+
? primaryKeyColumn(drizzleTable, versionFieldName)
|
|
341
|
+
: undefined;
|
|
342
|
+
// oxlint-disable-next-line max-statements -- atomic UPDATE ... WHERE with version guard and conflict diagnosis reads more clearly as one function
|
|
343
|
+
const updateEntity = (id, input, expectedVersion) => {
|
|
344
|
+
const base = applyGeneratedUpdateFields(definitionTable, requireUpdateFields(definitionTable.name, input));
|
|
345
|
+
// Atomic increment via SQL expression — avoids the read-then-write race
|
|
346
|
+
// on optimistic-concurrency updates.
|
|
347
|
+
const fields = definitionTable.versioned && versionColumn !== undefined
|
|
348
|
+
? { ...base, [versionFieldName]: sql `${versionColumn} + 1` }
|
|
349
|
+
: base;
|
|
350
|
+
const idColumn = primaryKeyColumn(drizzleTable, definitionTable.primaryKey);
|
|
351
|
+
const idCondition = eq(idColumn, id);
|
|
352
|
+
const condition = definitionTable.versioned &&
|
|
353
|
+
versionColumn !== undefined &&
|
|
354
|
+
expectedVersion !== undefined
|
|
355
|
+
? and(idCondition, eq(versionColumn, expectedVersion))
|
|
356
|
+
: idCondition;
|
|
357
|
+
const row = db
|
|
358
|
+
.update(drizzleTable)
|
|
359
|
+
.set(fields)
|
|
360
|
+
.where(condition)
|
|
361
|
+
.returning()
|
|
362
|
+
.get();
|
|
363
|
+
if (row !== undefined) {
|
|
364
|
+
return cloneValue(parseEntity(definitionTable, row));
|
|
365
|
+
}
|
|
366
|
+
if (!definitionTable.versioned ||
|
|
367
|
+
versionColumn === undefined ||
|
|
368
|
+
expectedVersion === undefined) {
|
|
369
|
+
return null;
|
|
370
|
+
}
|
|
371
|
+
const existing = readEntity(id);
|
|
372
|
+
throw versionConflictError(definitionTable.name, id, expectedVersion, existing === null ? null : versionFromEntity(definitionTable, existing));
|
|
373
|
+
};
|
|
374
|
+
const patchFromUpsert = (input) => Object.fromEntries(Object.entries(input).filter(([field, value]) => field !== definitionTable.identity &&
|
|
375
|
+
!isVersionManagedField(definitionTable, field) &&
|
|
376
|
+
value !== undefined));
|
|
377
|
+
const upsertEntity = (input) => {
|
|
378
|
+
const identifier = input[definitionTable.identity];
|
|
379
|
+
const expectedVersion = definitionTable.versioned
|
|
380
|
+
? expectedVersionFromInput(input)
|
|
381
|
+
: undefined;
|
|
382
|
+
if (identifier === undefined) {
|
|
383
|
+
if (expectedVersion !== undefined) {
|
|
384
|
+
throw new ValidationError(`Store table "${definitionTable.name}" cannot accept an expected version without an identity during upsert.`);
|
|
385
|
+
}
|
|
386
|
+
return insertEntity(input);
|
|
387
|
+
}
|
|
388
|
+
const patch = patchFromUpsert(input);
|
|
389
|
+
if (Object.keys(patch).length === 0) {
|
|
390
|
+
return resolveUpsertWithoutPatch(definitionTable, identifier, input, expectedVersion, readEntity, insertEntity);
|
|
391
|
+
}
|
|
392
|
+
return (updateEntity(identifier, patch, expectedVersion) ??
|
|
393
|
+
resolveUpsertAfterMissingUpdate(definitionTable, identifier, input, expectedVersion, insertEntity));
|
|
394
|
+
};
|
|
395
|
+
return {
|
|
396
|
+
...createReadOnlyAccessor(definitionTable, drizzleTable, db),
|
|
397
|
+
insert(input) {
|
|
398
|
+
try {
|
|
399
|
+
const parsed = definitionTable.insertSchema.parse(input);
|
|
400
|
+
return Promise.resolve(insertEntity(parsed));
|
|
401
|
+
}
|
|
402
|
+
catch (error) {
|
|
403
|
+
return Promise.reject(mapDatabaseError(definitionTable.name, error));
|
|
404
|
+
}
|
|
405
|
+
},
|
|
406
|
+
remove(id) {
|
|
407
|
+
try {
|
|
408
|
+
const deleted = db
|
|
409
|
+
.delete(drizzleTable)
|
|
410
|
+
.where(eq(primaryKeyColumn(drizzleTable, definitionTable.primaryKey), id))
|
|
411
|
+
.returning({
|
|
412
|
+
deletedId: primaryKeyColumn(drizzleTable, definitionTable.primaryKey),
|
|
413
|
+
})
|
|
414
|
+
.get();
|
|
415
|
+
return Promise.resolve({ deleted: deleted !== undefined });
|
|
416
|
+
}
|
|
417
|
+
catch (error) {
|
|
418
|
+
return Promise.reject(mapDatabaseError(definitionTable.name, error));
|
|
419
|
+
}
|
|
420
|
+
},
|
|
421
|
+
update(id, input) {
|
|
422
|
+
try {
|
|
423
|
+
const parsed = definitionTable.updateSchema.parse(input);
|
|
424
|
+
return Promise.resolve(updateEntity(id, parsed));
|
|
425
|
+
}
|
|
426
|
+
catch (error) {
|
|
427
|
+
return Promise.reject(mapDatabaseError(definitionTable.name, error));
|
|
428
|
+
}
|
|
429
|
+
},
|
|
430
|
+
upsert(input) {
|
|
431
|
+
try {
|
|
432
|
+
const parsed = definitionTable.fixtureSchema.parse(input);
|
|
433
|
+
const normalized = normalizeWriteInput(parsed);
|
|
434
|
+
return Promise.resolve(upsertEntity(normalized));
|
|
435
|
+
}
|
|
436
|
+
catch (error) {
|
|
437
|
+
return Promise.reject(mapDatabaseError(definitionTable.name, error));
|
|
438
|
+
}
|
|
439
|
+
},
|
|
440
|
+
};
|
|
441
|
+
};
|
|
442
|
+
/** Collect non-empty fixture arrays keyed by table name. */
|
|
443
|
+
const collectFixtures = (definition, seed) => {
|
|
444
|
+
const result = new Map();
|
|
445
|
+
for (const tableName of definition.tableNames) {
|
|
446
|
+
const table = definition.tables[tableName];
|
|
447
|
+
if (table === undefined) {
|
|
448
|
+
continue;
|
|
449
|
+
}
|
|
450
|
+
const fixtures = seed?.[tableName] ?? table.fixtures;
|
|
451
|
+
if (fixtures.length > 0) {
|
|
452
|
+
result.set(tableName, fixtures);
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
return result;
|
|
456
|
+
};
|
|
457
|
+
/** Insert fixture rows in topological order. */
|
|
458
|
+
const insertFixtureRows = (definition, tables, db, fixturesByTable) => {
|
|
459
|
+
for (const tableName of topologicalTableOrder(definition)) {
|
|
460
|
+
const defTable = definition.tables[tableName];
|
|
461
|
+
const drizzleTable = tables[tableName];
|
|
462
|
+
const fixtures = fixturesByTable.get(tableName);
|
|
463
|
+
if (!defTable || !drizzleTable || !fixtures) {
|
|
464
|
+
continue;
|
|
465
|
+
}
|
|
466
|
+
for (const fixture of fixtures) {
|
|
467
|
+
db.insert(drizzleTable)
|
|
468
|
+
.values(applyGeneratedInsertFields(defTable, fixture))
|
|
469
|
+
.run();
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
};
|
|
473
|
+
const seedFixtures = (definition, tables, db, seed) => {
|
|
474
|
+
const fixturesByTable = collectFixtures(definition, seed);
|
|
475
|
+
if (fixturesByTable.size === 0) {
|
|
476
|
+
return;
|
|
477
|
+
}
|
|
478
|
+
insertFixtureRows(definition, tables, db, fixturesByTable);
|
|
479
|
+
};
|
|
480
|
+
const createReadOnlyConnection = (definition, tables, db, client, tempDir) => {
|
|
481
|
+
const connection = {
|
|
482
|
+
async query(run) {
|
|
483
|
+
return await run({ drizzle: db, tables });
|
|
484
|
+
},
|
|
485
|
+
};
|
|
486
|
+
for (const tableName of storeTableNames(definition)) {
|
|
487
|
+
const definitionTable = definition.tables[tableName];
|
|
488
|
+
const drizzleTable = tables[tableName];
|
|
489
|
+
if (definitionTable === undefined || drizzleTable === undefined) {
|
|
490
|
+
continue;
|
|
491
|
+
}
|
|
492
|
+
Object.defineProperty(connection, tableName, {
|
|
493
|
+
enumerable: true,
|
|
494
|
+
value: createReadOnlyAccessor(definitionTable, drizzleTable, db),
|
|
495
|
+
});
|
|
496
|
+
}
|
|
497
|
+
return Object.freeze(registerConnection(connection, client, tempDir));
|
|
498
|
+
};
|
|
499
|
+
const createWritableConnection = (definition, tables, db, client) => {
|
|
500
|
+
const connection = {
|
|
501
|
+
async query(run) {
|
|
502
|
+
return await run({ drizzle: db, tables });
|
|
503
|
+
},
|
|
504
|
+
};
|
|
505
|
+
for (const tableName of storeTableNames(definition)) {
|
|
506
|
+
const definitionTable = definition.tables[tableName];
|
|
507
|
+
const drizzleTable = tables[tableName];
|
|
508
|
+
if (definitionTable === undefined || drizzleTable === undefined) {
|
|
509
|
+
continue;
|
|
510
|
+
}
|
|
511
|
+
Object.defineProperty(connection, tableName, {
|
|
512
|
+
enumerable: true,
|
|
513
|
+
value: createWritableAccessor(definitionTable, drizzleTable, db),
|
|
514
|
+
});
|
|
515
|
+
}
|
|
516
|
+
return Object.freeze(registerConnection(connection, client));
|
|
517
|
+
};
|
|
518
|
+
const seedReadonlyMockDatabase = (definition, tables, url, seed) => {
|
|
519
|
+
const writableClient = openSqliteDatabase(url, false);
|
|
520
|
+
try {
|
|
521
|
+
ensureSqliteSchema(writableClient, definition);
|
|
522
|
+
const writableDb = drizzle({ client: writableClient, schema: tables });
|
|
523
|
+
seedFixtures(definition, tables, writableDb, seed);
|
|
524
|
+
}
|
|
525
|
+
finally {
|
|
526
|
+
writableClient.close();
|
|
527
|
+
}
|
|
528
|
+
};
|
|
529
|
+
const openReadonlyMockConnection = (definition, tables, url, tempDir) => {
|
|
530
|
+
const client = openSqliteDatabase(url, true);
|
|
531
|
+
try {
|
|
532
|
+
const db = drizzle({ client, schema: tables });
|
|
533
|
+
return createReadOnlyConnection(definition, tables, db, client, tempDir);
|
|
534
|
+
}
|
|
535
|
+
catch (error) {
|
|
536
|
+
client.close();
|
|
537
|
+
throw error;
|
|
538
|
+
}
|
|
539
|
+
};
|
|
540
|
+
const createReadonlyMockConnection = (definition, tables, seed) => {
|
|
541
|
+
const tempDir = createReadonlyMockTempDir();
|
|
542
|
+
const url = join(tempDir, 'mock.sqlite');
|
|
543
|
+
try {
|
|
544
|
+
seedReadonlyMockDatabase(definition, tables, url, seed);
|
|
545
|
+
return openReadonlyMockConnection(definition, tables, url, tempDir);
|
|
546
|
+
}
|
|
547
|
+
catch (error) {
|
|
548
|
+
rmSync(tempDir, { force: true, recursive: true });
|
|
549
|
+
throw error;
|
|
550
|
+
}
|
|
551
|
+
};
|
|
552
|
+
/**
|
|
553
|
+
* Best-effort signal emission after a successful DB write.
|
|
554
|
+
*
|
|
555
|
+
* Signal errors are caught and logged rather than re-thrown so that a
|
|
556
|
+
* listener failure does not mask a successful database mutation. The
|
|
557
|
+
* caller already holds the write result; surfacing a signal error here
|
|
558
|
+
* would discard it and confuse error handling upstream.
|
|
559
|
+
*/
|
|
560
|
+
const fireDerivedSignal = async (fire, signalId, entity) => {
|
|
561
|
+
try {
|
|
562
|
+
const fired = await fire(signalId, entity);
|
|
563
|
+
if (fired.isErr()) {
|
|
564
|
+
console.warn(`[drizzle] signal "${signalId}" emission failed:`, fired.error);
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
catch (error) {
|
|
568
|
+
console.warn(`[drizzle] signal "${signalId}" emission threw:`, error);
|
|
569
|
+
}
|
|
570
|
+
};
|
|
571
|
+
const inputIdentity = (table, input) => input[table.identity];
|
|
572
|
+
const changedEntity = (previous, next) => previous !== null && next !== null && !Bun.deepEquals(previous, next);
|
|
573
|
+
const bindWritableAccessorSignals = (table, accessor, fire) => Object.freeze({
|
|
574
|
+
...accessor,
|
|
575
|
+
async insert(input) {
|
|
576
|
+
const created = await accessor.insert(input);
|
|
577
|
+
await fireDerivedSignal(fire, table.signals.created.id, created);
|
|
578
|
+
return created;
|
|
579
|
+
},
|
|
580
|
+
async remove(id) {
|
|
581
|
+
// Snapshot taken before delete. May be stale under concurrent writes
|
|
582
|
+
// since StoreAccessor.remove returns `{ deleted: boolean }` without a
|
|
583
|
+
// post-delete returning clause. Acceptable for signal consumers that
|
|
584
|
+
// tolerate eventual consistency; revisit if strict ordering is needed.
|
|
585
|
+
const existing = await accessor.get(id);
|
|
586
|
+
const removed = await accessor.remove(id);
|
|
587
|
+
if (removed.deleted && existing !== null) {
|
|
588
|
+
await fireDerivedSignal(fire, table.signals.removed.id, existing);
|
|
589
|
+
}
|
|
590
|
+
return removed;
|
|
591
|
+
},
|
|
592
|
+
async update(id, input) {
|
|
593
|
+
if (table.versioned) {
|
|
594
|
+
// Versioned tables auto-increment the version column on every write,
|
|
595
|
+
// so changedEntity always detects a diff. Skip the redundant pre-read
|
|
596
|
+
// and fire unconditionally on successful update.
|
|
597
|
+
const updated = await accessor.update(id, input);
|
|
598
|
+
if (updated !== null) {
|
|
599
|
+
await fireDerivedSignal(fire, table.signals.updated.id, updated);
|
|
600
|
+
}
|
|
601
|
+
return updated;
|
|
602
|
+
}
|
|
603
|
+
const existing = await accessor.get(id);
|
|
604
|
+
const updated = await accessor.update(id, input);
|
|
605
|
+
if (changedEntity(existing, updated)) {
|
|
606
|
+
await fireDerivedSignal(fire, table.signals.updated.id, updated);
|
|
607
|
+
}
|
|
608
|
+
return updated;
|
|
609
|
+
},
|
|
610
|
+
async upsert(input) {
|
|
611
|
+
const existingId = inputIdentity(table, input);
|
|
612
|
+
// NOTE: pre-read is not transactional with the write below. Under
|
|
613
|
+
// concurrent deletes, `existing` may be non-null while `accessor.upsert`
|
|
614
|
+
// actually inserts. `created` vs `updated` signal discrimination is
|
|
615
|
+
// best-effort — matches the same caveat documented on `remove`.
|
|
616
|
+
const existing = existingId === undefined ? null : await accessor.get(existingId);
|
|
617
|
+
const written = await accessor.upsert(input);
|
|
618
|
+
if (existing === null) {
|
|
619
|
+
await fireDerivedSignal(fire, table.signals.created.id, written);
|
|
620
|
+
return written;
|
|
621
|
+
}
|
|
622
|
+
if (changedEntity(existing, written)) {
|
|
623
|
+
await fireDerivedSignal(fire, table.signals.updated.id, written);
|
|
624
|
+
}
|
|
625
|
+
return written;
|
|
626
|
+
},
|
|
627
|
+
});
|
|
628
|
+
const bindWritableConnectionSignals = (definition, connection, fire) => {
|
|
629
|
+
const bound = {
|
|
630
|
+
query: connection.query,
|
|
631
|
+
};
|
|
632
|
+
for (const tableName of storeTableNames(definition)) {
|
|
633
|
+
const table = definition.tables[tableName];
|
|
634
|
+
const accessor = connection[tableName];
|
|
635
|
+
if (table === undefined || accessor === undefined) {
|
|
636
|
+
continue;
|
|
637
|
+
}
|
|
638
|
+
Object.defineProperty(bound, tableName, {
|
|
639
|
+
enumerable: true,
|
|
640
|
+
value: bindWritableAccessorSignals(table, accessor, fire),
|
|
641
|
+
});
|
|
642
|
+
}
|
|
643
|
+
return Object.freeze(bound);
|
|
644
|
+
};
|
|
645
|
+
const bindResourceConnection = (access, definition, connection, fire) => {
|
|
646
|
+
if (access !== 'readwrite' || fire === undefined) {
|
|
647
|
+
return connection;
|
|
648
|
+
}
|
|
649
|
+
return bindWritableConnectionSignals(definition, connection, fire);
|
|
650
|
+
};
|
|
651
|
+
const buildResourceShape = (value, store, tables, access) => Object.freeze({
|
|
652
|
+
...value,
|
|
653
|
+
access,
|
|
654
|
+
from(ctx) {
|
|
655
|
+
return bindResourceConnection(access, store, value.from(ctx), ctx.fire);
|
|
656
|
+
},
|
|
657
|
+
...(access === 'readwrite' ? { signals: store.signals } : {}),
|
|
658
|
+
store,
|
|
659
|
+
tables,
|
|
660
|
+
});
|
|
661
|
+
const connectionHealth = (connection) => {
|
|
662
|
+
const client = connectionClients.get(connection);
|
|
663
|
+
if (client === undefined) {
|
|
664
|
+
return Result.err(new InternalError('Drizzle store connection is missing its SQLite client'));
|
|
665
|
+
}
|
|
666
|
+
try {
|
|
667
|
+
client.query('SELECT 1').get();
|
|
668
|
+
return Result.ok({ ok: true });
|
|
669
|
+
}
|
|
670
|
+
catch (error) {
|
|
671
|
+
return Result.err(asError(error));
|
|
672
|
+
}
|
|
673
|
+
};
|
|
674
|
+
/**
|
|
675
|
+
* Bind a store definition to a Drizzle-backed SQLite resource.
|
|
676
|
+
*
|
|
677
|
+
* The returned resource manages its own connection lifecycle. The `mock()`
|
|
678
|
+
* factory creates an in-memory SQLite database seeded with fixtures — callers
|
|
679
|
+
* who obtain a mock connection are responsible for calling `closeConnection()`
|
|
680
|
+
* when done, or letting the connection be garbage-collected (the underlying
|
|
681
|
+
* `Database` client is tracked via `WeakMap`).
|
|
682
|
+
*
|
|
683
|
+
* Note: the `search` field on `StoreTableInput` is not yet interpreted by
|
|
684
|
+
* this connector — it is reserved for future full-text search support.
|
|
685
|
+
*/
|
|
686
|
+
export const connectDrizzle = (definition, options) => {
|
|
687
|
+
const scope = options.id ?? defaultResourceId;
|
|
688
|
+
const store = bindStoreDefinition(definition, scope);
|
|
689
|
+
const tables = deriveDrizzleTables(store);
|
|
690
|
+
return buildResourceShape(resource(scope, {
|
|
691
|
+
create: () => {
|
|
692
|
+
try {
|
|
693
|
+
const client = openSqliteDatabase(options.url, false);
|
|
694
|
+
try {
|
|
695
|
+
ensureSqliteSchema(client, store);
|
|
696
|
+
}
|
|
697
|
+
catch (error) {
|
|
698
|
+
client.close();
|
|
699
|
+
throw error;
|
|
700
|
+
}
|
|
701
|
+
const db = drizzle({ client, schema: tables });
|
|
702
|
+
return Result.ok(createWritableConnection(store, tables, db, client));
|
|
703
|
+
}
|
|
704
|
+
catch (error) {
|
|
705
|
+
return Result.err(new InternalError(`Drizzle store failed to open database at "${options.url}": ${asError(error).message}`, { cause: asError(error) }));
|
|
706
|
+
}
|
|
707
|
+
},
|
|
708
|
+
description: options.description ??
|
|
709
|
+
'Drizzle-backed writable store bound from an @ontrails/store definition.',
|
|
710
|
+
dispose: (connection) => {
|
|
711
|
+
closeConnection(connection);
|
|
712
|
+
},
|
|
713
|
+
health: connectionHealth,
|
|
714
|
+
meta: options.meta,
|
|
715
|
+
mock: () => {
|
|
716
|
+
const client = openSqliteDatabase(':memory:', false);
|
|
717
|
+
try {
|
|
718
|
+
ensureSqliteSchema(client, store);
|
|
719
|
+
const db = drizzle({ client, schema: tables });
|
|
720
|
+
seedFixtures(store, tables, db, options.mockSeed);
|
|
721
|
+
return createWritableConnection(store, tables, db, client);
|
|
722
|
+
}
|
|
723
|
+
catch (error) {
|
|
724
|
+
client.close();
|
|
725
|
+
throw error;
|
|
726
|
+
}
|
|
727
|
+
},
|
|
728
|
+
}), store, tables, 'readwrite');
|
|
729
|
+
};
|
|
730
|
+
export const connectReadOnlyDrizzle = (definition, options) => {
|
|
731
|
+
const scope = options.id ?? defaultResourceId;
|
|
732
|
+
const store = bindStoreDefinition(definition, scope);
|
|
733
|
+
const tables = deriveDrizzleTables(store);
|
|
734
|
+
return buildResourceShape(resource(scope, {
|
|
735
|
+
create: () => {
|
|
736
|
+
try {
|
|
737
|
+
const client = openSqliteDatabase(options.url, true);
|
|
738
|
+
const db = drizzle({ client, schema: tables });
|
|
739
|
+
return Result.ok(createReadOnlyConnection(store, tables, db, client));
|
|
740
|
+
}
|
|
741
|
+
catch (error) {
|
|
742
|
+
return Result.err(new InternalError(`Drizzle read-only store failed to open database at "${options.url}": ${asError(error).message}`, { cause: asError(error) }));
|
|
743
|
+
}
|
|
744
|
+
},
|
|
745
|
+
description: options.description ??
|
|
746
|
+
'Drizzle-backed read-only store bound from an @ontrails/store definition.',
|
|
747
|
+
dispose: (connection) => {
|
|
748
|
+
closeConnection(connection);
|
|
749
|
+
},
|
|
750
|
+
health: connectionHealth,
|
|
751
|
+
meta: options.meta,
|
|
752
|
+
mock: () => createReadonlyMockConnection(store, tables, options.mockSeed),
|
|
753
|
+
}), store, tables, 'readonly');
|
|
754
|
+
};
|
|
755
|
+
//# sourceMappingURL=runtime.js.map
|