@jarenjs/db 0.49.2 → 0.66.1

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 (110) hide show
  1. package/ARCHITECTURE.md +420 -71
  2. package/README.md +711 -79
  3. package/docs/HOSTS.md +269 -0
  4. package/docs/JOBS-FORMAT.md +309 -45
  5. package/docs/LIVE-FORMAT.md +156 -19
  6. package/docs/MIGRATION-FORMAT.md +247 -40
  7. package/docs/MODEL-FORMAT.md +968 -86
  8. package/package.json +21 -8
  9. package/schemas/jaren-migration.draft-07.schema.json +73 -0
  10. package/schemas/jaren-migration.schema.json +73 -0
  11. package/schemas/jaren-model.draft-07.schema.json +224 -162
  12. package/schemas/jaren-model.schema.json +224 -162
  13. package/src/algebra.js +227 -9
  14. package/src/backup.js +161 -0
  15. package/src/cancellation.js +48 -0
  16. package/src/capture.js +255 -44
  17. package/src/cli.js +337 -50
  18. package/src/cursor.js +411 -0
  19. package/src/dag-job.js +154 -21
  20. package/src/ddl.js +125 -11
  21. package/src/dialect.js +267 -112
  22. package/src/dialects/expression-read.js +158 -0
  23. package/src/dialects/postgres.js +618 -0
  24. package/src/dialects/rtree-ddl.js +129 -0
  25. package/src/dialects/sqlite.js +245 -12
  26. package/src/document-files.js +311 -0
  27. package/src/document-steps.js +422 -0
  28. package/src/documents.js +335 -0
  29. package/src/driver.js +503 -69
  30. package/src/drivers/bun.js +37 -1
  31. package/src/drivers/indexeddb-snapshot.js +149 -0
  32. package/src/drivers/node-pool.js +11 -0
  33. package/src/drivers/node-worker-endpoint.js +105 -0
  34. package/src/drivers/node-worker.js +204 -0
  35. package/src/drivers/node.js +41 -7
  36. package/src/drivers/postgres.js +331 -0
  37. package/src/drivers/wasm-oo1.js +97 -0
  38. package/src/drivers/wasm-session.js +67 -0
  39. package/src/drivers/wasm.js +18 -83
  40. package/src/drivers/worker-pool.js +183 -0
  41. package/src/drivers/worker-protocol.js +79 -0
  42. package/src/drivers/worker-queue.js +60 -0
  43. package/src/emit-model.js +14 -0
  44. package/src/emit.js +349 -51
  45. package/src/entity.js +102 -59
  46. package/src/errors.js +430 -2
  47. package/src/expression.js +284 -0
  48. package/src/graph.js +64 -8
  49. package/src/index.js +48 -19
  50. package/src/introspect.js +583 -0
  51. package/src/jobs.js +870 -99
  52. package/src/json-bytes.js +58 -0
  53. package/src/live-time.js +12 -3
  54. package/src/live.js +11 -1
  55. package/src/maintenance.js +175 -0
  56. package/src/migrate.js +606 -333
  57. package/src/model.js +241 -8
  58. package/src/plan.js +1238 -160
  59. package/src/pragmas.js +314 -0
  60. package/src/profile.js +151 -3
  61. package/src/query.js +1748 -312
  62. package/src/residual.js +17 -0
  63. package/src/series.js +12 -4
  64. package/src/store.js +1672 -276
  65. package/src/tracker.js +367 -68
  66. package/src/udf.js +88 -7
  67. package/types/index.d.ts +1246 -32
  68. package/types/node-pool.d.ts +28 -0
  69. package/types/node-worker.d.ts +54 -0
  70. package/types/node.d.ts +72 -3
  71. package/types/postgres.d.ts +46 -0
  72. package/types/typed.d.ts +81 -3
  73. package/types/wasm.d.ts +21 -0
  74. package/dist/types/algebra.d.ts +0 -230
  75. package/dist/types/app.d.ts +0 -49
  76. package/dist/types/capture.d.ts +0 -85
  77. package/dist/types/cli.d.ts +0 -2
  78. package/dist/types/dag-job.d.ts +0 -40
  79. package/dist/types/ddl.d.ts +0 -229
  80. package/dist/types/derive.d.ts +0 -250
  81. package/dist/types/dialect.d.ts +0 -154
  82. package/dist/types/dialects/sqlite.d.ts +0 -9
  83. package/dist/types/driver.d.ts +0 -110
  84. package/dist/types/drivers/bun.d.ts +0 -47
  85. package/dist/types/drivers/node.d.ts +0 -37
  86. package/dist/types/drivers/wasm.d.ts +0 -65
  87. package/dist/types/emit-model.d.ts +0 -44
  88. package/dist/types/emit.d.ts +0 -75
  89. package/dist/types/entity.d.ts +0 -23
  90. package/dist/types/errors.d.ts +0 -170
  91. package/dist/types/graph.d.ts +0 -28
  92. package/dist/types/index.d.ts +0 -37
  93. package/dist/types/jobs.d.ts +0 -140
  94. package/dist/types/knn.d.ts +0 -69
  95. package/dist/types/live-time.d.ts +0 -141
  96. package/dist/types/live.d.ts +0 -64
  97. package/dist/types/migrate.d.ts +0 -170
  98. package/dist/types/model.d.ts +0 -36
  99. package/dist/types/patch-sql.d.ts +0 -37
  100. package/dist/types/plan.d.ts +0 -142
  101. package/dist/types/profile.d.ts +0 -80
  102. package/dist/types/query.d.ts +0 -112
  103. package/dist/types/residual.d.ts +0 -64
  104. package/dist/types/series.d.ts +0 -227
  105. package/dist/types/store.d.ts +0 -60
  106. package/dist/types/tracker.d.ts +0 -43
  107. package/dist/types/typed.d.ts +0 -15
  108. package/dist/types/types.d.ts +0 -26
  109. package/dist/types/udf.d.ts +0 -75
  110. package/dist/types/window.d.ts +0 -52
