@jarenjs/db 0.34.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/ARCHITECTURE.md +397 -0
  2. package/README.md +218 -0
  3. package/dist/types/algebra.d.ts +133 -0
  4. package/dist/types/app.d.ts +49 -0
  5. package/dist/types/capture.d.ts +85 -0
  6. package/dist/types/cli.d.ts +2 -0
  7. package/dist/types/dag-job.d.ts +40 -0
  8. package/dist/types/ddl.d.ts +170 -0
  9. package/dist/types/dialect.d.ts +130 -0
  10. package/dist/types/dialects/sqlite.d.ts +9 -0
  11. package/dist/types/driver.d.ts +128 -0
  12. package/dist/types/drivers/bun.d.ts +47 -0
  13. package/dist/types/drivers/node.d.ts +37 -0
  14. package/dist/types/drivers/wasm.d.ts +65 -0
  15. package/dist/types/emit-model.d.ts +44 -0
  16. package/dist/types/emit.d.ts +72 -0
  17. package/dist/types/entity.d.ts +23 -0
  18. package/dist/types/errors.d.ts +165 -0
  19. package/dist/types/graph.d.ts +28 -0
  20. package/dist/types/index.d.ts +35 -0
  21. package/dist/types/jobs.d.ts +134 -0
  22. package/dist/types/live.d.ts +62 -0
  23. package/dist/types/migrate.d.ts +163 -0
  24. package/dist/types/model.d.ts +36 -0
  25. package/dist/types/patch-sql.d.ts +37 -0
  26. package/dist/types/plan.d.ts +119 -0
  27. package/dist/types/profile.d.ts +80 -0
  28. package/dist/types/query.d.ts +100 -0
  29. package/dist/types/residual.d.ts +50 -0
  30. package/dist/types/store.d.ts +53 -0
  31. package/dist/types/tracker.d.ts +43 -0
  32. package/dist/types/typed.d.ts +15 -0
  33. package/dist/types/types.d.ts +26 -0
  34. package/dist/types/udf.d.ts +70 -0
  35. package/dist/types/window.d.ts +52 -0
  36. package/docs/JOBS-FORMAT.md +218 -0
  37. package/docs/LIVE-FORMAT.md +348 -0
  38. package/docs/MIGRATION-FORMAT.md +302 -0
  39. package/docs/MODEL-FORMAT.md +928 -0
  40. package/package.json +81 -0
  41. package/schemas/jaren-migration.draft-07.schema.json +144 -0
  42. package/schemas/jaren-migration.schema.json +144 -0
  43. package/schemas/jaren-model.draft-07.schema.json +149 -0
  44. package/schemas/jaren-model.schema.json +149 -0
  45. package/src/algebra.js +105 -0
  46. package/src/app.js +108 -0
  47. package/src/capture.js +584 -0
  48. package/src/cli.js +264 -0
  49. package/src/dag-job.js +86 -0
  50. package/src/ddl.js +588 -0
  51. package/src/dialect.js +297 -0
  52. package/src/dialects/sqlite.js +175 -0
  53. package/src/driver.js +419 -0
  54. package/src/drivers/bun.js +101 -0
  55. package/src/drivers/node.js +93 -0
  56. package/src/drivers/wasm.js +178 -0
  57. package/src/emit-model.js +208 -0
  58. package/src/emit.js +393 -0
  59. package/src/entity.js +367 -0
  60. package/src/errors.js +173 -0
  61. package/src/graph.js +101 -0
  62. package/src/index.js +64 -0
  63. package/src/jobs.js +507 -0
  64. package/src/live.js +899 -0
  65. package/src/migrate.js +1411 -0
  66. package/src/model.js +476 -0
  67. package/src/patch-sql.js +150 -0
  68. package/src/plan.js +1038 -0
  69. package/src/profile.js +131 -0
  70. package/src/query.js +1010 -0
  71. package/src/residual.js +91 -0
  72. package/src/store.js +1422 -0
  73. package/src/tracker.js +776 -0
  74. package/src/typed.js +19 -0
  75. package/src/types.js +36 -0
  76. package/src/udf.js +132 -0
  77. package/src/window.js +125 -0
  78. package/types/app.d.ts +36 -0
  79. package/types/bun.d.ts +9 -0
  80. package/types/index.d.ts +592 -0
  81. package/types/node.d.ts +15 -0
  82. package/types/typed.d.ts +108 -0
  83. package/types/wasm.d.ts +5 -0
package/src/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
+ }
@@ -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
+ }