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,228 @@
1
+ import { deepStrictEqual, equal, notEqual, ok } from 'node:assert/strict'
2
+
3
+ import type { IOutboxRepository } from '@/application'
4
+ import type { DomainEvent } from '@/core'
5
+
6
+ /** Stored fields observed by the adapter contract, independent of a database library. */
7
+ export type OutboxContractRow = {
8
+ readonly id: string
9
+ readonly payload: DomainEvent
10
+ readonly createdAt: Date
11
+ readonly availableAt: Date
12
+ readonly attempts: number
13
+ readonly claimToken: string | null
14
+ readonly claimExpiresAt: Date | null
15
+ readonly publishedAt: Date | null
16
+ readonly failedAt: Date | null
17
+ readonly lastError: string | null
18
+ }
19
+
20
+ /** Fixture input for one stored row; omitted fields use fresh, due defaults. */
21
+ export type OutboxContractSeed = { readonly id: string } & Partial<
22
+ Omit<OutboxContractRow, 'id' | 'payload'>
23
+ >
24
+
25
+ /**
26
+ * An isolated database fixture. Configure a 60-second lease, 1-second base delay, and 60-second cap.
27
+ * The clock starts at a whole second. Each test receives fresh tables and an independent clock.
28
+ */
29
+ export interface OutboxRepositoryContractFixture {
30
+ readonly repository: IOutboxRepository
31
+ now(this: void): Date
32
+ advance(ms: number): void
33
+ seed(rows: ReadonlyArray<OutboxContractSeed>): Promise<void>
34
+ /** Return rows ordered by id. */
35
+ rows(): Promise<ReadonlyArray<OutboxContractRow>>
36
+ seedReceipts(
37
+ rows: ReadonlyArray<{ readonly messageId: string; readonly processedAt: Date }>,
38
+ ): Promise<void>
39
+ receipts(): Promise<ReadonlyArray<{ readonly messageId: string; readonly processedAt: Date }>>
40
+ }
41
+
42
+ /** Register the shared repository contract with an isolated fixture per test; run tests serially if they share a database. */
43
+ export function outboxRepositoryContract(
44
+ test: (name: string, run: () => Promise<void>) => void,
45
+ create: () => Promise<OutboxRepositoryContractFixture>,
46
+ ): void {
47
+ test('claims due rows with one token and incremented attempts', async () => {
48
+ const f = await create()
49
+ await f.seed([{ id: 'a' }, { id: 'b', attempts: 3 }])
50
+ const claim = await f.repository.claimPending(50)
51
+ deepStrictEqual(
52
+ claim.messages.map((row) => row.attempts),
53
+ [1, 4],
54
+ )
55
+ ok(claim.token.length > 0)
56
+ deepStrictEqual(
57
+ (await f.rows()).map((row) => row.claimToken),
58
+ [claim.token, claim.token],
59
+ )
60
+ })
61
+
62
+ test('skips terminal, future, and leased rows', async () => {
63
+ const f = await create()
64
+ const future = new Date(f.now().getTime() + 60_000)
65
+ await f.seed([
66
+ { id: 'published', publishedAt: f.now() },
67
+ { id: 'failed', failedAt: f.now() },
68
+ { id: 'future', availableAt: future },
69
+ { id: 'leased', claimToken: 'old', claimExpiresAt: future },
70
+ { id: 'due' },
71
+ ])
72
+ const claim = await f.repository.claimPending(50)
73
+ deepStrictEqual(
74
+ claim.messages.map((row) => row.payload.id),
75
+ ['due'],
76
+ )
77
+ })
78
+
79
+ test('limits a claim and takes the oldest due row first', async () => {
80
+ const f = await create()
81
+ await f.seed([{ id: 'new' }, { id: 'old', availableAt: new Date(f.now().getTime() - 1_000) }])
82
+ const claim = await f.repository.claimPending(1)
83
+ deepStrictEqual(
84
+ claim.messages.map((row) => row.payload.id),
85
+ ['old'],
86
+ )
87
+ })
88
+
89
+ test('concurrent claims never share a row', async () => {
90
+ const f = await create()
91
+ await f.seed([{ id: 'a' }, { id: 'b' }])
92
+ const claims = await Promise.all([f.repository.claimPending(1), f.repository.claimPending(1)])
93
+ const ids = claims.flatMap((claim) => claim.messages.map((row) => row.payload.id))
94
+ equal(ids.length, 2)
95
+ equal(new Set(ids).size, 2)
96
+ })
97
+
98
+ test('reclaims an expired lease and increments attempts again', async () => {
99
+ const f = await create()
100
+ await f.seed([{ id: 'a' }])
101
+ const first = await f.repository.claimPending(1)
102
+ f.advance(60_000)
103
+ const second = await f.repository.claimPending(1)
104
+ notEqual(first.token, second.token)
105
+ deepStrictEqual(
106
+ second.messages.map((row) => row.attempts),
107
+ [2],
108
+ )
109
+ })
110
+
111
+ for (const operation of ['publish', 'release', 'fail']) {
112
+ test(`a stale claim cannot ${operation} a row`, async () => {
113
+ const f = await create()
114
+ await f.seed([{ id: 'a' }])
115
+ const old = await f.repository.claimPending(1)
116
+ f.advance(60_000)
117
+ await f.repository.claimPending(1)
118
+ const before = await f.rows()
119
+ if (operation === 'publish') await f.repository.markPublished(old, old.messages)
120
+ if (operation === 'release') await f.repository.releaseClaim(old, old.messages)
121
+ if (operation === 'fail')
122
+ await f.repository.deadLetter(old, old.messages, 'terminal', 'denied')
123
+ deepStrictEqual(await f.rows(), before)
124
+ })
125
+ }
126
+
127
+ test('publishes only the supplied subset and clears its lease', async () => {
128
+ const f = await create()
129
+ await f.seed([{ id: 'a' }, { id: 'b' }])
130
+ const claim = await f.repository.claimPending(50)
131
+ await f.repository.markPublished(claim, claim.messages.slice(0, 1))
132
+ deepStrictEqual(
133
+ (await f.rows()).map((row) => ({ publishedAt: row.publishedAt, token: row.claimToken })),
134
+ [
135
+ { publishedAt: f.now(), token: null },
136
+ { publishedAt: null, token: claim.token },
137
+ ],
138
+ )
139
+ })
140
+
141
+ test('releases only the supplied subset with per-row capped backoff', async () => {
142
+ const f = await create()
143
+ await f.seed([{ id: 'a' }, { id: 'b', attempts: 2 }, { id: 'c', attempts: 8 }, { id: 'd' }])
144
+ const claim = await f.repository.claimPending(50)
145
+ await f.repository.releaseClaim(claim, claim.messages.slice(0, 3))
146
+ deepStrictEqual(
147
+ (await f.rows()).map((row) => [
148
+ row.availableAt.getTime() - f.now().getTime(),
149
+ row.claimToken,
150
+ ]),
151
+ [
152
+ [1000, null],
153
+ [4000, null],
154
+ [60000, null],
155
+ [0, claim.token],
156
+ ],
157
+ )
158
+ })
159
+
160
+ test('dead-letters only the supplied subset and keeps its payload', async () => {
161
+ const f = await create()
162
+ await f.seed([{ id: 'a' }, { id: 'b' }])
163
+ const claim = await f.repository.claimPending(50)
164
+ await f.repository.deadLetter(claim, claim.messages.slice(0, 1), 'oversized', 'too large')
165
+ deepStrictEqual(
166
+ (await f.rows()).map((row) => [row.payload.id, row.lastError, row.claimToken]),
167
+ [
168
+ ['a', 'oversized: too large', null],
169
+ ['b', null, claim.token],
170
+ ],
171
+ )
172
+ })
173
+
174
+ for (const operation of ['publish', 'release', 'fail']) {
175
+ test(`settles a 120-row claim through ${operation}`, async () => {
176
+ const f = await create()
177
+ await f.seed(Array.from({ length: 120 }, (_, i) => ({ id: String(i) })))
178
+ const claim = await f.repository.claimPending(120)
179
+ equal(claim.messages.length, 120)
180
+ if (operation === 'publish') await f.repository.markPublished(claim, claim.messages)
181
+ if (operation === 'release') await f.repository.releaseClaim(claim, claim.messages)
182
+ if (operation === 'fail')
183
+ await f.repository.deadLetter(claim, claim.messages, 'terminal', 'denied')
184
+ ok((await f.rows()).every((row) => row.claimToken === null))
185
+ })
186
+ }
187
+
188
+ for (const category of ['published', 'failed']) {
189
+ test(`deletes only old ${category} events within the batch limit`, async () => {
190
+ const f = await create()
191
+ const old = new Date(f.now().getTime() - 1000)
192
+ const dates = category === 'published' ? { publishedAt: old } : { failedAt: old }
193
+ const atCutoff = category === 'published' ? { publishedAt: f.now() } : { failedAt: f.now() }
194
+ await f.seed([
195
+ { id: 'old-a', ...dates },
196
+ { id: 'old-b', ...dates },
197
+ { id: 'cutoff', ...atCutoff },
198
+ { id: 'pending' },
199
+ ])
200
+ const remove =
201
+ category === 'published'
202
+ ? f.repository.deletePublishedBefore.bind(f.repository)
203
+ : f.repository.deleteFailedBefore.bind(f.repository)
204
+ equal(await remove(f.now(), 1), 1)
205
+ equal(await remove(f.now(), 50), 1)
206
+ deepStrictEqual(
207
+ (await f.rows()).map((row) => row.id),
208
+ ['cutoff', 'pending'],
209
+ )
210
+ })
211
+ }
212
+
213
+ test('deletes only old receipts within the batch limit', async () => {
214
+ const f = await create()
215
+ const old = new Date(f.now().getTime() - 1000)
216
+ await f.seedReceipts([
217
+ { messageId: 'old-a', processedAt: old },
218
+ { messageId: 'old-b', processedAt: old },
219
+ { messageId: 'cutoff', processedAt: f.now() },
220
+ ])
221
+ equal(await f.repository.deleteReceiptsBefore(f.now(), 1), 1)
222
+ equal(await f.repository.deleteReceiptsBefore(f.now(), 50), 1)
223
+ deepStrictEqual(
224
+ (await f.receipts()).map((row) => row.messageId),
225
+ ['cutoff'],
226
+ )
227
+ })
228
+ }
@@ -1 +0,0 @@
1
- {"version":3,"file":"application-CIxo_BpK.js","names":[],"sources":["../src/application/email-service.ts","../src/application/record-email-event.ts","../src/application/message-already-processed.error.ts","../src/application/message-bus.ts","../src/application/unit-of-work.ts","../src/application/unit-of-work.errors.ts"],"sourcesContent":["import type { EmailIdempotencyKey } from '@/core'\n\n/** A media type such as `application/pdf`. */\nexport type MimeType = `${string}/${string}`\n\n/** A file to attach. Binary content is bytes; the adapter encodes it for the provider. */\nexport type EmailAttachment = {\n readonly filename: string\n readonly content: string | Uint8Array\n /** Optional MIME metadata; Resend accepts filename and content without it. */\n readonly contentType?: MimeType\n}\n\n/**\n * One transactional email. Resend accepts 50 recipients, 75 tags with ASCII names and values,\n * and 40 MB of attachments after base64. Sources: https://resend.com/docs/api-reference/emails/send-email\n * and https://resend.com/docs/dashboard/emails/tags\n */\nexport type OutboundEmail = {\n readonly to: string | readonly string[]\n readonly subject: string\n readonly html: string\n /** The stable identity of this send. Resend deduplicates it for 24 hours. */\n readonly idempotencyKey: EmailIdempotencyKey\n /** Resend generates plain text from html when omitted. An empty string disables that generation. */\n readonly text?: string\n readonly replyTo?: string\n /** Labels the provider stores with the email and returns on every delivery event. */\n readonly tags?: Readonly<Record<string, string>>\n readonly attachments?: readonly EmailAttachment[]\n}\n\n/** The provider accepted the email and assigned its id. */\nexport type EmailAccepted = { readonly providerId: string }\n\n/**\n * The port a handler sends through. The adapter owns the sender address and the provider error mapping.\n * A provider rejection throws `EmailServiceError`; unexpected SDK rejections propagate unchanged.\n */\nexport interface IEmailService {\n /** Resolve after provider acceptance; this does not confirm delivery to the recipient. */\n sendEmail(email: OutboundEmail): Promise<EmailAccepted>\n}\n\n/** Records every send for a test. Provider ids count up as `recorded-1`, `recorded-2`. */\nexport class RecordingEmailService implements IEmailService {\n /** Sends in call order, including repeated idempotency keys. */\n readonly sent: OutboundEmail[] = []\n\n /** Record the send before returning its provider id. */\n async sendEmail(email: OutboundEmail): Promise<EmailAccepted> {\n this.sent.push(email)\n return { providerId: `recorded-${this.sent.length}` }\n }\n}\n","import type { BounceType, DomainCommand, EmailAddress, RecordEmailEventPayload } from '@/core'\n\nimport type { IUnitOfWork } from './unit-of-work.ts'\n\n/** What a bounce records for one address. */\nexport type EmailBounceRecord = {\n readonly type: BounceType\n readonly subType: string | null\n}\n\n/**\n * The application's deliverability state per address. An implementation keeps the higher bounce\n * severity with `shouldUpdateBounce`, and treats an unknown address as nothing to record.\n */\nexport interface IEmailDeliverabilityStore {\n /** Keep the higher severity atomically. Equal severity permits replacement; the store owns timestamp ordering. */\n recordBounce(address: EmailAddress, bounce: EmailBounceRecord): Promise<void>\n /** Record a complaint without clearing existing suppression state. */\n recordComplaint(address: EmailAddress): Promise<void>\n}\n\n/** The repositories the handler needs from the unit of work. */\nexport type RecordEmailEventRepos = { readonly emailDeliverability: IEmailDeliverabilityStore }\n\n/** The container slice the handler reads. */\nexport type RecordEmailEventDeps = { readonly uow: IUnitOfWork<RecordEmailEventRepos> }\n\n/** The command, under whatever name the application registered the payload. */\nexport type RecordEmailEventCommand = DomainCommand & RecordEmailEventPayload\n\n/**\n * Record a bounce or a complaint for every recipient inside the command's unit of work.\n * The command id is the provider's event id, so a redelivered webhook is `MessageAlreadyProcessedError`.\n */\nexport async function recordEmailEvent(\n command: RecordEmailEventCommand,\n deps: RecordEmailEventDeps,\n): Promise<void> {\n await deps.uow.run(command.id, async ({ emailDeliverability }) => {\n for (const address of command.recipients) {\n if (command.event.type === 'bounced') {\n // oxlint-disable-next-line no-await-in-loop -- Finish each transaction write before starting another; a failure must leave no pending writes.\n await emailDeliverability.recordBounce(address, {\n type: command.event.bounceType,\n subType: command.event.bounceSubType,\n })\n } else {\n // oxlint-disable-next-line no-await-in-loop -- A rejected write must stop the transaction before another write starts.\n await emailDeliverability.recordComplaint(address)\n }\n }\n })\n}\n","import { AppError, DatabaseError, type MessageId } from '@/core'\n\n/** The message's effects already committed. A caller can skip duplicate delivery without repeating those effects. */\nexport class MessageAlreadyProcessedError extends AppError {\n readonly _tag = 'MessageAlreadyProcessedError'\n readonly classification = 'terminal'\n\n constructor(\n readonly messageId: MessageId,\n options?: { readonly cause: unknown },\n ) {\n super(`message ${messageId} already processed`, options)\n }\n\n /** Recognize only the processed-message primary key; preserve other failures unchanged at the call site. */\n static from(cause: unknown, messageId: MessageId): MessageAlreadyProcessedError | undefined {\n if (cause instanceof MessageAlreadyProcessedError) return cause\n if (!(cause instanceof DatabaseError)) return undefined\n const details = cause.details\n const duplicate =\n details.database === 'd1'\n ? details.kind === 'unique' &&\n details.table === 'processed_messages' &&\n details.column === 'message_id'\n : details.code === '23505' &&\n details.table_name === 'processed_messages' &&\n details.constraint_name === 'processed_messages_pkey'\n if (!duplicate) return undefined\n return new MessageAlreadyProcessedError(messageId, { cause })\n }\n}\n","import { panic } from 'better-result'\n\nimport {\n createLogger,\n unreachable,\n type DomainCommand,\n type DomainEvent,\n type DomainMessage,\n type DomainQuery,\n} from '@/core'\n\nimport { MessageAlreadyProcessedError } from './message-already-processed.error.ts'\nimport type { Handlers, MessageResult, RegistryMessage } from './registry.ts'\n\nconst logger = createLogger('message-bus')\n\n/** The port an entrypoint dispatches through. `AppDeps` names this as `IMessageBus<typeof registry>`. */\nexport interface IMessageBus<TRegistry extends Handlers<never>> {\n handle<TMessage extends DomainMessage & RegistryMessage<TRegistry>>(\n message: TMessage,\n ): Promise<MessageResult<TMessage, TRegistry>>\n}\n\n/**\n * Routes one message to its slot. The bus opens no transaction and keeps no outbox:\n * a handler that writes opens the unit of work itself.\n */\nexport class MessageBus<Deps, TRegistry extends Handlers<Deps>> implements IMessageBus<TRegistry> {\n constructor(\n private readonly deps: Deps,\n private readonly registry: TRegistry,\n ) {}\n\n /** Resolves to the handler's own value for a command or query, and to void for an event. */\n async handle<TMessage extends DomainMessage & RegistryMessage<TRegistry>>(\n message: TMessage,\n ): Promise<MessageResult<TMessage, TRegistry>> {\n // SAFETY: the slot for this name is what `MessageResult` names, and dispatch returns its value.\n return (await this.dispatch(message)) as MessageResult<TMessage, TRegistry>\n }\n\n // oxlint-disable-next-line anti-slop/no-unknown-returns -- one table holds every slot's value; `handle` names it.\n private dispatch(message: DomainMessage): Promise<unknown> {\n if (message.type === 'command') return this.handleCommand(message)\n if (message.type === 'query') return this.handleQuery(message)\n if (message.type === 'event') return this.handleEvent(message)\n // A message parsed at a boundary cannot reach here; one that does must not resolve silently.\n return unreachable(message)\n }\n\n // oxlint-disable-next-line anti-slop/no-unknown-returns -- see dispatch.\n private handleCommand(command: DomainCommand): Promise<unknown> {\n // `satisfies` proved the registry complete, so a missing slot is a broken registry object.\n const handler = this.registry.commands[command.name]\n if (handler === undefined) return panic(`No handler for command ${command.name}`)\n logger.debug('handling_command', { name: command.name, id: command.id })\n return handler(command, this.deps)\n }\n\n // oxlint-disable-next-line anti-slop/no-unknown-returns -- see dispatch.\n private handleQuery(query: DomainQuery): Promise<unknown> {\n const handler = this.registry.queries[query.name]\n if (handler === undefined) return panic(`No handler for query ${query.name}`)\n return handler(query, this.deps)\n }\n\n /**\n * Every subscriber runs to completion. One failure is rethrown as is. Several are thrown as\n * one `AggregateError`. A `MessageAlreadyProcessedError` means that subscriber already committed on\n * an earlier delivery, so it counts as delivered.\n */\n private async handleEvent(event: DomainEvent): Promise<void> {\n const subscriptions = this.registry.events[event.name] ?? []\n const outcomes = await Promise.allSettled(\n subscriptions.map((subscription) => subscription(event, this.deps)),\n )\n const failures: unknown[] = []\n for (const [subscriber, outcome] of outcomes.entries()) {\n if (outcome.status === 'fulfilled') continue\n if (outcome.reason instanceof MessageAlreadyProcessedError) {\n // The bus swallows this error, so this line is the only record that the skip happened.\n logger.info('duplicate_subscriber_ignored', { event: event.name, id: event.id, subscriber })\n continue\n }\n failures.push(outcome.reason)\n }\n if (failures.length === 1) throw failures[0]\n if (failures.length > 1) {\n throw new AggregateError(failures, `${event.name}: ${failures.length} subscribers failed`)\n }\n }\n}\n","import type { MessageId } from '@/core'\n\nimport type { IEventCollector } from './event-collector.ts'\nimport { MessageAlreadyProcessedError } from './message-already-processed.error.ts'\nimport type { IMessageStore } from './message-store.ts'\nimport type { IOutboxRelay } from './outbox-relay.ts'\nimport type { ITransaction } from './transaction.ts'\n\n/**\n * Commits the processed-message record, repository writes, and emitted events together before resolving.\n * An existing record rejects with `MessageAlreadyProcessedError`; external effects must run through the outbox after commit.\n * Nested calls reject with `UnitOfWorkAlreadyActiveError`; independent concurrent calls are allowed.\n */\nexport interface IUnitOfWork<Repos> {\n run<T>(messageId: MessageId, work: (repos: Repos) => Promise<T>): Promise<T>\n}\n\nexport type UnitOfWorkDeps<Tx, Repos> = {\n readonly transaction: ITransaction<Tx>\n /** The processed-messages and outbox repository for this transaction. */\n readonly messageStore: (tx: Tx) => IMessageStore\n /** Repositories participate in the current transaction. */\n readonly createRepositories: (tx: Tx) => Repos\n readonly eventCollector: IEventCollector\n readonly relay: IOutboxRelay\n}\n\n/** The unit of work: the boundary, the repositories, the events. Every database call is a port. */\nexport class UnitOfWork<Tx, Repos> implements IUnitOfWork<Repos> {\n constructor(private readonly deps: UnitOfWorkDeps<Tx, Repos>) {}\n\n async run<T>(messageId: MessageId, work: (repos: Repos) => Promise<T>): Promise<T> {\n const { transaction, messageStore, createRepositories, eventCollector, relay } = this.deps\n let written = 0\n\n let value: T\n try {\n value = await transaction.transaction(async (tx) => {\n const store = messageStore(tx)\n await store.claim(messageId)\n const repos = createRepositories(tx)\n const result = await work(repos)\n const pending = eventCollector.collect()\n await store.persistEvents(pending)\n written = pending.length\n return result\n })\n } catch (error) {\n throw MessageAlreadyProcessedError.from(error, messageId) ?? error\n }\n\n if (written > 0) relay.wake()\n return value\n }\n}\n","import { AppError } from '@/core'\n\n/** A boundary was opened inside another. One message, one atomic scope. */\nexport class UnitOfWorkAlreadyActiveError extends AppError {\n readonly _tag = 'UnitOfWorkAlreadyActiveError'\n readonly classification = 'terminal'\n\n constructor() {\n super('a unit of work is already active')\n }\n}\n"],"mappings":"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"}
@@ -1 +0,0 @@
1
- {"version":3,"file":"index-DgDyeR5s.d.ts","names":[],"sources":["../src/application/handlers.ts","../src/application/email-service.ts","../src/application/event-collector.ts","../src/application/message-store.ts","../src/application/outbox-relay.ts","../src/application/transaction.ts","../src/application/unit-of-work.ts","../src/application/record-email-event.ts","../src/application/registry.ts","../src/application/message-bus.ts","../src/application/message-already-processed.error.ts","../src/application/unit-of-work.errors.ts"],"mappings":"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"}
@@ -1 +0,0 @@
1
- {"version":3,"file":"unit-of-work-context-BPE1In2f.js","names":[],"sources":["../src/infrastructure/unit-of-work-context.ts"],"sourcesContent":["import { AsyncLocalStorage } from 'node:async_hooks'\n\nimport { panic } from 'better-result'\nimport type { BatchItem } from 'drizzle-orm/batch'\n\nimport { UnitOfWorkAlreadyActiveError, type IEventCollector } from '@/application'\nimport type { DomainEvent } from '@/core'\n\ntype UnitOfWorkContext = { readonly events: DomainEvent[] } & (\n | { readonly database: 'd1'; readonly statements: BatchItem<'sqlite'>[] }\n | { readonly database: 'postgres' }\n)\n\n// One async scope isolates events and D1 statements and rejects nesting across both adapters.\nconst storage = new AsyncLocalStorage<UnitOfWorkContext>()\n\n/** Reject nested transactions before database work starts; preserve independent concurrent execution. */\nexport async function runWithUnitOfWorkContext<T>(\n database: UnitOfWorkContext['database'],\n work: () => Promise<T>,\n): Promise<T> {\n if (storage.getStore() !== undefined) throw new UnitOfWorkAlreadyActiveError()\n const context: UnitOfWorkContext =\n database === 'd1' ? { database, events: [], statements: [] } : { database, events: [] }\n return storage.run(context, work)\n}\n\n/** Inject this shared scope into the unit of work and expose its emitter to application handlers. */\nexport const eventCollector: IEventCollector = {\n emit(event) {\n currentContext().events.push(event)\n },\n collect() {\n return [...currentContext().events]\n },\n}\n\n/** Queue one native D1 statement for the active batch; other contexts cannot accept D1 writes. */\nexport function stageD1Statement(statement: BatchItem<'sqlite'>): void {\n d1Context().statements.push(statement)\n}\n\n/** Read the active D1 batch in staging order for commit. */\nexport function collectD1Statements(): ReadonlyArray<BatchItem<'sqlite'>> {\n return [...d1Context().statements]\n}\n\nfunction d1Context(): Extract<UnitOfWorkContext, { database: 'd1' }> {\n const context = currentContext()\n if (context.database !== 'd1') return panic('D1 statements require a D1 unit of work')\n return context\n}\n\nfunction currentContext(): UnitOfWorkContext {\n return storage.getStore() ?? panic('operation requires an active unit of work')\n}\n"],"mappings":";;;;;AAcA,MAAM,UAAU,IAAI,kBAAqC;;AAGzD,eAAsB,yBACpB,UACA,MACY;CACZ,IAAI,QAAQ,SAAS,MAAM,QAAW,MAAM,IAAI,6BAA6B;CAC7E,MAAM,UACJ,aAAa,OAAO;EAAE;EAAU,QAAQ,CAAC;EAAG,YAAY,CAAC;CAAE,IAAI;EAAE;EAAU,QAAQ,CAAC;CAAE;CACxF,OAAO,QAAQ,IAAI,SAAS,IAAI;AAClC;;AAGA,MAAa,iBAAkC;CAC7C,KAAK,OAAO;EACV,eAAe,CAAC,CAAC,OAAO,KAAK,KAAK;CACpC;CACA,UAAU;EACR,OAAO,CAAC,GAAG,eAAe,CAAC,CAAC,MAAM;CACpC;AACF;;AAGA,SAAgB,iBAAiB,WAAsC;CACrE,UAAU,CAAC,CAAC,WAAW,KAAK,SAAS;AACvC;;AAGA,SAAgB,sBAA0D;CACxE,OAAO,CAAC,GAAG,UAAU,CAAC,CAAC,UAAU;AACnC;AAEA,SAAS,YAA4D;CACnE,MAAM,UAAU,eAAe;CAC/B,IAAI,QAAQ,aAAa,MAAM,OAAO,MAAM,yCAAyC;CACrF,OAAO;AACT;AAEA,SAAS,iBAAoC;CAC3C,OAAO,QAAQ,SAAS,KAAK,MAAM,2CAA2C;AAChF"}
@@ -1,16 +0,0 @@
1
- import "./index-Bl0K1RQW.js";
2
- import { w as IEventCollector } from "./index-DgDyeR5s.js";
3
- import { BatchItem } from "drizzle-orm/batch";
4
- //#region src/infrastructure/drizzle-schema.d.ts
5
- /**
6
- * The app's drizzle schema: the tables it declared, keyed by name. This is drizzle's own
7
- * constraint on every database and transaction type, restated once so the adapters name it.
8
- */
9
- type DrizzleSchema = Record<string, unknown>;
10
- //#endregion
11
- //#region src/infrastructure/unit-of-work-context.d.ts
12
- /** Inject this shared scope into the unit of work and expose its emitter to application handlers. */
13
- declare const eventCollector: IEventCollector;
14
- //#endregion
15
- export { DrizzleSchema as n, eventCollector as t };
16
- //# sourceMappingURL=unit-of-work-context-BzmnjlDa.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"unit-of-work-context-BzmnjlDa.d.ts","names":[],"sources":["../src/infrastructure/drizzle-schema.ts","../src/infrastructure/unit-of-work-context.ts"],"mappings":";;;;;;;;KAKY,gBAAgB;;;;cCuBf,gBAAgB"}