package/src/cursor.js ADDED
@@ -0,0 +1,411 @@
1
+ //@ts-check
2
+ /**
3
+ * @file The ONE item cursor. `next()` / `return()` / `[Symbol.asyncIterator]`
4
+ * over a source that is either a ROW ITERATOR pulled one row at a time
5
+ * or a MATERIALISED item array (a barrier). Every engine — the
6
+ * collection engine's `query()`, the entity engine's, the graph
7
+ * loader's — builds its cursor here, so the release-at-a-row-boundary
8
+ * rule exists in one place:
9
+ *
10
+ * - a row source is opened on the first pull and released exactly
11
+ * once — on `return()`, on an error raised while a row is mapped or
12
+ * pulled, and on abort — through the driver iterator's own
13
+ * `return()`; a source pulled to exhaustion has already reset its
14
+ * statement and is released without a second call;
15
+ * - a buffered source materialises on the first pull and SAYS SO:
16
+ * `streaming: 'buffered'` beside the `barrier` that forced it, a
17
+ * `{ construct, reason }` pair whose construct is a stable
18
+ * identifier a test can assert and `explain()` can repeat;
19
+ * - `signal` cancels at a row boundary: an aborted cursor releases its
20
+ * statement and every later pull is `JD2072`, so an abandoned
21
+ * request neither keeps a statement open nor pulls another row.
22
+ *
23
+ * The source never materialises on its own: this module knows neither
24
+ * `.all()` nor `toArray()` — a gate holds it to that — and an engine
25
+ * that must buffer hands the buffered items in as `materialize`, named.
26
+ */
27
+
28
+ import { utf8ByteLength } from '@jarenjs/core/string';
29
+
30
+ import { DbRuntimeError } from './errors.js';
31
+ import { chain, isThenable } from './driver.js';
32
+
33
+ /** Preserve a failure, but acknowledge source cleanup before rejecting it.
34
+ * @param {() => any} call @param {() => any} release @param {(error:any)=>any} [wrap]
35
+ */
36
+ function settling(call, release, wrap = (error) => error) {
37
+ const failed = (error) => {
38
+ const failure = wrap(error);
39
+ let cleanup;
40
+ try { cleanup = release(); } catch { throw failure; }
41
+ if (isThenable(cleanup)) return cleanup.then(() => { throw failure; }, () => { throw failure; });
42
+ throw failure;
43
+ };
44
+ let result;
45
+ try { result = call(); } catch (error) { return failed(error); }
46
+ return isThenable(result) ? result.then(undefined, failed) : result;
47
+ }
48
+
49
+ /**
50
+ * The serialised size of a JSON text in UTF-8 bytes — the one measure
51
+ * every byte bound in this package counts (an include per root, a page,
52
+ * a change record): the suite's counter, re-exported under the name the
53
+ * engines read.
54
+ * @param {string} text
55
+ * @returns {number}
56
+ */
57
+ export function utf8Length(text) {
58
+ return utf8ByteLength(text);
59
+ }
60
+
61
+ /**
62
+ * @typedef {{ construct: string, reason: string }} CursorBarrier - what
63
+ * forces a cursor to buffer: the construct is the stable identifier
64
+ * (a planner construct such as `$orderby`, or `external`, `window`,
65
+ * `pushdown`), the reason the sentence for a person
66
+ */
67
+
68
+ /**
69
+ * How a cursor over a source the driver iterates actually behaves on
70
+ * THIS connection: one row per pull where the binding has a lazy
71
+ * iterator, and a buffer — declared as such, with the driver named as
72
+ * the barrier — where the driver composed `iterate` over `all()`. The
73
+ * capability is probed once at open; a cursor is classified at
74
+ * construction, before any statement exists, so the fact has to be the
75
+ * connection's. The one classification every engine and the job queue
76
+ * read.
77
+ * @param {any} connection
78
+ * @returns {{ streaming: 'row' | 'buffered', barrier: CursorBarrier | null }}
79
+ */
80
+ export function rowClassOf(connection) {
81
+ return connection.capabilities.lazyIteration === false
82
+ ? { streaming: 'buffered', barrier: { construct: 'driver',
83
+ reason: 'the driver binding has no lazy iterator; the first pull materialises the whole result' } }
84
+ : { streaming: 'row', barrier: null };
85
+ }
86
+
87
+ /**
88
+ * @typedef {object} CursorSpec
89
+ * @property {'row' | 'buffered'} streaming - whether items arrive one
90
+ * database row per pull or from a buffer the first pull filled
91
+ * @property {CursorBarrier | null} [barrier] - what forces buffering;
92
+ * `null` when the cursor streams
93
+ * @property {AbortSignal} [signal] - cancellation, honoured at a row boundary
94
+ * @property {() => any} [materialize] - a buffered source: value-or-promise
95
+ * of the whole item array, called once on the first pull
96
+ * @property {() => any} [open] - a row source: value-or-promise of the
97
+ * driver iterator, called once on the first pull
98
+ * @property {(row: any) => any[]} [items] - a row source: the items one
99
+ * row yields (none, one, or several); a throw releases the source
100
+ * @property {number} [deadline] - an epoch-millisecond deadline checked at
101
+ * every pull: past it, the cursor releases its source and refuses
102
+ * `JD2075` — a row-boundary check, never a statement interrupt
103
+ * @property {() => number} [now] - the clock the deadline is read
104
+ * against — the store's runtime record's; required beside a deadline,
105
+ * so no cursor reads the platform clock on its own
106
+ * @property {(error: any) => Error} [wrap] - classifies a failure raised
107
+ * while the source is opened, pulled or mapped — the engine's driver
108
+ * wrap, so no raw driver error leaves a cursor; a coded error passes
109
+ * through it unchanged
110
+ * @property {(opened: boolean) => void} [onSettle] - called exactly once
111
+ * when the cursor settles — exhausted, released, or aborted — with
112
+ * whether a pull ever reached the source; what an engine finalises its
113
+ * run accounting on
114
+ */
115
+
116
+ /**
117
+ * Build an asynchronous item cursor over one source.
118
+ * @param {CursorSpec} spec
119
+ * @returns {any}
120
+ */
121
+ export function createCursor(spec) {
122
+ return itemCursor(spec, false);
123
+ }
124
+
125
+ /**
126
+ * The same lifecycle over a synchronous source, answering values.
127
+ * @param {CursorSpec} spec
128
+ * @returns {any}
129
+ */
130
+ export function createSyncCursor(spec) {
131
+ return itemCursor(spec, true);
132
+ }
133
+
134
+ /** @param {CursorSpec} spec @param {boolean} synchronous */
135
+ function itemCursor(spec, synchronous) {
136
+ const { streaming, signal } = spec;
137
+ if (spec.deadline !== undefined && spec.now === undefined)
138
+ throw new TypeError('a cursor with a deadline is built with the clock it is read against (now)');
139
+ let buffered = [];
140
+ let offset = 0;
141
+ let underlying = null;
142
+ let opening = null;
143
+ let cleanup;
144
+ let sourceReleased = false;
145
+ let done = false;
146
+ let opened = false;
147
+ let tail = synchronous ? undefined : Promise.resolve();
148
+ const settle = () => {
149
+ if (done) return;
150
+ done = true;
151
+ buffered = [];
152
+ signal?.removeEventListener('abort', onAbort);
153
+ spec.onSettle?.(opened);
154
+ };
155
+ const releaseSource = () => {
156
+ if (sourceReleased || underlying === null) return;
157
+ sourceReleased = true;
158
+ return underlying.return?.(undefined);
159
+ };
160
+ const release = () => {
161
+ settle();
162
+ if (cleanup === undefined) cleanup = chain(opening, releaseSource);
163
+ return cleanup;
164
+ };
165
+ const onAbort = () => {
166
+ // Event listeners cannot await remote acknowledgement. The public
167
+ // return still awaits it, and a rejection is observed here as well.
168
+ try { const pending = release(); if (isThenable(pending)) pending.catch(() => {}); }
169
+ catch { /* the next/return boundary reports the source failure */ }
170
+ };
171
+ const aborted = () => new DbRuntimeError('JD2072',
172
+ 'the cursor was aborted: its statement was released at a row boundary and it pulls '
173
+ + 'no further row', { cause: signal?.reason });
174
+ const end = () => ({ done: true, value: undefined });
175
+ const boundary = () => {
176
+ if (signal?.aborted) throw aborted();
177
+ if (done) return;
178
+ if (spec.deadline !== undefined && spec.now() > spec.deadline)
179
+ throw new DbRuntimeError('JD2075',
180
+ `the deadline passed before the next row (${new Date(spec.deadline).toISOString()}); `
181
+ + 'the statement was released at a row boundary');
182
+ };
183
+ const accept = (step) => {
184
+ boundary();
185
+ if (done) return end();
186
+ if (step.done === true) {
187
+ sourceReleased = true; // exhausted native iterators already reset
188
+ settle();
189
+ return end();
190
+ }
191
+ buffered = spec.items?.(step.value) ?? [];
192
+ offset = 0;
193
+ return null;
194
+ };
195
+ const rows = () => {
196
+ for (;;) {
197
+ boundary();
198
+ if (done) return end();
199
+ if (offset < buffered.length) return { done: false, value: buffered[offset++] };
200
+ const next = underlying.next();
201
+ if (isThenable(next)) return next.then((step) => accept(step) ?? rows());
202
+ const result = accept(next);
203
+ if (result !== null) return result;
204
+ }
205
+ };
206
+ const pull = () => {
207
+ boundary();
208
+ if (done) return end();
209
+ if (spec.materialize !== undefined) {
210
+ if (!opened) {
211
+ opened = true;
212
+ return chain(spec.materialize(), (items) => {
213
+ boundary();
214
+ if (done) return end();
215
+ buffered = items;
216
+ return pull();
217
+ });
218
+ }
219
+ if (offset < buffered.length) return { done: false, value: buffered[offset++] };
220
+ settle();
221
+ return end();
222
+ }
223
+ if (!opened) {
224
+ opened = true;
225
+ opening = chain(spec.open?.(), (iterator) => { underlying = iterator; });
226
+ return chain(opening, () => done
227
+ ? chain(releaseSource(), () => { boundary(); return end(); }) : rows());
228
+ }
229
+ return rows();
230
+ };
231
+ const guarded = () => settling(pull, release, spec.wrap);
232
+ if (signal?.aborted) settle();
233
+ else signal?.addEventListener('abort', onAbort, { once: true });
234
+ const next = synchronous ? guarded : () => {
235
+ const result = tail.then(guarded);
236
+ tail = result.then(() => undefined, () => undefined);
237
+ return result;
238
+ };
239
+ const finish = () => chain(release(), end);
240
+ const cursor = {
241
+ streaming,
242
+ barrier: spec.barrier ?? null,
243
+ get settled() { return done; },
244
+ next,
245
+ return: synchronous ? finish : () => {
246
+ try { return Promise.resolve(finish()); }
247
+ catch (error) { return Promise.reject(error); }
248
+ },
249
+ ...(synchronous
250
+ ? { [Symbol.iterator]: () => cursor, [Symbol.dispose]: finish }
251
+ : { [Symbol.asyncIterator]: () => cursor, [Symbol.asyncDispose]: async () => { await finish(); } }),
252
+ };
253
+ return Object.freeze(cursor);
254
+ }
255
+
256
+ /**
257
+ * A ROOT cursor's admission: the one decorator every store-level cursor
258
+ * — a collection's `query()`, an entity set's `cursor()` and
259
+ * `loadCursor()` — is handed back through, so the rule that a root read
260
+ * cannot fall inside a transaction it is not part of (MODEL-FORMAT §5.1)
261
+ * holds for a streaming read exactly as it holds for a finite one, at the
262
+ * granularity a stream can afford: **per pull**. Construction holds
263
+ * nothing. Each `next()` borrows the store gate for all the source work
264
+ * one public item needs (opening the statement on the first pull, the
265
+ * row steps, a row that yields no item), then releases before the
266
+ * promise settles, so a consumer paused between pulls blocks no
267
+ * transaction and a pull made while one is open waits for its commit
268
+ * and reads committed state only. `return()` is admitted the same way;
269
+ * a release the gate REFUSES (a contended `transactions: 'strict'`
270
+ * store, a queue timeout) still runs, off-gate — a statement reset reads
271
+ * and writes nothing, and a statement left open until the store closes
272
+ * is the worse outcome — and answers `{ done: true }`. An abort likewise
273
+ * resets the source at once, off-gate, through the inner cursor's own
274
+ * listener: an abort asks for the statement to be let go, not for it to
275
+ * be held until a stranger's transaction commits.
276
+ *
277
+ * Refusals keep their granularity: a pull abandoned while QUEUED is the
278
+ * gate's `JD2064` (the source, never opened for that pull, needs no
279
+ * release); a pull whose signal is already aborted, or aborts at a row
280
+ * boundary, is the cursor's own `JD2072` and releases the source once; a
281
+ * passed deadline is `JD2075`. A cursor that has settled — exhausted,
282
+ * released, aborted — answers `{ done: true }` without borrowing the
283
+ * gate at all. The decorator buffers no item and holds no gate between
284
+ * two public pulls: `streaming`, `barrier` and the asynchronous-iterator
285
+ * identity are the inner cursor's own.
286
+ * @param {any} cursor - the engine's `QueryCursor`
287
+ * @param {(fn: () => any, what?: string, signal?: AbortSignal) => any} admit
288
+ * - the store gate: runs `fn` holding the connection, value-or-promise
289
+ * @param {AbortSignal | undefined} signal - the cursor's own signal, so an
290
+ * abort abandons a queued pull
291
+ * @param {string} what - what is waiting, for the gate's timeout message
292
+ * @returns {any} the admitted `QueryCursor`
293
+ */
294
+ export function admitCursor(cursor, admit, signal, what) {
295
+ /**
296
+ * @param {'next' | 'return'} member
297
+ * @param {string} label
298
+ * @param {boolean} abandonable - whether an abort leaves the queue
299
+ */
300
+ const through = (member, label, abandonable) => () => {
301
+ // an already-aborted cursor refuses on its own (`JD2072`, every later
302
+ // pull) and releases its source; the gate has nothing to admit
303
+ if (abandonable && signal?.aborted === true) return cursor[member]();
304
+ // a settled cursor holds no source: nothing to admit, nothing to wait for
305
+ if (cursor.settled === true) return Promise.resolve({ done: true, value: undefined });
306
+ let admitted;
307
+ try {
308
+ admitted = Promise.resolve(admit(() => cursor[member](), label, abandonable ? signal : undefined));
309
+ }
310
+ catch (error) {
311
+ admitted = Promise.reject(error);
312
+ }
313
+ if (member !== 'return') return admitted;
314
+ // a refused release still releases: the reset lands off-gate rather
315
+ // than leaving the statement open
316
+ return admitted.catch(() => cursor.return());
317
+ };
318
+ /** @type {any} */
319
+ const admitted = {
320
+ streaming: cursor.streaming,
321
+ barrier: cursor.barrier,
322
+ next: through('next', what, true),
323
+ return: through('return', `${what} (release)`, false),
324
+ [Symbol.asyncIterator]: () => admitted,
325
+ };
326
+ return Object.freeze(admitted);
327
+ }
328
+
329
+ /** Synchronous admission per pull; cleanup is permitted even after refusal.
330
+ * @param {any} cursor @param {(fn: () => any) => any} admit
331
+ * @returns {any}
332
+ */
333
+ export function admitSyncCursor(cursor, admit) {
334
+ const wrapped = {
335
+ streaming: cursor.streaming,
336
+ barrier: cursor.barrier,
337
+ next: () => admit(() => cursor.next()),
338
+ return: () => cursor.return(),
339
+ [Symbol.iterator]: () => wrapped,
340
+ [Symbol.dispose]: () => { cursor.return(); },
341
+ };
342
+ return Object.freeze(wrapped);
343
+ }
344
+
345
+ /** The page size a page takes when none is given. */
346
+ export const PAGE_LIMIT_DEFAULT = 100;
347
+
348
+ /**
349
+ * Drain a cursor into ONE page: at most `limit` items, at most `maxBytes`
350
+ * serialised bytes (`null` for no byte bound), stopping at an item
351
+ * boundary and releasing the cursor. The one implementation every page
352
+ * in this package is — an entity page, a change page — so the
353
+ * `item_too_large` rule exists once: an item that alone exceeds
354
+ * `maxBytes` when nothing has been delivered yet is the refusal
355
+ * `JD2074`, raised WITHOUT advancing the continuation, so a caller that
356
+ * retries meets the same refusal instead of a loop or a silent breach.
357
+ * An item that does not fit beside earlier ones ends the page before
358
+ * it: `hasMore` is true and the continuation is the last delivered
359
+ * item's, so the next page starts at the item that did not fit.
360
+ * `hasMore` is otherwise decided by one peek past `limit`.
361
+ * @param {any} cursor - a `QueryCursor`
362
+ * @param {{ limit: number, maxBytes: number | null, after?: any,
363
+ * sizeOf: (item: any) => number, continuationOf: (item: any) => any }} options
364
+ * @returns {any} value-or-promise, matching the cursor
365
+ */
366
+ export function drainPage(cursor, options) {
367
+ const { limit, maxBytes, sizeOf, continuationOf } = options;
368
+ const after = options.after ?? null;
369
+ const items = [];
370
+ let bytes = 0;
371
+ let last = after;
372
+ let hasMore = false;
373
+ const finish = () => chain(cursor.return(), () => ({
374
+ items, continuation: items.length > 0 ? last : (hasMore ? after : null), hasMore,
375
+ }));
376
+ const consume = (pulled) => {
377
+ if (items.length >= limit) {
378
+ hasMore = pulled.done !== true;
379
+ return finish();
380
+ }
381
+ if (pulled.done === true) return finish();
382
+ const item = pulled.value;
383
+ const size = maxBytes === null ? 0 : sizeOf(item);
384
+ if (maxBytes !== null && bytes + size > maxBytes) {
385
+ if (items.length === 0) {
386
+ return chain(cursor.return(), () => {
387
+ throw new DbRuntimeError('JD2074',
388
+ `the next item is ${size} serialised bytes, more than the page's maxBytes bound of `
389
+ + `${maxBytes}; the continuation was not advanced — raise the bound, or bound the `
390
+ + "item itself (an include's maxBytes, a narrower document)",
391
+ { errors: [{ bytes: size, maxBytes, at: continuationOf(item) }] });
392
+ });
393
+ }
394
+ hasMore = true;
395
+ return finish();
396
+ }
397
+ items.push(item);
398
+ bytes += size;
399
+ last = continuationOf(item);
400
+ return null;
401
+ };
402
+ const step = () => {
403
+ for (;;) {
404
+ const pulled = cursor.next();
405
+ if (isThenable(pulled)) return pulled.then((value) => consume(value) ?? step());
406
+ const result = consume(pulled);
407
+ if (result !== null) return result;
408
+ }
409
+ };
410
+ return settling(step, () => cursor.return());
411
+ }
package/src/dag-job.js CHANGED
@@ -8,13 +8,41 @@
8
8
  * graph name flow nowhere.
