@jarenjs/db 0.46.5 → 0.56.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 (72) hide show
  1. package/ARCHITECTURE.md +133 -17
  2. package/README.md +270 -36
  3. package/docs/JOBS-FORMAT.md +24 -8
  4. package/docs/LIVE-FORMAT.md +139 -7
  5. package/docs/MIGRATION-FORMAT.md +118 -36
  6. package/docs/MODEL-FORMAT.md +251 -30
  7. package/package.json +4 -5
  8. package/schemas/jaren-migration.draft-07.schema.json +73 -0
  9. package/schemas/jaren-migration.schema.json +73 -0
  10. package/src/algebra.js +22 -3
  11. package/src/capture.js +66 -28
  12. package/src/cli.js +225 -44
  13. package/src/ddl.js +23 -3
  14. package/src/dialect.js +13 -0
  15. package/src/dialects/sqlite.js +21 -1
  16. package/src/driver.js +63 -16
  17. package/src/drivers/wasm.js +1 -0
  18. package/src/emit-model.js +14 -0
  19. package/src/emit.js +42 -9
  20. package/src/entity.js +92 -47
  21. package/src/errors.js +28 -0
  22. package/src/index.js +2 -2
  23. package/src/jobs.js +40 -5
  24. package/src/live-time.js +605 -0
  25. package/src/live.js +52 -9
  26. package/src/migrate.js +397 -191
  27. package/src/model.js +173 -8
  28. package/src/plan.js +834 -47
  29. package/src/query.js +296 -22
  30. package/src/residual.js +15 -6
  31. package/src/series.js +349 -0
  32. package/src/store.js +243 -69
  33. package/src/tracker.js +173 -48
  34. package/types/index.d.ts +206 -12
  35. package/types/node.d.ts +3 -1
  36. package/types/typed.d.ts +58 -2
  37. package/types/wasm.d.ts +7 -0
  38. package/dist/types/algebra.d.ts +0 -199
  39. package/dist/types/app.d.ts +0 -49
  40. package/dist/types/capture.d.ts +0 -85
  41. package/dist/types/cli.d.ts +0 -2
  42. package/dist/types/dag-job.d.ts +0 -40
  43. package/dist/types/ddl.d.ts +0 -229
  44. package/dist/types/derive.d.ts +0 -250
  45. package/dist/types/dialect.d.ts +0 -149
  46. package/dist/types/dialects/sqlite.d.ts +0 -9
  47. package/dist/types/driver.d.ts +0 -110
  48. package/dist/types/drivers/bun.d.ts +0 -47
  49. package/dist/types/drivers/node.d.ts +0 -37
  50. package/dist/types/drivers/wasm.d.ts +0 -65
  51. package/dist/types/emit-model.d.ts +0 -44
  52. package/dist/types/emit.d.ts +0 -75
  53. package/dist/types/entity.d.ts +0 -23
  54. package/dist/types/errors.d.ts +0 -167
  55. package/dist/types/graph.d.ts +0 -28
  56. package/dist/types/index.d.ts +0 -37
  57. package/dist/types/jobs.d.ts +0 -140
  58. package/dist/types/knn.d.ts +0 -69
  59. package/dist/types/live.d.ts +0 -62
  60. package/dist/types/migrate.d.ts +0 -170
  61. package/dist/types/model.d.ts +0 -36
  62. package/dist/types/patch-sql.d.ts +0 -37
  63. package/dist/types/plan.d.ts +0 -140
  64. package/dist/types/profile.d.ts +0 -80
  65. package/dist/types/query.d.ts +0 -111
  66. package/dist/types/residual.d.ts +0 -61
  67. package/dist/types/store.d.ts +0 -53
  68. package/dist/types/tracker.d.ts +0 -43
  69. package/dist/types/typed.d.ts +0 -15
  70. package/dist/types/types.d.ts +0 -26
  71. package/dist/types/udf.d.ts +0 -75
  72. package/dist/types/window.d.ts +0 -52
package/src/model.js CHANGED
@@ -24,6 +24,7 @@
24
24
  import {
25
25
  collectSameDocumentAnchors, resolveSameDocumentRef,
26
26
  } from '@jarenjs/validate/normalize';
