better-ship 0.7.0 → 0.10.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 (65) hide show
  1. package/dist/{application-DjsQtpgw.js → application-DYfcAy-Q.js} +2 -2
  2. package/dist/{application-DjsQtpgw.js.map → application-DYfcAy-Q.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 +11 -4
  6. package/dist/better-auth/apple-client-secret.d.ts.map +1 -1
  7. package/dist/better-auth/apple-client-secret.js +39 -11
  8. package/dist/better-auth/apple-client-secret.js.map +1 -1
  9. package/dist/better-auth/auth-client.js +1 -1
  10. package/dist/better-auth/rate-limit.d.ts +21 -2
  11. package/dist/better-auth/rate-limit.d.ts.map +1 -1
  12. package/dist/better-auth/rate-limit.js +38 -2
  13. package/dist/better-auth/rate-limit.js.map +1 -1
  14. package/dist/better-auth.js +1 -1
  15. package/dist/cloudflare/auth-secondary-storage.d.ts +1 -1
  16. package/dist/cloudflare/auth-secondary-storage.js +1 -1
  17. package/dist/cloudflare/queue.d.ts +175 -0
  18. package/dist/cloudflare/queue.d.ts.map +1 -0
  19. package/dist/cloudflare/queue.js +419 -0
  20. package/dist/cloudflare/queue.js.map +1 -0
  21. package/dist/cloudflare/scheduled.d.ts +36 -0
  22. package/dist/cloudflare/scheduled.d.ts.map +1 -0
  23. package/dist/cloudflare/scheduled.js +37 -0
  24. package/dist/cloudflare/scheduled.js.map +1 -0
  25. package/dist/cloudflare.d.ts +3 -3
  26. package/dist/cloudflare.js +3 -3
  27. package/dist/{core-Cx5SeyKV.js → core-DC_4pOrI.js} +92 -2
  28. package/dist/core-DC_4pOrI.js.map +1 -0
  29. package/dist/core.d.ts +2 -2
  30. package/dist/core.js +2 -2
  31. package/dist/{index-BStnggQO.d.ts → index-6XMPJu7u.d.ts} +49 -2
  32. package/dist/index-6XMPJu7u.d.ts.map +1 -0
  33. package/dist/{index-DWIRrJMP.d.ts → index-_Cihfq1J.d.ts} +2 -2
  34. package/dist/{index-DWIRrJMP.d.ts.map → index-_Cihfq1J.d.ts.map} +1 -1
  35. package/dist/postgres.d.ts +3 -3
  36. package/dist/postgres.js +3 -3
  37. package/dist/tanstack.d.ts +18 -7
  38. package/dist/tanstack.d.ts.map +1 -1
  39. package/dist/tanstack.js +3 -3
  40. package/dist/tanstack.js.map +1 -1
  41. package/dist/{unit-of-work-context-DToDpAkv.js → unit-of-work-context-BFCmU92Z.js} +2 -2
  42. package/dist/{unit-of-work-context-DToDpAkv.js.map → unit-of-work-context-BFCmU92Z.js.map} +1 -1
  43. package/dist/{unit-of-work-context-CPz9rdSS.d.ts → unit-of-work-context-BWTJfYPW.d.ts} +3 -3
  44. package/dist/{unit-of-work-context-CPz9rdSS.d.ts.map → unit-of-work-context-BWTJfYPW.d.ts.map} +1 -1
  45. package/package.json +3 -6
  46. package/src/core/backoff.ts +29 -0
  47. package/src/core/classify-failure.ts +60 -0
  48. package/src/core/cloudflare-classification.ts +45 -0
  49. package/src/core/index.ts +14 -0
  50. package/src/infrastructure/better-auth/apple-client-secret.ts +41 -14
  51. package/src/infrastructure/better-auth/rate-limit.ts +44 -1
  52. package/src/infrastructure/cloudflare/queue/index.ts +25 -0
  53. package/src/infrastructure/cloudflare/queue/queue-client.ts +177 -0
  54. package/src/infrastructure/cloudflare/queue/queue-consumer.ts +146 -0
  55. package/src/infrastructure/cloudflare/queue/queue-limits.ts +54 -0
  56. package/src/infrastructure/cloudflare/queue/queue-message.ts +4 -0
  57. package/src/infrastructure/cloudflare/queue/queue.errors.ts +105 -0
  58. package/src/infrastructure/cloudflare/queue/redelivery-policy.ts +64 -0
  59. package/src/infrastructure/cloudflare/scheduled/index.ts +8 -0
  60. package/src/infrastructure/cloudflare/scheduled/scheduled-handler.ts +62 -0
  61. package/src/infrastructure/tanstack/auth.middleware.ts +5 -3
  62. package/src/infrastructure/tanstack/error.middleware.ts +6 -7
  63. package/src/infrastructure/tanstack/request-context.ts +11 -0
  64. package/dist/core-Cx5SeyKV.js.map +0 -1
  65. package/dist/index-BStnggQO.d.ts.map +0 -1
@@ -0,0 +1,146 @@
1
+ import type { z } from 'zod'
2
+
3
+ import { MessageAlreadyProcessedError } from '@/application'
4
+ import { createLogger } from '@/core'
5
+
6
+ import type { IQueueClient } from './queue-client.ts'
7
+ import type { QueueMessage } from './queue-message.ts'
8
+ import { DeadLetterUnavailableError } from './queue.errors.ts'
9
+ import { decideRedelivery } from './redelivery-policy.ts'
10
+
11
+ const logger = createLogger('queue-consumer')
12
+
13
+ /** Wraps one dispatch: an error-tracking scope, an analytics context. The default runs it as is. */
14
+ export type DispatchWrapper<TMessage extends QueueMessage> = (
15
+ message: TMessage,
16
+ attempt: number,
17
+ run: () => Promise<void>,
18
+ ) => Promise<void>
19
+
20
+ /** The one call the consumer makes on the bus. */
21
+ export type DispatchBus<TMessage extends QueueMessage> = {
22
+ // oxlint-disable-next-line anti-slop/no-unknown-returns -- the bus resolves to each handler's own value; the consumer discards it.
23
+ handle(message: TMessage): Promise<unknown>
24
+ }
25
+
26
+ /**
27
+ * Parses and dispatches each queue message, then selects acknowledgement or retry.
28
+ * Cloudflare owns the retry limit and sends exhausted messages to the configured dead letter.
29
+ * The consumer logs each failure once; handlers do not.
30
+ */
31
+ export class QueueConsumer<TMessage extends QueueMessage> {
32
+ /**
33
+ * @param bus - Where a parsed message goes.
34
+ * @param queueClient - Parks an unparseable body in the dead letter.
35
+ * @param schema - What a body may be: the application's `messages.queueSchema`.
36
+ * @param wrapDispatch - Wraps one dispatch in the application's context. Default: run it as is.
37
+ */
38
+ constructor(
39
+ private readonly bus: DispatchBus<TMessage>,
40
+ private readonly queueClient: Pick<IQueueClient<TMessage>, 'sendToDlq'>,
41
+ private readonly schema: z.ZodType<TMessage>,
42
+ private readonly wrapDispatch: DispatchWrapper<TMessage> = (_message, _attempt, run) => run(),
43
+ ) {}
44
+
45
+ /** Every message settles on its own. A settlement that throws rejects the batch after its siblings settle. */
46
+ async processBatch(batch: MessageBatch): Promise<void> {
47
+ logger.debug('queue_batch_received', { count: batch.messages.length })
48
+ const results = await Promise.allSettled(
49
+ batch.messages.map((message) => this.processMessage(message)),
50
+ )
51
+ const rejected = results
52
+ .filter((result) => result.status === 'rejected')
53
+ .map((result) => result.reason)
54
+
55
+ if (rejected.length > 0) {
56
+ throw new AggregateError(rejected, 'Queue message settlement failed')
57
+ }
58
+ }
59
+
60
+ private async processMessage(message: Message): Promise<void> {
61
+ let parsed: TMessage | null = null
62
+
63
+ try {
64
+ const body = await this.parseMessage(message)
65
+ if (body === null) {
66
+ // An unparseable body cannot succeed on retry, so park it for inspection.
67
+ logger.error('invalid_message_format', {
68
+ details: { attempt: message.attempts, queueMessageId: message.id },
69
+ })
70
+ await this.parkPoison(message)
71
+ return
72
+ }
73
+ parsed = body
74
+
75
+ await this.wrapDispatch(body, message.attempts, async () => {
76
+ await this.bus.handle(body)
77
+ })
78
+ message.ack()
79
+ logger.debug(`queue_message_processed:${body.name}`, { name: body.name })
80
+ } catch (cause) {
81
+ this.handleFailure(message, parsed, cause)
82
+ }
83
+ }
84
+
85
+ /** Returns null on failure; the caller owns the single log point. */
86
+ private async parseMessage(message: Message): Promise<TMessage | null> {
87
+ const result = await this.schema.safeParseAsync(message.body)
88
+ return result.success ? result.data : null
89
+ }
90
+
91
+ /** Park an unparseable message in the dead letter, then acknowledge its source delivery. */
92
+ private async parkPoison(message: Message): Promise<void> {
93
+ try {
94
+ await this.queueClient.sendToDlq(message)
95
+ message.ack()
96
+ } catch (cause) {
97
+ throw new DeadLetterUnavailableError(cause)
98
+ }
99
+ }
100
+
101
+ /**
102
+ * Retry declared transient and unknown failures so Cloudflare can enforce the attempt ceiling.
103
+ * Acknowledge only declared terminal failures.
104
+ */
105
+ private handleFailure(message: Message, parsed: TMessage | null, cause: unknown): void {
106
+ if (cause instanceof MessageAlreadyProcessedError) {
107
+ logger.debug('duplicate_skipped', {
108
+ name: parsed?.name,
109
+ messageId: cause.messageId,
110
+ attempt: message.attempts,
111
+ })
112
+ message.ack()
113
+ return
114
+ }
115
+
116
+ const decision = decideRedelivery(cause, message.attempts)
117
+ if (decision.action === 'ack') {
118
+ logger.error('message_discarded', {
119
+ error: cause,
120
+ details: {
121
+ name: parsed?.name,
122
+ messageId: parsed?.id,
123
+ queueMessageId: message.id,
124
+ attempt: message.attempts,
125
+ },
126
+ })
127
+ message.ack()
128
+ return
129
+ }
130
+
131
+ const context = {
132
+ name: parsed?.name,
133
+ messageId: parsed?.id,
134
+ queueMessageId: message.id,
135
+ attempt: message.attempts,
136
+ classification: decision.classification,
137
+ delaySeconds: decision.delaySeconds,
138
+ }
139
+ if (decision.classification === 'unknown' && message.attempts === 1) {
140
+ logger.error('queue_unknown_failure', { error: cause, details: context })
141
+ } else {
142
+ logger.debug('redelivery_requested', context)
143
+ }
144
+ message.retry({ delaySeconds: decision.delaySeconds })
145
+ }
146
+ }
@@ -0,0 +1,54 @@
1
+ import type { QueueMessage } from './queue-message.ts'
2
+
3
+ /**
4
+ * The Cloudflare Queues producer limits with metadata headroom, checked before each send so a
5
+ * violation never reaches the binding. The dead letter has the same per-message limit.
6
+ * https://developers.cloudflare.com/queues/platform/limits/
7
+ */
8
+ export const QUEUE_LIMITS = {
9
+ messageBytes: 127_000,
10
+ batchBytes: 250_000,
11
+ batchCount: 100,
12
+ } as const
13
+
14
+ /** The size the binding measures: the JSON encoding of the body, in UTF-8 bytes. */
15
+ export function messageByteLength(message: QueueMessage): number {
16
+ return new TextEncoder().encode(JSON.stringify(message)).length
17
+ }
18
+
19
+ /** A message with its measured size. */
20
+ export type SizedMessage<TMessage> = { readonly message: TMessage; readonly bytes: number }
21
+
22
+ /** Measure every message once. */
23
+ export function sizeMessages<TMessage extends QueueMessage>(
24
+ messages: ReadonlyArray<TMessage>,
25
+ ): SizedMessage<TMessage>[] {
26
+ return messages.map((message) => ({ message, bytes: messageByteLength(message) }))
27
+ }
28
+
29
+ /** Whether one message is over the per-message limit. */
30
+ export function isOversized(sized: SizedMessage<QueueMessage>): boolean {
31
+ return sized.bytes > QUEUE_LIMITS.messageBytes
32
+ }
33
+
34
+ /** Split a batch at the message-count and byte limits, keeping the order. */
35
+ export function chunkByLimits<TMessage>(
36
+ sized: ReadonlyArray<SizedMessage<TMessage>>,
37
+ ): TMessage[][] {
38
+ const chunks: TMessage[][] = []
39
+ let current: TMessage[] = []
40
+ let bytes = 0
41
+ for (const item of sized) {
42
+ const full =
43
+ current.length >= QUEUE_LIMITS.batchCount || bytes + item.bytes > QUEUE_LIMITS.batchBytes
44
+ if (current.length > 0 && full) {
45
+ chunks.push(current)
46
+ current = []
47
+ bytes = 0
48
+ }
49
+ current.push(item.message)
50
+ bytes += item.bytes
51
+ }
52
+ if (current.length > 0) chunks.push(current)
53
+ return chunks
54
+ }
@@ -0,0 +1,4 @@
1
+ import type { DomainCommand, DomainEvent } from '@/core'
2
+
3
+ /** A message a queue carries: a command from outside the request, or an event from the outbox. */
4
+ export type QueueMessage = DomainCommand | DomainEvent
@@ -0,0 +1,105 @@
1
+ import { AppError, classifyBoundaryError, type FailureClassification } from '@/core'
2
+
3
+ /** The Queue binding calls the client makes. Each is safe to repeat. */
4
+ export type QueueOperation = 'send' | 'send_batch' | 'send_dlq'
5
+
6
+ const QUEUE_OPERATION_MESSAGES = {
7
+ send: 'Failed to send queue message',
8
+ send_batch: 'Failed to send queue batch',
9
+ send_dlq: 'Failed to send DLQ message',
10
+ } satisfies Readonly<Record<QueueOperation, string>>
11
+
12
+ /**
13
+ * Cloudflare Queues error codes, by whether a repeat could change the answer. The reference
14
+ * marks only 10201 and 10250 for retry; 10251 waits for processing, 15000 is nobody's call.
15
+ * https://developers.cloudflare.com/queues/reference/error-codes/
16
+ */
17
+ const QUEUE_ERROR_CLASSIFICATIONS = new Map<number, FailureClassification>([
18
+ [10104, 'terminal'], // QueueNotFound
19
+ [10106, 'terminal'], // Unauthorized
20
+ [10107, 'terminal'], // QueueIDMalformed
21
+ [10201, 'transient'], // ClientDisconnected
22
+ [10202, 'terminal'], // BatchDelayInvalid
23
+ [10203, 'terminal'], // MessageMetadataInvalid
24
+ [10204, 'terminal'], // MessageSizeOutOfBounds
25
+ [10205, 'terminal'], // BatchSizeOutOfBounds
26
+ [10206, 'terminal'], // BatchCountOutOfBounds
27
+ [10207, 'terminal'], // JSONRequestBodyInvalid
28
+ [10208, 'terminal'], // JSONRequestBodyMalformed
29
+ [10250, 'transient'], // QueueOverloaded
30
+ [10251, 'transient'], // QueueStorageLimitExceeded
31
+ [10252, 'terminal'], // QueueDisabled
32
+ [10253, 'terminal'], // FreeTierLimitExceeded
33
+ [15000, 'unknown'], // UnknownInternalError
34
+ ])
35
+
36
+ function extractQueueErrorCode(cause: unknown): number | undefined {
37
+ if (!(cause instanceof Error)) return undefined
38
+ const match = /(\d{5})\)?\s*$/.exec(cause.message)
39
+ return match?.[1] === undefined ? undefined : Number(match[1])
40
+ }
41
+
42
+ function classifyQueueBoundaryError(cause: unknown): FailureClassification | undefined {
43
+ const code = extractQueueErrorCode(cause)
44
+ return code === undefined ? undefined : QUEUE_ERROR_CLASSIFICATIONS.get(code)
45
+ }
46
+
47
+ /** Classify a raw Queue binding failure: the code table first, then Cloudflare's signals, then transport. */
48
+ export function classifyQueueError(cause: unknown): FailureClassification {
49
+ return classifyBoundaryError({
50
+ cause,
51
+ classifyBoundary: classifyQueueBoundaryError,
52
+ includeCloudflare: true,
53
+ })
54
+ }
55
+
56
+ /** A send through a Queue binding failed. The classification says whether an immediate retry may help. */
57
+ export class QueueSendError extends AppError {
58
+ readonly _tag = 'QueueSendError'
59
+ readonly classification: FailureClassification
60
+ readonly operation: QueueOperation
61
+
62
+ constructor(args: { readonly operation: QueueOperation; readonly cause: unknown }) {
63
+ super(QUEUE_OPERATION_MESSAGES[args.operation], { cause: args.cause })
64
+ this.classification = classifyQueueError(args.cause)
65
+ this.operation = args.operation
66
+ }
67
+ }
68
+
69
+ /**
70
+ * A message over the per-message cap can never be sent on any queue; the dead letter has the
71
+ * same cap. The client detects it before the send, so it never comes from a platform error.
72
+ */
73
+ export class OversizedMessageError extends AppError {
74
+ readonly _tag = 'OversizedMessageError'
75
+ readonly classification = 'terminal'
76
+ readonly messageName: string
77
+ readonly bytes: number
78
+ readonly limit: number
79
+
80
+ constructor(args: {
81
+ readonly messageName: string
82
+ readonly bytes: number
83
+ readonly limit: number
84
+ }) {
85
+ super(
86
+ `Queue message "${args.messageName}" is ${String(args.bytes)} bytes, over the ${String(args.limit)} limit`,
87
+ )
88
+ this.messageName = args.messageName
89
+ this.bytes = args.bytes
90
+ this.limit = args.limit
91
+ }
92
+ }
93
+
94
+ /**
95
+ * The dead letter did not accept an unparseable message. Always transient, whatever the send
96
+ * failure was, so the source delivery retries instead of acknowledging and losing the body.
97
+ */
98
+ export class DeadLetterUnavailableError extends AppError {
99
+ readonly _tag = 'DeadLetterUnavailableError'
100
+ readonly classification = 'transient'
101
+
102
+ constructor(cause: unknown) {
103
+ super('The dead letter did not accept the message', { cause })
104
+ }
105
+ }
@@ -0,0 +1,64 @@
1
+ import {
2
+ classifyFailure,
3
+ exponentialEqualJitterMs,
4
+ retryAfterSecondsOf,
5
+ shouldRetryFailure,
6
+ spreadJitterMs,
7
+ type FailureClassification,
8
+ } from '@/core'
9
+
10
+ const BASE_RETRY_DELAY_MS = 5_000
11
+ const MAX_RETRY_DELAY_MS = 120_000
12
+ const RETRY_AFTER_SPREAD_MS = 5_000
13
+ /** Cloudflare Queues accept a redelivery delay of at most one day. */
14
+ const MAX_QUEUE_DELAY_SECONDS = 24 * 60 * 60
15
+
16
+ /** How the consumer settles one failed message: acknowledge it, or have Cloudflare redeliver it after a delay. */
17
+ export type RedeliveryDecision =
18
+ | { readonly action: 'ack'; readonly classification: 'terminal' }
19
+ | {
20
+ readonly action: 'retry'
21
+ readonly classification: Exclude<FailureClassification, 'terminal'>
22
+ readonly delaySeconds: number
23
+ }
24
+
25
+ /**
26
+ * Decide whether the consumer acknowledges a failed message or asks Cloudflare to redeliver it.
27
+ * A terminal failure acknowledges. Transient and unknown failures redeliver: after the advertised
28
+ * wait plus a spread when the error carries one, else with exponential equal-jitter backoff.
29
+ * An `AggregateError` from event delivery redelivers when any member does, after the longest wait.
30
+ */
31
+ export function decideRedelivery(cause: unknown, attempt: number): RedeliveryDecision {
32
+ if (cause instanceof AggregateError && cause.errors.length > 0) {
33
+ const decisions = cause.errors.map((member) => decideRedelivery(member, attempt))
34
+ const retries = decisions.filter((decision) => decision.action === 'retry')
35
+ if (retries.length === 0) return { action: 'ack', classification: 'terminal' }
36
+
37
+ // Redelivery must respect every failed subscriber's required wait.
38
+ return {
39
+ action: 'retry',
40
+ classification: retries.some((decision) => decision.classification === 'unknown')
41
+ ? 'unknown'
42
+ : 'transient',
43
+ delaySeconds: Math.max(...retries.map((decision) => decision.delaySeconds)),
44
+ }
45
+ }
46
+
47
+ const classification = classifyFailure(cause)
48
+ if (classification === 'terminal') return { action: 'ack', classification }
49
+
50
+ const canRetry = shouldRetryFailure({ classification, repeatSafe: true, owner: 'durable' })
51
+ if (!canRetry) return { action: 'ack', classification: 'terminal' }
52
+
53
+ const retryAfterSeconds = retryAfterSecondsOf(cause)
54
+ const delayMs =
55
+ retryAfterSeconds === null
56
+ ? exponentialEqualJitterMs(BASE_RETRY_DELAY_MS, MAX_RETRY_DELAY_MS, attempt)
57
+ : retryAfterSeconds * 1_000 + spreadJitterMs(RETRY_AFTER_SPREAD_MS)
58
+
59
+ return {
60
+ action: 'retry',
61
+ classification,
62
+ delaySeconds: Math.min(MAX_QUEUE_DELAY_SECONDS, Math.max(1, Math.ceil(delayMs / 1_000))),
63
+ }
64
+ }
@@ -0,0 +1,8 @@
1
+ export {
2
+ defineSchedules,
3
+ scheduledMessageId,
4
+ type AppSchedules,
5
+ type ScheduledHandler,
6
+ type ScheduledRun,
7
+ type ScheduleExpression,
8
+ } from './scheduled-handler.ts'
@@ -0,0 +1,62 @@
1
+ import { z } from 'zod'
2
+
3
+ import { createLogger, type MessageId } from '@/core'
4
+
5
+ const logger = createLogger('scheduled')
6
+
7
+ /** The work one schedule runs. Nothing returns to the platform, so each run reports its own outcome. */
8
+ export type ScheduledRun<Deps> = (deps: Deps, at: Date) => Promise<void>
9
+
10
+ /** The cron expressions of a schedule map, as literal types. */
11
+ export type ScheduleExpression<Schedules extends Readonly<Record<string, string>>> =
12
+ Schedules[keyof Schedules]
13
+
14
+ /** The Cloudflare `scheduled` export, with the application's dependencies as its second argument. */
15
+ export type ScheduledHandler<Deps> = (controller: ScheduledController, deps: Deps) => Promise<void>
16
+
17
+ /** What `defineSchedules` returns: the map, its parser, and the handler factory. */
18
+ export type AppSchedules<Schedules extends Readonly<Record<string, string>>> = {
19
+ readonly schedules: Schedules
20
+ /** Parses `controller.cron`, a bare string from the platform, into a known expression. */
21
+ readonly schema: z.ZodType<ScheduleExpression<Schedules>>
22
+ /**
23
+ * Route each schedule to its work. One run per expression, so a schedule added without
24
+ * work fails to compile. An expression Wrangler fires that the map does not know is logged
25
+ * once and ignored. A run that throws rejects, so Cloudflare marks the invocation failed.
26
+ */
27
+ handler<Deps>(
28
+ runs: Readonly<Record<ScheduleExpression<Schedules>, ScheduledRun<Deps>>>,
29
+ ): ScheduledHandler<Deps>
30
+ }
31
+
32
+ /**
33
+ * Define the application's schedules once: `EVERY_MINUTE: '* * * * *'`. Keys name the time,
34
+ * not the work, because one schedule may drive several commands. Wrangler's `triggers.crons`
35
+ * must hold exactly these expressions. Cron does not retry: a run that sweeps many items
36
+ * catches each item's failure itself.
37
+ */
38
+ export function defineSchedules<const Schedules extends Readonly<Record<string, string>>>(
39
+ schedules: Schedules,
40
+ ): AppSchedules<Schedules> {
41
+ const schema = z.enum(schedules)
42
+ return {
43
+ schedules,
44
+ schema,
45
+ handler: (runs) => async (controller, deps) => {
46
+ const { cron } = controller
47
+ if (!schema.validate(cron)) {
48
+ logger.error('scheduled_unknown_cron', { details: { cron } })
49
+ return
50
+ }
51
+ await runs[cron](deps, new Date(controller.scheduledTime))
52
+ },
53
+ }
54
+ }
55
+
56
+ /**
57
+ * The id of a command a schedule issues: `cron:<Name>:<scheduledTime>`. A doubled trigger for
58
+ * the same time is then a duplicate by construction, and the unit of work skips it.
59
+ */
60
+ export function scheduledMessageId(name: string, at: Date): MessageId {
61
+ return `cron:${name}:${String(at.getTime())}`
62
+ }
@@ -1,9 +1,11 @@
1
- import { createMiddleware, type Register, type RequestServerOptions } from '@tanstack/react-start'
1
+ import { createMiddleware } from '@tanstack/react-start'
2
2
  import { getRequestHeaders } from '@tanstack/react-start/server'
