@jarenjs/db 0.66.1 → 0.72.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/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';
package/src/jobs.js CHANGED
@@ -557,6 +557,12 @@ export function createJobEngine(options) {
557
557
  const lease = job?.lease;
558
558
  const generation = isLease(lease) ? lease.generation : 0;
559
559
  return {
560
+ inspect: (runId, nodeId) => chain(
561
+ prepared('cpIdentity', `SELECT value FROM "${JOB_CHECKPOINTS_TABLE}"
562
+ WHERE run_id=? AND node_id=? AND generation <= ?`).get([runId, nodeId, generation]),
563
+ (row) => chain(prepared('cpHasValues', `SELECT 1 AS present FROM "${JOB_CHECKPOINTS_TABLE}"
564
+ WHERE run_id=? AND node_id<>? AND generation <= ? LIMIT 1`).get([runId, nodeId, generation]),
565
+ (other) => ({ value: row === undefined ? undefined : JSON.parse(row.value), hasValues: other !== undefined }))),
560
566
  load: (runId) => chain(
561
567
  prepared('cpLoad', `SELECT node_id, value FROM "${JOB_CHECKPOINTS_TABLE}"
562
568
  WHERE run_id = ? AND generation <= ?`).all([runId, generation]),
@@ -772,6 +778,39 @@ export function createJobEngine(options) {
772
778
  });
773
779
  };
774
780
 
781
+ /**
782
+ * Explicitly discard one inactive run's checkpoints and start a new attempt
783
+ * budget. The caller must name the observed generation; a concurrent claim
784
+ * or reset invalidates that authorization. External task effects are not undone.
785
+ * @param {string} id
786
+ * @param {{ expectedGeneration: number, signal?: AbortSignal, deadline?: number }} resetOptions
787
+ */
788
+ const reset = (id, resetOptions) => {
789
+ if (typeof id !== 'string' || id === '') throw new TypeError('reset: id is a non-empty string');
790
+ const expected = resetOptions?.expectedGeneration;
791
+ if (!Number.isSafeInteger(expected) || expected < 0)
792
+ throw new TypeError('reset: expectedGeneration is the observed non-negative lease generation');
793
+ refuseCancelled(resetOptions, now, { abortCode: 'JD2081', aborted: 'reset() ran', passed: 'reset() ran' });
794
+ const at = now();
795
+ return connection.transaction(() => chain(
796
+ prepared('resetJob', `UPDATE "${JOBS_TABLE}" SET state='pending', attempts=0,
797
+ result=NULL, last_error=NULL, run_at=?, updated_at=?, lease_until=NULL,
798
+ lease_owner=NULL, lease_token=NULL, lease_generation=lease_generation+1
799
+ WHERE id=? AND lease_generation=? AND state<>'done'
800
+ AND (state<>'leased' OR lease_until<=?)`).run([at, at, id, expected, at]),
801
+ (out) => {
802
+ if (Number(out.changes ?? 0) === 0) return chain(get(id), (job) => {
803
+ const code = job?.state === 'leased' && job.leaseUntil > at ? 'JD2068'
804
+ : job !== undefined && job.leaseGeneration !== expected ? 'JD2066' : 'JD2065';
805
+ throw new DbRuntimeError(code,
806
+ `reset() refused: job '${id}' is unknown, completed, actively leased, or its generation changed; read it again before resetting`,
807
+ { docPath: '/jobs', collection: JOBS_TABLE, key: id });
808
+ });
809
+ return chain(prepared('resetCheckpoints', `DELETE FROM "${JOB_CHECKPOINTS_TABLE}" WHERE run_id=?`).run([id]),
810
+ (removed) => { wakeAll(); return { reset: true, discarded: Number(removed.changes ?? 0), generation: expected + 1 }; });
811
+ }));
812
+ };
813
+
775
814
  /**
776
815
  * Delete settled jobs (done, dead, cancelled) whose last change is
777
816
  * older than `settledBefore`, oldest first, at most `limit` of them,
@@ -1014,6 +1053,8 @@ export function createJobEngine(options) {
1014
1053
  // so a checkpoint can neither join an unrelated application
1015
1054
  // transaction nor still be writing when stop() has resolved
1016
1055
  attempt.checkpoints = {
1056
+ inspect: (runId, nodeId) => io(() =>
1057
+ checkpointsFor({ ...job, lease: attempt.lease }).inspect(runId, nodeId), 'a checkpoint identity read'),
1017
1058
  load: (runId) => io(() =>
1018
1059
  checkpointsFor({ ...job, lease: attempt.lease }).load(runId), 'a checkpoint read'),
1019
1060
  save: (runId, nodeId, value) => io(() =>
@@ -1297,6 +1338,7 @@ export function createJobEngine(options) {
1297
1338
  cancel,
1298
1339
  settledLocally,
1299
1340
  requeue,
1341
+ reset,
1300
1342
  sweep,
1301
1343
  /** Stop every worker, bounded. Resolves to the per-worker outcome so
1302
1344
  * `close()` can report a handler it could not wait out rather than
@@ -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
+ }