9
9
  *
10
10
  * Each kind's document compiles ONCE against a delegating checkpoint
11
- * store; per claimed job, the delegate binds the engine's guarded
12
- * per-job store (`checkpointsFor`) `save` refuses once the lease is
13
- * lost and `complete` records the DAG result, marks the job done and
14
- * prunes the checkpoint rows in ONE transaction, so a failure leaves
15
- * neither and a crash resumes instead of restarting.
11
+ * store; the delegate resolves per ATTEMPT, never per job, because two
12
+ * attempts of one job are two different runs of the same workflow and
13
+ * the older one may not write on the younger one's behalf. The engine's
14
+ * store carries the fence: `save` is refused once the lease is lost, and
15
+ * `complete` records the DAG result, marks the job done and prunes the
16
+ * checkpoint rows in ONE transaction, so a failure leaves neither and a
17
+ * crash resumes instead of restarting.
18
+ *
19
+ * A resumed run also has to be the SAME run. The workflow document's
20
+ * revision and a hash of the input are persisted with the checkpoints,
21
+ * and a resume that disagrees with either is refused by name — reusing
22
+ * checkpoints written by a different workflow is not a resume, it is a
23
+ * silently wrong answer.
16
24
  */
17
25
 
26
+ import { canonicalizeJson } from '@jarenjs/json/canonical';
27
+ import { hashContent } from '@jarenjs/core/string';
28
+ import { setObjectMember } from '@jarenjs/core/object';
29
+
30
+ import { DbRuntimeError } from './errors.js';
31
+
32
+ /** The checkpoint row a run's identity lives in. The leading unit
33
+ * separator keeps it out of any node-id namespace a document could
34
+ * declare, and the DAG's seeding skips it because no node is called
35
+ * that. It is pruned with the run it belongs to. */
36
+ export const RUN_IDENTITY_NODE = '\u001Fidentity';
37
+
38
+ /** What joins a job id to an attempt's token in the run key the DAG
39
+ * sees. A unit separator, so no job id can carry one by accident. */
40
+ const RUN_KEY_SEPARATOR = '\u001F';
41
+
42
+ /** A stable fingerprint of any JSON value: the suite's one content hash
43
+ * over the suite's one canonical form. */
44
+ const fingerprint = (value) => hashContent(canonicalizeJson(value ?? null));
45
+
18
46
  /**
19
47
  * Build a worker whose handlers run checkpointed DAG documents.
20
48
  * @param {any} store - an open store with `{ jobs: true }`
@@ -22,8 +50,10 @@
22
50
  * documents: Record<string, any>,
23
51
  * tasks?: Record<string, Function>,
24
52
  * concurrency?: number, pollInterval?: number, leaseMs?: number,
25
- * owner?: string, backoffBase?: number, backoffCap?: number }} options
26
- * @returns {{ start: () => any, stop: () => Promise<void>, stats: () => any }}
53
+ * owner?: string, renew?: boolean, onOutcome?: (event: any) => void,
54
+ * backoffBase?: number, backoffCap?: number,
55
+ * stopGraceMs?: number }} options
56
+ * @returns {{ start: () => any, stop: (options?: any) => Promise<any>, stats: () => any }}
27
57
  */
