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.
Files changed (76) 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-DgDyeR5s.d.ts → index-DG4jkwad.d.ts} +95 -4
  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 +3 -3
  43. package/dist/resend.js.map +1 -1
  44. package/dist/tanstack.d.ts +1 -1
  45. package/dist/tanstack.js +1 -1
  46. package/dist/testing/outbox-repository.d.ts +44 -0
  47. package/dist/testing/outbox-repository.d.ts.map +1 -0
  48. package/dist/testing/outbox-repository.js +206 -0
  49. package/dist/testing/outbox-repository.js.map +1 -0
  50. package/dist/{webhook-verification-BQ9qZnVK.js → webhook-verification-CeXUJXVP.js} +2 -2
  51. package/dist/{webhook-verification-BQ9qZnVK.js.map → webhook-verification-CeXUJXVP.js.map} +1 -1
  52. package/dist/{webhook-verification-sZtwTI5n.d.ts → webhook-verification-CyBoERFz.d.ts} +2 -2
  53. package/dist/{webhook-verification-sZtwTI5n.d.ts.map → webhook-verification-CyBoERFz.d.ts.map} +1 -1
  54. package/package.json +35 -1
  55. package/src/application/email-service.ts +3 -1
  56. package/src/application/index.ts +18 -1
  57. package/src/application/outbox-relay.ts +117 -0
  58. package/src/application/outbox-repository.ts +46 -0
  59. package/src/application/outbox-retention.ts +61 -0
  60. package/src/core/backoff.ts +6 -1
  61. package/src/core/index.ts +1 -1
  62. package/src/infrastructure/cloudflare/d1-outbox-repository.ts +224 -0
  63. package/src/infrastructure/cloudflare/index.ts +2 -0
  64. package/src/infrastructure/cloudflare/queue/index.ts +1 -0
  65. package/src/infrastructure/cloudflare/queue/queue-client.ts +6 -2
  66. package/src/infrastructure/cloudflare/queue/wait-until-wake.ts +19 -0
  67. package/src/infrastructure/outbox-repository-options.ts +9 -0
  68. package/src/infrastructure/postgres/index.ts +2 -0
  69. package/src/infrastructure/postgres/postgres-outbox-repository.ts +216 -0
  70. package/src/infrastructure/resend/resend-email-service.ts +1 -1
  71. package/src/infrastructure/testing/outbox-repository-contract.ts +228 -0
  72. package/dist/application-CIxo_BpK.js.map +0 -1
  73. package/dist/index-DgDyeR5s.d.ts.map +0 -1
  74. package/dist/unit-of-work-context-BPE1In2f.js.map +0 -1
  75. package/dist/unit-of-work-context-BzmnjlDa.d.ts +0 -16
  76. 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 { 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.11.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 port a handler sends through. The adapter owns the sender address and the provider error mapping.
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 {
@@ -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
+ }