nuxt-cf-jobs 0.5.2 → 0.6.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.
@@ -201,6 +201,22 @@ function forgetSql(id, t = defaultTableNames) {
201
201
  function flushSql(queue, t = defaultTableNames) {
202
202
  return `DELETE FROM ${t.failed} ${queue ? `WHERE queue = ${sqlString(queue)}` : ""}`.trimEnd();
203
203
  }
204
+ function pruneCompletedJobsSql(hours, t = defaultTableNames) {
205
+ return `DELETE FROM ${t.jobs} WHERE completed_at IS NOT NULL AND completed_at <= unixepoch() - ${sqlInt(hours * 3600)}`;
206
+ }
207
+ function pruneFailedJobsSql(hours, t = defaultTableNames) {
208
+ return `DELETE FROM ${t.failed} WHERE failed_at <= unixepoch() - ${sqlInt(hours * 3600)}`;
209
+ }
210
+ function pruneFinishedBatchesSql(hours, t = defaultTableNames) {
211
+ return `DELETE FROM ${t.batches} WHERE finished_at IS NOT NULL AND finished_at <= unixepoch() - ${sqlInt(hours * 3600)}`;
212
+ }
213
+ function pruneSql(opts, t = defaultTableNames) {
214
+ return [
215
+ pruneCompletedJobsSql(opts.completedHours, t),
216
+ pruneFailedJobsSql(opts.failedHours, t),
217
+ pruneFinishedBatchesSql(opts.batchesHours, t)
218
+ ].join(";\n");
219
+ }
204
220
  function clearSql(filters = {}, t = defaultTableNames) {
205
221
  return `DELETE FROM ${t.jobs}
206
222
  ${andClauses(
@@ -586,6 +602,36 @@ const clear = defineCommand({
586
602
  `);
587
603
  }
588
604
  });
