@jarenjs/db 0.75.0 → 0.83.3

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 (50) hide show
  1. package/ARCHITECTURE.md +20 -0
  2. package/README.md +25 -0
  3. package/docs/HOSTS.md +17 -0
  4. package/docs/JOBS-FORMAT.md +26 -0
  5. package/docs/LIVE-FORMAT.md +4 -0
  6. package/docs/MIGRATION-FORMAT.md +34 -0
  7. package/docs/MODEL-FORMAT.md +132 -1
  8. package/docs/NATIVE-PLANS.md +111 -0
  9. package/docs/SEARCH.md +55 -0
  10. package/package.json +8 -4
  11. package/schemas/jaren-migration.draft-07.schema.json +54 -5
  12. package/schemas/jaren-migration.schema.json +49 -0
  13. package/schemas/jaren-model.authoring.schema.json +360 -0
  14. package/schemas/jaren-model.draft-07.schema.json +128 -0
  15. package/schemas/jaren-model.schema.json +128 -0
  16. package/src/algebra.js +17 -1
  17. package/src/backup.js +12 -7
  18. package/src/cursor.js +27 -4
  19. package/src/dag-job.js +2 -1
  20. package/src/ddl.js +13 -0
  21. package/src/dialect.js +10 -0
  22. package/src/dialects/check-read.js +3 -3
  23. package/src/dialects/invariant-sql.js +117 -0
  24. package/src/dialects/postgres.js +14 -2
  25. package/src/dialects/sqlite.js +18 -2
  26. package/src/driver.js +1 -0
  27. package/src/drivers/bun.js +22 -4
  28. package/src/emit.js +80 -13
  29. package/src/entity.js +98 -41
  30. package/src/errors.js +8 -0
  31. package/src/graph.js +8 -1
  32. package/src/index.js +3 -0
  33. package/src/introspect.js +44 -7
  34. package/src/invariants.js +45 -0
  35. package/src/jobs.js +39 -6
  36. package/src/live.js +4 -1
  37. package/src/migrate.js +136 -22
  38. package/src/model.js +12 -0
  39. package/src/mutation.js +165 -0
  40. package/src/physical.js +147 -0
  41. package/src/plan.js +114 -20
  42. package/src/query.js +144 -70
  43. package/src/search.js +144 -0
  44. package/src/sql.js +60 -0
  45. package/src/store.js +49 -13
  46. package/src/tracker.js +63 -39
  47. package/src/window.js +1 -0
  48. package/types/index.d.ts +58 -3
  49. package/types/search.d.ts +20 -0
  50. package/types/typed.d.ts +1 -0
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
@@ -653,7 +658,7 @@ export function createEntityPredicateEmitters(dialect, param) {
653
658
  };
654
659
 
