@apifuse/provider-sdk 2.2.0-beta.7 → 2.2.0-beta.70

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 (478) hide show
  1. package/AUTHORING.md +943 -51
  2. package/CHANGELOG.md +273 -1
  3. package/LICENSE +21 -0
  4. package/README.md +117 -29
  5. package/SUBMISSION.md +3 -2
  6. package/bin/apifuse-check.ts +293 -13
  7. package/bin/apifuse-dev.ts +42 -19
  8. package/bin/apifuse-migrate-deployment.ts +200 -0
  9. package/bin/apifuse-migrate-operation-declaration.ts +55 -0
  10. package/bin/apifuse-migrate-shape.ts +202 -0
  11. package/bin/apifuse-pack-check.ts +19 -6
  12. package/bin/apifuse-pack-smoke.ts +57 -2
  13. package/bin/apifuse-pack-types.ts +736 -38
  14. package/bin/apifuse-perf.ts +59 -23
  15. package/bin/apifuse-record.ts +923 -94
  16. package/bin/apifuse-submit-check.ts +2313 -281
  17. package/bin/apifuse-test.ts +3 -1
  18. package/bin/submit-check-delimited-text.ts +50 -0
  19. package/dist/auth-turn/index.d.ts +3 -3
  20. package/dist/auth-turn/index.js +1 -1
  21. package/dist/auth.d.ts +14 -0
  22. package/dist/auth.js +67 -0
  23. package/dist/ceremonies/index.d.ts +16 -0
  24. package/dist/ceremonies/index.js +141 -36
  25. package/dist/cli/commands.d.ts +1 -1
  26. package/dist/cli/commands.js +30 -0
  27. package/dist/cli/create.d.ts +1 -1
  28. package/dist/cli/create.js +8 -2
  29. package/dist/cli/deployment-intent.d.ts +178 -0
  30. package/dist/cli/deployment-intent.js +277 -0
  31. package/dist/cli/local-connection.d.ts +111 -0
  32. package/dist/cli/local-connection.js +151 -0
  33. package/dist/cli/migrate-deployment-intent.d.ts +132 -0
  34. package/dist/cli/migrate-deployment-intent.js +1552 -0
  35. package/dist/cli/migrate-operation-declaration.d.ts +70 -0
  36. package/dist/cli/migrate-operation-declaration.js +2206 -0
  37. package/dist/cli/migrate-operation-shape.d.ts +44 -0
  38. package/dist/cli/migrate-operation-shape.js +113 -0
  39. package/dist/cli/migrate-provider-shape.d.ts +56 -0
  40. package/dist/cli/migrate-provider-shape.js +580 -0
  41. package/dist/cli/resolver-runtime.d.ts +21 -0
  42. package/dist/cli/resolver-runtime.js +31 -0
  43. package/dist/cli/templates/provider/.agents/skills/health-checks-and-fail-closed/SKILL.md.tpl +141 -23
  44. package/dist/cli/templates/provider/Dockerfile.tpl +1 -1
  45. package/dist/cli/templates/provider/README.md.tpl +41 -19
  46. package/dist/cli/templates/provider/index.ts.tpl +6 -3
  47. package/dist/cli/templates/provider/operations/ping.ts.tpl +4 -1
  48. package/dist/cli/templates/provider/provider.json.tpl +6 -0
  49. package/dist/config/loader.d.ts +202 -16
  50. package/dist/config/loader.js +491 -141
  51. package/dist/contract-serialization.js +4 -8
  52. package/dist/contract-types.d.ts +12 -5
  53. package/dist/contract.js +24 -10
  54. package/dist/declaration-validation.d.ts +34 -0
  55. package/dist/declaration-validation.js +305 -0
  56. package/dist/define.d.ts +71 -29
  57. package/dist/define.js +807 -69
  58. package/dist/dev.d.ts +5 -0
  59. package/dist/dev.js +8 -1
  60. package/dist/engine.d.ts +129 -0
  61. package/dist/engine.js +209 -0
  62. package/dist/error-observability.d.ts +7 -0
  63. package/dist/error-observability.js +61 -0
  64. package/dist/error-resolution.d.ts +18 -0
  65. package/dist/error-resolution.js +243 -0
  66. package/dist/errors.d.ts +74 -0
  67. package/dist/errors.js +99 -0
  68. package/dist/fixture-sanitization.d.ts +28 -0
  69. package/dist/fixture-sanitization.js +371 -0
  70. package/dist/handle-meta.d.ts +90 -0
  71. package/dist/handle-meta.js +73 -0
  72. package/dist/handle.d.ts +226 -0
  73. package/dist/handle.js +345 -0
  74. package/dist/health-scenario.d.ts +1901 -0
  75. package/dist/health-scenario.js +653 -0
  76. package/dist/i18n/catalog.js +21 -1
  77. package/dist/i18n/error-messages.d.ts +86 -0
  78. package/dist/i18n/error-messages.js +192 -0
  79. package/dist/i18n/index.d.ts +1 -0
  80. package/dist/i18n/index.js +1 -0
  81. package/dist/i18n/keys.d.ts +12 -0
  82. package/dist/i18n/keys.js +76 -5
  83. package/dist/i18n/operation-locale-namespace.d.ts +2 -0
  84. package/dist/i18n/operation-locale-namespace.js +7 -0
  85. package/dist/index.d.ts +40 -15
  86. package/dist/index.js +29 -11
  87. package/dist/lint.d.ts +43 -11
  88. package/dist/lint.js +2512 -177
  89. package/dist/native-address.d.ts +43 -0
  90. package/dist/native-address.js +281 -0
  91. package/dist/native-egress-policy.d.ts +31 -0
  92. package/dist/native-egress-policy.js +288 -0
  93. package/dist/observability.d.ts +5 -2
  94. package/dist/observability.js +48 -1
  95. package/dist/provider.d.ts +18 -8
  96. package/dist/provider.js +11 -6
  97. package/dist/runtime/auth-flow.d.ts +6 -1
  98. package/dist/runtime/auth-flow.js +8 -0
  99. package/dist/runtime/bounded-expiring-map.d.ts +21 -0
  100. package/dist/runtime/bounded-expiring-map.js +64 -0
  101. package/dist/runtime/browser-telemetry-binding.d.ts +5 -0
  102. package/dist/runtime/browser-telemetry-binding.js +83 -0
  103. package/dist/runtime/browser-telemetry.d.ts +76 -0
  104. package/dist/runtime/browser-telemetry.js +165 -0
  105. package/dist/runtime/browser.d.ts +4 -0
  106. package/dist/runtime/browser.js +688 -83
  107. package/dist/runtime/cache-telemetry.d.ts +71 -0
  108. package/dist/runtime/cache-telemetry.js +177 -0
  109. package/dist/runtime/cache.d.ts +3 -0
  110. package/dist/runtime/cache.js +245 -23
  111. package/dist/runtime/capability-response.d.ts +3 -0
  112. package/dist/runtime/capability-response.js +56 -0
  113. package/dist/runtime/capability-telemetry.d.ts +46 -0
  114. package/dist/runtime/capability-telemetry.js +68 -0
  115. package/dist/runtime/chrome149-header-order.d.ts +58 -0
  116. package/dist/runtime/chrome149-header-order.js +289 -0
  117. package/dist/runtime/diagnostic-env-values.d.ts +5 -0
  118. package/dist/runtime/diagnostic-env-values.js +57 -0
  119. package/dist/runtime/diagnostic-env.d.ts +14 -0
  120. package/dist/runtime/diagnostic-env.js +44 -0
  121. package/dist/runtime/diagnostic-redactor.d.ts +54 -0
  122. package/dist/runtime/diagnostic-redactor.js +571 -0
  123. package/dist/runtime/egress-lease.d.ts +65 -0
  124. package/dist/runtime/egress-lease.js +236 -0
  125. package/dist/runtime/engine-mode.d.ts +74 -0
  126. package/dist/runtime/engine-mode.js +152 -0
  127. package/dist/runtime/env.js +14 -1
  128. package/dist/runtime/executor.d.ts +3 -2
  129. package/dist/runtime/executor.js +23 -26
  130. package/dist/runtime/handle-wordlist.d.ts +43 -0
  131. package/dist/runtime/handle-wordlist.js +256 -0
  132. package/dist/runtime/handle.d.ts +71 -0
  133. package/dist/runtime/handle.js +908 -0
  134. package/dist/runtime/http-telemetry-binding.d.ts +10 -0
  135. package/dist/runtime/http-telemetry-binding.js +152 -0
  136. package/dist/runtime/http-telemetry-guard.d.ts +16 -0
  137. package/dist/runtime/http-telemetry-guard.js +202 -0
  138. package/dist/runtime/http-telemetry.d.ts +104 -0
  139. package/dist/runtime/http-telemetry.js +190 -0
  140. package/dist/runtime/http.d.ts +34 -0
  141. package/dist/runtime/http.js +680 -121
  142. package/dist/runtime/insights.js +2 -2
  143. package/dist/runtime/instrumentation.d.ts +2 -2
  144. package/dist/runtime/instrumentation.js +462 -19
  145. package/dist/runtime/native-network-errors.d.ts +33 -0
  146. package/dist/runtime/native-network-errors.js +69 -0
  147. package/dist/runtime/native-network.d.ts +105 -0
  148. package/dist/runtime/native-network.js +1433 -0
  149. package/dist/runtime/native-telemetry.d.ts +142 -0
  150. package/dist/runtime/native-telemetry.js +231 -0
  151. package/dist/runtime/ocr-telemetry.d.ts +82 -0
  152. package/dist/runtime/ocr-telemetry.js +192 -0
  153. package/dist/runtime/ocr.d.ts +29 -0
  154. package/dist/runtime/ocr.js +468 -0
  155. package/dist/runtime/otlp.d.ts +71 -2
  156. package/dist/runtime/otlp.js +398 -16
  157. package/dist/runtime/provider.d.ts +7 -0
  158. package/dist/runtime/provider.js +14 -0
  159. package/dist/runtime/proxy-errors.js +6 -2
  160. package/dist/runtime/proxy-nodemaven.d.ts +56 -0
  161. package/dist/runtime/proxy-nodemaven.js +147 -0
  162. package/dist/runtime/proxy-retry-policy.d.ts +4 -1
  163. package/dist/runtime/proxy-retry-policy.js +11 -2
  164. package/dist/runtime/proxy-telemetry.d.ts +133 -3
  165. package/dist/runtime/proxy-telemetry.js +218 -70
  166. package/dist/runtime/redirect-hop-headers.d.ts +19 -0
  167. package/dist/runtime/redirect-hop-headers.js +58 -0
  168. package/dist/runtime/redirects.d.ts +61 -0
  169. package/dist/runtime/redirects.js +149 -0
  170. package/dist/runtime/redis.d.ts +1 -1
  171. package/dist/runtime/redis.js +4 -2
  172. package/dist/runtime/request-options.d.ts +75 -1
  173. package/dist/runtime/request-options.js +588 -0
  174. package/dist/runtime/request-telemetry.d.ts +70 -0
  175. package/dist/runtime/request-telemetry.js +229 -0
  176. package/dist/runtime/resolver-config.d.ts +13 -0
  177. package/dist/runtime/resolver-config.js +14 -0
  178. package/dist/runtime/resolver-public.d.ts +3 -0
  179. package/dist/runtime/resolver-public.js +1 -0
  180. package/dist/runtime/resolver-runtime-options.d.ts +14 -0
  181. package/dist/runtime/resolver-runtime-options.js +33 -0
  182. package/dist/runtime/resolver-shared.d.ts +24 -0
  183. package/dist/runtime/resolver-shared.js +69 -0
  184. package/dist/runtime/resolver-span-attributes.d.ts +34 -0
  185. package/dist/runtime/resolver-span-attributes.js +93 -0
  186. package/dist/runtime/resolver-telemetry.d.ts +135 -0
  187. package/dist/runtime/resolver-telemetry.js +204 -0
  188. package/dist/runtime/resolver-usage.d.ts +32 -0
  189. package/dist/runtime/resolver-usage.js +54 -0
  190. package/dist/runtime/resolver-vendors/bindings.d.ts +53 -0
  191. package/dist/runtime/resolver-vendors/bindings.js +45 -0
  192. package/dist/runtime/resolver-vendors/browser.d.ts +24 -0
  193. package/dist/runtime/resolver-vendors/browser.js +423 -0
  194. package/dist/runtime/resolver-vendors/capsolver.d.ts +22 -0
  195. package/dist/runtime/resolver-vendors/capsolver.js +544 -0
  196. package/dist/runtime/resolver-vendors/hosts.d.ts +23 -0
  197. package/dist/runtime/resolver-vendors/hosts.js +68 -0
  198. package/dist/runtime/resolver-vendors/hypersolutions.d.ts +27 -0
  199. package/dist/runtime/resolver-vendors/hypersolutions.js +369 -0
  200. package/dist/runtime/resolver-vendors/twocaptcha.d.ts +24 -0
  201. package/dist/runtime/resolver-vendors/twocaptcha.js +425 -0
  202. package/dist/runtime/resolver-vendors/types.d.ts +125 -0
  203. package/dist/runtime/resolver-vendors/types.js +106 -0
  204. package/dist/runtime/resolver.d.ts +79 -0
  205. package/dist/runtime/resolver.js +1094 -0
  206. package/dist/runtime/state-telemetry.d.ts +79 -0
  207. package/dist/runtime/state-telemetry.js +194 -0
  208. package/dist/runtime/state.d.ts +10 -1
  209. package/dist/runtime/state.js +322 -82
  210. package/dist/runtime/stealth-akamai-sbsd.d.ts +54 -0
  211. package/dist/runtime/stealth-akamai-sbsd.js +142 -0
  212. package/dist/runtime/stealth-cookies.d.ts +24 -0
  213. package/dist/runtime/stealth-cookies.js +131 -0
  214. package/dist/runtime/stealth-owned-headers.d.ts +39 -0
  215. package/dist/runtime/stealth-owned-headers.js +57 -0
  216. package/dist/runtime/stealth-telemetry.d.ts +92 -0
  217. package/dist/runtime/stealth-telemetry.js +179 -0
  218. package/dist/runtime/stealth.d.ts +45 -6
  219. package/dist/runtime/stealth.js +1775 -320
  220. package/dist/runtime/stt-telemetry.d.ts +77 -0
  221. package/dist/runtime/stt-telemetry.js +176 -0
  222. package/dist/runtime/stt.js +18 -19
  223. package/dist/runtime/timeout.d.ts +5 -0
  224. package/dist/runtime/timeout.js +12 -0
  225. package/dist/runtime/trace-config.d.ts +12 -0
  226. package/dist/runtime/trace-config.js +62 -0
  227. package/dist/runtime/trace.d.ts +17 -0
  228. package/dist/runtime/trace.js +176 -25
  229. package/dist/runtime-boundary-lint.d.ts +137 -0
  230. package/dist/runtime-boundary-lint.js +590 -0
  231. package/dist/schema.d.ts +20 -0
  232. package/dist/schema.js +72 -2
  233. package/dist/serve.d.ts +1 -1
  234. package/dist/serve.js +1 -1
  235. package/dist/server/error-observability.d.ts +1 -0
  236. package/dist/server/error-observability.js +1 -0
  237. package/dist/server/error-stack-frames.d.ts +35 -0
  238. package/dist/server/error-stack-frames.js +219 -0
  239. package/dist/server/index.d.ts +21 -2
  240. package/dist/server/index.js +11 -2
  241. package/dist/server/runtime-diagnostics.d.ts +165 -0
  242. package/dist/server/runtime-diagnostics.js +360 -0
  243. package/dist/server/sdk-version.d.ts +3 -0
  244. package/dist/server/sdk-version.js +19 -0
  245. package/dist/server/self-test-input-tokens.d.ts +2 -1
  246. package/dist/server/self-test-input-tokens.js +18 -14
  247. package/dist/server/self-test-redaction.js +8 -33
  248. package/dist/server/self-test.d.ts +18 -3
  249. package/dist/server/self-test.js +139 -76
  250. package/dist/server/sensitive-values.d.ts +26 -0
  251. package/dist/server/sensitive-values.js +137 -0
  252. package/dist/server/serve-implementation.d.ts +285 -0
  253. package/dist/server/serve-implementation.js +3172 -0
  254. package/dist/server/serve.d.ts +1 -75
  255. package/dist/server/serve.js +1 -1170
  256. package/dist/server/state-backend-report.d.ts +70 -0
  257. package/dist/server/state-backend-report.js +113 -0
  258. package/dist/server/trace-output.d.ts +7 -0
  259. package/dist/server/trace-output.js +111 -0
  260. package/dist/server/types.d.ts +37 -5
  261. package/dist/server/types.js +34 -1
  262. package/dist/stateful/errors.d.ts +19 -0
  263. package/dist/stateful/errors.js +24 -0
  264. package/dist/stateful/http-provider-event-emitter.d.ts +40 -0
  265. package/dist/stateful/http-provider-event-emitter.js +237 -0
  266. package/dist/stateful/http-session-owner-registry.d.ts +44 -0
  267. package/dist/stateful/http-session-owner-registry.js +210 -0
  268. package/dist/stateful/index.d.ts +18 -0
  269. package/dist/stateful/index.js +18 -0
  270. package/dist/stateful/provider-event-delivery-failures.d.ts +32 -0
  271. package/dist/stateful/provider-event-delivery-failures.js +43 -0
  272. package/dist/stateful/provider-event-pipeline-metrics.d.ts +46 -0
  273. package/dist/stateful/provider-event-pipeline-metrics.js +48 -0
  274. package/dist/stateful/provider-event-pipeline.d.ts +50 -0
  275. package/dist/stateful/provider-event-pipeline.js +1 -0
  276. package/dist/stateful/provider-events.d.ts +101 -0
  277. package/dist/stateful/provider-events.js +289 -0
  278. package/dist/stateful/session-key.d.ts +15 -0
  279. package/dist/stateful/session-key.js +86 -0
  280. package/dist/stateful/stateful-provider-adapter-context.d.ts +5 -0
  281. package/dist/stateful/stateful-provider-adapter-context.js +42 -0
  282. package/dist/stateful/stateful-provider-adapter-metrics.d.ts +15 -0
  283. package/dist/stateful/stateful-provider-adapter-metrics.js +21 -0
  284. package/dist/stateful/stateful-provider-adapter.d.ts +98 -0
  285. package/dist/stateful/stateful-provider-adapter.js +287 -0
  286. package/dist/stateful/stateful-provider-observability.d.ts +62 -0
  287. package/dist/stateful/stateful-provider-observability.js +161 -0
  288. package/dist/stateful/stateful-provider-owner-forwarder.d.ts +41 -0
  289. package/dist/stateful/stateful-provider-owner-forwarder.js +237 -0
  290. package/dist/stateful/stateful-provider-runtime-context.d.ts +35 -0
  291. package/dist/stateful/stateful-provider-runtime-context.js +61 -0
  292. package/dist/stateful/stateful-provider-runtime-executor.d.ts +34 -0
  293. package/dist/stateful/stateful-provider-runtime-executor.js +52 -0
  294. package/dist/stateful/stateful-provider-session-routing.d.ts +67 -0
  295. package/dist/stateful/stateful-provider-session-routing.js +345 -0
  296. package/dist/stateful/stateful-provider-session-runtime.d.ts +98 -0
  297. package/dist/stateful/stateful-provider-session-runtime.js +245 -0
  298. package/dist/stateful-signing.d.ts +18 -0
  299. package/dist/stateful-signing.js +27 -0
  300. package/dist/stealth/profiles.d.ts +15 -4
  301. package/dist/stealth/profiles.js +169 -211
  302. package/dist/stream-evidence.d.ts +74 -0
  303. package/dist/stream-evidence.js +785 -0
  304. package/dist/stream.js +7 -1
  305. package/dist/testing/index.d.ts +2 -1
  306. package/dist/testing/index.js +2 -1
  307. package/dist/testing/run.d.ts +32 -2
  308. package/dist/testing/run.js +509 -25
  309. package/dist/trace-sanitization.d.ts +6 -0
  310. package/dist/trace-sanitization.js +68 -0
  311. package/dist/types.d.ts +899 -210
  312. package/dist/types.js +1 -0
  313. package/dist/typescript-module.d.ts +39 -0
  314. package/dist/typescript-module.js +72 -0
  315. package/package.json +52 -6
  316. package/src/auth-turn/index.ts +1 -1
  317. package/src/auth.ts +118 -0
  318. package/src/ceremonies/index.ts +189 -46
  319. package/src/cli/commands.ts +37 -0
  320. package/src/cli/create.ts +8 -2
  321. package/src/cli/deployment-intent.ts +364 -0
  322. package/src/cli/local-connection.ts +221 -0
  323. package/src/cli/migrate-deployment-intent.ts +1908 -0
  324. package/src/cli/migrate-operation-declaration.ts +3026 -0
  325. package/src/cli/migrate-operation-shape.ts +184 -0
  326. package/src/cli/migrate-provider-shape.ts +778 -0
  327. package/src/cli/resolver-runtime.ts +72 -0
  328. package/src/cli/templates/provider/.agents/skills/health-checks-and-fail-closed/SKILL.md.tpl +141 -23
  329. package/src/cli/templates/provider/Dockerfile.tpl +1 -1
  330. package/src/cli/templates/provider/README.md.tpl +41 -19
  331. package/src/cli/templates/provider/index.ts.tpl +6 -3
  332. package/src/cli/templates/provider/operations/ping.ts.tpl +4 -1
  333. package/src/cli/templates/provider/provider.json.tpl +6 -0
  334. package/src/config/loader.ts +774 -181
  335. package/src/contract-serialization.ts +5 -7
  336. package/src/contract-types.ts +12 -5
  337. package/src/contract.ts +24 -10
  338. package/src/declaration-validation.ts +376 -0
  339. package/src/define.ts +1105 -131
  340. package/src/dev.ts +13 -1
  341. package/src/engine.ts +390 -0
  342. package/src/error-observability.ts +64 -0
  343. package/src/error-resolution.ts +269 -0
  344. package/src/errors.ts +157 -0
  345. package/src/fixture-sanitization.ts +422 -0
  346. package/src/handle-meta.ts +143 -0
  347. package/src/handle.ts +694 -0
  348. package/src/health-scenario.ts +960 -0
  349. package/src/i18n/catalog.ts +23 -1
  350. package/src/i18n/error-messages.ts +255 -0
  351. package/src/i18n/index.ts +1 -0
  352. package/src/i18n/keys.ts +80 -6
  353. package/src/i18n/operation-locale-namespace.ts +10 -0
  354. package/src/index.ts +390 -23
  355. package/src/lint.ts +2975 -234
  356. package/src/native-address.ts +340 -0
  357. package/src/native-egress-policy.ts +358 -0
  358. package/src/observability.ts +51 -1
  359. package/src/provider.ts +207 -27
  360. package/src/runtime/auth-flow.ts +15 -0
  361. package/src/runtime/bounded-expiring-map.ts +67 -0
  362. package/src/runtime/browser-telemetry-binding.ts +92 -0
  363. package/src/runtime/browser-telemetry.ts +289 -0
  364. package/src/runtime/browser.ts +882 -99
  365. package/src/runtime/cache-telemetry.ts +247 -0
  366. package/src/runtime/cache.ts +258 -22
  367. package/src/runtime/capability-response.ts +53 -0
  368. package/src/runtime/capability-telemetry.ts +101 -0
  369. package/src/runtime/chrome149-header-order.ts +330 -0
  370. package/src/runtime/diagnostic-env-values.ts +56 -0
  371. package/src/runtime/diagnostic-env.ts +65 -0
  372. package/src/runtime/diagnostic-redactor.ts +668 -0
  373. package/src/runtime/egress-lease.ts +341 -0
  374. package/src/runtime/engine-mode.ts +233 -0
  375. package/src/runtime/env.ts +15 -1
  376. package/src/runtime/executor.ts +39 -32
  377. package/src/runtime/handle-wordlist.ts +266 -0
  378. package/src/runtime/handle.ts +1180 -0
  379. package/src/runtime/http-telemetry-binding.ts +162 -0
  380. package/src/runtime/http-telemetry-guard.ts +208 -0
  381. package/src/runtime/http-telemetry.ts +292 -0
  382. package/src/runtime/http.ts +896 -150
  383. package/src/runtime/insights.ts +2 -2
  384. package/src/runtime/instrumentation.ts +628 -28
  385. package/src/runtime/native-network-errors.ts +99 -0
  386. package/src/runtime/native-network.ts +1853 -0
  387. package/src/runtime/native-telemetry.ts +366 -0
  388. package/src/runtime/ocr-telemetry.ts +308 -0
  389. package/src/runtime/ocr.ts +564 -0
  390. package/src/runtime/otlp.ts +468 -21
  391. package/src/runtime/provider.ts +15 -0
  392. package/src/runtime/proxy-errors.ts +12 -4
  393. package/src/runtime/proxy-nodemaven.ts +222 -0
  394. package/src/runtime/proxy-retry-policy.ts +12 -1
  395. package/src/runtime/proxy-telemetry.ts +358 -103
  396. package/src/runtime/redirect-hop-headers.ts +71 -0
  397. package/src/runtime/redirects.ts +215 -0
  398. package/src/runtime/redis.ts +7 -2
  399. package/src/runtime/request-options.ts +728 -1
  400. package/src/runtime/request-telemetry.ts +375 -0
  401. package/src/runtime/resolver-config.ts +18 -0
  402. package/src/runtime/resolver-public.ts +44 -0
  403. package/src/runtime/resolver-runtime-options.ts +50 -0
  404. package/src/runtime/resolver-shared.ts +113 -0
  405. package/src/runtime/resolver-span-attributes.ts +139 -0
  406. package/src/runtime/resolver-telemetry.ts +362 -0
  407. package/src/runtime/resolver-usage.ts +99 -0
  408. package/src/runtime/resolver-vendors/bindings.ts +61 -0
  409. package/src/runtime/resolver-vendors/browser.ts +591 -0
  410. package/src/runtime/resolver-vendors/capsolver.ts +727 -0
  411. package/src/runtime/resolver-vendors/hosts.ts +81 -0
  412. package/src/runtime/resolver-vendors/hypersolutions.ts +480 -0
  413. package/src/runtime/resolver-vendors/twocaptcha.ts +555 -0
  414. package/src/runtime/resolver-vendors/types.ts +261 -0
  415. package/src/runtime/resolver.ts +1543 -0
  416. package/src/runtime/state-telemetry.ts +298 -0
  417. package/src/runtime/state.ts +435 -89
  418. package/src/runtime/stealth-akamai-sbsd.ts +207 -0
  419. package/src/runtime/stealth-cookies.ts +155 -0
  420. package/src/runtime/stealth-owned-headers.ts +67 -0
  421. package/src/runtime/stealth-telemetry.ts +309 -0
  422. package/src/runtime/stealth.ts +2275 -372
  423. package/src/runtime/stt-telemetry.ts +282 -0
  424. package/src/runtime/stt.ts +96 -91
  425. package/src/runtime/timeout.ts +18 -0
  426. package/src/runtime/trace-config.ts +78 -0
  427. package/src/runtime/trace.ts +257 -35
  428. package/src/runtime-boundary-lint.ts +707 -0
  429. package/src/schema.ts +80 -2
  430. package/src/serve.ts +6 -1
  431. package/src/server/error-observability.ts +1 -0
  432. package/src/server/error-stack-frames.ts +214 -0
  433. package/src/server/index.ts +184 -2
  434. package/src/server/runtime-diagnostics.ts +522 -0
  435. package/src/server/sdk-version.ts +22 -0
  436. package/src/server/self-test-input-tokens.ts +29 -14
  437. package/src/server/self-test-redaction.ts +10 -40
  438. package/src/server/self-test.ts +205 -89
  439. package/src/server/sensitive-values.ts +211 -0
  440. package/src/server/serve-implementation.ts +4596 -0
  441. package/src/server/serve.ts +1 -1685
  442. package/src/server/state-backend-report.ts +185 -0
  443. package/src/server/trace-output.ts +189 -0
  444. package/src/server/types.ts +34 -1
  445. package/src/stateful/README.md +146 -0
  446. package/src/stateful/errors.ts +35 -0
  447. package/src/stateful/http-provider-event-emitter.ts +314 -0
  448. package/src/stateful/http-session-owner-registry.ts +306 -0
  449. package/src/stateful/index.ts +18 -0
  450. package/src/stateful/provider-event-delivery-failures.ts +80 -0
  451. package/src/stateful/provider-event-pipeline-metrics.ts +95 -0
  452. package/src/stateful/provider-event-pipeline.ts +61 -0
  453. package/src/stateful/provider-events.ts +462 -0
  454. package/src/stateful/session-key.ts +111 -0
  455. package/src/stateful/stateful-provider-adapter-context.ts +59 -0
  456. package/src/stateful/stateful-provider-adapter-metrics.ts +48 -0
  457. package/src/stateful/stateful-provider-adapter.ts +562 -0
  458. package/src/stateful/stateful-provider-observability.ts +261 -0
  459. package/src/stateful/stateful-provider-owner-forwarder.ts +310 -0
  460. package/src/stateful/stateful-provider-runtime-context.ts +96 -0
  461. package/src/stateful/stateful-provider-runtime-executor.ts +96 -0
  462. package/src/stateful/stateful-provider-session-routing.ts +546 -0
  463. package/src/stateful/stateful-provider-session-runtime.ts +403 -0
  464. package/src/stateful-signing.ts +46 -0
  465. package/src/stealth/profiles.ts +212 -231
  466. package/src/stream-evidence.ts +988 -0
  467. package/src/stream.ts +8 -1
  468. package/src/testing/index.ts +10 -1
  469. package/src/testing/run.ts +685 -19
  470. package/src/trace-sanitization.ts +110 -0
  471. package/src/types.ts +1014 -273
  472. package/src/typescript-module.ts +100 -0
  473. package/dist/choice-token.d.ts +0 -24
  474. package/dist/choice-token.js +0 -74
  475. package/dist/runtime/choice.d.ts +0 -15
  476. package/dist/runtime/choice.js +0 -413
  477. package/src/choice-token.ts +0 -165
  478. package/src/runtime/choice.ts +0 -686
