@jarenjs/db 0.56.0 → 0.67.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/ARCHITECTURE.md +412 -56
- package/README.md +600 -57
- package/docs/HOSTS.md +269 -0
- package/docs/JOBS-FORMAT.md +293 -45
- package/docs/LIVE-FORMAT.md +169 -20
- package/docs/MIGRATION-FORMAT.md +142 -17
- package/docs/MODEL-FORMAT.md +752 -64
- package/docs/REPLICATION-FORMAT.md +208 -0
- package/package.json +21 -7
- package/schemas/jaren-model.draft-07.schema.json +224 -162
- package/schemas/jaren-model.schema.json +224 -162
- package/schemas/jaren-replication-snapshot.draft-07.schema.json +83 -0
- package/schemas/jaren-replication-snapshot.schema.json +83 -0
- package/schemas/jaren-replication.draft-07.schema.json +82 -0
- package/schemas/jaren-replication.schema.json +82 -0
- package/src/algebra.js +227 -9
- package/src/backup.js +161 -0
- package/src/cancellation.js +48 -0
- package/src/capture.js +230 -47
- package/src/cli.js +165 -59
- package/src/cursor.js +417 -0
- package/src/dag-job.js +154 -21
- package/src/ddl.js +102 -8
- package/src/dialect.js +268 -113
- package/src/dialects/expression-read.js +158 -0
- package/src/dialects/postgres.js +618 -0
- package/src/dialects/rtree-ddl.js +129 -0
- package/src/dialects/sqlite.js +244 -11
- package/src/document-files.js +311 -0
- package/src/document-steps.js +422 -0
- package/src/documents.js +335 -0
- package/src/driver.js +448 -61
- package/src/drivers/bun.js +37 -1
- package/src/drivers/indexeddb-snapshot.js +149 -0
- package/src/drivers/node-pool.js +11 -0
- package/src/drivers/node-worker-endpoint.js +105 -0
- package/src/drivers/node-worker.js +204 -0
- package/src/drivers/node.js +41 -7
- package/src/drivers/postgres.js +331 -0
- package/src/drivers/wasm-oo1.js +97 -0
- package/src/drivers/wasm-session.js +67 -0
- package/src/drivers/wasm.js +17 -83
- package/src/drivers/worker-pool.js +183 -0
- package/src/drivers/worker-protocol.js +79 -0
- package/src/drivers/worker-queue.js +60 -0
- package/src/emit.js +339 -48
- package/src/entity.js +20 -22
- package/src/errors.js +430 -19
- package/src/expression.js +284 -0
- package/src/graph.js +64 -8
- package/src/index.js +48 -17
- package/src/introspect.js +583 -0
- package/src/jobs.js +843 -107
- package/src/json-bytes.js +58 -0
- package/src/live-join.js +250 -0
- package/src/live-nested.js +120 -0
- package/src/live.js +18 -4
- package/src/logical-rows.js +90 -0
- package/src/maintenance.js +175 -0
- package/src/migrate.js +248 -181
- package/src/model.js +68 -0
- package/src/plan.js +1119 -138
- package/src/pragmas.js +314 -0
- package/src/profile.js +151 -3
- package/src/query.js +1634 -323
- package/src/replication-format.js +115 -0
- package/src/replication.js +332 -0
- package/src/residual.js +17 -0
- package/src/series.js +12 -4
- package/src/store.js +1567 -273
- package/src/tracker.js +203 -29
- package/src/udf.js +88 -7
- package/types/index.d.ts +1158 -27
- package/types/node-pool.d.ts +28 -0
- package/types/node-worker.d.ts +54 -0
- package/types/node.d.ts +69 -2
- package/types/postgres.d.ts +46 -0
- package/types/typed.d.ts +27 -4
- package/types/wasm.d.ts +14 -0
|
@@ -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
|
+
}
|