better-ship 0.12.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/dist/{application-CIxo_BpK.js → application-DnlgWcOZ.js} +118 -6
  2. package/dist/application-DnlgWcOZ.js.map +1 -0
  3. package/dist/application.d.ts +3 -3
  4. package/dist/application.js +2 -2
  5. package/dist/better-auth/apple-client-secret.d.ts +1 -1
  6. package/dist/better-auth/apple-client-secret.js +1 -1
  7. package/dist/better-auth/auth-client.js +1 -1
  8. package/dist/better-auth/rate-limit.d.ts +1 -1
  9. package/dist/better-auth/rate-limit.js +1 -1
  10. package/dist/better-auth.js +1 -1
  11. package/dist/cloudflare/auth-secondary-storage.d.ts +1 -1
  12. package/dist/cloudflare/auth-secondary-storage.js +1 -1
  13. package/dist/cloudflare/queue.d.ts +8 -3
  14. package/dist/cloudflare/queue.d.ts.map +1 -1
  15. package/dist/cloudflare/queue.js +26 -16
  16. package/dist/cloudflare/queue.js.map +1 -1
  17. package/dist/cloudflare/scheduled.d.ts +1 -1
  18. package/dist/cloudflare/scheduled.js +1 -1
  19. package/dist/cloudflare.d.ts +30 -4
  20. package/dist/cloudflare.d.ts.map +1 -1
  21. package/dist/cloudflare.js +98 -5
  22. package/dist/cloudflare.js.map +1 -1
  23. package/dist/{core-DG_cC8YT.js → core-BiqdOMwU.js} +3 -2
  24. package/dist/{core-DG_cC8YT.js.map → core-BiqdOMwU.js.map} +1 -1
  25. package/dist/core.d.ts +2 -2
  26. package/dist/core.js +2 -2
  27. package/dist/http.d.ts +2 -2
  28. package/dist/http.js +2 -2
  29. package/dist/{index-Bl0K1RQW.d.ts → index-B5O6mNjZ.d.ts} +4 -2
  30. package/dist/{index-Bl0K1RQW.d.ts.map → index-B5O6mNjZ.d.ts.map} +1 -1
  31. package/dist/{index-DwWxIdQP.d.ts → index-DG4jkwad.d.ts} +92 -3
  32. package/dist/index-DG4jkwad.d.ts.map +1 -0
  33. package/dist/{unit-of-work-context-BPE1In2f.js → outbox-repository-options-BI75j7b7.js} +12 -3
  34. package/dist/outbox-repository-options-BI75j7b7.js.map +1 -0
  35. package/dist/outbox-repository-options-BiilQf4_.d.ts +26 -0
  36. package/dist/outbox-repository-options-BiilQf4_.d.ts.map +1 -0
  37. package/dist/postgres.d.ts +30 -4
  38. package/dist/postgres.d.ts.map +1 -1
  39. package/dist/postgres.js +92 -5
  40. package/dist/postgres.js.map +1 -1
  41. package/dist/resend.d.ts +3 -3
  42. package/dist/resend.js +2 -2
  43. package/dist/tanstack.d.ts +1 -1
  44. package/dist/tanstack.js +1 -1
  45. package/dist/testing/outbox-repository.d.ts +44 -0
  46. package/dist/testing/outbox-repository.d.ts.map +1 -0
  47. package/dist/testing/outbox-repository.js +206 -0
  48. package/dist/testing/outbox-repository.js.map +1 -0
  49. package/dist/{webhook-verification-BQ9qZnVK.js → webhook-verification-CeXUJXVP.js} +2 -2
  50. package/dist/{webhook-verification-BQ9qZnVK.js.map → webhook-verification-CeXUJXVP.js.map} +1 -1
  51. package/dist/{webhook-verification-sZtwTI5n.d.ts → webhook-verification-CyBoERFz.d.ts} +2 -2
  52. package/dist/{webhook-verification-sZtwTI5n.d.ts.map → webhook-verification-CyBoERFz.d.ts.map} +1 -1
  53. package/package.json +35 -1
  54. package/src/application/index.ts +18 -1
  55. package/src/application/outbox-relay.ts +117 -0
  56. package/src/application/outbox-repository.ts +46 -0
  57. package/src/application/outbox-retention.ts +61 -0
  58. package/src/core/backoff.ts +6 -1
  59. package/src/core/index.ts +1 -1
  60. package/src/infrastructure/cloudflare/d1-outbox-repository.ts +224 -0
  61. package/src/infrastructure/cloudflare/index.ts +2 -0
  62. package/src/infrastructure/cloudflare/queue/index.ts +1 -0
  63. package/src/infrastructure/cloudflare/queue/queue-client.ts +6 -2
  64. package/src/infrastructure/cloudflare/queue/wait-until-wake.ts +19 -0
  65. package/src/infrastructure/outbox-repository-options.ts +9 -0
  66. package/src/infrastructure/postgres/index.ts +2 -0
  67. package/src/infrastructure/postgres/postgres-outbox-repository.ts +216 -0
  68. package/src/infrastructure/testing/outbox-repository-contract.ts +228 -0
  69. package/dist/application-CIxo_BpK.js.map +0 -1
  70. package/dist/index-DwWxIdQP.d.ts.map +0 -1
  71. package/dist/unit-of-work-context-BPE1In2f.js.map +0 -1
  72. package/dist/unit-of-work-context-D5ytJIa-.d.ts +0 -16
  73. package/dist/unit-of-work-context-D5ytJIa-.d.ts.map +0 -1
@@ -1,4 +1,4 @@
1
- import { t as UnitOfWorkAlreadyActiveError } from "./application-CIxo_BpK.js";
1
+ import { t as UnitOfWorkAlreadyActiveError } from "./application-DnlgWcOZ.js";
2
2
  import { panic } from "better-result";
3
3
  import { AsyncLocalStorage } from "node:async_hooks";
4
4
 
@@ -44,5 +44,14 @@ function currentContext() {
44
44
  }
45
45
 
46
46
  //#endregion
