better-ship 0.16.0 → 0.18.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 (129) hide show
  1. package/dist/{application-Cf9SER-i.js → application-CNdzHIXk.js} +5 -7
  2. package/dist/{application-Cf9SER-i.js.map → application-CNdzHIXk.js.map} +1 -1
  3. package/dist/application.d.ts +2 -2
  4. package/dist/application.js +1 -1
  5. package/dist/better-auth/apple-client-secret.d.ts +1 -1
  6. package/dist/better-auth/apple-client-secret.js +2 -2
  7. package/dist/better-auth/apple-client-secret.js.map +1 -1
  8. package/dist/better-auth/auth-client.js +1 -1
  9. package/dist/better-auth/rate-limit.d.ts +1 -1
  10. package/dist/better-auth/rate-limit.js +4 -6
  11. package/dist/better-auth/rate-limit.js.map +1 -1
  12. package/dist/better-auth.js +1 -1
  13. package/dist/billing/client.d.ts +86 -0
  14. package/dist/billing/client.d.ts.map +1 -0
  15. package/dist/billing/client.js +77 -0
  16. package/dist/billing/client.js.map +1 -0
  17. package/dist/cloudflare/auth-secondary-storage.d.ts +1 -1
  18. package/dist/cloudflare/auth-secondary-storage.js +1 -1
  19. package/dist/cloudflare/durable-object-client.js +1 -1
  20. package/dist/cloudflare/durable-object-errors.d.ts +1 -1
  21. package/dist/cloudflare/durable-object-errors.js +1 -1
  22. package/dist/cloudflare/kv-client.d.ts +31 -0
  23. package/dist/cloudflare/kv-client.d.ts.map +1 -0
  24. package/dist/cloudflare/kv-client.js +62 -0
  25. package/dist/cloudflare/kv-client.js.map +1 -0
  26. package/dist/cloudflare/kv-errors.d.ts +52 -0
  27. package/dist/cloudflare/kv-errors.d.ts.map +1 -0
  28. package/dist/cloudflare/kv-errors.js +144 -0
  29. package/dist/cloudflare/kv-errors.js.map +1 -0
  30. package/dist/cloudflare/queue.d.ts +2 -2
  31. package/dist/cloudflare/queue.d.ts.map +1 -1
  32. package/dist/cloudflare/queue.js +10 -12
  33. package/dist/cloudflare/queue.js.map +1 -1
  34. package/dist/cloudflare/r2-client.d.ts +70 -0
  35. package/dist/cloudflare/r2-client.d.ts.map +1 -0
  36. package/dist/cloudflare/r2-client.js +153 -0
  37. package/dist/cloudflare/r2-client.js.map +1 -0
  38. package/dist/cloudflare/r2-errors.d.ts +34 -0
  39. package/dist/cloudflare/r2-errors.d.ts.map +1 -0
  40. package/dist/cloudflare/r2-errors.js +90 -0
  41. package/dist/cloudflare/r2-errors.js.map +1 -0
  42. package/dist/cloudflare/scheduled.d.ts +1 -1
  43. package/dist/cloudflare/scheduled.js +2 -2
  44. package/dist/cloudflare/scheduled.js.map +1 -1
  45. package/dist/cloudflare.d.ts +10 -5
  46. package/dist/cloudflare.d.ts.map +1 -1
  47. package/dist/cloudflare.js +21 -10
  48. package/dist/cloudflare.js.map +1 -1
  49. package/dist/{core-ClSP5-V9.js → core-UUW2unJd.js} +106 -195
  50. package/dist/core-UUW2unJd.js.map +1 -0
  51. package/dist/core.d.ts +2 -2
  52. package/dist/core.js +2 -2
  53. package/dist/http.d.ts +2 -2
  54. package/dist/http.js +3 -3
  55. package/dist/http.js.map +1 -1
  56. package/dist/{index-Cchdg2aQ.d.ts → index-CYHXOH08.d.ts} +2 -2
  57. package/dist/{index-Cchdg2aQ.d.ts.map → index-CYHXOH08.d.ts.map} +1 -1
  58. package/dist/{index-DvJUcSW1.d.ts → index-DUFS0SJ_.d.ts} +41 -102
  59. package/dist/index-DUFS0SJ_.d.ts.map +1 -0
  60. package/dist/{outbox-repository-options-ICOF8g2h.js → outbox-repository-options-CgdqU20T.js} +2 -2
  61. package/dist/{outbox-repository-options-ICOF8g2h.js.map → outbox-repository-options-CgdqU20T.js.map} +1 -1
  62. package/dist/{outbox-repository-options-AkihEaik.d.ts → outbox-repository-options-DjBgFhMi.d.ts} +3 -3
  63. package/dist/{outbox-repository-options-AkihEaik.d.ts.map → outbox-repository-options-DjBgFhMi.d.ts.map} +1 -1
  64. package/dist/postgres.d.ts +10 -5
  65. package/dist/postgres.d.ts.map +1 -1
  66. package/dist/postgres.js +21 -10
  67. package/dist/postgres.js.map +1 -1
  68. package/dist/posthog/metrics.d.ts +1 -1
  69. package/dist/posthog/server.d.ts +2 -2
  70. package/dist/posthog/server.js +1 -1
  71. package/dist/resend.d.ts +3 -3
  72. package/dist/resend.js +2 -2
  73. package/dist/sentry/client.d.ts +1 -1
  74. package/dist/sentry/client.d.ts.map +1 -1
  75. package/dist/sentry/client.js +10 -4
  76. package/dist/sentry/client.js.map +1 -1
  77. package/dist/sentry/server.d.ts +2 -2
  78. package/dist/sentry/server.js +5 -5
  79. package/dist/sentry/server.js.map +1 -1
  80. package/dist/stripe/payment-service.d.ts +52 -0
  81. package/dist/stripe/payment-service.d.ts.map +1 -0
  82. package/dist/stripe/payment-service.error.d.ts +13 -0
  83. package/dist/stripe/payment-service.error.d.ts.map +1 -0
  84. package/dist/stripe/payment-service.error.js +30 -0
  85. package/dist/stripe/payment-service.error.js.map +1 -0
  86. package/dist/stripe/payment-service.js +72 -0
  87. package/dist/stripe/payment-service.js.map +1 -0
  88. package/dist/stripe/payment-webhook.d.ts +18 -0
  89. package/dist/stripe/payment-webhook.d.ts.map +1 -0
  90. package/dist/stripe/payment-webhook.js +47 -0
  91. package/dist/stripe/payment-webhook.js.map +1 -0
  92. package/dist/tanstack.d.ts +1 -1
  93. package/dist/tanstack.js +4 -4
  94. package/dist/tanstack.js.map +1 -1
  95. package/dist/testing/outbox-repository.d.ts +2 -2
  96. package/dist/testing/payment-service.d.ts +10 -0
  97. package/dist/testing/payment-service.d.ts.map +1 -0
  98. package/dist/testing/payment-service.js +76 -0
  99. package/dist/testing/payment-service.js.map +1 -0
  100. package/dist/{webhook-verification-D6aGQfNi.js → webhook-verification-BBmjkHgI.js} +2 -2
  101. package/dist/{webhook-verification-D6aGQfNi.js.map → webhook-verification-BBmjkHgI.js.map} +1 -1
  102. package/dist/{webhook-verification-BcQQG-vY.d.ts → webhook-verification-BuxPzdr4.d.ts} +2 -2
  103. package/dist/{webhook-verification-BcQQG-vY.d.ts.map → webhook-verification-BuxPzdr4.d.ts.map} +1 -1
  104. package/package.json +15 -6
  105. package/src/application/outbox-relay.ts +3 -1
  106. package/src/core/console-log-sink.ts +65 -0
  107. package/src/core/index.ts +1 -4
  108. package/src/core/logger.ts +80 -281
  109. package/src/infrastructure/better-auth/apple-client-secret.ts +1 -1
  110. package/src/infrastructure/better-auth/rate-limit.ts +3 -1
  111. package/src/infrastructure/billing/client.ts +173 -0
  112. package/src/infrastructure/cloudflare/d1-database-execution.ts +27 -6
  113. package/src/infrastructure/cloudflare/kv-client.ts +79 -0
  114. package/src/infrastructure/cloudflare/kv-errors.ts +157 -0
  115. package/src/infrastructure/cloudflare/queue/queue-consumer.ts +8 -9
  116. package/src/infrastructure/cloudflare/r2-client.ts +162 -0
  117. package/src/infrastructure/cloudflare/r2-errors.ts +111 -0
  118. package/src/infrastructure/cloudflare/scheduled/scheduled-handler.ts +1 -1
  119. package/src/infrastructure/http/webhook-handler.ts +1 -1
  120. package/src/infrastructure/postgres/postgres-database-execution.ts +27 -6
  121. package/src/infrastructure/sentry/client.ts +6 -3
  122. package/src/infrastructure/sentry/server.ts +4 -4
  123. package/src/infrastructure/stripe/payment-service.error.ts +42 -0
  124. package/src/infrastructure/stripe/payment-service.ts +129 -0
  125. package/src/infrastructure/stripe/payment-webhook.ts +49 -0
  126. package/src/infrastructure/tanstack/resolve-failure.ts +3 -2
  127. package/src/infrastructure/testing/payment-service-contract.ts +91 -0
  128. package/dist/core-ClSP5-V9.js.map +0 -1
  129. package/dist/index-DvJUcSW1.d.ts.map +0 -1