655
660
  const emitColumnPred = (aliasSql, pred) => {
656
- const column = `${aliasSql}.${q(pred.ref.column)}`;
661
+ const column = physicalComparable(pred.ref, `${aliasSql}.${q(pred.ref.column)}`, dialect);
657
662
  if (pred.p === 'typeIs') {
658
663
  if (pred.types.length === 0)
659
664
  return pred.positive ? `${column} IS NOT NULL` : `${column} IS NULL`;
@@ -710,7 +715,7 @@ export function createEntityPredicateEmitters(dialect, param) {
710
715
  const emitPred = (aliasSql, docSql, pred) => {
711
716
  if (pred.p === 'refCmp') return compareRefs(pred, (ref) => {
712
717
  if (ref.flavor === 'entity-column') {
713
- const value = `${aliasSql}.${q(ref.column)}`;
718
+ const value = physicalComparable(ref, `${aliasSql}.${q(ref.column)}`, dialect);
714
719
  return { value, present: `${value} IS NOT NULL` };
715
720
  }
716
721
  // Epoch columns retain the document's lexical comparison rule.
@@ -804,12 +809,27 @@ export function emitEntityPlan(plan, dialect, physicalOf) {
804
809
  const projectedPair = (leaf, suffix) => {
805
810
  const names = `${q(`v${suffix}`)}`;
806
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
+ }
807
822
  if (leaf.ref.flavor === 'entity-column') {
808
- 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';
809
829
  const type = leaf.ref.storage === 'boolean'
810
- ? `CASE WHEN ${column} IS NULL THEN NULL WHEN ${column} = 0 `
830
+ ? `CASE WHEN ${column} IS NULL THEN ${emptyType} WHEN ${column} = 0 `
811
831
  + `THEN ${sl('false')} ELSE ${sl('true')} END`
812
- : `CASE WHEN ${column} IS NULL THEN NULL ELSE ${sl(
832
+ : `CASE WHEN ${column} IS NULL THEN ${emptyType} ELSE ${sl(
813
833
  leaf.ref.storage === 'string' ? 'text'
814
834
  : leaf.ref.storage === 'integer'
815
835
  ? dialect.numericTypeNames[0]
@@ -829,7 +849,33 @@ export function emitEntityPlan(plan, dialect, physicalOf) {
829
849
  // every returned column plus the document rendered to text; the
830
850
  // caller merges them back into the entity shape — or, for a projected
831
851
  // shape, one value/type pair per DISTINCT leaf and no document at all
832
- 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'
833
879
  ? `COUNT(*) AS ${q('value')}`
834
880
  : plan.project != null
835
881
  // `p`-prefixed, because a bare `t0` would collide with this
@@ -839,15 +885,18 @@ export function emitEntityPlan(plan, dialect, physicalOf) {
839
885
  // a join-table root IS its two key columns: it has no document
840
886
  // column, so the merge is handed an empty one
841
887
  : physicalOf(entityOf.get(ret)).document === false
842
- ? `${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')}`
843
891
  : `${aliasOf(ret)}.*, ${dialect.jsonText(docOf(ret))} AS ${q('__doc')}`;
844
892
 
845
893
  const tableOf = (name) =>
846
894
  `${q(physicalOf(entityOf.get(name)).table)} AS ${aliasOf(name)}`;
847
895
  const JOIN_OPS = { eq: '=', ne: '<>', lt: '<', le: '<=', gt: '>', ge: '>=' };
848
896
  const onSql = (edge) =>
849
- `${aliasOf(edge.left.binding)}.${q(edge.left.column)}`
850
- + ` ${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);
851
900
 
852
901
  let sql = `SELECT ${selection} FROM `;
853
902
  // the JOIN order the planner settled: the first binding, then each
@@ -863,21 +912,39 @@ export function emitEntityPlan(plan, dialect, physicalOf) {
863
912
  .map((entry) => emitPred(entry.binding, entry.filter));
864
913
  if (filterSql.length > 0) sql += ` WHERE ${filterSql.join(' AND ')}`;
865
914
 
866
- 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) {
867
929
  const terms = (plan.order ?? []).map((term) => {
868
930
  // only a plain mapped column orders by its column; an epoch
869
931
  // path orders by the document string — codepoint order, exactly
870
932
  // the engine's — because mixed stored precisions would let the
871
933
  // integer column sort differently
872
934
  const value = term.ref.flavor === 'entity-column'
873
- ? `${aliasOf(term.binding)}.${q(term.ref.column)}`
935
+ ? physicalComparable(term.ref, `${aliasOf(term.binding)}.${q(term.ref.column)}`, dialect)
874
936
  : dialect.jsonExtract(docOf(term.binding), pathTextOf(term.ref), 'text');
875
937
  const nullsFirst = term.emptyGreatest === term.desc;
876
938
  return `${value} ${term.desc ? 'DESC' : 'ASC'}${dialect.orderNulls(nullsFirst)}`;
877
939
  });
878
940
  // the engine's nested-loop order: binding-order row identities
879
- for (const binding of plan.bindings)
880
- 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
+ }
881
948
  sql += ` ORDER BY ${terms.join(', ')}`;
882
949
  if (plan.window !== null)
883
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
  }
package/src/errors.js CHANGED
@@ -46,6 +46,7 @@ export const DB_CODES = Object.freeze({
46
46
  JD0035: 'the continuation does not belong to this ordering',
47
47
  JD0036: 'a snapshot page needs an immutable ordering',
48
48
  JD0037: 'strictStreaming refused a plan that buffers',
49
+ JD0038: 'the native mutation document is unsupported or invalid',
49
50
  JD0040: 'the save spans a relation cycle',
50
51
  JD0050: 'live queries require change capture',
51
52
  JD0051: 'the demanded live mode is unavailable',
@@ -109,6 +110,8 @@ export const DB_CODES = Object.freeze({
109
110
  JD2092: 'a worker transport bound was exceeded',
110
111
  JD2093: 'the worker protocol frame is invalid',
111
112
  JD2094: 'the durable snapshot failed and the connection is invalid',
113
+ JD2095: 'the trusted SQL or synchronous transaction authority was refused',
114
+ JD2096: 'a persistence invariant rejected the mutation',
112
115
  });
113
116
 
114
117
  /**
@@ -554,6 +557,11 @@ export function wrapDriverError(error, details = {}) {
554
557
  generic.retryable = false;
555
558
  return generic;
556
559
  }
560
+ if (typeof error?.message === 'string' && error.message.includes('jaren invariant:')) {
561
+ const wrapped = new DbRuntimeError('JD2096', error.message, { ...details, cause: error });
562
+ wrapped.class = 'constraint'; wrapped.retryable = false;
563
+ return wrapped;
564
+ }
557
565
  const classified = classifyDriverError(error, details.unique);
558
566
  const message = error?.message ?? String(error);
559
567
  /** @type {any} */
package/src/graph.js CHANGED
@@ -14,6 +14,8 @@
14
14
  * whole one.
15
15
  */
16
16
 
17
+ import { columnCodec } from './physical.js';
18
+
17
19
  import { DbRuntimeError } from './errors.js';
18
20
  import { utf8Length } from './cursor.js';
19
21
  import { jsonBytes, decodeCountedJson } from './json-bytes.js';
@@ -73,9 +75,14 @@ function checkBounds(node, include, keyed, raw, sizes) {
73
75
  * @returns {any}
74
76
  */
75
77
  export function mergeEntityRow(entityMapping, row, docField = 'doc') {
76
- const doc = JSON.parse(row[docField]);
78
+ const doc = entityMapping.document === false ? {} : JSON.parse(row[docField]);
77
79
  for (const column of entityMapping.columns) {
78
80
  if (column.source === 'epoch(document)') continue;
81
+ if (entityMapping.document === false && column.codec) {
82
+ const value = columnCodec(column).decode(row[column.physical ?? column.name]);
83
+ if (value !== undefined) Object.defineProperty(doc, column.name, { value, enumerable: true, writable: true, configurable: true });
84
+ continue;
85
+ }
79
86
  const value = row[column.name];
80
87
  if (value === null || value === undefined) continue;
81
88
  doc[column.name] = column.storage === 'boolean' ? value === 1 : value;
package/src/index.js CHANGED
@@ -100,3 +100,6 @@ export {
100
100
  export { createDagJobRunner, RUN_IDENTITY_NODE } from './dag-job.js';
101
101
  export { REPLICATION_VERSION, REPLICATION_DEFAULTS, normalizeFrontier,
102
102
  normalizeReplication, normalizeReplicationSnapshot, encodeReplication, replicationIdentity } from './replication-format.js';
103
+
104
+ export { planInvariants } from './ddl.js';
105
+ export { planPhysicalMigration } from './migrate.js';
package/src/introspect.js CHANGED
@@ -73,7 +73,7 @@ function loss(code, object, detail) {
73
73
  * @param {string} table
74
74
  * @returns {any} value-or-promise
75
75
  */
76
- function readTable(connection, table) {
76
+ function readTable(connection, table, objects) {
77
77
  const dialect = connection.dialect;
78
78
  const all = (sql) => chain(connection.prepare(sql), (statement) => statement.all([]));
79
79
  return chain(all(dialect.introspect.columns(table)), (columnRows) =>
@@ -97,6 +97,7 @@ function readTable(connection, table) {
97
97
  unique: Number(declared[i].uniq) !== 0,
98
98
  partial: Number(declared[i].partial ?? 0) !== 0,
99
99
  columns: rows.map((row) => row.name == null ? null : String(row.name)),
100
+ sql: objects.find((o) => o.type === 'index' && o.name === String(declared[i].name))?.sql ?? null,
100
101
  }]));
101
102
  };
102
103
  return chain(withColumns(0, []), (indexes) =>
@@ -106,11 +107,18 @@ function readTable(connection, table) {
106
107
  chain(dialect.introspect.checks === undefined ? []
107
108
  : all(dialect.introspect.checks(table)), (checkRows) => ({
108
109
  name: table,
109
- primaryKey: keyRows.map((row) => String(row.name)),
110
+ sql: objects.find((o) => o.type === 'table' && o.name === table)?.sql ?? null,
111
+ primaryKey: columnRows.some((row) => Number(row.pk) > 0)
112
+ ? columnRows.filter((row) => Number(row.pk) > 0)
113
+ .sort((a, b) => Number(a.pk) - Number(b.pk)).map((row) => String(row.name))
114
+ : keyRows.map((row) => String(row.name)),
110
115
  columns: columnRows.map((row) => ({
111
116
  name: String(row.name),
112
117
  type: String(row.type),
113
118
  generated: Number(row.hidden) !== 0,
119
+ primaryKeyOrdinal: Number(row.pk ?? 0),
120
+ nullable: Number(row.not_null ?? 0) === 0,
121
+ default: row.default_value ?? null,
114
122
  })),
115
123
  generated: dialect.readGenerated(generatedRows),
116
124
  indexes,
@@ -121,6 +129,10 @@ function readTable(connection, table) {
121
129
  targetColumn: row.target_column === null || row.target_column === undefined
122
130
  ? null : String(row.target_column),
123
131
  onDelete: String(row.on_delete ?? 'NO ACTION').toUpperCase(),
132
+ onUpdate: String(row.on_update ?? 'NO ACTION').toUpperCase(),
133
+ group: row.id ?? null,
134
+ ordinal: Number(row.seq ?? 0),
135
+ match: String(row.match ?? 'NONE'),
124
136
  })),
125
137
  }))));
126
138
  }))));
@@ -153,10 +165,23 @@ export function readSchema(connection, options = undefined) {
153
165
  if (String(row.type) === 'view') views.push(name);
154
166
  else names.push(name);
155
167
  }
156
- const step = (i, out) => (i >= names.length
157
- ? { tables: out, views }
158
- : chain(readTable(connection, names[i]), (table) => step(i + 1, [...out, table])));
159
- return step(0, []);
168
+ const readObjects = dialect.introspect.objects === undefined ? []
169
+ : chain(connection.prepare(dialect.introspect.objects()), (s) => s.all([]));
170
+ return chain(readObjects, (catalog) => {
171
+ const complete = [...catalog];
172
+ for (const row of rows) {
173
+ if (!complete.some((o) => o.type === row.type && o.name === row.name))
174
+ complete.push({ ...row, owner: row.name, sql: null });
175
+ }
176
+ const objects = complete.filter((row) => !engine.has(String(row.owner))
177
+ && (wanted === null || wanted.has(String(row.owner)) || wanted.has(String(row.name))))
178
+ .map((row) => ({ type: String(row.type), name: String(row.name),
179
+ owner: String(row.owner), sql: row.sql ?? null }));
180
+ const step = (i, out) => (i >= names.length
181
+ ? { tables: out, views, objects }
182
+ : chain(readTable(connection, names[i], objects), (table) => step(i + 1, [...out, table])));
183
+ return step(0, []);
184
+ });
160
185
  }));
161
186
  }
162
187
 
@@ -547,6 +572,11 @@ export function introspectModel(connection, options = undefined) {
547
572
  add(loss('unmapped-view', view,
548
573
  'a view is not a shape a model document can declare'));
549
574
  }
575
+ for (const object of schema.objects) {
576
+ if (object.type === 'trigger') add({ ...loss('unmapped-object', object.name,
577
+ 'an application-owned trigger program must be explicitly preserved; inspection grants no drop permission'),
578
+ sql: object.sql, owner: object.owner });
579
+ }
550
580
 
551
581
  const joinTables = new Set(schema.tables
552
582
  .filter((table) => looksLikeJoinTable(dialect, table))
@@ -610,6 +640,13 @@ export function introspectModel(connection, options = undefined) {
610
640
  + `thing(s) the model would — ${report.map((row) => `${row.code} (${row.object})`)
611
641
  .join(', ')}`);
612
642
  }
613
- return { model, report: Object.freeze(report.map((row) => Object.freeze(row))) };
643
+ const inventory = schema.objects.map((object) => Object.freeze({ ...object,
644
+ disposition: object.type === 'trigger' || object.type === 'view'
645
+ || report.some((row) => row.object === object.name
646
+ || row.object === `${object.owner}.${object.name}`
647
+ || (row.code === 'unmapped-table' && row.object === object.owner))
648
+ ? 'preserve' : 'derived' }));
649
+ return { model, report: Object.freeze(report.map((row) => Object.freeze(row))),
650
+ inventory: Object.freeze(inventory) };
614
651
  });
615
652
  }