orpc-fn 0.1.0 → 0.3.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 (156) hide show
  1. package/README.md +70 -33
  2. package/dist/assemble-procedure.d.ts +13 -0
  3. package/dist/assemble-procedure.d.ts.map +1 -0
  4. package/dist/assemble-procedure.js +29 -0
  5. package/dist/assemble-procedure.js.map +1 -0
  6. package/dist/bound-call.d.ts +1 -1
  7. package/dist/bound-call.d.ts.map +1 -1
  8. package/dist/bound-call.js.map +1 -1
  9. package/dist/cli.d.ts +3 -0
  10. package/dist/cli.d.ts.map +1 -0
  11. package/dist/cli.js +56 -0
  12. package/dist/cli.js.map +1 -0
  13. package/dist/client.d.ts +4 -2
  14. package/dist/client.d.ts.map +1 -1
  15. package/dist/client.js +4 -2
  16. package/dist/client.js.map +1 -1
  17. package/dist/compatibility.d.ts +16 -0
  18. package/dist/compatibility.d.ts.map +1 -0
  19. package/dist/compatibility.js +4 -0
  20. package/dist/compatibility.js.map +1 -0
  21. package/dist/create-fn.d.ts +30 -34
  22. package/dist/create-fn.d.ts.map +1 -1
  23. package/dist/create-fn.js +177 -121
  24. package/dist/create-fn.js.map +1 -1
  25. package/dist/expo.d.ts +11 -25
  26. package/dist/expo.d.ts.map +1 -1
  27. package/dist/expo.js +25 -30
  28. package/dist/expo.js.map +1 -1
  29. package/dist/hono.d.ts +12 -4
  30. package/dist/hono.d.ts.map +1 -1
  31. package/dist/hono.js +29 -12
  32. package/dist/hono.js.map +1 -1
  33. package/dist/index.d.ts +2 -2
  34. package/dist/index.d.ts.map +1 -1
  35. package/dist/index.js +2 -2
  36. package/dist/index.js.map +1 -1
  37. package/dist/json-schema.d.ts +2 -0
  38. package/dist/json-schema.d.ts.map +1 -1
  39. package/dist/json-schema.js +11 -0
  40. package/dist/json-schema.js.map +1 -1
  41. package/dist/live/codec.d.ts +6 -1
  42. package/dist/live/codec.d.ts.map +1 -1
  43. package/dist/live/codec.js +14 -3
  44. package/dist/live/codec.js.map +1 -1
  45. package/dist/live/fn-live.d.ts +67 -22
  46. package/dist/live/fn-live.d.ts.map +1 -1
  47. package/dist/live/fn-live.js +98 -16
  48. package/dist/live/fn-live.js.map +1 -1
  49. package/dist/live/index.d.ts +1 -1
  50. package/dist/live/index.d.ts.map +1 -1
  51. package/dist/live/index.js.map +1 -1
  52. package/dist/live/ioredis.d.ts +4 -1
  53. package/dist/live/ioredis.d.ts.map +1 -1
  54. package/dist/live/ioredis.js +21 -2
  55. package/dist/live/ioredis.js.map +1 -1
  56. package/dist/live/lifecycle.d.ts +7 -0
  57. package/dist/live/lifecycle.d.ts.map +1 -0
  58. package/dist/live/lifecycle.js +36 -0
  59. package/dist/live/lifecycle.js.map +1 -0
  60. package/dist/live/memory.d.ts.map +1 -1
  61. package/dist/live/memory.js +4 -0
  62. package/dist/live/memory.js.map +1 -1
  63. package/dist/live/pub-sub.d.ts +81 -13
  64. package/dist/live/pub-sub.d.ts.map +1 -1
  65. package/dist/live/pub-sub.js +437 -126
  66. package/dist/live/pub-sub.js.map +1 -1
  67. package/dist/live/redis-bun.d.ts +1 -1
  68. package/dist/live/redis-bun.d.ts.map +1 -1
  69. package/dist/live/redis-bun.js +3 -5
  70. package/dist/live/redis-bun.js.map +1 -1
  71. package/dist/live/redis.d.ts +2 -2
  72. package/dist/live/redis.d.ts.map +1 -1
  73. package/dist/live/redis.js +36 -9
  74. package/dist/live/redis.js.map +1 -1
  75. package/dist/live/transport.d.ts +5 -1
  76. package/dist/live/transport.d.ts.map +1 -1
  77. package/dist/live/transport.js.map +1 -1
  78. package/dist/logger.d.ts +2 -0
  79. package/dist/logger.d.ts.map +1 -1
  80. package/dist/logger.js +21 -0
  81. package/dist/logger.js.map +1 -1
  82. package/dist/mastra.d.ts.map +1 -1
  83. package/dist/mastra.js +16 -7
  84. package/dist/mastra.js.map +1 -1
  85. package/dist/mcp/core.d.ts +41 -0
  86. package/dist/mcp/core.d.ts.map +1 -0
  87. package/dist/mcp/core.js +61 -0
  88. package/dist/mcp/core.js.map +1 -0
  89. package/dist/mcp/sdk.d.ts +12 -0
  90. package/dist/mcp/sdk.d.ts.map +1 -0
  91. package/dist/mcp/sdk.js +14 -0
  92. package/dist/mcp/sdk.js.map +1 -0
  93. package/dist/mcp/server.d.ts +11 -0
  94. package/dist/mcp/server.d.ts.map +1 -0
  95. package/dist/mcp/server.js +13 -0
  96. package/dist/mcp/server.js.map +1 -0
  97. package/dist/mcp.d.ts +3 -2
  98. package/dist/mcp.d.ts.map +1 -1
  99. package/dist/mcp.js +22 -7
  100. package/dist/mcp.js.map +1 -1
  101. package/dist/meta.d.ts +15 -7
  102. package/dist/meta.d.ts.map +1 -1
  103. package/dist/meta.js +9 -2
  104. package/dist/meta.js.map +1 -1
  105. package/dist/otel.d.ts.map +1 -1
  106. package/dist/otel.js +77 -6
  107. package/dist/otel.js.map +1 -1
  108. package/dist/route-options.d.ts +22 -0
  109. package/dist/route-options.d.ts.map +1 -0
  110. package/dist/route-options.js +67 -0
  111. package/dist/route-options.js.map +1 -0
  112. package/dist/router.d.ts +13 -1
  113. package/dist/router.d.ts.map +1 -1
  114. package/dist/router.js +35 -0
  115. package/dist/router.js.map +1 -1
  116. package/dist/timing.d.ts +8 -0
  117. package/dist/timing.d.ts.map +1 -0
  118. package/dist/timing.js +4 -0
  119. package/dist/timing.js.map +1 -0
  120. package/dist/types.d.ts +96 -41
  121. package/dist/types.d.ts.map +1 -1
  122. package/dist/types.js +6 -2
  123. package/dist/types.js.map +1 -1
  124. package/package.json +28 -3
  125. package/src/assemble-procedure.ts +42 -0
  126. package/src/bound-call.ts +4 -3
  127. package/src/cli.ts +68 -0
  128. package/src/client.ts +9 -2
  129. package/src/compatibility.ts +39 -0
  130. package/src/create-fn.ts +295 -182
  131. package/src/expo.ts +49 -44
  132. package/src/hono.ts +36 -17
  133. package/src/index.ts +7 -2
  134. package/src/json-schema.ts +16 -0
  135. package/src/live/codec.ts +22 -3
  136. package/src/live/fn-live.ts +248 -69
  137. package/src/live/index.ts +2 -0
  138. package/src/live/ioredis.ts +22 -2
  139. package/src/live/lifecycle.ts +44 -0
  140. package/src/live/memory.ts +4 -0
  141. package/src/live/pub-sub.ts +619 -168
  142. package/src/live/redis-bun.ts +7 -5
  143. package/src/live/redis.ts +37 -11
  144. package/src/live/transport.ts +4 -1
  145. package/src/logger.ts +20 -0
  146. package/src/mastra.ts +15 -7
  147. package/src/mcp/core.ts +102 -0
  148. package/src/mcp/sdk.ts +36 -0
  149. package/src/mcp/server.ts +35 -0
  150. package/src/mcp.ts +48 -20
  151. package/src/meta.ts +40 -24
  152. package/src/otel.ts +77 -10
  153. package/src/route-options.ts +100 -0
  154. package/src/router.ts +58 -1
  155. package/src/timing.ts +5 -0
  156. package/src/types.ts +186 -62
