@beignet/provider-db-drizzle 0.0.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/CHANGELOG.md +82 -0
  2. package/README.md +1046 -0
  3. package/dist/mysql/index.d.ts +254 -0
  4. package/dist/mysql/index.d.ts.map +1 -0
  5. package/dist/mysql/index.js +348 -0
  6. package/dist/mysql/index.js.map +1 -0
  7. package/dist/postgres/index.d.ts +240 -0
  8. package/dist/postgres/index.d.ts.map +1 -0
  9. package/dist/postgres/index.js +296 -0
  10. package/dist/postgres/index.js.map +1 -0
  11. package/dist/shared/idempotency-core.d.ts +71 -0
  12. package/dist/shared/idempotency-core.d.ts.map +1 -0
  13. package/dist/shared/idempotency-core.js +169 -0
  14. package/dist/shared/idempotency-core.js.map +1 -0
  15. package/dist/shared/identifiers.d.ts +20 -0
  16. package/dist/shared/identifiers.d.ts.map +1 -0
  17. package/dist/shared/identifiers.js +27 -0
  18. package/dist/shared/identifiers.js.map +1 -0
  19. package/dist/shared/instrumentation.d.ts +19 -0
  20. package/dist/shared/instrumentation.d.ts.map +1 -0
  21. package/dist/shared/instrumentation.js +41 -0
  22. package/dist/shared/instrumentation.js.map +1 -0
  23. package/dist/shared/outbox-core.d.ts +76 -0
  24. package/dist/shared/outbox-core.d.ts.map +1 -0
  25. package/dist/shared/outbox-core.js +193 -0
  26. package/dist/shared/outbox-core.js.map +1 -0
  27. package/dist/shared/rows.d.ts +84 -0
  28. package/dist/shared/rows.d.ts.map +1 -0
  29. package/dist/shared/rows.js +128 -0
  30. package/dist/shared/rows.js.map +1 -0
  31. package/dist/sqlite/index.d.ts +235 -0
  32. package/dist/sqlite/index.d.ts.map +1 -0
  33. package/dist/sqlite/index.js +293 -0
  34. package/dist/sqlite/index.js.map +1 -0
  35. package/package.json +173 -0
  36. package/src/mysql/index.ts +627 -0
  37. package/src/postgres/index.ts +572 -0
  38. package/src/shared/idempotency-core.ts +280 -0
  39. package/src/shared/identifiers.ts +28 -0
  40. package/src/shared/instrumentation.ts +49 -0
  41. package/src/shared/outbox-core.ts +322 -0
  42. package/src/shared/rows.ts +197 -0
  43. package/src/sqlite/index.ts +547 -0
