@openlup/outbox 0.0.0 → 0.14.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.
@@ -0,0 +1,11 @@
1
+ {
2
+ "schemaVersion": 1,
3
+ "version": "outbox-1",
4
+ "migrations": [
5
+ {
6
+ "file": "0001_outbox.sql",
7
+ "source": "db/platform/migrations/20260807190000_comms_outbox_rail.sql",
8
+ "sha256": "dfb3e9808714846738222bb74a1bfdb38ceafdde2ca8a0c71ab835739fb333bd"
9
+ }
10
+ ]
11
+ }
package/src/config.ts ADDED
@@ -0,0 +1,79 @@
1
+ import type { OutboxDispatchConfig } from "./contracts.js";
2
+ const CONFIG_BOUNDS = {
3
+ batchSize: { env: "COMMERCE_OUTBOX_DISPATCH_BATCH_SIZE", def: 25, min: 1, max: 100 },
4
+ maxAttempts: { env: "COMMERCE_OUTBOX_DISPATCH_MAX_ATTEMPTS", def: 8, min: 1, max: 20 },
5
+ visibilitySeconds: {
6
+ env: "COMMERCE_OUTBOX_DISPATCH_VISIBILITY_SECONDS",
7
+ def: 300,
8
+ min: 300,
9
+ max: 3600,
10
+ },
11
+ backoffBaseSeconds: { env: "COMMERCE_OUTBOX_DISPATCH_BACKOFF_BASE_SECONDS", def: 60, min: 5, max: 3600 },
12
+ backoffCapSeconds: { env: "COMMERCE_OUTBOX_DISPATCH_BACKOFF_CAP_SECONDS", def: 3600, min: 5, max: 86_400 },
13
+ snoozeSeconds: { env: "COMMERCE_OUTBOX_DISPATCH_SNOOZE_SECONDS", def: 300, min: 60, max: 3600 },
14
+ maxSnoozes: { env: "COMMERCE_OUTBOX_DISPATCH_MAX_SNOOZES", def: 48, min: 1, max: 1000 },
15
+ // Max keeps a >= 15s tail under the cron's hard kill: drain stop + in-flight
16
+ // handler + marks + queue stats + finishJobRun must all fit after the budget.
17
+ softBudgetMs: {
18
+ env: "COMMERCE_OUTBOX_DISPATCH_SOFT_BUDGET_MS",
19
+ def: 40_000,
20
+ min: 5000,
21
+ max: 60 * 1000 - 15_000,
22
+ },
23
+ } as const;
24
+
25
+ function readBoundedInt(
26
+ env: Record<string, string | undefined>,
27
+ bound: { env: string; def: number; min: number; max: number },
28
+ ): number {
29
+ const raw = env[bound.env];
30
+ if (raw === undefined || raw.trim() === "") return bound.def;
31
+ const parsed = Number.parseInt(raw, 10);
32
+ if (!Number.isFinite(parsed)) {
33
+ console.warn(`[outbox-dispatch] invalid ${bound.env}=${raw}; using default ${bound.def}`);
34
+ return bound.def;
35
+ }
36
+ const clamped = Math.min(Math.max(parsed, bound.min), bound.max);
37
+ if (clamped !== parsed) {
38
+ console.warn(`[outbox-dispatch] ${bound.env}=${parsed} clamped to ${clamped}`);
39
+ }
40
+ return clamped;
41
+ }
42
+
43
+ export function readOutboxDispatchConfig(
44
+ env: Record<string, string | undefined>,
45
+ prefix = "COMMERCE_OUTBOX_DISPATCH_",
46
+ ): OutboxDispatchConfig {
47
+ const bounds = Object.fromEntries(Object.entries(CONFIG_BOUNDS).map(([key, bound]) => [key, { ...bound, env: prefix + bound.env.slice("COMMERCE_OUTBOX_DISPATCH_".length) }])) as typeof CONFIG_BOUNDS;
48
+ const config: OutboxDispatchConfig = {
49
+ batchSize: readBoundedInt(env, bounds.batchSize),
50
+ maxAttempts: readBoundedInt(env, bounds.maxAttempts),
51
+ visibilitySeconds: readBoundedInt(env, bounds.visibilitySeconds),
52
+ backoffBaseSeconds: readBoundedInt(env, bounds.backoffBaseSeconds),
53
+ backoffCapSeconds: readBoundedInt(env, bounds.backoffCapSeconds),
54
+ snoozeSeconds: readBoundedInt(env, bounds.snoozeSeconds),
55
+ maxSnoozes: readBoundedInt(env, bounds.maxSnoozes),
56
+ softBudgetMs: readBoundedInt(env, bounds.softBudgetMs),
57
+ };
58
+ if (config.backoffCapSeconds < config.backoffBaseSeconds) {
59
+ console.warn(
60
+ `[outbox-dispatch] backoff cap ${config.backoffCapSeconds}s below base ${config.backoffBaseSeconds}s; raising cap to base`,
61
+ );
62
+ config.backoffCapSeconds = config.backoffBaseSeconds;
63
+ }
64
+ return config;
65
+ }
66
+
67
+ /** @beta Scheduled configuration is stricter than the bare immediate worker. */
68
+ export function validateScheduledConfig(config: OutboxDispatchConfig, hostLimitSeconds: number, timeouts: readonly number[]): void {
69
+ const fail = (key: string, fix: string): never => { throw new Error(`@openlup/outbox: ${key}: ${fix}`); };
70
+ if (!Number.isFinite(hostLimitSeconds) || hostLimitSeconds < 20 || hostLimitSeconds > 720) fail("hostLimitSeconds", "use 20–720 seconds");
71
+ for (const [key, bound] of Object.entries(CONFIG_BOUNDS)) {
72
+ const value = config[key as keyof OutboxDispatchConfig];
73
+ const min = key === "visibilitySeconds" ? Math.min(5 * hostLimitSeconds, 3600) : bound.min;
74
+ const max = key === "softBudgetMs" ? hostLimitSeconds * 1000 - 15000 : bound.max;
75
+ if (!Number.isInteger(value) || value < min || value > max) fail(key, `use an integer in ${min}–${max}`);
76
+ }
77
+ if (config.backoffCapSeconds < config.backoffBaseSeconds) fail("backoffCapSeconds", "keep cap at least the base delay");
78
+ for (const timeout of timeouts) if (!Number.isFinite(timeout) || timeout <= 0 || timeout + 2000 > config.softBudgetMs) fail("handler.timeoutMs", "fit the effective timeout plus 2000 ms mark margin in the budget");
79
+ }
@@ -0,0 +1,86 @@
1
+ import type { OutboxHandler as KernelHandler, OutboxEventRow } from "@openlup/core/outbox";
2
+ export type { OutboxEventRow, OutboxHandlerOutcome, OutboxHandlerExecutionContext, OutboxHandlerDescriptor } from "@openlup/core/outbox";
3
+ /** @beta The worker creates the host's standard AbortController. */
4
+ export type OutboxHandler = KernelHandler<AbortSignal>;
5
+ export type OutboxHandlerRegistry = ReadonlyMap<string, OutboxHandler>;
6
+
7
+ export type OutboxMarkFailedStatus = "failed" | "discarded" | "snoozed" | "missed";
8
+
9
+ export interface OutboxStore {
10
+ /** Read-only preflight used to admit a terminal handler when active providers are unavailable. */
11
+ hasPendingEventType?(eventType: string): Promise<boolean>;
12
+ claimBatch(input: {
13
+ eventTypes: readonly string[];
14
+ knownEventTypes?: readonly string[];
15
+ batchSize: number;
16
+ visibilitySeconds: number;
17
+ maxAttempts: number;
18
+ }): Promise<OutboxEventRow[]>;
19
+ markProcessed(input: {
20
+ eventId: string;
21
+ claimToken: string;
22
+ metadata?: Record<string, unknown>;
23
+ }): Promise<{ applied: boolean }>;
24
+ markFailed(input: {
25
+ eventId: string;
26
+ claimToken: string;
27
+ error: string;
28
+ outcome: "retry" | "discard" | "snooze";
29
+ baseDelaySeconds: number;
30
+ maxDelaySeconds: number;
31
+ maxAttempts: number;
32
+ snoozeSeconds: number;
33
+ }): Promise<{ status: OutboxMarkFailedStatus }>;
34
+ releaseUnprocessed(
35
+ items: ReadonlyArray<{ eventId: string; claimToken: string }>,
36
+ delaySeconds: number,
37
+ ): Promise<number>;
38
+ }
39
+
40
+ /** Managed-only best-effort queue observation, separate from core claim/ack policy. */
41
+ export interface OutboxQueueDiagnostics {
42
+ queueStats(): Promise<Record<string, unknown>>;
43
+ }
44
+
45
+ export interface Clock {
46
+ now(): number;
47
+ }
48
+
49
+ export interface OutboxDispatchRunResult {
50
+ ok: boolean;
51
+ checked: number;
52
+ updated: number;
53
+ failures: number;
54
+ skipped: boolean;
55
+ reason?: string;
56
+ claimed: number;
57
+ processed: number;
58
+ retried: number;
59
+ snoozed: number;
60
+ discarded: number;
61
+ released: number;
62
+ duplicateInRun: number;
63
+ leaseLost: number;
64
+ batches: number;
65
+ runMs: number;
66
+ byEventType: Record<string, Record<string, number>>;
67
+ discardedEventIds: string[];
68
+ queue?: Record<string, unknown>;
69
+ }
70
+
71
+ export interface OutboxDispatchConfig {
72
+ batchSize: number;
73
+ maxAttempts: number;
74
+ visibilitySeconds: number;
75
+ backoffBaseSeconds: number;
76
+ backoffCapSeconds: number;
77
+ snoozeSeconds: number;
78
+ maxSnoozes: number;
79
+ softBudgetMs: number;
80
+ }
81
+
82
+
83
+ /** @beta Logging defaults retain the dispatch console transcript. */
84
+ export interface OutboxLogger { warn(...args: unknown[]): void; error(...args: unknown[]): void }
85
+ /** @beta Synchronous bounded observations; no payloads or credentials. */
86
+ export interface OutboxObservation { event: "claimed" | "finished"; count: number }
package/src/enqueue.ts ADDED
@@ -0,0 +1,15 @@
1
+ /** @beta Caller executes this inside the same transaction as its domain write. */
2
+ export interface EnqueueEvent {
3
+ aggregateType: string; aggregateId: string; eventType: string; idempotencyKey: string;
4
+ payload: Record<string, unknown>; metadata?: Record<string, unknown>;
5
+ availableAt?: string;
6
+ }
7
+ /** @beta Duplicate enqueue keeps the durable event identity; no independent commit. */
8
+ export function buildOutboxEnqueue(event: EnqueueEvent): { text: string; values: readonly unknown[] } {
9
+ return {
10
+ text: `insert into public.outbox_events (aggregate_type, aggregate_id, event_type, idempotency_key, payload, metadata, available_at)
11
+ values ($1, $2::uuid, $3, $4, $5::jsonb, $6::jsonb, coalesce($7::timestamptz, now()))
12
+ on conflict (event_type, idempotency_key) do nothing returning id::text`,
13
+ values: [event.aggregateType, event.aggregateId, event.eventType, event.idempotencyKey, JSON.stringify(event.payload), JSON.stringify(event.metadata ?? {}), event.availableAt ?? null],
14
+ };
15
+ }
package/src/factory.ts ADDED
@@ -0,0 +1,107 @@
1
+ import metadata from "../package.json" with { type: "json" };
2
+ import type { Contribution, RequiredSchema } from "@openlup/core/readiness";
3
+ import type { JobLeasePort, PlatformJobInvocation, PlatformJobFinishSummary } from "@openlup/core/platform-runtime";
4
+ import type { OutboxHandlerDescriptor } from "@openlup/core/outbox";
5
+ import type { OutboxDispatchConfig, OutboxDispatchRunResult, OutboxHandlerRegistry, OutboxLogger, OutboxStore, OutboxQueueDiagnostics, Clock } from "./contracts.js";
6
+ import { validateScheduledConfig } from "./config.js";
7
+ import { runOutboxDispatchWorker } from "./worker.js";
8
+ /** @beta */
9
+ export interface CompactionPort { compact(input: { processedDays: number; discardedDays: number; limit: number }): Promise<number> }
10
+ /** @beta The builder owns partially acquired resources until this scope is returned. */
11
+ export interface HandlerScope { handlers: OutboxHandlerRegistry; close?(): Promise<void> | void }
12
+ /** @beta */
13
+ export interface OutboxFactoryPorts {
14
+ store: OutboxStore; lease: JobLeasePort;
15
+ descriptors: readonly OutboxHandlerDescriptor[];
16
+ buildHandlers(run: { runId: string; invocation: PlatformJobInvocation }): Promise<OutboxHandlerRegistry | HandlerScope> | OutboxHandlerRegistry | HandlerScope;
17
+ beforeDispatch?(run: { runId: string; invocation: PlatformJobInvocation }): Promise<Record<string, unknown>>;
18
+ diagnostics?: OutboxQueueDiagnostics; clock?: Clock; logger?: OutboxLogger;
19
+ compactor?: CompactionPort;
20
+ }
21
+ /** @beta Schema requirements describe the selected adapters, not a guessed default. */
22
+ export interface OutboxFactoryOptions {
23
+ config: OutboxDispatchConfig; knownEventTypes: readonly string[];
24
+ hostLimitSeconds: number; leaseSeconds: number; cadenceSeconds: number;
25
+ requiredSchema: RequiredSchema;
26
+ prune?: { enabled: boolean; cadenceSeconds: number; processedDays?: number; discardedDays?: number; limit?: number };
27
+ }
28
+ /** @beta Finish/cleanup evidence is distinct from the accepted worker result. */
29
+ export interface OutboxScheduleResult {
30
+ outcome: "success" | "failed" | "skipped" | "disabled";
31
+ detail: OutboxDispatchRunResult | { ok: boolean; checked: number; updated: number; failures: number; skipped: boolean; reason?: string };
32
+ finishApplied?: boolean;
33
+ }
34
+ export function createOutbox(ports: OutboxFactoryPorts & { compactor: CompactionPort }, options: OutboxFactoryOptions): Contribution;
35
+ export function createOutbox(ports: OutboxFactoryPorts, options: OutboxFactoryOptions & { prune?: { enabled: false; cadenceSeconds: number } }): Contribution;
36
+ /** @beta Lease-bound dispatch and terminal compaction, with no host entrypoint. */
37
+ export function createOutbox(ports: OutboxFactoryPorts, options: OutboxFactoryOptions): Contribution {
38
+ options = { ...options, config: { ...options.config }, knownEventTypes: [...options.knownEventTypes],
39
+ requiredSchema: { ...options.requiredSchema, objects: options.requiredSchema.objects.map((o) => ({ ...o })) },
40
+ ...(options.prune ? { prune: { ...options.prune } } : {}) };
41
+ ports = { ...ports };
42
+ const descriptors = ports.descriptors.map((d) => Object.freeze({ eventType: d.eventType, timeoutMs: d.timeoutMs }));
43
+ validateScheduledConfig(options.config, options.hostLimitSeconds, descriptors.map((d) => d.timeoutMs));
44
+ const positive = (key: string, value: number, maximum: number): void => {
45
+ if (!Number.isInteger(value) || value <= 0 || value > maximum) throw new Error(`@openlup/outbox: ${key}: use a positive integer at most ${maximum}`);
46
+ };
47
+ positive("leaseSeconds", options.leaseSeconds, 3600);
48
+ if (options.leaseSeconds < options.hostLimitSeconds) throw new Error("@openlup/outbox: leaseSeconds: cover the full host execution window, including finish time"); positive("cadenceSeconds", options.cadenceSeconds, 86400);
49
+ if (new Set(descriptors.map((d) => d.eventType)).size !== descriptors.length || descriptors.some((d) => !d.eventType.trim())) throw new Error("@openlup/outbox: descriptors: supply one descriptor for each composed event handler");
50
+ if (!ports.store || !ports.lease || typeof ports.buildHandlers !== "function") throw new Error("@openlup/outbox: ports: supply store, lease and handler builder");
51
+ const suppliedLogger = ports.logger ?? console;
52
+ const log = (method: "warn" | "error", args: unknown[]): void => { try { suppliedLogger[method](...args); } catch { /* Diagnostics cannot skip finish or cleanup. */ } };
53
+ const logger = { warn: (...args: unknown[]) => log("warn", args), error: (...args: unknown[]) => log("error", args) };
54
+ const config = { ...options.config }, knownEventTypes = [...options.knownEventTypes];
55
+ const pruning = options.prune?.enabled === true;
56
+ const retention = { processedDays: options.prune?.processedDays ?? 30, discardedDays: options.prune?.discardedDays ?? 90, limit: options.prune?.limit ?? 500 };
57
+ if (pruning) {
58
+ if (!ports.compactor) throw new Error("@openlup/outbox: compactor: bind terminal compaction when prune is selected");
59
+ positive("prune.cadenceSeconds", options.prune!.cadenceSeconds, 604800);
60
+ positive("processedDays", retention.processedDays, 3650); positive("discardedDays", retention.discardedDays, 3650); positive("limit", retention.limit, 10000);
61
+ }
62
+ const summary = (reason: string): PlatformJobFinishSummary & { ok: false } => ({ ok: false, checked: 0, updated: 0, failures: 1, skipped: false, reason });
63
+ const run = async (jobName: string, invocation: PlatformJobInvocation, dispatch: boolean): Promise<OutboxScheduleResult> => {
64
+ const claim = await ports.lease.claimJobRun(jobName, invocation, options.leaseSeconds);
65
+ if (!claim.acquired) return { outcome: claim.reason === "job_disabled" ? "disabled" : "skipped", detail: { ok: true, checked: 0, updated: 0, failures: 0, skipped: true, reason: claim.reason } };
66
+ if (!claim.runId) throw new Error("@openlup/outbox: acquired lease has no run id");
67
+ let scope: HandlerScope | undefined; let hookMetadata: Record<string, unknown> = {};
68
+ let detail: OutboxScheduleResult["detail"] = summary("outbox_dispatch_failed");
69
+ let failure = false;
70
+ try {
71
+ try {
72
+ if (dispatch) {
73
+ const context = { runId: claim.runId, invocation };
74
+ hookMetadata = await ports.beforeDispatch?.(context) ?? {};
75
+ const built = await ports.buildHandlers(context);
76
+ scope = "handlers" in built ? built : { handlers: built };
77
+ const actual = scope.handlers;
78
+ if (actual.size !== descriptors.length || descriptors.some((d) => actual.get(d.eventType)?.eventType !== d.eventType || actual.get(d.eventType)?.timeoutMs !== d.timeoutMs)) throw new Error("outbox_registry_descriptor_mismatch");
79
+ detail = await runOutboxDispatchWorker({ store: ports.store, registry: actual, config, knownEventTypes, diagnostics: ports.diagnostics, clock: ports.clock, logger });
80
+ } else {
81
+ const count = await ports.compactor!.compact(retention);
82
+ detail = { ok: true, checked: count, updated: count, failures: 0, skipped: false };
83
+ }
84
+ } catch (error) {
85
+ failure = true;
86
+ detail = summary(message(error));
87
+ logger.error("[outbox-dispatch] schedule_failed", message(error));
88
+ }
89
+ // Reserved run identity and worker counters override hook metadata.
90
+ const extra = { ...hookMetadata, ...detail, runId: claim.runId };
91
+ const finishApplied = await ports.lease.finishJobRun(jobName, claim.runId, invocation, failure ? "failed" : "success", detail, extra);
92
+ return { outcome: failure || !finishApplied ? "failed" : detail.skipped ? "skipped" : "success", detail, finishApplied };
93
+ } catch (error) {
94
+ logger.error("[outbox-dispatch] finish_job_run_failed", message(error));
95
+ throw error;
96
+ } finally {
97
+ try { await scope?.close?.(); } catch (error) { logger.error("[outbox-dispatch] handler_scope_close_failed", message(error)); }
98
+ }
99
+ };
100
+ const schedules = [{ id: "outbox-dispatch", cadenceSeconds: options.cadenceSeconds, run: (invocation: PlatformJobInvocation) => run("outbox-dispatch", invocation, true) },
101
+ ...(pruning ? [{ id: "outbox-prune", cadenceSeconds: options.prune!.cadenceSeconds, run: (invocation: PlatformJobInvocation) => run("outbox-prune", invocation, false) }] : [])];
102
+ return { handlers: descriptors, schedules, routes: [], manifest: {
103
+ name: metadata.name, version: metadata.version, kind: "rail", emits: [], handles: descriptors.map((d) => d.eventType),
104
+ schedules: schedules.map(({id,cadenceSeconds}) => ({id,cadenceSeconds})), routes: [], requiredPorts: ["store", "lease", ...(pruning ? ["compactor"] : [])], env: [], requiredSchema: options.requiredSchema,
105
+ } };
106
+ }
107
+ function message(error: unknown): string { return (error instanceof Error ? error.message : String(error)).slice(0,180); }
package/src/index.ts ADDED
@@ -0,0 +1,10 @@
1
+ export * from "./contracts.js";
2
+ export { runOutboxDispatchWorker } from "./worker.js";
3
+ export { OutboxHandlerExecutionTrace } from "./trace.js";
4
+ export { uniqueRegistry, composeHandlers, claimAllowlist } from "./registry.js";
5
+ export { readOutboxDispatchConfig, validateScheduledConfig } from "./config.js";
6
+ export { buildOutboxEnqueue } from "./enqueue.js";
7
+ export type { EnqueueEvent } from "./enqueue.js";
8
+ export { withPayloadSchema } from "./typedEvents.js";
9
+ export { createOutbox } from "./factory.js";
10
+ export type { OutboxFactoryPorts, OutboxFactoryOptions, OutboxScheduleResult, HandlerScope, CompactionPort } from "./factory.js";
@@ -0,0 +1,76 @@
1
+ import type { SqlExecutor, RequiredSchema } from "@openlup/core/readiness";
2
+ import type { OutboxStore, OutboxEventRow, OutboxMarkFailedStatus } from "./contracts.js";
3
+ import type { CompactionPort } from "./factory.js";
4
+ /** @beta These identities describe the shipped default adapter only. */
5
+ export const POSTGRES_OUTBOX_SCHEMA: RequiredSchema = {
6
+ version: "outbox-1", migration: "sql/0001_outbox.sql", objects: [
7
+ { kind: "table", name: "public.outbox_events" }, { kind: "table", name: "public.outbox_dormant_event_types" },
8
+ ...["id", "created_at", "available_at", "processed_at", "aggregate_type", "aggregate_id", "event_type", "idempotency_key", "status", "attempts", "payload", "error", "metadata"].map((name) => ({ kind: "column" as const, name: `public.outbox_events.${name}` })),
9
+ { kind: "function", name: "public.outbox_claim_batch", signature: "text[],text[],integer,integer,integer" },
10
+ { kind: "function", name: "public.outbox_mark_processed", signature: "uuid,text,jsonb" },
11
+ { kind: "function", name: "public.outbox_mark_failed", signature: "uuid,text,text,text,integer,integer,integer,integer" },
12
+ { kind: "function", name: "public.outbox_release_unprocessed", signature: "uuid[],text[],integer" },
13
+ { kind: "function", name: "pg_catalog.pg_input_is_valid", signature: "text,text" },
14
+ ],
15
+ };
16
+ /** @beta Supply an autocommit executor or a per-operation transaction that commits before resolve.
17
+ * Never pass a producer transaction or retain an operation transaction across handler I/O. */
18
+ export function createPostgresOutboxStore(executor: SqlExecutor): OutboxStore {
19
+ const scalar = async (text: string, values: readonly unknown[]): Promise<unknown> => {
20
+ const { rows } = await executor.query(text, values);
21
+ if (rows.length !== 1 || !Object.hasOwn(rows[0], "value")) throw new Error("outbox_store_invalid_scalar");
22
+ return rows[0].value;
23
+ };
24
+ return {
25
+ async claimBatch(input) {
26
+ const { rows } = await executor.query(`select id::text, to_char(created_at at time zone 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.US"Z"') as created_at,
27
+ to_char(available_at at time zone 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.US"Z"') as available_at,
28
+ to_char(processed_at at time zone 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.US"Z"') as processed_at,
29
+ aggregate_type, aggregate_id::text, event_type, idempotency_key, status, attempts, payload, error, metadata
30
+ from public.outbox_claim_batch($1::text[], $2::text[], $3::integer, $4::integer, $5::integer)`,
31
+ [input.eventTypes, input.knownEventTypes ?? [], input.batchSize, input.visibilitySeconds, input.maxAttempts]);
32
+ return rows as unknown as OutboxEventRow[];
33
+ },
34
+ async markProcessed(input) {
35
+ const value = await scalar("select public.outbox_mark_processed($1::uuid, $2::text, $3::jsonb) as value", [input.eventId, input.claimToken, JSON.stringify(input.metadata ?? {})]);
36
+ if (typeof value !== "boolean") throw new Error("outbox_mark_processed_invalid_response");
37
+ return { applied: value };
38
+ },
39
+ async markFailed(input) {
40
+ const value = await scalar("select public.outbox_mark_failed($1::uuid,$2::text,$3::text,$4::text,$5::integer,$6::integer,$7::integer,$8::integer) as value", [input.eventId,input.claimToken,input.error,input.outcome,input.baseDelaySeconds,input.maxDelaySeconds,input.maxAttempts,input.snoozeSeconds]);
41
+ if (!["failed","discarded","snoozed","missed"].includes(String(value))) throw new Error("outbox_mark_failed_unexpected_status");
42
+ return { status: value as OutboxMarkFailedStatus };
43
+ },
44
+ async releaseUnprocessed(items, delaySeconds) {
45
+ if (!items.length) return 0;
46
+ const value = await scalar("select public.outbox_release_unprocessed($1::uuid[],$2::text[],$3::integer) as value", [items.map((i) => i.eventId),items.map((i) => i.claimToken),delaySeconds]);
47
+ if (typeof value !== "number" || !Number.isInteger(value) || value < 0) throw new Error("outbox_release_invalid_response");
48
+ return value;
49
+ },
50
+ async hasPendingEventType(eventType) {
51
+ const value = await scalar("select exists (select 1 from public.outbox_events where event_type=$1 and status in ('pending','failed','processing')) as value", [eventType]);
52
+ if (typeof value !== "boolean") throw new Error("outbox_pending_invalid_response");
53
+ return value;
54
+ },
55
+ };
56
+ }
57
+ /** @beta Compacts terminal payload/error, retaining the event and its unique key. */
58
+ export function createPostgresOutboxCompactor(executor: SqlExecutor): CompactionPort {
59
+ return { async compact(input) {
60
+ const { rows } = await executor.query(`with targets as (
61
+ select id from public.outbox_events
62
+ where metadata->>'retentionCompactedAt' is null and (
63
+ (status = 'processed' and coalesce(processed_at, created_at) < now() - make_interval(days => $1::integer))
64
+ or (status = 'discarded' and case when pg_catalog.pg_input_is_valid(metadata->>'discardedAt', 'timestamp with time zone')
65
+ then (metadata->>'discardedAt')::timestamptz else null end < now() - make_interval(days => $2::integer)))
66
+ order by created_at, id limit $3::integer for update skip locked
67
+ ), compacted as (
68
+ update public.outbox_events e set payload = '{}'::jsonb, error = null,
69
+ metadata = e.metadata || jsonb_build_object('retentionCompactedAt', now(), 'retentionCompactedBy', 'outbox_compact_terminal', 'retentionCompactedStatus', e.status)
70
+ from targets t where e.id = t.id returning e.id
71
+ ) select count(*)::integer as value from compacted`, [input.processedDays,input.discardedDays,input.limit]);
72
+ const value = rows[0]?.value;
73
+ if (rows.length !== 1 || typeof value !== "number" || !Number.isInteger(value) || value < 0) throw new Error("outbox_compaction_invalid_response");
74
+ return value;
75
+ } };
76
+ }
@@ -0,0 +1,49 @@
1
+ import type { OutboxHandler, OutboxHandlerRegistry } from "./contracts.js";
2
+ export function claimAllowlist(registry: OutboxHandlerRegistry): string[] {
3
+ return [...registry.keys()];
4
+ }
5
+
6
+ export function uniqueRegistry(handlers: readonly OutboxHandler[]): OutboxHandlerRegistry {
7
+ const registry = new Map<string, OutboxHandler>();
8
+ for (const handler of handlers) {
9
+ const existing = registry.get(handler.eventType);
10
+ registry.set(handler.eventType, existing ? composeHandlers(existing, handler) : handler);
11
+ }
12
+ return registry;
13
+ }
14
+
15
+ // Preserve top-level delivery observations, with first-component precedence.
16
+ const DELIVERY_LIFECYCLE_METADATA_KEYS = ["resendId", "providerMessageId", "skipped", "dedupe"] as const;
17
+
18
+ function hoistDeliveryLifecycleKeys(detail: Record<string, unknown> | undefined): Record<string, unknown> {
19
+ if (!detail) return {};
20
+ const hoisted: Record<string, unknown> = {};
21
+ for (const key of DELIVERY_LIFECYCLE_METADATA_KEYS) {
22
+ if (detail[key] !== undefined) hoisted[key] = detail[key];
23
+ }
24
+ return hoisted;
25
+ }
26
+
27
+ export function composeHandlers(first: OutboxHandler, second: OutboxHandler): OutboxHandler {
28
+ return {
29
+ eventType: first.eventType,
30
+ timeoutMs: first.timeoutMs + second.timeoutMs,
31
+ async handle(row, signal) {
32
+ const firstOutcome = await first.handle(row, signal);
33
+ if (firstOutcome.kind !== "processed") return firstOutcome;
34
+ const secondOutcome = await second.handle(row, signal);
35
+ if (secondOutcome.kind !== "processed") return secondOutcome;
36
+ return {
37
+ kind: "processed",
38
+ detail: {
39
+ // Hoist second first, then first, so the primary (email) handler wins on
40
+ // the rare chance both emit a lifecycle key.
41
+ ...hoistDeliveryLifecycleKeys(secondOutcome.detail),
42
+ ...hoistDeliveryLifecycleKeys(firstOutcome.detail),
43
+ first: firstOutcome.detail ?? {},
44
+ second: secondOutcome.detail ?? {},
45
+ },
46
+ };
47
+ },
48
+ };
49
+ }
package/src/testing.ts ADDED
@@ -0,0 +1,13 @@
1
+ import type { OutboxStore, OutboxEventRow } from "./contracts.js";
2
+ /** @beta Framework-free refusal suite. The caller supplies a real processing row. */
3
+ export async function assertOutboxStoreFences(store: OutboxStore, processing: OutboxEventRow): Promise<void> {
4
+ const token = processing.metadata.claimToken;
5
+ if (processing.status !== "processing" || typeof token !== "string" || !token) throw new Error("conformance_requires_processing_row");
6
+ if ((await store.markProcessed({ eventId: processing.id, claimToken: `stale-${token}` })).applied) throw new Error("conformance_stale_processed_applied");
7
+ if ((await store.markFailed({ eventId: processing.id, claimToken: `stale-${token}`, error: "synthetic", outcome: "retry", baseDelaySeconds: 5, maxDelaySeconds: 10, maxAttempts: 3, snoozeSeconds: 60 })).status !== "missed") throw new Error("conformance_stale_failure_applied");
8
+ if (await store.releaseUnprocessed([{ eventId: processing.id, claimToken: `stale-${token}` }], 0) !== 0) throw new Error("conformance_stale_release_applied");
9
+ }
10
+ /** @beta Synthetic envelope; never use for deployed queue observations. */
11
+ export function syntheticOutboxRow(overrides: Partial<OutboxEventRow> = {}): OutboxEventRow {
12
+ return { id: "00000000-0000-4000-8000-000000000001", created_at: "2026-01-01T00:00:00.000001Z", available_at: "2026-01-01T00:00:00.000001Z", processed_at: null, aggregate_type: "example", aggregate_id: "00000000-0000-4000-8000-000000000002", event_type: "example.created", idempotency_key: "synthetic-1", status: "processing", attempts: 1, payload: {}, error: null, metadata: { claimToken: "synthetic-token" }, ...overrides };
13
+ }
package/src/trace.ts ADDED
@@ -0,0 +1,52 @@
1
+ import type { OutboxHandler, OutboxEventRow, OutboxHandlerOutcome, OutboxHandlerExecutionContext } from "./contracts.js";
2
+ const SAFE_MESSAGE_MAX_CHARS = 300;
3
+ export class OutboxHandlerExecutionTrace implements OutboxHandlerExecutionContext {
4
+ private lastPhase: string | undefined;
5
+
6
+ setPhase(phase: string): void {
7
+ const sanitized = phase.trim().toLowerCase().replace(/[^a-z0-9_-]/g, "_").slice(0, 48);
8
+ if (sanitized) this.lastPhase = sanitized;
9
+ }
10
+
11
+ timeoutReason(): string {
12
+ return this.lastPhase ? `outbox_handler_timeout:${this.lastPhase}` : "outbox_handler_timeout";
13
+ }
14
+
15
+ qualifyTimeout(outcome: OutboxHandlerOutcome): OutboxHandlerOutcome {
16
+ return this.lastPhase && outcome.kind === "retry" && outcome.reason === "outbox_handler_timeout"
17
+ ? { ...outcome, reason: this.timeoutReason() }
18
+ : outcome;
19
+ }
20
+ }
21
+
22
+ export async function executeHandler(
23
+ handler: OutboxHandler,
24
+ row: OutboxEventRow,
25
+ ): Promise<OutboxHandlerOutcome> {
26
+ const controller = new AbortController();
27
+ const execution = new OutboxHandlerExecutionTrace();
28
+ let abortTimer: ReturnType<typeof setTimeout> | undefined;
29
+ let backstopTimer: ReturnType<typeof setTimeout> | undefined;
30
+ try {
31
+ // Abort is the primary timeout mechanism (handlers must honor the
32
+ // signal); the race is only a backstop for non-abortable code, firing
33
+ // 500ms later so abort-aware handlers get to settle first.
34
+ const backstop = new Promise<OutboxHandlerOutcome>((resolve) => {
35
+ backstopTimer = setTimeout(
36
+ () => resolve({ kind: "retry", reason: execution.timeoutReason() }),
37
+ handler.timeoutMs + 500,
38
+ );
39
+ });
40
+ abortTimer = setTimeout(() => controller.abort(), handler.timeoutMs);
41
+ const outcome = await Promise.race([handler.handle(row, controller.signal, execution), backstop]);
42
+ return execution.qualifyTimeout(outcome);
43
+ } catch (error) {
44
+ return { kind: "retry", reason: safeMessage(error) };
45
+ } finally {
46
+ if (abortTimer !== undefined) clearTimeout(abortTimer);
47
+ if (backstopTimer !== undefined) clearTimeout(backstopTimer);
48
+ }
49
+ }
50
+
51
+
52
+ function safeMessage(error: unknown): string { return (error instanceof Error ? error.message : String(error)).slice(0, SAFE_MESSAGE_MAX_CHARS); }
@@ -0,0 +1,10 @@
1
+ import type { StandardSchemaV1 } from "@openlup/core/standard-schema";
2
+ import type { OutboxHandler } from "./contracts.js";
3
+ /** @beta Opt-in validation retries invalid payloads; it never changes untyped handlers. */
4
+ export function withPayloadSchema<Payload extends Record<string, unknown>>(handler: OutboxHandler, schema: StandardSchemaV1<unknown, Payload>): OutboxHandler {
5
+ return { eventType: handler.eventType, timeoutMs: handler.timeoutMs, async handle(row, signal, execution) {
6
+ const result = await schema["~standard"].validate(row.payload);
7
+ if (result.issues) return { kind: "retry", reason: "outbox_payload_invalid" };
8
+ return handler.handle({ ...row, payload: result.value }, signal, execution);
9
+ } };
10
+ }