@jarenjs/db 0.46.5 → 0.56.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/ARCHITECTURE.md +133 -17
  2. package/README.md +270 -36
  3. package/docs/JOBS-FORMAT.md +24 -8
  4. package/docs/LIVE-FORMAT.md +139 -7
  5. package/docs/MIGRATION-FORMAT.md +118 -36
  6. package/docs/MODEL-FORMAT.md +251 -30
  7. package/package.json +4 -5
  8. package/schemas/jaren-migration.draft-07.schema.json +73 -0
  9. package/schemas/jaren-migration.schema.json +73 -0
  10. package/src/algebra.js +22 -3
  11. package/src/capture.js +66 -28
  12. package/src/cli.js +225 -44
  13. package/src/ddl.js +23 -3
  14. package/src/dialect.js +13 -0
  15. package/src/dialects/sqlite.js +21 -1
  16. package/src/driver.js +63 -16
  17. package/src/drivers/wasm.js +1 -0
  18. package/src/emit-model.js +14 -0
  19. package/src/emit.js +42 -9
  20. package/src/entity.js +92 -47
  21. package/src/errors.js +28 -0
  22. package/src/index.js +2 -2
  23. package/src/jobs.js +40 -5
  24. package/src/live-time.js +605 -0
  25. package/src/live.js +52 -9
  26. package/src/migrate.js +397 -191
  27. package/src/model.js +173 -8
  28. package/src/plan.js +834 -47
  29. package/src/query.js +296 -22
  30. package/src/residual.js +15 -6
  31. package/src/series.js +349 -0
  32. package/src/store.js +243 -69
  33. package/src/tracker.js +173 -48
  34. package/types/index.d.ts +206 -12
  35. package/types/node.d.ts +3 -1
  36. package/types/typed.d.ts +58 -2
  37. package/types/wasm.d.ts +7 -0
  38. package/dist/types/algebra.d.ts +0 -199
  39. package/dist/types/app.d.ts +0 -49
  40. package/dist/types/capture.d.ts +0 -85
  41. package/dist/types/cli.d.ts +0 -2
  42. package/dist/types/dag-job.d.ts +0 -40
  43. package/dist/types/ddl.d.ts +0 -229
  44. package/dist/types/derive.d.ts +0 -250
  45. package/dist/types/dialect.d.ts +0 -149
  46. package/dist/types/dialects/sqlite.d.ts +0 -9
  47. package/dist/types/driver.d.ts +0 -110
  48. package/dist/types/drivers/bun.d.ts +0 -47
  49. package/dist/types/drivers/node.d.ts +0 -37
  50. package/dist/types/drivers/wasm.d.ts +0 -65
  51. package/dist/types/emit-model.d.ts +0 -44
  52. package/dist/types/emit.d.ts +0 -75
  53. package/dist/types/entity.d.ts +0 -23
  54. package/dist/types/errors.d.ts +0 -167
  55. package/dist/types/graph.d.ts +0 -28
  56. package/dist/types/index.d.ts +0 -37
  57. package/dist/types/jobs.d.ts +0 -140
  58. package/dist/types/knn.d.ts +0 -69
  59. package/dist/types/live.d.ts +0 -62
  60. package/dist/types/migrate.d.ts +0 -170
  61. package/dist/types/model.d.ts +0 -36
  62. package/dist/types/patch-sql.d.ts +0 -37
  63. package/dist/types/plan.d.ts +0 -140
  64. package/dist/types/profile.d.ts +0 -80
  65. package/dist/types/query.d.ts +0 -111
  66. package/dist/types/residual.d.ts +0 -61
  67. package/dist/types/store.d.ts +0 -53
  68. package/dist/types/tracker.d.ts +0 -43
  69. package/dist/types/typed.d.ts +0 -15
  70. package/dist/types/types.d.ts +0 -26
  71. package/dist/types/udf.d.ts +0 -75
  72. package/dist/types/window.d.ts +0 -52
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
+ }