@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.
- package/ARCHITECTURE.md +397 -0
- package/README.md +218 -0
- package/dist/types/algebra.d.ts +133 -0
- package/dist/types/app.d.ts +49 -0
- package/dist/types/capture.d.ts +85 -0
- package/dist/types/cli.d.ts +2 -0
- package/dist/types/dag-job.d.ts +40 -0
- package/dist/types/ddl.d.ts +170 -0
- package/dist/types/dialect.d.ts +130 -0
- package/dist/types/dialects/sqlite.d.ts +9 -0
- package/dist/types/driver.d.ts +128 -0
- package/dist/types/drivers/bun.d.ts +47 -0
- package/dist/types/drivers/node.d.ts +37 -0
- package/dist/types/drivers/wasm.d.ts +65 -0
- package/dist/types/emit-model.d.ts +44 -0
- package/dist/types/emit.d.ts +72 -0
- package/dist/types/entity.d.ts +23 -0
- package/dist/types/errors.d.ts +165 -0
- package/dist/types/graph.d.ts +28 -0
- package/dist/types/index.d.ts +35 -0
- package/dist/types/jobs.d.ts +134 -0
- package/dist/types/live.d.ts +62 -0
- package/dist/types/migrate.d.ts +163 -0
- package/dist/types/model.d.ts +36 -0
- package/dist/types/patch-sql.d.ts +37 -0
- package/dist/types/plan.d.ts +119 -0
- package/dist/types/profile.d.ts +80 -0
- package/dist/types/query.d.ts +100 -0
- package/dist/types/residual.d.ts +50 -0
- package/dist/types/store.d.ts +53 -0
- package/dist/types/tracker.d.ts +43 -0
- package/dist/types/typed.d.ts +15 -0
- package/dist/types/types.d.ts +26 -0
- package/dist/types/udf.d.ts +70 -0
- package/dist/types/window.d.ts +52 -0
- package/docs/JOBS-FORMAT.md +218 -0
- package/docs/LIVE-FORMAT.md +348 -0
- package/docs/MIGRATION-FORMAT.md +302 -0
- package/docs/MODEL-FORMAT.md +928 -0
- package/package.json +81 -0
- package/schemas/jaren-migration.draft-07.schema.json +144 -0
- package/schemas/jaren-migration.schema.json +144 -0
- package/schemas/jaren-model.draft-07.schema.json +149 -0
- package/schemas/jaren-model.schema.json +149 -0
- package/src/algebra.js +105 -0
- package/src/app.js +108 -0
- package/src/capture.js +584 -0
- package/src/cli.js +264 -0
- package/src/dag-job.js +86 -0
- package/src/ddl.js +588 -0
- package/src/dialect.js +297 -0
- package/src/dialects/sqlite.js +175 -0
- package/src/driver.js +419 -0
- package/src/drivers/bun.js +101 -0
- package/src/drivers/node.js +93 -0
- package/src/drivers/wasm.js +178 -0
- package/src/emit-model.js +208 -0
- package/src/emit.js +393 -0
- package/src/entity.js +367 -0
- package/src/errors.js +173 -0
- package/src/graph.js +101 -0
- package/src/index.js +64 -0
- package/src/jobs.js +507 -0
- package/src/live.js +899 -0
- package/src/migrate.js +1411 -0
- package/src/model.js +476 -0
- package/src/patch-sql.js +150 -0
- package/src/plan.js +1038 -0
- package/src/profile.js +131 -0
- package/src/query.js +1010 -0
- package/src/residual.js +91 -0
- package/src/store.js +1422 -0
- package/src/tracker.js +776 -0
- package/src/typed.js +19 -0
- package/src/types.js +36 -0
- package/src/udf.js +132 -0
- package/src/window.js +125 -0
- package/types/app.d.ts +36 -0
- package/types/bun.d.ts +9 -0
- package/types/index.d.ts +592 -0
- package/types/node.d.ts +15 -0
- package/types/typed.d.ts +108 -0
- 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
|
+
}
|