@@ -0,0 +1,1180 @@
1
+ /**
2
+ * LLM-friendly handles (ADR-0012): runtime (`ctx.handle`).
3
+ *
4
+ * Storage layout: one state namespace per kind, `handle.<kind>`, scoped by the
5
+ * kind's access (bound → connection scope via `state.forConnection`, public →
6
+ * provider scope; both cursors and drafts may be either). The state key is the
7
+ * canonical handle string. Records hold
8
+ * the validated data plus status/timestamps; the handle string itself carries
9
+ * no payload and no signature.
10
+ */
11
+ import { randomInt, randomUUID } from "node:crypto";
12
+ import { isProviderError } from "../errors.js";
13
+ import {
14
+ type DataOf,
15
+ type InputOf,
16
+ type DraftKind,
17
+ type HandleCommitResult,
18
+ type HandleContext,
19
+ HandleError,
20
+ type HandleKind,
21
+ type HandleOperation,
22
+ type HandleRecord,
23
+ type HandleStatus,
24
+ type HandleTelemetryEvent,
25
+ type HandleTelemetryOutcome,
26
+ handleRecoverySentence,
27
+ isDraftKind,
28
+ type ResultOf,
29
+ } from "../handle.js";
30
+ import { formatHandleIssuers, handleHasDirectionalFieldNames } from "../handle-meta.js";
31
+ import type {
32
+ ProviderRequestContext,
33
+ ProviderRuntimeState,
34
+ ProviderStateDurationString,
35
+ ProviderStateNamespace,
36
+ StateValue,
37
+ StateCasResult,
38
+ } from "../types.js";
39
+ import {
40
+ BOUND_HANDLE_WORD_COUNT,
41
+ findHandleWordWithinDistance,
42
+ HIGH_PUBLIC_HANDLE_WORD_COUNT,
43
+ ISSUABLE_HANDLE_WORDS,
44
+ PUBLIC_HANDLE_WORD_COUNT,
45
+ } from "./handle-wordlist.js";
46
+ import { createMemoryProviderRuntimeState } from "./state.js";
47
+
48
+ const RECORD_VERSION = 1;
49
+ const CREATE_ATTEMPTS = 5;
50
+ /** Minimum time a `committing` claim survives while upstream work runs. */
51
+ const COMMIT_LEASE_MS = 5 * 60_000;
52
+ const UPDATE_ATTEMPTS = 3;
53
+ const DISCARD_ATTEMPTS = 3;
54
+ /** Upper bound on how long an expired record is kept so it can report HANDLE_EXPIRED. */
55
+ const EXPIRY_GRACE_MAX_MS = 60 * 60_000;
56
+ const COMMIT_FINALIZE_RETRY_DELAY_MS = 50;
57
+ const COMMIT_FINALIZE_ATTEMPTS = 3;
58
+ const NAMESPACE_PREFIX = "handle.";
59
+
60
+ export interface CreateHandleContextOptions {
61
+ readonly providerId: string;
62
+ readonly request?: ProviderRequestContext;
63
+ readonly state?: ProviderRuntimeState;
64
+ /** Clock used for record timestamps and TTL math; defaults to `Date.now`. */
65
+ readonly nowMs?: () => number;
66
+ /** Receives allowlisted metadata only; handle values, data, and results are never included. */
67
+ readonly onTelemetry?: (event: HandleTelemetryEvent) => void;
68
+ /**
69
+ * Lease held (and renewed) by a `committing` claim while upstream work runs.
70
+ * Defaults to 5 minutes; exposed so tests can exercise renewal quickly.
71
+ */
72
+ readonly commitLeaseMs?: number;
73
+ }
74
+
75
+ export interface CreateTestHandleContextOptions {
76
+ readonly providerId?: string;
77
+ readonly state?: ProviderRuntimeState;
78
+ readonly request?: ProviderRequestContext;
79
+ readonly nowMs?: () => number;
80
+ readonly onTelemetry?: (event: HandleTelemetryEvent) => void;
81
+ }
82
+
83
+ /**
84
+ * Persisted record shape (spec §6). `expires_at_ms` is the logical expiry the
85
+ * caller sees (sliding for drafts); `max_expires_at_ms` is the hard lifetime.
86
+ * Records are stored past their logical expiry for a grace window so an expired
87
+ * handle can be told apart from an unknown one (`HANDLE_EXPIRED` vs
88
+ * `HANDLE_NOT_FOUND`); after the grace window they are simply gone.
89
+ */
90
+ export type StoredHandleRecord = {
91
+ readonly v: typeof RECORD_VERSION;
92
+ readonly kind: string;
93
+ readonly type: "cursor" | "draft";
94
+ /** `discarded` is an internal tombstone written by `discard`; reads treat it as absent. */
95
+ readonly status: HandleStatus | "discarded";
96
+ readonly data: unknown;
97
+ readonly result?: unknown;
98
+ readonly created_at_ms: number;
99
+ readonly expires_at_ms: number;
100
+ readonly max_expires_at_ms: number;
101
+ readonly committed_at_ms?: number;
102
+ /** Owner token of a `committing` claim; lets the claimant recognise its record after an ambiguous write. */
103
+ readonly claim_id?: string;
104
+ };
105
+
106
+ export type HandleNormalizationClass = "case" | "whitespace" | "separator" | "punctuation" | "typo";
107
+
108
+ export interface NormalizedHandle {
109
+ readonly canonical: string;
110
+ readonly words: readonly string[];
111
+ readonly normalization: readonly HandleNormalizationClass[];
112
+ }
113
+
114
+ // ---------------------------------------------------------------------------
115
+ // Normalization (spec §4)
116
+ // ---------------------------------------------------------------------------
117
+
118
+ /**
119
+ * Security argument: normalization is a deterministic many-to-one map from raw
120
+ * input onto exactly one canonical key. One attempt still tests exactly one
121
+ * key, so the ADR 0006 §2 guessing numbers for public 4/5-word handles are
122
+ * unchanged. Distance-1 word repair cannot widen the target set because the
123
+ * wordlist has pairwise edit distance >= 3: the distance-1 neighbourhoods of
124
+ * two different words never overlap, so a segment resolves to at most one
125
+ * word. Bound 2-word handles need no guessing analysis at all (other
126
+ * connections cannot address them).
127
+ */
128
+ // None of these characters can occur inside a canonical handle, so stripping
129
+ // any run of them at either end (balanced or not — the 2026-08-21 "trailing
130
+ // quote" shape was unbalanced) cannot change which key is addressed.
131
+ const LEADING_WRAPPING = /^["'`<([{\s]+/;
132
+ const TRAILING_WRAPPING = /["'`>)\]}\s.,;:!?]+$/;
133
+ const KIND_SEPARATOR = /^[_\-:\s]+/;
134
+ const WORD_SEPARATOR = /[-_\s]+/;
135
+
136
+ type NormalizationFailure =
137
+ | { readonly ok: false; readonly reason: "kind_mismatch" }
138
+ | { readonly ok: false; readonly reason: "invalid"; readonly detail: string };
139
+
140
+ type NormalizationOutcome = ({ readonly ok: true } & NormalizedHandle) | NormalizationFailure;
141
+
142
+ function stripWrapping(value: string): { text: string; stripped: boolean } {
143
+ const text = value.replace(LEADING_WRAPPING, "").replace(TRAILING_WRAPPING, "");
144
+ return { text, stripped: text !== value };
145
+ }
146
+
147
+ function classifySeparator(
148
+ actual: string,
149
+ expected: string,
150
+ classes: Set<HandleNormalizationClass>,
151
+ ): void {
152
+ if (actual === expected) return;
153
+ if (/\s/.test(actual)) classes.add("whitespace");
154
+ if (actual.replace(/\s+/g, "") !== expected) classes.add("separator");
155
+ }
156
+
157
+ function acceptedWordCounts(kind: HandleKind): readonly number[] {
158
+ // Public cursors accept both 4 and 5 words so a kind can move between
159
+ // standard and high strength without invalidating handles in flight.
160
+ if (kind.access === "public") return [PUBLIC_HANDLE_WORD_COUNT, HIGH_PUBLIC_HANDLE_WORD_COUNT];
161
+ return [BOUND_HANDLE_WORD_COUNT];
162
+ }
163
+
164
+ /**
165
+ * Longest input the normalizer will look at. A canonical handle is at most
166
+ * 12 (kind) + 1 + 5 × 10 + 4 (words and hyphens) = 67 characters; the budget
167
+ * leaves room for wrapping punctuation and stray whitespace while keeping the
168
+ * synchronous normalization path bounded against arbitrary client input.
169
+ */
170
+ const MAX_HANDLE_INPUT_LENGTH = 256;
171
+
172
+ function normalizeHandleInternal(kind: HandleKind, raw: string): NormalizationOutcome {
173
+ const classes = new Set<HandleNormalizationClass>();
174
+ if (raw.length > MAX_HANDLE_INPUT_LENGTH) {
175
+ return { ok: false, reason: "invalid", detail: "input too long" };
176
+ }
177
+ // 1. trim; strip wrapping quotes/backticks/brackets and trailing punctuation.
178
+ const trimmed = raw.trim();
179
+ if (trimmed !== raw) classes.add("whitespace");
180
+ const { text: unwrapped, stripped } = stripWrapping(trimmed);
181
+ if (stripped) classes.add("punctuation");
182
+ // 2. lowercase.
183
+ const lowered = unwrapped.toLowerCase();
184
+ if (lowered !== unwrapped) classes.add("case");
185
+ // 3. must start with the kind name followed by 1+ separators.
186
+ if (!lowered.startsWith(kind.name)) return { ok: false, reason: "kind_mismatch" };
187
+ const afterKind = lowered.slice(kind.name.length);
188
+ const kindSeparator = KIND_SEPARATOR.exec(afterKind)?.[0];
189
+ if (kindSeparator === undefined) {
190
+ if (afterKind.length === 0) return { ok: false, reason: "invalid", detail: "missing words" };
191
+ // e.g. kind "page" against "pages_..." — a different (longer) kind name.
192
+ return { ok: false, reason: "kind_mismatch" };
193
+ }
194
+ classifySeparator(kindSeparator, "_", classes);
195
+ const body = afterKind.slice(kindSeparator.length);
196
+ if (body.length === 0) return { ok: false, reason: "invalid", detail: "missing words" };
197
+ // 4. split on separators; resolve each segment exactly or at edit distance 1.
198
+ const segments = body.split(WORD_SEPARATOR);
199
+ const separators = body.match(new RegExp(WORD_SEPARATOR.source, "g")) ?? [];
200
+ if (segments.some((segment) => segment.length === 0)) {
201
+ return { ok: false, reason: "invalid", detail: "trailing separator" };
202
+ }
203
+ for (const separator of separators) classifySeparator(separator, "-", classes);
204
+ const words: string[] = [];
205
+ for (const segment of segments) {
206
+ const word = findHandleWordWithinDistance(segment, 1);
207
+ if (word === null) return { ok: false, reason: "invalid", detail: "unknown word" };
208
+ if (word !== segment) classes.add("typo");
209
+ words.push(word);
210
+ }
211
+ // 5. word count must match the kind.
212
+ if (!acceptedWordCounts(kind).includes(words.length)) {
213
+ return { ok: false, reason: "invalid", detail: `expected ${kind.wordCount} words` };
214
+ }
215
+ // 6. canonical form.
216
+ return {
217
+ ok: true,
218
+ canonical: `${kind.name}_${words.join("-")}`,
219
+ words,
220
+ normalization: Array.from(classes).sort(),
221
+ };
222
+ }
223
+
224
+ /**
225
+ * Tolerant parse of a raw handle string into its canonical form. Throws
226
+ * `HANDLE_KIND_MISMATCH` when the string does not start with the kind name and
227
+ * `HANDLE_INVALID` when the body cannot be resolved to the kind's word count.
228
+ */
229
+ export function normalizeHandle(
230
+ kind: HandleKind,
231
+ raw: string,
232
+ ): { canonical: string; words: string[]; normalization: string[] } {
233
+ const outcome = normalizeHandleInternal(kind, raw);
234
+ if (!outcome.ok) throw normalizationError(kind, outcome);
235
+ return {
236
+ canonical: outcome.canonical,
237
+ words: [...outcome.words],
238
+ normalization: [...outcome.normalization],
239
+ };
240
+ }
241
+
242
+ export function formatNormalizationClasses(classes: readonly string[]): string {
243
+ return classes.length === 0 ? "none" : [...classes].sort().join("+");
244
+ }
245
+
246
+ // ---------------------------------------------------------------------------
247
+ // Errors (spec §5)
248
+ // ---------------------------------------------------------------------------
249
+
250
+ function publicCollapsedMessage(kind: HandleKind): string {
251
+ // "a new one" is unambiguous only while the kind has one name. When it is
252
+ // issued under `next_cursor` and accepted under `cursor`, the caller has to
253
+ // be told which field to read, so use the full recovery sentence — it names
254
+ // both keys and is identical for invalid / expired / not-found, which is
255
+ // what keeps the public collapse indistinguishable.
256
+ if (handleHasDirectionalFieldNames(kind)) {
257
+ return `\`${kind.fieldName}\` is not valid or has expired. ${handleRecoverySentence(kind)}`;
258
+ }
259
+ const issuers = formatHandleIssuers(kind.issuedBy);
260
+ const again = issuers ? `Call ${issuers} again to get a new one.` : "Request a new one.";
261
+ return `\`${kind.fieldName}\` is not valid or has expired. ${again}`;
262
+ }
263
+
264
+ function normalizationError(kind: HandleKind, failure: NormalizationFailure): HandleError {
265
+ if (failure.reason === "kind_mismatch") {
266
+ return new HandleError(
267
+ "HANDLE_KIND_MISMATCH",
268
+ `\`${kind.fieldName}\` must be a \`${kind.name}_...\` handle as returned by ${
269
+ formatHandleIssuers(kind.issuedBy) ?? "the issuing operation"
270
+ }. ${handleRecoverySentence(kind)}`,
271
+ { kind: kind.name, details: { field: kind.fieldName } },
272
+ );
273
+ }
274
+ if (kind.access === "public") {
275
+ return new HandleError("HANDLE_INVALID", publicCollapsedMessage(kind), {
276
+ kind: kind.name,
277
+ details: { field: kind.fieldName },
278
+ });
279
+ }
280
+ return new HandleError(
281
+ "HANDLE_INVALID",
282
+ `\`${kind.fieldName}\` is not a well-formed \`${kind.name}\` handle (${failure.detail}). ${handleRecoverySentence(kind)}`,
283
+ { kind: kind.name, details: { field: kind.fieldName } },
284
+ );
285
+ }
286
+
287
+ function notFoundError(kind: HandleKind): HandleError {
288
+ if (kind.access === "public") {
289
+ return new HandleError("HANDLE_INVALID", publicCollapsedMessage(kind), {
290
+ kind: kind.name,
291
+ details: { field: kind.fieldName },
292
+ });
293
+ }
294
+ return new HandleError(
295
+ "HANDLE_NOT_FOUND",
296
+ `\`${kind.fieldName}\` was not found; it may have expired or belong to another session. ${handleRecoverySentence(kind)}`,
297
+ { kind: kind.name, details: { field: kind.fieldName } },
298
+ );
299
+ }
300
+
301
+ type ExpiredDeadline = "ttl" | "idle" | "max" | "result";
302
+
303
+ /** Which deadline a present record missed; drives the wording of HANDLE_EXPIRED. */
304
+ function expiredDeadline(
305
+ kind: HandleKind,
306
+ record: StoredHandleRecord,
307
+ now: number,
308
+ ): ExpiredDeadline {
309
+ if (record.status === "committed") return "result";
310
+ if (!isDraftKind(kind)) return "ttl";
311
+ return now >= record.max_expires_at_ms ? "max" : "idle";
312
+ }
313
+
314
+ function expiredError(kind: HandleKind, deadline: ExpiredDeadline = "ttl"): HandleError {
315
+ if (kind.access === "public") {
316
+ return new HandleError("HANDLE_INVALID", publicCollapsedMessage(kind), {
317
+ kind: kind.name,
318
+ details: { field: kind.fieldName },
319
+ });
320
+ }
321
+ const lifetimeMs = isDraftKind(kind)
322
+ ? deadline === "idle"
323
+ ? kind.idleTtlMs
324
+ : deadline === "result"
325
+ ? kind.resultTtlMs
326
+ : kind.maxTtlMs
327
+ : kind.ttlMs;
328
+ const minutes = Math.max(1, Math.round(lifetimeMs / 60_000));
329
+ const reason =
330
+ deadline === "idle"
331
+ ? `expired after ${minutes} minutes of inactivity`
332
+ : deadline === "result"
333
+ ? `was completed more than ${minutes} minutes ago and its result is no longer available`
334
+ : `expired after ${minutes} minutes`;
335
+ return new HandleError(
336
+ "HANDLE_EXPIRED",
337
+ `\`${kind.fieldName}\` ${reason}. ${handleRecoverySentence(kind)}`,
338
+ {
339
+ kind: kind.name,
340
+ details: { field: kind.fieldName, deadline, lifetimeMinutes: minutes },
341
+ },
342
+ );
343
+ }
344
+
345
+ function busyError(kind: HandleKind): HandleError {
346
+ return new HandleError(
347
+ "HANDLE_BUSY",
348
+ `\`${kind.fieldName}\` is being committed by another request. Retry shortly with the same \`${kind.fieldName}\`.`,
349
+ { kind: kind.name, details: { field: kind.fieldName } },
350
+ );
351
+ }
352
+
353
+ function committedError(kind: HandleKind): HandleError {
354
+ return new HandleError(
355
+ "HANDLE_COMMITTED",
356
+ `\`${kind.fieldName}\` has already been committed and can no longer be changed. ${handleRecoverySentence(kind)}`,
357
+ { kind: kind.name, details: { field: kind.fieldName } },
358
+ );
359
+ }
360
+
361
+ function connectionRequiredError(kind: HandleKind, operation: HandleOperation): HandleError {
362
+ return new HandleError(
363
+ "HANDLE_CONNECTION_REQUIRED",
364
+ `Handle kind "${kind.name}" is bound to a connection, but request.connectionId is missing for ${operation}. Bound handles require a connection-scoped request; use access: "public" for connection-less kinds.`,
365
+ { kind: kind.name },
366
+ );
367
+ }
368
+
369
+ function storageUnavailableError(kind: HandleKind, reason: string, cause?: Error): HandleError {
370
+ return new HandleError(
371
+ "HANDLE_STORAGE_UNAVAILABLE",
372
+ `Handle storage for kind "${kind.name}" is not available: ${reason}.`,
373
+ { kind: kind.name, cause, details: { reason } },
374
+ );
375
+ }
376
+
377
+ function tooLargeError(kind: HandleKind, bytes: number): HandleError {
378
+ return new HandleError(
379
+ "HANDLE_TOO_LARGE",
380
+ `Handle record for kind "${kind.name}" is ${bytes} bytes, above maxValueBytes ${kind.maxValueBytes}. Store less in the handle or raise maxValueBytes on the kind.`,
381
+ { kind: kind.name, details: { bytes, maxValueBytes: kind.maxValueBytes } },
382
+ );
383
+ }
384
+
385
+ function invalidDataError(
386
+ kind: HandleKind,
387
+ operation: HandleOperation,
388
+ cause: unknown,
389
+ ): HandleError {
390
+ const summary =
391
+ cause !== null && typeof cause === "object" && "message" in cause
392
+ ? String((cause as { message: unknown }).message).split("\n")[0]
393
+ : "schema validation failed";
394
+ return new HandleError(
395
+ "HANDLE_INVALID_DATA",
396
+ `Handle kind "${kind.name}" ${operation} received data that does not satisfy its schema (${summary}). This is a provider bug: validate or shape the data before storing it.`,
397
+ {
398
+ kind: kind.name,
399
+ cause: cause instanceof Error ? cause : undefined,
400
+ details: { operation, issues: extractIssues(cause) },
401
+ },
402
+ );
403
+ }
404
+
405
+ function extractIssues(cause: unknown): unknown {
406
+ if (cause !== null && typeof cause === "object" && "issues" in cause) {
407
+ return (cause as { issues: unknown }).issues;
408
+ }
409
+ return undefined;
410
+ }
411
+
412
+ // ---------------------------------------------------------------------------
413
+ // Runtime
414
+ // ---------------------------------------------------------------------------
415
+
416
+ function msDuration(ms: number): ProviderStateDurationString {
417
+ return `${Math.max(1, Math.floor(ms))}ms`;
418
+ }
419
+
420
+ function generateWords(count: number): string[] {
421
+ return Array.from(
422
+ { length: count },
423
+ () => ISSUABLE_HANDLE_WORDS[randomInt(ISSUABLE_HANDLE_WORDS.length)]!,
424
+ );
425
+ }
426
+
427
+ function isStoredHandleRecord(value: unknown): value is StoredHandleRecord {
428
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return false;
429
+ const record = value as Record<string, unknown>;
430
+ return (
431
+ record.v === RECORD_VERSION &&
432
+ typeof record.kind === "string" &&
433
+ (record.type === "cursor" || record.type === "draft") &&
434
+ (record.status === "active" ||
435
+ record.status === "committing" ||
436
+ record.status === "committed" ||
437
+ record.status === "discarded") &&
438
+ "data" in record &&
439
+ typeof record.created_at_ms === "number" &&
440
+ typeof record.expires_at_ms === "number" &&
441
+ typeof record.max_expires_at_ms === "number"
442
+ );
443
+ }
444
+
445
+ function recordBytes(record: StoredHandleRecord): number {
446
+ return Buffer.byteLength(JSON.stringify(record), "utf8");
447
+ }
448
+
449
+ function isStateUnsupportedError(error: unknown): error is Error {
450
+ return isProviderError(error) && error.code === "PROVIDER_STATE_UNSUPPORTED";
451
+ }
452
+
453
+ /** Returns the path of the first value JSON cannot represent faithfully, or undefined. */
454
+ function findNonJsonSafePath(value: unknown, path: string): string | undefined {
455
+ if (value === null) return undefined;
456
+ switch (typeof value) {
457
+ case "string":
458
+ case "boolean":
459
+ return undefined;
460
+ case "number":
461
+ return Number.isFinite(value) ? undefined : path;
462
+ case "undefined":
463
+ // Dropped by JSON.stringify; the schema decides whether that is acceptable.
464
+ return undefined;
465
+ case "object": {
466
+ if (Array.isArray(value)) {
467
+ for (const [index, entry] of value.entries()) {
468
+ if (entry === undefined) return `${path}[${index}]`;
469
+ const nested = findNonJsonSafePath(entry, `${path}[${index}]`);
470
+ if (nested) return nested;
471
+ }
472
+ return undefined;
473
+ }
474
+ const proto = Object.getPrototypeOf(value);
475
+ if (proto !== Object.prototype && proto !== null) return path;
476
+ for (const [key, entry] of Object.entries(value as Record<string, unknown>)) {
477
+ const nested = findNonJsonSafePath(entry, `${path}.${key}`);
478
+ if (nested) return nested;
479
+ }
480
+ return undefined;
481
+ }
482
+ default:
483
+ return path;
484
+ }
485
+ }
486
+
487
+ export function createHandleContext(options: CreateHandleContextOptions): HandleContext {
488
+ const nowMs = options.nowMs ?? (() => Date.now());
489
+ const commitLeaseMs = Math.max(1, options.commitLeaseMs ?? COMMIT_LEASE_MS);
490
+ const connectionId = options.request?.connectionId;
491
+
492
+ function emit(
493
+ kind: HandleKind,
494
+ operation: HandleOperation,
495
+ outcome: HandleTelemetryOutcome,
496
+ normalization: readonly string[],
497
+ words: number,
498
+ ): void {
499
+ if (!options.onTelemetry) return;
500
+ try {
501
+ options.onTelemetry({
502
+ providerId: options.providerId,
503
+ kind: kind.name,
504
+ type: kind.type,
505
+ operation,
506
+ outcome,
507
+ normalization: formatNormalizationClasses(normalization),
508
+ words,
509
+ });
510
+ } catch {
511
+ // Telemetry must never affect the operation outcome.
512
+ }
513
+ }
514
+
515
+ function outcomeForError(error: unknown): HandleTelemetryOutcome {
516
+ if (!(error instanceof HandleError)) return "error";
517
+ switch (error.code) {
518
+ case "HANDLE_NOT_FOUND":
519
+ return "not_found";
520
+ case "HANDLE_EXPIRED":
521
+ return "expired";
522
+ case "HANDLE_INVALID":
523
+ return "invalid";
524
+ case "HANDLE_KIND_MISMATCH":
525
+ return "kind_mismatch";
526
+ case "HANDLE_BUSY":
527
+ return "busy";
528
+ default:
529
+ return "error";
530
+ }
531
+ }
532
+
533
+ /** Runs an operation and emits exactly one telemetry event for it. */
534
+ async function instrumented<T>(
535
+ kind: HandleKind,
536
+ operation: HandleOperation,
537
+ run: (report: (normalization: readonly string[], words: number) => void) => Promise<{
538
+ readonly value: T;
539
+ readonly outcome: HandleTelemetryOutcome;
540
+ }>,
541
+ ): Promise<T> {
542
+ let normalization: readonly string[] = [];
543
+ let words = 0;
544
+ const report = (nextNormalization: readonly string[], nextWords: number): void => {
545
+ normalization = nextNormalization;
546
+ words = nextWords;
547
+ };
548
+ try {
549
+ const result = await run(report);
550
+ emit(kind, operation, result.outcome, normalization, words);
551
+ return result.value;
552
+ } catch (error) {
553
+ emit(kind, operation, outcomeForError(error), normalization, words);
554
+ throw error;
555
+ }
556
+ }
557
+
558
+ function resolveNamespace(kind: HandleKind, operation: HandleOperation): ProviderStateNamespace {
559
+ if (!options.state) {
560
+ throw storageUnavailableError(kind, "no runtime state was provided to ctx.handle");
561
+ }
562
+ const bound = kind.access === "bound";
563
+ if (bound && connectionId === undefined) {
564
+ throw connectionRequiredError(kind, operation);
565
+ }
566
+ const state = bound ? options.state.forConnection(connectionId) : options.state;
567
+ // Storage ttl = logical remaining lifetime + expiry grace; drafts also need
568
+ // room for the commit lease (see COMMIT_LEASE_MS).
569
+ const maxTtlMs = isDraftKind(kind)
570
+ ? Math.max(
571
+ kind.maxTtlMs + graceMs(kind.maxTtlMs),
572
+ kind.resultTtlMs + graceMs(kind.resultTtlMs),
573
+ commitLeaseMs,
574
+ )
575
+ : kind.ttlMs + graceMs(kind.ttlMs);
576
+ const defaultTtlMs = isDraftKind(kind) ? kind.idleTtlMs : kind.ttlMs;
577
+ try {
578
+ return state.namespace(`${NAMESPACE_PREFIX}${kind.name}`, {
579
+ scope: bound ? "connection" : "provider",
580
+ defaultTtl: msDuration(defaultTtlMs),
581
+ maxTtl: msDuration(maxTtlMs),
582
+ maxEntries: kind.maxEntries,
583
+ maxValueBytes: kind.maxValueBytes,
584
+ });
585
+ } catch (error) {
586
+ throw translateStorageError(kind, error);
587
+ }
588
+ }
589
+
590
+ function translateStorageError(kind: HandleKind, error: unknown): unknown {
591
+ if (isStateUnsupportedError(error)) {
592
+ const reason = /quota exceeded/i.test(error.message)
593
+ ? "live-entry quota exceeded (maxEntries)"
594
+ : error.message;
595
+ return storageUnavailableError(kind, reason, error);
596
+ }
597
+ return error;
598
+ }
599
+
600
+ async function storage<T>(kind: HandleKind, run: () => Promise<T>): Promise<T> {
601
+ try {
602
+ return await run();
603
+ } catch (error) {
604
+ throw translateStorageError(kind, error);
605
+ }
606
+ }
607
+
608
+ function validateData(kind: HandleKind, operation: HandleOperation, data: unknown): unknown {
609
+ const parsed = kind.schema.safeParse(data);
610
+ if (!parsed.success) throw invalidDataError(kind, operation, parsed.error);
611
+ if (parsed.data === undefined) {
612
+ // JSON drops an undefined property, so the record would come back
613
+ // without `data` and fail its shape check on the next read.
614
+ throw invalidDataError(kind, operation, new Error("data must not be undefined"));
615
+ }
616
+ // Records are persisted as JSON (Redis). Values that JSON cannot represent
617
+ // (Date, Map, Set, class instances, NaN, bigint, functions) would silently
618
+ // come back as strings or empty objects and violate the schema's output
619
+ // type on read, so they are rejected at write time instead.
620
+ const unsafePath = findNonJsonSafePath(parsed.data, "data");
621
+ if (unsafePath) {
622
+ throw invalidDataError(
623
+ kind,
624
+ operation,
625
+ new Error(
626
+ `${unsafePath} is not JSON-safe (Date, Map, Set, class instances, NaN, bigint and functions cannot be stored)`,
627
+ ),
628
+ );
629
+ }
630
+ // Detach from the caller's object graph so later mutation of the input (or,
631
+ // with memory state, of the stored reference) cannot rewrite the record.
632
+ return detach(parsed.data);
633
+ }
634
+
635
+ /** Deep copy handed to callbacks and returned from reads so provider code cannot alias stored state. */
636
+ function detach<T>(value: T): T {
637
+ return value === undefined ? value : (structuredClone(value) as T);
638
+ }
639
+
640
+ /** Grace window after logical expiry during which HANDLE_EXPIRED is still reportable. */
641
+ function graceMs(lifetimeMs: number): number {
642
+ return Math.min(lifetimeMs, EXPIRY_GRACE_MAX_MS);
643
+ }
644
+
645
+ /** Logical expiry of an active draft after a touch: `min(now + idle, max_expires_at_ms)`. */
646
+ function draftExpiresAtMs(kind: DraftKind, record: StoredHandleRecord, now: number): number {
647
+ return Math.min(now + kind.idleTtlMs, record.max_expires_at_ms);
648
+ }
649
+
650
+ /**
651
+ * Physical storage ttl for a record: the time until it can no longer report
652
+ * HANDLE_EXPIRED. Drafts live to their hard lifetime (small, per-connection
653
+ * quota) so idle expiry stays distinguishable until `max` + grace.
654
+ */
655
+ function storageTtlMs(kind: HandleKind, record: StoredHandleRecord, now: number): number {
656
+ if (record.status === "committed") {
657
+ const lifetimeMs = isDraftKind(kind) ? kind.resultTtlMs : kind.ttlMs;
658
+ return Math.max(1, record.expires_at_ms - now) + graceMs(lifetimeMs);
659
+ }
660
+ if (isDraftKind(kind)) {
661
+ return Math.max(1, record.max_expires_at_ms - now) + graceMs(kind.maxTtlMs);
662
+ }
663
+ return Math.max(1, record.expires_at_ms - now) + graceMs(kind.ttlMs);
664
+ }
665
+
666
+ function assertSize(kind: HandleKind, record: StoredHandleRecord): void {
667
+ const bytes = recordBytes(record);
668
+ if (bytes > kind.maxValueBytes) throw tooLargeError(kind, bytes);
669
+ }
670
+
671
+ function toHandleRecord<K extends HandleKind>(
672
+ kind: K,
673
+ canonical: string,
674
+ record: StoredHandleRecord,
675
+ ): HandleRecord<K> {
676
+ return {
677
+ handle: canonical,
678
+ kind: kind.name,
679
+ status: record.status as HandleStatus,
680
+ data: detach(record.data) as DataOf<K>,
681
+ ...(record.status === "committed" ? { result: detach(record.result) as ResultOf<K> } : {}),
682
+ createdAt: new Date(record.created_at_ms).toISOString(),
683
+ expiresAt: new Date(record.expires_at_ms).toISOString(),
684
+ };
685
+ }
686
+
687
+ type Loaded = {
688
+ readonly canonical: string;
689
+ readonly normalization: readonly string[];
690
+ readonly words: readonly string[];
691
+ readonly namespace: ProviderStateNamespace;
692
+ readonly stored: StateValue<StoredHandleRecord>;
693
+ readonly record: StoredHandleRecord;
694
+ };
695
+
696
+ /**
697
+ * Normalizes, resolves storage, and loads the record, applying the spec §5
698
+ * expiry classification: absent → NOT_FOUND, present past `max_expires_at_ms`
699
+ * → EXPIRED (public kinds collapse both to HANDLE_INVALID).
700
+ */
701
+ async function load(
702
+ kind: HandleKind,
703
+ operation: HandleOperation,
704
+ raw: string,
705
+ report: (normalization: readonly string[], words: number) => void,
706
+ ): Promise<Loaded> {
707
+ const outcome = normalizeHandleInternal(kind, raw);
708
+ if (!outcome.ok) throw normalizationError(kind, outcome);
709
+ report(outcome.normalization, outcome.words.length);
710
+ const namespace = resolveNamespace(kind, operation);
711
+ const stored = await storage(kind, () => namespace.get<unknown>(outcome.canonical));
712
+ if (!stored) throw notFoundError(kind);
713
+ const value = stored.value;
714
+ if (
715
+ !isStoredHandleRecord(value) ||
716
+ value.kind !== kind.name ||
717
+ value.type !== kind.type ||
718
+ value.status === "discarded"
719
+ ) {
720
+ // Corrupt or foreign record under our key (or a discard tombstone that has
721
+ // not been reclaimed yet): unusable, never disclose contents.
722
+ if (isStoredHandleRecord(value) && value.status === "discarded") throw notFoundError(kind);
723
+ throw kind.access === "public"
724
+ ? notFoundError(kind)
725
+ : normalizationError(kind, {
726
+ ok: false,
727
+ reason: "invalid",
728
+ detail: "record unreadable",
729
+ });
730
+ }
731
+ // A live `committing` claim is still doing work under its lease even if the
732
+ // draft's hard expiry passed meanwhile; callers must see BUSY, not EXPIRED,
733
+ // so they do not start a second flow while the first may still succeed.
734
+ if (value.status !== "committing") {
735
+ const now = nowMs();
736
+ if (now >= value.expires_at_ms || now >= value.max_expires_at_ms) {
737
+ throw expiredError(kind, expiredDeadline(kind, value, now));
738
+ }
739
+ }
740
+ return {
741
+ canonical: outcome.canonical,
742
+ normalization: outcome.normalization,
743
+ words: outcome.words,
744
+ namespace,
745
+ stored: { ...stored, value },
746
+ record: value,
747
+ };
748
+ }
749
+
750
+ async function createRecord<K extends HandleKind>(
751
+ kind: K,
752
+ data: InputOf<K>,
753
+ ): Promise<HandleRecord<K>> {
754
+ return await instrumented<HandleRecord<K>>(kind, "create", async (report) => {
755
+ const validated = validateData(kind, "create", data);
756
+ const namespace = resolveNamespace(kind, "create");
757
+ const now = nowMs();
758
+ const lifetimeMs = isDraftKind(kind) ? kind.maxTtlMs : kind.ttlMs;
759
+ const record: StoredHandleRecord = {
760
+ v: RECORD_VERSION,
761
+ kind: kind.name,
762
+ type: kind.type,
763
+ status: "active",
764
+ data: validated,
765
+ created_at_ms: now,
766
+ expires_at_ms:
767
+ now + (isDraftKind(kind) ? Math.min(kind.idleTtlMs, kind.maxTtlMs) : lifetimeMs),
768
+ max_expires_at_ms: now + lifetimeMs,
769
+ };
770
+ const writeTtlMs = storageTtlMs(kind, record, now);
771
+ assertSize(kind, record);
772
+ report([], kind.wordCount);
773
+ // CAS-if-absent (expected version 0). A collision with a live handle of
774
+ // the same scope simply retries with fresh words; five misses in a
775
+ // 1.68M (bound) or 2.8T+ (public) key space means storage is unhealthy.
776
+ for (let attempt = 0; attempt < CREATE_ATTEMPTS; attempt += 1) {
777
+ const canonical = `${kind.name}_${generateWords(kind.wordCount).join("-")}`;
778
+ const result = await storage(kind, () =>
779
+ namespace.compareAndSet(canonical, 0, record, { ttl: msDuration(writeTtlMs) }),
780
+ );
781
+ if (result.ok) {
782
+ return { value: toHandleRecord(kind, canonical, record), outcome: "success" };
783
+ }
784
+ }
785
+ throw storageUnavailableError(
786
+ kind,
787
+ `could not allocate a unique handle after ${CREATE_ATTEMPTS} attempts`,
788
+ );
789
+ });
790
+ }
791
+
792
+ async function create<K extends HandleKind>(kind: K, data: InputOf<K>): Promise<string> {
793
+ return (await createRecord(kind, data)).handle;
794
+ }
795
+
796
+ async function read<K extends HandleKind>(kind: K, handle: string): Promise<HandleRecord<K>> {
797
+ return await instrumented<HandleRecord<K>>(kind, "read", async (report) => {
798
+ const loaded = await load(kind, "read", handle, report);
799
+ const now = nowMs();
800
+ if (isDraftKind(kind) && loaded.record.status === "active") {
801
+ // Sliding TTL: rewrite the record with a fresh logical expiry. Losing the
802
+ // CAS means a concurrent update/commit already refreshed it.
803
+ const touched: StoredHandleRecord = {
804
+ ...loaded.record,
805
+ expires_at_ms: draftExpiresAtMs(kind, loaded.record, now),
806
+ };
807
+ await storage(kind, () =>
808
+ touchDraft(
809
+ loaded.namespace,
810
+ loaded.canonical,
811
+ loaded.stored,
812
+ touched,
813
+ storageTtlMs(kind, touched, now),
814
+ ),
815
+ );
816
+ return { value: toHandleRecord(kind, loaded.canonical, touched), outcome: "success" };
817
+ }
818
+ return { value: toHandleRecord(kind, loaded.canonical, loaded.record), outcome: "success" };
819
+ });
820
+ }
821
+
822
+ async function touchDraft(
823
+ namespace: ProviderStateNamespace,
824
+ canonical: string,
825
+ stored: StateValue<StoredHandleRecord>,
826
+ touched: StoredHandleRecord,
827
+ ttlMs: number,
828
+ ): Promise<void> {
829
+ await namespace.compareAndSet(canonical, stored.version, touched, {
830
+ ttl: msDuration(ttlMs),
831
+ });
832
+ }
833
+
834
+ async function update<K extends DraftKind>(
835
+ kind: K,
836
+ handle: string,
837
+ updater: (data: DataOf<K>) => InputOf<K> | Promise<InputOf<K>>,
838
+ ): Promise<HandleRecord<K>> {
839
+ return await instrumented<HandleRecord<K>>(kind, "update", async (report) => {
840
+ for (let attempt = 0; attempt < UPDATE_ATTEMPTS; attempt += 1) {
841
+ const loaded = await load(kind, "update", handle, report);
842
+ if (loaded.record.status === "committed") throw committedError(kind);
843
+ if (loaded.record.status === "committing") throw busyError(kind);
844
+ const nextData = validateData(
845
+ kind,
846
+ "update",
847
+ await updater(detach(loaded.record.data) as DataOf<K>),
848
+ );
849
+ const now = nowMs();
850
+ const next: StoredHandleRecord = {
851
+ ...loaded.record,
852
+ data: nextData,
853
+ expires_at_ms: draftExpiresAtMs(kind, loaded.record, now),
854
+ };
855
+ assertSize(kind, next);
856
+ const ttlMs = storageTtlMs(kind, next, now);
857
+ const result = await storage(kind, () =>
858
+ loaded.namespace.compareAndSet(loaded.canonical, loaded.stored.version, next, {
859
+ ttl: msDuration(ttlMs),
860
+ }),
861
+ );
862
+ if (result.ok) {
863
+ return {
864
+ value: toHandleRecord(kind, loaded.canonical, next),
865
+ outcome: "success",
866
+ };
867
+ }
868
+ }
869
+ throw busyError(kind);
870
+ });
871
+ }
872
+
873
+ async function commit<K extends DraftKind>(
874
+ kind: K,
875
+ handle: string,
876
+ work: (data: DataOf<K>) => Promise<ResultOf<K>>,
877
+ ): Promise<HandleCommitResult<K>> {
878
+ return await instrumented<HandleCommitResult<K>>(kind, "commit", async (report) => {
879
+ const loaded = await load(kind, "commit", handle, report);
880
+ if (loaded.record.status === "committed") {
881
+ return {
882
+ value: {
883
+ status: "replayed",
884
+ handle: loaded.canonical,
885
+ result: detach(loaded.record.result) as ResultOf<K>,
886
+ },
887
+ outcome: "replayed",
888
+ };
889
+ }
890
+ if (loaded.record.status === "committing") throw busyError(kind);
891
+
892
+ // active → committing. The claim holds a lease that is independent of the
893
+ // draft's remaining idle ttl: upstream work started near expiry must still
894
+ // be able to persist its result, otherwise a successful mutation would be
895
+ // lost and the retry would see NOT_FOUND instead of the promised replay.
896
+ // A crashed committer leaves the record BUSY for at most the lease.
897
+ const claimId = randomUUID();
898
+ const committing: StoredHandleRecord = {
899
+ ...loaded.record,
900
+ status: "committing",
901
+ claim_id: claimId,
902
+ };
903
+ const remainingMs = storageTtlMs(kind, committing, nowMs());
904
+ let claim: StateCasResult<unknown>;
905
+ try {
906
+ claim = await storage(kind, () =>
907
+ loaded.namespace.compareAndSet(loaded.canonical, loaded.stored.version, committing, {
908
+ ttl: msDuration(Math.max(remainingMs, commitLeaseMs)),
909
+ }),
910
+ );
911
+ } catch (error) {
912
+ // Ambiguous write: the claim may have landed although the response was
913
+ // lost. Re-read and recognise our own claim id; otherwise the draft is
914
+ // untouched (still active) and the storage error stands.
915
+ const current = await storage(kind, () => loaded.namespace.get<unknown>(loaded.canonical));
916
+ if (
917
+ current &&
918
+ isStoredHandleRecord(current.value) &&
919
+ current.value.status === "committing" &&
920
+ current.value.claim_id === claimId
921
+ ) {
922
+ claim = { ok: true, value: current };
923
+ } else {
924
+ throw error;
925
+ }
926
+ }
927
+ if (!claim.ok) {
928
+ // Lost the race: another caller moved the record. Classify by what they did.
929
+ const current = claim.current?.value;
930
+ if (isStoredHandleRecord(current) && current.status === "committed") {
931
+ return {
932
+ value: {
933
+ status: "replayed",
934
+ handle: loaded.canonical,
935
+ result: detach(current.result) as ResultOf<K>,
936
+ },
937
+ outcome: "replayed",
938
+ };
939
+ }
940
+ throw busyError(kind);
941
+ }
942
+ let version = claim.value.version;
943
+
944
+ // Renew the claim while upstream work is running so its storage lifetime
945
+ // can never end underneath a slow callback (which would lose the result
946
+ // and turn the retry into NOT_FOUND instead of a replay). Best effort:
947
+ // a lost renewal CAS means another actor moved the record, and the
948
+ // finalize step below classifies that.
949
+ let renewing = true;
950
+ let wakeRenewal: (() => void) | undefined;
951
+ const stopRenewal = (): void => {
952
+ renewing = false;
953
+ wakeRenewal?.();
954
+ };
955
+ const renew = async (): Promise<void> => {
956
+ while (renewing) {
957
+ await new Promise<void>((resolve) => {
958
+ const timer = setTimeout(resolve, Math.max(1, Math.floor(commitLeaseMs / 2)));
959
+ timer.unref?.();
960
+ wakeRenewal = () => {
961
+ clearTimeout(timer);
962
+ resolve();
963
+ };
964
+ });
965
+ if (!renewing) return;
966
+ try {
967
+ const renewed = await loaded.namespace.compareAndSet(
968
+ loaded.canonical,
969
+ version,
970
+ committing,
971
+ { ttl: msDuration(commitLeaseMs) },
972
+ );
973
+ if (renewed.ok) {
974
+ version = renewed.value.version;
975
+ continue;
976
+ }
977
+ // Lost the CAS. Only a `committing` record can still be ours (another
978
+ // actor cannot claim a committing draft); adopt its version and keep
979
+ // renewing, otherwise the record moved on and finalize classifies it.
980
+ const current = renewed.current;
981
+ if (
982
+ current &&
983
+ isStoredHandleRecord(current.value) &&
984
+ current.value.status === "committing" &&
985
+ current.value.claim_id === claimId
986
+ ) {
987
+ version = current.version;
988
+ continue;
989
+ }
990
+ return;
991
+ } catch {
992
+ // Ambiguous write (e.g. a Redis timeout after the SET landed): the
993
+ // version we hold may be stale. Re-read and adopt the live version so
994
+ // the next renewal does not fail its CAS and let the lease lapse.
995
+ try {
996
+ const current = await loaded.namespace.get<unknown>(loaded.canonical);
997
+ if (
998
+ current &&
999
+ isStoredHandleRecord(current.value) &&
1000
+ current.value.status === "committing" &&
1001
+ current.value.claim_id === claimId
1002
+ ) {
1003
+ version = current.version;
1004
+ }
1005
+ } catch {
1006
+ // Still unreachable; try again after the next interval.
1007
+ }
1008
+ }
1009
+ }
1010
+ };
1011
+ const renewal = renew();
1012
+
1013
+ let result: ResultOf<K>;
1014
+ try {
1015
+ result = await work(detach(loaded.record.data) as DataOf<K>);
1016
+ } catch (error) {
1017
+ stopRenewal();
1018
+ await renewal;
1019
+ // Restore committing → active so the caller can retry; best effort.
1020
+ try {
1021
+ // Refresh the idle deadline (bounded by max) so a confirm that failed
1022
+ // after a long upstream call stays retryable instead of reading as
1023
+ // expired the moment it is restored.
1024
+ const restoreNow = nowMs();
1025
+ const restored: StoredHandleRecord = {
1026
+ ...loaded.record,
1027
+ status: "active",
1028
+ expires_at_ms: draftExpiresAtMs(kind, loaded.record, restoreNow),
1029
+ };
1030
+ const ttlMs = storageTtlMs(kind, restored, restoreNow);
1031
+ await loaded.namespace.compareAndSet(loaded.canonical, version, restored, {
1032
+ ttl: msDuration(ttlMs),
1033
+ });
1034
+ } catch {
1035
+ // The record stays `committing` until its ttl lapses; the work error wins.
1036
+ }
1037
+ throw error;
1038
+ }
1039
+
1040
+ stopRenewal();
1041
+ await renewal;
1042
+ const now = nowMs();
1043
+ // Results are persisted as JSON too: reject Date/Map/Set/class instances so
1044
+ // the initial return and every replay carry the same value, and detach so
1045
+ // the caller's post-processing cannot rewrite the stored replay.
1046
+ const unsafeResultPath = findNonJsonSafePath(result, "result");
1047
+ if (unsafeResultPath) {
1048
+ throw invalidDataError(
1049
+ kind,
1050
+ "commit",
1051
+ new Error(
1052
+ `${unsafeResultPath} is not JSON-safe (Date, Map, Set, class instances, NaN, bigint and functions cannot be stored)`,
1053
+ ),
1054
+ );
1055
+ }
1056
+ const committed: StoredHandleRecord = {
1057
+ ...loaded.record,
1058
+ status: "committed",
1059
+ claim_id: undefined,
1060
+ result: detach(result),
1061
+ committed_at_ms: now,
1062
+ expires_at_ms: now + kind.resultTtlMs,
1063
+ max_expires_at_ms: now + kind.resultTtlMs,
1064
+ };
1065
+ // If the result does not fit we deliberately leave the record `committing`
1066
+ // (BUSY until ttl) rather than `active`: the work already ran upstream and
1067
+ // re-running it is the worse failure. This is a provider bug (internal_error).
1068
+ assertSize(kind, committed);
1069
+ // The work already ran upstream: persisting its result is the one step
1070
+ // that must not give up on a transient storage error, so storage
1071
+ // failures are retried (with a short pause) up to the attempt budget and
1072
+ // an already-committed record is reconciled as a replay.
1073
+ let lastStorageError: unknown;
1074
+ for (let attempt = 0; attempt < COMMIT_FINALIZE_ATTEMPTS; attempt += 1) {
1075
+ let finalize: StateCasResult<unknown>;
1076
+ try {
1077
+ finalize = await storage(kind, () =>
1078
+ loaded.namespace.compareAndSet(loaded.canonical, version, committed, {
1079
+ ttl: msDuration(storageTtlMs(kind, committed, now)),
1080
+ }),
1081
+ );
1082
+ } catch (error) {
1083
+ lastStorageError = error;
1084
+ await new Promise((resolve) => {
1085
+ const timer = setTimeout(resolve, COMMIT_FINALIZE_RETRY_DELAY_MS * (attempt + 1));
1086
+ timer.unref?.();
1087
+ });
1088
+ continue;
1089
+ }
1090
+ if (finalize.ok) {
1091
+ return {
1092
+ value: { status: "committed", handle: loaded.canonical, result: detach(result) },
1093
+ outcome: "success",
1094
+ };
1095
+ }
1096
+ const current = finalize.current;
1097
+ if (!current || !isStoredHandleRecord(current.value)) break;
1098
+ if (current.value.status === "committed") {
1099
+ return {
1100
+ value: {
1101
+ status: "replayed",
1102
+ handle: loaded.canonical,
1103
+ result: detach(current.value.result) as ResultOf<K>,
1104
+ },
1105
+ outcome: "replayed",
1106
+ };
1107
+ }
1108
+ if (current.value.status !== "committing" || current.value.claim_id !== claimId) break;
1109
+ version = current.version;
1110
+ }
1111
+ if (lastStorageError !== undefined) throw lastStorageError;
1112
+ throw storageUnavailableError(
1113
+ kind,
1114
+ "could not persist the commit result after the work completed",
1115
+ );
1116
+ });
1117
+ }
1118
+
1119
+ async function discard<K extends HandleKind>(kind: K, handle: string): Promise<void> {
1120
+ return await instrumented<void>(kind, "discard", async (report) => {
1121
+ const outcome = normalizeHandleInternal(kind, handle);
1122
+ if (!outcome.ok) throw normalizationError(kind, outcome);
1123
+ report(outcome.normalization, outcome.words.length);
1124
+ const namespace = resolveNamespace(kind, "discard");
1125
+ // Discard participates in the draft state machine: a record that another
1126
+ // caller has claimed for commit must not be deleted underneath it (the
1127
+ // upstream work may succeed and needs to persist its replayable result).
1128
+ // Deletion is a version-checked tombstone write; a concurrent refresh or
1129
+ // update that wins the CAS is retried against the new version, a commit
1130
+ // claim reports BUSY. The tombstone ttl is 1ms so it fits every namespace
1131
+ // limit; reads treat it as absent and the slot is reclaimed right after.
1132
+ for (let attempt = 0; attempt < DISCARD_ATTEMPTS; attempt += 1) {
1133
+ const stored = await storage(kind, () => namespace.get<unknown>(outcome.canonical));
1134
+ if (!stored) return { value: undefined, outcome: "success" };
1135
+ const record = stored.value;
1136
+ if (isStoredHandleRecord(record) && record.status === "committing") throw busyError(kind);
1137
+ const tombstone = isStoredHandleRecord(record)
1138
+ ? ({ ...record, status: "discarded" } satisfies StoredHandleRecord)
1139
+ : record;
1140
+ const result = await storage(kind, () =>
1141
+ namespace.compareAndSet(outcome.canonical, stored.version, tombstone, { ttl: "1ms" }),
1142
+ );
1143
+ if (result.ok) {
1144
+ // The 1ms tombstone expires on its own (Redis PXAT / memory prune) and
1145
+ // reads treat it as absent. No follow-up delete: the state API has no
1146
+ // atomic conditional delete, and any read-then-delete could remove a
1147
+ // record re-allocated under the same key after the tombstone expired.
1148
+ return { value: undefined, outcome: "success" };
1149
+ }
1150
+ const current = result.current?.value;
1151
+ if (isStoredHandleRecord(current) && current.status === "committing") {
1152
+ throw busyError(kind);
1153
+ }
1154
+ if (!result.current) return { value: undefined, outcome: "success" };
1155
+ }
1156
+ throw busyError(kind);
1157
+ });
1158
+ }
1159
+
1160
+ return { create, createRecord, read, update, commit, discard };
1161
+ }
1162
+
1163
+ /**
1164
+ * Handle context for unit tests: memory state, a fixed connection, and an
1165
+ * optional fake clock. Share one `state` between two contexts with different
1166
+ * `request.connectionId`s to exercise bound isolation.
1167
+ */
1168
+ export function createTestHandleContext(
1169
+ options: CreateTestHandleContextOptions = {},
1170
+ ): HandleContext {
1171
+ return createHandleContext({
1172
+ providerId: options.providerId ?? "test-provider",
1173
+ state:
1174
+ options.state ??
1175
+ createMemoryProviderRuntimeState(options.nowMs ? { now: options.nowMs } : undefined),
1176
+ request: options.request ?? { headers: {}, connectionId: "test-connection" },
1177
+ nowMs: options.nowMs,
1178
+ onTelemetry: options.onTelemetry,
1179
+ });
1180
+ }