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,61 @@
1
+ import type { IOutboxRetentionRepository } from './outbox-repository.ts'
2
+
3
+ /** Typist's retention defaults; receipt retention must cover the application's supported replay period. */
4
+ export type OutboxRetentionOptions = {
5
+ readonly publishedRetentionMs?: number
6
+ readonly failedRetentionMs?: number
7
+ readonly receiptRetentionMs?: number
8
+ readonly batchSize?: number
9
+ readonly maxBatches?: number
10
+ }
11
+
12
+ /** Deleted records per category during one bounded sweep. */
13
+ export type OutboxRetentionResult = {
14
+ readonly published: number
15
+ readonly failed: number
16
+ readonly receipts: number
17
+ }
18
+
19
+ /** Bounded cleanup ported from Typist's message retention handler. The application schedules each sweep. */
20
+ export class OutboxRetention {
21
+ private readonly options: Required<OutboxRetentionOptions>
22
+
23
+ constructor(
24
+ private readonly repository: IOutboxRetentionRepository,
25
+ options: OutboxRetentionOptions = {},
26
+ ) {
27
+ this.options = {
28
+ publishedRetentionMs: options.publishedRetentionMs ?? 86_400_000,
29
+ failedRetentionMs: options.failedRetentionMs ?? 30 * 86_400_000,
30
+ receiptRetentionMs: options.receiptRetentionMs ?? 7 * 86_400_000,
31
+ batchSize: options.batchSize ?? 1_000,
32
+ maxBatches: options.maxBatches ?? 10,
33
+ }
34
+ }
35
+
36
+ /** Calculate all cutoffs from one supplied time; propagate repository failures to the calling boundary. */
37
+ async sweep(at: Date): Promise<OutboxRetentionResult> {
38
+ const { publishedRetentionMs, failedRetentionMs, receiptRetentionMs } = this.options
39
+ const published = await this.deleteBatches((size) =>
40
+ this.repository.deletePublishedBefore(new Date(at.getTime() - publishedRetentionMs), size),
41
+ )
42
+ const failed = await this.deleteBatches((size) =>
43
+ this.repository.deleteFailedBefore(new Date(at.getTime() - failedRetentionMs), size),
44
+ )
45
+ const receipts = await this.deleteBatches((size) =>
46
+ this.repository.deleteReceiptsBefore(new Date(at.getTime() - receiptRetentionMs), size),
47
+ )
48
+ return { published, failed, receipts }
49
+ }
50
+
51
+ private async deleteBatches(deleteBatch: (size: number) => Promise<number>): Promise<number> {
52
+ let total = 0
53
+ for (let batch = 0; batch < this.options.maxBatches; batch += 1) {
54
+ // oxlint-disable-next-line no-await-in-loop -- The delete count decides whether another batch is needed.
55
+ const deleted = await deleteBatch(this.options.batchSize)
56
+ total += deleted
57
+ if (deleted < this.options.batchSize) break
58
+ }
59
+ return total
60
+ }
61
+ }
@@ -21,7 +21,12 @@ export function exponentialEqualJitterMs(
21
21
  return Math.floor(halfCapMs) + spreadJitterMs(halfCapMs)
22
22
  }
23
23
 
