@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
@@ -2,8 +2,8 @@
2
2
 
3
3
  This document is normative. The key words MUST, MUST NOT, SHOULD and
4
4
  MAY are to be interpreted as described in RFC 2119. Error codes join
5
- the package's single runtime table (MODEL-FORMAT §7); this order adds
6
- none see §9.
5
+ the package's single runtime table (MODEL-FORMAT §7) the fence's five
6
+ are listed in §9.
7
7
 
8
8
  ## 1. Scope
9
9
 
@@ -37,28 +37,38 @@ restarting.
37
37
  | `id` | TEXT primary key; caller-supplied or a UUID. **A caller-supplied id makes `enqueue` idempotent**: re-enqueueing an existing id changes nothing and answers the id — this is the idempotency key of §7 |
38
38
  | `kind` | the registered handler name (the suite's named-registry discipline) |
39
39
  | `payload` | the enqueue payload, JSON text; `null` stays null |
40
- | `state` | `pending` → `leased` → `done`, or `failed` (awaiting retry) → `dead` (attempts exhausted) |
40
+ | `state` | `pending` → `leased` → `done`, or `failed` (awaiting retry) → `dead` (attempts exhausted); `cancelled` (terminal, set only by `cancel()`, §10) |
41
41
  | `run_at` | epoch ms eligibility: scheduling and retry backoff are the same mechanism |
42
42
  | `attempts` | claims so far; incremented AT claim, so a crashed attempt counts |
43
43
  | `max_attempts` | per job, default 5 |
44
- | `lease_until`, `lease_owner` | the lease (§3); null unless leased |
44
+ | `lease_until`, `lease_owner` | the lease (§3); null unless leased. The owner is DIAGNOSTICS: one worker reuses one owner for every attempt it makes, so it never guards anything |
45
+ | `lease_generation` | the attempt this row is on, incremented by every claim. It identifies the attempt, which the owner cannot |
46
+ | `lease_token` | the opaque fence one claim mints; the guard every settling call carries. Never handed out by `get` |
45
47
  | `last_error` | the last failure's `message`, retained through retries and into `dead` |
46
48
  | `result` | the completion value, JSON text (§7 records the DAG output here) |
47
49
  | `created_at`, `updated_at` | epoch ms |
48
50
 
49
- `_jaren_job_checkpoints` holds `(run_id, node_id, value)` rows — the
50
- flow checkpoint store of §7, keyed by the JOB id (the run id IS the
51
- job id). Both tables are created on open when `jobs` is requested;
52
- neither appears in the model.
51
+ `_jaren_job_checkpoints` holds `(run_id, node_id, value, generation)`
52
+ rows — the flow checkpoint store of §7, keyed by the JOB id (the run id
53
+ IS the job id) and stamped with the generation that wrote them. Both
54
+ tables are created on open when `jobs` is requested;
55
+ neither appears in the model. A database written before the fence is
56
+ upgraded IN PLACE — the three columns are added behind a presence check,
57
+ because jobs rows are live work and a rebuild would drop a queue.
58
+ `enqueue`'s options are validated as
59
+ they are stored: `runAt` must be a finite epoch in milliseconds and
60
+ `maxAttempts` a positive integer (`TypeError`) — a `NaN` eligibility
61
+ was once stored, and that job was pending forever.
53
62
 
54
- ## 3. Leasing and exactly-once execution
63
+ ## 3. Leasing, the fence, and exactly-once settlement
55
64
 
56
65
  Claiming is ONE guarded statement — one statement is one transaction,
57
66
  so two workers cannot claim the same job, without any distributed
58
- lock:
67
+ lock — and it MINTS the fence the attempt will settle with:
59
68
 
60
69
  ```sql
61
70
  UPDATE "_jaren_jobs" SET state='leased', lease_owner=?, lease_until=?,
71
+ lease_generation=lease_generation+1, lease_token=?,
62
72
  attempts=attempts+1, updated_at=?
63
73
  WHERE id = (SELECT id FROM "_jaren_jobs"
64
74
  WHERE (state='pending' OR state='failed'
@@ -72,16 +82,105 @@ RETURNING *
72
82
  whose kind has no registered handler on this worker is simply never
73
83
  claimed by it** — it stays `pending` and shows in `counts()`, it is
74
84
  NOT dead-lettered by an accident of rollout order.
75
- - Every later transition is guarded by the lease:
76
- `… WHERE id=? AND state='leased' AND lease_owner=?`. A worker whose
77
- lease expired mid-run (a stall, a long GC, a laptop lid) finds its
78
- completion matching ZERO rows and its result discarded — the job
79
- belongs to whoever re-claimed it. Execution is therefore
80
- AT-LEAST-ONCE; **completion is exactly-once**. Idempotency of side
81
- effects is the handler's responsibility (§7).
82
85
  - Eligibility order is `run_at, created_at, id` — oldest first,
83
86
  deterministic. There are no priority classes (§8).
84
87
 
88
+ ### The lease is a capability
89
+
90
+ `claim` answers the job record with a frozen `lease` beside it:
91
+
92
+ ```jsonc
93
+ { "jobId": "…", "token": "…", "generation": 3,
94
+ "attempt": 3, "owner": "worker-a", "expiresAt": 1730000000000 }
95
+ ```
96
+
97
+ It is a **token, never an owner**. One worker mints one owner and reuses
98
+ it for every attempt it ever makes, so an owner cannot say *which*
99
+ attempt is speaking — and the two attempts that matter are exactly the
100
+ ones an owner cannot tell apart: the corpse of an expired attempt and
101
+ the live one that re-claimed the job. The `generation` names the attempt;
102
+ the `token` proves the caller holds it.
103
+
104
+ It is also **immutable**. `renew` answers a NEW lease and retires the one
105
+ it was given, so a reference someone kept across a renewal can never
106
+ quietly become valid again.
107
+
108
+ `get()` returns the record with `leaseGeneration` but **no token**: a
109
+ record anyone can read must not carry the capability to settle the job it
110
+ describes.
111
+
112
+ ### Every settling call carries the fence
113
+
114
+ `renew`, `complete`, `fail` and a checkpoint `save` all guard on:
115
+
116
+ ```sql
117
+ … WHERE id=? AND state='leased' AND lease_token=? AND lease_until > ?
118
+ ```
119
+
120
+ The owner appears nowhere in that clause, and the validity check is not
121
+ optional: without it, a lease thirty seconds dead still completed a job.
122
+
123
+ A guard that matches nothing is **never `false`**. It is a coded refusal
124
+ naming which of three things happened, because a caller that cannot tell
125
+ "already done" from "you are stale" guesses, and guesses wrong:
126
+
127
+ | code | what happened |
128
+ |---|---|
129
+ | `JD2065` | the job is not leased — unknown, or already settled by someone else |
130
+ | `JD2066` | the lease was superseded: another claim or renewal holds it now, and the message names both generations |
131
+ | `JD2067` | the lease expired before the call, and the message says by how long |
132
+
133
+ A fourth, `JD2068`, catches the pre-fence spelling: `complete(id, owner,
134
+ result)` is refused by name and pointed at `job.lease`.
135
+
136
+ Settling **twice from one attempt** is idempotent, not a refusal — a §7
137
+ handler completes transactionally and the worker's own completion lands
138
+ behind it, and the row's generation says whose settlement it carries.
139
+
140
+ ### What exactly-once means here, and what it does not
141
+
142
+ Execution is AT-LEAST-ONCE. **Settlement is exactly-once against the
143
+ store**: exactly one attempt's result can ever land on the row, and every
144
+ other attempt is told, by code, that it is not the one.
145
+
146
+ That is not the same as making an *external* effect exactly-once. If a
147
+ handler sends an email, charges a card or writes to another system, the
148
+ fence cannot un-send it: a reclaimed job runs the handler again, and both
149
+ runs reach the outside world even though only one of them will ever
150
+ settle the row. Idempotency of side effects remains the handler's
151
+ responsibility — the fence guarantees the *record*, not the world.
152
+
153
+ ### Renewal
154
+
155
+ `jobs.renew(lease, { leaseMs })` moves the expiry out and mints a new
156
+ token, keeping the same generation (a renewal is the same attempt, so its
157
+ checkpoints stay its own). It is what a handler that legitimately
158
+ outlives `leaseMs` uses instead of hoping; the worker does it
159
+ automatically (§6).
160
+
161
+ ### Ownership: root calls and the transactional outbox
162
+
163
+ The handle decides whose transaction a job write belongs to, exactly as
164
+ it does for collections (MODEL-FORMAT §5.1):
165
+
166
+ - **Root `store.jobs.*`** finite calls take the store gate. An enqueue,
167
+ claim, checkpoint or settlement made while an unrelated application
168
+ transaction is open **waits for its commit** (or refuses under
169
+ `transactions: 'strict'`) and can never join its rollback.
170
+ - **`tx.jobs.*`** — the jobs view a transaction callback receives — runs
171
+ as that exact scope. This is the **transactional outbox** spelling: an
172
+ enqueue there becomes visible with the domain transaction's commit and
173
+ vanishes with its rollback, atomically. A retained `tx.jobs` view is
174
+ `JD2070` once its scope settles.
175
+ - **A worker is a root-owned long-lived component**, wherever it was
176
+ created: its claims, renewals, checkpoint stores and settlements take
177
+ the root gate even when an application transaction happens to be open,
178
+ and creating a worker through a transaction view does not bind its
179
+ future loop to that transaction.
180
+ - **A checkpoint store keeps the ownership of the jobs view that created
181
+ it** — root stores gate, transaction-scoped stores stay pinned to
182
+ their exact scope and cannot switch.
183
+
85
184
  ## 4. Retry and dead-lettering
86
185
 
87
186
  A failed attempt (the handler threw or rejected) records
@@ -95,8 +194,9 @@ A failed attempt (the handler threw or rejected) records
95
194
 
96
195
  Backoff is exponential with jitter:
97
196
  `min(cap, base × 2^(attempts−1)) × (0.5 + random()/2)`, defaults
98
- `base` 1 000 ms, `cap` 60 000 ms. `Math.random` is the runtime
99
- default; the `random` option injects a deterministic source and every
197
+ `base` 1 000 ms, `cap` 60 000 ms. `Math.random` is the platform
198
+ default; the `random` option or the store's runtime record
199
+ (`@jarenjs/core/runtime`), where no explicit option is given — injects a deterministic source and every
100
200
  test in this repository does.
101
201
 
102
202
  ## 5. Recovery
@@ -105,30 +205,73 @@ A worker that dies mid-job leaves a `leased` row whose `lease_until`
105
205
  passes; the claim statement (§3) treats an expired lease exactly like
106
206
  `pending`, so **recovery is not a separate sweeper — it is the next
107
207
  claim**. The reclaimed attempt re-runs the handler; a §7 DAG job
108
- resumes from its checkpoint rows rather than restarting. The lease
109
- duration (`leaseMs`, default 30 000 ms) is therefore the recovery
110
- latency ceiling: a handler that legitimately outlives its lease gets
111
- reclaimed — size `leaseMs` to the slowest honest handler.
208
+ resumes from its checkpoint rows rather than restarting, and the
209
+ reclaimed attempt reads the checkpoints written up to and including its
210
+ own generation, so it resumes from its predecessor's work.
211
+
212
+ The reclaim also FENCES the attempt it displaced: the older token settles
213
+ nothing from that moment, and its settlement is refused `JD2066` rather
214
+ than silently discarded. A stale attempt therefore cannot mark the job
215
+ done over the live one's result, and cannot prune the live one's
216
+ checkpoints — a settlement deletes only generations at or below its own.
217
+
218
+ The lease duration (`leaseMs`, default 30 000 ms) is the recovery
219
+ latency ceiling. A handler that legitimately outlives it does not have to
220
+ be reclaimed: the worker renews while it runs (§6).
112
221
 
113
222
  ## 6. Workers and concurrency
114
223
 
115
224
  `createWorker({ handlers, concurrency, pollInterval, leaseMs, owner,
116
- maxAttempts, backoff, random })` returns `{ start(), stop(),
117
- stats() }`:
225
+ renew, onOutcome, backoffBase, backoffCap, stopGraceMs })` returns
226
+ `{ start(), stop(), stats(), leases() }`:
118
227
 
119
- - `concurrency` (default 1) independent claim-execute loops share one
120
- worker registration;
228
+ - `concurrency` (default 1, a positive integer `TypeError`
229
+ otherwise, because a worker with zero loops would `start()` and never
230
+ claim) independent claim-execute loops share one worker registration;
121
231
  - an idle loop sleeps `pollInterval` (default 500 ms — at most
122
232
  2 claims/s of idle cost per loop, stated). An `enqueue` on the SAME
123
233
  store wakes every idle local loop immediately, so same-process
124
234
  latency is not poll-bound; **cross-process wake-up is polling**,
125
235
  plainly (§8);
236
+ - retry backoff is `min(backoffCap, backoffBase × 2^(attempts − 1))`,
237
+ jittered to between half and all of itself (defaults 1 s and 60 s);
238
+ `maxAttempts` belongs to the JOB (`enqueue(kind, payload,
239
+ { maxAttempts })`, default 5), not to the worker;
126
240
  - `stop({ graceMs })` aborts in-flight handlers and resolves once they
127
- settle **or** the grace period expires (default 5 s), answering
241
+ settle **or** the grace period expires (default `stopGraceMs`, 5 s),
242
+ answering
128
243
  `{ drained, inFlight }`; a loop the grace period could not drain is
129
244
  **cancelled**, not left running — see §6.1;
130
- - `stats()` reports claims, completions, failures, wakes, polls and the
131
- in-flight handler count.
245
+ - each in-flight attempt's lease is **renewed** while its handler runs,
246
+ at a third of `leaseMs`, so two renewals may fail before the attempt
247
+ is actually at risk. Every renewal replaces the lease. `renew: false`
248
+ turns it off for a handler that must not outlive its lease.
249
+ **A renewal that fails for a storage reason is not lease loss**: only
250
+ the three fence codes (`JD2065`/`JD2066`/`JD2067`) prove the attempt
251
+ no longer holds the job. On any other failure — a busy database, a
252
+ contended gate — the current immutable lease stays in force, the
253
+ handler's signal stays live, and the next renewal is armed; if the
254
+ lease truly expires first, the next fenced call answers `JD2067` and
255
+ that is the one recorded loss;
256
+ - an attempt whose lease is **lost** — superseded or expired — has its
257
+ signal aborted with the refusal as the reason, and is then barred from
258
+ settling. It is its own outcome: `lostSettlements`, never a completion
259
+ (which is how a corpse once reported success over work another attempt
260
+ was still doing) and never a failure (which would burn a retry the job
261
+ never spent);
262
+ - `onOutcome` is called once per settled attempt —
263
+ `{ outcome: 'completed' | 'failed' | 'lost', jobId, kind, attempt,
264
+ generation, code?, reason? }`. An observer that throws never affects
265
+ the loop;
266
+ - `leases()` lists the leases this worker holds right now, one per
267
+ in-flight attempt. In-flight state is keyed by the fence TOKEN, never
268
+ by the owner: one worker reuses one owner, and two attempts of one job
269
+ must not collide in its own bookkeeping;
270
+ - `stats()` reports claims, completions, failures, wakes, polls,
271
+ renewals, lost settlements, the in-flight handler count and
272
+ `claimErrors` — a claim statement the database refused (a read-only
273
+ file, a closed store), counted rather than swallowed, so a worker that
274
+ can never claim is visible instead of silently idle.
132
275
 
133
276
  ### 6.1 A handler cannot break the loop, and cannot hold shutdown
134
277
 
@@ -148,13 +291,20 @@ job:
148
291
 
149
292
  ```js
150
293
  handlers: {
151
- sync: async (payload, { job, signal, checkpointsFor }) => {
294
+ sync: async (payload, { job, signal, checkpoints }) => {
152
295
  const res = await fetch(url, { signal }); // cancelled on stop()
153
296
 
154
297
  },
155
298
  }
156
299
  ```
157
300
 
301
+ The same signal aborts for the other reason a handler must wind up: this
302
+ attempt no longer holds the job. Its `reason` is then the coded refusal
303
+ that says which — so a handler can tell "we are shutting down" from "you
304
+ have been superseded" without asking. `checkpoints` is the §7 store
305
+ already bound to this attempt, and it follows the attempt's current
306
+ lease, so a renewal does not strand it.
307
+
158
308
  `stop({ graceMs })` aborts the signal, waits up to `graceMs`, and then
159
309
  returns `{ drained, inFlight }` regardless. `store.close({ graceMs })`
160
310
  does the same and **closes the connection either way**, then reports
@@ -173,6 +323,18 @@ re-registers the worker's wake-on-enqueue hook and its place in
173
323
  `stopAll`), so a cancelled loop can never be revived as an extra