28
58
  export function createDagJobRunner(store, options) {
29
59
  if (store?.jobs === undefined) {
@@ -42,20 +72,109 @@ export function createDagJobRunner(store, options) {
42
72
  'createDagJobRunner: "documents" must map job kinds to dag documents');
43
73
  }
44
74
 
45
- /** The active claim contexts, keyed by run id (= job id): the
46
- * delegate resolves the CURRENT lease binding per store call. */
75
+ /**
76
+ * The attempts running right now, keyed by the run key the DAG was
77
+ * given — the job id and this attempt's fence token. Never by owner,
78
+ * and never by job id alone: a re-claim of the same job is a different
79
+ * attempt, and a delegate that could not tell them apart would let the
80
+ * older one write through the younger one's store.
81
+ * @type {Map<string, any>}
82
+ */
47
83
  const active = new Map();
48
- const boundStore = (runId) => {
49
- const context = active.get(runId);
84
+
85
+ /** The run key the DAG sees, and the job id the STORE sees, are not
86
+ * the same string: the store keys rows by job, the delegate keys
87
+ * bindings by attempt. */
88
+ const runKeyOf = (jobId, token) => `${jobId}${RUN_KEY_SEPARATOR}${token}`;
89
+ const jobIdOf = (runKey) => runKey.slice(0, runKey.indexOf(RUN_KEY_SEPARATOR));
90
+
91
+ const bound = (runKey) => {
92
+ const context = active.get(runKey);
50
93
  if (context === undefined) {
51
- throw new Error(`no active job holds run '${runId}'`);
94
+ throw new DbRuntimeError('JD2069',
95
+ `no attempt holds run '${jobIdOf(runKey)}' — its lease was lost, or the run `
96
+ + 'outlived the handler that started it',
97
+ { docPath: '/jobs' });
52
98
  }
53
- return context.checkpointsFor(context.job);
99
+ return context;
54
100
  };
55
101
  const checkpoint = {
56
- load: (runId) => boundStore(runId).load(runId),
57
- save: (runId, nodeId, value) => boundStore(runId).save(runId, nodeId, value),
58
- complete: (runId, result) => boundStore(runId).complete(runId, result),
102
+ load: (runKey) => bound(runKey).checkpoints.load(jobIdOf(runKey)),
103
+ save: (runKey, nodeId, value) =>
104
+ bound(runKey).checkpoints.save(jobIdOf(runKey), nodeId, value),
105
+ complete: (runKey, result) =>
106
+ bound(runKey).checkpoints.complete(jobIdOf(runKey), result),
107
+ };
108
+
109
+ /** Which declared task identities moved between two version maps. */
110
+ const describeVersionDrift = (before, after) => {
111
+ const names = [...new Set([...Object.keys(before ?? {}), ...Object.keys(after ?? {})])].sort();
112
+ const moved = [];
113
+ for (const name of names) {
114
+ const was = before?.[name];
115
+ const now = after?.[name];
116
+ if (was === now) continue;
117
+ if (was === undefined) moved.push(`'${name}' is new at version ${now}`);
118
+ else if (now === undefined) moved.push(`'${name}' is gone (was version ${was})`);
119
+ else moved.push(`'${name}' moved from version ${was} to ${now}`);
120
+ }
121
+ return moved;
122
+ };
123
+
124
+ /**
125
+ * The identity a resumed run must agree with: the workflow document,
126
+ * the input, and the DECLARED versions of the task implementations
127
+ * (FLOW-FORMAT §7.8). The third closes the gap the first two cannot
128
+ * see — a handler reimplemented while its document stayed byte-equal
129
+ * produces checkpoints that describe a computation nobody asked for
130
+ * just as surely as an edited document does.
131
+ */
132
+ const requireSameRun = async (context, jobId, revision, inputHash, taskVersions) => {
133
+ const loaded = await context.checkpoints.load(jobId);
134
+ const stored = loaded?.values?.[RUN_IDENTITY_NODE];
135
+ const taskVersionsHash = fingerprint(taskVersions);
136
+ const identity = { revision, inputHash, taskVersionsHash, taskVersions };
137
+ if (stored === undefined) {
138
+ await context.checkpoints.save(jobId, RUN_IDENTITY_NODE, identity);
139
+ return;
140
+ }
141
+ const differs = [];
142
+ if (stored.revision !== revision) {
143
+ differs.push(`the workflow (checkpointed under revision ${stored.revision}, `
144
+ + `this runner compiles revision ${revision})`);
145
+ }
146
+ if (stored.inputHash !== inputHash) {
147
+ differs.push(`the input (checkpointed under ${stored.inputHash}, `
148
+ + `this attempt was given ${inputHash})`);
149
+ }
150
+ if (stored.taskVersionsHash === undefined) {
151
+ // A row written before task identity was recorded. Unknown is not
152
+ // equal: the upgrade is allowed only where nothing can be replayed
153
+ // wrongly — when no node value has been recorded yet, so the run has
154
+ // nothing to inherit from an implementation nobody can name.
155
+ const recorded = Object.keys(loaded?.values ?? {})
156
+ .filter((nodeId) => nodeId !== RUN_IDENTITY_NODE);
157
+ if (recorded.length === 0) {
158
+ await context.checkpoints.save(jobId, RUN_IDENTITY_NODE, identity);
159
+ }
160
+ else {
161
+ differs.push(`the task versions (this run recorded ${recorded.length} node value(s) `
162
+ + 'before task identity was persisted, so the implementation that produced them '
163
+ + 'cannot be confirmed)');
164
+ }
165
+ }
166
+ else if (stored.taskVersionsHash !== taskVersionsHash) {
167
+ const moved = describeVersionDrift(stored.taskVersions, taskVersions);
168
+ differs.push(`the task versions (${moved.length > 0 ? moved.join(', ')
169
+ : `checkpointed under ${stored.taskVersionsHash}, this runner compiles `
170
+ + `${taskVersionsHash}`})`);
171
+ }
172
+ if (differs.length === 0) return;
173
+ throw new DbRuntimeError('JD2069',
174
+ `run '${jobId}' cannot resume: ${differs.join(' and ')} changed since its `
175
+ + 'checkpoints were written. Enqueue it under a new id, or drop the run — '
176
+ + 'reusing them would answer for a computation nobody asked for',
177
+ { docPath: '/jobs', collection: jobId });
59
178
  };
60
179
 
61
180
  /** @type {Record<string, Function>} */
@@ -63,15 +182,24 @@ export function createDagJobRunner(store, options) {
63
182
  for (const kind of Object.keys(documents)) {
64
183
  const compiled = compileDag(documents[kind],
65
184
  { tasks: options.tasks ?? {}, checkpoint });
66
- handlers[kind] = async (payload, context) => {
67
- active.set(context.job.id, context);
185
+ // one revision per compiled document, so every attempt of every run
186
+ // of this kind compares against the same number
187
+ const revision = fingerprint(documents[kind]);
188
+ setObjectMember(handlers, kind, async (payload, context) => {
189
+ const input = payload?.input ?? null;
190
+ const runKey = runKeyOf(context.job.id, context.job.lease.token);
191
+ active.set(runKey, context);
68
192
  try {
69
- return await compiled.run(payload?.input ?? null, { runId: context.job.id });
193
+ await requireSameRun(context, context.job.id, revision, fingerprint(input),
194
+ compiled.taskVersions);
195
+ // the handler's signal reaches every task: a worker winding down
196
+ // inside its grace period, or a lease this attempt has lost
197
+ return await compiled.run(input, { runId: runKey, signal: context.signal });
70
198
  }
71
199
  finally {
72
- active.delete(context.job.id);
200
+ active.delete(runKey);
73
201
  }
74
- };
202
+ });
75
203
  }
76
204
 
77
205
  return store.jobs.createWorker({
@@ -80,7 +208,12 @@ export function createDagJobRunner(store, options) {
80
208
  pollInterval: options.pollInterval,
81
209
  leaseMs: options.leaseMs,
82
210
  owner: options.owner,
211
+ renew: options.renew,
212
+ onOutcome: options.onOutcome,
83
213
  backoffBase: options.backoffBase,
84
214
  backoffCap: options.backoffCap,
215
+ // the runner's declared default for `stop()` with no override; an
216
+ // explicit `stop({ graceMs })` still wins inside the worker
217
+ stopGraceMs: options.stopGraceMs,
85
218
  });
86
219
  }