27
+ import { compileJsonQuery } from '@jarenjs/json/query';
27
28
 
28
29
  import { DbCompileError } from './errors.js';
29
30
 
@@ -123,12 +124,65 @@ function checkDefault(declared, docPath) {
123
124
  || declared === 'auto') return;
124
125
  if (declared !== null && typeof declared === 'object' && !Array.isArray(declared)
125
126
  && (Object.hasOwn(declared, 'value') !== Object.hasOwn(declared, 'query'))
126
- && Object.keys(declared).length === 1) return;
127
+ && Object.keys(declared).length === 1) {
128
+ if (Object.hasOwn(declared, 'query')) {
129
+ // compiled where the model is checked, not at the first
130
+ // `store.entity()` call — a default that cannot compile is a model
131
+ // defect, and the engine's reason travels with the model position
132
+ try {
133
+ compileJsonQuery(declared.query);
134
+ }
135
+ catch (cause) {
136
+ throw new DbCompileError('JD0005',
137
+ `the { query } default does not compile: ${/** @type {Error} */ (cause).message}`,
138
+ docPath, /** @type {Error} */ (cause));
139
+ }
140
+ }
141
+ return;
142
+ }
127
143
  throw modelError(
128
144
  "a default is 'now', 'updated', 'uuid', 'auto', { value: … } or { query: … }",
129
145
  docPath);
130
146
  }
131
147
 
