@apifuse/provider-sdk 2.2.0-beta.62 → 2.2.0-beta.64

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 (261) hide show
  1. package/AUTHORING.md +367 -9
  2. package/CHANGELOG.md +8 -0
  3. package/LICENSE +21 -0
  4. package/README.md +1 -0
  5. package/bin/apifuse-check.ts +84 -2
  6. package/bin/apifuse-dev.ts +11 -31
  7. package/bin/apifuse-migrate-deployment.ts +200 -0
  8. package/bin/apifuse-pack-check.ts +11 -4
  9. package/bin/apifuse-pack-types.ts +187 -4
  10. package/bin/apifuse-record.ts +44 -32
  11. package/bin/apifuse-submit-check.ts +9 -5
  12. package/bin/apifuse-test.ts +3 -1
  13. package/dist/cli/commands.d.ts +1 -1
  14. package/dist/cli/commands.js +11 -0
  15. package/dist/cli/deployment-intent.d.ts +178 -0
  16. package/dist/cli/deployment-intent.js +277 -0
  17. package/dist/cli/migrate-deployment-intent.d.ts +132 -0
  18. package/dist/cli/migrate-deployment-intent.js +1552 -0
  19. package/dist/cli/migrate-provider-shape.d.ts +5 -1
  20. package/dist/cli/migrate-provider-shape.js +4 -2
  21. package/dist/cli/resolver-runtime.d.ts +21 -0
  22. package/dist/cli/resolver-runtime.js +31 -0
  23. package/dist/define.d.ts +3 -0
  24. package/dist/define.js +5 -0
  25. package/dist/dev.d.ts +3 -1
  26. package/dist/dev.js +6 -1
  27. package/dist/engine.d.ts +35 -1
  28. package/dist/engine.js +21 -2
  29. package/dist/error-resolution.d.ts +1 -0
  30. package/dist/error-resolution.js +54 -0
  31. package/dist/errors.d.ts +39 -1
  32. package/dist/errors.js +50 -0
  33. package/dist/handle-meta.d.ts +36 -1
  34. package/dist/handle-meta.js +16 -0
  35. package/dist/handle.d.ts +23 -5
  36. package/dist/handle.js +54 -8
  37. package/dist/health-scenario.d.ts +38 -0
  38. package/dist/i18n/error-messages.d.ts +86 -0
  39. package/dist/i18n/error-messages.js +192 -0
  40. package/dist/i18n/index.d.ts +1 -0
  41. package/dist/i18n/index.js +1 -0
  42. package/dist/i18n/keys.d.ts +12 -0
  43. package/dist/i18n/keys.js +76 -5
  44. package/dist/index.d.ts +11 -3
  45. package/dist/index.js +9 -1
  46. package/dist/lint.d.ts +15 -0
  47. package/dist/lint.js +469 -82
  48. package/dist/provider.d.ts +6 -6
  49. package/dist/provider.js +3 -3
  50. package/dist/runtime/browser-telemetry-binding.d.ts +5 -0
  51. package/dist/runtime/browser-telemetry-binding.js +83 -0
  52. package/dist/runtime/browser-telemetry.d.ts +76 -0
  53. package/dist/runtime/browser-telemetry.js +165 -0
  54. package/dist/runtime/browser.d.ts +3 -0
  55. package/dist/runtime/browser.js +208 -47
  56. package/dist/runtime/cache-telemetry.d.ts +71 -0
  57. package/dist/runtime/cache-telemetry.js +177 -0
  58. package/dist/runtime/cache.js +67 -7
  59. package/dist/runtime/capability-response.d.ts +3 -0
  60. package/dist/runtime/capability-response.js +56 -0
  61. package/dist/runtime/capability-telemetry.d.ts +46 -0
  62. package/dist/runtime/capability-telemetry.js +68 -0
  63. package/dist/runtime/diagnostic-env.d.ts +3 -0
  64. package/dist/runtime/diagnostic-env.js +10 -1
  65. package/dist/runtime/engine-mode.d.ts +74 -0
  66. package/dist/runtime/engine-mode.js +152 -0
  67. package/dist/runtime/handle.js +9 -1
  68. package/dist/runtime/http-telemetry-guard.d.ts +10 -0
  69. package/dist/runtime/http-telemetry-guard.js +62 -10
  70. package/dist/runtime/http.js +141 -35
  71. package/dist/runtime/instrumentation.js +92 -13
  72. package/dist/runtime/ocr-telemetry.d.ts +82 -0
  73. package/dist/runtime/ocr-telemetry.js +192 -0
  74. package/dist/runtime/ocr.js +35 -8
  75. package/dist/runtime/request-telemetry.d.ts +2 -0
  76. package/dist/runtime/request-telemetry.js +2 -1
  77. package/dist/runtime/resolver-runtime-options.d.ts +14 -0
  78. package/dist/runtime/resolver-runtime-options.js +33 -0
  79. package/dist/runtime/resolver-shared.d.ts +3 -2
  80. package/dist/runtime/resolver-shared.js +4 -4
  81. package/dist/runtime/resolver-telemetry.d.ts +1 -0
  82. package/dist/runtime/resolver-telemetry.js +1 -1
  83. package/dist/runtime/resolver-vendors/browser.d.ts +2 -0
  84. package/dist/runtime/resolver-vendors/browser.js +46 -0
  85. package/dist/runtime/resolver-vendors/hosts.d.ts +21 -0
  86. package/dist/runtime/resolver-vendors/hosts.js +35 -0
  87. package/dist/runtime/resolver-vendors/hypersolutions.js +2 -2
  88. package/dist/runtime/resolver-vendors/types.d.ts +5 -4
  89. package/dist/runtime/resolver.d.ts +4 -1
  90. package/dist/runtime/resolver.js +46 -13
  91. package/dist/runtime/state-telemetry.d.ts +79 -0
  92. package/dist/runtime/state-telemetry.js +194 -0
  93. package/dist/runtime/state.d.ts +3 -0
  94. package/dist/runtime/state.js +26 -7
  95. package/dist/runtime/stealth-cookies.d.ts +5 -1
  96. package/dist/runtime/stealth-cookies.js +21 -1
  97. package/dist/runtime/stealth-owned-headers.d.ts +26 -0
  98. package/dist/runtime/stealth-owned-headers.js +37 -0
  99. package/dist/runtime/stealth-telemetry.d.ts +92 -0
  100. package/dist/runtime/stealth-telemetry.js +179 -0
  101. package/dist/runtime/stealth.d.ts +4 -1
  102. package/dist/runtime/stealth.js +228 -85
  103. package/dist/runtime/stt-telemetry.d.ts +77 -0
  104. package/dist/runtime/stt-telemetry.js +176 -0
  105. package/dist/runtime/stt.js +15 -6
  106. package/dist/runtime-boundary-lint.d.ts +132 -0
  107. package/dist/runtime-boundary-lint.js +586 -0
  108. package/dist/server/error-stack-frames.d.ts +35 -0
  109. package/dist/server/error-stack-frames.js +219 -0
  110. package/dist/server/index.d.ts +7 -0
  111. package/dist/server/index.js +4 -0
  112. package/dist/server/sdk-version.d.ts +3 -0
  113. package/dist/server/sdk-version.js +19 -0
  114. package/dist/server/self-test.js +1 -18
  115. package/dist/server/sensitive-values.d.ts +1 -1
  116. package/dist/server/sensitive-values.js +9 -0
  117. package/dist/server/serve-implementation.d.ts +51 -2
  118. package/dist/server/serve-implementation.js +337 -90
  119. package/dist/server/types.d.ts +1 -0
  120. package/dist/server/types.js +7 -0
  121. package/dist/stateful/stateful-provider-owner-forwarder.js +22 -0
  122. package/dist/stateful/stateful-provider-runtime-context.d.ts +3 -0
  123. package/dist/stateful/stateful-provider-runtime-context.js +1 -0
  124. package/dist/types.d.ts +123 -1
  125. package/dist/typescript-module.d.ts +39 -0
  126. package/dist/typescript-module.js +72 -0
  127. package/package.json +6 -1
  128. package/src/cli/commands.ts +14 -0
  129. package/src/cli/deployment-intent.ts +364 -0
  130. package/src/cli/migrate-deployment-intent.ts +1908 -0
  131. package/src/cli/migrate-provider-shape.ts +10 -4
  132. package/src/cli/resolver-runtime.ts +72 -0
  133. package/src/define.ts +13 -0
  134. package/src/dev.ts +9 -2
  135. package/src/engine.ts +38 -2
  136. package/src/error-resolution.ts +58 -0
  137. package/src/errors.ts +77 -1
  138. package/src/handle-meta.ts +51 -1
  139. package/src/handle.ts +92 -10
  140. package/src/health-scenario.ts +38 -0
  141. package/src/i18n/error-messages.ts +255 -0
  142. package/src/i18n/index.ts +1 -0
  143. package/src/i18n/keys.ts +80 -6
  144. package/src/index.ts +86 -0
  145. package/src/lint.ts +604 -91
  146. package/src/provider.ts +12 -0
  147. package/src/runtime/browser-telemetry-binding.ts +92 -0
  148. package/src/runtime/browser-telemetry.ts +289 -0
  149. package/src/runtime/browser.ts +249 -65
  150. package/src/runtime/cache-telemetry.ts +247 -0
  151. package/src/runtime/cache.ts +68 -17
  152. package/src/runtime/capability-response.ts +53 -0
  153. package/src/runtime/capability-telemetry.ts +101 -0
  154. package/src/runtime/diagnostic-env.ts +11 -1
  155. package/src/runtime/engine-mode.ts +233 -0
  156. package/src/runtime/handle.ts +9 -1
  157. package/src/runtime/http-telemetry-guard.ts +66 -13
  158. package/src/runtime/http.ts +168 -37
  159. package/src/runtime/instrumentation.ts +105 -16
  160. package/src/runtime/ocr-telemetry.ts +308 -0
  161. package/src/runtime/ocr.ts +114 -74
  162. package/src/runtime/request-telemetry.ts +2 -1
  163. package/src/runtime/resolver-runtime-options.ts +50 -0
  164. package/src/runtime/resolver-shared.ts +9 -4
  165. package/src/runtime/resolver-telemetry.ts +1 -1
  166. package/src/runtime/resolver-vendors/browser.ts +65 -7
  167. package/src/runtime/resolver-vendors/hosts.ts +43 -0
  168. package/src/runtime/resolver-vendors/hypersolutions.ts +2 -2
  169. package/src/runtime/resolver-vendors/types.ts +5 -4
  170. package/src/runtime/resolver.ts +64 -21
  171. package/src/runtime/state-telemetry.ts +298 -0
  172. package/src/runtime/state.ts +46 -45
  173. package/src/runtime/stealth-cookies.ts +24 -1
  174. package/src/runtime/stealth-owned-headers.ts +41 -0
  175. package/src/runtime/stealth-telemetry.ts +304 -0
  176. package/src/runtime/stealth.ts +355 -150
  177. package/src/runtime/stt-telemetry.ts +282 -0
  178. package/src/runtime/stt.ts +93 -71
  179. package/src/runtime-boundary-lint.ts +703 -0
  180. package/src/server/error-stack-frames.ts +214 -0
  181. package/src/server/index.ts +53 -0
  182. package/src/server/sdk-version.ts +22 -0
  183. package/src/server/self-test.ts +1 -21
  184. package/src/server/sensitive-values.ts +14 -0
  185. package/src/server/serve-implementation.ts +569 -108
  186. package/src/server/types.ts +7 -0
  187. package/src/stateful/stateful-provider-owner-forwarder.ts +23 -0
  188. package/src/stateful/stateful-provider-runtime-context.ts +4 -0
  189. package/src/types.ts +128 -1
  190. package/src/typescript-module.ts +100 -0
  191. package/src/cli/__tests__/fixtures/migrate-operation-declaration/approval-override.ts.txt +0 -6
  192. package/src/cli/__tests__/fixtures/migrate-operation-declaration/codemod-syntax.ts.txt +0 -3
  193. package/src/cli/__tests__/fixtures/migrate-operation-declaration/connection-precedence.ts.txt +0 -10
  194. package/src/cli/__tests__/fixtures/migrate-operation-declaration/discovery-ambiguous-a.ts.txt +0 -3
  195. package/src/cli/__tests__/fixtures/migrate-operation-declaration/discovery-ambiguous-b.ts.txt +0 -3
  196. package/src/cli/__tests__/fixtures/migrate-operation-declaration/discovery-ambiguous-barrel.ts.txt +0 -2
  197. package/src/cli/__tests__/fixtures/migrate-operation-declaration/discovery-ambiguous-index.ts.txt +0 -3
  198. package/src/cli/__tests__/fixtures/migrate-operation-declaration/discovery-computed.ts.txt +0 -6
  199. package/src/cli/__tests__/fixtures/migrate-operation-declaration/discovery-cycle-a.ts.txt +0 -1
  200. package/src/cli/__tests__/fixtures/migrate-operation-declaration/discovery-cycle-b.ts.txt +0 -1
  201. package/src/cli/__tests__/fixtures/migrate-operation-declaration/discovery-cycle-index.ts.txt +0 -3
  202. package/src/cli/__tests__/fixtures/migrate-operation-declaration/discovery-daiso-barrel.ts.txt +0 -7
  203. package/src/cli/__tests__/fixtures/migrate-operation-declaration/discovery-daiso-catalog.ts.txt +0 -19
  204. package/src/cli/__tests__/fixtures/migrate-operation-declaration/discovery-daiso-docs.ts.txt +0 -6
  205. package/src/cli/__tests__/fixtures/migrate-operation-declaration/discovery-daiso-index.ts.txt +0 -3
  206. package/src/cli/__tests__/fixtures/migrate-operation-declaration/discovery-daiso-stores.ts.txt +0 -8
  207. package/src/cli/__tests__/fixtures/migrate-operation-declaration/discovery-indirect-config.ts.txt +0 -5
  208. package/src/cli/__tests__/fixtures/migrate-operation-declaration/discovery-nonstatic.ts.txt +0 -4
  209. package/src/cli/__tests__/fixtures/migrate-operation-declaration/discovery-package-index.ts.txt +0 -3
  210. package/src/cli/__tests__/fixtures/migrate-operation-declaration/discovery-reexport-index.ts.txt +0 -3
  211. package/src/cli/__tests__/fixtures/migrate-operation-declaration/discovery-reexport-leaf.ts.txt +0 -10
  212. package/src/cli/__tests__/fixtures/migrate-operation-declaration/discovery-reexport-one.ts.txt +0 -1
  213. package/src/cli/__tests__/fixtures/migrate-operation-declaration/discovery-reexport-two.ts.txt +0 -1
  214. package/src/cli/__tests__/fixtures/migrate-operation-declaration/discovery-runtime-composed-index.ts.txt +0 -3
  215. package/src/cli/__tests__/fixtures/migrate-operation-declaration/discovery-runtime-composed-one.ts.txt +0 -6
  216. package/src/cli/__tests__/fixtures/migrate-operation-declaration/discovery-runtime-composed-two.ts.txt +0 -6
  217. package/src/cli/__tests__/fixtures/migrate-operation-declaration/discovery-runtime-mixed-index.ts.txt +0 -14
  218. package/src/cli/__tests__/fixtures/migrate-operation-declaration/discovery-runtime-registry.ts.txt +0 -3
  219. package/src/cli/__tests__/fixtures/migrate-operation-declaration/discovery-star-package-barrel.ts.txt +0 -2
  220. package/src/cli/__tests__/fixtures/migrate-operation-declaration/discovery-star-package-index.ts.txt +0 -3
  221. package/src/cli/__tests__/fixtures/migrate-operation-declaration/discovery-star-package-leaf.ts.txt +0 -3
  222. package/src/cli/__tests__/fixtures/migrate-operation-declaration/docs-conflict.ts.txt +0 -8
  223. package/src/cli/__tests__/fixtures/migrate-operation-declaration/examples-hoisted-array-spread.ts.txt +0 -15
  224. package/src/cli/__tests__/fixtures/migrate-operation-declaration/examples-imported-array-spread.ts.txt +0 -9
  225. package/src/cli/__tests__/fixtures/migrate-operation-declaration/examples-kebab-operation-id.ts.txt +0 -17
  226. package/src/cli/__tests__/fixtures/migrate-operation-declaration/examples-map.ts.txt +0 -5
  227. package/src/cli/__tests__/fixtures/migrate-operation-declaration/examples-mixed-array-spread.ts.txt +0 -24
  228. package/src/cli/__tests__/fixtures/migrate-operation-declaration/examples-operation.ts.txt +0 -16
  229. package/src/cli/__tests__/fixtures/migrate-operation-declaration/examples-snake-operation-id.ts.txt +0 -21
  230. package/src/cli/__tests__/fixtures/migrate-operation-declaration/factory-duplicate-provider.ts.txt +0 -11
  231. package/src/cli/__tests__/fixtures/migrate-operation-declaration/factory-id-unresolved.ts.txt +0 -7
  232. package/src/cli/__tests__/fixtures/migrate-operation-declaration/factory-map.ts.txt +0 -3
  233. package/src/cli/__tests__/fixtures/migrate-operation-declaration/factory-one-to-many.ts.txt +0 -18
  234. package/src/cli/__tests__/fixtures/migrate-operation-declaration/factory-one-to-one.ts.txt +0 -13
  235. package/src/cli/__tests__/fixtures/migrate-operation-declaration/hoist-all.ts.txt +0 -31
  236. package/src/cli/__tests__/fixtures/migrate-operation-declaration/hoisted-const.ts.txt +0 -11
  237. package/src/cli/__tests__/fixtures/migrate-operation-declaration/imported-spread.ts.txt +0 -11
  238. package/src/cli/__tests__/fixtures/migrate-operation-declaration/inline-map.ts.txt +0 -11
  239. package/src/cli/__tests__/fixtures/migrate-operation-declaration/inline-spread-cast-tail.ts.txt +0 -21
  240. package/src/cli/__tests__/fixtures/migrate-operation-declaration/inline-spread-ekitan.ts.txt +0 -11
  241. package/src/cli/__tests__/fixtures/migrate-operation-declaration/inline-spread-override.ts.txt +0 -14
  242. package/src/cli/__tests__/fixtures/migrate-operation-declaration/locale-canonical-en.json +0 -1
  243. package/src/cli/__tests__/fixtures/migrate-operation-declaration/locale-canonical-ja.json +0 -1
  244. package/src/cli/__tests__/fixtures/migrate-operation-declaration/locale-canonical-ko.json +0 -1
  245. package/src/cli/__tests__/fixtures/migrate-operation-declaration/locale-existing-operation-namespace-en.json +0 -8
  246. package/src/cli/__tests__/fixtures/migrate-operation-declaration/missing-english-locale.ts.txt +0 -7
  247. package/src/cli/__tests__/fixtures/migrate-operation-declaration/no-safety.ts.txt +0 -6
  248. package/src/cli/__tests__/fixtures/migrate-operation-declaration/non-literal.ts.txt +0 -7
  249. package/src/cli/__tests__/fixtures/migrate-operation-declaration/redundant-approval.ts.txt +0 -6
  250. package/src/cli/__tests__/fixtures/migrate-operation-declaration/registry-binding-ambiguous.ts.txt +0 -12
  251. package/src/cli/__tests__/fixtures/migrate-operation-declaration/registry-imported-other.ts.txt +0 -7
  252. package/src/cli/__tests__/fixtures/migrate-operation-declaration/registry-imported.ts.txt +0 -5
  253. package/src/cli/__tests__/fixtures/migrate-operation-declaration/registry-key-ambiguous.ts.txt +0 -19
  254. package/src/cli/__tests__/fixtures/migrate-operation-declaration/registry-shorthand.ts.txt +0 -9
  255. package/src/cli/__tests__/fixtures/migrate-operation-declaration/registry-typed.ts.txt +0 -11
  256. package/src/cli/__tests__/fixtures/migrate-operation-declaration/registry-unrelated.ts.txt +0 -13
  257. package/src/cli/__tests__/fixtures/migrate-operation-declaration/safety-conflict.ts.txt +0 -7
  258. package/src/cli/__tests__/fixtures/migrate-operation-declaration/stream.ts.txt +0 -7
  259. package/src/cli/__tests__/fixtures/migrate-operation-declaration/tool-router-spread.ts.txt +0 -15
  260. package/src/cli/__tests__/fixtures/migrate-operation-declaration/unparseable.ts.txt +0 -4
  261. package/src/cli/__tests__/fixtures/migrate-operation-declaration/verbatim-template.ts.txt +0 -12
