better-ship 0.17.0 → 0.19.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 (126) hide show
  1. package/dist/{application-Cf9SER-i.js → application-Dg1jIKPe.js} +5 -7
  2. package/dist/{application-Cf9SER-i.js.map → application-Dg1jIKPe.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.js +1 -1
  23. package/dist/cloudflare/kv-errors.d.ts +1 -1
  24. package/dist/cloudflare/kv-errors.js +1 -1
  25. package/dist/cloudflare/queue.d.ts +2 -2
  26. package/dist/cloudflare/queue.d.ts.map +1 -1
  27. package/dist/cloudflare/queue.js +10 -12
  28. package/dist/cloudflare/queue.js.map +1 -1
  29. package/dist/cloudflare/r2-client.d.ts +70 -0
  30. package/dist/cloudflare/r2-client.d.ts.map +1 -0
  31. package/dist/cloudflare/r2-client.js +153 -0
  32. package/dist/cloudflare/r2-client.js.map +1 -0
  33. package/dist/cloudflare/r2-errors.d.ts +34 -0
  34. package/dist/cloudflare/r2-errors.d.ts.map +1 -0
  35. package/dist/cloudflare/r2-errors.js +90 -0
  36. package/dist/cloudflare/r2-errors.js.map +1 -0
  37. package/dist/cloudflare/scheduled.d.ts +1 -1
  38. package/dist/cloudflare/scheduled.js +2 -2
  39. package/dist/cloudflare/scheduled.js.map +1 -1
  40. package/dist/cloudflare.d.ts +3 -3
  41. package/dist/cloudflare.js +3 -3
  42. package/dist/{core-ClSP5-V9.js → core-BT1dSEQI.js} +106 -195
  43. package/dist/core-BT1dSEQI.js.map +1 -0
  44. package/dist/core.d.ts +2 -2
  45. package/dist/core.js +2 -2
  46. package/dist/http.d.ts +16 -8
  47. package/dist/http.d.ts.map +1 -1
  48. package/dist/http.js +51 -12
  49. package/dist/http.js.map +1 -1
  50. package/dist/{index-Cchdg2aQ.d.ts → index-CYHXOH08.d.ts} +2 -2
  51. package/dist/{index-Cchdg2aQ.d.ts.map → index-CYHXOH08.d.ts.map} +1 -1
  52. package/dist/{index-DvJUcSW1.d.ts → index-DUFS0SJ_.d.ts} +41 -102
  53. package/dist/index-DUFS0SJ_.d.ts.map +1 -0
  54. package/dist/{outbox-repository-options-ICOF8g2h.js → outbox-repository-options-B8TnAE1h.js} +2 -2
  55. package/dist/{outbox-repository-options-ICOF8g2h.js.map → outbox-repository-options-B8TnAE1h.js.map} +1 -1
  56. package/dist/{outbox-repository-options-AkihEaik.d.ts → outbox-repository-options-DjBgFhMi.d.ts} +3 -3
  57. package/dist/{outbox-repository-options-AkihEaik.d.ts.map → outbox-repository-options-DjBgFhMi.d.ts.map} +1 -1
  58. package/dist/postgres.d.ts +3 -3
  59. package/dist/postgres.js +3 -3
  60. package/dist/posthog/metrics.d.ts +1 -1
  61. package/dist/posthog/server.d.ts +2 -2
  62. package/dist/posthog/server.js +1 -1
  63. package/dist/resend.d.ts +5 -5
  64. package/dist/resend.d.ts.map +1 -1
  65. package/dist/resend.js +30 -57
  66. package/dist/resend.js.map +1 -1
  67. package/dist/sentry/client.d.ts +1 -1
  68. package/dist/sentry/client.d.ts.map +1 -1
  69. package/dist/sentry/client.js +10 -4
  70. package/dist/sentry/client.js.map +1 -1
  71. package/dist/sentry/server.d.ts +2 -2
  72. package/dist/sentry/server.js +5 -5
  73. package/dist/sentry/server.js.map +1 -1
  74. package/dist/stripe/payment-service.d.ts +52 -0
  75. package/dist/stripe/payment-service.d.ts.map +1 -0
  76. package/dist/stripe/payment-service.error.d.ts +13 -0
  77. package/dist/stripe/payment-service.error.d.ts.map +1 -0
  78. package/dist/stripe/payment-service.error.js +30 -0
  79. package/dist/stripe/payment-service.error.js.map +1 -0
  80. package/dist/stripe/payment-service.js +72 -0
  81. package/dist/stripe/payment-service.js.map +1 -0
  82. package/dist/stripe/payment-webhook.d.ts +18 -0
  83. package/dist/stripe/payment-webhook.d.ts.map +1 -0
  84. package/dist/stripe/payment-webhook.js +35 -0
  85. package/dist/stripe/payment-webhook.js.map +1 -0
  86. package/dist/tanstack.d.ts +1 -1
  87. package/dist/tanstack.js +4 -4
  88. package/dist/tanstack.js.map +1 -1
  89. package/dist/testing/outbox-repository.d.ts +2 -2
  90. package/dist/testing/payment-service.d.ts +10 -0
  91. package/dist/testing/payment-service.d.ts.map +1 -0
  92. package/dist/testing/payment-service.js +76 -0
  93. package/dist/testing/payment-service.js.map +1 -0
  94. package/dist/webhook-verification-D1c7BY4q.d.ts +21 -0
  95. package/dist/webhook-verification-D1c7BY4q.d.ts.map +1 -0
  96. package/dist/{webhook-verification-D6aGQfNi.js → webhook-verification-qSFXv5H3.js} +2 -2
  97. package/dist/webhook-verification-qSFXv5H3.js.map +1 -0
  98. package/package.json +13 -6
  99. package/src/application/outbox-relay.ts +3 -1
  100. package/src/core/console-log-sink.ts +61 -0
  101. package/src/core/index.ts +1 -4
  102. package/src/core/logger.ts +80 -281
  103. package/src/infrastructure/better-auth/apple-client-secret.ts +1 -1
  104. package/src/infrastructure/better-auth/rate-limit.ts +3 -1
  105. package/src/infrastructure/billing/client.ts +173 -0
  106. package/src/infrastructure/cloudflare/queue/queue-consumer.ts +8 -9
  107. package/src/infrastructure/cloudflare/r2-client.ts +162 -0
  108. package/src/infrastructure/cloudflare/r2-errors.ts +111 -0
  109. package/src/infrastructure/cloudflare/scheduled/scheduled-handler.ts +1 -1
  110. package/src/infrastructure/http/index.ts +2 -6
  111. package/src/infrastructure/http/webhook-handler.ts +69 -12
  112. package/src/infrastructure/http/webhook-verification.ts +6 -9
  113. package/src/infrastructure/resend/email-event.ts +14 -30
  114. package/src/infrastructure/resend/resend-webhook.ts +12 -22
  115. package/src/infrastructure/sentry/client.ts +6 -3
  116. package/src/infrastructure/sentry/server.ts +4 -4
  117. package/src/infrastructure/stripe/payment-service.error.ts +42 -0
  118. package/src/infrastructure/stripe/payment-service.ts +129 -0
  119. package/src/infrastructure/stripe/payment-webhook.ts +36 -0
  120. package/src/infrastructure/tanstack/resolve-failure.ts +3 -2
  121. package/src/infrastructure/testing/payment-service-contract.ts +91 -0
  122. package/dist/core-ClSP5-V9.js.map +0 -1
  123. package/dist/index-DvJUcSW1.d.ts.map +0 -1
  124. package/dist/webhook-verification-BcQQG-vY.d.ts +0 -29
  125. package/dist/webhook-verification-BcQQG-vY.d.ts.map +0 -1
  126. package/dist/webhook-verification-D6aGQfNi.js.map +0 -1
@@ -1,28 +1,19 @@
1
- /**
2
- * Module-scoped logging for every host.
3
- *
4
- * A logger emits one plain-data entry per log. The default sink hands it to `console` as an
5
- * entry object for a log service or as a colored line for a person, selected at startup.
6
- */
1
+ import { consoleSink, setConsoleLogFormat } from './console-log-sink.ts'
7
2
 
8
- /** A value a log entry may carry. Errors enter only through `Logger.error`. */
3
+ /** One JSON-compatible value in a normal log field. */
9
4
  export type LogValue =
10
5
  | string
11
6
  | number
12
7
  | boolean
13
8
  | null
14
- | undefined
15
- | ReadonlyArray<LogValue>
16
- | LogFields
9
+ | readonly LogValue[]
10
+ | { readonly [key: string]: LogValue }
17
11
 
18
- /** Named log values. */
19
- export type LogFields = { readonly [key: string]: LogValue }
12
+ /** Flat fields for one log call. Only `error` can contain an arbitrary value. */
13
+ // oxlint-disable-next-line anti-slop/no-unsafe-dictionary-type -- A logger accepts caller-owned fields at its public boundary.
14
+ export type LogFields = Readonly<Record<string, unknown>>
20
15
 
21
- /**
22
- * The supported log severity levels, in ascending order.
23
- *
24
- * A const object, not an enum, so a parsed environment value typed `'INFO'` is a `LogLevel`.
25
- */
16
+ /** Supported log levels in ascending order. */
26
17
  export const LogLevel = {
27
18
  DEBUG: 'DEBUG',
28
19
  INFO: 'INFO',
@@ -31,303 +22,111 @@ export const LogLevel = {
31
22
  } as const
32
23
  export type LogLevel = (typeof LogLevel)[keyof typeof LogLevel]
33
24
 
34
- /** An error as plain data, including diagnostics, causes, and aggregate members. */
35
- export type SerializedError = {
36
- readonly name: string
37
- readonly message: string
38
- readonly stack?: string
39
- readonly fields?: LogFields
40
- readonly cause?: SerializedError
41
- readonly errors?: ReadonlyArray<SerializedError>
25
+ /** The minimum emitted level, or `OFF` for no console output. */
26
+ export type LogThreshold = LogLevel | 'OFF'
27
+
28
+ /** Explicit output formats for production, browser development, and terminal development. */
29
+ export type LogFormat = 'json' | 'pretty-browser' | 'pretty-terminal'
30
+
31
+ /** Process-wide logger settings supplied at startup. */
32
+ export interface LoggerSettings {
33
+ readonly level?: LogThreshold
34
+ readonly format?: LogFormat
42
35
  }
43
36
 
44
- /** One log, as the sink receives it. Plain data, safe to stringify. */
37
+ /** One structured record sent to a log sink. */
45
38
  export type LogEntry = {
46
39
  readonly timestamp: string
47
40
  readonly level: LogLevel
48
41
  readonly module: string
49
- readonly message: string
50
- readonly details: LogFields
42
+ readonly event: string
43
+ // oxlint-disable-next-line anti-slop/no-unsafe-dictionary-type -- The entry keeps caller-owned fields for its sink.
44
+ readonly attributes: LogFields
51
45
  readonly userId?: string
52
- /** Present on `Logger.error` entries that carried an error. */
53
- readonly error?: SerializedError
54
- }
55
-
56
- /** The context an error log carries. */
57
- export type ErrorLogContext = {
58
46
  readonly error?: unknown
59
- readonly userId?: string
60
- readonly details?: LogFields
61
- }
62
-
63
- /** What a logger lets through: a level and everything above it, or nothing. */
64
- export type LogThreshold = LogLevel | 'OFF'
65
-
66
- /** Overrides the application settings for one logger. */
67
- export interface LoggerConfig {
68
- readonly level?: LogThreshold
69
- }
70
-
71
- /** `json` writes the entry object. `pretty` writes one readable line for a person. */
72
- export type LogFormat = 'json' | 'pretty'
73
-
74
- /**
75
- * The application settings, set once at the composition root.
76
- *
77
- * An omitted field keeps its default. An explicit `undefined` is a compile error, so the root
78
- * parses an environment value before passing it. The logger reads no environment itself.
79
- */
80
- export interface LoggerSettings extends LoggerConfig {
81
- readonly format?: LogFormat
82
- }
83
-
84
- // The production server setting, so a root that never configures still logs safely.
85
- const DEFAULT_SETTINGS: Required<LoggerSettings> = {
86
- level: LogLevel.INFO,
87
- format: 'json',
88
- }
89
-
90
- // The application configures once at startup; loggers can exist before startup completes.
91
- let settings = DEFAULT_SETTINGS
92
-
93
- /**
94
- * Set application settings, including for existing loggers. Per-logger overrides take priority.
95
- * Call at startup, never per request. An omitted field resets to INFO and JSON output.
96
- */
97
- export function configureLogger(config: LoggerSettings): void {
98
- settings = {
99
- level: config.level ?? DEFAULT_SETTINGS.level,
100
- format: config.format ?? DEFAULT_SETTINGS.format,
101
- }
102
47
  }
103
48
 
104
- /** The error data sent to the configured error hook. */
49
+ /** The original error and fields sent to an error service. */
105
50
  export interface ErrorCaptureEntry {
106
51
  readonly error: unknown
107
- readonly distinctId: string | undefined
108
- readonly context: LogFields
52
+ readonly userId: string | undefined
53
+ // oxlint-disable-next-line anti-slop/no-unsafe-dictionary-type -- Sentry accepts the caller-owned fields as extra context.
54
+ readonly attributes: LogFields
109
55
  }
110
56
 
111
- /** A function that forwards one logged error to an error service. */
57
+ /** A function that forwards one error to an error service. */
112
58
  export type LoggerErrorHook = (entry: ErrorCaptureEntry) => void
113
59
 
114
- /** Where every log entry goes. */
60
+ /** A destination for structured log records. */
115
61
  export type LogSink = (entry: LogEntry) => void
116
62
 
117
- let errorHook: LoggerErrorHook | null = null
118
-
119
- /**
120
- * Register the function that receives each logged error.
121
- *
122
- * Set once at the process entry point. A later call replaces the earlier hook.
123
- */
124
- export function setLoggerErrorHook(fn: LoggerErrorHook): void {
125
- errorHook = fn
63
+ /** The common flat logger API for all levels. */
64
+ export interface Logger {
65
+ debug(event: string, fields?: LogFields): void
66
+ info(event: string, fields?: LogFields): void
67
+ warn(event: string, fields?: LogFields): void
68
+ error(event: string, fields?: LogFields): void
126
69
  }
127
70
 
128
- // One console method per level, so a host that filters by method can tell them apart.
129
- const CONSOLE_METHOD = {
130
- [LogLevel.DEBUG]: 'debug',
131
- [LogLevel.INFO]: 'info',
132
- [LogLevel.WARN]: 'warn',
133
- [LogLevel.ERROR]: 'error',
134
- } as const satisfies Record<LogLevel, 'debug' | 'info' | 'warn' | 'error'>
135
-
136
- // Built from the code point so no raw control character sits in the source.
137
- const ESC = String.fromCharCode(27)
138
- const ANSI_RESET = `${ESC}[0m`
139
- const ANSI_GRAY = `${ESC}[90m`
140
- const ANSI_BLUE = `${ESC}[34m`
141
-
142
- // Severity reads at a glance: quiet levels stay dim, and an error is the only bold line.
143
- const LEVEL_ANSI = {
144
- DEBUG: `${ESC}[2;36m`,
145
- INFO: ANSI_BLUE,
146
- WARN: `${ESC}[33m`,
147
- ERROR: `${ESC}[1;31m`,
148
- } satisfies Record<LogLevel, string>
71
+ const LEVELS = Object.values(LogLevel)
72
+ let threshold: LogThreshold = LogLevel.INFO
73
+ let logSink: LogSink = consoleSink
74
+ let errorHook: LoggerErrorHook | null = null
149
75
 
150
- function prettyLine(entry: LogEntry): string {
151
- // Node prints debug and info exactly like log, so the level must be in the text too.
152
- const level = `[${entry.level}]`
153
- const module = `[${entry.module}]`
154
- // A browser console colors by method on its own; a terminal needs ANSI to do the same.
155
- if ('window' in globalThis) return `${entry.timestamp} ${level} ${module} ${entry.message}`
156
- const timestamp = `${ANSI_GRAY}${entry.timestamp}${ANSI_RESET}`
157
- const coloredLevel = `${LEVEL_ANSI[entry.level]}${level}${ANSI_RESET}`
158
- const coloredModule = `${ANSI_BLUE}${module}${ANSI_RESET}`
159
- return `${timestamp} ${coloredLevel} ${coloredModule} ${entry.message}`
76
+ /** Set the process-wide level and output format. Omitted fields restore production defaults. */
77
+ export function configureLogger(settings: LoggerSettings): void {
78
+ threshold = settings.level ?? LogLevel.INFO
79
+ setConsoleLogFormat(settings.format ?? 'json')
160
80
  }
161
81
 
162
- /**
163
- * JSON format writes the entry object. Pretty format writes one line, then the details,
164
- * the error, and the stack as separate console arguments.
165
- */
166
- export const consoleSink: LogSink = (entry) => {
167
- // Resolved per call, so a console replaced after import still receives the output.
168
- const write = console[CONSOLE_METHOD[entry.level]]
169
- if (settings.format === 'json') {
170
- write(entry)
171
- return
172
- }
173
- const line = prettyLine(entry)
174
- const fields =
175
- entry.userId === undefined ? entry.details : { ...entry.details, userId: entry.userId }
176
- const details = Object.keys(fields).length === 0 ? [] : [fields]
177
- if (entry.error === undefined) {
178
- write(line, ...details)
179
- return
180
- }
181
- // A stack inside an object prints as one quoted string; as its own argument it prints as lines.
182
- const { stack, ...error } = entry.error
183
- if (stack === undefined) write(line, ...details, error)
184
- else write(line, ...details, error, `\n${stack}`)
82
+ /** Set the process-wide log destination. */
83
+ export function setLogSink(sink: LogSink): void {
84
+ logSink = sink
185
85
  }
186
86
 
187
- let logSink: LogSink = consoleSink
188
-
189
- /**
190
- * Send every log entry somewhere other than `console`.
191
- *
192
- * Set once at the process entry point, never per module. A logger is created
193
- * by name and nothing else, so its destination is a fact about the process.
194
- */
195
- export function setLogSink(fn: LogSink): void {
196
- logSink = fn
87
+ /** Set or clear the process-wide error service hook. */
88
+ export function setLoggerErrorHook(hook: LoggerErrorHook | null): void {
89
+ errorHook = hook
197
90
  }
198
91
 
199
- const LOG_LEVELS = Object.values(LogLevel)
200
- const MAX_ERROR_DEPTH = 3
201
-
202
- /**
203
- * Diagnostics worth keeping off an error, named one by one.
204
- *
205
- * An allowlist rather than every own property: an error raised by a library we
206
- * do not control may hang a request or a user payload off itself, and a log is
207
- * the wrong place to discover that. `_tag` and `classification` are what every `AppError`
208
- * declares. `remote` and the three flags after it are the ones workerd sets itself.
209
- */
210
- const KEPT_ERROR_FIELDS = [
211
- '_tag',
212
- 'classification',
213
- 'code',
214
- 'operation',
215
- 'status',
216
- 'statusCode',
217
- 'remote',
218
- 'retryable',
219
- 'overloaded',
220
- 'durableObjectReset',
221
- ] as const
222
-
223
- type ErrorDiagnostics = Error & { readonly [K in (typeof KEPT_ERROR_FIELDS)[number]]?: LogValue }
224
-
225
- type MutableSerializedError = { -readonly [K in keyof SerializedError]: SerializedError[K] }
226
-
227
- const serializeError = (error: Error, depth = 0): SerializedError => {
228
- if (depth > MAX_ERROR_DEPTH) return { name: 'Error', message: '[error nesting truncated]' }
229
-
230
- // Every field is optional, so an `Error` is already one of these.
231
- const carrier: ErrorDiagnostics = error
232
- const kept = KEPT_ERROR_FIELDS.filter((key) => carrier[key] !== undefined)
233
- const serialized: MutableSerializedError = { name: error.name, message: error.message }
234
- if (error.stack !== undefined) serialized.stack = error.stack
235
- if (kept.length > 0)
236
- serialized.fields = Object.fromEntries(kept.map((key) => [key, carrier[key]]))
237
- if (error.cause instanceof Error) serialized.cause = serializeError(error.cause, depth + 1)
238
- else if (error.cause !== undefined)
239
- serialized.cause = { name: 'Error', message: '[non-error cause]' }
240
- if (error instanceof AggregateError) {
241
- // oxlint-disable-next-line anti-slop/no-unknown-parameters -- AggregateError accepts arbitrary values; instanceof Error validates each member.
242
- serialized.errors = error.errors.map((failure: unknown) =>
243
- failure instanceof Error
244
- ? serializeError(failure, depth + 1)
245
- : { name: 'NonError', message: '[non-error aggregate member]' },
246
- )
247
- }
248
- return serialized
92
+ /* oxlint-disable anti-slop/no-runtime-typeof -- The logger validates its reserved userId field. */
93
+ function splitFields(fields: LogFields): ErrorCaptureEntry {
94
+ const { error, userId, ...attributes } = fields
95
+ return { error, userId: typeof userId === 'string' ? userId : undefined, attributes }
249
96
  }
250
-
251
- /** Write module-scoped logs with application defaults and optional overrides. */
252
- export class Logger {
253
- private readonly config: LoggerConfig
254
-
255
- /** Create a logger for one module. */
256
- constructor(
257
- private readonly module: string,
258
- overrideConfig?: LoggerConfig,
259
- ) {
260
- this.config = { ...overrideConfig }
261
- }
262
-
263
- private log(
264
- level: LogLevel,
265
- message: string,
266
- details: LogFields,
267
- error?: SerializedError,
268
- userId?: string,
269
- ): void {
270
- const threshold = this.config.level ?? settings.level
271
- if (threshold === 'OFF') return
272
- if (LOG_LEVELS.indexOf(level) < LOG_LEVELS.indexOf(threshold)) return
273
-
274
- let entry: LogEntry = {
275
- timestamp: new Date().toISOString(),
276
- level,
277
- module: this.module,
278
- message,
279
- details,
280
- }
281
- if (userId !== undefined) entry = { ...entry, userId }
282
- logSink(error === undefined ? entry : { ...entry, error })
283
- }
284
-
285
- /** Write a debug log. */
286
- debug(message: string, details: LogFields = {}): void {
287
- this.log(LogLevel.DEBUG, message, details)
288
- }
289
-
290
- /** Write an information log. */
291
- info(message: string, details: LogFields = {}): void {
292
- this.log(LogLevel.INFO, message, details)
293
- }
294
-
295
- /** Write a warning log. */
296
- warn(message: string, details: LogFields = {}): void {
297
- this.log(LogLevel.WARN, message, details)
97
+ /* oxlint-enable anti-slop/no-runtime-typeof */
98
+
99
+ function writeLog(module: string, level: LogLevel, event: string, fields: LogFields): void {
100
+ if (threshold === 'OFF' || LEVELS.indexOf(level) < LEVELS.indexOf(threshold)) return
101
+
102
+ const { error, userId, attributes } = splitFields(fields)
103
+ let entry: LogEntry = { timestamp: new Date().toISOString(), level, module, event, attributes }
104
+ if (userId !== undefined) entry = { ...entry, userId }
105
+ if (error !== undefined) entry = { ...entry, error }
106
+ try {
107
+ logSink(entry)
108
+ } catch {
109
+ // A diagnostic failure must not change application control flow.
298
110
  }
111
+ }
299
112
 
300
- /**
301
- * Write an error log and send the error to the configured hook.
302
- *
303
- * The details say which call this was, and the error says what went wrong
304
- * inside it. Both the entry and the error hook receive the supplied user ID.
305
- */
306
- error(message: string, context: ErrorLogContext = {}): void {
307
- const details = context.details ?? {}
308
- const thrown = context.error
309
- let error: SerializedError | undefined
310
- if (thrown instanceof Error) {
311
- error = serializeError(thrown)
312
- } else if (thrown !== undefined) {
313
- // Not an `Error`, so there is no name or stack to keep, only its JSON form.
314
- let json: string
113
+ /** Create a logger for one module. */
114
+ export function createLogger(module: string): Logger {
115
+ return {
116
+ debug: (event, fields = {}) => writeLog(module, LogLevel.DEBUG, event, fields),
117
+ info: (event, fields = {}) => writeLog(module, LogLevel.INFO, event, fields),
118
+ warn: (event, fields = {}) => writeLog(module, LogLevel.WARN, event, fields),
119
+ error: (event, fields = {}) => {
120
+ writeLog(module, LogLevel.ERROR, event, fields)
121
+ const capture = splitFields(fields)
122
+ if (errorHook === null || capture.error === undefined) return
315
123
  try {
316
- json = JSON.stringify(thrown)
124
+ errorHook(capture)
317
125
  } catch {
318
- json = '[non-serializable error]'
126
+ // An error service failure must not change application control flow.
319
127
  }
320
- error = { name: 'NonError', message: json }
321
- }
322
- this.log(LogLevel.ERROR, message, details, error, context.userId)
323
-
324
- if (errorHook !== null && thrown !== undefined) {
325
- errorHook({ error: thrown, distinctId: context.userId, context: details })
326
- }
128
+ },
327
129
  }
328
130
  }
329
131
 
330
- /** Create a logger for one module. */
331
- export function createLogger(module: string, config?: LoggerConfig): Logger {
332
- return new Logger(module, config)
333
- }
132
+ export { consoleSink }
@@ -56,7 +56,7 @@ export function generateAppleClientSecret(config: AppleKeyConfig): string {
56
56
  } catch (error) {
57
57
  if (!appleKeyFailureLogged) {
58
58
  appleKeyFailureLogged = true
59
- logger.error('apple_private_key_invalid', { error, details: { keyId: config.appleKeyId } })
59
+ logger.error('apple_private_key_invalid', { error, keyId: config.appleKeyId })
60
60
  }
61
61
  return ''
62
62
  }
@@ -103,7 +103,9 @@ export function createAuthRateLimitStorage(
103
103
  if (failureMode === 'block') throw new AuthRateLimitError(cause)
104
104
  logger.error('rate_limit_binding_failed', {
105
105
  error: cause,
106
- details: { window: rule.window, max: rule.max, failureMode },
106
+ window: rule.window,
107
+ max: rule.max,
108
+ failureMode,
107
109
  })
108
110
  return { allowed: true, retryAfter: null }
109
111
  }
@@ -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
+ }
@@ -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
  }