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
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://www.schemastore.org/package.json",
3
3
  "name": "better-ship",
4
- "version": "0.12.0",
4
+ "version": "0.14.0",
5
5
  "description": "Foundation for applications on Cloudflare Workers: logger, errors, message bus, infrastructure adapters, and UI primitives",
6
6
  "license": "Apache-2.0",
7
7
  "repository": {
@@ -29,9 +29,17 @@
29
29
  "./core": "./dist/core.js",
30
30
  "./http": "./dist/http.js",
31
31
  "./postgres": "./dist/postgres.js",
32
+ "./posthog/client": "./dist/posthog/client.js",
33
+ "./posthog/metrics": "./dist/posthog/metrics.js",
34
+ "./posthog/proxy": "./dist/posthog/proxy.js",
35
+ "./posthog/server": "./dist/posthog/server.js",
32
36
  "./resend": "./dist/resend.js",
37
+ "./sentry/client": "./dist/sentry/client.js",
38
+ "./sentry/server": "./dist/sentry/server.js",
39
+ "./sentry/vite": "./dist/sentry/vite.js",
33
40
  "./tanstack": "./dist/tanstack.js",
34
41
  "./tanstack/server": "./dist/tanstack/server.js",
42
+ "./testing/outbox-repository": "./dist/testing/outbox-repository.js",
35
43
  "./ui": "./dist/ui.js",
36
44
  "./package.json": "./package.json"
37
45
  },
@@ -48,16 +56,25 @@
48
56
  "@better-auth/stripe": "^1.7.4",
49
57
  "@cloudflare/vitest-plugin": "^1.1.7",
50
58
  "@cloudflare/workers-types": "5.20260911.1",
59
+ "@posthog/react": "^1.10.6",
60
+ "@sentry/cloudflare": "^10.74.0",
61
+ "@sentry/tanstackstart-react": "^10.74.0",
51
62
  "@tanstack/react-router": "^1.170.35",
52
63
  "@tanstack/react-start": "^1.168.52",
53
64
  "@tanstack/start-client-core": "^1.170.29",
54
65
  "@tanstack/start-storage-context": "^1.167.31",
55
66
  "@types/node": "^26.5.1",
67
+ "@types/react": "^19.3.0",
68
+ "@vitest/browser-playwright": "^4.1.11",
56
69
  "auth": "^1.7.4",
57
70
  "better-auth": "^1.7.4",
58
71
  "drizzle-kit": "^0.31.10",
59
72
  "drizzle-orm": "^0.45.2",
73
+ "playwright": "^1.63.0",
60
74
  "postgres": "^3.4.9",
75
+ "posthog-js": "^1.430.2",
76
+ "posthog-node": "^5.52.1",
77
+ "react": "^19.3.0",
61
78
  "resend": "^6.28.0",
62
79
  "standardwebhooks": "^1.1.1",
63
80
  "stripe": "^22.6.2",
@@ -68,14 +85,38 @@
68
85
  },
69
86
  "peerDependencies": {
70
87
  "@better-auth/stripe": "^1.7.4",
88
+ "@posthog/react": "^1.10.6",
89
+ "@sentry/cloudflare": "^10.74.0",
90
+ "@sentry/tanstackstart-react": "^10.74.0",
71
91
  "@tanstack/react-router": "^1.170.35",
72
92
  "@tanstack/react-start": "^1.168.52",
73
93
  "better-auth": "^1.7.4",
74
94
  "drizzle-orm": "^0.45.2",
95
+ "posthog-js": "^1.430.2",
96
+ "posthog-node": "^5.52.1",
97
+ "react": "^19.3.0",
75
98
  "resend": "^6.28.0",
76
99
  "stripe": "^22.6.2"
77
100
  },
