@jarenjs/db 0.73.0 → 0.83.2

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 (53) hide show
  1. package/ARCHITECTURE.md +70 -7
  2. package/README.md +69 -6
  3. package/docs/HOSTS.md +17 -0
  4. package/docs/JOBS-FORMAT.md +26 -0
  5. package/docs/LIVE-FORMAT.md +52 -13
  6. package/docs/MIGRATION-FORMAT.md +34 -0
  7. package/docs/MODEL-FORMAT.md +163 -15
  8. package/docs/NATIVE-PLANS.md +111 -0
  9. package/docs/REPLICATION-FORMAT.md +19 -13
  10. package/docs/SEARCH.md +55 -0
  11. package/package.json +8 -4
  12. package/schemas/jaren-migration.draft-07.schema.json +54 -5
  13. package/schemas/jaren-migration.schema.json +49 -0
  14. package/schemas/jaren-model.authoring.schema.json +360 -0
  15. package/schemas/jaren-model.draft-07.schema.json +128 -0
  16. package/schemas/jaren-model.schema.json +128 -0
  17. package/src/algebra.js +26 -4
  18. package/src/backup.js +12 -7
  19. package/src/cursor.js +27 -4
  20. package/src/dag-job.js +2 -1
  21. package/src/ddl.js +13 -0
  22. package/src/derive.js +14 -3
  23. package/src/dialect.js +12 -0
  24. package/src/dialects/check-read.js +151 -0
  25. package/src/dialects/invariant-sql.js +117 -0
  26. package/src/dialects/postgres.js +28 -4
  27. package/src/dialects/sqlite.js +23 -3
  28. package/src/driver.js +1 -0
  29. package/src/drivers/bun.js +22 -4
  30. package/src/emit.js +133 -25
  31. package/src/entity.js +98 -41
  32. package/src/errors.js +8 -0
  33. package/src/graph.js +8 -1
  34. package/src/index.js +3 -0
  35. package/src/introspect.js +81 -12
  36. package/src/invariants.js +45 -0
  37. package/src/jobs.js +39 -6
  38. package/src/live-nested.js +27 -10
  39. package/src/live.js +51 -136
  40. package/src/migrate.js +136 -22
  41. package/src/model.js +12 -0
  42. package/src/mutation.js +165 -0
  43. package/src/physical.js +147 -0
  44. package/src/plan.js +275 -64
  45. package/src/query.js +175 -78
  46. package/src/search.js +144 -0
  47. package/src/sql.js +60 -0
  48. package/src/store.js +49 -13
  49. package/src/tracker.js +63 -39
  50. package/src/window.js +1 -0
  51. package/types/index.d.ts +59 -4
  52. package/types/search.d.ts +20 -0
  53. package/types/typed.d.ts +1 -0
@@ -27,7 +27,7 @@ import { PRAGMA_NAMES } from '../pragmas.js';
27
27
  * with its shape) into a probed connection. Exported so the adapter is
28
28
  * exercisable without the builtin.
29
29
  * @param {any} db - A Bun `Database`-shaped database
30
- * @param {{ queueTimeout?: number }} [options]
30
+ * @param {{ queueTimeout?: number, backup?: any }} [options]
31
31
  * @returns {any} a Connection, or a promise of one
32
32
  */
