@fungi.computer/watchdog 0.0.0-stage → 0.0.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.
- package/LICENSE +21 -0
- package/README.md +334 -2
- package/dist/d1.d.ts +36 -0
- package/dist/d1.js +41 -0
- package/dist/effect.d.ts +336 -0
- package/dist/effect.js +140 -0
- package/dist/index.d.ts +65 -0
- package/dist/index.js +41 -0
- package/dist/internal/async-runtime.d.ts +17 -0
- package/dist/internal/async-runtime.js +39 -0
- package/dist/internal/async-sql.d.ts +15 -0
- package/dist/internal/async-sql.js +66 -0
- package/dist/internal/async-store.d.ts +58 -0
- package/dist/internal/async-store.js +235 -0
- package/dist/internal/identities.d.ts +3 -0
- package/dist/internal/identities.js +8 -0
- package/dist/internal/lane.d.ts +3 -0
- package/dist/internal/lane.js +6 -0
- package/dist/internal/promise-watchdog-host.d.ts +4 -0
- package/dist/internal/promise-watchdog-host.js +25 -0
- package/dist/internal/runtime.d.ts +3 -0
- package/dist/internal/runtime.js +199 -0
- package/dist/internal/sqlite-store.d.ts +107 -0
- package/dist/internal/sqlite-store.js +478 -0
- package/dist/internal/watchdog-error.d.ts +12 -0
- package/dist/internal/watchdog-error.js +17 -0
- package/dist/node-sqlite.d.ts +17 -0
- package/dist/node-sqlite.js +63 -0
- package/dist/postgres.d.ts +22 -0
- package/dist/postgres.js +31 -0
- package/docs/api/README.md +17 -0
- package/docs/api/d1.md +162 -0
- package/docs/api/effect.md +875 -0
- package/docs/api/index.md +421 -0
- package/docs/api/node-sqlite.md +57 -0
- package/docs/api/postgres.md +102 -0
- package/package.json +78 -3
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Jamstack Consulting LLC
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,335 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Watchdog (`@fungi.computer/watchdog`)
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Watchdog is a small durable job engine. You hand it jobs, it stores them in your
|
|
4
|
+
SQLite database, and each `tick()` claims one ready job, runs your executor and
|
|
5
|
+
records exactly one terminal outcome. If the process dies mid-job, the next tick
|
|
6
|
+
asks your liveness probe what happened and recovers the work.
|
|
7
|
+
|
|
8
|
+
Watchdog never looks inside a job. The payload, queue name and lane are opaque
|
|
9
|
+
values you choose. Your code decides what a job means and how to run it.
|
|
10
|
+
Watchdog owns the lifecycle: queued, running, settled. Claims, claim tokens and
|
|
11
|
+
the SQL that moves rows between states stay private to the package.
|
|
12
|
+
|
|
13
|
+
Use it when you need work to survive a restart, run once per claim, and settle
|
|
14
|
+
with an honest outcome, without writing your own state machine. It runs on any
|
|
15
|
+
synchronous SQLite owner, such as a Cloudflare Durable Object's storage or
|
|
16
|
+
`node:sqlite`, and on shared D1 or PostgreSQL databases.
|
|
17
|
+
|
|
18
|
+
## Install or set up
|
|
19
|
+
|
|
20
|
+
```sh
|
|
21
|
+
npm install @fungi.computer/watchdog
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Watchdog is ESM-only. The SQLite entrypoint runs anywhere you have a synchronous
|
|
25
|
+
SQLite handle, such as Cloudflare Durable Object storage or `node:sqlite` in
|
|
26
|
+
Node 24. It depends on `effect`, `pg` and `@cloudflare/workers-types`, which npm
|
|
27
|
+
installs for you.
|
|
28
|
+
|
|
29
|
+
Entrypoints:
|
|
30
|
+
|
|
31
|
+
- `@fungi.computer/watchdog`: the Promise API, `Watchdog.make`, and the public
|
|
32
|
+
job types and schemas.
|
|
33
|
+
- `@fungi.computer/watchdog/effect`: the same runtime as Effect values, via
|
|
34
|
+
`makeRuntime`.
|
|
35
|
+
- `@fungi.computer/watchdog/node-sqlite`: `fromNodeSqlite(database)` for a
|
|
36
|
+
caller-owned Node SQLite database. It returns `NodeSqliteOwner`; its
|
|
37
|
+
`NodeSqliteCursor` provides `toArray()` and `one()`, which returns exactly one
|
|
38
|
+
decoded row and throws for zero or multiple rows.
|
|
39
|
+
- `@fungi.computer/watchdog/d1`: a runtime over a migrated Cloudflare D1
|
|
40
|
+
database.
|
|
41
|
+
- `@fungi.computer/watchdog/postgres`: a runtime over a migrated PostgreSQL
|
|
42
|
+
database.
|
|
43
|
+
|
|
44
|
+
## Quick start
|
|
45
|
+
|
|
46
|
+
This runs one job through an in-memory `node:sqlite` database. The `owner`
|
|
47
|
+
object is the whole host contract for the SQLite entrypoint: run a statement,
|
|
48
|
+
and run a synchronous transaction.
|
|
49
|
+
|
|
50
|
+
```ts
|
|
51
|
+
import { DatabaseSync } from "node:sqlite";
|
|
52
|
+
import { fromNodeSqlite } from "@fungi.computer/watchdog/node-sqlite";
|
|
53
|
+
import { Watchdog } from "@fungi.computer/watchdog";
|
|
54
|
+
|
|
55
|
+
const database = new DatabaseSync(":memory:");
|
|
56
|
+
const owner = fromNodeSqlite(database);
|
|
57
|
+
|
|
58
|
+
const watchdog = await Watchdog.make({
|
|
59
|
+
owner,
|
|
60
|
+
execute: async (job, signal) => {
|
|
61
|
+
if (signal.aborted) return { kind: "cancelled" };
|
|
62
|
+
console.log(`sending ${job.jobId}`, job.payload);
|
|
63
|
+
return { kind: "completed" };
|
|
64
|
+
},
|
|
65
|
+
// This process owns every claim, so a running job is alive while it runs.
|
|
66
|
+
isAlive: async () => true,
|
|
67
|
+
// Arm your real timer or alarm here. Ticking by hand is enough for a demo.
|
|
68
|
+
wake: { recompute: async () => {} },
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
await watchdog.ingest({
|
|
72
|
+
jobId: "welcome-email-1",
|
|
73
|
+
queue: "email",
|
|
74
|
+
lane: "default",
|
|
75
|
+
priority: 0,
|
|
76
|
+
payload: { to: "ada@example.com" },
|
|
77
|
+
state: "queued",
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
const tick = await watchdog.tick();
|
|
81
|
+
console.log(tick.status); // "settled"
|
|
82
|
+
|
|
83
|
+
const read = await watchdog.read("welcome-email-1");
|
|
84
|
+
if (read.status === "found" && read.job.state === "settled")
|
|
85
|
+
console.log(read.job.outcome); // "completed"
|
|
86
|
+
|
|
87
|
+
database.close();
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Run `tick()` again and it returns `{ status: "no_ready" }`. Ingest the same job
|
|
91
|
+
again and it returns `{ status: "duplicate" }`.
|
|
92
|
+
|
|
93
|
+
In a Durable Object, `ctx.storage` is already the SQLite owner.
|
|
94
|
+
|
|
95
|
+
## Core concepts
|
|
96
|
+
|
|
97
|
+
**Jobs.** A job has a `jobId`, a `queue`, a `lane`, an integer `priority`, an
|
|
98
|
+
opaque JSON `payload` and an optional `recovery: { maxRecoveries }`. IDs and
|
|
99
|
+
queue names are 1 to 256 characters; lanes are 1 to 512 characters. Owner-local
|
|
100
|
+
construction can reserve one lane for concurrent delivery; otherwise the lane is
|
|
101
|
+
stored without a scheduling policy. A job moves through three states:
|
|
102
|
+
|
|
103
|
+
- `queued`: accepted and waiting.
|
|
104
|
+
- `running`: claimed by one tick and handed to your executor.
|
|
105
|
+
- `settled`: finished, with an `outcome` of `completed`, `failed`, `cancelled`,
|
|
106
|
+
`outcome_unknown` or `interrupted`, and an optional `failureReason`.
|
|
107
|
+
|
|
108
|
+
**Ingest is idempotent.** `ingest(job)` returns `queued` for a new ID and
|
|
109
|
+
`duplicate` for an identical replay. A replay with the same ID and different
|
|
110
|
+
queue, lane, priority, payload or recovery policy fails.
|
|
111
|
+
|
|
112
|
+
**One tick, one job.** `tick()` picks a ready queue head with the highest
|
|
113
|
+
priority, oldest first, across eligible queues. It claims that job, runs your
|
|
114
|
+
executor, and settles the exact claim it took. A second `tick()` on the same
|
|
115
|
+
runtime while one is in flight returns `{ status: "busy" }` instead of running
|
|
116
|
+
in parallel. Your executor returns one of `completed`, `failed` (with an
|
|
117
|
+
optional short `reason`), `cancelled` or `outcome_unknown`. If it throws or is
|
|
118
|
+
interrupted, Watchdog settles the job as `failed` with its own reason code, such
|
|
119
|
+
as `execution_failed` or `execution_interrupted`.
|
|
120
|
+
|
|
121
|
+
**Host ports.** You supply four things:
|
|
122
|
+
|
|
123
|
+
- `owner`: the SQLite capability shown in the quick start.
|
|
124
|
+
- `execute(job, signal)`: runs one claimed job. Honour the `AbortSignal`.
|
|
125
|
+
- `isAlive(job)`: answers whether a running job's execution still exists.
|
|
126
|
+
- `wake.recompute()`: arms your next timer or alarm after durable state changes.
|
|
127
|
+
|
|
128
|
+
**Disappearance recovery.** Each tick first checks the oldest running claim. If
|
|
129
|
+
`isAlive` returns `false`, the execution is gone. A job with recovery budget
|
|
130
|
+
left goes back to `queued`. Otherwise it settles as `interrupted`. If `isAlive`
|
|
131
|
+
throws, Watchdog does not guess. The claim stays as it is, and an otherwise idle
|
|
132
|
+
tick reports `{ status: "liveness_unknown", jobId, reason }`.
|
|
133
|
+
|
|
134
|
+
**Cancellation.** `cancel({ jobId, cancellationId })` records a durable
|
|
135
|
+
cancellation. A queued job settles as `cancelled` on its next tick. A job the
|
|
136
|
+
current runtime is executing is interrupted and settles as `cancelled`.
|
|
137
|
+
Replaying the same `cancellationId` returns `replayed`. A different one returns
|
|
138
|
+
`conflict`. Pass `onlyIfQueued: true` to refuse cancellation once a job has
|
|
139
|
+
started.
|
|
140
|
+
|
|
141
|
+
**Lane refusal.** `failLane({ lane, reason })` terminally settles queued and
|
|
142
|
+
running work in that lane as `failed`, retaining each job and its bounded owner
|
|
143
|
+
reason code. It handles at most 256 jobs per call, running jobs first, and
|
|
144
|
+
returns `{ settled, more }`. Repeat only while `more` is true; 256 is a
|
|
145
|
+
transition batch, not a cap on accepted work. Already settled history and other
|
|
146
|
+
lanes stay intact. Once exhausted, another call returns
|
|
147
|
+
`{ settled: 0, more: false }` and fires no projector. A later enqueue is new
|
|
148
|
+
work, not a permanent refusal marker.
|
|
149
|
+
|
|
150
|
+
On owner-local SQLite, every changed job invokes `project` in the same
|
|
151
|
+
transaction as its settlement. A projector throw rolls back the entire batch and
|
|
152
|
+
all owner projection writes. A locally running executor is interrupted after
|
|
153
|
+
durable refusal; its tick still awaits cleanup and reports the committed failure
|
|
154
|
+
without projecting it again. Replaced durable claims cannot overwrite that
|
|
155
|
+
failure. A refusal does not undo provider effects already accepted before it.
|
|
156
|
+
|
|
157
|
+
The shared D1/PostgreSQL adapters expose the same bounded settlement method and
|
|
158
|
+
fence leases in one atomic SQL mutation. They do not offer the synchronous
|
|
159
|
+
SQLite-only `project` callback. Hosts still own authority and must stop revoked
|
|
160
|
+
execution; Watchdog owns durable job settlement, not App admission.
|
|
161
|
+
|
|
162
|
+
**Owner transactions.** `runtime.transactional` is a synchronous projection for
|
|
163
|
+
code already inside your own SQLite transaction. `enqueueAcceptedJob` and
|
|
164
|
+
`recordCancellationIntent` use the same mutation as `ingest` and `cancel`, so
|
|
165
|
+
your domain write and the job commit or roll back together. `readQueueHead` and
|
|
166
|
+
`readLatestSettledJob` read queue state inside that transaction.
|
|
167
|
+
|
|
168
|
+
**Queue projection.** Pass `project` to `Watchdog.make` to receive a
|
|
169
|
+
`{ queue, head, settled? }` transition inside the same transaction as every
|
|
170
|
+
ingest, claim, cancellation, settlement, lane refusal, recovery and purge. Use
|
|
171
|
+
it to keep a per-queue status row in step with Watchdog. A throw from `project`
|
|
172
|
+
fails the transition.
|
|
173
|
+
|
|
174
|
+
**Purge.** `purge(queue)` deletes every queued job in one queue. Running and
|
|
175
|
+
settled jobs stay.
|
|
176
|
+
|
|
177
|
+
## Concurrent owner-local delivery
|
|
178
|
+
|
|
179
|
+
Owner-local construction may opt into one `concurrentLane`. Only the configured
|
|
180
|
+
runtime type adds `tickConcurrent()`: ordinary ticks exclude that lane and the
|
|
181
|
+
second tick selects only it. Both slots share one store and transactional
|
|
182
|
+
projection, with separate busy guards and active executions. Cancellation
|
|
183
|
+
signals the slot owning the job; recovery inspects only the idle slot's
|
|
184
|
+
partition. Both slots require the queue head across lanes, ordered by running
|
|
185
|
+
first, then priority descending and enqueue sequence ascending. Cancelled queued
|
|
186
|
+
jobs can settle past a busy queue without executing.
|
|
187
|
+
|
|
188
|
+
The Effect runtime's `readSettled(limit, afterSequence)` exposes the serial
|
|
189
|
+
partition in enqueue order. A prefix fence withholds later settlements until all
|
|
190
|
+
earlier serial jobs settle or are purged. Concurrent receipts never consume a
|
|
191
|
+
page slot; they remain readable by job ID. `hasPendingWork` still covers both
|
|
192
|
+
partitions. These owner-local operations require no stored schema change and do
|
|
193
|
+
not change the D1 or PostgreSQL adapters.
|
|
194
|
+
|
|
195
|
+
## Shared D1 and PostgreSQL storage
|
|
196
|
+
|
|
197
|
+
`@fungi.computer/watchdog/d1` exports `createD1Watchdog` and `watchdogD1Schema`.
|
|
198
|
+
`@fungi.computer/watchdog/postgres` exports `createPostgresWatchdog` and
|
|
199
|
+
`watchdogPostgresSchema`. Apply the package-owned schema through your migration
|
|
200
|
+
tool before you open a runtime. Opening a runtime never migrates a database.
|
|
201
|
+
|
|
202
|
+
These runtimes have the same `ingest`, `read`, `readQueueHead`, `tick`,
|
|
203
|
+
`cancel`, `failLane`, `purge` and `hasPendingWork` operations. They take
|
|
204
|
+
`execute`, `wake` and an optional `now` clock instead of an owner and liveness
|
|
205
|
+
probe. Watchdog answers liveness itself from the claim's lease.
|
|
206
|
+
|
|
207
|
+
D1 `enqueue(job)` returns a prepared statement for the producer's native batch.
|
|
208
|
+
`enqueueFromSelect({ text, values })` accepts a trusted native SQLite
|
|
209
|
+
`?NNN`-bound SELECT of `job_id`, `queue_name`, `lane`, `priority`,
|
|
210
|
+
`payload_json` and nullable `recovery_max`. Watchdog supplies its INSERT and
|
|
211
|
+
conflict checks. This lets a job depend on facts conditionally inserted earlier
|
|
212
|
+
in the same batch, without a JavaScript read in between. PostgreSQL
|
|
213
|
+
`enqueue(client, job)` uses the caller's checked-out transaction client. The
|
|
214
|
+
caller owns BEGIN, COMMIT and ROLLBACK. An identical replay does nothing. A
|
|
215
|
+
conflicting identity or cargo fails the whole native transaction.
|
|
216
|
+
|
|
217
|
+
Both adapters use the same lifecycle engine as the SQLite entrypoint. Claims
|
|
218
|
+
stay private, with two-minute leases, ten-second renewal and bounded renewal
|
|
219
|
+
I/O. Execution must honour its AbortSignal. Renewal and settlement reject
|
|
220
|
+
expired or replaced claims. A failed liveness probe never declares an execution
|
|
221
|
+
dead. Database time checks stop an in-flight renewal from resurrecting an
|
|
222
|
+
expired claim. The optional `now` clock replaces database time for deterministic
|
|
223
|
+
tests. Production hosts should use the default. A durable host wake still drives
|
|
224
|
+
ticks and disappearance recovery after a process dies. Provider effects remain
|
|
225
|
+
at-least-once.
|
|
226
|
+
|
|
227
|
+
`purgeD1Queues` and `purgePostgresQueues` return parameterized erasure
|
|
228
|
+
statements for queues selected by trusted producer SQL. The caller joins them to
|
|
229
|
+
its native batch or transaction, and Watchdog stays the only owner of job
|
|
230
|
+
deletion SQL. These helpers remove every attempt in the selected queues and
|
|
231
|
+
expose no claim or settlement controls. [Carrier](../carrier/README.md) composes
|
|
232
|
+
them with envelope erasure.
|
|
233
|
+
|
|
234
|
+
## API reference
|
|
235
|
+
|
|
236
|
+
The generated reference is in [docs/api](docs/api/README.md). It covers four
|
|
237
|
+
modules:
|
|
238
|
+
|
|
239
|
+
- `@fungi.computer/watchdog`: `Watchdog.make`, `WatchdogRuntime`,
|
|
240
|
+
`WatchdogOptions`, `WatchdogError`, and the job, result and SQLite owner types
|
|
241
|
+
with their Effect schemas.
|
|
242
|
+
- `@fungi.computer/watchdog/effect`: `makeRuntime`, `WatchdogRuntimeEffect` and
|
|
243
|
+
`EffectWatchdogOptions`. The Effect runtime also has
|
|
244
|
+
`readSettled(limit, afterSequence?)` for paging through settled jobs.
|
|
245
|
+
- `@fungi.computer/watchdog/node-sqlite`: `fromNodeSqlite(database)` for a
|
|
246
|
+
caller-owned Node SQLite database. It returns `NodeSqliteOwner`; its
|
|
247
|
+
`NodeSqliteCursor` provides `toArray()` and `one()`, which returns exactly one
|
|
248
|
+
decoded row and throws for zero or multiple rows.
|
|
249
|
+
- `@fungi.computer/watchdog/d1`: `createD1Watchdog`, `prepareD1Admission`,
|
|
250
|
+
`watchdogD1Schema` and `purgeD1Queues`.
|
|
251
|
+
- `@fungi.computer/watchdog/postgres`: `createPostgresWatchdog`,
|
|
252
|
+
`watchdogPostgresSchema` and `purgePostgresQueues`.
|
|
253
|
+
|
|
254
|
+
## Examples and checks
|
|
255
|
+
|
|
256
|
+
The Effect entrypoint runs the same engine without the Promise wrapper:
|
|
257
|
+
|
|
258
|
+
```ts
|
|
259
|
+
import * as Effect from "effect/Effect";
|
|
260
|
+
import { makeRuntime } from "@fungi.computer/watchdog/effect";
|
|
261
|
+
|
|
262
|
+
const runtime = await Effect.runPromise(
|
|
263
|
+
makeRuntime({
|
|
264
|
+
owner, // the same owner as in the quick start
|
|
265
|
+
executor: { execute: () => Effect.succeed({ kind: "completed" as const }) },
|
|
266
|
+
isAlive: () => Effect.succeed(true),
|
|
267
|
+
wake: { recompute: () => Effect.void },
|
|
268
|
+
}),
|
|
269
|
+
);
|
|
270
|
+
const result = await Effect.runPromise(runtime.tick());
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
Inside your own transaction, enqueue work with the domain write it belongs to:
|
|
274
|
+
|
|
275
|
+
```ts
|
|
276
|
+
owner.transactionSync(() => {
|
|
277
|
+
owner.sql.exec("INSERT INTO orders (id) VALUES (?)", "order-1");
|
|
278
|
+
watchdog.transactional.enqueueAcceptedJob({
|
|
279
|
+
jobId: "charge-order-1",
|
|
280
|
+
queue: "order-1",
|
|
281
|
+
lane: "default",
|
|
282
|
+
priority: 0,
|
|
283
|
+
payload: { orderId: "order-1" },
|
|
284
|
+
state: "queued",
|
|
285
|
+
});
|
|
286
|
+
});
|
|
287
|
+
```
|
|
288
|
+
|
|
289
|
+
The package tests run the engine on a real in-memory `node:sqlite` owner. They
|
|
290
|
+
cover the tick lifecycle, cancellation of queued and running jobs, disappearance
|
|
291
|
+
recovery, failure reasons, purge, queue projection, the transactional projection
|
|
292
|
+
and shared-storage leases. From the package directory:
|
|
293
|
+
|
|
294
|
+
```sh
|
|
295
|
+
pnpm test
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
## Limits and errors
|
|
299
|
+
|
|
300
|
+
Every failure is a `WatchdogError` with a closed `cause`:
|
|
301
|
+
|
|
302
|
+
- `store_unavailable`: the store threw, or an ingest replay conflicted with the
|
|
303
|
+
stored job.
|
|
304
|
+
- `store_invalid`: the lane-refusal input failed its strict lane/reason decoder.
|
|
305
|
+
Other store faults remain `store_unavailable`.
|
|
306
|
+
- `claim_unavailable`: a claim could not be taken or renewed.
|
|
307
|
+
- `execution_failed`: your Promise executor threw. `tick()` does not reject. The
|
|
308
|
+
job settles as `failed` with this code as its `failureReason`.
|
|
309
|
+
- `settlement_rejected`: the claim was replaced or expired before settlement.
|
|
310
|
+
- `wake_unavailable`: `wake.recompute()` threw.
|
|
311
|
+
- `liveness_unavailable`: `isAlive` threw.
|
|
312
|
+
|
|
313
|
+
`underlying` keeps the original thrown value for server-side diagnosis. Do not
|
|
314
|
+
send it to clients.
|
|
315
|
+
|
|
316
|
+
Limits and guarantees:
|
|
317
|
+
|
|
318
|
+
- One tick runs at most one job. Call `tick()` again, or from your wake, to
|
|
319
|
+
drain a queue.
|
|
320
|
+
- Concurrency is per runtime. Open one SQLite runtime per owner. The D1 and
|
|
321
|
+
PostgreSQL runtimes use leases so more than one worker can share a database.
|
|
322
|
+
- Delivery is at-least-once. A job recovered after a crash runs again, so make
|
|
323
|
+
executor side effects idempotent.
|
|
324
|
+
- `outcome_unknown` is a terminal outcome. Watchdog does not retry it.
|
|
325
|
+
- IDs and queue names are 1 to 256 characters. Lanes and `concurrentLane` share
|
|
326
|
+
one 1-to-512-character bound across job admission, storage and construction. A
|
|
327
|
+
canonical 256-character identity needs 263 characters with `member:` or 265
|
|
328
|
+
with `team-key:`; 512 leaves room for composition without hashing or
|
|
329
|
+
truncating the identity. Fresh shared database schemas enforce the same bound.
|
|
330
|
+
`failureReason` is 1 to 200 characters and should be a short code, never
|
|
331
|
+
provider text.
|
|
332
|
+
|
|
333
|
+
## Contribute and license
|
|
334
|
+
|
|
335
|
+
Watchdog is MIT licensed. See [LICENSE](./LICENSE).
|
package/dist/d1.d.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { D1Database, D1PreparedStatement } from "@cloudflare/workers-types";
|
|
2
|
+
import type { WatchdogQueuedJob } from "./effect.js";
|
|
3
|
+
import { type AsyncWatchdogOptions, type AsyncWatchdogRuntime } from "./internal/async-runtime.js";
|
|
4
|
+
export type { AsyncWatchdogOptions, AsyncWatchdogRuntime, } from "./internal/async-runtime.js";
|
|
5
|
+
/** Trusted parameter-bound SELECT yielding queued job fields; never lifecycle fields. */
|
|
6
|
+
export type D1AdmissionSource = Readonly<{
|
|
7
|
+
/** Trusted native SELECT text with SQLite ?NNN bindings. */
|
|
8
|
+
text: string;
|
|
9
|
+
/** Scalar values bound by the native driver. */
|
|
10
|
+
values: readonly (string | number | null)[];
|
|
11
|
+
}>;
|
|
12
|
+
/** D1 runtime with admission statements that join the producer's native batch. */
|
|
13
|
+
export type D1Admission = Readonly<{
|
|
14
|
+
/** Prepare a typed queued job without executing or committing it. */
|
|
15
|
+
enqueue: (job: WatchdogQueuedJob) => D1PreparedStatement;
|
|
16
|
+
/** Prepare jobs selected inside the same batch as the producer writes. */
|
|
17
|
+
enqueueFromSelect: (source: D1AdmissionSource) => D1PreparedStatement;
|
|
18
|
+
}>;
|
|
19
|
+
/** D1 lifecycle and native transaction admission. */
|
|
20
|
+
export type D1Watchdog = AsyncWatchdogRuntime & D1Admission;
|
|
21
|
+
/** Package-owned schema statements, to run through the deployment migration owner. */
|
|
22
|
+
export declare const watchdogD1Schema: readonly string[];
|
|
23
|
+
/** Open the shared Effect engine on a migrated D1 database; no implicit schema writes. */
|
|
24
|
+
export declare function createD1Watchdog(options: AsyncWatchdogOptions & Readonly<{
|
|
25
|
+
database: D1Database;
|
|
26
|
+
}>): D1Watchdog;
|
|
27
|
+
/** Prepare native admission without constructing an executor or performing I/O. */
|
|
28
|
+
export declare function prepareD1Admission(database: D1Database): D1Admission;
|
|
29
|
+
/** Parameterized native purge; caller commits it with its own erasure facts. */
|
|
30
|
+
export type D1PurgeStatement = Readonly<{
|
|
31
|
+
text: string;
|
|
32
|
+
values: readonly (string | number | null)[];
|
|
33
|
+
}>;
|
|
34
|
+
/** Purge every attempt in selected queues, fencing any executor still holding a claim.
|
|
35
|
+
* The trusted SELECT yields queue_name. It never supplies lifecycle mutations. */
|
|
36
|
+
export declare function purgeD1Queues(source: D1PurgeStatement): readonly D1PurgeStatement[];
|
package/dist/d1.js
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { createAsyncRuntime, } from "./internal/async-runtime.js";
|
|
2
|
+
import { enqueueSelect, enqueueStatement, schemaStatements, } from "./internal/async-sql.js";
|
|
3
|
+
/** Package-owned schema statements, to run through the deployment migration owner. */
|
|
4
|
+
export const watchdogD1Schema = schemaStatements(false);
|
|
5
|
+
/** Open the shared Effect engine on a migrated D1 database; no implicit schema writes. */
|
|
6
|
+
export function createD1Watchdog(options) {
|
|
7
|
+
// Only package-owned templates use $N; caller SELECTs remain native SQL.
|
|
8
|
+
const prepare = (statement) => options.database
|
|
9
|
+
.prepare(statement.text.replace(/\$(\d+)/gu, "?$1"))
|
|
10
|
+
.bind(...statement.values);
|
|
11
|
+
const runtime = createAsyncRuntime({
|
|
12
|
+
clock: options.now === undefined ? "unixepoch('subsec')*1000" : undefined,
|
|
13
|
+
query: async (statement) => (await prepare(statement).all()).results,
|
|
14
|
+
}, options);
|
|
15
|
+
return { ...runtime, ...prepareD1Admission(options.database) };
|
|
16
|
+
}
|
|
17
|
+
/** Prepare native admission without constructing an executor or performing I/O. */
|
|
18
|
+
export function prepareD1Admission(database) {
|
|
19
|
+
return {
|
|
20
|
+
enqueue: (job) => {
|
|
21
|
+
const statement = enqueueStatement(job);
|
|
22
|
+
return database
|
|
23
|
+
.prepare(statement.text.replace(/\$(\d+)/gu, "?$1"))
|
|
24
|
+
.bind(...statement.values);
|
|
25
|
+
},
|
|
26
|
+
enqueueFromSelect: (source) => {
|
|
27
|
+
const statement = enqueueSelect(source);
|
|
28
|
+
return database.prepare(statement.text).bind(...statement.values);
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
/** Purge every attempt in selected queues, fencing any executor still holding a claim.
|
|
33
|
+
* The trusted SELECT yields queue_name. It never supplies lifecycle mutations. */
|
|
34
|
+
export function purgeD1Queues(source) {
|
|
35
|
+
return [
|
|
36
|
+
{
|
|
37
|
+
text: `DELETE FROM watchdog_jobs WHERE queue_name IN (SELECT queue_name FROM (${source.text}) AS erasure)`,
|
|
38
|
+
values: source.values,
|
|
39
|
+
},
|
|
40
|
+
];
|
|
41
|
+
}
|