@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/schema.ts
ADDED
|
@@ -0,0 +1,590 @@
|
|
|
1
|
+
import { ValidationError } from '@ontrails/core';
|
|
2
|
+
import {
|
|
3
|
+
customType,
|
|
4
|
+
index,
|
|
5
|
+
integer,
|
|
6
|
+
real,
|
|
7
|
+
sqliteTable,
|
|
8
|
+
text,
|
|
9
|
+
} from 'drizzle-orm/sqlite-core';
|
|
10
|
+
import type { AnyStoreDefinition, AnyStoreTable } from '@ontrails/store';
|
|
11
|
+
import type {
|
|
12
|
+
AnySQLiteColumn,
|
|
13
|
+
AnySQLiteTable,
|
|
14
|
+
SQLiteColumnBuilderBase,
|
|
15
|
+
} from 'drizzle-orm/sqlite-core';
|
|
16
|
+
import type { z } from 'zod';
|
|
17
|
+
|
|
18
|
+
import type { DrizzleStoreSchema } from './types.js';
|
|
19
|
+
|
|
20
|
+
const dateText = customType<{ data: Date; driverData: string }>({
|
|
21
|
+
dataType() {
|
|
22
|
+
return 'text';
|
|
23
|
+
},
|
|
24
|
+
fromDriver(value) {
|
|
25
|
+
return new Date(value);
|
|
26
|
+
},
|
|
27
|
+
toDriver(value) {
|
|
28
|
+
return value.toISOString();
|
|
29
|
+
},
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
interface InnerTypeDef {
|
|
33
|
+
readonly innerType: z.ZodType;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface DefaultTypeDef extends InnerTypeDef {
|
|
37
|
+
readonly defaultValue?: unknown;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
interface UnwrappedSchema {
|
|
41
|
+
readonly defaultValue?: unknown;
|
|
42
|
+
readonly nullable: boolean;
|
|
43
|
+
readonly optional: boolean;
|
|
44
|
+
readonly schema: z.ZodType;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
interface SqliteFieldSpec {
|
|
48
|
+
readonly defaultValue?: unknown;
|
|
49
|
+
readonly enumValues?: readonly [string, ...string[]];
|
|
50
|
+
readonly kind: 'boolean' | 'date' | 'integer' | 'json' | 'real' | 'text';
|
|
51
|
+
readonly nullable: boolean;
|
|
52
|
+
readonly optional: boolean;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
interface SqliteColumnBuilder {
|
|
56
|
+
default(value: unknown): SqliteColumnBuilder;
|
|
57
|
+
notNull(): SqliteColumnBuilder;
|
|
58
|
+
primaryKey(config?: {
|
|
59
|
+
readonly autoIncrement?: boolean;
|
|
60
|
+
}): SqliteColumnBuilder;
|
|
61
|
+
references(
|
|
62
|
+
ref: () => AnySQLiteColumn,
|
|
63
|
+
actions?: {
|
|
64
|
+
readonly onDelete?:
|
|
65
|
+
| 'cascade'
|
|
66
|
+
| 'restrict'
|
|
67
|
+
| 'set null'
|
|
68
|
+
| 'set default'
|
|
69
|
+
| 'no action';
|
|
70
|
+
readonly onUpdate?:
|
|
71
|
+
| 'cascade'
|
|
72
|
+
| 'restrict'
|
|
73
|
+
| 'set null'
|
|
74
|
+
| 'set default'
|
|
75
|
+
| 'no action';
|
|
76
|
+
}
|
|
77
|
+
): SqliteColumnBuilder;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const defaultUnwrapState: Omit<UnwrappedSchema, 'schema'> = {
|
|
81
|
+
nullable: false,
|
|
82
|
+
optional: false,
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* The unwrap helpers below access Zod internal `._def` shapes (`def.type`,
|
|
87
|
+
* `def.checks`, `def.innerType`, `def.defaultValue`). These are not part of
|
|
88
|
+
* Zod's public API and may change across minor versions. The store package
|
|
89
|
+
* pins Zod through the workspace catalog — test `z.number().int()` mapping
|
|
90
|
+
* to `INTEGER` (see drizzle.test.ts) to catch regressions on Zod upgrades.
|
|
91
|
+
*/
|
|
92
|
+
const unwrapInnerType = (schema: z.ZodType): z.ZodType => {
|
|
93
|
+
const { innerType } = schema.def as unknown as InnerTypeDef;
|
|
94
|
+
return innerType;
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
const unwrapDefaultLayer = (
|
|
98
|
+
schema: z.ZodType
|
|
99
|
+
): {
|
|
100
|
+
readonly defaultValue?: unknown;
|
|
101
|
+
readonly schema: z.ZodType;
|
|
102
|
+
} => {
|
|
103
|
+
const { defaultValue } = schema.def as unknown as DefaultTypeDef;
|
|
104
|
+
return {
|
|
105
|
+
defaultValue,
|
|
106
|
+
schema: unwrapInnerType(schema),
|
|
107
|
+
};
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
const unwrapSchema = (
|
|
111
|
+
schema: z.ZodType,
|
|
112
|
+
state: Omit<UnwrappedSchema, 'schema'> = defaultUnwrapState
|
|
113
|
+
): UnwrappedSchema => {
|
|
114
|
+
switch (schema.def.type) {
|
|
115
|
+
case 'default': {
|
|
116
|
+
const { defaultValue, schema: innerSchema } = unwrapDefaultLayer(schema);
|
|
117
|
+
return unwrapSchema(innerSchema, { ...state, defaultValue });
|
|
118
|
+
}
|
|
119
|
+
case 'nullable': {
|
|
120
|
+
return unwrapSchema(unwrapInnerType(schema), {
|
|
121
|
+
...state,
|
|
122
|
+
nullable: true,
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
case 'optional': {
|
|
126
|
+
return unwrapSchema(unwrapInnerType(schema), {
|
|
127
|
+
...state,
|
|
128
|
+
optional: true,
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
default: {
|
|
132
|
+
return {
|
|
133
|
+
...state,
|
|
134
|
+
schema,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
const isIntegerNumber = (schema: z.ZodType): boolean =>
|
|
141
|
+
schema.def.type === 'number' &&
|
|
142
|
+
((schema.def.checks as readonly unknown[] | undefined) ?? []).some(
|
|
143
|
+
(check) => {
|
|
144
|
+
const { def } = check as { readonly def?: Record<string, unknown> };
|
|
145
|
+
return def?.['check'] === 'number_format';
|
|
146
|
+
}
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
const toEnumValues = (
|
|
150
|
+
entries: Record<string, string>
|
|
151
|
+
): readonly [string, ...string[]] => {
|
|
152
|
+
const values = Object.values(entries);
|
|
153
|
+
if (values.length === 0) {
|
|
154
|
+
throw new ValidationError('Enum-backed store fields must declare values');
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return values as unknown as readonly [string, ...string[]];
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
const fieldSpecBase = (
|
|
161
|
+
unwrapped: UnwrappedSchema
|
|
162
|
+
): Omit<SqliteFieldSpec, 'kind'> => ({
|
|
163
|
+
...(unwrapped.defaultValue === undefined
|
|
164
|
+
? {}
|
|
165
|
+
: { defaultValue: unwrapped.defaultValue }),
|
|
166
|
+
nullable: unwrapped.nullable,
|
|
167
|
+
optional: unwrapped.optional,
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
const enumEntriesOf = (schema: z.ZodType): Record<string, string> => {
|
|
171
|
+
const { entries } = schema.def as unknown as {
|
|
172
|
+
readonly entries: Record<string, string>;
|
|
173
|
+
};
|
|
174
|
+
return entries;
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
const inferFieldKind = (
|
|
178
|
+
field: string,
|
|
179
|
+
schema: z.ZodType
|
|
180
|
+
): SqliteFieldSpec['kind'] => {
|
|
181
|
+
switch (schema.def.type) {
|
|
182
|
+
case 'array':
|
|
183
|
+
case 'object': {
|
|
184
|
+
return 'json';
|
|
185
|
+
}
|
|
186
|
+
case 'boolean': {
|
|
187
|
+
return 'boolean';
|
|
188
|
+
}
|
|
189
|
+
case 'date': {
|
|
190
|
+
return 'date';
|
|
191
|
+
}
|
|
192
|
+
case 'enum': {
|
|
193
|
+
return 'text';
|
|
194
|
+
}
|
|
195
|
+
case 'number': {
|
|
196
|
+
return isIntegerNumber(schema) ? 'integer' : 'real';
|
|
197
|
+
}
|
|
198
|
+
case 'string': {
|
|
199
|
+
return 'text';
|
|
200
|
+
}
|
|
201
|
+
default: {
|
|
202
|
+
throw new ValidationError(
|
|
203
|
+
`Store field "${field}" uses unsupported schema type "${schema.def.type}" for the Drizzle SQLite adapter`
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
export const deriveFieldSpec = (
|
|
210
|
+
field: string,
|
|
211
|
+
schema: z.ZodType
|
|
212
|
+
): SqliteFieldSpec => {
|
|
213
|
+
const unwrapped = unwrapSchema(schema);
|
|
214
|
+
const base = fieldSpecBase(unwrapped);
|
|
215
|
+
const kind = inferFieldKind(field, unwrapped.schema);
|
|
216
|
+
|
|
217
|
+
return {
|
|
218
|
+
...base,
|
|
219
|
+
...(unwrapped.schema.def.type === 'enum'
|
|
220
|
+
? { enumValues: toEnumValues(enumEntriesOf(unwrapped.schema)) }
|
|
221
|
+
: {}),
|
|
222
|
+
kind,
|
|
223
|
+
};
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
const quoteIdentifier = (value: string): string =>
|
|
227
|
+
`"${value.replaceAll('"', '""')}"`;
|
|
228
|
+
|
|
229
|
+
const quoteStringLiteral = (value: string): string =>
|
|
230
|
+
`'${value.replaceAll("'", "''")}'`;
|
|
231
|
+
|
|
232
|
+
const serializeJsonDefault = (value: object | readonly unknown[]): string =>
|
|
233
|
+
quoteStringLiteral(JSON.stringify(value));
|
|
234
|
+
|
|
235
|
+
const serializePrimitiveDefault = (
|
|
236
|
+
value: string | number | boolean
|
|
237
|
+
): string | undefined => {
|
|
238
|
+
switch (typeof value) {
|
|
239
|
+
case 'boolean': {
|
|
240
|
+
return value ? '1' : '0';
|
|
241
|
+
}
|
|
242
|
+
case 'number': {
|
|
243
|
+
return Number.isFinite(value) ? `${value}` : undefined;
|
|
244
|
+
}
|
|
245
|
+
case 'string': {
|
|
246
|
+
return quoteStringLiteral(value);
|
|
247
|
+
}
|
|
248
|
+
default: {
|
|
249
|
+
return undefined;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
const toSqlDefault = (value: unknown): string | undefined => {
|
|
255
|
+
if (value === undefined || value === null) {
|
|
256
|
+
return undefined;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
if (value instanceof Date) {
|
|
260
|
+
return quoteStringLiteral(value.toISOString());
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
return typeof value === 'object'
|
|
264
|
+
? serializeJsonDefault(value as object)
|
|
265
|
+
: serializePrimitiveDefault(value as string | number | boolean);
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
const toSqlType = (kind: SqliteFieldSpec['kind']): string => {
|
|
269
|
+
switch (kind) {
|
|
270
|
+
case 'boolean':
|
|
271
|
+
case 'integer': {
|
|
272
|
+
return 'INTEGER';
|
|
273
|
+
}
|
|
274
|
+
case 'real': {
|
|
275
|
+
return 'REAL';
|
|
276
|
+
}
|
|
277
|
+
case 'date':
|
|
278
|
+
case 'json':
|
|
279
|
+
case 'text': {
|
|
280
|
+
return 'TEXT';
|
|
281
|
+
}
|
|
282
|
+
default: {
|
|
283
|
+
return 'TEXT';
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
};
|
|
287
|
+
|
|
288
|
+
const createBaseColumnBuilder = (
|
|
289
|
+
field: string,
|
|
290
|
+
spec: SqliteFieldSpec
|
|
291
|
+
): SqliteColumnBuilder => {
|
|
292
|
+
switch (spec.kind) {
|
|
293
|
+
case 'boolean': {
|
|
294
|
+
return integer(field, {
|
|
295
|
+
mode: 'boolean',
|
|
296
|
+
}) as unknown as SqliteColumnBuilder;
|
|
297
|
+
}
|
|
298
|
+
case 'date': {
|
|
299
|
+
return dateText(field) as unknown as SqliteColumnBuilder;
|
|
300
|
+
}
|
|
301
|
+
case 'integer': {
|
|
302
|
+
return integer(field) as unknown as SqliteColumnBuilder;
|
|
303
|
+
}
|
|
304
|
+
case 'json': {
|
|
305
|
+
return text(field, { mode: 'json' }) as unknown as SqliteColumnBuilder;
|
|
306
|
+
}
|
|
307
|
+
case 'real': {
|
|
308
|
+
return real(field) as unknown as SqliteColumnBuilder;
|
|
309
|
+
}
|
|
310
|
+
case 'text': {
|
|
311
|
+
return spec.enumValues === undefined
|
|
312
|
+
? (text(field) as unknown as SqliteColumnBuilder)
|
|
313
|
+
: (text(field, {
|
|
314
|
+
enum: spec.enumValues,
|
|
315
|
+
}) as unknown as SqliteColumnBuilder);
|
|
316
|
+
}
|
|
317
|
+
default: {
|
|
318
|
+
throw new ValidationError(
|
|
319
|
+
`Store field "${field}" resolved to an unsupported Drizzle column builder`
|
|
320
|
+
);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
};
|
|
324
|
+
|
|
325
|
+
const applyPrimaryKey = (
|
|
326
|
+
builder: SqliteColumnBuilder,
|
|
327
|
+
spec: SqliteFieldSpec,
|
|
328
|
+
isPrimaryKey: boolean,
|
|
329
|
+
isGenerated: boolean
|
|
330
|
+
): SqliteColumnBuilder => {
|
|
331
|
+
if (!isPrimaryKey) {
|
|
332
|
+
return builder;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
const shouldAutoIncrement =
|
|
336
|
+
isGenerated && spec.kind === 'integer' && spec.defaultValue === undefined;
|
|
337
|
+
|
|
338
|
+
return shouldAutoIncrement
|
|
339
|
+
? builder.primaryKey({ autoIncrement: true })
|
|
340
|
+
: builder.primaryKey();
|
|
341
|
+
};
|
|
342
|
+
|
|
343
|
+
const applyCommonBuilderState = (
|
|
344
|
+
builder: SqliteColumnBuilder,
|
|
345
|
+
spec: SqliteFieldSpec,
|
|
346
|
+
notNull: boolean
|
|
347
|
+
): SqliteColumnBuilder => {
|
|
348
|
+
let next = builder;
|
|
349
|
+
|
|
350
|
+
if (notNull) {
|
|
351
|
+
next = next.notNull();
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
if (toSqlDefault(spec.defaultValue) !== undefined) {
|
|
355
|
+
next = next.default(spec.defaultValue);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
return next;
|
|
359
|
+
};
|
|
360
|
+
|
|
361
|
+
const resolveReferencedTable = (
|
|
362
|
+
definition: AnyStoreDefinition,
|
|
363
|
+
sourceTable: AnyStoreTable,
|
|
364
|
+
targetTableName: string
|
|
365
|
+
): AnyStoreTable => {
|
|
366
|
+
const target = definition.tables[targetTableName];
|
|
367
|
+
if (target !== undefined) {
|
|
368
|
+
return target;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
throw new ValidationError(
|
|
372
|
+
`Store table "${sourceTable.name}" references unknown table "${targetTableName}"`
|
|
373
|
+
);
|
|
374
|
+
};
|
|
375
|
+
|
|
376
|
+
const resolveReferencedColumn = (
|
|
377
|
+
tables: Record<string, AnySQLiteTable>,
|
|
378
|
+
targetTableName: string,
|
|
379
|
+
targetPrimaryKey: string
|
|
380
|
+
): AnySQLiteColumn => {
|
|
381
|
+
const targetTable = tables[targetTableName];
|
|
382
|
+
if (targetTable === undefined) {
|
|
383
|
+
throw new ValidationError(
|
|
384
|
+
`Store table reference to "${targetTableName}" could not be resolved during Drizzle schema derivation`
|
|
385
|
+
);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
return targetTable[
|
|
389
|
+
targetPrimaryKey as keyof typeof targetTable
|
|
390
|
+
] as AnySQLiteColumn;
|
|
391
|
+
};
|
|
392
|
+
|
|
393
|
+
const applyReference = (
|
|
394
|
+
builder: SqliteColumnBuilder,
|
|
395
|
+
field: string,
|
|
396
|
+
table: AnyStoreTable,
|
|
397
|
+
definition: AnyStoreDefinition,
|
|
398
|
+
tables: Record<string, AnySQLiteTable>
|
|
399
|
+
): SqliteColumnBuilder => {
|
|
400
|
+
const targetTableName = table.references[field];
|
|
401
|
+
if (targetTableName === undefined) {
|
|
402
|
+
return builder;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
const target = resolveReferencedTable(definition, table, targetTableName);
|
|
406
|
+
return builder.references(
|
|
407
|
+
() => resolveReferencedColumn(tables, targetTableName, target.primaryKey),
|
|
408
|
+
{ onDelete: 'restrict', onUpdate: 'cascade' }
|
|
409
|
+
);
|
|
410
|
+
};
|
|
411
|
+
|
|
412
|
+
const deriveColumnBuilder = (
|
|
413
|
+
field: string,
|
|
414
|
+
table: AnyStoreTable,
|
|
415
|
+
definition: AnyStoreDefinition,
|
|
416
|
+
tables: Record<string, AnySQLiteTable>
|
|
417
|
+
): SqliteColumnBuilder => {
|
|
418
|
+
const schema = table.schema.shape[field] as z.ZodType;
|
|
419
|
+
const spec = deriveFieldSpec(field, schema);
|
|
420
|
+
const isPrimaryKey = field === table.primaryKey;
|
|
421
|
+
const isGenerated = table.generated.includes(field);
|
|
422
|
+
const baseBuilder = createBaseColumnBuilder(field, spec);
|
|
423
|
+
const keyedBuilder = applyPrimaryKey(
|
|
424
|
+
baseBuilder,
|
|
425
|
+
spec,
|
|
426
|
+
isPrimaryKey,
|
|
427
|
+
isGenerated
|
|
428
|
+
);
|
|
429
|
+
const finalizedBuilder = applyCommonBuilderState(
|
|
430
|
+
keyedBuilder,
|
|
431
|
+
spec,
|
|
432
|
+
!isPrimaryKey && !spec.optional && !spec.nullable
|
|
433
|
+
);
|
|
434
|
+
|
|
435
|
+
return applyReference(finalizedBuilder, field, table, definition, tables);
|
|
436
|
+
};
|
|
437
|
+
|
|
438
|
+
const appendPrimaryKeySql = (
|
|
439
|
+
parts: string[],
|
|
440
|
+
field: string,
|
|
441
|
+
table: AnyStoreTable,
|
|
442
|
+
spec: SqliteFieldSpec
|
|
443
|
+
): void => {
|
|
444
|
+
const isPrimaryKey = field === table.primaryKey;
|
|
445
|
+
if (!isPrimaryKey) {
|
|
446
|
+
if (!spec.optional && !spec.nullable) {
|
|
447
|
+
parts.push('NOT NULL');
|
|
448
|
+
}
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
parts.push('NOT NULL');
|
|
453
|
+
parts.push('PRIMARY KEY');
|
|
454
|
+
const isAutoIncrement =
|
|
455
|
+
table.generated.includes(field) &&
|
|
456
|
+
spec.kind === 'integer' &&
|
|
457
|
+
spec.defaultValue === undefined;
|
|
458
|
+
|
|
459
|
+
if (isAutoIncrement) {
|
|
460
|
+
parts.push('AUTOINCREMENT');
|
|
461
|
+
}
|
|
462
|
+
};
|
|
463
|
+
|
|
464
|
+
const appendDefaultSql = (parts: string[], spec: SqliteFieldSpec): void => {
|
|
465
|
+
const sqlDefault = toSqlDefault(spec.defaultValue);
|
|
466
|
+
if (sqlDefault !== undefined) {
|
|
467
|
+
parts.push(`DEFAULT ${sqlDefault}`);
|
|
468
|
+
}
|
|
469
|
+
};
|
|
470
|
+
|
|
471
|
+
const appendReferenceSql = (
|
|
472
|
+
parts: string[],
|
|
473
|
+
field: string,
|
|
474
|
+
table: AnyStoreTable,
|
|
475
|
+
definition: AnyStoreDefinition
|
|
476
|
+
): void => {
|
|
477
|
+
const targetTableName = table.references[field];
|
|
478
|
+
if (targetTableName === undefined) {
|
|
479
|
+
return;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
const target = resolveReferencedTable(definition, table, targetTableName);
|
|
483
|
+
parts.push(
|
|
484
|
+
`REFERENCES ${quoteIdentifier(targetTableName)} (${quoteIdentifier(target.primaryKey)}) ON DELETE RESTRICT ON UPDATE CASCADE`
|
|
485
|
+
);
|
|
486
|
+
};
|
|
487
|
+
|
|
488
|
+
const createColumnSqlParts = (
|
|
489
|
+
field: string,
|
|
490
|
+
schema: z.ZodType,
|
|
491
|
+
table: AnyStoreTable,
|
|
492
|
+
definition: AnyStoreDefinition
|
|
493
|
+
): string[] => {
|
|
494
|
+
const spec = deriveFieldSpec(field, schema);
|
|
495
|
+
const parts = [quoteIdentifier(field), toSqlType(spec.kind)];
|
|
496
|
+
appendPrimaryKeySql(parts, field, table, spec);
|
|
497
|
+
appendDefaultSql(parts, spec);
|
|
498
|
+
appendReferenceSql(parts, field, table, definition);
|
|
499
|
+
return parts;
|
|
500
|
+
};
|
|
501
|
+
|
|
502
|
+
const createColumnSql = (
|
|
503
|
+
field: string,
|
|
504
|
+
schema: z.ZodType,
|
|
505
|
+
table: AnyStoreTable,
|
|
506
|
+
definition: AnyStoreDefinition
|
|
507
|
+
): string =>
|
|
508
|
+
` ${createColumnSqlParts(field, schema, table, definition).join(' ')}`;
|
|
509
|
+
|
|
510
|
+
const createTableSql = (
|
|
511
|
+
table: AnyStoreTable,
|
|
512
|
+
definition: AnyStoreDefinition
|
|
513
|
+
): string => {
|
|
514
|
+
const columns = Object.entries(table.schema.shape).map(([field, schema]) =>
|
|
515
|
+
createColumnSql(field, schema as z.ZodType, table, definition)
|
|
516
|
+
);
|
|
517
|
+
|
|
518
|
+
return [
|
|
519
|
+
`CREATE TABLE IF NOT EXISTS ${quoteIdentifier(table.name)} (`,
|
|
520
|
+
columns.join(',\n'),
|
|
521
|
+
')',
|
|
522
|
+
].join('\n');
|
|
523
|
+
};
|
|
524
|
+
|
|
525
|
+
const createIndexSql = (table: AnyStoreTable, field: string): string =>
|
|
526
|
+
`CREATE INDEX IF NOT EXISTS ${quoteIdentifier(`${table.name}_${field}_idx`)} ON ${quoteIdentifier(table.name)} (${quoteIdentifier(field)})`;
|
|
527
|
+
|
|
528
|
+
const assertTabularStoreKind = (definition: AnyStoreDefinition): void => {
|
|
529
|
+
if (definition.kind === 'tabular') {
|
|
530
|
+
return;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
throw new ValidationError(
|
|
534
|
+
`@ontrails/drizzle only supports store definitions with kind "tabular". Received "${definition.kind}".`
|
|
535
|
+
);
|
|
536
|
+
};
|
|
537
|
+
|
|
538
|
+
const definedTables = (
|
|
539
|
+
definition: AnyStoreDefinition
|
|
540
|
+
): readonly AnyStoreTable[] =>
|
|
541
|
+
definition.tableNames.flatMap((name) => {
|
|
542
|
+
const table = definition.tables[name];
|
|
543
|
+
return table === undefined ? [] : [table];
|
|
544
|
+
});
|
|
545
|
+
|
|
546
|
+
const createIndexStatements = (
|
|
547
|
+
definition: AnyStoreDefinition
|
|
548
|
+
): readonly string[] =>
|
|
549
|
+
definedTables(definition).flatMap((table) =>
|
|
550
|
+
table.indexes.map((field) => createIndexSql(table, field))
|
|
551
|
+
);
|
|
552
|
+
|
|
553
|
+
export const deriveDrizzleTables = <TStore extends AnyStoreDefinition>(
|
|
554
|
+
definition: TStore
|
|
555
|
+
): DrizzleStoreSchema<TStore> => {
|
|
556
|
+
assertTabularStoreKind(definition);
|
|
557
|
+
const tables: Record<string, AnySQLiteTable> = {};
|
|
558
|
+
|
|
559
|
+
for (const table of definedTables(definition)) {
|
|
560
|
+
tables[table.name] = sqliteTable(
|
|
561
|
+
table.name,
|
|
562
|
+
Object.fromEntries(
|
|
563
|
+
Object.keys(table.schema.shape).map((field) => [
|
|
564
|
+
field,
|
|
565
|
+
deriveColumnBuilder(field, table, definition, tables),
|
|
566
|
+
])
|
|
567
|
+
) as unknown as Record<string, SQLiteColumnBuilderBase>,
|
|
568
|
+
(self) =>
|
|
569
|
+
table.indexes.map((field) =>
|
|
570
|
+
index(`${table.name}_${field}_idx`).on(
|
|
571
|
+
self[field as keyof typeof self] as AnySQLiteColumn
|
|
572
|
+
)
|
|
573
|
+
)
|
|
574
|
+
);
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
return Object.freeze(tables) as DrizzleStoreSchema<TStore>;
|
|
578
|
+
};
|
|
579
|
+
|
|
580
|
+
export const deriveSqliteSchemaStatements = (
|
|
581
|
+
definition: AnyStoreDefinition
|
|
582
|
+
): readonly string[] => {
|
|
583
|
+
assertTabularStoreKind(definition);
|
|
584
|
+
return Object.freeze([
|
|
585
|
+
...definedTables(definition).map((table) =>
|
|
586
|
+
createTableSql(table, definition)
|
|
587
|
+
),
|
|
588
|
+
...createIndexStatements(definition),
|
|
589
|
+
]);
|
|
590
|
+
};
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import type { Resource } from '@ontrails/core';
|
|
2
|
+
import type {
|
|
3
|
+
AnyStoreDefinition,
|
|
4
|
+
ReadOnlyStoreConnection,
|
|
5
|
+
StoreAccessMode,
|
|
6
|
+
StoreAdapterOptions,
|
|
7
|
+
StoreMockSeed,
|
|
8
|
+
StoreTableConnection,
|
|
9
|
+
} from '@ontrails/store';
|
|
10
|
+
import type { BunSQLiteDatabase } from 'drizzle-orm/bun-sqlite';
|
|
11
|
+
import type { AnySQLiteTable } from 'drizzle-orm/sqlite-core';
|
|
12
|
+
|
|
13
|
+
export type DrizzleStoreSchema<TStore extends AnyStoreDefinition> = {
|
|
14
|
+
readonly [TName in keyof TStore['tables']]: AnySQLiteTable<{
|
|
15
|
+
name: Extract<TName, string>;
|
|
16
|
+
}>;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export interface DrizzleQueryContext<TStore extends AnyStoreDefinition> {
|
|
20
|
+
readonly drizzle: BunSQLiteDatabase<DrizzleStoreSchema<TStore>>;
|
|
21
|
+
readonly tables: DrizzleStoreSchema<TStore>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Options accepted by the Drizzle store adapter.
|
|
26
|
+
*
|
|
27
|
+
* Extends {@link StoreAdapterOptions} with drizzle-specific fields. The
|
|
28
|
+
* read-only vs writable distinction is enforced by the factory that consumes
|
|
29
|
+
* these options, not by the options type itself.
|
|
30
|
+
*/
|
|
31
|
+
export interface DrizzleStoreOptions<
|
|
32
|
+
TDef extends AnyStoreDefinition = AnyStoreDefinition,
|
|
33
|
+
> extends StoreAdapterOptions<TDef> {
|
|
34
|
+
/** Path to the SQLite database file. Use `":memory:"` for ephemeral runs. */
|
|
35
|
+
readonly url: string;
|
|
36
|
+
/**
|
|
37
|
+
* Optional fixture overrides applied when the writable runtime database is
|
|
38
|
+
* first created.
|
|
39
|
+
*
|
|
40
|
+
* Mirrors {@link StoreAdapterOptions.mockSeed} but for the `create`
|
|
41
|
+
* (runtime) path: when supplied, the adapter seeds these rows into the
|
|
42
|
+
* writable database immediately after schema initialization. Use this for
|
|
43
|
+
* demo/dogfood apps that want commands and examples to find pre-loaded
|
|
44
|
+
* entities on a fresh `:memory:` boot. Defaults to `undefined` — runtime
|
|
45
|
+
* databases are not auto-seeded from `table.fixtures`.
|
|
46
|
+
*
|
|
47
|
+
* Has no effect on the read-only adapter, which receives runtime data
|
|
48
|
+
* from disk rather than from fixtures.
|
|
49
|
+
*/
|
|
50
|
+
readonly seed?: StoreMockSeed<TDef>;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export type ReadOnlyDrizzleStoreConnection<TStore extends AnyStoreDefinition> =
|
|
54
|
+
ReadOnlyStoreConnection<TStore> & {
|
|
55
|
+
query<TResult>(
|
|
56
|
+
run: (ctx: DrizzleQueryContext<TStore>) => TResult | Promise<TResult>
|
|
57
|
+
): Promise<Awaited<TResult>>;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
export type DrizzleStoreConnection<TStore extends AnyStoreDefinition> =
|
|
61
|
+
StoreTableConnection<TStore> & ReadOnlyDrizzleStoreConnection<TStore>;
|
|
62
|
+
|
|
63
|
+
export interface DrizzleStoreResourceShape<
|
|
64
|
+
TStore extends AnyStoreDefinition,
|
|
65
|
+
TConnection,
|
|
66
|
+
TAccess extends StoreAccessMode,
|
|
67
|
+
> {
|
|
68
|
+
readonly access: TAccess;
|
|
69
|
+
readonly signals?: TStore['signals'] | undefined;
|
|
70
|
+
readonly store: TStore;
|
|
71
|
+
readonly tables: DrizzleStoreSchema<TStore>;
|
|
72
|
+
from(ctx: Parameters<Resource<TConnection>['from']>[0]): TConnection;
|
|
73
|
+
readonly kind: 'resource';
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export type DrizzleStoreResource<
|
|
77
|
+
TStore extends AnyStoreDefinition,
|
|
78
|
+
TConnection,
|
|
79
|
+
TAccess extends StoreAccessMode,
|
|
80
|
+
> = Resource<TConnection> &
|
|
81
|
+
DrizzleStoreResourceShape<TStore, TConnection, TAccess>;
|