@jarenjs/db 0.34.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/ARCHITECTURE.md +397 -0
  2. package/README.md +218 -0
  3. package/dist/types/algebra.d.ts +133 -0
  4. package/dist/types/app.d.ts +49 -0
  5. package/dist/types/capture.d.ts +85 -0
  6. package/dist/types/cli.d.ts +2 -0
  7. package/dist/types/dag-job.d.ts +40 -0
  8. package/dist/types/ddl.d.ts +170 -0
  9. package/dist/types/dialect.d.ts +130 -0
  10. package/dist/types/dialects/sqlite.d.ts +9 -0
  11. package/dist/types/driver.d.ts +128 -0
  12. package/dist/types/drivers/bun.d.ts +47 -0
  13. package/dist/types/drivers/node.d.ts +37 -0
  14. package/dist/types/drivers/wasm.d.ts +65 -0
  15. package/dist/types/emit-model.d.ts +44 -0
  16. package/dist/types/emit.d.ts +72 -0
  17. package/dist/types/entity.d.ts +23 -0
  18. package/dist/types/errors.d.ts +165 -0
  19. package/dist/types/graph.d.ts +28 -0
  20. package/dist/types/index.d.ts +35 -0
  21. package/dist/types/jobs.d.ts +134 -0
  22. package/dist/types/live.d.ts +62 -0
  23. package/dist/types/migrate.d.ts +163 -0
  24. package/dist/types/model.d.ts +36 -0
  25. package/dist/types/patch-sql.d.ts +37 -0
  26. package/dist/types/plan.d.ts +119 -0
  27. package/dist/types/profile.d.ts +80 -0
  28. package/dist/types/query.d.ts +100 -0
  29. package/dist/types/residual.d.ts +50 -0
  30. package/dist/types/store.d.ts +53 -0
  31. package/dist/types/tracker.d.ts +43 -0
  32. package/dist/types/typed.d.ts +15 -0
  33. package/dist/types/types.d.ts +26 -0
  34. package/dist/types/udf.d.ts +70 -0
  35. package/dist/types/window.d.ts +52 -0
  36. package/docs/JOBS-FORMAT.md +218 -0
  37. package/docs/LIVE-FORMAT.md +348 -0
  38. package/docs/MIGRATION-FORMAT.md +302 -0
  39. package/docs/MODEL-FORMAT.md +928 -0
  40. package/package.json +81 -0
  41. package/schemas/jaren-migration.draft-07.schema.json +144 -0
  42. package/schemas/jaren-migration.schema.json +144 -0
  43. package/schemas/jaren-model.draft-07.schema.json +149 -0
  44. package/schemas/jaren-model.schema.json +149 -0
  45. package/src/algebra.js +105 -0
  46. package/src/app.js +108 -0
  47. package/src/capture.js +584 -0
  48. package/src/cli.js +264 -0
  49. package/src/dag-job.js +86 -0
  50. package/src/ddl.js +588 -0
  51. package/src/dialect.js +297 -0
  52. package/src/dialects/sqlite.js +175 -0
  53. package/src/driver.js +419 -0
  54. package/src/drivers/bun.js +101 -0
  55. package/src/drivers/node.js +93 -0
  56. package/src/drivers/wasm.js +178 -0
  57. package/src/emit-model.js +208 -0
  58. package/src/emit.js +393 -0
  59. package/src/entity.js +367 -0
  60. package/src/errors.js +173 -0
  61. package/src/graph.js +101 -0
  62. package/src/index.js +64 -0
  63. package/src/jobs.js +507 -0
  64. package/src/live.js +899 -0
  65. package/src/migrate.js +1411 -0
  66. package/src/model.js +476 -0
  67. package/src/patch-sql.js +150 -0
  68. package/src/plan.js +1038 -0
  69. package/src/profile.js +131 -0
  70. package/src/query.js +1010 -0
  71. package/src/residual.js +91 -0
  72. package/src/store.js +1422 -0
  73. package/src/tracker.js +776 -0
  74. package/src/typed.js +19 -0
  75. package/src/types.js +36 -0
  76. package/src/udf.js +132 -0
  77. package/src/window.js +125 -0
  78. package/types/app.d.ts +36 -0
  79. package/types/bun.d.ts +9 -0
  80. package/types/index.d.ts +592 -0
  81. package/types/node.d.ts +15 -0
  82. package/types/typed.d.ts +108 -0
  83. package/types/wasm.d.ts +5 -0
