better-ship 0.11.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-DgDyeR5s.d.ts → index-DG4jkwad.d.ts} +95 -4
- 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 +3 -3
- package/dist/resend.js.map +1 -1
- 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/email-service.ts +3 -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/resend/resend-email-service.ts +1 -1
- package/src/infrastructure/testing/outbox-repository-contract.ts +228 -0
- package/dist/application-CIxo_BpK.js.map +0 -1
- package/dist/index-DgDyeR5s.d.ts.map +0 -1
- package/dist/unit-of-work-context-BPE1In2f.js.map +0 -1
- package/dist/unit-of-work-context-BzmnjlDa.d.ts +0 -16
- package/dist/unit-of-work-context-BzmnjlDa.d.ts.map +0 -1
|
@@ -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
|
|
@@ -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
|
},
|
|
@@ -17,6 +17,8 @@ export type EmailAttachment = {
|
|
|
17
17
|
* and https://resend.com/docs/dashboard/emails/tags
|
|
18
18
|
*/
|
|
19
19
|
export type OutboundEmail = {
|
|
20
|
+
/** Override the default for this email, for example "Display Name <address@domain.com>". */
|
|
21
|
+
readonly from?: string
|
|
20
22
|
readonly to: string | readonly string[]
|
|
21
23
|
readonly subject: string
|
|
22
24
|
readonly html: string
|
|
@@ -34,7 +36,7 @@ export type OutboundEmail = {
|
|
|
34
36
|
export type EmailAccepted = { readonly providerId: string }
|
|
35
37
|
|
|
36
38
|
/**
|
|
37
|
-
* The
|
|
39
|
+
* The adapter supplies the default sender and maps provider errors. Each email can override the sender.
|
|
38
40
|
* A provider rejection throws `EmailServiceError`; unexpected SDK rejections propagate unchanged.
|
|
39
41
|
*/
|
|
40
42
|
export interface IEmailService {
|
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
|
+
}
|