@@ -0,0 +1,173 @@
1
+ import type { stripeClient } from '@better-auth/stripe/client'
2
+ import type { createAuthClient } from 'better-auth/client'
3
+
4
+ import { AppError, type FailureClassification } from '@/core'
5
+
6
+ type AuthClient = ReturnType<
7
+ typeof createAuthClient<{
8
+ plugins: [ReturnType<typeof stripeClient<{ subscription: true }>>]
9
+ }>
10
+ >
11
+
12
+ /** Native Better Auth subscription operations used by the browser billing service. */
13
+ export type SubscriptionBillingClient = Pick<
14
+ AuthClient['subscription'],
15
+ 'upgrade' | 'billingPortal'
16
+ >
17
+
18
+ /** Native upgrade input. Navigation remains with the UI. */
19
+ export type ChangePlanInput = Omit<
20
+ NonNullable<Parameters<SubscriptionBillingClient['upgrade']>[0]>,
21
+ 'disableRedirect'
22
+ >
23
+
24
+ /** Native portal input. Navigation remains with the UI. */
25
+ export type BillingPortalInput = Omit<
26
+ NonNullable<Parameters<SubscriptionBillingClient['billingPortal']>[0]>,
27
+ 'disableRedirect'
28
+ >
29
+
30
+ /** Native successful subscription response, including portal or Checkout data. */
31
+ export type ChangePlanResult = NonNullable<
32
+ Awaited<ReturnType<SubscriptionBillingClient['upgrade']>>['data']
33
+ >
34
+
35
+ /** Native successful portal response. */
36
+ export type BillingPortalResult = NonNullable<
37
+ Awaited<ReturnType<SubscriptionBillingClient['billingPortal']>>['data']
38
+ >
39
+
40
+ /** Native Better Auth billing failure; its code remains available to application UI. */
41
+ export type BillingApiError = NonNullable<
42
+ Awaited<ReturnType<SubscriptionBillingClient['upgrade']>>['error']
43
+ >
44
+
45
+ /** A Better Auth billing endpoint rejected the request. The browser boundary owns reporting. */
46
+ export class BillingRequestError extends AppError {
47
+ readonly _tag = 'BillingRequestError'
48
+ readonly classification: FailureClassification
49
+ readonly code: BillingApiError['code']
50
+
51
+ /** Preserve the native code and cause without copying provider metadata. */
52
+ constructor(
53
+ readonly operation: 'subscription.upgrade' | 'subscription.billingPortal',
54
+ cause: BillingApiError,
55
+ ) {
56
+ super('Billing request failed', { cause })
57
+ this.code = cause.code
58
+ this.classification = cause.status === 429 || cause.status >= 500 ? 'transient' : 'terminal'
59
+ }
60
+ }
61
+
62
+ // oxlint-disable-next-line anti-slop/no-unknown-returns -- Generic constraint only; service methods retain each callback's exact return type.
63
+ type BillingCallback = (this: void, ...args: never[]) => Promise<unknown>
64
+
65
+ /**
66
+ * Supply only the capabilities the application uses. API callbacks must not depend on `this`.
67
+ * Their parameters, results, abort signals, and typed rejections remain application-owned.
68
+ */
69
+ export type BillingOptions = {
70
+ readonly subscriptions?: SubscriptionBillingClient
71
+ readonly createTopUp?: BillingCallback
72
+ readonly getAttempt?: BillingCallback
73
+ readonly configureAutomaticTopUp?: BillingCallback
74
+ readonly setupPaymentMethod?: BillingCallback
75
+ }
76
+
77
+ type ConfiguredMethod<
78
+ Options extends BillingOptions,
79
+ Key extends PropertyKey,
80
+ > = Options extends BillingOptions ? (Key extends keyof Options ? Options[Key] : undefined) : never
81
+ type SubscriptionMethod<Options extends BillingOptions, Method> = Options extends {
82
+ subscriptions: SubscriptionBillingClient
83
+ }
84
+ ? Method
85
+ : 'subscriptions' extends keyof Options
86
+ ? Method | undefined
87
+ : undefined
88
+ type ChangePlan = (input: ChangePlanInput) => Promise<ChangePlanResult>
89
+ type CreateBillingPortal = (input: BillingPortalInput) => Promise<BillingPortalResult>
90
+
91
+ /** One browser billing API; unconfigured operations are undefined and cannot be called. */
92
+ export interface IBillingService<Options extends BillingOptions> {
93
+ /** Start or change a subscription; return native data without redirecting. */
94
+ readonly changePlan: SubscriptionMethod<Options, ChangePlan>
95
+ /** Create portal access; return native data without redirecting. */
96
+ readonly createBillingPortal: SubscriptionMethod<Options, CreateBillingPortal>
97
+ /** Call the application's top-up API with its exact input and output types. */
98
+ readonly createTopUp: ConfiguredMethod<Options, 'createTopUp'>
99
+ /** Read the application's persisted attempt state. */
100
+ readonly getAttempt: ConfiguredMethod<Options, 'getAttempt'>
101
+ /** Persist automatic top-up settings through the application's API. */
102
+ readonly configureAutomaticTopUp: ConfiguredMethod<Options, 'configureAutomaticTopUp'>
103
+ /** Start payment method setup through the application's API. */
104
+ readonly setupPaymentMethod: ConfiguredMethod<Options, 'setupPaymentMethod'>
105
+ }
106
+
107
+ /**
108
+ * Ported from Typist's CheckoutService: one browser API for Better Auth and application payments.
109
+ * Unwraps Better Auth failures; preserves application callbacks, cancellations, and existing errors.
110
+ * Does not parse payloads, retry requests, log, navigate, or credit balances.
111
+ */
112
+ export class BillingService<
113
+ const Options extends BillingOptions,
114
+ > implements IBillingService<Options> {
115
+ /** Configured Better Auth upgrade operation. */
116
+ readonly changePlan: SubscriptionMethod<Options, ChangePlan>
117
+ /** Configured Better Auth portal operation. */
118
+ readonly createBillingPortal: SubscriptionMethod<Options, CreateBillingPortal>
119
+ /** Exact application checkout callback, or undefined. */
120
+ readonly createTopUp: ConfiguredMethod<Options, 'createTopUp'>
121
+ /** Exact application attempt callback, or undefined. */
122
+ readonly getAttempt: ConfiguredMethod<Options, 'getAttempt'>
123
+ /** Exact application settings callback, or undefined. */
124
+ readonly configureAutomaticTopUp: ConfiguredMethod<Options, 'configureAutomaticTopUp'>
125
+ /** Exact application setup callback, or undefined. */
126
+ readonly setupPaymentMethod: ConfiguredMethod<Options, 'setupPaymentMethod'>
127
+
128
+ /** Inject configured Better Auth methods and standalone application API callbacks. */
129
+ constructor(options: Options) {
130
+ const subscriptions = options.subscriptions
131
+ const changePlan: ChangePlan | undefined =
132
+ subscriptions &&
133
+ (async (input) => {
134
+ const result = await subscriptions.upgrade({
135
+ ...input,
136
+ disableRedirect: true,
137
+ fetchOptions: { ...input.fetchOptions, throw: false },
138
+ })
139
+ if (result.error) throw new BillingRequestError('subscription.upgrade', result.error)
140
+ return result.data
141
+ })
142
+ const createBillingPortal: CreateBillingPortal | undefined =
143
+ subscriptions &&
144
+ (async (input) => {
145
+ const result = await subscriptions.billingPortal({
146
+ ...input,
147
+ disableRedirect: true,
148
+ fetchOptions: { ...input.fetchOptions, throw: false },
149
+ })
150
+ if (result.error) throw new BillingRequestError('subscription.billingPortal', result.error)
151
+ return result.data
152
+ })
153
+ // SAFETY: Each subscription method exists exactly when the required subscriptions dependency exists.
154
+ this.changePlan = changePlan as SubscriptionMethod<Options, ChangePlan>
155
+ // SAFETY: Each subscription method exists exactly when the required subscriptions dependency exists.
156
+ this.createBillingPortal = createBillingPortal as SubscriptionMethod<
157
+ Options,
158
+ CreateBillingPortal
159
+ >
160
+ this.createTopUp = configuredMethod(options, 'createTopUp')
161
+ this.getAttempt = configuredMethod(options, 'getAttempt')
162
+ this.configureAutomaticTopUp = configuredMethod(options, 'configureAutomaticTopUp')
163
+ this.setupPaymentMethod = configuredMethod(options, 'setupPaymentMethod')
164
+ }
165
+ }
166
+
167
+ function configuredMethod<Options extends BillingOptions, Key extends keyof BillingOptions>(
168
+ options: Options,
169
+ key: Key,
170
+ ): ConfiguredMethod<Options, Key> {
171
+ // SAFETY: Optional callbacks are returned unchanged; absent keys read as undefined.
172
+ return options[key] as ConfiguredMethod<Options, Key>
173
+ }
@@ -1,13 +1,34 @@
1
+ import { Result } from 'better-result'
2
+
3
+ import { DatabaseError } from '@/core'
4
+
1
5
  import { classifyD1Error } from './classify-d1-error.ts'
