File size: 22,054 Bytes
1477a90
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
// Code generated by @openmeter/typespec-typescript. DO NOT EDIT.

import type { output, ZodType } from 'zod'

export function toCamelCase(name: string): string {
  return name.replace(/_([a-z0-9])/g, (_m, c: string) => c.toUpperCase())
}

export function toSnakeCase(name: string): string {
  return name.replace(/([A-Z])/g, (_m, c: string) => `_${c.toLowerCase()}`)
}

type ZodDef = {
  type: string
  innerType?: ZodType
  valueType?: ZodType
  element?: ZodType
  discriminator?: string
  options?: ZodType[]
  entries?: Record<string, string>
  defaultValue?: unknown
}

function def(schema: ZodType | undefined): ZodDef | undefined {
  return (schema as { def?: ZodDef } | undefined)?.def
}

// Unwrap optional/nullable/default to the schema that describes the value's
// shape. These wrappers never change keys, so the walker looks through them
// before classifying a node.
function unwrap(schema: ZodType | undefined): ZodType | undefined {
  let current = schema
  for (let i = 0; i < 100 && current; i++) {
    const d = def(current)
    if (
      d &&
      (d.type === 'optional' ||
        d.type === 'nullable' ||
        d.type === 'default') &&
      d.innerType
    ) {
      current = d.innerType
      continue
    }
    return current
  }
  /* v8 ignore next -- loop returns inside; reached only past the cycle guard */
  return current
}

// The default to write for a field the caller omitted, present only when the
// field is required-with-default in the spec — emitted as `.default(x)` with no
// `.optional()` in the wrapper chain (TypeSpec `field: T = x`, not
// `field?: T = x`). Such fields must be present on the wire (e.g. a CloudEvents
// envelope's `specversion`, which the server's parser rejects when absent), yet
// the generated request type allows omitting them so callers get the documented
// default for free — toWire is the layer that has to reconcile the two.
// Spec-optional fields (`.optional().default(x)`) return undefined and stay off
// the wire: their default is the server's to apply, and materializing them
// client-side would silently overwrite server state on update requests.
function requiredDefault(
  schema: ZodType | undefined,
): { value: unknown } | undefined {
  let current = schema
  let found: { value: unknown } | undefined
  for (let i = 0; i < 100 && current; i++) {
    const d = def(current)
    /* v8 ignore next 3 -- every zod schema carries a def; guards the type only */
    if (!d) {
      break
    }
    if (d.type === 'optional') {
      return undefined
    }
    if (d.type === 'default') {
      found ??= { value: d.defaultValue }
    } else if (d.type !== 'nullable') {
      break
    }
    current = d.innerType
  }
  return found
}

function shapeOf(
  schema: ZodType | undefined,
): Record<string, ZodType> | undefined {
  return (schema as { shape?: Record<string, ZodType> } | undefined)?.shape
}

// The element schema for array data: the schema's own element when it is an
// array, or the array variant's element when it is a union of T and T[]
// (the single-or-batch body shape).
function arrayElement(schema: ZodType | undefined): ZodType | undefined {
  const d = def(schema)
  if (d?.type === 'array') {
    return d.element
  }
  if (d?.type === 'union') {
    for (const option of d.options ?? []) {
      const od = def(unwrap(option))
      if (od?.type === 'array') {
        return od.element
      }
    }
  }
  return undefined
}

// Whether a record value schema needs walking: it carries renamable fields
// (object/record/array/union of such) or date-typed values that must map
// between `Date` and the RFC 3339 wire string. Other scalars, literals,
// unknown, and any are left untouched, so user data (labels, dimensions,
// event payloads) is never rewritten.
function needsWalk(schema: ZodType | undefined): boolean {
  const s = unwrap(schema)
  const d = def(s)
  /* v8 ignore next 3 -- a record always has a value schema; defensive only */
  if (!d) {
    return false
  }
  if (
    d.type === 'object' ||
    d.type === 'record' ||
    d.type === 'date' ||
    d.type === 'bigint'
  ) {
    return true
  }
  if (d.type === 'array') {
    return needsWalk(d.element)
  }
  if (d.type === 'union') {
    return (d.options ?? []).some(needsWalk)
  }
  return false
}

