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
@@ -0,0 +1 @@
1
+ {"version":3,"file":"outbox-repository.js","names":[],"sources":["../../src/infrastructure/testing/outbox-repository-contract.ts"],"sourcesContent":["import { deepStrictEqual, equal, notEqual, ok } from 'node:assert/strict'\n\nimport type { IOutboxRepository } from '@/application'\nimport type { DomainEvent } from '@/core'\n\n/** Stored fields observed by the adapter contract, independent of a database library. */\nexport type OutboxContractRow = {\n readonly id: string\n readonly payload: DomainEvent\n readonly createdAt: Date\n readonly availableAt: Date\n readonly attempts: number\n readonly claimToken: string | null\n readonly claimExpiresAt: Date | null\n readonly publishedAt: Date | null\n readonly failedAt: Date | null\n readonly lastError: string | null\n}\n\n/** Fixture input for one stored row; omitted fields use fresh, due defaults. */\nexport type OutboxContractSeed = { readonly id: string } & Partial<\n Omit<OutboxContractRow, 'id' | 'payload'>\n>\n\n/**\n * An isolated database fixture. Configure a 60-second lease, 1-second base delay, and 60-second cap.\n * The clock starts at a whole second. Each test receives fresh tables and an independent clock.\n */\nexport interface OutboxRepositoryContractFixture {\n readonly repository: IOutboxRepository\n now(this: void): Date\n advance(ms: number): void\n seed(rows: ReadonlyArray<OutboxContractSeed>): Promise<void>\n /** Return rows ordered by id. */\n rows(): Promise<ReadonlyArray<OutboxContractRow>>\n seedReceipts(\n rows: ReadonlyArray<{ readonly messageId: string; readonly processedAt: Date }>,\n ): Promise<void>\n receipts(): Promise<ReadonlyArray<{ readonly messageId: string; readonly processedAt: Date }>>\n}\n\n/** Register the shared repository contract with an isolated fixture per test; run tests serially if they share a database. */\nexport function outboxRepositoryContract(\n test: (name: string, run: () => Promise<void>) => void,\n create: () => Promise<OutboxRepositoryContractFixture>,\n): void {\n test('claims due rows with one token and incremented attempts', async () => {\n const f = await create()\n await f.seed([{ id: 'a' }, { id: 'b', attempts: 3 }])\n const claim = await f.repository.claimPending(50)\n deepStrictEqual(\n claim.messages.map((row) => row.attempts),\n [1, 4],\n )\n ok(claim.token.length > 0)\n deepStrictEqual(\n (await f.rows()).map((row) => row.claimToken),\n [claim.token, claim.token],\n )\n })\n\n test('skips terminal, future, and leased rows', async () => {\n const f = await create()\n const future = new Date(f.now().getTime() + 60_000)\n await f.seed([\n { id: 'published', publishedAt: f.now() },\n { id: 'failed', failedAt: f.now() },\n { id: 'future', availableAt: future },\n { id: 'leased', claimToken: 'old', claimExpiresAt: future },\n { id: 'due' },\n ])\n const claim = await f.repository.claimPending(50)\n deepStrictEqual(\n claim.messages.map((row) => row.payload.id),\n ['due'],\n )\n })\n\n test('limits a claim and takes the oldest due row first', async () => {\n const f = await create()\n await f.seed([{ id: 'new' }, { id: 'old', availableAt: new Date(f.now().getTime() - 1_000) }])\n const claim = await f.repository.claimPending(1)\n deepStrictEqual(\n claim.messages.map((row) => row.payload.id),\n ['old'],\n )\n })\n\n test('concurrent claims never share a row', async () => {\n const f = await create()\n await f.seed([{ id: 'a' }, { id: 'b' }])\n const claims = await Promise.all([f.repository.claimPending(1), f.repository.claimPending(1)])\n const ids = claims.flatMap((claim) => claim.messages.map((row) => row.payload.id))\n equal(ids.length, 2)\n equal(new Set(ids).size, 2)\n })\n\n test('reclaims an expired lease and increments attempts again', async () => {\n const f = await create()\n await f.seed([{ id: 'a' }])\n const first = await f.repository.claimPending(1)\n f.advance(60_000)\n const second = await f.repository.claimPending(1)\n notEqual(first.token, second.token)\n deepStrictEqual(\n second.messages.map((row) => row.attempts),\n [2],\n )\n })\n\n for (const operation of ['publish', 'release', 'fail']) {\n test(`a stale claim cannot ${operation} a row`, async () => {\n const f = await create()\n await f.seed([{ id: 'a' }])\n const old = await f.repository.claimPending(1)\n f.advance(60_000)\n await f.repository.claimPending(1)\n const before = await f.rows()\n if (operation === 'publish') await f.repository.markPublished(old, old.messages)\n if (operation === 'release') await f.repository.releaseClaim(old, old.messages)\n if (operation === 'fail')\n await f.repository.deadLetter(old, old.messages, 'terminal', 'denied')\n deepStrictEqual(await f.rows(), before)\n })\n }\n\n test('publishes only the supplied subset and clears its lease', async () => {\n const f = await create()\n await f.seed([{ id: 'a' }, { id: 'b' }])\n const claim = await f.repository.claimPending(50)\n await f.repository.markPublished(claim, claim.messages.slice(0, 1))\n deepStrictEqual(\n (await f.rows()).map((row) => ({ publishedAt: row.publishedAt, token: row.claimToken })),\n [\n { publishedAt: f.now(), token: null },\n { publishedAt: null, token: claim.token },\n ],\n )\n })\n\n test('releases only the supplied subset with per-row capped backoff', async () => {\n const f = await create()\n await f.seed([{ id: 'a' }, { id: 'b', attempts: 2 }, { id: 'c', attempts: 8 }, { id: 'd' }])\n const claim = await f.repository.claimPending(50)\n await f.repository.releaseClaim(claim, claim.messages.slice(0, 3))\n deepStrictEqual(\n (await f.rows()).map((row) => [\n row.availableAt.getTime() - f.now().getTime(),\n row.claimToken,\n ]),\n [\n [1000, null],\n [4000, null],\n [60000, null],\n [0, claim.token],\n ],\n )\n })\n\n test('dead-letters only the supplied subset and keeps its payload', async () => {\n const f = await create()\n await f.seed([{ id: 'a' }, { id: 'b' }])\n const claim = await f.repository.claimPending(50)\n await f.repository.deadLetter(claim, claim.messages.slice(0, 1), 'oversized', 'too large')\n deepStrictEqual(\n (await f.rows()).map((row) => [row.payload.id, row.lastError, row.claimToken]),\n [\n ['a', 'oversized: too large', null],\n ['b', null, claim.token],\n ],\n )\n })\n\n for (const operation of ['publish', 'release', 'fail']) {\n test(`settles a 120-row claim through ${operation}`, async () => {\n const f = await create()\n await f.seed(Array.from({ length: 120 }, (_, i) => ({ id: String(i) })))\n const claim = await f.repository.claimPending(120)\n equal(claim.messages.length, 120)\n if (operation === 'publish') await f.repository.markPublished(claim, claim.messages)\n if (operation === 'release') await f.repository.releaseClaim(claim, claim.messages)\n if (operation === 'fail')\n await f.repository.deadLetter(claim, claim.messages, 'terminal', 'denied')\n ok((await f.rows()).every((row) => row.claimToken === null))\n })\n }\n\n for (const category of ['published', 'failed']) {\n test(`deletes only old ${category} events within the batch limit`, async () => {\n const f = await create()\n const old = new Date(f.now().getTime() - 1000)\n const dates = category === 'published' ? { publishedAt: old } : { failedAt: old }\n const atCutoff = category === 'published' ? { publishedAt: f.now() } : { failedAt: f.now() }\n await f.seed([\n { id: 'old-a', ...dates },\n { id: 'old-b', ...dates },\n { id: 'cutoff', ...atCutoff },\n { id: 'pending' },\n ])\n const remove =\n category === 'published'\n ? f.repository.deletePublishedBefore.bind(f.repository)\n : f.repository.deleteFailedBefore.bind(f.repository)\n equal(await remove(f.now(), 1), 1)\n equal(await remove(f.now(), 50), 1)\n deepStrictEqual(\n (await f.rows()).map((row) => row.id),\n ['cutoff', 'pending'],\n )\n })\n }\n\n test('deletes only old receipts within the batch limit', async () => {\n const f = await create()\n const old = new Date(f.now().getTime() - 1000)\n await f.seedReceipts([\n { messageId: 'old-a', processedAt: old },\n { messageId: 'old-b', processedAt: old },\n { messageId: 'cutoff', processedAt: f.now() },\n ])\n equal(await f.repository.deleteReceiptsBefore(f.now(), 1), 1)\n equal(await f.repository.deleteReceiptsBefore(f.now(), 50), 1)\n deepStrictEqual(\n (await f.receipts()).map((row) => row.messageId),\n ['cutoff'],\n )\n })\n}\n"],"mappings":"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"}
@@ -1,4 +1,4 @@
1
- import { Q as AppError } from "./core-DG_cC8YT.js";
1
+ import { $ as AppError } from "./core-BiqdOMwU.js";
2
2
 