2
6
 
3
- /** Execute once without retries; convert recognized D1 failures and preserve other exceptions unchanged. */
7
+ /**
8
+ * Convert database failures and preserve other exceptions. The default executes once.
9
+ * Opt-in transient retries allow three attempts with full jitter and backoff caps of 250 ms and 500 ms.
10
+ */
4
11
  export async function executeDatabase<T>(
5
12
  operation: string,
6
13
  work: () => PromiseLike<T>,
14
+ options: { readonly retry?: 'never' | 'transient' } = {},
7
15
  ): Promise<T> {
8
- try {
9
- return await work()
10
- } catch (cause) {
11
- throw classifyD1Error(cause, operation) ?? cause
12
- }
16
+ const result = await Result.tryPromise(
17
+ {
18
+ try: async () => await work(),
19
+ catch: (cause) => classifyD1Error(cause, operation) ?? cause,
20
+ },
21
+ {
22
+ retry: {
23
+ times: options.retry === 'transient' ? 2 : 0,
24
+ delayMs: 250,
25
+ backoff: 'exponential',
26
+ jitter: true,
27
+ shouldRetry: (error) =>
28
+ error instanceof DatabaseError && error.classification === 'transient',
29
+ },
30
+ },
31
+ )
32
+ if (result.isErr()) throw result.error
33
+ return result.value
13
34
  }