package/src/handle.ts CHANGED
@@ -18,9 +18,12 @@ import {
18
18
  APIFUSE_HANDLE_META_KEY,
19
19
  formatHandleIssuers,
20
20
  handleFieldDescription,
21
+ handleHasDirectionalFieldNames,
21
22
  HANDLE_KIND_NAME_PATTERN,
22
23
  type HandleAccess,
24
+ type HandleFieldDirection,
23
25
  type HandleFieldMeta,
26
+ type HandleFieldNames,
24
27
  type HandleIssuedBy,
25
28
  type HandleKindDeclaration,
26
29
  type HandleKindType,
@@ -36,7 +39,9 @@ import type { ProviderStateDurationString } from "./types.js";
36
39
 
37
40
  export type {
38
41
  HandleAccess,
42
+ HandleFieldDirection,
39
43
  HandleFieldMeta,
44
+ HandleFieldNames,
40
45
  HandleIssuedBy,
41
46
  HandleKindDeclaration,
42
47
  HandleKindType,
@@ -69,6 +74,10 @@ export interface HandleKindBase<TSchema extends ZodType = ZodType> extends Handl
69
74
  /**
70
75
  * Schema for the handle field, usable in output and input positions. Carries
71
76
  * the `x-apifuse-handle` meta and an SDK-owned English description.
77
+ *
78
+ * One schema serves both directions even when the kind declares separate
79
+ * `input` / `output` property keys: the meta carries both names and lint
80
+ * checks the key against the side the field actually appears on.
72
81
  */
73
82
  field(): ZodString;
74
83
  }
@@ -259,9 +268,20 @@ export function isHandleError(value: unknown): value is HandleError {
259
268
  */
260
269
  export function handleRecoverySentence(kind: {
261
270
  readonly fieldName: string;
271
+ readonly outputFieldName?: string;
262
272
  readonly issuedBy?: HandleIssuedBy;
263
273
  }): string {
264
274
  const issuers = formatHandleIssuers(kind.issuedBy);
275
+ // When the kind is issued under one key and accepted under another, naming
276
+ // only one of them tells the LLM to look for a field that is not there. Name
277
+ // the output key it reads and the input key it writes, in that order.
278
+ if (handleHasDirectionalFieldNames(kind)) {
279
+ const issued = kind.outputFieldName as string;
280
+ if (issuers) {
281
+ return `Call ${issuers} again and pass the new \`${issued}\` back as \`${kind.fieldName}\`, exactly as returned.`;
282
+ }
283
+ return `Request a new \`${issued}\` and pass it back as \`${kind.fieldName}\`, exactly as returned.`;
284
+ }
265
285
  if (issuers) {
266
286
  return `Call ${issuers} again and pass the new \`${kind.fieldName}\` exactly as returned.`;
267
287
  }
@@ -317,8 +337,16 @@ export function parseHandleDurationMs(value: ProviderStateDurationString, label:
317
337
  export interface DefineCursorOptions<TSchema extends ZodType> {
318
338
  /** Kind name, `/^[a-z]{2,12}$/`; becomes the handle prefix. */
319
339
  readonly name: string;
320
- /** Schema property key carrying the handle. Default `${name}_token`. */
321
- readonly fieldName?: string;
340
+ /**
341
+ * Schema property key carrying the handle. Default `${name}_token`.
342
+ *
343
+ * Pass `{ input, output }` when the caller-facing contract names the same
344
+ * handle differently in each direction — the canon's `{ input: "cursor",
345
+ * output: "next_cursor" }`. Both keys must be given; the handle's identity
346
+ * stays the kind, so a value issued under `output` is still only valid when
347
+ * sent back under `input`.
348
+ */
349
+ readonly fieldName?: string | HandleFieldNames;
322
350
  readonly schema: TSchema;
323
351
  readonly ttl: ProviderStateDurationString;
324
352
  /** `"bound"` (default): connection-scoped, 2 words. `"public"`: provider-scoped, 4-5 words. */
@@ -338,7 +366,12 @@ export interface DefineDraftOptions<
338
366
  TResultSchema extends ZodType | undefined,
339
367
  > {
340
368
  readonly name: string;
341
- readonly fieldName?: string;
369
+ /**
370
+ * Schema property key carrying the handle. Default `${name}_token`. Accepts
371
+ * `{ input, output }` for a contract that names the handle differently in
372
+ * each direction; see {@link DefineCursorOptions.fieldName}.
373
+ */
374
+ readonly fieldName?: string | HandleFieldNames;
342
375
  readonly schema: TSchema;
343
376
  /** Optional schema of the commit result (type-level). */
344
377
  readonly result?: TResultSchema;
@@ -372,12 +405,59 @@ function assertKindName(name: unknown, define: string): asserts name is string {
372
405
  }
373
406
  }
374
407
 
375
- function assertFieldName(fieldName: string, define: string): void {
376
- if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(fieldName)) {
408
+ function assertFieldName(
409
+ fieldName: unknown,
410
+ define: string,
411
+ label: string,
412
+ ): asserts fieldName is string {
413
+ if (typeof fieldName !== "string" || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(fieldName)) {
414
+ throw new Error(
415
+ `${define}: ${label} must be a plain identifier usable as a schema property key; received ${JSON.stringify(fieldName)}.`,
416
+ );
417
+ }
418
+ }
419
+
420
+ /**
421
+ * Resolves the `fieldName` option to the input key and, when the two differ,
422
+ * the output key.
423
+ *
424
+ * The string form names one key for both directions and is unchanged. The
425
+ * object form requires BOTH keys: a half-declaration would leave one side
426
+ * silently defaulting to `${name}_token`, which is the kind of mismatch this
427
+ * option exists to make explicit. `outputFieldName` is left undefined when the
428
+ * two keys are equal so a one-name kind emits exactly the meta it always did.
429
+ */
430
+ function resolveFieldNames(
431
+ value: string | HandleFieldNames | undefined,
432
+ define: string,
433
+ name: string,
434
+ ): { fieldName: string; outputFieldName?: string } {
435
+ if (value === undefined) return { fieldName: `${name}_token` };
436
+ if (typeof value === "string") {
437
+ assertFieldName(value, define, "fieldName");
438
+ return { fieldName: value };
439
+ }
440
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
441
+ throw new Error(
442
+ `${define}: "${name}" fieldName must be a property key or { input, output }; received ${JSON.stringify(value)}.`,
443
+ );
444
+ }
445
+ const record = value as unknown as Record<string, unknown>;
446
+ const extra = Object.keys(record).filter((key) => key !== "input" && key !== "output");
447
+ if (extra.length > 0) {
448
+ throw new Error(
449
+ `${define}: "${name}" fieldName accepts only "input" and "output"; received extra ${extra.map((key) => JSON.stringify(key)).join(", ")}.`,
450
+ );
451
+ }
452
+ if (record.input === undefined || record.output === undefined) {
377
453
  throw new Error(
378
- `${define}: fieldName must be a plain identifier usable as a schema property key; received ${JSON.stringify(fieldName)}.`,
454
+ `${define}: "${name}" fieldName must declare both "input" and "output" when the two directions differ; declaring one would leave the other defaulting to "${name}_token".`,
379
455
  );
380
456
  }
457
+ assertFieldName(record.input, define, "fieldName.input");
458
+ assertFieldName(record.output, define, "fieldName.output");
459
+ if (record.input === record.output) return { fieldName: record.input };
460
+ return { fieldName: record.input, outputFieldName: record.output };
381
461
  }
382
462
 
383
463
  function assertPositiveInteger(value: number, label: string, define: string): void {
@@ -442,8 +522,7 @@ export function defineCursor<TSchema extends ZodType>(
442
522
  ): CursorKind<TSchema> {
443
523
  const define = "defineCursor";
444
524
  assertKindName(options.name, define);
445
- const fieldName = options.fieldName ?? `${options.name}_token`;
446
- assertFieldName(fieldName, define);
525
+ const { fieldName, outputFieldName } = resolveFieldNames(options.fieldName, define, options.name);
447
526
  const access: unknown = options.access ?? "bound";
448
527
  assertAccess(access, define);
449
528
  const ttlMs = parseHandleDurationMs(options.ttl, `"${options.name}" ttl`);
@@ -464,12 +543,14 @@ export function defineCursor<TSchema extends ZodType>(
464
543
  kind: options.name,
465
544
  type: "cursor",
466
545
  fieldName,
546
+ ...(outputFieldName ? { outputFieldName } : {}),
467
547
  ...(issuedBy ? { issuedBy } : {}),
468
548
  };
469
549
  return Object.freeze({
470
550
  name: options.name,
471
551
  type: "cursor",
472
552
  fieldName,
553
+ outputFieldName,
473
554
  access,
474
555
  issuedBy,
475
556
  schema: options.schema,
@@ -494,8 +575,7 @@ export function defineDraft<
494
575
  >(options: DefineDraftOptions<TSchema, TResultSchema>): DraftKind<TSchema, TResultSchema> {
495
576
  const define = "defineDraft";
496
577
  assertKindName(options.name, define);
497
- const fieldName = options.fieldName ?? `${options.name}_token`;
498
- assertFieldName(fieldName, define);
578
+ const { fieldName, outputFieldName } = resolveFieldNames(options.fieldName, define, options.name);
499
579
  if (!options.ttl || typeof options.ttl !== "object") {
500
580
  throw new Error(`${define}: draft "${options.name}" ttl must be { idle, max }.`);
501
581
  }
@@ -533,12 +613,14 @@ export function defineDraft<
533
613
  kind: options.name,
534
614
  type: "draft",
535
615
  fieldName,
616
+ ...(outputFieldName ? { outputFieldName } : {}),
536
617
  ...(issuedBy ? { issuedBy } : {}),
537
618
  };
538
619
  return Object.freeze({
539
620
  name: options.name,
540
621
  type: "draft",
541
622
  fieldName,
623
+ outputFieldName,
542
624
  access,
543
625
  strength,
544
626
  issuedBy,
@@ -545,6 +545,44 @@ export type OperationResult = {
545
545
  data: JsonValue;
546
546
  request_id: string;
547
547
  duration_ms: number;
548
+ /**
549
+ * Whether the monitor's operation step was answered from a stale-if-error
550
+ * cache serve — the runtime's projection of the response envelope's
551
+ * `meta.stale`.
552
+ *
553
+ * An operation that reads its upstream through `ctx.cache.getOrSet(...,
554
+ * { staleIfErrorMs })` answers an upstream outage with HTTP 200 and a
555
+ * well-formed, fully schema-valid body for the whole stale window. Every
556
+ * assertion over `status_code` and `data` therefore passes, and the probe
557
+ * rolls up `ok` while the upstream is down. This boolean is the only thing
558
+ * in the step result that separates that from a live read.
559
+ *
560
+ * Always present, so a reference to it resolves to `false` rather than
561
+ * raising `reference_unresolvable`; a response whose `meta` is malformed
562
+ * fails the step rather than projecting as fresh.
563
+ *
564
+ * Providers that serve stale-if-error should guard on it, so an outage the
565
+ * cache is absorbing stops reading as green:
566
+ *
567
+ * ```ts
568
+ * {
569
+ * id: "guard-freshness",
570
+ * result: "freshness-guarded",
571
+ * kind: "guard",
572
+ * condition: {
573
+ * kind: "predicate",
574
+ * operator: "not_equals",
575
+ * actual: { ref: { namespace: "steps", binding: "case", path: ["served_stale_cache"] } },
576
+ * expected: true,
577
+ * },
578
+ * onFail: {
579
+ * attribute: [{ operationId, status: "degraded", reasonCode: "expected_absence", reasonKey }],
580
+ * stop: "scenario",
581
+ * },
582
+ * }
583
+ * ```
584
+ */
585
+ served_stale_cache: boolean;
548
586
  candidate?: { attempts: number; selected_index: number | null };
549
587
  };
550
588
  export type ExtractResult<T extends JsonValue = JsonValue> = {
@@ -0,0 +1,255 @@
1
+ import { sanitizeDiagnosticText } from "../fixture-sanitization.js";
2
+ import type { ProviderLocaleCatalogMap } from "./catalog.js";
3
+ import {
4
+ getProviderLocaleSegments,
5
+ isProviderLocaleKey,
6
+ type ProviderLocale,
7
+ type ProviderLocaleValue,
8
+ } from "./keys.js";
9
+
10
+ /**
11
+ * Values interpolated into a localized provider error message or fix.
12
+ *
13
+ * Strings and finite numbers only. String values are treated as untrusted
14
+ * (upstream bodies frequently end up here), so they are scrubbed and capped by
15
+ * {@link interpolateProviderErrorText} before substitution.
16
+ */
17
+ export type ProviderErrorMessageParams = Readonly<Record<string, string | number>>;
18
+
19
+ /** Locales the SDK negotiates for client-facing provider text. */
20
+ export const PROVIDER_ERROR_LOCALES = ["en", "ko", "ja"] as const;
21
+
22
+ /** Locale used when the caller asks for nothing the provider catalog covers. */
23
+ export const DEFAULT_PROVIDER_ERROR_LOCALE = "en";
24
+
25
+ /** Catalog namespace for derived `errors.<code>.message` / `errors.<code>.fix` lookups. */
26
+ export const PROVIDER_ERROR_CATALOG_NAMESPACE = "errors";
27
+
28
+ /** Per-value cap applied to every interpolated param, before substitution. */
29
+ export const MAX_INTERPOLATED_PARAM_LENGTH = 200;
30
+
31
+ /** Cap applied to a rendered localized message or fix. */
32
+ export const MAX_LOCALIZED_ERROR_TEXT_LENGTH = 2_000;
33
+
34
+ const TRUNCATION_SUFFIX = "…";
35
+
36
+ // One pass, three alternatives: `{{` and `}}` are the literal-brace escapes and
37
+ // `{name}` is a substitution. Named groups are deliberately not used so the
38
+ // callback can distinguish an escape from a substitution by the matched text.
39
+ const PLACEHOLDER_PATTERN = /\{\{|\}\}|\{([A-Za-z_][A-Za-z0-9_]*)\}/g;
40
+
41
+ // Markup openers are removed outright rather than entity-escaped: the envelope
42
+ // is plain text, so an escaped `&lt;` would be as wrong for a terminal as a raw
43
+ // `<` is for an HTML surface. The `&` rule only fires in front of an
44
+ // entity-shaped run, so ordinary prose ("Tom & Jerry") survives while
45
+ // `&lt;script&gt;` cannot be revived by a consumer that decodes entities.
46
+ const MARKUP_OPENER_PATTERN = /[<>`]/g;
47
+ const HTML_ENTITY_AMPERSAND_PATTERN = /&(?=#?[0-9A-Za-z]{1,32};)/g;
48
+
49
+ function isProviderErrorLocale(value: string | undefined): value is ProviderLocale {
50
+ return PROVIDER_ERROR_LOCALES.some((locale) => locale === value);
51
+ }
52
+
53
+ type RankedLanguageRange = {
54
+ readonly primary: string;
55
+ readonly quality: number;
56
+ readonly position: number;
57
+ };
58
+
59
+ /**
60
+ * Negotiates an `Accept-Language` header against {@link PROVIDER_ERROR_LOCALES}.
61
+ *
62
+ * Quality values are honoured (`ko;q=0.1, ja;q=0.9` selects `ja`) and `q=0`
63
+ * means "not acceptable". Ranges are matched on the primary subtag, so `ko-KR`
64
+ * selects `ko`. Returns `undefined` when the header is absent or names no
65
+ * supported locale, so callers can consult a lower-precedence source before
66
+ * defaulting.
67
+ */
68
+ export function providerLocaleFromAcceptLanguage(
69
+ header: string | null | undefined,
70
+ ): ProviderLocale | undefined {
71
+ if (typeof header !== "string" || header.length === 0) return undefined;
72
+ const ranges: RankedLanguageRange[] = [];
73
+ for (const [position, token] of header.split(",").entries()) {
74
+ const [rangeText, ...parameters] = token.split(";");
75
+ const primary = rangeText?.trim().split("-")[0]?.toLowerCase();
76
+ if (!primary) continue;
77
+ const quality = parseQualityValue(parameters);
78
+ if (quality <= 0) continue;
79
+ ranges.push({ primary, quality, position });
80
+ }
81
+ // Stable by construction: equal qualities keep header order.
82
+ ranges.sort((left, right) =>
83
+ left.quality === right.quality ? left.position - right.position : right.quality - left.quality,
84
+ );
85
+ for (const range of ranges) {
86
+ if (isProviderErrorLocale(range.primary)) return range.primary;
87
+ }
88
+ return undefined;
89
+ }
90
+
91
+ function parseQualityValue(parameters: readonly string[]): number {
92
+ for (const parameter of parameters) {
93
+ const [name, value] = parameter.split("=");
94
+ if (name?.trim().toLowerCase() !== "q") continue;
95
+ const quality = Number.parseFloat(value?.trim() ?? "");
96
+ // A malformed `q` is not a rejection; RFC 9110 treats the range as
97
+ // fully acceptable when no usable quality is present.
98
+ if (!Number.isFinite(quality)) return 1;
99
+ return Math.min(Math.max(quality, 0), 1);
100
+ }
101
+ return 1;
102
+ }
103
+
104
+ /** Reads `accept-language` from a case-insensitive header record. */
105
+ export function acceptLanguageHeaderValue(
106
+ headers: Readonly<Record<string, unknown>> | undefined,
107
+ ): string | undefined {
108
+ if (!headers) return undefined;
109
+ for (const [name, value] of Object.entries(headers)) {
110
+ if (name.toLowerCase() === "accept-language" && typeof value === "string") return value;
111
+ }
112
+ return undefined;
113
+ }
114
+
115
+ /**
116
+ * Picks the first supported locale across header sources, in precedence order,
117
+ * falling back to {@link DEFAULT_PROVIDER_ERROR_LOCALE}.
118
+ */
119
+ export function resolveProviderErrorLocale(
120
+ ...headerValues: readonly (string | null | undefined)[]
121
+ ): ProviderLocale {
122
+ for (const value of headerValues) {
123
+ const locale = providerLocaleFromAcceptLanguage(value);
124
+ if (locale) return locale;
125
+ }
126
+ return DEFAULT_PROVIDER_ERROR_LOCALE;
127
+ }
128
+
129
+ function truncateText(value: string, limit: number): string {
130
+ return value.length <= limit ? value : `${value.slice(0, limit)}${TRUNCATION_SUFFIX}`;
131
+ }
132
+
133
+ /**
134
+ * Renders one param value as substitution text, or `undefined` when the value
135
+ * cannot be substituted (absent, non-finite, or not a string/number).
136
+ */
137
+ export function interpolatedParamText(value: unknown): string | undefined {
138
+ if (typeof value === "number") {
139
+ return Number.isFinite(value) ? String(value) : undefined;
140
+ }
141
+ if (typeof value !== "string") return undefined;
142
+ // sanitizeDiagnosticText scrubs credentials and encodes control, bidi and
143
+ // line-separator characters; the markup pass closes the HTML/markdown hole
144
+ // it deliberately leaves open for log text.
145
+ const scrubbed = sanitizeDiagnosticText(value)
146
+ .replace(MARKUP_OPENER_PATTERN, "")
147
+ .replace(HTML_ENTITY_AMPERSAND_PATTERN, "")
148
+ .replace(/\s+/g, " ")
149
+ .trim();
150
+ return truncateText(scrubbed, MAX_INTERPOLATED_PARAM_LENGTH);
151
+ }
152
+
153
+ /**
154
+ * Substitutes `{name}` placeholders in a catalog template.
155
+ *
156
+ * - `{{` and `}}` are the literal `{` and `}` escapes.
157
+ * - A placeholder with no usable param is left verbatim, so an authoring gap is
158
+ * visible in the response instead of silently deleting text.
159
+ * - Substituted text is never rescanned, so a param value containing `{other}`
160
+ * cannot trigger a second round of interpolation.
161
+ */
162
+ export function interpolateProviderErrorText(
163
+ template: string,
164
+ params?: ProviderErrorMessageParams,
165
+ ): string {
166
+ const rendered = template.replace(PLACEHOLDER_PATTERN, (match, name?: string) => {
167
+ if (match === "{{") return "{";
168
+ if (match === "}}") return "}";
169
+ if (name === undefined) return match;
170
+ const substitution = interpolatedParamText(
171
+ params && Object.hasOwn(params, name) ? params[name] : undefined,
172
+ );
173
+ return substitution ?? match;
174
+ });
175
+ return truncateText(rendered, MAX_LOCALIZED_ERROR_TEXT_LENGTH);
176
+ }
177
+
178
+ function asCatalogText(value: ProviderLocaleValue | undefined): string | undefined {
179
+ return typeof value === "string" && value.trim().length > 0 ? value : undefined;
180
+ }
181
+
182
+ function readCatalogSegments(
183
+ catalogs: ProviderLocaleCatalogMap,
184
+ segments: readonly string[],
185
+ locale: ProviderLocale,
186
+ fallbackLocale: ProviderLocale,
187
+ ): string | undefined {
188
+ return (
189
+ asCatalogText(getProviderLocaleSegments(catalogs[locale] ?? {}, segments)) ??
190
+ asCatalogText(getProviderLocaleSegments(catalogs[fallbackLocale] ?? {}, segments))
191
+ );
192
+ }
193
+
194
+ /** A key candidate considered by {@link localizeProviderErrorText}, in priority order. */
195
+ export type ProviderErrorTextCandidate =
196
+ /** An explicit dot-path key from a throw site or an `errorCodes[]` declaration. */
197
+ | { readonly kind: "key"; readonly key: string | undefined }
198
+ /** The `errors.<code>.<field>` convention; `code` is used verbatim as a segment. */
199
+ | { readonly kind: "derived"; readonly code: string | undefined; readonly field: string };
200
+
201
+ export interface LocalizeProviderErrorTextOptions {
202
+ readonly catalogs?: ProviderLocaleCatalogMap;
203
+ readonly locale: ProviderLocale;
204
+ readonly fallbackLocale?: ProviderLocale;
205
+ readonly candidates: readonly ProviderErrorTextCandidate[];
206
+ readonly params?: ProviderErrorMessageParams;
207
+ /** Literal text returned when no candidate resolves. May be `undefined` for `fix`. */
208
+ readonly fallback: string | undefined;
209
+ }
210
+
211
+ /**
212
+ * Resolves client-facing error text against the provider locale catalogs.
213
+ *
214
+ * Candidates are tried in order and each one is resolved in the caller's locale
215
+ * before the `en` catalog, so a more specific key always beats a more specific
216
+ * locale. Nothing here throws: a malformed key, an absent catalog, a
217
+ * non-string catalog value and a missing key all fall through to the next
218
+ * candidate and ultimately to `fallback`.
219
+ */
220
+ export function localizeProviderErrorText(
221
+ options: LocalizeProviderErrorTextOptions,
222
+ ): string | undefined {
223
+ const { catalogs } = options;
224
+ if (!catalogs) return options.fallback;
225
+ const fallbackLocale = options.fallbackLocale ?? DEFAULT_PROVIDER_ERROR_LOCALE;
226
+ for (const candidate of options.candidates) {
227
+ const segments = candidateSegments(candidate);
228
+ if (!segments) continue;
229
+ try {
230
+ const template = readCatalogSegments(catalogs, segments, options.locale, fallbackLocale);
231
+ if (template === undefined) continue;
232
+ return interpolateProviderErrorText(template, options.params);
233
+ } catch {
234
+ // A hostile catalog or params object (throwing getters, exotic proxies)
235
+ // must not turn a provider error into a masked 500. Fall through to the
236
+ // next candidate and ultimately to the English literal.
237
+ }
238
+ }
239
+ return options.fallback;
240
+ }
241
+
242
+ function candidateSegments(candidate: ProviderErrorTextCandidate): readonly string[] | undefined {
243
+ if (candidate.kind === "key") {
244
+ // Explicit keys must satisfy the dot-path grammar; a malformed key is a
245
+ // miss, never a throw at envelope time.
246
+ return isProviderLocaleKey(candidate.key) ? candidate.key.split(".") : undefined;
247
+ }
248
+ if (typeof candidate.code !== "string" || candidate.code.length === 0) return undefined;
249
+ return [PROVIDER_ERROR_CATALOG_NAMESPACE, candidate.code, candidate.field];
250
+ }
251
+
252
+ /** The `errors.<code>.<field>` dot path this SDK derives for an undeclared key. */
253
+ export function derivedProviderErrorCatalogPath(code: string, field: "message" | "fix"): string {
254
+ return `${PROVIDER_ERROR_CATALOG_NAMESPACE}.${code}.${field}`;
255
+ }
package/src/i18n/index.ts CHANGED
@@ -1,2 +1,3 @@
1
1
  export * from "./catalog.js";
2
+ export * from "./error-messages.js";
2
3
  export * from "./keys.js";
package/src/i18n/keys.ts CHANGED
@@ -2,8 +2,55 @@ import type { ProviderLocaleKey } from "../types.js";
2
2
 
3
3
  export type { ProviderLocale, ProviderLocaleKey } from "../types.js";
4
4
 
5
- const PROVIDER_LOCALE_KEY_RE =
6
- /^[a-z][a-z0-9]*(?:[A-Z][a-z0-9]+)*(?:\.[a-z][a-z0-9]*(?:[A-Z][a-z0-9]+)*|\.[0-9]+)*$/;
5
+ /**
6
+ * An ordinary catalog name segment: camelCase, ASCII only.
7
+ *
8
+ * `[A-Z][a-z0-9]+` forces at least one character after each hump, so a
9
+ * screaming acronym (`errorsURL`) is not silently read as camelCase.
10
+ */
11
+ const NAME_SEGMENT_RE = /^[a-z][a-z0-9]*(?:[A-Z][a-z0-9]+)*$/;
12
+
13
+ /** An array-index segment, e.g. the `0` in `meta.tags.0`. */
14
+ const INDEX_SEGMENT_RE = /^[0-9]+$/;
15
+
16
+ /**
17
+ * An error-code segment: the thrown `code` used verbatim as a catalog key.
18
+ *
19
+ * Single-case underscore words only — `UPSTREAM_SCHEMA_ERROR`, `BLOCKED`,
20
+ * `reauth_required` — matching the shape the SDK registers and the fleet
21
+ * throws (a census of 91 provider repos found 224 distinct codes: 213
22
+ * SCREAMING_SNAKE, 11 lower_snake, none mixed-case, none with a leading digit).
23
+ * A leading letter is mandatory, so no error code can be spelled like the
24
+ * numeric array-index form and the two segment kinds stay disjoint.
25
+ * The lower_snake arm requires an underscore because a bare lowercase word is
26
+ * already a {@link NAME_SEGMENT_RE} segment.
27
+ */
28
+ const ERROR_CODE_SEGMENT_RE = /^(?:[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)*|[a-z][a-z0-9]*(?:_[a-z0-9]+)+)$/;
29
+
30
+ /**
31
+ * The only parent under which an error-code segment is legal.
32
+ *
33
+ * Must stay equal to `PROVIDER_ERROR_CATALOG_NAMESPACE` in `error-messages.ts`
34
+ * (the derived-path test in `keys.test.ts` fails if they drift). Anchoring the
35
+ * code vocabulary to this one parent is what keeps the grammar from degrading
36
+ * into "any identifier anywhere": everywhere else a segment is still camelCase
37
+ * or an array index.
38
+ */
39
+ const ERROR_CODE_PARENT_SEGMENT = "errors";
40
+
41
+ /**
42
+ * Validates one segment in position.
43
+ *
44
+ * `parent` is the preceding segment, or `undefined` for the first segment. The
45
+ * first segment is always a plain name: catalogs are rooted in SDK-owned
46
+ * namespaces (`meta`, `operations`, `auth`, `errors`), never in an index or a
47
+ * code.
48
+ */
49
+ function isValidProviderLocaleSegment(segment: string, parent: string | undefined): boolean {
50
+ if (parent === undefined) return NAME_SEGMENT_RE.test(segment);
51
+ if (NAME_SEGMENT_RE.test(segment) || INDEX_SEGMENT_RE.test(segment)) return true;
52
+ return parent === ERROR_CODE_PARENT_SEGMENT && ERROR_CODE_SEGMENT_RE.test(segment);
53
+ }
7
54
 
8
55
  export function providerLocaleKey(key: string): ProviderLocaleKey {
9
56
  assertProviderLocaleKey(key);
@@ -11,13 +58,21 @@ export function providerLocaleKey(key: string): ProviderLocaleKey {
11
58
  }
12
59
 
13
60
  export function isProviderLocaleKey(value: unknown): value is ProviderLocaleKey {
14
- return typeof value === "string" && PROVIDER_LOCALE_KEY_RE.test(value);
61
+ if (typeof value !== "string" || value.length === 0) return false;
62
+ const segments = value.split(".");
63
+ for (const [index, segment] of segments.entries()) {
64
+ // `split(".")` yields "" for an empty segment ("a..b", ".a", "a."), which
65
+ // no segment pattern matches, so those stay rejected.
66
+ if (!isValidProviderLocaleSegment(segment, index === 0 ? undefined : segments[index - 1]))
67
+ return false;
68
+ }
69
+ return true;
15
70
  }
16
71
 
17
72
  export function assertProviderLocaleKey(value: unknown): asserts value is ProviderLocaleKey {
18
73
  if (!isProviderLocaleKey(value)) {
19
74
  throw new Error(
20
- `Provider locale key must be a dot path such as "meta.description" or "operations.search.description"; received ${JSON.stringify(value)}`,
75
+ `Provider locale key must be a camelCase dot path such as "meta.description" or "operations.search.description", optionally naming an error code under "${ERROR_CODE_PARENT_SEGMENT}." as in "errors.UPSTREAM_SCHEMA_ERROR.message"; received ${JSON.stringify(value)}`,
21
76
  );
22
77
  }
23
78
  }
@@ -35,9 +90,28 @@ export function getProviderLocalePath(
35
90
  key: ProviderLocaleKey | string,
36
91
  ): ProviderLocaleValue | undefined {
37
92
  assertProviderLocaleKey(key);
93
+ return getProviderLocaleSegments(catalog, key.split("."));
94
+ }
95
+
96
+ /**
97
+ * Reads a catalog path from raw segments, bypassing the dot-path grammar.
98
+ *
99
+ * Derived error-message lookups address the catalog by the thrown error code
100
+ * (`errors.<code>.message`). The grammar now accepts the code shapes the SDK
101
+ * registers and the fleet throws, but `code` is provider data: a provider may
102
+ * throw anything, and an unusual code must still resolve its catalog entry
103
+ * rather than be dropped as malformed. Own properties only: a
104
+ * `__proto__`/`constructor` segment must read as a miss, not as the prototype
105
+ * chain.
106
+ */
107
+ export function getProviderLocaleSegments(
108
+ catalog: ProviderLocaleCatalog,
109
+ segments: readonly string[],
110
+ ): ProviderLocaleValue | undefined {
111
+ if (segments.length === 0) return undefined;
38
112
  let cursor: unknown = catalog;
39
- for (const segment of key.split(".")) {
40
- if (!isRecord(cursor)) return undefined;
113
+ for (const segment of segments) {
114
+ if (!isRecord(cursor) || !Object.hasOwn(cursor, segment)) return undefined;
41
115
  cursor = cursor[segment];
42
116
  }
43
117
  return isProviderLocaleValue(cursor) ? cursor : undefined;