@jarenjs/db 0.49.2 → 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 (70) hide show
  1. package/ARCHITECTURE.md +27 -15
  2. package/README.md +141 -41
  3. package/docs/JOBS-FORMAT.md +24 -8
  4. package/docs/LIVE-FORMAT.md +38 -9
  5. package/docs/MIGRATION-FORMAT.md +118 -36
  6. package/docs/MODEL-FORMAT.md +232 -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/capture.js +66 -28
  11. package/src/cli.js +225 -44
  12. package/src/ddl.js +23 -3
  13. package/src/dialects/sqlite.js +2 -1
  14. package/src/driver.js +63 -16
  15. package/src/drivers/wasm.js +1 -0
  16. package/src/emit-model.js +14 -0
  17. package/src/emit.js +10 -3
  18. package/src/entity.js +92 -47
  19. package/src/errors.js +25 -0
  20. package/src/index.js +2 -2
  21. package/src/jobs.js +40 -5
  22. package/src/live-time.js +12 -3
  23. package/src/live.js +11 -1
  24. package/src/migrate.js +397 -191
  25. package/src/model.js +173 -8
  26. package/src/plan.js +135 -38
  27. package/src/query.js +138 -13
  28. package/src/store.js +221 -66
  29. package/src/tracker.js +173 -48
  30. package/types/index.d.ts +152 -10
  31. package/types/node.d.ts +3 -1
  32. package/types/typed.d.ts +58 -2
  33. package/types/wasm.d.ts +7 -0
  34. package/dist/types/algebra.d.ts +0 -230
  35. package/dist/types/app.d.ts +0 -49
  36. package/dist/types/capture.d.ts +0 -85
  37. package/dist/types/cli.d.ts +0 -2
  38. package/dist/types/dag-job.d.ts +0 -40
  39. package/dist/types/ddl.d.ts +0 -229
  40. package/dist/types/derive.d.ts +0 -250
  41. package/dist/types/dialect.d.ts +0 -154
  42. package/dist/types/dialects/sqlite.d.ts +0 -9
  43. package/dist/types/driver.d.ts +0 -110
  44. package/dist/types/drivers/bun.d.ts +0 -47
  45. package/dist/types/drivers/node.d.ts +0 -37
  46. package/dist/types/drivers/wasm.d.ts +0 -65
  47. package/dist/types/emit-model.d.ts +0 -44
  48. package/dist/types/emit.d.ts +0 -75
  49. package/dist/types/entity.d.ts +0 -23
  50. package/dist/types/errors.d.ts +0 -170
  51. package/dist/types/graph.d.ts +0 -28
  52. package/dist/types/index.d.ts +0 -37
  53. package/dist/types/jobs.d.ts +0 -140
  54. package/dist/types/knn.d.ts +0 -69
  55. package/dist/types/live-time.d.ts +0 -141
  56. package/dist/types/live.d.ts +0 -64
  57. package/dist/types/migrate.d.ts +0 -170
  58. package/dist/types/model.d.ts +0 -36
  59. package/dist/types/patch-sql.d.ts +0 -37
  60. package/dist/types/plan.d.ts +0 -142
  61. package/dist/types/profile.d.ts +0 -80
  62. package/dist/types/query.d.ts +0 -112
  63. package/dist/types/residual.d.ts +0 -64
  64. package/dist/types/series.d.ts +0 -227
  65. package/dist/types/store.d.ts +0 -60
  66. package/dist/types/tracker.d.ts +0 -43
  67. package/dist/types/typed.d.ts +0 -15
  68. package/dist/types/types.d.ts +0 -26
  69. package/dist/types/udf.d.ts +0 -75
  70. package/dist/types/window.d.ts +0 -52
package/src/query.js CHANGED
@@ -39,7 +39,7 @@ import { chain } from './driver.js';
39
39
  import {
40
40
  planQuery, planEntityQuery, entityShape, planEntityPredicate, entityPathRef,
41
41
  } from './plan.js';
42
- import { emitPlan, emitEntityPlan, createEntityPredicateEmitters } from './emit.js';
42
+ import { emitPlan, emitEntityPlan, createEntityPredicateEmitters, UnrepresentablePath } from './emit.js';
43
43
  import { selectPlan } from './algebra.js';
44
44
  import { compileSetResidual, compileRowResidual, sequenceResult } from './residual.js';
