@jarenjs/db 0.46.4 → 0.49.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,596 @@
1
+ //@ts-check
2
+ /**
3
+ * @file Event-time live views (LIVE-FORMAT §13): a `$resample` or
4
+ * `$rolling` document over a collection, maintained against an
5
+ * explicit watermark.
6
+ *
7
+ * There is no clock in this file, and there is none anywhere under it.
8
+ * A live view over time needs to know what "now" is — which bucket is
9
+ * still open, which reading counts as late — and the only honest
10
+ * source of that is the host, because the machine's clock is a
11
+ * different quantity from the instant a reading carries. So the
12
+ * watermark ARRIVES: it is a finite epoch supplied at registration and
13
+ * moved forward by `advance()`, it never goes backwards, and a test can
14
+ * put it wherever the story needs it without waiting for a timer.
15
+ *
16
+ * What the maintenance actually does:
17
+ *
18
+ * - **A bucket view keeps its rows by bucket.** A write touches one
19
+ * bucket (two, when it moves a reading across a boundary), and only
20
+ * those are folded again — through `resampleSeries` itself, over that
21
+ * bucket's own rows, so the aggregate is the kernel's and cannot
22
+ * drift from what a fresh query would answer.
23
+ * - **A rolling view keeps its rows in instant order.** A write at `t`
24
+ * can only change the windows ending in `[t, t + width)`, so exactly
25
+ * that stretch is recomputed — again by the kernel, over the slice
26
+ * that stretch can see.
27
+ *
28
+ * And what it refuses. A calendar ladder walks a wall clock, a named
29
+ * zone needs host code, `locf`/`linear` couple every bucket to its
30
+ * neighbours, and `first`/`last` name a row by a position a maintained
31
+ * map does not preserve. Each of those re-runs with its own reason
32
+ * rather than being approximated. So does a reading older than the
33
+ * declared lateness: the view re-runs, the emission carries a
34
+ * `lateData` record naming the instant and the boundary, and the row is
35
+ * never quietly folded into a bucket its reader already believed
36
+ * closed.
37
+ *
38
+ * `retention` is the horizon this view claims to work over. It is
39
+ * checked, not assumed: it must cover a whole window plus the lateness
40
+ * the caller allows, which is the span a single repair can read. It is
41
+ * NOT a compaction policy — the maintained state is bounded by
42
+ * `live.maxMaintained` exactly as every other strategy's is, and this
43
+ * file drops nothing that an answer still depends on.
44
+ */
45
+
46
+ import { compileJsonQuery } from '@jarenjs/json/query';
47
+ import { isJsonObject } from '@jarenjs/core/object';
48
+ import { compileBuckets, resampleSeries, rollingSeries, toEpoch } from '@jarenjs/core/series';
49
+
50
+ import { DbCompileError } from './errors.js';
51
+ import { chain } from './driver.js';
52
+ import { singularSelector } from './series.js';
53
+
54
+ /** The closed `eventTime` member set (§13.1). */
55
+ const EVENT_TIME_MEMBERS = Object.freeze(['path', 'watermark', 'allowedLateness', 'retention']);
56
+
57
+ /** The aggregates a maintained state answers exactly. `first`/`last`
58
+ * are absent for the planner's own reason: they name a row by its
59
+ * position in the series, and a per-key map does not keep one. */
60
+ const MAINTAINED_AGGREGATES = Object.freeze(['sum', 'mean', 'min', 'max', 'count']);
61
+
62
+ /** The fill policies an EMPTY bucket can answer on its own. `locf` and
63
+ * `linear` read their neighbours, so one late reading moves buckets it
64
+ * never belonged to — that is a re-run, not a repair. */
65
+ const MAINTAINED_FILLS = Object.freeze(['omit', 'null', 'zero']);
66
+
67
+ /**
68
+ * Validate the `eventTime` option into the record the classifier and
69
+ * the strategies read, or `null` when the caller declared none.
70
+ * @param {any} options - the live options
71
+ * @param {string} collection - for the error's `collection` property
72
+ * @returns {null | { member: string, watermark: number,
73
+ * allowedLateness: number, retention: number }}
74
+ * @throws {DbCompileError} `JD0053` for any member this does not admit
75
+ */
76
+ export function normalizeEventTime(options, collection) {
77
+ const declared = options?.eventTime;
78
+ if (declared === undefined || declared === null) return null;
79
+ const refuse = (reason) => {
80
+ throw new DbCompileError('JD0053', reason, { collection });
81
+ };
82
+ if (!isJsonObject(declared))
83
+ refuse('live eventTime is an object with a path, a watermark and a retention');
84
+ for (const name of Object.keys(declared)) {
85
+ if (!EVENT_TIME_MEMBERS.includes(name)) {
86
+ refuse(`live eventTime has no member '${name}' — it admits ${
87
+ EVENT_TIME_MEMBERS.map((m) => `'${m}'`).join(', ')}`);
88
+ }
89
+ }
90
+ const member = singularSelector(declared.path);
91
+ if (member === null)
92
+ refuse("live eventTime.path is a singular row selector naming the instant member, like '$.at'");
93
+ const finite = (value, name) => {
94
+ if (typeof value !== 'number' || !Number.isFinite(value))
95
+ refuse(`live eventTime.${name} is a finite epoch in milliseconds, not ${JSON.stringify(value)}`);
96
+ return value;
97
+ };
98
+ const watermark = finite(declared.watermark, 'watermark');
99
+ const retention = finite(declared.retention ?? NaN, 'retention');
100
+ if (retention <= 0)
101
+ refuse(`live eventTime.retention is a positive span, not ${retention}`);
102
+ const allowedLateness = declared.allowedLateness === undefined
103
+ ? 0 : finite(declared.allowedLateness, 'allowedLateness');
104
+ if (allowedLateness < 0)
105
+ refuse(`live eventTime.allowedLateness is not negative, unlike ${allowedLateness}`);
106
+ return { member: /** @type {string} */ (member), watermark, allowedLateness, retention };
107
+ }
108
+
109
+ /**
110
+ * The rows behind a series operand, as one document: the bare
111
+ * collection, or the collection under the operand's own `$where`.
112
+ * Anything else — a projection, a second binding, an ordering — is not
113
+ * a shape whose rows a key can be tracked through.
114
+ * @param {any} operand - the operator's first (or right) argument
115
+ * @returns {{ source: any } | null}
116
+ */
117
+ function operandSource(operand) {
118
+ if (operand === '$[*]') return { source: { $for: { it: '$[*]' }, $return: '$it' } };
119
+ if (!isJsonObject(operand) || !isJsonObject(operand.$for)) return null;
120
+ const names = Object.keys(operand.$for);
121
+ if (names.length !== 1 || operand.$for[names[0]] !== '$[*]') return null;
122
+ const binding = names[0];
123
+ const allowed = new Set(['$for', '$where', '$return']);
124
+ if (!Object.keys(operand).every((key) => allowed.has(key))) return null;
125
+ if (operand.$return !== `$${binding}`) return null;
126
+ return {
127
+ source: {
128
+ $for: { [binding]: '$[*]' },
129
+ ...(operand.$where !== undefined ? { $where: operand.$where } : {}),
130
+ $return: `$${binding}`,
131
+ },
132
+ };
133
+ }
134
+
135
+ /**
136
+ * Classify a document as an event-time view, or say why it is not one.
137
+ *
138
+ * Returns `null` when the document does not name `$resample` or
139
+ * `$rolling` over this collection at all — the caller then goes on to
140
+ * §7's ordinary table. Every other outcome is a decision: a maintained
141
+ * description, or `{ strategy: 'rerun', reason }`.
142
+ * @param {any} inner - the unwrapped document
143
+ * @param {boolean} windowed - whether a `$subsequence` wrapped it
144
+ * @param {boolean} keyed
145
+ * @param {null | { member: string, watermark: number,
146
+ * allowedLateness: number, retention: number }} eventTime
147
+ * @returns {any}
148
+ */
149
+ export function classifyEventTime(inner, windowed, keyed, eventTime) {
150
+ if (!isJsonObject(inner)) return null;
151
+ const keys = Object.keys(inner);
152
+ if (keys.length !== 1) return null;
153
+ const name = keys[0];
154
+ if (name !== '$resample' && name !== '$rolling') return null;
155
+ const args = inner[name];
156
+ if (!Array.isArray(args) || args.length !== 2) return null;
157
+ const operand = operandSource(args[0]);
158
+ if (operand === null) return null;
159
+
160
+ const rerun = (reason) => ({ strategy: 'rerun', reason });
161
+ const spec = args[1];
162
+ if (!isJsonObject(spec)) return null;
163
+ if (eventTime === null) {
164
+ return rerun(`'${name}' — a temporal view maintains event time, and none was declared `
165
+ + '(live options need an eventTime with a finite watermark)');
166
+ }
167
+ if (windowed) return rerun(`'${name}' — a windowed temporal view re-runs`);
168
+ if (!keyed) return rerun('rows without a document key cannot be tracked');
169
+
170
+ // the instant the state places a row by must be the instant the
171
+ // kernel aggregates it by, or the two would disagree row for row
172
+ const at = spec.at === undefined ? 'at' : singularSelector(spec.at);
173
+ if (at === null || at !== eventTime.member) {
174
+ return rerun(`'${name}' — eventTime.path names '${eventTime.member}' and the spec reads `
175
+ + `${at === null ? 'a selector this view cannot follow' : `'${at}'`}`);
176
+ }
177
+ if (spec.zone !== undefined && spec.zone !== 'UTC') {
178
+ return rerun(`'${name}' — a named zone resolves through the injected provider, which `
179
+ + 'maintenance would have to consult per boundary');
180
+ }
181
+ const aggregate = spec.aggregate ?? 'mean';
182
+ if (!MAINTAINED_AGGREGATES.includes(aggregate)) {
183
+ return rerun(`'${name}' — '${aggregate}' names a row by its position in the series, `
184
+ + 'which a per-key state does not preserve');
185
+ }
186
+
187
+ // the ladder (or the window) through the kernel's own compiler, so
188
+ // 'PT1H', 3600000 and 'PT60M' are one width and the default anchor is
189
+ // the kernel's rather than a second guess at it
190
+ const span = (() => {
191
+ try {
192
+ return compileBuckets(name === '$resample' ? spec : spec.width, spec);
193
+ }
194
+ catch {
195
+ return null;
196
+ }
197
+ })();
198
+ if (span === null)
199
+ return rerun(`'${name}' — the temporal kernel refuses this specification`);
200
+ if (span.calendar) {
201
+ return rerun(`'${name}' — a calendar ladder walks a wall clock and a month has no width, `
202
+ + 'so its boundaries move with the data rather than with arithmetic');
203
+ }
204
+ const covered = span.width + eventTime.allowedLateness;
205
+ if (eventTime.retention < covered) {
206
+ return rerun(`'${name}' — a retention of ${eventTime.retention} ms does not cover `
207
+ + `${covered} ms of window plus allowed lateness, so a repair could read outside `
208
+ + 'the horizon this view claims');
209
+ }
210
+
211
+ if (name === '$rolling') {
212
+ const minPeriods = spec.minPeriods ?? 1;
213
+ if (!Number.isInteger(minPeriods) || minPeriods < 1)
214
+ return rerun("'$rolling' — the temporal kernel refuses this specification");
215
+ return {
216
+ strategy: 'rolling',
217
+ source: operand.source,
218
+ spec,
219
+ member: eventTime.member,
220
+ width: span.width,
221
+ eventTime,
222
+ deps: { whole: true, members: new Set() },
223
+ };
224
+ }
225
+
226
+ const fill = spec.fill ?? 'omit';
227
+ if (!MAINTAINED_FILLS.includes(fill)) {
228
+ return rerun(`'$resample' — '${fill}' fills an empty bucket from its neighbours, so one `
229
+ + 'late reading moves buckets it never belonged to');
230
+ }
231
+ const ladder = span;
232
+ const start = boundOf(spec.start);
233
+ const end = boundOf(spec.end);
234
+ if (start === false || end === false || (start !== null && end !== null && !(start < end)))
235
+ return rerun("'$resample' — the temporal kernel refuses this specification");
236
+ return {
237
+ strategy: 'bucket',
238
+ source: operand.source,
239
+ spec,
240
+ member: eventTime.member,
241
+ ladder,
242
+ fill,
243
+ aggregate,
244
+ start,
245
+ end,
246
+ eventTime,
247
+ deps: { whole: true, members: new Set() },
248
+ };
249
+ }
250
+
251
+ /**
252
+ * A window bound as an epoch, `null` when absent, `false` when it names
253
+ * no instant (the kernel's own refusal, asked before a plan exists).
254
+ * @param {any} value
255
+ * @returns {number | null | false}
256
+ */
257
+ function boundOf(value) {
258
+ if (value === undefined) return null;
259
+ try {
260
+ return toEpoch(value);
261
+ }
262
+ catch {
263
+ return false;
264
+ }
265
+ }
266
+
267
+ /**
268
+ * The shared half of both event-time strategies: the watermark, the
269
+ * lateness boundary, per-key row bookkeeping and the re-run a too-late
270
+ * reading forces.
271
+ * @param {any} description
272
+ * @param {any} context
273
+ */
274
+ function eventTimeBase(description, context) {
275
+ const { member } = description;
276
+ const evaluate = compileJsonQuery([description.source]);
277
+ const stats = { lateData: 0, reruns: 0, recomputes: 0 };
278
+ let watermark = description.eventTime.watermark;
279
+
280
+ /** The instant a row is late BEFORE. */
281
+ const boundary = () => watermark - description.eventTime.allowedLateness;
282
+
283
+ /** The row this document contributes, or `undefined` for none. */
284
+ const rowOf = (doc) => {
285
+ if (doc === undefined) return undefined;
286
+ const items = /** @type {any[]} */ (evaluate([doc], context.externals));
287
+ return items.length === 0 ? undefined : items[0];
288
+ };
289
+
290
+ /** The instant a contributed row carries, through the kernel's own
291
+ * reader so a live view refuses exactly where a query would. */
292
+ const instantOf = (row) => toEpoch(row[member]);
293
+
294
+ const advance = (next) => {
295
+ if (typeof next !== 'number' || !Number.isFinite(next))
296
+ throw new TypeError(`a watermark is a finite epoch in milliseconds, not ${next}`);
297
+ if (next < watermark) {
298
+ throw new TypeError(
299
+ `a watermark only advances: ${next} is behind the current ${watermark}`);
300
+ }
301
+ watermark = next;
302
+ };
303
+
304
+ return {
305
+ stats,
306
+ rowOf,
307
+ instantOf,
308
+ advance,
309
+ boundary,
310
+ watermarkOf: () => watermark,
311
+ /** The `lateData` record an emission carries when a reading landed
312
+ * behind the boundary. */
313
+ late: (at, key) => ({
314
+ reason: 'late-data',
315
+ at,
316
+ key,
317
+ watermark,
318
+ allowedLateness: description.eventTime.allowedLateness,
319
+ boundary: boundary(),
320
+ }),
321
+ };
322
+ }
323
+
324
+ /**
325
+ * `$resample` over a fixed ladder: one maintained fold per bucket.
326
+ * @param {any} description
327
+ * @param {any} context
328
+ */
329
+ export function bucketStrategy(description, context) {
330
+ const { ladder, fill, aggregate, start, end, spec } = description;
331
+ const base = eventTimeBase(description, context);
332
+
333
+ /** @type {Map<string, { at: number, bucket: number, row: any }>} */
334
+ const placed = new Map();
335
+ /** @type {Map<number, Map<string, any>>} bucket start → its rows */
336
+ const rows = new Map();
337
+ /** @type {Map<number, { at: number, value: number|null, count: number }>} */
338
+ const folded = new Map();
339
+
340
+ /** Is this instant inside the view's own half-open window? */
341
+ const inWindow = (at) => (start === null || at >= start) && (end === null || at < end);
342
+
343
+ /** Re-fold one bucket through the kernel — the same call, over the
344
+ * same rows, that a fresh query would make over this stretch. */
345
+ const refold = (bucket) => {
346
+ base.stats.recomputes += 1;
347
+ const held = rows.get(bucket);
348
+ if (held === undefined || held.size === 0) {
349
+ rows.delete(bucket);
350
+ folded.delete(bucket);
351
+ return;
352
+ }
353
+ const out = resampleSeries([...held.values()],
354
+ { ...spec, start: bucket, end: bucket + ladder.width, fill: 'null' });
355
+ folded.set(bucket, out[0]);
356
+ };
357
+
358
+ /** Put a document's row where it belongs, reporting the buckets that
359
+ * moved. `null` rows out of the window and rows with no contribution. */
360
+ const place = (key, row, touched) => {
361
+ const previous = placed.get(key);
362
+ if (previous !== undefined) {
363
+ rows.get(previous.bucket)?.delete(key);
364
+ touched.add(previous.bucket);
365
+ placed.delete(key);
366
+ }
367
+ if (row === undefined) return;
368
+ const at = base.instantOf(row);
369
+ if (!inWindow(at)) return;
370
+ const bucket = ladder.startOf(ladder.indexOf(at));
371
+ let held = rows.get(bucket);
372
+ if (held === undefined) {
373
+ held = new Map();
374
+ rows.set(bucket, held);
375
+ }
376
+ held.set(key, row);
377
+ placed.set(key, { at, bucket, row });
378
+ touched.add(bucket);
379
+ };
380
+
381
+ /** The result rows: the maintained folds, and — under a fill policy —
382
+ * the empty positions of the ladder between them. */
383
+ const emit = () => {
384
+ const starts = [...folded.keys()].sort((a, b) => a - b);
385
+ if (fill === 'omit') return starts.map((at) => folded.get(at));
386
+ if (starts.length === 0 && (start === null || end === null)) return [];
387
+ const empty = aggregate === 'count' ? 0 : (fill === 'zero' ? 0 : null);
388
+ const from = start === null ? starts[0] : start;
389
+ const last = end === null ? starts[starts.length - 1] : null;
390
+ const out = [];
391
+ let index = ladder.indexOf(from);
392
+ let at = ladder.startOf(index);
393
+ while (end === null ? at <= /** @type {number} */ (last) : at < end) {
394
+ out.push(folded.get(at) ?? { at, value: empty, count: 0 });
395
+ index += 1;
396
+ at = ladder.startOf(index);
397
+ }
398
+ return out;
399
+ };
400
+
401
+ return {
402
+ advance: base.advance,
403
+ stats: () => ({ ...base.stats, watermark: base.watermarkOf() }),
404
+ entries: () => placed.size + folded.size,
405
+ init: () => chain(context.execute([description.source], { externals: context.externals }),
406
+ (docs) => {
407
+ const touched = new Set();
408
+ for (const doc of /** @type {any[]} */ (docs))
409
+ place(context.keyOf(doc), base.rowOf(doc), touched);
410
+ for (const bucket of touched) refold(bucket);
411
+ return emit();
412
+ }),
413
+ apply(record) {
414
+ const touched = context.touchedKeys(record, description.deps);
415
+ if (touched === null) return null;
416
+ /** @type {any} */
417
+ let late = null;
418
+ const moved = new Set();
419
+ for (const [key, change] of touched) {
420
+ const doc = change.kind === 'delete' ? undefined
421
+ : change.kind === 'insert' ? change.doc : context.readRow(key);
422
+ const row = base.rowOf(doc);
423
+ const before = placed.get(key);
424
+ const after = row === undefined ? null : base.instantOf(row);
425
+ const boundary = base.boundary();
426
+ const behind = [before?.at, after].find(
427
+ (at) => typeof at === 'number' && inWindow(at) && at < boundary);
428
+ if (behind !== undefined) {
429
+ late = base.late(behind, key);
430
+ break;
431
+ }
432
+ place(key, row, moved);
433
+ }
434
+ if (late !== null) return { rebuild: true, late };
435
+ if (moved.size === 0) return null;
436
+ for (const bucket of moved) refold(bucket);
437
+ return { rows: emit() };
438
+ },
439
+ /** A re-run rebuilds the whole state from the store — the only
440
+ * answer to a reading the maintained state cannot place. */
441
+ rebuild() {
442
+ base.stats.lateData += 1;
443
+ base.stats.reruns += 1;
444
+ placed.clear();
445
+ rows.clear();
446
+ folded.clear();
447
+ return this.init();
448
+ },
449
+ };
450
+ }
451
+
452
+ /**
453
+ * `$rolling` over a fixed width: one output per input instant, with
454
+ * only the stretch a write can reach recomputed.
455
+ * @param {any} description
456
+ * @param {any} context
457
+ */
458
+ export function rollingStrategy(description, context) {
459
+ const { spec, width } = description;
460
+ const base = eventTimeBase(description, context);
461
+
462
+ /** @type {Map<string, { at: number, row: any }>} */
463
+ const placed = new Map();
464
+ /** Rows in instant order; ties keep insertion order, which the seven
465
+ * maintained aggregates cannot tell apart. @type {any[]} */
466
+ let ordered = [];
467
+ /** One output per row of `ordered`. @type {any[]} */
468
+ let out = [];
469
+
470
+ const compareAt = (a, b) => base.instantOf(a) - base.instantOf(b);
471
+
472
+ /** The first position whose instant is at or after `at`. */
473
+ const lowerBound = (at) => {
474
+ let lo = 0;
475
+ let hi = ordered.length;
476
+ while (lo < hi) {
477
+ const mid = (lo + hi) >> 1;
478
+ if (base.instantOf(ordered[mid]) < at) lo = mid + 1;
479
+ else hi = mid;
480
+ }
481
+ return lo;
482
+ };
483
+ /** The first position whose instant is after `at`. */
484
+ const upperBound = (at) => {
485
+ let lo = 0;
486
+ let hi = ordered.length;
487
+ while (lo < hi) {
488
+ const mid = (lo + hi) >> 1;
489
+ if (base.instantOf(ordered[mid]) <= at) lo = mid + 1;
490
+ else hi = mid;
491
+ }
492
+ return lo;
493
+ };
494
+
495
+ /**
496
+ * Recompute every window ending in `[from, to + width)` — the whole
497
+ * reach of a write at any instant in `[from, to]` — over the slice
498
+ * those windows can see. A window opens EXCLUSIVELY at `at - width`,
499
+ * so the slice starts one position past `from - width` and the
500
+ * kernel's answer for a row inside it is the answer it would give
501
+ * over the entire series.
502
+ */
503
+ const repair = (from, to) => {
504
+ base.stats.recomputes += 1;
505
+ const lo = upperBound(from - width);
506
+ const hi = lowerBound(to + width);
507
+ const answers = rollingSeries(ordered.slice(lo, hi), spec);
508
+ for (let i = lowerBound(from); i < hi; i++) out[i] = answers[i - lo];
509
+ };
510
+
511
+ const removeRow = (key) => {
512
+ const previous = placed.get(key);
513
+ if (previous === undefined) return null;
514
+ const at = lowerBound(previous.at);
515
+ for (let i = at; i < ordered.length; i++) {
516
+ if (ordered[i] === previous.row) {
517
+ ordered.splice(i, 1);
518
+ out.splice(i, 1);
519
+ break;
520
+ }
521
+ }
522
+ placed.delete(key);
523
+ return previous.at;
524
+ };
525
+
526
+ const insertRow = (key, row) => {
527
+ const at = base.instantOf(row);
528
+ const position = upperBound(at);
529
+ ordered.splice(position, 0, row);
530
+ out.splice(position, 0, null);
531
+ placed.set(key, { at, row });
532
+ return at;
533
+ };
534
+
535
+ return {
536
+ advance: base.advance,
537
+ stats: () => ({ ...base.stats, watermark: base.watermarkOf() }),
538
+ entries: () => placed.size,
539
+ init: () => chain(context.execute([description.source], { externals: context.externals }),
540
+ (docs) => {
541
+ ordered = [];
542
+ for (const doc of /** @type {any[]} */ (docs)) {
543
+ const row = base.rowOf(doc);
544
+ if (row === undefined) continue;
545
+ placed.set(context.keyOf(doc), { at: base.instantOf(row), row });
546
+ ordered.push(row);
547
+ }
548
+ ordered.sort(compareAt);
549
+ out = rollingSeries(ordered, spec);
550
+ return out.slice();
551
+ }),
552
+ apply(record) {
553
+ const touched = context.touchedKeys(record, description.deps);
554
+ if (touched === null) return null;
555
+ /** @type {any} */
556
+ let late = null;
557
+ let from = Infinity;
558
+ let to = -Infinity;
559
+ const moves = [];
560
+ for (const [key, change] of touched) {
561
+ const doc = change.kind === 'delete' ? undefined
562
+ : change.kind === 'insert' ? change.doc : context.readRow(key);
563
+ const row = base.rowOf(doc);
564
+ const before = placed.get(key)?.at;
565
+ const after = row === undefined ? undefined : base.instantOf(row);
566
+ const boundary = base.boundary();
567
+ const behind = [before, after].find(
568
+ (at) => typeof at === 'number' && at < boundary);
569
+ if (behind !== undefined) {
570
+ late = base.late(behind, key);
571
+ break;
572
+ }
573
+ moves.push({ key, row, before, after });
574
+ }
575
+ if (late !== null) return { rebuild: true, late };
576
+ for (const move of moves) {
577
+ for (const at of [move.before, move.after]) {
578
+ if (typeof at !== 'number') continue;
579
+ if (at < from) from = at;
580
+ if (at > to) to = at;
581
+ }
582
+ removeRow(move.key);
583
+ if (move.row !== undefined) insertRow(move.key, move.row);
584
+ }
585
+ if (from === Infinity) return null;
586
+ repair(from, to);
587
+ return { rows: out.slice() };
588
+ },
589
+ rebuild() {
590
+ base.stats.lateData += 1;
591
+ base.stats.reruns += 1;
592
+ placed.clear();
593
+ return this.init();
594
+ },
595
+ };
596
+ }
package/src/live.js CHANGED
@@ -26,6 +26,7 @@ import { DbCompileError, DbRuntimeError } from './errors.js';
26
26
  import { chain } from './driver.js';
