@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.
- package/AUTHORING.md +943 -51
- package/CHANGELOG.md +273 -1
- package/LICENSE +21 -0
- package/README.md +117 -29
- package/SUBMISSION.md +3 -2
- package/bin/apifuse-check.ts +293 -13
- package/bin/apifuse-dev.ts +42 -19
- package/bin/apifuse-migrate-deployment.ts +200 -0
- package/bin/apifuse-migrate-operation-declaration.ts +55 -0
- package/bin/apifuse-migrate-shape.ts +202 -0
- package/bin/apifuse-pack-check.ts +19 -6
- package/bin/apifuse-pack-smoke.ts +57 -2
- package/bin/apifuse-pack-types.ts +736 -38
- package/bin/apifuse-perf.ts +59 -23
- package/bin/apifuse-record.ts +923 -94
- package/bin/apifuse-submit-check.ts +2313 -281
- package/bin/apifuse-test.ts +3 -1
- package/bin/submit-check-delimited-text.ts +50 -0
- package/dist/auth-turn/index.d.ts +3 -3
- package/dist/auth-turn/index.js +1 -1
- package/dist/auth.d.ts +14 -0
- package/dist/auth.js +67 -0
- package/dist/ceremonies/index.d.ts +16 -0
- package/dist/ceremonies/index.js +141 -36
- package/dist/cli/commands.d.ts +1 -1
- package/dist/cli/commands.js +30 -0
- package/dist/cli/create.d.ts +1 -1
- package/dist/cli/create.js +8 -2
- package/dist/cli/deployment-intent.d.ts +178 -0
- package/dist/cli/deployment-intent.js +277 -0
- package/dist/cli/local-connection.d.ts +111 -0
- package/dist/cli/local-connection.js +151 -0
- package/dist/cli/migrate-deployment-intent.d.ts +132 -0
- package/dist/cli/migrate-deployment-intent.js +1552 -0
- package/dist/cli/migrate-operation-declaration.d.ts +70 -0
- package/dist/cli/migrate-operation-declaration.js +2206 -0
- package/dist/cli/migrate-operation-shape.d.ts +44 -0
- package/dist/cli/migrate-operation-shape.js +113 -0
- package/dist/cli/migrate-provider-shape.d.ts +56 -0
- package/dist/cli/migrate-provider-shape.js +580 -0
- package/dist/cli/resolver-runtime.d.ts +21 -0
- package/dist/cli/resolver-runtime.js +31 -0
- package/dist/cli/templates/provider/.agents/skills/health-checks-and-fail-closed/SKILL.md.tpl +141 -23
- package/dist/cli/templates/provider/Dockerfile.tpl +1 -1
- package/dist/cli/templates/provider/README.md.tpl +41 -19
- package/dist/cli/templates/provider/index.ts.tpl +6 -3
- package/dist/cli/templates/provider/operations/ping.ts.tpl +4 -1
- package/dist/cli/templates/provider/provider.json.tpl +6 -0
- package/dist/config/loader.d.ts +202 -16
- package/dist/config/loader.js +491 -141
- package/dist/contract-serialization.js +4 -8
- package/dist/contract-types.d.ts +12 -5
- package/dist/contract.js +24 -10
- package/dist/declaration-validation.d.ts +34 -0
- package/dist/declaration-validation.js +305 -0
- package/dist/define.d.ts +71 -29
- package/dist/define.js +807 -69
- package/dist/dev.d.ts +5 -0
- package/dist/dev.js +8 -1
- package/dist/engine.d.ts +129 -0
- package/dist/engine.js +209 -0
- package/dist/error-observability.d.ts +7 -0
- package/dist/error-observability.js +61 -0
- package/dist/error-resolution.d.ts +18 -0
- package/dist/error-resolution.js +243 -0
- package/dist/errors.d.ts +74 -0
- package/dist/errors.js +99 -0
- package/dist/fixture-sanitization.d.ts +28 -0
- package/dist/fixture-sanitization.js +371 -0
- package/dist/handle-meta.d.ts +90 -0
- package/dist/handle-meta.js +73 -0
- package/dist/handle.d.ts +226 -0
- package/dist/handle.js +345 -0
- package/dist/health-scenario.d.ts +1901 -0
- package/dist/health-scenario.js +653 -0
- package/dist/i18n/catalog.js +21 -1
- package/dist/i18n/error-messages.d.ts +86 -0
- package/dist/i18n/error-messages.js +192 -0
- package/dist/i18n/index.d.ts +1 -0
- package/dist/i18n/index.js +1 -0
- package/dist/i18n/keys.d.ts +12 -0
- package/dist/i18n/keys.js +76 -5
- package/dist/i18n/operation-locale-namespace.d.ts +2 -0
- package/dist/i18n/operation-locale-namespace.js +7 -0
- package/dist/index.d.ts +40 -15
- package/dist/index.js +29 -11
- package/dist/lint.d.ts +43 -11
- package/dist/lint.js +2512 -177
- package/dist/native-address.d.ts +43 -0
- package/dist/native-address.js +281 -0
- package/dist/native-egress-policy.d.ts +31 -0
- package/dist/native-egress-policy.js +288 -0
- package/dist/observability.d.ts +5 -2
- package/dist/observability.js +48 -1
- package/dist/provider.d.ts +18 -8
- package/dist/provider.js +11 -6
- package/dist/runtime/auth-flow.d.ts +6 -1
- package/dist/runtime/auth-flow.js +8 -0
- package/dist/runtime/bounded-expiring-map.d.ts +21 -0
- package/dist/runtime/bounded-expiring-map.js +64 -0
- package/dist/runtime/browser-telemetry-binding.d.ts +5 -0
- package/dist/runtime/browser-telemetry-binding.js +83 -0
- package/dist/runtime/browser-telemetry.d.ts +76 -0
- package/dist/runtime/browser-telemetry.js +165 -0
- package/dist/runtime/browser.d.ts +4 -0
- package/dist/runtime/browser.js +688 -83
- package/dist/runtime/cache-telemetry.d.ts +71 -0
- package/dist/runtime/cache-telemetry.js +177 -0
- package/dist/runtime/cache.d.ts +3 -0
- package/dist/runtime/cache.js +245 -23
- package/dist/runtime/capability-response.d.ts +3 -0
- package/dist/runtime/capability-response.js +56 -0
- package/dist/runtime/capability-telemetry.d.ts +46 -0
- package/dist/runtime/capability-telemetry.js +68 -0
- package/dist/runtime/chrome149-header-order.d.ts +58 -0
- package/dist/runtime/chrome149-header-order.js +289 -0
- package/dist/runtime/diagnostic-env-values.d.ts +5 -0
- package/dist/runtime/diagnostic-env-values.js +57 -0
- package/dist/runtime/diagnostic-env.d.ts +14 -0
- package/dist/runtime/diagnostic-env.js +44 -0
- package/dist/runtime/diagnostic-redactor.d.ts +54 -0
- package/dist/runtime/diagnostic-redactor.js +571 -0
- package/dist/runtime/egress-lease.d.ts +65 -0
- package/dist/runtime/egress-lease.js +236 -0
- package/dist/runtime/engine-mode.d.ts +74 -0
- package/dist/runtime/engine-mode.js +152 -0
- package/dist/runtime/env.js +14 -1
- package/dist/runtime/executor.d.ts +3 -2
- package/dist/runtime/executor.js +23 -26
- package/dist/runtime/handle-wordlist.d.ts +43 -0
- package/dist/runtime/handle-wordlist.js +256 -0
- package/dist/runtime/handle.d.ts +71 -0
- package/dist/runtime/handle.js +908 -0
- package/dist/runtime/http-telemetry-binding.d.ts +10 -0
- package/dist/runtime/http-telemetry-binding.js +152 -0
- package/dist/runtime/http-telemetry-guard.d.ts +16 -0
- package/dist/runtime/http-telemetry-guard.js +202 -0
- package/dist/runtime/http-telemetry.d.ts +104 -0
- package/dist/runtime/http-telemetry.js +190 -0
- package/dist/runtime/http.d.ts +34 -0
- package/dist/runtime/http.js +680 -121
- package/dist/runtime/insights.js +2 -2
- package/dist/runtime/instrumentation.d.ts +2 -2
- package/dist/runtime/instrumentation.js +462 -19
- package/dist/runtime/native-network-errors.d.ts +33 -0
- package/dist/runtime/native-network-errors.js +69 -0
- package/dist/runtime/native-network.d.ts +105 -0
- package/dist/runtime/native-network.js +1433 -0
- package/dist/runtime/native-telemetry.d.ts +142 -0
- package/dist/runtime/native-telemetry.js +231 -0
- package/dist/runtime/ocr-telemetry.d.ts +82 -0
- package/dist/runtime/ocr-telemetry.js +192 -0
- package/dist/runtime/ocr.d.ts +29 -0
- package/dist/runtime/ocr.js +468 -0
- package/dist/runtime/otlp.d.ts +71 -2
- package/dist/runtime/otlp.js +398 -16
- package/dist/runtime/provider.d.ts +7 -0
- package/dist/runtime/provider.js +14 -0
- package/dist/runtime/proxy-errors.js +6 -2
- package/dist/runtime/proxy-nodemaven.d.ts +56 -0
- package/dist/runtime/proxy-nodemaven.js +147 -0
- package/dist/runtime/proxy-retry-policy.d.ts +4 -1
- package/dist/runtime/proxy-retry-policy.js +11 -2
- package/dist/runtime/proxy-telemetry.d.ts +133 -3
- package/dist/runtime/proxy-telemetry.js +218 -70
- package/dist/runtime/redirect-hop-headers.d.ts +19 -0
- package/dist/runtime/redirect-hop-headers.js +58 -0
- package/dist/runtime/redirects.d.ts +61 -0
- package/dist/runtime/redirects.js +149 -0
- package/dist/runtime/redis.d.ts +1 -1
- package/dist/runtime/redis.js +4 -2
- package/dist/runtime/request-options.d.ts +75 -1
- package/dist/runtime/request-options.js +588 -0
- package/dist/runtime/request-telemetry.d.ts +70 -0
- package/dist/runtime/request-telemetry.js +229 -0
- package/dist/runtime/resolver-config.d.ts +13 -0
- package/dist/runtime/resolver-config.js +14 -0
- package/dist/runtime/resolver-public.d.ts +3 -0
- package/dist/runtime/resolver-public.js +1 -0
- package/dist/runtime/resolver-runtime-options.d.ts +14 -0
- package/dist/runtime/resolver-runtime-options.js +33 -0
- package/dist/runtime/resolver-shared.d.ts +24 -0
- package/dist/runtime/resolver-shared.js +69 -0
- package/dist/runtime/resolver-span-attributes.d.ts +34 -0
- package/dist/runtime/resolver-span-attributes.js +93 -0
- package/dist/runtime/resolver-telemetry.d.ts +135 -0
- package/dist/runtime/resolver-telemetry.js +204 -0
- package/dist/runtime/resolver-usage.d.ts +32 -0
- package/dist/runtime/resolver-usage.js +54 -0
- package/dist/runtime/resolver-vendors/bindings.d.ts +53 -0
- package/dist/runtime/resolver-vendors/bindings.js +45 -0
- package/dist/runtime/resolver-vendors/browser.d.ts +24 -0
- package/dist/runtime/resolver-vendors/browser.js +423 -0
- package/dist/runtime/resolver-vendors/capsolver.d.ts +22 -0
- package/dist/runtime/resolver-vendors/capsolver.js +544 -0
- package/dist/runtime/resolver-vendors/hosts.d.ts +23 -0
- package/dist/runtime/resolver-vendors/hosts.js +68 -0
- package/dist/runtime/resolver-vendors/hypersolutions.d.ts +27 -0
- package/dist/runtime/resolver-vendors/hypersolutions.js +369 -0
- package/dist/runtime/resolver-vendors/twocaptcha.d.ts +24 -0
- package/dist/runtime/resolver-vendors/twocaptcha.js +425 -0
- package/dist/runtime/resolver-vendors/types.d.ts +125 -0
- package/dist/runtime/resolver-vendors/types.js +106 -0
- package/dist/runtime/resolver.d.ts +79 -0
- package/dist/runtime/resolver.js +1094 -0
- package/dist/runtime/state-telemetry.d.ts +79 -0
- package/dist/runtime/state-telemetry.js +194 -0
- package/dist/runtime/state.d.ts +10 -1
- package/dist/runtime/state.js +322 -82
- package/dist/runtime/stealth-akamai-sbsd.d.ts +54 -0
- package/dist/runtime/stealth-akamai-sbsd.js +142 -0
- package/dist/runtime/stealth-cookies.d.ts +24 -0
- package/dist/runtime/stealth-cookies.js +131 -0
- package/dist/runtime/stealth-owned-headers.d.ts +39 -0
- package/dist/runtime/stealth-owned-headers.js +57 -0
- package/dist/runtime/stealth-telemetry.d.ts +92 -0
- package/dist/runtime/stealth-telemetry.js +179 -0
- package/dist/runtime/stealth.d.ts +45 -6
- package/dist/runtime/stealth.js +1775 -320
- package/dist/runtime/stt-telemetry.d.ts +77 -0
- package/dist/runtime/stt-telemetry.js +176 -0
- package/dist/runtime/stt.js +18 -19
- package/dist/runtime/timeout.d.ts +5 -0
- package/dist/runtime/timeout.js +12 -0
- package/dist/runtime/trace-config.d.ts +12 -0
- package/dist/runtime/trace-config.js +62 -0
- package/dist/runtime/trace.d.ts +17 -0
- package/dist/runtime/trace.js +176 -25
- package/dist/runtime-boundary-lint.d.ts +137 -0
- package/dist/runtime-boundary-lint.js +590 -0
- package/dist/schema.d.ts +20 -0
- package/dist/schema.js +72 -2
- package/dist/serve.d.ts +1 -1
- package/dist/serve.js +1 -1
- package/dist/server/error-observability.d.ts +1 -0
- package/dist/server/error-observability.js +1 -0
- package/dist/server/error-stack-frames.d.ts +35 -0
- package/dist/server/error-stack-frames.js +219 -0
- package/dist/server/index.d.ts +21 -2
- package/dist/server/index.js +11 -2
- package/dist/server/runtime-diagnostics.d.ts +165 -0
- package/dist/server/runtime-diagnostics.js +360 -0
- package/dist/server/sdk-version.d.ts +3 -0
- package/dist/server/sdk-version.js +19 -0
- package/dist/server/self-test-input-tokens.d.ts +2 -1
- package/dist/server/self-test-input-tokens.js +18 -14
- package/dist/server/self-test-redaction.js +8 -33
- package/dist/server/self-test.d.ts +18 -3
- package/dist/server/self-test.js +139 -76
- package/dist/server/sensitive-values.d.ts +26 -0
- package/dist/server/sensitive-values.js +137 -0
- package/dist/server/serve-implementation.d.ts +285 -0
- package/dist/server/serve-implementation.js +3172 -0
- package/dist/server/serve.d.ts +1 -75
- package/dist/server/serve.js +1 -1170
- package/dist/server/state-backend-report.d.ts +70 -0
- package/dist/server/state-backend-report.js +113 -0
- package/dist/server/trace-output.d.ts +7 -0
- package/dist/server/trace-output.js +111 -0
- package/dist/server/types.d.ts +37 -5
- package/dist/server/types.js +34 -1
- package/dist/stateful/errors.d.ts +19 -0
- package/dist/stateful/errors.js +24 -0
- package/dist/stateful/http-provider-event-emitter.d.ts +40 -0
- package/dist/stateful/http-provider-event-emitter.js +237 -0
- package/dist/stateful/http-session-owner-registry.d.ts +44 -0
- package/dist/stateful/http-session-owner-registry.js +210 -0
- package/dist/stateful/index.d.ts +18 -0
- package/dist/stateful/index.js +18 -0
- package/dist/stateful/provider-event-delivery-failures.d.ts +32 -0
- package/dist/stateful/provider-event-delivery-failures.js +43 -0
- package/dist/stateful/provider-event-pipeline-metrics.d.ts +46 -0
- package/dist/stateful/provider-event-pipeline-metrics.js +48 -0
- package/dist/stateful/provider-event-pipeline.d.ts +50 -0
- package/dist/stateful/provider-event-pipeline.js +1 -0
- package/dist/stateful/provider-events.d.ts +101 -0
- package/dist/stateful/provider-events.js +289 -0
- package/dist/stateful/session-key.d.ts +15 -0
- package/dist/stateful/session-key.js +86 -0
- package/dist/stateful/stateful-provider-adapter-context.d.ts +5 -0
- package/dist/stateful/stateful-provider-adapter-context.js +42 -0
- package/dist/stateful/stateful-provider-adapter-metrics.d.ts +15 -0
- package/dist/stateful/stateful-provider-adapter-metrics.js +21 -0
- package/dist/stateful/stateful-provider-adapter.d.ts +98 -0
- package/dist/stateful/stateful-provider-adapter.js +287 -0
- package/dist/stateful/stateful-provider-observability.d.ts +62 -0
- package/dist/stateful/stateful-provider-observability.js +161 -0
- package/dist/stateful/stateful-provider-owner-forwarder.d.ts +41 -0
- package/dist/stateful/stateful-provider-owner-forwarder.js +237 -0
- package/dist/stateful/stateful-provider-runtime-context.d.ts +35 -0
- package/dist/stateful/stateful-provider-runtime-context.js +61 -0
- package/dist/stateful/stateful-provider-runtime-executor.d.ts +34 -0
- package/dist/stateful/stateful-provider-runtime-executor.js +52 -0
- package/dist/stateful/stateful-provider-session-routing.d.ts +67 -0
- package/dist/stateful/stateful-provider-session-routing.js +345 -0
- package/dist/stateful/stateful-provider-session-runtime.d.ts +98 -0
- package/dist/stateful/stateful-provider-session-runtime.js +245 -0
- package/dist/stateful-signing.d.ts +18 -0
- package/dist/stateful-signing.js +27 -0
- package/dist/stealth/profiles.d.ts +15 -4
- package/dist/stealth/profiles.js +169 -211
- package/dist/stream-evidence.d.ts +74 -0
- package/dist/stream-evidence.js +785 -0
- package/dist/stream.js +7 -1
- package/dist/testing/index.d.ts +2 -1
- package/dist/testing/index.js +2 -1
- package/dist/testing/run.d.ts +32 -2
- package/dist/testing/run.js +509 -25
- package/dist/trace-sanitization.d.ts +6 -0
- package/dist/trace-sanitization.js +68 -0
- package/dist/types.d.ts +899 -210
- package/dist/types.js +1 -0
- package/dist/typescript-module.d.ts +39 -0
- package/dist/typescript-module.js +72 -0
- package/package.json +52 -6
- package/src/auth-turn/index.ts +1 -1
- package/src/auth.ts +118 -0
- package/src/ceremonies/index.ts +189 -46
- package/src/cli/commands.ts +37 -0
- package/src/cli/create.ts +8 -2
- package/src/cli/deployment-intent.ts +364 -0
- package/src/cli/local-connection.ts +221 -0
- package/src/cli/migrate-deployment-intent.ts +1908 -0
- package/src/cli/migrate-operation-declaration.ts +3026 -0
- package/src/cli/migrate-operation-shape.ts +184 -0
- package/src/cli/migrate-provider-shape.ts +778 -0
- package/src/cli/resolver-runtime.ts +72 -0
- package/src/cli/templates/provider/.agents/skills/health-checks-and-fail-closed/SKILL.md.tpl +141 -23
- package/src/cli/templates/provider/Dockerfile.tpl +1 -1
- package/src/cli/templates/provider/README.md.tpl +41 -19
- package/src/cli/templates/provider/index.ts.tpl +6 -3
- package/src/cli/templates/provider/operations/ping.ts.tpl +4 -1
- package/src/cli/templates/provider/provider.json.tpl +6 -0
- package/src/config/loader.ts +774 -181
- package/src/contract-serialization.ts +5 -7
- package/src/contract-types.ts +12 -5
- package/src/contract.ts +24 -10
- package/src/declaration-validation.ts +376 -0
- package/src/define.ts +1105 -131
- package/src/dev.ts +13 -1
- package/src/engine.ts +390 -0
- package/src/error-observability.ts +64 -0
- package/src/error-resolution.ts +269 -0
- package/src/errors.ts +157 -0
- package/src/fixture-sanitization.ts +422 -0
- package/src/handle-meta.ts +143 -0
- package/src/handle.ts +694 -0
- package/src/health-scenario.ts +960 -0
- package/src/i18n/catalog.ts +23 -1
- package/src/i18n/error-messages.ts +255 -0
- package/src/i18n/index.ts +1 -0
- package/src/i18n/keys.ts +80 -6
- package/src/i18n/operation-locale-namespace.ts +10 -0
- package/src/index.ts +390 -23
- package/src/lint.ts +2975 -234
- package/src/native-address.ts +340 -0
- package/src/native-egress-policy.ts +358 -0
- package/src/observability.ts +51 -1
- package/src/provider.ts +207 -27
- package/src/runtime/auth-flow.ts +15 -0
- package/src/runtime/bounded-expiring-map.ts +67 -0
- package/src/runtime/browser-telemetry-binding.ts +92 -0
- package/src/runtime/browser-telemetry.ts +289 -0
- package/src/runtime/browser.ts +882 -99
- package/src/runtime/cache-telemetry.ts +247 -0
- package/src/runtime/cache.ts +258 -22
- package/src/runtime/capability-response.ts +53 -0
- package/src/runtime/capability-telemetry.ts +101 -0
- package/src/runtime/chrome149-header-order.ts +330 -0
- package/src/runtime/diagnostic-env-values.ts +56 -0
- package/src/runtime/diagnostic-env.ts +65 -0
- package/src/runtime/diagnostic-redactor.ts +668 -0
- package/src/runtime/egress-lease.ts +341 -0
- package/src/runtime/engine-mode.ts +233 -0
- package/src/runtime/env.ts +15 -1
- package/src/runtime/executor.ts +39 -32
- package/src/runtime/handle-wordlist.ts +266 -0
- package/src/runtime/handle.ts +1180 -0
- package/src/runtime/http-telemetry-binding.ts +162 -0
- package/src/runtime/http-telemetry-guard.ts +208 -0
- package/src/runtime/http-telemetry.ts +292 -0
- package/src/runtime/http.ts +896 -150
- package/src/runtime/insights.ts +2 -2
- package/src/runtime/instrumentation.ts +628 -28
- package/src/runtime/native-network-errors.ts +99 -0
- package/src/runtime/native-network.ts +1853 -0
- package/src/runtime/native-telemetry.ts +366 -0
- package/src/runtime/ocr-telemetry.ts +308 -0
- package/src/runtime/ocr.ts +564 -0
- package/src/runtime/otlp.ts +468 -21
- package/src/runtime/provider.ts +15 -0
- package/src/runtime/proxy-errors.ts +12 -4
- package/src/runtime/proxy-nodemaven.ts +222 -0
- package/src/runtime/proxy-retry-policy.ts +12 -1
- package/src/runtime/proxy-telemetry.ts +358 -103
- package/src/runtime/redirect-hop-headers.ts +71 -0
- package/src/runtime/redirects.ts +215 -0
- package/src/runtime/redis.ts +7 -2
- package/src/runtime/request-options.ts +728 -1
- package/src/runtime/request-telemetry.ts +375 -0
- package/src/runtime/resolver-config.ts +18 -0
- package/src/runtime/resolver-public.ts +44 -0
- package/src/runtime/resolver-runtime-options.ts +50 -0
- package/src/runtime/resolver-shared.ts +113 -0
- package/src/runtime/resolver-span-attributes.ts +139 -0
- package/src/runtime/resolver-telemetry.ts +362 -0
- package/src/runtime/resolver-usage.ts +99 -0
- package/src/runtime/resolver-vendors/bindings.ts +61 -0
- package/src/runtime/resolver-vendors/browser.ts +591 -0
- package/src/runtime/resolver-vendors/capsolver.ts +727 -0
- package/src/runtime/resolver-vendors/hosts.ts +81 -0
- package/src/runtime/resolver-vendors/hypersolutions.ts +480 -0
- package/src/runtime/resolver-vendors/twocaptcha.ts +555 -0
- package/src/runtime/resolver-vendors/types.ts +261 -0
- package/src/runtime/resolver.ts +1543 -0
- package/src/runtime/state-telemetry.ts +298 -0
- package/src/runtime/state.ts +435 -89
- package/src/runtime/stealth-akamai-sbsd.ts +207 -0
- package/src/runtime/stealth-cookies.ts +155 -0
- package/src/runtime/stealth-owned-headers.ts +67 -0
- package/src/runtime/stealth-telemetry.ts +309 -0
- package/src/runtime/stealth.ts +2275 -372
- package/src/runtime/stt-telemetry.ts +282 -0
- package/src/runtime/stt.ts +96 -91
- package/src/runtime/timeout.ts +18 -0
- package/src/runtime/trace-config.ts +78 -0
- package/src/runtime/trace.ts +257 -35
- package/src/runtime-boundary-lint.ts +707 -0
- package/src/schema.ts +80 -2
- package/src/serve.ts +6 -1
- package/src/server/error-observability.ts +1 -0
- package/src/server/error-stack-frames.ts +214 -0
- package/src/server/index.ts +184 -2
- package/src/server/runtime-diagnostics.ts +522 -0
- package/src/server/sdk-version.ts +22 -0
- package/src/server/self-test-input-tokens.ts +29 -14
- package/src/server/self-test-redaction.ts +10 -40
- package/src/server/self-test.ts +205 -89
- package/src/server/sensitive-values.ts +211 -0
- package/src/server/serve-implementation.ts +4596 -0
- package/src/server/serve.ts +1 -1685
- package/src/server/state-backend-report.ts +185 -0
- package/src/server/trace-output.ts +189 -0
- package/src/server/types.ts +34 -1
- package/src/stateful/README.md +146 -0
- package/src/stateful/errors.ts +35 -0
- package/src/stateful/http-provider-event-emitter.ts +314 -0
- package/src/stateful/http-session-owner-registry.ts +306 -0
- package/src/stateful/index.ts +18 -0
- package/src/stateful/provider-event-delivery-failures.ts +80 -0
- package/src/stateful/provider-event-pipeline-metrics.ts +95 -0
- package/src/stateful/provider-event-pipeline.ts +61 -0
- package/src/stateful/provider-events.ts +462 -0
- package/src/stateful/session-key.ts +111 -0
- package/src/stateful/stateful-provider-adapter-context.ts +59 -0
- package/src/stateful/stateful-provider-adapter-metrics.ts +48 -0
- package/src/stateful/stateful-provider-adapter.ts +562 -0
- package/src/stateful/stateful-provider-observability.ts +261 -0
- package/src/stateful/stateful-provider-owner-forwarder.ts +310 -0
- package/src/stateful/stateful-provider-runtime-context.ts +96 -0
- package/src/stateful/stateful-provider-runtime-executor.ts +96 -0
- package/src/stateful/stateful-provider-session-routing.ts +546 -0
- package/src/stateful/stateful-provider-session-runtime.ts +403 -0
- package/src/stateful-signing.ts +46 -0
- package/src/stealth/profiles.ts +212 -231
- package/src/stream-evidence.ts +988 -0
- package/src/stream.ts +8 -1
- package/src/testing/index.ts +10 -1
- package/src/testing/run.ts +685 -19
- package/src/trace-sanitization.ts +110 -0
- package/src/types.ts +1014 -273
- package/src/typescript-module.ts +100 -0
- package/dist/choice-token.d.ts +0 -24
- package/dist/choice-token.js +0 -74
- package/dist/runtime/choice.d.ts +0 -15
- package/dist/runtime/choice.js +0 -413
- package/src/choice-token.ts +0 -165
- 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
|
+
}
|