@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/model.js
ADDED
|
@@ -0,0 +1,476 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
/**
|
|
3
|
+
* @file The entity model walk: `x-entity` normalization with a CLOSED
|
|
4
|
+
* vocabulary, relation resolution with inverse agreement, and
|
|
5
|
+
* `explainMapping` — the derived physical shape as plain data, so the
|
|
6
|
+
* hybrid mapping rule is golden-testable rather than folklore.
|
|
7
|
+
*
|
|
8
|
+
* THE DESCENT DECISION (recorded here because TODO's D22 demands it
|
|
9
|
+
* be explicit): six copies of the `properties`/`prefixItems`/`items`/
|
|
10
|
+
* `allOf` descent spine exist in this repository, and this walk was
|
|
11
|
+
* the candidate seventh. It is NOT one. The entity walk is
|
|
12
|
+
* deliberately ONE level deep — it enumerates the TOP-LEVEL
|
|
13
|
+
* properties of an entity schema, resolves `$ref` and shallow-merges
|
|
14
|
+
* `allOf` at each property through the resolvers
|
|
15
|
+
* `@jarenjs/validate/normalize` exports for exactly this purpose, and
|
|
16
|
+
* never recurses further, because the mapping rule sends every nested
|
|
17
|
+
* shape to the JSONB document wholesale. A consumer with no recursion
|
|
18
|
+
* has no descent spine to share, so the shared-enumerator question
|
|
19
|
+
* (three different termination strategies across the six copies)
|
|
20
|
+
* stays open for the first consumer that actually recurses. No
|
|
21
|
+
* seventh copy was added.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import {
|
|
25
|
+
collectSameDocumentAnchors, resolveSameDocumentRef,
|
|
26
|
+
} from '@jarenjs/validate/normalize';
|
|
27
|
+
|
|
28
|
+
import { DbCompileError } from './errors.js';
|
|
29
|
+
|
|
30
|
+
/** The closed `x-entity` vocabulary; anything else is `JD0030`. */
|
|
31
|
+
const ENTITY_MEMBERS = new Set(['key', 'unique', 'index', 'default', 'column', 'relation', 'version']);
|
|
32
|
+
const RELATION_MEMBERS = new Set(['to', 'many', 'via', 'through', 'onDelete']);
|
|
33
|
+
const ON_DELETE = new Set(['cascade', 'restrict', 'setNull']);
|
|
34
|
+
const ENTITY_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
35
|
+
const SCALARS = new Set(['string', 'integer', 'number', 'boolean']);
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* @param {string} reason
|
|
39
|
+
* @param {string} docPath
|
|
40
|
+
* @param {string} [code]
|
|
41
|
+
* @returns {DbCompileError}
|
|
42
|
+
*/
|
|
43
|
+
function modelError(reason, docPath, code = 'JD0005') {
|
|
44
|
+
return new DbCompileError(code, reason, docPath);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Resolve a property subschema to its effective shape: follow a local
|
|
49
|
+
* `$ref`, then shallow-merge `allOf` branches (last write wins for
|
|
50
|
+
* scalars; the entity walk needs `type`, `format`, `enum` and
|
|
51
|
+
* `x-entity`, nothing deeper).
|
|
52
|
+
* @param {any} node
|
|
53
|
+
* @param {any} root - the entity schema (same-document refs only)
|
|
54
|
+
* @param {Map<string, any>} anchors
|
|
55
|
+
* @param {string} docPath
|
|
56
|
+
* @returns {any}
|
|
57
|
+
*/
|
|
58
|
+
function effectiveSchema(node, root, anchors, docPath) {
|
|
59
|
+
if (node === null || typeof node !== 'object' || Array.isArray(node))
|
|
60
|
+
return {};
|
|
61
|
+
let resolved = node;
|
|
62
|
+
if (typeof node.$ref === 'string') {
|
|
63
|
+
const target = resolveSameDocumentRef(node.$ref, root, anchors);
|
|
64
|
+
if (target === undefined) {
|
|
65
|
+
throw modelError(`the $ref '${node.$ref}' does not resolve inside the entity schema`,
|
|
66
|
+
docPath);
|
|
67
|
+
}
|
|
68
|
+
resolved = /** @type {any} */ (target);
|
|
69
|
+
}
|
|
70
|
+
if (!Array.isArray(resolved.allOf)) return resolved;
|
|
71
|
+
const merged = { ...resolved };
|
|
72
|
+
delete merged.allOf;
|
|
73
|
+
for (const branch of resolved.allOf) {
|
|
74
|
+
const flat = effectiveSchema(branch, root, anchors, docPath);
|
|
75
|
+
for (const key of Object.keys(flat)) {
|
|
76
|
+
if (!(key in merged)) merged[key] = flat[key];
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return merged;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Normalize one property's `x-entity` block against the closed set.
|
|
84
|
+
* @param {any} block
|
|
85
|
+
* @param {string} docPath
|
|
86
|
+
* @returns {any}
|
|
87
|
+
*/
|
|
88
|
+
function normalizeEntityBlock(block, docPath) {
|
|
89
|
+
if (block === undefined) return {};
|
|
90
|
+
if (block === null || typeof block !== 'object' || Array.isArray(block))
|
|
91
|
+
throw modelError('x-entity must be an object', docPath);
|
|
92
|
+
for (const member of Object.keys(block)) {
|
|
93
|
+
if (!ENTITY_MEMBERS.has(member)) {
|
|
94
|
+
throw new DbCompileError('JD0030',
|
|
95
|
+
`unknown x-entity member '${member}' — the vocabulary is closed `
|
|
96
|
+
+ '(key, unique, index, default, column, relation, version) because a silently '
|
|
97
|
+
+ 'ignored mapping directive loses data',
|
|
98
|
+
`${docPath}/${member}`);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
if (block.relation !== undefined) {
|
|
102
|
+
const relation = block.relation;
|
|
103
|
+
if (relation === null || typeof relation !== 'object' || Array.isArray(relation))
|
|
104
|
+
throw modelError('x-entity.relation must be an object', `${docPath}/relation`);
|
|
105
|
+
for (const member of Object.keys(relation)) {
|
|
106
|
+
if (!RELATION_MEMBERS.has(member)) {
|
|
107
|
+
throw new DbCompileError('JD0030',
|
|
108
|
+
`unknown x-entity relation member '${member}'`,
|
|
109
|
+
`${docPath}/relation/${member}`);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return block;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Validate a default declaration.
|
|
118
|
+
* @param {any} declared
|
|
119
|
+
* @param {string} docPath
|
|
120
|
+
*/
|
|
121
|
+
function checkDefault(declared, docPath) {
|
|
122
|
+
if (declared === 'now' || declared === 'updated' || declared === 'uuid'
|
|
123
|
+
|| declared === 'auto') return;
|
|
124
|
+
if (declared !== null && typeof declared === 'object' && !Array.isArray(declared)
|
|
125
|
+
&& (Object.hasOwn(declared, 'value') !== Object.hasOwn(declared, 'query'))
|
|
126
|
+
&& Object.keys(declared).length === 1) return;
|
|
127
|
+
throw modelError(
|
|
128
|
+
"a default is 'now', 'updated', 'uuid', 'auto', { value: … } or { query: … }",
|
|
129
|
+
docPath);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Normalize the `entities` member of a model document.
|
|
134
|
+
* @param {any} model
|
|
135
|
+
* @returns {Map<string, any>} entity name -> normalized entity
|
|
136
|
+
*/
|
|
137
|
+
export function normalizeEntities(model) {
|
|
138
|
+
const declared = model?.entities;
|
|
139
|
+
if (declared === undefined) return new Map();
|
|
140
|
+
if (declared === null || typeof declared !== 'object' || Array.isArray(declared)
|
|
141
|
+
|| Object.keys(declared).length === 0)
|
|
142
|
+
throw modelError('entities must be a non-empty object', '/entities');
|
|
143
|
+
|
|
144
|
+
/** @type {Map<string, any>} */
|
|
145
|
+
const entities = new Map();
|
|
146
|
+
for (const name of Object.keys(declared)) {
|
|
147
|
+
const docPath = `/entities/${name}`;
|
|
148
|
+
if (!ENTITY_NAME.test(name))
|
|
149
|
+
throw modelError(`entity names are identifiers, got '${name}'`, '/entities');
|
|
150
|
+
const spec = declared[name];
|
|
151
|
+
if (spec === null || typeof spec !== 'object' || Array.isArray(spec))
|
|
152
|
+
throw modelError('an entity must be an object', docPath);
|
|
153
|
+
const schema = spec.schema;
|
|
154
|
+
if (schema === null || typeof schema !== 'object' || Array.isArray(schema)
|
|
155
|
+
|| schema.properties === null || typeof schema.properties !== 'object')
|
|
156
|
+
throw modelError('an entity needs an object schema with properties',
|
|
157
|
+
`${docPath}/schema`);
|
|
158
|
+
|
|
159
|
+
const anchors = collectSameDocumentAnchors(schema);
|
|
160
|
+
const properties = new Map();
|
|
161
|
+
const keys = [];
|
|
162
|
+
const relations = [];
|
|
163
|
+
for (const propertyName of Object.keys(schema.properties)) {
|
|
164
|
+
const propertyPath = `${docPath}/schema/properties/${propertyName}`;
|
|
165
|
+
const raw = schema.properties[propertyName];
|
|
166
|
+
const effective = effectiveSchema(raw, schema, anchors, propertyPath);
|
|
167
|
+
const entityBlock = normalizeEntityBlock(
|
|
168
|
+
effective['x-entity'] ?? (raw !== null && typeof raw === 'object'
|
|
169
|
+
? raw['x-entity'] : undefined),
|
|
170
|
+
`${propertyPath}/x-entity`);
|
|
171
|
+
|
|
172
|
+
const type = typeof effective.type === 'string'
|
|
173
|
+
? effective.type
|
|
174
|
+
: Array.isArray(effective.type)
|
|
175
|
+
? effective.type.find((t) => t !== 'null')
|
|
176
|
+
: undefined;
|
|
177
|
+
|
|
178
|
+
if (entityBlock.default !== undefined)
|
|
179
|
+
checkDefault(entityBlock.default, `${propertyPath}/x-entity/default`);
|
|
180
|
+
if (entityBlock.column !== undefined
|
|
181
|
+
&& entityBlock.column !== 'integer' && entityBlock.column !== 'json') {
|
|
182
|
+
throw modelError("x-entity.column is 'integer' (epoch date column) or 'json' (stay in the document)",
|
|
183
|
+
`${propertyPath}/x-entity/column`);
|
|
184
|
+
}
|
|
185
|
+
if (entityBlock.column === 'integer'
|
|
186
|
+
&& !(type === 'string' && (effective.format === 'date-time' || effective.format === 'date'))) {
|
|
187
|
+
throw modelError("column: 'integer' applies to date-time/date formatted strings",
|
|
188
|
+
`${propertyPath}/x-entity/column`);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const property = {
|
|
192
|
+
name: propertyName,
|
|
193
|
+
docPath: propertyPath,
|
|
194
|
+
type,
|
|
195
|
+
format: typeof effective.format === 'string' ? effective.format : undefined,
|
|
196
|
+
enum: Array.isArray(effective.enum)
|
|
197
|
+
&& effective.enum.every((v) => v === null || SCALARS.has(typeof v === 'number' ? (Number.isInteger(v) ? 'integer' : 'number') : typeof v))
|
|
198
|
+
? effective.enum : undefined,
|
|
199
|
+
key: entityBlock.key === true,
|
|
200
|
+
unique: entityBlock.unique === true,
|
|
201
|
+
index: entityBlock.index === true,
|
|
202
|
+
version: entityBlock.version === true,
|
|
203
|
+
default: entityBlock.default,
|
|
204
|
+
column: entityBlock.column,
|
|
205
|
+
// COPIED: relation resolution annotates this object (kind,
|
|
206
|
+
// fkEntity, joinTable) and must never write into the caller's
|
|
207
|
+
// model document
|
|
208
|
+
relation: entityBlock.relation === undefined
|
|
209
|
+
? undefined
|
|
210
|
+
: { ...entityBlock.relation },
|
|
211
|
+
};
|
|
212
|
+
if (property.key) {
|
|
213
|
+
if (!SCALARS.has(property.type ?? ''))
|
|
214
|
+
throw modelError('a key property must be a scalar', propertyPath);
|
|
215
|
+
keys.push(propertyName);
|
|
216
|
+
}
|
|
217
|
+
if (property.relation !== undefined) relations.push(property);
|
|
218
|
+
properties.set(propertyName, property);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if (keys.length === 0)
|
|
222
|
+
throw modelError(`entity '${name}' declares no key property`, docPath);
|
|
223
|
+
const autoKeys = keys.filter((k) => properties.get(k).default === 'auto');
|
|
224
|
+
const uuidKeys = keys.filter((k) => properties.get(k).default === 'uuid');
|
|
225
|
+
if (autoKeys.length > 0 && (keys.length > 1
|
|
226
|
+
|| properties.get(autoKeys[0]).type !== 'integer'))
|
|
227
|
+
throw modelError("default: 'auto' needs a single integer key", docPath);
|
|
228
|
+
if (uuidKeys.length > 0 && (keys.length > 1
|
|
229
|
+
|| properties.get(uuidKeys[0]).type !== 'string'))
|
|
230
|
+
throw modelError("default: 'uuid' needs a single string key", docPath);
|
|
231
|
+
// the optimistic-concurrency token (§11.5): one integer, mapped to
|
|
232
|
+
// its own column, never the key
|
|
233
|
+
const versions = [...properties.values()].filter((p) => p.version);
|
|
234
|
+
for (const p of versions) {
|
|
235
|
+
if (p.type !== 'integer' || p.key || p.relation !== undefined
|
|
236
|
+
|| p.column !== undefined) {
|
|
237
|
+
throw modelError('a version property is a plain integer column '
|
|
238
|
+
+ '(not a key, not a relation, not column-mapped)', p.docPath);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
if (versions.length > 1)
|
|
242
|
+
throw modelError(`entity '${name}' declares more than one version property`, docPath);
|
|
243
|
+
|
|
244
|
+
entities.set(name, {
|
|
245
|
+
name,
|
|
246
|
+
docPath,
|
|
247
|
+
schema,
|
|
248
|
+
properties,
|
|
249
|
+
keys,
|
|
250
|
+
relations,
|
|
251
|
+
version: versions.length === 1 ? versions[0].name : null,
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
resolveRelations(entities);
|
|
256
|
+
return entities;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Resolve every relation: shape checks, the foreign-key placement
|
|
261
|
+
* rule, inverse agreement (`JD0031` on contradiction), and join
|
|
262
|
+
* tables for many-to-many.
|
|
263
|
+
* @param {Map<string, any>} entities
|
|
264
|
+
*/
|
|
265
|
+
function resolveRelations(entities) {
|
|
266
|
+
/** @type {{ owner: string, property: any }[]} */
|
|
267
|
+
const declarations = [];
|
|
268
|
+
for (const entity of entities.values()) {
|
|
269
|
+
for (const property of entity.relations)
|
|
270
|
+
declarations.push({ owner: entity.name, property });
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
for (const { owner, property } of declarations) {
|
|
274
|
+
const relation = property.relation;
|
|
275
|
+
const docPath = `${property.docPath}/x-entity/relation`;
|
|
276
|
+
if (typeof relation.to !== 'string' || !entities.has(relation.to)) {
|
|
277
|
+
throw modelError(`relation target '${String(relation.to)}' is not a declared entity`,
|
|
278
|
+
`${docPath}/to`);
|
|
279
|
+
}
|
|
280
|
+
const many = relation.many === true;
|
|
281
|
+
const hasVia = relation.via !== undefined;
|
|
282
|
+
const hasThrough = relation.through !== undefined;
|
|
283
|
+
if (hasVia && hasThrough)
|
|
284
|
+
throw modelError('a relation declares via (a foreign key) or through (a join table), not both', docPath);
|
|
285
|
+
if (!many && hasThrough)
|
|
286
|
+
throw modelError('through is for many-to-many relations', `${docPath}/through`);
|
|
287
|
+
|
|
288
|
+
if (many && !hasVia) {
|
|
289
|
+
// many-to-many: a join table
|
|
290
|
+
relation.kind = 'manyToMany';
|
|
291
|
+
const other = relation.to;
|
|
292
|
+
relation.joinTable = typeof relation.through === 'string'
|
|
293
|
+
? relation.through
|
|
294
|
+
: [owner, other].sort().join('_');
|
|
295
|
+
continue;
|
|
296
|
+
}
|
|
297
|
+
if (!hasVia)
|
|
298
|
+
throw modelError('a foreign-key relation needs via (the FK property name)', docPath);
|
|
299
|
+
if (typeof relation.via !== 'string' || !ENTITY_NAME.test(relation.via))
|
|
300
|
+
throw modelError('via must be an identifier property name', `${docPath}/via`);
|
|
301
|
+
if (!ON_DELETE.has(relation.onDelete)) {
|
|
302
|
+
throw modelError(
|
|
303
|
+
"a foreign-key relation must declare onDelete: 'cascade', 'restrict' or 'setNull' — never defaulted silently",
|
|
304
|
+
`${docPath}/onDelete`);
|
|
305
|
+
}
|
|
306
|
+
// the placement rule: many:true puts the FK on the TARGET entity;
|
|
307
|
+
// one-to-one (no many) puts it on the DECLARING entity
|
|
308
|
+
relation.kind = many ? 'oneToMany' : 'oneToOne';
|
|
309
|
+
relation.fkEntity = many ? relation.to : owner;
|
|
310
|
+
relation.fkTargets = many ? owner : relation.to;
|
|
311
|
+
// a DECLARED via property must be a column-mapped scalar of the
|
|
312
|
+
// referenced key's type (§9.4)
|
|
313
|
+
const holder = entities.get(relation.fkEntity);
|
|
314
|
+
const declaredVia = holder.properties.get(relation.via);
|
|
315
|
+
const targetEntity = entities.get(relation.fkTargets);
|
|
316
|
+
const targetKeyType = targetEntity.properties.get(targetEntity.keys[0]).type;
|
|
317
|
+
if (declaredVia !== undefined) {
|
|
318
|
+
if (declaredVia.type !== targetKeyType || declaredVia.column === 'json') {
|
|
319
|
+
throw modelError(
|
|
320
|
+
`via property '${relation.via}' on '${relation.fkEntity}' must be a `
|
|
321
|
+
+ `column-mapped ${targetKeyType} (the referenced key's type)`,
|
|
322
|
+
`${docPath}/via`);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// inverse agreement: two FK declarations describing the same edge
|
|
328
|
+
// must agree on via/onDelete and be a many/one pairing
|
|
329
|
+
for (let i = 0; i < declarations.length; i++) {
|
|
330
|
+
for (let j = i + 1; j < declarations.length; j++) {
|
|
331
|
+
const a = declarations[i];
|
|
332
|
+
const b = declarations[j];
|
|
333
|
+
const relationA = a.property.relation;
|
|
334
|
+
const relationB = b.property.relation;
|
|
335
|
+
if (relationA.to !== b.owner || relationB.to !== a.owner) continue;
|
|
336
|
+
if (relationA.kind === 'manyToMany' || relationB.kind === 'manyToMany') {
|
|
337
|
+
if (relationA.kind !== relationB.kind) {
|
|
338
|
+
throw new DbCompileError('JD0031',
|
|
339
|
+
`relation '${a.owner}.${a.property.name}' and '${b.owner}.${b.property.name}' `
|
|
340
|
+
+ 'contradict: one is many-to-many, the other is not',
|
|
341
|
+
`${a.property.docPath}/x-entity/relation`);
|
|
342
|
+
}
|
|
343
|
+
if (relationA.joinTable !== relationB.joinTable) {
|
|
344
|
+
throw new DbCompileError('JD0031',
|
|
345
|
+
`relation '${a.owner}.${a.property.name}' and '${b.owner}.${b.property.name}' `
|
|
346
|
+
+ `disagree on the join table ('${relationA.joinTable}' vs '${relationB.joinTable}')`,
|
|
347
|
+
`${a.property.docPath}/x-entity/relation`);
|
|
348
|
+
}
|
|
349
|
+
continue;
|
|
350
|
+
}
|
|
351
|
+
if (relationA.via !== relationB.via) {
|
|
352
|
+
// a mutual one/many pair is presumed ONE edge and must agree
|
|
353
|
+
// on its key; a mutual SAME-kind pair with different vias is
|
|
354
|
+
// two independent edges (how a legitimate cycle is written)
|
|
355
|
+
if (relationA.kind !== relationB.kind) {
|
|
356
|
+
throw new DbCompileError('JD0031',
|
|
357
|
+
`relation '${a.owner}.${a.property.name}' and '${b.owner}.${b.property.name}' `
|
|
358
|
+
+ `disagree on the foreign key ('${relationA.via}' vs '${relationB.via}')`,
|
|
359
|
+
`${a.property.docPath}/x-entity/relation`);
|
|
360
|
+
}
|
|
361
|
+
continue;
|
|
362
|
+
}
|
|
363
|
+
if (relationA.kind === relationB.kind) {
|
|
364
|
+
throw new DbCompileError('JD0031',
|
|
365
|
+
`relation '${a.owner}.${a.property.name}' and '${b.owner}.${b.property.name}' `
|
|
366
|
+
+ 'both claim the same side of the edge (an inverse pair is one many and one one)',
|
|
367
|
+
`${a.property.docPath}/x-entity/relation`);
|
|
368
|
+
}
|
|
369
|
+
if (relationA.fkEntity !== relationB.fkEntity) continue; // different edges
|
|
370
|
+
if (relationA.onDelete !== relationB.onDelete) {
|
|
371
|
+
throw new DbCompileError('JD0031',
|
|
372
|
+
`relation '${a.owner}.${a.property.name}' and '${b.owner}.${b.property.name}' `
|
|
373
|
+
+ `disagree on onDelete ('${relationA.onDelete}' vs '${relationB.onDelete}')`,
|
|
374
|
+
`${a.property.docPath}/x-entity/relation`);
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* The hybrid mapping, derived mechanically from §9.3's table and
|
|
382
|
+
* returned as DATA: per entity, the columns (name, type, source),
|
|
383
|
+
* the checks, the foreign keys, the indexes, and which properties
|
|
384
|
+
* live in the JSONB document.
|
|
385
|
+
* @param {any} model - a model document with `entities`
|
|
386
|
+
* @returns {any}
|
|
387
|
+
*/
|
|
388
|
+
export function explainMapping(model) {
|
|
389
|
+
const entities = normalizeEntities(model);
|
|
390
|
+
/** @type {any} */
|
|
391
|
+
const mapping = { entities: {}, joinTables: {} };
|
|
392
|
+
|
|
393
|
+
for (const entity of entities.values()) {
|
|
394
|
+
const columns = [];
|
|
395
|
+
const document = [];
|
|
396
|
+
const indexes = [];
|
|
397
|
+
for (const property of entity.properties.values()) {
|
|
398
|
+
if (property.relation !== undefined) continue; // no storage of its own
|
|
399
|
+
const isScalar = SCALARS.has(property.type ?? '');
|
|
400
|
+
const epoch = property.column === 'integer';
|
|
401
|
+
if (!isScalar || property.column === 'json') {
|
|
402
|
+
document.push(property.name);
|
|
403
|
+
continue;
|
|
404
|
+
}
|
|
405
|
+
const column = {
|
|
406
|
+
name: property.name,
|
|
407
|
+
storage: epoch ? 'integer' : property.type,
|
|
408
|
+
source: epoch ? 'epoch(document)' : 'document',
|
|
409
|
+
key: property.key,
|
|
410
|
+
};
|
|
411
|
+
if (property.enum !== undefined) column.check = property.enum;
|
|
412
|
+
columns.push(column);
|
|
413
|
+
if (property.unique) indexes.push({ property: property.name, unique: true });
|
|
414
|
+
else if (property.index) indexes.push({ property: property.name, unique: false });
|
|
415
|
+
}
|
|
416
|
+
const foreignKeys = [];
|
|
417
|
+
/** @type {Map<string, any>} */
|
|
418
|
+
const fkByColumn = new Map();
|
|
419
|
+
for (const other of entities.values()) {
|
|
420
|
+
for (const declaring of other.relations) {
|
|
421
|
+
const relation = declaring.relation;
|
|
422
|
+
if (relation.kind === 'manyToMany') continue;
|
|
423
|
+
if (relation.fkEntity !== entity.name) continue;
|
|
424
|
+
const existing = fkByColumn.get(relation.via);
|
|
425
|
+
if (existing !== undefined) {
|
|
426
|
+
// the validated inverse pair (one many, one one) shares ONE
|
|
427
|
+
// physical key; a many side means children share the parent,
|
|
428
|
+
// so the key cannot be unique
|
|
429
|
+
if (existing.references !== relation.fkTargets
|
|
430
|
+
|| existing.onDelete !== relation.onDelete) {
|
|
431
|
+
throw new DbCompileError('JD0031',
|
|
432
|
+
`two relations claim foreign key '${relation.via}' on `
|
|
433
|
+
+ `'${entity.name}' with different targets or on-delete`,
|
|
434
|
+
declaring.docPath);
|
|
435
|
+
}
|
|
436
|
+
existing.unique = existing.unique && relation.kind === 'oneToOne';
|
|
437
|
+
continue;
|
|
438
|
+
}
|
|
439
|
+
const fk = {
|
|
440
|
+
column: relation.via,
|
|
441
|
+
references: relation.fkTargets,
|
|
442
|
+
referencesKey: entities.get(relation.fkTargets).keys[0],
|
|
443
|
+
onDelete: relation.onDelete,
|
|
444
|
+
unique: relation.kind === 'oneToOne',
|
|
445
|
+
};
|
|
446
|
+
fkByColumn.set(relation.via, fk);
|
|
447
|
+
foreignKeys.push(fk);
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
mapping.entities[entity.name] = {
|
|
451
|
+
table: entity.name,
|
|
452
|
+
keys: entity.keys,
|
|
453
|
+
columns,
|
|
454
|
+
foreignKeys,
|
|
455
|
+
indexes,
|
|
456
|
+
document,
|
|
457
|
+
version: entity.version ?? null,
|
|
458
|
+
};
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
const seenJoins = new Set();
|
|
462
|
+
for (const entity of entities.values()) {
|
|
463
|
+
for (const declaring of entity.relations) {
|
|
464
|
+
const relation = declaring.relation;
|
|
465
|
+
if (relation.kind !== 'manyToMany' || seenJoins.has(relation.joinTable)) continue;
|
|
466
|
+
seenJoins.add(relation.joinTable);
|
|
467
|
+
const [a, b] = [entity.name, relation.to].sort();
|
|
468
|
+
mapping.joinTables[relation.joinTable] = {
|
|
469
|
+
left: { entity: a, column: `${a}_key`, referencesKey: entities.get(a).keys[0] },
|
|
470
|
+
right: { entity: b, column: `${b}_key`, referencesKey: entities.get(b).keys[0] },
|
|
471
|
+
onDelete: 'cascade',
|
|
472
|
+
};
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
return mapping;
|
|
476
|
+
}
|
package/src/patch-sql.js
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
/**
|
|
3
|
+
* @file RFC 6902 → dialect JSON-set primitives, so a one-field update
|
|
4
|
+
* does not rewrite a large document. The translation is decided
|
|
5
|
+
* AGAINST THE LIVE DOCUMENT: an RFC 6901 pointer cannot say whether
|
|
6
|
+
* `/a/0` names an array position or an object member called `"0"`, so
|
|
7
|
+
* each segment is discriminated by walking the document the patch was
|
|
8
|
+
* validated against, and the walked state is advanced op by op so a
|
|
9
|
+
* later operation sees what the earlier ones produced.
|
|
10
|
+
*
|
|
11
|
+
* Translatable in 0.1: `replace` anywhere, `add` of an object member,
|
|
12
|
+
* `add` at an array's end (`/-` or the index equal to its length), and
|
|
13
|
+
* `remove`. Everything else — `test`, `move`, `copy`, a mid-array
|
|
14
|
+
* insert (the shift has no single JSON-function spelling) — returns
|
|
15
|
+
* `null` and the store falls back to a whole-document write. The
|
|
16
|
+
* fallback is counted and exposed by the store, measured rather than
|
|
17
|
+
* assumed.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { applyJSONPatch } from '@jarenjs/json/patch';
|
|
21
|
+
import { parseJSONPointer } from '@jarenjs/json/pointer';
|
|
22
|
+
|
|
23
|
+
/** @typedef {import('./dialect.js').JsonPathSegment} JsonPathSegment */
|
|
24
|
+
|
|
25
|
+
const ARRAY_INDEX = /^(?:0|[1-9][0-9]*)$/;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Discriminate one pointer against the live document into typed
|
|
29
|
+
* segments, stopping before the final segment.
|
|
30
|
+
* @param {any} doc
|
|
31
|
+
* @param {string[]} names - parsed pointer segments
|
|
32
|
+
* @returns {{ segments: JsonPathSegment[], parent: any } | null}
|
|
33
|
+
*/
|
|
34
|
+
function walkParent(doc, names) {
|
|
35
|
+
/** @type {JsonPathSegment[]} */
|
|
36
|
+
const segments = [];
|
|
37
|
+
let node = doc;
|
|
38
|
+
for (let i = 0; i < names.length - 1; i++) {
|
|
39
|
+
const name = names[i];
|
|
40
|
+
if (Array.isArray(node)) {
|
|
41
|
+
if (!ARRAY_INDEX.test(name)) return null;
|
|
42
|
+
const index = Number(name);
|
|
43
|
+
segments.push({ index });
|
|
44
|
+
node = node[index];
|
|
45
|
+
}
|
|
46
|
+
else if (node !== null && typeof node === 'object') {
|
|
47
|
+
segments.push({ name });
|
|
48
|
+
node = node[name];
|
|
49
|
+
}
|
|
50
|
+
else {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return { segments, parent: node };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Translate one operation against the current document state.
|
|
59
|
+
* @param {any} op
|
|
60
|
+
* @param {any} doc
|
|
61
|
+
* @returns {{ kind: 'set' | 'remove', segments: JsonPathSegment[],
|
|
62
|
+
* value?: any } | { kind: 'append', segments: JsonPathSegment[],
|
|
63
|
+
* value: any } | null}
|
|
64
|
+
*/
|
|
65
|
+
function translateOp(op, doc) {
|
|
66
|
+
if (op === null || typeof op !== 'object' || typeof op.path !== 'string')
|
|
67
|
+
return null;
|
|
68
|
+
if (op.op !== 'replace' && op.op !== 'add' && op.op !== 'remove') return null;
|
|
69
|
+
let names;
|
|
70
|
+
try {
|
|
71
|
+
names = parseJSONPointer(op.path);
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
// a root write replaces the whole document — that IS the fallback
|
|
77
|
+
if (names.length === 0) return null;
|
|
78
|
+
const walked = walkParent(doc, names);
|
|
79
|
+
if (walked === null) return null;
|
|
80
|
+
const { segments, parent } = walked;
|
|
81
|
+
const last = names[names.length - 1];
|
|
82
|
+
|
|
83
|
+
if (Array.isArray(parent)) {
|
|
84
|
+
if (op.op === 'add') {
|
|
85
|
+
if (last === '-' || (ARRAY_INDEX.test(last) && Number(last) === parent.length))
|
|
86
|
+
return { kind: 'append', segments, value: op.value };
|
|
87
|
+
return null; // a mid-array insert shifts neighbours: whole-document
|
|
88
|
+
}
|
|
89
|
+
if (!ARRAY_INDEX.test(last)) return null;
|
|
90
|
+
const indexed = [...segments, { index: Number(last) }];
|
|
91
|
+
return op.op === 'remove'
|
|
92
|
+
? { kind: 'remove', segments: indexed }
|
|
93
|
+
: { kind: 'set', segments: indexed, value: op.value };
|
|
94
|
+
}
|
|
95
|
+
if (parent !== null && typeof parent === 'object') {
|
|
96
|
+
const named = [...segments, { name: last }];
|
|
97
|
+
return op.op === 'remove'
|
|
98
|
+
? { kind: 'remove', segments: named }
|
|
99
|
+
: { kind: 'set', segments: named, value: op.value };
|
|
100
|
+
}
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Translate a whole patch into a dialect expression builder, or `null`
|
|
106
|
+
* when any operation needs the whole-document fallback. The caller has
|
|
107
|
+
* already applied the patch in memory (the copy-on-write engine
|
|
108
|
+
* validates the RESULT); this translation only decides how the same
|
|
109
|
+
* outcome reaches the database.
|
|
110
|
+
* @param {any[]} ops - RFC 6902 operations, already known applicable
|
|
111
|
+
* @param {any} doc - The stored document the patch applies to
|
|
112
|
+
* @param {any} dialect
|
|
113
|
+
* @returns {{ build: (docColumnSql: string,
|
|
114
|
+
* parameterIndexBase: number) => { expression: string,
|
|
115
|
+
* params: string[] } } | null}
|
|
116
|
+
*/
|
|
117
|
+
export function translatePatch(ops, doc, dialect) {
|
|
118
|
+
/** @type {{ kind: string, pathText: string, value?: any }[]} */
|
|
119
|
+
const steps = [];
|
|
120
|
+
let current = doc;
|
|
121
|
+
for (const op of ops) {
|
|
122
|
+
const translated = translateOp(op, current);
|
|
123
|
+
if (translated === null) return null;
|
|
124
|
+
const pathText = dialect.jsonPathText(translated.segments);
|
|
125
|
+
if (pathText === null) return null;
|
|
126
|
+
steps.push({ kind: translated.kind, pathText, value: translated.value });
|
|
127
|
+
// advance the discrimination state past this op
|
|
128
|
+
current = applyJSONPatch(current, [op]);
|
|
129
|
+
}
|
|
130
|
+
return {
|
|
131
|
+
build(docColumnSql, parameterIndexBase) {
|
|
132
|
+
let expression = docColumnSql;
|
|
133
|
+
/** @type {string[]} */
|
|
134
|
+
const params = [];
|
|
135
|
+
for (const step of steps) {
|
|
136
|
+
if (step.kind === 'remove') {
|
|
137
|
+
expression = dialect.jsonRemove(expression, step.pathText);
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
const ref = dialect.jsonEncode(
|
|
141
|
+
dialect.parameterRef(parameterIndexBase + params.length, 'value'));
|
|
142
|
+
params.push(JSON.stringify(step.value));
|
|
143
|
+
expression = step.kind === 'append'
|
|
144
|
+
? dialect.jsonAppend(expression, step.pathText, ref)
|
|
145
|
+
: dialect.jsonSet(expression, step.pathText, ref);
|
|
146
|
+
}
|
|
147
|
+
return { expression, params };
|
|
148
|
+
},
|
|
149
|
+
};
|
|
150
|
+
}
|