33
33
  export function adaptBunDatabase(db, options) {
@@ -38,6 +38,7 @@ export function adaptBunDatabase(db, options) {
38
38
  const statements = new Set();
39
39
  const collected = new FinalizationRegistry((ref) => statements.delete(ref));
40
40
  const raw = {
41
+ ...(options?.backup ? { backup: options.backup } : {}),
41
42
  /** @param {string} sql */
42
43
  exec: (sql) => db.run(sql),
43
44
  /** @param {string} sql */
@@ -91,6 +92,7 @@ export function adaptBunDatabase(db, options) {
91
92
  synchronous: true,
92
93
  queueTimeout: options?.queueTimeout,
93
94
  declared: {
95
+ backup: options?.backup !== undefined,
94
96
  sessions: false,
95
97
  userFunctions: false,
96
98
  deterministicIndexableFunctions: false,
@@ -112,9 +114,25 @@ export function adaptBunDatabase(db, options) {
112
114
  * @returns {any}
113
115
  */
114
116
  export function fromBunModule(mod, path, options) {
115
- return adaptBunDatabase(options?.readOnly === true
116
- ? new mod.Database(path, { readonly: true })
117
- : new mod.Database(path), options);
117
+ const db = options?.readOnly === true ? new mod.Database(path, { readonly: true }) : new mod.Database(path);
118
+ const backup = typeof db.serialize !== 'function' ? undefined : {
119
+ snapshot: true,
120
+ copy: async (target, copyOptions) => {
121
+ const bytes = db.serialize();
122
+ const pageSize = ((bytes[16] << 8) | bytes[17]) || 65536;
123
+ const pages = bytes.length / (pageSize === 1 ? 65536 : pageSize);
124
+ copyOptions?.progress?.({ totalPages: pages, remainingPages: pages });
125
+ const fs = await import('node:fs/promises');
126
+ const file = await fs.open(target, 'wx');
127
+ try { await file.writeFile(bytes); await file.sync(); }
128
+ finally { await file.close(); }
129
+ copyOptions?.progress?.({ totalPages: pages, remainingPages: 0 });
130
+ return pages;
131
+ },
132
+ rename: (from, to) => import('node:fs/promises').then((fs) => fs.rename(from, to)),
133
+ remove: (target) => import('node:fs/promises').then((fs) => fs.rm(target, { force: true })),
134
+ };
135
+ return adaptBunDatabase(db, { ...options, ...(backup ? { backup } : {}) });
118
136
  }
119
137
 
120
138
  /**
package/src/emit.js CHANGED
@@ -17,6 +17,11 @@
17
17
  */
18
18
 
19
19
  import { codePointPrefixSuccessor } from '@jarenjs/core/string';
20
+ import { physicalSelection } from './physical.js';
21
+
22
+ /** Physical text expressions must not inherit an application's collation. */
23
+ const physicalComparable = (ref, sql, dialect) => ref.codec !== undefined
24
+ && ['text', 'date', 'datetime'].includes(ref.codec) ? dialect.codepoint(sql) : sql;
20
25
 
21
26
  /**
22
27
  * A promoted path the dialect's JSON path grammar cannot spell (a
@@ -26,10 +31,21 @@ import { codePointPrefixSuccessor } from '@jarenjs/core/string';
26
31
  */
27
32
  export class UnrepresentablePath extends Error {}
28
33
 
34
+ /** A comparison of schema-compatible paths is false if either is absent.
35
+ * @param {any} pred @param {(ref: any) => { value: string, present: string }} read */
36
+ function compareRefs(pred, read) {
37
+ const left = read(pred.left);
38
+ const right = read(pred.right);
39
+ const symbol = { eq: '=', ne: '<>', lt: '<', le: '<=', gt: '>', ge: '>=' }[pred.op];
40
+ return `(${left.present} AND ${right.present} AND ${left.value} ${symbol} ${right.value})`;
41
+ }
42
+
29
43
  /**
30
44
  * @typedef {{ external: string } | { literal: unknown } |
31
45
  * { derived: { kind: 'bboxAxis', external: string,
32
46
  * axis: 'w' | 's' | 'e' | 'n' } } |
47
+ * { derived: { kind: 'circleAxis', centre: { external: string } | { literal: unknown },
48
+ * radius: { external: string } | { literal: unknown }, axis: 'w' | 's' | 'e' | 'n' } } |
33
49
  * { typed: { seek: string, type: 'number' | 'text' } }} ParamSlot
34
50
  * Four kinds, closed. A DERIVED slot is the escape for a value SQL
35
51
  * cannot bind at all: a GeoJSON region arrives as an external object,
@@ -85,7 +101,7 @@ function stropForm(dialect, param, valueSql, pred) {
85
101
  */
86
102
  function slotName(slot) {
87
103
  if ('external' in slot) return slot.external;
88
- if ('derived' in slot) return slot.derived.external;
104
+ if ('derived' in slot) return slot.derived.kind === 'bboxAxis' ? slot.derived.external : 'circle';
89
105
  if ('typed' in slot) return slot.typed.seek;
90
106
  return 'value';
91
107
  }
@@ -107,6 +123,8 @@ const BOX_AT = { w: 0, s: 1, e: 2, n: 3 };
107
123
  * @returns {string}
108
124
  */
109
125
  function probeEdge(probe, param, axis) {
126
+ if ('circle' in probe)
127
+ return param({ derived: { kind: 'circleAxis', ...probe.circle, axis } });
110
128
  return 'box' in probe
111
129
  ? param({ literal: probe.box[BOX_AT[axis]] })
112
130
  : param({ derived: { kind: 'bboxAxis', external: probe.ext, axis } });
@@ -120,6 +138,11 @@ function probeEdge(probe, param, axis) {
120
138
  * @returns {{ sql: string, slots: ParamSlot[] }}
121
139
  */
122
140
  export function emitPlan(plan, dialect, physical) {
141
+ if (plan.group !== null && plan.aggregate?.fn === 'count') {
142
+ const inner = emitPlan({ ...plan, aggregate: null }, dialect, physical);
143
+ return { ...inner, sql: `SELECT COUNT(*) AS ${dialect.quoteIdentifier('value')} `
144
+ + `FROM (${inner.sql}) AS ${dialect.quoteIdentifier('_groups')}` };
145
+ }
123
146
  const q = dialect.quoteIdentifier;
124
147
  const docColumn = q(physical.docColumn);
125
148
  /** @type {ParamSlot[]} */
@@ -297,6 +320,9 @@ export function emitPlan(plan, dialect, physical) {
297
320
  */
298
321
  const emitPred = (pred) => {
299
322
  switch (pred.p) {
323
+ case 'refCmp':
324
+ return compareRefs(pred, (ref) => ({ value: valueOf(ref, kindOf(ref)),
325
+ present: `${typeOf(ref)} IS NOT NULL` }));
300
326
  case 'and':
301
327
  return `(${pred.items.map(emitPred).join(' AND ')})`;
302
328
  case 'or':
@@ -439,9 +465,12 @@ export function emitPlan(plan, dialect, physical) {
439
465
  + `${q(plan.rank.alternatives[0].column)} AS ${q('vec')}`
440
466
  : plan.group !== null
441
467
  ? [...plan.group.keys.map((key, i) => projectedPair(key.ref, `k${i}`)),
442
- ...plan.group.aggregates.map((entry, i) =>
443
- `${dialect.groupAggregate(entry.fn, foldValue(entry.fn, entry.ref))} `
444
- + `AS ${q(`a${i}`)}`)].join(', ')
468
+ ...plan.group.aggregates.map((entry, i) => {
469
+ const value = dialect.groupAggregate(entry.fn, foldValue(entry.fn, entry.ref));
470
+ // Empty sums order as zero, the same value reconstruction
471
+ // returns. Naming that value also works in PostgreSQL ORDER BY.
472
+ return `${entry.empty === 'zero' ? `COALESCE(${value}, 0)` : value} AS ${q(`a${i}`)}`;
473
+ })].join(', ')
445
474
  : plan.bucket !== null
446
475
  ? [`${bucketSql} AS ${q(plan.bucket.as)}`,
447
476
  ...plan.bucket.aggregates.map((entry) =>
@@ -461,7 +490,8 @@ export function emitPlan(plan, dialect, physical) {
461
490
  // a projection TREE: the same value/type pair per DISTINCT
462
491
  // leaf, numbered, and nothing else — the document blob is
463
492
  // never selected, and a leaf named twice is fetched once
464
- : plan.project.leaves.map((ref, i) => projectedPair(ref, String(i))).join(', '))
493
+ : (plan.project.leaves.map((ref, i) => projectedPair(ref, String(i))).join(', ')
494
+ || `1 AS ${q('_row')}`))
465
495
  : plan.aggregate.fn === 'count'
466
496
  ? `COUNT(*) AS ${q('value')}`
467
497
  // a REGISTERED aggregate calls the function the store
@@ -486,10 +516,11 @@ export function emitPlan(plan, dialect, physical) {
486
516
  // the groups' order: the engine's own order of first appearance —
487
517
  // over a collection, each group's earliest row identity — or the
488
518
  // key ordering an `$orderby` declared
489
- sql += ` ORDER BY ${plan.group.order === 'first-seen'
490
- ? dialect.groupAggregate('min', dialect.rowIdentity())
491
- : plan.group.order.map((term) => `${q(`vk${term.index}`)} `
492
- + `${term.desc ? 'DESC' : 'ASC'}${dialect.orderNulls(term.nullsFirst)}`).join(', ')}`;
519
+ const order = plan.group.order === 'first-seen' ? []
520
+ : plan.group.order.map((term) => `${q(term.aggregate === undefined ? `vk${term.index}` : `a${term.aggregate}`)} `
521
+ + `${term.desc ? 'DESC' : 'ASC'}${dialect.orderNulls(term.nullsFirst)}`);
522
+ order.push(dialect.groupAggregate('min', dialect.rowIdentity()));
523
+ sql += ` ORDER BY ${order.join(', ')}`;
493
524
  }
494
525
  if (plan.bucket !== null) {
495
526
  // `first-seen` is the engine's own group order (§6.5, first
@@ -518,7 +549,7 @@ export function emitPlan(plan, dialect, physical) {
518
549
  terms.push(dialect.rowIdentity());
519
550
  sql += ` ORDER BY ${terms.join(', ')}`;
520
551
  }
521
- if (plan.window !== null && plan.aggregate === null && plan.group === null) {
552
+ if (plan.window !== null && plan.aggregate === null) {
522
553
  sql += ` ${dialect.limitClause(plan.window.limit, plan.window.offset)}`;
523
554
  }
524
555
  return { sql, slots, seeks: (plan.seeks ?? []).map((seek) => emitSeek(seek)) };
@@ -627,7 +658,7 @@ export function createEntityPredicateEmitters(dialect, param) {
627
658
  };
628
659
 
629
660
  const emitColumnPred = (aliasSql, pred) => {
630
- const column = `${aliasSql}.${q(pred.ref.column)}`;
661
+ const column = physicalComparable(pred.ref, `${aliasSql}.${q(pred.ref.column)}`, dialect);
631
662
  if (pred.p === 'typeIs') {
632
663
  if (pred.types.length === 0)
633
664
  return pred.positive ? `${column} IS NOT NULL` : `${column} IS NULL`;
@@ -682,6 +713,15 @@ export function createEntityPredicateEmitters(dialect, param) {
682
713
  };
683
714
 
684
715
  const emitPred = (aliasSql, docSql, pred) => {
716
+ if (pred.p === 'refCmp') return compareRefs(pred, (ref) => {
717
+ if (ref.flavor === 'entity-column') {
718
+ const value = physicalComparable(ref, `${aliasSql}.${q(ref.column)}`, dialect);
719
+ return { value, present: `${value} IS NOT NULL` };
720
+ }
721
+ // Epoch columns retain the document's lexical comparison rule.
722
+ return { value: memberAt(docSql, ref, ref.type === 'string' ? 'text' : 'number'),
723
+ present: `${dialect.jsonTypeOf(docSql, pathTextOf(ref))} IS NOT NULL` };
724
+ });
685
725
  if (pred.p === 'and')
686
726
  return `(${pred.items.map((item) => emitPred(aliasSql, docSql, item)).join(' AND ')})`;
687
727
  if (pred.p === 'or')
@@ -745,8 +785,13 @@ export function emitEntityPlan(plan, dialect, physicalOf) {
745
785
 
746
786
  const entityOf = new Map(plan.bindings.map((binding) => [binding.name, binding.entity]));
747
787
  const emitters = createEntityPredicateEmitters(dialect, param);
748
- const emitPred = (bindingName, pred) =>
749
- emitters.emitPred(aliasOf(bindingName), docOf(bindingName), pred);
788
+ const emitPred = (bindingName, pred) => {
789
+ if (pred.p === 'binding') return emitPred(pred.binding, pred.filter);
790
+ if (pred.p === 'and' || pred.p === 'or') return `(${pred.items
791
+ .map((item) => emitPred(bindingName, item)).join(pred.p === 'and' ? ' AND ' : ' OR ')})`;
792
+ if (pred.p === 'not') return `NOT (${emitPred(bindingName, pred.item)})`;
793
+ return emitters.emitPred(aliasOf(bindingName), docOf(bindingName), pred);
794
+ };
750
795
 
751
796
  /**
752
797
  * One projected member of a binding: its value beside its JSON type,
@@ -764,12 +809,27 @@ export function emitEntityPlan(plan, dialect, physicalOf) {
764
809
  const projectedPair = (leaf, suffix) => {
765
810
  const names = `${q(`v${suffix}`)}`;
766
811
  const typeName = `${q(`t${suffix}`)}`;
812
+ if (leaf.count) {
813
+ const count = leaf.count;
814
+ const alias = q(`c${suffix}`);
815
+ const value = (side) => physicalComparable(side.ref,
816
+ `${side.binding === count.binding ? alias : aliasOf(side.binding)}.${q(side.ref.column)}`, dialect);
817
+ const predicates = count.edges.map((edge) => `${value(edge.left)} = ${value(edge.right)}`);
818
+ if (count.filter) predicates.push(emitters.emitPred(alias, `${alias}.${q('doc')}`, count.filter));
819
+ return `(SELECT COUNT(*) FROM ${q(physicalOf(count.entity).table)} AS ${alias} `
820
+ + `WHERE ${predicates.join(' AND ')}) AS ${names}, ${sl('integer')} AS ${typeName}`;
821
+ }
767
822
  if (leaf.ref.flavor === 'entity-column') {
768
- const column = `${aliasOf(leaf.binding)}.${q(leaf.ref.column)}`;
823
+ const raw = `${aliasOf(leaf.binding)}.${q(leaf.ref.column)}`;
824
+ const column = physicalComparable(leaf.ref,
825
+ leaf.ref.codec === 'integer'
826
+ ? `CASE WHEN ${dialect.valueTypeOf(raw)} = ${sl('integer')} THEN CAST(${raw} AS REAL) ELSE ${raw} END`
827
+ : raw, dialect);
828
+ const emptyType = leaf.ref.nullPolicy === 'null' ? sl('null') : 'NULL';
769
829
  const type = leaf.ref.storage === 'boolean'
770
- ? `CASE WHEN ${column} IS NULL THEN NULL WHEN ${column} = 0 `
830
+ ? `CASE WHEN ${column} IS NULL THEN ${emptyType} WHEN ${column} = 0 `
771
831
  + `THEN ${sl('false')} ELSE ${sl('true')} END`
772
- : `CASE WHEN ${column} IS NULL THEN NULL ELSE ${sl(
832
+ : `CASE WHEN ${column} IS NULL THEN ${emptyType} ELSE ${sl(
773
833
  leaf.ref.storage === 'string' ? 'text'
774
834
  : leaf.ref.storage === 'integer'
775
835
  ? dialect.numericTypeNames[0]
@@ -789,24 +849,54 @@ export function emitEntityPlan(plan, dialect, physicalOf) {
789
849
  // every returned column plus the document rendered to text; the
790
850
  // caller merges them back into the entity shape — or, for a projected
791
851
  // shape, one value/type pair per DISTINCT leaf and no document at all
792
- const selection = plan.aggregate === 'count'
852
+ const group = plan.group;
853
+ const groupValue = (ref) => physicalComparable(ref,
854
+ `${aliasOf(plan.bindings[0].name)}.${q(ref.column)}`, dialect);
855
+ // Every integer addition is exact when the largest magnitude times
856
+ // the number of terms fits in the safe range. Sum as REAL so even a
857
+ // rejected group cannot overflow SQLite's integer accumulator first.
858
+ const numericGroup = (entry) => ['sum', 'avg'].includes(entry.fn);
859
+ const groupSafe = (entry) => `(COUNT(${groupValue(entry.ref)}) = 0 OR `
860
+ + `MAX(ABS(CAST(${groupValue(entry.ref)} AS REAL))) <= 9007199254740991 / COUNT(${groupValue(entry.ref)}))`;
861
+ const groupValid = (ref) => {
862
+ const value = groupValue(ref);
863
+ const type = dialect.valueTypeOf(value);
864
+ const valid = ref.codec === 'text' ? `${type} = ${sl('text')}`
865
+ : ref.codec === 'boolean' ? `${type} = ${sl('integer')} AND ${value} IN (0, 1)`
866
+ : `${type} ${ref.codec === 'integer' ? `= ${sl('integer')}` : `IN (${sl('integer')}, ${sl('real')})`}`
867
+ + ` AND ABS(CAST(${value} AS REAL)) <= 9007199254740991`;
868
+ return `MIN(CASE WHEN ${ref.nullPolicy === 'reject' ? '' : `${value} IS NULL OR `}(${valid}) THEN 1 ELSE 0 END)`;
869
+ };
870
+ const groupRefs = group ? [...group.keys, ...group.aggregates].map((entry) => entry.ref).filter((ref) => ref?.codecColumn) : [];
871
+ const selection = group
872
+ ? [...group.keys.map((key, i) => projectedPair({ binding: plan.bindings[0].name, ref: key.ref }, `k${i}`)),
873
+ ...group.aggregates.map((entry, i) => `${entry.fn === 'rows' ? 'COUNT(*)'
874
+ : dialect.groupAggregate(entry.fn, numericGroup(entry) || entry.ref.codec === 'integer'
875
+ ? `CAST(${groupValue(entry.ref)} AS REAL)` : groupValue(entry.ref))} AS ${q(`a${i}`)}`),
876
+ ...group.aggregates.flatMap((entry, i) => numericGroup(entry) ? [`${groupSafe(entry)} AS ${q(`_safe${i}`)}`] : []),
877
+ ...(groupRefs.length ? [`${groupRefs.map(groupValid).join(' AND ')} AS ${q('_valid')}`] : [])].join(', ')
878
+ : plan.aggregate === 'count'
793
879
  ? `COUNT(*) AS ${q('value')}`
794
880
  : plan.project != null
795
881
  // `p`-prefixed, because a bare `t0` would collide with this
796
882
  // plan's own binding aliases
797
- ? plan.project.leaves.map((leaf, i) => projectedPair(leaf, `p${i}`)).join(', ')
883
+ ? (plan.project.leaves.map((leaf, i) => projectedPair(leaf, `p${i}`)).join(', ')
884
+ || `1 AS ${q('_row')}`)
798
885
  // a join-table root IS its two key columns: it has no document
799
886
  // column, so the merge is handed an empty one
800
887
  : physicalOf(entityOf.get(ret)).document === false
801
- ? `${aliasOf(ret)}.*, ${sl('{}')} AS ${q('__doc')}`
888
+ ? physicalOf(entityOf.get(ret)).mapping
889
+ ? physicalSelection(physicalOf(entityOf.get(ret)).mapping, dialect, `${aliasOf(ret)}.`)
890
+ : `${aliasOf(ret)}.*, ${sl('{}')} AS ${q('__doc')}`
802
891
  : `${aliasOf(ret)}.*, ${dialect.jsonText(docOf(ret))} AS ${q('__doc')}`;
803
892
 
804
893
  const tableOf = (name) =>
805
894
  `${q(physicalOf(entityOf.get(name)).table)} AS ${aliasOf(name)}`;
806
895
  const JOIN_OPS = { eq: '=', ne: '<>', lt: '<', le: '<=', gt: '>', ge: '>=' };
807
896
  const onSql = (edge) =>
808
- `${aliasOf(edge.left.binding)}.${q(edge.left.column)}`
809
- + ` ${JOIN_OPS[edge.op ?? 'eq']} ${aliasOf(edge.right.binding)}.${q(edge.right.column)}`;
897
+ physicalComparable(edge.left, `${aliasOf(edge.left.binding)}.${q(edge.left.column)}`, dialect)
898
+ + ` ${JOIN_OPS[edge.op ?? 'eq']} `
899
+ + physicalComparable(edge.right, `${aliasOf(edge.right.binding)}.${q(edge.right.column)}`, dialect);
810
900
 
811
901
  let sql = `SELECT ${selection} FROM `;
812
902
  // the JOIN order the planner settled: the first binding, then each
@@ -822,21 +912,39 @@ export function emitEntityPlan(plan, dialect, physicalOf) {
822
912
  .map((entry) => emitPred(entry.binding, entry.filter));
823
913
  if (filterSql.length > 0) sql += ` WHERE ${filterSql.join(' AND ')}`;
824
914
 
825
- if (plan.aggregate === null) {
915
+ if (group) {
916
+ sql += ` GROUP BY ${group.keys.map((key, i) => `${q(`vk${i}`)}, ${q(`tk${i}`)}`).join(', ')}`;
917
+ const terms = group.order === 'first-seen' ? [] : group.order.map((term) =>
918
+ `${q(term.aggregate === undefined ? `vk${term.index}` : `a${term.aggregate}`)} ${term.desc ? 'DESC' : 'ASC'}${dialect.orderNulls(term.nullsFirst)}`);
919
+ const physical = physicalOf(plan.bindings[0].entity);
920
+ for (const key of physical.keys ?? [null]) {
921
+ const value = `${aliasOf(plan.bindings[0].name)}.${key === null ? dialect.rowIdentity() : q(key)}`;
922
+ const column = physical.mapping?.columns.find((c) => c.physical === key);
923
+ terms.push(`MIN(${column ? physicalComparable(column, value, dialect) : value})`);
924
+ }
925
+ sql += ` ORDER BY ${terms.join(', ')}`;
926
+ if (plan.window !== null) sql += ` ${dialect.limitClause(plan.window.limit, plan.window.offset)}`;
927
+ }
928
+ else if (plan.aggregate === null) {
826
929
  const terms = (plan.order ?? []).map((term) => {
827
930
  // only a plain mapped column orders by its column; an epoch
828
931
  // path orders by the document string — codepoint order, exactly
829
932
  // the engine's — because mixed stored precisions would let the
830
933
  // integer column sort differently
831
934
  const value = term.ref.flavor === 'entity-column'
832
- ? `${aliasOf(term.binding)}.${q(term.ref.column)}`
935
+ ? physicalComparable(term.ref, `${aliasOf(term.binding)}.${q(term.ref.column)}`, dialect)
833
936
  : dialect.jsonExtract(docOf(term.binding), pathTextOf(term.ref), 'text');
834
937
  const nullsFirst = term.emptyGreatest === term.desc;
835
938
  return `${value} ${term.desc ? 'DESC' : 'ASC'}${dialect.orderNulls(nullsFirst)}`;
836
939
  });
837
940
  // the engine's nested-loop order: binding-order row identities
838
- for (const binding of plan.bindings)
839
- terms.push(`${aliasOf(binding.name)}.${dialect.rowIdentity()}`);
941
+ for (const binding of plan.bindings) {
942
+ const keys = physicalOf(binding.entity).keys;
943
+ if (keys) terms.push(...keys.map((key) => physicalComparable(
944
+ physicalOf(binding.entity).mapping.columns.find((c) => c.physical === key),
945
+ `${aliasOf(binding.name)}.${q(key)}`, dialect)));
946
+ else terms.push(`${aliasOf(binding.name)}.${dialect.rowIdentity()}`);
947
+ }
840
948
  sql += ` ORDER BY ${terms.join(', ')}`;
841
949
  if (plan.window !== null)
842
950
  sql += ` ${dialect.limitClause(plan.window.limit, plan.window.offset)}`;
package/src/entity.js CHANGED
@@ -22,6 +22,11 @@ import { resolveRuntime } from '@jarenjs/core/runtime';
22
22
 
23
23
  import { DbRuntimeError, wrapDriverError } from './errors.js';
24
24
  import { chain, attempt } from './driver.js';
25
+ import { checkInvariants } from './invariants.js';
26
+ import { columnCodec, physicalRead } from './physical.js';
27
+ import { mergeEntityRow } from './graph.js';
28
+ import { createJSONPatch } from '@jarenjs/json/patch';
29
+ import { createEntityMutation } from './mutation.js';
25
30
 
26
31
  /**
27
32
  * The write/read machinery for one entity, prepared once.
@@ -40,6 +45,11 @@ export function entityCore(connection, entity, entityMapping, validate, runtime
40
45
  const q = dialect.quoteIdentifier;
41
46
  const table = entityMapping.table;
42
47
  const docPath = entity.docPath;
48
+ const physical = entityMapping.document === false;
49
+ const physicalName = (name) => entityMapping.columns.find((c) => c.name === name)?.physical ?? name;
50
+ const writable = () => {
51
+ if (entityMapping.kind === 'view') throw new DbRuntimeError('JD2003', `entity '${entity.name}' is a read-only view`);
52
+ };
43
53
 
44
54
  // the column plan: mapped scalars (epoch ones derived), then FKs;
45
55
  // everything else lives in the JSONB document
@@ -47,6 +57,7 @@ export function entityCore(connection, entity, entityMapping, validate, runtime
47
57
  ...column,
48
58
  epoch: column.source === 'epoch(document)',
49
59
  property: entity.properties.get(column.name),
60
+ codecPlan: physical ? columnCodec(column) : null,
50
61
  }));
51
62
  // a declared via property is ALREADY a scalar column — the foreign
52
63
  // key adds a column only when no property claims it
@@ -90,7 +101,7 @@ export function entityCore(connection, entity, entityMapping, validate, runtime
90
101
 
91
102
  /** Split a completed document into bound column values + the rest.
92
103
  * Relation members are PROJECTIONS (§10.1) — never stored. */
93
- const split = (doc) => {
104
+ const split = (doc, { updating = false } = {}) => {
94
105
  const values = [];
95
106
  /** @type {any} */
96
107
  const rest = {};
@@ -99,7 +110,12 @@ export function entityCore(connection, entity, entityMapping, validate, runtime
99
110
  }
100
111
  for (const column of scalarColumns) {
101
112
  if (column.name === autoKey && doc[column.name] === undefined) continue;
113
+ if (physical && (column.generated || (!updating && column.databaseDefault && doc[column.name] === undefined))) continue;
102
114
  const value = doc[column.name];
115
+ if (physical) {
116
+ values.push({ name: column.name, value: column.codecPlan.encode(value) });
117
+ continue;
118
+ }
103
119
  if (column.epoch) {
104
120
  // derived: the string stays in the document, the epoch rides
105
121
  // the column
@@ -118,24 +134,13 @@ export function entityCore(connection, entity, entityMapping, validate, runtime
118
134
  const value = doc[fk];
119
135
  values.push({ name: fk, value: value === undefined || value === null ? null : value });
120
136
  }
137
+ if (physical && Object.keys(rest).length > 0) throw new DbRuntimeError('JD2003',
138
+ `column-only entity '${entity.name}' cannot store undeclared members: ${Object.keys(rest).join(', ')}`);
121
139
  return { values, rest };
122
140
  };
123
141
 
124
142
  /** Merge a row back into a document. */
125
- const merge = (row) => {
126
- const doc = JSON.parse(row.doc);
127
- for (const column of scalarColumns) {
128
- if (column.epoch) continue; // the string is already in the doc
129
- const value = row[column.name];
130
- if (value === null || value === undefined) continue; // absent (§9.3)
131
- doc[column.name] = column.storage === 'boolean' ? value === 1 : value;
132
- }
133
- for (const fk of fkColumns) {
134
- const value = row[fk];
135
- if (value !== null && value !== undefined) doc[fk] = value;
136
- }
137
- return doc;
138
- };
143
+ const merge = (row) => mergeEntityRow(entityMapping, row);
139
144
 
140
145
  /** The document as a read will answer it. For a column-mapped scalar,
141
146
  * JSON `null` and absence both store as SQL NULL and read back ABSENT
@@ -145,6 +150,7 @@ export function entityCore(connection, entity, entityMapping, validate, runtime
145
150
  * `column: "json"` and stays in the document, where it survives; an
146
151
  * epoch column keeps its string in the document for the same reason. */
147
152
  const asStored = (doc) => {
153
+ if (physical) return doc;
148
154
  let out = doc;
149
155
  const drop = (name) => {
150
156
  if (!(name in out) || (out[name] !== null && out[name] !== undefined)) return;
@@ -158,6 +164,17 @@ export function entityCore(connection, entity, entityMapping, validate, runtime
158
164
  for (const fk of fkColumns) drop(fk);
159
165
  return out;
160
166
  };
167
+ const normalizePhysicalDoc = (doc) => {
168
+ if (!physical) return doc;
169
+ const normalized = { ...doc };
170
+ for (const column of scalarColumns) {
171
+ if (column.generated || !Object.hasOwn(doc, column.name)) continue;
172
+ const value = column.codecPlan.normalize(doc[column.name]);
173
+ if (value === undefined) delete normalized[column.name];
174
+ else normalized[column.name] = value;
175
+ }
176
+ return normalized;
177
+ };
161
178
 
162
179
  // defaults, compiled once
163
180
  const defaulters = [];
@@ -255,22 +272,24 @@ export function entityCore(connection, entity, entityMapping, validate, runtime
255
272
  };
256
273
  const parameterAt = (i) => dialect.parameterRef(i, 'v');
257
274
  const keyWhere = (offset) => keys
258
- .map((key, i) => `${q(key)} = ${parameterAt(offset + i + 1)}`).join(' AND ');
275
+ .map((key, i) => `${q(physicalName(key))} = ${parameterAt(offset + i + 1)}`).join(' AND ');
259
276
  const selectColumns = [
260
- ...scalarColumns.filter((column) => !column.epoch).map((column) => q(column.name)),
277
+ ...scalarColumns.filter((column) => !column.epoch).map((column) => physical
278
+ ? `${physicalRead(column, dialect)} AS ${q(physicalName(column.name))}` : q(column.name)),
261
279
  ...fkColumns.map((column) => q(column)),
262
- `${dialect.jsonText(q('doc'))} AS ${q('doc')}`,
280
+ ...(physical ? [] : [`${dialect.jsonText(q('doc'))} AS ${q('doc')}`]),
263
281
  ].join(', ');
264
282
 
265
283
  const insertSqlFor = (names) => {
266
- const withDoc = [...names, 'doc'];
267
- const refs = withDoc.map((name, i) => (name === 'doc'
284
+ const withDoc = physical ? names : [...names, 'doc'];
285
+ const refs = withDoc.map((name, i) => (!physical && name === 'doc'
268
286
  ? dialect.jsonEncode(parameterAt(i + 1))
269
287
  : parameterAt(i + 1)));
270
288
  const returning = autoKey !== null && !names.includes(autoKey)
271
- ? ` RETURNING ${q(autoKey)} AS ${q('key')}`
289
+ ? ` RETURNING ${q(physicalName(autoKey))} AS ${q('key')}`
272
290
  : '';
273
- return `INSERT INTO ${q(table)} (${withDoc.map(q).join(', ')}) `
291
+ if (withDoc.length === 0) return `INSERT INTO ${q(table)} DEFAULT VALUES${returning}`;
292
+ return `INSERT INTO ${q(table)} (${withDoc.map((n) => q(physicalName(n))).join(', ')}) `
274
293
  + `VALUES (${refs.join(', ')})${returning}`;
275
294
  };
276
295
 
@@ -303,17 +322,22 @@ export function entityCore(connection, entity, entityMapping, validate, runtime
303
322
  /** Encode ONE column assignment the way {@link split} would. */
304
323
  const encodeColumn = (name, value) => {
305
324
  const column = columnByName.get(name);
325
+ if (physical && column) return column.codecPlan.encode(value);
306
326
  if (column !== undefined && column.epoch)
307
327
  return value === undefined ? null : epochOf(column.property, value);
308
328
  if (value === undefined || value === null) return null;
309
329
  return typeof value === 'boolean' ? (value ? 1 : 0) : value;
310
330
  };
311
331
 
312
- return {
332
+ const core = {
313
333
  // the unit-of-work exposure (tracker.js): the column plan and the
314
334
  // completion/validation/stamping machinery, one source of truth
315
335
  plan: {
316
336
  table,
337
+ document: !physical,
338
+ physicalName,
339
+ writable,
340
+ checkMutation: (op, before, after) => checkInvariants(entity.invariants, op, before, after),
317
341
  keys,
318
342
  autoKey,
319
343
  version: entity.version ?? null,
@@ -325,7 +349,10 @@ export function entityCore(connection, entity, entityMapping, validate, runtime
325
349
  encodeColumn,
326
350
  },
327
351
  complete: (doc, { updating }) => {
328
- const completed = applyDefaults(doc, { updating });
352
+ writable();
353
+ if (!updating && physical && scalarColumns.some((c) => c.generated && Object.hasOwn(doc, c.name)))
354
+ throw new DbRuntimeError('JD2003', 'generated columns are database-owned');
355
+ const completed = applyDefaults(normalizePhysicalDoc(doc), { updating });
329
356
  checkValid(completed);
330
357
  return asStored(completed);
331
358
  },
@@ -338,23 +365,33 @@ export function entityCore(connection, entity, entityMapping, validate, runtime
338
365
  },
339
366
  normalizeKey: (key) => normalizeKeyArg(key),
340
367
  create(doc) {
368
+ writable();
341
369
  refuseProjections(doc, 'create', true);
342
370
  const completed = applyDefaults(doc, { updating: false });
343
371
  checkValid(completed);
372
+ if (!physical) checkInvariants(entity.invariants, 'insert', null, completed);
373
+ if (physical && scalarColumns.some((c) => c.generated && Object.hasOwn(doc, c.name)))
374
+ throw new DbRuntimeError('JD2003', 'generated columns are database-owned');
344
375
  const { values, rest } = split(completed);
345
376
  const names = values.map((value) => value.name);
346
377
  const sql = insertSqlFor(names);
347
- return chain(prepared(`insert:${names.join(',')}`, sql), (statement) => {
348
- const params = [...values.map((value) => value.value), JSON.stringify(rest)];
378
+ const insert = () => chain(prepared(`insert:${names.join(',')}`, sql), (statement) => {
379
+ const params = [...values.map((value) => value.value), ...(physical ? [] : [JSON.stringify(rest)])];
349
380
  const returning = autoKey !== null && !names.includes(autoKey);
350
381
  return chain(
351
382
  attempt(() => (returning ? statement.get(params) : statement.run(params)),
352
383
  (error) => wrapWrite(error, completed[keys[0]])),
353
- (out) => asStored(returning ? { ...completed, [autoKey]: out.key } : completed));
384
+ (out) => {
385
+ const made = returning ? { ...completed, [autoKey]: out.key } : completed;
386
+ return physical ? chain(this.get(made), (stored) => {
387
+ checkValid(stored); checkInvariants(entity.invariants, 'insert', null, stored); return stored;
388
+ }) : asStored(made);
389
+ });
354
390
  });
391
+ return physical ? connection.transaction(insert) : insert();
355
392
  },
356
393
  get(key) {
357
- const parts = normalizeKeyArg(key);
394
+ const parts = normalizeKeyArg(key).map((v, i) => physical ? columnByName.get(keys[i]).codecPlan.encode(v) : v);
358
395
  const sql = `SELECT ${selectColumns} FROM ${q(table)} WHERE ${keyWhere(0)}`;
359
396
  // classified like every read of the query engines, never raw
360
397
  return attempt(() => chain(prepared('get', sql), (statement) =>
@@ -362,9 +399,10 @@ export function entityCore(connection, entity, entityMapping, validate, runtime
362
399
  (error) => wrapDriverError(error, { docPath, collection: entity.name, key }));
363
400
  },
364
401
  update(key, changes) {
365
- const parts = normalizeKeyArg(key);
402
+ writable();
403
+ const parts = normalizeKeyArg(key).map((v, i) => physical ? columnByName.get(keys[i]).codecPlan.encode(v) : v);
366
404
  refuseProjections(changes, 'update', false);
367
- return chain(this.get(key), (current) => {
405
+ const update = () => chain(this.get(key), (current) => {
368
406
  if (current === undefined) {
369
407
  throw new DbRuntimeError('JD2006',
370
408
  `no '${entity.name}' to update under that key`,
@@ -379,32 +417,51 @@ export function entityCore(connection, entity, entityMapping, validate, runtime
379
417
  { docPath, collection: entity.name, key: parts[0] });
380
418
  }
381
419
  }
382
- const next = applyDefaults({ ...current, ...changes }, { updating: true });
420
+ const candidate = normalizePhysicalDoc({ ...current, ...changes });
421
+ if (physical && createJSONPatch(current, candidate).length === 0) return current;
422
+ if (physical && scalarColumns.some((c) => c.generated && Object.hasOwn(changes, c.name)
423
+ && changes[c.name] !== current[c.name])) throw new DbRuntimeError('JD2003', 'generated columns are database-owned');
424
+ const next = applyDefaults(candidate, { updating: true });
383
425
  // an explicit update is last-write-wins by contract (§11.2),
384
426
  // but it still moves a declared version token so optimistic
385
427
  // savers see the row changed
386
428
  if (entity.version !== null && entity.version !== undefined)
387
429
  next[entity.version] = (Number(current[entity.version]) || 0) + 1;
388
430
  checkValid(next);
389
- const { values, rest } = split(next);
431
+ if (!physical) checkInvariants(entity.invariants, 'update', current, next);
432
+ const { values, rest } = split(next, { updating: true });
390
433
  const assignments = [
391
- ...values.map((value, i) => `${q(value.name)} = ${parameterAt(i + 1)}`),
392
- `${q('doc')} = ${dialect.jsonEncode(parameterAt(values.length + 1))}`,
434
+ ...values.map((value, i) => `${q(physicalName(value.name))} = ${parameterAt(i + 1)}`),
435
+ ...(physical ? [] : [`${q('doc')} = ${dialect.jsonEncode(parameterAt(values.length + 1))}`]),
393
436
  ].join(', ');
394
437
  const sql = `UPDATE ${q(table)} SET ${assignments} `
395
- + `WHERE ${keyWhere(values.length + 1)}`;
396
- return chain(prepared(`update:${values.length}`, sql), (statement) =>
438
+ + `WHERE ${keyWhere(values.length + (physical ? 0 : 1))}`;
439
+ return chain(prepared(`update:${values.map((v) => v.name).join(',')}`, sql), (statement) =>
397
440
  chain(attempt(() => statement.run([...values.map((value) => value.value),
398
- JSON.stringify(rest), ...parts]), (error) => wrapWrite(error, parts[0])),
399
- () => asStored(next)));
441
+ ...(physical ? [] : [JSON.stringify(rest)]), ...parts]), (error) => wrapWrite(error, parts[0])),
442
+ () => physical ? chain(this.get(key), (stored) => {
443
+ checkValid(stored); checkInvariants(entity.invariants, 'update', current, stored); return stored;
444
+ }) : asStored(next)));
400
445
  });
446
+ return physical ? connection.transaction(update) : update();
401
447
  },
402
448
  delete(key) {
403
- const parts = normalizeKeyArg(key);
449
+ writable();
450
+ if (entity.invariants.some((r) => r.enforcement === 'store' && r.on.includes('delete')))
451
+ return chain(this.get(key), (before) => {
452
+ checkInvariants(entity.invariants, 'delete', before, null);
453
+ return remove(key);
454
+ });
455
+ return remove(key);
456
+ },
457
+ };
458
+ core.mutate = createEntityMutation(connection, entity, entityMapping, core);
459
+ return core;
460
+ function remove(key) {
461
+ const parts = normalizeKeyArg(key).map((v, i) => physical ? columnByName.get(keys[i]).codecPlan.encode(v) : v);
404
462
  const sql = `DELETE FROM ${q(table)} WHERE ${keyWhere(0)}`;
405
463
  return chain(prepared('delete', sql), (statement) =>
406
464
  chain(attempt(() => statement.run(parts), (error) => wrapWrite(error, parts[0])),
407
465
  (result) => Number(result?.changes ?? 0) > 0));
408
- },
409
- };
466
+ }
410
467
  }