better-ship 0.12.0 → 0.14.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 (116) hide show
  1. package/dist/application-C4JOf71r.js +291 -0
  2. package/dist/application-C4JOf71r.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-hdaNiCOU.js} +22 -2
  24. package/dist/core-hdaNiCOU.js.map +1 -0
  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-DwWxIdQP.d.ts → index-C28HgVfY.d.ts} +145 -3
  30. package/dist/index-C28HgVfY.d.ts.map +1 -0
  31. package/dist/{index-Bl0K1RQW.d.ts → index-G1QG0P5g.d.ts} +36 -2
  32. package/dist/index-G1QG0P5g.d.ts.map +1 -0
  33. package/dist/outbox-repository-options-BZjfDqGf.d.ts +26 -0
  34. package/dist/outbox-repository-options-BZjfDqGf.d.ts.map +1 -0
  35. package/dist/{unit-of-work-context-BPE1In2f.js → outbox-repository-options-DOTpJH-e.js} +12 -3
  36. package/dist/outbox-repository-options-DOTpJH-e.js.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/posthog/client.d.ts +22 -0
  42. package/dist/posthog/client.d.ts.map +1 -0
  43. package/dist/posthog/client.js +34 -0
  44. package/dist/posthog/client.js.map +1 -0
  45. package/dist/posthog/metrics.d.ts +18 -0
  46. package/dist/posthog/metrics.d.ts.map +1 -0
  47. package/dist/posthog/metrics.js +30 -0
  48. package/dist/posthog/metrics.js.map +1 -0
  49. package/dist/posthog/proxy.d.ts +17 -0
  50. package/dist/posthog/proxy.d.ts.map +1 -0
  51. package/dist/posthog/proxy.js +34 -0
  52. package/dist/posthog/proxy.js.map +1 -0
  53. package/dist/posthog/server.d.ts +37 -0
  54. package/dist/posthog/server.d.ts.map +1 -0
  55. package/dist/posthog/server.js +81 -0
  56. package/dist/posthog/server.js.map +1 -0
  57. package/dist/resend.d.ts +3 -3
  58. package/dist/resend.js +2 -2
  59. package/dist/sentry/client.d.ts +22 -0
  60. package/dist/sentry/client.d.ts.map +1 -0
  61. package/dist/sentry/client.js +48 -0
  62. package/dist/sentry/client.js.map +1 -0
  63. package/dist/sentry/server.d.ts +24 -0
  64. package/dist/sentry/server.d.ts.map +1 -0
  65. package/dist/sentry/server.js +48 -0
  66. package/dist/sentry/server.js.map +1 -0
  67. package/dist/sentry/vite.d.ts +11 -0
  68. package/dist/sentry/vite.d.ts.map +1 -0
  69. package/dist/sentry/vite.js +17 -0
  70. package/dist/sentry/vite.js.map +1 -0
  71. package/dist/tanstack.d.ts +1 -1
  72. package/dist/tanstack.js +2 -2
  73. package/dist/testing/outbox-repository.d.ts +44 -0
  74. package/dist/testing/outbox-repository.d.ts.map +1 -0
  75. package/dist/testing/outbox-repository.js +206 -0
  76. package/dist/testing/outbox-repository.js.map +1 -0
  77. package/dist/{webhook-verification-sZtwTI5n.d.ts → webhook-verification-D7DIhH6C.d.ts} +2 -2
  78. package/dist/{webhook-verification-sZtwTI5n.d.ts.map → webhook-verification-D7DIhH6C.d.ts.map} +1 -1
  79. package/dist/{webhook-verification-BQ9qZnVK.js → webhook-verification-trEgyz7I.js} +2 -2
  80. package/dist/{webhook-verification-BQ9qZnVK.js.map → webhook-verification-trEgyz7I.js.map} +1 -1
  81. package/package.json +42 -1
  82. package/src/application/analytics-service.ts +21 -0
  83. package/src/application/index.ts +31 -1
  84. package/src/application/metrics-service.ts +28 -0
  85. package/src/application/observability-service.ts +16 -0
  86. package/src/application/outbox-relay.ts +117 -0
  87. package/src/application/outbox-repository.ts +46 -0
  88. package/src/application/outbox-retention.ts +61 -0
  89. package/src/core/analytics-event.ts +37 -0
  90. package/src/core/backoff.ts +6 -1
  91. package/src/core/index.ts +10 -1
  92. package/src/core/missing-configuration.error.ts +12 -0
  93. package/src/infrastructure/cloudflare/d1-outbox-repository.ts +224 -0
  94. package/src/infrastructure/cloudflare/index.ts +2 -0
  95. package/src/infrastructure/cloudflare/queue/index.ts +1 -0
  96. package/src/infrastructure/cloudflare/queue/queue-client.ts +6 -2
  97. package/src/infrastructure/cloudflare/queue/wait-until-wake.ts +19 -0
  98. package/src/infrastructure/outbox-repository-options.ts +9 -0
  99. package/src/infrastructure/postgres/index.ts +2 -0
  100. package/src/infrastructure/postgres/postgres-outbox-repository.ts +216 -0
  101. package/src/infrastructure/posthog/client.ts +41 -0
  102. package/src/infrastructure/posthog/metrics.ts +30 -0
  103. package/src/infrastructure/posthog/proxy.ts +47 -0
  104. package/src/infrastructure/posthog/server.ts +107 -0
  105. package/src/infrastructure/sentry/client.ts +55 -0
  106. package/src/infrastructure/sentry/server.ts +51 -0
  107. package/src/infrastructure/sentry/vite.ts +17 -0
  108. package/src/infrastructure/testing/outbox-repository-contract.ts +228 -0
  109. package/dist/application-CIxo_BpK.js +0 -160
  110. package/dist/application-CIxo_BpK.js.map +0 -1
  111. package/dist/core-DG_cC8YT.js.map +0 -1
  112. package/dist/index-Bl0K1RQW.d.ts.map +0 -1
  113. package/dist/index-DwWxIdQP.d.ts.map +0 -1
  114. package/dist/unit-of-work-context-BPE1In2f.js.map +0 -1
  115. package/dist/unit-of-work-context-D5ytJIa-.d.ts +0 -16
  116. package/dist/unit-of-work-context-D5ytJIa-.d.ts.map +0 -1
