@jarenjs/db 0.66.1 → 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.
@@ -0,0 +1,82 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://jarenjs.dev/schemas/jaren-replication",
4
+ "title": "Portable logical replication transaction",
5
+ "type": "object",
6
+ "additionalProperties": false,
7
+ "required": [
8
+ "$replication",
9
+ "replica",
10
+ "seq",
11
+ "frontier",
12
+ "model",
13
+ "operations"
14
+ ],
15
+ "properties": {
16
+ "$replication": {
17
+ "const": "0.1"
18
+ },
19
+ "replica": {
20
+ "type": "string",
21
+ "minLength": 1,
22
+ "maxLength": 128
23
+ },
24
+ "seq": {
25
+ "type": "integer",
26
+ "minimum": 1,
27
+ "maximum": 9007199254740991
28
+ },
29
+ "frontier": {
30
+ "type": "object",
31
+ "propertyNames": {
32
+ "type": "string",
33
+ "minLength": 1,
34
+ "maxLength": 128
35
+ },
36
+ "additionalProperties": {
37
+ "type": "integer",
38
+ "minimum": 0,
39
+ "maximum": 9007199254740991
40
+ }
41
+ },
42
+ "model": {
43
+ "type": "string",
44
+ "minLength": 1
45
+ },
46
+ "operations": {
47
+ "type": "array",
48
+ "minItems": 1,
49
+ "items": {
50
+ "type": "object",
51
+ "additionalProperties": false,
52
+ "required": [
53
+ "table",
54
+ "key",
55
+ "before",
56
+ "after"
57
+ ],
58
+ "properties": {
59
+ "table": {
60
+ "type": "string",
61
+ "pattern": "^[A-Za-z_][A-Za-z0-9_]*$"
62
+ },
63
+ "key": {
64
+ "type": "string"
65
+ },
66
+ "before": {
67
+ "type": [
68
+ "object",
69
+ "null"
70
+ ]
71
+ },
72
+ "after": {
73
+ "type": [
74
+ "object",
75
+ "null"
76
+ ]
77
+ }
78
+ }
79
+ }
80
+ }
81
+ }
82
+ }
package/src/capture.js CHANGED
@@ -328,7 +328,8 @@ export function translateOperations(connection, shapes, operations) {
328
328
  * mode: 'session' | 'journal',
329
329
  * log: boolean, retention: number,
330
330
  * now?: () => number,
331
- * bracket?: (fn: () => any) => any }} options - `now` is the clock a
331
+ * bracket?: (fn: () => any) => any,
332
+ * beforeCommit?: (patch: any[], context: any) => any }} options - `now` is the clock a
332
333
  * delivery is stamped with (the store's runtime record); the platform's
333
334
  * when absent. `bracket` runs the first-open DDL (the store's immediate
334
335
  * transaction on a writable store); bare when absent
@@ -353,6 +354,7 @@ export function createCaptureEngine(options) {
353
354
  // from the durable row — never an answer to a watermark question
354
355
  let seq = 0;
355
356
  let depth = 0;
357
+ let context = null;
356
358
  /** @type {any} */
357
359
  let session = null;
358
360
  /** @type {any[]} */
@@ -572,6 +574,7 @@ export function createCaptureEngine(options) {
572
574
  depth = 1;
573
575
  const cleanupFailure = () => {
574
576
  depth = 0;
577
+ context = null;
575
578
  if (session !== null) {
576
579
  session.close();
577
580
  session = null;
@@ -584,7 +587,7 @@ export function createCaptureEngine(options) {
584
587
  else journal = [];
585
588
  outcome = connection.transaction((...scopeArgs) =>
586
589
  chain(fn(...scopeArgs), (result) =>
587
- chain(collect(), (patch) => {
590
+ chain(collect(), (patch) => chain(options.beforeCommit?.(patch, context), () => {
588
591
  if (patch.length === 0) return { result, delivery: null };
589
592
  const at = clock();
590
593
  return chain(persist(patch, at), () => ({
@@ -597,7 +600,7 @@ export function createCaptureEngine(options) {
597
600
  patch,
598
601
  },
599
602
  }));
600
- })));
603
+ }))));
601
604
  }
602
605
  catch (error) {
603
606
  cleanupFailure();
@@ -605,6 +608,7 @@ export function createCaptureEngine(options) {
605
608
  }
606
609
  const finish = (bundle) => {
607
610
  depth = 0;
611
+ context = null;
608
612
  if (bundle.delivery !== null) pendingDeliveries.push(bundle.delivery);
609
613
  deliver();
610
614
  return bundle.result;
@@ -669,6 +673,12 @@ export function createCaptureEngine(options) {
669
673
  mark,
670
674
  truncate,
671
675
  record,
676
+ // Metadata belongs to this capture transaction and is cleared on every
677
+ // settlement, including a failure while collecting or persisting changes.
678
+ setContext(value) {
679
+ if (depth === 0) throw new TypeError('capture context needs an active transaction');
680
+ context = value;
681
+ },
672
682
  observe(fn) {
673
683
  if (typeof fn !== 'function')
674
684
  throw new TypeError('observe needs a function');
package/src/cursor.js CHANGED
@@ -384,11 +384,7 @@ export function drainPage(cursor, options) {
384
384
  if (maxBytes !== null && bytes + size > maxBytes) {
385
385
  if (items.length === 0) {
386
386
  return chain(cursor.return(), () => {
387
- throw new DbRuntimeError('JD2074',
388
- `the next item is ${size} serialised bytes, more than the page's maxBytes bound of `
389
- + `${maxBytes}; the continuation was not advanced — raise the bound, or bound the `
390
- + "item itself (an include's maxBytes, a narrower document)",
391
- { errors: [{ bytes: size, maxBytes, at: continuationOf(item) }] });
387
+ assertItemBytes(size, maxBytes, continuationOf(item));
392
388
  });
393
389
  }
394
390
  hasMore = true;
@@ -409,3 +405,13 @@ export function drainPage(cursor, options) {
409
405
  };
410
406
  return settling(step, () => cursor.return());
411
407
  }
408
+
409
+ /** The shared refusal for an indivisible item, including a replicated transaction.
410
+ * @param {number} size @param {number} maxBytes @param {any} [at] */
411
+ export function assertItemBytes(size, maxBytes, at) {
412
+ if (size > maxBytes) throw new DbRuntimeError('JD2074',
413
+ `the next item is ${size} serialised bytes, more than the page's maxBytes bound of `
414
+ + `${maxBytes}; the continuation was not advanced — raise the bound, or bound the `
415
+ + "item itself (an include's maxBytes, a narrower document)",
416
+ { errors: [{ bytes: size, maxBytes, at }] });
417
+ }
package/src/dialect.js CHANGED
@@ -171,7 +171,7 @@ function normalizeCapabilities(declared) {
171
171
  * usesIndex: (line: string, index: string) => boolean,
172
172
  * excludedRef: (columnSql: string) => string,
173
173
  * tx: { begin: string, beginImmediate: string, commit: string,
174
- * rollback: string,
174
+ * rollback: string, deferForeignKeys?: string,
175
175
  * savepoint: (n: string) => string, release: (n: string) => string,
176
176
  * rollbackTo: (n: string) => string },
177
177
  * pragma?: { set: (name: string, value: number | string) => string,
@@ -401,6 +401,7 @@ export const sqliteDialect = createDialect({
401
401
  tx: {
402
402
  begin: 'BEGIN',
403
403
  beginImmediate: 'BEGIN IMMEDIATE',
404
+ deferForeignKeys: 'PRAGMA defer_foreign_keys = ON',
404
405
  commit: 'COMMIT',
405
406
  rollback: 'ROLLBACK',
406
407
  savepoint: (n) => `SAVEPOINT ${quoteIdentifier(n)}`,
package/src/errors.js CHANGED
@@ -51,6 +51,14 @@ export const DB_CODES = Object.freeze({
51
51
  JD0051: 'the demanded live mode is unavailable',
52
52
  JD0052: 'the live-query bound was reached',
53
53
  JD0053: 'the live event-time declaration is invalid',
54
+ JD0060: 'the replication document is invalid',
55
+ JD2100: 'replication encountered a sequence or causal gap',
56
+ JD2101: 'an envelope identity has a different payload or origin',
57
+ JD2102: 'the replica identity or model revision disagrees',
58
+ JD2103: 'the conflict resolver returned an invalid decision',
59
+ JD2104: 'a logical row disagrees with its replication history',
60
+ JD2105: 'replication requires an explicit snapshot reset',
61
+ JD2106: 'replication exceeded its operation bound',
54
62
  JD0020: "the migration's from-shape does not match the database",
55
63
  JD0021: 'the migration is missing a required data transform',
56
64
  JD0022: 'an applied migration disagrees with the history record',
package/src/index.js CHANGED
@@ -98,3 +98,5 @@ export {
98
98
  describeValue, serializeResult,
99
99
  } from './jobs.js';
100
100
  export { createDagJobRunner, RUN_IDENTITY_NODE } from './dag-job.js';
101
+ export { REPLICATION_VERSION, REPLICATION_DEFAULTS, normalizeFrontier,
102
+ normalizeReplication, normalizeReplicationSnapshot, encodeReplication, replicationIdentity } from './replication-format.js';
@@ -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
  },