@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 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
- # Temporary Holding Version
1
+ # Watchdog (`@fungi.computer/watchdog`)
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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
+ }