@@ -0,0 +1,280 @@
1
+ /**
2
+ * Dialect-independent idempotency port orchestration.
3
+ *
4
+ * Every Drizzle dialect (SQLite, Postgres, MySQL) implements the small
5
+ * `IdempotencySqlExecutor` seam below; reservation, replay, conflict, and
6
+ * mutation-guard semantics live here so they stay byte-identical across
7
+ * dialects. The dialect-specific mutation error class is injected so callers
8
+ * keep catching the error type their dialect package exports.
9
+ *
10
+ * This module must never import dialect database types — only `SQL` from
11
+ * drizzle-orm and core ports from `@beignet/core`.
12
+ */
13
+
14
+ import {
15
+ createIdempotencyStorageKey,
16
+ type IdempotencyCompleteInput,
17
+ type IdempotencyFailInput,
18
+ type IdempotencyPort,
19
+ type IdempotencyReservation,
20
+ type IdempotencyReserveInput,
21
+ normalizeIdempotencyScope,
22
+ } from "@beignet/core/idempotency";
23
+ import { type SQL, sql } from "drizzle-orm";
24
+ import type { SqlExecutorBase } from "./outbox-core.js";
25
+ import {
26
+ encodeTimestamp,
27
+ type IdempotencyRow,
28
+ idempotencyReservationFromRow,
29
+ } from "./rows.js";
30
+
31
+ /**
32
+ * SQL execution seam for the shared idempotency core.
33
+ */
34
+ export interface IdempotencySqlExecutor
35
+ extends SqlExecutorBase<IdempotencySqlExecutor> {
36
+ /**
37
+ * Insert statement prefix for conflict-tolerant reservation inserts.
38
+ *
39
+ * SQLite: `insert or ignore into`. Postgres/MySQL: `insert into`.
40
+ */
41
+ insertPrefix: SQL;
42
+
43
+ /**
44
+ * Insert statement suffix for conflict-tolerant reservation inserts.
45
+ *
46
+ * SQLite: empty. Postgres: `on conflict (storage_key) do nothing`.
47
+ * MySQL: `on duplicate key update storage_key = storage_key`.
48
+ */
49
+ insertConflictSuffix: SQL;
50
+ }
51
+
52
+ /**
53
+ * Options for the shared idempotency core.
54
+ */
55
+ export interface IdempotencyPortCoreOptions {
56
+ /**
57
+ * Clock used by tests and deterministic environments.
58
+ */
59
+ now?: () => Date;
60
+ }
61
+
62
+ /**
63
+ * Arguments for a dialect's idempotency mutation error class.
64
+ */
65
+ export interface IdempotencyMutationErrorArgs {
66
+ action: "complete" | "fail";
67
+ namespace: string;
68
+ key: string;
69
+ scopeKey: string;
70
+ }
71
+
72
+ /**
73
+ * Constructor shape for the dialect-specific mutation error class injected
74
+ * into `createIdempotencyPortCore(...)`.
75
+ */
76
+ export type IdempotencyMutationErrorConstructor = new (
77
+ args: IdempotencyMutationErrorArgs,
78
+ ) => Error;
79
+
80
+ /**
81
+ * Build the mutation error message. Shared so the wording is identical
82
+ * across every dialect's mutation error class.
83
+ */
84
+ export function formatIdempotencyMutationErrorMessage(
85
+ args: IdempotencyMutationErrorArgs,
86
+ ): string {
87
+ return `Idempotency ${args.action} for key "${args.key}" in namespace "${args.namespace}" matched no in-progress reservation with this fingerprint. The reservation may be missing, expired, already completed or released, or reserved with a different fingerprint.`;
88
+ }
89
+
90
+ function nowFrom(options: IdempotencyPortCoreOptions): Date {
91
+ return options.now?.() ?? new Date();
92
+ }
93
+
94
+ function assertNonEmptyString(name: string, value: string): void {
95
+ if (typeof value !== "string" || value.trim().length === 0) {
96
+ throw new Error(`${name} must be a non-empty string`);
97
+ }
98
+ }
99
+
100
+ function assertIdempotencyTtl(ttlSec: number | undefined): void {
101
+ if (ttlSec === undefined) return;
102
+ if (!Number.isInteger(ttlSec) || ttlSec <= 0) {
103
+ throw new Error("ttlSec must be a positive integer when provided");
104
+ }
105
+ }
106
+
107
+ function resolveIdempotencyExpiresAt(
108
+ ttlSec: number | undefined,
109
+ now: Date,
110
+ ): Date | null {
111
+ assertIdempotencyTtl(ttlSec);
112
+ return ttlSec === undefined ? null : new Date(now.getTime() + ttlSec * 1000);
113
+ }
114
+
115
+ function validateReserveInput(input: IdempotencyReserveInput): void {
116
+ assertNonEmptyString("namespace", input.namespace);
117
+ assertNonEmptyString("key", input.key);
118
+ assertNonEmptyString("fingerprint", input.fingerprint);
119
+ assertIdempotencyTtl(input.ttlSec);
120
+ }
121
+
122
+ function validateMutationInput(
123
+ input: IdempotencyCompleteInput | IdempotencyFailInput,
124
+ ): void {
125
+ assertNonEmptyString("namespace", input.namespace);
126
+ assertNonEmptyString("key", input.key);
127
+ assertNonEmptyString("fingerprint", input.fingerprint);
128
+ }
129
+
130
+ /**
131
+ * Create the dialect-independent idempotency port orchestration on top of a
132
+ * dialect's `IdempotencySqlExecutor`.
133
+ *
134
+ * The port accepts both root databases and transaction clients (via the
135
+ * executor), so applications can expose root idempotency for ordinary
136
+ * retry-safe commands and transaction-scoped idempotency from Unit of Work.
137
+ */
138
+ export function createIdempotencyPortCore(
139
+ executor: IdempotencySqlExecutor,
140
+ options: IdempotencyPortCoreOptions,
141
+ MutationErrorClass: IdempotencyMutationErrorConstructor,
142
+ ): IdempotencyPort {
143
+ /**
144
+ * Verify that an idempotency mutation matched an in-progress reservation.
145
+ *
146
+ * Like `reserve(...)`, a missing `rowsAffected` from the driver is treated
147
+ * as success; the assertion only fires when the driver positively reports
148
+ * that no row matched.
149
+ */
150
+ function assertIdempotencyMutationSucceeded(
151
+ action: "complete" | "fail",
152
+ input: IdempotencyCompleteInput | IdempotencyFailInput,
153
+ rowsAffected: number | undefined,
154
+ ): void {
155
+ if (rowsAffected === undefined || rowsAffected > 0) {
156
+ return;
157
+ }
158
+
159
+ throw new MutationErrorClass({
160
+ action,
161
+ namespace: input.namespace,
162
+ key: input.key,
163
+ scopeKey: normalizeIdempotencyScope(input.scope),
164
+ });
165
+ }
166
+
167
+ async function reserveWith(
168
+ tx: IdempotencySqlExecutor,
169
+ input: IdempotencyReserveInput,
170
+ ): Promise<IdempotencyReservation> {
171
+ validateReserveInput(input);
172
+
173
+ const now = nowFrom(options);
174
+ const nowIso = encodeTimestamp(now);
175
+ const expiresAt = resolveIdempotencyExpiresAt(input.ttlSec, now);
176
+ const storageKey = createIdempotencyStorageKey(input);
177
+ const scopeKey = normalizeIdempotencyScope(input.scope);
178
+
179
+ await tx.run(sql`delete from ${tx.table}
180
+ where storage_key = ${storageKey}
181
+ and expires_at is not null
182
+ and expires_at <= ${nowIso}`);
183
+
184
+ const rowsAffected = await tx.run(sql`${tx.insertPrefix} ${tx.table} (
185
+ storage_key,
186
+ namespace,
187
+ idempotency_key,
188
+ scope_key,
189
+ fingerprint,
190
+ status,
191
+ result_json,
192
+ reserved_at,
193
+ completed_at,
194
+ expires_at,
195
+ updated_at
196
+ ) values (
197
+ ${storageKey},
198
+ ${input.namespace},
199
+ ${input.key},
200
+ ${scopeKey},
201
+ ${input.fingerprint},
202
+ 'in-progress',
203
+ null,
204
+ ${nowIso},
205
+ null,
206
+ ${expiresAt ? encodeTimestamp(expiresAt) : null},
207
+ ${nowIso}
208
+ )${tx.insertConflictSuffix}`);
209
+
210
+ if (rowsAffected === undefined || rowsAffected > 0) {
211
+ return {
212
+ status: "reserved",
213
+ namespace: input.namespace,
214
+ key: input.key,
215
+ scopeKey,
216
+ fingerprint: input.fingerprint,
217
+ reservedAt: now,
218
+ expiresAt,
219
+ };
220
+ }
221
+
222
+ const row = (await tx.row(
223
+ sql`select * from ${tx.table} where storage_key = ${storageKey}`,
224
+ )) as IdempotencyRow | undefined;
225
+ if (!row) {
226
+ throw new Error(
227
+ `Idempotency reservation "${input.key}" could not be read after insert conflict.`,
228
+ );
229
+ }
230
+
231
+ return idempotencyReservationFromRow(row, input.fingerprint);
232
+ }
233
+
234
+ return {
235
+ reserve(input) {
236
+ const transactional = executor.withTransaction((tx) =>
237
+ reserveWith(tx, input),
238
+ );
239
+ if (transactional !== undefined) {
240
+ return transactional;
241
+ }
242
+
243
+ return reserveWith(executor, input);
244
+ },
245
+
246
+ async complete(input) {
247
+ validateMutationInput(input);
248
+
249
+ const nowIso = encodeTimestamp(nowFrom(options));
250
+ const storageKey = createIdempotencyStorageKey(input);
251
+ const serializedResult = JSON.stringify(input.result);
252
+ const resultJson =
253
+ serializedResult === undefined ? null : serializedResult;
254
+
255
+ const rowsAffected = await executor.run(sql`update ${executor.table}
256
+ set
257
+ status = 'completed',
258
+ result_json = ${resultJson},
259
+ completed_at = ${nowIso},
260
+ updated_at = ${nowIso}
261
+ where storage_key = ${storageKey}
262
+ and fingerprint = ${input.fingerprint}
263
+ and status = 'in-progress'`);
264
+
265
+ assertIdempotencyMutationSucceeded("complete", input, rowsAffected);
266
+ },
267
+
268
+ async fail(input) {
269
+ validateMutationInput(input);
270
+
271
+ const storageKey = createIdempotencyStorageKey(input);
272
+ const rowsAffected = await executor.run(sql`delete from ${executor.table}
273
+ where storage_key = ${storageKey}
274
+ and fingerprint = ${input.fingerprint}
275
+ and status = 'in-progress'`);
276
+
277
+ assertIdempotencyMutationSucceeded("fail", input, rowsAffected);
278
+ },
279
+ };
280
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Shared SQL identifier helpers used by every Drizzle dialect in this
3
+ * package. Identifiers are validated before quoting so table names from
4
+ * options can never inject SQL.
5
+ */
6
+
7
+ /**
8
+ * Assert that a SQL identifier only contains safe characters.
9
+ *
10
+ * @throws Error when the identifier contains anything beyond
11
+ * `[A-Za-z0-9_]` or starts with a digit.
12
+ */
13
+ export function assertSafeIdentifier(name: string): void {
14
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) {
15
+ throw new Error(`Unsafe SQL identifier "${name}".`);
16
+ }
17
+ }
18
+
19
+ /**
20
+ * Quote a validated SQL identifier with the dialect's identifier quote.
21
+ *
22
+ * - SQLite and Postgres use `"`.
23
+ * - MySQL uses `` ` ``.
24
+ */
25
+ export function quoteIdentifier(name: string, quote: '"' | "`"): string {
26
+ assertSafeIdentifier(name);
27
+ return `${quote}${name.replaceAll(quote, `${quote}${quote}`)}${quote}`;
28
+ }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Shared devtools instrumentation helpers for Drizzle database providers.
3
+ */
4
+
5
+ import type { ProviderInstrumentation } from "@beignet/core/providers";
6
+ import type { Logger } from "drizzle-orm";
7
+
8
+ /**
9
+ * Summarize a SQL query for devtools event summaries: collapse whitespace
10
+ * and truncate long statements.
11
+ */
12
+ export function summarizeQuery(query: string): string {
13
+ const normalized = query.replace(/\s+/g, " ").trim();
14
+ return normalized.length > 120
15
+ ? `${normalized.slice(0, 117)}...`
16
+ : normalized;
17
+ }
18
+
19
+ /**
20
+ * Create a Drizzle logger that records `db.query` custom instrumentation
21
+ * events for every executed query.
22
+ *
23
+ * Returns `undefined` when instrumentation is disabled so providers can skip
24
+ * installing a logger entirely.
25
+ */
26
+ export function createDbQueryLogger(
27
+ instrumentation: ProviderInstrumentation,
28
+ portName: string,
29
+ ): Logger | undefined {
30
+ if (!instrumentation.isEnabled()) {
31
+ return undefined;
32
+ }
33
+
34
+ return {
35
+ logQuery(query: string, params: unknown[]) {
36
+ instrumentation.custom({
37
+ name: "db.query",
38
+ label: "Database query",
39
+ summary: summarizeQuery(query),
40
+ details: {
41
+ query,
42
+ params: params.length > 0 ? "[redacted]" : [],
43
+ paramsCount: params.length,
44
+ portName,
45
+ },
46
+ });
47
+ },
48
+ };
49
+ }
@@ -0,0 +1,322 @@
1
+ /**
2
+ * Dialect-independent outbox port orchestration.
3
+ *
4
+ * Every Drizzle dialect (SQLite, Postgres, MySQL) implements the small
5
+ * `OutboxSqlExecutor` seam below; the complete enqueue/claim/deliver/fail
6
+ * workflow lives here so the semantics stay byte-identical across dialects.
7
+ *
8
+ * This module must never import dialect database types — only `SQL` from
9
+ * drizzle-orm and core ports from `@beignet/core`.
10
+ */
11
+
12
+ import {
13
+ type ClaimedOutboxMessage,
14
+ createOutboxMessage,
15
+ DEFAULT_OUTBOX_LEASE_MS,
16
+ OutboxClaimError,
17
+ type OutboxEnqueueInput,
18
+ type OutboxMessage,
19
+ type OutboxMessageStatus,
20
+ type OutboxPort,
21
+ serializeOutboxError,
22
+ } from "@beignet/core/outbox";
23
+ import { type SQL, sql } from "drizzle-orm";
24
+ import {
25
+ encodeTimestamp,
26
+ type OutboxRow,
27
+ rowToClaimedMessage,
28
+ } from "./rows.js";
29
+
30
+ /**
31
+ * Minimal SQL execution seam a dialect implements to back the shared
32
+ * database executor cores.
33
+ *
34
+ * @template TSelf - The concrete executor interface, so `withTransaction`
35
+ * hands the callback a transaction-scoped executor of the same shape.
36
+ */
37
+ export interface SqlExecutorBase<TSelf> {
38
+ /**
39
+ * The quoted table name as a raw SQL fragment, e.g.
40
+ * `sql.raw(quoteIdentifier(tableName, '"'))`.
41
+ */
42
+ table: SQL;
43
+
44
+ /**
45
+ * Execute a query and return all rows as plain column-name keyed records.
46
+ */
47
+ rows(query: SQL): Promise<Record<string, unknown>[]>;
48
+
49
+ /**
50
+ * Execute a query and return the first row, or `undefined` when no row
51
+ * matched.
52
+ */
53
+ row(query: SQL): Promise<Record<string, unknown> | undefined>;
54
+
55
+ /**
56
+ * Execute a mutation and return the number of affected rows, or
57
+ * `undefined` when the driver does not report it.
58
+ */
59
+ run(query: SQL): Promise<number | undefined>;
60
+
61
+ /**
62
+ * Run `fn` inside a database transaction with a transaction-scoped
63
+ * executor. Return `undefined` (synchronously) when the executor is
64
+ * already transaction-scoped or the client cannot open a transaction; the
65
+ * core then runs the work directly on this executor.
66
+ */
67
+ withTransaction<R>(fn: (tx: TSelf) => Promise<R>): Promise<R> | undefined;
68
+ }
69
+
70
+ /**
71
+ * SQL execution seam for the shared outbox core.
72
+ */
73
+ export interface OutboxSqlExecutor extends SqlExecutorBase<OutboxSqlExecutor> {
74
+ /**
75
+ * Row-locking suffix appended to the eligible-message select.
76
+ *
77
+ * SQLite: empty. Postgres/MySQL: ` for update skip locked`.
78
+ */
79
+ claimLockSuffix: SQL;
80
+ }
81
+
82
+ /**
83
+ * Options for the shared outbox core.
84
+ */
85
+ export interface OutboxPortCoreOptions {
86
+ /**
87
+ * Clock used by tests and deterministic environments.
88
+ */
89
+ now?: () => Date;
90
+ }
91
+
92
+ function nowFrom(options: OutboxPortCoreOptions): Date {
93
+ return options.now?.() ?? new Date();
94
+ }
95
+
96
+ function createClaimToken(): string {
97
+ return crypto.randomUUID();
98
+ }
99
+
100
+ async function getOutboxRow(
101
+ executor: OutboxSqlExecutor,
102
+ id: string,
103
+ ): Promise<OutboxRow | undefined> {
104
+ const row = await executor.row(
105
+ sql`select * from ${executor.table} where id = ${id}`,
106
+ );
107
+ return row as OutboxRow | undefined;
108
+ }
109
+
110
+ function assertClaimedRowSucceeded(
111
+ row: OutboxRow | undefined,
112
+ input: { id: string; expectedStatus: OutboxMessageStatus },
113
+ ): void {
114
+ if (!row || row.status !== input.expectedStatus || row.claim_token !== null) {
115
+ throw new OutboxClaimError({
116
+ id: input.id,
117
+ message: `Outbox message "${input.id}" is not claimed by this worker.`,
118
+ });
119
+ }
120
+ }
121
+
122
+ /**
123
+ * Verify that a claim-token-guarded outbox update matched a row.
124
+ *
125
+ * When the driver reports `rowsAffected`, trust it: a verification re-read
126
+ * would race with legitimate re-claims (for example, a concurrent worker can
127
+ * re-claim a message immediately after `markFailed` returns it to `pending`)
128
+ * and report a spurious claim failure. The re-read only runs as a fallback for
129
+ * drivers that do not report `rowsAffected`.
130
+ */
131
+ async function assertOutboxMutationSucceeded(
132
+ executor: OutboxSqlExecutor,
133
+ input: { id: string; expectedStatus: OutboxMessageStatus },
134
+ rowsAffected: number | undefined,
135
+ ): Promise<void> {
136
+ if (rowsAffected !== undefined) {
137
+ if (rowsAffected <= 0) {
138
+ throw new OutboxClaimError({
139
+ id: input.id,
140
+ message: `Outbox message "${input.id}" is not claimed by this worker.`,
141
+ });
142
+ }
143
+ return;
144
+ }
145
+
146
+ assertClaimedRowSucceeded(await getOutboxRow(executor, input.id), input);
147
+ }
148
+
149
+ /**
150
+ * Create the dialect-independent outbox port orchestration on top of a
151
+ * dialect's `OutboxSqlExecutor`.
152
+ *
153
+ * Claiming uses a claim-token lease so concurrent workers can safely compete
154
+ * for eligible messages.
155
+ */
156
+ export function createOutboxPortCore(
157
+ executor: OutboxSqlExecutor,
158
+ options: OutboxPortCoreOptions = {},
159
+ ): OutboxPort {
160
+ async function claimWith(
161
+ tx: OutboxSqlExecutor,
162
+ input: { limit: number; now: Date; leaseMs: number },
163
+ ): Promise<ClaimedOutboxMessage[]> {
164
+ const nowIso = encodeTimestamp(input.now);
165
+ const lockedUntilIso = encodeTimestamp(
166
+ new Date(input.now.getTime() + input.leaseMs),
167
+ );
168
+ const rows = (await tx.rows(sql`select * from ${tx.table}
169
+ where (
170
+ (status = 'pending' and available_at <= ${nowIso})
171
+ or (status = 'claimed' and locked_until is not null and locked_until <= ${nowIso})
172
+ )
173
+ order by available_at asc, created_at asc
174
+ limit ${input.limit}${tx.claimLockSuffix}`)) as unknown as OutboxRow[];
175
+ const claimed: ClaimedOutboxMessage[] = [];
176
+
177
+ for (const row of rows) {
178
+ const claimToken = createClaimToken();
179
+ const rowsAffected = await tx.run(sql`update ${tx.table}
180
+ set
181
+ status = 'claimed',
182
+ attempts = attempts + 1,
183
+ claimed_at = ${nowIso},
184
+ locked_until = ${lockedUntilIso},
185
+ claim_token = ${claimToken},
186
+ updated_at = ${nowIso}
187
+ where id = ${row.id}
188
+ and (
189
+ (status = 'pending' and available_at <= ${nowIso})
190
+ or (status = 'claimed' and locked_until is not null and locked_until <= ${nowIso})
191
+ )`);
192
+ if (rowsAffected !== undefined && rowsAffected <= 0) {
193
+ continue;
194
+ }
195
+
196
+ const claimedRow = (await tx.row(
197
+ sql`select * from ${tx.table} where id = ${row.id} and claim_token = ${claimToken}`,
198
+ )) as OutboxRow | undefined;
199
+ if (claimedRow) {
200
+ claimed.push(rowToClaimedMessage(claimedRow));
201
+ }
202
+ }
203
+
204
+ return claimed;
205
+ }
206
+
207
+ return {
208
+ async enqueue(input: OutboxEnqueueInput): Promise<OutboxMessage> {
209
+ const message = createOutboxMessage(input, {
210
+ now: nowFrom(options),
211
+ });
212
+ const nowIso = encodeTimestamp(message.createdAt);
213
+
214
+ await executor.run(sql`insert into ${executor.table} (
215
+ id,
216
+ kind,
217
+ name,
218
+ payload_json,
219
+ status,
220
+ attempts,
221
+ max_attempts,
222
+ available_at,
223
+ claimed_at,
224
+ locked_until,
225
+ claim_token,
226
+ delivered_at,
227
+ last_error_json,
228
+ created_at,
229
+ updated_at
230
+ ) values (
231
+ ${message.id},
232
+ ${message.kind},
233
+ ${message.name},
234
+ ${JSON.stringify(message.payload)},
235
+ ${message.status},
236
+ ${message.attempts},
237
+ ${message.maxAttempts},
238
+ ${encodeTimestamp(message.availableAt)},
239
+ null,
240
+ null,
241
+ null,
242
+ null,
243
+ null,
244
+ ${nowIso},
245
+ ${nowIso}
246
+ )`);
247
+
248
+ return message;
249
+ },
250
+
251
+ async claimBatch(input) {
252
+ const limit = input.limit;
253
+ if (!Number.isInteger(limit) || limit <= 0) {
254
+ throw new Error("limit must be a positive integer");
255
+ }
256
+ const now = input.now ?? nowFrom(options);
257
+ const leaseMs = input.leaseMs ?? DEFAULT_OUTBOX_LEASE_MS;
258
+ if (!Number.isInteger(leaseMs) || leaseMs <= 0) {
259
+ throw new Error("leaseMs must be a positive integer");
260
+ }
261
+ const claimInput = { limit, now, leaseMs };
262
+
263
+ const transactional = executor.withTransaction((tx) =>
264
+ claimWith(tx, claimInput),
265
+ );
266
+ if (transactional !== undefined) {
267
+ return transactional;
268
+ }
269
+
270
+ return claimWith(executor, claimInput);
271
+ },
272
+
273
+ async markDelivered(input) {
274
+ const nowIso = encodeTimestamp(input.now ?? nowFrom(options));
275
+ const rowsAffected = await executor.run(sql`update ${executor.table}
276
+ set
277
+ status = 'delivered',
278
+ delivered_at = ${nowIso},
279
+ claimed_at = null,
280
+ locked_until = null,
281
+ claim_token = null,
282
+ updated_at = ${nowIso}
283
+ where id = ${input.id}
284
+ and status = 'claimed'
285
+ and claim_token = ${input.claimToken}`);
286
+
287
+ await assertOutboxMutationSucceeded(
288
+ executor,
289
+ { id: input.id, expectedStatus: "delivered" },
290
+ rowsAffected,
291
+ );
292
+ },
293
+
294
+ async markFailed(input) {
295
+ const now = input.now ?? nowFrom(options);
296
+ const nowIso = encodeTimestamp(now);
297
+ const status: OutboxMessageStatus = input.deadLetter
298
+ ? "deadLettered"
299
+ : "pending";
300
+ const availableAt = input.retryAt ?? now;
301
+
302
+ const rowsAffected = await executor.run(sql`update ${executor.table}
303
+ set
304
+ status = ${status},
305
+ available_at = ${encodeTimestamp(availableAt)},
306
+ claimed_at = null,
307
+ locked_until = null,
308
+ claim_token = null,
309
+ last_error_json = ${JSON.stringify(serializeOutboxError(input.error))},
310
+ updated_at = ${nowIso}
311
+ where id = ${input.id}
312
+ and status = 'claimed'
313
+ and claim_token = ${input.claimToken}`);
314
+
315
+ await assertOutboxMutationSucceeded(
316
+ executor,
317
+ { id: input.id, expectedStatus: status },
318
+ rowsAffected,
319
+ );
320
+ },
321
+ };
322
+ }