@@ -0,0 +1,107 @@
1
+ import { PostHog, type PostHogOptions } from 'posthog-node'
2
+
3
+ import type { AnalyticsService } from '@/application'
4
+ import {
5
+ MissingConfigurationError,
6
+ type AnalyticsEventIdentity,
7
+ type AnalyticsEventInput,
8
+ type AnalyticsProperties,
9
+ } from '@/core'
10
+
11
+ import { PostHogMetricsService } from './metrics.ts'
12
+
13
+ /** Deployment values and SDK options; the runtime owns delivery promises. */
14
+ export interface PostHogAnalyticsConfig {
15
+ readonly apiKey: string
16
+ readonly environment: string
17
+ readonly source: string
18
+ readonly waitUntil: (promise: Promise<unknown>) => void
19
+ readonly options?: Omit<
20
+ PostHogOptions,
21
+ 'waitUntil' | 'flushAt' | 'flushInterval' | 'metrics' | 'disabled'
22
+ > & {
23
+ readonly metrics?: Omit<NonNullable<PostHogOptions['metrics']>, 'serviceName' | 'environment'>
24
+ }
25
+ }
26
+
27
+ /** Worker analytics ported from Typist; disabled applications select Porte's NULL_ANALYTICS instead. */
28
+ export class PostHogAnalyticsService implements AnalyticsService {
29
+ /** Metrics share this service's SDK client and delivery lifetime. */
30
+ readonly metrics: PostHogMetricsService
31
+
32
+ private constructor(
33
+ /** Raw SDK access for integrations such as PostHog AI tracing. */
34
+ readonly posthog: PostHog,
35
+ private readonly waitUntil: (promise: Promise<unknown>) => void,
36
+ ) {
37
+ this.metrics = new PostHogMetricsService(posthog, waitUntil)
38
+ }
39
+
40
+ /** Construct once per Worker invocation; a blank key throws MissingConfigurationError. */
41
+ static create(config: PostHogAnalyticsConfig): PostHogAnalyticsService {
42
+ const apiKey = config.apiKey.trim()
43
+ if (apiKey.length === 0) throw new MissingConfigurationError('POSTHOG_API_KEY')
44
+ const client = new PostHog(apiKey, {
45
+ host: 'https://us.i.posthog.com',
46
+ ...config.options,
47
+ flushAt: 1,
48
+ flushInterval: 0,
49
+ waitUntil: config.waitUntil,
50
+ metrics: {
51
+ ...config.options?.metrics,
52
+ environment: config.environment,
53
+ serviceName: config.source,
54
+ },
55
+ })
56
+ // The SDK applies in-memory super properties synchronously before this promise resolves.
57
+ void client.register({ environment: config.environment, source: config.source })
58
+ return new PostHogAnalyticsService(client, config.waitUntil)
59
+ }
60
+
61
+ /** Schedule person properties; the SDK's waitUntil option protects queued delivery. */
62
+ identify(distinctId: string, properties?: AnalyticsProperties): void {
63
+ const payload: Parameters<PostHog['identify']>[0] = { distinctId }
64
+ if (properties !== undefined) payload.properties = properties
65
+ this.posthog.identify(payload)
66
+ }
67
+
68
+ /** Schedule an irreversible person merge; only alias IDs the application proves belong to one person. */
69
+ alias(distinctId: string, previousDistinctId: string): void {
70
+ this.waitUntil(this.posthog.aliasImmediate({ distinctId, alias: previousDistinctId }))
71
+ }
72
+
73
+ /** Schedule an event; omit identity for SDK-generated UUID and capture time. */
74
+ track(event: AnalyticsEventInput, distinctId: string, identity?: AnalyticsEventIdentity): void {
75
+ this.capture(event, distinctId, 'person', identity)
76
+ }
77
+
78
+ /** Schedule an event without creating a person profile for an attempt or resource ID. */
79
+ trackAnonymous(
80
+ event: AnalyticsEventInput,
81
+ distinctId: string,
82
+ identity?: AnalyticsEventIdentity,
83
+ ): void {
84
+ this.capture(event, distinctId, 'anonymous', identity)
85
+ }
86
+
87
+ private capture(
88
+ event: AnalyticsEventInput,
89
+ distinctId: string,
90
+ profile: 'person' | 'anonymous',
91
+ identity?: AnalyticsEventIdentity,
92
+ ): void {
93
+ const properties = { ...event.properties }
94
+ const payload: Parameters<PostHog['captureImmediate']>[0] = {
95
+ distinctId,
96
+ event: event.name,
97
+ properties,
98
+ }
99
+ if (profile === 'anonymous')
100
+ payload.properties = { ...properties, $process_person_profile: false }
101
+ if (identity !== undefined) {
102
+ payload.uuid = identity.uuid
103
+ payload.timestamp = identity.timestamp
104
+ }
105
+ this.waitUntil(this.posthog.captureImmediate(payload))
106
+ }
107
+ }
@@ -0,0 +1,55 @@
1
+ import * as Sentry from '@sentry/tanstackstart-react'
2
+ import { isNotFound, isRedirect } from '@tanstack/react-router'
3
+
4
+ import { setLoggerErrorHook, type LoggerErrorHook } from '@/core'
5
+
6
+ /** Application settings use Sentry's names; enablement and environment belong to the application. */
7
+ export type SentryClientOptions = Sentry.BrowserOptions & {
8
+ readonly dsn: string
9
+ readonly environment: string
10
+ readonly enabled: boolean
11
+ }
12
+
13
+ /** Privacy defaults from Porte and browser noise filtering from Typist; explicit SDK options take precedence. */
14
+ export function createSentryClientOptions(options: SentryClientOptions): Sentry.BrowserOptions {
15
+ return {
16
+ enableLogs: true,
17
+ ignoreErrors: [/Failed to execute '(insertBefore|removeChild)' on 'Node'/],
18
+ ...options,
19
+ dataCollection: {
20
+ userInfo: false,
21
+ httpBodies: [],
22
+ ...options.dataCollection,
23
+ genAI: { inputs: false, outputs: false, ...options.dataCollection?.genAI },
24
+ },
25
+ }
26
+ }
27
+
28
+ /** Forward logged browser errors once, excluding TanStack navigation control flow. */
29
+ export function createSentryLoggerErrorHook(): LoggerErrorHook {
30
+ return ({ error, context }) => {
31
+ if (isNotFound(error) || isRedirect(error)) return
32
+ const captured = error instanceof Error ? error : new Error('Logger received a non-Error value')
33
+ Sentry.captureException(captured, { extra: context })
34
+ }
35
+ }
36
+
37
+ /** Initialize once in the browser entrypoint before hydration; server imports have no side effects. */
38
+ export function initializeSentry(options: SentryClientOptions): ReturnType<typeof Sentry.init> {
39
+ const client = Sentry.init(createSentryClientOptions(options))
40
+ setLoggerErrorHook(createSentryLoggerErrorHook())
41
+ return client
42
+ }
43
+
44
+ /** Attach tracing once after creating the browser router, as Porte does in getRouter. */
45
+ export function instrumentSentryRouter(
46
+ router: Parameters<typeof Sentry.tanstackRouterBrowserTracingIntegration>[0],
47
+ ): void {
48
+ if (router.isServer) return
49
+ Sentry.addIntegration(Sentry.tanstackRouterBrowserTracingIntegration(router))
50
+ }
51
+
52
+ /** The SDK error boundary; the application supplies its fallback component. */
53
+ export const ErrorBoundary: typeof Sentry.ErrorBoundary = Sentry.ErrorBoundary
54
+
55
+ export { Sentry }
@@ -0,0 +1,51 @@
1
+ import * as Sentry from '@sentry/cloudflare'
2
+
3
+ import type { ObservabilityScope, ObservabilityService, ObservabilityUser } from '@/application'
4
+ import type { LoggerErrorHook } from '@/core'
5
+
6
+ /** Application-owned deployment settings with the full Cloudflare SDK option contract. */
7
+ export type SentryServerOptions = Sentry.CloudflareOptions & {
8
+ readonly dsn: string
9
+ readonly environment: string
10
+ readonly enabled: boolean
11
+ }
12
+
13
+ /** Options for Workers, Durable Objects, and Workflows, ported from Typist and Porte. */
14
+ export function createSentryOptions(options: SentryServerOptions): Sentry.CloudflareOptions {
15
+ return {
16
+ enableLogs: true,
17
+ ...options,
18
+ dataCollection: {
19
+ userInfo: false,
20
+ httpBodies: [],
21
+ ...options.dataCollection,
22
+ genAI: { inputs: false, outputs: false, ...options.dataCollection?.genAI },
23
+ },
24
+ }
25
+ }
26
+
27
+ /** Register with setLoggerErrorHook once at startup; an explicit user applies only to this report. */
28
+ export function createSentryLoggerErrorHook(): LoggerErrorHook {
29
+ return ({ error, distinctId, context }) => {
30
+ if (distinctId !== undefined) {
31
+ Sentry.captureException(error, { extra: context, user: { id: distinctId } })
32
+ return
33
+ }
34
+ Sentry.captureException(error, { extra: context })
35
+ }
36
+ }
37
+
38
+ /** Identity and scope adapter from Typist and Porte; call inside Sentry's instrumented runtime. */
39
+ export class SentryObservabilityService implements ObservabilityService {
40
+ /** Attribute subsequent reports in the current isolation scope. */
41
+ setUser(user: ObservabilityUser): void {
42
+ Sentry.setUser(user)
43
+ }
44
+
45
+ /** Await work in a separate scope for each queue message or concurrent operation. */
46
+ async withIsolationScope<T>(callback: (scope: ObservabilityScope) => T | Promise<T>): Promise<T> {
47
+ return await Sentry.withIsolationScope(callback)
48
+ }
49
+ }
50
+
51
+ export { Sentry }
@@ -0,0 +1,17 @@
1
+ import {
2
+ sentryTanstackStart as sentryPlugin,
3
+ type SentryTanstackStartOptions,
4
+ } from '@sentry/tanstackstart-react/vite'
5
+
6
+ /** Sentry's Vite options; the SDK creates the tunnel route and sets the browser tunnel URL. */
7
+ export type SentryViteOptions = SentryTanstackStartOptions
8
+
9
+ /**
10
+ * Add after tanstackStart() in Vite. The SDK enables a same-origin tunnel by default here.
11
+ * Set tunnelRoute.allowedDsns when the browser and Worker use different Sentry projects.
12
+ */
13
+ export function sentryTanstackStart(
14
+ options: SentryViteOptions = {},
15
+ ): ReturnType<typeof sentryPlugin> {
16
+ return sentryPlugin({ ...options, tunnelRoute: options.tunnelRoute ?? true })
17
+ }
@@ -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,160 +0,0 @@
1
- import { E as DatabaseError, Q as AppError, T as unreachable, a as createLogger } from "./core-DG_cC8YT.js";
2
- import { panic } from "better-result";
3
-
4
- //#region src/application/email-service.ts
5
- /** Records every send for a test. Provider ids count up as `recorded-1`, `recorded-2`. */
6
- var RecordingEmailService = class {
7
- /** Sends in call order, including repeated idempotency keys. */
8
- sent = [];
9
- /** Record the send before returning its provider id. */
10
- async sendEmail(email) {
11
- this.sent.push(email);
12
- return { providerId: `recorded-${this.sent.length}` };
13
- }
14
- };
15
-
16
- //#endregion
17
- //#region src/application/record-email-event.ts
18
- /**
19
- * Record a bounce or a complaint for every recipient inside the command's unit of work.
20
- * The command id is the provider's event id, so a redelivered webhook is `MessageAlreadyProcessedError`.
21
- */
22
- async function recordEmailEvent(command, deps) {
23
- await deps.uow.run(command.id, async ({ emailDeliverability }) => {
24
- for (const address of command.recipients) if (command.event.type === "bounced") await emailDeliverability.recordBounce(address, {
25
- type: command.event.bounceType,
26
- subType: command.event.bounceSubType
27
- });
28
- else await emailDeliverability.recordComplaint(address);
29
- });
30
- }
31
-
32
- //#endregion
33
- //#region src/application/message-already-processed.error.ts
34
- /** The message's effects already committed. A caller can skip duplicate delivery without repeating those effects. */
35
- var MessageAlreadyProcessedError = class MessageAlreadyProcessedError extends AppError {
36
- messageId;
37
- _tag = "MessageAlreadyProcessedError";
38
- classification = "terminal";
39
- constructor(messageId, options) {
40
- super(`message ${messageId} already processed`, options);
41
- this.messageId = messageId;
42
- }
43
- /** Recognize only the processed-message primary key; preserve other failures unchanged at the call site. */
44
- static from(cause, messageId) {
45
- if (cause instanceof MessageAlreadyProcessedError) return cause;
46
- if (!(cause instanceof DatabaseError)) return void 0;
47
- const details = cause.details;
48
- if (!(details.database === "d1" ? details.kind === "unique" && details.table === "processed_messages" && details.column === "message_id" : details.code === "23505" && details.table_name === "processed_messages" && details.constraint_name === "processed_messages_pkey")) return void 0;
49
- return new MessageAlreadyProcessedError(messageId, { cause });
50
- }
51
- };
52
-
53
- //#endregion
54
- //#region src/application/message-bus.ts
55
- const logger = createLogger("message-bus");
56
- /**
57
- * Routes one message to its slot. The bus opens no transaction and keeps no outbox:
58
- * a handler that writes opens the unit of work itself.
59
- */
60
- var MessageBus = class {
61
- deps;
62
- registry;
63
- constructor(deps, registry) {
64
- this.deps = deps;
65
- this.registry = registry;
66
- }
67
- /** Resolves to the handler's own value for a command or query, and to void for an event. */
68
- async handle(message) {
69
- return await this.dispatch(message);
70
- }
71
- dispatch(message) {
72
- if (message.type === "command") return this.handleCommand(message);
73
- if (message.type === "query") return this.handleQuery(message);
74
- if (message.type === "event") return this.handleEvent(message);
75
- return unreachable(message);
76
- }
77
- handleCommand(command) {
78
- const handler = this.registry.commands[command.name];
79
- if (handler === void 0) return panic(`No handler for command ${command.name}`);
80
- logger.debug("handling_command", {
81
- name: command.name,
82
- id: command.id
83
- });
84
- return handler(command, this.deps);
85
- }
86
- handleQuery(query) {
87
- const handler = this.registry.queries[query.name];
88
- if (handler === void 0) return panic(`No handler for query ${query.name}`);
89
- return handler(query, this.deps);
90
- }
91
- /**
92
- * Every subscriber runs to completion. One failure is rethrown as is. Several are thrown as
93
- * one `AggregateError`. A `MessageAlreadyProcessedError` means that subscriber already committed on
94
- * an earlier delivery, so it counts as delivered.
95
- */
96
- async handleEvent(event) {
97
- const subscriptions = this.registry.events[event.name] ?? [];
98
- const outcomes = await Promise.allSettled(subscriptions.map((subscription) => subscription(event, this.deps)));
99
- const failures = [];
100
- for (const [subscriber, outcome] of outcomes.entries()) {
101
- if (outcome.status === "fulfilled") continue;
102
- if (outcome.reason instanceof MessageAlreadyProcessedError) {
103
- logger.info("duplicate_subscriber_ignored", {
104
- event: event.name,
105
- id: event.id,
106
- subscriber
107
- });
108
- continue;
109
- }
110
- failures.push(outcome.reason);
111
- }
112
- if (failures.length === 1) throw failures[0];
113
- if (failures.length > 1) throw new AggregateError(failures, `${event.name}: ${failures.length} subscribers failed`);
114
- }
115
- };
116
-
117
- //#endregion
118
- //#region src/application/unit-of-work.ts
119
- /** The unit of work: the boundary, the repositories, the events. Every database call is a port. */
120
- var UnitOfWork = class {
121
- deps;
122
- constructor(deps) {
123
- this.deps = deps;
124
- }
125
- async run(messageId, work) {
126
- const { transaction, messageStore, createRepositories, eventCollector, relay } = this.deps;
127
- let written = 0;
128
- let value;
129
- try {
130
- value = await transaction.transaction(async (tx) => {
131
- const store = messageStore(tx);
132
- await store.claim(messageId);
133
- const result = await work(createRepositories(tx));
134
- const pending = eventCollector.collect();
135
- await store.persistEvents(pending);
136
- written = pending.length;
137
- return result;
138
- });
139
- } catch (error) {
140
- throw MessageAlreadyProcessedError.from(error, messageId) ?? error;
141
- }
142
- if (written > 0) relay.wake();
143
- return value;
144
- }
145
- };
146
-
147
- //#endregion
148
- //#region src/application/unit-of-work.errors.ts
149
- /** A boundary was opened inside another. One message, one atomic scope. */
150
- var UnitOfWorkAlreadyActiveError = class extends AppError {
151
- _tag = "UnitOfWorkAlreadyActiveError";
152
- classification = "terminal";
153
- constructor() {
154
- super("a unit of work is already active");
155
- }
156
- };
157
-
158
- //#endregion
159
- export { recordEmailEvent as a, MessageAlreadyProcessedError as i, UnitOfWork as n, RecordingEmailService as o, MessageBus as r, UnitOfWorkAlreadyActiveError as t };
160
- //# sourceMappingURL=application-CIxo_BpK.js.map
@@ -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 /** Override the default for this email, for example \"Display Name <address@domain.com>\". */\n readonly from?: string\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 adapter supplies the default sender and maps provider errors. Each email can override the sender.\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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"}