3
3
  //#region src/infrastructure/http/webhook-verification.ts
4
4
  /** A webhook cannot be trusted or parsed. The HTTP handler logs the rejection once. */
@@ -15,4 +15,4 @@ var WebhookVerificationError = class extends AppError {
15
15
 
16
16
  //#endregion
17
17
  export { WebhookVerificationError as t };
18
- //# sourceMappingURL=webhook-verification-BQ9qZnVK.js.map
18
+ //# sourceMappingURL=webhook-verification-CeXUJXVP.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"webhook-verification-BQ9qZnVK.js","names":[],"sources":["../src/infrastructure/http/webhook-verification.ts"],"sourcesContent":["import { AppError } from '@/core'\n\n/** A webhook cannot be trusted or parsed. The HTTP handler logs the rejection once. */\nexport class WebhookVerificationError extends AppError {\n readonly _tag = 'WebhookVerificationError'\n readonly classification = 'terminal'\n\n /** Keep secrets and payload details out of the public message. */\n constructor(\n readonly reason: 'missing_headers' | 'invalid_signature' | 'invalid_payload',\n cause?: unknown,\n ) {\n super(`Webhook rejected: ${reason}`, { cause })\n }\n}\n\n/** A verified event, an ignored event, or an invalid request. */\nexport type WebhookVerification<Event> =\n | { readonly outcome: 'event'; readonly event: Event }\n | { readonly outcome: 'ignored'; readonly type: string }\n | { readonly outcome: 'rejected'; readonly error: WebhookVerificationError }\n\n/** The provider owns signature verification, timestamp tolerance, and payload parsing. */\nexport interface WebhookVerifier<Event> {\n /** Verify the exact body before parsing it. Return invalid requests; throw operational failures. */\n verify(body: string, headers: Headers): Promise<WebhookVerification<Event>>\n}\n"],"mappings":";;;;AAGA,IAAa,2BAAb,cAA8C,SAAS;CAM1C;CALX,AAAS,OAAO;CAChB,AAAS,iBAAiB;;CAG1B,YACE,AAAS,QACT,OACA;EACA,MAAM,qBAAqB,UAAU,EAAE,MAAM,CAAC;EAHrC;CAIX;AACF"}
1
+ {"version":3,"file":"webhook-verification-CeXUJXVP.js","names":[],"sources":["../src/infrastructure/http/webhook-verification.ts"],"sourcesContent":["import { AppError } from '@/core'\n\n/** A webhook cannot be trusted or parsed. The HTTP handler logs the rejection once. */\nexport class WebhookVerificationError extends AppError {\n readonly _tag = 'WebhookVerificationError'\n readonly classification = 'terminal'\n\n /** Keep secrets and payload details out of the public message. */\n constructor(\n readonly reason: 'missing_headers' | 'invalid_signature' | 'invalid_payload',\n cause?: unknown,\n ) {\n super(`Webhook rejected: ${reason}`, { cause })\n }\n}\n\n/** A verified event, an ignored event, or an invalid request. */\nexport type WebhookVerification<Event> =\n | { readonly outcome: 'event'; readonly event: Event }\n | { readonly outcome: 'ignored'; readonly type: string }\n | { readonly outcome: 'rejected'; readonly error: WebhookVerificationError }\n\n/** The provider owns signature verification, timestamp tolerance, and payload parsing. */\nexport interface WebhookVerifier<Event> {\n /** Verify the exact body before parsing it. Return invalid requests; throw operational failures. */\n verify(body: string, headers: Headers): Promise<WebhookVerification<Event>>\n}\n"],"mappings":";;;;AAGA,IAAa,2BAAb,cAA8C,SAAS;CAM1C;CALX,AAAS,OAAO;CAChB,AAAS,iBAAiB;;CAG1B,YACE,AAAS,QACT,OACA;EACA,MAAM,qBAAqB,UAAU,EAAE,MAAM,CAAC;EAHrC;CAIX;AACF"}
@@ -1,4 +1,4 @@
1
- import { Ut as AppError } from "./index-Bl0K1RQW.js";
1
+ import { Wt as AppError } from "./index-B5O6mNjZ.js";
2
2
  //#region src/infrastructure/http/webhook-verification.d.ts
3
3
  /** A webhook cannot be trusted or parsed. The HTTP handler logs the rejection once. */
4
4
  declare class WebhookVerificationError extends AppError {
@@ -26,4 +26,4 @@ interface WebhookVerifier<Event> {
26
26
  }
27
27
  //#endregion
28
28
  export { WebhookVerificationError as n, WebhookVerifier as r, WebhookVerification as t };
29
- //# sourceMappingURL=webhook-verification-sZtwTI5n.d.ts.map
29
+ //# sourceMappingURL=webhook-verification-CyBoERFz.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"webhook-verification-sZtwTI5n.d.ts","names":[],"sources":["../src/infrastructure/http/webhook-verification.ts"],"mappings":";;;cAGa,iCAAiC;WAMjC;WALF;WACA;;EAGT,YACW,qEACT;;;KAOQ,oBAAoB;WACjB;WAA2B,OAAO;;WAClC;WAA6B;;WAC7B;WAA8B,OAAO;;;UAGnC,gBAAgB;;EAE/B,OAAO,cAAc,SAAS,UAAU,QAAQ,oBAAoB"}
1
+ {"version":3,"file":"webhook-verification-CyBoERFz.d.ts","names":[],"sources":["../src/infrastructure/http/webhook-verification.ts"],"mappings":";;;cAGa,iCAAiC;WAMjC;WALF;WACA;;EAGT,YACW,qEACT;;;KAOQ,oBAAoB;WACjB;WAA2B,OAAO;;WAClC;WAA6B;;WAC7B;WAA8B,OAAO;;;UAGnC,gBAAgB;;EAE/B,OAAO,cAAc,SAAS,UAAU,QAAQ,oBAAoB"}
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://www.schemastore.org/package.json",
3
3
  "name": "better-ship",
4
- "version": "0.12.0",
4
+ "version": "0.13.0",
5
5
  "description": "Foundation for applications on Cloudflare Workers: logger, errors, message bus, infrastructure adapters, and UI primitives",
6
6
  "license": "Apache-2.0",
7
7
  "repository": {
@@ -32,6 +32,7 @@
32
32
  "./resend": "./dist/resend.js",
33
33
  "./tanstack": "./dist/tanstack.js",
34
34
  "./tanstack/server": "./dist/tanstack/server.js",
35
+ "./testing/outbox-repository": "./dist/testing/outbox-repository.js",
35
36
  "./ui": "./dist/ui.js",
36
37
  "./package.json": "./package.json"
37
38
  },
@@ -48,16 +49,25 @@
48
49
  "@better-auth/stripe": "^1.7.4",
49
50
  "@cloudflare/vitest-plugin": "^1.1.7",
50
51
  "@cloudflare/workers-types": "5.20260911.1",
52
+ "@posthog/react": "^1.10.6",
53
+ "@sentry/cloudflare": "^10.74.0",
54
+ "@sentry/tanstackstart-react": "^10.74.0",
51
55
  "@tanstack/react-router": "^1.170.35",
52
56
  "@tanstack/react-start": "^1.168.52",
53
57
  "@tanstack/start-client-core": "^1.170.29",
54
58
  "@tanstack/start-storage-context": "^1.167.31",
55
59
  "@types/node": "^26.5.1",
60
+ "@types/react": "^19.3.0",
61
+ "@vitest/browser-playwright": "^5.0.0",
56
62
  "auth": "^1.7.4",
57
63
  "better-auth": "^1.7.4",
58
64
  "drizzle-kit": "^0.31.10",
59
65
  "drizzle-orm": "^0.45.2",
66
+ "playwright": "^1.63.0",
60
67
  "postgres": "^3.4.9",
68
+ "posthog-js": "^1.430.2",
69
+ "posthog-node": "^5.52.1",
70
+ "react": "^19.3.0",
61
71
  "resend": "^6.28.0",
62
72
  "standardwebhooks": "^1.1.1",
63
73
  "stripe": "^22.6.2",
@@ -68,14 +78,38 @@
68
78
  },
69
79
  "peerDependencies": {
70
80
  "@better-auth/stripe": "^1.7.4",
81
+ "@posthog/react": "^1.10.6",
82
+ "@sentry/cloudflare": "^10.74.0",
83
+ "@sentry/tanstackstart-react": "^10.74.0",
71
84
  "@tanstack/react-router": "^1.170.35",
72
85
  "@tanstack/react-start": "^1.168.52",
73
86
  "better-auth": "^1.7.4",
74
87
  "drizzle-orm": "^0.45.2",
88
+ "posthog-js": "^1.430.2",
89
+ "posthog-node": "^5.52.1",
90
+ "react": "^19.3.0",
75
91
  "resend": "^6.28.0",
76
92
  "stripe": "^22.6.2"
77
93
  },
78
94
  "peerDependenciesMeta": {
95
+ "@sentry/cloudflare": {
96
+ "optional": true
97
+ },
98
+ "@sentry/tanstackstart-react": {
99
+ "optional": true
100
+ },
101
+ "posthog-node": {
102
+ "optional": true
103
+ },
104
+ "posthog-js": {
105
+ "optional": true
106
+ },
107
+ "@posthog/react": {
108
+ "optional": true
109
+ },
110
+ "react": {
111
+ "optional": true
112
+ },
79
113
  "resend": {
80
114
  "optional": true
81
115
  },
@@ -18,7 +18,24 @@ export {
18
18
  export type { IMessageStore } from './message-store.ts'
19
19
  export type { DomainCommand, DomainEvent, DomainMessage, DomainQuery, MessageId } from '@/core'
20
20
  export { MessageBus, type IMessageBus } from './message-bus.ts'
21
- export type { IOutboxRelay } from './outbox-relay.ts'
21
+ export {
22
+ OutboxRelay,
23
+ type IOutboxRelay,
24
+ type IOutboxPublisher,
25
+ type OutboxRelayOptions,
26
+ } from './outbox-relay.ts'
27
+ export type {
28
+ OutboxClaim,
29
+ OutboxMessage,
30
+ DeadLetterReason,
31
+ IOutboxRepository,
32
+ IOutboxRetentionRepository,
33
+ } from './outbox-repository.ts'
34
+ export {
35
+ OutboxRetention,
36
+ type OutboxRetentionOptions,
37
+ type OutboxRetentionResult,
38
+ } from './outbox-retention.ts'
22
39
  export type {
23
40
  CommandRegistry,
24
41
  EventRegistry,
@@ -1,3 +1,12 @@
1
+ import { classifyFailure, createLogger, type DomainEvent } from '@/core'
2
+
3
+ import type {
4
+ DeadLetterReason,
5
+ IOutboxRepository,
6
+ OutboxClaim,
7
+ OutboxMessage,
8
+ } from './outbox-repository.ts'
9
+
1
10
  /**
2
11
  * Delivers committed outbox rows. The unit of work only wakes it after a commit that wrote
3
12
  * events; the app decides what a wake is: `waitUntil`, a Durable Object alarm, nothing.
@@ -6,3 +15,111 @@
6
15
  export interface IOutboxRelay {
7
16
  wake(): void
8
17
  }
18
+
19
+ /** The publisher sends valid events and returns oversized events. QueueClient satisfies this port directly. */
20
+ export interface IOutboxPublisher {
21
+ /** Resolves after queue acceptance; a rejected batch may already have sent some events. */
22
+ sendBatchPartial(messages: ReadonlyArray<DomainEvent>): Promise<ReadonlyArray<DomainEvent>>
23
+ }
24
+
25
+ /** Optional limits for one relay; durable attempts exclude the publisher's immediate retries. */
26
+ export type OutboxRelayOptions = {
27
+ readonly batchSize?: number
28
+ readonly maxBatches?: number
29
+ readonly maxAttempts?: number
30
+ }
31
+
32
+ const logger = createLogger('outbox-relay')
33
+
34
+ /**
35
+ * Publishes committed events with bounded durable retries. Ported from Typist's relay with per-row exhaustion.
36
+ * Send failures are recorded; repository failures propagate to the wake or scheduled boundary.
37
+ */
38
+ export class OutboxRelay {
39
+ private readonly options: Required<OutboxRelayOptions>
40
+
41
+ constructor(
42
+ private readonly publisher: IOutboxPublisher,
43
+ private readonly repository: IOutboxRepository,
44
+ options: OutboxRelayOptions = {},
45
+ ) {
46
+ this.options = {
47
+ batchSize: options.batchSize ?? 50,
48
+ maxBatches: options.maxBatches ?? 10,
49
+ maxAttempts: options.maxAttempts ?? 10,
50
+ }
51
+ }
52
+
53
+ /** Return the claimed count, including released and dead-lettered rows. Zero means no due rows. */
54
+ async flush(): Promise<number> {
55
+ const claim = await this.repository.claimPending(this.options.batchSize)
56
+ const exhausted = claim.messages.filter((row) => row.attempts > this.options.maxAttempts)
57
+ await this.deadLetter(claim, exhausted, 'exhausted', 'Claim attempt limit exceeded')
58
+ const pending = claim.messages.filter((row) => row.attempts <= this.options.maxAttempts)
59
+ if (pending.length === 0) return claim.messages.length
60
+
61
+ let oversized: ReadonlyArray<DomainEvent>
62
+ try {
63
+ oversized = await this.publisher.sendBatchPartial(pending.map((row) => row.payload))
64
+ } catch (error) {
65
+ await this.handleSendFailure(claim, pending, error)
66
+ return claim.messages.length
67
+ }
68
+
69
+ const oversizedIds = new Set(oversized.map((event) => event.id))
70
+ await this.deadLetter(
71
+ claim,
72
+ pending.filter((row) => oversizedIds.has(row.payload.id)),
73
+ 'oversized',
74
+ 'Exceeds queue message size limit',
75
+ )
76
+ const sent = pending.filter((row) => !oversizedIds.has(row.payload.id))
77
+ if (sent.length > 0) await this.repository.markPublished(claim, sent)
78
+ return claim.messages.length
79
+ }
80
+
81
+ /** Drain until an empty claim or the batch limit; return the total claimed count. */
82
+ async drain(): Promise<number> {
83
+ let total = 0
84
+ for (let batch = 0; batch < this.options.maxBatches; batch += 1) {
85
+ // oxlint-disable-next-line no-await-in-loop -- An empty claim must stop the drain before another claim starts.
86
+ const claimed = await this.flush()
87
+ total += claimed
88
+ if (claimed === 0) break
89
+ }
90
+ return total
91
+ }
92
+
93
+ private async handleSendFailure(
94
+ claim: OutboxClaim,
95
+ pending: ReadonlyArray<OutboxMessage>,
96
+ cause: unknown,
97
+ ): Promise<void> {
98
+ const classification = classifyFailure(cause)
99
+ const message =
100
+ cause instanceof Error ? cause.message : 'Publisher rejected with a non-error value'
101
+ if (classification === 'terminal') {
102
+ await this.deadLetter(claim, pending, 'terminal', message, cause)
103
+ return
104
+ }
105
+ const exhausted = pending.filter((row) => row.attempts >= this.options.maxAttempts)
106
+ await this.deadLetter(claim, exhausted, 'exhausted', message, cause)
107
+ const retry = pending.filter((row) => row.attempts < this.options.maxAttempts)
108
+ if (retry.length > 0) await this.repository.releaseClaim(claim, retry)
109
+ }
110
+
111
+ private async deadLetter(
112
+ claim: OutboxClaim,
113
+ messages: ReadonlyArray<OutboxMessage>,
114
+ reason: DeadLetterReason,
115
+ errorMessage: string,
116
+ cause?: unknown,
117
+ ): Promise<void> {
118
+ if (messages.length === 0) return
119
+ await this.repository.deadLetter(claim, messages, reason, errorMessage)
120
+ logger.error('outbox_messages_dead_lettered', {
121
+ error: cause,
122
+ details: { claimToken: claim.token, count: messages.length, reason },
123
+ })
124
+ }
125
+ }
@@ -0,0 +1,46 @@
1
+ import type { DomainEvent } from '@/core'
2
+
3
+ /** A claimed event and its durable attempt count. The payload id identifies the outbox row. */
4
+ export type OutboxMessage = {
5
+ readonly payload: DomainEvent
6
+ readonly attempts: number
7
+ }
8
+
9
+ /** One lease shared by the rows returned from a single atomic claim. */
10
+ export type OutboxClaim = {
11
+ readonly token: string
12
+ readonly messages: ReadonlyArray<OutboxMessage>
13
+ }
14
+
15
+ /** Why an event remains in the outbox without further automatic delivery. */
16
+ export type DeadLetterReason = 'oversized' | 'terminal' | 'exhausted'
17
+
18
+ /** Bounded retention operations on committed records, outside the unit of work. */
19
+ export interface IOutboxRetentionRepository {
20
+ /** Delete published events strictly older than the cutoff. */
21
+ deletePublishedBefore(cutoff: Date, batchSize: number): Promise<number>
22
+ /** Delete failed events strictly older than the cutoff. */
23
+ deleteFailedBefore(cutoff: Date, batchSize: number): Promise<number>
24
+ /** Delete duplicate-protection receipts strictly older than the cutoff. */
25
+ deleteReceiptsBefore(cutoff: Date, batchSize: number): Promise<number>
26
+ }
27
+
28
+ /**
29
+ * Claims and settles committed events on the plain database connection. Never retry these calls locally.
30
+ * Each settlement affects only the supplied rows that still hold the claim token.
31
+ */
32
+ export interface IOutboxRepository extends IOutboxRetentionRepository {
33
+ /** Atomically lease due rows and increment their attempts, including claims after an expired lease. */
34
+ claimPending(batchSize: number): Promise<OutboxClaim>
35
+ /** Record queue acceptance and clear the lease. */
36
+ markPublished(claim: OutboxClaim, messages: ReadonlyArray<OutboxMessage>): Promise<void>
37
+ /** Clear the lease and delay each row according to its own attempt count. */
38
+ releaseClaim(claim: OutboxClaim, messages: ReadonlyArray<OutboxMessage>): Promise<void>
39
+ /** Stop automatic delivery while retaining the payload and a readable failure reason. */
40
+ deadLetter(
41
+ claim: OutboxClaim,
42
+ messages: ReadonlyArray<OutboxMessage>,
43
+ reason: DeadLetterReason,
44
+ errorMessage: string,
45
+ ): Promise<void>
46
+ }
@@ -0,0 +1,61 @@
1
+ import type { IOutboxRetentionRepository } from './outbox-repository.ts'
2
+
3
+ /** Typist's retention defaults; receipt retention must cover the application's supported replay period. */
4
+ export type OutboxRetentionOptions = {
5
+ readonly publishedRetentionMs?: number
6
+ readonly failedRetentionMs?: number
7
+ readonly receiptRetentionMs?: number
8
+ readonly batchSize?: number
9
+ readonly maxBatches?: number
10
+ }
11
+
12
+ /** Deleted records per category during one bounded sweep. */
13
+ export type OutboxRetentionResult = {
14
+ readonly published: number
15
+ readonly failed: number
16
+ readonly receipts: number
17
+ }
18
+
19
+ /** Bounded cleanup ported from Typist's message retention handler. The application schedules each sweep. */
20
+ export class OutboxRetention {
21
+ private readonly options: Required<OutboxRetentionOptions>
22
+
23
+ constructor(
24
+ private readonly repository: IOutboxRetentionRepository,
25
+ options: OutboxRetentionOptions = {},
26
+ ) {
27
+ this.options = {
28
+ publishedRetentionMs: options.publishedRetentionMs ?? 86_400_000,
29
+ failedRetentionMs: options.failedRetentionMs ?? 30 * 86_400_000,
30
+ receiptRetentionMs: options.receiptRetentionMs ?? 7 * 86_400_000,
31
+ batchSize: options.batchSize ?? 1_000,
32
+ maxBatches: options.maxBatches ?? 10,
33
+ }
34
+ }
35
+
36
+ /** Calculate all cutoffs from one supplied time; propagate repository failures to the calling boundary. */
37
+ async sweep(at: Date): Promise<OutboxRetentionResult> {
38
+ const { publishedRetentionMs, failedRetentionMs, receiptRetentionMs } = this.options
39
+ const published = await this.deleteBatches((size) =>
40
+ this.repository.deletePublishedBefore(new Date(at.getTime() - publishedRetentionMs), size),
41
+ )
42
+ const failed = await this.deleteBatches((size) =>
43
+ this.repository.deleteFailedBefore(new Date(at.getTime() - failedRetentionMs), size),
44
+ )
45
+ const receipts = await this.deleteBatches((size) =>
46
+ this.repository.deleteReceiptsBefore(new Date(at.getTime() - receiptRetentionMs), size),
47
+ )
48
+ return { published, failed, receipts }
49
+ }
50
+
51
+ private async deleteBatches(deleteBatch: (size: number) => Promise<number>): Promise<number> {
52
+ let total = 0
53
+ for (let batch = 0; batch < this.options.maxBatches; batch += 1) {
54
+ // oxlint-disable-next-line no-await-in-loop -- The delete count decides whether another batch is needed.
55
+ const deleted = await deleteBatch(this.options.batchSize)
56
+ total += deleted
57
+ if (deleted < this.options.batchSize) break
58
+ }
59
+ return total
60
+ }
61
+ }
@@ -21,7 +21,12 @@ export function exponentialEqualJitterMs(
21
21
  return Math.floor(halfCapMs) + spreadJitterMs(halfCapMs)
22
22
  }
23
23
 
24
- function exponentialDelayCapMs(baseDelayMs: number, maxDelayMs: number, attempt: number): number {
24
+ /** Capped exponential delay for a durable retry recorded in storage. */
25
+ export function exponentialDelayCapMs(
26
+ baseDelayMs: number,
27
+ maxDelayMs: number,
28
+ attempt: number,
29
+ ): number {
25
30
  if (!Number.isSafeInteger(attempt) || attempt < 1) {
26
31
  return panic('attempt must be a positive integer')
27
32
  }
package/src/core/index.ts CHANGED
@@ -88,7 +88,7 @@ export {
88
88
  retryAfterSecondsOf,
89
89
  type BoundaryErrorClassifier,
90
90
  } from './classify-failure.ts'
91
- export { exponentialEqualJitterMs, spreadJitterMs } from './backoff.ts'
91
+ export { exponentialDelayCapMs, exponentialEqualJitterMs, spreadJitterMs } from './backoff.ts'
92
92
  export {
93
93
  configureLogger,
94
94
  consoleSink,
@@ -0,0 +1,224 @@
1
+ import { and, asc, eq, inArray, isNull, lt, lte, or, sql } from 'drizzle-orm'
2
+
3
+ import type { DeadLetterReason, IOutboxRepository, OutboxClaim, OutboxMessage } from '@/application'
4
+ import { exponentialDelayCapMs } from '@/core'
5
+
6
+ import type { DrizzleSchema } from '../drizzle-schema.ts'
7
+ import {
8
+ OUTBOX_REPOSITORY_DEFAULTS,
9
+ type OutboxRepositoryOptions,
10
+ } from '../outbox-repository-options.ts'
11
+ import { executeDatabase } from './d1-database-execution.ts'
12
+ import type { D1Db } from './d1-database-types.ts'
13
+ import { messageOutbox, processedMessages } from './d1-schema.ts'
14
+
15
+ /**
16
+ * Typist's atomic D1 claims on a plain connection, outside a unit of work.
17
+ * Every write runs once; lease expiry and the scheduled sweep recover interrupted operations.
18
+ */
19
+ export class D1OutboxRepository<Schema extends DrizzleSchema> implements IOutboxRepository {
20
+ private readonly options: Required<OutboxRepositoryOptions>
21
+
22
+ constructor(
23
+ private readonly db: D1Db<Schema>,
24
+ options: OutboxRepositoryOptions = {},
25
+ private readonly now: () => Date = () => new Date(),
26
+ ) {
27
+ this.options = { ...OUTBOX_REPOSITORY_DEFAULTS, ...options }
28
+ }
29
+
30
+ /** Lease due rows in one statement; increment attempts even when reclaiming an expired lease. */
31
+ async claimPending(batchSize: number): Promise<OutboxClaim> {
32
+ const token = crypto.randomUUID()
33
+ const now = this.now()
34
+ const rows = await executeDatabase('claim pending outbox messages', () =>
35
+ this.db
36
+ .update(messageOutbox)
37
+ .set({
38
+ claimToken: token,
39
+ claimExpiresAt: deadline(now.getTime() + this.options.leaseMs),
40
+ attempts: sql`${messageOutbox.attempts} + 1`,
41
+ })
42
+ .where(
43
+ inArray(
44
+ messageOutbox.id,
45
+ this.db
46
+ .select({ id: messageOutbox.id })
47
+ .from(messageOutbox)
48
+ .where(
49
+ and(
50
+ isNull(messageOutbox.publishedAt),
51
+ isNull(messageOutbox.failedAt),
52
+ lte(messageOutbox.availableAt, now),
53
+ or(isNull(messageOutbox.claimExpiresAt), lte(messageOutbox.claimExpiresAt, now)),
54
+ ),
55
+ )
56
+ .orderBy(
57
+ asc(messageOutbox.availableAt),
58
+ asc(messageOutbox.createdAt),
59
+ asc(messageOutbox.id),
60
+ )
61
+ .limit(batchSize),
62
+ ),
63
+ )
64
+ .returning({
65
+ payload: messageOutbox.payload,
66
+ attempts: messageOutbox.attempts,
67
+ availableAt: messageOutbox.availableAt,
68
+ createdAt: messageOutbox.createdAt,
69
+ }),
70
+ )
71
+ // UPDATE RETURNING does not preserve the selection order.
72
+ rows.sort(
73
+ (a, b) =>
74
+ a.availableAt.getTime() - b.availableAt.getTime() ||
75
+ a.createdAt.getTime() - b.createdAt.getTime() ||
76
+ a.payload.id.localeCompare(b.payload.id),
77
+ )
78
+ return { token, messages: rows.map(({ payload, attempts }) => ({ payload, attempts })) }
79
+ }
80
+
81
+ /** Mark only supplied rows that still hold this token as published. */
82
+ async markPublished(claim: OutboxClaim, messages: ReadonlyArray<OutboxMessage>): Promise<void> {
83
+ if (messages.length === 0) return
84
+ await executeDatabase('mark outbox messages published', () =>
85
+ this.db
86
+ .update(messageOutbox)
87
+ .set({ publishedAt: this.now(), claimToken: null, claimExpiresAt: null })
88
+ .where(
89
+ and(
90
+ inArray(messageOutbox.id, outboxIds(messages)),
91
+ eq(messageOutbox.claimToken, claim.token),
92
+ ),
93
+ ),
94
+ )
95
+ }
96
+
97
+ /** Release supplied rows with per-row backoff; a stale token cannot change a newer claim. */
98
+ async releaseClaim(claim: OutboxClaim, messages: ReadonlyArray<OutboxMessage>): Promise<void> {
99
+ const now = this.now()
100
+ for (const attempts of new Set(messages.map((row) => row.attempts))) {
101
+ const rows = messages.filter((row) => row.attempts === attempts)
102
+ const delay = exponentialDelayCapMs(
103
+ this.options.retryBaseDelayMs,
104
+ this.options.retryMaxDelayMs,
105
+ attempts,
106
+ )
107
+ // oxlint-disable-next-line no-await-in-loop -- Stop releasing rows on the first database failure.
108
+ await executeDatabase('release outbox claim', () =>
109
+ this.db
110
+ .update(messageOutbox)
111
+ .set({
112
+ availableAt: deadline(now.getTime() + delay),
113
+ claimToken: null,
114
+ claimExpiresAt: null,
115
+ })
116
+ .where(
117
+ and(
118
+ inArray(messageOutbox.id, outboxIds(rows)),
119
+ eq(messageOutbox.claimToken, claim.token),
120
+ ),
121
+ ),
122
+ )
123
+ }
124
+ }
125
+
126
+ /** Retain the full payload and record a closed reason with a readable error message. */
127
+ async deadLetter(
128
+ claim: OutboxClaim,
129
+ messages: ReadonlyArray<OutboxMessage>,
130
+ reason: DeadLetterReason,
131
+ errorMessage: string,
132
+ ): Promise<void> {
133
+ if (messages.length === 0) return
134
+ await executeDatabase('dead-letter outbox messages', () =>
135
+ this.db
136
+ .update(messageOutbox)
137
+ .set({
138
+ failedAt: this.now(),
139
+ lastError: `${reason}: ${errorMessage}`,
140
+ claimToken: null,
141
+ claimExpiresAt: null,
142
+ })
143
+ .where(
144
+ and(
145
+ inArray(messageOutbox.id, outboxIds(messages)),
146
+ eq(messageOutbox.claimToken, claim.token),
147
+ ),
148
+ ),
149
+ )
150
+ }
151
+
152
+ /** Delete at most batchSize published rows, strictly before the cutoff. */
153
+ async deletePublishedBefore(cutoff: Date, batchSize: number): Promise<number> {
154
+ const rows = await executeDatabase('delete published outbox messages', () =>
155
+ this.db
156
+ .delete(messageOutbox)
157
+ .where(
158
+ inArray(
159
+ messageOutbox.id,
160
+ this.db
161
+ .select({ id: messageOutbox.id })
162
+ .from(messageOutbox)
163
+ .where(lt(messageOutbox.publishedAt, cutoff))
164
+ .orderBy(asc(messageOutbox.publishedAt), asc(messageOutbox.id))
165
+ .limit(batchSize),
166
+ ),
167
+ )
168
+ .returning({ id: messageOutbox.id }),
169
+ )
170
+ return rows.length
171
+ }
172
+
173
+ /** Delete at most batchSize failed rows; pending and published rows remain untouched. */
174
+ async deleteFailedBefore(cutoff: Date, batchSize: number): Promise<number> {
175
+ const rows = await executeDatabase('delete failed outbox messages', () =>
176
+ this.db
177
+ .delete(messageOutbox)
178
+ .where(
179
+ inArray(
180
+ messageOutbox.id,
181
+ this.db
182
+ .select({ id: messageOutbox.id })
183
+ .from(messageOutbox)
184
+ .where(and(isNull(messageOutbox.publishedAt), lt(messageOutbox.failedAt, cutoff)))
185
+ .orderBy(asc(messageOutbox.failedAt), asc(messageOutbox.id))
186
+ .limit(batchSize),
187
+ ),
188
+ )
189
+ .returning({ id: messageOutbox.id }),
190
+ )
191
+ return rows.length
192
+ }
193
+
194
+ /** Delete at most batchSize receipts, strictly before the cutoff. */
195
+ async deleteReceiptsBefore(cutoff: Date, batchSize: number): Promise<number> {
196
+ const rows = await executeDatabase('delete message receipts', () =>
197
+ this.db
198
+ .delete(processedMessages)
199
+ .where(
200
+ inArray(
201
+ processedMessages.messageId,
202
+ this.db
203
+ .select({ messageId: processedMessages.messageId })
204
+ .from(processedMessages)
205
+ .where(lt(processedMessages.processedAt, cutoff))
206
+ .orderBy(asc(processedMessages.processedAt), asc(processedMessages.messageId))
207
+ .limit(batchSize),
208
+ ),
209
+ )
210
+ .returning({ messageId: processedMessages.messageId }),
211
+ )
212
+ return rows.length
213
+ }
214
+ }
215
+
216
+ // One JSON parameter keeps large claims below D1's 100-parameter limit.
217
+ function outboxIds(messages: ReadonlyArray<OutboxMessage>) {
218
+ return sql`(select value from json_each(${JSON.stringify(messages.map((row) => row.payload.id))}))`
219
+ }
220
+
221
+ // D1 timestamps repository seconds; round future deadlines up so a lease or delay never ends early.
222
+ function deadline(ms: number): Date {
223
+ return new Date(Math.ceil(ms / 1_000) * 1_000)
224
+ }