// Thrown by toWire when a bigint value (an int64/uint64 field) is outside
// JSON's exactly-representable integer range. The wire carries int64 as a JSON
// number (the server decodes it into a Go int64), so a value beyond 2^53-1
// cannot be sent without silent precision corruption — a typed, immediate
// failure is the only honest option. request() catches it like any Error and
// surfaces it as Result.error.
export class UnsafeIntegerError extends Error {
  constructor(value: bigint) {
    super(
      `bigint value ${value} exceeds JSON's safe integer range and cannot be sent without precision loss`,
    )
    this.name = 'UnsafeIntegerError'
  }
}

// An RFC 3339 string standing in for a `Date` on request input. The
// `Record<never, never>` intersection keeps a plain string assignable while
// stopping the union simplifier from absorbing sibling string literals — a
// bare `| string` would collapse `'immediate' | 'next_billing_cycle' | Date |
// string` to `string | Date` and kill literal autocomplete.
export type DateString = string & Record<never, never>

// The request-side widening of a payload type: every `Date` also accepts its
// RFC 3339 string form. Applied to the generated `…Request` aliases only —
// domain interfaces and response types stay `Date`, so responses always carry
// real `Date`s. At runtime the mapper passes request strings through verbatim
// (never re-parses or normalizes them), so the wire sees exactly what was given
// and the optional wire validation still checks the string against the RFC 3339
// wire schema.
export type AcceptDateStrings<T> = T extends Date
  ? Date | DateString
  : T extends (infer E)[]
    ? AcceptDateStrings<E>[]
    : T extends object
      ? { [K in keyof T]: AcceptDateStrings<T[K]> }
      : T

// Literal siblings of `Date` must survive the widening. Checked at compile
// time in both the emitter build and the generated SDK's typecheck, so a
// regression to a bare `| string` arm fails the build.
type _LiteralsSurviveWidening =
  'x' extends Extract<AcceptDateStrings<'x' | Date>, 'x'>
    ? true
    : {
        __error: 'AcceptDateStrings absorbed literal union members into string'
      }
const _literalsSurviveWidening: _LiteralsSurviveWidening = true
void _literalsSurviveWidening

type Direction = {
  // The wire→public or public→wire key rename for object fields.
  rename: (key: string) => string
  // The data key holding a discriminated union's discriminator, given the
  // schema's (camelCase) discriminator key.
  discriminatorKey: (camelKey: string) => string
  // The value mapping at a date-typed node: public `Date` → RFC 3339 wire
  // string, wire string → `Date`. Values already in the target form (or not
  // convertible) pass through unchanged.
  mapDate: (value: unknown) => unknown
  // The value mapping at a bigint-typed (int64/uint64) node: public `bigint` →
  // JSON number (throwing UnsafeIntegerError beyond 2^53-1, where JSON numbers
  // lose integer precision), wire number → `bigint`. Without the public→wire
  // mapping, JSON.stringify throws an opaque TypeError on any bigint. Values
  // already in the target form (or not convertible) pass through unchanged.
  mapBigInt: (value: unknown) => unknown
  // Whether absent required-with-default fields are materialized (see
  // requiredDefault). True only public→wire: requests must satisfy the wire
  // contract, while responses are reported as the server sent them — fromWire
  // fabricating fields would mask genuine contract violations.
  applyDefaults: boolean
  // JSON.stringify omits object properties and record entries whose value is
  // undefined. True only public→wire so validation sees the effective JSON
  // payload instead of an intermediate object that the transport cannot send.
  // Array entries are deliberately unaffected: JSON serializes undefined array
  // values as null rather than omitting them.
  omitUndefinedObjectEntries: boolean
}

// A handful of schemas are genuinely self-referential (e.g. the `and`/`or`
// legs of a filter tree), so nesting depth is bounded only by the DATA the
// server sends, not by the schema. Without a limit, a crafted or
// accidentally-deep response recurses until the JS engine throws a raw
// `RangeError: Maximum call stack size exceeded` — still caught by request()
// and surfaced as Result.error, but as an opaque native error instead of a
// typed one. 500 levels is far beyond any real filter/record/array nesting
// in the API today; it exists to fail predictably, not to constrain valid data.
const MAX_WALK_DEPTH = 500

