@jarenjs/db 0.34.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.
Files changed (83) hide show
  1. package/ARCHITECTURE.md +397 -0
  2. package/README.md +218 -0
  3. package/dist/types/algebra.d.ts +133 -0
  4. package/dist/types/app.d.ts +49 -0
  5. package/dist/types/capture.d.ts +85 -0
  6. package/dist/types/cli.d.ts +2 -0
  7. package/dist/types/dag-job.d.ts +40 -0
  8. package/dist/types/ddl.d.ts +170 -0
  9. package/dist/types/dialect.d.ts +130 -0
  10. package/dist/types/dialects/sqlite.d.ts +9 -0
  11. package/dist/types/driver.d.ts +128 -0
  12. package/dist/types/drivers/bun.d.ts +47 -0
  13. package/dist/types/drivers/node.d.ts +37 -0
  14. package/dist/types/drivers/wasm.d.ts +65 -0
  15. package/dist/types/emit-model.d.ts +44 -0
  16. package/dist/types/emit.d.ts +72 -0
  17. package/dist/types/entity.d.ts +23 -0
  18. package/dist/types/errors.d.ts +165 -0
  19. package/dist/types/graph.d.ts +28 -0
  20. package/dist/types/index.d.ts +35 -0
  21. package/dist/types/jobs.d.ts +134 -0
  22. package/dist/types/live.d.ts +62 -0
  23. package/dist/types/migrate.d.ts +163 -0
  24. package/dist/types/model.d.ts +36 -0
  25. package/dist/types/patch-sql.d.ts +37 -0
  26. package/dist/types/plan.d.ts +119 -0
  27. package/dist/types/profile.d.ts +80 -0
  28. package/dist/types/query.d.ts +100 -0
  29. package/dist/types/residual.d.ts +50 -0
  30. package/dist/types/store.d.ts +53 -0
  31. package/dist/types/tracker.d.ts +43 -0
  32. package/dist/types/typed.d.ts +15 -0
  33. package/dist/types/types.d.ts +26 -0
  34. package/dist/types/udf.d.ts +70 -0
  35. package/dist/types/window.d.ts +52 -0
  36. package/docs/JOBS-FORMAT.md +218 -0
  37. package/docs/LIVE-FORMAT.md +348 -0
  38. package/docs/MIGRATION-FORMAT.md +302 -0
  39. package/docs/MODEL-FORMAT.md +928 -0
  40. package/package.json +81 -0
  41. package/schemas/jaren-migration.draft-07.schema.json +144 -0
  42. package/schemas/jaren-migration.schema.json +144 -0
  43. package/schemas/jaren-model.draft-07.schema.json +149 -0
  44. package/schemas/jaren-model.schema.json +149 -0
  45. package/src/algebra.js +105 -0
  46. package/src/app.js +108 -0
  47. package/src/capture.js +584 -0
  48. package/src/cli.js +264 -0
  49. package/src/dag-job.js +86 -0
  50. package/src/ddl.js +588 -0
  51. package/src/dialect.js +297 -0
  52. package/src/dialects/sqlite.js +175 -0
  53. package/src/driver.js +419 -0
  54. package/src/drivers/bun.js +101 -0
  55. package/src/drivers/node.js +93 -0
  56. package/src/drivers/wasm.js +178 -0
  57. package/src/emit-model.js +208 -0
  58. package/src/emit.js +393 -0
  59. package/src/entity.js +367 -0
  60. package/src/errors.js +173 -0
  61. package/src/graph.js +101 -0
  62. package/src/index.js +64 -0
  63. package/src/jobs.js +507 -0
  64. package/src/live.js +899 -0
  65. package/src/migrate.js +1411 -0
  66. package/src/model.js +476 -0
  67. package/src/patch-sql.js +150 -0
  68. package/src/plan.js +1038 -0
  69. package/src/profile.js +131 -0
  70. package/src/query.js +1010 -0
  71. package/src/residual.js +91 -0
  72. package/src/store.js +1422 -0
  73. package/src/tracker.js +776 -0
  74. package/src/typed.js +19 -0
  75. package/src/types.js +36 -0
  76. package/src/udf.js +132 -0
  77. package/src/window.js +125 -0
  78. package/types/app.d.ts +36 -0
  79. package/types/bun.d.ts +9 -0
  80. package/types/index.d.ts +592 -0
  81. package/types/node.d.ts +15 -0
  82. package/types/typed.d.ts +108 -0
  83. package/types/wasm.d.ts +5 -0
