@makerkit/workers-outbox 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.
@@ -0,0 +1,6 @@
1
+ import { SQL } from "drizzle-orm";
2
+ /** Drivers disagree on the result shape, so read through `queryRows`. */
3
+ interface SqlRunner {
4
+ execute(query: SQL): PromiseLike<unknown>;
5
+ }
6
+ export { SqlRunner as t };
@@ -0,0 +1,46 @@
1
+ import { sql } from "drizzle-orm";
2
+ // src/drizzle/catalog.ts
3
+ async function queryRows(db, query) {
4
+ const result = await db.execute(query);
5
+ if (Array.isArray(result)) return result;
6
+ return result.rows;
7
+ }
8
+ async function readOutboxCatalog(db) {
9
+ const columns = await queryRows(db, sql`SELECT c.relname AS "table", a.attname AS "column",
10
+ format_type(a.atttypid, a.atttypmod) AS "type",
11
+ a.attnotnull AS "not_null",
12
+ pg_get_expr(d.adbin, d.adrelid) AS "default"
13
+ FROM pg_attribute a
14
+ JOIN pg_class c ON c.oid = a.attrelid
15
+ JOIN pg_namespace n ON n.oid = c.relnamespace
16
+ LEFT JOIN pg_attrdef d ON d.adrelid = a.attrelid AND d.adnum = a.attnum
17
+ WHERE n.nspname = 'outbox' AND c.relkind = 'r' AND a.attnum > 0 AND NOT a.attisdropped
18
+ ORDER BY 1, 2`);
19
+ const constraints = await queryRows(db, sql`SELECT c.relname AS "table", con.conname AS "name", con.contype AS "type",
20
+ pg_get_constraintdef(con.oid) AS "definition"
21
+ FROM pg_constraint con
22
+ JOIN pg_class c ON c.oid = con.conrelid
23
+ JOIN pg_namespace n ON n.oid = c.relnamespace
24
+ WHERE n.nspname = 'outbox' AND con.contype IN ('p', 'u', 'f', 'c')
25
+ ORDER BY 1, 2`);
26
+ const indexes = await queryRows(db, sql`SELECT tc.relname AS "table", ic.relname AS "name",
27
+ pg_get_indexdef(ic.oid) AS "definition", i.indisvalid AS "valid"
28
+ FROM pg_index i
29
+ JOIN pg_class ic ON ic.oid = i.indexrelid
30
+ JOIN pg_class tc ON tc.oid = i.indrelid
31
+ JOIN pg_namespace n ON n.oid = tc.relnamespace
32
+ WHERE n.nspname = 'outbox'
33
+ ORDER BY 1, 2`);
34
+ return {
35
+ columns: columns.map((row) => ({
36
+ table: row.table,
37
+ column: row.column,
38
+ type: row.type,
39
+ notNull: row.not_null,
40
+ default: row.default
41
+ })),
42
+ constraints,
43
+ indexes
44
+ };
45
+ }
46
+ export { readOutboxCatalog as n, queryRows as t };
@@ -0,0 +1,117 @@
1
+ import { H as AnyJob, _ as Budget, c as RUNTIME, h as EventSink, i as EnqueueRequest, o as Outbox, u as ScopedEnqueue } from "./create-outbox-C4vdn6Dr.mjs";
2
+ import { r as OutboxDb } from "./types-ClomUJ_8.mjs";
3
+ interface QueueMessage {
4
+ readonly body: unknown;
5
+ readonly attempts: number;
6
+ ack(): void;
7
+ retry(options?: {
8
+ delaySeconds?: number;
9
+ }): void;
10
+ }
11
+ interface QueueBatch {
12
+ readonly queue: string;
13
+ readonly messages: readonly QueueMessage[];
14
+ }
15
+ interface QueueHandlerOptions {
16
+ /**
17
+ * Wall time shared by every message in one batch. Default 840 s (14 min); at most 870 s,
18
+ * leaving headroom under Cloudflare's 15-minute consumer limit.
19
+ */
20
+ invocationBudgetMs?: number;
21
+ /** Default: 100 jobs within the invocation budget. Its deadline must hold the longest legal lease (635 s). */
22
+ nudgeBudget?: Budget;
23
+ /** Default: 50 jobs within 45 s. */
24
+ drainBudget?: Budget;
25
+ /** Default `'default'`; only this lane's kinds are claimed. */
26
+ lane?: string;
27
+ }
28
+ export declare const messageRetryDelaySeconds: (attempts: number) => number;
29
+ /** A handler failure is a job outcome; only an infrastructure fault retries the message. */
30
+ export declare function createQueueHandler<Env, Ctx = unknown>(outboxFor: (env: Env, ctx: Ctx) => Outbox, options?: QueueHandlerOptions): (batch: QueueBatch, env: Env, ctx: Ctx) => Promise<void>;
31
+ /** One firing of a schedule's cron. */
32
+ interface ScheduleOccurrence {
33
+ /**
34
+ * The time this tick was scheduled for (`event.scheduledTime`), not when it ran: a repeated
35
+ * delivery of the same tick has the same value, so keys derived from it dedupe.
36
+ */
37
+ readonly scheduledAt: Date;
38
+ readonly cron: string;
39
+ }
40
+ /** Enqueues one job per occurrence. */
41
+ interface JobSchedule<T extends AnyJob = AnyJob> {
42
+ /** Must match one of the Worker's `triggers.crons` exactly, as Cloudflare passes it back. */
43
+ readonly cron: string;
44
+ /** Names the schedule in events and in the default dedupe key. Default: the job's kind. */
45
+ readonly name?: string;
46
+ readonly job: T;
47
+ readonly payload: (occurrence: ScheduleOccurrence) => EnqueueRequest<T>['payload'];
48
+ /** Default `schedule:<name>:<scheduledAt as ISO>`, so a repeated tick finds its job. */
49
+ readonly dedupeKey?: (occurrence: ScheduleOccurrence) => string;
50
+ readonly tenantKey?: string | null;
51
+ }
52
+ /** Enqueues any number of jobs per occurrence, in one transaction (e.g. one per tenant). */
53
+ interface RunSchedule {
54
+ readonly cron: string;
55
+ readonly name: string;
56
+ /**
57
+ * Runs in a transaction on the outbox's connection, so reading app tables needs grants for the
58
+ * outbox role. Derive every dedupe key from `scheduledAt`, so a repeated tick enqueues nothing new.
59
+ */
60
+ readonly run: (occurrence: ScheduleOccurrence & {
61
+ readonly tx: OutboxDb;
62
+ readonly enqueue: ScopedEnqueue<readonly AnyJob[]>;
63
+ }) => Promise<void>;
64
+ }
65
+ type Schedule = JobSchedule<any> | RunSchedule;
66
+ /** Type-checks a schedule's payload against its job. A plain object works too, untyped. */
67
+ export declare function defineSchedule<T extends AnyJob>(schedule: JobSchedule<T>): JobSchedule<T>;
68
+ export declare function defineSchedule(schedule: RunSchedule): RunSchedule;
69
+ interface ScheduledHandlerOptions {
70
+ /**
71
+ * Wall time shared by every phase of one tick. Default 840 s; at most 870 s, under
72
+ * Cloudflare's 15-minute limit for a scheduled invocation.
73
+ */
74
+ invocationBudgetMs?: number;
75
+ /** Default: 50 jobs within 180 s. */
76
+ budget?: Budget;
77
+ /** Default 50. */
78
+ recoveryLimit?: number;
79
+ /**
80
+ * Recovery's share of the tick, so a crash backlog can't starve fresh work. Default 120 s.
81
+ * What doesn't fit stays expired for the next tick. Counted from when recovery starts, and held
82
+ * back from `schedules` (up to half the invocation), so schedules can't consume it.
83
+ */
84
+ recoveryBudgetMs?: number;
85
+ /** Drained before the catch-all so other kinds can't starve these; keep each ≤ 45 s. */
86
+ reserved?: readonly {
87
+ name: string;
88
+ kinds: readonly string[];
89
+ budget: Budget;
90
+ }[];
91
+ backlog?: Parameters<Outbox[typeof RUNTIME]['monitorBacklog']>[0];
92
+ retention?: {
93
+ succeededRetentionMs?: number;
94
+ terminalRetentionMs?: number;
95
+ batch?: number;
96
+ maxBatches?: number;
97
+ };
98
+ /**
99
+ * Recurring jobs. On a tick whose cron matches a schedule's, its jobs are enqueued (keyed by the
100
+ * tick's scheduled time, so a repeated tick enqueues nothing new) before the sweep runs. A tick
101
+ * that never fires is skipped, not caught up.
102
+ */
103
+ schedules?: readonly Schedule[];
104
+ }
105
+ export declare const DEFAULT_SWEEP_BUDGET: Budget;
106
+ interface ScheduledEventLike {
107
+ readonly cron?: string;
108
+ /** Milliseconds since the epoch; Cloudflare sets it. Default: when the tick runs. */
109
+ readonly scheduledTime?: number;
110
+ }
111
+ /** The durable backstop: each phase is isolated, and the handler always resolves. */
112
+ export declare function createScheduledHandler<Env, Ctx = unknown>(outboxFor: (env: Env, ctx: Ctx) => Outbox, options?: ScheduledHandlerOptions): (event: ScheduledEventLike, env: Env, ctx: Ctx) => Promise<void>;
113
+ /** A dead-lettered nudge loses nothing (cron still drives the job): report and ack. */
114
+ export declare function createDlqHandler(options?: {
115
+ events?: EventSink;
116
+ }): (batch: QueueBatch) => Promise<void>;
117
+ export type { JobSchedule, QueueBatch, QueueHandlerOptions, QueueMessage, RunSchedule, Schedule, ScheduleOccurrence, ScheduledEventLike, ScheduledHandlerOptions };
@@ -0,0 +1,300 @@
1
+ import { a as emptyReport, c as createEmitter, l as MAX_LEASE_MS, n as RUNTIME, o as serializeError, s as isTransientDbError } from "./create-outbox-BTMmdvLs.mjs";
2
+ import "./backlog-Bd62tjCT.mjs";
3
+ // src/cloudflare/limits.ts
4
+ /** The most an invocation budget may claim of the platform window. */
5
+ const MAX_INVOCATION_BUDGET_MS = 87e4;
6
+ // src/cloudflare/message-codec.ts
7
+ const MAX_BODY_PREVIEW = 500;
8
+ function decodeMessage(body) {
9
+ if (typeof body !== "object" || body === null) return reject("malformed", body);
10
+ const candidate = body;
11
+ if (candidate["v"] !== 1) return reject("unknown_version", body);
12
+ if (candidate["type"] === "nudge") {
13
+ const ids = candidate["ids"];
14
+ if (Array.isArray(ids) && ids.length > 0 && ids.every((id) => typeof id === "string")) return {
15
+ ok: true,
16
+ message: {
17
+ v: 1,
18
+ type: "nudge",
19
+ ids
20
+ }
21
+ };
22
+ return reject("malformed", body);
23
+ }
24
+ if (candidate["type"] === "drain" && typeof candidate["lane"] === "string") return {
25
+ ok: true,
26
+ message: {
27
+ v: 1,
28
+ type: "drain",
29
+ lane: candidate["lane"]
30
+ }
31
+ };
32
+ return reject("malformed", body);
33
+ }
34
+ function reject(reason, body) {
35
+ return {
36
+ ok: false,
37
+ reason,
38
+ body: previewBody(body)
39
+ };
40
+ }
41
+ /** A message body as bounded text for an event: its JSON form, or its string form if it has none. */
42
+ function previewBody(body) {
43
+ let text;
44
+ try {
45
+ text = JSON.stringify(body) ?? String(body);
46
+ } catch {
47
+ text = String(body);
48
+ }
49
+ return text.slice(0, MAX_BODY_PREVIEW);
50
+ }
51
+ // src/cloudflare/queue-handler.ts
52
+ const DEFAULT_DRAIN_BUDGET = {
53
+ maxJobs: 50,
54
+ deadlineMs: 45e3
55
+ };
56
+ const messageRetryDelaySeconds = (attempts) => Math.min(300, 5 * 2 ** Math.max(0, attempts - 1));
57
+ /** A handler failure is a job outcome; only an infrastructure fault retries the message. */
58
+ function createQueueHandler(outboxFor, options = {}) {
59
+ const invocationBudgetMs = options.invocationBudgetMs ?? 84e4;
60
+ if (!Number.isInteger(invocationBudgetMs) || invocationBudgetMs < 635e3 || invocationBudgetMs > 87e4) throw new Error(`createQueueHandler: invocationBudgetMs must be an integer in [${MAX_LEASE_MS}, ${MAX_INVOCATION_BUDGET_MS}], got ${invocationBudgetMs}`);
61
+ const nudgeBudget = options.nudgeBudget ?? {
62
+ maxJobs: 100,
63
+ deadlineMs: invocationBudgetMs
64
+ };
65
+ const drainBudget = options.drainBudget ?? DEFAULT_DRAIN_BUDGET;
66
+ const lane = options.lane ?? "default";
67
+ if (nudgeBudget.deadlineMs < 635e3 || nudgeBudget.deadlineMs > invocationBudgetMs) throw new Error(`createQueueHandler: nudgeBudget.deadlineMs must be in [${MAX_LEASE_MS}, ${invocationBudgetMs}] (the longest legal lease, the invocation budget), got ${nudgeBudget.deadlineMs}`);
68
+ if (drainBudget.deadlineMs > invocationBudgetMs) throw new Error(`createQueueHandler: drainBudget.deadlineMs must not exceed invocationBudgetMs (${invocationBudgetMs}), got ${drainBudget.deadlineMs}`);
69
+ return async (batch, env, ctx) => {
70
+ const runtime = outboxFor(env, ctx)[RUNTIME];
71
+ const started = runtime.nowMs();
72
+ const deadlineAt = started + invocationBudgetMs;
73
+ const within = (budget) => ({
74
+ ...budget,
75
+ deadlineMs: Math.max(0, Math.min(budget.deadlineMs, invocationBudgetMs - (runtime.nowMs() - started)))
76
+ });
77
+ for (const message of batch.messages) {
78
+ const decoded = decodeMessage(message.body);
79
+ if (!decoded.ok) {
80
+ runtime.emit({
81
+ type: "message_rejected",
82
+ reason: decoded.reason,
83
+ body: decoded.body
84
+ });
85
+ message.ack();
86
+ continue;
87
+ }
88
+ const { message: body } = decoded;
89
+ runtime.emit({
90
+ type: "message_received",
91
+ messageType: body.type,
92
+ ids: body.type === "nudge" ? body.ids : [],
93
+ attempts: message.attempts
94
+ });
95
+ try {
96
+ if (body.type === "nudge") await runtime.processNudge(body.ids, within(nudgeBudget), { lane }, { deadlineAt });
97
+ else {
98
+ const budget = within(drainBudget);
99
+ const report = await runtime.drain(budget, "drain", { lane }, { deadlineAt });
100
+ const progressed = report.claimed > report.errored;
101
+ const starved = budget.deadlineMs < drainBudget.deadlineMs && report.reachedDeadline;
102
+ if ((progressed || starved) && await runtime.hasBacklog({ lane }, { deadlineAt })) await runtime.continueDrain(lane, { deadlineAt });
103
+ }
104
+ message.ack();
105
+ } catch (error) {
106
+ const delaySeconds = messageRetryDelaySeconds(message.attempts);
107
+ runtime.emit({
108
+ type: "message_retried",
109
+ messageType: body.type,
110
+ delaySeconds,
111
+ attempts: message.attempts,
112
+ error: serializeError(error)
113
+ });
114
+ message.retry({ delaySeconds });
115
+ }
116
+ }
117
+ };
118
+ }
119
+ // src/cloudflare/schedules.ts
120
+ function defineSchedule(schedule) {
121
+ return schedule;
122
+ }
123
+ const nameOf = (schedule) => "run" in schedule ? schedule.name : schedule.name ?? schedule.job.kind;
124
+ /** Rejects what can't work, at handler creation rather than on the first tick. */
125
+ function validateSchedules(schedules) {
126
+ const names = /* @__PURE__ */ new Set();
127
+ for (const schedule of schedules) {
128
+ const name = nameOf(schedule);
129
+ if (typeof schedule.cron !== "string" || schedule.cron.trim() === "") throw new Error(`createScheduledHandler: schedule ${JSON.stringify(name)} needs a cron`);
130
+ if ("run" in schedule === "job" in schedule) throw new Error(`createScheduledHandler: schedule ${JSON.stringify(name)} needs either job + payload, or run`);
131
+ if (!name) throw new Error("createScheduledHandler: a run schedule needs a name");
132
+ if (names.has(name)) throw new Error(`createScheduledHandler: two schedules are named ${JSON.stringify(name)}`);
133
+ names.add(name);
134
+ }
135
+ }
136
+ /** Waits between attempts; a transient failure retries within the tick, as dedupe makes it safe. */
137
+ const RETRY_DELAYS_MS = [1e3, 4e3];
138
+ /** Runs every schedule whose cron is this tick's, each in its own transaction. Never throws. */
139
+ async function runSchedules(outbox, schedules, cron, scheduledAtMs, deadlineAt) {
140
+ const runtime = outbox[RUNTIME];
141
+ const scheduledAt = new Date(scheduledAtMs);
142
+ const due = schedules.filter((schedule) => schedule.cron === cron);
143
+ for (const schedule of due) {
144
+ const name = nameOf(schedule);
145
+ const occurrence = {
146
+ scheduledAt,
147
+ cron: schedule.cron
148
+ };
149
+ const tally = {
150
+ created: 0,
151
+ existing: 0,
152
+ rejected: 0
153
+ };
154
+ for (let attempt = 1;; attempt++) {
155
+ tally.created = tally.existing = tally.rejected = 0;
156
+ try {
157
+ const { pending } = await runtime.withConnection((connection) => runtime.collectTransaction(connection.db, async (tx, enqueue) => {
158
+ const counted = async (job, request) => {
159
+ const result = await enqueue(job, request);
160
+ count(tally, result);
161
+ return result;
162
+ };
163
+ if ("run" in schedule) {
164
+ await schedule.run({
165
+ ...occurrence,
166
+ tx,
167
+ enqueue: counted
168
+ });
169
+ return;
170
+ }
171
+ const result = await counted(schedule.job, {
172
+ payload: schedule.payload(occurrence),
173
+ dedupeKey: schedule.dedupeKey?.(occurrence) ?? `schedule:${name}:${scheduledAt.toISOString()}`,
174
+ ...schedule.tenantKey === void 0 ? {} : { tenantKey: schedule.tenantKey }
175
+ });
176
+ if (result.status !== "created" && result.status !== "exists") throw new Error(`schedule ${name}: enqueue returned ${result.status}`);
177
+ }), deadlineAt);
178
+ runtime.emit({
179
+ type: "schedule_ran",
180
+ name,
181
+ cron: schedule.cron,
182
+ scheduledAt: scheduledAt.toISOString(),
183
+ ...tally,
184
+ attempts: attempt
185
+ });
186
+ await runtime.nudgePending(pending, deadlineAt);
187
+ break;
188
+ } catch (error) {
189
+ const delay = RETRY_DELAYS_MS[attempt - 1];
190
+ if (isTransientDbError(error) && delay !== void 0 && runtime.nowMs() + delay < deadlineAt) {
191
+ await runtime.sleep(delay);
192
+ continue;
193
+ }
194
+ runtime.emit({
195
+ type: "schedule_failed",
196
+ severity: "error",
197
+ name,
198
+ cron: schedule.cron,
199
+ scheduledAt: scheduledAt.toISOString(),
200
+ attempts: attempt,
201
+ error: serializeError(error)
202
+ });
203
+ break;
204
+ }
205
+ }
206
+ }
207
+ }
208
+ function count(tally, result) {
209
+ if (result.status === "created") tally.created++;
210
+ else if (result.status === "exists") tally.existing++;
211
+ else tally.rejected++;
212
+ }
213
+ // src/cloudflare/scheduled-handler.ts
214
+ const DEFAULT_SWEEP_BUDGET = {
215
+ maxJobs: 50,
216
+ deadlineMs: 18e4
217
+ };
218
+ /** The durable backstop: each phase is isolated, and the handler always resolves. */
219
+ function createScheduledHandler(outboxFor, options = {}) {
220
+ const budget = options.budget ?? DEFAULT_SWEEP_BUDGET;
221
+ const recoveryLimit = options.recoveryLimit ?? 50;
222
+ const recoveryBudgetMs = options.recoveryBudgetMs ?? 12e4;
223
+ const invocationBudgetMs = options.invocationBudgetMs ?? 84e4;
224
+ const schedules = options.schedules ?? [];
225
+ validateSchedules(schedules);
226
+ if (!Number.isInteger(invocationBudgetMs) || invocationBudgetMs < 1 || invocationBudgetMs > 87e4) throw new Error(`createScheduledHandler: invocationBudgetMs must be an integer in [1, ${MAX_INVOCATION_BUDGET_MS}], got ${invocationBudgetMs}`);
227
+ if (!Number.isInteger(recoveryBudgetMs) || recoveryBudgetMs < 1) throw new Error(`createScheduledHandler: recoveryBudgetMs must be a positive integer, got ${recoveryBudgetMs}`);
228
+ const recoveryReserveMs = Math.min(recoveryBudgetMs, Math.floor(invocationBudgetMs / 2));
229
+ const retention = {
230
+ succeededRetentionMs: options.retention?.succeededRetentionMs ?? 6048e5,
231
+ terminalRetentionMs: options.retention?.terminalRetentionMs ?? 7776e6,
232
+ batch: options.retention?.batch ?? 1e3,
233
+ maxBatches: options.retention?.maxBatches ?? 10
234
+ };
235
+ return async (event, env, ctx) => {
236
+ const outbox = outboxFor(env, ctx);
237
+ const runtime = outbox[RUNTIME];
238
+ const cron = event.cron ?? null;
239
+ const phase = async (name, work) => {
240
+ try {
241
+ return await work();
242
+ } catch (error) {
243
+ runtime.emit({
244
+ type: "phase_failed",
245
+ phase: name,
246
+ cron,
247
+ error: serializeError(error)
248
+ });
249
+ return;
250
+ }
251
+ };
252
+ const startedAt = runtime.nowMs();
253
+ const deadlineAt = startedAt + invocationBudgetMs;
254
+ const within = { deadlineAt };
255
+ if (schedules.length > 0) await runSchedules(outbox, schedules, cron, event.scheduledTime ?? startedAt, deadlineAt - recoveryReserveMs);
256
+ const recovery = await phase("recover", () => runtime.recover(recoveryLimit, { deadlineAt: Math.min(deadlineAt, runtime.nowMs() + recoveryBudgetMs) }));
257
+ for (const slice of options.reserved ?? []) await phase("reserved", () => runtime.drain(slice.budget, "sweep", { kinds: slice.kinds }, within));
258
+ const report = await phase("drain", () => runtime.drain(budget, "sweep", {}, within));
259
+ await phase("handoff", () => runtime.handoff(budget.deadlineMs, 100, within));
260
+ const deleted = await phase("cleanup", () => runtime.cleanup(retention, within));
261
+ let continued = false;
262
+ if (report && report.claimed > report.errored) continued = await phase("drain", async () => {
263
+ let any = false;
264
+ for (const lane of runtime.lanes()) if (await runtime.hasBacklog({ lane }, within) && await runtime.continueDrain(lane, within)) any = true;
265
+ return any;
266
+ }) ?? false;
267
+ if (options.backlog) {
268
+ const backlogOptions = options.backlog;
269
+ await phase("backlog", () => runtime.monitorBacklog({
270
+ ...backlogOptions,
271
+ ...within
272
+ }));
273
+ }
274
+ runtime.emit({
275
+ type: "sweep_finished",
276
+ cron,
277
+ recovered: recovery ? recovery.retrying + recovery.dead : 0,
278
+ report: report ?? emptyReport(),
279
+ deleted: deleted ?? 0,
280
+ continued
281
+ });
282
+ };
283
+ }
284
+ // src/cloudflare/dlq-handler.ts
285
+ /** A dead-lettered nudge loses nothing (cron still drives the job): report and ack. */
286
+ function createDlqHandler(options = {}) {
287
+ const emit = createEmitter(options.events);
288
+ return async (batch) => {
289
+ for (const message of batch.messages) {
290
+ emit({
291
+ type: "dlq_message",
292
+ queue: batch.queue,
293
+ attempts: message.attempts,
294
+ body: previewBody(message.body)
295
+ });
296
+ message.ack();
297
+ }
298
+ };
299
+ }
300
+ export { DEFAULT_SWEEP_BUDGET, createDlqHandler, createQueueHandler, createScheduledHandler, defineSchedule, messageRetryDelaySeconds };