@@ -1,6 +1,8 @@
1
- import type { Schema } from '@orpc/contract'
1
+ import type { AnySchema, ErrorMap, Schema } from '@orpc/contract'
2
2
  import { type AnyProcedure, ORPCError } from '@orpc/server'
3
3
  import type { ZodType, z } from 'zod'
4
+ import { RAW_INPUT } from '../timing.js'
5
+ import { LIVE_GAP, positiveInteger } from './lifecycle.js'
4
6
  import type { FnLogger } from '../logger.js'
5
7
  import { errorMessageOf, type SpanLike } from '../otel.js'
6
8
  import type {
@@ -17,6 +19,7 @@ import type {
17
19
  Channel,
18
20
  ChannelDefinition,
19
21
  ChannelOptions,
22
+ ChannelQueueOptions,
20
23
  ObjectSchema,
21
24
  PublisherConfig
22
25
  } from './pub-sub.js'
@@ -57,45 +60,92 @@ export type FnLiveConfig<
57
60
  TEventSchema extends ZodType,
58
61
  TOutput,
59
62
  TKey extends ProcedureKey<TDef>,
60
- TEmit = never
61
- > = Omit<PublisherConfig, 'name'> & {
62
- /** Subscribe route name; default `${name}.live`. */
63
- name?: string
64
- eventSchema: TEventSchema
65
- channel: ChannelDefinition<
66
- TInput,
67
- z.output<TEventSchema>,
68
- FnContext<TDef, TKey>
69
- >
70
- /** Fold an event into the snapshot; without it every event re-runs the handler. */
71
- transformerFn?: (
72
- params: HandlerParams<TDef, TInput, TKey> & {
73
- event: z.output<TEventSchema>
74
- previous: TOutput | undefined
75
- rerun: (input?: TInput) => Promise<TOutput>
76
- }
77
- ) => MaybePromise<TOutput | FnLivePatch<TOutput, TEmit> | undefined>
78
- /** Return false to skip an event. */
79
- shouldUpdate?: (
80
- params: HandlerParams<TDef, TInput, TKey> & {
81
- event: z.output<TEventSchema>
82
- previous: TOutput | undefined
83
- }
84
- ) => MaybePromise<boolean>
85
- authFn?: AuthFn<TInput, FnContext<TDef, TKey>>
86
- /** Log publish failures instead of throwing them into the publishing route. */
87
- safePublish?: boolean
88
- /** Batch events arriving within this window into one snapshot update. */
89
- coalesceMs?: number
90
- /**
91
- * When a slow subscriber's queue overflows (drop-oldest), this marker is
92
- * enqueued INSTEAD of the dropped event so the client resyncs. Return
93
- * `null` to skip.
94
- */
95
- overflowMarker?: (input: TInput) => z.output<TEventSchema> | null
96
- summary?: string
97
- description?: string
98
- }
63
+ TEmit = never,
64
+ TErrors extends ErrorMap = Record<never, never>
65
+ > = Omit<PublisherConfig, 'name'> &
66
+ ChannelQueueOptions & {
67
+ /** Subscribe route name; default `${name}.live`. */
68
+ name?: string
69
+ /**
70
+ * Validates reducer state without reapplying an output transform. Required with a
71
+ * transformer. Its input may be looser than its output (e.g. branded IDs); it is
72
+ * checked at runtime.
73
+ */
74
+ stateSchema?: Schema<unknown, TOutput>
75
+ /** Validates patch payloads. Required when a transformer returns fnLivePatch. */
76
+ emitSchema?: Schema<unknown, TEmit>
77
+ /** Optional access check before each update; idle streams keep their initial authorization. */
78
+ reauthorize?: AuthFn<TInput, FnContext<TDef, TKey>>
79
+ eventSchema: TEventSchema
80
+ channel: ChannelDefinition<
81
+ TInput,
82
+ z.output<TEventSchema>,
83
+ FnContext<TDef, TKey>
84
+ >
85
+ /** Fold an event into the snapshot; without it every event re-runs the handler. */
86
+ transformerFn?: FnLiveConfigTransformer<
87
+ TDef,
88
+ TInput,
89
+ TEventSchema,
90
+ TOutput,
91
+ TKey,
92
+ TEmit,
93
+ TErrors
94
+ >
95
+ /** Return false to skip an event. */
96
+ shouldUpdate?: (
97
+ params: HandlerParams<TDef, TInput, TKey, TErrors> & {
98
+ event: z.output<TEventSchema>
99
+ previous: TOutput
100
+ }
101
+ ) => MaybePromise<boolean>
102
+ authFn?: AuthFn<TInput, FnContext<TDef, TKey>>
103
+ /** Log publish failures instead of throwing them into the publishing route. */
104
+ safePublish?: boolean
105
+ /** Batch events arriving within this window into one snapshot update. */
106
+ coalesceMs?: number
107
+ /** Maximum events collected in one coalescing batch (default 1000). */
108
+ maxBatchSize?: number
109
+ /**
110
+ * When a slow subscriber's queue overflows (drop-oldest), this marker is
111
+ * enqueued INSTEAD of the dropped event so the client resyncs. Return
112
+ * `null` to skip.
113
+ */
114
+ overflowMarker?: (input: TInput) => z.output<TEventSchema> | null
115
+ summary?: string
116
+ description?: string
117
+ } & (
118
+ | { transformerFn?: undefined }
119
+ | {
120
+ transformerFn: FnLiveConfigTransformer<
121
+ TDef,
122
+ TInput,
123
+ TEventSchema,
124
+ TOutput,
125
+ TKey,
126
+ TEmit,
127
+ TErrors
128
+ >
129
+ stateSchema: Schema<unknown, TOutput>
130
+ }
131
+ ) &
132
+ ([TEmit] extends [never] ? object : { emitSchema: Schema<unknown, TEmit> })
133
+
134
+ type FnLiveConfigTransformer<
135
+ TDef extends FnDefinition,
136
+ TInput,
137
+ TEventSchema extends ZodType,
138
+ TOutput,
139
+ TKey extends ProcedureKey<TDef>,
140
+ TEmit,
141
+ TErrors extends ErrorMap
142
+ > = (
143
+ params: HandlerParams<TDef, TInput, TKey, TErrors> & {
144
+ event: z.output<TEventSchema>
145
+ previous: TOutput
146
+ rerun: () => Promise<TOutput>
147
+ }
148
+ ) => MaybePromise<TOutput | FnLivePatch<TOutput, TEmit> | undefined>
99
149
 
100
150
  export type FnLiveReturn<
101
151
  TDef extends FnDefinition,
@@ -104,16 +154,24 @@ export type FnLiveReturn<
104
154
  TOutputSchema extends Schema<unknown, unknown>,
105
155
  TOutput,
106
156
  TEventSchema extends ZodType,
107
- TEmit = never
157
+ TEmit = never,
158
+ TErrors extends ErrorMap = Record<never, never>
108
159
  > = {
109
- procedure: FnProcedure<TDef, TKey, TInput, TOutputSchema>
160
+ procedure: FnProcedure<TDef, TKey, TInput, TOutputSchema, TErrors>
110
161
  subscribe: FnProcedure<
111
162
  TDef,
112
163
  TKey,
113
164
  TInput,
114
- Schema<AsyncGenerator<TOutput | TEmit>, AsyncGenerator<TOutput | TEmit>>
165
+ Schema<AsyncGenerator<TOutput | TEmit>, AsyncGenerator<TOutput | TEmit>>,
166
+ TErrors
115
167
  >
116
168
  publish: (event: z.input<TEventSchema>) => Promise<void>
169
+ getSubscriptionChannelName: (params: {
170
+ input: z.output<TInput>
171
+ context: FnContext<TDef, TKey>
172
+ }) => string
173
+ getPublishChannelName: (event: z.output<TEventSchema>) => string
174
+ /** @deprecated Prefer the separate required-input resolver methods. */
117
175
  getChannelName: (
118
176
  params: Partial<z.output<TInput>> | Partial<z.output<TEventSchema>>,
119
177
  context?: FnContext<TDef, TKey>
@@ -130,13 +188,19 @@ export interface FnLive<TDef extends FnDefinition> {
130
188
  TOutputSchema extends ZodType,
131
189
  TEventSchema extends ZodType,
132
190
  TKey extends ProcedureKey<TDef> = TDef['default'],
133
- TEmit = never
191
+ TEmit = never,
192
+ TErrors extends ErrorMap = Record<never, never>
134
193
  >(
135
194
  options: {
136
195
  input: TInput
137
196
  output: TOutputSchema
138
197
  handler: (
139
- params: HandlerParams<TDef, z.output<TInput>, NoInfer<TKey>> & {
198
+ params: HandlerParams<
199
+ TDef,
200
+ z.output<TInput>,
201
+ NoInfer<TKey>,
202
+ TErrors
203
+ > & {
140
204
  publish: (event: z.input<TEventSchema>) => Promise<void>
141
205
  }
142
206
  // The output schema parses what the handler returns: its input type.
@@ -147,9 +211,10 @@ export interface FnLive<TDef extends FnDefinition> {
147
211
  TEventSchema,
148
212
  z.output<TOutputSchema>,
149
213
  NoInfer<TKey>,
150
- TEmit
214
+ TEmit,
215
+ TErrors
151
216
  >
152
- } & FnRouteOptions<TDef, TKey>
217
+ } & FnRouteOptions<TDef, TKey, z.output<TInput>, TErrors>
153
218
  ): FnLiveReturn<
154
219
  TDef,
155
220
  TKey,
@@ -157,7 +222,8 @@ export interface FnLive<TDef extends FnDefinition> {
157
222
  TOutputSchema,
158
223
  z.output<TOutputSchema>,
159
224
  TEventSchema,
160
- TEmit
225
+ TEmit,
226
+ TErrors
161
227
  >
162
228
 
163
229
  <
@@ -165,13 +231,19 @@ export interface FnLive<TDef extends FnDefinition> {
165
231
  TOutput,
166
232
  TEventSchema extends ZodType,
167
233
  TKey extends ProcedureKey<TDef> = TDef['default'],
168
- TEmit = never
234
+ TEmit = never,
235
+ TErrors extends ErrorMap = Record<never, never>
169
236
  >(
170
237
  options: {
171
238
  input: TInput
172
239
  output?: undefined
173
240
  handler: (
174
- params: HandlerParams<TDef, z.output<TInput>, NoInfer<TKey>> & {
241
+ params: HandlerParams<
242
+ TDef,
243
+ z.output<TInput>,
244
+ NoInfer<TKey>,
245
+ TErrors
246
+ > & {
175
247
  publish: (event: z.input<TEventSchema>) => Promise<void>
176
248
  }
177
249
  ) => MaybePromise<TOutput>
@@ -181,9 +253,10 @@ export interface FnLive<TDef extends FnDefinition> {
181
253
  TEventSchema,
182
254
  TOutput,
183
255
  NoInfer<TKey>,
184
- TEmit
256
+ TEmit,
257
+ TErrors
185
258
  >
186
- } & FnRouteOptions<TDef, TKey>
259
+ } & FnRouteOptions<TDef, TKey, z.output<TInput>, TErrors>
187
260
  ): FnLiveReturn<
188
261
  TDef,
189
262
  TKey,
@@ -191,7 +264,8 @@ export interface FnLive<TDef extends FnDefinition> {
191
264
  Schema<TOutput, TOutput>,
192
265
  TOutput,
193
266
  TEventSchema,
194
- TEmit
267
+ TEmit,
268
+ TErrors
195
269
  >
196
270
  }
197
271
 
@@ -253,6 +327,12 @@ export async function* streamLiveSnapshots<
253
327
  TEmit = never
254
328
  >(opts: {
255
329
  source: AsyncIterator<TEvent>
330
+ /** Maximum events retained in one coalescing batch. */
331
+ maxBatchSize?: number
332
+ applyBatch?: (
333
+ events: readonly TEvent[],
334
+ previous: TOutput
335
+ ) => MaybePromise<TOutput | undefined>
256
336
  initial: TOutput
257
337
  coalesceMs?: number | undefined
258
338
  apply: (
@@ -261,6 +341,15 @@ export async function* streamLiveSnapshots<
261
341
  ) => MaybePromise<TOutput | FnLivePatch<TOutput, TEmit> | undefined>
262
342
  }): AsyncGenerator<TOutput | TEmit> {
263
343
  const { source, initial, coalesceMs, apply } = opts
344
+ const maxBatchSize = positiveInteger(
345
+ opts.maxBatchSize ?? 1000,
346
+ 'maxBatchSize'
347
+ )
348
+ if (
349
+ coalesceMs !== undefined &&
350
+ (!Number.isFinite(coalesceMs) || coalesceMs < 0)
351
+ )
352
+ throw new RangeError('orpc-fn: coalesceMs must be finite and nonnegative')
264
353
 
265
354
  let pending: Promise<IteratorResult<TEvent>> | null = null
266
355
  const readNext = () => {
@@ -280,7 +369,7 @@ export async function* streamLiveSnapshots<
280
369
 
281
370
  if (coalesceMs) {
282
371
  const endAt = Date.now() + coalesceMs
283
- while (Date.now() < endAt) {
372
+ while (Date.now() < endAt && batch.length < maxBatchSize) {
284
373
  const remainingMs = endAt - Date.now()
285
374
  const read = readNext()
286
375
  let timer: ReturnType<typeof setTimeout> | undefined
@@ -307,17 +396,31 @@ export async function* streamLiveSnapshots<
307
396
  }
308
397
  }
309
398
 
399
+ if (opts.applyBatch) {
400
+ const next = await opts.applyBatch(batch, previous)
401
+ if (next !== undefined) {
402
+ previous = next
403
+ yield previous
404
+ }
405
+ continue
406
+ }
407
+ let changed = false
310
408
  for (const event of batch) {
311
409
  const next = await apply(event, previous)
312
410
  if (next === undefined) continue
313
411
  if (isFnLivePatch(next)) {
412
+ if (changed) {
413
+ yield previous
414
+ changed = false
415
+ }
314
416
  previous = next.state as TOutput
315
417
  yield next.emit as TEmit
316
418
  continue
317
419
  }
318
420
  previous = next
319
- yield previous
421
+ changed = true
320
422
  }
423
+ if (changed) yield previous
321
424
  }
322
425
  }
323
426
 
@@ -326,15 +429,21 @@ type RuntimeParams = Record<string, unknown> & {
326
429
  context: unknown
327
430
  signal: AbortSignal | undefined
328
431
  logger: FnLogger
432
+ call: (procedure: AnyProcedure, input: unknown) => Promise<unknown>
433
+ [RAW_INPUT]: unknown
329
434
  }
330
435
 
331
436
  /** `FnLiveConfig` with the generics erased: what the implementation reads. */
332
437
  type RuntimeLiveConfig = Omit<ChannelOptions, 'name'> & {
333
438
  name?: string
439
+ stateSchema?: AnySchema
440
+ emitSchema?: AnySchema
441
+ reauthorize?: AuthFn<Record<string, unknown>, unknown>
334
442
  transformerFn?: (params: RuntimeParams & Record<string, unknown>) => unknown
335
443
  shouldUpdate?: (params: RuntimeParams & Record<string, unknown>) => unknown
336
444
  safePublish?: boolean
337
445
  coalesceMs?: number
446
+ maxBatchSize?: number
338
447
  summary?: string
339
448
  description?: string
340
449
  }
@@ -367,6 +476,17 @@ export function createFnLive(runtime: {
367
476
  } & Record<string, unknown>
368
477
  ) => {
369
478
  const { live, handler, input, output, name, ...routeConfig } = options
479
+ if (live.transformerFn && !live.stateSchema)
480
+ throw new TypeError(
481
+ 'orpc-fn: live transformers require a stateSchema for parsed state'
482
+ )
483
+ if (
484
+ live.coalesceMs !== undefined &&
485
+ (!Number.isFinite(live.coalesceMs) || live.coalesceMs < 0)
486
+ )
487
+ throw new RangeError('orpc-fn: coalesceMs must be finite and nonnegative')
488
+ if (live.maxBatchSize !== undefined)
489
+ positiveInteger(live.maxBatchSize, 'maxBatchSize')
370
490
  const liveName = live.name ?? `${name}.live`
371
491
  const liveSummary =
372
492
  live.summary ?? `Live updates of ${routeConfig.summary ?? name}`
@@ -391,11 +511,6 @@ export function createFnLive(runtime: {
391
511
 
392
512
  const runHandler = (params: RuntimeParams) =>
393
513
  handler({ ...params, publish })
394
- // Snapshots go through the output schema like the route's own result.
395
- const runSnapshot = async (params: RuntimeParams) => {
396
- const result = await runHandler(params)
397
- return output ? output.parseAsync(result) : result
398
- }
399
514
 
400
515
  const procedure = fn({
401
516
  ...routeConfig,
@@ -405,12 +520,28 @@ export function createFnLive(runtime: {
405
520
  handler: runHandler
406
521
  })
407
522
 
408
- // Guards run on the subscribe route too; route meta stays on `procedure`.
523
+ const validate = async (schema: AnySchema, value: unknown) => {
524
+ const result = await schema['~standard'].validate(value)
525
+ if (result.issues)
526
+ throw new ORPCError('INTERNAL_SERVER_ERROR', {
527
+ message: 'Invalid live state or patch',
528
+ cause: result.issues
529
+ })
530
+ return result.value
531
+ }
532
+ const runSnapshot = (params: RuntimeParams) =>
533
+ params.call(procedure, params[RAW_INPUT])
534
+ // Generated streams inherit access policies and metadata; tool adapters reject streams.
535
+
409
536
  const guardOptions = Object.fromEntries(
410
537
  Object.entries(routeConfig).filter(([option]) => isGuard(option))
411
538
  )
412
539
  const subscribe = fn({
413
540
  ...guardOptions,
541
+ guardResolvers: routeConfig.guardResolvers,
542
+ errors: routeConfig.errors,
543
+ meta: routeConfig.meta,
544
+ stream: true,
414
545
  procedure: routeConfig.procedure,
415
546
  tags: routeConfig.tags,
416
547
  name: liveName,
@@ -424,9 +555,11 @@ export function createFnLive(runtime: {
424
555
  const subscription = await channel.open({
425
556
  input: params.input,
426
557
  context: params.context,
427
- signal: params.signal
558
+ signal: params.signal,
559
+ recoverGaps: true
428
560
  })
429
561
  try {
562
+ if (subscription.isClosed()) return
430
563
  let initial: unknown
431
564
  try {
432
565
  initial = await runSnapshot(params)
@@ -435,24 +568,68 @@ export function createFnLive(runtime: {
435
568
  throw toSnapshotError(err, liveName)
436
569
  }
437
570
 
438
- const rerun = (nextInput?: Record<string, unknown>) =>
439
- runSnapshot({ ...params, input: nextInput ?? params.input })
571
+ const rerun = () => runSnapshot(params)
572
+ const authorize = async () => {
573
+ if (
574
+ live.reauthorize &&
575
+ live.reauthorize !== true &&
576
+ !(await live.reauthorize({
577
+ input: params.input,
578
+ ctx: params.context
579
+ }))
580
+ )
581
+ throw new ORPCError('FORBIDDEN')
582
+ }
440
583
  const apply = async (event: unknown, previous: unknown) => {
584
+ await authorize()
585
+ if (event === LIVE_GAP) return rerun()
441
586
  if (
442
587
  live.shouldUpdate &&
443
588
  !(await live.shouldUpdate({ ...params, event, previous }))
444
- ) {
589
+ )
445
590
  return undefined
591
+ if (!live.transformerFn) return rerun()
592
+ const next = await live.transformerFn({
593
+ ...params,
594
+ event,
595
+ previous,
596
+ rerun
597
+ })
598
+ if (next === undefined) return undefined
599
+ if (isFnLivePatch(next)) {
600
+ if (!live.emitSchema)
601
+ throw new TypeError('orpc-fn: live patches require emitSchema')
602
+ return fnLivePatch(
603
+ await validate(live.stateSchema as AnySchema, next.state),
604
+ await validate(live.emitSchema, next.emit)
605
+ )
446
606
  }
447
- if (!live.transformerFn) return runSnapshot(params)
448
- return live.transformerFn({ ...params, event, previous, rerun })
607
+ return validate(live.stateSchema as AnySchema, next)
449
608
  }
609
+ const applyBatch = live.transformerFn
610
+ ? undefined
611
+ : async (events: readonly unknown[], previous: unknown) => {
612
+ await authorize()
613
+ let invalidated = false
614
+ for (const event of events)
615
+ if (
616
+ event === LIVE_GAP ||
617
+ !live.shouldUpdate ||
618
+ (await live.shouldUpdate({ ...params, event, previous }))
619
+ )
620
+ invalidated = true
621
+ return invalidated ? rerun() : undefined
622
+ }
450
623
 
451
624
  yield* streamLiveSnapshots({
452
625
  source: subscription.events,
453
626
  initial,
454
627
  coalesceMs: live.coalesceMs,
455
- apply
628
+ ...(live.maxBatchSize !== undefined
629
+ ? { maxBatchSize: live.maxBatchSize }
630
+ : {}),
631
+ apply,
632
+ ...(applyBatch ? { applyBatch } : {})
456
633
  })
457
634
  } finally {
458
635
  subscription.close()
@@ -464,7 +641,9 @@ export function createFnLive(runtime: {
464
641
  procedure,
465
642
  subscribe,
466
643
  publish,
467
- getChannelName: channel.getChannelName
644
+ getChannelName: channel.getChannelName,
645
+ getSubscriptionChannelName: channel.getSubscriptionChannelName,
646
+ getPublishChannelName: channel.getPublishChannelName
468
647
  }
469
648
  }
470
649
  }
package/src/live/index.ts CHANGED
@@ -11,6 +11,8 @@ export {
11
11
  export {
12
12
  type AuthFn,
13
13
  type ChannelDefinition,
14
+ type ChannelQueueOptions,
15
+ type PubSubMetric,
14
16
  type CreatePublisher,
15
17
  type CreatePubSub,
16
18
  createBoundedEventQueue,
@@ -6,17 +6,20 @@ export interface IORedisLike {
6
6
  subscribe(...channels: string[]): Promise<unknown>
7
7
  unsubscribe(...channels: string[]): Promise<unknown>
8
8
  duplicate(): IORedisLike
9
+ status?: string
9
10
  disconnect(): void
10
11
  on(
11
12
  event: 'message',
12
13
  listener: (channel: string, message: string) => void
13
14
  ): unknown
15
+ on(event: 'ready', listener: () => void): unknown
14
16
  on(event: 'end', listener: () => void): unknown
15
17
  on(event: 'error', listener: (error: Error) => void): unknown
16
18
  off(
17
19
  event: 'message',
18
20
  listener: (channel: string, message: string) => void
19
21
  ): unknown
22
+ off(event: 'ready', listener: () => void): unknown
20
23
  off(event: 'end', listener: () => void): unknown
21
24
  off(event: 'error', listener: (error: Error) => void): unknown
22
25
  }
@@ -41,11 +44,23 @@ export function ioredisTransport(
41
44
  const onMessage = (channel: string, message: string) =>
42
45
  handlers.onMessage(channel, message)
43
46
  // Without a listener an ioredis 'error' event would crash the process.
44
- const onError = (error: Error) => options.onError?.(error)
47
+ const onError = (error: Error) => {
48
+ try {
49
+ options.onError?.(error)
50
+ } catch {
51
+ /* Isolate telemetry. */
52
+ }
53
+ }
54
+ let readyOnce = subscriber.status === 'ready'
55
+ const onReady = () => {
56
+ if (readyOnce) handlers.onReconnect()
57
+ readyOnce = true
58
+ }
45
59
  // 'end' means ioredis stopped reconnecting.
46
60
  const onEnd = () =>
47
61
  handlers.onLost(new Error('Redis subscriber connection ended'))
48
62
  const ours = new Set<string>()
63
+ subscriber.on('ready', onReady)
49
64
  subscriber.on('message', onMessage)
50
65
  subscriber.on('error', onError)
51
66
  subscriber.on('end', onEnd)
@@ -54,18 +69,23 @@ export function ioredisTransport(
54
69
  ours.add(channel)
55
70
  return subscriber.subscribe(channel)
56
71
  },
72
+ resubscribe: (channel: string) => subscriber.subscribe(channel),
57
73
  unsubscribe: (channel) => {
58
74
  ours.delete(channel)
59
75
  return subscriber.unsubscribe(channel)
60
76
  },
61
77
  close() {
62
78
  // Detach first: a given subscriber outlives this transport's use.
79
+ subscriber.off('ready', onReady)
63
80
  subscriber.off('message', onMessage)
64
81
  subscriber.off('end', onEnd)
65
82
  if (owned) return subscriber.disconnect()
66
83
  subscriber.off('error', onError)
67
84
  if (ours.size > 0) {
68
- void subscriber.unsubscribe(...ours).catch(() => {})
85
+ return subscriber
86
+ .unsubscribe(...ours)
87
+ .then(() => {})
88
+ .catch(() => {})
69
89
  }
70
90
  }
71
91
  }
@@ -0,0 +1,44 @@
1
+ /** Internal control message: live queries reload after loss; raw streams fail. */
2
+ export const LIVE_GAP = Symbol('orpc-fn.liveGap')
3
+
4
+ export function positiveInteger(value: number, name: string): number {
5
+ if (!Number.isSafeInteger(value) || value <= 0)
6
+ throw new RangeError(`orpc-fn: ${name} must be a positive safe integer`)
7
+ return value
8
+ }
9
+
10
+ export function safely(callback: (() => unknown) | undefined): void {
11
+ try {
12
+ callback?.()
13
+ } catch {
14
+ /* Observability must not disrupt delivery. */
15
+ }
16
+ }
17
+
18
+ /** Abort and timeout only stop waiting; callers release late transport results. */
19
+ export async function waitFor<T>(
20
+ promise: Promise<T>,
21
+ timeoutMs: number,
22
+ signal: AbortSignal
23
+ ): Promise<T> {
24
+ signal.throwIfAborted()
25
+ let timer: ReturnType<typeof setTimeout> | undefined
26
+ let abort = () => {}
27
+ try {
28
+ return await Promise.race([
29
+ promise,
30
+ new Promise<never>((_resolve, reject) => {
31
+ abort = () => reject(signal.reason)
32
+ signal.addEventListener('abort', abort, { once: true })
33
+ timer = setTimeout(
34
+ () =>
35
+ reject(new Error('orpc-fn: subscription initialization timed out')),
36
+ timeoutMs
37
+ )
38
+ })
39
+ ])
40
+ } finally {
41
+ clearTimeout(timer)
42
+ signal.removeEventListener('abort', abort)
43
+ }
44
+ }
@@ -12,6 +12,10 @@ export function memoryTransport(): PubSubTransport & {
12
12
  const backlogs = new Map<string, { items: string[]; expiresAt: number }>()
13
13
 
14
14
  return {
15
+ async close() {
16
+ listeners.clear()
17
+ backlogs.clear()
18
+ },
15
19
  async publish(messages, backlog) {
16
20
  for (const { channel, payload } of messages) {
17
21
  if (backlog) {