@makerkit/workers-outbox 0.0.0-stage → 0.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +1120 -2
- package/dist/backlog-Bd62tjCT.mjs +686 -0
- package/dist/catalog-DzvOuOHQ.d.mts +6 -0
- package/dist/catalog-G6w4wb_w.mjs +46 -0
- package/dist/cloudflare.d.mts +117 -0
- package/dist/cloudflare.mjs +300 -0
- package/dist/create-outbox-BTMmdvLs.mjs +1758 -0
- package/dist/create-outbox-C4vdn6Dr.d.mts +959 -0
- package/dist/drizzle.d.mts +654 -0
- package/dist/drizzle.mjs +166 -0
- package/dist/index.d.mts +3 -0
- package/dist/index.mjs +1 -0
- package/dist/inspect.d.mts +2 -0
- package/dist/inspect.mjs +41 -0
- package/dist/migrations-CX7O2Lcq.mjs +98 -0
- package/dist/pg.d.mts +30 -0
- package/dist/pg.mjs +76 -0
- package/dist/sql.d.mts +7 -0
- package/dist/sql.mjs +1 -0
- package/dist/status-BZhIWoE0.mjs +30 -0
- package/dist/testing.d.mts +49 -0
- package/dist/testing.mjs +97 -0
- package/dist/types-ClomUJ_8.d.mts +33 -0
- package/package.json +94 -4
package/dist/drizzle.mjs
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { a as REASONED_TERMINAL_STATUSES, i as JOB_STATUSES, n as ATTEMPT_TRIGGERS, o as TERMINAL_STATUSES, r as CLAIMABLE_STATUSES, t as ATTEMPT_OUTCOMES } from "./status-BZhIWoE0.mjs";
|
|
2
|
+
import { r as sqlLiteralList, t as MIN_SERVER_VERSION_NUM } from "./migrations-CX7O2Lcq.mjs";
|
|
3
|
+
import { n as readOutboxCatalog, t as queryRows } from "./catalog-G6w4wb_w.mjs";
|
|
4
|
+
import { sql } from "drizzle-orm";
|
|
5
|
+
import { bigint, check, foreignKey, getTableConfig, index, integer, jsonb, pgSchema, primaryKey, text, timestamp, uniqueIndex, uuid } from "drizzle-orm/pg-core";
|
|
6
|
+
// src/drizzle/schema.ts
|
|
7
|
+
/** Source of truth for the tables; the `/sql` DDL is pinned to it by a parity test. */
|
|
8
|
+
const outboxSchema = pgSchema("outbox");
|
|
9
|
+
const timestamptz = (name) => timestamp(name, {
|
|
10
|
+
precision: 3,
|
|
11
|
+
withTimezone: true
|
|
12
|
+
});
|
|
13
|
+
const list = (values) => sql.raw(sqlLiteralList(values));
|
|
14
|
+
const outboxJob = outboxSchema.table("job", {
|
|
15
|
+
id: uuid("id").primaryKey(),
|
|
16
|
+
kind: text("kind").notNull(),
|
|
17
|
+
dedupeKey: text("dedupe_key").notNull(),
|
|
18
|
+
payload: jsonb("payload").notNull(),
|
|
19
|
+
status: text("status").$type().notNull().default("pending"),
|
|
20
|
+
tenantKey: text("tenant_key"),
|
|
21
|
+
correlationId: text("correlation_id"),
|
|
22
|
+
metadata: jsonb("metadata").$type().notNull().default({}),
|
|
23
|
+
availableAt: timestamptz("available_at").notNull().defaultNow(),
|
|
24
|
+
nextAttemptAt: timestamptz("next_attempt_at"),
|
|
25
|
+
leaseToken: uuid("lease_token"),
|
|
26
|
+
leasedUntil: timestamptz("leased_until"),
|
|
27
|
+
startedAttempts: integer("started_attempts").notNull().default(0),
|
|
28
|
+
finishedAttempts: integer("finished_attempts").notNull().default(0),
|
|
29
|
+
spentAttempts: integer("spent_attempts").notNull().default(0),
|
|
30
|
+
codeStreak: integer("code_streak").notNull().default(0),
|
|
31
|
+
stickyCode: text("sticky_code"),
|
|
32
|
+
minVersionMs: bigint("min_version_ms", { mode: "number" }),
|
|
33
|
+
lastErrorCode: text("last_error_code"),
|
|
34
|
+
lastErrorMessage: text("last_error_message"),
|
|
35
|
+
terminalReason: text("terminal_reason"),
|
|
36
|
+
createdAt: timestamptz("created_at").notNull().defaultNow(),
|
|
37
|
+
updatedAt: timestamptz("updated_at").notNull().defaultNow(),
|
|
38
|
+
finishedAt: timestamptz("finished_at"),
|
|
39
|
+
nudgeSentAt: timestamptz("nudge_sent_at"),
|
|
40
|
+
nudgeAttempts: integer("nudge_attempts").notNull().default(0),
|
|
41
|
+
lastNudgeError: text("last_nudge_error"),
|
|
42
|
+
orderingKey: text("ordering_key"),
|
|
43
|
+
seq: bigint("seq", { mode: "number" }).notNull().generatedAlwaysAsIdentity()
|
|
44
|
+
}, (t) => [
|
|
45
|
+
uniqueIndex("job_tenant_dedupe_key_unique").on(sql`COALESCE(${t.tenantKey}, '')`, t.dedupeKey),
|
|
46
|
+
check("job_status_check", sql`${t.status} IN (${list(JOB_STATUSES)})`),
|
|
47
|
+
check("job_retrying_requires_next_attempt_at", sql`${t.status} <> 'retrying' OR ${t.nextAttemptAt} IS NOT NULL`),
|
|
48
|
+
check("job_processing_iff_leased", sql`(${t.status} = 'processing') = (${t.leaseToken} IS NOT NULL AND ${t.leasedUntil} IS NOT NULL)`),
|
|
49
|
+
check("job_attempt_counters", sql`${t.finishedAttempts} >= 0 AND ${t.finishedAttempts} <= ${t.startedAttempts} AND ${t.spentAttempts} >= 0 AND ${t.spentAttempts} <= ${t.finishedAttempts} AND ${t.codeStreak} >= 0`),
|
|
50
|
+
check("job_terminal_iff_finished_at", sql`(${t.status} IN (${list(TERMINAL_STATUSES)})) = (${t.finishedAt} IS NOT NULL)`),
|
|
51
|
+
check("job_terminal_reason", sql`(${t.status} IN (${list(REASONED_TERMINAL_STATUSES)})) = (${t.terminalReason} IS NOT NULL)`),
|
|
52
|
+
index("job_claim_due_idx").on(sql`COALESCE(${t.nextAttemptAt}, ${t.availableAt})`, t.createdAt, t.id).where(sql`${t.status} IN (${list(CLAIMABLE_STATUSES)})`),
|
|
53
|
+
index("job_pending_kind_idx").on(t.kind, t.availableAt, t.createdAt).where(sql`${t.status} = 'pending'`),
|
|
54
|
+
index("job_retrying_kind_idx").on(t.kind, t.nextAttemptAt, t.createdAt).where(sql`${t.status} = 'retrying'`),
|
|
55
|
+
index("job_lease_expiry_idx").on(t.leasedUntil).where(sql`${t.status} = 'processing'`),
|
|
56
|
+
index("job_retention_idx").on(t.status, t.finishedAt).where(sql`${t.status} IN (${list(TERMINAL_STATUSES)})`),
|
|
57
|
+
index("job_tenant_key_idx").on(t.tenantKey).where(sql`${t.tenantKey} IS NOT NULL`),
|
|
58
|
+
index("job_correlation_id_idx").on(t.correlationId).where(sql`${t.correlationId} IS NOT NULL`),
|
|
59
|
+
index("job_status_created_idx").on(t.status, t.createdAt, t.id)
|
|
60
|
+
]);
|
|
61
|
+
const outboxAttempt = outboxSchema.table("attempt", {
|
|
62
|
+
jobId: uuid("job_id").notNull(),
|
|
63
|
+
attemptNo: integer("attempt_no").notNull(),
|
|
64
|
+
kind: text("kind").notNull(),
|
|
65
|
+
leaseToken: uuid("lease_token").notNull(),
|
|
66
|
+
trigger: text("trigger").$type().notNull(),
|
|
67
|
+
startedAt: timestamptz("started_at").notNull(),
|
|
68
|
+
finishedAt: timestamptz("finished_at"),
|
|
69
|
+
outcome: text("outcome").$type(),
|
|
70
|
+
code: text("code"),
|
|
71
|
+
message: text("message"),
|
|
72
|
+
durationMs: integer("duration_ms")
|
|
73
|
+
}, (t) => [
|
|
74
|
+
primaryKey({
|
|
75
|
+
name: "attempt_pkey",
|
|
76
|
+
columns: [t.jobId, t.attemptNo]
|
|
77
|
+
}),
|
|
78
|
+
foreignKey({
|
|
79
|
+
name: "attempt_job_id_fkey",
|
|
80
|
+
columns: [t.jobId],
|
|
81
|
+
foreignColumns: [outboxJob.id]
|
|
82
|
+
}).onDelete("cascade"),
|
|
83
|
+
check("attempt_trigger_check", sql`${t.trigger} IN (${list(ATTEMPT_TRIGGERS)})`),
|
|
84
|
+
check("attempt_outcome_check", sql`${t.outcome} IS NULL OR ${t.outcome} IN (${list(ATTEMPT_OUTCOMES)})`),
|
|
85
|
+
check("attempt_finished_iff_outcome", sql`(${t.outcome} IS NULL) = (${t.finishedAt} IS NULL)`),
|
|
86
|
+
index("attempt_finished_at_idx").on(t.finishedAt).where(sql`${t.finishedAt} IS NOT NULL`)
|
|
87
|
+
]);
|
|
88
|
+
// src/drizzle/verify-schema.ts
|
|
89
|
+
const OUTBOX_TABLES = [outboxJob, outboxAttempt];
|
|
90
|
+
/** Empty = matches. Compares columns and names, not index/CHECK bodies; extra objects aren't drift. */
|
|
91
|
+
async function verifyOutboxSchema(db) {
|
|
92
|
+
const [version] = await queryRows(db, sql`SELECT current_setting('server_version_num') AS server_version_num, now() AS read_at`);
|
|
93
|
+
const actual = Number(version?.server_version_num);
|
|
94
|
+
const drift = actual < 17e4 ? [{
|
|
95
|
+
type: "unsupported_version",
|
|
96
|
+
minimum: MIN_SERVER_VERSION_NUM,
|
|
97
|
+
actual
|
|
98
|
+
}] : [];
|
|
99
|
+
const catalog = await readOutboxCatalog(db);
|
|
100
|
+
return [...drift, ...OUTBOX_TABLES.flatMap((table) => diffTable(table, catalog))];
|
|
101
|
+
}
|
|
102
|
+
function diffTable(table, catalog) {
|
|
103
|
+
const config = getTableConfig(table);
|
|
104
|
+
const tableName = config.name;
|
|
105
|
+
const liveColumns = catalog.columns.filter((column) => column.table === tableName);
|
|
106
|
+
if (liveColumns.length === 0) return [{
|
|
107
|
+
type: "missing_table",
|
|
108
|
+
table: tableName
|
|
109
|
+
}];
|
|
110
|
+
const drift = [];
|
|
111
|
+
for (const column of config.columns) {
|
|
112
|
+
const live = liveColumns.find((candidate) => candidate.column === column.name);
|
|
113
|
+
if (!live) {
|
|
114
|
+
drift.push({
|
|
115
|
+
type: "missing_column",
|
|
116
|
+
table: tableName,
|
|
117
|
+
column: column.name
|
|
118
|
+
});
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
const expected = describeColumn(column.getSQLType(), column.notNull);
|
|
122
|
+
const actual = describeColumn(live.type, live.notNull);
|
|
123
|
+
if (expected !== actual) drift.push({
|
|
124
|
+
type: "column_mismatch",
|
|
125
|
+
table: tableName,
|
|
126
|
+
column: column.name,
|
|
127
|
+
expected,
|
|
128
|
+
actual
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
const liveConstraints = new Set(catalog.constraints.filter((c) => c.table === tableName).map((c) => c.name));
|
|
132
|
+
for (const name of expectedConstraintNames(table)) if (!liveConstraints.has(name)) drift.push({
|
|
133
|
+
type: "missing_constraint",
|
|
134
|
+
table: tableName,
|
|
135
|
+
name
|
|
136
|
+
});
|
|
137
|
+
const liveIndexes = new Map(catalog.indexes.filter((i) => i.table === tableName).map((i) => [i.name, i]));
|
|
138
|
+
for (const name of config.indexes.flatMap((index) => index.config.name ?? [])) {
|
|
139
|
+
const live = liveIndexes.get(name);
|
|
140
|
+
if (!live) drift.push({
|
|
141
|
+
type: "missing_index",
|
|
142
|
+
table: tableName,
|
|
143
|
+
name
|
|
144
|
+
});
|
|
145
|
+
else if (!live.valid) drift.push({
|
|
146
|
+
type: "invalid_index",
|
|
147
|
+
table: tableName,
|
|
148
|
+
name
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
return drift;
|
|
152
|
+
}
|
|
153
|
+
function expectedConstraintNames(table) {
|
|
154
|
+
const config = getTableConfig(table);
|
|
155
|
+
return [
|
|
156
|
+
...config.columns.some((column) => column.primary) ? [`${config.name}_pkey`] : [],
|
|
157
|
+
...config.primaryKeys.map((pk) => pk.getName()),
|
|
158
|
+
...config.uniqueConstraints.flatMap((u) => u.getName() ?? []),
|
|
159
|
+
...config.foreignKeys.map((fk) => fk.getName()),
|
|
160
|
+
...config.checks.map((c) => c.name)
|
|
161
|
+
];
|
|
162
|
+
}
|
|
163
|
+
function describeColumn(type, notNull) {
|
|
164
|
+
return `${type.replace(/\s+\(/g, "(").toLowerCase()}${notNull ? " not null" : ""}`;
|
|
165
|
+
}
|
|
166
|
+
export { outboxAttempt, outboxJob, outboxSchema, verifyOutboxSchema };
|
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
import { $ as cancel, A as DeadReason, G as JobDefinitionInput, H as AnyJob, J as defineJob, K as JobDeps, M as AttemptOutcome, N as AttemptTrigger, P as JobStatus, Q as HandlerOutcome, U as JobContext, W as JobDefinition, X as RetryPolicy, Y as defineJobFor, Z as RetryPolicyInput, a as EnqueueResult, b as TransitionHook, d as StrictOutbox, et as fail, f as StrictScopedEnqueue, g as OutboxEvent, h as EventSink, i as EnqueueRequest, j as Transition, l as RefusedEnqueueResult, m as createOutbox, n as DepsOf, nt as retry, o as Outbox, p as TenantScope, q as defaultClassify, r as EnqueueRefusedError, rt as MessageRedactor, s as OutboxOptions, t as AcceptedEnqueueResult, tt as ok, u as ScopedEnqueue, x as TransitionHookEvent, y as RunReport } from "./create-outbox-C4vdn6Dr.mjs";
|
|
2
|
+
import { i as QueueSender, n as LaneConnection, r as OutboxDb, t as Clock } from "./types-ClomUJ_8.mjs";
|
|
3
|
+
export { type AcceptedEnqueueResult, type AnyJob, type AttemptOutcome, type AttemptTrigger, type Clock, type DeadReason, type DepsOf, EnqueueRefusedError, type EnqueueRequest, type EnqueueResult, type EventSink, type HandlerOutcome, type JobContext, type JobDefinition, type JobDefinitionInput, type JobDeps, type JobStatus, type LaneConnection, type MessageRedactor, type Outbox, type OutboxDb, type OutboxEvent, type OutboxOptions, type QueueSender, type RefusedEnqueueResult, type RetryPolicy, type RetryPolicyInput, type RunReport, type ScopedEnqueue, type StrictOutbox, type StrictScopedEnqueue, type TenantScope, type Transition, type TransitionHook, type TransitionHookEvent, cancel, createOutbox, defaultClassify, defineJob, defineJobFor, fail, ok, retry };
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { t as EnqueueRefusedError, p as cancel, r as createOutbox, u as defaultClassify, d as defineJob, f as defineJobFor, m as fail, h as ok, g as retry } from "./create-outbox-BTMmdvLs.mjs";
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
import { B as KindBacklog, C as countBacklog, D as listJobs, E as getJob, F as BacklogReport, I as BacklogSeverity, L as BacklogThresholds, O as DEFAULT_INSPECT_STATEMENT_TIMEOUT_MS, R as DEFAULT_BACKLOG_AGED_MS, S as aggregateAttemptOutcomes, T as findJobsByCorrelationId, V as evaluateBacklog, k as InspectBound, w as countJobs, z as DEFAULT_BACKLOG_THRESHOLDS } from "./create-outbox-C4vdn6Dr.mjs";
|
|
2
|
+
export { type BacklogReport, type BacklogSeverity, type BacklogThresholds, DEFAULT_BACKLOG_AGED_MS, DEFAULT_BACKLOG_THRESHOLDS, DEFAULT_INSPECT_STATEMENT_TIMEOUT_MS, type InspectBound, type KindBacklog, aggregateAttemptOutcomes, countBacklog, countJobs, evaluateBacklog, findJobsByCorrelationId, getJob, listJobs };
|
package/dist/inspect.mjs
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { S as listJobs$1, d as countBacklog$1, f as countJobs$1, g as getJob$1, h as findJobsByCorrelationId$1, n as DEFAULT_BACKLOG_THRESHOLDS, o as aggregateAttemptOutcomes$1, r as evaluateBacklog, t as DEFAULT_BACKLOG_AGED_MS } from "./backlog-Bd62tjCT.mjs";
|
|
2
|
+
import { t as queryRows } from "./catalog-G6w4wb_w.mjs";
|
|
3
|
+
import { sql } from "drizzle-orm";
|
|
4
|
+
// src/inspect/bounded.ts
|
|
5
|
+
/**
|
|
6
|
+
* Default bound on one inspection read. A long scan holds back the xmin horizon, so autovacuum
|
|
7
|
+
* can't clean the queue table's dead tuples while it runs.
|
|
8
|
+
*/
|
|
9
|
+
const DEFAULT_INSPECT_STATEMENT_TIMEOUT_MS = 3e4;
|
|
10
|
+
const MAX_STATEMENT_TIMEOUT_MS = 2147483647;
|
|
11
|
+
const canTransact = (db) => typeof db.transaction === "function";
|
|
12
|
+
/**
|
|
13
|
+
* Runs `read` in its own transaction (a savepoint inside the caller's) with a transaction-local
|
|
14
|
+
* `statement_timeout`, restored before returning, so nothing leaks into a caller's transaction or,
|
|
15
|
+
* on Hyperdrive, into another client's session. A runner without `transaction` runs unbounded.
|
|
16
|
+
*/
|
|
17
|
+
async function boundedRead(db, bound, read) {
|
|
18
|
+
const timeoutMs = bound?.statementTimeoutMs ?? 3e4;
|
|
19
|
+
if (!Number.isInteger(timeoutMs) || timeoutMs < 0 || timeoutMs > MAX_STATEMENT_TIMEOUT_MS) throw new Error(`inspect: statementTimeoutMs must be an integer in [0, ${MAX_STATEMENT_TIMEOUT_MS}], got ${timeoutMs}`);
|
|
20
|
+
if (timeoutMs === 0 || !canTransact(db)) return read(db);
|
|
21
|
+
return db.transaction(async (tx) => {
|
|
22
|
+
const [previous] = await queryRows(tx, sql`SELECT current_setting('statement_timeout') AS previous,
|
|
23
|
+
set_config('statement_timeout', ${String(timeoutMs)}, true) AS applied`);
|
|
24
|
+
const result = await read(tx);
|
|
25
|
+
await tx.execute(sql`SELECT set_config('statement_timeout', ${previous?.previous ?? "0"}, true)`);
|
|
26
|
+
return result;
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
// src/inspect/index.ts
|
|
30
|
+
/** Each read runs under a transaction-local `statement_timeout` (default 30 s; see `InspectBound`). */
|
|
31
|
+
const bounded = (read) => (db, input) => {
|
|
32
|
+
const { statementTimeoutMs, ...rest } = input;
|
|
33
|
+
return boundedRead(db, { statementTimeoutMs }, (tx) => read(tx, rest));
|
|
34
|
+
};
|
|
35
|
+
const aggregateAttemptOutcomes = bounded(aggregateAttemptOutcomes$1);
|
|
36
|
+
const countBacklog = bounded(countBacklog$1);
|
|
37
|
+
const countJobs = bounded(countJobs$1);
|
|
38
|
+
const findJobsByCorrelationId = bounded(findJobsByCorrelationId$1);
|
|
39
|
+
const getJob = bounded(getJob$1);
|
|
40
|
+
const listJobs = bounded(listJobs$1);
|
|
41
|
+
export { DEFAULT_BACKLOG_AGED_MS, DEFAULT_BACKLOG_THRESHOLDS, DEFAULT_INSPECT_STATEMENT_TIMEOUT_MS, aggregateAttemptOutcomes, countBacklog, countJobs, evaluateBacklog, findJobsByCorrelationId, getJob, listJobs };
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { a as REASONED_TERMINAL_STATUSES, i as JOB_STATUSES, n as ATTEMPT_TRIGGERS, o as TERMINAL_STATUSES, r as CLAIMABLE_STATUSES, t as ATTEMPT_OUTCOMES } from "./status-BZhIWoE0.mjs";
|
|
2
|
+
// src/sql/sql-list.ts
|
|
3
|
+
const IDENTIFIER_LIKE = /^[a-z_]+$/;
|
|
4
|
+
function sqlLiteralList(values) {
|
|
5
|
+
for (const value of values) if (!IDENTIFIER_LIKE.test(value)) throw new Error(`sqlLiteralList: refusing non-constant value ${JSON.stringify(value)}`);
|
|
6
|
+
return values.map((value) => `'${value}'`).join(", ");
|
|
7
|
+
}
|
|
8
|
+
// src/sql/migrations.ts
|
|
9
|
+
const jobStatuses = sqlLiteralList(JOB_STATUSES);
|
|
10
|
+
const claimable = sqlLiteralList(CLAIMABLE_STATUSES);
|
|
11
|
+
const terminal = sqlLiteralList(TERMINAL_STATUSES);
|
|
12
|
+
const reasonedTerminal = sqlLiteralList(REASONED_TERMINAL_STATUSES);
|
|
13
|
+
/** PostgreSQL 17: settlement relies on `transaction_timeout`. */
|
|
14
|
+
const MIN_SERVER_VERSION_NUM = 17e4;
|
|
15
|
+
/** Fails setup on an older server, before any object is created. */
|
|
16
|
+
function serverVersionGuard(minimum) {
|
|
17
|
+
return `DO $guard$
|
|
18
|
+
BEGIN
|
|
19
|
+
IF current_setting('server_version_num')::int < ${minimum} THEN
|
|
20
|
+
RAISE EXCEPTION 'workers-outbox requires PostgreSQL ${Math.floor(minimum / 1e4)} or newer; this server is %', current_setting('server_version')
|
|
21
|
+
USING ERRCODE = 'feature_not_supported';
|
|
22
|
+
END IF;
|
|
23
|
+
END
|
|
24
|
+
$guard$`;
|
|
25
|
+
}
|
|
26
|
+
const OUTBOX_MIGRATIONS = [{
|
|
27
|
+
id: "0001_init",
|
|
28
|
+
statements: [
|
|
29
|
+
serverVersionGuard(MIN_SERVER_VERSION_NUM),
|
|
30
|
+
`CREATE SCHEMA "outbox"`,
|
|
31
|
+
`CREATE TABLE "outbox"."job" (
|
|
32
|
+
"id" uuid PRIMARY KEY,
|
|
33
|
+
"kind" text NOT NULL,
|
|
34
|
+
"dedupe_key" text NOT NULL,
|
|
35
|
+
"payload" jsonb NOT NULL,
|
|
36
|
+
"status" text DEFAULT 'pending' NOT NULL,
|
|
37
|
+
"tenant_key" text,
|
|
38
|
+
"correlation_id" text,
|
|
39
|
+
"metadata" jsonb DEFAULT '{}' NOT NULL,
|
|
40
|
+
"available_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
|
41
|
+
"next_attempt_at" timestamp(3) with time zone,
|
|
42
|
+
"lease_token" uuid,
|
|
43
|
+
"leased_until" timestamp(3) with time zone,
|
|
44
|
+
"started_attempts" integer DEFAULT 0 NOT NULL,
|
|
45
|
+
"finished_attempts" integer DEFAULT 0 NOT NULL,
|
|
46
|
+
"spent_attempts" integer DEFAULT 0 NOT NULL,
|
|
47
|
+
"code_streak" integer DEFAULT 0 NOT NULL,
|
|
48
|
+
"sticky_code" text,
|
|
49
|
+
"min_version_ms" bigint,
|
|
50
|
+
"last_error_code" text,
|
|
51
|
+
"last_error_message" text,
|
|
52
|
+
"terminal_reason" text,
|
|
53
|
+
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
|
54
|
+
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
|
55
|
+
"finished_at" timestamp(3) with time zone,
|
|
56
|
+
"nudge_sent_at" timestamp(3) with time zone,
|
|
57
|
+
"nudge_attempts" integer DEFAULT 0 NOT NULL,
|
|
58
|
+
"last_nudge_error" text,
|
|
59
|
+
"ordering_key" text,
|
|
60
|
+
"seq" bigint GENERATED ALWAYS AS IDENTITY NOT NULL,
|
|
61
|
+
CONSTRAINT "job_status_check" CHECK ("status" IN (${jobStatuses})),
|
|
62
|
+
CONSTRAINT "job_retrying_requires_next_attempt_at" CHECK ("status" <> 'retrying' OR "next_attempt_at" IS NOT NULL),
|
|
63
|
+
CONSTRAINT "job_processing_iff_leased" CHECK (("status" = 'processing') = ("lease_token" IS NOT NULL AND "leased_until" IS NOT NULL)),
|
|
64
|
+
CONSTRAINT "job_attempt_counters" CHECK ("finished_attempts" >= 0 AND "finished_attempts" <= "started_attempts" AND "spent_attempts" >= 0 AND "spent_attempts" <= "finished_attempts" AND "code_streak" >= 0),
|
|
65
|
+
CONSTRAINT "job_terminal_iff_finished_at" CHECK (("status" IN (${terminal})) = ("finished_at" IS NOT NULL)),
|
|
66
|
+
CONSTRAINT "job_terminal_reason" CHECK (("status" IN (${reasonedTerminal})) = ("terminal_reason" IS NOT NULL))
|
|
67
|
+
)`,
|
|
68
|
+
`CREATE TABLE "outbox"."attempt" (
|
|
69
|
+
"job_id" uuid NOT NULL,
|
|
70
|
+
"attempt_no" integer NOT NULL,
|
|
71
|
+
"kind" text NOT NULL,
|
|
72
|
+
"lease_token" uuid NOT NULL,
|
|
73
|
+
"trigger" text NOT NULL,
|
|
74
|
+
"started_at" timestamp(3) with time zone NOT NULL,
|
|
75
|
+
"finished_at" timestamp(3) with time zone,
|
|
76
|
+
"outcome" text,
|
|
77
|
+
"code" text,
|
|
78
|
+
"message" text,
|
|
79
|
+
"duration_ms" integer,
|
|
80
|
+
CONSTRAINT "attempt_pkey" PRIMARY KEY ("job_id", "attempt_no"),
|
|
81
|
+
CONSTRAINT "attempt_job_id_fkey" FOREIGN KEY ("job_id") REFERENCES "outbox"."job" ("id") ON DELETE CASCADE,
|
|
82
|
+
CONSTRAINT "attempt_trigger_check" CHECK ("trigger" IN (${sqlLiteralList(ATTEMPT_TRIGGERS)})),
|
|
83
|
+
CONSTRAINT "attempt_outcome_check" CHECK ("outcome" IS NULL OR "outcome" IN (${sqlLiteralList(ATTEMPT_OUTCOMES)})),
|
|
84
|
+
CONSTRAINT "attempt_finished_iff_outcome" CHECK (("outcome" IS NULL) = ("finished_at" IS NULL))
|
|
85
|
+
)`,
|
|
86
|
+
`CREATE UNIQUE INDEX "job_tenant_dedupe_key_unique" ON "outbox"."job" (COALESCE("tenant_key", ''), "dedupe_key")`,
|
|
87
|
+
`CREATE INDEX "job_claim_due_idx" ON "outbox"."job" (COALESCE("next_attempt_at", "available_at"), "created_at", "id") WHERE "status" IN (${claimable})`,
|
|
88
|
+
`CREATE INDEX "job_pending_kind_idx" ON "outbox"."job" ("kind", "available_at", "created_at") WHERE "status" = 'pending'`,
|
|
89
|
+
`CREATE INDEX "job_retrying_kind_idx" ON "outbox"."job" ("kind", "next_attempt_at", "created_at") WHERE "status" = 'retrying'`,
|
|
90
|
+
`CREATE INDEX "job_lease_expiry_idx" ON "outbox"."job" ("leased_until") WHERE "status" = 'processing'`,
|
|
91
|
+
`CREATE INDEX "job_retention_idx" ON "outbox"."job" ("status", "finished_at") WHERE "status" IN (${terminal})`,
|
|
92
|
+
`CREATE INDEX "job_tenant_key_idx" ON "outbox"."job" ("tenant_key") WHERE "tenant_key" IS NOT NULL`,
|
|
93
|
+
`CREATE INDEX "job_correlation_id_idx" ON "outbox"."job" ("correlation_id") WHERE "correlation_id" IS NOT NULL`,
|
|
94
|
+
`CREATE INDEX "job_status_created_idx" ON "outbox"."job" ("status", "created_at", "id")`,
|
|
95
|
+
`CREATE INDEX "attempt_finished_at_idx" ON "outbox"."attempt" ("finished_at") WHERE "finished_at" IS NOT NULL`
|
|
96
|
+
]
|
|
97
|
+
}];
|
|
98
|
+
export { OUTBOX_MIGRATIONS as n, sqlLiteralList as r, MIN_SERVER_VERSION_NUM as t };
|
package/dist/pg.d.mts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { n as LaneConnection } from "./types-ClomUJ_8.mjs";
|
|
2
|
+
import { ClientConfig, Pool } from "pg";
|
|
3
|
+
export interface PgConnectorOptions {
|
|
4
|
+
/**
|
|
5
|
+
* Errors a session raises outside a query, e.g. the FATAL `transaction_timeout` sends when it
|
|
6
|
+
* ends a settlement past its deadline. They are always caught (node-pg would otherwise crash the
|
|
7
|
+
* process); this only observes them. The outbox has already seen the failed query.
|
|
8
|
+
*/
|
|
9
|
+
onError?: (error: Error) => void;
|
|
10
|
+
}
|
|
11
|
+
export interface PgClientConnectorOptions extends PgConnectorOptions {
|
|
12
|
+
/** Bound on opening one session. Default 10 s. */
|
|
13
|
+
connectionTimeoutMs?: number;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* A fresh `pg.Client` per lane — the shape Hyperdrive expects, since it pools upstream:
|
|
17
|
+
* `createPgConnector(env.OUTBOX_DB.connectionString)`. `close()` and `destroy()` both end the
|
|
18
|
+
* session (a client is never reused); a query still running server-side runs until it next
|
|
19
|
+
* writes to the closed socket, which the settlement's own `transaction_timeout` bounds.
|
|
20
|
+
*/
|
|
21
|
+
export declare function createPgConnector(config: string | ClientConfig, options?: PgClientConnectorOptions): () => Promise<LaneConnection>;
|
|
22
|
+
/**
|
|
23
|
+
* Checks clients out of a pool you own (Node, or a long-lived Worker-side pool). Set the pool's
|
|
24
|
+
* `connectionTimeoutMillis`: the outbox bounds its own wait, but a checkout that never returns
|
|
25
|
+
* still holds a pool slot. Also attach `pool.on('error', …)`: node-pg re-emits a returned
|
|
26
|
+
* client's late errors (a FATAL after release) on the pool, which crashes the process unlistened.
|
|
27
|
+
* `close()` returns the client; `destroy()` discards it
|
|
28
|
+
* (`release(true)`), so a session abandoned mid-query is never reused.
|
|
29
|
+
*/
|
|
30
|
+
export declare function createPgPoolConnector(pool: Pool, options?: PgConnectorOptions): () => Promise<LaneConnection>;
|
package/dist/pg.mjs
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { drizzle } from "drizzle-orm/node-postgres";
|
|
2
|
+
import { Client } from "pg";
|
|
3
|
+
// src/pg/index.ts
|
|
4
|
+
const DEFAULT_CONNECTION_TIMEOUT_MS = 1e4;
|
|
5
|
+
/**
|
|
6
|
+
* A fresh `pg.Client` per lane — the shape Hyperdrive expects, since it pools upstream:
|
|
7
|
+
* `createPgConnector(env.OUTBOX_DB.connectionString)`. `close()` and `destroy()` both end the
|
|
8
|
+
* session (a client is never reused); a query still running server-side runs until it next
|
|
9
|
+
* writes to the closed socket, which the settlement's own `transaction_timeout` bounds.
|
|
10
|
+
*/
|
|
11
|
+
function createPgConnector(config, options = {}) {
|
|
12
|
+
const base = typeof config === "string" ? { connectionString: config } : config;
|
|
13
|
+
const connectionTimeoutMillis = base.connectionTimeoutMillis ?? options.connectionTimeoutMs ?? DEFAULT_CONNECTION_TIMEOUT_MS;
|
|
14
|
+
return async () => {
|
|
15
|
+
const client = new Client({
|
|
16
|
+
...base,
|
|
17
|
+
connectionTimeoutMillis
|
|
18
|
+
});
|
|
19
|
+
let broken = false;
|
|
20
|
+
client.on("error", (error) => {
|
|
21
|
+
broken = true;
|
|
22
|
+
options.onError?.(error);
|
|
23
|
+
});
|
|
24
|
+
try {
|
|
25
|
+
await client.connect();
|
|
26
|
+
} catch (error) {
|
|
27
|
+
await client.end().catch(() => void 0);
|
|
28
|
+
throw error;
|
|
29
|
+
}
|
|
30
|
+
const end = once(() => client.end());
|
|
31
|
+
return {
|
|
32
|
+
db: drizzleOf(client),
|
|
33
|
+
close: end,
|
|
34
|
+
destroy: end,
|
|
35
|
+
isBroken: () => broken
|
|
36
|
+
};
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Checks clients out of a pool you own (Node, or a long-lived Worker-side pool). Set the pool's
|
|
41
|
+
* `connectionTimeoutMillis`: the outbox bounds its own wait, but a checkout that never returns
|
|
42
|
+
* still holds a pool slot. Also attach `pool.on('error', …)`: node-pg re-emits a returned
|
|
43
|
+
* client's late errors (a FATAL after release) on the pool, which crashes the process unlistened.
|
|
44
|
+
* `close()` returns the client; `destroy()` discards it
|
|
45
|
+
* (`release(true)`), so a session abandoned mid-query is never reused.
|
|
46
|
+
*/
|
|
47
|
+
function createPgPoolConnector(pool, options = {}) {
|
|
48
|
+
return async () => {
|
|
49
|
+
const client = await pool.connect();
|
|
50
|
+
let broken = false;
|
|
51
|
+
const onError = (error) => {
|
|
52
|
+
broken = true;
|
|
53
|
+
options.onError?.(error);
|
|
54
|
+
};
|
|
55
|
+
client.on("error", onError);
|
|
56
|
+
const release = (destroy) => once(async () => {
|
|
57
|
+
client.off("error", onError);
|
|
58
|
+
client.release(destroy);
|
|
59
|
+
});
|
|
60
|
+
let ended;
|
|
61
|
+
const close = release(false);
|
|
62
|
+
const destroy = release(true);
|
|
63
|
+
return {
|
|
64
|
+
db: drizzleOf(client),
|
|
65
|
+
close: () => ended ??= broken ? destroy() : close(),
|
|
66
|
+
destroy: () => ended ??= destroy(),
|
|
67
|
+
isBroken: () => broken
|
|
68
|
+
};
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
const drizzleOf = (client) => drizzle({ client });
|
|
72
|
+
function once(fn) {
|
|
73
|
+
let done;
|
|
74
|
+
return () => done ??= fn().catch(() => void 0);
|
|
75
|
+
}
|
|
76
|
+
export { createPgConnector, createPgPoolConnector };
|
package/dist/sql.d.mts
ADDED
package/dist/sql.mjs
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { n as OUTBOX_MIGRATIONS } from "./migrations-CX7O2Lcq.mjs";
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// src/core/status.ts
|
|
2
|
+
const JOB_STATUSES = [
|
|
3
|
+
"pending",
|
|
4
|
+
"processing",
|
|
5
|
+
"retrying",
|
|
6
|
+
"succeeded",
|
|
7
|
+
"dead",
|
|
8
|
+
"cancelled"
|
|
9
|
+
];
|
|
10
|
+
const CLAIMABLE_STATUSES = ["pending", "retrying"];
|
|
11
|
+
const TERMINAL_STATUSES = [
|
|
12
|
+
"succeeded",
|
|
13
|
+
"dead",
|
|
14
|
+
"cancelled"
|
|
15
|
+
];
|
|
16
|
+
const REASONED_TERMINAL_STATUSES = ["dead", "cancelled"];
|
|
17
|
+
const ATTEMPT_TRIGGERS = [
|
|
18
|
+
"nudge",
|
|
19
|
+
"sweep",
|
|
20
|
+
"drain"
|
|
21
|
+
];
|
|
22
|
+
const ATTEMPT_OUTCOMES = [
|
|
23
|
+
"succeeded",
|
|
24
|
+
"retry",
|
|
25
|
+
"failed",
|
|
26
|
+
"cancelled",
|
|
27
|
+
"timeout",
|
|
28
|
+
"lease_expired"
|
|
29
|
+
];
|
|
30
|
+
export { REASONED_TERMINAL_STATUSES as a, JOB_STATUSES as i, ATTEMPT_TRIGGERS as n, TERMINAL_STATUSES as o, CLAIMABLE_STATUSES as r, ATTEMPT_OUTCOMES as t };
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { n as LaneConnection, t as Clock } from "./types-ClomUJ_8.mjs";
|
|
2
|
+
import { o as Outbox, v as OutcomeTally } from "./create-outbox-C4vdn6Dr.mjs";
|
|
3
|
+
import { t as SqlRunner } from "./catalog-DzvOuOHQ.mjs";
|
|
4
|
+
import { PGlite } from "@electric-sql/pglite";
|
|
5
|
+
/**
|
|
6
|
+
* An in-memory pglite with the outbox schema applied. pglite is one session: it can't reproduce multi-connection races such as SKIP LOCKED contention.
|
|
7
|
+
*/
|
|
8
|
+
export declare function createOutboxTestDatabase(): Promise<{
|
|
9
|
+
db: import("drizzle-orm/pglite").PgliteDatabase<import("drizzle-orm").EmptyRelations> & {
|
|
10
|
+
$client: PGlite & (Record<string, never> | {
|
|
11
|
+
[x: string]: never;
|
|
12
|
+
});
|
|
13
|
+
};
|
|
14
|
+
client: PGlite & (Record<string, never> | {
|
|
15
|
+
[x: string]: never;
|
|
16
|
+
});
|
|
17
|
+
/** Pass as `createOutbox({ connect })`. */
|
|
18
|
+
connect: () => Promise<LaneConnection>;
|
|
19
|
+
reset: () => Promise<void>;
|
|
20
|
+
close: () => Promise<void>;
|
|
21
|
+
}>;
|
|
22
|
+
type OutboxTestDatabase = Awaited<ReturnType<typeof createOutboxTestDatabase>>;
|
|
23
|
+
interface TestClock extends Clock {
|
|
24
|
+
/** Moves time forward; the database sees it too (`dbNow`), so due checks follow. */
|
|
25
|
+
advance(ms: number): void;
|
|
26
|
+
current(): Date;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* A frozen clock for tests: pass it to `createOutbox({ clock })` and to `runUntilIdle`. Sleeps
|
|
30
|
+
* resolve at once; the runtime still bounds every wait in real time, so a frozen clock can't hang.
|
|
31
|
+
*/
|
|
32
|
+
export declare function createTestClock(start?: Date): TestClock;
|
|
33
|
+
interface RunUntilIdleOptions {
|
|
34
|
+
/**
|
|
35
|
+
* With a test clock (the one the outbox was created with), time skips ahead to the next retry,
|
|
36
|
+
* delayed job or expired lease until nothing is left. Without one, only jobs due now run.
|
|
37
|
+
*/
|
|
38
|
+
clock?: TestClock;
|
|
39
|
+
/** Reads the next due instant; required with `clock`. */
|
|
40
|
+
db?: SqlRunner;
|
|
41
|
+
/** Throws past this many rounds, so an always-retrying job can't hang a test. Default 100. */
|
|
42
|
+
maxRounds?: number;
|
|
43
|
+
}
|
|
44
|
+
interface IdleReport extends OutcomeTally {
|
|
45
|
+
rounds: number;
|
|
46
|
+
}
|
|
47
|
+
/** Runs every job this outbox can run, then returns the totals. */
|
|
48
|
+
export declare function runUntilIdle(outbox: Outbox, options?: RunUntilIdleOptions): Promise<IdleReport>;
|
|
49
|
+
export type { IdleReport, OutboxTestDatabase, RunUntilIdleOptions, TestClock };
|
package/dist/testing.mjs
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { n as OUTBOX_MIGRATIONS } from "./migrations-CX7O2Lcq.mjs";
|
|
2
|
+
import { t as queryRows } from "./catalog-G6w4wb_w.mjs";
|
|
3
|
+
import { n as RUNTIME } from "./create-outbox-BTMmdvLs.mjs";
|
|
4
|
+
import { sql } from "drizzle-orm";
|
|
5
|
+
import { PGlite } from "@electric-sql/pglite";
|
|
6
|
+
import { drizzle } from "drizzle-orm/pglite";
|
|
7
|
+
// src/testing/create-outbox-test-database.ts
|
|
8
|
+
/**
|
|
9
|
+
* An in-memory pglite with the outbox schema applied. pglite is one session: it can't reproduce multi-connection races such as SKIP LOCKED contention.
|
|
10
|
+
*/
|
|
11
|
+
async function createOutboxTestDatabase() {
|
|
12
|
+
const client = await PGlite.create();
|
|
13
|
+
for (const migration of OUTBOX_MIGRATIONS) for (const statement of migration.statements) await client.exec(statement);
|
|
14
|
+
const db = drizzle({ client });
|
|
15
|
+
const lane = {
|
|
16
|
+
db,
|
|
17
|
+
close: async () => void 0,
|
|
18
|
+
destroy: async () => void 0
|
|
19
|
+
};
|
|
20
|
+
return {
|
|
21
|
+
db,
|
|
22
|
+
client,
|
|
23
|
+
/** Pass as `createOutbox({ connect })`. */
|
|
24
|
+
connect: async () => lane,
|
|
25
|
+
reset: async () => {
|
|
26
|
+
await db.execute(sql`TRUNCATE "outbox"."job" CASCADE`);
|
|
27
|
+
},
|
|
28
|
+
close: () => client.close()
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
// src/testing/test-clock.ts
|
|
32
|
+
/**
|
|
33
|
+
* A frozen clock for tests: pass it to `createOutbox({ clock })` and to `runUntilIdle`. Sleeps
|
|
34
|
+
* resolve at once; the runtime still bounds every wait in real time, so a frozen clock can't hang.
|
|
35
|
+
*/
|
|
36
|
+
function createTestClock(start = /* @__PURE__ */ new Date()) {
|
|
37
|
+
let current = new Date(start.getTime());
|
|
38
|
+
return {
|
|
39
|
+
dbNow: () => current,
|
|
40
|
+
nowMs: () => current.getTime(),
|
|
41
|
+
sleep: async () => void 0,
|
|
42
|
+
advance: (ms) => {
|
|
43
|
+
current = new Date(current.getTime() + ms);
|
|
44
|
+
},
|
|
45
|
+
current: () => current
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
// src/testing/run-until-idle.ts
|
|
49
|
+
const TALLY_KEYS = [
|
|
50
|
+
"claimed",
|
|
51
|
+
"succeeded",
|
|
52
|
+
"retrying",
|
|
53
|
+
"dead",
|
|
54
|
+
"cancelled",
|
|
55
|
+
"leaseLost",
|
|
56
|
+
"settleFailed"
|
|
57
|
+
];
|
|
58
|
+
const BUDGET = {
|
|
59
|
+
maxJobs: 1e4,
|
|
60
|
+
deadlineMs: 6e5
|
|
61
|
+
};
|
|
62
|
+
/** Runs every job this outbox can run, then returns the totals. */
|
|
63
|
+
async function runUntilIdle(outbox, options = {}) {
|
|
64
|
+
const { clock, db, maxRounds = 100 } = options;
|
|
65
|
+
if (clock && !db) throw new Error("runUntilIdle: pass `db` with `clock`, to find the next due job");
|
|
66
|
+
const runtime = outbox[RUNTIME];
|
|
67
|
+
const totals = {
|
|
68
|
+
rounds: 0,
|
|
69
|
+
...Object.fromEntries(TALLY_KEYS.map((k) => [k, 0]))
|
|
70
|
+
};
|
|
71
|
+
const add = (report) => {
|
|
72
|
+
for (const key of TALLY_KEYS) totals[key] += report[key];
|
|
73
|
+
};
|
|
74
|
+
for (;;) {
|
|
75
|
+
if (totals.rounds >= maxRounds) throw new Error(`runUntilIdle: still busy after ${maxRounds} rounds (${JSON.stringify(totals)})`);
|
|
76
|
+
totals.rounds++;
|
|
77
|
+
const drained = await runtime.drain(BUDGET, "drain");
|
|
78
|
+
add(drained);
|
|
79
|
+
if (drained.claimed > 0) continue;
|
|
80
|
+
if (!clock || !db) return totals;
|
|
81
|
+
const recovered = await runtime.recover(BUDGET.maxJobs);
|
|
82
|
+
add(recovered);
|
|
83
|
+
if (TALLY_KEYS.some((key) => recovered[key] > 0)) continue;
|
|
84
|
+
const next = await nextWakeAt(db);
|
|
85
|
+
if (next === null || next <= clock.nowMs()) return totals;
|
|
86
|
+
clock.advance(next - clock.nowMs());
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
async function nextWakeAt(db) {
|
|
90
|
+
const [row] = await queryRows(db, sql`SELECT LEAST(
|
|
91
|
+
(SELECT min(COALESCE(next_attempt_at, available_at)) FROM "outbox"."job"
|
|
92
|
+
WHERE status IN ('pending', 'retrying')),
|
|
93
|
+
(SELECT min(leased_until) FROM "outbox"."job" WHERE status = 'processing')
|
|
94
|
+
) AS at, now() AS read_at`);
|
|
95
|
+
return row?.at == null ? null : new Date(row.at).getTime();
|
|
96
|
+
}
|
|
97
|
+
export { createOutboxTestDatabase, createTestClock, runUntilIdle };
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { t as SqlRunner } from "./catalog-DzvOuOHQ.mjs";
|
|
2
|
+
interface OutboxDb extends SqlRunner {
|
|
3
|
+
transaction<T>(fn: (tx: OutboxDb) => Promise<T>): Promise<T>;
|
|
4
|
+
}
|
|
5
|
+
/** One client per lane per invocation: lanes never share a Hyperdrive session. */
|
|
6
|
+
interface LaneConnection {
|
|
7
|
+
db: OutboxDb;
|
|
8
|
+
/** Returns a healthy client (e.g. `client.release()`). */
|
|
9
|
+
close(): Promise<void>;
|
|
10
|
+
/**
|
|
11
|
+
* Discards the session: it must never be reused (e.g. `client.release(true)`, `client.end()`).
|
|
12
|
+
* Called for a runaway or timed-out lane, before its replacement is acquired, so a one-client
|
|
13
|
+
* pool can still reconnect. Must be idempotent, and make a later `close()` a no-op.
|
|
14
|
+
*/
|
|
15
|
+
destroy(): Promise<void>;
|
|
16
|
+
/**
|
|
17
|
+
* Optional: true once the session is known dead (e.g. its socket errored while idle). The lane
|
|
18
|
+
* is then replaced before its next claim or settlement instead of failing a round trip first.
|
|
19
|
+
*/
|
|
20
|
+
isBroken?(): boolean;
|
|
21
|
+
}
|
|
22
|
+
interface QueueSender {
|
|
23
|
+
send(body: unknown, options?: {
|
|
24
|
+
delaySeconds?: number;
|
|
25
|
+
}): Promise<unknown>;
|
|
26
|
+
}
|
|
27
|
+
interface Clock {
|
|
28
|
+
/** `null` = the database's own `now()`. */
|
|
29
|
+
dbNow(): Date | null;
|
|
30
|
+
nowMs(): number;
|
|
31
|
+
sleep(ms: number): Promise<void>;
|
|
32
|
+
}
|
|
33
|
+
export { QueueSender as i, LaneConnection as n, OutboxDb as r, Clock as t };
|