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.
- package/dist/{application-CIxo_BpK.js → application-DnlgWcOZ.js} +118 -6
- package/dist/application-DnlgWcOZ.js.map +1 -0
- package/dist/application.d.ts +3 -3
- package/dist/application.js +2 -2
- package/dist/better-auth/apple-client-secret.d.ts +1 -1
- package/dist/better-auth/apple-client-secret.js +1 -1
- package/dist/better-auth/auth-client.js +1 -1
- package/dist/better-auth/rate-limit.d.ts +1 -1
- package/dist/better-auth/rate-limit.js +1 -1
- package/dist/better-auth.js +1 -1
- package/dist/cloudflare/auth-secondary-storage.d.ts +1 -1
- package/dist/cloudflare/auth-secondary-storage.js +1 -1
- package/dist/cloudflare/queue.d.ts +8 -3
- package/dist/cloudflare/queue.d.ts.map +1 -1
- package/dist/cloudflare/queue.js +26 -16
- package/dist/cloudflare/queue.js.map +1 -1
- package/dist/cloudflare/scheduled.d.ts +1 -1
- package/dist/cloudflare/scheduled.js +1 -1
- package/dist/cloudflare.d.ts +30 -4
- package/dist/cloudflare.d.ts.map +1 -1
- package/dist/cloudflare.js +98 -5
- package/dist/cloudflare.js.map +1 -1
- package/dist/{core-DG_cC8YT.js → core-BiqdOMwU.js} +3 -2
- package/dist/{core-DG_cC8YT.js.map → core-BiqdOMwU.js.map} +1 -1
- package/dist/core.d.ts +2 -2
- package/dist/core.js +2 -2
- package/dist/http.d.ts +2 -2
- package/dist/http.js +2 -2
- package/dist/{index-Bl0K1RQW.d.ts → index-B5O6mNjZ.d.ts} +4 -2
- package/dist/{index-Bl0K1RQW.d.ts.map → index-B5O6mNjZ.d.ts.map} +1 -1
- package/dist/{index-DwWxIdQP.d.ts → index-DG4jkwad.d.ts} +92 -3
- package/dist/index-DG4jkwad.d.ts.map +1 -0
- package/dist/{unit-of-work-context-BPE1In2f.js → outbox-repository-options-BI75j7b7.js} +12 -3
- package/dist/outbox-repository-options-BI75j7b7.js.map +1 -0
- package/dist/outbox-repository-options-BiilQf4_.d.ts +26 -0
- package/dist/outbox-repository-options-BiilQf4_.d.ts.map +1 -0
- package/dist/postgres.d.ts +30 -4
- package/dist/postgres.d.ts.map +1 -1
- package/dist/postgres.js +92 -5
- package/dist/postgres.js.map +1 -1
- package/dist/resend.d.ts +3 -3
- package/dist/resend.js +2 -2
- package/dist/tanstack.d.ts +1 -1
- package/dist/tanstack.js +1 -1
- package/dist/testing/outbox-repository.d.ts +44 -0
- package/dist/testing/outbox-repository.d.ts.map +1 -0
- package/dist/testing/outbox-repository.js +206 -0
- package/dist/testing/outbox-repository.js.map +1 -0
- package/dist/{webhook-verification-BQ9qZnVK.js → webhook-verification-CeXUJXVP.js} +2 -2
- package/dist/{webhook-verification-BQ9qZnVK.js.map → webhook-verification-CeXUJXVP.js.map} +1 -1
- package/dist/{webhook-verification-sZtwTI5n.d.ts → webhook-verification-CyBoERFz.d.ts} +2 -2
- package/dist/{webhook-verification-sZtwTI5n.d.ts.map → webhook-verification-CyBoERFz.d.ts.map} +1 -1
- package/package.json +35 -1
- package/src/application/index.ts +18 -1
- package/src/application/outbox-relay.ts +117 -0
- package/src/application/outbox-repository.ts +46 -0
- package/src/application/outbox-retention.ts +61 -0
- package/src/core/backoff.ts +6 -1
- package/src/core/index.ts +1 -1
- package/src/infrastructure/cloudflare/d1-outbox-repository.ts +224 -0
- package/src/infrastructure/cloudflare/index.ts +2 -0
- package/src/infrastructure/cloudflare/queue/index.ts +1 -0
- package/src/infrastructure/cloudflare/queue/queue-client.ts +6 -2
- package/src/infrastructure/cloudflare/queue/wait-until-wake.ts +19 -0
- package/src/infrastructure/outbox-repository-options.ts +9 -0
- package/src/infrastructure/postgres/index.ts +2 -0
- package/src/infrastructure/postgres/postgres-outbox-repository.ts +216 -0
- package/src/infrastructure/testing/outbox-repository-contract.ts +228 -0
- package/dist/application-CIxo_BpK.js.map +0 -1
- package/dist/index-DwWxIdQP.d.ts.map +0 -1
- package/dist/unit-of-work-context-BPE1In2f.js.map +0 -1
- package/dist/unit-of-work-context-D5ytJIa-.d.ts +0 -16
- 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 {
|
|
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-
|
|
18
|
+
//# sourceMappingURL=webhook-verification-CeXUJXVP.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"webhook-verification-
|
|
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 {
|
|
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-
|
|
29
|
+
//# sourceMappingURL=webhook-verification-CyBoERFz.d.ts.map
|
package/dist/{webhook-verification-sZtwTI5n.d.ts.map → webhook-verification-CyBoERFz.d.ts.map}
RENAMED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"webhook-verification-
|
|
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.
|
|
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
|
},
|
package/src/application/index.ts
CHANGED
|
@@ -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
|
|
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
|
+
}
|
package/src/core/backoff.ts
CHANGED
|
@@ -21,7 +21,12 @@ export function exponentialEqualJitterMs(
|
|
|
21
21
|
return Math.floor(halfCapMs) + spreadJitterMs(halfCapMs)
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
-
|
|
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
|
+
}
|