@@ -0,0 +1,79 @@
1
+ import { Result } from 'better-result'
2
+
3
+ import { exponentialDelayCapMs, spreadJitterMs } from '@/core'
4
+
5
+ import { decideKVRetry, isKVPutRateLimitError, KVError } from './kv-errors.ts'
6
+
7
+ /** Text storage bridge with the same TTL behavior as KVClient. */
8
+ export type KVSecondaryStorage = {
9
+ get: (key: string) => Promise<string | null>
10
+ set: (key: string, value: string, ttl?: number) => Promise<void>
11
+ delete: (key: string) => Promise<void>
12
+ }
13
+
14
+ type KVOperation = 'get' | 'put' | 'delete'
15
+
16
+ /** KV access with Typist's automatic transient retries and final KVError conversion. */
17
+ export class KVClient {
18
+ constructor(private readonly kv: KVNamespace) {}
19
+
20
+ /** Read text, or return null when the key does not exist. */
21
+ async get(key: string): Promise<string | null> {
22
+ return await this.run('get', key, async () => this.kv.get(key))
23
+ }
24
+
25
+ /** Read JSON without schema validation; the caller supplies its stored type. */
26
+ async getJson<T>(key: string): Promise<T | null> {
27
+ return await this.run('get', key, async () => this.kv.get<T>(key, 'json'))
28
+ }
29
+
30
+ /** Write text; positive TTL values pass to KV in seconds, and other TTL values omit expiration. */
31
+ async put(key: string, value: string, options?: { ttl?: number | undefined }): Promise<void> {
32
+ const kvOptions: KVNamespacePutOptions = {}
33
+ if (options?.ttl !== undefined && options.ttl > 0) {
34
+ kvOptions.expirationTtl = options.ttl
35
+ }
36
+ await this.run('put', key, async () => this.kv.put(key, value, kvOptions))
37
+ }
38
+
39
+ /** Serialize once before writing; serialization failures retain their original error. */
40
+ async putJson(
41
+ key: string,
42
+ // oxlint-disable-next-line anti-slop/no-unknown-parameters -- Preserve the approved JSON serialization boundary and Typist's public signature.
43
+ value: unknown,
44
+ options?: { ttl?: number | undefined },
45
+ ): Promise<void> {
46
+ await this.put(key, JSON.stringify(value), options)
47
+ }
48
+
49
+ /** Delete a key; a missing key succeeds, and transient failures retry automatically. */
50
+ async delete(key: string): Promise<void> {
51
+ await this.run('delete', key, async () => this.kv.delete(key))
52
+ }
53
+
54
+ /** Expose Typist's three-method secondary storage bridge without changing KV semantics. */
55
+ asSecondaryStorage(): KVSecondaryStorage {
56
+ return {
57
+ get: (key) => this.get(key),
58
+ set: (key, value, ttl) => this.put(key, value, { ttl }),
59
+ delete: (key) => this.delete(key),
60
+ }
61
+ }
62
+
63
+ private async run<T>(operation: KVOperation, key: string, work: () => Promise<T>): Promise<T> {
64
+ const result = await Result.tryPromise(
65
+ { try: work, catch: (cause) => cause },
66
+ {
67
+ retry: {
68
+ times: 2,
69
+ shouldRetry: (cause) => decideKVRetry(cause).retry,
70
+ delayMs: (cause, { attempt }) =>
71
+ (isKVPutRateLimitError(cause) ? 1_000 : 0) +
72
+ spreadJitterMs(exponentialDelayCapMs(25, 3_000, attempt)),
73
+ },
74
+ },
75
+ )
76
+ if (result.isOk()) return result.value
77
+ throw new KVError({ operation, key, cause: result.error })
78
+ }
79
+ }
@@ -0,0 +1,157 @@
1
+ import {
2
+ AppError,
3
+ isCloudflareOverloadedError,
4
+ isCloudflareRetryableError,
5
+ isTransientTransportError,
6
+ type FailureClassification,
7
+ } from '@/core'
8
+
9
+ /** KV operations recognized in binding error messages. */
10
+ export type KVOperation = 'get' | 'put' | 'delete' | 'list'
11
+
12
+ /** KV failure kinds retained from Typist's binding tests. */
13
+ export type KVErrorKind =
14
+ | 'invalid_key'
15
+ | 'key_too_large'
16
+ | 'invalid_expiration_ttl'
17
+ | 'invalid_expiration'
18
+ | 'invalid_cache_ttl'
19
+ | 'invalid_list_limit'
20
+ | 'value_too_large'
21
+ | 'client_error'
22
+ | 'rate_limited'
23
+ | 'server_error'
24
+
25
+ /** Evidence extracted from a KV error and its causes. */
26
+ export interface KVErrorMeta {
27
+ kind: KVErrorKind
28
+ message: string
29
+ status?: number | undefined
30
+ operation?: Uppercase<KVOperation> | undefined
31
+ }
32
+
33
+ /** Extract Typist's verified KV message formats, including nested causes. */
34
+ export function extractKVMeta(cause: unknown): KVErrorMeta | undefined {
35
+ const message = getDeepErrorMessage(cause)
36
+ if (!message) return undefined
37
+ const statusMatch = message.match(/\bKV\s+(GET|PUT|DELETE|LIST)\s+failed:\s+(\d{3})\b/)
38
+ // SAFETY: The regular expression permits only uppercase KV operation names in this capture.
39
+ const operation = statusMatch?.[1] as Uppercase<KVOperation> | undefined
40
+ const status = statusMatch ? Number(statusMatch[2]) : undefined
41
+
42
+ if (/Key name cannot be empty/i.test(message)) {
43
+ return { kind: 'invalid_key', message, status, operation }
44
+ }
45
+ if (/exceeds key length limit/i.test(message)) {
46
+ return { kind: 'key_too_large', message, status, operation }
47
+ }
48
+ if (/Invalid expiration_ttl/i.test(message)) {
49
+ return { kind: 'invalid_expiration_ttl', message, status, operation }
50
+ }
51
+ if (/Invalid expiration of/i.test(message)) {
52
+ return { kind: 'invalid_expiration', message, status, operation }
53
+ }
54
+ if (/Invalid cache_ttl/i.test(message)) {
55
+ return { kind: 'invalid_cache_ttl', message, status, operation }
56
+ }
57
+ if (/Invalid key_count_limit/i.test(message)) {
58
+ return { kind: 'invalid_list_limit', message, status, operation }
59
+ }
60
+ if (/value.*(too large|exceeds)|exceeds.*value/i.test(message)) {
61
+ return { kind: 'value_too_large', message, status, operation }
62
+ }
63
+ if (status === 429) return { kind: 'rate_limited', message, status, operation }
64
+ if (status !== undefined && status >= 500) {
65
+ return { kind: 'server_error', message, status, operation }
66
+ }
67
+ if (status !== undefined && status >= 400) {
68
+ return { kind: 'client_error', message, status, operation }
69
+ }
70
+ return undefined
71
+ }
72
+
73
+ /** Identify recognized KV failures that another attempt cannot resolve. */
74
+ export function isTerminalKVError(cause: unknown): boolean {
75
+ const meta = extractKVMeta(cause)
76
+ if (!meta) return false
77
+ if (meta.kind === 'rate_limited' || meta.kind === 'server_error') return false
78
+ if (meta.status === 408) return false
79
+ return true
80
+ }
81
+
82
+ /** Identify a PUT rate limit that requires at least one second before retrying. */
83
+ export function isKVPutRateLimitError(cause: unknown): boolean {
84
+ const meta = extractKVMeta(cause)
85
+ return meta?.operation === 'PUT' && meta.kind === 'rate_limited'
86
+ }
87
+
88
+ /** Identify PUT rate limits, server failures, and request timeouts. */
89
+ export function isTransientKVError(cause: unknown): boolean {
90
+ const meta = extractKVMeta(cause)
91
+ if (!meta) return false
92
+ return isKVPutRateLimitError(cause) || meta.kind === 'server_error' || meta.status === 408
93
+ }
94
+
95
+ /** Apply Typist's KV, overload, platform, and transport checks in their original order. */
96
+ export function isRetryableKVError(cause: unknown): boolean {
97
+ if (isTerminalKVError(cause)) return false
98
+ if (isTransientKVError(cause)) return true
99
+ if (isCloudflareOverloadedError(cause)) return false
100
+ return isCloudflareRetryableError(cause) || isTransientTransportError(cause)
101
+ }
102
+
103
+ /** Return the existing retry decision and minimum delay in milliseconds. */
104
+ export function decideKVRetry(
105
+ cause: unknown,
106
+ ): { retry: false } | { retry: true; retryAfterMs: number } {
107
+ if (!isRetryableKVError(cause)) return { retry: false }
108
+ return { retry: true, retryAfterMs: isKVPutRateLimitError(cause) ? 1_000 : 0 }
109
+ }
110
+
111
+ /** A final KV failure with Typist's diagnostics and Better Ship's error classification. */
112
+ export class KVError extends AppError {
113
+ readonly _tag = 'KVError'
114
+ readonly classification: FailureClassification
115
+ retryable: boolean
116
+ readonly operation: KVOperation
117
+ readonly key?: string | undefined
118
+ override readonly cause: unknown
119
+ readonly kv?: KVErrorMeta | undefined
120
+ readonly context: {
121
+ operation: KVOperation
122
+ key?: string | undefined
123
+ kv?: KVErrorMeta | undefined
124
+ }
125
+
126
+ constructor(args: { operation: KVOperation; key?: string | undefined; cause: unknown }) {
127
+ const kv = extractKVMeta(args.cause)
128
+ const causeMessage = getDeepErrorMessage(args.cause) || String(args.cause)
129
+ const keyContext = args.key === undefined ? '' : ` for key "${args.key}"`
130
+ super(`KV ${args.operation} failed${keyContext}: ${causeMessage}`, { cause: args.cause })
131
+ this.operation = args.operation
132
+ this.key = args.key
133
+ this.cause = args.cause
134
+ this.kv = kv
135
+ this.context = { operation: args.operation, key: args.key, kv }
136
+ this.retryable = isRetryableKVError(args.cause)
137
+ this.classification = isTerminalKVError(args.cause)
138
+ ? 'terminal'
139
+ : this.retryable
140
+ ? 'transient'
141
+ : 'unknown'
142
+ }
143
+ }
144
+
145
+ // oxlint-disable anti-slop/no-runtime-typeof -- Preserve Typist's primitive messages and nested causes at the raw binding boundary.
146
+ function getDeepErrorMessage(cause: unknown): string {
147
+ if (typeof cause === 'string') return cause
148
+ if (cause === null || cause === undefined) return ''
149
+ if (typeof cause === 'number' || typeof cause === 'boolean' || typeof cause === 'bigint') {
150
+ return String(cause)
151
+ }
152
+ if (typeof cause !== 'object') return ''
153
+ const message = 'message' in cause && typeof cause.message === 'string' ? cause.message : ''
154
+ const causeMessage = 'cause' in cause ? getDeepErrorMessage(cause.cause) : ''
155
+ return `${message} ${causeMessage}`.trim()
156
+ }
157
+ // oxlint-enable anti-slop/no-runtime-typeof
@@ -65,7 +65,8 @@ export class QueueConsumer<TMessage extends QueueMessage> {
65
65
  if (body === null) {
66
66
  // An unparseable body cannot succeed on retry, so park it for inspection.
67
67
  logger.error('invalid_message_format', {
68
- details: { attempt: message.attempts, queueMessageId: message.id },
68
+ attempt: message.attempts,
69
+ queueMessageId: message.id,
69
70
  })
70
71
  await this.parkPoison(message)
71
72
  return
@@ -76,7 +77,7 @@ export class QueueConsumer<TMessage extends QueueMessage> {
76
77
  await this.bus.handle(body)
77
78
  })
78
79
  message.ack()
79
- logger.debug(`queue_message_processed:${body.name}`, { name: body.name })
80
+ logger.debug('queue_message_processed', { name: body.name })
80
81
  } catch (cause) {
81
82
  this.handleFailure(message, parsed, cause)
82
83
  }
