@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.
- package/ARCHITECTURE.md +106 -2
- package/README.md +145 -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 +107 -13
- package/types/typed.d.ts +4 -4
package/src/series.js
ADDED
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
/**
|
|
3
|
+
* @file The temporal recognizer: which documents ask a §8.16 question,
|
|
4
|
+
* which of those a declared `(series, at)` index can answer, and what
|
|
5
|
+
* the honest reason is when it cannot.
|
|
6
|
+
*
|
|
7
|
+
* No SQL and no storage kind live here. The physical feature is the
|
|
8
|
+
* composite JSONPath index a model already declares
|
|
9
|
+
* (`{ "name": "by_series_at", "path": ["$.series", "$.at"] }`); this
|
|
10
|
+
* module only decides which of the three CLOSED shapes a planned
|
|
11
|
+
* selection is in:
|
|
12
|
+
*
|
|
13
|
+
* 1. **range** — an equality on every leading column of an instant
|
|
14
|
+
* index plus a half-open range on the instant column, ordered by
|
|
15
|
+
* the instant. The index seeks; nothing is left over.
|
|
16
|
+
* 2. **as-of** — the same prefix with ONE instant bound, ordered by
|
|
17
|
+
* the instant and cut to a finite window. One index seek per probe.
|
|
18
|
+
* 3. **bucket** — a fixed-width ladder over the instant column with
|
|
19
|
+
* the exact `sum|mean|min|max|count` aggregates, which is a
|
|
20
|
+
* `GROUP BY` over integer arithmetic.
|
|
21
|
+
*
|
|
22
|
+
* Everything else — a calendar ladder, a fill policy, a rolling window,
|
|
23
|
+
* an as-of JOIN, `first`/`last` — is a named core refinement: the
|
|
24
|
+
* database narrows through the index and `@jarenjs/core/series` (via
|
|
25
|
+
* the residual, which is the ENGINE running the caller's own document)
|
|
26
|
+
* decides. The narrowing is the contribution; the answer is always the
|
|
27
|
+
* engine's, which is what makes a refinement idempotent.
|
|
28
|
+
*
|
|
29
|
+
* Every refusal here has a CODE, and the code is the first word of the
|
|
30
|
+
* sentence the plan carries, so `explain().series.reasons[].code` and
|
|
31
|
+
* `explain().residual.reasons[].reason` cannot drift apart.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import { isNumericType } from './types.js';
|
|
35
|
+
|
|
36
|
+
/** The three §8.16 operators a whole document can BE. */
|
|
37
|
+
export const SERIES_ROOT_OPS = Object.freeze(['$resample', '$rolling', '$asof']);
|
|
38
|
+
|
|
39
|
+
/** Every §8.16 operator: naming one makes a document temporal. */
|
|
40
|
+
export const SERIES_OPS = Object.freeze([
|
|
41
|
+
'$overlaps', '$time-bucket', '$resample', '$rolling', '$asof']);
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The D5 aggregates a `GROUP BY` reproduces exactly, and the plan's
|
|
45
|
+
* name for each. `count` is `rows` because it counts SOURCE ROWS —
|
|
46
|
+
* duplicates and measured gaps included — which is `COUNT(*)` and not
|
|
47
|
+
* `COUNT(value)`; the six value aggregates skip a `null` reading,
|
|
48
|
+
* which is what SQL's aggregates already do with SQL `NULL`.
|
|
49
|
+
*
|
|
50
|
+
* `first` and `last` are deliberately absent: they name a row by its
|
|
51
|
+
* position in the series, and a group's order is not the series' order.
|
|
52
|
+
*/
|
|
53
|
+
export const NATIVE_AGGREGATES = Object.freeze({
|
|
54
|
+
mean: 'avg', sum: 'sum', min: 'min', max: 'max', count: 'rows',
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The closed reason table. A reason is a CODE and a sentence; the plan
|
|
59
|
+
* carries `"<code>: <sentence>"` so one string serves strict mode's
|
|
60
|
+
* refusal, `explain().residual.reasons` and the machine-readable
|
|
61
|
+
* `explain().series.reasons[].code` at once.
|
|
62
|
+
*/
|
|
63
|
+
export const SERIES_REASONS = Object.freeze({
|
|
64
|
+
'missing-series-prefix':
|
|
65
|
+
'no declared index ends with the instant column with every leading column pinned by an '
|
|
66
|
+
+ 'equality, so the fetch cannot seek and the engine reads the collection',
|
|
67
|
+
'calendar-width':
|
|
68
|
+
'a calendar ladder walks a wall clock and a month has no width, so the boundaries are '
|
|
69
|
+
+ 'computed in the temporal kernel',
|
|
70
|
+
'named-zone':
|
|
71
|
+
'a named zone resolves through the injected provider, which is host code the database '
|
|
72
|
+
+ 'does not have',
|
|
73
|
+
'fill-policy':
|
|
74
|
+
'what an EMPTY bucket says is a policy over buckets the fetch never produces, so the '
|
|
75
|
+
+ 'fill runs in the temporal kernel',
|
|
76
|
+
'rolling-refinement':
|
|
77
|
+
'a window measured in time answers once per input instant, so the kernel walks the '
|
|
78
|
+
+ 'fetched rows',
|
|
79
|
+
'asof-refinement':
|
|
80
|
+
'an as-of join walks both sides once, so the index bounds the fetch and the kernel joins',
|
|
81
|
+
'nonliteral-spec':
|
|
82
|
+
"'$time-bucket' takes its width and its origin as EXPRESSIONS, and a ladder computed per "
|
|
83
|
+
+ 'row cannot be a grouping key',
|
|
84
|
+
'unsupported-aggregate':
|
|
85
|
+
"'first' and 'last' name a row by its position in the series, which a group's order does "
|
|
86
|
+
+ 'not preserve',
|
|
87
|
+
'row-selector':
|
|
88
|
+
'the spec reads its instant or its reading through a row selector, and the native bucket '
|
|
89
|
+
+ 'reads the declared columns',
|
|
90
|
+
'instant-not-integer':
|
|
91
|
+
'the instant column is not declared a whole epoch, and bucket boundaries in SQL are '
|
|
92
|
+
+ 'integer arithmetic',
|
|
93
|
+
'value-not-numeric':
|
|
94
|
+
'the reading is not a schema-typed number, and a SQL aggregate over an untyped member '
|
|
95
|
+
+ 'answers where the engine refuses',
|
|
96
|
+
'nonnative-grouping':
|
|
97
|
+
'the grouping key or the projection is not the closed bucket shape',
|
|
98
|
+
'invalid-spec':
|
|
99
|
+
'the temporal kernel refuses this specification, so the engine\'s own refusal is the answer '
|
|
100
|
+
+ 'rather than a plan that would have answered where it raises',
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* One reason, in both spellings at once.
|
|
105
|
+
* @param {keyof SERIES_REASONS | string} code
|
|
106
|
+
* @param {string} construct - the operator or clause that forced it
|
|
107
|
+
* @returns {{ code: string, construct: string, reason: string }}
|
|
108
|
+
*/
|
|
109
|
+
export function seriesReason(code, construct) {
|
|
110
|
+
const sentence = SERIES_REASONS[code];
|
|
111
|
+
if (sentence === undefined)
|
|
112
|
+
throw new Error(`series planner: no reason text for '${code}'`);
|
|
113
|
+
return { code, construct, reason: `${code}: ${sentence}` };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Every declared index whose LAST covered column is `column`, with the
|
|
118
|
+
* columns before it as the prefix that must be pinned.
|
|
119
|
+
*
|
|
120
|
+
* `minColumns` is what keeps an ordinary query ordinary. A collection
|
|
121
|
+
* that declares `(age)` and is asked for `age > 21` is not asking a
|
|
122
|
+
* temporal question, and nothing in a column can say otherwise — so
|
|
123
|
+
* the shape D9 actually names, a COMPOSITE index whose last column is
|
|
124
|
+
* the instant, is what makes a plain selection temporal. A document
|
|
125
|
+
* that named a §8.16 operator has already said so itself, and reads
|
|
126
|
+
* the singular index too.
|
|
127
|
+
* @param {any} shape - { indexes?: { name, columns }[] }
|
|
128
|
+
* @param {string} column
|
|
129
|
+
* @param {number} [minColumns]
|
|
130
|
+
* @returns {{ name: string, prefix: string[], column: string }[]}
|
|
131
|
+
*/
|
|
132
|
+
export function instantIndexesOver(shape, column, minColumns = 1) {
|
|
133
|
+
const declared = shape?.indexes ?? [];
|
|
134
|
+
const out = [];
|
|
135
|
+
for (const index of declared) {
|
|
136
|
+
const columns = index.columns ?? [];
|
|
137
|
+
if (columns.length < minColumns) continue;
|
|
138
|
+
if (columns.length === 0 || columns[columns.length - 1] !== column) continue;
|
|
139
|
+
out.push({ name: index.name, prefix: columns.slice(0, -1), column });
|
|
140
|
+
}
|
|
141
|
+
return out;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* The index a fetch actually SEEKS through, or `null` when none does.
|
|
146
|
+
*
|
|
147
|
+
* A B-tree is seekable exactly as far as its leading columns are
|
|
148
|
+
* decided: a run of equalities, and then at most one range. So the
|
|
149
|
+
* index that wins is the one with the longest leading run of PINNED
|
|
150
|
+
* columns whose next column is the instant the query ranges over —
|
|
151
|
+
* which is `(series, at)` under an equality on the series, and is
|
|
152
|
+
* nothing at all under a bare instant bound, because a range on a
|
|
153
|
+
* trailing column reads every row of the index.
|
|
154
|
+
*
|
|
155
|
+
* With no instant column of its own (an as-of join reading an instant
|
|
156
|
+
* the model does not index) a pinned prefix alone still seeks, and is
|
|
157
|
+
* reported as what it is.
|
|
158
|
+
* @param {any} shape
|
|
159
|
+
* @param {string | null} column - the instant column, or `null`
|
|
160
|
+
* @param {{ pinned: Set<string>, bounds: Map<string, any> }} facts
|
|
161
|
+
* @param {number} [minColumns] - see {@link instantIndexesOver}
|
|
162
|
+
* @returns {{ name: string, prefix: string[], column: string | null } | null}
|
|
163
|
+
*/
|
|
164
|
+
export function seekingIndexFor(shape, column, facts, minColumns = 1) {
|
|
165
|
+
let best = null;
|
|
166
|
+
for (const index of shape?.indexes ?? []) {
|
|
167
|
+
const columns = index.columns ?? [];
|
|
168
|
+
if (columns.length < minColumns) continue;
|
|
169
|
+
let run = 0;
|
|
170
|
+
while (run < columns.length && facts.pinned.has(columns[run])) run++;
|
|
171
|
+
if (column === null ? run === 0 : columns[run] !== column) continue;
|
|
172
|
+
if (best === null || run > best.run)
|
|
173
|
+
best = { run, name: index.name, prefix: columns.slice(0, run), column };
|
|
174
|
+
}
|
|
175
|
+
return best === null ? null
|
|
176
|
+
: { name: best.name, prefix: best.prefix, column: best.column };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Walk a pushed filter and report, per column, what it decided: which
|
|
181
|
+
* columns an equality pinned and what instant bounds a range put on
|
|
182
|
+
* one. Only a top-level conjunction counts — a disjunction or a
|
|
183
|
+
* negation decides nothing about a seek.
|
|
184
|
+
* @param {import('./algebra.js').PlanPredicate | null} filter
|
|
185
|
+
* @returns {{ pinned: Set<string>,
|
|
186
|
+
* bounds: Map<string, { from: any, fromOp: string | null,
|
|
187
|
+
* to: any, toOp: string | null }> }}
|
|
188
|
+
*/
|
|
189
|
+
export function filterFacts(filter) {
|
|
190
|
+
/** @type {Set<string>} */
|
|
191
|
+
const pinned = new Set();
|
|
192
|
+
/** @type {Map<string, any>} */
|
|
193
|
+
const bounds = new Map();
|
|
194
|
+
const boundOf = (column) => {
|
|
195
|
+
let entry = bounds.get(column);
|
|
196
|
+
if (entry === undefined) {
|
|
197
|
+
entry = { from: null, fromOp: null, to: null, toOp: null };
|
|
198
|
+
bounds.set(column, entry);
|
|
199
|
+
}
|
|
200
|
+
return entry;
|
|
201
|
+
};
|
|
202
|
+
const walk = (pred) => {
|
|
203
|
+
if (pred === null) return;
|
|
204
|
+
if (pred.p === 'and') {
|
|
205
|
+
pred.items.forEach(walk);
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
// a disjunction of equalities over ONE column is a membership test,
|
|
209
|
+
// and a membership test still seeks — once per value. It pins the
|
|
210
|
+
// column exactly as a single equality does, which is why an as-of
|
|
211
|
+
// join over several keys reads its index rather than the table
|
|
212
|
+
if (pred.p === 'or') {
|
|
213
|
+
const columns = new Set();
|
|
214
|
+
for (const item of pred.items) {
|
|
215
|
+
if (item.p !== 'cmp' || item.op !== 'eq' || item.ref.column === null) return;
|
|
216
|
+
columns.add(item.ref.column);
|
|
217
|
+
}
|
|
218
|
+
if (columns.size === 1) pinned.add([...columns][0]);
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
if (pred.p !== 'cmp' || pred.ref.column === null) return;
|
|
222
|
+
const column = pred.ref.column;
|
|
223
|
+
const operand = 'lit' in pred.operand ? pred.operand.lit : undefined;
|
|
224
|
+
if (pred.op === 'eq') {
|
|
225
|
+
pinned.add(column);
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
if (pred.op === 'ge' || pred.op === 'gt') {
|
|
229
|
+
const entry = boundOf(column);
|
|
230
|
+
entry.from = operand ?? null;
|
|
231
|
+
entry.fromOp = pred.op;
|
|
232
|
+
}
|
|
233
|
+
else if (pred.op === 'le' || pred.op === 'lt') {
|
|
234
|
+
const entry = boundOf(column);
|
|
235
|
+
entry.to = operand ?? null;
|
|
236
|
+
entry.toOp = pred.op;
|
|
237
|
+
}
|
|
238
|
+
};
|
|
239
|
+
walk(filter);
|
|
240
|
+
return { pinned, bounds };
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* The fixed ladder a `$time-bucket`/`$resample` spec asks for, or the
|
|
245
|
+
* reason it is not one. `origin` is folded to an epoch here — a
|
|
246
|
+
* `{ offset }` context moves the ladder's default anchor off UTC's
|
|
247
|
+
* midnight, which is arithmetic, while a named zone is not.
|
|
248
|
+
*
|
|
249
|
+
* The width is read through the temporal kernel's OWN compiler, so
|
|
250
|
+
* `'PT1H'`, `3600000` and `'PT60M'` are the same ladder, the default
|
|
251
|
+
* anchor is the kernel's rather than a second guess at it, and a width
|
|
252
|
+
* mixing the two families was already refused when the query compiled.
|
|
253
|
+
* @param {{ every: any, origin?: any, zone?: any, offset?: any }} spec
|
|
254
|
+
* @param {(spec: any, options: any) => any} compileBuckets - the kernel's
|
|
255
|
+
* @returns {{ every: number, origin: number } | { code: string }}
|
|
256
|
+
*/
|
|
257
|
+
export function fixedLadder(spec, compileBuckets) {
|
|
258
|
+
if (spec.zone !== undefined && spec.zone !== 'UTC') return { code: 'named-zone' };
|
|
259
|
+
const clock = spec.offset === undefined ? {} : { offset: spec.offset };
|
|
260
|
+
const ladder = (() => {
|
|
261
|
+
try {
|
|
262
|
+
return compileBuckets(spec.origin === undefined || spec.origin === null
|
|
263
|
+
? { every: spec.every } : { every: spec.every, origin: spec.origin }, clock);
|
|
264
|
+
}
|
|
265
|
+
catch {
|
|
266
|
+
// the kernel already refused an impossible spec when the query
|
|
267
|
+
// compiled, so reaching here means a ladder this one cannot walk
|
|
268
|
+
return null;
|
|
269
|
+
}
|
|
270
|
+
})();
|
|
271
|
+
if (ladder === null || ladder.calendar) return { code: 'calendar-width' };
|
|
272
|
+
if (!Number.isSafeInteger(ladder.origin) || !Number.isSafeInteger(ladder.width)
|
|
273
|
+
|| ladder.width <= 0)
|
|
274
|
+
return { code: 'calendar-width' };
|
|
275
|
+
return { every: ladder.width, origin: ladder.origin };
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Whether a `PlanRef` can carry a native bucket ladder: the instant
|
|
280
|
+
* must be a declared whole epoch, because the boundary arithmetic in
|
|
281
|
+
* SQL is integer arithmetic and a truncating division over a real
|
|
282
|
+
* would put an instant before 1970 in the bucket after its own.
|
|
283
|
+
* @param {import('./algebra.js').PlanRef | null} ref
|
|
284
|
+
* @returns {string | null} the reason code, or `null` when it can
|
|
285
|
+
*/
|
|
286
|
+
export function instantRefusal(ref) {
|
|
287
|
+
if (ref === null || ref.column === null) return 'missing-series-prefix';
|
|
288
|
+
if (ref.type !== 'integer') return 'instant-not-integer';
|
|
289
|
+
return null;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Whether a `PlanRef` can carry a native VALUE aggregate.
|
|
294
|
+
* @param {import('./algebra.js').PlanRef | null} ref
|
|
295
|
+
* @returns {string | null}
|
|
296
|
+
*/
|
|
297
|
+
export function valueRefusal(ref) {
|
|
298
|
+
if (ref === null || !isNumericType(ref.type)) return 'value-not-numeric';
|
|
299
|
+
return null;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* The one member name a `'$.on'`-style row selector reads, or `null`
|
|
304
|
+
* for anything a declared column cannot stand in for. The language's
|
|
305
|
+
* own reader (`compileSelector`) folds a single-segment path to a bare
|
|
306
|
+
* name; this reads the same two spellings out of the FROZEN literal a
|
|
307
|
+
* planner sees, and refuses everything else rather than guessing.
|
|
308
|
+
* @param {any} text
|
|
309
|
+
* @returns {string | null}
|
|
310
|
+
*/
|
|
311
|
+
export function singularSelector(text) {
|
|
312
|
+
if (typeof text !== 'string') return null;
|
|
313
|
+
const dotted = /^\$\.([A-Za-z_$][A-Za-z0-9_$]*)$/.exec(text);
|
|
314
|
+
if (dotted !== null) return dotted[1];
|
|
315
|
+
const bracketed = /^\$\[(?:'([^'\\]*)'|"([^"\\]*)")\]$/.exec(text);
|
|
316
|
+
if (bracketed !== null) return bracketed[1] ?? bracketed[2];
|
|
317
|
+
return null;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* The explain record for one temporal document. Counts are the LAST
|
|
322
|
+
* ACTUAL execution's — never an estimate — and are `null` until the
|
|
323
|
+
* document has run once.
|
|
324
|
+
* @param {{ mode: 'native' | 'hybrid' | 'engine', operation: string,
|
|
325
|
+
* index?: string | null, prefix?: string[], range?: any,
|
|
326
|
+
* ladder?: any, aggregates?: string[], refinement?: string | null,
|
|
327
|
+
* reasons?: { code: string, construct: string, reason: string }[] }} facts
|
|
328
|
+
* @returns {any}
|
|
329
|
+
*/
|
|
330
|
+
export function seriesRecord(facts) {
|
|
331
|
+
const range = facts.range ?? null;
|
|
332
|
+
return {
|
|
333
|
+
mode: facts.mode,
|
|
334
|
+
operation: facts.operation,
|
|
335
|
+
index: facts.index ?? null,
|
|
336
|
+
prefix: [...(facts.prefix ?? [])],
|
|
337
|
+
range: range === null ? null : {
|
|
338
|
+
column: range.column ?? null,
|
|
339
|
+
from: range.from ?? null,
|
|
340
|
+
fromOp: range.fromOp ?? null,
|
|
341
|
+
to: range.to ?? null,
|
|
342
|
+
toOp: range.toOp ?? null,
|
|
343
|
+
},
|
|
344
|
+
ladder: facts.ladder ?? null,
|
|
345
|
+
aggregates: [...(facts.aggregates ?? [])],
|
|
346
|
+
refinement: facts.refinement ?? null,
|
|
347
|
+
reasons: (facts.reasons ?? []).map((r) => ({ code: r.code, reason: r.reason })),
|
|
348
|
+
};
|
|
349
|
+
}
|
package/src/store.js
CHANGED
|
@@ -34,6 +34,7 @@ import { entityCore } from './entity.js';
|
|
|
34
34
|
import { createTracker } from './tracker.js';
|
|
35
35
|
import { createCaptureEngine, DEFAULT_RETENTION } from './capture.js';
|
|
36
36
|
import { createLiveRegistry, classifyLiveQuery, LIVE_DEFAULTS } from './live.js';
|
|
37
|
+
import { normalizeEventTime } from './live-time.js';
|
|
37
38
|
import { createJobEngine } from './jobs.js';
|
|
38
39
|
import { collectEntityRoots } from './plan.js';
|
|
39
40
|
import {
|
|
@@ -794,8 +795,14 @@ function resolveOperators(options) {
|
|
|
794
795
|
* @param {{ driver: any, path?: string, compileSchema?: Function,
|
|
795
796
|
* busyTimeout?: number, queueTimeout?: number, journalMode?: string,
|
|
796
797
|
* statementCacheBound?: number, profile?: any, operators?: any,
|
|
797
|
-
* functions?: any, extensions?: any,
|
|
798
|
+
* functions?: any, extensions?: any, zoneProvider?: any,
|
|
798
799
|
* readOnly?: boolean }} options
|
|
800
|
+
* `zoneProvider` is D7's injected clock: a named zone in a temporal
|
|
801
|
+
* spec (`{ "every": "P1M", "zone": "Europe/Amsterdam" }`) is host code
|
|
802
|
+
* the database cannot have, so a store that never received one refuses
|
|
803
|
+
* such a document (`JQ0003`) rather than answering it in UTC. It
|
|
804
|
+
* reaches every residual compilation, which is where the calendar
|
|
805
|
+
* ladder actually walks.
|
|
799
806
|
* @returns {Promise<any>}
|
|
800
807
|
*/
|
|
801
808
|
export function openStore(model, options) {
|
|
@@ -1151,9 +1158,10 @@ export function openStore(model, options) {
|
|
|
1151
1158
|
const registerCollectionLive = (core, document, liveOptions) => {
|
|
1152
1159
|
const externals = liveOptions?.externals ?? {};
|
|
1153
1160
|
const keyed = core.model.keySegments !== null;
|
|
1161
|
+
const eventTime = normalizeEventTime(liveOptions, core.model.name);
|
|
1154
1162
|
const classification = liveOptions?.mode === 'rerun'
|
|
1155
1163
|
? { strategy: 'rerun', reason: 'rerun was requested' }
|
|
1156
|
-
: classifyLiveQuery(document, core.queryShape, keyed);
|
|
1164
|
+
: classifyLiveQuery(document, core.queryShape, keyed, eventTime);
|
|
1157
1165
|
return /** @type {any} */ (liveRegistry).register({
|
|
1158
1166
|
name: core.model.name,
|
|
1159
1167
|
tables: new Set([core.model.name]),
|
|
@@ -1295,6 +1303,11 @@ export function openStore(model, options) {
|
|
|
1295
1303
|
pushableOperators: operators === null || connection.capabilities.userFunctions !== true
|
|
1296
1304
|
? Object.freeze([])
|
|
1297
1305
|
: Object.freeze([...operators.pushableScalar]),
|
|
1306
|
+
// D7's injected clock: whether a temporal spec naming a
|
|
1307
|
+
// ZONE will compile at all here. Without one the document is
|
|
1308
|
+
// refused (`JQ0003`) rather than answered in UTC, and a
|
|
1309
|
+
// consumer that wants to know before it asks reads this
|
|
1310
|
+
zoneProvider: options.zoneProvider !== undefined && options.zoneProvider !== null,
|
|
1298
1311
|
capture: captureMode,
|
|
1299
1312
|
captureLog: captureMode !== 'none'
|
|
1300
1313
|
&& (captureRequested.log === true
|
|
@@ -1304,7 +1317,8 @@ export function openStore(model, options) {
|
|
|
1304
1317
|
|| (options.jobs !== undefined && options.jobs !== false),
|
|
1305
1318
|
});
|
|
1306
1319
|
|
|
1307
|
-
const queryState = createQueryState(options.statementCacheBound, operators
|
|
1320
|
+
const queryState = createQueryState(options.statementCacheBound, operators,
|
|
1321
|
+
options.zoneProvider);
|
|
1308
1322
|
const entityEngine = entities.size > 0
|
|
1309
1323
|
? createEntityQueryEngine({ connection, entities, mapping, state: queryState })
|
|
1310
1324
|
: null;
|
|
@@ -1456,6 +1470,11 @@ export function openStore(model, options) {
|
|
|
1456
1470
|
throw new DbCompileError('JD0050',
|
|
1457
1471
|
'live queries require change capture — open the store with { capture: true }');
|
|
1458
1472
|
}
|
|
1473
|
+
if (liveOptions?.eventTime !== undefined) {
|
|
1474
|
+
throw new DbCompileError('JD0053',
|
|
1475
|
+
'live eventTime maintains a collection view — an entity document re-runs, '
|
|
1476
|
+
+ 'so a watermark would describe nothing (LIVE-FORMAT §13)');
|
|
1477
|
+
}
|
|
1459
1478
|
const roots = collectEntityRoots(document, entities);
|
|
1460
1479
|
if (roots.size === 0) {
|
|
1461
1480
|
throw new TypeError(
|
package/types/index.d.ts
CHANGED
|
@@ -48,6 +48,35 @@ export declare class DbRuntimeError extends Error {
|
|
|
48
48
|
/** A single-column key, or the `{ prop: value, … }` composite form. */
|
|
49
49
|
export type EntityKeyArg = string | number | Readonly<Record<string, string | number>>;
|
|
50
50
|
|
|
51
|
+
/**
|
|
52
|
+
* What `execute` answers: the ENGINE's result shape (QUERY-FORMAT §1,
|
|
53
|
+
* "singleton ≡ item"). The empty sequence is `undefined`, a sequence of
|
|
54
|
+
* exactly one item IS that item, and anything longer is an array. A
|
|
55
|
+
* single array-valued item is therefore indistinguishable from many
|
|
56
|
+
* items — a consumer whose items may themselves be arrays reads them
|
|
57
|
+
* through `query()`, which answers one item per pull and never
|
|
58
|
+
* unwraps.
|
|
59
|
+
*/
|
|
60
|
+
export type SequenceResult<T = unknown> = T[] | T | undefined;
|
|
61
|
+
|
|
62
|
+
/** The D2 provider contract: a synchronous driver answers the value
|
|
63
|
+
* itself and an asynchronous one a promise of it, so a linq chain over
|
|
64
|
+
* a synchronous driver stays synchronous; `await` reads both. */
|
|
65
|
+
export type ValueOrPromise<T> = T | Promise<T>;
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The item cursor `query()` answers: one result item per `next()`,
|
|
69
|
+
* never singleton-unwrapped, so `for await` walks a result whose
|
|
70
|
+
* shape `SequenceResult` cannot say. `return()` releases the
|
|
71
|
+
* underlying statement early. Native and row modes stream row by row;
|
|
72
|
+
* a set residual materializes first (a barrier `explain()` names).
|
|
73
|
+
*/
|
|
74
|
+
export interface QueryCursor<T = unknown> {
|
|
75
|
+
next(): Promise<IteratorResult<T, undefined>>;
|
|
76
|
+
return(): Promise<IteratorResult<T, undefined>>;
|
|
77
|
+
[Symbol.asyncIterator](): QueryCursor<T>;
|
|
78
|
+
}
|
|
79
|
+
|
|
51
80
|
export interface ExecuteOptions {
|
|
52
81
|
externals?: Readonly<Record<string, unknown>>;
|
|
53
82
|
strict?: boolean;
|
|
@@ -114,6 +143,10 @@ export interface StoreCapabilities {
|
|
|
114
143
|
readonly captureLog: boolean;
|
|
115
144
|
readonly live: boolean;
|
|
116
145
|
readonly jobs: boolean;
|
|
146
|
+
/** Whether a temporal spec naming a ZONE compiles here (D7's
|
|
147
|
+
* injected clock was supplied at open). Without it such a document
|
|
148
|
+
* is refused rather than answered in UTC. */
|
|
149
|
+
readonly zoneProvider: boolean;
|
|
117
150
|
readonly [capability: string]: unknown;
|
|
118
151
|
}
|
|
119
152
|
|
|
@@ -126,10 +159,15 @@ export interface Collection<T = unknown> {
|
|
|
126
159
|
put(doc: T, key?: string | number): Promise<string | number>;
|
|
127
160
|
patch(key: string | number, ops: readonly unknown[]): Promise<T>;
|
|
128
161
|
delete(key: string | number): Promise<boolean>;
|
|
129
|
-
/**
|
|
130
|
-
*
|
|
131
|
-
|
|
132
|
-
|
|
162
|
+
/** Run a query document and answer in the engine's result shape.
|
|
163
|
+
* `R` is what the document's `$return` produces — a document, a
|
|
164
|
+
* projected value, an aggregate's number — and only the caller knows
|
|
165
|
+
* it, so it is stated per call and defaults to `unknown` rather than
|
|
166
|
+
* to a guess. The D2 provider: value-or-promise so a linq chain over
|
|
167
|
+
* a synchronous driver stays synchronous. */
|
|
168
|
+
execute<R = unknown>(document: unknown, options?: ExecuteOptions): ValueOrPromise<SequenceResult<R>>;
|
|
169
|
+
/** The same document as an item cursor — one item per pull. */
|
|
170
|
+
query<R = unknown>(document: unknown, options?: ExecuteOptions): QueryCursor<R>;
|
|
133
171
|
explain(document: unknown, options?: ExecuteOptions): Promise<unknown>;
|
|
134
172
|
/** Register a live query (LIVE-FORMAT §7); requires capture. */
|
|
135
173
|
live(document: unknown, options?: LiveOptions): Promise<LiveQuery>;
|
|
@@ -142,7 +180,7 @@ export interface SyncCollection<T = unknown> {
|
|
|
142
180
|
put(doc: T, key?: string | number): string | number;
|
|
143
181
|
patch(key: string | number, ops: readonly unknown[]): T;
|
|
144
182
|
delete(key: string | number): boolean;
|
|
145
|
-
execute(document: unknown, options?: ExecuteOptions):
|
|
183
|
+
execute<R = unknown>(document: unknown, options?: ExecuteOptions): SequenceResult<R>;
|
|
146
184
|
explain(document: unknown, options?: ExecuteOptions): unknown;
|
|
147
185
|
}
|
|
148
186
|
|
|
@@ -193,10 +231,12 @@ export interface SyncEntitySet<T = unknown, I = unknown> {
|
|
|
193
231
|
// ————— the store —————
|
|
194
232
|
|
|
195
233
|
export interface SyncStore {
|
|
196
|
-
collection
|
|
234
|
+
/** `T` is the collection's document shape — the model's schema in
|
|
235
|
+
* the consumer's words; the handle's writes take it and reads answer it. */
|
|
236
|
+
collection<T = unknown>(name: string): SyncCollection<T>;
|
|
197
237
|
entity(name: string): SyncEntitySet;
|
|
198
238
|
transaction<R>(fn: (store: Store) => R): R;
|
|
199
|
-
execute
|
|
239
|
+
execute?<R = unknown>(document: unknown, options?: ExecuteOptions): SequenceResult<R>;
|
|
200
240
|
saveChanges?(): SaveReport;
|
|
201
241
|
}
|
|
202
242
|
|
|
@@ -204,11 +244,14 @@ export interface Store {
|
|
|
204
244
|
readonly capabilities: StoreCapabilities;
|
|
205
245
|
readonly dialect: Dialect;
|
|
206
246
|
stats(): StoreStats;
|
|
207
|
-
collection
|
|
247
|
+
/** `T` is the collection's document shape — the model's schema in
|
|
248
|
+
* the consumer's words; the handle's writes take it and reads answer it. */
|
|
249
|
+
collection<T = unknown>(name: string): Collection<T>;
|
|
208
250
|
entity(name: string): EntitySet;
|
|
209
251
|
/** Entity documents over the multi-entity root (§10.1); present
|
|
210
|
-
* only when the model declares entities. Value-or-promise (D2)
|
|
211
|
-
|
|
252
|
+
* only when the model declares entities. Value-or-promise (D2),
|
|
253
|
+
* in the engine's result shape. */
|
|
254
|
+
execute?<R = unknown>(document: unknown, options?: ExecuteOptions): ValueOrPromise<SequenceResult<R>>;
|
|
212
255
|
explain?(document: unknown, options?: ExecuteOptions): Promise<unknown>;
|
|
213
256
|
/** The unit of work (§11); present only with entities. */
|
|
214
257
|
saveChanges?(): Promise<SaveReport>;
|
|
@@ -222,7 +265,10 @@ export interface Store {
|
|
|
222
265
|
/** Register a live query over an entity-root document (re-run
|
|
223
266
|
* strategy in this version); present only with entities. */
|
|
224
267
|
live?(document: unknown, options?: LiveOptions): Promise<LiveQuery>;
|
|
225
|
-
|
|
268
|
+
/** Close the store. Job workers are asked to stop and given
|
|
269
|
+
* `graceMs` to wind up; the connection closes whether or not they
|
|
270
|
+
* did, and a handler still in flight is reported as JD2062. */
|
|
271
|
+
close(options?: { graceMs?: number }): Promise<void>;
|
|
226
272
|
/** The queue surface; present when opened with `jobs` (JOBS-FORMAT). */
|
|
227
273
|
readonly jobs?: JobsApi;
|
|
228
274
|
/** Present exactly when the driver is synchronous — never stubs. */
|
|
@@ -255,10 +301,30 @@ export interface LiveOptions {
|
|
|
255
301
|
/** 'incremental' DEMANDS incrementality (JD0051 when the shape
|
|
256
302
|
* re-runs); 'rerun' forces the re-run strategy. */
|
|
257
303
|
mode?: 'auto' | 'incremental' | 'rerun';
|
|
304
|
+
/** Event time for a `$resample` / `$rolling` view (LIVE-FORMAT §13).
|
|
305
|
+
* Its members are closed: anything else is JD0053. */
|
|
306
|
+
eventTime?: LiveEventTime;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
export interface LiveEventTime {
|
|
310
|
+
/** A singular row selector naming the instant member, e.g. '$.at'.
|
|
311
|
+
* It must be the member the spec aggregates by. */
|
|
312
|
+
path: string;
|
|
313
|
+
/** A finite epoch in milliseconds. Never a clock reading — the host
|
|
314
|
+
* supplies it, and `advance()` is the only way it moves. */
|
|
315
|
+
watermark: number;
|
|
316
|
+
/** How far behind the watermark a reading may still be applied
|
|
317
|
+
* (default 0). Older readings emit `lateData` and re-run. */
|
|
318
|
+
allowedLateness?: number;
|
|
319
|
+
/** The horizon this view claims, in milliseconds. It must cover the
|
|
320
|
+
* window (or bucket) width plus `allowedLateness`, or the view is
|
|
321
|
+
* classified as a re-run. */
|
|
322
|
+
retention: number;
|
|
258
323
|
}
|
|
259
324
|
|
|
260
325
|
export interface LiveMode {
|
|
261
|
-
readonly strategy: 'rows' | 'window' | 'accumulator' | 'group'
|
|
326
|
+
readonly strategy: 'rows' | 'window' | 'accumulator' | 'group'
|
|
327
|
+
| 'bucket' | 'rolling' | 'rerun';
|
|
262
328
|
readonly mode: 'incremental' | 'rerun';
|
|
263
329
|
/** Present exactly when the strategy is 'rerun': the named reason. */
|
|
264
330
|
readonly reason?: string;
|
|
@@ -270,6 +336,17 @@ export interface LiveEvent {
|
|
|
270
336
|
seq?: number;
|
|
271
337
|
/** A maintenance failure (JD2060 …): the query closed after this. */
|
|
272
338
|
error?: unknown;
|
|
339
|
+
/** Present when a reading behind the lateness boundary forced this
|
|
340
|
+
* emission: the view re-read, and the row was never folded in as
|
|
341
|
+
* though it had arrived on time (LIVE-FORMAT §13). */
|
|
342
|
+
lateData?: {
|
|
343
|
+
reason: 'late-data';
|
|
344
|
+
at: number;
|
|
345
|
+
key: string;
|
|
346
|
+
watermark: number;
|
|
347
|
+
allowedLateness: number;
|
|
348
|
+
boundary: number;
|
|
349
|
+
};
|
|
273
350
|
}
|
|
274
351
|
|
|
275
352
|
export interface LiveStats {
|
|
@@ -278,8 +355,15 @@ export interface LiveStats {
|
|
|
278
355
|
emissions: number;
|
|
279
356
|
/** min/max extremum-removal recomputes (accumulator strategy). */
|
|
280
357
|
fallbacks?: number;
|
|
281
|
-
/** whole-query re-executions (re-run strategy
|
|
358
|
+
/** whole-query re-executions (re-run strategy, and the re-read a
|
|
359
|
+
* late reading forces). */
|
|
282
360
|
reruns?: number;
|
|
361
|
+
/** readings that arrived behind the lateness boundary (event time). */
|
|
362
|
+
lateData?: number;
|
|
363
|
+
/** buckets or window stretches folded again (event time). */
|
|
364
|
+
recomputes?: number;
|
|
365
|
+
/** the current watermark (event time). */
|
|
366
|
+
watermark?: number;
|
|
283
367
|
}
|
|
284
368
|
|
|
285
369
|
export interface LiveQuery {
|
|
@@ -291,6 +375,10 @@ export interface LiveQuery {
|
|
|
291
375
|
readonly mode: LiveMode;
|
|
292
376
|
stats(): LiveStats;
|
|
293
377
|
subscribe(observer: (event: LiveEvent) => void): () => void;
|
|
378
|
+
/** Move the event-time watermark forward. Present only on a view
|
|
379
|
+
* registered with `eventTime`; a non-finite or backward value is a
|
|
380
|
+
* TypeError. */
|
|
381
|
+
advance?(watermark: number): void;
|
|
294
382
|
close(): void;
|
|
295
383
|
}
|
|
296
384
|
|
|
@@ -314,6 +402,12 @@ export interface OpenStoreOptions {
|
|
|
314
402
|
/** The injected validation hook (D10); absent means unvalidated,
|
|
315
403
|
* declared through `capabilities.validated`. */
|
|
316
404
|
compileSchema?: (schema: unknown) => (doc: unknown) => unknown;
|
|
405
|
+
/** D7's injected clock — `{ toParts(epoch, zone), toEpoch(parts, zone,
|
|
406
|
+
* disambiguation) }`. A temporal spec naming a zone
|
|
407
|
+
* (`{ "every": "P1M", "zone": "Europe/Amsterdam" }`) compiles only
|
|
408
|
+
* where one was injected; without it the document is refused rather
|
|
409
|
+
* than answered in UTC. No time-zone database is bundled. */
|
|
410
|
+
zoneProvider?: unknown;
|
|
317
411
|
busyTimeout?: number;
|
|
318
412
|
journalMode?: string;
|
|
319
413
|
statementCacheBound?: number;
|
package/types/typed.d.ts
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
|
|
15
15
|
import type {
|
|
16
16
|
EntityKeyArg, LoadExplanation, SaveReport, Store, StoreCapabilities,
|
|
17
|
-
StoreStats, Collection, ExecuteOptions,
|
|
17
|
+
StoreStats, Collection, ExecuteOptions, SequenceResult, ValueOrPromise,
|
|
18
18
|
} from '@jarenjs/db';
|
|
19
19
|
|
|
20
20
|
/** The self-referential constraint an interface can satisfy: generated
|
|
@@ -91,12 +91,12 @@ export interface TypedEntitySet<E extends MetaMap<E>, M extends EntityMeta> {
|
|
|
91
91
|
export interface TypedStore<E extends MetaMap<E>> {
|
|
92
92
|
readonly capabilities: StoreCapabilities;
|
|
93
93
|
stats(): StoreStats;
|
|
94
|
-
collection(name: string): Collection
|
|
94
|
+
collection<T = unknown>(name: string): Collection<T>;
|
|
95
95
|
entity<K extends keyof E & string>(name: K): TypedEntitySet<E, E[K]>;
|
|
96
|
-
execute
|
|
96
|
+
execute?<R = unknown>(document: unknown, options?: ExecuteOptions): ValueOrPromise<SequenceResult<R>>;
|
|
97
97
|
saveChanges?(): Promise<SaveReport>;
|
|
98
98
|
transaction<R>(fn: (store: Store) => R | Promise<R>): Promise<Awaited<R>>;
|
|
99
|
-
close(): Promise<void>;
|
|
99
|
+
close(options?: { graceMs?: number }): Promise<void>;
|
|
100
100
|
}
|
|
101
101
|
|
|
102
102
|
/**
|