export class DepthLimitExceededError extends Error {
  constructor() {
    super(`wire mapping exceeded maximum nesting depth (${MAX_WALK_DEPTH})`)
    this.name = 'DepthLimitExceededError'
  }
}

function walk(
  data: unknown,
  schema: ZodType | undefined,
  dir: Direction,
  depth = 0,
): unknown {
  if (data === null || data === undefined) {
    return data
  }
  // A Date can only ever mean its wire serialization, wherever it sits — a
  // typed date field, a record value, or an unknown-schema position. Wire→
  // public data never contains Date instances (it comes from JSON.parse), so
  // this only rewrites public→wire. The same holds for bigint: JSON.parse
  // never produces one, and public→wire it must become a JSON number wherever
  // it sits.
  if (data instanceof Date) {
    return dir.mapDate(data)
  }
  if (typeof data === 'bigint') {
    return dir.mapBigInt(data)
  }
  if (depth > MAX_WALK_DEPTH) {
    throw new DepthLimitExceededError()
  }
  const s = unwrap(schema)
  const d = def(s)
  // A date-typed node maps between the public `Date` and the RFC 3339 wire
  // string (fromWire revives the string; a string handed to toWire by an
  // untyped caller passes through as-is).
  if (d?.type === 'date') {
    return dir.mapDate(data)
  }
  // A bigint-typed node revives the wire's JSON number into the public
  // `bigint` (public→wire bigints were already mapped by the value check
  // above, so only fromWire reaches a number here).
  if (d?.type === 'bigint') {
    return dir.mapBigInt(data)
  }
  if (Array.isArray(data)) {
    // The schema may be the array itself or a union with an array variant
    // (e.g. a single-or-batch body `T | T[]`); resolve the element schema from
    // whichever applies so array items are still walked with their shape.
    const element = arrayElement(s)
    return data.map((item) => walk(item, element, dir, depth + 1))
  }
  if (typeof data !== 'object') {
    // A wire datetime can sit behind a union (`DateTime | null`,
    // enum-or-DateTime): revive the string only when the union's date variant
    // is its sole plausible owner, so enum literals and plain-string variants
    // pass through untouched.
    if (
      typeof data === 'string' &&
      d?.type === 'union' &&
      unionDateClaims(s, data)
    ) {
      return dir.mapDate(data)
    }
    return data
  }
  const record = data as Record<string, unknown>

  if (d?.type === 'record') {
    // Record keys are user data (label/dimension names) — preserved verbatim.
    // Only the value is walked, and only when it needs mapping. A null
    // prototype avoids the `__proto__` key silently reassigning `out`'s own
    // prototype instead of becoming a visible entry (user data may contain
    // any key, including reserved object-literal property names). The
    // prototype is restored once every key is a plain own property, so the
    // returned object still behaves normally for consumers (instanceof,
    // template literals) — `Object.prototype` itself was never touched.
    const valueSchema = needsWalk(d.valueType) ? d.valueType : undefined
    const out: Record<string, unknown> = Object.create(null)
    for (const [key, value] of Object.entries(record)) {
      if (dir.omitUndefinedObjectEntries && value === undefined) {
        continue
      }
      out[key] = valueSchema ? walk(value, valueSchema, dir, depth + 1) : value
    }
    Object.setPrototypeOf(out, Object.prototype)
    return out
  }

  if (d?.type === 'union') {
    const variant = selectVariant(record, s, dir)
    if (!variant) {
      // No confident match: leave keys untransformed rather than guess.
      return data
    }
    return walk(data, variant, dir, depth + 1)
  }

  if (d?.type === 'object') {
    const shape = shapeOf(s) ?? {}
    // A null prototype avoids two failure modes from data-controlled keys
    // like `__proto__`/`constructor`: (1) `fieldFor` below reading an
    // inherited Object.prototype member instead of correctly treating the
    // key as schema-undeclared, and (2) the assignment at the end of this
    // loop reassigning `out`'s own prototype instead of adding a visible key.
    const out: Record<string, unknown> = Object.create(null)
    for (const [key, value] of Object.entries(record)) {
      if (dir.omitUndefinedObjectEntries && value === undefined) {
        continue
      }
      const fieldSchema = fieldFor(shape, key)
      // Keys the schema does not declare are dropped, so the result matches the
      // typed shape exactly (a server-added field has no place in the type).
      if (fieldSchema === undefined) {
        continue
      }
      out[dir.rename(key)] = walk(value, fieldSchema, dir, depth + 1)
    }
    if (dir.applyDefaults) {
      // Shape keys are generated camelCase identifiers (never data-controlled),
      // so direct indexing into `record` is safe here. Runs after the data loop
      // so an explicit `key: undefined` entry is also replaced by the default.
      for (const [key, fieldSchema] of Object.entries(shape)) {
        if (record[key] !== undefined) {
          continue
        }
        const dflt = requiredDefault(fieldSchema)
        if (dflt !== undefined) {
          out[dir.rename(key)] = walk(dflt.value, fieldSchema, dir, depth + 1)
        }
      }
    }
    Object.setPrototypeOf(out, Object.prototype)
    return out
  }

  // Scalar or unknown schema: pass through untransformed.
  return data
}

