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.
- package/dist/application-C4JOf71r.js +291 -0
- package/dist/application-C4JOf71r.js.map +1 -0
- package/dist/application.d.ts +3 -3
- package/dist/application.js +2 -2
- package/dist/better-auth/apple-client-secret.d.ts +1 -1
- package/dist/better-auth/apple-client-secret.js +1 -1
- package/dist/better-auth/auth-client.js +1 -1
- package/dist/better-auth/rate-limit.d.ts +1 -1
- package/dist/better-auth/rate-limit.js +1 -1
- package/dist/better-auth.js +1 -1
- package/dist/cloudflare/auth-secondary-storage.d.ts +1 -1
- package/dist/cloudflare/auth-secondary-storage.js +1 -1
- package/dist/cloudflare/queue.d.ts +8 -3
- package/dist/cloudflare/queue.d.ts.map +1 -1
- package/dist/cloudflare/queue.js +26 -16
- package/dist/cloudflare/queue.js.map +1 -1
- package/dist/cloudflare/scheduled.d.ts +1 -1
- package/dist/cloudflare/scheduled.js +1 -1
- package/dist/cloudflare.d.ts +30 -4
- package/dist/cloudflare.d.ts.map +1 -1
- package/dist/cloudflare.js +98 -5
- package/dist/cloudflare.js.map +1 -1
- package/dist/{core-DG_cC8YT.js → core-hdaNiCOU.js} +22 -2
- package/dist/core-hdaNiCOU.js.map +1 -0
- package/dist/core.d.ts +2 -2
- package/dist/core.js +2 -2
- package/dist/http.d.ts +2 -2
- package/dist/http.js +2 -2
- package/dist/{index-DwWxIdQP.d.ts → index-C28HgVfY.d.ts} +145 -3
- package/dist/index-C28HgVfY.d.ts.map +1 -0
- package/dist/{index-Bl0K1RQW.d.ts → index-G1QG0P5g.d.ts} +36 -2
- package/dist/index-G1QG0P5g.d.ts.map +1 -0
- package/dist/outbox-repository-options-BZjfDqGf.d.ts +26 -0
- package/dist/outbox-repository-options-BZjfDqGf.d.ts.map +1 -0
- package/dist/{unit-of-work-context-BPE1In2f.js → outbox-repository-options-DOTpJH-e.js} +12 -3
- package/dist/outbox-repository-options-DOTpJH-e.js.map +1 -0
- package/dist/postgres.d.ts +30 -4
- package/dist/postgres.d.ts.map +1 -1
- package/dist/postgres.js +92 -5
- package/dist/postgres.js.map +1 -1
- package/dist/posthog/client.d.ts +22 -0
- package/dist/posthog/client.d.ts.map +1 -0
- package/dist/posthog/client.js +34 -0
- package/dist/posthog/client.js.map +1 -0
- package/dist/posthog/metrics.d.ts +18 -0
- package/dist/posthog/metrics.d.ts.map +1 -0
- package/dist/posthog/metrics.js +30 -0
- package/dist/posthog/metrics.js.map +1 -0
- package/dist/posthog/proxy.d.ts +17 -0
- package/dist/posthog/proxy.d.ts.map +1 -0
- package/dist/posthog/proxy.js +34 -0
- package/dist/posthog/proxy.js.map +1 -0
- package/dist/posthog/server.d.ts +37 -0
- package/dist/posthog/server.d.ts.map +1 -0
- package/dist/posthog/server.js +81 -0
- package/dist/posthog/server.js.map +1 -0
- package/dist/resend.d.ts +3 -3
- package/dist/resend.js +2 -2
- package/dist/sentry/client.d.ts +22 -0
- package/dist/sentry/client.d.ts.map +1 -0
- package/dist/sentry/client.js +48 -0
- package/dist/sentry/client.js.map +1 -0
- package/dist/sentry/server.d.ts +24 -0
- package/dist/sentry/server.d.ts.map +1 -0
- package/dist/sentry/server.js +48 -0
- package/dist/sentry/server.js.map +1 -0
- package/dist/sentry/vite.d.ts +11 -0
- package/dist/sentry/vite.d.ts.map +1 -0
- package/dist/sentry/vite.js +17 -0
- package/dist/sentry/vite.js.map +1 -0
- package/dist/tanstack.d.ts +1 -1
- package/dist/tanstack.js +2 -2
- package/dist/testing/outbox-repository.d.ts +44 -0
- package/dist/testing/outbox-repository.d.ts.map +1 -0
- package/dist/testing/outbox-repository.js +206 -0
- package/dist/testing/outbox-repository.js.map +1 -0
- package/dist/{webhook-verification-sZtwTI5n.d.ts → webhook-verification-D7DIhH6C.d.ts} +2 -2
- package/dist/{webhook-verification-sZtwTI5n.d.ts.map → webhook-verification-D7DIhH6C.d.ts.map} +1 -1
- package/dist/{webhook-verification-BQ9qZnVK.js → webhook-verification-trEgyz7I.js} +2 -2
- package/dist/{webhook-verification-BQ9qZnVK.js.map → webhook-verification-trEgyz7I.js.map} +1 -1
- package/package.json +42 -1
- package/src/application/analytics-service.ts +21 -0
- package/src/application/index.ts +31 -1
- package/src/application/metrics-service.ts +28 -0
- package/src/application/observability-service.ts +16 -0
- package/src/application/outbox-relay.ts +117 -0
- package/src/application/outbox-repository.ts +46 -0
- package/src/application/outbox-retention.ts +61 -0
- package/src/core/analytics-event.ts +37 -0
- package/src/core/backoff.ts +6 -1
- package/src/core/index.ts +10 -1
- package/src/core/missing-configuration.error.ts +12 -0
- package/src/infrastructure/cloudflare/d1-outbox-repository.ts +224 -0
- package/src/infrastructure/cloudflare/index.ts +2 -0
- package/src/infrastructure/cloudflare/queue/index.ts +1 -0
- package/src/infrastructure/cloudflare/queue/queue-client.ts +6 -2
- package/src/infrastructure/cloudflare/queue/wait-until-wake.ts +19 -0
- package/src/infrastructure/outbox-repository-options.ts +9 -0
- package/src/infrastructure/postgres/index.ts +2 -0
- package/src/infrastructure/postgres/postgres-outbox-repository.ts +216 -0
- package/src/infrastructure/posthog/client.ts +41 -0
- package/src/infrastructure/posthog/metrics.ts +30 -0
- package/src/infrastructure/posthog/proxy.ts +47 -0
- package/src/infrastructure/posthog/server.ts +107 -0
- package/src/infrastructure/sentry/client.ts +55 -0
- package/src/infrastructure/sentry/server.ts +51 -0
- package/src/infrastructure/sentry/vite.ts +17 -0
- package/src/infrastructure/testing/outbox-repository-contract.ts +228 -0
- package/dist/application-CIxo_BpK.js +0 -160
- package/dist/application-CIxo_BpK.js.map +0 -1
- package/dist/core-DG_cC8YT.js.map +0 -1
- package/dist/index-Bl0K1RQW.d.ts.map +0 -1
- package/dist/index-DwWxIdQP.d.ts.map +0 -1
- package/dist/unit-of-work-context-BPE1In2f.js.map +0 -1
- package/dist/unit-of-work-context-D5ytJIa-.d.ts +0 -16
- 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.
|
|
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
|
+
}
|
package/src/application/index.ts
CHANGED
|
@@ -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
|
|
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
|
+
}
|
package/src/core/backoff.ts
CHANGED
|
@@ -21,7 +21,12 @@ export function exponentialEqualJitterMs(
|
|
|
21
21
|
return Math.floor(halfCapMs) + spreadJitterMs(halfCapMs)
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
-
|
|
24
|
+
/** Capped exponential delay for a durable retry recorded in storage. */
|
|
25
|
+
export function exponentialDelayCapMs(
|
|
26
|
+
baseDelayMs: number,
|
|
27
|
+
maxDelayMs: number,
|
|
28
|
+
attempt: number,
|
|
29
|
+
): number {
|
|
25
30
|
if (!Number.isSafeInteger(attempt) || attempt < 1) {
|
|
26
31
|
return panic('attempt must be a positive integer')
|
|
27
32
|
}
|
package/src/core/index.ts
CHANGED
|
@@ -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
|
+
}
|