@beignet/provider-db-drizzle 0.0.32 → 0.0.33

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.
@@ -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,
@@ -123,6 +124,32 @@ export type DrizzleSqliteTransaction<
123
124
  TSchema extends Record<string, unknown> = Record<string, unknown>,
124
125
  > = LibSQLTransaction<TSchema, ExtractTablesWithRelations<TSchema>>;
125
126
 
127
+ const sqliteTransactionTails = new WeakMap<object, Promise<void>>();
128
+
129
+ /**
130
+ * Queue Beignet-owned transactions per Drizzle client. SQLite only permits one
131
+ * writer at a time, while the local libSQL driver otherwise exposes concurrent
132
+ * transaction attempts as SQLITE_BUSY instead of waiting for the active write.
133
+ */
134
+ function runSerializedSqliteTransaction<Result>(
135
+ db: object,
136
+ transaction: () => Promise<Result>,
137
+ ): Promise<Result> {
138
+ const previous = sqliteTransactionTails.get(db) ?? Promise.resolve();
139
+ const result = previous.then(transaction);
140
+ const tail = result.then(
141
+ () => undefined,
142
+ () => undefined,
143
+ );
144
+ sqliteTransactionTails.set(db, tail);
145
+
146
+ return result.finally(() => {
147
+ if (sqliteTransactionTails.get(db) === tail) {
148
+ sqliteTransactionTails.delete(db);
149
+ }
150
+ });
151
+ }
152
+
126
153
  async function checkSqliteHealth(client: Client): Promise<DbHealth> {
127
154
  try {
128
155
  await client.execute("select 1");
@@ -185,13 +212,15 @@ export function createDrizzleSqliteUnitOfWork<
185
212
  ): Promise<Result> {
186
213
  const events = createDomainEventRecorder();
187
214
 
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);
215
+ const result = await runSerializedSqliteTransaction(options.db, () =>
216
+ options.db.transaction(async (tx) => {
217
+ const txPorts = options.createTransactionPorts(
218
+ tx as DrizzleSqliteTransaction<TSchema>,
219
+ events,
220
+ );
221
+ return work(txPorts);
222
+ }, options.transactionConfig),
223
+ );
195
224
 
196
225
  if (options.eventBus) {
197
226
  await events.flush(options.eventBus);
@@ -336,6 +365,11 @@ function createSqliteExecutor<TSchema extends Record<string, unknown>>(
336
365
  },
337
366
 
338
367
  async row(query) {
368
+ if ("all" in db && typeof db.all === "function") {
369
+ const rows = await db.all<Record<string, unknown>>(query);
370
+ return rows[0];
371
+ }
372
+
339
373
  return db.get<Record<string, unknown> | undefined>(query);
340
374
  },
341
375
 
@@ -345,8 +379,12 @@ function createSqliteExecutor<TSchema extends Record<string, unknown>>(
345
379
 
346
380
  withTransaction(fn) {
347
381
  if ("transaction" in db && typeof db.transaction === "function") {
348
- return db.transaction((tx) =>
349
- fn(createSqliteExecutor(tx as DrizzleSqliteDatabase<TSchema>, table)),
382
+ return runSerializedSqliteTransaction(db, () =>
383
+ db.transaction((tx) =>
384
+ fn(
385
+ createSqliteExecutor(tx as DrizzleSqliteDatabase<TSchema>, table),
386
+ ),
387
+ ),
350
388
  );
351
389
  }
352
390
 
@@ -411,6 +449,26 @@ export function createDrizzleSqliteOutboxPort<
411
449
  return createOutboxPortCore(executor, { now: adapterOptions.now });
412
450
  }
413
451
 
452
+ /**
453
+ * Create a Beignet outbox admin port backed by a Drizzle SQLite database.
454
+ *
455
+ * Wire this only into operational contexts that need to inspect, requeue,
456
+ * purge, or prune outbox messages.
457
+ */
458
+ export function createDrizzleSqliteOutboxAdminPort<
459
+ TSchema extends Record<string, unknown>,
460
+ >(
461
+ db: DrizzleSqliteDatabase<TSchema>,
462
+ adapterOptions: DrizzleSqliteOutboxOptions = {},
463
+ ): OutboxAdminPort {
464
+ const table = sql.raw(
465
+ quoteIdentifier(adapterOptions.tableName ?? "outbox_messages", '"'),
466
+ );
467
+ const executor: OutboxSqlExecutor = createSqliteExecutor(db, table);
468
+
469
+ return createOutboxAdminPortCore(executor, { now: adapterOptions.now });
470
+ }
471
+
414
472
  /**
415
473
  * Options for a Drizzle SQLite-backed idempotency port.
416
474
  */