// Resolve a data key to its field schema. The schema is camelCase-keyed; a
// wire→public data key is snake, so it is camelized to index the shape.
// Own-property checks (not `shape[key]`) so a data-controlled key like
// `__proto__` or `constructor` cannot resolve to an inherited
// Object.prototype member and be mistaken for a declared schema field.
function fieldFor(
  shape: Record<string, ZodType>,
  dataKey: string,
): ZodType | undefined {
  if (Object.hasOwn(shape, dataKey)) {
    return shape[dataKey]
  }
  const camelKey = toCamelCase(dataKey)
  return Object.hasOwn(shape, camelKey) ? shape[camelKey] : undefined
}

function selectVariant(
  data: Record<string, unknown>,
  schema: ZodType | undefined,
  dir: Direction,
): ZodType | undefined {
  const d = def(schema)
  const options = d?.options ?? []
  if (d?.discriminator && schema) {
    // O(1) dispatch on the discriminator literal. The data key is the wire-name in
    // fromWire (snake) and the public name in toWire (camel); the variant map is
    // keyed by the literal value, which is identical in both directions.
    const dataKey = dir.discriminatorKey(d.discriminator)
    return variantsByDiscriminator(schema, d).get(data[dataKey])
  }
  // Non-discriminated union: the codegen gate guarantees at most one object
  // variant (it fails the build for a mapped union with two or more), so the single
  // object-shaped option is unambiguous. Other variants (scalars, arrays) reach the
  // walk through their own data-kind branches, not here.
  return options.find((option) => def(unwrap(option))?.type === 'object')
}

// Memoized literal→variant map for a discriminated union, built once per schema.
const variantMapCache = new WeakMap<ZodType, Map<unknown, ZodType>>()

function variantsByDiscriminator(
  schema: ZodType,
  d: ZodDef,
): Map<unknown, ZodType> {
  const cached = variantMapCache.get(schema)
  if (cached) {
    return cached
  }
  const map = new Map<unknown, ZodType>()
  for (const option of d.options ?? []) {
    const shape = shapeOf(unwrap(option))
    const literal = literalValue(shape?.[d.discriminator as string])
    if (literal !== undefined) {
      map.set(literal, option)
    }
  }
  variantMapCache.set(schema, map)
  return map
}

function literalValue(schema: ZodType | undefined): unknown {
  const s = unwrap(schema)
  if (def(s)?.type === 'literal') {
    return (s as { value?: unknown }).value
  }
  /* v8 ignore next -- a discriminated-union variant's discriminator is a literal */
  return undefined
}

// Whether a union's date variant is the sole plausible owner of a string
// value: the union carries a date option, no string-capable sibling (a plain
// string variant, an enum containing the value, an equal string literal)
// claims it, and the value actually parses as a date. `DateTime | null`
// revives its RFC 3339 string; `'immediate' | DateTime` keeps the enum
// literal a string. Fail-open: an unclaimed string stays a string.
function unionDateClaims(schema: ZodType | undefined, value: string): boolean {
  let hasDate = false
  for (const option of def(schema)?.options ?? []) {
    const od = def(unwrap(option))
    if (od?.type === 'date') {
      hasDate = true
    } else if (od?.type === 'string') {
      return false
    } else if (
      od?.type === 'enum' &&
      Object.values(od.entries ?? {}).includes(value)
    ) {
      return false
    } else if (od?.type === 'literal' && literalValue(option) === value) {
      return false
    }
  }
  return hasDate && !Number.isNaN(Date.parse(value))
}

