@jarenjs/db 0.56.0 → 0.67.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ARCHITECTURE.md +412 -56
- package/README.md +600 -57
- package/docs/HOSTS.md +269 -0
- package/docs/JOBS-FORMAT.md +293 -45
- package/docs/LIVE-FORMAT.md +169 -20
- package/docs/MIGRATION-FORMAT.md +142 -17
- package/docs/MODEL-FORMAT.md +752 -64
- package/docs/REPLICATION-FORMAT.md +208 -0
- package/package.json +21 -7
- package/schemas/jaren-model.draft-07.schema.json +224 -162
- package/schemas/jaren-model.schema.json +224 -162
- package/schemas/jaren-replication-snapshot.draft-07.schema.json +83 -0
- package/schemas/jaren-replication-snapshot.schema.json +83 -0
- package/schemas/jaren-replication.draft-07.schema.json +82 -0
- package/schemas/jaren-replication.schema.json +82 -0
- package/src/algebra.js +227 -9
- package/src/backup.js +161 -0
- package/src/cancellation.js +48 -0
- package/src/capture.js +230 -47
- package/src/cli.js +165 -59
- package/src/cursor.js +417 -0
- package/src/dag-job.js +154 -21
- package/src/ddl.js +102 -8
- package/src/dialect.js +268 -113
- package/src/dialects/expression-read.js +158 -0
- package/src/dialects/postgres.js +618 -0
- package/src/dialects/rtree-ddl.js +129 -0
- package/src/dialects/sqlite.js +244 -11
- package/src/document-files.js +311 -0
- package/src/document-steps.js +422 -0
- package/src/documents.js +335 -0
- package/src/driver.js +448 -61
- package/src/drivers/bun.js +37 -1
- package/src/drivers/indexeddb-snapshot.js +149 -0
- package/src/drivers/node-pool.js +11 -0
- package/src/drivers/node-worker-endpoint.js +105 -0
- package/src/drivers/node-worker.js +204 -0
- package/src/drivers/node.js +41 -7
- package/src/drivers/postgres.js +331 -0
- package/src/drivers/wasm-oo1.js +97 -0
- package/src/drivers/wasm-session.js +67 -0
- package/src/drivers/wasm.js +17 -83
- package/src/drivers/worker-pool.js +183 -0
- package/src/drivers/worker-protocol.js +79 -0
- package/src/drivers/worker-queue.js +60 -0
- package/src/emit.js +339 -48
- package/src/entity.js +20 -22
- package/src/errors.js +430 -19
- package/src/expression.js +284 -0
- package/src/graph.js +64 -8
- package/src/index.js +48 -17
- package/src/introspect.js +583 -0
- package/src/jobs.js +843 -107
- package/src/json-bytes.js +58 -0
- package/src/live-join.js +250 -0
- package/src/live-nested.js +120 -0
- package/src/live.js +18 -4
- package/src/logical-rows.js +90 -0
- package/src/maintenance.js +175 -0
- package/src/migrate.js +248 -181
- package/src/model.js +68 -0
- package/src/plan.js +1119 -138
- package/src/pragmas.js +314 -0
- package/src/profile.js +151 -3
- package/src/query.js +1634 -323
- package/src/replication-format.js +115 -0
- package/src/replication.js +332 -0
- package/src/residual.js +17 -0
- package/src/series.js +12 -4
- package/src/store.js +1567 -273
- package/src/tracker.js +203 -29
- package/src/udf.js +88 -7
- package/types/index.d.ts +1158 -27
- package/types/node-pool.d.ts +28 -0
- package/types/node-worker.d.ts +54 -0
- package/types/node.d.ts +69 -2
- package/types/postgres.d.ts +46 -0
- package/types/typed.d.ts +27 -4
- package/types/wasm.d.ts +14 -0
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
/** JSON byte accounting over decoded data, without allocating encoded text. */
|
|
3
|
+
|
|
4
|
+
/** UTF-8 bytes of a JSON string, including quotes and well-formed escapes.
|
|
5
|
+
* @param {string} value @returns {number}
|
|
6
|
+
*/
|
|
7
|
+
export function jsonStringBytes(value) {
|
|
8
|
+
let bytes = 2;
|
|
9
|
+
for (let i = 0; i < value.length; i++) {
|
|
10
|
+
const code = value.charCodeAt(i);
|
|
11
|
+
if (code === 34 || code === 92 || code === 8 || code === 9
|
|
12
|
+
|| code === 10 || code === 12 || code === 13) bytes += 2;
|
|
13
|
+
else if (code < 32) bytes += 6;
|
|
14
|
+
else if (code < 128) bytes++;
|
|
15
|
+
else if (code < 2048) bytes += 2;
|
|
16
|
+
else if (code >= 0xd800 && code <= 0xdfff) {
|
|
17
|
+
const next = value.charCodeAt(i + 1);
|
|
18
|
+
if (code <= 0xdbff && next >= 0xdc00 && next <= 0xdfff) { bytes += 4; i++; }
|
|
19
|
+
else bytes += 6;
|
|
20
|
+
}
|
|
21
|
+
else bytes += 3;
|
|
22
|
+
}
|
|
23
|
+
return bytes;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** The JSON serialization size of decoded JSON data. Objects are memoized
|
|
27
|
+
* during decoding, so a nested include reads its size without another walk.
|
|
28
|
+
* @param {any} value @param {WeakMap<object, number>} [sizes]
|
|
29
|
+
* @returns {number}
|
|
30
|
+
*/
|
|
31
|
+
export function jsonBytes(value, sizes = new WeakMap()) {
|
|
32
|
+
if (value === null) return 4;
|
|
33
|
+
if (typeof value === 'string') return jsonStringBytes(value);
|
|
34
|
+
if (typeof value === 'boolean') return value ? 4 : 5;
|
|
35
|
+
if (typeof value === 'number') return Number.isFinite(value) ? String(value).length : 4;
|
|
36
|
+
if (typeof value !== 'object') throw new TypeError('byte accounting requires decoded JSON data');
|
|
37
|
+
const cached = sizes.get(value);
|
|
38
|
+
if (cached !== undefined) return cached;
|
|
39
|
+
const keys = Object.keys(value);
|
|
40
|
+
let bytes = 2 + Math.max(0, keys.length - 1);
|
|
41
|
+
const array = Array.isArray(value);
|
|
42
|
+
for (const key of keys) bytes += (array ? 0 : jsonStringBytes(key) + 1) + jsonBytes(value[key], sizes);
|
|
43
|
+
sizes.set(value, bytes);
|
|
44
|
+
return bytes;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Decode and account bottom-up in the decoder's construction traversal.
|
|
48
|
+
* The caller checks the incoming text bound before decoding and checks the
|
|
49
|
+
* recorded nested bounds before attaching any reconstructed children.
|
|
50
|
+
* @param {string} text @param {WeakMap<object, number>} sizes
|
|
51
|
+
* @returns {any}
|
|
52
|
+
*/
|
|
53
|
+
export function decodeCountedJson(text, sizes) {
|
|
54
|
+
return JSON.parse(text, (_key, value) => {
|
|
55
|
+
if (value !== null && typeof value === 'object') jsonBytes(value, sizes);
|
|
56
|
+
return value;
|
|
57
|
+
});
|
|
58
|
+
}
|
package/src/live-join.js
ADDED
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
/** Indexed dependency maintenance over bounded, mapped entity roots. */
|
|
3
|
+
import { compileJsonQuery } from '@jarenjs/json/query';
|
|
4
|
+
import { stableStringify } from '@jarenjs/core/object';
|
|
5
|
+
import { decodeJSONPointerSegment } from '@jarenjs/json/pointer';
|
|
6
|
+
import { planEntityQuery, entityRoot } from './plan.js';
|
|
7
|
+
import { joinTableRoots } from './model.js';
|
|
8
|
+
import { keyToken } from './capture.js';
|
|
9
|
+
import { DbRuntimeError } from './errors.js';
|
|
10
|
+
import { utf8Length } from './cursor.js';
|
|
11
|
+
|
|
12
|
+
/** Compile eligibility from the existing planner and declared mapping. @param {any} document @param {any} entities @param {any} mapping @param {any} operators */
|
|
13
|
+
export function classifyEntityLive(document, entities, mapping, operators) {
|
|
14
|
+
const extra = joinTableRoots(entities, mapping);
|
|
15
|
+
const roots = new Map([...entities, ...extra.entities]);
|
|
16
|
+
const mapped = { ...mapping, entities: { ...mapping.entities, ...extra.mappings } };
|
|
17
|
+
// Canonical allowing-empty equality subquery: its native surrogate settles
|
|
18
|
+
// the dependency columns; the original expression still evaluates the tuple.
|
|
19
|
+
let surrogate = document;
|
|
20
|
+
let strategy = 'join';
|
|
21
|
+
const inner = Array.isArray(document) && document.length === 1 ? document[0] : document;
|
|
22
|
+
const entries = Object.entries(inner?.$for ?? {});
|
|
23
|
+
const globalRead = (node) => {
|
|
24
|
+
if (typeof node === 'string') return node === '$' || node.startsWith('$.') || node.startsWith('$[');
|
|
25
|
+
if (Array.isArray(node)) return node.some(globalRead);
|
|
26
|
+
return node !== null && typeof node === 'object'
|
|
27
|
+
&& Object.entries(node).some(([key, value]) => key !== '$for' && globalRead(value));
|
|
28
|
+
};
|
|
29
|
+
if (entries.length === 1 && typeof entries[0][1] === 'string'
|
|
30
|
+
&& Object.keys(inner).every((key) => ['$for', '$return'].includes(key))) {
|
|
31
|
+
const bindings = { ...inner.$for };
|
|
32
|
+
const predicates = [];
|
|
33
|
+
let valid = true;
|
|
34
|
+
const visit = (node) => {
|
|
35
|
+
if (node === null || typeof node !== 'object') return;
|
|
36
|
+
if (Array.isArray(node)) { node.forEach(visit); return; }
|
|
37
|
+
if (node.$for !== undefined) {
|
|
38
|
+
if (!Object.keys(node).every((key) => ['$for', '$where', '$return'].includes(key)) || node.$where === undefined) { valid = false; return; }
|
|
39
|
+
for (const [name, source] of Object.entries(node.$for)) {
|
|
40
|
+
if (Object.hasOwn(bindings, name) || typeof source !== 'string') { valid = false; return; }
|
|
41
|
+
bindings[name] = source;
|
|
42
|
+
}
|
|
43
|
+
predicates.push(node.$where);
|
|
44
|
+
visit(node.$return);
|
|
45
|
+
}
|
|
46
|
+
else Object.values(node).forEach(visit);
|
|
47
|
+
};
|
|
48
|
+
visit(inner.$return);
|
|
49
|
+
if (valid && predicates.length > 0 && !globalRead(inner.$return)) {
|
|
50
|
+
const terms = predicates.flatMap((predicate) => predicate.$and ?? [predicate]);
|
|
51
|
+
surrogate = [{ $for: bindings, $where: terms.length === 1 ? terms[0] : { $and: terms }, $return: `$${entries[0][0]}` }];
|
|
52
|
+
strategy = 'graph';
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
if (entries.length === 2 && Object.keys(inner).every((key) => ['$for', '$return'].includes(key))) {
|
|
56
|
+
const [outer, right] = entries;
|
|
57
|
+
const allowing = right[1];
|
|
58
|
+
const subquery = allowing?.$in;
|
|
59
|
+
const nested = Object.entries(subquery?.$for ?? {});
|
|
60
|
+
const equality = subquery?.$where?.$eq;
|
|
61
|
+
if (allowing?.['$allowing-empty'] === true && Object.keys(allowing).length === 2
|
|
62
|
+
&& nested.length === 1 && subquery.$return === `$${nested[0][0]}`
|
|
63
|
+
&& Object.keys(subquery).length === 3 && Object.keys(subquery.$where).length === 1
|
|
64
|
+
&& Array.isArray(equality) && equality.length === 2 && equality.every((part) => typeof part === 'string')
|
|
65
|
+
&& typeof outer[1] === 'string' && typeof nested[0][1] === 'string' && !globalRead(inner.$return)) {
|
|
66
|
+
const prefix = `$${nested[0][0]}.`;
|
|
67
|
+
surrogate = [{ $for: { [outer[0]]: outer[1], [right[0]]: nested[0][1] },
|
|
68
|
+
$where: { $eq: equality.map((part) => part.startsWith(prefix) ? `$${right[0]}.${part.slice(prefix.length)}` : part) },
|
|
69
|
+
$return: `$${outer[0]}` }];
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
const planned = planEntityQuery(surrogate, roots, mapped, operators);
|
|
73
|
+
const rerun = (reason) => ({ strategy: 'rerun', reason });
|
|
74
|
+
if (planned.mode !== 'native') return rerun(`entity dependency plan: ${planned.reasons[0]?.reason ?? 'unmapped expression'}`);
|
|
75
|
+
const plan = planned.plan;
|
|
76
|
+
if (plan.window !== null) return rerun('an entity offset or limit window re-runs');
|
|
77
|
+
if (plan.aggregate !== null) return rerun('an entity aggregate has no bounded tuple accumulator');
|
|
78
|
+
if (plan.order !== null) return rerun('an explicitly ordered entity join has no maintained ordering');
|
|
79
|
+
const bindings = plan.bindings.map((binding) => ({ ...binding, mapping: mapped.entities[binding.entity] }));
|
|
80
|
+
if (new Set(bindings.map((b) => b.entity)).size !== bindings.length) return rerun('a self join has ambiguous root invalidation');
|
|
81
|
+
const indexed = (binding, column) => {
|
|
82
|
+
const m = binding.mapping;
|
|
83
|
+
return m.keys[0] === column || m.indexes.some((index) => index.property === column)
|
|
84
|
+
|| m.foreignKeys.some((fk) => fk.column === column);
|
|
85
|
+
};
|
|
86
|
+
const edges = plan.joins.flatMap((join) => join.on);
|
|
87
|
+
for (const edge of edges) {
|
|
88
|
+
if (edge.op !== 'eq') return rerun('a non-equality join refinement re-runs');
|
|
89
|
+
for (const side of [edge.left, edge.right]) {
|
|
90
|
+
if (!indexed(bindings.find((binding) => binding.name === side.binding), side.column))
|
|
91
|
+
return rerun(`join dependency '${side.binding}.${side.column}' has no declared index`);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
if (bindings.some((binding) => binding.mapping.keys.length === 0)) return rerun('a join root has no stable key');
|
|
95
|
+
return { strategy, bindings, edges };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Maintain tuples through inverse key dependencies; query evaluation uses only affected root subsets.
|
|
99
|
+
* @param {any} description @param {any} context */
|
|
100
|
+
export function joinStrategy(description, context) {
|
|
101
|
+
const { bindings, edges } = description;
|
|
102
|
+
const inner = Array.isArray(context.document) ? context.document[0] : context.document;
|
|
103
|
+
const evaluate = compileJsonQuery([{ $subsequence: [inner, 0, context.maxMaintained + 1] }]);
|
|
104
|
+
const caches = new Map(bindings.map((binding) => [binding.name, new Map()]));
|
|
105
|
+
const positions = new Map(bindings.map((binding) => [binding.name, new Map()]));
|
|
106
|
+
const indices = new Map();
|
|
107
|
+
const items = new Map();
|
|
108
|
+
let inputCount = 0;
|
|
109
|
+
let outputCount = 0;
|
|
110
|
+
let maintainedBytes = 0;
|
|
111
|
+
const bytesOf = (value) => utf8Length(stableStringify(value));
|
|
112
|
+
const counts = { dependencyReads: 0, refreshedRoots: 0, reruns: 0 };
|
|
113
|
+
const root = bindings[0];
|
|
114
|
+
const keyOf = (binding, row) => keyToken(binding.mapping.keys.map((name) => row[name]));
|
|
115
|
+
const sorted = (binding, values) => [...values].sort((a, b) =>
|
|
116
|
+
positions.get(binding.name).get(keyOf(binding, a)) - positions.get(binding.name).get(keyOf(binding, b)));
|
|
117
|
+
const signature = (binding, column) => JSON.stringify([binding, column]);
|
|
118
|
+
for (const edge of edges) for (const side of [edge.left, edge.right]) indices.set(signature(side.binding, side.column), new Map());
|
|
119
|
+
const bound = () => {
|
|
120
|
+
const size = inputCount + outputCount;
|
|
121
|
+
if (size > context.maxMaintained) throw new DbRuntimeError('JD2060', `the join dependency state exceeds live.maxMaintained (${context.maxMaintained})`);
|
|
122
|
+
if (maintainedBytes > context.maxBytes) throw new DbRuntimeError('JD2060', 'the join dependency state exceeds live.maxBytes');
|
|
123
|
+
return size;
|
|
124
|
+
};
|
|
125
|
+
const indexRow = (binding, row, insert) => {
|
|
126
|
+
const token = keyOf(binding, row);
|
|
127
|
+
for (const [signatureKey, index] of indices) {
|
|
128
|
+
const [name, column] = JSON.parse(signatureKey);
|
|
129
|
+
if (name !== binding.name) continue;
|
|
130
|
+
const value = row[column];
|
|
131
|
+
if (value === undefined || value === null) continue;
|
|
132
|
+
const key = stableStringify(value);
|
|
133
|
+
if (insert) {
|
|
134
|
+
if (!index.has(key)) index.set(key, new Set());
|
|
135
|
+
index.get(key).add(token);
|
|
136
|
+
}
|
|
137
|
+
else {
|
|
138
|
+
const set = index.get(key); set?.delete(token);
|
|
139
|
+
if (set?.size === 0) index.delete(key);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
};
|
|
143
|
+
// A changed row walks equality edges in both directions to its bounded
|
|
144
|
+
// outer owners. The same walk works for multi-entity projection chains.
|
|
145
|
+
const connected = (binding, row) => {
|
|
146
|
+
const found = new Map(bindings.map((b) => [b.name, new Map()]));
|
|
147
|
+
const queue = [[binding, row]];
|
|
148
|
+
for (let i = 0; i < queue.length; i++) {
|
|
149
|
+
const [current, doc] = queue[i];
|
|
150
|
+
const key = keyOf(current, doc);
|
|
151
|
+
if (found.get(current.name).has(key)) continue;
|
|
152
|
+
found.get(current.name).set(key, doc);
|
|
153
|
+
// When finding owners, arriving at an owner completes this path.
|
|
154
|
+
// When evaluating one owner, crossing back to its siblings would
|
|
155
|
+
// widen a selective dependency into the entire connected component.
|
|
156
|
+
if (binding !== root && current === root) continue;
|
|
157
|
+
for (const edge of edges) {
|
|
158
|
+
const own = edge.left.binding === current.name ? edge.left : edge.right.binding === current.name ? edge.right : null;
|
|
159
|
+
if (own === null || doc[own.column] === undefined || doc[own.column] === null) continue;
|
|
160
|
+
const other = own === edge.left ? edge.right : edge.left;
|
|
161
|
+
if (binding === root && other.binding === root.name) continue;
|
|
162
|
+
const target = bindings.find((b) => b.name === other.binding);
|
|
163
|
+
for (const token of indices.get(signature(other.binding, other.column)).get(stableStringify(doc[own.column])) ?? []) {
|
|
164
|
+
const match = caches.get(other.binding).get(token);
|
|
165
|
+
if (!found.get(target.name).has(token)) queue.push([target, match]);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
if (queue.length > context.maxMaintained * Math.max(1, edges.length * 2))
|
|
169
|
+
throw new DbRuntimeError('JD2060', 'the join dependency fan-out exceeds live.maxMaintained');
|
|
170
|
+
}
|
|
171
|
+
return found;
|
|
172
|
+
};
|
|
173
|
+
const refresh = (key) => {
|
|
174
|
+
const row = caches.get(root.name).get(key);
|
|
175
|
+
if (row === undefined) {
|
|
176
|
+
const previous = items.get(key);
|
|
177
|
+
if (previous !== undefined) { outputCount -= previous.length; maintainedBytes -= bytesOf(previous); }
|
|
178
|
+
items.delete(key); return;
|
|
179
|
+
}
|
|
180
|
+
const related = connected(root, row);
|
|
181
|
+
const input = Object.fromEntries(bindings.map((binding) => [binding.entity,
|
|
182
|
+
binding === root ? [row] : sorted(binding, related.get(binding.name).values())]));
|
|
183
|
+
const result = evaluate(input, context.externals);
|
|
184
|
+
const fresh = result === undefined ? [] : Array.isArray(result) ? result : [result];
|
|
185
|
+
const previous = items.get(key) ?? [];
|
|
186
|
+
outputCount += fresh.length - previous.length;
|
|
187
|
+
maintainedBytes += bytesOf(fresh) - (items.has(key) ? bytesOf(previous) : 0);
|
|
188
|
+
items.set(key, fresh.map((item, i) => stableStringify(previous[i]) === stableStringify(item) ? previous[i] : item));
|
|
189
|
+
counts.refreshedRoots++;
|
|
190
|
+
bound();
|
|
191
|
+
};
|
|
192
|
+
const flatten = () => sorted(root, caches.get(root.name).values()).flatMap((row) => items.get(keyOf(root, row)) ?? []);
|
|
193
|
+
return {
|
|
194
|
+
close() { for (const cache of caches.values()) cache.clear(); for (const index of indices.values()) index.clear();
|
|
195
|
+
for (const position of positions.values()) position.clear(); items.clear(); },
|
|
196
|
+
init() {
|
|
197
|
+
for (const binding of bindings) {
|
|
198
|
+
const document = [{ $subsequence: [{ $for: { it: entityRoot(binding.entity) }, $return: '$it' }, 0, context.maxMaintained + 1] }];
|
|
199
|
+
const rows = context.execute(document, { externals: context.externals });
|
|
200
|
+
for (const row of rows) {
|
|
201
|
+
const key = keyOf(binding, row);
|
|
202
|
+
caches.get(binding.name).set(key, row); indexRow(binding, row, true);
|
|
203
|
+
inputCount++; maintainedBytes += bytesOf(row);
|
|
204
|
+
bound();
|
|
205
|
+
positions.get(binding.name).set(key, context.dependencyPosition(binding.entity, key));
|
|
206
|
+
}
|
|
207
|
+
bound();
|
|
208
|
+
}
|
|
209
|
+
for (const key of caches.get(root.name).keys()) refresh(key);
|
|
210
|
+
return flatten();
|
|
211
|
+
},
|
|
212
|
+
entries: bound,
|
|
213
|
+
stats: () => ({ ...counts }),
|
|
214
|
+
apply(record, previousRows) {
|
|
215
|
+
const changed = new Map();
|
|
216
|
+
const affected = new Set();
|
|
217
|
+
for (const operation of record.patch) {
|
|
218
|
+
const [table, token] = operation.path.split('/').slice(1).map(decodeJSONPointerSegment);
|
|
219
|
+
const binding = bindings.find((b) => b.entity === table);
|
|
220
|
+
if (binding) changed.set(JSON.stringify([binding.name, token]), { binding, token });
|
|
221
|
+
}
|
|
222
|
+
for (const { binding, token } of changed.values()) {
|
|
223
|
+
const old = caches.get(binding.name).get(token);
|
|
224
|
+
if (binding === root) affected.add(token);
|
|
225
|
+
if (old !== undefined) for (const key of connected(binding, old).get(root.name).keys()) affected.add(key);
|
|
226
|
+
}
|
|
227
|
+
for (const { binding, token } of changed.values()) {
|
|
228
|
+
const cache = caches.get(binding.name);
|
|
229
|
+
const old = cache.get(token);
|
|
230
|
+
if (old !== undefined) { indexRow(binding, old, false); inputCount--; maintainedBytes -= bytesOf(old); }
|
|
231
|
+
const row = context.readDependency(binding.entity, token);
|
|
232
|
+
counts.dependencyReads++;
|
|
233
|
+
if (row === undefined) { cache.delete(token); positions.get(binding.name).delete(token); }
|
|
234
|
+
else {
|
|
235
|
+
cache.set(token, row); indexRow(binding, row, true);
|
|
236
|
+
inputCount++; maintainedBytes += bytesOf(row);
|
|
237
|
+
positions.get(binding.name).set(token, context.dependencyPosition(binding.entity, token));
|
|
238
|
+
}
|
|
239
|
+
bound();
|
|
240
|
+
}
|
|
241
|
+
for (const { binding, token } of changed.values()) {
|
|
242
|
+
const row = caches.get(binding.name).get(token);
|
|
243
|
+
if (row !== undefined) for (const key of connected(binding, row).get(root.name).keys()) affected.add(key);
|
|
244
|
+
}
|
|
245
|
+
for (const key of affected) refresh(key);
|
|
246
|
+
const next = flatten();
|
|
247
|
+
return context.diff(previousRows, next);
|
|
248
|
+
},
|
|
249
|
+
};
|
|
250
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
/** Two-level groups use bounded per-parent recomputation over maintained leaves. */
|
|
3
|
+
import { compileJsonQuery } from '@jarenjs/json/query';
|
|
4
|
+
import { stableStringify } from '@jarenjs/core/object';
|
|
5
|
+
import { DbRuntimeError } from './errors.js';
|
|
6
|
+
import { utf8Length } from './cursor.js';
|
|
7
|
+
|
|
8
|
+
/** Recognise nested groups whose parent key is a singular source member. @param {any} document */
|
|
9
|
+
export function classifyNestedGroup(document) {
|
|
10
|
+
const inner = Array.isArray(document) && document.length === 1 ? document[0] : document;
|
|
11
|
+
if (inner === null || typeof inner !== 'object' || !inner.$for || !inner.$groupby) return null;
|
|
12
|
+
const bindings = Object.entries(inner.$for);
|
|
13
|
+
const groups = Object.entries(inner.$groupby);
|
|
14
|
+
if (bindings.length !== 1 || groups.length !== 1
|
|
15
|
+
|| !Object.keys(inner).every((key) => ['$for', '$where', '$groupby', '$return'].includes(key))) return null;
|
|
16
|
+
const [binding, source] = bindings[0];
|
|
17
|
+
if (!(source === '$[*]' || (Array.isArray(source) && source.length === 1 && source[0] === '$[*]'))) return null;
|
|
18
|
+
const key = groups[0][1];
|
|
19
|
+
if (typeof key !== 'string' || !key.startsWith(`$${binding}.`) || !/^\$[A-Za-z_][A-Za-z0-9_]*\.[A-Za-z_][A-Za-z0-9_]*$/.test(key)) return null;
|
|
20
|
+
let count = 0;
|
|
21
|
+
let supported = true;
|
|
22
|
+
const allowed = new Set(['$for', '$where', '$groupby', '$return', '$count', '$sum', '$avg', '$min', '$max', '$default', '$eq', '$ne', '$lt', '$le', '$gt', '$ge', '$and', '$or']);
|
|
23
|
+
const visit = (node) => {
|
|
24
|
+
if (typeof node === 'string' && (node === '$' || node.startsWith('$.') || node.startsWith('$['))) supported = false;
|
|
25
|
+
if (Array.isArray(node)) { node.forEach(visit); return; }
|
|
26
|
+
if (node === null || typeof node !== 'object') return;
|
|
27
|
+
if (node.$groupby) count++;
|
|
28
|
+
if (node !== inner && node.$for) {
|
|
29
|
+
const nested = Object.values(node.$for);
|
|
30
|
+
if (nested.length !== 1 || !(nested[0] === `$${binding}`
|
|
31
|
+
|| (Array.isArray(nested[0]) && nested[0].length === 1 && nested[0][0] === `$${binding}`))) supported = false;
|
|
32
|
+
}
|
|
33
|
+
for (const [name, value] of Object.entries(node)) {
|
|
34
|
+
if (node === inner && name === '$for') continue;
|
|
35
|
+
if (name.startsWith('$') && !allowed.has(name)) supported = false;
|
|
36
|
+
visit(value);
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
visit(inner);
|
|
40
|
+
if (count !== 2 || !supported) return null;
|
|
41
|
+
return { strategy: 'nested-group', inner, binding, key };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** @param {any} description @param {any} context */
|
|
45
|
+
export function nestedGroupStrategy(description, context) {
|
|
46
|
+
const { inner, binding, key } = description;
|
|
47
|
+
const keyOf = compileJsonQuery([{ $for: { [binding]: '$[*]' },
|
|
48
|
+
...(inner.$where === undefined ? {} : { $where: inner.$where }), $return: [key] }]);
|
|
49
|
+
const evaluate = compileJsonQuery([inner]);
|
|
50
|
+
const docs = new Map();
|
|
51
|
+
const groups = new Map();
|
|
52
|
+
const results = new Map();
|
|
53
|
+
const positions = new Map();
|
|
54
|
+
let maintainedBytes = 0;
|
|
55
|
+
let resultCount = 0;
|
|
56
|
+
const bytesOf = (value) => utf8Length(stableStringify(value));
|
|
57
|
+
const stats = { refreshedGroups: 0, dependencyReads: 0, reruns: 0 };
|
|
58
|
+
const groupOf = (doc) => stableStringify(keyOf([doc], context.externals));
|
|
59
|
+
const sorted = (keys) => [...keys].sort((a, b) => positions.get(a) - positions.get(b));
|
|
60
|
+
const check = () => {
|
|
61
|
+
const entries = docs.size + resultCount;
|
|
62
|
+
if (entries > context.maxMaintained) throw new DbRuntimeError('JD2060', 'nested group dependencies exceed live.maxMaintained');
|
|
63
|
+
if (maintainedBytes > context.maxBytes) throw new DbRuntimeError('JD2060', 'nested group dependencies exceed live.maxBytes');
|
|
64
|
+
return entries;
|
|
65
|
+
};
|
|
66
|
+
const add = (token, doc) => {
|
|
67
|
+
const group = groupOf(doc);
|
|
68
|
+
docs.set(token, doc);
|
|
69
|
+
maintainedBytes += bytesOf(doc);
|
|
70
|
+
positions.set(token, context.rowPosition(token));
|
|
71
|
+
if (!groups.has(group)) groups.set(group, new Set());
|
|
72
|
+
groups.get(group).add(token);
|
|
73
|
+
return group;
|
|
74
|
+
};
|
|
75
|
+
const refresh = (group) => {
|
|
76
|
+
const keys = groups.get(group);
|
|
77
|
+
if (!keys || keys.size === 0) {
|
|
78
|
+
const previous = results.get(group);
|
|
79
|
+
if (previous !== undefined) { maintainedBytes -= bytesOf(previous); resultCount -= previous.length; }
|
|
80
|
+
groups.delete(group); results.delete(group); return;
|
|
81
|
+
}
|
|
82
|
+
const next = evaluate(sorted(keys).map((token) => docs.get(token)), context.externals);
|
|
83
|
+
const previous = results.get(group);
|
|
84
|
+
maintainedBytes += bytesOf(next) - (previous === undefined ? 0 : bytesOf(previous));
|
|
85
|
+
resultCount += next.length - (previous?.length ?? 0);
|
|
86
|
+
results.set(group, stableStringify(previous) === stableStringify(next) ? previous : next);
|
|
87
|
+
stats.refreshedGroups++;
|
|
88
|
+
check();
|
|
89
|
+
};
|
|
90
|
+
const flatten = () => [...groups.keys()].sort((a, b) =>
|
|
91
|
+
positions.get(sorted(groups.get(a))[0]) - positions.get(sorted(groups.get(b))[0]))
|
|
92
|
+
.flatMap((group) => results.get(group) ?? []);
|
|
93
|
+
return {
|
|
94
|
+
close() { docs.clear(); groups.clear(); results.clear(); positions.clear(); },
|
|
95
|
+
init() {
|
|
96
|
+
const source = [{ $subsequence: [{ $for: { it: '$[*]' }, $return: '$it' }, 0, context.maxMaintained + 1] }];
|
|
97
|
+
for (const doc of context.execute(source, { externals: context.externals })) add(context.keyOf(doc), doc);
|
|
98
|
+
check();
|
|
99
|
+
for (const group of groups.keys()) refresh(group);
|
|
100
|
+
return flatten();
|
|
101
|
+
}, entries: check, stats: () => ({ ...stats }),
|
|
102
|
+
apply(record, previousRows) {
|
|
103
|
+
const changes = context.touchedKeys(record, { whole: true, members: new Set() });
|
|
104
|
+
if (changes === null) return null;
|
|
105
|
+
const affected = new Set();
|
|
106
|
+
for (const token of changes.keys()) {
|
|
107
|
+
const previous = docs.get(token);
|
|
108
|
+
if (previous !== undefined) {
|
|
109
|
+
const group = groupOf(previous); affected.add(group); groups.get(group).delete(token);
|
|
110
|
+
docs.delete(token); positions.delete(token); maintainedBytes -= bytesOf(previous);
|
|
111
|
+
}
|
|
112
|
+
const doc = context.readRow(token); stats.dependencyReads++;
|
|
113
|
+
if (doc !== undefined) affected.add(add(token, doc));
|
|
114
|
+
}
|
|
115
|
+
check();
|
|
116
|
+
for (const group of affected) refresh(group);
|
|
117
|
+
return context.diff(previousRows, flatten());
|
|
118
|
+
},
|
|
119
|
+
};
|
|
120
|
+
}
|
package/src/live.js
CHANGED
|
@@ -27,10 +27,12 @@ import { chain } from './driver.js';
|
|
|
27
27
|
import { planQuery } from './plan.js';
|
|
28
28
|
import { createSortedWindow } from './window.js';
|
|
29
29
|
import { classifyEventTime, bucketStrategy, rollingStrategy } from './live-time.js';
|
|
30
|
+
import { joinStrategy } from './live-join.js';
|
|
31
|
+
import { classifyNestedGroup, nestedGroupStrategy } from './live-nested.js';
|
|
30
32
|
|
|
31
33
|
/** The store-level live bounds and their defaults (§12: printed,
|
|
32
34
|
* never silent). */
|
|
33
|
-
export const LIVE_DEFAULTS = Object.freeze({ maxQueries: 64, maxMaintained: 10_000 });
|
|
35
|
+
export const LIVE_DEFAULTS = Object.freeze({ maxQueries: 64, maxMaintained: 10_000, maxBytes: 4194304 });
|
|
34
36
|
|
|
35
37
|
const AGGREGATE_MEMBERS = new Map([
|
|
36
38
|
['$count', 'count'], ['$sum', 'sum'], ['$avg', 'avg'],
|
|
@@ -263,6 +265,8 @@ export function classifyLiveQuery(document, queryShape, keyed, eventTime = null)
|
|
|
263
265
|
return `'${forcing.construct}' — ${forcing.reason}`;
|
|
264
266
|
};
|
|
265
267
|
const { inner, whole, windowed, offset, limit, aggregate } = unwrapDocument(document);
|
|
268
|
+
const nested = classifyNestedGroup(inner);
|
|
269
|
+
if (nested !== null && !windowed && keyed && aggregate === null) return nested;
|
|
266
270
|
|
|
267
271
|
// §13: a document that IS a temporal operator over the collection
|
|
268
272
|
// answers to event time or re-runs, and never to the §7 table — the
|
|
@@ -855,9 +859,11 @@ function rerunStrategy(description, context) {
|
|
|
855
859
|
/**
|
|
856
860
|
* The store-level live-query registry: registration against the §12
|
|
857
861
|
* bounds, capture-record delivery in commit order, lifecycle.
|
|
858
|
-
* @param {{ maxQueries: number, maxMaintained: number }} bounds
|
|
862
|
+
* @param {{ maxQueries: number, maxMaintained: number, maxBytes?: number }} bounds
|
|
859
863
|
*/
|
|
860
864
|
export function createLiveRegistry(bounds) {
|
|
865
|
+
if (bounds.maxBytes !== undefined && (!Number.isSafeInteger(bounds.maxBytes) || bounds.maxBytes < 1))
|
|
866
|
+
throw new TypeError('live.maxBytes must be a positive safe integer');
|
|
861
867
|
/** @type {Set<any>} */
|
|
862
868
|
const queries = new Set();
|
|
863
869
|
|
|
@@ -884,6 +890,12 @@ export function createLiveRegistry(bounds) {
|
|
|
884
890
|
execute: definition.execute,
|
|
885
891
|
readRow: definition.readRow,
|
|
886
892
|
keyOf: definition.keyOf,
|
|
893
|
+
readDependency: definition.readDependency,
|
|
894
|
+
dependencyPosition: definition.dependencyPosition,
|
|
895
|
+
rowPosition: definition.rowPosition,
|
|
896
|
+
maxMaintained: bounds.maxMaintained,
|
|
897
|
+
maxBytes: bounds.maxBytes ?? LIVE_DEFAULTS.maxBytes,
|
|
898
|
+
diff: diffAgainst,
|
|
887
899
|
// §8's touched-key reader, handed to the strategies rather than
|
|
888
900
|
// imported by them: `live-time.js` maintains its own state and
|
|
889
901
|
// must not become a second implementation of the pointer walk
|
|
@@ -891,7 +903,8 @@ export function createLiveRegistry(bounds) {
|
|
|
891
903
|
};
|
|
892
904
|
const STRATEGIES = {
|
|
893
905
|
rows: rowsStrategy, window: windowStrategy, accumulator: accumulatorStrategy,
|
|
894
|
-
group: groupStrategy, bucket: bucketStrategy, rolling: rollingStrategy,
|
|
906
|
+
group: groupStrategy, bucket: bucketStrategy, rolling: rollingStrategy, join: joinStrategy, graph: joinStrategy,
|
|
907
|
+
'nested-group': nestedGroupStrategy,
|
|
895
908
|
};
|
|
896
909
|
const strategy = (STRATEGIES[classification.strategy] ?? rerunStrategy)(
|
|
897
910
|
classification, context);
|
|
@@ -948,6 +961,7 @@ export function createLiveRegistry(bounds) {
|
|
|
948
961
|
state.status = 'errored';
|
|
949
962
|
state.error = error;
|
|
950
963
|
queries.delete(query);
|
|
964
|
+
strategy.close?.();
|
|
951
965
|
const failure = { error };
|
|
952
966
|
for (const observer of observers) {
|
|
953
967
|
try {
|
|
@@ -972,7 +986,7 @@ export function createLiveRegistry(bounds) {
|
|
|
972
986
|
}
|
|
973
987
|
},
|
|
974
988
|
close() {
|
|
975
|
-
if (state.status === 'live') state.status = 'closed';
|
|
989
|
+
if (state.status === 'live') { state.status = 'closed'; strategy.close?.(); }
|
|
976
990
|
queries.delete(query);
|
|
977
991
|
observers.clear();
|
|
978
992
|
},
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
/** Logical row access through the store's validation and physical column plans. */
|
|
3
|
+
import { chain } from './driver.js';
|
|
4
|
+
import { keyToken } from './capture.js';
|
|
5
|
+
import { DbRuntimeError } from './errors.js';
|
|
6
|
+
|
|
7
|
+
/** @param {any} options */
|
|
8
|
+
export function createLogicalRows({ connection, shapes, collectionCore, entityCore, capture, captureJoinDelete }) {
|
|
9
|
+
const dialect = connection.dialect;
|
|
10
|
+
const q = dialect.quoteIdentifier;
|
|
11
|
+
const statement = (sql, method, params = []) => chain(connection.prepare(sql), (s) => s[method](params));
|
|
12
|
+
const shapeOf = (table) => {
|
|
13
|
+
const shape = shapes.get(table);
|
|
14
|
+
if (!shape) throw new DbRuntimeError('JD2104', `replication names undeclared table '${table}'`);
|
|
15
|
+
return shape;
|
|
16
|
+
};
|
|
17
|
+
const keyColumns = (shape) => shape.keyIndexes.map((i) => shape.columns[i].name);
|
|
18
|
+
const partsOf = (shape, key) => {
|
|
19
|
+
if (shape.keyIndexes.length === 1) return [key];
|
|
20
|
+
let parts;
|
|
21
|
+
try { parts = JSON.parse(key); } catch { parts = null; }
|
|
22
|
+
if (!Array.isArray(parts) || parts.length !== shape.keyIndexes.length
|
|
23
|
+
|| parts.some((part) => typeof part !== 'string' && !(typeof part === 'number' && Number.isFinite(part))))
|
|
24
|
+
throw new DbRuntimeError('JD2104', 'replication contains an invalid composite key');
|
|
25
|
+
return parts;
|
|
26
|
+
};
|
|
27
|
+
const where = (keys) => keys.map((key) => `${q(key)} = ?`).join(' AND ');
|
|
28
|
+
const entityKey = (shape, key) => Object.fromEntries(keyColumns(shape).map((name, i) => [name, partsOf(shape, key)[i]]));
|
|
29
|
+
const read = (table, key) => {
|
|
30
|
+
const shape = shapeOf(table);
|
|
31
|
+
if (shape.kind === 'collection') return collectionCore(table).get(key);
|
|
32
|
+
if (shape.kind === 'entity') return entityCore(table).get(entityKey(shape, key));
|
|
33
|
+
return chain(statement(`SELECT * FROM ${q(table)} WHERE ${where(keyColumns(shape))}`, 'get', partsOf(shape, key)),
|
|
34
|
+
(row) => row === undefined ? undefined : { ...row });
|
|
35
|
+
};
|
|
36
|
+
const write = (operation) => {
|
|
37
|
+
const { table, key, before, after } = operation;
|
|
38
|
+
const shape = shapeOf(table);
|
|
39
|
+
if (shape.kind === 'collection') {
|
|
40
|
+
const core = collectionCore(table);
|
|
41
|
+
return after === null ? core.delete(key) : core.put(after, key);
|
|
42
|
+
}
|
|
43
|
+
const keys = keyColumns(shape);
|
|
44
|
+
let names;
|
|
45
|
+
let params;
|
|
46
|
+
let expressions;
|
|
47
|
+
if (after !== null) {
|
|
48
|
+
if (keyToken(keys.map((name) => after[name])) !== key)
|
|
49
|
+
throw new DbRuntimeError('JD2104', 'replication cannot change a row identity');
|
|
50
|
+
if (shape.kind === 'entity') {
|
|
51
|
+
const core = entityCore(table);
|
|
52
|
+
core.validateOnly(after);
|
|
53
|
+
const { values, rest } = core.plan.split(after);
|
|
54
|
+
names = [...values.map((value) => value.name), 'doc'];
|
|
55
|
+
params = [...values.map((value) => value.value), JSON.stringify(rest)];
|
|
56
|
+
expressions = names.map((name) => name === 'doc' ? dialect.jsonEncode('?') : '?');
|
|
57
|
+
}
|
|
58
|
+
else {
|
|
59
|
+
if (Object.keys(after).length !== keys.length)
|
|
60
|
+
throw new DbRuntimeError('JD2104', 'a membership row contains only its two keys');
|
|
61
|
+
names = keys;
|
|
62
|
+
params = keys.map((name) => after[name]);
|
|
63
|
+
expressions = keys.map(() => '?');
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
const sql = after === null ? `DELETE FROM ${q(table)} WHERE ${where(keys)}`
|
|
67
|
+
: before === null ? `INSERT INTO ${q(table)} (${names.map(q).join(', ')}) VALUES (${expressions.join(', ')})`
|
|
68
|
+
: `UPDATE ${q(table)} SET ${names.map((name, i) => `${q(name)} = ${expressions[i]}`).join(', ')} WHERE ${where(keys)}`;
|
|
69
|
+
return chain(after === null && shape.kind === 'entity' ? captureJoinDelete?.(table, partsOf(shape, key)) : null,
|
|
70
|
+
() => chain(statement(sql, 'run', after === null ? partsOf(shape, key)
|
|
71
|
+
: before === null ? params : [...params, ...partsOf(shape, key)]), () => {
|
|
72
|
+
if (capture.mode === 'journal') capture.record(table, partsOf(shape, key), before, after);
|
|
73
|
+
}));
|
|
74
|
+
};
|
|
75
|
+
return { read, write, tables: [...shapes.keys()],
|
|
76
|
+
position: (table, key) => {
|
|
77
|
+
const shape = shapeOf(table);
|
|
78
|
+
return chain(statement(`SELECT ${dialect.rowIdentity()} AS position FROM ${q(table)} WHERE ${where(keyColumns(shape))}`, 'get', partsOf(shape, key)),
|
|
79
|
+
(row) => row?.position);
|
|
80
|
+
},
|
|
81
|
+
empty: () => {
|
|
82
|
+
let result = true;
|
|
83
|
+
const entries = [...shapes.keys()];
|
|
84
|
+
const next = (i) => i === entries.length ? result : chain(
|
|
85
|
+
statement(`SELECT 1 AS present FROM ${q(entries[i])} LIMIT 1`, 'get'),
|
|
86
|
+
(row) => { result &&= row === undefined; return next(i + 1); });
|
|
87
|
+
return next(0);
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
}
|