3
3
  import { getSessionCookie } from 'better-auth/cookies'
4
4
 
5
5
  import { AuthenticationError, StaleSessionError, createLogger } from '@/core'
6
6
 
7
+ import type { RequestContext } from './request-context.ts'
8
+
7
9
  const logger = createLogger('auth-middleware')
8
10
 
9
11
  /** The fields middleware reads; generic callers retain every additional session and user field. */
@@ -28,7 +30,7 @@ export type AuthContext<Session extends AuthSession> =
28
30
 
29
31
  /** Select the current request's Better Auth instance from TanStack's registered context. */
30
32
  export type AuthMiddlewareOptions<Session extends AuthSession> = {
31
- readonly getAuth: (context: RequestServerOptions<Register, undefined>['context']) => {
33
+ readonly getAuth: (context: RequestContext) => {
32
34
  readonly api: {
33
35
  readonly getSession: (options: { headers: Headers }) => Promise<Session | null>
34
36
  }
@@ -60,7 +62,7 @@ export function createAuthMiddleware<Session extends AuthSession>(
60
62
  )
61
63
 
62
64
  async function resolveRequiredSession(
63
- context: RequestServerOptions<Register, undefined>['context'],
65
+ context: RequestContext,
64
66
  headers: Headers,
65
67
  ): Promise<Session> {
66
68
  const resolved = await options.getAuth(context).api.getSession({ headers })
@@ -1,15 +1,14 @@
1
- import { isNotFound, isRedirect, type AnyRedirect } from '@tanstack/react-router'
2
- import { createMiddleware, type Register, type RequestServerOptions } from '@tanstack/react-start'
1
+ import { isNotFound, isRedirect, redirect } from '@tanstack/react-router'
2
+ import { createMiddleware } from '@tanstack/react-start'
3
3
  import type { BetterAuthCookies } from 'better-auth'
4
4
 
5
5
  import { StaleSessionError, type ErrorTable } from '@/core'
6
6
 
7
7
  import { createHttpErrorResponseConverter } from './http-error-response.ts'
8
+ import type { RequestContext } from './request-context.ts'
8
9
  import { clearStaleSessionCookies } from './session-cookies.ts'
9
10
  import { createErrorPayloadConverter } from './to-error-payload.ts'
10
11
 
11
- type RequestContext = RequestServerOptions<Register, undefined>['context']
12
-
13
12
  /** The part of a Better Auth instance the boundary reads: its computed cookie definitions. */
14
13
  export type AuthCookieSource = {
15
14
  readonly $context: Promise<{ readonly authCookies: BetterAuthCookies }>
@@ -18,8 +17,8 @@ export type AuthCookieSource = {
18
17
  /** The application's error table, its stale-session redirect, and its Better Auth instance. */
19
18
  export type ErrorMiddlewareOptions<Entries> = {
20
19
  readonly errors: ErrorTable<Entries>
21
- /** Build the redirect a server function throws for a stale session. */
22
- readonly signIn: () => AnyRedirect
20
+ /** The path a server function redirects to for a stale session, with any search string. */
21
+ readonly signIn: string
23
22
  /** Select the request's Better Auth instance; its cookie definitions say what to clear. */
24
23
  readonly getAuth: (context: RequestContext) => AuthCookieSource
25
24
  }
@@ -43,7 +42,7 @@ export function createErrorMiddleware<Entries>(options: ErrorMiddlewareOptions<E
43
42
  if (cause instanceof StaleSessionError) {
44
43
  await clearSession(context)
45
44
  // oxlint-disable-next-line typescript/only-throw-error -- Router redirects by throwing this.
46
- throw options.signIn()
45
+ throw redirect({ href: options.signIn })
47
46
  }
48
47
  // oxlint-disable-next-line typescript/only-throw-error -- The client rejects with this and reads its tag.
49
48
  throw toErrorPayload(cause)
@@ -0,0 +1,11 @@
1
+ import type { Register } from '@tanstack/react-start'
2
+
3
+ /**
4
+ * The request context an application registers as `Register.server.requestContext`.
5
+ * TanStack's own request context type also reads `Register.config`, which lists the middleware
6
+ * whose options carry this type, so using it here would make every application's types circular.
7
+ * Without a registration TanStack passes no context, so the fallback is `undefined`.
8
+ */
9
+ export type RequestContext = Register extends { server: { requestContext: infer Context } }
10
+ ? Context
11
+ : undefined