@jarenjs/db 0.75.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.
- package/ARCHITECTURE.md +20 -0
- package/README.md +25 -0
- package/docs/HOSTS.md +17 -0
- package/docs/JOBS-FORMAT.md +26 -0
- package/docs/LIVE-FORMAT.md +4 -0
- package/docs/MIGRATION-FORMAT.md +34 -0
- package/docs/MODEL-FORMAT.md +132 -1
- package/docs/NATIVE-PLANS.md +111 -0
- package/docs/SEARCH.md +55 -0
- package/package.json +8 -4
- package/schemas/jaren-migration.draft-07.schema.json +54 -5
- package/schemas/jaren-migration.schema.json +49 -0
- package/schemas/jaren-model.authoring.schema.json +360 -0
- package/schemas/jaren-model.draft-07.schema.json +128 -0
- package/schemas/jaren-model.schema.json +128 -0
- package/src/algebra.js +17 -1
- package/src/backup.js +12 -7
- package/src/cursor.js +27 -4
- package/src/dag-job.js +2 -1
- package/src/ddl.js +13 -0
- package/src/dialect.js +10 -0
- package/src/dialects/check-read.js +3 -3
- package/src/dialects/invariant-sql.js +117 -0
- package/src/dialects/postgres.js +14 -2
- package/src/dialects/sqlite.js +18 -2
- package/src/driver.js +1 -0
- package/src/drivers/bun.js +22 -4
- package/src/emit.js +80 -13
- package/src/entity.js +98 -41
- package/src/errors.js +8 -0
- package/src/graph.js +8 -1
- package/src/index.js +3 -0
- package/src/introspect.js +44 -7
- package/src/invariants.js +45 -0
- package/src/jobs.js +39 -6
- package/src/live.js +4 -1
- package/src/migrate.js +136 -22
- package/src/model.js +12 -0
- package/src/mutation.js +165 -0
- package/src/physical.js +147 -0
- package/src/plan.js +114 -20
- package/src/query.js +144 -70
- package/src/search.js +144 -0
- package/src/sql.js +60 -0
- package/src/store.js +49 -13
- package/src/tracker.js +63 -39
- package/src/window.js +1 -0
- package/types/index.d.ts +58 -3
- package/types/search.d.ts +20 -0
- 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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
?
|
|
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']}
|
|
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 (
|
|
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
|
-
|
|
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) =>
|
|
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)}
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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) =>
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
-
() =>
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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
|
-
|
|
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
|
}
|