const toWireDirection: Direction = {
  rename: toSnakeCase,
  discriminatorKey: (camelKey) => camelKey,
  mapDate: (value) => (value instanceof Date ? value.toISOString() : value),
  mapBigInt: (value) => {
    if (typeof value !== 'bigint') {
      return value
    }
    if (
      value > BigInt(Number.MAX_SAFE_INTEGER) ||
      value < -BigInt(Number.MAX_SAFE_INTEGER)
    ) {
      throw new UnsafeIntegerError(value)
    }
    return Number(value)
  },
  applyDefaults: true,
  omitUndefinedObjectEntries: true,
}

// Path binding names are transport metadata, not JSON object member names, so
// they must remain exactly as declared while their values receive the same
// Date/bigint/default mapping used by request bodies and query parameters.
const toPathWireDirection: Direction = {
  ...toWireDirection,
  rename: (key) => key,
}

const fromWireDirection: Direction = {
  rename: toCamelCase,
  discriminatorKey: (camelKey) => toSnakeCase(camelKey),
  mapDate: (value) => (typeof value === 'string' ? new Date(value) : value),
  mapBigInt: (value) =>
    typeof value === 'number' && Number.isInteger(value)
      ? BigInt(value)
      : value,
  applyDefaults: false,
  omitUndefinedObjectEntries: false,
}

// Rewrite a request body or query object from the camelCase public shape to the
// snake_case wire shape, driven by its schema. Record keys (label/dimension names)
// are preserved; `Date` values serialize to RFC 3339 strings; `bigint` values
// (int64 fields) become JSON numbers; omitted required-with-default fields are
// filled with their declared default (see requiredDefault); explicit undefined
// object/record entries are omitted just as JSON.stringify would omit them. The
// return is typed as the input `T` so call sites stay cast-free (the runtime object
// has snake keys and wire-encoded dates, but the value is write-only — it flows
// straight into `json:`/`toURLSearchParams`, both of which accept any object).
export function toWire<T>(data: T, schema: ZodType): T {
  return walk(data, schema, toWireDirection) as T
}

// Rewrite path-parameter values to their transport representation without
// renaming the path binding keys. The returned object is subsequently validated
// against the generated `…PathParamsWire` schema and URL-encoded by the func.
export function toPathWire<T>(data: T, schema: ZodType): T {
  return walk(data, schema, toPathWireDirection) as T
}

// Rewrite a response body from the snake_case wire shape to the camelCase public
// shape: renames keys and revives RFC 3339 strings into `Date`s at date-typed
// nodes — never applies defaults or any other coercion. The result is the
// schema's output shape: `walk` produces exactly the schema's known fields in
// camelCase, so the inferred `output<S>` type describes the runtime value (the
// same wire-trust boundary as a plain `.json<T>()`, with no `.parse()`).
export function fromWire<S extends ZodType>(
  data: unknown,
  schema: S,
): output<S> {
  return walk(data, schema, fromWireDirection) as output<S>
}

// Thrown by assertValid when the optional `validate` client option is on and data
// fails its schema. request() catches it like any Error and surfaces it as
// Result.error.
export class ValidationError extends Error {
  constructor(
    message: string,
    public readonly issues: unknown,
  ) {
    super(message)
    this.name = 'ValidationError'
  }
}

// Opt-in schema check used by the funcs (when the validate option is on) against
// the snake_case wire payload: the request body after toWire, the raw response
// before fromWire, each against its generated `…Wire` schema. It is a GATE, not a
// transform — the safeParse output (coercions/defaults) is discarded, so validation
// never mutates the payload or return value. Off by default; the SDK does not
// validate by default (additive server fields must not break clients).
export function assertValid(schema: ZodType, data: unknown): void {
  const result = schema.safeParse(data)
  if (!result.success) {
    throw new ValidationError('schema validation failed', result.error.issues)
  }
}