@jarenjs/db 0.49.2 → 0.66.1

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 (110) hide show
  1. package/ARCHITECTURE.md +420 -71
  2. package/README.md +711 -79
  3. package/docs/HOSTS.md +269 -0
  4. package/docs/JOBS-FORMAT.md +309 -45
  5. package/docs/LIVE-FORMAT.md +156 -19
  6. package/docs/MIGRATION-FORMAT.md +247 -40
  7. package/docs/MODEL-FORMAT.md +968 -86
  8. package/package.json +21 -8
  9. package/schemas/jaren-migration.draft-07.schema.json +73 -0
  10. package/schemas/jaren-migration.schema.json +73 -0
  11. package/schemas/jaren-model.draft-07.schema.json +224 -162
  12. package/schemas/jaren-model.schema.json +224 -162
  13. package/src/algebra.js +227 -9
  14. package/src/backup.js +161 -0
  15. package/src/cancellation.js +48 -0
  16. package/src/capture.js +255 -44
  17. package/src/cli.js +337 -50
  18. package/src/cursor.js +411 -0
  19. package/src/dag-job.js +154 -21
  20. package/src/ddl.js +125 -11
  21. package/src/dialect.js +267 -112
  22. package/src/dialects/expression-read.js +158 -0
  23. package/src/dialects/postgres.js +618 -0
  24. package/src/dialects/rtree-ddl.js +129 -0
  25. package/src/dialects/sqlite.js +245 -12
  26. package/src/document-files.js +311 -0
  27. package/src/document-steps.js +422 -0
  28. package/src/documents.js +335 -0
  29. package/src/driver.js +503 -69
  30. package/src/drivers/bun.js +37 -1
  31. package/src/drivers/indexeddb-snapshot.js +149 -0
  32. package/src/drivers/node-pool.js +11 -0
  33. package/src/drivers/node-worker-endpoint.js +105 -0
  34. package/src/drivers/node-worker.js +204 -0
  35. package/src/drivers/node.js +41 -7
  36. package/src/drivers/postgres.js +331 -0
  37. package/src/drivers/wasm-oo1.js +97 -0
  38. package/src/drivers/wasm-session.js +67 -0
  39. package/src/drivers/wasm.js +18 -83
  40. package/src/drivers/worker-pool.js +183 -0
  41. package/src/drivers/worker-protocol.js +79 -0
  42. package/src/drivers/worker-queue.js +60 -0
  43. package/src/emit-model.js +14 -0
  44. package/src/emit.js +349 -51
  45. package/src/entity.js +102 -59
  46. package/src/errors.js +430 -2
  47. package/src/expression.js +284 -0
  48. package/src/graph.js +64 -8
  49. package/src/index.js +48 -19
  50. package/src/introspect.js +583 -0
  51. package/src/jobs.js +870 -99
  52. package/src/json-bytes.js +58 -0
  53. package/src/live-time.js +12 -3
  54. package/src/live.js +11 -1
  55. package/src/maintenance.js +175 -0
  56. package/src/migrate.js +606 -333
  57. package/src/model.js +241 -8
  58. package/src/plan.js +1238 -160
  59. package/src/pragmas.js +314 -0
  60. package/src/profile.js +151 -3
  61. package/src/query.js +1748 -312
  62. package/src/residual.js +17 -0
  63. package/src/series.js +12 -4
  64. package/src/store.js +1672 -276
  65. package/src/tracker.js +367 -68
  66. package/src/udf.js +88 -7
  67. package/types/index.d.ts +1246 -32
  68. package/types/node-pool.d.ts +28 -0
  69. package/types/node-worker.d.ts +54 -0
  70. package/types/node.d.ts +72 -3
  71. package/types/postgres.d.ts +46 -0
  72. package/types/typed.d.ts +81 -3
  73. package/types/wasm.d.ts +21 -0
  74. package/dist/types/algebra.d.ts +0 -230
  75. package/dist/types/app.d.ts +0 -49
  76. package/dist/types/capture.d.ts +0 -85
  77. package/dist/types/cli.d.ts +0 -2
  78. package/dist/types/dag-job.d.ts +0 -40
  79. package/dist/types/ddl.d.ts +0 -229
  80. package/dist/types/derive.d.ts +0 -250
  81. package/dist/types/dialect.d.ts +0 -154
  82. package/dist/types/dialects/sqlite.d.ts +0 -9
  83. package/dist/types/driver.d.ts +0 -110
  84. package/dist/types/drivers/bun.d.ts +0 -47
  85. package/dist/types/drivers/node.d.ts +0 -37
  86. package/dist/types/drivers/wasm.d.ts +0 -65
  87. package/dist/types/emit-model.d.ts +0 -44
  88. package/dist/types/emit.d.ts +0 -75
  89. package/dist/types/entity.d.ts +0 -23
  90. package/dist/types/errors.d.ts +0 -170
  91. package/dist/types/graph.d.ts +0 -28
  92. package/dist/types/index.d.ts +0 -37
  93. package/dist/types/jobs.d.ts +0 -140
  94. package/dist/types/knn.d.ts +0 -69
  95. package/dist/types/live-time.d.ts +0 -141
  96. package/dist/types/live.d.ts +0 -64
  97. package/dist/types/migrate.d.ts +0 -170
  98. package/dist/types/model.d.ts +0 -36
  99. package/dist/types/patch-sql.d.ts +0 -37
  100. package/dist/types/plan.d.ts +0 -142
  101. package/dist/types/profile.d.ts +0 -80
  102. package/dist/types/query.d.ts +0 -112
  103. package/dist/types/residual.d.ts +0 -64
  104. package/dist/types/series.d.ts +0 -227
  105. package/dist/types/store.d.ts +0 -60
  106. package/dist/types/tracker.d.ts +0 -43
  107. package/dist/types/typed.d.ts +0 -15
  108. package/dist/types/types.d.ts +0 -26
  109. package/dist/types/udf.d.ts +0 -75
  110. package/dist/types/window.d.ts +0 -52