174
324
  claimer.
175
325
 
326
+ `stop()` carries a second, UNCONDITIONAL obligation beside the
327
+ grace-bounded handler drain, and the two never share one unresolved
328
+ promise: **quiescence**. Before `stop()` resolves, every claim, renewal,
329
+ checkpoint and settlement the worker itself started is cancelled or
330
+ drained — a claim still queued behind an open application transaction
331
+ leaves the connection's queue on the shutdown signal instead of running
332
+ later against a closing store — and no poll or renewal timer stays
333
+ armed or re-arms. A grace timeout may detach a hostile handler
334
+ (`{ drained: false, inFlight }`); it may not leave a database operation
335
+ behind it. This is what makes `worker.stop()` followed by
336
+ `store.close()` release the database file deterministically.
337
+
176
338
  ## 7. The DAG composition
177
339
 
178
340
  ```js
@@ -190,19 +352,52 @@ await store.jobs.enqueue('sync-report', { input: { day: '2026-08-05' } });
190
352
  `@jarenjs/db`'s manifest and import graph name `@jarenjs/flow`
191
353
  nowhere, asserted by test.
192
354
  - The job id is the run id. Node values save into
193
- `_jaren_job_checkpoints` as the run progresses; `complete` records
194
- the DAG result AND marks the job `done` **in one guarded
195
- statement** — one transaction, so a failure leaves neither, and the
196
- checkpoint rows of a finished job are pruned in the same breath.
355
+ `_jaren_job_checkpoints` as the run progresses, each stamped with the
356
+ writing attempt's generation; `complete` records the DAG result AND
357
+ marks the job `done` **in one guarded statement** — one transaction, so
358
+ a failure leaves neither, and the checkpoint rows of a finished job are
359
+ pruned in the same breath. The prune deletes only generations at or
360
+ below the settling lease's, so a stale attempt cannot take a live one's
361
+ work with it.
197
362
  - A reclaimed DAG job resumes: recorded nodes seed (FLOW-FORMAT
198
363
  §7.6), the rest re-run. A task node with side effects MUST thread
199
364
  an idempotency key (the job id is the natural one) into whatever it
200
365
  touches; the queue cannot make a non-idempotent effect safe, and
201
- does not pretend to.
202
- - The document belongs WITH the job kind at worker construction
203
- resuming under a different document is undefined (FLOW-FORMAT
204
- §7.6), so deploys that change a dag document SHOULD drain old jobs
205
- first or version the kind name.
366
+ does not pretend to (§3, *what exactly-once means here*).
367
+ - Every task's handler receives the run's signal, which is the job
368
+ handler's: a worker winding down inside its `graceMs`, or an attempt
369
+ whose lease has been lost, reaches every task rather than only the
370
+ outermost await.
371
+ - The runner forwards the worker options it declares — `concurrency`,
372
+ `pollInterval`, `leaseMs`, `owner`, `renew`, `onOutcome`, the backoff
373
+ pair and `stopGraceMs`. A `runner.stop()` with no override observes
374
+ the runner's declared `stopGraceMs` default; an explicit
375
+ `stop({ graceMs })` still wins.
376
+ - **A resume must be the same run.** THREE things are persisted beside a
377
+ run's checkpoints, under a reserved node id, and pruned with them: the
378
+ workflow document's revision, a hash of the run's input, and the
379
+ canonical map of DECLARED task versions (FLOW-FORMAT §7.8) with its
380
+ hash. A resume that disagrees with any of them is `JD2069` naming what
381
+ changed — each is identified separately, and a moved task version names
382
+ the task and both versions (`'draft' moved from version 1 to 2`).
383
+ A deploy that edits a dag document therefore does not have to drain old
384
+ jobs to be safe: the in-flight ones refuse by name.
385
+
386
+ The third component is what a document revision cannot see: a handler
387
+ reimplemented while its document stayed byte-equal. The host declares
388
+ that identity and the registry supplies it, so the queue can tell a
389
+ redeployed implementation from the one that wrote the checkpoints.
390
+
391
+ **The legacy rule is deterministic, and never reads unknown as equal.**
392
+ A run checkpointed by a release that did not record task identity has
393
+ no `taskVersionsHash`. If it recorded no node value yet, there is
394
+ nothing that could be replayed wrongly, so the identity is upgraded in
395
+ place and the run proceeds. If it DID record values, the implementation
396
+ that produced them cannot be confirmed and the resume is refused,
397
+ saying exactly that.
398
+ - A resume that DOES agree changes nothing: the recorded nodes are
399
+ restored rather than re-run, no checkpoint row is written, and none is
400
+ pruned.
206
401
 
207
402
  ## 8. Non-goals
208
403
 
@@ -213,17 +408,86 @@ await store.jobs.enqueue('sync-report', { input: { day: '2026-08-05' } });
213
408
  processes over WAL are the supported topology.
214
409
  - No priority classes in 0.1 (`run_at` ordering only), no cron or
215
410
  recurring schedules (re-enqueue from a completed handler if
216
- needed), no workflow-level compensation or sagas, no per-job abort
217
- signal (the lease is the timeout story), no cross-process push —
218
- wake-on-write is same-process; everything else polls.
411
+ needed), no workflow-level compensation or sagas, no cross-process
412
+ push wake-on-write is same-process; everything else polls. A
413
+ per-job cancellation exists (§10) and aborts a handler's signal in the
414
+ process that holds the attempt; across processes the lease is still
415
+ the timeout story.
219
416
  - Throughput is SQLite's single-writer throughput; the measured
220
417
  numbers live in the execution notes, not in marketing.
221
418
 
222
419
  ## 9. Errors
223
420
 
224
- This format adds NO codes. API misuse (a malformed handler map, a
421
+ The fence adds five, all in the package's single runtime table
422
+ (MODEL-FORMAT §7), all raised where a silent `false` used to be:
423
+
424
+ | code | raised when |
425
+ |---|---|
426
+ | `JD2065` | a settling call names a job that is not leased — unknown, or already settled by someone else |
427
+ | `JD2066` | the lease was superseded by a newer claim or renewal |
428
+ | `JD2067` | the lease expired before the call |
429
+ | `JD2068` | a settling call used the pre-fence `(id, owner)` spelling instead of the lease |
430
+ | `JD2069` | a resumed run disagrees with the workflow revision, the input hash or the declared task versions its checkpoints were written under (§7) |
431
+
432
+ Otherwise: API misuse (a malformed handler map, a
225
433
  non-string kind, a worker started twice) is a `TypeError` at the
226
434
  call, matching the capture and live precedents; storage failures ride
227
- the existing `JD2005` wrap; a job's own failure is DATArecorded in
435
+ the store's one driver-failure classification (MODEL-FORMAT §7 a
436
+ locked or read-only file arrives classed) and a coded error passes
437
+ through it
438
+ unchanged (`JD2063` after `close()`); `store.jobs` on a read-only
439
+ store is `JD0002` at first use, because the queue tables cannot be
440
+ created — named there, not a raw `SQLITE_READONLY` at the first
441
+ enqueue; a job's own failure is DATA — recorded in
228
442
  `last_error` and the state machine of §4 — because a queue that
229
443
  throws away its failure story has failed twice.
444
+
445
+ ## 10. Administration
446
+
447
+ Four root operations let an operator inspect and steer the queue
448
+ without raw SQL. Each is a mechanism with a coded failure surface and
449
+ an honest second run; none of them decides WHEN — a retention horizon,
450
+ a cancellation policy, a retry policy are the host's.
451
+
452
+ - **`page({ state, kind, after, limit, signal, deadline })`** — a
453
+ keyset cursor over the queue by job id (the id to continue past in
454
+ `after`, at most `limit` items, default 100), filtered by a closed
455
+ state and a kind; each item is the record `get` answers. It is the
456
+ store's own cursor: admitted per pull under the store gate, cancelled
457
+ at row boundaries (`JD2072` / `JD2075`), classified by the driver's
458
+ streaming capability, every driver failure classified.
459
+ - **`cancel(id, { lease })`** — two cases, both fenced. A QUEUED job
460
+ (pending or failed) is settled as `cancelled` in one write; the
461
+ enqueue-time identity authorises. A CLAIMED job needs the CURRENT
462
+ lease its attempt holds (§3's rule, so no stranger settles another's
463
+ work): the fenced write settles the row, and an attempt of this
464
+ process is aborted through the handler's `signal` (its reason is the
465
+ coded `JD2065` naming the cancellation); the call resolves once that
466
+ attempt has wound up — the acknowledgement §3's settling calls named.
467
+ `true` when this call cancelled the job, `false` when it already was;
468
+ unknown or settled `JD2065`, claimed-without-lease `JD2068`, a stale
469
+ or expired lease `JD2066`/`JD2067`. A worker reports such an attempt
470
+ as `outcome: 'cancelled'` (neither a completion, a failure nor a loss)
471
+ and counts it under `stats().cancellations`; an attempt held by
472
+ another process meets the fence at its next settling call and records
473
+ a loss, as any superseded attempt does.
474
+ - **`requeue(id)`** — returns a failed, dead, cancelled or lease-expired
475
+ job to `pending` at the clock's instant (the queue's own order). The
476
+ attempt history is KEPT: `attempts` counts every claim the job ever
477
+ had and the next claim increments it, so `maxAttempts` still bounds
478
+ what follows (a dead job requeued gets exactly one more attempt before
479
+ it is dead again). `false` when the job already was pending; a live
480
+ lease refuses `JD2068` (requeue is not a steal); unknown or done
481
+ `JD2065`.
482
+ - **`sweep({ settledBefore, limit })`** — deletes settled jobs (`done`,
483
+ `dead`, `cancelled`) whose last change is older than the horizon,
484
+ oldest first, at most `limit` of them, together with their
485
+ checkpoints, in one transaction; answers `{ removed }`, and a second
486
+ identical sweep answers `{ removed: 0 }`. The horizon is REQUIRED: a
487
+ sweep with none is a retention policy. Live jobs' checkpoints and the
488
+ change log's watermark are untouched.
489
+
490
+ `cancelled` is a sixth job state, terminal like `done` and `dead`, set
491
+ only by `cancel()`; `counts()` reports it. A transaction view's `jobs`
492
+ (the outbox, §3) carries none of the four: an administration call is a
493
+ root call.