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