@@ -0,0 +1,583 @@
1
+ //@ts-check
2
+ /**
3
+ * @file Database → model: `introspectModel(connection)` reads a live
4
+ * database's catalog and derives the `jaren-model` document that would
5
+ * produce it, beside a report of everything it could not.
6
+ *
7
+ * Two rules shape the whole module.
8
+ *
9
+ * **It is read-only.** Nothing here issues DDL or DML, opens a
10
+ * transaction or registers a function. A derived model is an ANSWER;
11
+ * applying it is the migration planner's job and the operator's
12
+ * decision, and the two are deliberately not the same act.
13
+ *
14
+ * **It never claims a byte-perfect round trip.** A physical shape
15
+ * carries less than the model that made it — a document's unindexed
16
+ * members are simply not there, a `TEXT` key column cannot say which
17
+ * pointer filled it, `numeric` on PostgreSQL carries both `integer` and
18
+ * `number` — so every gap is a REPORTED row with a stable code and a
19
+ * path, sorted, once. `strict: true` refuses rather than returning a
20
+ * partial model, because a caller that is going to diff the result
21
+ * against a declared model needs to know the difference is real.
22
+ *
23
+ * The neutral IR in the middle is what makes the two engines answer the
24
+ * same question: the dialect's catalog statements hand back tables,
25
+ * columns, indexes and foreign keys in one row shape, and only the
26
+ * dialect knows how to read its own generated-column expression back
27
+ * into a member path.
28
+ */
29
+
30
+ import { chain } from './driver.js';
31
+ import { DbCompileError } from './errors.js';
32
+ import { KEY_COLUMN, DOC_COLUMN } from './ddl.js';
33
+ import { MODEL_VERSION } from './store.js';
34
+ import { ENGINE_TABLES } from './migrate.js';
35
+ import { registeredName, expressionMembers } from './expression.js';
36
+
37
+ /**
38
+ * Every code the loss report uses, with what it means. Closed, and
39
+ * stable: a caller branches on these, and a report row nobody can name
40
+ * is a report nobody reads.
41
+ */
42
+ export const INTROSPECT_CODES = Object.freeze({
43
+ 'unmapped-table': 'a table whose shape is not one this model format declares',
44
+ 'unmapped-view': 'a view, which a model document cannot declare',
45
+ 'unmapped-object': 'a trigger or another schema object a model cannot declare',
46
+ 'unmapped-column': 'a column that is neither a key, the document, the row identity, '
47
+ + 'nor a generated column over a member path',
48
+ 'unmapped-type': 'a column type no schema type maps back from',
49
+ 'unmapped-index': 'an index over something the model cannot name — an expression, '
50
+ + 'a partial predicate, or a column that is not mapped',
51
+ 'unmapped-constraint': 'a CHECK or a constraint the model has no vocabulary for',
52
+ 'lossy-type': 'two schema types share this column type, so the derived one is a choice',
53
+ 'key-source': 'the key column cannot say which document member filled it',
54
+ 'document-members': 'the document\'s unindexed members are not in the physical shape',
55
+ 'ambiguous-relation': 'a foreign key whose relation cannot be inferred from the shape alone',
56
+ });
57
+
58
+ /**
59
+ * One report row.
60
+ * @param {string} code
61
+ * @param {string} object - the physical object it is about
62
+ * @param {string} detail
63
+ * @returns {{ code: string, object: string, detail: string }}
64
+ */
65
+ function loss(code, object, detail) {
66
+ return { code, object, detail };
67
+ }
68
+
69
+ /**
70
+ * Read the neutral IR for one table: its columns, its declared indexes
71
+ * with their covered columns in order, and its foreign keys.
72
+ * @param {any} connection
73
+ * @param {string} table
74
+ * @returns {any} value-or-promise
75
+ */
76
+ function readTable(connection, table) {
77
+ const dialect = connection.dialect;
78
+ const all = (sql) => chain(connection.prepare(sql), (statement) => statement.all([]));
79
+ return chain(all(dialect.introspect.columns(table)), (columnRows) =>
80
+ chain(all(dialect.introspect.indexes(table)), (indexRows) =>
81
+ chain(all(dialect.introspect.generated(table)), (generatedRows) =>
82
+ chain(all(dialect.introspect.foreignKeyList(table)), (fkRows) => {
83
+ // a DECLARED index is one the model could have named; the
84
+ // engine's own key index is the primary key, read the same way
85
+ // BY NAME, whatever order the catalog answered in: a physical
86
+ // shape carries no record of the order a model declared its
87
+ // indexes in, and a derivation two engines must agree on
88
+ // cannot inherit one engine's listing order
89
+ const declared = indexRows.filter((row) => String(row.origin) === 'c')
90
+ .sort((a, b) => (String(a.name) < String(b.name) ? -1 : 1));
91
+ const keyIndex = indexRows.find((row) => String(row.origin) === 'pk');
92
+ const withColumns = (i, out) => {
93
+ if (i >= declared.length) return out;
94
+ return chain(all(dialect.introspect.indexColumns(String(declared[i].name))),
95
+ (rows) => withColumns(i + 1, [...out, {
96
+ name: String(declared[i].name),
97
+ unique: Number(declared[i].uniq) !== 0,
98
+ columns: rows.map((row) => String(row.name)),
99
+ }]));
100
+ };
101
+ return chain(withColumns(0, []), (indexes) =>
102
+ chain(keyIndex === undefined
103
+ ? []
104
+ : all(dialect.introspect.indexColumns(String(keyIndex.name))), (keyRows) => ({
105
+ name: table,
106
+ primaryKey: keyRows.map((row) => String(row.name)),
107
+ columns: columnRows.map((row) => ({
108
+ name: String(row.name),
109
+ type: String(row.type),
110
+ generated: Number(row.hidden) !== 0,
111
+ })),
112
+ generated: dialect.readGenerated(generatedRows),
113
+ indexes,
114
+ foreignKeys: fkRows.map((row) => ({
115
+ column: String(row.source_column),
116
+ target: String(row.target),
117
+ targetColumn: row.target_column === null || row.target_column === undefined
118
+ ? null : String(row.target_column),
119
+ onDelete: String(row.on_delete ?? 'NO ACTION').toUpperCase(),
120
+ })),
121
+ })));
122
+ }))));
123
+ }
124
+
125
+ /** The on-delete word a model declares, from the catalog's. */
126
+ const ON_DELETE = Object.freeze({
127
+ CASCADE: 'cascade', RESTRICT: 'restrict', 'SET NULL': 'setNull',
128
+ });
129
+
130
+ /**
131
+ * Read the whole database into the neutral IR: one entry per table and
132
+ * per view, in catalog order.
133
+ * @param {any} connection
134
+ * @param {{ tables?: readonly string[] }} [options]
135
+ * @returns {any} value-or-promise of `{ tables, views }`
136
+ */
137
+ export function readSchema(connection, options = undefined) {
138
+ const dialect = connection.dialect;
139
+ const engine = ENGINE_TABLES;
140
+ const wanted = options?.tables === undefined ? null : new Set(options.tables);
141
+ return chain(connection.prepare(dialect.introspect.tables()), (statement) =>
142
+ chain(statement.all([]), (rows) => {
143
+ const views = [];
144
+ const names = [];
145
+ for (const row of rows) {
146
+ const name = String(row.name);
147
+ if (engine.has(name)) continue;
148
+ if (wanted !== null && !wanted.has(name)) continue;
149
+ if (String(row.type) === 'view') views.push(name);
150
+ else names.push(name);
151
+ }
152
+ const step = (i, out) => (i >= names.length
153
+ ? { tables: out, views }
154
+ : chain(readTable(connection, names[i]), (table) => step(i + 1, [...out, table])));
155
+ return step(0, []);
156
+ }));
157
+ }
158
+
159
+ /**
160
+ * Whether a table's shape is a COLLECTION's: a key column, the document
161
+ * column, whatever row identity the dialect declares, and generated
162
+ * columns over member paths and nothing else.
163
+ * @param {any} dialect
164
+ * @param {any} table
165
+ * @returns {boolean}
166
+ */
167
+ function looksLikeCollection(dialect, table) {
168
+ const identity = dialect.identityColumn?.name;
169
+ const byName = new Map(table.columns.map((column) => [column.name, column]));
170
+ if (!byName.has(KEY_COLUMN) || !byName.has(DOC_COLUMN)) return false;
171
+ if (dialect.comparableColumnType(byName.get(DOC_COLUMN).type)
172
+ !== dialect.comparableColumnType(dialect.docColumnType)) return false;
173
+ for (const column of table.columns) {
174
+ if (column.name === KEY_COLUMN || column.name === DOC_COLUMN
175
+ || column.name === identity) continue;
176
+ if (!column.generated) return false;
177
+ }
178
+ return true;
179
+ }
180
+
181
+ /**
182
+ * Derive one collection. The schema it answers holds exactly the
183
+ * members the physical shape carries — the indexed paths, and the key
184
+ * where one is known — because the rest of the document is not there to
185
+ * be read; that absence is a reported row, not a silence.
186
+ * @param {any} dialect
187
+ * @param {any} table
188
+ * @param {(row: any) => void} report
189
+ * @param {Record<string, string> | undefined} keys
190
+ * @returns {any}
191
+ */
192
+ function deriveCollection(dialect, table, report, keys, functionNames) {
193
+ const identity = dialect.identityColumn?.name;
194
+ const byName = new Map(table.columns.map((column) => [column.name, column]));
195
+ const expressions = new Map(table.generated.map((entry) => [entry.name, entry.expression]));
196
+
197
+ /** @type {Map<string, { path: string, type: string | undefined,
198
+ * segments: import('./dialect.js').JsonPathSegment[] }>} */
199
+ const paths = new Map();
200
+ /** @type {Map<string, any>} column -> the declared index expression */
201
+ const computed = new Map();
202
+ for (const column of table.columns) {
203
+ if (!column.generated) continue;
204
+ const expression = expressions.get(column.name);
205
+ const segments = expression === undefined ? null : dialect.memberPathOf(expression);
206
+ if (segments === null) {
207
+ // a DECLARED EXPRESSION, then — the other kind of generated column
208
+ // this format writes, and the dialect that wrote its SQL is the
209
+ // one that can read it back
210
+ const declared = expression === undefined || dialect.expressionOf === undefined
211
+ ? null : dialect.expressionOf(expression, functionNames ?? {});
212
+ if (declared !== null) {
213
+ computed.set(column.name, declared);
214
+ continue;
215
+ }
216
+ report(loss('unmapped-column', `${table.name}.${column.name}`,
217
+ 'a generated column whose expression is neither a member path nor a declared '
218
+ + 'index expression this dialect wrote'));
219
+ continue;
220
+ }
221
+ const type = dialect.schemaTypeOf(column.type);
222
+ if (type === undefined) {
223
+ report(loss('unmapped-type', `${table.name}.${column.name}`,
224
+ `the column type '${column.type}' maps back to no schema type, so the member `
225
+ + 'is left untyped'));
226
+ }
227
+ paths.set(column.name, { path: pathExpression(segments), type, segments });
228
+ }
229
+
230
+ const indexes = [];
231
+ for (const index of table.indexes) {
232
+ if (index.columns.length === 1 && computed.has(index.columns[0])) {
233
+ const declared = { name: indexName(table.name, index.name),
234
+ expression: computed.get(index.columns[0]) };
235
+ if (index.unique) declared.unique = true;
236
+ indexes.push(declared);
237
+ continue;
238
+ }
239
+ const covered = index.columns.map((column) => paths.get(column));
240
+ if (covered.some((entry) => entry === undefined)) {
241
+ report(loss('unmapped-index', `${table.name}.${index.name}`,
242
+ `the index covers (${index.columns.join(', ')}), which is not a member path set`));
243
+ continue;
244
+ }
245
+ const declared = { name: indexName(table.name, index.name), path: covered.length === 1
246
+ ? covered[0].path : covered.map((entry) => entry.path) };
247
+ if (index.unique) declared.unique = true;
248
+ indexes.push(declared);
249
+ }
250
+
251
+ // the key: a database-allocated integer says so in its type; a text
252
+ // one cannot say which member filled it, and the caller may
253
+ const keyColumn = byName.get(KEY_COLUMN);
254
+ const keyType = dialect.schemaTypeOf(keyColumn.type);
255
+ const collection = { schema: { type: 'object', properties: {} }, indexes };
256
+ const hinted = keys?.[table.name];
257
+ if (keyType === 'integer') {
258
+ collection.key = null;
259
+ collection.identity = 'integer';
260
+ }
261
+ else if (hinted !== undefined) {
262
+ collection.key = hinted;
263
+ }
264
+ else {
265
+ collection.key = null;
266
+ collection.identity = 'uuid';
267
+ report(loss('key-source', `${table.name}.${KEY_COLUMN}`,
268
+ 'the key column carries no record of which document member filled it; '
269
+ + "identity: 'uuid' was derived, and a key pointer can be supplied by the caller"));
270
+ }
271
+
272
+ // the schema: the members the shape carries, and a stated absence for
273
+ // every one it does not
274
+ if (typeof collection.key === 'string' && collection.key.startsWith('/')) {
275
+ placeType(collection.schema, collection.key.slice(1).split('/')
276
+ .filter((member) => member.length > 0).map((name) => ({ name })), keyType);
277
+ }
278
+ for (const entry of paths.values())
279
+ placeType(collection.schema, entry.segments, entry.type);
280
+ // a member an EXPRESSION reads is in the document too, and the
281
+ // expression's own column says nothing about its type — the schema
282
+ // names it untyped rather than not at all
283
+ for (const expression of computed.values()) {
284
+ for (const member of expressionMembers(expression)) {
285
+ const segments = memberSegments(member);
286
+ if (segments !== null) placeType(collection.schema, segments, undefined);
287
+ }
288
+ }
289
+ if (collection.schema.properties === undefined) collection.schema.properties = {};
290
+ report(loss('document-members', table.name,
291
+ 'a document\'s unindexed members leave no trace in the physical shape, so the derived '
292
+ + 'schema holds only the members an index or the key names'));
293
+ if (identity !== undefined && !byName.has(identity)) {
294
+ report(loss('unmapped-table', table.name,
295
+ `the dialect declares the row identity column '${identity}', which this table does not `
296
+ + 'have — the collection was derived, but its order is not the engine\'s'));
297
+ }
298
+ return collection;
299
+ }
300
+
301
+ /**
302
+ * Place a member's declared type at the end of its path, building the
303
+ * skeleton the walk needs to find it again.
304
+ *
305
+ * The physical shape carries the type of every member an index covers,
306
+ * at whatever depth — and `schemaTypeAt` finds it by walking
307
+ * `properties` / `prefixItems` / `items`, so a derived schema that only
308
+ * named the top member would type the column `ANY` on the way back and
309
+ * the round trip would not converge. Nothing else is invented: a node
310
+ * on the way to a typed leaf is an object or an array because the path
311
+ * says so, and a leaf with no type is an empty schema.
312
+ * @param {any} node - the schema node to place into
313
+ * @param {import('./dialect.js').JsonPathSegment[]} segments
314
+ * @param {string | undefined} type
315
+ */
316
+ function placeType(node, segments, type) {
317
+ let current = node;
318
+ for (let i = 0; i < segments.length; i++) {
319
+ const segment = segments[i];
320
+ const last = i === segments.length - 1;
321
+ if ('index' in segment) {
322
+ if (current.type === undefined) current.type = 'array';
323
+ if (current.prefixItems === undefined) current.prefixItems = [];
324
+ while (current.prefixItems.length <= segment.index) current.prefixItems.push({});
325
+ if (last) {
326
+ if (type !== undefined) current.prefixItems[segment.index] = { type };
327
+ return;
328
+ }
329
+ current = current.prefixItems[segment.index];
330
+ continue;
331
+ }
332
+ if (current.type === undefined) current.type = 'object';
333
+ if (current.properties === undefined) current.properties = {};
334
+ if (last) {
335
+ const existing = current.properties[segment.name];
336
+ // a member already typed by the key keeps that type; two indexes
337
+ // over one member agree by construction (one column serves both)
338
+ if (existing === undefined || existing.type === undefined)
339
+ current.properties[segment.name] = type === undefined ? {} : { type };
340
+ return;
341
+ }
342
+ if (current.properties[segment.name] === undefined) current.properties[segment.name] = {};
343
+ current = current.properties[segment.name];
344
+ }
345
+ }
346
+
347
+ /** `$.a.b[0]`, in the spelling a model's index path takes. */
348
+ function pathExpression(segments) {
349
+ let text = '$';
350
+ for (const segment of segments) {
351
+ if ('index' in segment) { text += `[${segment.index}]`; continue; }
352
+ text += /^[A-Za-z_][A-Za-z0-9_]*$/.test(segment.name)
353
+ ? `.${segment.name}` : `[${JSON.stringify(segment.name)}]`;
354
+ }
355
+ return text;
356
+ }
357
+
358
+ /**
359
+ * A `$.a.b[0]` path back into typed segments — the shape `placeType`
360
+ * walks. `null` for a spelling this module did not produce.
361
+ * @param {string} path
362
+ * @returns {import('./dialect.js').JsonPathSegment[] | null}
363
+ */
364
+ function memberSegments(path) {
365
+ if (!path.startsWith('$')) return null;
366
+ const segments = [];
367
+ let i = 1;
368
+ while (i < path.length) {
369
+ if (path[i] === '.') {
370
+ const match = /^\.([A-Za-z_][A-Za-z0-9_]*)/.exec(path.slice(i));
371
+ if (match === null) return null;
372
+ segments.push({ name: match[1] });
373
+ i += match[0].length;
374
+ continue;
375
+ }
376
+ if (path[i] === '[') {
377
+ const end = path.indexOf(']', i + 1);
378
+ if (end < 0) return null;
379
+ const body = path.slice(i + 1, end);
380
+ if (/^(?:0|[1-9][0-9]*)$/.test(body)) segments.push({ index: Number(body) });
381
+ else {
382
+ try {
383
+ const name = JSON.parse(body);
384
+ if (typeof name !== 'string') return null;
385
+ segments.push({ name });
386
+ }
387
+ catch { return null; }
388
+ }
389
+ i = end + 1;
390
+ continue;
391
+ }
392
+ return null;
393
+ }
394
+ return segments.length === 0 ? null : segments;
395
+ }
396
+
397
+ /** The model's own index name, out of the physical `<table>_<name>`. */
398
+ function indexName(table, physical) {
399
+ return physical.startsWith(`${table}_`) ? physical.slice(table.length + 1) : physical;
400
+ }
401
+
402
+ /**
403
+ * Derive one ENTITY: the primary key, the mapped scalar columns, the
404
+ * foreign keys as `manyToOne` relations, and the document column for
405
+ * everything else.
406
+ * @param {any} dialect
407
+ * @param {any} table
408
+ * @param {(row: any) => void} report
409
+ * @param {Set<string>} joinTables
410
+ * @returns {any}
411
+ */
412
+ function deriveEntity(dialect, table, report) {
413
+ const identity = dialect.identityColumn?.name;
414
+ const primaryKey = new Set(table.primaryKey ?? []);
415
+ const fkByColumn = new Map(table.foreignKeys.map((fk) => [fk.column, fk]));
416
+ const uniqueColumns = new Set();
417
+ const indexedColumns = new Set();
418
+ for (const index of table.indexes) {
419
+ if (index.columns.length !== 1) {
420
+ report(loss('unmapped-index', `${table.name}.${index.name}`,
421
+ 'a composite index over an entity is not a property-level declaration'));
422
+ continue;
423
+ }
424
+ (index.unique ? uniqueColumns : indexedColumns).add(index.columns[0]);
425
+ }
426
+
427
+ const properties = {};
428
+ const required = [];
429
+ for (const column of table.columns) {
430
+ if (column.name === identity || column.name === DOC_COLUMN) continue;
431
+ if (column.generated) {
432
+ report(loss('unmapped-column', `${table.name}.${column.name}`,
433
+ 'a generated column on an entity table is not an entity property'));
434
+ continue;
435
+ }
436
+ const fk = fkByColumn.get(column.name);
437
+ if (fk !== undefined) {
438
+ // the FOREIGN KEY side. Which side declared it — and whether the
439
+ // other holds many — is not in the shape: a `to`/`via` pair is
440
+ // derivable, the inverse is not
441
+ properties[column.name] = { 'x-entity': { relation: {
442
+ to: fk.target,
443
+ via: column.name,
444
+ ...(ON_DELETE[fk.onDelete] === undefined ? {} : { onDelete: ON_DELETE[fk.onDelete] }),
445
+ } } };
446
+ report(loss('ambiguous-relation', `${table.name}.${column.name}`,
447
+ `the foreign key points at '${fk.target}', which is one side of the edge; whether `
448
+ + 'the other side holds many is not in the physical shape'));
449
+ continue;
450
+ }
451
+ const type = dialect.schemaTypeOf(column.type);
452
+ if (type === undefined) {
453
+ report(loss('unmapped-type', `${table.name}.${column.name}`,
454
+ `the column type '${column.type}' maps back to no schema type`));
455
+ }
456
+ const property = type === undefined ? {} : { type };
457
+ const entity = {};
458
+ if (primaryKey.has(column.name)) { entity.key = true; required.push(column.name); }
459
+ if (uniqueColumns.has(column.name)) entity.unique = true;
460
+ else if (indexedColumns.has(column.name)) entity.index = true;
461
+ if (Object.keys(entity).length > 0) property['x-entity'] = entity;
462
+ properties[column.name] = property;
463
+ }
464
+ report(loss('document-members', table.name,
465
+ 'an entity\'s document column holds every property the mapping did not give a column, '
466
+ + 'and those are not in the physical shape'));
467
+ const schema = { type: 'object', properties };
468
+ if (required.length > 0) schema.required = required.sort();
469
+ return { schema };
470
+ }
471
+
472
+ /**
473
+ * Whether a table is a many-to-many JOIN table: two foreign-key columns,
474
+ * a composite key over exactly those, no document column of its own.
475
+ * @param {any} dialect
476
+ * @param {any} table
477
+ * @returns {boolean}
478
+ */
479
+ function looksLikeJoinTable(dialect, table) {
480
+ const identity = dialect.identityColumn?.name;
481
+ const own = table.columns.filter((column) => column.name !== identity);
482
+ if (own.length !== 2) return false;
483
+ if (own.some((column) => column.name === DOC_COLUMN)) return false;
484
+ const keys = new Set(table.foreignKeys.map((fk) => fk.column));
485
+ return own.every((column) => keys.has(column.name));
486
+ }
487
+
488
+ /**
489
+ * Derive a model from a live database.
490
+ *
491
+ * Read-only, whatever the answer: no statement it issues writes, and a
492
+ * refusal in `strict` mode happens after every read and before any
493
+ * model is returned — a caller never gets half of one.
494
+ * @param {any} connection - an open connection (a store's, or a driver's)
495
+ * @param {{ strict?: boolean, tables?: readonly string[],
496
+ * keys?: Record<string, string> }} [options] - `keys` supplies the
497
+ * document pointer a text key column cannot record; `tables` narrows
498
+ * the read to a named set
499
+ * @returns {any} value-or-promise of `{ model, report }`
500
+ */
501
+ export function introspectModel(connection, options = undefined) {
502
+ const dialect = connection.dialect;
503
+ // the engine's function name back to the model's: the registration
504
+ // this store made where it computes an expression itself, the host's
505
+ // own `sql` name where the engine calls its own. Only the
506
+ // declarations carry the mapping, which is why they are an option
507
+ const byName = {};
508
+ for (const [name, declared] of Object.entries(options?.expressions ?? {})) {
509
+ byName[registeredName(name)] = name;
510
+ if (typeof declared?.sql === 'string') byName[declared.sql] = name;
511
+ }
512
+ return chain(readSchema(connection, options), (schema) => {
513
+ /** @type {{ code: string, object: string, detail: string }[]} */
514
+ const report = [];
515
+ const add = (row) => report.push(row);
516
+ for (const view of schema.views) {
517
+ add(loss('unmapped-view', view,
518
+ 'a view is not a shape a model document can declare'));
519
+ }
520
+
521
+ const joinTables = new Set(schema.tables
522
+ .filter((table) => looksLikeJoinTable(dialect, table))
523
+ .map((table) => table.name));
524
+
525
+ /** @type {any} */
526
+ const model = { $model: MODEL_VERSION };
527
+ const collections = {};
528
+ const entities = {};
529
+ for (const table of schema.tables) {
530
+ if (joinTables.has(table.name)) continue;
531
+ if (looksLikeCollection(dialect, table)) {
532
+ collections[table.name] = deriveCollection(dialect, table, add, options?.keys, byName);
533
+ continue;
534
+ }
535
+ const hasDocument = table.columns.some((column) => column.name === DOC_COLUMN);
536
+ const hasKey = (table.primaryKey ?? []).length > 0;
537
+ if (!hasDocument && !hasKey) {
538
+ add(loss('unmapped-table', table.name,
539
+ 'the table has neither a document column nor a key, so it is neither a collection '
540
+ + 'nor an entity'));
541
+ continue;
542
+ }
543
+ entities[table.name] = deriveEntity(dialect, table, add);
544
+ }
545
+
546
+ // a join table's edge belongs to the two entities it joins, and it
547
+ // is declared on ONE of them — the shape cannot say which, so it is
548
+ // declared on the alphabetically first and reported
549
+ for (const table of schema.tables) {
550
+ if (!joinTables.has(table.name)) continue;
551
+ const [left, right] = table.foreignKeys.map((fk) => fk.target).sort();
552
+ if (left === undefined || right === undefined
553
+ || entities[left] === undefined || entities[right] === undefined) {
554
+ add(loss('unmapped-table', table.name,
555
+ 'the join table points at something this read did not derive as an entity'));
556
+ continue;
557
+ }
558
+ const member = `${right.toLowerCase()}s`;
559
+ entities[left].schema.properties[member] = {
560
+ 'x-entity': { relation: { to: right, many: true, through: table.name } },
561
+ };
562
+ add(loss('ambiguous-relation', table.name,
563
+ `a many-to-many edge between '${left}' and '${right}' was declared on '${left}' as `
564
+ + `'${member}'; which side a model declared it on, and under what name, is not in the `
565
+ + 'physical shape'));
566
+ }
567
+
568
+ if (Object.keys(collections).length > 0) model.collections = collections;
569
+ if (Object.keys(entities).length > 0) model.entities = entities;
570
+
571
+ report.sort((a, b) => (a.code === b.code
572
+ ? (a.object < b.object ? -1 : a.object > b.object ? 1 : 0)
573
+ : (a.code < b.code ? -1 : 1)));
574
+
575
+ if (options?.strict === true && report.length > 0) {
576
+ throw new DbCompileError('JD0002',
577
+ `strict introspection refused: the physical shape does not carry ${report.length} `
578
+ + `thing(s) the model would — ${report.map((row) => `${row.code} (${row.object})`)
579
+ .join(', ')}`);
580
+ }
581
+ return { model, report: Object.freeze(report.map((row) => Object.freeze(row))) };
582
+ });
583
+ }