47
- export { stageD1Statement as i, eventCollector as n, runWithUnitOfWorkContext as r, collectD1Statements as t };
48
- //# sourceMappingURL=unit-of-work-context-BPE1In2f.js.map
47
+ //#region src/infrastructure/outbox-repository-options.ts
48
+ /** Shared lease and delay defaults for both database adapters. */
49
+ const OUTBOX_REPOSITORY_DEFAULTS = {
50
+ leaseMs: 6e4,
51
+ retryBaseDelayMs: 1e3,
52
+ retryMaxDelayMs: 6e4
53
+ };
54
+
55
+ //#endregion
56
+ export { stageD1Statement as a, runWithUnitOfWorkContext as i, collectD1Statements as n, eventCollector as r, OUTBOX_REPOSITORY_DEFAULTS as t };
57
+ //# sourceMappingURL=outbox-repository-options-BI75j7b7.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"outbox-repository-options-BI75j7b7.js","names":[],"sources":["../src/infrastructure/unit-of-work-context.ts","../src/infrastructure/outbox-repository-options.ts"],"sourcesContent":["import { AsyncLocalStorage } from 'node:async_hooks'\n\nimport { panic } from 'better-result'\nimport type { BatchItem } from 'drizzle-orm/batch'\n\nimport { UnitOfWorkAlreadyActiveError, type IEventCollector } from '@/application'\nimport type { DomainEvent } from '@/core'\n\ntype UnitOfWorkContext = { readonly events: DomainEvent[] } & (\n | { readonly database: 'd1'; readonly statements: BatchItem<'sqlite'>[] }\n | { readonly database: 'postgres' }\n)\n\n// One async scope isolates events and D1 statements and rejects nesting across both adapters.\nconst storage = new AsyncLocalStorage<UnitOfWorkContext>()\n\n/** Reject nested transactions before database work starts; preserve independent concurrent execution. */\nexport async function runWithUnitOfWorkContext<T>(\n database: UnitOfWorkContext['database'],\n work: () => Promise<T>,\n): Promise<T> {\n if (storage.getStore() !== undefined) throw new UnitOfWorkAlreadyActiveError()\n const context: UnitOfWorkContext =\n database === 'd1' ? { database, events: [], statements: [] } : { database, events: [] }\n return storage.run(context, work)\n}\n\n/** Inject this shared scope into the unit of work and expose its emitter to application handlers. */\nexport const eventCollector: IEventCollector = {\n emit(event) {\n currentContext().events.push(event)\n },\n collect() {\n return [...currentContext().events]\n },\n}\n\n/** Queue one native D1 statement for the active batch; other contexts cannot accept D1 writes. */\nexport function stageD1Statement(statement: BatchItem<'sqlite'>): void {\n d1Context().statements.push(statement)\n}\n\n/** Read the active D1 batch in staging order for commit. */\nexport function collectD1Statements(): ReadonlyArray<BatchItem<'sqlite'>> {\n return [...d1Context().statements]\n}\n\nfunction d1Context(): Extract<UnitOfWorkContext, { database: 'd1' }> {\n const context = currentContext()\n if (context.database !== 'd1') return panic('D1 statements require a D1 unit of work')\n return context\n}\n\nfunction currentContext(): UnitOfWorkContext {\n return storage.getStore() ?? panic('operation requires an active unit of work')\n}\n","/** Shared lease and delay defaults for both database adapters. */\nexport const OUTBOX_REPOSITORY_DEFAULTS = {\n leaseMs: 60_000,\n retryBaseDelayMs: 1_000,\n retryMaxDelayMs: 60_000,\n}\n\n/** Override lease and delay limits at the composition root. */\nexport type OutboxRepositoryOptions = Readonly<Partial<typeof OUTBOX_REPOSITORY_DEFAULTS>>\n"],"mappings":";;;;;AAcA,MAAM,UAAU,IAAI,kBAAqC;;AAGzD,eAAsB,yBACpB,UACA,MACY;CACZ,IAAI,QAAQ,SAAS,MAAM,QAAW,MAAM,IAAI,6BAA6B;CAC7E,MAAM,UACJ,aAAa,OAAO;EAAE;EAAU,QAAQ,CAAC;EAAG,YAAY,CAAC;CAAE,IAAI;EAAE;EAAU,QAAQ,CAAC;CAAE;CACxF,OAAO,QAAQ,IAAI,SAAS,IAAI;AAClC;;AAGA,MAAa,iBAAkC;CAC7C,KAAK,OAAO;EACV,eAAe,CAAC,CAAC,OAAO,KAAK,KAAK;CACpC;CACA,UAAU;EACR,OAAO,CAAC,GAAG,eAAe,CAAC,CAAC,MAAM;CACpC;AACF;;AAGA,SAAgB,iBAAiB,WAAsC;CACrE,UAAU,CAAC,CAAC,WAAW,KAAK,SAAS;AACvC;;AAGA,SAAgB,sBAA0D;CACxE,OAAO,CAAC,GAAG,UAAU,CAAC,CAAC,UAAU;AACnC;AAEA,SAAS,YAA4D;CACnE,MAAM,UAAU,eAAe;CAC/B,IAAI,QAAQ,aAAa,MAAM,OAAO,MAAM,yCAAyC;CACrF,OAAO;AACT;AAEA,SAAS,iBAAoC;CAC3C,OAAO,QAAQ,SAAS,KAAK,MAAM,2CAA2C;AAChF;;;;;ACtDA,MAAa,6BAA6B;CACxC,SAAS;CACT,kBAAkB;CAClB,iBAAiB;AACnB"}
@@ -0,0 +1,26 @@
1
+ import "./index-B5O6mNjZ.js";
2
+ import { F as IEventCollector } from "./index-DG4jkwad.js";
3
+ import { BatchItem } from "drizzle-orm/batch";
4
+ //#region src/infrastructure/drizzle-schema.d.ts
5
+ /**
6
+ * The app's drizzle schema: the tables it declared, keyed by name. This is drizzle's own
7
+ * constraint on every database and transaction type, restated once so the adapters name it.
8
+ */
9
+ type DrizzleSchema = Record<string, unknown>;
10
+ //#endregion
11
+ //#region src/infrastructure/unit-of-work-context.d.ts
12
+ /** Inject this shared scope into the unit of work and expose its emitter to application handlers. */
13
+ declare const eventCollector: IEventCollector;
14
+ //#endregion
15
+ //#region src/infrastructure/outbox-repository-options.d.ts
16
+ /** Shared lease and delay defaults for both database adapters. */
17
+ declare const OUTBOX_REPOSITORY_DEFAULTS: {
18
+ leaseMs: number;
19
+ retryBaseDelayMs: number;
20
+ retryMaxDelayMs: number;
21
+ };
22
+ /** Override lease and delay limits at the composition root. */
23
+ type OutboxRepositoryOptions = Readonly<Partial<typeof OUTBOX_REPOSITORY_DEFAULTS>>;
24
+ //#endregion
25
+ export { eventCollector as n, DrizzleSchema as r, OutboxRepositoryOptions as t };
26
+ //# sourceMappingURL=outbox-repository-options-BiilQf4_.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"outbox-repository-options-BiilQf4_.d.ts","names":[],"sources":["../src/infrastructure/drizzle-schema.ts","../src/infrastructure/unit-of-work-context.ts","../src/infrastructure/outbox-repository-options.ts"],"mappings":";;;;;;;;KAKY,gBAAgB;;;;cCuBf,gBAAgB;;;;cC3BhB;EACX;EACA;EACA;;;KAIU,0BAA0B,SAAS,eAAe"}
@@ -1,6 +1,6 @@
1
- import { W as DomainEvent, nt as DatabaseFailure, q as MessageId, tt as DatabaseError } from "./index-Bl0K1RQW.js";
2
- import { C as IMessageStore, x as ITransaction } from "./index-DwWxIdQP.js";
3
- import { n as DrizzleSchema, t as eventCollector } from "./unit-of-work-context-D5ytJIa-.js";
1
+ import { G as DomainEvent, J as MessageId, nt as DatabaseError, rt as DatabaseFailure } from "./index-B5O6mNjZ.js";
2
+ import { A as IOutboxRepository, M as OutboxClaim, N as OutboxMessage, P as IMessageStore, k as DeadLetterReason, w as ITransaction } from "./index-DG4jkwad.js";
3
+ import { n as eventCollector, r as DrizzleSchema, t as OutboxRepositoryOptions } from "./outbox-repository-options-BiilQf4_.js";
4
4
  import { ExtractTablesWithRelations } from "drizzle-orm";
5
5
  import { PostgresJsDatabase, PostgresJsTransaction, drizzle } from "drizzle-orm/postgres-js";
6
6
  //#region src/infrastructure/postgres/postgres-database-types.d.ts