605
+ const prune = defineCommand({
606
+ meta: { name: "prune", description: "Delete terminal rows past retention (artisan queue:prune-batches + queue:prune-failed)" },
607
+ args: {
608
+ ...sharedArgs,
609
+ "completed-hours": { type: "string", description: "Completed-jobs retention in hours", default: "24" },
610
+ "failed-hours": { type: "string", description: "Failed-jobs retention in hours", default: "168" },
611
+ "batches-hours": { type: "string", description: "Finished-batches retention in hours", default: "72" },
612
+ "yes": { type: "boolean", alias: "y", description: "Skip confirmation", default: false }
613
+ },
614
+ async run({ args }) {
615
+ const { target, tables } = context(args);
616
+ const hours = {
617
+ completedHours: Number(args["completed-hours"]),
618
+ failedHours: Number(args["failed-hours"]),
619
+ batchesHours: Number(args["batches-hours"])
620
+ };
621
+ if (Object.values(hours).some((h) => !Number.isFinite(h) || h < 0)) {
622
+ process$1.stderr.write(`${color.red("\u2717")} --*-hours must be non-negative numbers.
623
+ `);
624
+ process$1.exitCode = 1;
625
+ return;
626
+ }
627
+ const scope = `completed >${hours.completedHours}h, failed >${hours.failedHours}h, batches >${hours.batchesHours}h`;
628
+ if (!await confirm(`Prune ${scope} (${target.remote ? "remote" : "local"})?`, args.yes))
629
+ return;
630
+ await execD1(target, `${pruneSql(hours, tables)};`);
631
+ process$1.stdout.write(`${color.green("\u2713")} Pruned ${scope}.
632
+ `);
633
+ }
634
+ });
589
635
  const migrate = defineCommand({
590
636
  meta: { name: "migrate", description: "Create the job tables/indexes in D1" },
591
637
  args: {
@@ -658,7 +704,7 @@ const main = defineCommand({
658
704
  description: "Inspect and manage nuxt-cf-jobs durable jobs in Cloudflare D1"
659
705
  },
660
706
  args: sharedArgs,
661
- subCommands: { status, jobs, failed, retry, forget, flush, clear, migrate, schedule, tasks },
707
+ subCommands: { status, jobs, failed, retry, forget, flush, clear, prune, migrate, schedule, tasks },
662
708
  async run({ args, rawArgs }) {
663
709
  if (rawArgs.length === 0 || rawArgs.every((a) => a.startsWith("-")))
664
710
  await runStatus(args);
package/dist/module.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "nuxt-cf-jobs",
3
3
  "configKey": "cfJobs",
4
- "version": "0.5.2",
4
+ "version": "0.6.1",
5
5
  "builder": {
6
6
  "@nuxt/module-builder": "1.0.2",
7
7
  "unbuild": "3.6.1"
package/dist/module.mjs CHANGED
@@ -131,6 +131,7 @@ const module$1 = defineNuxtModule({
131
131
  async setup(options, nuxt) {
132
132
  const resolver = createResolver(import.meta.url);
133
133
  nuxt.options.alias["#cf-jobs/server"] = resolver.resolve("./runtime/server");
134
+ nuxt.options.alias["#cf-jobs/cloudflare"] = resolver.resolve("./runtime/server/cloudflare");
134
135
  addServerImports([
135
136
  { name: "defineJob", from: resolver.resolve("./runtime/server/registry") },
136
137
  { name: "defineScheduledTask", from: resolver.resolve("./runtime/server/scheduled") }
@@ -96,6 +96,7 @@ export declare function createJobBatch<Name extends string = string, Payload ext
96
96
  export interface BatchProgress {
97
97
  batchId: string;
98
98
  name: string | null;
99
+ siteId: string | null;
99
100
  completed: number;
100
101
  total: number;
101
102
  failed: number;
@@ -103,6 +103,7 @@ function toProgress(batch) {
103
103
  return {
104
104
  batchId: batch.id,
105
105
  name: batch.name,
106
+ siteId: batch.siteId,
106
107
  completed: batch.totalJobs - batch.pendingJobs,
107
108
  total: batch.totalJobs,
108
109
  failed: batch.failedJobs,
@@ -0,0 +1,46 @@
1
+ import type { JobMetricsEvent, JobMetricsSink } from './metrics.js';
2
+ /**
3
+ * Minimal structural shape of a Cloudflare Analytics Engine binding
4
+ * (`AnalyticsEngineDataset`). Declared locally so this module needs no
5
+ * `@cloudflare/workers-types` dependency; the real binding is assignable.
6
+ */
7
+ export interface AnalyticsEngineDataset {
8
+ writeDataPoint: (point: {
9
+ indexes?: string[];
10
+ blobs?: Array<string | null>;
11
+ doubles?: number[];
12
+ }) => void;
13
+ }
14
+ export type AnalyticsEngineDataPoint = Parameters<AnalyticsEngineDataset['writeDataPoint']>[0];
15
+ export interface AnalyticsEngineSinkOptions {
16
+ /**
17
+ * Override the event → data-point mapping. Mind the Analytics Engine limits:
18
+ * at most ONE index (≤96 bytes), ≤20 doubles, and blobs totalling ≤5120 bytes.
19
+ */
20
+ toDataPoint?: (event: JobMetricsEvent) => AnalyticsEngineDataPoint;
21
+ }
22
+ /**
23
+ * Default mapping. `queue` is the single index (the natural rollup key); status
24
+ * and jobType ride as blobs for filtering; duration + attempts as doubles for
25
+ * latency/retry aggregates.
26
+ */
27
+ export declare function defaultJobDataPoint(event: JobMetricsEvent): AnalyticsEngineDataPoint;
28
+ /** Build a sink that writes each event to the given Analytics Engine dataset. */
29
+ export declare function createAnalyticsEngineSink(dataset: AnalyticsEngineDataset, opts?: AnalyticsEngineSinkOptions): JobMetricsSink;
30
+ export interface ResolveAnalyticsEngineSinkOptions extends AnalyticsEngineSinkOptions {
31
+ /** Env binding name of the Analytics Engine dataset (e.g. `'JOB_ANALYTICS'`). */
32
+ binding: string;
33
+ /**
34
+ * Sink used when the binding is absent (local dev / not yet provisioned) — so
35
+ * wiring this in is a safe no-op until the dataset exists. Defaults to
36
+ * {@link noopMetricsSink}; pass `createConsoleMetricsSink()` for dev logs.
37
+ */
38
+ fallback?: JobMetricsSink;
39
+ }
40
+ /**
41
+ * Resolve an Analytics Engine sink from a Workers `env`, degrading to a fallback
42
+ * (no-op by default) when the binding is missing or malformed. This is the
43
+ * "provide the binding name, it just works" entry point — drop it straight into
44
+ * a repository's hooks via `metricsSinkToRepoHooks`.
45
+ */
46
+ export declare function resolveAnalyticsEngineSink(env: Record<string, unknown> | undefined, opts: ResolveAnalyticsEngineSinkOptions): JobMetricsSink;
@@ -0,0 +1,31 @@
1
+ import { noopMetricsSink } from "./metrics.js";
2
+ export function defaultJobDataPoint(event) {
3
+ return {
4
+ indexes: [event.queue],
5
+ blobs: [event.queue, event.jobType, event.status, event.error ?? null],
6
+ // doubles are positional — keep this order stable (the AE SQL API references
7
+ // double1..double6). duration, attempts, then the reported execution stats.
8
+ doubles: [
9
+ event.durationMs ?? 0,
10
+ event.attempts,
11
+ event.rowsFetched ?? 0,
12
+ event.rowsInserted ?? 0,
13
+ event.d1RowsRead ?? 0,
14
+ event.d1RowsWritten ?? 0
15
+ ]
16
+ };
17
+ }
18
+ export function createAnalyticsEngineSink(dataset, opts = {}) {
19
+ const toDataPoint = opts.toDataPoint ?? defaultJobDataPoint;
20
+ return {
21
+ record(event) {
22
+ dataset.writeDataPoint(toDataPoint(event));
23
+ }
24
+ };
25
+ }
26
+ export function resolveAnalyticsEngineSink(env, opts) {
27
+ const dataset = env?.[opts.binding];
28
+ if (dataset && typeof dataset.writeDataPoint === "function")
29
+ return createAnalyticsEngineSink(dataset, opts);
30
+ return opts.fallback ?? noopMetricsSink;
31
+ }
@@ -1,4 +1,4 @@
1
- import type { DurableJobFailureRepository, DurableJobLifecycle, DurableJobRecord, DurableJobRecoveryRepository, DurableJobRepository, ReleaseDurableJobOptions } from './outbox.js';
1
+ import type { DurableJobFailureRepository, DurableJobLifecycle, DurableJobPruneRepository, DurableJobRecord, DurableJobRecoveryRepository, DurableJobRepository, ReleaseDurableJobOptions } from './outbox.js';
2
2
  export interface D1PreparedStatementLike<T = unknown> {
3
3
  bind: (...values: unknown[]) => D1PreparedStatementLike<T>;
4
4
  run: () => Promise<{
@@ -108,7 +108,7 @@ export interface D1InsertJobsResult<Queue extends string = string> {
108
108
  inserted: Array<DurableJobRecord<Queue>>;
109
109
  chunks: D1InsertJobsChunkResult[];
110
110
  }
111
- export type D1DurableJobRepository<Queue extends string = string> = DurableJobRepository<Queue, DurableJobRecord<Queue>> & DurableJobRecoveryRepository<Queue, D1DurableJobRecord<Queue>> & DurableJobLifecycle<D1DurableJobRecord<Queue>> & DurableJobFailureRepository & {
111
+ export type D1DurableJobRepository<Queue extends string = string> = DurableJobRepository<Queue, DurableJobRecord<Queue>> & DurableJobRecoveryRepository<Queue, D1DurableJobRecord<Queue>> & DurableJobLifecycle<D1DurableJobRecord<Queue>> & DurableJobFailureRepository & DurableJobPruneRepository & {
112
112
  migrate: () => Promise<void>;
113
113
  insertJobs: (records: readonly DurableJobRecord<Queue>[], opts?: {
114
114
  batchSize?: number;
@@ -14,6 +14,8 @@ export const d1DurableJobMigrationSql = [
14
14
  "CREATE INDEX IF NOT EXISTS idx_jobs_batch ON jobs (batch_id)",
15
15
  "CREATE INDEX IF NOT EXISTS idx_jobs_trace ON jobs (trace_id)",
16
16
  "CREATE INDEX IF NOT EXISTS idx_jobs_sync_dedup ON jobs (site_id, job_type)",
17
+ // Partial index backing pruneCompletedJobs (completed_at IS NOT NULL AND <= ?).
18
+ "CREATE INDEX IF NOT EXISTS idx_jobs_completed_at ON jobs (completed_at) WHERE completed_at IS NOT NULL",
17
19
  "CREATE UNIQUE INDEX IF NOT EXISTS idx_jobs_unique_active ON jobs (unique_key) WHERE unique_key IS NOT NULL AND completed_at IS NULL AND failed_at IS NULL",
18
20
  "CREATE INDEX IF NOT EXISTS idx_failed_jobs_queue ON failed_jobs (queue)",
19
21
  "CREATE INDEX IF NOT EXISTS idx_failed_jobs_site ON failed_jobs (site_id)",
@@ -23,6 +25,7 @@ export const d1DurableJobMigrationSql = [
23
25
  export function createD1DurableJobRepository(db, opts = {}) {
24
26
  const jobsTable = opts.jobsTable ?? "jobs";
25
27
  const failedJobsTable = opts.failedJobsTable ?? "failed_jobs";
28
+ const batchesTable = opts.batchesTable ?? "job_batches";
26
29
  const insertJobSql = `
27
30
  INSERT OR IGNORE INTO ${jobsTable} (
28
31
  id, queue, job_type, batch_id, user_id, site_id, partner_id, trace_id, unique_key, payload,
@@ -116,12 +119,26 @@ export function createD1DurableJobRepository(db, opts = {}) {
116
119
  return "in-flight";
117
120
  },
118
121
  async completeJob(job, result) {
119
- const durationMs = result && typeof result === "object" && "durationMs" in result && typeof result.durationMs === "number" ? result.durationMs : null;
122
+ const stats = readResultStat(result);
123
+ const reportedDuration = stats("durationMs");
124
+ const durationMs = reportedDuration ?? (job.reserved_at != null ? (currentUnixSeconds() - job.reserved_at) * 1e3 : null);
120
125
  await db.prepare(`
121
126
  UPDATE ${jobsTable}
122
- SET completed_at = unixepoch(), reserved_at = NULL, duration_ms = COALESCE(?, duration_ms)
127
+ SET completed_at = unixepoch(), reserved_at = NULL,
128
+ duration_ms = COALESCE(?, duration_ms),
129
+ rows_fetched = COALESCE(?, rows_fetched),
130
+ rows_inserted = COALESCE(?, rows_inserted),
131
+ d1_rows_read = COALESCE(?, d1_rows_read),
132
+ d1_rows_written = COALESCE(?, d1_rows_written)
123
133
  WHERE id = ?
124
- `).bind(durationMs, job.id).run();
134
+ `).bind(
135
+ durationMs,
136
+ stats("rowsFetched") ?? null,
137
+ stats("rowsInserted") ?? null,
138
+ stats("d1RowsRead") ?? null,
139
+ stats("d1RowsWritten") ?? null,
140
+ job.id
141
+ ).run();
125
142
  fireHook(() => opts.onJobCompleted?.({ job, durationMs, result }));
126
143
  },
127
144
  async failJob(job, error) {
@@ -235,12 +252,54 @@ export function createD1DurableJobRepository(db, opts = {}) {
235
252
  siteId: job.site_id,
236
253
  userId: job.user_id
237
254
  };
255
+ },
256
+ async pruneCompletedJobs(query) {
257
+ return await pruneInChunks(db, jobsTable, "completed_at IS NOT NULL AND completed_at <= ?", query);
258
+ },
259
+ async pruneFailedJobs(query) {
260
+ return await pruneInChunks(db, failedJobsTable, "failed_at <= ?", query);
261
+ },
262
+ async pruneFinishedBatches(query) {
263
+ return await pruneInChunks(
264
+ db,
265
+ batchesTable,
266
+ `finished_at IS NOT NULL AND finished_at <= ? AND NOT EXISTS (SELECT 1 FROM ${jobsTable} WHERE ${jobsTable}.batch_id = ${batchesTable}.id)`,
267
+ query
268
+ );
238
269
  }
239
270
  };
240
271
  }
272
+ const DEFAULT_PRUNE_CHUNK = 1e3;
273
+ async function pruneInChunks(db, table, whereClause, query) {
274
+ const chunk = Math.max(1, query.limit ?? DEFAULT_PRUNE_CHUNK);
275
+ const sql = `
276
+ DELETE FROM ${table}
277
+ WHERE id IN (
278
+ SELECT id FROM ${table}
279
+ WHERE ${whereClause}
280
+ LIMIT ?
281
+ )
282
+ `;
283
+ let total = 0;
284
+ for (; ; ) {
285
+ const result = await db.prepare(sql).bind(query.before, chunk).run();
286
+ const changes = result.meta?.changes ?? 0;
287
+ total += changes;
288
+ if (changes < chunk)
289
+ break;
290
+ }
291
+ return total;
292
+ }
241
293
  function currentUnixSeconds() {
242
294
  return Math.floor(Date.now() / 1e3);
243
295
  }
296
+ function readResultStat(result) {
297
+ const obj = result && typeof result === "object" ? result : void 0;
298
+ return (key) => {
299
+ const v = obj?.[key];
300
+ return typeof v === "number" ? v : void 0;
301
+ };
302
+ }
244
303
  function fireHook(fn) {
245
304
  try {
246
305
  const result = fn();
@@ -3,12 +3,14 @@ export * from './batch.js';
3
3
  export * from './d1.js';
4
4
  export * from './dispatch.js';
5
5
  export * from './errors.js';
6
+ export * from './metrics.js';
6
7
  export * from './outbox.js';
7
8
  export * from './policy.js';
8
9
  export { CF_QUEUE_MAX_BATCH_BYTES, CF_QUEUE_MAX_BATCH_SIZE, CF_QUEUE_MAX_DELAY_SECONDS, CF_QUEUE_MAX_MESSAGE_BYTES, createJobQueue, defineCfJobsQueues, exponentialBackoff, resolveNitroTaskEnv, resolveQueueBindingName, } from './queue.js';
9
10
  export type { JobQueuePublisher, QueueSource } from './queue.js';
10
11
  export * from './registry.js';
11
12
  export * from './result.js';
13
+ export * from './runtime.js';
12
14
  export * from './scheduled.js';
13
15
  export * from './schema.js';
14
16
  export * from './types.js';
@@ -3,6 +3,7 @@ export * from "./batch.js";
3
3
  export * from "./d1.js";
4
4
  export * from "./dispatch.js";
5
5
  export * from "./errors.js";
6
+ export * from "./metrics.js";
6
7
  export * from "./outbox.js";
7
8
  export * from "./policy.js";
8
9
  export {
@@ -18,6 +19,7 @@ export {
18
19
  } from "./queue.js";
19
20
  export * from "./registry.js";
20
21
  export * from "./result.js";
22
+ export * from "./runtime.js";
21
23
  export * from "./scheduled.js";
22
24
  export * from "./schema.js";
23
25
  export * from "./types.js";
@@ -0,0 +1,53 @@
1
+ import type { D1DurableJobRepositoryOptions } from './d1.js';
2
+ export type JobMetricStatus = 'completed' | 'failed' | 'released';
3
+ /**
4
+ * One terminal-ish job lifecycle event, engine-neutral. `released` is a
5
+ * deliberate retry (`ctx.release()` / a handler throw) rather than a terminal
6
+ * state, but it is worth recording for retry-rate dashboards.
7
+ */
8
+ export interface JobMetricsEvent {
9
+ jobId: string;
10
+ queue: string;
11
+ jobType: string;
12
+ status: JobMetricStatus;
13
+ attempts: number;
14
+ /** Wall-clock duration when known (completed jobs); null otherwise. */
15
+ durationMs: number | null;
16
+ batchId: string | null;
17
+ siteId: string | null;
18
+ userId: number | null;
19
+ /** Present for `failed` / `released` (the release reason). */
20
+ error?: string;
21
+ rowsFetched?: number;
22
+ rowsInserted?: number;
23
+ d1RowsRead?: number;
24
+ d1RowsWritten?: number;
25
+ }
26
+ export interface JobMetricsSink {
27
+ record: (event: JobMetricsEvent) => void | Promise<void>;
28
+ }
29
+ /**
30
+ * Adapt a {@link JobMetricsSink} to the repository's lifecycle hooks. Spread the
31
+ * result into {@link D1DurableJobRepositoryOptions}:
32
+ *
33
+ * ```ts
34
+ * createD1DurableJobRepository(db, { ...metricsSinkToRepoHooks(sink) })
35
+ * ```
36
+ *
37
+ * The repo invokes these via its own `fireHook` (swallows sync throws + async
38
+ * rejections), so a misbehaving sink can never break a job's lifecycle.
39
+ */
40
+ export declare function metricsSinkToRepoHooks(sink: JobMetricsSink): Pick<D1DurableJobRepositoryOptions, 'onJobCompleted' | 'onJobFailed' | 'onJobReleased'>;
41
+ /**
42
+ * Fan an event out to several sinks, isolating each so one throwing/rejecting
43
+ * sink never starves the others (the repo guards the outermost call, but a sink
44
+ * combined here is also reachable directly).
45
+ */
46
+ export declare function combineMetricsSinks(...sinks: JobMetricsSink[]): JobMetricsSink;
47
+ /**
48
+ * A console sink — a zero-dependency default for local dev where no real metrics
49
+ * engine binding exists. Pass your own `log` to route elsewhere.
50
+ */
51
+ export declare function createConsoleMetricsSink(log?: (event: JobMetricsEvent) => void): JobMetricsSink;
52
+ /** A sink that records nothing — the safe default when metrics are unconfigured. */
53
+ export declare const noopMetricsSink: JobMetricsSink;
@@ -0,0 +1,59 @@
1
+ const STAT_KEYS = ["rowsFetched", "rowsInserted", "d1RowsRead", "d1RowsWritten"];
2
+ function readStats(result) {
3
+ if (!result || typeof result !== "object")
4
+ return {};
5
+ const out = {};
6
+ for (const k of STAT_KEYS) {
7
+ const v = result[k];
8
+ if (typeof v === "number")
9
+ out[k] = v;
10
+ }
11
+ return out;
12
+ }
13
+ function toEvent(job, status, extra) {
14
+ return {
15
+ jobId: job.id,
16
+ queue: job.queue,
17
+ jobType: job.job_type,
18
+ status,
19
+ attempts: job.attempts,
20
+ durationMs: extra.durationMs ?? null,
21
+ batchId: job.batch_id,
22
+ siteId: job.site_id,
23
+ userId: job.user_id,
24
+ error: extra.error
25
+ };
26
+ }
27
+ export function metricsSinkToRepoHooks(sink) {
28
+ return {
29
+ onJobCompleted({ job, durationMs, result }) {
30
+ return sink.record({ ...toEvent(job, "completed", { durationMs }), ...readStats(result) });
31
+ },
32
+ onJobFailed({ job, error }) {
33
+ return sink.record(toEvent(job, "failed", { durationMs: job.duration_ms ?? null, error }));
34
+ },
35
+ onJobReleased({ job, opts }) {
36
+ return sink.record(toEvent(job, "released", { error: opts?.error }));
37
+ }
38
+ };
39
+ }
40
+ export function combineMetricsSinks(...sinks) {
41
+ return {
42
+ record(event) {
43
+ for (const sink of sinks) {
44
+ try {
45
+ const result = sink.record(event);
46
+ if (result && typeof result.then === "function")
47
+ result.catch(() => {
48
+ });
49
+ } catch {
50
+ }
51
+ }
52
+ }
53
+ };
54
+ }
55
+ export function createConsoleMetricsSink(log = (event) => console.debug("[cf-jobs:metric]", event)) {
56
+ return { record: log };
57
+ }
58
+ export const noopMetricsSink = { record() {
59
+ } };
@@ -122,6 +122,54 @@ export interface DurableJobRecoveryRepository<Queue extends string = string, Rec
122
122
  findStaleReservedJobs?: (query: DurableJobStaleRecoveryQuery) => Promise<Record[]>;
123
123
  releaseStaleReservedJobs?: (query: DurableJobStaleRecoveryQuery) => Promise<number>;
124
124
  }
125
+ export interface PruneDurableJobsQuery {
126
+ /** Unix-seconds cutoff: terminal rows with a timestamp at or before this are deleted. */
127
+ before: number;
128
+ /**
129
+ * Max rows deleted per statement. Implementations chunk the delete in a loop
130
+ * (D1 caps rows touched per statement), so the full backlog older than `before`
131
+ * is removed regardless of this value. Defaults to the implementation's chunk size.
132
+ */
133
+ limit?: number;
134
+ }
135
+ /**
136
+ * Pruning the three terminal-row tables (Laravel `queue:prune-batches` /
137
+ * `queue:prune-failed` parity). Each method deletes only rows that are genuinely
138
+ * terminal (completed / failed / finished) and older than `before`, returning the
139
+ * total deleted. In-flight rows (`completed_at`/`failed_at`/`finished_at` IS NULL)
140
+ * are never touched.
141
+ */
142
+ export interface DurableJobPruneRepository {
143
+ /** Delete `jobs` rows with `completed_at <= before` (soft-completed, kept for observability). */
144
+ pruneCompletedJobs: (query: PruneDurableJobsQuery) => Promise<number>;
145
+ /** Delete `job_batches` rows with `finished_at <= before` (terminal batches). */
146
+ pruneFinishedBatches: (query: PruneDurableJobsQuery) => Promise<number>;
147
+ /** Delete `failed_jobs` rows with `failed_at <= before`. */
148
+ pruneFailedJobs: (query: PruneDurableJobsQuery) => Promise<number>;
149
+ }
150
+ export interface PruneDurableJobsOptions {
151
+ /** Cutoff for `completeJob`-soft-completed `jobs` rows. Omit to skip. */
152
+ completedBefore?: number;
153
+ /** Cutoff for terminal `job_batches` rows. Omit to skip. */
154
+ finishedBatchesBefore?: number;
155
+ /** Cutoff for `failed_jobs` rows. Omit to skip. */
156
+ failedBefore?: number;
157
+ /** Per-statement chunk size forwarded to each prune method. */
158
+ limit?: number;
159
+ }
160
+ export interface PruneDurableJobsResult {
161
+ completedJobs: number;
162
+ finishedBatches: number;
163
+ failedJobs: number;
164
+ }
165
+ /**
166
+ * Convenience over {@link DurableJobPruneRepository} that prunes all three tables
167
+ * with independent cutoffs. Ordering matters: `jobs.batch_id` FKs `job_batches(id)`,
168
+ * so member jobs (completed + failed) are pruned BEFORE their batches — otherwise a
169
+ * batch delete can violate the FK where D1 enforces it. A cutoff left `undefined`
170
+ * skips that table (the corresponding count is 0).
171
+ */
172
+ export declare function pruneDurableJobs(repository: DurableJobPruneRepository, opts: PruneDurableJobsOptions): Promise<PruneDurableJobsResult>;
125
173
  export interface PrepareDurableJobOptions<Name extends string, Payload extends object, Queue extends string> {
126
174
  name: Name;
127
175
  payload: Payload;
@@ -231,7 +279,7 @@ export interface RunDurableJobMessageOptions<StoredJob, Job extends Dispatchable
231
279
  message: Pick<QueueMessage<Message>, 'body' | 'ack' | 'retry'>;
232
280
  lifecycle: Pick<DurableJobLifecycle<StoredJob, CompleteResult, FailOptions>, 'claimJob' | 'resolveClaimMiss' | 'completeJob' | 'failJob' | 'releaseJob'>;
233
281
  registry: {
234
- getHandler: (name: string) => JobHandler<unknown, Env, Db, Logger> | undefined;
282
+ getHandler: (name: string) => JobHandler<unknown, Env, Db, Logger> | undefined | Promise<JobHandler<unknown, Env, Db, Logger> | undefined>;
235
283
  getJobDefinition?: (name: string) => JobDefinition<string, unknown, string, Env, Db, Logger> | undefined;
236
284
  };
237
285
  toDispatchableJob: (job: StoredJob) => Job;
@@ -252,6 +300,55 @@ export interface RunDurableJobMessageOptions<StoredJob, Job extends Dispatchable
252
300
  job: StoredJob;
253
301
  dispatch: DispatchResult;
254
302
  }) => unknown | Promise<unknown>;
303
+ /**
304
+ * Laravel worker model: a handler that throws is retried until `attempts`
305
+ * reaches the job's max, then failed (→ `failed_jobs`) instead of released for
306
+ * another try. Supply this to read the stored job's max so the consumer — not
307
+ * just the queue transport's `max_retries` — enforces the per-job attempt cap.
308
+ * Omit to keep retrying every throw (the transport/DLQ then decides terminal).
309
+ */
310
+ maxAttemptsOf?: (job: StoredJob) => number | undefined;
311
+ /**
312
+ * Per-job scope created right after claim: `wrapDispatch` wraps the handler run
313
+ * (e.g. AsyncLocalStorage telemetry), `onSettled` observes every terminal/
314
+ * released outcome (e.g. write a run-log row). Both close over the same scope so
315
+ * the wrapper's collected data is available to the observer.
316
+ */
317
+ createJobScope?: (storedJob: StoredJob) => DurableJobScope<StoredJob>;
318
+ /**
319
+ * Decide whether a THROWN error is terminal (→ `failed_jobs`) vs released for
320
+ * retry. Overrides the default `attempts >= maxAttemptsOf` so callers can, e.g.,
321
+ * never fail on transient infra errors. `maxAttempts` is `maxAttemptsOf`'s value.
322
+ */
323
+ isPermanentFailure?: (input: {
324
+ error: unknown;
325
+ storedJob: StoredJob;
326
+ attempts: number;
327
+ maxAttempts: number | undefined;
328
+ }) => boolean;
329
+ /**
330
+ * Dispatch the job payload's `then`/`catch`/`finally` continuations after a
331
+ * terminal outcome: `then`+`finally` on success, `catch`+`finally` on terminal
332
+ * failure, none on a release/retry.
333
+ */
334
+ dispatchContinuations?: (input: {
335
+ storedJob: StoredJob;
336
+ stage: DurableJobContinuationStage;
337
+ }) => void | Promise<void>;
338
+ }
339
+ export interface DurableJobSettlement<StoredJob> {
340
+ storedJob: StoredJob;
341
+ status: RunDurableJobMessageResult['status'];
342
+ /** Wall-clock ms from claim to settle. */
343
+ durationMs: number;
344
+ /** True when the job reached a terminal failure (not a release/retry). */
345
+ permanent: boolean;
346
+ /** The error for failed/released/exhausted outcomes (raw, for the caller to classify). */
347
+ error?: unknown;
348
+ }
349
+ export interface DurableJobScope<StoredJob> {
350
+ wrapDispatch?: (run: () => Promise<DispatchResult>) => Promise<DispatchResult>;
351
+ onSettled?: (settlement: DurableJobSettlement<StoredJob>) => void | Promise<void>;
255
352
  }
256
353
  /**
257
354
  * Outcome of {@link runDurableJobMessage}, discriminated on `status` so each
@@ -264,6 +361,8 @@ export interface RunDurableJobMessageOptions<StoredJob, Job extends Dispatchable
264
361
  * - `errored`: the handler threw an unexpected defect; the message is retried and
265
362
  * `error` carries the defect as a `handler-threw` `JobError` (`error.cause` is the
266
363
  * original throw). Distinct from `released` (a deliberate `ctx.release()`).
364
+ * - `exhausted`: the handler threw AND `attempts` reached `maxAttemptsOf` — the job
365
+ * was failed (→ `failed_jobs`) rather than retried. Terminal.
267
366
  */
268
367
  export type RunDurableJobMessageResult = {
269
368
  status: 'invalid-message';
@@ -285,5 +384,8 @@ export type RunDurableJobMessageResult = {
285
384
  } | {
286
385
  status: 'errored';
287
386
  error: JobError;
387
+ } | {
388
+ status: 'exhausted';
389
+ error: JobError;
288
390
  };
289
391
  export declare function runDurableJobMessage<StoredJob, Job extends DispatchableJob, Message extends QueueJobMessage = QueueJobMessage, Env = unknown, Db = unknown, Logger = unknown, CompleteResult = unknown, FailOptions = unknown>(opts: RunDurableJobMessageOptions<StoredJob, Job, Message, Env, Db, Logger, CompleteResult, FailOptions>): Promise<RunDurableJobMessageResult>;
@@ -8,6 +8,12 @@ import { err, ok, unwrapResult } from "./result.js";
8
8
  function byteLength(value) {
9
9
  return typeof Buffer !== "undefined" ? Buffer.byteLength(value, "utf8") : new TextEncoder().encode(value).byteLength;
10
10
  }
11
+ export async function pruneDurableJobs(repository, opts) {
12
+ const completedJobs = typeof opts.completedBefore === "number" ? await repository.pruneCompletedJobs({ before: opts.completedBefore, limit: opts.limit }) : 0;
13
+ const failedJobs = typeof opts.failedBefore === "number" ? await repository.pruneFailedJobs({ before: opts.failedBefore, limit: opts.limit }) : 0;
14
+ const finishedBatches = typeof opts.finishedBatchesBefore === "number" ? await repository.pruneFinishedBatches({ before: opts.finishedBatchesBefore, limit: opts.limit }) : 0;
15
+ return { completedJobs, failedJobs, finishedBatches };
16
+ }
11
17
  export async function prepareDurableJobResult(opts) {
12
18
  const now = opts.now ?? Math.floor(Date.now() / 1e3);
13
19
  const definition = opts.definition ?? opts.registry?.getJobDefinition?.(opts.name);
@@ -221,31 +227,75 @@ export async function runDurableJobMessage(opts) {
221
227
  }
222
228
  const storedJob = claimed.job;
223
229
  const job = opts.toDispatchableJob(storedJob);
230
+ const reportedStats = {};
231
+ const reportStats = (s) => {
232
+ for (const k of ["rowsFetched", "rowsInserted", "d1RowsRead", "d1RowsWritten"]) {
233
+ if (typeof s[k] === "number")
234
+ reportedStats[k] = (reportedStats[k] ?? 0) + s[k];
235
+ }
236
+ };
237
+ const scope = opts.createJobScope?.(storedJob);
238
+ const startedMs = Date.now();
239
+ const settle = async (status, permanent, error) => {
240
+ await scope?.onSettled?.({ storedJob, status, durationMs: Date.now() - startedMs, permanent, error });
241
+ };
242
+ const continuations = async (...stages) => {
243
+ for (const stage of stages)
244
+ await opts.dispatchContinuations?.({ storedJob, stage });
245
+ };
224
246
  try {
225
- const dispatch = await dispatchRegisteredJob({
247
+ const runOnce = () => dispatchRegisteredJob({
226
248
  registry: opts.registry,
227
249
  job,
228
- createContext: (input) => opts.createJobContext({ ...input, storedJob })
250
+ createContext: async (input) => ({ ...await opts.createJobContext({ ...input, storedJob }), reportStats })
229
251
  });
252
+ const dispatch = await (scope?.wrapDispatch ? scope.wrapDispatch(runOnce) : runOnce());
230
253
  if (!dispatch.success) {
231
254
  if (opts.failDispatchFailure !== false)
232
255
  await failDurableJob(opts.lifecycle, storedJob, dispatch.error ? formatJobError(dispatch.error) : "Job dispatch failed");
256
+ await settle("dispatch-failed", true, dispatch.error);
257
+ await continuations("catch", "finally");
233
258
  opts.message.ack();
234
259
  return { status: "dispatch-failed", dispatch, error: dispatch.error };
235
260
  }
236
261
  if (dispatch.control?.handled) {
237
- opts.message.ack();
238
- return { status: dispatch.control.action === "failed" ? "failed" : "released", dispatch };
262
+ if (dispatch.control.action === "failed") {
263
+ await failDurableJob(opts.lifecycle, storedJob, dispatch.control.error ?? "Job failed via ctx.fail()");
264
+ await settle("failed", true, dispatch.control.error);
265
+ await continuations("catch", "finally");
266
+ opts.message.ack();
267
+ return { status: "failed", dispatch };
268
+ }
269
+ const delaySeconds = dispatch.control.delaySeconds ?? 0;
270
+ await releaseDurableJob(opts.lifecycle, storedJob, { delaySeconds, error: dispatch.control.error });
271
+ await settle("released", false, dispatch.control.error);
272
+ opts.message.retry({ delaySeconds });
273
+ return { status: "released", dispatch };
239
274
  }
240
- await completeDurableJob(opts.lifecycle, storedJob, await opts.completeResult?.({ job: storedJob, dispatch }));
275
+ const result = await opts.completeResult?.({ job: storedJob, dispatch });
276
+ const hasStats = Object.keys(reportedStats).length > 0;
277
+ const completeWith = hasStats ? { ...result && typeof result === "object" ? result : {}, ...reportedStats } : result;
278
+ await completeDurableJob(opts.lifecycle, storedJob, completeWith);
279
+ await settle("completed", false);
280
+ await continuations("then", "finally");
241
281
  opts.message.ack();
242
282
  return { status: "completed", dispatch };
243
283
  } catch (error) {
284
+ const maxAttempts = opts.maxAttemptsOf?.(storedJob);
285
+ const permanent = opts.isPermanentFailure ? opts.isPermanentFailure({ error, storedJob, attempts: job.attempts, maxAttempts }) : typeof maxAttempts === "number" && job.attempts >= maxAttempts;
286
+ if (permanent) {
287
+ await failDurableJob(opts.lifecycle, storedJob, describeCause(error));
288
+ await settle("exhausted", true, error);
289
+ await continuations("catch", "finally");
290
+ opts.message.ack();
291
+ return { status: "exhausted", error: jobErrors.handlerThrew(error) };
292
+ }
244
293
  const delaySeconds = typeof opts.retryDelaySeconds === "function" ? opts.retryDelaySeconds({ error, job: storedJob }) : opts.retryDelaySeconds ?? 0;
245
294
  await releaseDurableJob(opts.lifecycle, storedJob, {
246
295
  delaySeconds,
247
296
  error: describeCause(error)
248
297
  });
298
+ await settle("errored", false, error);
249
299
  opts.message.retry({ delaySeconds });
250
300
  return { status: "errored", error: jobErrors.handlerThrew(error) };
251
301
  }
@@ -0,0 +1,195 @@
1
+ import type { BatchProgress, DurableBatchStore, SettleBatchMemberOptions, SettleBatchMemberResult } from './batch.js';
2
+ import type { D1DatabaseLike, D1DurableJobRecord, D1DurableJobRepository } from './d1.js';
3
+ import type { JobMetricsSink } from './metrics.js';
4
+ import type { CreateJobBatchOptions, CreateJobBatchResult, DurableJobContinuationStage, DurableJobRecord, DurableJobScope, EnqueueDurableJobResult, PruneDurableJobsOptions, PruneDurableJobsResult, QueuePublisher, RunDurableJobMessageOptions, RunDurableJobMessageResult } from './outbox.js';
5
+ import type { DispatchableJob, JobDefinition, JobHandler, QueueBatch, QueueMessage } from './types.js';
6
+ export interface RunDurableJobBatchMessageOptions<StoredJob, Job extends DispatchableJob, Message extends {
7
+ jobId: string;
8
+ queue: string;
9
+ } = {
10
+ jobId: string;
11
+ queue: string;
12
+ }, Env = unknown, Db = unknown, Logger = unknown> extends RunDurableJobMessageOptions<StoredJob, Job, Message, Env, Db, Logger> {
13
+ store: DurableBatchStore;
14
+ /** Run the winning settle's `onFinish`. Defaults to nothing. */
15
+ dispatchOnFinish?: SettleBatchMemberOptions['dispatchOnFinish'];
16
+ /** Notified after each settle with the batch's progress (live UI / telemetry). */
17
+ onBatchProgress?: (progress: BatchProgress) => void | Promise<void>;
18
+ }
19
+ export interface RunDurableJobBatchMessageResult {
20
+ run: RunDurableJobMessageResult;
21
+ settled: SettleBatchMemberResult | null;
22
+ }
23
+ /**
24
+ * Run one queue message through the full durable lifecycle, then settle its
25
+ * batch. Only terminal outcomes (`completed` / `failed` / `dispatch-failed`)
26
+ * settle — a `released`/`errored` message will re-run, so settling it would
27
+ * double-count. The settle's progress is forwarded to `onBatchProgress`, always
28
+ * awaited so `onFinish` has fired before this resolves.
29
+ */
30
+ export declare function runDurableJobBatchMessage<StoredJob, Job extends DispatchableJob, Message extends {
31
+ jobId: string;
32
+ queue: string;
33
+ } = {
34
+ jobId: string;
35
+ queue: string;
36
+ }, Env = unknown, Db = unknown, Logger = unknown>(opts: RunDurableJobBatchMessageOptions<StoredJob, Job, Message, Env, Db, Logger>): Promise<RunDurableJobBatchMessageResult>;
37
+ export interface RunLightweightMessageOptions<Env = unknown, Db = unknown, Logger = unknown> {
38
+ message: Pick<QueueMessage, 'id' | 'body' | 'attempts' | 'ack' | 'retry'>;
39
+ registry: DurableJobsRuntimeRegistry<Env, Db, Logger>;
40
+ createJobContext: ConsumerContextFactory<Env, Db, Logger>;
41
+ /** Per-isolate dedup of at-least-once redeliveries. Return true to drop. */
42
+ isDuplicate?: (id: string | undefined) => boolean;
43
+ /** Diagnostic sink for dropped/invalid messages (no throw). */
44
+ onLog?: (event: {
45
+ stage: string;
46
+ taskName?: string;
47
+ error?: string;
48
+ }) => void;
49
+ }
50
+ export type RunLightweightMessageResult = {
51
+ status: 'invalid' | 'duplicate' | 'dispatch-failed' | 'released' | 'completed' | 'errored';
52
+ };
53
+ export declare function runLightweightMessage<Env, Db, Logger>(opts: RunLightweightMessageOptions<Env, Db, Logger>): Promise<RunLightweightMessageResult>;
54
+ export interface ConsumeQueueBatchOptions<Queue extends string, Env, Db, Logger> {
55
+ batch: QueueBatch;
56
+ repository: D1DurableJobRepository<Queue>;
57
+ store: DurableBatchStore;
58
+ registry: DurableJobsRuntimeRegistry<Env, Db, Logger>;
59
+ createJobContext: ConsumerContextFactory<Env, Db, Logger>;
60
+ dispatchOnFinish?: SettleBatchMemberOptions['dispatchOnFinish'];
61
+ onBatchProgress?: (progress: BatchProgress) => void | Promise<void>;
62
+ retryDelaySeconds?: RunDurableJobMessageOptions<unknown, DispatchableJob>['retryDelaySeconds'];
63
+ /** Defaults to a `-dlq` suffix check. */
64
+ isDlqQueue?: (queue: string) => boolean;
65
+ isDuplicate?: (id: string | undefined) => boolean;
66
+ onLog?: (event: {
67
+ stage: string;
68
+ queue?: string;
69
+ taskName?: string;
70
+ jobId?: string;
71
+ error?: string;
72
+ }) => void;
73
+ createJobScope?: (storedJob: D1DurableJobRecord<Queue>) => DurableJobScope<D1DurableJobRecord<Queue>>;
74
+ isPermanentFailure?: (input: {
75
+ error: unknown;
76
+ storedJob: D1DurableJobRecord<Queue>;
77
+ attempts: number;
78
+ maxAttempts: number | undefined;
79
+ }) => boolean;
80
+ dispatchContinuations?: (input: {
81
+ storedJob: D1DurableJobRecord<Queue>;
82
+ stage: DurableJobContinuationStage;
83
+ }) => void | Promise<void>;
84
+ /**
85
+ * Soft batch CPU budget (ms). Before each message, if the batch has run longer
86
+ * than this, the remaining messages are retried instead of processed — so a
87
+ * heavy batch can't blow the Worker CPU limit mid-message.
88
+ */
89
+ maxBatchCpuMs?: number;
90
+ /** Delay (s) for messages deferred by the CPU guard. Default 5. */
91
+ cpuGuardRetryDelaySeconds?: number;
92
+ }
93
+ /**
94
+ * Process one CF queue batch end-to-end — the loop both apps hand-roll:
95
+ * - a DLQ-queue batch settles each durable member (failed) so a CF-exhausted
96
+ * message can't hang its batch;
97
+ * - `{ jobId }` envelopes take the durable path (claim → run → settle + progress);
98
+ * - `{ _task }` payloads take the lightweight path.
99
+ */
100
+ export declare function consumeQueueBatch<Queue extends string, Env, Db, Logger>(opts: ConsumeQueueBatchOptions<Queue, Env, Db, Logger>): Promise<void>;
101
+ export interface DurableJobsRuntimeRegistry<Env = unknown, Db = unknown, Logger = unknown> {
102
+ /** May resolve async for lazily-loaded jobs (dispatchRegisteredJob awaits it). */
103
+ getHandler: (name: string) => JobHandler<unknown, Env, Db, Logger> | undefined | Promise<JobHandler<unknown, Env, Db, Logger> | undefined>;
104
+ getJobDefinition?: (name: string) => JobDefinition<string, unknown, string, Env, Db, Logger> | undefined;
105
+ getJobRoute?: (name: string) => {
106
+ queue: string;
107
+ jobType: string;
108
+ } | undefined;
109
+ }
110
+ type ConsumerMessage = RunDurableJobMessageOptions<unknown, DispatchableJob>['message'];
111
+ type ConsumerContextFactory<Env, Db, Logger> = RunDurableJobMessageOptions<unknown, DispatchableJob, {
112
+ jobId: string;
113
+ queue: string;
114
+ }, Env, Db, Logger>['createJobContext'];
115
+ export interface CreateDurableJobsRuntimeOptions<Queue extends string = string, Env = unknown, Db = unknown, Logger = unknown> {
116
+ /** Raw D1 binding. The repo + batch store are built from it. */
117
+ db: D1DatabaseLike;
118
+ /** Worker `env`, used to resolve queue bindings. */
119
+ env: Record<string, unknown>;
120
+ registry: DurableJobsRuntimeRegistry<Env, Db, Logger>;
121
+ /** Map a logical queue to its `env` binding name. */
122
+ resolveQueueBinding: (queue: Queue) => string | undefined;
123
+ /** Build the per-job context (db, log, control, …). */
124
+ createJobContext: ConsumerContextFactory<Env, Db, Logger>;
125
+ /** Laravel `retry_after` — reclaim a reservation older than this on claim. */
126
+ reclaimAfterSeconds?: number;
127
+ /** Telemetry sink; wired into the repo's lifecycle hooks automatically. */
128
+ metricsSink?: JobMetricsSink;
129
+ /** Notified after each batch settle. */
130
+ onBatchProgress?: (progress: BatchProgress) => void | Promise<void>;
131
+ /** Override how a batch's onFinish continuation runs (default: durable enqueue). */
132
+ dispatchOnFinish?: SettleBatchMemberOptions['dispatchOnFinish'];
133
+ /** Per-throw retry backoff for the consumer. */
134
+ retryDelaySeconds?: RunDurableJobMessageOptions<unknown, DispatchableJob>['retryDelaySeconds'];
135
+ onMissingBinding?: (queue: Queue, count: number) => void | Promise<void>;
136
+ /** Classify a queue as a dead-letter queue (defaults to a `-dlq` suffix check). */
137
+ isDlqQueue?: (queue: string) => boolean;
138
+ /**
139
+ * Per-isolate dedup set for the lightweight `_task` path. Pass a module-level
140
+ * Set so redeliveries dedup across invocations (durable jobs are claim-guarded,
141
+ * so they don't need this).
142
+ */
143
+ dedup?: Set<string>;
144
+ /** Diagnostic log sink for DLQ / dropped lightweight messages. */
145
+ onLog?: (event: {
146
+ stage: string;
147
+ queue?: string;
148
+ taskName?: string;
149
+ jobId?: string;
150
+ error?: string;
151
+ }) => void;
152
+ /** Per-job scope: wrap dispatch (e.g. telemetry) + observe each settlement. */
153
+ createJobScope?: (storedJob: D1DurableJobRecord<Queue>) => DurableJobScope<D1DurableJobRecord<Queue>>;
154
+ /** Decide whether a thrown error is terminal (default: attempts >= max). */
155
+ isPermanentFailure?: (input: {
156
+ error: unknown;
157
+ storedJob: D1DurableJobRecord<Queue>;
158
+ attempts: number;
159
+ maxAttempts: number | undefined;
160
+ }) => boolean;
161
+ /** Dispatch then/catch/finally payload continuations after a terminal outcome. */
162
+ dispatchContinuations?: (input: {
163
+ storedJob: D1DurableJobRecord<Queue>;
164
+ stage: DurableJobContinuationStage;
165
+ }) => void | Promise<void>;
166
+ /** Soft per-batch CPU budget (ms); remaining messages are deferred once exceeded. */
167
+ maxBatchCpuMs?: number;
168
+ /** Delay (s) for CPU-guard-deferred messages. Default 5. */
169
+ cpuGuardRetryDelaySeconds?: number;
170
+ }
171
+ export interface DurableJobsRuntime<Queue extends string = string> {
172
+ repository: D1DurableJobRepository<Queue>;
173
+ store: DurableBatchStore;
174
+ publisher: QueuePublisher<Queue>;
175
+ /** Durably enqueue a prepared record (persist row + dispatch message). */
176
+ enqueue: (record: DurableJobRecord<Queue>, opts?: {
177
+ delaySeconds?: number;
178
+ }) => Promise<EnqueueDurableJobResult>;
179
+ /** Register + dispatch a batch; `onFinish` fires once every member settles. */
180
+ createBatch: (opts: Omit<CreateJobBatchOptions<string, Record<string, unknown>, Queue>, 'store' | 'repository' | 'publisher'>) => Promise<CreateJobBatchResult>;
181
+ /** Run one durable queue message end-to-end (lifecycle + batch settle + progress). */
182
+ consumeMessage: (message: ConsumerMessage) => Promise<RunDurableJobBatchMessageResult>;
183
+ /** Process a whole CF queue batch (DLQ + durable + lightweight) — the consumer entrypoint. */
184
+ consumeBatch: (batch: QueueBatch) => Promise<void>;
185
+ /** Prune terminal rows (completed jobs / finished batches / failed jobs). */
186
+ prune: (opts: PruneDurableJobsOptions) => Promise<PruneDurableJobsResult>;
187
+ }
188
+ /**
189
+ * Bundle the durable-jobs verbs behind one factory so consumers stop
190
+ * hand-assembling repo + store + publisher + enqueue + batch + consumer loop.
191
+ * The metrics sink is wired into the repo here (no `metricsSinkToRepoHooks` at
192
+ * call sites), and `onFinish` continuations are enqueued durably by default.
193
+ */
194
+ export declare function createDurableJobsRuntime<Queue extends string = string, Env = unknown, Db = unknown, Logger = unknown>(opts: CreateDurableJobsRuntimeOptions<Queue, Env, Db, Logger>): DurableJobsRuntime<Queue>;
195
+ export {};
@@ -0,0 +1,218 @@
1
+ import { createD1DurableBatchStore, createJobBatch, settleBatchMember } from "./batch.js";
2
+ import { createD1DurableJobRepository } from "./d1.js";
3
+ import { dispatchRegisteredJob } from "./dispatch.js";
4
+ import { formatJobError } from "./errors.js";
5
+ import { metricsSinkToRepoHooks } from "./metrics.js";
6
+ import { createQueuePublisher, enqueueDurableJob, prepareDurableJob, pruneDurableJobs, runDurableJobMessage } from "./outbox.js";
7
+ import { resolveJobRetryDelay } from "./policy.js";
8
+ export async function runDurableJobBatchMessage(opts) {
9
+ const run = await runDurableJobMessage(opts);
10
+ const terminal = run.status === "completed" || run.status === "failed" || run.status === "dispatch-failed" || run.status === "exhausted";
11
+ if (!terminal)
12
+ return { run, settled: null };
13
+ const jobId = opts.getJobId?.(opts.message.body) ?? opts.message.body.jobId;
14
+ if (!jobId)
15
+ return { run, settled: null };
16
+ const settled = await settleBatchMember({
17
+ store: opts.store,
18
+ jobId,
19
+ failed: run.status !== "completed",
20
+ dispatchOnFinish: opts.dispatchOnFinish
21
+ });
22
+ if (settled.progress && opts.onBatchProgress)
23
+ await opts.onBatchProgress(settled.progress);
24
+ return { run, settled };
25
+ }
26
+ export async function runLightweightMessage(opts) {
27
+ const { message, registry, createJobContext } = opts;
28
+ const body = message.body ?? {};
29
+ const taskName = typeof body._task === "string" ? body._task : "";
30
+ const definition = taskName ? registry.getJobDefinition?.(taskName) : void 0;
31
+ if (!definition) {
32
+ opts.onLog?.({ stage: "invalid_payload", taskName, error: taskName ? `No handler for task: ${taskName}` : "No _task in payload" });
33
+ message.ack();
34
+ return { status: "invalid" };
35
+ }
36
+ if (opts.isDuplicate?.(message.id)) {
37
+ message.ack();
38
+ return { status: "duplicate" };
39
+ }
40
+ const job = {
41
+ id: `${taskName}:${typeof body.jobId === "string" ? body.jobId : crypto.randomUUID()}`,
42
+ queue: definition.queue ?? "",
43
+ payload: body,
44
+ attempts: message.attempts,
45
+ batchId: null,
46
+ siteId: typeof body.siteId === "string" ? body.siteId : null,
47
+ userId: typeof body.userId === "number" ? body.userId : null
48
+ };
49
+ try {
50
+ const dispatch = await dispatchRegisteredJob({
51
+ registry,
52
+ job,
53
+ createContext: ({ control }) => createJobContext({ job, storedJob: job, taskName, payload: job.payload, control })
54
+ });
55
+ if (!dispatch.success) {
56
+ opts.onLog?.({ stage: "invalid_payload", taskName, error: dispatch.error ? formatJobError(dispatch.error) : "invalid payload" });
57
+ message.ack();
58
+ return { status: "dispatch-failed" };
59
+ }
60
+ if (dispatch.control?.action === "released") {
61
+ message.retry({ delaySeconds: dispatch.control.delaySeconds ?? 0 });
62
+ return { status: "released" };
63
+ }
64
+ message.ack();
65
+ return { status: "completed" };
66
+ } catch (error) {
67
+ const delaySeconds = resolveJobRetryDelay(definition, message.attempts);
68
+ opts.onLog?.({ stage: "unexpected", taskName, error: error instanceof Error ? error.message : String(error) });
69
+ message.retry({ delaySeconds });
70
+ return { status: "errored" };
71
+ }
72
+ }
73
+ function defaultIsDlqQueue(queue) {
74
+ return queue.includes("-dlq");
75
+ }
76
+ export async function consumeQueueBatch(opts) {
77
+ const isDlq = opts.isDlqQueue ?? defaultIsDlqQueue;
78
+ if (isDlq(opts.batch.queue)) {
79
+ for (const message of opts.batch.messages) {
80
+ const body = message.body ?? {};
81
+ const jobId = typeof body.jobId === "string" ? body.jobId : void 0;
82
+ opts.onLog?.({ stage: "dlq", queue: opts.batch.queue, taskName: typeof body._task === "string" ? body._task : void 0, jobId });
83
+ if (jobId) {
84
+ const claimed = await opts.repository.claimJob(jobId).catch(() => null);
85
+ if (claimed) {
86
+ await opts.repository.failJob(claimed, "Exhausted retries (dead-letter queue)").catch(() => {
87
+ });
88
+ await settleBatchMember({ store: opts.store, jobId, failed: true, dispatchOnFinish: opts.dispatchOnFinish }).then((r) => r.progress && opts.onBatchProgress ? opts.onBatchProgress(r.progress) : void 0).catch(() => {
89
+ });
90
+ }
91
+ }
92
+ message.ack();
93
+ }
94
+ return;
95
+ }
96
+ const batchStartedMs = Date.now();
97
+ const cpuBudget = opts.maxBatchCpuMs;
98
+ for (const message of opts.batch.messages) {
99
+ if (cpuBudget != null && Date.now() - batchStartedMs > cpuBudget) {
100
+ message.retry({ delaySeconds: opts.cpuGuardRetryDelaySeconds ?? 5 });
101
+ continue;
102
+ }
103
+ const body = message.body ?? {};
104
+ const jobId = typeof body.jobId === "string" ? body.jobId : void 0;
105
+ if (jobId) {
106
+ await runDurableJobBatchMessage({
107
+ message,
108
+ lifecycle: opts.repository,
109
+ registry: opts.registry,
110
+ store: opts.store,
111
+ toDispatchableJob: opts.repository.toDispatchableJob,
112
+ createJobContext: opts.createJobContext,
113
+ retryDelaySeconds: opts.retryDelaySeconds,
114
+ // Honour the stored job's attempt cap (Laravel worker model).
115
+ maxAttemptsOf: (stored) => stored.max_attempts,
116
+ createJobScope: opts.createJobScope,
117
+ isPermanentFailure: opts.isPermanentFailure,
118
+ dispatchContinuations: opts.dispatchContinuations,
119
+ dispatchOnFinish: opts.dispatchOnFinish,
120
+ onBatchProgress: opts.onBatchProgress
121
+ });
122
+ } else {
123
+ await runLightweightMessage({
124
+ message,
125
+ registry: opts.registry,
126
+ createJobContext: opts.createJobContext,
127
+ isDuplicate: opts.isDuplicate,
128
+ onLog: opts.onLog
129
+ });
130
+ }
131
+ }
132
+ }
133
+ const DEFAULT_DEDUP_CAPACITY = 1024;
134
+ function makeIsDuplicate(seen) {
135
+ if (!seen)
136
+ return void 0;
137
+ return (id) => {
138
+ if (!id)
139
+ return false;
140
+ if (seen.has(id))
141
+ return true;
142
+ seen.add(id);
143
+ if (seen.size > DEFAULT_DEDUP_CAPACITY) {
144
+ const first = seen.values().next().value;
145
+ if (first !== void 0)
146
+ seen.delete(first);
147
+ }
148
+ return false;
149
+ };
150
+ }
151
+ export function createDurableJobsRuntime(opts) {
152
+ const repository = createD1DurableJobRepository(opts.db, {
153
+ reclaimAfterSeconds: opts.reclaimAfterSeconds,
154
+ ...opts.metricsSink ? metricsSinkToRepoHooks(opts.metricsSink) : {}
155
+ });
156
+ const store = createD1DurableBatchStore(opts.db);
157
+ const publisher = createQueuePublisher(
158
+ opts.env,
159
+ opts.resolveQueueBinding,
160
+ { onMissingBinding: opts.onMissingBinding }
161
+ );
162
+ const dispatchOnFinish = opts.dispatchOnFinish ?? (async ({ continuation, batch }) => {
163
+ const c = continuation;
164
+ try {
165
+ const record = await prepareDurableJob({ name: c.name, payload: c.payload, registry: opts.registry });
166
+ await enqueueDurableJob(repository, publisher, record);
167
+ } catch (error) {
168
+ opts.onLog?.({ stage: "onfinish-failed", taskName: c.name, jobId: batch.id, error: error instanceof Error ? error.message : String(error) });
169
+ }
170
+ });
171
+ const isDuplicate = makeIsDuplicate(opts.dedup);
172
+ return {
173
+ repository,
174
+ store,
175
+ publisher,
176
+ enqueue: (record, enqueueOpts) => enqueueDurableJob(repository, publisher, record, enqueueOpts),
177
+ createBatch: (batchOpts) => createJobBatch({
178
+ store,
179
+ repository,
180
+ publisher,
181
+ ...batchOpts
182
+ }),
183
+ consumeMessage: (message) => runDurableJobBatchMessage({
184
+ message,
185
+ lifecycle: repository,
186
+ registry: opts.registry,
187
+ store,
188
+ toDispatchableJob: repository.toDispatchableJob,
189
+ createJobContext: opts.createJobContext,
190
+ retryDelaySeconds: opts.retryDelaySeconds,
191
+ maxAttemptsOf: (stored) => stored.max_attempts,
192
+ createJobScope: opts.createJobScope,
193
+ isPermanentFailure: opts.isPermanentFailure,
194
+ dispatchContinuations: opts.dispatchContinuations,
195
+ dispatchOnFinish,
196
+ onBatchProgress: opts.onBatchProgress
197
+ }),
198
+ consumeBatch: (batch) => consumeQueueBatch({
199
+ batch,
200
+ repository,
201
+ store,
202
+ registry: opts.registry,
203
+ createJobContext: opts.createJobContext,
204
+ dispatchOnFinish,
205
+ onBatchProgress: opts.onBatchProgress,
206
+ retryDelaySeconds: opts.retryDelaySeconds,
207
+ isDlqQueue: opts.isDlqQueue,
208
+ isDuplicate,
209
+ onLog: opts.onLog,
210
+ createJobScope: opts.createJobScope,
211
+ isPermanentFailure: opts.isPermanentFailure,
212
+ dispatchContinuations: opts.dispatchContinuations,
213
+ maxBatchCpuMs: opts.maxBatchCpuMs,
214
+ cpuGuardRetryDelaySeconds: opts.cpuGuardRetryDelaySeconds
215
+ }),
216
+ prune: (pruneOpts) => pruneDurableJobs(repository, pruneOpts)
217
+ };
218
+ }
@@ -55,6 +55,7 @@ export const cfJobs = sqliteTable("jobs", {
55
55
  index("idx_jobs_batch").on(t.batchId),
56
56
  index("idx_jobs_trace").on(t.traceId),
57
57
  index("idx_jobs_sync_dedup").on(t.siteId, t.jobType),
58
+ index("idx_jobs_completed_at").on(t.completedAt).where(sql`completed_at IS NOT NULL`),
58
59
  uniqueIndex("idx_jobs_unique_active").on(t.uniqueKey).where(sql`unique_key IS NOT NULL AND completed_at IS NULL AND failed_at IS NULL`)
59
60
  ]);
60
61
  export const cfFailedJobs = sqliteTable("failed_jobs", {
@@ -60,6 +60,17 @@ export interface JobControlResult {
60
60
  delaySeconds?: number;
61
61
  error?: string;
62
62
  }
63
+ /**
64
+ * Per-run execution stats a handler can report (rows touched, D1 reads/writes).
65
+ * Reported via {@link JobContext.reportStats}; the consumer persists them to the
66
+ * job row + forwards them to the metrics sink. Multiple calls accumulate (sum).
67
+ */
68
+ export interface JobRunStats {
69
+ rowsFetched?: number;
70
+ rowsInserted?: number;
71
+ d1RowsRead?: number;
72
+ d1RowsWritten?: number;
73
+ }
63
74
  export interface JobContext<Env, Db, Logger> {
64
75
  env: Env;
65
76
  jobId: string;
@@ -69,6 +80,12 @@ export interface JobContext<Env, Db, Logger> {
69
80
  log: Logger;
70
81
  release: (delaySeconds: number) => Promise<void>;
71
82
  fail: (error: string) => Promise<void>;
83
+ /**
84
+ * Report execution stats (rows fetched/inserted, D1 reads/writes) for metrics +
85
+ * observability. Injected by the durable consumer; optional so plain handlers
86
+ * and hand-built contexts need not provide it.
87
+ */
88
+ reportStats?: (stats: JobRunStats) => void;
72
89
  }
73
90
  export type JobHandler<Payload, Env, Db, Logger> = (payload: Payload, ctx: JobContext<Env, Db, Logger>) => Promise<void>;
74
91
  export type JobFailedHandler<Payload, Env, Db, Logger> = (payload: Payload, ctx: JobContext<Env, Db, Logger>, error: unknown) => Promise<void>;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "nuxt-cf-jobs",
3
3
  "type": "module",
4
- "version": "0.5.2",
4
+ "version": "0.6.1",
5
5
  "description": "Nuxt module for typed Cloudflare queue jobs.",
6
6
  "author": {
7
7
  "name": "Harlan Wilton",
@@ -23,6 +23,11 @@
23
23
  "import": "./dist/runtime/server/index.js",
24
24
  "default": "./dist/runtime/server/index.js"
25
25
  },
26
+ "./cloudflare": {
27
+ "types": "./dist/runtime/server/cloudflare.d.ts",
28
+ "import": "./dist/runtime/server/cloudflare.js",
29
+ "default": "./dist/runtime/server/cloudflare.js"
30
+ },
26
31
  "./d1": {
27
32
  "types": "./dist/runtime/server/d1.d.ts",
28
33
  "import": "./dist/runtime/server/d1.js",
@@ -46,6 +51,9 @@
46
51
  "server": [
47
52
  "dist/runtime/server/index.d.ts"
48
53
  ],
54
+ "cloudflare": [
55
+ "dist/runtime/server/cloudflare.d.ts"
56
+ ],
49
57
  "d1": [
50
58
  "dist/runtime/server/d1.d.ts"
51
59
  ],