148
+ /** The schema positions whose `x-entity` block the one-level walk READS:
149
+ * a top-level property, an `allOf` branch of one (shallow-merged), and
150
+ * a `$defs`/anchor target (a property's `$ref` resolves there). */
151
+ const READ_BLOCK_KEYS = new Set(['allOf', '$defs', 'definitions']);
152
+
153
+ /**
154
+ * Find an `x-entity` block the entity walk would never read — nested
155
+ * inside a property's `properties`, `items`, `anyOf`, … — so it fails
156
+ * the model instead of being ignored. §9.2's promise: a silently
157
+ * ignored mapping directive is a data-loss bug, so the vocabulary is
158
+ * closed in POSITION as well as in name.
159
+ * @param {any} node
160
+ * @param {string} path
161
+ * @param {boolean} read - whether a block AT this node is read
162
+ * @returns {string | null} the docPath of an unread block
163
+ */
164
+ function unreadEntityBlock(node, path, read) {
165
+ if (node === null || typeof node !== 'object') return null;
166
+ if (Array.isArray(node)) {
167
+ for (let i = 0; i < node.length; i++) {
168
+ const found = unreadEntityBlock(node[i], `${path}/${i}`, read);
169
+ if (found !== null) return found;
170
+ }
171
+ return null;
172
+ }
173
+ for (const key of Object.keys(node)) {
174
+ if (key === 'x-entity') {
175
+ if (!read) return `${path}/x-entity`;
176
+ continue;
177
+ }
178
+ // a block one level under a read position is read only through
179
+ // `allOf`/`$defs`; under anything else it is out of the walk
180
+ const found = unreadEntityBlock(node[key], `${path}/${key}`, read && READ_BLOCK_KEYS.has(key));
181
+ if (found !== null) return found;
182
+ }
183
+ return null;
184
+ }
185
+
132
186
  /**
133
187
  * Normalize the `entities` member of a model document.
134
188
  * @param {any} model
@@ -169,14 +223,54 @@ export function normalizeEntities(model) {
169
223
  ? raw['x-entity'] : undefined),
170
224
  `${propertyPath}/x-entity`);
171
225
 
172
- const type = typeof effective.type === 'string'
173
- ? effective.type
174
- : Array.isArray(effective.type)
175
- ? effective.type.find((t) => t !== 'null')
176
- : undefined;
226
+ // a nullable scalar (`['string', 'null']`) is that scalar; a union
227
+ // of two or more scalar types has no one column type and lives in
228
+ // the document (§9.3) — mapping it by its FIRST member stored `5`
229
+ // as `"5.0"` in a text column
230
+ const scalarMembers = Array.isArray(effective.type)
231
+ ? effective.type.filter((t) => t !== 'null')
232
+ : [effective.type];
233
+ const type = scalarMembers.length === 1 && typeof scalarMembers[0] === 'string'
234
+ ? scalarMembers[0]
235
+ : undefined;
236
+ const union = scalarMembers.length > 1;
177
237
 
178
238
  if (entityBlock.default !== undefined)
179
239
  checkDefault(entityBlock.default, `${propertyPath}/x-entity/default`);
240
+ if (entityBlock.default !== undefined && entityBlock.relation !== undefined) {
241
+ throw modelError('a relation member takes no default — it is a projection, not stored state',
242
+ `${propertyPath}/x-entity/default`);
243
+ }
244
+ if (entityBlock.default === 'auto' && entityBlock.key !== true) {
245
+ throw modelError("default: 'auto' is allocated by the database for a single integer key only",
246
+ `${propertyPath}/x-entity/default`);
247
+ }
248
+ // the mapping directives that need a column of their own: on a
249
+ // property the document keeps — `column: 'json'`, a non-scalar, a
250
+ // union — they were accepted and never applied (a key with no key
251
+ // column let duplicates in and broke every read)
252
+ const columnMapped = SCALARS.has(type ?? '') && entityBlock.column !== 'json';
253
+ const needsColumn = ['key', 'unique', 'index'].filter((member) => entityBlock[member] === true);
254
+ if (!columnMapped && needsColumn.length > 0) {
255
+ const why = entityBlock.column === 'json'
256
+ ? "column: 'json' keeps the property in the document"
257
+ : union ? 'a union of scalar types lives in the document'
258
+ : 'only a top-level scalar takes a column';
259
+ throw modelError(entityBlock.key === true
260
+ ? `a key property must be a scalar with a column of its own — ${why}`
261
+ : `'${propertyName}' declares ${needsColumn.join('/')} but has no column — ${why}`,
262
+ propertyPath);
263
+ }
264
+ for (const key of Object.keys(raw !== null && typeof raw === 'object' ? raw : {})) {
265
+ if (key === 'x-entity') continue;
266
+ const unread = unreadEntityBlock(raw[key], `${propertyPath}/${key}`, READ_BLOCK_KEYS.has(key));
267
+ if (unread !== null) {
268
+ throw new DbCompileError('JD0030',
269
+ 'x-entity applies to an entity\'s top-level properties (and their allOf/$ref '
270
+ + 'targets) only — a nested block is never read, so it is refused rather than ignored',
271
+ unread);
272
+ }
273
+ }
180
274
  if (entityBlock.column !== undefined
181
275
  && entityBlock.column !== 'integer' && entityBlock.column !== 'json') {
182
276
  throw modelError("x-entity.column is 'integer' (epoch date column) or 'json' (stay in the document)",
@@ -289,6 +383,16 @@ function resolveRelations(entities) {
289
383
  // many-to-many: a join table
290
384
  relation.kind = 'manyToMany';
291
385
  const other = relation.to;
386
+ if (other === owner) {
387
+ throw modelError('a many-to-many relation to the entity itself is not supported — '
388
+ + 'both endpoint columns would carry the same name', docPath);
389
+ }
390
+ for (const endpoint of [owner, other]) {
391
+ if (entities.get(endpoint).keys.length !== 1) {
392
+ throw modelError(`a many-to-many relation needs single-key endpoints; '${endpoint}' `
393
+ + 'declares a composite key', docPath);
394
+ }
395
+ }
292
396
  relation.joinTable = typeof relation.through === 'string'
293
397
  ? relation.through
294
398
  : [owner, other].sort().join('_');
@@ -313,6 +417,13 @@ function resolveRelations(entities) {
313
417
  const holder = entities.get(relation.fkEntity);
314
418
  const declaredVia = holder.properties.get(relation.via);
315
419
  const targetEntity = entities.get(relation.fkTargets);
420
+ if (targetEntity.keys.length !== 1) {
421
+ // a foreign key references ONE column; a composite-key target
422
+ // rendered a reference to its first key alone, which SQLite
423
+ // refused at the first write with a raw "foreign key mismatch"
424
+ throw modelError(`a foreign-key relation must reference a single-key entity; `
425
+ + `'${relation.fkTargets}' declares a composite key`, docPath);
426
+ }
316
427
  const targetKeyType = targetEntity.properties.get(targetEntity.keys[0]).type;
317
428
  if (declaredVia !== undefined) {
318
429
  if (declaredVia.type !== targetKeyType || declaredVia.column === 'json') {
@@ -355,7 +466,8 @@ function resolveRelations(entities) {
355
466
  if (relationA.kind !== relationB.kind) {
356
467
  throw new DbCompileError('JD0031',
357
468
  `relation '${a.owner}.${a.property.name}' and '${b.owner}.${b.property.name}' `
358
- + `disagree on the foreign key ('${relationA.via}' vs '${relationB.via}')`,
469
+ + `disagree on the foreign key ('${relationA.via}' vs '${relationB.via}') `
470
+ + '— two edges between the same pair declare their inverse on one side only (§9.4)',
359
471
  `${a.property.docPath}/x-entity/relation`);
360
472
  }
361
473
  continue;
@@ -377,6 +489,53 @@ function resolveRelations(entities) {
377
489
  }
378
490
  }
379
491
 
492
+ /**
493
+ * The relation table a producer may read (§10.1): per entity, one
494
+ * frozen record per declared relation member — `to`, `kind`, and for a
495
+ * foreign-key relation `via`, `fkEntity`, `fkTargets` and `targetKey`
496
+ * (the key property the foreign key references on `fkTargets`, which
497
+ * is what a hop's equality compares `via` against); for a many-to-many
498
+ * `joinTable` and the target's `targetKey`. Plain data, keyed by
499
+ * entity name and then by member name, so a query producer can lower
500
+ * a relation hop to the phrases the translator runs without a second
501
+ * vocabulary and without importing this package.
502
+ * @param {Map<string, any>} entities - normalized entities
503
+ * @returns {Readonly<Record<string, Readonly<Record<string, any>>>>}
504
+ */
505
+ export function relationTables(entities) {
506
+ /** @type {Record<string, any>} */
507
+ const tables = {};
508
+ for (const entity of entities.values()) {
509
+ /** @type {Record<string, any>} */
510
+ const table = {};
511
+ for (const property of entity.relations) {
512
+ const relation = property.relation;
513
+ const entry = relation.kind === 'manyToMany'
514
+ ? {
515
+ to: relation.to,
516
+ kind: relation.kind,
517
+ joinTable: relation.joinTable,
518
+ targetKey: entities.get(relation.to).keys[0],
519
+ }
520
+ : {
521
+ to: relation.to,
522
+ kind: relation.kind,
523
+ via: relation.via,
524
+ fkEntity: relation.fkEntity,
525
+ fkTargets: relation.fkTargets,
526
+ targetKey: entities.get(relation.fkTargets).keys[0],
527
+ };
528
+ // an own member, whatever the property is named: `__proto__` as
529
+ // a member name would otherwise rewrite the record's prototype
530
+ Object.defineProperty(table, property.name,
531
+ { value: Object.freeze(entry), writable: true, enumerable: true, configurable: true });
532
+ }
533
+ Object.defineProperty(tables, entity.name,
534
+ { value: Object.freeze(table), writable: true, enumerable: true, configurable: true });
535
+ }
536
+ return Object.freeze(tables);
537
+ }
538
+
380
539
  /**
381
540
  * The hybrid mapping, derived mechanically from §9.3's table and
382
541
  * returned as DATA: per entity, the columns (name, type, source),
@@ -408,7 +567,13 @@ export function explainMapping(model) {
408
567
  source: epoch ? 'epoch(document)' : 'document',
409
568
  key: property.key,
410
569
  };
411
- if (property.enum !== undefined) column.check = property.enum;
570
+ if (property.enum !== undefined) {
571
+ // `null` never fails a CHECK (NULL IN (…) is unknown, which
572
+ // passes), but rendered into the list it made the whole CHECK
573
+ // unknown for EVERY value — a nullable enum accepted anything
574
+ const values = property.enum.filter((value) => value !== null);
575
+ if (values.length > 0) column.check = values;
576
+ }
412
577
  columns.push(column);
413
578
  if (property.unique) indexes.push({ property: property.name, unique: true });
414
579
  else if (property.index) indexes.push({ property: property.name, unique: false });