package/src/index.js ADDED
@@ -0,0 +1,64 @@
1
+ //@ts-check
2
+ /**
3
+ * @file @jarenjs/db — document storage over SQLite through two seams:
4
+ * a driver (how a connection is made: `@jarenjs/db/node`, `/bun`, or
5
+ * `/wasm` with an injected handle) and a dialect (how SQL is spelled).
6
+ * This root subpath never touches a runtime builtin — a browser
7
+ * bundler resolves it clean; the bindings live behind their own
8
+ * subpaths and load their builtin lazily inside `open()`.
9
+ */
10
+
11
+ export { openStore, normalizeModel, MODEL_VERSION } from './store.js';
12
+ export { createDialect } from './dialect.js';
13
+ export { sqliteDialect } from './dialects/sqlite.js';
14
+ export {
15
+ SQLITE_FLOOR, chain, toPromise, isThenable, compareVersions,
16
+ openConnection, wrapStatement, lazyOpen,
17
+ } from './driver.js';
18
+ export {
19
+ planCollection, compileIndexPath, schemaTypeAt, KEY_COLUMN, DOC_COLUMN,
20
+ normalizeDeclaredSql, comparableDeclaredSql,
21
+ } from './ddl.js';
22
+ export {
23
+ planQuery, assertDecidedKind, entityShape, entityPathRef,
24
+ planEntityPredicate, planEntityQuery,
25
+ } from './plan.js';
26
+ export { emitPlan, createEntityPredicateEmitters, emitEntityPlan } from './emit.js';
27
+ export { mergeEntityRow, parseGraphRow } from './graph.js';
28
+ export { selectPlan, conjoin, assertNoSqlText, PLAN_VERSION } from './algebra.js';
29
+ export { typeOfPath, isNumericType } from './types.js';
30
+ export { compileSetResidual, compileRowResidual, sequenceResult } from './residual.js';
31
+ export { deterministicFragment, registerFragment } from './udf.js';
32
+ export {
33
+ createQueryEngine, createQueryState, createEntityQueryEngine,
34
+ createLoadEngine, INCLUDE_DEPTH_DEFAULT,
35
+ } from './query.js';
36
+ export {
37
+ normalizeProfile, SAFE_PROFILE, translateProfilePredicate,
38
+ applyMandatoryPredicate, applyRowBound,
39
+ } from './profile.js';
40
+ export { translatePatch } from './patch-sql.js';
41
+ export { normalizeEntities, explainMapping } from './model.js';
42
+ export { planEntity, planJoinTable } from './ddl.js';
43
+ export { entityCore } from './entity.js';
44
+ export { entityEmitModel } from './emit-model.js';
45
+ export {
46
+ parseChangeset, translateOperations, keyToken, createCaptureEngine,
47
+ CHANGES_TABLE, DEFAULT_RETENTION,
48
+ } from './capture.js';
49
+ export {
50
+ createTracker, deepFreeze, BATCH_PARAM_BUDGET, BATCH_ROW_BOUND,
51
+ } from './tracker.js';
52
+ export {
53
+ planMigration, planModelMigration, migrate, migrationStatus, shapeHash,
54
+ migrationChecksum, createModelShape, schemaShapeOf, compareShapeToModel,
55
+ MIGRATION_VERSION, HISTORY_TABLE,
56
+ } from './migrate.js';
57
+ export { DbCompileError, DbRuntimeError, DB_CODES } from './errors.js';
58
+ export { classifyLiveQuery, createLiveRegistry, diffRows, LIVE_DEFAULTS } from './live.js';
59
+ export { createSortedWindow, compareCodepoint } from './window.js';
60
+ export {
61
+ createJobEngine, JOBS_TABLE, JOB_CHECKPOINTS_TABLE, JOB_DEFAULTS,
62
+ describeValue, serializeResult,
63
+ } from './jobs.js';
64
+ export { createDagJobRunner } from './dag-job.js';
package/src/jobs.js ADDED
@@ -0,0 +1,507 @@
1
+ //@ts-check
2
+ /**
3
+ * @file The durable job queue (JOBS-FORMAT): enqueue, the
4
+ * single-statement guarded claim (§3 — one statement is one
5
+ * transaction, so no double-claim without any distributed lock),
6
+ * retry with exponential backoff and jitter (§4), recovery as
7
+ * re-claim of expired leases (§5), polling workers with same-process
8
+ * wake-on-enqueue (§6), and the per-job flow checkpoint store the DAG
9
+ * composition binds (§7) — completion marks the job done and records
10
+ * the result in ONE guarded transaction.
11
+ *
12
+ * Every worker transition is guarded by `state='leased' AND
13
+ * lease_owner=?`: execution is at-least-once, completion is
14
+ * exactly-once. `now` and `random` are injectable — the runtime
15
+ * defaults are the clock and `Math.random`; every test injects.
16
+ *
17
+ * The worker LIFECYCLE holds two invariants that a long-running process
18
+ * depends on, and neither is a detail:
19
+ *
20
+ * - **No handler value can break the loop.** A handler is host code and
21
+ * may resolve with something JSON cannot express, or reject with a
22
+ * value whose own `message` throws when read. Both are normalized
23
+ * totally, and `runOne` is isolated inside the loop, so the worst a
24
+ * single job can do is fail its own attempt. A rejected claim-execute
25
+ * loop would stop draining the queue silently.
26
+ * - **Shutdown is bounded.** Handlers receive an `AbortSignal` and
27
+ * `stop()` takes a deadline, so a handler that never settles cannot
28
+ * hold `stop()` — and therefore `store.close()`, and therefore the
29
+ * database file — open forever.
30
+ */
31
+
32
+ import { chain } from './driver.js';
33
+
34
+ export const JOBS_TABLE = '_jaren_jobs';
35
+ export const JOB_CHECKPOINTS_TABLE = '_jaren_job_checkpoints';
36
+
37
+ /** §4 defaults, all overridable per worker. */
38
+ export const JOB_DEFAULTS = Object.freeze({
39
+ maxAttempts: 5,
40
+ leaseMs: 30_000,
41
+ pollInterval: 500,
42
+ backoffBase: 1_000,
43
+ backoffCap: 60_000,
44
+ /** How long `stop()` waits for in-flight handlers after signalling
45
+ * abort, before it stops waiting and reports what is still running.
46
+ * Bounded on purpose: an unbounded wait makes one stuck handler
47
+ * indistinguishable from a hung process. */
48
+ stopGraceMs: 5_000,
49
+ });
50
+
51
+ /**
52
+ * A diagnostic string for ANY value, including ones that fight back — a
53
+ * getter that throws, a null-prototype object, a revoked proxy, a
54
+ * symbol. Total by construction: an error report is never the thing that
55
+ * fails.
56
+ * @param {any} value
57
+ * @returns {string}
58
+ */
59
+ export function describeValue(value) {
60
+ try {
61
+ if (value === null || value === undefined) return String(value);
62
+ if (typeof value === 'symbol') return value.toString();
63
+ if (typeof value !== 'object') return String(value);
64
+ const message = /** @type {any} */ (value).message;
65
+ if (typeof message === 'string') return message;
66
+ return String(value);
67
+ }
68
+ catch {
69
+ return '[unreportable value]';
70
+ }
71
+ }
72
+
73
+ /**
74
+ * JSON text for a job result, or `null` when the value cannot be
75
+ * expressed — a BigInt, a cycle, a `toJSON` that throws. The caller
76
+ * treats that as a failed attempt, never as a broken worker.
77
+ * @param {any} value
78
+ * @returns {{ text: string | null } | { reason: string }}
79
+ */
80
+ export function serializeResult(value) {
81
+ if (value === undefined || value === null) return { text: null };
82
+ try {
83
+ const text = JSON.stringify(value);
84
+ if (text === undefined) return { reason: 'the result is not representable as JSON' };
85
+ return { text };
86
+ }
87
+ catch (error) {
88
+ return { reason: `the result could not be serialized: ${describeValue(error)}` };
89
+ }
90
+ }
91
+
92
+ const CREATE_JOBS = `CREATE TABLE IF NOT EXISTS "${JOBS_TABLE}" (
93
+ id TEXT PRIMARY KEY,
94
+ kind TEXT NOT NULL,
95
+ payload TEXT,
96
+ state TEXT NOT NULL DEFAULT 'pending',
97
+ run_at INTEGER NOT NULL,
98
+ attempts INTEGER NOT NULL DEFAULT 0,
99
+ max_attempts INTEGER NOT NULL,
100
+ lease_until INTEGER,
101
+ lease_owner TEXT,
102
+ last_error TEXT,
103
+ result TEXT,
104
+ created_at INTEGER NOT NULL,
105
+ updated_at INTEGER NOT NULL
106
+ );
107
+ CREATE INDEX IF NOT EXISTS "${JOBS_TABLE}_claim"
108
+ ON "${JOBS_TABLE}" (state, run_at);
109
+ CREATE TABLE IF NOT EXISTS "${JOB_CHECKPOINTS_TABLE}" (
110
+ run_id TEXT NOT NULL,
111
+ node_id TEXT NOT NULL,
112
+ value TEXT NOT NULL,
113
+ PRIMARY KEY (run_id, node_id)
114
+ );`;
115
+
116
+ /** Map a raw row to the frozen public record (§2). */
117
+ function publicJob(row) {
118
+ return Object.freeze({
119
+ id: row.id,
120
+ kind: row.kind,
121
+ payload: row.payload === null ? null : JSON.parse(row.payload),
122
+ state: row.state,
123
+ runAt: Number(row.run_at),
124
+ attempts: Number(row.attempts),
125
+ maxAttempts: Number(row.max_attempts),
126
+ leaseUntil: row.lease_until === null ? null : Number(row.lease_until),
127
+ leaseOwner: row.lease_owner,
128
+ lastError: row.last_error,
129
+ result: row.result === null ? null : JSON.parse(row.result),
130
+ createdAt: Number(row.created_at),
131
+ updatedAt: Number(row.updated_at),
132
+ });
133
+ }
134
+
135
+ /**
136
+ * The queue engine over one open connection.
137
+ * @param {{ connection: any, now?: () => number,
138
+ * random?: () => number,
139
+ * defaults?: Partial<typeof JOB_DEFAULTS> }} options
140
+ */
141
+ export function createJobEngine(options) {
142
+ const { connection } = options;
143
+ const now = options.now ?? Date.now;
144
+ const random = options.random ?? Math.random;
145
+ const defaults = { ...JOB_DEFAULTS, ...options.defaults };
146
+
147
+ /** @type {Map<string, any>} */
148
+ const statements = new Map();
149
+ const prepared = (key, sql) => {
150
+ let statement = statements.get(key);
151
+ if (statement === undefined) {
152
+ statement = connection.prepare(sql);
153
+ statements.set(key, statement);
154
+ }
155
+ return statement;
156
+ };
157
+
158
+ /** Same-process wake-on-enqueue (§6). */
159
+ const wakers = new Set();
160
+ const wakeAll = () => {
161
+ for (const wake of [...wakers]) wake();
162
+ };
163
+
164
+ const ready = connection.exec(CREATE_JOBS);
165
+
166
+ const enqueue = (kind, payload, enqueueOptions) => {
167
+ if (typeof kind !== 'string' || kind === '') {
168
+ throw new TypeError('enqueue: "kind" must be a non-empty string');
169
+ }
170
+ const id = enqueueOptions?.id ?? crypto.randomUUID();
171
+ const at = now();
172
+ return chain(prepared('enqueue', `INSERT INTO "${JOBS_TABLE}"
173
+ (id, kind, payload, state, run_at, max_attempts, created_at, updated_at)
174
+ VALUES (?, ?, ?, 'pending', ?, ?, ?, ?)
175
+ ON CONFLICT (id) DO NOTHING`).run([
176
+ id, kind,
177
+ payload === undefined || payload === null ? null : JSON.stringify(payload),
178
+ enqueueOptions?.runAt ?? at,
179
+ enqueueOptions?.maxAttempts ?? defaults.maxAttempts,
180
+ at, at,
181
+ ]), () => {
182
+ wakeAll();
183
+ return id;
184
+ });
185
+ };
186
+
187
+ const get = (id) => chain(
188
+ prepared('get', `SELECT * FROM "${JOBS_TABLE}" WHERE id = ?`).get([id]),
189
+ (row) => (row === undefined ? undefined : publicJob(row)));
190
+
191
+ const counts = () => chain(
192
+ prepared('counts', `SELECT state, COUNT(*) AS n FROM "${JOBS_TABLE}" GROUP BY state`).all([]),
193
+ (states) => chain(
194
+ prepared('pendingKinds', `SELECT kind, COUNT(*) AS n FROM "${JOBS_TABLE}"
195
+ WHERE state IN ('pending', 'failed') GROUP BY kind`).all([]),
196
+ (kinds) => {
197
+ const out = {
198
+ pending: 0, leased: 0, done: 0, failed: 0, dead: 0,
199
+ /** @type {Record<string, number>} */
200
+ pendingKinds: {},
201
+ };
202
+ for (const row of states) out[row.state] = Number(row.n);
203
+ for (const row of kinds) out.pendingKinds[row.kind] = Number(row.n);
204
+ return out;
205
+ }));
206
+
207
+ /**
208
+ * §3: the single guarded claim; expired leases are claimable — the
209
+ * next claim IS crash recovery.
210
+ * @param {{ kinds: string[], owner: string, leaseMs?: number }} claimOptions
211
+ */
212
+ const claim = (claimOptions) => {
213
+ const { kinds, owner } = claimOptions;
214
+ if (!Array.isArray(kinds) || kinds.length === 0
215
+ || typeof owner !== 'string' || owner === '') {
216
+ throw new TypeError('claim: needs a non-empty "kinds" array and an "owner"');
217
+ }
218
+ const at = now();
219
+ const placeholders = kinds.map(() => '?').join(', ');
220
+ const statement = prepared(`claim:${kinds.length}`,
221
+ `UPDATE "${JOBS_TABLE}" SET state='leased', lease_owner=?, lease_until=?,
222
+ attempts = attempts + 1, updated_at=?
223
+ WHERE id = (SELECT id FROM "${JOBS_TABLE}"
224
+ WHERE (state='pending' OR state='failed'
225
+ OR (state='leased' AND lease_until < ?))
226
+ AND run_at <= ? AND kind IN (${placeholders})
227
+ ORDER BY run_at, created_at, id LIMIT 1)
228
+ RETURNING *`);
229
+ return chain(statement.get([
230
+ owner, at + (claimOptions.leaseMs ?? defaults.leaseMs), at, at, at, ...kinds,
231
+ ]), (row) => (row === undefined ? undefined : publicJob(row)));
232
+ };
233
+
234
+ /** §4: the jittered exponential backoff. */
235
+ const backoffOf = (attempts, workerDefaults) => {
236
+ const base = workerDefaults?.backoffBase ?? defaults.backoffBase;
237
+ const cap = workerDefaults?.backoffCap ?? defaults.backoffCap;
238
+ return Math.round(Math.min(cap, base * 2 ** (attempts - 1)) * (0.5 + random() / 2));
239
+ };
240
+
241
+ /**
242
+ * Complete a leased job — guarded, exactly-once (§3). A result JSON
243
+ * cannot express is refused HERE, before any write, so the caller can
244
+ * fail the attempt instead of the worker.
245
+ * @throws {TypeError} when the result is not representable
246
+ */
247
+ const complete = (id, owner, result) => {
248
+ const serialized = serializeResult(result);
249
+ if (!('text' in serialized)) throw new TypeError(serialized.reason);
250
+ return chain(
251
+ prepared('complete', `UPDATE "${JOBS_TABLE}"
252
+ SET state='done', result=?, lease_until=NULL, updated_at=?
253
+ WHERE id=? AND state='leased' AND lease_owner=?`).run([
254
+ serialized.text, now(), id, owner,
255
+ ]), (out) => Number(out.changes ?? 0) > 0);
256
+ };
257
+
258
+ /** Fail a leased job: schedule the retry or dead-letter (§4). */
259
+ const fail = (id, owner, error, workerDefaults) => chain(get(id), (job) => {
260
+ if (job === undefined) return false;
261
+ const terminal = job.attempts >= job.maxAttempts;
262
+ const at = now();
263
+ const statement = terminal
264
+ ? prepared('dead', `UPDATE "${JOBS_TABLE}"
265
+ SET state='dead', last_error=?, lease_until=NULL, updated_at=?
266
+ WHERE id=? AND state='leased' AND lease_owner=?`)
267
+ : prepared('retry', `UPDATE "${JOBS_TABLE}"
268
+ SET state='failed', last_error=?, lease_until=NULL, run_at=?, updated_at=?
269
+ WHERE id=? AND state='leased' AND lease_owner=?`);
270
+ // host code decides what it throws; reading it must not throw back
271
+ const message = describeValue(error);
272
+ const params = terminal
273
+ ? [message, at, id, owner]
274
+ : [message, at + backoffOf(job.attempts, workerDefaults), at, id, owner];
275
+ return chain(statement.run(params), (out) => Number(out.changes ?? 0) > 0);
276
+ });
277
+
278
+ /**
279
+ * §7: the flow checkpoint store bound to ONE claimed job. `save`
280
+ * refuses once the lease is lost (the stale run aborts fast);
281
+ * `complete` records the result, marks the job done and prunes the
282
+ * checkpoint rows in ONE guarded transaction — a failure leaves
283
+ * neither.
284
+ * @param {{ id: string, leaseOwner: string | null }} job
285
+ */
286
+ const checkpointsFor = (job) => ({
287
+ load: (runId) => chain(
288
+ prepared('cpLoad', `SELECT node_id, value FROM "${JOB_CHECKPOINTS_TABLE}"
289
+ WHERE run_id = ?`).all([runId]),
290
+ (rows) => (rows.length === 0
291
+ ? null
292
+ : {
293
+ values: Object.fromEntries(
294
+ rows.map((row) => [row.node_id, JSON.parse(row.value)])),
295
+ })),
296
+ save: (runId, nodeId, value) => connection.transaction(() => chain(
297
+ prepared('cpOwner', `SELECT lease_owner FROM "${JOBS_TABLE}"
298
+ WHERE id=? AND state='leased'`).get([runId]),
299
+ (row) => {
300
+ if (row === undefined || row.lease_owner !== job.leaseOwner) {
301
+ throw new Error(
302
+ `the lease on job '${runId}' was lost; the checkpoint is refused`);
303
+ }
304
+ return prepared('cpSave', `INSERT INTO "${JOB_CHECKPOINTS_TABLE}"
305
+ (run_id, node_id, value) VALUES (?, ?, ?)
306
+ ON CONFLICT (run_id, node_id) DO UPDATE SET value=excluded.value`)
307
+ .run([runId, nodeId, JSON.stringify(value)]);
308
+ })),
309
+ complete: (runId, result) => connection.transaction(() => chain(
310
+ complete(runId, /** @type {string} */ (job.leaseOwner), result),
311
+ (completed) => {
312
+ if (!completed) {
313
+ throw new Error(
314
+ `the lease on job '${runId}' was lost; the completion is refused`);
315
+ }
316
+ return prepared('cpPrune',
317
+ `DELETE FROM "${JOB_CHECKPOINTS_TABLE}" WHERE run_id = ?`).run([runId]);
318
+ })),
319
+ });
320
+
321
+ /** @type {Set<any>} */
322
+ const workers = new Set();
323
+
324
+ /**
325
+ * §6: N claim-execute loops over one handler registry.
326
+ * @param {{ handlers: Record<string, Function>, concurrency?: number,
327
+ * pollInterval?: number, leaseMs?: number, owner?: string,
328
+ * backoffBase?: number, backoffCap?: number }} workerOptions
329
+ */
330
+ const createWorker = (workerOptions) => {
331
+ const handlers = workerOptions?.handlers;
332
+ if (handlers === null || typeof handlers !== 'object'
333
+ || Object.keys(handlers).length === 0
334
+ || Object.values(handlers).some((handler) => typeof handler !== 'function')) {
335
+ throw new TypeError(
336
+ 'createWorker: "handlers" must be a non-empty object of functions');
337
+ }
338
+ const kinds = Object.keys(handlers);
339
+ const owner = workerOptions.owner ?? crypto.randomUUID();
340
+ const concurrency = workerOptions.concurrency ?? 1;
341
+ const pollInterval = workerOptions.pollInterval ?? defaults.pollInterval;
342
+ const stats = { claims: 0, completions: 0, failures: 0, polls: 0, wakes: 0 };
343
+
344
+ let running = false;
345
+ /** @type {Promise<void>[]} */
346
+ let loops = [];
347
+ /** @type {Set<{ timer: any, wake: () => void }>} */
348
+ const sleepers = new Set();
349
+
350
+ const sleep = () => new Promise((resolve) => {
351
+ /** @type {{ timer: any, wake: () => void }} */
352
+ const sleeper = {
353
+ timer: setTimeout(() => {
354
+ sleepers.delete(sleeper);
355
+ stats.polls += 1;
356
+ resolve(undefined);
357
+ }, pollInterval),
358
+ wake: () => {
359
+ clearTimeout(sleeper.timer);
360
+ sleepers.delete(sleeper);
361
+ stats.wakes += 1;
362
+ resolve(undefined);
363
+ },
364
+ };
365
+ sleepers.add(sleeper);
366
+ });
367
+ const onWake = () => {
368
+ for (const sleeper of [...sleepers]) sleeper.wake();
369
+ };
370
+
371
+ /** Aborted when `stop()` is called: the handler's cue to wind up. */
372
+ let shutdown = new AbortController();
373
+ /** In-flight handler count, so `stop()` can report what it left. */
374
+ let inFlight = 0;
375
+
376
+ /**
377
+ * Record one failed attempt. Reporting a failure must never itself
378
+ * fail the loop, so a storage error here is swallowed after the
379
+ * attempt count has already been incremented by the claim.
380
+ */
381
+ const recordFailure = async (job, error) => {
382
+ stats.failures += 1;
383
+ try {
384
+ await Promise.resolve(fail(job.id, owner, error, workerOptions));
385
+ }
386
+ catch {
387
+ // the lease will expire and the job will be re-claimed (§5);
388
+ // a worker must not die because the failure write failed
389
+ }
390
+ };
391
+
392
+ const runOne = async (job) => {
393
+ stats.claims += 1;
394
+ inFlight += 1;
395
+ try {
396
+ let result;
397
+ try {
398
+ result = await handlers[job.kind](job.payload,
399
+ { job, checkpointsFor, signal: shutdown.signal });
400
+ }
401
+ catch (error) {
402
+ await recordFailure(job, error);
403
+ return;
404
+ }
405
+ try {
406
+ // a §7 handler may have completed transactionally already; the
407
+ // guarded update makes this a no-op then
408
+ await Promise.resolve(complete(job.id, owner, result ?? null));
409
+ }
410
+ catch (error) {
411
+ // an unrepresentable result, or a storage failure at the
412
+ // completion write: this attempt failed, the worker did not
413
+ await recordFailure(job, error);
414
+ return;
415
+ }
416
+ stats.completions += 1;
417
+ }
418
+ finally {
419
+ inFlight -= 1;
420
+ }
421
+ };
422
+
423
+ const loop = async () => {
424
+ while (running) {
425
+ let job;
426
+ try {
427
+ job = await Promise.resolve(claim({
428
+ kinds, owner, leaseMs: workerOptions.leaseMs }));
429
+ }
430
+ catch {
431
+ job = undefined; // a transient storage failure: back off to the poll
432
+ }
433
+ if (!running) return;
434
+ if (job === undefined) {
435
+ await sleep();
436
+ continue;
437
+ }
438
+ try {
439
+ await runOne(job);
440
+ }
441
+ catch {
442
+ // `runOne` normalizes every handler outcome, so reaching here
443
+ // means the normalization itself broke. The loop still must not
444
+ // die: a stopped claim-execute loop drains nothing, silently.
445
+ stats.failures += 1;
446
+ }
447
+ }
448
+ };
449
+
450
+ const worker = {
451
+ stats: () => ({ ...stats, inFlight }),
452
+ start() {
453
+ if (running) throw new TypeError('the worker is already started');
454
+ running = true;
455
+ shutdown = new AbortController();
456
+ loops = Array.from({ length: concurrency }, () => loop());
457
+ return worker;
458
+ },
459
+ /**
460
+ * Stop claiming, signal in-flight handlers to abort, and wait for
461
+ * the loops — but only up to `graceMs`. A handler that ignores its
462
+ * signal cannot hold the process open; the resolved record says so
463
+ * instead, and the lease expiry (§5) lets another worker re-claim.
464
+ * @param {{ graceMs?: number }} [stopOptions]
465
+ * @returns {Promise<{ drained: boolean, inFlight: number }>}
466
+ */
467
+ async stop(stopOptions) {
468
+ running = false;
469
+ shutdown.abort();
470
+ onWake();
471
+ const graceMs = stopOptions?.graceMs
472
+ ?? workerOptions.stopGraceMs ?? defaults.stopGraceMs;
473
+ /** @type {any} */
474
+ let timer;
475
+ const drained = await Promise.race([
476
+ Promise.all(loops).then(() => true),
477
+ new Promise((resolve) => { timer = setTimeout(() => resolve(false), graceMs); }),
478
+ ]);
479
+ clearTimeout(timer);
480
+ if (drained) loops = [];
481
+ wakers.delete(onWake);
482
+ workers.delete(worker);
483
+ return { drained: drained === true, inFlight };
484
+ },
485
+ };
486
+ wakers.add(onWake);
487
+ workers.add(worker);
488
+ return worker;
489
+ };
490
+
491
+ return {
492
+ ready,
493
+ enqueue,
494
+ get,
495
+ counts,
496
+ claim,
497
+ complete,
498
+ fail,
499
+ checkpointsFor,
500
+ createWorker,
501
+ /** Stop every worker, bounded. Resolves to the per-worker outcome so
502
+ * `close()` can report a handler it could not wait out rather than
503
+ * hanging on it. */
504
+ stopAll: (stopOptions) =>
505
+ Promise.all([...workers].map((worker) => worker.stop(stopOptions))),
506
+ };
507
+ }