@jarenjs/db 0.46.5 → 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.
- package/ARCHITECTURE.md +106 -2
- package/README.md +136 -2
- package/dist/types/algebra.d.ts +34 -3
- package/dist/types/dialect.d.ts +5 -0
- package/dist/types/errors.d.ts +3 -0
- package/dist/types/live-time.d.ts +141 -0
- package/dist/types/live.d.ts +3 -1
- package/dist/types/plan.d.ts +2 -0
- package/dist/types/query.d.ts +2 -1
- package/dist/types/residual.d.ts +5 -2
- package/dist/types/series.d.ts +227 -0
- package/dist/types/store.d.ts +8 -1
- package/docs/LIVE-FORMAT.md +103 -0
- package/docs/MODEL-FORMAT.md +19 -0
- package/package.json +4 -4
- package/src/algebra.js +22 -3
- package/src/dialect.js +13 -0
- package/src/dialects/sqlite.js +19 -0
- package/src/emit.js +32 -6
- package/src/errors.js +3 -0
- package/src/live-time.js +596 -0
- package/src/live.js +41 -8
- package/src/plan.js +706 -16
- package/src/query.js +160 -11
- package/src/residual.js +15 -6
- package/src/series.js +349 -0
- package/src/store.js +22 -3
- package/types/index.d.ts +54 -2
package/src/live-time.js
ADDED
|
@@ -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
|
|
870
|
-
|
|
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);
|