45
45
  import { derivedSlotValue, probeBox, probeVector, columnScore } from './derive.js';
@@ -77,6 +77,33 @@ export function createQueryState(bound = undefined, operators = null,
77
77
  };
78
78
  }
79
79
 
80
+ /**
81
+ * The answer for a native selection's items: the engine's result shape
82
+ * (`undefined | item | items`), or — when the document is a chain's
83
+ * element WINDOW, `[<phrase>]` (plan.js, `wrapped`) — the items as the
84
+ * ONE array that constructor yields, never singleton-unwrapped: an empty
85
+ * selection is `[]`, one row is `[row]`. Exactly the engine's answer for
86
+ * the same document, which is what lets a chain's `toArray()` push.
87
+ * @param {any} entry
88
+ * @param {any[]} items
89
+ * @returns {any}
90
+ */
91
+ function answerOf(entry, items) {
92
+ return entry.planned.wrapped === true ? items : sequenceResult(items);
93
+ }
94
+
95
+ /**
96
+ * The same for one aggregate value: `[value]` under the window, and `[]`
97
+ * for an aggregate that answers nothing.
98
+ * @param {any} entry
99
+ * @param {any} value
100
+ * @returns {any}
101
+ */
102
+ function wrapValue(entry, value) {
103
+ if (entry.planned.wrapped !== true) return value;
104
+ return value === undefined ? [] : [value];
105
+ }
106
+
80
107
  /** @param {any} value - a bindable native parameter? */
