@beignet/provider-db-drizzle 0.0.32 → 0.0.34

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.
@@ -175,11 +175,15 @@ export function createIdempotencyPortCore(
175
175
  const expiresAt = resolveIdempotencyExpiresAt(input.ttlSec, now);
176
176
  const storageKey = createIdempotencyStorageKey(input);
177
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}`);
178
+ const reserved = (): IdempotencyReservation => ({
179
+ status: "reserved",
180
+ namespace: input.namespace,
181
+ key: input.key,
182
+ scopeKey,
183
+ fingerprint: input.fingerprint,
184
+ reservedAt: now,
185
+ expiresAt,
186
+ });
183
187
 
184
188
  const rowsAffected = await tx.run(sql`${tx.insertPrefix} ${tx.table} (
185
189
  storage_key,
@@ -208,15 +212,30 @@ export function createIdempotencyPortCore(
208
212
  )${tx.insertConflictSuffix}`);
209
213
 
210
214
  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
- };
215
+ return reserved();
216
+ }
217
+
218
+ // Reset an expired reservation in place after the insert conflict. Doing
219
+ // this after the insert avoids MySQL gap-lock deadlocks when several
220
+ // transactions reserve the same previously missing storage key.
221
+ const expiredRowsAffected = await tx.run(sql`update ${tx.table}
222
+ set
223
+ namespace = ${input.namespace},
224
+ idempotency_key = ${input.key},
225
+ scope_key = ${scopeKey},
226
+ fingerprint = ${input.fingerprint},
227
+ status = 'in-progress',
228
+ result_json = null,
229
+ reserved_at = ${nowIso},
230
+ completed_at = null,
231
+ expires_at = ${expiresAt ? encodeTimestamp(expiresAt) : null},
232
+ updated_at = ${nowIso}
233
+ where storage_key = ${storageKey}
234
+ and expires_at is not null
235
+ and expires_at <= ${nowIso}`);
236
+
237
+ if (expiredRowsAffected !== undefined && expiredRowsAffected > 0) {
238
+ return reserved();
220
239
  }
221
240
 
222
241
  const row = (await tx.row(
@@ -13,11 +13,20 @@ import {
13
13
  type ClaimedOutboxMessage,
14
14
  createOutboxMessage,
15
15
  DEFAULT_OUTBOX_LEASE_MS,
16
+ OutboxAdminError,
17
+ type OutboxAdminPort,
16
18
  OutboxClaimError,
19
+ type OutboxCountMessagesOptions,
20
+ type OutboxDeleteResult,
17
21
  type OutboxEnqueueInput,
22
+ type OutboxListMessagesOptions,
18
23
  type OutboxMessage,
24
+ type OutboxMessageQuery,
19
25
  type OutboxMessageStatus,
20
26
  type OutboxPort,
27
+ type OutboxPruneDeliveredInput,
28
+ type OutboxPurgeDeadLetteredInput,
29
+ type OutboxRequeueMessageInput,
21
30
  serializeOutboxError,
22
31
  } from "@beignet/core/outbox";
23
32
  import { type SQL, sql } from "drizzle-orm";
@@ -25,6 +34,7 @@ import {
25
34
  encodeTimestamp,
26
35
  type OutboxRow,
27
36
  rowToClaimedMessage,
37
+ rowToMessage,
28
38
  } from "./rows.js";
29
39
 
30
40
  /**
@@ -93,6 +103,24 @@ function nowFrom(options: OutboxPortCoreOptions): Date {
93
103
  return options.now?.() ?? new Date();
94
104
  }
95
105
 
106
+ function assertNonEmptyString(name: string, value: string): void {
107
+ if (typeof value !== "string" || value.trim().length === 0) {
108
+ throw new Error(`${name} must be a non-empty string`);
109
+ }
110
+ }
111
+
112
+ function assertPositiveInteger(name: string, value: number): void {
113
+ if (!Number.isInteger(value) || value <= 0) {
114
+ throw new Error(`${name} must be a positive integer`);
115
+ }
116
+ }
117
+
118
+ function assertValidDate(name: string, value: Date): void {
119
+ if (!(value instanceof Date) || Number.isNaN(value.getTime())) {
120
+ throw new Error(`${name} must be a valid Date`);
121
+ }
122
+ }
123
+
96
124
  function createClaimToken(): string {
97
125
  return crypto.randomUUID();
98
126
  }
@@ -107,6 +135,124 @@ async function getOutboxRow(
107
135
  return row as OutboxRow | undefined;
108
136
  }
109
137
 
138
+ function normalizeStatusFilter(
139
+ status: OutboxMessageQuery["status"],
140
+ ): readonly OutboxMessageStatus[] | undefined {
141
+ if (status === undefined) return undefined;
142
+ if (typeof status === "string") return [status];
143
+ return status;
144
+ }
145
+
146
+ function orConditions(conditions: readonly SQL[]): SQL {
147
+ if (conditions.length === 0) return sql`1 = 0`;
148
+ let condition = conditions[0] ?? sql`1 = 0`;
149
+ for (const next of conditions.slice(1)) {
150
+ condition = sql`${condition} or ${next}`;
151
+ }
152
+ return sql`(${condition})`;
153
+ }
154
+
155
+ function andConditions(conditions: readonly SQL[]): SQL {
156
+ let condition = conditions[0] ?? sql`1 = 1`;
157
+ for (const next of conditions.slice(1)) {
158
+ condition = sql`${condition} and ${next}`;
159
+ }
160
+ return condition;
161
+ }
162
+
163
+ function whereClause(conditions: readonly SQL[]): SQL {
164
+ if (conditions.length === 0) return sql.raw("");
165
+ return sql` where ${andConditions(conditions)}`;
166
+ }
167
+
168
+ function messageQueryConditions(query: OutboxMessageQuery = {}): SQL[] {
169
+ const conditions: SQL[] = [];
170
+ const statuses = normalizeStatusFilter(query.status);
171
+ if (statuses !== undefined) {
172
+ conditions.push(
173
+ orConditions(statuses.map((status) => sql`status = ${status}`)),
174
+ );
175
+ }
176
+ if (query.kind !== undefined) conditions.push(sql`kind = ${query.kind}`);
177
+ if (query.name !== undefined) {
178
+ assertNonEmptyString("name", query.name);
179
+ conditions.push(sql`name = ${query.name}`);
180
+ }
181
+ if (query.updatedBefore !== undefined) {
182
+ assertValidDate("updatedBefore", query.updatedBefore);
183
+ conditions.push(sql`updated_at < ${encodeTimestamp(query.updatedBefore)}`);
184
+ }
185
+ if (query.deliveredBefore !== undefined) {
186
+ assertValidDate("deliveredBefore", query.deliveredBefore);
187
+ conditions.push(sql`delivered_at is not null`);
188
+ conditions.push(
189
+ sql`delivered_at < ${encodeTimestamp(query.deliveredBefore)}`,
190
+ );
191
+ }
192
+ return conditions;
193
+ }
194
+
195
+ function readCount(row: Record<string, unknown> | undefined): number {
196
+ const value = row?.count;
197
+ if (typeof value === "number") return value;
198
+ if (typeof value === "bigint") return Number(value);
199
+ if (typeof value === "string") return Number(value);
200
+ return 0;
201
+ }
202
+
203
+ function adminListLimit(limit: number | undefined): number {
204
+ const resolved = limit ?? 50;
205
+ assertPositiveInteger("limit", resolved);
206
+ return resolved;
207
+ }
208
+
209
+ async function countRows(
210
+ executor: OutboxSqlExecutor,
211
+ conditions: readonly SQL[],
212
+ ): Promise<number> {
213
+ const row = await executor.row(
214
+ sql`select count(*) as count from ${executor.table}${whereClause(conditions)}`,
215
+ );
216
+ return readCount(row);
217
+ }
218
+
219
+ function limitedDeleteQuery(
220
+ executor: OutboxSqlExecutor,
221
+ conditions: readonly SQL[],
222
+ limit: number | undefined,
223
+ orderBy: SQL,
224
+ ): SQL {
225
+ const where = whereClause(conditions);
226
+ if (limit === undefined) {
227
+ return sql`delete from ${executor.table}${where}`;
228
+ }
229
+
230
+ return sql`delete from ${executor.table}
231
+ where id in (
232
+ select id from (
233
+ select id from ${executor.table}${where}
234
+ order by ${orderBy}
235
+ limit ${limit}
236
+ ) as beignet_outbox_admin_delete
237
+ )`;
238
+ }
239
+
240
+ async function deleteRows(
241
+ executor: OutboxSqlExecutor,
242
+ conditions: readonly SQL[],
243
+ limit: number | undefined,
244
+ orderBy: SQL = sql`updated_at asc, created_at asc, id asc`,
245
+ ): Promise<OutboxDeleteResult> {
246
+ if (limit !== undefined) assertPositiveInteger("limit", limit);
247
+ const expected = await countRows(executor, conditions);
248
+ const cappedExpected =
249
+ limit === undefined ? expected : Math.min(expected, limit);
250
+ const rowsAffected = await executor.run(
251
+ limitedDeleteQuery(executor, conditions, limit, orderBy),
252
+ );
253
+ return { deleted: rowsAffected ?? cappedExpected };
254
+ }
255
+
110
256
  function assertClaimedRowSucceeded(
111
257
  row: OutboxRow | undefined,
112
258
  input: { id: string; expectedStatus: OutboxMessageStatus },
@@ -320,3 +466,135 @@ export function createOutboxPortCore(
320
466
  },
321
467
  };
322
468
  }
469
+
470
+ /**
471
+ * Create the dialect-independent outbox admin port orchestration on top of a
472
+ * dialect's `OutboxSqlExecutor`.
473
+ */
474
+ export function createOutboxAdminPortCore(
475
+ executor: OutboxSqlExecutor,
476
+ options: OutboxPortCoreOptions = {},
477
+ ): OutboxAdminPort {
478
+ return {
479
+ async listMessages(
480
+ input: OutboxListMessagesOptions = {},
481
+ ): Promise<readonly OutboxMessage[]> {
482
+ const limit = adminListLimit(input.limit);
483
+ const conditions = messageQueryConditions(input);
484
+ const rows =
485
+ (await executor.rows(sql`select * from ${executor.table}${whereClause(
486
+ conditions,
487
+ )}
488
+ order by updated_at desc, created_at desc, id desc
489
+ limit ${limit}`)) as unknown as OutboxRow[];
490
+
491
+ return rows.map(rowToMessage);
492
+ },
493
+
494
+ async countMessages(
495
+ input: OutboxCountMessagesOptions = {},
496
+ ): Promise<number> {
497
+ return countRows(executor, messageQueryConditions(input));
498
+ },
499
+
500
+ async getMessage(id: string): Promise<OutboxMessage | null> {
501
+ assertNonEmptyString("id", id);
502
+ const row = await getOutboxRow(executor, id);
503
+ return row ? rowToMessage(row) : null;
504
+ },
505
+
506
+ async requeueMessage(
507
+ input: OutboxRequeueMessageInput,
508
+ ): Promise<OutboxMessage> {
509
+ assertNonEmptyString("id", input.id);
510
+ const existing = await getOutboxRow(executor, input.id);
511
+ if (!existing) {
512
+ throw new OutboxAdminError({
513
+ id: input.id,
514
+ message: `Outbox message "${input.id}" does not exist.`,
515
+ });
516
+ }
517
+ if (existing.status !== "deadLettered") {
518
+ throw new OutboxAdminError({
519
+ id: input.id,
520
+ message: `Outbox message "${input.id}" is not dead-lettered.`,
521
+ });
522
+ }
523
+
524
+ const now = input.now ?? nowFrom(options);
525
+ const availableAt = input.availableAt ?? now;
526
+ assertValidDate("now", now);
527
+ assertValidDate("availableAt", availableAt);
528
+ const nowIso = encodeTimestamp(now);
529
+ const availableAtIso = encodeTimestamp(availableAt);
530
+
531
+ const rowsAffected = input.resetAttempts
532
+ ? await executor.run(sql`update ${executor.table}
533
+ set
534
+ status = 'pending',
535
+ attempts = 0,
536
+ available_at = ${availableAtIso},
537
+ claimed_at = null,
538
+ locked_until = null,
539
+ claim_token = null,
540
+ updated_at = ${nowIso}
541
+ where id = ${input.id} and status = 'deadLettered'`)
542
+ : await executor.run(sql`update ${executor.table}
543
+ set
544
+ status = 'pending',
545
+ available_at = ${availableAtIso},
546
+ claimed_at = null,
547
+ locked_until = null,
548
+ claim_token = null,
549
+ updated_at = ${nowIso}
550
+ where id = ${input.id} and status = 'deadLettered'`);
551
+
552
+ if (rowsAffected !== undefined && rowsAffected <= 0) {
553
+ throw new OutboxAdminError({
554
+ id: input.id,
555
+ message: `Outbox message "${input.id}" could not be requeued because it changed state.`,
556
+ });
557
+ }
558
+
559
+ const requeued = await getOutboxRow(executor, input.id);
560
+ if (!requeued) {
561
+ throw new OutboxAdminError({
562
+ id: input.id,
563
+ message: `Outbox message "${input.id}" could not be requeued because it no longer exists.`,
564
+ });
565
+ }
566
+
567
+ return rowToMessage(requeued);
568
+ },
569
+
570
+ async purgeDeadLettered(
571
+ input: OutboxPurgeDeadLetteredInput = {},
572
+ ): Promise<OutboxDeleteResult> {
573
+ const conditions: SQL[] = [sql`status = 'deadLettered'`];
574
+ if (input.before !== undefined) {
575
+ assertValidDate("before", input.before);
576
+ conditions.push(sql`updated_at < ${encodeTimestamp(input.before)}`);
577
+ }
578
+
579
+ return deleteRows(executor, conditions, input.limit);
580
+ },
581
+
582
+ async pruneDelivered(
583
+ input: OutboxPruneDeliveredInput,
584
+ ): Promise<OutboxDeleteResult> {
585
+ assertValidDate("before", input.before);
586
+ const conditions: SQL[] = [
587
+ sql`status = 'delivered'`,
588
+ sql`delivered_at is not null`,
589
+ sql`delivered_at < ${encodeTimestamp(input.before)}`,
590
+ ];
591
+
592
+ return deleteRows(
593
+ executor,
594
+ conditions,
595
+ input.limit,
596
+ sql`delivered_at asc, updated_at asc, created_at asc, id asc`,
597
+ );
598
+ },
599
+ };
600
+ }
@@ -30,7 +30,7 @@
30
30
  */
31
31
 
32
32
  import type { IdempotencyPort } from "@beignet/core/idempotency";
33
- import type { OutboxPort } from "@beignet/core/outbox";
33
+ import type { OutboxAdminPort, OutboxPort } from "@beignet/core/outbox";
34
34
  import {
35
35
  type AuditLogPort,
36
36
  type BufferedDomainEventRecorder,
@@ -63,6 +63,7 @@ import {
63
63
  import { quoteIdentifier } from "../shared/identifiers.js";
64
64
  import { createDbQueryLogger } from "../shared/instrumentation.js";
65
65
  import {
66
+ createOutboxAdminPortCore,
66
67
  createOutboxPortCore,
67
68
  type OutboxSqlExecutor,
68
69
  type SqlExecutorBase,
@@ -83,13 +84,6 @@ export interface DbPort<
83
84
  */
84
85
  drizzle: LibSQLDatabase<TSchema>;
85
86
 
86
- /**
87
- * Backwards-compatible alias for `drizzle`.
88
- *
89
- * @deprecated Use `drizzle` for the Drizzle ORM instance.
90
- */
91
- db: LibSQLDatabase<TSchema>;
92
-
93
87
  /**
94
88
  * The underlying libSQL client instance.
95
89
  * Use this for advanced operations not covered by Drizzle ORM.
@@ -123,6 +117,32 @@ export type DrizzleSqliteTransaction<
123
117
  TSchema extends Record<string, unknown> = Record<string, unknown>,
124
118
  > = LibSQLTransaction<TSchema, ExtractTablesWithRelations<TSchema>>;
125
119
 
120
+ const sqliteTransactionTails = new WeakMap<object, Promise<void>>();
121
+
122
+ /**
123
+ * Queue Beignet-owned transactions per Drizzle client. SQLite only permits one
124
+ * writer at a time, while the local libSQL driver otherwise exposes concurrent
125
+ * transaction attempts as SQLITE_BUSY instead of waiting for the active write.
126
+ */
127
+ function runSerializedSqliteTransaction<Result>(
128
+ db: object,
129
+ transaction: () => Promise<Result>,
130
+ ): Promise<Result> {
131
+ const previous = sqliteTransactionTails.get(db) ?? Promise.resolve();
132
+ const result = previous.then(transaction);
133
+ const tail = result.then(
134
+ () => undefined,
135
+ () => undefined,
136
+ );
137
+ sqliteTransactionTails.set(db, tail);
138
+
139
+ return result.finally(() => {
140
+ if (sqliteTransactionTails.get(db) === tail) {
141
+ sqliteTransactionTails.delete(db);
142
+ }
143
+ });
144
+ }
145
+
126
146
  async function checkSqliteHealth(client: Client): Promise<DbHealth> {
127
147
  try {
128
148
  await client.execute("select 1");
@@ -185,13 +205,15 @@ export function createDrizzleSqliteUnitOfWork<
185
205
  ): Promise<Result> {
186
206
  const events = createDomainEventRecorder();
187
207
 
188
- const result = await options.db.transaction(async (tx) => {
189
- const txPorts = options.createTransactionPorts(
190
- tx as DrizzleSqliteTransaction<TSchema>,
191
- events,
192
- );
193
- return work(txPorts);
194
- }, options.transactionConfig);
208
+ const result = await runSerializedSqliteTransaction(options.db, () =>
209
+ options.db.transaction(async (tx) => {
210
+ const txPorts = options.createTransactionPorts(
211
+ tx as DrizzleSqliteTransaction<TSchema>,
212
+ events,
213
+ );
214
+ return work(txPorts);
215
+ }, options.transactionConfig),
216
+ );
195
217
 
196
218
  if (options.eventBus) {
197
219
  await events.flush(options.eventBus);
@@ -336,6 +358,11 @@ function createSqliteExecutor<TSchema extends Record<string, unknown>>(
336
358
  },
337
359
 
338
360
  async row(query) {
361
+ if ("all" in db && typeof db.all === "function") {
362
+ const rows = await db.all<Record<string, unknown>>(query);
363
+ return rows[0];
364
+ }
365
+
339
366
  return db.get<Record<string, unknown> | undefined>(query);
340
367
  },
341
368
 
@@ -345,8 +372,12 @@ function createSqliteExecutor<TSchema extends Record<string, unknown>>(
345
372
 
346
373
  withTransaction(fn) {
347
374
  if ("transaction" in db && typeof db.transaction === "function") {
348
- return db.transaction((tx) =>
349
- fn(createSqliteExecutor(tx as DrizzleSqliteDatabase<TSchema>, table)),
375
+ return runSerializedSqliteTransaction(db, () =>
376
+ db.transaction((tx) =>
377
+ fn(
378
+ createSqliteExecutor(tx as DrizzleSqliteDatabase<TSchema>, table),
379
+ ),
380
+ ),
350
381
  );
351
382
  }
352
383
 
@@ -411,6 +442,26 @@ export function createDrizzleSqliteOutboxPort<
411
442
  return createOutboxPortCore(executor, { now: adapterOptions.now });
412
443
  }
413
444
 
445
+ /**
446
+ * Create a Beignet outbox admin port backed by a Drizzle SQLite database.
447
+ *
448
+ * Wire this only into operational contexts that need to inspect, requeue,
449
+ * purge, or prune outbox messages.
450
+ */
451
+ export function createDrizzleSqliteOutboxAdminPort<
452
+ TSchema extends Record<string, unknown>,
453
+ >(
454
+ db: DrizzleSqliteDatabase<TSchema>,
455
+ adapterOptions: DrizzleSqliteOutboxOptions = {},
456
+ ): OutboxAdminPort {
457
+ const table = sql.raw(
458
+ quoteIdentifier(adapterOptions.tableName ?? "outbox_messages", '"'),
459
+ );
460
+ const executor: OutboxSqlExecutor = createSqliteExecutor(db, table);
461
+
462
+ return createOutboxAdminPortCore(executor, { now: adapterOptions.now });
463
+ }
464
+
414
465
  /**
415
466
  * Options for a Drizzle SQLite-backed idempotency port.
416
467
  */
@@ -645,7 +696,6 @@ export function createDrizzleSqliteProvider<
645
696
  // 3. Build a typed DbPort
646
697
  const dbPort: DbPort<TSchema> = {
647
698
  drizzle: db,
648
- db,
649
699
  client,
650
700
  checkHealth: () => checkSqliteHealth(client),
651
701
  };