@@ -117,12 +118,10 @@ export class QueueConsumer<TMessage extends QueueMessage> {
117
118
  if (decision.action === 'ack') {
118
119
  logger.error('message_discarded', {
119
120
  error: cause,
120
- details: {
121
- name: parsed?.name,
122
- messageId: parsed?.id,
123
- queueMessageId: message.id,
124
- attempt: message.attempts,
125
- },
121
+ name: parsed?.name,
122
+ messageId: parsed?.id,
123
+ queueMessageId: message.id,
124
+ attempt: message.attempts,
126
125
  })
127
126
  message.ack()
128
127
  return
@@ -137,7 +136,7 @@ export class QueueConsumer<TMessage extends QueueMessage> {
137
136
  delaySeconds: decision.delaySeconds,
138
137
  }
139
138
  if (decision.classification === 'unknown' && message.attempts === 1) {
140
- logger.error('queue_unknown_failure', { error: cause, details: context })
139
+ logger.error('queue_unknown_failure', { error: cause, ...context })
141
140
  } else {
142
141
  logger.debug('redelivery_requested', context)
143
142
  }
@@ -0,0 +1,162 @@
1
+ import { AppError, isAbortError } from '@/core'
2
+
3
+ import { R2Error, R2JsonError, type R2Operation } from './r2-errors.ts'
4
+
5
+ /** Values accepted by the R2 binding. */
6
+ export type R2Value = Parameters<R2Bucket['put']>[1]
7
+
8
+ /** Missing objects, failed conditions, and returned bodies are distinct outcomes. */
9
+ export type R2GetResult =
10
+ | { status: 'missing' }
11
+ | { status: 'precondition-failed'; object: R2Object }
12
+ | { status: 'found'; object: R2ObjectBody }
13
+
14
+ /** A failed condition does not establish whether an earlier attempt committed. */
15
+ export type R2PutResult = { status: 'precondition-failed' } | { status: 'stored'; object: R2Object }
16
+
17
+ /** Untrusted JSON data, with absence separate from a stored null. The application supplies its schema. */
18
+ export type R2JsonResult = { status: 'missing' } | { status: 'found'; value: unknown }
19
+
20
+ /**
21
+ * R2 access with one attempt per operation and typed failures. Inject the bucket at the composition root.
22
+ * Callers own retries, stream replay, and recovery. The client does not log or buffer streamed uploads.
23
+ */
24
+ export class R2Client {
25
+ constructor(private readonly bucket: R2Bucket) {}
26
+
27
+ /** Read native metadata, or return null when the object does not exist. */
28
+ head(key: string): Promise<R2Object | null> {
29
+ return callR2('head', () => this.bucket.head(key))
30
+ }
31
+
32
+ /** Return a native body without consuming it. The consumer owns later body-read failures. */
33
+ async get(key: string, options?: R2GetOptions): Promise<R2GetResult> {
34
+ const object = await callR2('get', () => this.bucket.get(key, options))
35
+ if (object === null) return { status: 'missing' }
36
+ if (!('body' in object)) return { status: 'precondition-failed', object }
37
+ return { status: 'found', object }
38
+ }
39
+
40
+ /** Buffer one stored JSON body. Read failures throw R2Error; invalid JSON throws R2JsonError. */
41
+ async getJson(key: string): Promise<R2JsonResult> {
42
+ const text = await callR2('getJson', async () => {
43
+ const object = await this.bucket.get(key)
44
+ return object === null ? null : await object.text()
45
+ })
46
+ if (text === null) return { status: 'missing' }
47
+ try {
48
+ return { status: 'found', value: JSON.parse(text) }
49
+ } catch (cause) {
50
+ throw new R2JsonError({ operation: 'decode', cause })
51
+ }
52
+ }
53
+
54
+ /** Write once, including conditional writes. Pass streams directly to the binding. */
55
+ async put(key: string, body: R2Value, options?: R2PutOptions): Promise<R2PutResult> {
56
+ const object = await callR2('put', () => this.bucket.put(key, body, options))
57
+ return object === null ? { status: 'precondition-failed' } : { status: 'stored', object }
58
+ }
59
+
60
+ /** Serialize once and default the content type to application/json without changing caller options. */
61
+ async putJson(
62
+ key: string,
63
+ // oxlint-disable-next-line anti-slop/no-unknown-parameters -- This serialization boundary accepts any value and rejects values without a JSON representation.
64
+ value: unknown,
65
+ options?: R2PutOptions,
66
+ ): Promise<R2PutResult> {
67
+ let text: string | undefined
68
+ try {
69
+ text = JSON.stringify(value)
70
+ } catch (cause) {
71
+ throw new R2JsonError({ operation: 'encode', cause })
72
+ }
73
+ if (text === undefined) throw new R2JsonError({ operation: 'encode', cause: undefined })
74
+ return this.put(key, text, { ...options, httpMetadata: jsonMetadata(options?.httpMetadata) })
75
+ }
76
+
77
+ /** Delete keys in one binding call. Missing keys succeed; R2 enforces its batch limit. */
78
+ delete(keys: string | string[]): Promise<void> {
79
+ return callR2('delete', () => this.bucket.delete(keys))
80
+ }
81
+
82
+ /** Return one native page with the requested metadata, cursor, and delimiter results. */
83
+ list(options?: R2ListOptions): Promise<R2Objects> {
84
+ return callR2('list', () => this.bucket.list(options))
85
+ }
86
+
87
+ /** Create once. A failed response can leave an upload whose ID the caller never received. */
88
+ async createMultipartUpload(
89
+ key: string,
90
+ options?: R2MultipartOptions,
91
+ ): Promise<R2MultipartClient> {
92
+ const upload = await callR2('createMultipartUpload', () =>
93
+ this.bucket.createMultipartUpload(key, options),
94
+ )
95
+ return new R2MultipartClient(upload)
96
+ }
97
+
98
+ /** Construct a handle without I/O. Each subsequent operation checks whether the upload exists. */
99
+ resumeMultipartUpload(key: string, uploadId: string): R2MultipartClient {
100
+ return new R2MultipartClient(this.bucket.resumeMultipartUpload(key, uploadId))
101
+ }
102
+ }
103
+
104
+ /** An upload handle that binds its key and ID once. Every operation makes one attempt. */
105
+ export class R2MultipartClient {
106
+ constructor(private readonly upload: R2MultipartUpload) {}
107
+
108
+ /** The destination object key. */
109
+ get key(): string {
110
+ return this.upload.key
111
+ }
112
+
113
+ /** The provider ID to persist when the application needs to resume this upload. */
114
+ get uploadId(): string {
115
+ return this.upload.uploadId
116
+ }
117
+
118
+ /** Upload one part without buffering or replaying its body. */
119
+ uploadPart(
120
+ partNumber: number,
121
+ body: Exclude<R2Value, null>,
122
+ options?: R2UploadPartOptions,
123
+ ): Promise<R2UploadedPart> {
124
+ return callR2('uploadPart', () => this.upload.uploadPart(partNumber, body, options))
125
+ }
126
+
127
+ /** Sort a copy of the parts and return native metadata after completion. Callers own uncertain outcomes. */
128
+ complete(parts: ReadonlyArray<R2UploadedPart>): Promise<R2Object> {
129
+ return callR2('complete', () =>
130
+ this.upload.complete([...parts].sort((a, b) => a.partNumber - b.partNumber)),
131
+ )
132
+ }
133
+
134
+ /** Ensure no active upload remains. NoSuchUpload succeeds; a completed object remains intact. */
135
+ async abort(): Promise<void> {
136
+ try {
137
+ await callR2('abort', () => this.upload.abort())
138
+ } catch (cause) {
139
+ if (cause instanceof R2Error && cause.operation === 'abort' && cause.r2?.code === 10024)
140
+ return
141
+ throw cause
142
+ }
143
+ }
144
+ }
145
+
146
+ async function callR2<T>(operation: R2Operation, work: () => Promise<T>): Promise<T> {
147
+ try {
148
+ return await work()
149
+ } catch (cause) {
150
+ if (isAbortError(cause) || cause instanceof AppError) throw cause
151
+ throw new R2Error({ operation, cause })
152
+ }
153
+ }
154
+
155
+ function jsonMetadata(metadata: R2HTTPMetadata | Headers | undefined): R2HTTPMetadata | Headers {
156
+ if (metadata instanceof Headers) {
157
+ const headers = new Headers(metadata)
158
+ if (!headers.has('content-type')) headers.set('content-type', 'application/json')
159
+ return headers
160
+ }
161
+ return { ...metadata, contentType: metadata?.contentType ?? 'application/json' }
162
+ }