24
- function exponentialDelayCapMs(baseDelayMs: number, maxDelayMs: number, attempt: number): number {
24
+ /** Capped exponential delay for a durable retry recorded in storage. */
25
+ export function exponentialDelayCapMs(
26
+ baseDelayMs: number,
27
+ maxDelayMs: number,
28
+ attempt: number,
29
+ ): number {
25
30
  if (!Number.isSafeInteger(attempt) || attempt < 1) {
26
31
  return panic('attempt must be a positive integer')
27
32
  }
package/src/core/index.ts CHANGED
@@ -88,7 +88,7 @@ export {
88
88
  retryAfterSecondsOf,
89
89
  type BoundaryErrorClassifier,
90
90
  } from './classify-failure.ts'
91
- export { exponentialEqualJitterMs, spreadJitterMs } from './backoff.ts'
91
+ export { exponentialDelayCapMs, exponentialEqualJitterMs, spreadJitterMs } from './backoff.ts'
92
92
  export {
93
93
  configureLogger,
94
94
  consoleSink,
@@ -0,0 +1,224 @@
1
+ import { and, asc, eq, inArray, isNull, lt, lte, or, sql } from 'drizzle-orm'
2
+
3
+ import type { DeadLetterReason, IOutboxRepository, OutboxClaim, OutboxMessage } from '@/application'
4
+ import { exponentialDelayCapMs } from '@/core'
5
+
6
+ import type { DrizzleSchema } from '../drizzle-schema.ts'
7
+ import {
8
+ OUTBOX_REPOSITORY_DEFAULTS,
9
+ type OutboxRepositoryOptions,
10
+ } from '../outbox-repository-options.ts'
11
+ import { executeDatabase } from './d1-database-execution.ts'
12
+ import type { D1Db } from './d1-database-types.ts'
13
+ import { messageOutbox, processedMessages } from './d1-schema.ts'
14
+
15
+ /**
16
+ * Typist's atomic D1 claims on a plain connection, outside a unit of work.
17
+ * Every write runs once; lease expiry and the scheduled sweep recover interrupted operations.
18
+ */
19
+ export class D1OutboxRepository<Schema extends DrizzleSchema> implements IOutboxRepository {
20
+ private readonly options: Required<OutboxRepositoryOptions>
21
+
22
+ constructor(
23
+ private readonly db: D1Db<Schema>,
24
+ options: OutboxRepositoryOptions = {},
25
+ private readonly now: () => Date = () => new Date(),
26
+ ) {
27
+ this.options = { ...OUTBOX_REPOSITORY_DEFAULTS, ...options }
28
+ }
29
+
30
+ /** Lease due rows in one statement; increment attempts even when reclaiming an expired lease. */
31
+ async claimPending(batchSize: number): Promise<OutboxClaim> {
32
+ const token = crypto.randomUUID()
33
+ const now = this.now()
34
+ const rows = await executeDatabase('claim pending outbox messages', () =>
35
+ this.db
36
+ .update(messageOutbox)
37
+ .set({
38
+ claimToken: token,
39
+ claimExpiresAt: deadline(now.getTime() + this.options.leaseMs),
40
+ attempts: sql`${messageOutbox.attempts} + 1`,
41
+ })
42
+ .where(
43
+ inArray(
44
+ messageOutbox.id,
45
+ this.db
46
+ .select({ id: messageOutbox.id })
47
+ .from(messageOutbox)
48
+ .where(
49
+ and(
50
+ isNull(messageOutbox.publishedAt),
51
+ isNull(messageOutbox.failedAt),
52
+ lte(messageOutbox.availableAt, now),
53
+ or(isNull(messageOutbox.claimExpiresAt), lte(messageOutbox.claimExpiresAt, now)),
54
+ ),
55
+ )
56
+ .orderBy(
57
+ asc(messageOutbox.availableAt),
58
+ asc(messageOutbox.createdAt),
59
+ asc(messageOutbox.id),
60
+ )
61
+ .limit(batchSize),
62
+ ),
63
+ )
64
+ .returning({
65
+ payload: messageOutbox.payload,
66
+ attempts: messageOutbox.attempts,
67
+ availableAt: messageOutbox.availableAt,
68
+ createdAt: messageOutbox.createdAt,
69
+ }),
70
+ )
71
+ // UPDATE RETURNING does not preserve the selection order.
72
+ rows.sort(
73
+ (a, b) =>
74
+ a.availableAt.getTime() - b.availableAt.getTime() ||
75
+ a.createdAt.getTime() - b.createdAt.getTime() ||
76
+ a.payload.id.localeCompare(b.payload.id),
77
+ )
78
+ return { token, messages: rows.map(({ payload, attempts }) => ({ payload, attempts })) }
79
+ }
80
+
81
+ /** Mark only supplied rows that still hold this token as published. */
82
+ async markPublished(claim: OutboxClaim, messages: ReadonlyArray<OutboxMessage>): Promise<void> {
83
+ if (messages.length === 0) return
84
+ await executeDatabase('mark outbox messages published', () =>
85
+ this.db
86
+ .update(messageOutbox)
87
+ .set({ publishedAt: this.now(), claimToken: null, claimExpiresAt: null })
88
+ .where(
89
+ and(
90
+ inArray(messageOutbox.id, outboxIds(messages)),
91
+ eq(messageOutbox.claimToken, claim.token),
92
+ ),
93
+ ),
94
+ )
95
+ }
96
+
97
+ /** Release supplied rows with per-row backoff; a stale token cannot change a newer claim. */
98
+ async releaseClaim(claim: OutboxClaim, messages: ReadonlyArray<OutboxMessage>): Promise<void> {
99
+ const now = this.now()
100
+ for (const attempts of new Set(messages.map((row) => row.attempts))) {
101
+ const rows = messages.filter((row) => row.attempts === attempts)
102
+ const delay = exponentialDelayCapMs(
103
+ this.options.retryBaseDelayMs,
104
+ this.options.retryMaxDelayMs,
105
+ attempts,
106
+ )
107
+ // oxlint-disable-next-line no-await-in-loop -- Stop releasing rows on the first database failure.
108
+ await executeDatabase('release outbox claim', () =>
109
+ this.db
110
+ .update(messageOutbox)
111
+ .set({
112
+ availableAt: deadline(now.getTime() + delay),
113
+ claimToken: null,
114
+ claimExpiresAt: null,
115
+ })
116
+ .where(
117
+ and(
118
+ inArray(messageOutbox.id, outboxIds(rows)),
119
+ eq(messageOutbox.claimToken, claim.token),
120
+ ),
121
+ ),
122
+ )
123
+ }
124
+ }
125
+
126
+ /** Retain the full payload and record a closed reason with a readable error message. */
127
+ async deadLetter(
128
+ claim: OutboxClaim,
129
+ messages: ReadonlyArray<OutboxMessage>,
130
+ reason: DeadLetterReason,
131
+ errorMessage: string,
132
+ ): Promise<void> {
133
+ if (messages.length === 0) return
134
+ await executeDatabase('dead-letter outbox messages', () =>
135
+ this.db
136
+ .update(messageOutbox)
137
+ .set({
138
+ failedAt: this.now(),
139
+ lastError: `${reason}: ${errorMessage}`,
140
+ claimToken: null,
141
+ claimExpiresAt: null,
142
+ })
143
+ .where(
144
+ and(
145
+ inArray(messageOutbox.id, outboxIds(messages)),
146
+ eq(messageOutbox.claimToken, claim.token),
147
+ ),
148
+ ),
149
+ )
150
+ }
151
+
152
+ /** Delete at most batchSize published rows, strictly before the cutoff. */
153
+ async deletePublishedBefore(cutoff: Date, batchSize: number): Promise<number> {
154
+ const rows = await executeDatabase('delete published outbox messages', () =>
155
+ this.db
156
+ .delete(messageOutbox)
157
+ .where(
158
+ inArray(
159
+ messageOutbox.id,
160
+ this.db
161
+ .select({ id: messageOutbox.id })
162
+ .from(messageOutbox)
163
+ .where(lt(messageOutbox.publishedAt, cutoff))
164
+ .orderBy(asc(messageOutbox.publishedAt), asc(messageOutbox.id))
165
+ .limit(batchSize),
166
+ ),
167
+ )
168
+ .returning({ id: messageOutbox.id }),
169
+ )
170
+ return rows.length
171
+ }
172
+
173
+ /** Delete at most batchSize failed rows; pending and published rows remain untouched. */
174
+ async deleteFailedBefore(cutoff: Date, batchSize: number): Promise<number> {
175
+ const rows = await executeDatabase('delete failed outbox messages', () =>
176
+ this.db
177
+ .delete(messageOutbox)
178
+ .where(
179
+ inArray(
180
+ messageOutbox.id,
181
+ this.db
182
+ .select({ id: messageOutbox.id })
183
+ .from(messageOutbox)
184
+ .where(and(isNull(messageOutbox.publishedAt), lt(messageOutbox.failedAt, cutoff)))
185
+ .orderBy(asc(messageOutbox.failedAt), asc(messageOutbox.id))
186
+ .limit(batchSize),
187
+ ),
188
+ )
189
+ .returning({ id: messageOutbox.id }),
190
+ )
191
+ return rows.length
192
+ }
193
+
194
+ /** Delete at most batchSize receipts, strictly before the cutoff. */
195
+ async deleteReceiptsBefore(cutoff: Date, batchSize: number): Promise<number> {
196
+ const rows = await executeDatabase('delete message receipts', () =>
197
+ this.db
198
+ .delete(processedMessages)
199
+ .where(
200
+ inArray(
201
+ processedMessages.messageId,
202
+ this.db
203
+ .select({ messageId: processedMessages.messageId })
204
+ .from(processedMessages)
205
+ .where(lt(processedMessages.processedAt, cutoff))
206
+ .orderBy(asc(processedMessages.processedAt), asc(processedMessages.messageId))
207
+ .limit(batchSize),
208
+ ),
209
+ )
210
+ .returning({ messageId: processedMessages.messageId }),
211
+ )
212
+ return rows.length
213
+ }
214
+ }
215
+
216
+ // One JSON parameter keeps large claims below D1's 100-parameter limit.
217
+ function outboxIds(messages: ReadonlyArray<OutboxMessage>) {
218
+ return sql`(select value from json_each(${JSON.stringify(messages.map((row) => row.payload.id))}))`
219
+ }
220
+
221
+ // D1 timestamps repository seconds; round future deadlines up so a lease or delay never ends early.
222
+ function deadline(ms: number): Date {
223
+ return new Date(Math.ceil(ms / 1_000) * 1_000)
224
+ }
@@ -7,3 +7,5 @@ export type { D1Connection, D1Db, D1ReadDb, D1Tx } from './d1-database-types.ts'
7
7
  export { DatabaseError, type DatabaseFailure, type D1ConstraintKind } from '@/core'
8
8
  export { executeDatabase } from './d1-database-execution.ts'
9
9
  export { eventCollector } from '../unit-of-work-context.ts'
10
+ export { D1OutboxRepository } from './d1-outbox-repository.ts'
11
+ export type { OutboxRepositoryOptions } from '../outbox-repository-options.ts'
@@ -23,3 +23,4 @@ export {
23
23
  type QueueOperation,
24
24
  } from './queue.errors.ts'
25
25
  export { decideRedelivery, type RedeliveryDecision } from './redelivery-policy.ts'
26
+ export { waitUntilWake } from './wait-until-wake.ts'
@@ -35,7 +35,9 @@ export interface IQueueClient<TMessage extends QueueMessage> {
35
35
  send(message: TMessage, options?: QueueSendOptions): Promise<void>
36
36
  sendBatch(messages: ReadonlyArray<TMessage>): Promise<void>
37
37
  /** Sends every message within the limits and returns the oversized ones. */
38
- sendBatchPartial(messages: ReadonlyArray<TMessage>): Promise<ReadonlyArray<TMessage>>
38
+ sendBatchPartial<Sent extends TMessage>(
39
+ messages: ReadonlyArray<Sent>,
40
+ ): Promise<ReadonlyArray<Sent>>
39
41
  /** Parks a delivered message's body as it arrived. */
40
42
  sendToDlq(message: Message): Promise<void>
41
43
  }
@@ -109,7 +111,9 @@ export class QueueClient<
109
111
  await this.sendSizedBatch(sized)
110
112
  }
111
113
 
112
- async sendBatchPartial(messages: ReadonlyArray<TMessage>): Promise<ReadonlyArray<TMessage>> {
114
+ async sendBatchPartial<Sent extends TMessage>(
115
+ messages: ReadonlyArray<Sent>,
116
+ ): Promise<ReadonlyArray<Sent>> {
113
117
  const sized = sizeMessages(messages)
114
118
  await this.sendSizedBatch(sized.filter((item) => !isOversized(item)))
115
119
  return sized.filter(isOversized).map((item) => item.message)
@@ -0,0 +1,19 @@
1
+ import type { IOutboxRelay, OutboxRelay } from '@/application'
2
+ import { createLogger } from '@/core'
3
+
4
+ const logger = createLogger('outbox-wake')
5
+
6
+ /** Register delivery after commit; log a failed drain once and leave recovery to the scheduled sweep. */
7
+ export function waitUntilWake(
8
+ context: Pick<ExecutionContext, 'waitUntil'>,
9
+ relay: Pick<OutboxRelay, 'drain'>,
10
+ ): IOutboxRelay {
11
+ return {
12
+ wake: () =>
13
+ context.waitUntil(
14
+ relay.drain().catch((cause: unknown) => {
15
+ logger.error('outbox_wake_failed', { error: cause })
16
+ }),
17
+ ),
18
+ }
19
+ }
@@ -0,0 +1,9 @@
1
+ /** Shared lease and delay defaults for both database adapters. */
2
+ export const OUTBOX_REPOSITORY_DEFAULTS = {
3
+ leaseMs: 60_000,
4
+ retryBaseDelayMs: 1_000,
5
+ retryMaxDelayMs: 60_000,
6
+ }
7
+
8
+ /** Override lease and delay limits at the composition root. */
9
+ export type OutboxRepositoryOptions = Readonly<Partial<typeof OUTBOX_REPOSITORY_DEFAULTS>>
@@ -13,3 +13,5 @@ export { DatabaseError, type DatabaseFailure } from '@/core'
13
13
  export { executeDatabase } from './postgres-database-execution.ts'
14
14
  export { classifyPostgresError } from './classify-postgres-error.ts'
15
15
  export { eventCollector } from '../unit-of-work-context.ts'
16
+ export { PostgresOutboxRepository } from './postgres-outbox-repository.ts'
17
+ export type { OutboxRepositoryOptions } from '../outbox-repository-options.ts'
@@ -0,0 +1,216 @@
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 './postgres-database-execution.ts'
12
+ import type { PostgresDb } from './postgres-database-types.ts'
13
+ import { messageOutbox, processedMessages } from './postgres-schema.ts'
14
+
15
+ /**
16
+ * Typist's lease protocol with riposte's SKIP LOCKED selection 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 PostgresOutboxRepository implements IOutboxRepository {
20
+ private readonly options: Required<OutboxRepositoryOptions>
21
+
22
+ constructor(
23
+ private readonly db: PostgresDb<DrizzleSchema>,
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: new Date(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
+ .for('update', { skipLocked: true }),
63
+ ),
64
+ )
65
+ .returning({
66
+ payload: messageOutbox.payload,
67
+ attempts: messageOutbox.attempts,
68
+ availableAt: messageOutbox.availableAt,
69
+ createdAt: messageOutbox.createdAt,
70
+ }),
71
+ )
72
+ // UPDATE RETURNING does not preserve the selection order.
73
+ rows.sort(
74
+ (a, b) =>
75
+ a.availableAt.getTime() - b.availableAt.getTime() ||
76
+ a.createdAt.getTime() - b.createdAt.getTime() ||
77
+ a.payload.id.localeCompare(b.payload.id),
78
+ )
79
+ return { token, messages: rows.map(({ payload, attempts }) => ({ payload, attempts })) }
80
+ }
81
+
82
+ /** Mark only supplied rows that still hold this token as published. */
83
+ async markPublished(claim: OutboxClaim, messages: ReadonlyArray<OutboxMessage>): Promise<void> {
84
+ if (messages.length === 0) return
85
+ await executeDatabase('mark outbox messages published', () =>
86
+ this.db
87
+ .update(messageOutbox)
88
+ .set({ publishedAt: this.now(), claimToken: null, claimExpiresAt: null })
89
+ .where(
90
+ and(
91
+ inArray(
92
+ messageOutbox.id,
93
+ messages.map((row) => row.payload.id),
94
+ ),
95
+ eq(messageOutbox.claimToken, claim.token),
96
+ ),
97
+ ),
98
+ )
99
+ }
100
+
101
+ /** Release supplied rows with per-row backoff; a stale token cannot change a newer claim. */
102
+ async releaseClaim(claim: OutboxClaim, messages: ReadonlyArray<OutboxMessage>): Promise<void> {
103
+ const now = this.now()
104
+ for (const attempts of new Set(messages.map((row) => row.attempts))) {
105
+ const ids = messages.filter((row) => row.attempts === attempts).map((row) => row.payload.id)
106
+ const delay = exponentialDelayCapMs(
107
+ this.options.retryBaseDelayMs,
108
+ this.options.retryMaxDelayMs,
109
+ attempts,
110
+ )
111
+ // oxlint-disable-next-line no-await-in-loop -- Stop releasing rows on the first database failure.
112
+ await executeDatabase('release outbox claim', () =>
113
+ this.db
114
+ .update(messageOutbox)
115
+ .set({
116
+ availableAt: new Date(now.getTime() + delay),
117
+ claimToken: null,
118
+ claimExpiresAt: null,
119
+ })
120
+ .where(and(inArray(messageOutbox.id, ids), eq(messageOutbox.claimToken, claim.token))),
121
+ )
122
+ }
123
+ }
124
+
125
+ /** Retain the full payload and record a closed reason with a readable error message. */
126
+ async deadLetter(
127
+ claim: OutboxClaim,
128
+ messages: ReadonlyArray<OutboxMessage>,
129
+ reason: DeadLetterReason,
130
+ errorMessage: string,
131
+ ): Promise<void> {
132
+ if (messages.length === 0) return
133
+ await executeDatabase('dead-letter outbox messages', () =>
134
+ this.db
135
+ .update(messageOutbox)
136
+ .set({
137
+ failedAt: this.now(),
138
+ lastError: `${reason}: ${errorMessage}`,
139
+ claimToken: null,
140
+ claimExpiresAt: null,
141
+ })
142
+ .where(
143
+ and(
144
+ inArray(
145
+ messageOutbox.id,
146
+ messages.map((row) => row.payload.id),
147
+ ),
148
+ eq(messageOutbox.claimToken, claim.token),
149
+ ),
150
+ ),
151
+ )
152
+ }
153
+
154
+ /** Delete at most batchSize published rows, strictly before the cutoff. */
155
+ async deletePublishedBefore(cutoff: Date, batchSize: number): Promise<number> {
156
+ const rows = await executeDatabase('delete published outbox messages', () =>
157
+ this.db
158
+ .delete(messageOutbox)
159
+ .where(
160
+ inArray(
161
+ messageOutbox.id,
162
+ this.db
163
+ .select({ id: messageOutbox.id })
164
+ .from(messageOutbox)
165
+ .where(lt(messageOutbox.publishedAt, cutoff))
166
+ .orderBy(asc(messageOutbox.publishedAt), asc(messageOutbox.id))
167
+ .limit(batchSize),
168
+ ),
169
+ )
170
+ .returning({ id: messageOutbox.id }),
171
+ )
172
+ return rows.length
173
+ }
174
+
175
+ /** Delete at most batchSize failed rows; pending and published rows remain untouched. */
176
+ async deleteFailedBefore(cutoff: Date, batchSize: number): Promise<number> {
177
+ const rows = await executeDatabase('delete failed outbox messages', () =>
178
+ this.db
179
+ .delete(messageOutbox)
180
+ .where(
181
+ inArray(
182
+ messageOutbox.id,
183
+ this.db
184
+ .select({ id: messageOutbox.id })
185
+ .from(messageOutbox)
186
+ .where(and(isNull(messageOutbox.publishedAt), lt(messageOutbox.failedAt, cutoff)))
187
+ .orderBy(asc(messageOutbox.failedAt), asc(messageOutbox.id))
188
+ .limit(batchSize),
189
+ ),
190
+ )
191
+ .returning({ id: messageOutbox.id }),
192
+ )
193
+ return rows.length
194
+ }
195
+
196
+ /** Delete at most batchSize receipts, strictly before the cutoff. */
197
+ async deleteReceiptsBefore(cutoff: Date, batchSize: number): Promise<number> {
198
+ const rows = await executeDatabase('delete message receipts', () =>
199
+ this.db
200
+ .delete(processedMessages)
201
+ .where(
202
+ inArray(
203
+ processedMessages.messageId,
204
+ this.db
205
+ .select({ messageId: processedMessages.messageId })
206
+ .from(processedMessages)
207
+ .where(lt(processedMessages.processedAt, cutoff))
208
+ .orderBy(asc(processedMessages.processedAt), asc(processedMessages.messageId))
209
+ .limit(batchSize),
210
+ ),
211
+ )
212
+ .returning({ messageId: processedMessages.messageId }),
213
+ )
214
+ return rows.length
215
+ }
216
+ }
@@ -99,7 +99,7 @@ export class ResendEmailService implements IEmailService {
99
99
  }
100
100
  const client = await this.client
101
101
  const payload: CreateEmailOptions = {
102
- from: this.fromAddress,
102
+ from: email.from ?? this.fromAddress,
103
103
  to: [email.to].flat(),
104
104
  subject: email.subject,
105
105
  html: email.html,