@@ -285,5 +285,31 @@ export declare function executeDatabase<T>(operation: string, work: () => Promis
285
285
  /** Read SQLSTATE or driver codes; unknown callback exceptions remain unchanged. */
286
286
  export declare function classifyPostgresError(cause: unknown, operation: string): DatabaseError | undefined;
287
287
  //#endregion
288
- export { type AggregateEvents, DatabaseError, type DatabaseFailure, type PostgresConnection, type PostgresDb, type PostgresDbTransaction, type PostgresReadDb, type PostgresTx, eventCollector };
288
+ //#region src/infrastructure/postgres/postgres-outbox-repository.d.ts
289
+ /**
290
+ * Typist's lease protocol with riposte's SKIP LOCKED selection on a plain connection, outside a unit of work.
291
+ * Every write runs once; lease expiry and the scheduled sweep recover interrupted operations.
292
+ */
293
+ export declare class PostgresOutboxRepository implements IOutboxRepository {
294
+ private readonly db;
295
+ private readonly now;
296
+ private readonly options;
297
+ constructor(db: PostgresDb<DrizzleSchema>, options?: OutboxRepositoryOptions, now?: () => Date);
298
+ /** Lease due rows in one statement; increment attempts even when reclaiming an expired lease. */
299
+ claimPending(batchSize: number): Promise<OutboxClaim>;
300
+ /** Mark only supplied rows that still hold this token as published. */
301
+ markPublished(claim: OutboxClaim, messages: ReadonlyArray<OutboxMessage>): Promise<void>;
302
+ /** Release supplied rows with per-row backoff; a stale token cannot change a newer claim. */
303
+ releaseClaim(claim: OutboxClaim, messages: ReadonlyArray<OutboxMessage>): Promise<void>;
304
+ /** Retain the full payload and record a closed reason with a readable error message. */
305
+ deadLetter(claim: OutboxClaim, messages: ReadonlyArray<OutboxMessage>, reason: DeadLetterReason, errorMessage: string): Promise<void>;
306
+ /** Delete at most batchSize published rows, strictly before the cutoff. */
307
+ deletePublishedBefore(cutoff: Date, batchSize: number): Promise<number>;
308
+ /** Delete at most batchSize failed rows; pending and published rows remain untouched. */
309
+ deleteFailedBefore(cutoff: Date, batchSize: number): Promise<number>;
310
+ /** Delete at most batchSize receipts, strictly before the cutoff. */
311
+ deleteReceiptsBefore(cutoff: Date, batchSize: number): Promise<number>;
312
+ }
313
+ //#endregion
314
+ export { type AggregateEvents, DatabaseError, type DatabaseFailure, type OutboxRepositoryOptions, type PostgresConnection, type PostgresDb, type PostgresDbTransaction, type PostgresReadDb, type PostgresTx, eventCollector };
289
315
  //# sourceMappingURL=postgres.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"postgres.d.ts","names":[],"sources":["../src/infrastructure/postgres/postgres-database-types.ts","../src/infrastructure/postgres/base-postgres-repository.ts","../src/infrastructure/postgres/postgres-message-store.ts","../src/infrastructure/postgres/postgres-schema.ts","../src/infrastructure/postgres/postgres-transaction.ts","../src/infrastructure/postgres/postgres-database-execution.ts","../src/infrastructure/postgres/classify-postgres-error.ts"],"mappings":";;;;;;;KAMY,qBAAqB,kBAAkB;;KAGvC,WAAW,eAAe,iBAAiB,mBAAmB;;KAG9D,eAAe,eAAe,iBAAiB,KACzD,WAAW;;KAKD,sBAAsB,eAAe,iBAAiB,sBAChE,QACA,2BAA2B;;KAIjB,WAAW,eAAe;WAC3B,IAAI,sBAAsB;;;;;UCjBpB;EACf,iBAAiB,cAAc;EAC/B;;;8BAIoB,uBAAuB,eAAe;qBAC3B,IAAI,sBAAsB;EAAzD,YAA+B,IAAI,sBAAsB;;YAG/C,KAAK,GAAG,mBAAmB,aAAa,YAAY,KAAK,QAAQ;;YAKjE,eAAe,WAAW;;;;;qBCfzB,qBAAqB,eAAe,0BAA0B;mBAC5C;EAA7B,YAA6B,IAAI,sBAAsB;EAEjD,MAAM,WAAW,YAAY;EAU7B,cAAc,QAAQ,cAAc,eAAe;;;;;qBCf9C,iDAAiB;;;;;;;;;;;;;;;;;;;;;;;;;;YAAjB;;;;;;;;;;;;;;;;qBAMA,6CAAa;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;qBCDb,oBAAoB,eAAe,0BAA0B,aACxE,WAAW;mBAEkB;EAA7B,YAA6B,IAAI,WAAW;EAE5C,YAAY,GAAG,OAAO,IAAI,WAAW,YAAY,QAAQ,KAAK,QAAQ;;;;;wBCblD,gBAAgB,GACpC,mBACA,YAAY,YAAY,KACvB,QAAQ;;;;wBC2BK,sBACd,gBACA,oBACC"}
1
+ {"version":3,"file":"postgres.d.ts","names":[],"sources":["../src/infrastructure/postgres/postgres-database-types.ts","../src/infrastructure/postgres/base-postgres-repository.ts","../src/infrastructure/postgres/postgres-message-store.ts","../src/infrastructure/postgres/postgres-schema.ts","../src/infrastructure/postgres/postgres-transaction.ts","../src/infrastructure/postgres/postgres-database-execution.ts","../src/infrastructure/postgres/classify-postgres-error.ts","../src/infrastructure/postgres/postgres-outbox-repository.ts"],"mappings":";;;;;;;KAMY,qBAAqB,kBAAkB;;KAGvC,WAAW,eAAe,iBAAiB,mBAAmB;;KAG9D,eAAe,eAAe,iBAAiB,KACzD,WAAW;;KAKD,sBAAsB,eAAe,iBAAiB,sBAChE,QACA,2BAA2B;;KAIjB,WAAW,eAAe;WAC3B,IAAI,sBAAsB;;;;;UCjBpB;EACf,iBAAiB,cAAc;EAC/B;;;8BAIoB,uBAAuB,eAAe;qBAC3B,IAAI,sBAAsB;EAAzD,YAA+B,IAAI,sBAAsB;;YAG/C,KAAK,GAAG,mBAAmB,aAAa,YAAY,KAAK,QAAQ;;YAKjE,eAAe,WAAW;;;;;qBCfzB,qBAAqB,eAAe,0BAA0B;mBAC5C;EAA7B,YAA6B,IAAI,sBAAsB;EAEjD,MAAM,WAAW,YAAY;EAU7B,cAAc,QAAQ,cAAc,eAAe;;;;;qBCf9C,iDAAiB;;;;;;;;;;;;;;;;;;;;;;;;;;YAAjB;;;;;;;;;;;;;;;;qBAMA,6CAAa;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;qBCDb,oBAAoB,eAAe,0BAA0B,aACxE,WAAW;mBAEkB;EAA7B,YAA6B,IAAI,WAAW;EAE5C,YAAY,GAAG,OAAO,IAAI,WAAW,YAAY,QAAQ,KAAK,QAAQ;;;;;wBCblD,gBAAgB,GACpC,mBACA,YAAY,YAAY,KACvB,QAAQ;;;;wBC2BK,sBACd,gBACA,oBACC;;;;;;;qBClBU,oCAAoC;mBAI5B;mBAEA;mBALF;EAEjB,YACmB,IAAI,WAAW,gBAChC,UAAS,yBACQ,YAAW;;EAMxB,aAAa,oBAAoB,QAAQ;;EAoDzC,cAAc,OAAO,aAAa,UAAU,cAAc,iBAAiB;;EAmB3E,aAAa,OAAO,aAAa,UAAU,cAAc,iBAAiB;;EAwB1E,WACJ,OAAO,aACP,UAAU,cAAc,gBACxB,QAAQ,kBACR,uBACC;;EAwBG,sBAAsB,QAAQ,MAAM,oBAAoB;;EAqBxD,mBAAmB,QAAQ,MAAM,oBAAoB;;EAqBrD,qBAAqB,QAAQ,MAAM,oBAAoB"}
package/dist/postgres.js CHANGED
@@ -1,8 +1,8 @@
1
- import { E as DatabaseError, Q as AppError } from "./core-DG_cC8YT.js";
2
- import { i as MessageAlreadyProcessedError } from "./application-CIxo_BpK.js";
3
- import { n as eventCollector, r as runWithUnitOfWorkContext } from "./unit-of-work-context-BPE1In2f.js";
1
+ import { $ as AppError, D as DatabaseError, c as exponentialDelayCapMs } from "./core-BiqdOMwU.js";
2
+ import { o as MessageAlreadyProcessedError } from "./application-DnlgWcOZ.js";
3
+ import { i as runWithUnitOfWorkContext, r as eventCollector, t as OUTBOX_REPOSITORY_DEFAULTS } from "./outbox-repository-options-BI75j7b7.js";
4
4
  import { DrizzleQueryError } from "drizzle-orm/errors";
5
- import { isNull } from "drizzle-orm";
5
+ import { and, asc, eq, inArray, isNull, lt, lte, or, sql } from "drizzle-orm";
6
6
  import { index, integer, jsonb, pgTable, text, timestamp } from "drizzle-orm/pg-core";
7
7
 
8
8
  //#region src/infrastructure/postgres/classify-postgres-error.ts
@@ -165,5 +165,92 @@ var PostgresTransaction = class {
165
165
  };
166
166
 
167
167
  //#endregion
168
- export { BasePostgresRepository, DatabaseError, PostgresMessageStore, PostgresTransaction, classifyPostgresError, eventCollector, executeDatabase, messageOutbox, processedMessages };
168
+ //#region src/infrastructure/postgres/postgres-outbox-repository.ts
169
+ /**
170
+ * Typist's lease protocol with riposte's SKIP LOCKED selection on a plain connection, outside a unit of work.
171
+ * Every write runs once; lease expiry and the scheduled sweep recover interrupted operations.
172
+ */
173
+ var PostgresOutboxRepository = class {
174
+ db;
175
+ now;
176
+ options;
177
+ constructor(db, options = {}, now = () => /* @__PURE__ */ new Date()) {
178
+ this.db = db;
179
+ this.now = now;
180
+ this.options = {
181
+ ...OUTBOX_REPOSITORY_DEFAULTS,
182
+ ...options
183
+ };
184
+ }
185
+ /** Lease due rows in one statement; increment attempts even when reclaiming an expired lease. */
186
+ async claimPending(batchSize) {
187
+ const token = crypto.randomUUID();
188
+ const now = this.now();
189
+ const rows = await executeDatabase("claim pending outbox messages", () => this.db.update(messageOutbox).set({
190
+ claimToken: token,
191
+ claimExpiresAt: new Date(now.getTime() + this.options.leaseMs),
192
+ attempts: sql`${messageOutbox.attempts} + 1`
193
+ }).where(inArray(messageOutbox.id, this.db.select({ id: messageOutbox.id }).from(messageOutbox).where(and(isNull(messageOutbox.publishedAt), isNull(messageOutbox.failedAt), lte(messageOutbox.availableAt, now), or(isNull(messageOutbox.claimExpiresAt), lte(messageOutbox.claimExpiresAt, now)))).orderBy(asc(messageOutbox.availableAt), asc(messageOutbox.createdAt), asc(messageOutbox.id)).limit(batchSize).for("update", { skipLocked: true }))).returning({
194
+ payload: messageOutbox.payload,
195
+ attempts: messageOutbox.attempts,
196
+ availableAt: messageOutbox.availableAt,
197
+ createdAt: messageOutbox.createdAt
198
+ }));
199
+ rows.sort((a, b) => a.availableAt.getTime() - b.availableAt.getTime() || a.createdAt.getTime() - b.createdAt.getTime() || a.payload.id.localeCompare(b.payload.id));
200
+ return {
201
+ token,
202
+ messages: rows.map(({ payload, attempts }) => ({
203
+ payload,
204
+ attempts
205
+ }))
206
+ };
207
+ }
208
+ /** Mark only supplied rows that still hold this token as published. */
209
+ async markPublished(claim, messages) {
210
+ if (messages.length === 0) return;
211
+ await executeDatabase("mark outbox messages published", () => this.db.update(messageOutbox).set({
212
+ publishedAt: this.now(),
213
+ claimToken: null,
214
+ claimExpiresAt: null
215
+ }).where(and(inArray(messageOutbox.id, messages.map((row) => row.payload.id)), eq(messageOutbox.claimToken, claim.token))));
216
+ }
217
+ /** Release supplied rows with per-row backoff; a stale token cannot change a newer claim. */
218
+ async releaseClaim(claim, messages) {
219
+ const now = this.now();
220
+ for (const attempts of new Set(messages.map((row) => row.attempts))) {
221
+ const ids = messages.filter((row) => row.attempts === attempts).map((row) => row.payload.id);
222
+ const delay = exponentialDelayCapMs(this.options.retryBaseDelayMs, this.options.retryMaxDelayMs, attempts);
223
+ await executeDatabase("release outbox claim", () => this.db.update(messageOutbox).set({
224
+ availableAt: new Date(now.getTime() + delay),
225
+ claimToken: null,
226
+ claimExpiresAt: null
227
+ }).where(and(inArray(messageOutbox.id, ids), eq(messageOutbox.claimToken, claim.token))));
228
+ }
229
+ }
230
+ /** Retain the full payload and record a closed reason with a readable error message. */
231
+ async deadLetter(claim, messages, reason, errorMessage) {
232
+ if (messages.length === 0) return;
233
+ await executeDatabase("dead-letter outbox messages", () => this.db.update(messageOutbox).set({
234
+ failedAt: this.now(),
235
+ lastError: `${reason}: ${errorMessage}`,
236
+ claimToken: null,
237
+ claimExpiresAt: null
238
+ }).where(and(inArray(messageOutbox.id, messages.map((row) => row.payload.id)), eq(messageOutbox.claimToken, claim.token))));
239
+ }
240
+ /** Delete at most batchSize published rows, strictly before the cutoff. */
241
+ async deletePublishedBefore(cutoff, batchSize) {
242
+ return (await executeDatabase("delete published outbox messages", () => this.db.delete(messageOutbox).where(inArray(messageOutbox.id, this.db.select({ id: messageOutbox.id }).from(messageOutbox).where(lt(messageOutbox.publishedAt, cutoff)).orderBy(asc(messageOutbox.publishedAt), asc(messageOutbox.id)).limit(batchSize))).returning({ id: messageOutbox.id }))).length;
243
+ }
244
+ /** Delete at most batchSize failed rows; pending and published rows remain untouched. */
245
+ async deleteFailedBefore(cutoff, batchSize) {
246
+ return (await executeDatabase("delete failed outbox messages", () => this.db.delete(messageOutbox).where(inArray(messageOutbox.id, this.db.select({ id: messageOutbox.id }).from(messageOutbox).where(and(isNull(messageOutbox.publishedAt), lt(messageOutbox.failedAt, cutoff))).orderBy(asc(messageOutbox.failedAt), asc(messageOutbox.id)).limit(batchSize))).returning({ id: messageOutbox.id }))).length;
247
+ }
248
+ /** Delete at most batchSize receipts, strictly before the cutoff. */
249
+ async deleteReceiptsBefore(cutoff, batchSize) {
250
+ return (await executeDatabase("delete message receipts", () => this.db.delete(processedMessages).where(inArray(processedMessages.messageId, this.db.select({ messageId: processedMessages.messageId }).from(processedMessages).where(lt(processedMessages.processedAt, cutoff)).orderBy(asc(processedMessages.processedAt), asc(processedMessages.messageId)).limit(batchSize))).returning({ messageId: processedMessages.messageId }))).length;
251
+ }
252
+ };
253
+
254
+ //#endregion
255
+ export { BasePostgresRepository, DatabaseError, PostgresMessageStore, PostgresOutboxRepository, PostgresTransaction, classifyPostgresError, eventCollector, executeDatabase, messageOutbox, processedMessages };
169
256
  //# sourceMappingURL=postgres.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"postgres.js","names":[],"sources":["../src/infrastructure/postgres/classify-postgres-error.ts","../src/infrastructure/postgres/postgres-database-execution.ts","../src/infrastructure/postgres/base-postgres-repository.ts","../src/infrastructure/postgres/postgres-schema.ts","../src/infrastructure/postgres/postgres-message-store.ts","../src/infrastructure/postgres/postgres-transaction.ts"],"sourcesContent":["import { DrizzleQueryError } from 'drizzle-orm/errors'\n\nimport { AppError, DatabaseError, type FailureClassification } from '@/core'\n\nconst TRANSIENT_CODES = new Set([\n '40001',\n '40P01',\n '08000',\n '08001',\n '08003',\n '08006',\n '57P01',\n '57P02',\n '57P03',\n 'CONNECT_TIMEOUT',\n 'CONNECTION_CLOSED',\n 'ECONNRESET',\n 'ECONNREFUSED',\n 'EPIPE',\n 'ETIMEDOUT',\n 'EAI_AGAIN',\n])\nconst TERMINAL_CODES = new Set(['57014', 'CONNECTION_ENDED', 'CONNECTION_DESTROYED', 'ECANCELED'])\nconst DRIVER_CODES = new Set([\n 'CONNECT_TIMEOUT',\n 'CONNECTION_CLOSED',\n 'CONNECTION_ENDED',\n 'CONNECTION_DESTROYED',\n])\nconst TERMINAL_CLASSES = new Set(['0A', '22', '23', '25', '28', '3D', '3F', '42', '54'])\n\n/* oxlint-disable anti-slop/no-runtime-typeof -- Raw driver exceptions have no shared schema; this boundary validates the fields it exposes. */\n/** Read SQLSTATE or driver codes; unknown callback exceptions remain unchanged. */\nexport function classifyPostgresError(\n cause: unknown,\n operation: string,\n): DatabaseError | undefined {\n let error = cause\n const seen = new Set<unknown>()\n while (error instanceof DrizzleQueryError && !seen.has(error)) {\n seen.add(error)\n error = error.cause\n }\n if (error instanceof AppError || (error instanceof Error && error.name === 'AbortError'))\n return undefined\n const code =\n error instanceof Error && 'code' in error && typeof error.code === 'string'\n ? error.code\n : undefined\n const serverError =\n error instanceof Error &&\n error.name === 'PostgresError' &&\n code !== undefined &&\n /^[0-9A-Z]{5}$/.test(code)\n const driverError = code !== undefined && DRIVER_CODES.has(code)\n if (!(cause instanceof DrizzleQueryError) && !serverError && !driverError) return undefined\n\n return new DatabaseError(\n operation,\n classificationOf(code),\n {\n database: 'postgres',\n code,\n table_name:\n error instanceof Error && 'table_name' in error && typeof error.table_name === 'string'\n ? error.table_name\n : undefined,\n column_name:\n error instanceof Error && 'column_name' in error && typeof error.column_name === 'string'\n ? error.column_name\n : undefined,\n constraint_name:\n error instanceof Error &&\n 'constraint_name' in error &&\n typeof error.constraint_name === 'string'\n ? error.constraint_name\n : undefined,\n },\n { cause },\n )\n}\n\n/* oxlint-enable anti-slop/no-runtime-typeof */\n\nfunction classificationOf(code: string | undefined): FailureClassification {\n if (code === undefined) return 'unknown'\n if (TRANSIENT_CODES.has(code)) return 'transient'\n if (TERMINAL_CODES.has(code) || TERMINAL_CLASSES.has(code.slice(0, 2))) return 'terminal'\n return 'unknown'\n}\n","import { classifyPostgresError } from './classify-postgres-error.ts'\n\n/** Execute once without retries; convert recognized Postgres failures and preserve other exceptions unchanged. */\nexport async function executeDatabase<T>(\n operation: string,\n work: () => PromiseLike<T>,\n): Promise<T> {\n try {\n return await work()\n } catch (cause) {\n throw classifyPostgresError(cause, operation) ?? cause\n }\n}\n","import type { DomainEvent } from '@/core'\n\nimport type { DrizzleSchema } from '../drizzle-schema.ts'\nimport { eventCollector } from '../unit-of-work-context.ts'\nimport { executeDatabase } from './postgres-database-execution.ts'\nimport type { PostgresDbTransaction } from './postgres-database-types.ts'\n\n/** A domain aggregate that accumulates events for the unit of work to drain. */\nexport interface AggregateEvents {\n collectEvents(): ReadonlyArray<DomainEvent>\n clearEvents(): void\n}\n\n/** Shared plumbing for a repository that writes inside a Postgres unit of work. */\nexport abstract class BasePostgresRepository<Schema extends DrizzleSchema> {\n constructor(protected readonly db: PostgresDbTransaction<Schema>) {}\n\n /** Execute one read with database error conversion and no automatic retries. */\n protected read<T>(operation: string, query: () => PromiseLike<T>): Promise<T> {\n return executeDatabase(operation, query)\n }\n\n /** Drain an aggregate's pending events into the active boundary. */\n protected dispatchEvents(aggregate: AggregateEvents): void {\n const events = aggregate.collectEvents()\n if (events.length === 0) return\n for (const event of events) eventCollector.emit(event)\n aggregate.clearEvents()\n }\n}\n","import { isNull } from 'drizzle-orm'\nimport { index, integer, jsonb, pgTable, text, timestamp } from 'drizzle-orm/pg-core'\n\nimport type { DomainEvent } from '@/core'\n\n/** One row per committed message. The primary key is the idempotency claim. */\nexport const processedMessages = pgTable('processed_messages', {\n messageId: text('message_id').primaryKey(),\n processedAt: timestamp('processed_at', { withTimezone: true }).notNull().defaultNow(),\n})\n\n/** Events committed with their state change, waiting for the relay to deliver them. */\nexport const messageOutbox = pgTable(\n 'message_outbox',\n {\n id: text('id').primaryKey(),\n payload: jsonb('payload').$type<DomainEvent>().notNull(),\n createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),\n availableAt: timestamp('available_at', { withTimezone: true }).notNull().defaultNow(),\n attempts: integer('attempts').notNull().default(0),\n claimToken: text('claim_token'),\n claimExpiresAt: timestamp('claim_expires_at', { withTimezone: true }),\n publishedAt: timestamp('published_at', { withTimezone: true }),\n failedAt: timestamp('failed_at', { withTimezone: true }),\n lastError: text('last_error'),\n },\n (table) => [\n index('idx_message_outbox_due')\n .on(table.availableAt, table.createdAt)\n .where(isNull(table.publishedAt)),\n index('idx_message_outbox_claim_token').on(table.claimToken),\n ],\n)\n","import { MessageAlreadyProcessedError, type IMessageStore, type MessageId } from '@/application'\nimport type { DomainEvent } from '@/core'\n\nimport type { DrizzleSchema } from '../drizzle-schema.ts'\nimport type { PostgresDbTransaction } from './postgres-database-types.ts'\nimport { messageOutbox, processedMessages } from './postgres-schema.ts'\n\n/** Processed messages and outbox events share the active Postgres transaction; the insert result establishes the claim. */\nexport class PostgresMessageStore<Schema extends DrizzleSchema> implements IMessageStore {\n constructor(private readonly db: PostgresDbTransaction<Schema>) {}\n\n async claim(messageId: MessageId): Promise<void> {\n const claimed = await this.db\n .insert(processedMessages)\n .values({ messageId })\n .onConflictDoNothing()\n .returning({ messageId: processedMessages.messageId })\n // Thrown inside the transaction, so drizzle rolls back everything written before the claim.\n if (claimed.length === 0) throw new MessageAlreadyProcessedError(messageId)\n }\n\n async persistEvents(events: ReadonlyArray<DomainEvent>): Promise<void> {\n if (events.length === 0) return\n await this.db\n .insert(messageOutbox)\n .values(events.map((event) => ({ id: event.id, payload: event })))\n }\n}\n","import type { ITransaction } from '@/application'\n\nimport type { DrizzleSchema } from '../drizzle-schema.ts'\nimport { runWithUnitOfWorkContext } from '../unit-of-work-context.ts'\nimport { executeDatabase } from './postgres-database-execution.ts'\nimport type { PostgresDb, PostgresTx } from './postgres-database-types.ts'\n\n/**\n * The Postgres transaction boundary uses native Drizzle transactions through postgres.js.\n * Drizzle commits when `work` returns and rolls back when it throws.\n */\nexport class PostgresTransaction<Schema extends DrizzleSchema> implements ITransaction<\n PostgresTx<Schema>\n> {\n constructor(private readonly db: PostgresDb<Schema>) {}\n\n transaction<T>(work: (tx: PostgresTx<Schema>) => Promise<T>): Promise<T> {\n return runWithUnitOfWorkContext('postgres', () =>\n executeDatabase('unit of work transaction', () => this.db.transaction((db) => work({ db }))),\n )\n }\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"postgres.js","names":[],"sources":["../src/infrastructure/postgres/classify-postgres-error.ts","../src/infrastructure/postgres/postgres-database-execution.ts","../src/infrastructure/postgres/base-postgres-repository.ts","../src/infrastructure/postgres/postgres-schema.ts","../src/infrastructure/postgres/postgres-message-store.ts","../src/infrastructure/postgres/postgres-transaction.ts","../src/infrastructure/postgres/postgres-outbox-repository.ts"],"sourcesContent":["import { DrizzleQueryError } from 'drizzle-orm/errors'\n\nimport { AppError, DatabaseError, type FailureClassification } from '@/core'\n\nconst TRANSIENT_CODES = new Set([\n '40001',\n '40P01',\n '08000',\n '08001',\n '08003',\n '08006',\n '57P01',\n '57P02',\n '57P03',\n 'CONNECT_TIMEOUT',\n 'CONNECTION_CLOSED',\n 'ECONNRESET',\n 'ECONNREFUSED',\n 'EPIPE',\n 'ETIMEDOUT',\n 'EAI_AGAIN',\n])\nconst TERMINAL_CODES = new Set(['57014', 'CONNECTION_ENDED', 'CONNECTION_DESTROYED', 'ECANCELED'])\nconst DRIVER_CODES = new Set([\n 'CONNECT_TIMEOUT',\n 'CONNECTION_CLOSED',\n 'CONNECTION_ENDED',\n 'CONNECTION_DESTROYED',\n])\nconst TERMINAL_CLASSES = new Set(['0A', '22', '23', '25', '28', '3D', '3F', '42', '54'])\n\n/* oxlint-disable anti-slop/no-runtime-typeof -- Raw driver exceptions have no shared schema; this boundary validates the fields it exposes. */\n/** Read SQLSTATE or driver codes; unknown callback exceptions remain unchanged. */\nexport function classifyPostgresError(\n cause: unknown,\n operation: string,\n): DatabaseError | undefined {\n let error = cause\n const seen = new Set<unknown>()\n while (error instanceof DrizzleQueryError && !seen.has(error)) {\n seen.add(error)\n error = error.cause\n }\n if (error instanceof AppError || (error instanceof Error && error.name === 'AbortError'))\n return undefined\n const code =\n error instanceof Error && 'code' in error && typeof error.code === 'string'\n ? error.code\n : undefined\n const serverError =\n error instanceof Error &&\n error.name === 'PostgresError' &&\n code !== undefined &&\n /^[0-9A-Z]{5}$/.test(code)\n const driverError = code !== undefined && DRIVER_CODES.has(code)\n if (!(cause instanceof DrizzleQueryError) && !serverError && !driverError) return undefined\n\n return new DatabaseError(\n operation,\n classificationOf(code),\n {\n database: 'postgres',\n code,\n table_name:\n error instanceof Error && 'table_name' in error && typeof error.table_name === 'string'\n ? error.table_name\n : undefined,\n column_name:\n error instanceof Error && 'column_name' in error && typeof error.column_name === 'string'\n ? error.column_name\n : undefined,\n constraint_name:\n error instanceof Error &&\n 'constraint_name' in error &&\n typeof error.constraint_name === 'string'\n ? error.constraint_name\n : undefined,\n },\n { cause },\n )\n}\n\n/* oxlint-enable anti-slop/no-runtime-typeof */\n\nfunction classificationOf(code: string | undefined): FailureClassification {\n if (code === undefined) return 'unknown'\n if (TRANSIENT_CODES.has(code)) return 'transient'\n if (TERMINAL_CODES.has(code) || TERMINAL_CLASSES.has(code.slice(0, 2))) return 'terminal'\n return 'unknown'\n}\n","import { classifyPostgresError } from './classify-postgres-error.ts'\n\n/** Execute once without retries; convert recognized Postgres failures and preserve other exceptions unchanged. */\nexport async function executeDatabase<T>(\n operation: string,\n work: () => PromiseLike<T>,\n): Promise<T> {\n try {\n return await work()\n } catch (cause) {\n throw classifyPostgresError(cause, operation) ?? cause\n }\n}\n","import type { DomainEvent } from '@/core'\n\nimport type { DrizzleSchema } from '../drizzle-schema.ts'\nimport { eventCollector } from '../unit-of-work-context.ts'\nimport { executeDatabase } from './postgres-database-execution.ts'\nimport type { PostgresDbTransaction } from './postgres-database-types.ts'\n\n/** A domain aggregate that accumulates events for the unit of work to drain. */\nexport interface AggregateEvents {\n collectEvents(): ReadonlyArray<DomainEvent>\n clearEvents(): void\n}\n\n/** Shared plumbing for a repository that writes inside a Postgres unit of work. */\nexport abstract class BasePostgresRepository<Schema extends DrizzleSchema> {\n constructor(protected readonly db: PostgresDbTransaction<Schema>) {}\n\n /** Execute one read with database error conversion and no automatic retries. */\n protected read<T>(operation: string, query: () => PromiseLike<T>): Promise<T> {\n return executeDatabase(operation, query)\n }\n\n /** Drain an aggregate's pending events into the active boundary. */\n protected dispatchEvents(aggregate: AggregateEvents): void {\n const events = aggregate.collectEvents()\n if (events.length === 0) return\n for (const event of events) eventCollector.emit(event)\n aggregate.clearEvents()\n }\n}\n","import { isNull } from 'drizzle-orm'\nimport { index, integer, jsonb, pgTable, text, timestamp } from 'drizzle-orm/pg-core'\n\nimport type { DomainEvent } from '@/core'\n\n/** One row per committed message. The primary key is the idempotency claim. */\nexport const processedMessages = pgTable('processed_messages', {\n messageId: text('message_id').primaryKey(),\n processedAt: timestamp('processed_at', { withTimezone: true }).notNull().defaultNow(),\n})\n\n/** Events committed with their state change, waiting for the relay to deliver them. */\nexport const messageOutbox = pgTable(\n 'message_outbox',\n {\n id: text('id').primaryKey(),\n payload: jsonb('payload').$type<DomainEvent>().notNull(),\n createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),\n availableAt: timestamp('available_at', { withTimezone: true }).notNull().defaultNow(),\n attempts: integer('attempts').notNull().default(0),\n claimToken: text('claim_token'),\n claimExpiresAt: timestamp('claim_expires_at', { withTimezone: true }),\n publishedAt: timestamp('published_at', { withTimezone: true }),\n failedAt: timestamp('failed_at', { withTimezone: true }),\n lastError: text('last_error'),\n },\n (table) => [\n index('idx_message_outbox_due')\n .on(table.availableAt, table.createdAt)\n .where(isNull(table.publishedAt)),\n index('idx_message_outbox_claim_token').on(table.claimToken),\n ],\n)\n","import { MessageAlreadyProcessedError, type IMessageStore, type MessageId } from '@/application'\nimport type { DomainEvent } from '@/core'\n\nimport type { DrizzleSchema } from '../drizzle-schema.ts'\nimport type { PostgresDbTransaction } from './postgres-database-types.ts'\nimport { messageOutbox, processedMessages } from './postgres-schema.ts'\n\n/** Processed messages and outbox events share the active Postgres transaction; the insert result establishes the claim. */\nexport class PostgresMessageStore<Schema extends DrizzleSchema> implements IMessageStore {\n constructor(private readonly db: PostgresDbTransaction<Schema>) {}\n\n async claim(messageId: MessageId): Promise<void> {\n const claimed = await this.db\n .insert(processedMessages)\n .values({ messageId })\n .onConflictDoNothing()\n .returning({ messageId: processedMessages.messageId })\n // Thrown inside the transaction, so drizzle rolls back everything written before the claim.\n if (claimed.length === 0) throw new MessageAlreadyProcessedError(messageId)\n }\n\n async persistEvents(events: ReadonlyArray<DomainEvent>): Promise<void> {\n if (events.length === 0) return\n await this.db\n .insert(messageOutbox)\n .values(events.map((event) => ({ id: event.id, payload: event })))\n }\n}\n","import type { ITransaction } from '@/application'\n\nimport type { DrizzleSchema } from '../drizzle-schema.ts'\nimport { runWithUnitOfWorkContext } from '../unit-of-work-context.ts'\nimport { executeDatabase } from './postgres-database-execution.ts'\nimport type { PostgresDb, PostgresTx } from './postgres-database-types.ts'\n\n/**\n * The Postgres transaction boundary uses native Drizzle transactions through postgres.js.\n * Drizzle commits when `work` returns and rolls back when it throws.\n */\nexport class PostgresTransaction<Schema extends DrizzleSchema> implements ITransaction<\n PostgresTx<Schema>\n> {\n constructor(private readonly db: PostgresDb<Schema>) {}\n\n transaction<T>(work: (tx: PostgresTx<Schema>) => Promise<T>): Promise<T> {\n return runWithUnitOfWorkContext('postgres', () =>\n executeDatabase('unit of work transaction', () => this.db.transaction((db) => work({ db }))),\n )\n }\n}\n","import { and, asc, eq, inArray, isNull, lt, lte, or, sql } from 'drizzle-orm'\n\nimport type { DeadLetterReason, IOutboxRepository, OutboxClaim, OutboxMessage } from '@/application'\nimport { exponentialDelayCapMs } from '@/core'\n\nimport type { DrizzleSchema } from '../drizzle-schema.ts'\nimport {\n OUTBOX_REPOSITORY_DEFAULTS,\n type OutboxRepositoryOptions,\n} from '../outbox-repository-options.ts'\nimport { executeDatabase } from './postgres-database-execution.ts'\nimport type { PostgresDb } from './postgres-database-types.ts'\nimport { messageOutbox, processedMessages } from './postgres-schema.ts'\n\n/**\n * Typist's lease protocol with riposte's SKIP LOCKED selection on a plain connection, outside a unit of work.\n * Every write runs once; lease expiry and the scheduled sweep recover interrupted operations.\n */\nexport class PostgresOutboxRepository implements IOutboxRepository {\n private readonly options: Required<OutboxRepositoryOptions>\n\n constructor(\n private readonly db: PostgresDb<DrizzleSchema>,\n options: OutboxRepositoryOptions = {},\n private readonly now: () => Date = () => new Date(),\n ) {\n this.options = { ...OUTBOX_REPOSITORY_DEFAULTS, ...options }\n }\n\n /** Lease due rows in one statement; increment attempts even when reclaiming an expired lease. */\n async claimPending(batchSize: number): Promise<OutboxClaim> {\n const token = crypto.randomUUID()\n const now = this.now()\n const rows = await executeDatabase('claim pending outbox messages', () =>\n this.db\n .update(messageOutbox)\n .set({\n claimToken: token,\n claimExpiresAt: new Date(now.getTime() + this.options.leaseMs),\n attempts: sql`${messageOutbox.attempts} + 1`,\n })\n .where(\n inArray(\n messageOutbox.id,\n this.db\n .select({ id: messageOutbox.id })\n .from(messageOutbox)\n .where(\n and(\n isNull(messageOutbox.publishedAt),\n isNull(messageOutbox.failedAt),\n lte(messageOutbox.availableAt, now),\n or(isNull(messageOutbox.claimExpiresAt), lte(messageOutbox.claimExpiresAt, now)),\n ),\n )\n .orderBy(\n asc(messageOutbox.availableAt),\n asc(messageOutbox.createdAt),\n asc(messageOutbox.id),\n )\n .limit(batchSize)\n .for('update', { skipLocked: true }),\n ),\n )\n .returning({\n payload: messageOutbox.payload,\n attempts: messageOutbox.attempts,\n availableAt: messageOutbox.availableAt,\n createdAt: messageOutbox.createdAt,\n }),\n )\n // UPDATE RETURNING does not preserve the selection order.\n rows.sort(\n (a, b) =>\n a.availableAt.getTime() - b.availableAt.getTime() ||\n a.createdAt.getTime() - b.createdAt.getTime() ||\n a.payload.id.localeCompare(b.payload.id),\n )\n return { token, messages: rows.map(({ payload, attempts }) => ({ payload, attempts })) }\n }\n\n /** Mark only supplied rows that still hold this token as published. */\n async markPublished(claim: OutboxClaim, messages: ReadonlyArray<OutboxMessage>): Promise<void> {\n if (messages.length === 0) return\n await executeDatabase('mark outbox messages published', () =>\n this.db\n .update(messageOutbox)\n .set({ publishedAt: this.now(), claimToken: null, claimExpiresAt: null })\n .where(\n and(\n inArray(\n messageOutbox.id,\n messages.map((row) => row.payload.id),\n ),\n eq(messageOutbox.claimToken, claim.token),\n ),\n ),\n )\n }\n\n /** Release supplied rows with per-row backoff; a stale token cannot change a newer claim. */\n async releaseClaim(claim: OutboxClaim, messages: ReadonlyArray<OutboxMessage>): Promise<void> {\n const now = this.now()\n for (const attempts of new Set(messages.map((row) => row.attempts))) {\n const ids = messages.filter((row) => row.attempts === attempts).map((row) => row.payload.id)\n const delay = exponentialDelayCapMs(\n this.options.retryBaseDelayMs,\n this.options.retryMaxDelayMs,\n attempts,\n )\n // oxlint-disable-next-line no-await-in-loop -- Stop releasing rows on the first database failure.\n await executeDatabase('release outbox claim', () =>\n this.db\n .update(messageOutbox)\n .set({\n availableAt: new Date(now.getTime() + delay),\n claimToken: null,\n claimExpiresAt: null,\n })\n .where(and(inArray(messageOutbox.id, ids), eq(messageOutbox.claimToken, claim.token))),\n )\n }\n }\n\n /** Retain the full payload and record a closed reason with a readable error message. */\n async deadLetter(\n claim: OutboxClaim,\n messages: ReadonlyArray<OutboxMessage>,\n reason: DeadLetterReason,\n errorMessage: string,\n ): Promise<void> {\n if (messages.length === 0) return\n await executeDatabase('dead-letter outbox messages', () =>\n this.db\n .update(messageOutbox)\n .set({\n failedAt: this.now(),\n lastError: `${reason}: ${errorMessage}`,\n claimToken: null,\n claimExpiresAt: null,\n })\n .where(\n and(\n inArray(\n messageOutbox.id,\n messages.map((row) => row.payload.id),\n ),\n eq(messageOutbox.claimToken, claim.token),\n ),\n ),\n )\n }\n\n /** Delete at most batchSize published rows, strictly before the cutoff. */\n async deletePublishedBefore(cutoff: Date, batchSize: number): Promise<number> {\n const rows = await executeDatabase('delete published outbox messages', () =>\n this.db\n .delete(messageOutbox)\n .where(\n inArray(\n messageOutbox.id,\n this.db\n .select({ id: messageOutbox.id })\n .from(messageOutbox)\n .where(lt(messageOutbox.publishedAt, cutoff))\n .orderBy(asc(messageOutbox.publishedAt), asc(messageOutbox.id))\n .limit(batchSize),\n ),\n )\n .returning({ id: messageOutbox.id }),\n )\n return rows.length\n }\n\n /** Delete at most batchSize failed rows; pending and published rows remain untouched. */\n async deleteFailedBefore(cutoff: Date, batchSize: number): Promise<number> {\n const rows = await executeDatabase('delete failed outbox messages', () =>\n this.db\n .delete(messageOutbox)\n .where(\n inArray(\n messageOutbox.id,\n this.db\n .select({ id: messageOutbox.id })\n .from(messageOutbox)\n .where(and(isNull(messageOutbox.publishedAt), lt(messageOutbox.failedAt, cutoff)))\n .orderBy(asc(messageOutbox.failedAt), asc(messageOutbox.id))\n .limit(batchSize),\n ),\n )\n .returning({ id: messageOutbox.id }),\n )\n return rows.length\n }\n\n /** Delete at most batchSize receipts, strictly before the cutoff. */\n async deleteReceiptsBefore(cutoff: Date, batchSize: number): Promise<number> {\n const rows = await executeDatabase('delete message receipts', () =>\n this.db\n .delete(processedMessages)\n .where(\n inArray(\n processedMessages.messageId,\n this.db\n .select({ messageId: processedMessages.messageId })\n .from(processedMessages)\n .where(lt(processedMessages.processedAt, cutoff))\n .orderBy(asc(processedMessages.processedAt), asc(processedMessages.messageId))\n .limit(batchSize),\n ),\n )\n .returning({ messageId: processedMessages.messageId }),\n )\n return rows.length\n }\n}\n"],"mappings":"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"}
package/dist/resend.d.ts CHANGED
@@ -1,6 +1,6 @@
1
- import { jt as BounceType } from "./index-Bl0K1RQW.js";
2
- import { D as IEmailService, T as EmailAccepted, k as OutboundEmail } from "./index-DwWxIdQP.js";
3
- import { r as WebhookVerifier, t as WebhookVerification } from "./webhook-verification-sZtwTI5n.js";
1
+ import { Mt as BounceType } from "./index-B5O6mNjZ.js";
2
+ import { B as OutboundEmail, I as EmailAccepted, R as IEmailService } from "./index-DG4jkwad.js";
3
+ import { r as WebhookVerifier, t as WebhookVerification } from "./webhook-verification-CyBoERFz.js";
4
4
  import { Resend } from "resend";
5
5
  //#region src/infrastructure/resend/resend-email-service.d.ts
6
6
  /** The SDK operations required by the adapter; a test factory can return a plain object. */
package/dist/resend.js CHANGED
@@ -1,5 +1,5 @@
1
- import { J as EmailServiceError, K as parseBounceType, Z as EmailAddressSchema } from "./core-DG_cC8YT.js";
2
- import { t as WebhookVerificationError } from "./webhook-verification-BQ9qZnVK.js";
1
+ import { Q as EmailAddressSchema, Y as EmailServiceError, q as parseBounceType } from "./core-BiqdOMwU.js";
2
+ import { t as WebhookVerificationError } from "./webhook-verification-CeXUJXVP.js";
3
3
  import { z } from "zod";
4
4
  import { Buffer } from "node:buffer";
5
5
 
@@ -1,4 +1,4 @@
1
- import { ot as ErrorTable } from "./index-Bl0K1RQW.js";
1
+ import { st as ErrorTable } from "./index-B5O6mNjZ.js";
2
2
  import { Register } from "@tanstack/react-start";
3
3
  import { BetterAuthCookies } from "better-auth";
4
4
  //#region src/infrastructure/tanstack/request-context.d.ts
package/dist/tanstack.js CHANGED
@@ -1,4 +1,4 @@
1
- import { B as StaleSessionError, H as AuthenticationError, I as ValidationError, Q as AppError, a as createLogger } from "./core-DG_cC8YT.js";
1
+ import { $ as AppError, L as ValidationError, U as AuthenticationError, V as StaleSessionError, a as createLogger } from "./core-BiqdOMwU.js";
2
2
  import { z } from "zod";
3
3
  import { getReasonPhrase } from "http-status-codes";
4
4
  import { createMiddleware } from "@tanstack/react-start";
@@ -0,0 +1,44 @@
1
+ import { G as DomainEvent } from "../index-B5O6mNjZ.js";
2
+ import { A as IOutboxRepository } from "../index-DG4jkwad.js";
3
+ //#region src/infrastructure/testing/outbox-repository-contract.d.ts
4
+ /** Stored fields observed by the adapter contract, independent of a database library. */
5
+ export type OutboxContractRow = {
6
+ readonly id: string;
7
+ readonly payload: DomainEvent;
8
+ readonly createdAt: Date;
9
+ readonly availableAt: Date;
10
+ readonly attempts: number;
11
+ readonly claimToken: string | null;
12
+ readonly claimExpiresAt: Date | null;
13
+ readonly publishedAt: Date | null;
14
+ readonly failedAt: Date | null;
15
+ readonly lastError: string | null;
16
+ };
17
+ /** Fixture input for one stored row; omitted fields use fresh, due defaults. */
18
+ export type OutboxContractSeed = {
19
+ readonly id: string;
20
+ } & Partial<Omit<OutboxContractRow, 'id' | 'payload'>>;
21
+ /**
22
+ * An isolated database fixture. Configure a 60-second lease, 1-second base delay, and 60-second cap.
23
+ * The clock starts at a whole second. Each test receives fresh tables and an independent clock.
24
+ */
25
+ export interface OutboxRepositoryContractFixture {
26
+ readonly repository: IOutboxRepository;
27
+ now(this: void): Date;
28
+ advance(ms: number): void;
29
+ seed(rows: ReadonlyArray<OutboxContractSeed>): Promise<void>;
30
+ /** Return rows ordered by id. */
31
+ rows(): Promise<ReadonlyArray<OutboxContractRow>>;
32
+ seedReceipts(rows: ReadonlyArray<{
33
+ readonly messageId: string;
34
+ readonly processedAt: Date;
35
+ }>): Promise<void>;
36
+ receipts(): Promise<ReadonlyArray<{
37
+ readonly messageId: string;
38
+ readonly processedAt: Date;
39
+ }>>;
40
+ }
41
+ /** Register the shared repository contract with an isolated fixture per test; run tests serially if they share a database. */
42
+ export declare function outboxRepositoryContract(test: (name: string, run: () => Promise<void>) => void, create: () => Promise<OutboxRepositoryContractFixture>): void;
43
+ //#endregion
44
+ //# sourceMappingURL=outbox-repository.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"outbox-repository.d.ts","names":[],"sources":["../../src/infrastructure/testing/outbox-repository-contract.ts"],"mappings":";;;;YAMY;WACD;WACA,SAAS;WACT,WAAW;WACX,aAAa;WACb;WACA;WACA,gBAAgB;WAChB,aAAa;WACb,UAAU;WACV;;;YAIC;WAAgC;IAAe,QACzD,KAAK;;;;;iBAOU;WACN,YAAY;EACrB,IAAI,aAAa;EACjB,QAAQ;EACR,KAAK,MAAM,cAAc,sBAAsB;;EAE/C,QAAQ,QAAQ,cAAc;EAC9B,aACE,MAAM;aAAyB;aAA4B,aAAa;OACvE;EACH,YAAY,QAAQ;aAAyB;aAA4B,aAAa;;;;wBAIxE,yBACd,OAAO,cAAc,WAAW,wBAChC,cAAc,QAAQ"}
@@ -0,0 +1,206 @@
1
+ import { deepStrictEqual, equal, notEqual, ok } from "node:assert/strict";
2
+
3
+ //#region src/infrastructure/testing/outbox-repository-contract.ts
4
+ /** Register the shared repository contract with an isolated fixture per test; run tests serially if they share a database. */
5
+ function outboxRepositoryContract(test, create) {
6
+ test("claims due rows with one token and incremented attempts", async () => {
7
+ const f = await create();
8
+ await f.seed([{ id: "a" }, {
9
+ id: "b",
10
+ attempts: 3
11
+ }]);
12
+ const claim = await f.repository.claimPending(50);
13
+ deepStrictEqual(claim.messages.map((row) => row.attempts), [1, 4]);
14
+ ok(claim.token.length > 0);
15
+ deepStrictEqual((await f.rows()).map((row) => row.claimToken), [claim.token, claim.token]);
16
+ });
17
+ test("skips terminal, future, and leased rows", async () => {
18
+ const f = await create();
19
+ const future = new Date(f.now().getTime() + 6e4);
20
+ await f.seed([
21
+ {
22
+ id: "published",
23
+ publishedAt: f.now()
24
+ },
25
+ {
26
+ id: "failed",
27
+ failedAt: f.now()
28
+ },
29
+ {
30
+ id: "future",
31
+ availableAt: future
32
+ },
33
+ {
34
+ id: "leased",
35
+ claimToken: "old",
36
+ claimExpiresAt: future
37
+ },
38
+ { id: "due" }
39
+ ]);
40
+ const claim = await f.repository.claimPending(50);
41
+ deepStrictEqual(claim.messages.map((row) => row.payload.id), ["due"]);
42
+ });
43
+ test("limits a claim and takes the oldest due row first", async () => {
44
+ const f = await create();
45
+ await f.seed([{ id: "new" }, {
46
+ id: "old",
47
+ availableAt: /* @__PURE__ */ new Date(f.now().getTime() - 1e3)
48
+ }]);
49
+ const claim = await f.repository.claimPending(1);
50
+ deepStrictEqual(claim.messages.map((row) => row.payload.id), ["old"]);
51
+ });
52
+ test("concurrent claims never share a row", async () => {
53
+ const f = await create();
54
+ await f.seed([{ id: "a" }, { id: "b" }]);
55
+ const ids = (await Promise.all([f.repository.claimPending(1), f.repository.claimPending(1)])).flatMap((claim) => claim.messages.map((row) => row.payload.id));
56
+ equal(ids.length, 2);
57
+ equal(new Set(ids).size, 2);
58
+ });
59
+ test("reclaims an expired lease and increments attempts again", async () => {
60
+ const f = await create();
61
+ await f.seed([{ id: "a" }]);
62
+ const first = await f.repository.claimPending(1);
63
+ f.advance(6e4);
64
+ const second = await f.repository.claimPending(1);
65
+ notEqual(first.token, second.token);
66
+ deepStrictEqual(second.messages.map((row) => row.attempts), [2]);
67
+ });
68
+ for (const operation of [
69
+ "publish",
70
+ "release",
71
+ "fail"
72
+ ]) test(`a stale claim cannot ${operation} a row`, async () => {
73
+ const f = await create();
74
+ await f.seed([{ id: "a" }]);
75
+ const old = await f.repository.claimPending(1);
76
+ f.advance(6e4);
77
+ await f.repository.claimPending(1);
78
+ const before = await f.rows();
79
+ if (operation === "publish") await f.repository.markPublished(old, old.messages);
80
+ if (operation === "release") await f.repository.releaseClaim(old, old.messages);
81
+ if (operation === "fail") await f.repository.deadLetter(old, old.messages, "terminal", "denied");
82
+ deepStrictEqual(await f.rows(), before);
83
+ });
84
+ test("publishes only the supplied subset and clears its lease", async () => {
85
+ const f = await create();
86
+ await f.seed([{ id: "a" }, { id: "b" }]);
87
+ const claim = await f.repository.claimPending(50);
88
+ await f.repository.markPublished(claim, claim.messages.slice(0, 1));
89
+ deepStrictEqual((await f.rows()).map((row) => ({
90
+ publishedAt: row.publishedAt,
91
+ token: row.claimToken
92
+ })), [{
93
+ publishedAt: f.now(),
94
+ token: null
95
+ }, {
96
+ publishedAt: null,
97
+ token: claim.token
98
+ }]);
99
+ });
100
+ test("releases only the supplied subset with per-row capped backoff", async () => {
101
+ const f = await create();
102
+ await f.seed([
103
+ { id: "a" },
104
+ {
105
+ id: "b",
106
+ attempts: 2
107
+ },
108
+ {
109
+ id: "c",
110
+ attempts: 8
111
+ },
112
+ { id: "d" }
113
+ ]);
114
+ const claim = await f.repository.claimPending(50);
115
+ await f.repository.releaseClaim(claim, claim.messages.slice(0, 3));
116
+ deepStrictEqual((await f.rows()).map((row) => [row.availableAt.getTime() - f.now().getTime(), row.claimToken]), [
117
+ [1e3, null],
118
+ [4e3, null],
119
+ [6e4, null],
120
+ [0, claim.token]
121
+ ]);
122
+ });
123
+ test("dead-letters only the supplied subset and keeps its payload", async () => {
124
+ const f = await create();
125
+ await f.seed([{ id: "a" }, { id: "b" }]);
126
+ const claim = await f.repository.claimPending(50);
127
+ await f.repository.deadLetter(claim, claim.messages.slice(0, 1), "oversized", "too large");
128
+ deepStrictEqual((await f.rows()).map((row) => [
129
+ row.payload.id,
130
+ row.lastError,
131
+ row.claimToken
132
+ ]), [[
133
+ "a",
134
+ "oversized: too large",
135
+ null
136
+ ], [
137
+ "b",
138
+ null,
139
+ claim.token
140
+ ]]);
141
+ });
142
+ for (const operation of [
143
+ "publish",
144
+ "release",
145
+ "fail"
146
+ ]) test(`settles a 120-row claim through ${operation}`, async () => {
147
+ const f = await create();
148
+ await f.seed(Array.from({ length: 120 }, (_, i) => ({ id: String(i) })));
149
+ const claim = await f.repository.claimPending(120);
150
+ equal(claim.messages.length, 120);
151
+ if (operation === "publish") await f.repository.markPublished(claim, claim.messages);
152
+ if (operation === "release") await f.repository.releaseClaim(claim, claim.messages);
153
+ if (operation === "fail") await f.repository.deadLetter(claim, claim.messages, "terminal", "denied");
154
+ ok((await f.rows()).every((row) => row.claimToken === null));
155
+ });
156
+ for (const category of ["published", "failed"]) test(`deletes only old ${category} events within the batch limit`, async () => {
157
+ const f = await create();
158
+ const old = /* @__PURE__ */ new Date(f.now().getTime() - 1e3);
159
+ const dates = category === "published" ? { publishedAt: old } : { failedAt: old };
160
+ const atCutoff = category === "published" ? { publishedAt: f.now() } : { failedAt: f.now() };
161
+ await f.seed([
162
+ {
163
+ id: "old-a",
164
+ ...dates
165
+ },
166
+ {
167
+ id: "old-b",
168
+ ...dates
169
+ },
170
+ {
171
+ id: "cutoff",
172
+ ...atCutoff
173
+ },
174
+ { id: "pending" }
175
+ ]);
176
+ const remove = category === "published" ? f.repository.deletePublishedBefore.bind(f.repository) : f.repository.deleteFailedBefore.bind(f.repository);
177
+ equal(await remove(f.now(), 1), 1);
178
+ equal(await remove(f.now(), 50), 1);
179
+ deepStrictEqual((await f.rows()).map((row) => row.id), ["cutoff", "pending"]);
180
+ });
181
+ test("deletes only old receipts within the batch limit", async () => {
182
+ const f = await create();
183
+ const old = /* @__PURE__ */ new Date(f.now().getTime() - 1e3);
184
+ await f.seedReceipts([
185
+ {
186
+ messageId: "old-a",
187
+ processedAt: old
188
+ },
189
+ {
190
+ messageId: "old-b",
191
+ processedAt: old
192
+ },
193
+ {
194
+ messageId: "cutoff",
195
+ processedAt: f.now()
196
+ }
197
+ ]);
198
+ equal(await f.repository.deleteReceiptsBefore(f.now(), 1), 1);
199
+ equal(await f.repository.deleteReceiptsBefore(f.now(), 50), 1);
200
+ deepStrictEqual((await f.receipts()).map((row) => row.messageId), ["cutoff"]);
201
+ });
202
+ }
203
+
204
+ //#endregion
205
+ export { outboxRepositoryContract };
206
+ //# sourceMappingURL=outbox-repository.js.map