package/src/ddl.js ADDED
@@ -0,0 +1,588 @@
1
+ //@ts-check
2
+ /**
3
+ * @file DDL planning: a normalized collection becomes one physical
4
+ * table — a key column, a JSON document column, a virtual generated
5
+ * column per indexed path, and the declared indexes — with every byte
6
+ * of SQL rendered by the dialect.
7
+ *
8
+ * Index paths are JSONPath expressions analyzed through the engine's
9
+ * PUBLISHED AST (`analyzeQuery`): a path is indexable exactly when the
10
+ * analysis says it is singular and every segment is a plain member or
11
+ * index selection. That reuses the one grammar authority instead of
12
+ * re-parsing, and it fails loudly (`JD0004`) on everything else —
13
+ * wildcards, slices, filters, descendants, functions — rather than
14
+ * silently indexing the wrong thing.
15
+ */
16
+
17
+ import { analyzeQuery } from '@jarenjs/json/query';
18
+ import { DbCompileError } from './errors.js';
19
+ import { chain } from './driver.js';
20
+
21
+ /** The fixed physical column names of the 0.1 mapping. */
22
+ export const KEY_COLUMN = 'key';
23
+ export const DOC_COLUMN = 'doc';
24
+
25
+ /**
26
+ * Analyze one index path expression down to typed segments.
27
+ * @param {string} expression - A JSONPath expression (`$.email`)
28
+ * @param {string} docPath - Model-document pointer for diagnostics
29
+ * @returns {{ segments: import('./dialect.js').JsonPathSegment[],
30
+ * canonical: string }}
31
+ */
32
+ export function compileIndexPath(expression, docPath) {
33
+ let analysis;
34
+ try {
35
+ analysis = analyzeQuery(expression);
36
+ }
37
+ catch (cause) {
38
+ throw new DbCompileError('JD0004',
39
+ `the index path '${expression}' is not a valid query expression`,
40
+ docPath, /** @type {Error} */ (cause));
41
+ }
42
+ const root = analysis.root;
43
+ if (root.kind !== 'path' || root.name !== '$' || root.external === true) {
44
+ throw new DbCompileError('JD0004',
45
+ `the index path '${expression}' must address the stored document through '$'`,
46
+ docPath);
47
+ }
48
+ if (root.singular !== true) {
49
+ throw new DbCompileError('JD0004',
50
+ `the index path '${expression}' is not singular — wildcards, slices, filters and descendants are not indexable`,
51
+ docPath);
52
+ }
53
+ /** @type {import('./dialect.js').JsonPathSegment[]} */
54
+ const segments = [];
55
+ for (const segment of root.segments) {
56
+ const selector = segment.selectors[0];
57
+ if (segment.descendant === true || segment.selectors.length !== 1
58
+ || (selector.kind !== 'name' && selector.kind !== 'index')) {
59
+ throw new DbCompileError('JD0004',
60
+ `the index path '${expression}' uses a selector that does not pick one member`,
61
+ docPath);
62
+ }
63
+ segments.push(selector.kind === 'name'
64
+ ? { name: selector.name }
65
+ : { index: selector.index });
66
+ }
67
+ if (segments.length === 0) {
68
+ throw new DbCompileError('JD0004',
69
+ `the index path '${expression}' selects the whole document — index a member`,
70
+ docPath);
71
+ }
72
+ const canonical = segments
73
+ .map((s) => ('name' in s ? `.${s.name}` : `[${s.index}]`))
74
+ .join('');
75
+ return { segments, canonical };
76
+ }
77
+
78
+ /**
79
+ * The declared schema type at a segment path, walked structurally
80
+ * through `properties` / `items` / `prefixItems`. The collection's
81
+ * schema is the type source — that is why the physical mapping needs
82
+ * no engine-side inference.
83
+ * @param {any} schema
84
+ * @param {import('./dialect.js').JsonPathSegment[]} segments
85
+ * @returns {string | undefined}
86
+ */
87
+ export function schemaTypeAt(schema, segments) {
88
+ let node = schema;
89
+ for (const segment of segments) {
90
+ if (node === null || typeof node !== 'object') return undefined;
91
+ node = 'name' in segment
92
+ ? node.properties?.[segment.name]
93
+ : node.prefixItems?.[segment.index] ?? node.items;
94
+ }
95
+ if (node === null || typeof node !== 'object') return undefined;
96
+ if (typeof node.type === 'string') return node.type;
97
+ if (Array.isArray(node.type)) {
98
+ return node.type.find((t) => typeof t === 'string' && t !== 'null');
99
+ }
100
+ return undefined;
101
+ }
102
+
103
+ /**
104
+ * A stable generated-column name for a canonical path: readable where
105
+ * the path is tame, disambiguated by suffix where sanitizing collides.
106
+ * @param {string} canonical
107
+ * @param {Map<string, string>} byCanonical - canonical -> column name
108
+ * @param {Set<string>} taken
109
+ * @returns {string}
110
+ */
111
+ function generatedColumnName(canonical, byCanonical, taken) {
112
+ const existing = byCanonical.get(canonical);
113
+ if (existing !== undefined) return existing;
114
+ const base = `gx_${canonical.replace(/[^A-Za-z0-9]+/g, '_').replace(/^_+|_+$/g, '')}`;
115
+ let name = base;
116
+ for (let i = 2; taken.has(name); i++) name = `${base}_${i}`;
117
+ byCanonical.set(canonical, name);
118
+ taken.add(name);
119
+ return name;
120
+ }
121
+
122
+ /**
123
+ * Plan one collection's physical shape: the DDL statements to create
124
+ * it and the structural facts an existing table must match (the
125
+ * `JD0002` comparison set).
126
+ * @param {string} name - The collection name (also the table name)
127
+ * @param {{ schema: any, keySegments: { name: string }[] | null,
128
+ * identity: string, indexes: { name: string, paths: string[],
129
+ * unique: boolean, docPath: string }[] }} collection - normalized
130
+ * @param {any} dialect
131
+ * @returns {{
132
+ * table: string, keyColumn: string, docColumn: string,
133
+ * keyType: string,
134
+ * generated: { name: string, type: string, pathText: string,
135
+ * canonical: string }[],
136
+ * columnByCanonical: Map<string, string>,
137
+ * createSql: string[],
138
+ * expected: { columns: { name: string, type: string,
139
+ * generated: boolean }[], indexes: { name: string, unique: boolean,
140
+ * columns: string[] }[] },
141
+ * }}
142
+ */
143
+ export function planCollection(name, collection, dialect) {
144
+ const keyType = collection.identity === 'integer'
145
+ ? dialect.typeFor('integer', 'key')
146
+ : collection.identity === 'uuid'
147
+ ? dialect.typeFor('string', 'key')
148
+ : dialect.typeFor(
149
+ schemaTypeAt(collection.schema, collection.keySegments ?? []), 'key');
150
+
151
+ /** @type {Map<string, string>} */
152
+ const columnByCanonical = new Map();
153
+ /** @type {Set<string>} */
154
+ const taken = new Set([KEY_COLUMN, DOC_COLUMN]);
155
+ /** @type {{ name: string, type: string, pathText: string, canonical: string }[]} */
156
+ const generated = [];
157
+ /** @type {{ name: string, unique: boolean, columns: string[] }[]} */
158
+ const indexes = [];
159
+
160
+ for (const index of collection.indexes) {
161
+ const columns = [];
162
+ for (let i = 0; i < index.paths.length; i++) {
163
+ const pathDocPath = `${index.docPath}/path`;
164
+ const { segments, canonical } = compileIndexPath(index.paths[i], pathDocPath);
165
+ const pathText = dialect.jsonPathText(segments);
166
+ if (pathText === null) {
167
+ throw new DbCompileError('JD0004',
168
+ `the index path '${index.paths[i]}' names a member the dialect's JSON path grammar cannot carry`,
169
+ pathDocPath);
170
+ }
171
+ const known = columnByCanonical.has(canonical);
172
+ const columnName = generatedColumnName(canonical, columnByCanonical, taken);
173
+ if (!known) {
174
+ generated.push({
175
+ name: columnName,
176
+ type: dialect.typeFor(schemaTypeAt(collection.schema, segments), 'generated'),
177
+ pathText,
178
+ canonical,
179
+ });
180
+ }
181
+ columns.push(columnName);
182
+ }
183
+ indexes.push({
184
+ name: `${name}_${index.name}`,
185
+ unique: index.unique,
186
+ columns,
187
+ });
188
+ }
189
+
190
+ const tableShape = {
191
+ table: name,
192
+ keyColumn: KEY_COLUMN,
193
+ keyType,
194
+ docColumn: DOC_COLUMN,
195
+ generated,
196
+ };
197
+ const createSql = [
198
+ dialect.ddl.createTable(tableShape),
199
+ ...indexes.map((index) => dialect.ddl.createIndex({
200
+ name: index.name,
201
+ table: name,
202
+ columns: index.columns,
203
+ unique: index.unique,
204
+ })),
205
+ ];
206
+
207
+ return {
208
+ table: name,
209
+ keyColumn: KEY_COLUMN,
210
+ docColumn: DOC_COLUMN,
211
+ keyType,
212
+ generated,
213
+ columnByCanonical,
214
+ createSql,
215
+ expected: {
216
+ columns: [
217
+ { name: KEY_COLUMN, type: keyType, generated: false },
218
+ { name: DOC_COLUMN, type: dialect.docColumnType, generated: false },
219
+ ...generated.map((g) => ({ name: g.name, type: g.type, generated: true })),
220
+ ],
221
+ indexes: indexes
222
+ // the COLUMNS keep their declared order — an index is ordered, and
223
+ // comparing sorted term lists made `(a,b)` and `(b,a)` equal. Only
224
+ // the index list itself is sorted, to compare by name.
225
+ .map((index) => ({
226
+ name: index.name,
227
+ unique: index.unique,
228
+ columns: [...index.columns],
229
+ }))
230
+ .sort((a, b) => (a.name < b.name ? -1 : 1)),
231
+ },
232
+ };
233
+ }
234
+
235
+ /**
236
+ * Normalize a stored `CREATE` statement for comparison: collapse runs of
237
+ * whitespace, drop whitespace around punctuation, and strip the
238
+ * `IF NOT EXISTS` SQLite does not keep. What survives is every token that
239
+ * carries meaning, so two statements compare equal exactly when they
240
+ * declare the same physical object.
241
+ * @param {string} sql
242
+ * @returns {string}
243
+ */
244
+ export function normalizeDeclaredSql(sql) {
245
+ return String(sql)
246
+ .replace(/\s+/g, ' ')
247
+ .replace(/\s*([(),])\s*/g, '$1')
248
+ .replace(/\bIF NOT EXISTS\s+/i, '')
249
+ .trim();
250
+ }
251
+
252
+ /**
253
+ * Split a comma-separated list at TOP-LEVEL commas only, so a
254
+ * `CHECK(x IN (1,2))` or a multi-column constraint stays one item.
255
+ * @param {string} body
256
+ * @returns {string[]}
257
+ */
258
+ function splitTopLevel(body) {
259
+ /** @type {string[]} */
260
+ const parts = [];
261
+ let depth = 0;
262
+ let quote = '';
263
+ let start = 0;
264
+ for (let i = 0; i < body.length; i++) {
265
+ const c = body[i];
266
+ if (quote !== '') {
267
+ if (c === quote) quote = '';
268
+ continue;
269
+ }
270
+ if (c === '"' || c === "'") quote = c;
271
+ else if (c === '(') depth++;
272
+ else if (c === ')') depth--;
273
+ else if (c === ',' && depth === 0) {
274
+ parts.push(body.slice(start, i));
275
+ start = i + 1;
276
+ }
277
+ }
278
+ parts.push(body.slice(start));
279
+ return parts.map((part) => part.trim()).filter((part) => part !== '');
280
+ }
281
+
282
+ /**
283
+ * A comparable form of one `CREATE` statement.
284
+ *
285
+ * For a TABLE the column definitions compare as a SET, because
286
+ * `ALTER TABLE … ADD COLUMN` can only append — so a migrated table and a
287
+ * freshly built one legitimately differ in column order, and this store
288
+ * never reads a column positionally. Everything else is exact: each
289
+ * column's full definition (type, `PRIMARY KEY`, `NOT NULL`, `DEFAULT`,
290
+ * `CHECK`, `GENERATED … AS`, `REFERENCES … ON DELETE …`), the table
291
+ * constraints, and the trailing table options (`STRICT`,
292
+ * `WITHOUT ROWID`).
293
+ *
294
+ * For an INDEX the text compares whole, because an index IS its order —
295
+ * `(a,b)` and `(b,a)` serve different lookups — as are its partial
296
+ * predicate and each term's collation and direction.
297
+ * @param {string} sql
298
+ * @returns {string}
299
+ */
300
+ export function comparableDeclaredSql(sql) {
301
+ const normalized = normalizeDeclaredSql(sql);
302
+ const open = normalized.indexOf('(');
303
+ const close = normalized.lastIndexOf(')');
304
+ if (!/^CREATE\s+TABLE\b/i.test(normalized) || open < 0 || close < open)
305
+ return normalized;
306
+ const head = normalized.slice(0, open);
307
+ const options = normalized.slice(close + 1).trim();
308
+ const items = splitTopLevel(normalized.slice(open + 1, close));
309
+ // a column definition opens with the quoted column name; anything else
310
+ // (PRIMARY KEY(...), UNIQUE(...), CHECK(...), FOREIGN KEY(...)) is a
311
+ // table constraint, and those are unordered too
312
+ const columns = items.filter((item) => item.startsWith('"')).sort();
313
+ const constraints = items.filter((item) => !item.startsWith('"')).sort();
314
+ return `${head}(${[...columns, ...constraints].join(',')})${options}`;
315
+ }
316
+
317
+ /**
318
+ * The declared-SQL half of verification: compare every schema object the
319
+ * table owns against the statements the plan would have created.
320
+ *
321
+ * The structural pragma comparison above reads column names, types and
322
+ * index membership — real facts, and nowhere near all of them. A table
323
+ * can lose its PRIMARY KEY, its NOT NULL, its STRICT, a CHECK, a default
324
+ * or a generated column's expression; a foreign key can change
325
+ * `ON DELETE SET NULL` to `ON DELETE CASCADE`; a composite index can
326
+ * reverse its terms, gain a partial predicate, or change an index term's
327
+ * collation — and every one of those leaves names and types untouched.
328
+ * They all live in the CREATE text, so this compares that, and a drifted
329
+ * database is refused instead of opened.
330
+ * @param {any} connection
331
+ * @param {any} plan
332
+ * @param {(difference: string) => never} disagree
333
+ * @returns {any} value-or-promise
334
+ */
335
+ function verifyDeclaredSql(connection, plan, disagree) {
336
+ const dialect = connection.dialect;
337
+ const planned = new Map();
338
+ for (const sql of plan.createSql) {
339
+ const comparable = comparableDeclaredSql(sql);
340
+ // the object's name is the first quoted identifier in the statement
341
+ const name = /"((?:[^"]|"")*)"/.exec(comparable)?.[1]?.replace(/""/g, '"');
342
+ if (name === undefined) continue;
343
+ planned.set(name, comparable);
344
+ }
345
+ return chain(connection.prepare(dialect.introspect.declaredSql(plan.table)),
346
+ (statement) => chain(statement.all([]), (rows) => {
347
+ /** @type {Map<string, string>} */
348
+ const actual = new Map();
349
+ for (const row of rows) actual.set(String(row.name), comparableDeclaredSql(row.sql));
350
+ for (const [name, wanted] of planned) {
351
+ const have = actual.get(name);
352
+ if (have === undefined)
353
+ disagree(`the model declares '${name}', which the database does not have`);
354
+ if (have !== wanted) {
355
+ disagree(`'${name}' is declared as\n ${have}\nand the model declares\n ${wanted}`);
356
+ }
357
+ }
358
+ for (const name of actual.keys()) {
359
+ if (!planned.has(name)) {
360
+ disagree(`the database has '${name}', which the model does not declare — `
361
+ + 'an undeclared index or trigger changes deletion semantics and query plans');
362
+ }
363
+ }
364
+ return null;
365
+ }));
366
+ }
367
+
368
+ /**
369
+ * Verify an existing table against the planned shape; any difference
370
+ * is `JD0002` and nothing is altered. Shared by the store's open path
371
+ * and the migration engine's shadow validation.
372
+ * @param {any} connection
373
+ * @param {any} plan
374
+ * @param {string} collection
375
+ * @param {string} docPath
376
+ * @returns {any} value-or-promise
377
+ */
378
+ export function verifyShape(connection, plan, collection, docPath) {
379
+ const dialect = connection.dialect;
380
+ const disagree = (difference) => {
381
+ throw new DbCompileError('JD0002',
382
+ `collection '${collection}': the existing table does not match the declared model (${difference}); reshaping a live database is the migration story, and nothing was altered`,
383
+ docPath);
384
+ };
385
+ return chain(connection.prepare(dialect.introspect.columns(plan.table)), (columnsStatement) =>
386
+ chain(columnsStatement.all([]), (columnRows) => {
387
+ const actual = columnRows
388
+ .map((row) => ({
389
+ name: String(row.name),
390
+ type: String(row.type).toUpperCase(),
391
+ generated: Number(row.hidden) !== 0,
392
+ }))
393
+ .sort((a, b) => (a.name < b.name ? -1 : 1));
394
+ const expected = [...plan.expected.columns]
395
+ .map((c) => ({ ...c, type: c.type.toUpperCase() }))
396
+ .sort((a, b) => (a.name < b.name ? -1 : 1));
397
+ if (actual.length !== expected.length)
398
+ disagree(`${actual.length} columns exist, the model declares ${expected.length}`);
399
+ for (let i = 0; i < expected.length; i++) {
400
+ const want = expected[i];
401
+ const have = actual[i];
402
+ if (want.name !== have.name || want.type !== have.type
403
+ || want.generated !== have.generated) {
404
+ disagree(`column '${have.name}' is ${have.type}${have.generated ? ' generated' : ''}, `
405
+ + `the model declares '${want.name}' ${want.type}${want.generated ? ' generated' : ''}`);
406
+ }
407
+ }
408
+ return chain(connection.prepare(dialect.introspect.indexes(plan.table)), (indexesStatement) =>
409
+ chain(indexesStatement.all([]), (indexRows) => {
410
+ const created = indexRows
411
+ .filter((row) => String(row.origin) === 'c')
412
+ .map((row) => ({ name: String(row.name), unique: Number(row.uniq) !== 0 }))
413
+ .sort((a, b) => (a.name < b.name ? -1 : 1));
414
+ const wantedIndexes = plan.expected.indexes;
415
+ if (created.length !== wantedIndexes.length)
416
+ disagree(`${created.length} declared indexes exist, the model declares ${wantedIndexes.length}`);
417
+ const collectColumns = (i) => {
418
+ if (i >= created.length) return null;
419
+ const have = created[i];
420
+ const want = wantedIndexes[i];
421
+ if (have.name !== want.name || have.unique !== want.unique)
422
+ disagree(`index '${have.name}'${have.unique ? ' (unique)' : ''} does not match the declared '${want.name}'`);
423
+ return chain(connection.prepare(dialect.introspect.indexColumns(have.name)), (statement) =>
424
+ chain(statement.all([]), (rows) => {
425
+ // NOT sorted: `(a,b)` and `(b,a)` are different indexes —
426
+ // one serves an `a`-prefix lookup and the other does not,
427
+ // and sorting made them compare equal
428
+ const haveColumns = rows.map((row) => String(row.name));
429
+ if (haveColumns.join(',') !== want.columns.join(','))
430
+ disagree(`index '${have.name}' covers (${haveColumns.join(', ')}) in that order, the model declares (${want.columns.join(', ')})`);
431
+ return collectColumns(i + 1);
432
+ }));
433
+ };
434
+ return chain(collectColumns(0), () =>
435
+ verifyDeclaredSql(connection, plan, disagree));
436
+ }));
437
+ }));
438
+ }
439
+
440
+
441
+ /**
442
+ * Plan one ENTITY's physical shape from the mapping data
443
+ * `explainMapping` derived: the relational table (typed columns,
444
+ * checks, foreign keys, the JSONB document column), its indexes, and
445
+ * the structural facts an existing table must match. One verify path
446
+ * serves both document kinds.
447
+ * @param {string} name
448
+ * @param {any} entityMapping - `explainMapping(model).entities[name]`
449
+ * @param {any} entities - the full `explainMapping` result (key types
450
+ * come from the referenced entity's columns)
451
+ * @param {any} dialect
452
+ * @returns {{ table: string, createSql: string[], expected: any,
453
+ * columnNames: Set<string> }}
454
+ */
455
+ export function planEntity(name, entityMapping, entities, dialect) {
456
+ const storageType = (storage) => dialect.typeFor(storage, 'generated');
457
+ const keyType = (entityName) => {
458
+ const target = entities.entities[entityName];
459
+ const keyColumn = target.columns.find((column) => column.key);
460
+ return storageType(keyColumn.storage);
461
+ };
462
+ const renderCheck = (columnName, values) => {
463
+ const rendered = values.map((value) => (typeof value === 'string'
464
+ ? dialect.stringLiteral(value)
465
+ : typeof value === 'boolean' ? dialect.booleanLiteral(value) : String(value)));
466
+ return `${dialect.quoteIdentifier(columnName)} IN (${rendered.join(', ')})`;
467
+ };
468
+
469
+ const singleKey = entityMapping.keys.length === 1;
470
+ // a declared `via` property and its foreign key are ONE column: the
471
+ // FK definition claims it, so the scalar list must not repeat it
472
+ const fkNames = new Set(entityMapping.foreignKeys.map((fk) => fk.column));
473
+ const columns = [];
474
+ for (const column of entityMapping.columns) {
475
+ if (fkNames.has(column.name)) continue;
476
+ columns.push({
477
+ name: column.name,
478
+ type: storageType(column.storage),
479
+ primaryKey: singleKey && column.key,
480
+ notNull: column.key && !singleKey,
481
+ check: column.check !== undefined ? renderCheck(column.name, column.check) : undefined,
482
+ });
483
+ }
484
+ for (const fk of entityMapping.foreignKeys) {
485
+ columns.push({
486
+ name: fk.column,
487
+ type: keyType(fk.references),
488
+ references: { table: fk.references, column: fk.referencesKey, onDelete: fk.onDelete },
489
+ });
490
+ }
491
+ columns.push({ name: DOC_COLUMN, type: dialect.docColumnType, notNull: true });
492
+
493
+ const createSql = [dialect.ddl.createRelationalTable({
494
+ table: name,
495
+ columns,
496
+ compositeKey: singleKey ? undefined : entityMapping.keys,
497
+ })];
498
+ const expectedIndexes = [];
499
+ for (const index of entityMapping.indexes) {
500
+ const indexName = `${name}_${index.property}`;
501
+ createSql.push(dialect.ddl.createIndex({
502
+ name: indexName, table: name, columns: [index.property], unique: index.unique,
503
+ }));
504
+ expectedIndexes.push({ name: indexName, unique: index.unique, columns: [index.property] });
505
+ }
506
+ for (const fk of entityMapping.foreignKeys) {
507
+ // every foreign key gets an index: unique for a strict one-to-one,
508
+ // plain otherwise — the correlated graph-load subqueries probe the
509
+ // child's via column once per parent
510
+ const indexName = `${name}_${fk.column}`;
511
+ createSql.push(dialect.ddl.createIndex({
512
+ name: indexName, table: name, columns: [fk.column], unique: fk.unique,
513
+ }));
514
+ expectedIndexes.push({ name: indexName, unique: fk.unique, columns: [fk.column] });
515
+ }
516
+
517
+ return {
518
+ table: name,
519
+ createSql,
520
+ columnNames: new Set(columns.map((column) => column.name)),
521
+ expectedForeignKeys: columns
522
+ .filter((column) => column.references !== undefined)
523
+ .map((column) => ({
524
+ column: column.name,
525
+ references: column.references.table,
526
+ // the ACTION, not just the edge: SET NULL and CASCADE are both
527
+ // "a foreign key exists" and mean opposite things for the row
528
+ onDelete: column.references.onDelete ?? null,
529
+ onUpdate: column.references.onUpdate ?? null,
530
+ targetColumn: column.references.column ?? null,
531
+ })),
532
+ expected: {
533
+ columns: columns
534
+ .map((column) => ({ name: column.name, type: column.type, generated: false }))
535
+ .sort((a, b) => (a.name < b.name ? -1 : 1)),
536
+ indexes: expectedIndexes.sort((a, b) => (a.name < b.name ? -1 : 1)),
537
+ },
538
+ };
539
+ }
540
+
541
+ /**
542
+ * Plan a many-to-many join table.
543
+ * @param {string} tableName
544
+ * @param {any} join - `explainMapping(model).joinTables[tableName]`
545
+ * @param {any} entities - the full mapping
546
+ * @param {any} dialect
547
+ * @returns {{ table: string, createSql: string[], expected: any }}
548
+ */
549
+ export function planJoinTable(tableName, join, entities, dialect) {
550
+ const keyType = (entityName) => {
551
+ const target = entities.entities[entityName];
552
+ const keyColumn = target.columns.find((column) => column.key);
553
+ return dialect.typeFor(keyColumn.storage, 'generated');
554
+ };
555
+ const columns = [
556
+ {
557
+ name: join.left.column,
558
+ type: keyType(join.left.entity),
559
+ references: { table: join.left.entity, column: join.left.referencesKey, onDelete: 'cascade' },
560
+ },
561
+ {
562
+ name: join.right.column,
563
+ type: keyType(join.right.entity),
564
+ references: { table: join.right.entity, column: join.right.referencesKey, onDelete: 'cascade' },
565
+ },
566
+ ];
567
+ return {
568
+ table: tableName,
569
+ createSql: [dialect.ddl.createRelationalTable({
570
+ table: tableName,
571
+ columns,
572
+ compositeKey: [join.left.column, join.right.column],
573
+ })],
574
+ expectedForeignKeys: columns.map((column) => ({
575
+ column: column.name,
576
+ references: column.references.table,
577
+ onDelete: column.references.onDelete ?? null,
578
+ onUpdate: column.references.onUpdate ?? null,
579
+ targetColumn: column.references.column ?? null,
580
+ })),
581
+ expected: {
582
+ columns: columns
583
+ .map((column) => ({ name: column.name, type: column.type, generated: false }))
584
+ .sort((a, b) => (a.name < b.name ? -1 : 1)),
585
+ indexes: [],
586
+ },
587
+ };
588
+ }