27
27
  import { planQuery } from './plan.js';
28
28
  import { createSortedWindow } from './window.js';
29
+ import { classifyEventTime, bucketStrategy, rollingStrategy } from './live-time.js';
29
30
 
30
31
  /** The store-level live bounds and their defaults (§12: printed,
31
32
  * never silent). */
@@ -238,9 +239,11 @@ const SPATIAL_RERUN = {
238
239
  * columnByCanonical)
239
240
  * @param {boolean} keyed - whether documents carry their key (a
240
241
  * declared key pointer); unkeyed rows cannot be tracked by key
242
+ * @param {any} [eventTime] - the normalized `eventTime` option
243
+ * (`live-time.js`), or null when the caller declared none
241
244
  * @returns {any}
242
245
  */
243
- export function classifyLiveQuery(document, queryShape, keyed) {
246
+ export function classifyLiveQuery(document, queryShape, keyed, eventTime = null) {
244
247
  const rerun = (reason) => ({ strategy: 'rerun', reason });
245
248
  // the reason named is the first one that is NOT a spatial refinement:
246
249
  // a refinement narrows and never forces a re-run by itself
@@ -251,6 +254,12 @@ export function classifyLiveQuery(document, queryShape, keyed) {
251
254
  };
252
255
  const { inner, whole, windowed, offset, limit, aggregate } = unwrapDocument(document);
253
256
 
257
+ // §13: a document that IS a temporal operator over the collection
258
+ // answers to event time or re-runs, and never to the §7 table — the
259
+ // planner's own residual for it is a fetch, not a maintainable shape
260
+ const temporal = classifyEventTime(inner, windowed, keyed, eventTime);
261
+ if (temporal !== null) return temporal;
262
+
254
263
  if (aggregate !== null) {
255
264
  if (windowed) return rerun('a windowed aggregate maintains no accumulator');
256
265
  const planned = planQuery({ [aggregate.name]: inner }, queryShape, {});
@@ -865,13 +874,17 @@ export function createLiveRegistry(bounds) {
865
874
  execute: definition.execute,
866
875
  readRow: definition.readRow,
867
876
  keyOf: definition.keyOf,
877
+ // §8's touched-key reader, handed to the strategies rather than
878
+ // imported by them: `live-time.js` maintains its own state and
879
+ // must not become a second implementation of the pointer walk
880
+ touchedKeys: (record, deps) => touchedKeys(record, definition.name, deps),
881
+ };
882
+ const STRATEGIES = {
883
+ rows: rowsStrategy, window: windowStrategy, accumulator: accumulatorStrategy,
884
+ group: groupStrategy, bucket: bucketStrategy, rolling: rollingStrategy,
868
885
  };
869
- const strategy = classification.strategy === 'rows' ? rowsStrategy(classification, context)
870
- : classification.strategy === 'window' ? windowStrategy(classification, context)
871
- : classification.strategy === 'accumulator'
872
- ? accumulatorStrategy(classification, context)
873
- : classification.strategy === 'group' ? groupStrategy(classification, context)
874
- : rerunStrategy(classification, context);
886
+ const strategy = (STRATEGIES[classification.strategy] ?? rerunStrategy)(
887
+ classification, context);
875
888
 
876
889
  /** @type {Set<Function>} */
877
890
  const observers = new Set();
@@ -906,6 +919,19 @@ export function createLiveRegistry(bounds) {
906
919
  let outcome;
907
920
  try {
908
921
  outcome = strategy.apply(record, state.result.rows);
922
+ if (outcome !== null && outcome.ops === undefined) {
923
+ // §13: a reading behind the lateness boundary is never
924
+ // folded in silently — the view re-reads from the store,
925
+ // and the emission carries the reason EVEN when the rows
926
+ // did not move, because "nothing changed" is exactly what
927
+ // a reader must not conclude on its own here
928
+ const late = outcome.rebuild === true;
929
+ const fresh = late ? strategy.rebuild() : outcome.rows;
930
+ const rows = shareByValue(state.result.rows, fresh);
931
+ const ops = diffRows(state.result.rows, rows);
932
+ outcome = ops.length === 0 && !late ? null
933
+ : { ops, rows, ...(late ? { late: outcome.late } : {}) };
934
+ }
909
935
  if (outcome !== null) checkBound(strategy.entries(outcome.rows));
910
936
  }
911
937
  catch (error) {
@@ -926,7 +952,8 @@ export function createLiveRegistry(bounds) {
926
952
  state.stats.matched += 1;
927
953
  state.stats.emissions += 1;
928
954
  state.result = { rows: outcome.rows };
929
- const event = { patch: outcome.ops, seq: record.seq };
955
+ const event = { patch: outcome.ops, seq: record.seq,
956
+ ...(outcome.late === undefined ? {} : { lateData: outcome.late }) };
930
957
  for (const observer of observers) {
931
958
  try {
932
959
  observer(event);
@@ -956,6 +983,12 @@ export function createLiveRegistry(bounds) {
956
983
  get error() { return state.error; },
957
984
  mode,
958
985
  stats: () => ({ ...state.stats, ...(strategy.stats?.() ?? {}) }),
986
+ ...(strategy.advance === undefined ? {} : {
987
+ advance(watermark) {
988
+ if (state.status !== 'live') throw new TypeError('the live query is closed');
989
+ strategy.advance(watermark);
990
+ },
991
+ }),
959
992
  subscribe(observer) {
960
993
  if (state.status !== 'live') throw new TypeError('the live query is closed');
961
994
  observers.add(observer);