78
101
  "peerDependenciesMeta": {
102
+ "@sentry/cloudflare": {
103
+ "optional": true
104
+ },
105
+ "@sentry/tanstackstart-react": {
106
+ "optional": true
107
+ },
108
+ "posthog-node": {
109
+ "optional": true
110
+ },
111
+ "posthog-js": {
112
+ "optional": true
113
+ },
114
+ "@posthog/react": {
115
+ "optional": true
116
+ },
117
+ "react": {
118
+ "optional": true
119
+ },
79
120
  "resend": {
80
121
  "optional": true
81
122
  },
@@ -0,0 +1,21 @@
1
+ import type { AnalyticsEventIdentity, AnalyticsEventInput, AnalyticsProperties } from '@/core'
2
+
3
+ /** Product analytics without an SDK dependency, ported from Typist and Porte. */
4
+ export interface AnalyticsService<TEvent extends AnalyticsEventInput = AnalyticsEventInput> {
5
+ /** Attach traits to one person. */
6
+ identify(distinctId: string, properties?: AnalyticsProperties): void
7
+ /** Merge a previous ID into this person; the application must establish that both identify the same person. */
8
+ alias(distinctId: string, previousDistinctId: string): void
9
+ /** Schedule an event for a person; returning does not confirm remote acceptance. */
10
+ track(event: TEvent, distinctId: string, identity?: AnalyticsEventIdentity): void
11
+ /** Schedule an event without creating a person profile for its distinct ID. */
12
+ trackAnonymous(event: TEvent, distinctId: string, identity?: AnalyticsEventIdentity): void
13
+ }
14
+
15
+ /** Reports to nobody. The composition root selects this service when analytics is disabled. */
16
+ export const NULL_ANALYTICS: AnalyticsService = {
17
+ identify: () => {},
18
+ alias: () => {},
19
+ track: () => {},
20
+ trackAnonymous: () => {},
21
+ }
@@ -1,4 +1,17 @@
1
1
  export type { CommandHandler, EventHandler, QueryHandler } from './handlers.ts'
2
+ export { NULL_ANALYTICS, type AnalyticsService } from './analytics-service.ts'
3
+ export {
4
+ NULL_METRICS_SERVICE,
5
+ type MetricsService,
6
+ type MetricOptions,
7
+ type MetricAttributes,
8
+ type MetricAttributeValue,
9
+ } from './metrics-service.ts'
10
+ export type {
11
+ ObservabilityService,
12
+ ObservabilityScope,
13
+ ObservabilityUser,
14
+ } from './observability-service.ts'
2
15
  export {
3
16
  RecordingEmailService,
4
17
  type IEmailService,
@@ -18,7 +31,24 @@ export {
18
31
  export type { IMessageStore } from './message-store.ts'
19
32
  export type { DomainCommand, DomainEvent, DomainMessage, DomainQuery, MessageId } from '@/core'
20
33
  export { MessageBus, type IMessageBus } from './message-bus.ts'
21
- export type { IOutboxRelay } from './outbox-relay.ts'
34
+ export {
35
+ OutboxRelay,
36
+ type IOutboxRelay,
37
+ type IOutboxPublisher,
38
+ type OutboxRelayOptions,
39
+ } from './outbox-relay.ts'
40
+ export type {
41
+ OutboxClaim,
42
+ OutboxMessage,
43
+ DeadLetterReason,
44
+ IOutboxRepository,
45
+ IOutboxRetentionRepository,
46
+ } from './outbox-repository.ts'
47
+ export {
48
+ OutboxRetention,
49
+ type OutboxRetentionOptions,
50
+ type OutboxRetentionResult,
51
+ } from './outbox-retention.ts'
22
52
  export type {
23
53
  CommandRegistry,
24
54
  EventRegistry,
@@ -0,0 +1,28 @@
1
+ /** A metric dimension; use a small, bounded value set and never user or request IDs. */
2
+ export type MetricAttributeValue = string | number | boolean
3
+
4
+ /** Dimensions shared by observations in one time series. */
5
+ export type MetricAttributes = Readonly<Record<string, MetricAttributeValue>>
6
+
7
+ /** Unit and dimensions for an operational measurement. */
8
+ export interface MetricOptions {
9
+ readonly attributes?: MetricAttributes
10
+ readonly unit?: string
11
+ }
12
+
13
+ /** Operational measurements, ported from Typist's metrics-service.ts; applications own metric names. */
14
+ export interface MetricsService<TName extends string = string> {
15
+ /** Add a nonnegative value to a total; the default increment is one. */
16
+ count(name: TName, value?: number, options?: MetricOptions): void
17
+ /** Record a current value. */
18
+ gauge(name: TName, value: number, options?: MetricOptions): void
19
+ /** Record one observation in a distribution. */
20
+ histogram(name: TName, value: number, options?: MetricOptions): void
21
+ }
22
+
23
+ /** Records nothing when the composition root disables metrics. */
24
+ export const NULL_METRICS_SERVICE: MetricsService = {
25
+ count: () => {},
26
+ gauge: () => {},
27
+ histogram: () => {},
28
+ }
@@ -0,0 +1,16 @@
1
+ /** The identity an error report belongs to. */
2
+ export interface ObservabilityUser {
3
+ readonly id: string
4
+ }
5
+
6
+ /** Identity within one isolated execution. */
7
+ export interface ObservabilityScope {
8
+ /** Attribute reports in this scope to one person. */
9
+ setUser(user: ObservabilityUser): void
10
+ }
11
+
12
+ /** Error identity and isolation, ported from Porte's sentry-observability-service.ts. */
13
+ export interface ObservabilityService extends ObservabilityScope {
14
+ /** Await work in its own scope so concurrent executions do not share identity. */
15
+ withIsolationScope<T>(callback: (scope: ObservabilityScope) => T | Promise<T>): Promise<T>
16
+ }
@@ -1,3 +1,12 @@
1
+ import { classifyFailure, createLogger, type DomainEvent } from '@/core'
2
+
3
+ import type {
4
+ DeadLetterReason,
5
+ IOutboxRepository,
6
+ OutboxClaim,
7
+ OutboxMessage,
8
+ } from './outbox-repository.ts'
9
+
1
10
  /**
2
11
  * Delivers committed outbox rows. The unit of work only wakes it after a commit that wrote
3
12
  * events; the app decides what a wake is: `waitUntil`, a Durable Object alarm, nothing.
@@ -6,3 +15,111 @@
6
15
  export interface IOutboxRelay {
7
16
  wake(): void
8
17
  }
18
+
19
+ /** The publisher sends valid events and returns oversized events. QueueClient satisfies this port directly. */
20
+ export interface IOutboxPublisher {
21
+ /** Resolves after queue acceptance; a rejected batch may already have sent some events. */
22
+ sendBatchPartial(messages: ReadonlyArray<DomainEvent>): Promise<ReadonlyArray<DomainEvent>>
23
+ }
24
+
25
+ /** Optional limits for one relay; durable attempts exclude the publisher's immediate retries. */
26
+ export type OutboxRelayOptions = {
27
+ readonly batchSize?: number
28
+ readonly maxBatches?: number
29
+ readonly maxAttempts?: number
30
+ }
31
+
32
+ const logger = createLogger('outbox-relay')
33
+
34
+ /**
35
+ * Publishes committed events with bounded durable retries. Ported from Typist's relay with per-row exhaustion.
36
+ * Send failures are recorded; repository failures propagate to the wake or scheduled boundary.
37
+ */
38
+ export class OutboxRelay {
39
+ private readonly options: Required<OutboxRelayOptions>
40
+
41
+ constructor(
42
+ private readonly publisher: IOutboxPublisher,
43
+ private readonly repository: IOutboxRepository,
44
+ options: OutboxRelayOptions = {},
45
+ ) {
46
+ this.options = {
47
+ batchSize: options.batchSize ?? 50,
48
+ maxBatches: options.maxBatches ?? 10,
49
+ maxAttempts: options.maxAttempts ?? 10,
50
+ }
51
+ }
52
+
53
+ /** Return the claimed count, including released and dead-lettered rows. Zero means no due rows. */
54
+ async flush(): Promise<number> {
55
+ const claim = await this.repository.claimPending(this.options.batchSize)
56
+ const exhausted = claim.messages.filter((row) => row.attempts > this.options.maxAttempts)
57
+ await this.deadLetter(claim, exhausted, 'exhausted', 'Claim attempt limit exceeded')
58
+ const pending = claim.messages.filter((row) => row.attempts <= this.options.maxAttempts)
59
+ if (pending.length === 0) return claim.messages.length
60
+
61
+ let oversized: ReadonlyArray<DomainEvent>
62
+ try {
63
+ oversized = await this.publisher.sendBatchPartial(pending.map((row) => row.payload))
64
+ } catch (error) {
65
+ await this.handleSendFailure(claim, pending, error)
66
+ return claim.messages.length
67
+ }
68
+
69
+ const oversizedIds = new Set(oversized.map((event) => event.id))
70
+ await this.deadLetter(
71
+ claim,
72
+ pending.filter((row) => oversizedIds.has(row.payload.id)),
73
+ 'oversized',
74
+ 'Exceeds queue message size limit',
75
+ )
76
+ const sent = pending.filter((row) => !oversizedIds.has(row.payload.id))
77
+ if (sent.length > 0) await this.repository.markPublished(claim, sent)
78
+ return claim.messages.length
79
+ }
80
+
81
+ /** Drain until an empty claim or the batch limit; return the total claimed count. */
82
+ async drain(): Promise<number> {
83
+ let total = 0
84
+ for (let batch = 0; batch < this.options.maxBatches; batch += 1) {
85
+ // oxlint-disable-next-line no-await-in-loop -- An empty claim must stop the drain before another claim starts.
86
+ const claimed = await this.flush()
87
+ total += claimed
88
+ if (claimed === 0) break
89
+ }
90
+ return total
91
+ }
92
+
93
+ private async handleSendFailure(
94
+ claim: OutboxClaim,
95
+ pending: ReadonlyArray<OutboxMessage>,
96
+ cause: unknown,
97
+ ): Promise<void> {
98
+ const classification = classifyFailure(cause)
99
+ const message =
100
+ cause instanceof Error ? cause.message : 'Publisher rejected with a non-error value'
101
+ if (classification === 'terminal') {
102
+ await this.deadLetter(claim, pending, 'terminal', message, cause)
103
+ return
104
+ }
105
+ const exhausted = pending.filter((row) => row.attempts >= this.options.maxAttempts)
106
+ await this.deadLetter(claim, exhausted, 'exhausted', message, cause)
107
+ const retry = pending.filter((row) => row.attempts < this.options.maxAttempts)
108
+ if (retry.length > 0) await this.repository.releaseClaim(claim, retry)
109
+ }
110
+
111
+ private async deadLetter(
112
+ claim: OutboxClaim,
113
+ messages: ReadonlyArray<OutboxMessage>,
114
+ reason: DeadLetterReason,
115
+ errorMessage: string,
116
+ cause?: unknown,
117
+ ): Promise<void> {
118
+ if (messages.length === 0) return
119
+ await this.repository.deadLetter(claim, messages, reason, errorMessage)
120
+ logger.error('outbox_messages_dead_lettered', {
121
+ error: cause,
122
+ details: { claimToken: claim.token, count: messages.length, reason },
123
+ })
124
+ }
125
+ }
@@ -0,0 +1,46 @@
1
+ import type { DomainEvent } from '@/core'
2
+
3
+ /** A claimed event and its durable attempt count. The payload id identifies the outbox row. */
4
+ export type OutboxMessage = {
5
+ readonly payload: DomainEvent
6
+ readonly attempts: number
7
+ }
8
+
9
+ /** One lease shared by the rows returned from a single atomic claim. */
10
+ export type OutboxClaim = {
11
+ readonly token: string
12
+ readonly messages: ReadonlyArray<OutboxMessage>
13
+ }
14
+
15
+ /** Why an event remains in the outbox without further automatic delivery. */
16
+ export type DeadLetterReason = 'oversized' | 'terminal' | 'exhausted'
17
+
18
+ /** Bounded retention operations on committed records, outside the unit of work. */
19
+ export interface IOutboxRetentionRepository {
20
+ /** Delete published events strictly older than the cutoff. */
21
+ deletePublishedBefore(cutoff: Date, batchSize: number): Promise<number>
22
+ /** Delete failed events strictly older than the cutoff. */
23
+ deleteFailedBefore(cutoff: Date, batchSize: number): Promise<number>
24
+ /** Delete duplicate-protection receipts strictly older than the cutoff. */
25
+ deleteReceiptsBefore(cutoff: Date, batchSize: number): Promise<number>
26
+ }
27
+
28
+ /**
29
+ * Claims and settles committed events on the plain database connection. Never retry these calls locally.
30
+ * Each settlement affects only the supplied rows that still hold the claim token.
31
+ */
32
+ export interface IOutboxRepository extends IOutboxRetentionRepository {
33
+ /** Atomically lease due rows and increment their attempts, including claims after an expired lease. */
34
+ claimPending(batchSize: number): Promise<OutboxClaim>
35
+ /** Record queue acceptance and clear the lease. */
36
+ markPublished(claim: OutboxClaim, messages: ReadonlyArray<OutboxMessage>): Promise<void>
37
+ /** Clear the lease and delay each row according to its own attempt count. */
38
+ releaseClaim(claim: OutboxClaim, messages: ReadonlyArray<OutboxMessage>): Promise<void>
39
+ /** Stop automatic delivery while retaining the payload and a readable failure reason. */
40
+ deadLetter(
41
+ claim: OutboxClaim,
42
+ messages: ReadonlyArray<OutboxMessage>,
43
+ reason: DeadLetterReason,
44
+ errorMessage: string,
45
+ ): Promise<void>
46
+ }
@@ -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
+ }
@@ -0,0 +1,37 @@
1
+ import { z } from 'zod'
2
+
3
+ /** An event name and its application-owned properties, ported from Typist's analytics/types.ts. */
4
+ export interface AnalyticsEventInput<
5
+ TName extends string = string,
6
+ TProperties extends object = object,
7
+ > {
8
+ readonly name: TName
9
+ readonly properties: TProperties
10
+ }
11
+
12
+ /** A value supported by analytics person properties. */
13
+ export type AnalyticsProperty =
14
+ | string
15
+ | number
16
+ | boolean
17
+ | null
18
+ | undefined
19
+ | readonly AnalyticsProperty[]
20
+ | AnalyticsProperties
21
+
22
+ /** Person properties supplied by the application. */
23
+ export interface AnalyticsProperties {
24
+ readonly [name: string]: AnalyticsProperty
25
+ }
26
+
27
+ /** Parse a persisted analytics event UUID; the SDK generates ordinary event UUIDs itself. */
28
+ export const AnalyticsEventIdSchema = z.uuid().brand<'AnalyticsEventId'>()
29
+
30
+ /** The identity of one analytics event across repeated application deliveries. */
31
+ export type AnalyticsEventId = z.infer<typeof AnalyticsEventIdSchema>
32
+
33
+ /** Supply both original values when replaying one event; never share them across different occurrences. */
34
+ export interface AnalyticsEventIdentity {
35
+ readonly uuid: AnalyticsEventId
36
+ readonly timestamp: Date
37
+ }
@@ -21,7 +21,12 @@ export function exponentialEqualJitterMs(
21
21
  return Math.floor(halfCapMs) + spreadJitterMs(halfCapMs)
22
22
  }
23
23
 
24
- function exponentialDelayCapMs(baseDelayMs: number, maxDelayMs: number, attempt: number): number {
24
+ /** Capped exponential delay for a durable retry recorded in storage. */
25
+ export function exponentialDelayCapMs(
26
+ baseDelayMs: number,
27
+ maxDelayMs: number,
28
+ attempt: number,
29
+ ): number {
25
30
  if (!Number.isSafeInteger(attempt) || attempt < 1) {
26
31
  return panic('attempt must be a positive integer')
27
32
  }
package/src/core/index.ts CHANGED
@@ -1,4 +1,13 @@
1
1
  export { AppError } from './app-error.ts'
2
+ export { MissingConfigurationError } from './missing-configuration.error.ts'
3
+ export {
4
+ AnalyticsEventIdSchema,
5
+ type AnalyticsEventId,
6
+ type AnalyticsEventIdentity,
7
+ type AnalyticsEventInput,
8
+ type AnalyticsProperties,
9
+ type AnalyticsProperty,
10
+ } from './analytics-event.ts'
2
11
  export { EmailAddressSchema, type EmailAddress } from './email-address.ts'
3
12
  export {
4
13
  emailIdempotencyKey,
@@ -88,7 +97,7 @@ export {
88
97
  retryAfterSecondsOf,
89
98
  type BoundaryErrorClassifier,
90
99
  } from './classify-failure.ts'
91
- export { exponentialEqualJitterMs, spreadJitterMs } from './backoff.ts'
100
+ export { exponentialDelayCapMs, exponentialEqualJitterMs, spreadJitterMs } from './backoff.ts'
92
101
  export {
93
102
  configureLogger,
94
103
  consoleSink,
@@ -0,0 +1,12 @@
1
+ import { AppError } from './app-error.ts'
2
+
3
+ /** Required configuration is absent. Ported from Porte's configuration.errors.ts. */
4
+ export class MissingConfigurationError extends AppError {
5
+ readonly _tag = 'MissingConfigurationError'
6
+ readonly classification = 'terminal'
7
+
8
+ /** Name the missing setting without including its value. */
9
+ constructor(readonly key: string) {
10
+ super(`${key} is required`)
11
+ }
12
+ }