81
108
  function bindable(value) {
82
109
  return typeof value === 'string'
@@ -299,8 +326,26 @@ export function createQueryEngine(context) {
299
326
  return out;
300
327
  };
301
328
 
302
- const plan = shapePlan(planned.plan ?? selectPlan(collection.name));
303
- const emitted = emitPlan(plan, dialect, physical);
329
+ let plan = shapePlan(planned.plan ?? selectPlan(collection.name));
330
+ let emitted;
331
+ try {
332
+ emitted = emitPlan(plan, dialect, physical);
333
+ }
334
+ catch (error) {
335
+ if (!(error instanceof UnrepresentablePath)) throw error;
336
+ // a member name the dialect cannot spell: the whole document runs
337
+ // in the set residual, named — and strict mode refuses it by name
338
+ if (strict) {
339
+ throw new DbCompileError('JD0010',
340
+ `strict mode refused a residual: 'path' — ${error.message}`, collection.docPath);
341
+ }
342
+ planned = {
343
+ ...planned, plan: null, mode: 'set', rowReturn: null, udfs: [], prefilters: [],
344
+ series: null, reasons: [{ construct: 'path', reason: error.message }, ...planned.reasons],
345
+ };
346
+ plan = shapePlan(selectPlan(collection.name));
347
+ emitted = emitPlan(plan, dialect, physical);
348
+ }
304
349
  const externalNames = planned.analysis.externals.map((e) => e.name);
305
350
  const limits = profile === null ? undefined : profile.limits;
306
351
  const entry = {
@@ -549,6 +594,12 @@ export function createQueryEngine(context) {
549
594
  */
550
595
  const candidatesOf = (entry, externals, diverted) => {
551
596
  if (diverted) {
597
+ // a diversion IS a full-table scan; the plan-shape check above
598
+ // only ever saw the native statement
599
+ if (entry.needsScanCheck) {
600
+ throw profileRefusal(`the profile refuses a full-table scan of '${collection.name}' `
601
+ + '(a bound external the database cannot take diverted the call to the whole collection)');
602
+ }
552
603
  if (entry.planned.mode === 'knn') knnStats.diverted++;
553
604
  return chain(fullScanOf(entry), (statement) =>
554
605
  chain(statement.all(fullScanParams(entry)), (rows) =>
@@ -612,26 +663,26 @@ export function createQueryEngine(context) {
612
663
  const items = [];
613
664
  for (const row of checkRowBound(entry, rows))
614
665
  items.push(...entry.rowResidual(JSON.parse(row.doc), externals));
615
- return sequenceResult(items);
666
+ return answerOf(entry, items);
616
667
  }));
617
668
  }
618
669
  return chain(statementOf(entry), (statement) => {
619
670
  if (entry.plan.aggregate !== null) {
620
671
  return chain(statement.get(bindParams(entry, externals)),
621
- (row) => aggregateResult(entry, row));
672
+ (row) => wrapValue(entry, aggregateResult(entry, row)));
622
673
  }
623
674
  if (entry.plan.bucket !== null) {
624
675
  return chain(statement.all(bindParams(entry, externals)), (rows) => {
625
676
  const items = bucketItems(entry, checkRowBound(entry, rows));
626
677
  if (items === null) return divertBucket(entry, document, externals);
627
678
  countSeries(entry, 1, rows.length, items.length);
628
- return sequenceResult(items);
679
+ return answerOf(entry, items);
629
680
  });
630
681
  }
631
682
  return chain(statement.all(bindParams(entry, externals)), (rows) => {
632
683
  const docs = rowsToDocs(checkRowBound(entry, rows));
633
684
  countSeries(entry, 1, docs.length, docs.length);
634
- return sequenceResult(docs);
685
+ return answerOf(entry, docs);
635
686
  });
636
687
  });
637
688
  });
@@ -664,6 +715,21 @@ export function createQueryEngine(context) {
664
715
  if (done) return Promise.resolve({ done: true, value: undefined });
665
716
  if (bufferedAt < buffered.length) return Promise.resolve(nextFromBuffer());
666
717
 
718
+ if (entry.planned.wrapped === true) {
719
+ // a chain's element window is ONE item — the array — whatever
720
+ // the plan mode; the cursor hands it over as `execute` answers it
721
+ if (materialized === null) {
722
+ materialized = Promise.resolve(chain(execute(document, options), (value) => {
723
+ buffered = [value];
724
+ bufferedAt = 0;
725
+ }));
726
+ }
727
+ return materialized.then(() => {
728
+ if (bufferedAt < buffered.length) return nextFromBuffer();
729
+ done = true;
730
+ return { done: true, value: undefined };
731
+ });
732
+ }
667
733
  if (entry.planned.mode === 'set' || entry.planned.mode === 'knn'
668
734
  || mustDivert(entry, externals)) {
669
735
  // the barrier: materialize candidates, pack the result items
@@ -827,11 +893,14 @@ export function createQueryEngine(context) {
827
893
  return chain(connection.prepare(dialect.explainQuery(entry.sql)), (statement) =>
828
894
  chain(statement.all(eqpParams), (rows) => ({
829
895
  mode: entry.planned.mode,
896
+ // a chain's element window (`[<phrase>]`): the phrase planned as
897
+ // if bare, its rows answered as the one array item
898
+ wrapped: entry.planned.wrapped === true,
830
899
  externals: [...entry.externalNames],
831
900
  operators: [...entry.dependencies.operators],
832
901
  functions: [...entry.dependencies.functions],
833
902
  collations: [...entry.dependencies.collations],
834
- limits: entry.limits,
903
+ limits: entry.residualLimits ?? entry.limits,
835
904
  sql: entry.sql,
836
905
  params,
837
906
  indexes,
@@ -876,6 +945,7 @@ export function createQueryEngine(context) {
876
945
  // ————— The entity query surface (the second document kind) —————
877
946
 
878
947
  import { mergeEntityRow, parseGraphRow } from './graph.js';
948
+ import { relationTables } from './model.js';
879
949
 
880
950
  /** The default include depth bound (D14: printed, never silent). */
881
951
  export const INCLUDE_DEPTH_DEFAULT = 3;
@@ -897,6 +967,10 @@ export function createEntityQueryEngine(context) {
897
967
  const dialect = connection.dialect;
898
968
  const q = dialect.quoteIdentifier;
899
969
  const physicalOf = (name) => ({ table: mapping.entities[name].table });
970
+ // the relation tables of every root this engine serves (§10.1): the
971
+ // engine is the scope every entity set of the store shares, so a
972
+ // producer holding one set can follow a hop into another root
973
+ const relations = relationTables(entities);
900
974
 
901
975
  const entryFor = (document, pushdown) => {
902
976
  const key = ['E', document, dialect.name, pushdown];
@@ -907,6 +981,14 @@ export function createEntityQueryEngine(context) {
907
981
  }
908
982
  state.counters.misses++;
909
983
  let planned = planEntityQuery(document, entities, mapping, operators);
984
+ if (planned.referenced.length === 0) {
985
+ // `$[*]` over the entity MAP answered the rows of every entity,
986
+ // mixed, and explain() named no table read; the root is the map
987
+ // of entity arrays, and a query ranges over one of them by name
988
+ throw new DbCompileError('JD0033',
989
+ 'an entity query ranges over a declared entity array ($.<Entity>[*]); this '
990
+ + 'document names none, so it has no rows to answer', '/entities');
991
+ }
910
992
  if (!pushdown) {
911
993
  planned = { ...planned, mode: 'set', plan: null,
912
994
  reasons: [{ construct: 'pushdown', reason: 'disabled by the harness switch' }] };
@@ -986,11 +1068,11 @@ export function createEntityQueryEngine(context) {
986
1068
  if (entry.statement === null) entry.statement = connection.prepare(entry.sql);
987
1069
  return chain(entry.statement, (statement) => {
988
1070
  if (entry.planned.plan.aggregate === 'count')
989
- return chain(statement.get(params), (row) => row?.value ?? 0);
1071
+ return chain(statement.get(params), (row) => wrapValue(entry, row?.value ?? 0));
990
1072
  return chain(statement.all(params), (rows) => {
991
1073
  const retEntity = entry.planned.plan.bindings
992
1074
  .find((binding) => binding.name === entry.planned.plan.ret).entity;
993
- return sequenceResult(rows.map((row) =>
1075
+ return answerOf(entry, rows.map((row) =>
994
1076
  mergeEntityRow(mapping.entities[retEntity], row, '__doc')));
995
1077
  });
996
1078
  });
@@ -1001,6 +1083,7 @@ export function createEntityQueryEngine(context) {
1001
1083
  const entry = entryFor(document, pushdown);
1002
1084
  const base = {
1003
1085
  mode: entry.planned.mode,
1086
+ wrapped: entry.planned.wrapped === true,
1004
1087
  referenced: [...entry.planned.referenced],
1005
1088
  reasons: entry.planned.reasons,
1006
1089
  sql: entry.sql,
@@ -1018,7 +1101,7 @@ export function createEntityQueryEngine(context) {
1018
1101
  })));
1019
1102
  };
1020
1103
 
1021
- return { execute, explain };
1104
+ return { execute, explain, relations };
1022
1105
  }
1023
1106
 
1024
1107
  /**
@@ -1048,6 +1131,11 @@ export function createLoadEngine(context, entityName) {
1048
1131
  const refuse = (reason, path) => new DbCompileError('JD0032',
1049
1132
  `${reason} (include path: ${path.join('.') || '<root>'})`,
1050
1133
  entities.get(entityName)?.docPath);
1134
+ const isWindowBound = (value) => Number.isSafeInteger(value) && value >= 0;
1135
+ /** An include's window inside its subquery: LIMIT, and OFFSET for a
1136
+ * `skip` — per parent row, since the subquery is correlated (§10.4). */
1137
+ const windowClause = (child) => (child.take !== undefined || (child.skip !== undefined && child.skip > 0)
1138
+ ? ` ${dialect.limitClause(child.take ?? null, child.skip)}` : '');
1051
1139
 
1052
1140
  /** Compile a where EXPRESSION over `$it` against one entity. */
1053
1141
  const compileWhere = (expression, entity, path) => {
@@ -1118,6 +1206,7 @@ export function createLoadEngine(context, entityName) {
1118
1206
  where: spec?.where !== undefined ? compileWhere(spec.where, entity, path) : null,
1119
1207
  order: spec?.orderBy !== undefined ? compileOrder(spec.orderBy, entity, path) : null,
1120
1208
  take: spec?.take,
1209
+ skip: spec?.skip,
1121
1210
  includes: [],
1122
1211
  };
1123
1212
  const includeSpec = spec?.include;
@@ -1133,6 +1222,26 @@ export function createLoadEngine(context, entityName) {
1133
1222
  [...path, relationName]);
1134
1223
  }
1135
1224
  const childSpec = includeSpec[relationName] === true ? {} : includeSpec[relationName];
1225
+ if (childSpec.count === true) {
1226
+ // a count counts EVERY related row; a where/take beside it was
1227
+ // dropped without a word, and the number answered was the total
1228
+ const dropped = ['where', 'orderBy', 'take', 'skip', 'include', 'after']
1229
+ .filter((member) => childSpec[member] !== undefined);
1230
+ if (dropped.length > 0) {
1231
+ throw refuse(`count: true counts every related row and takes no ${dropped.join('/')} — `
1232
+ + 'load the rows to count a subset', [...path, relationName]);
1233
+ }
1234
+ }
1235
+ for (const member of ['take', 'skip']) {
1236
+ if (childSpec[member] !== undefined && !isWindowBound(childSpec[member]))
1237
+ throw refuse(`${member} must be a non-negative integer`, [...path, relationName]);
1238
+ }
1239
+ // a keyset cursor is one position in ONE ordered set; an include is
1240
+ // a set per parent, so it windows with skip/take and never seeks
1241
+ if (childSpec.after !== undefined) {
1242
+ throw refuse("'after' (keyset pagination) paginates the root — an include windows with skip and take",
1243
+ [...path, relationName]);
1244
+ }
1136
1245
  const childName = relation.to;
1137
1246
  const include = {
1138
1247
  name: relationName,
@@ -1186,6 +1295,17 @@ export function createLoadEngine(context, entityName) {
1186
1295
  const parentKey = parentNode.entityMapping.keys[0];
1187
1296
  if (include.count === true) {
1188
1297
  const childTable = mapping.entities[relation.to].table;
1298
+ if (relation.kind === 'manyToMany') {
1299
+ const join = mapping.joinTables[relation.joinTable];
1300
+ const own = join.left.entity === parentNode.entity.name ? join.left : join.right;
1301
+ return `(SELECT COUNT(*) FROM ${q(relation.joinTable)} AS ${q(childAlias)} `
1302
+ + `WHERE ${q(childAlias)}.${q(own.column)} = ${q(parentAlias)}.${q(parentKey)})`;
1303
+ }
1304
+ if (relation.kind === 'oneToOne') {
1305
+ const childKey = mapping.entities[relation.to].keys[0];
1306
+ return `(SELECT COUNT(*) FROM ${q(childTable)} AS ${q(childAlias)} `
1307
+ + `WHERE ${q(childAlias)}.${q(childKey)} = ${q(parentAlias)}.${q(relation.via)})`;
1308
+ }
1189
1309
  return `(SELECT COUNT(*) FROM ${q(childTable)} AS ${q(childAlias)} `
1190
1310
  + `WHERE ${q(childAlias)}.${q(relation.via)} = ${q(parentAlias)}.${q(parentKey)})`;
1191
1311
  }
@@ -1220,10 +1340,10 @@ export function createLoadEngine(context, entityName) {
1220
1340
  + (child.where !== null
1221
1341
  ? ` AND ${emitters.emitPred(rendered.aliasSql, rendered.docSql, child.where)}` : '')
1222
1342
  + ` ORDER BY ${orderSql.join(', ')}`
1223
- + (child.take !== undefined ? ` ${dialect.limitClause(child.take, undefined)}` : '')
1343
+ + windowClause(child)
1224
1344
  : `SELECT ${rendered.aliasSql}.* FROM ${q(childTable)} AS ${q(childAlias)} `
1225
1345
  + `WHERE ${conditions.join(' AND ')} ORDER BY ${orderSql.join(', ')}`
1226
- + (child.take !== undefined ? ` ${dialect.limitClause(child.take, undefined)}` : '');
1346
+ + windowClause(child);
1227
1347
  if (relation.kind === 'oneToOne') {
1228
1348
  return `(SELECT json_object(${rendered.projection()}) FROM `
1229
1349
  + `(${inner} ${dialect.limitClause(1, undefined)}) AS ${q(childAlias)})`;
@@ -1250,6 +1370,11 @@ export function createLoadEngine(context, entityName) {
1250
1370
  };
1251
1371
  const emitters = createEntityPredicateEmitters(dialect, param);
1252
1372
  const maxDepth = spec?.maxDepth ?? INCLUDE_DEPTH_DEFAULT;
1373
+ for (const member of ['take', 'skip']) {
1374
+ // interpolated into LIMIT/OFFSET as written: a string ran as SQL
1375
+ if (spec?.[member] !== undefined && !isWindowBound(spec[member]))
1376
+ throw refuse(`${member} must be a non-negative integer`, []);
1377
+ }
1253
1378
  const tree = buildTree(entityName, spec ?? {}, 0, maxDepth, [], new Set());
1254
1379
  const rendered = render(tree, 'r', param, emitters);
1255
1380