@pyric/cli 0.1.0-alpha.11 → 0.1.0-alpha.13
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/dist/cli/serve.js +4 -4
- package/dist/cli/serve.js.map +1 -1
- package/dist/conformance/.generated/can-i-use-browser.d.ts.map +1 -1
- package/dist/conformance/.generated/can-i-use-browser.js +1 -1
- package/dist/conformance/.generated/can-i-use-browser.js.map +1 -1
- package/dist/conformance/.generated/can-i-use.d.ts.map +1 -1
- package/dist/conformance/.generated/can-i-use.js +2 -2
- package/dist/conformance/.generated/can-i-use.js.map +1 -1
- package/dist/serve/bundler.d.ts +13 -8
- package/dist/serve/bundler.d.ts.map +1 -1
- package/dist/serve/bundler.js +31 -36
- package/dist/serve/bundler.js.map +1 -1
- package/dist/serve/docs-ui/docs/_rhythm/index.html +1 -1
- package/dist/serve/docs-ui/docs/agent/set-up-your-agent/index.html +1 -1
- package/dist/serve/docs-ui/docs/agent/watch-and-review/index.html +1 -1
- package/dist/serve/docs-ui/docs/agent/work-with-an-agent/index.html +1 -1
- package/dist/serve/docs-ui/docs/ai-compat/index.html +1 -1
- package/dist/serve/docs-ui/docs/api-reference/index.html +2 -2
- package/dist/serve/docs-ui/docs/api-reference.md +1 -1
- package/dist/serve/docs-ui/docs/app-compat/index.html +1 -1
- package/dist/serve/docs-ui/docs/auth-compat/index.html +1 -1
- package/dist/serve/docs-ui/docs/build/ai-logic/index.html +44 -22
- package/dist/serve/docs-ui/docs/build/ai-logic.md +69 -21
- package/dist/serve/docs-ui/docs/build/authentication/index.html +1 -1
- package/dist/serve/docs-ui/docs/build/cloud-firestore/index.html +1 -1
- package/dist/serve/docs-ui/docs/build/cloud-messaging/index.html +1 -1
- package/dist/serve/docs-ui/docs/build/cloud-storage/index.html +1 -1
- package/dist/serve/docs-ui/docs/build/realtime-database/index.html +1 -1
- package/dist/serve/docs-ui/docs/conformance-scores/index.html +1 -1
- package/dist/serve/docs-ui/docs/create-pyric-reference-api/index.html +1 -1
- package/dist/serve/docs-ui/docs/database-compat/index.html +1 -1
- package/dist/serve/docs-ui/docs/firestore-compat/index.html +1 -1
- package/dist/serve/docs-ui/docs/functions-rtdb-compat/index.html +1 -1
- package/dist/serve/docs-ui/docs/get-started/how-the-swap-works/index.html +2 -1
- package/dist/serve/docs-ui/docs/get-started/how-the-swap-works.md +2 -0
- package/dist/serve/docs-ui/docs/get-started/start-building/index.html +3 -2
- package/dist/serve/docs-ui/docs/get-started/start-building.md +3 -1
- package/dist/serve/docs-ui/docs/index.json +349 -29
- package/dist/serve/docs-ui/docs/messaging-compat/index.html +1 -1
- package/dist/serve/docs-ui/docs/observe/resolve-runtime-status/index.html +28 -0
- package/dist/serve/docs-ui/docs/observe/resolve-runtime-status.md +47 -0
- package/dist/serve/docs-ui/docs/observe/see-whats-happening/index.html +1 -1
- package/dist/serve/docs-ui/docs/observe/shape-your-data/index.html +1 -1
- package/dist/serve/docs-ui/docs/overview/index.html +1 -1
- package/dist/serve/docs-ui/docs/pyric-admin-app-reference-api/index.html +1 -1
- package/dist/serve/docs-ui/docs/pyric-admin-auth-reference-api/index.html +1 -1
- package/dist/serve/docs-ui/docs/pyric-admin-database-reference-api/index.html +1 -1
- package/dist/serve/docs-ui/docs/pyric-admin-firestore-reference-api/index.html +1 -1
- package/dist/serve/docs-ui/docs/pyric-admin-messaging-reference-api/index.html +916 -0
- package/dist/serve/docs-ui/docs/pyric-admin-messaging-reference-api.md +706 -0
- package/dist/serve/docs-ui/docs/pyric-admin-storage-reference-api/index.html +1 -1
- package/dist/serve/docs-ui/docs/pyric-ai-reference-api/index.html +1 -1
- package/dist/serve/docs-ui/docs/pyric-ai-scripting-reference-api/index.html +1 -1
- package/dist/serve/docs-ui/docs/pyric-app-reference-api/index.html +1 -1
- package/dist/serve/docs-ui/docs/pyric-auth-reference-api/index.html +1 -1
- package/dist/serve/docs-ui/docs/pyric-cli-assurance-browser-reference-api/index.html +1 -1
- package/dist/serve/docs-ui/docs/pyric-cli-assurance-reference-api/index.html +1 -1
- package/dist/serve/docs-ui/docs/pyric-cli-bridge-client-reference-api/index.html +1 -1
- package/dist/serve/docs-ui/docs/pyric-cli-bridge-reference-api/index.html +1 -1
- package/dist/serve/docs-ui/docs/pyric-cli-conformance-browser-reference-api/index.html +1 -1
- package/dist/serve/docs-ui/docs/pyric-cli-conformance-docs-reference-api/index.html +1 -1
- package/dist/serve/docs-ui/docs/pyric-cli-conformance-reference-api/index.html +1 -1
- package/dist/serve/docs-ui/docs/pyric-cli-credentials-node-reference-api/index.html +1 -1
- package/dist/serve/docs-ui/docs/pyric-cli-discover-reference-api/index.html +1 -1
- package/dist/serve/docs-ui/docs/pyric-cli-register-reference-api/index.html +1 -1
- package/dist/serve/docs-ui/docs/pyric-cli-remote-reference-api/index.html +1 -1
- package/dist/serve/docs-ui/docs/pyric-cli-serve-worker-reference-api/index.html +168 -76
- package/dist/serve/docs-ui/docs/pyric-cli-serve-worker-reference-api.md +156 -73
- package/dist/serve/docs-ui/docs/pyric-cli-verify-reference-api/index.html +1 -1
- package/dist/serve/docs-ui/docs/pyric-cli-vite-reference-api/index.html +11 -8
- package/dist/serve/docs-ui/docs/pyric-cli-vite-reference-api.md +17 -4
- package/dist/serve/docs-ui/docs/pyric-database-reference-api/index.html +1 -1
- package/dist/serve/docs-ui/docs/pyric-firestore-reference-api/index.html +1 -1
- package/dist/serve/docs-ui/docs/pyric-messaging-reference-api/index.html +1 -1
- package/dist/serve/docs-ui/docs/pyric-messaging-sw-reference-api/index.html +1 -1
- package/dist/serve/docs-ui/docs/pyric-rules-reference-api/index.html +1 -1
- package/dist/serve/docs-ui/docs/pyric-sandbox-database-reference-api/index.html +1 -1
- package/dist/serve/docs-ui/docs/pyric-sandbox-firestore-reference-api/index.html +1 -1
- package/dist/serve/docs-ui/docs/pyric-sandbox-reference-api/index.html +37 -14
- package/dist/serve/docs-ui/docs/pyric-sandbox-reference-api.md +28 -8
- package/dist/serve/docs-ui/docs/pyric-storage-reference-api/index.html +1 -1
- package/dist/serve/docs-ui/docs/pyric-ui-agents-reference-api/index.html +1 -1
- package/dist/serve/docs-ui/docs/pyric-ui-auth-hooks-reference-api/index.html +1 -1
- package/dist/serve/docs-ui/docs/pyric-ui-auth-reference-api/index.html +1 -1
- package/dist/serve/docs-ui/docs/pyric-ui-events-hooks-reference-api/index.html +1 -1
- package/dist/serve/docs-ui/docs/pyric-ui-events-reference-api/index.html +1 -1
- package/dist/serve/docs-ui/docs/pyric-ui-firestore-hooks-reference-api/index.html +1 -1
- package/dist/serve/docs-ui/docs/pyric-ui-firestore-reference-api/index.html +1 -1
- package/dist/serve/docs-ui/docs/pyric-ui-primitives-reference-api/index.html +1 -1
- package/dist/serve/docs-ui/docs/pyric-ui-rtdb-reference-api/index.html +1 -1
- package/dist/serve/docs-ui/docs/pyric-ui-rules-hooks-reference-api/index.html +1 -1
- package/dist/serve/docs-ui/docs/pyric-ui-rules-reference-api/index.html +1 -1
- package/dist/serve/docs-ui/docs/pyric-ui-storage-hooks-reference-api/index.html +1 -1
- package/dist/serve/docs-ui/docs/pyric-ui-storage-reference-api/index.html +1 -1
- package/dist/serve/docs-ui/docs/pyric-ui-traffic-hooks-reference-api/index.html +1 -1
- package/dist/serve/docs-ui/docs/pyric-ui-traffic-reference-api/index.html +1 -1
- package/dist/serve/docs-ui/docs/rules-compat/index.html +1 -1
- package/dist/serve/docs-ui/docs/secure/audit-your-rules/index.html +1 -1
- package/dist/serve/docs-ui/docs/secure/firestore-rules-limits/index.html +1 -1
- package/dist/serve/docs-ui/docs/secure/read-a-denial/index.html +1 -1
- package/dist/serve/docs-ui/docs/secure/rtdb-rules-in-typescript/index.html +1 -1
- package/dist/serve/docs-ui/docs/secure/rules-standard-library/index.html +1 -1
- package/dist/serve/docs-ui/docs/secure/secure-it-with-rules/index.html +1 -1
- package/dist/serve/docs-ui/docs/secure/simulate-and-lint/index.html +1 -1
- package/dist/serve/docs-ui/docs/secure/write-a-rules-test-suite/index.html +1 -1
- package/dist/serve/docs-ui/docs/ship/ship-to-production/index.html +1 -1
- package/dist/serve/docs-ui/docs/ship/test-in-node/index.html +1 -1
- package/dist/serve/docs-ui/docs/storage-compat/index.html +1 -1
- package/dist/serve/docs-ui/docs/trust/how-we-know-it-matches-firebase/index.html +1 -1
- package/dist/serve/docs-ui/docs/trust/versioning-and-compatibility/index.html +1 -1
- package/dist/serve/docs-ui/docs/tutorial/index.html +429 -0
- package/dist/serve/docs-ui/docs/tutorial.md +701 -0
- package/dist/serve/docs-ui/llms.txt +3 -1
- package/dist/serve/entries/init.d.ts.map +1 -1
- package/dist/serve/entries/init.js +3 -0
- package/dist/serve/entries/init.js.map +1 -1
- package/dist/serve/entries/runtime.d.ts.map +1 -1
- package/dist/serve/entries/runtime.js +20 -5
- package/dist/serve/entries/runtime.js.map +1 -1
- package/dist/serve/entries/worker-runtime.d.ts +2 -2
- package/dist/serve/entries/worker-runtime.d.ts.map +1 -1
- package/dist/serve/entries/worker-runtime.js +45 -10
- package/dist/serve/entries/worker-runtime.js.map +1 -1
- package/dist/serve/runtime/chip-config.d.ts +19 -0
- package/dist/serve/runtime/chip-config.d.ts.map +1 -0
- package/dist/serve/runtime/chip-config.js +19 -0
- package/dist/serve/runtime/chip-config.js.map +1 -0
- package/dist/serve/runtime/chip-install.d.ts +10 -0
- package/dist/serve/runtime/chip-install.d.ts.map +1 -0
- package/dist/serve/runtime/chip-install.js +15 -0
- package/dist/serve/runtime/chip-install.js.map +1 -0
- package/dist/serve/runtime/chip.d.ts +17 -0
- package/dist/serve/runtime/chip.d.ts.map +1 -0
- package/dist/serve/runtime/chip.js +257 -0
- package/dist/serve/runtime/chip.js.map +1 -0
- package/dist/serve/runtime/manifest.d.ts +22 -0
- package/dist/serve/runtime/manifest.d.ts.map +1 -0
- package/dist/serve/runtime/manifest.js +20 -0
- package/dist/serve/runtime/manifest.js.map +1 -0
- package/dist/serve/runtime/status.d.ts +49 -0
- package/dist/serve/runtime/status.d.ts.map +1 -0
- package/dist/serve/runtime/status.js +164 -0
- package/dist/serve/runtime/status.js.map +1 -0
- package/dist/serve/runtime/worker-connection.d.ts +4 -0
- package/dist/serve/runtime/worker-connection.d.ts.map +1 -0
- package/dist/serve/runtime/worker-connection.js +11 -0
- package/dist/serve/runtime/worker-connection.js.map +1 -0
- package/dist/serve/runtime/worker-generation.d.ts +14 -0
- package/dist/serve/runtime/worker-generation.d.ts.map +1 -0
- package/dist/serve/runtime/worker-generation.js +56 -0
- package/dist/serve/runtime/worker-generation.js.map +1 -0
- package/dist/serve/runtime/worker-replacement.d.ts +18 -0
- package/dist/serve/runtime/worker-replacement.d.ts.map +1 -0
- package/dist/serve/runtime/worker-replacement.js +29 -0
- package/dist/serve/runtime/worker-replacement.js.map +1 -0
- package/dist/serve/standalone-assets.d.ts +2 -2
- package/dist/serve/standalone-assets.d.ts.map +1 -1
- package/dist/serve/standalone-assets.js +1 -1
- package/dist/serve/standalone-assets.js.map +1 -1
- package/dist/serve/studio-ui/assets/{RulesCodeEditor-CxVYcZXw.js → RulesCodeEditor-BDlH-5lp.js} +1 -1
- package/dist/serve/studio-ui/assets/{index-cpSfgQ7H.js → index-Bf9SSE8L.js} +5 -5
- package/dist/serve/studio-ui/assets/{seed-DeuFKi4J.js → seed-D_UjxfOU.js} +1 -1
- package/dist/serve/studio-ui/index.html +1 -1
- package/dist/serve/vite-ai-config.d.ts +25 -0
- package/dist/serve/vite-ai-config.d.ts.map +1 -0
- package/dist/serve/vite-ai-config.js +45 -0
- package/dist/serve/vite-ai-config.js.map +1 -0
- package/dist/serve/vite-plugin.d.ts +17 -36
- package/dist/serve/vite-plugin.d.ts.map +1 -1
- package/dist/serve/vite-plugin.js +38 -57
- package/dist/serve/vite-plugin.js.map +1 -1
- package/dist/serve/vite-rules-source.d.ts +8 -0
- package/dist/serve/vite-rules-source.d.ts.map +1 -0
- package/dist/serve/vite-rules-source.js +23 -0
- package/dist/serve/vite-rules-source.js.map +1 -0
- package/dist/serve/vite-worker-runtime.d.ts +20 -0
- package/dist/serve/vite-worker-runtime.d.ts.map +1 -0
- package/dist/serve/vite-worker-runtime.js +25 -0
- package/dist/serve/vite-worker-runtime.js.map +1 -0
- package/dist/serve/worker/client/connection.d.ts +7 -2
- package/dist/serve/worker/client/connection.d.ts.map +1 -1
- package/dist/serve/worker/client/connection.js +13 -16
- package/dist/serve/worker/client/connection.js.map +1 -1
- package/dist/serve/worker/client/core.d.ts +7 -0
- package/dist/serve/worker/client/core.d.ts.map +1 -1
- package/dist/serve/worker/client/core.js +28 -0
- package/dist/serve/worker/client/core.js.map +1 -1
- package/dist/serve/worker/client/runtime-control.d.ts +8 -0
- package/dist/serve/worker/client/runtime-control.d.ts.map +1 -0
- package/dist/serve/worker/client/runtime-control.js +11 -0
- package/dist/serve/worker/client/runtime-control.js.map +1 -0
- package/dist/serve/worker/entry.js +44 -2
- package/dist/serve/worker/entry.js.map +1 -1
- package/dist/serve/worker/host/core.js +1 -1
- package/dist/serve/worker/host/core.js.map +1 -1
- package/dist/serve/worker/host/firestore-writes.d.ts.map +1 -1
- package/dist/serve/worker/host/firestore-writes.js +3 -2
- package/dist/serve/worker/host/firestore-writes.js.map +1 -1
- package/dist/serve/worker/host/studio.js +1 -1
- package/dist/serve/worker/host/studio.js.map +1 -1
- package/dist/serve/worker/host-context.d.ts +1 -1
- package/dist/serve/worker/host-context.d.ts.map +1 -1
- package/dist/serve/worker/index.d.ts +2 -1
- package/dist/serve/worker/index.d.ts.map +1 -1
- package/dist/serve/worker/index.js +2 -1
- package/dist/serve/worker/index.js.map +1 -1
- package/dist/serve/worker/protocol.d.ts +15 -1
- package/dist/serve/worker/protocol.d.ts.map +1 -1
- package/dist/serve/worker/protocol.js +2 -2
- package/dist/serve/worker/protocol.js.map +1 -1
- package/dist/serve/worker/retirement.d.ts +21 -0
- package/dist/serve/worker/retirement.d.ts.map +1 -0
- package/dist/serve/worker/retirement.js +97 -0
- package/dist/serve/worker/retirement.js.map +1 -0
- package/dist/serve/worker/serve-init.d.ts.map +1 -1
- package/dist/serve/worker/serve-init.js +5 -6
- package/dist/serve/worker/serve-init.js.map +1 -1
- package/dist/vite.d.ts +1 -0
- package/dist/vite.d.ts.map +1 -1
- package/dist/vite.js.map +1 -1
- package/package.json +6 -4
- package/src/assurance/.generated/conformance-verdicts.ts +1090 -0
- package/src/assurance/attachment.ts +211 -0
- package/src/assurance/browser.ts +80 -0
- package/src/assurance/campaign.ts +496 -0
- package/src/assurance/capabilities.ts +446 -0
- package/src/assurance/cases.ts +43 -0
- package/src/assurance/index.ts +75 -0
- package/src/assurance/runner.ts +885 -0
- package/src/assurance/tool-names.ts +15 -0
- package/src/assurance/tools.ts +830 -0
- package/src/assurance/types.ts +352 -0
- package/src/assurance/validation.ts +434 -0
- package/src/bridge/client/bridge.ts +550 -0
- package/src/bridge/client/dispatch.ts +137 -0
- package/src/bridge/client.ts +45 -0
- package/src/bridge/protocol.ts +351 -0
- package/src/bridge/server/audit.ts +53 -0
- package/src/bridge/server/bridge.ts +597 -0
- package/src/bridge/server/headless.ts +160 -0
- package/src/bridge/server/json-schema-to-zod.ts +108 -0
- package/src/bridge/server/local-bridge.ts +66 -0
- package/src/bridge/server/logger.ts +50 -0
- package/src/bridge/server/mcp-contract.ts +93 -0
- package/src/bridge/server/mcp.ts +129 -0
- package/src/bridge/server/peer.ts +232 -0
- package/src/bridge/server/standalone.ts +347 -0
- package/src/bridge/server/tool-metadata.ts +99 -0
- package/src/bridge/server.ts +28 -0
- package/src/cli/can-i-use.ts +50 -0
- package/src/cli/cli.test.ts +493 -0
- package/src/cli/database-rules.ts +329 -0
- package/src/cli/dev-runner.ts +282 -0
- package/src/cli/firebase-json.ts +100 -0
- package/src/cli/firestore-indexes.ts +70 -0
- package/src/cli/index.ts +425 -0
- package/src/cli/init.ts +260 -0
- package/src/cli/mcp-proxy.ts +196 -0
- package/src/cli/parse-args.ts +80 -0
- package/src/cli/rules.ts +259 -0
- package/src/cli/scope.ts +67 -0
- package/src/cli/serve.ts +996 -0
- package/src/cli/service-commands.ts +73 -0
- package/src/cli/snapshot.ts +164 -0
- package/src/cli/storage-rules.ts +172 -0
- package/src/cli/verify.ts +416 -0
- package/src/conformance/.generated/can-i-use-browser.ts +113 -0
- package/src/conformance/.generated/can-i-use.ts +117 -0
- package/src/conformance/.generated/conformance-docs.ts +14 -0
- package/src/conformance/browser.ts +24 -0
- package/src/conformance/can-i-use-tool.ts +51 -0
- package/src/conformance/can-i-use.ts +18 -0
- package/src/conformance/docs.ts +8 -0
- package/src/conformance/index.ts +20 -0
- package/src/conformance/tools.ts +16 -0
- package/src/credentials/core/memoize-ttl.ts +142 -0
- package/src/credentials/core/types.ts +10 -0
- package/src/credentials/node/from-adc.ts +96 -0
- package/src/credentials/node/from-service-account.ts +133 -0
- package/src/credentials/node/index.ts +6 -0
- package/src/discover/concurrency.ts +127 -0
- package/src/discover/crawler-adapter.ts +142 -0
- package/src/discover/crawler.ts +1127 -0
- package/src/discover/credential-free.ts +27 -0
- package/src/discover/findCollectionGroup.ts +131 -0
- package/src/discover/firestore-source.ts +59 -0
- package/src/discover/index.ts +5 -0
- package/src/discover/merge.ts +523 -0
- package/src/discover/session.ts +402 -0
- package/src/discover/tools.ts +201 -0
- package/src/discover/types.ts +187 -0
- package/src/discover/wire.ts +324 -0
- package/src/functions-rtdb/child.ts +382 -0
- package/src/functions-rtdb/delivery.ts +7 -0
- package/src/functions-rtdb/discovery.ts +136 -0
- package/src/functions-rtdb/event.ts +64 -0
- package/src/functions-rtdb/execution.ts +111 -0
- package/src/functions-rtdb/in-memory-delivery.ts +40 -0
- package/src/functions-rtdb/project.ts +74 -0
- package/src/functions-rtdb/projection.ts +104 -0
- package/src/functions-rtdb/reference-pattern.ts +20 -0
- package/src/functions-rtdb/remote-delivery.ts +23 -0
- package/src/pkg-version.ts +49 -0
- package/src/register/esm-exports.ts +63 -0
- package/src/register/hooks.ts +30 -0
- package/src/register/index.ts +150 -0
- package/src/register/mapping.ts +33 -0
- package/src/remote/index.ts +1050 -0
- package/src/rtdb/crawl-snapshot.ts +118 -0
- package/src/rtdb/inspection.ts +137 -0
- package/src/rtdb/load-rules-document.ts +56 -0
- package/src/rtdb/rules-generation-tool.ts +38 -0
- package/src/rtdb/rules-json.ts +29 -0
- package/src/serve/activity-guard.ts +29 -0
- package/src/serve/activity-route.ts +164 -0
- package/src/serve/activity-warning.ts +26 -0
- package/src/serve/bridge-mount.ts +200 -0
- package/src/serve/bundler.ts +519 -0
- package/src/serve/capture-store.ts +65 -0
- package/src/serve/discovery.ts +199 -0
- package/src/serve/entries/ai.ts +292 -0
- package/src/serve/entries/app-backend.ts +4 -0
- package/src/serve/entries/app-client.ts +26 -0
- package/src/serve/entries/app-session-store.ts +47 -0
- package/src/serve/entries/app.ts +11 -0
- package/src/serve/entries/auth-helper-core.ts +181 -0
- package/src/serve/entries/auth-helper-dom.ts +134 -0
- package/src/serve/entries/auth-helper-runtime.ts +20 -0
- package/src/serve/entries/auth.ts +255 -0
- package/src/serve/entries/bridge-url.ts +31 -0
- package/src/serve/entries/database.ts +129 -0
- package/src/serve/entries/firestore.ts +232 -0
- package/src/serve/entries/init.ts +49 -0
- package/src/serve/entries/keepalive.ts +46 -0
- package/src/serve/entries/messaging-sw.ts +75 -0
- package/src/serve/entries/messaging.ts +96 -0
- package/src/serve/entries/runtime.ts +556 -0
- package/src/serve/entries/session-store.ts +97 -0
- package/src/serve/entries/storage.ts +92 -0
- package/src/serve/entries/tab-sync-wiring.ts +274 -0
- package/src/serve/entries/worker-runtime.ts +135 -0
- package/src/serve/init-payload.ts +42 -0
- package/src/serve/namespace.ts +675 -0
- package/src/serve/open-browser.ts +68 -0
- package/src/serve/rules.ts +272 -0
- package/src/serve/runtime/chip-config.ts +30 -0
- package/src/serve/runtime/chip-install.ts +27 -0
- package/src/serve/runtime/chip.ts +282 -0
- package/src/serve/runtime/manifest.ts +37 -0
- package/src/serve/runtime/status.ts +216 -0
- package/src/serve/runtime/worker-connection.ts +14 -0
- package/src/serve/runtime/worker-generation.ts +75 -0
- package/src/serve/runtime/worker-replacement.ts +42 -0
- package/src/serve/sandbox-marker.ts +31 -0
- package/src/serve/server.ts +428 -0
- package/src/serve/standalone-assets.ts +180 -0
- package/src/serve/state-store.ts +166 -0
- package/src/serve/studio/disk-project-store.ts +185 -0
- package/src/serve/studio/disk-workspace.ts +162 -0
- package/src/serve/studio/index.ts +20 -0
- package/src/serve/studio/routes.ts +237 -0
- package/src/serve/studio/store-types.ts +48 -0
- package/src/serve/studio/studio-storage.test.ts +289 -0
- package/src/serve/vite-ai-config.ts +77 -0
- package/src/serve/vite-plugin.ts +1075 -0
- package/src/serve/vite-rules-source.ts +28 -0
- package/src/serve/vite-worker-runtime.ts +51 -0
- package/src/serve/worker/activity-bootstrap.ts +24 -0
- package/src/serve/worker/client/admin-firestore.ts +38 -0
- package/src/serve/worker/client/ai.ts +124 -0
- package/src/serve/worker/client/auth.ts +489 -0
- package/src/serve/worker/client/connection.ts +227 -0
- package/src/serve/worker/client/core.ts +384 -0
- package/src/serve/worker/client/disconnect.ts +45 -0
- package/src/serve/worker/client/firestore-reads.ts +181 -0
- package/src/serve/worker/client/firestore-refs.ts +226 -0
- package/src/serve/worker/client/firestore-writes.ts +232 -0
- package/src/serve/worker/client/handles.ts +91 -0
- package/src/serve/worker/client/messaging.ts +119 -0
- package/src/serve/worker/client/presence.ts +174 -0
- package/src/serve/worker/client/rtdb.ts +311 -0
- package/src/serve/worker/client/rules.ts +63 -0
- package/src/serve/worker/client/runtime-control.ts +22 -0
- package/src/serve/worker/client/service-worker-connection.ts +59 -0
- package/src/serve/worker/client/snapshots.ts +86 -0
- package/src/serve/worker/client/storage.ts +195 -0
- package/src/serve/worker/client/studio.ts +88 -0
- package/src/serve/worker/client.ts +57 -0
- package/src/serve/worker/durable-persistence.ts +137 -0
- package/src/serve/worker/entry.ts +223 -0
- package/src/serve/worker/host/admin-firestore.ts +88 -0
- package/src/serve/worker/host/connection.ts +179 -0
- package/src/serve/worker/host/core.ts +387 -0
- package/src/serve/worker/host/dispatch.ts +316 -0
- package/src/serve/worker/host/firestore-reads.ts +117 -0
- package/src/serve/worker/host/firestore-writes.ts +456 -0
- package/src/serve/worker/host/presence.ts +312 -0
- package/src/serve/worker/host/rtdb.ts +136 -0
- package/src/serve/worker/host/rules.ts +128 -0
- package/src/serve/worker/host/storage.ts +312 -0
- package/src/serve/worker/host/studio.ts +75 -0
- package/src/serve/worker/host/subscriptions.ts +238 -0
- package/src/serve/worker/host-ai.ts +165 -0
- package/src/serve/worker/host-auth.ts +469 -0
- package/src/serve/worker/host-context.ts +273 -0
- package/src/serve/worker/host-events.ts +94 -0
- package/src/serve/worker/host-messaging.ts +240 -0
- package/src/serve/worker/host.ts +47 -0
- package/src/serve/worker/index.ts +196 -0
- package/src/serve/worker/presence-timing.ts +15 -0
- package/src/serve/worker/protocol.ts +1124 -0
- package/src/serve/worker/retirement.ts +125 -0
- package/src/serve/worker/serve-init.ts +605 -0
- package/src/serve/worker/service-worker-channel.ts +33 -0
- package/src/serve/worker/service-worker-relay.ts +86 -0
- package/src/serve/writer-lock.ts +53 -0
- package/src/verify/cases.ts +233 -0
- package/src/verify/fixture.ts +258 -0
- package/src/verify/index.ts +519 -0
- package/src/verify/tools.ts +108 -0
- package/src/version/compat-target.ts +17 -0
- package/src/vite.ts +18 -0
- package/dist/serve/docs-ui/docs/pyric-firestore-values-reference-api/index.html +0 -31
- package/dist/serve/docs-ui/docs/pyric-firestore-values-reference-api.md +0 -29
|
@@ -0,0 +1,1127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BFS-layer crawler for `firestore_discover_paths`.
|
|
3
|
+
*
|
|
4
|
+
* Phase 0.3 of the validation plan locked layered BFS with bounded
|
|
5
|
+
* concurrency as the production strategy (38× faster than serial DFS on a
|
|
6
|
+
* 416-doc corpus). This module owns the structure-discovery half of the
|
|
7
|
+
* crawl: enumerate every collection reachable from the root via
|
|
8
|
+
* `listCollections` + `listDocuments` + per-doc `listCollections`. Document
|
|
9
|
+
* sampling and merge integration land in Item 2.3; permission-error
|
|
10
|
+
* resilience lands in Item 2.4.
|
|
11
|
+
*
|
|
12
|
+
* Output of this skeleton:
|
|
13
|
+
* - `collection_discovered` events emitted as each collection enters the
|
|
14
|
+
* frontier (depth-tagged so agents can render a tree)
|
|
15
|
+
* - `discoveredCollections` map with the collection refs collected per
|
|
16
|
+
* templatePath, ready for Item 2.3 to feed through sampling
|
|
17
|
+
*
|
|
18
|
+
* What this module deliberately does NOT do (yet):
|
|
19
|
+
* - read any document fields (no `.get()` calls)
|
|
20
|
+
* - emit `schema_updated` (no merge integration)
|
|
21
|
+
* - tolerate permission errors (any RPC throw aborts the crawl)
|
|
22
|
+
* - adaptive sampling, continuation, or dryRun
|
|
23
|
+
*/
|
|
24
|
+
'use strict';
|
|
25
|
+
|
|
26
|
+
import { runWithLimit } from './concurrency.js';
|
|
27
|
+
import { emptySchema, mergeDoc } from './merge.js';
|
|
28
|
+
import { SessionStore, type SessionError } from './session.js';
|
|
29
|
+
import { snapshotToObservations } from './wire.js';
|
|
30
|
+
import type {
|
|
31
|
+
CrawlerCollectionRef,
|
|
32
|
+
CrawlerDocumentRef,
|
|
33
|
+
CrawlerFirestore,
|
|
34
|
+
} from './firestore-source.js';
|
|
35
|
+
import type {
|
|
36
|
+
CollectionSchema,
|
|
37
|
+
DiscoverEvent,
|
|
38
|
+
FieldSchema,
|
|
39
|
+
SamplingComplete,
|
|
40
|
+
} from './types.js';
|
|
41
|
+
|
|
42
|
+
// ─── Crawl options ────────────────────────────────────────────────────────
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Crawl options. All fields are optional with documented defaults.
|
|
46
|
+
*
|
|
47
|
+
* - `maxConcurrency`: in-flight RPC cap. Default 32 per the Risk #1 sweep
|
|
48
|
+
* on 2026-05-05. Sweep over `{4, 8, 16, 32, 64}` against the corpus
|
|
49
|
+
* showed `4 → 8 → 16 → 32 → 64` was a steady ~10–15% per-doubling
|
|
50
|
+
* descent (no plateau). 32 was picked over 64 because the curve hadn't
|
|
51
|
+
* flattened — 64 was the cap of the test range, not a true knee — and
|
|
52
|
+
* doubling in-flight RPCs again increases the chance of tripping
|
|
53
|
+
* per-project connection/quota limits in agent environments. Agents
|
|
54
|
+
* that want max speed can override.
|
|
55
|
+
* - `maxDepth`: hard cap on BFS layers from the root. Defaults to 10
|
|
56
|
+
* (well above any real-world Firestore tree). Used as a runaway guard,
|
|
57
|
+
* not an agent-facing knob.
|
|
58
|
+
* - `rootFilter`: optional predicate on root collection IDs. Used by tests
|
|
59
|
+
* and the corpus harness to scope discovery to a known prefix without
|
|
60
|
+
* walking the entire database.
|
|
61
|
+
*/
|
|
62
|
+
export interface CrawlOptions {
|
|
63
|
+
maxConcurrency?: number;
|
|
64
|
+
maxDepth?: number;
|
|
65
|
+
rootFilter?: (collectionId: string) => boolean;
|
|
66
|
+
/**
|
|
67
|
+
* Hard cap on docs sampled per templatePath. Default 50 per Phase 2.1
|
|
68
|
+
* lock. Item 3 will add adaptive `stopOnStable` early-exit on top of
|
|
69
|
+
* this cap; for now sampling reads exactly `min(maxSamples, available)`
|
|
70
|
+
* docs per templatePath.
|
|
71
|
+
*/
|
|
72
|
+
maxSamples?: number;
|
|
73
|
+
/**
|
|
74
|
+
* Per-templatePath cap on tolerated PERMISSION_DENIED / transient errors
|
|
75
|
+
* during sampling. Default 3 per prerequisite 0.E. Past this threshold,
|
|
76
|
+
* sampling for the templatePath stops and `samplingComplete` is set to
|
|
77
|
+
* `sampling_open` so the agent can see the collection wasn't fully
|
|
78
|
+
* sampled. Errors during structure discovery are emitted but do not
|
|
79
|
+
* count toward this cap.
|
|
80
|
+
*/
|
|
81
|
+
maxErrorsPerCollection?: number;
|
|
82
|
+
/**
|
|
83
|
+
* Adaptive early-exit threshold. After this many consecutive
|
|
84
|
+
* no-change merges in a templatePath's sampling stream, sampling stops
|
|
85
|
+
* and `samplingComplete` is set to `converged_via_stable`. Default 8 per
|
|
86
|
+
* Phase 2.1 lock. Set to a value > maxSamples to disable early-exit
|
|
87
|
+
* (the hard cap then governs).
|
|
88
|
+
*
|
|
89
|
+
* Reads are issued in chunks of `stopOnStable` so an early-exit avoids
|
|
90
|
+
* fetching the remainder of `sampleRefs`. Worst-case wasted-read
|
|
91
|
+
* count per templatePath is `stopOnStable - 1`.
|
|
92
|
+
*/
|
|
93
|
+
stopOnStable?: number;
|
|
94
|
+
/**
|
|
95
|
+
* Resume a previously paused crawl. Only valid when a `SessionStore` is
|
|
96
|
+
* passed to `crawl()`. The token is the `continuation` value from a
|
|
97
|
+
* prior paused result. Malformed/expired tokens surface as a
|
|
98
|
+
* `SESSION_EXPIRED`/`SESSION_EVICTED`/`SESSION_MALFORMED_TOKEN`
|
|
99
|
+
* error event with no other side effects (per 0.C).
|
|
100
|
+
*/
|
|
101
|
+
continuation?: string;
|
|
102
|
+
/**
|
|
103
|
+
* Pause threshold for batch payload size. After each layer (structure
|
|
104
|
+
* phase) and each templatePath (sampling phase) the crawler measures
|
|
105
|
+
* the JSON-serialized state size; if it exceeds this many bytes, the
|
|
106
|
+
* crawl pauses and returns a continuation token. Default 1 MB per
|
|
107
|
+
* Phase 0.4 sizing — well below `maxSessionBytes=32MB` so the agent
|
|
108
|
+
* has headroom for response framing.
|
|
109
|
+
*
|
|
110
|
+
* Only effective when a `SessionStore` is provided to `crawl()`. With
|
|
111
|
+
* no store, single-call mode runs to completion regardless of size.
|
|
112
|
+
*/
|
|
113
|
+
maxBatchBytes?: number;
|
|
114
|
+
/**
|
|
115
|
+
* Informational-only cost preview. When `true`, the crawler issues
|
|
116
|
+
* exactly **one** RPC — `db.listCollections()` at the root — and
|
|
117
|
+
* returns a heuristic projection of what a real crawl would cost.
|
|
118
|
+
*
|
|
119
|
+
* No documents are read, no per-doc `listCollections` calls are
|
|
120
|
+
* made, no sampling occurs. The result has empty `discovered` and
|
|
121
|
+
* `finalizedSchemas`, `complete: true`, and `continuation: undefined`.
|
|
122
|
+
* It is **not** a partial crawl that can be resumed — to "commit",
|
|
123
|
+
* call `crawl()` again with `dryRun: false` (a fresh full crawl).
|
|
124
|
+
*
|
|
125
|
+
* Rationale: an agent reading `dryRun: true` reasonably expects no
|
|
126
|
+
* real crawl happened. Doing a structure walk under that flag would
|
|
127
|
+
* let the agent make decisions on data it didn't realize it paid
|
|
128
|
+
* for. See Item 5 revision in the implementation plan.
|
|
129
|
+
*
|
|
130
|
+
* Default `false`.
|
|
131
|
+
*/
|
|
132
|
+
dryRun?: boolean;
|
|
133
|
+
/**
|
|
134
|
+
* Heuristic multiplier used in the dryRun cost projection: assumes
|
|
135
|
+
* each root collection has roughly this many subtree-collections
|
|
136
|
+
* (root + descendants) on average. Default 3 — conservative for
|
|
137
|
+
* typical app schemas. Surfaced as an option so agents tuning for
|
|
138
|
+
* known shapes can revise.
|
|
139
|
+
*
|
|
140
|
+
* Only consulted when `dryRun: true`.
|
|
141
|
+
*/
|
|
142
|
+
dryRunSubtreeMultiplier?: number;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const DEFAULT_MAX_CONCURRENCY = 32;
|
|
146
|
+
const DEFAULT_MAX_DEPTH = 10;
|
|
147
|
+
const DEFAULT_MAX_SAMPLES = 50;
|
|
148
|
+
const DEFAULT_MAX_ERRORS_PER_COLLECTION = 3;
|
|
149
|
+
/**
|
|
150
|
+
* Default subtree multiplier for the dryRun projection. 3 = "each root
|
|
151
|
+
* collection has, on average, itself + ~2 descendant collections."
|
|
152
|
+
* Conservative for typical app schemas; configurable via
|
|
153
|
+
* `CrawlOptions.dryRunSubtreeMultiplier`.
|
|
154
|
+
*/
|
|
155
|
+
const DEFAULT_DRY_RUN_SUBTREE_MULTIPLIER = 3;
|
|
156
|
+
/**
|
|
157
|
+
* Phase 2.1 lock — `stopOnStable=8` was validated against the real corpus
|
|
158
|
+
* (Phase 2.2) with 0 false positives. Optimistic early-exit signal; the
|
|
159
|
+
* `maxSamples` hard cap remains the absolute ceiling.
|
|
160
|
+
*/
|
|
161
|
+
const DEFAULT_STOP_ON_STABLE = 8;
|
|
162
|
+
/**
|
|
163
|
+
* Default batch payload threshold — pause when the persisted state
|
|
164
|
+
* crosses this many bytes. 1 MB matches Phase 0.4's per-batch budget;
|
|
165
|
+
* leaves ~31 MB of headroom against `maxSessionBytes`.
|
|
166
|
+
*/
|
|
167
|
+
const DEFAULT_MAX_BATCH_BYTES = 1024 * 1024;
|
|
168
|
+
|
|
169
|
+
// ─── Crawl result ─────────────────────────────────────────────────────────
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Per-template-path bookkeeping built up during a crawl. Collection refs
|
|
173
|
+
* are kept here for Item 2.3 to drive document sampling.
|
|
174
|
+
*
|
|
175
|
+
* Multiple concrete collection paths may collapse to the same template
|
|
176
|
+
* path (e.g. `users/uid_1/posts` and `users/uid_2/posts` both map to
|
|
177
|
+
* `users/{userId}/posts`); their refs are accumulated under one entry.
|
|
178
|
+
*/
|
|
179
|
+
export interface DiscoveredCollection {
|
|
180
|
+
templatePath: string;
|
|
181
|
+
depth: number;
|
|
182
|
+
/** First concrete collection path encountered for this template. */
|
|
183
|
+
examplePath: string;
|
|
184
|
+
/** All concrete collection refs that share this template path. */
|
|
185
|
+
refs: CrawlerCollectionRef[];
|
|
186
|
+
/**
|
|
187
|
+
* Doc refs accumulated across `refs` during BFS expansion. Sampling
|
|
188
|
+
* draws from this pool — re-listing would double the listDocuments
|
|
189
|
+
* cost we already paid during structure discovery.
|
|
190
|
+
*/
|
|
191
|
+
docRefs: CrawlerDocumentRef[];
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export interface CrawlResult {
|
|
195
|
+
events: DiscoverEvent[];
|
|
196
|
+
discovered: Map<string, DiscoveredCollection>;
|
|
197
|
+
/** Total `listCollections` + `listDocuments` calls — feeds cost reporting. */
|
|
198
|
+
listOps: number;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Result of a full crawl (structure + sampling). Augments `CrawlResult`
|
|
203
|
+
* with the per-templatePath finalized schemas the agent surface consumes.
|
|
204
|
+
*
|
|
205
|
+
* `continuation` is present iff the crawl paused at a `maxBatchBytes`
|
|
206
|
+
* boundary; agents resume by calling `crawl(db, { continuation }, sessions)`.
|
|
207
|
+
* `complete` is true iff the crawl finished — equivalent to
|
|
208
|
+
* `continuation === undefined` but more readable at call sites.
|
|
209
|
+
*
|
|
210
|
+
* Counter fields (`listOps`, `readOps`) are *cumulative* across batches:
|
|
211
|
+
* a paused crawl returns the running total so the agent's cost-reporting
|
|
212
|
+
* doesn't have to do the bookkeeping.
|
|
213
|
+
*/
|
|
214
|
+
export interface FullCrawlResult extends CrawlResult {
|
|
215
|
+
finalizedSchemas: Map<string, CollectionSchema>;
|
|
216
|
+
/** `.get()` calls issued during sampling — feeds cost reporting. */
|
|
217
|
+
readOps: number;
|
|
218
|
+
/** Opaque resume handle (only present when paused). */
|
|
219
|
+
continuation?: string;
|
|
220
|
+
/** True iff the crawl completed (no continuation pending). */
|
|
221
|
+
complete: boolean;
|
|
222
|
+
/**
|
|
223
|
+
* Present iff the crawl was a `dryRun: true` preview. Heuristic
|
|
224
|
+
* projection of what a full crawl would cost; see {@link CrawlOptions.dryRun}.
|
|
225
|
+
*/
|
|
226
|
+
dryRunCostEstimate?: DryRunCostEstimate;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Heuristic cost projection returned by `dryRun: true`. The numbers are
|
|
231
|
+
* upper-bound estimates — agents should treat them as "no more than"
|
|
232
|
+
* figures, not exact predictions. Formulas are documented in-line so
|
|
233
|
+
* consumers can sanity-check.
|
|
234
|
+
*/
|
|
235
|
+
export interface DryRunCostEstimate {
|
|
236
|
+
/** Number of root collections discovered by the single root listCollections call. */
|
|
237
|
+
rootCollectionCount: number;
|
|
238
|
+
/** The root collection IDs (after `rootFilter` is applied, if any). */
|
|
239
|
+
rootCollectionIds: string[];
|
|
240
|
+
/** The `maxSamples` value the projection used. */
|
|
241
|
+
maxSamples: number;
|
|
242
|
+
/** The `dryRunSubtreeMultiplier` value the projection used. */
|
|
243
|
+
subtreeMultiplier: number;
|
|
244
|
+
/**
|
|
245
|
+
* Projected total `listCollections` + `listDocuments` cost of a real
|
|
246
|
+
* crawl: `1 + rootCount × subtreeMultiplier`. The `1` is the root
|
|
247
|
+
* `listCollections`; each subtree contributes one `listDocuments`
|
|
248
|
+
* call to enumerate docs. Per-doc `listCollections` cost is folded
|
|
249
|
+
* into the multiplier (a subtree of 3 implies ~2 layers of doc
|
|
250
|
+
* listings).
|
|
251
|
+
*/
|
|
252
|
+
estimatedListOps: number;
|
|
253
|
+
/**
|
|
254
|
+
* Projected total `.get()` cost of sampling: `rootCount ×
|
|
255
|
+
* subtreeMultiplier × maxSamples`. Upper bound — `stopOnStable`
|
|
256
|
+
* early-exit and `cappedByMax` can reduce the actual draw.
|
|
257
|
+
*/
|
|
258
|
+
estimatedReadOps: number;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// ─── Persisted state (Item 4.2) ───────────────────────────────────────────
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* JSON-serializable snapshot of an in-progress crawl. Stored in the
|
|
265
|
+
* session between batches. Refs (CollectionRef/DocumentRef) carry
|
|
266
|
+
* methods so they can't be persisted directly — we serialize their
|
|
267
|
+
* paths and reconstruct via `db.collection(path)` / `db.doc(path)` on
|
|
268
|
+
* resume.
|
|
269
|
+
*
|
|
270
|
+
* Phase invariants:
|
|
271
|
+
* - `structure` phase: `frontierPaths` may be non-empty; `samplingQueue`
|
|
272
|
+
* is empty.
|
|
273
|
+
* - `sampling` phase: `frontierPaths` is empty; `samplingQueue` lists
|
|
274
|
+
* the templatePaths still pending. Existing entries in
|
|
275
|
+
* `finalizedSchemas` are immutable across the rest of the crawl.
|
|
276
|
+
*/
|
|
277
|
+
export interface PersistedCrawlState {
|
|
278
|
+
phase: 'structure' | 'sampling';
|
|
279
|
+
/** Layer index after the last completed structure pass. */
|
|
280
|
+
currentDepth: number;
|
|
281
|
+
/** Crawl options carried so resume preserves caps the agent set. */
|
|
282
|
+
maxDepth: number;
|
|
283
|
+
/** Concrete collection paths to expand in the next structure layer. */
|
|
284
|
+
frontierPaths: string[];
|
|
285
|
+
/** Discovered map serialized — all paths only, refs reconstructed on resume. */
|
|
286
|
+
discovered: Record<string, PersistedDiscoveredCollection>;
|
|
287
|
+
/** TemplatePaths still to sample. Drained left-to-right. */
|
|
288
|
+
samplingQueue: string[];
|
|
289
|
+
/** Per-templatePath finalized schemas — immutable once set. */
|
|
290
|
+
finalizedSchemas: Record<string, CollectionSchema>;
|
|
291
|
+
/** Cumulative cost counters across batches. */
|
|
292
|
+
listOps: number;
|
|
293
|
+
readOps: number;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/** Persisted shape of a `DiscoveredCollection` (refs → paths). */
|
|
297
|
+
export interface PersistedDiscoveredCollection {
|
|
298
|
+
templatePath: string;
|
|
299
|
+
depth: number;
|
|
300
|
+
examplePath: string;
|
|
301
|
+
refPaths: string[];
|
|
302
|
+
docRefPaths: string[];
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// ─── Template-path inference ──────────────────────────────────────────────
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Map a concrete collection path to its template-path form per Phase 3.1
|
|
309
|
+
* lock. Doc-id segments become `{singular(parentColl)Id}` so the result
|
|
310
|
+
* matches Firestore rules' `path.raw` segments under typical naming
|
|
311
|
+
* conventions (TTT corpus verified: `ttt_lobbies` → `{lobbyId}`).
|
|
312
|
+
*
|
|
313
|
+
* Inputs alternate `coll/doc/coll/doc/.../coll`. Length is always odd (a
|
|
314
|
+
* collection path ends on a collection segment).
|
|
315
|
+
*
|
|
316
|
+
* Heuristic — agents needing strict alignment with rules should normalize
|
|
317
|
+
* both sides before joining. See Risk 6 in the implementation plan.
|
|
318
|
+
*
|
|
319
|
+
* Examples:
|
|
320
|
+
* `users` → `users`
|
|
321
|
+
* `users/uid_1/posts` → `users/{userId}/posts`
|
|
322
|
+
* `ttt_lobbies/abc/games/g1/moves` → `ttt_lobbies/{lobbyId}/games/{gameId}/moves`
|
|
323
|
+
*/
|
|
324
|
+
export function toTemplatePath(concretePath: string): string {
|
|
325
|
+
const segs = concretePath.split('/');
|
|
326
|
+
if (segs.length % 2 === 0) {
|
|
327
|
+
throw new Error(
|
|
328
|
+
`toTemplatePath: expected odd-length collection path, got ${segs.length} segments: ${concretePath}`,
|
|
329
|
+
);
|
|
330
|
+
}
|
|
331
|
+
const out: string[] = [];
|
|
332
|
+
for (let i = 0; i < segs.length; i++) {
|
|
333
|
+
if (i % 2 === 0) {
|
|
334
|
+
out.push(segs[i]!); // collection segment
|
|
335
|
+
} else {
|
|
336
|
+
const parentColl = segs[i - 1]!;
|
|
337
|
+
out.push(`{${inferTemplateVariable(parentColl)}}`);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
return out.join('/');
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Convert a collection ID to the conventional template-variable name a
|
|
345
|
+
* Firestore rules author would write for its docs. Strips a trailing
|
|
346
|
+
* snake/dot-cased prefix word so `ttt_lobbies` → `lobbyId` (not
|
|
347
|
+
* `ttt_lobbieId`).
|
|
348
|
+
*/
|
|
349
|
+
export function inferTemplateVariable(collectionId: string): string {
|
|
350
|
+
// Take the last underscore-separated word — `ttt_lobbies` → `lobbies`,
|
|
351
|
+
// `users` → `users`. Lets compound prefixes (test harness, app
|
|
352
|
+
// namespace) drop out of the derived variable name.
|
|
353
|
+
const lastWord = collectionId.split('_').pop() ?? collectionId;
|
|
354
|
+
const singular = singularize(lastWord);
|
|
355
|
+
return `${singular}Id`;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/** Trivial English singularizer covering the cases the corpus exercises. */
|
|
359
|
+
function singularize(word: string): string {
|
|
360
|
+
if (word.length <= 2) return word; // too short to safely strip
|
|
361
|
+
if (word.endsWith('ies')) return word.slice(0, -3) + 'y'; // lobbies → lobby
|
|
362
|
+
if (word.endsWith('ses')) return word.slice(0, -2); // classes → class
|
|
363
|
+
if (word.endsWith('s') && !word.endsWith('ss')) return word.slice(0, -1); // users → user
|
|
364
|
+
return word;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// ─── Error classification (0.E) ───────────────────────────────────────────
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* gRPC status codes (and stringified equivalents) that the crawler treats
|
|
371
|
+
* as recoverable per prerequisite 0.E. Any other thrown error escapes —
|
|
372
|
+
* silent swallowing of structural errors would mask real bugs.
|
|
373
|
+
*
|
|
374
|
+
* - PERMISSION_DENIED (7): the canonical "subcollection has restricted IAM"
|
|
375
|
+
* - UNAVAILABLE (14): transient — tool surfaces it, agent retries
|
|
376
|
+
* - DEADLINE_EXCEEDED (4): transient — same
|
|
377
|
+
* - ABORTED (10): transient — same
|
|
378
|
+
*
|
|
379
|
+
* firebase-admin throws errors with `code` either as a numeric gRPC code
|
|
380
|
+
* or as a string ('permission-denied'); match both.
|
|
381
|
+
*/
|
|
382
|
+
const RETRYABLE_NUMERIC_CODES = new Set([4, 7, 10, 14]);
|
|
383
|
+
const RETRYABLE_STRING_CODES = new Set([
|
|
384
|
+
'PERMISSION_DENIED',
|
|
385
|
+
'UNAVAILABLE',
|
|
386
|
+
'DEADLINE_EXCEEDED',
|
|
387
|
+
'ABORTED',
|
|
388
|
+
'permission-denied',
|
|
389
|
+
'unavailable',
|
|
390
|
+
'deadline-exceeded',
|
|
391
|
+
'aborted',
|
|
392
|
+
]);
|
|
393
|
+
|
|
394
|
+
interface ClassifiedError {
|
|
395
|
+
code: string;
|
|
396
|
+
message: string;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function classifyRpcError(err: unknown): ClassifiedError | null {
|
|
400
|
+
if (err === null || typeof err !== 'object') return null;
|
|
401
|
+
const e = err as { code?: unknown; message?: unknown };
|
|
402
|
+
let codeStr: string | null = null;
|
|
403
|
+
if (typeof e.code === 'number' && RETRYABLE_NUMERIC_CODES.has(e.code)) {
|
|
404
|
+
codeStr = numericCodeToString(e.code);
|
|
405
|
+
} else if (typeof e.code === 'string' && RETRYABLE_STRING_CODES.has(e.code)) {
|
|
406
|
+
codeStr = e.code.toUpperCase().replace(/-/g, '_');
|
|
407
|
+
}
|
|
408
|
+
if (codeStr === null) return null;
|
|
409
|
+
const message = typeof e.message === 'string' ? e.message : String(err);
|
|
410
|
+
return { code: codeStr, message };
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
function numericCodeToString(code: number): string {
|
|
414
|
+
switch (code) {
|
|
415
|
+
case 4: return 'DEADLINE_EXCEEDED';
|
|
416
|
+
case 7: return 'PERMISSION_DENIED';
|
|
417
|
+
case 10: return 'ABORTED';
|
|
418
|
+
case 14: return 'UNAVAILABLE';
|
|
419
|
+
default: return `CODE_${code}`;
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* Run an RPC, returning the value on success or null on a classified error
|
|
425
|
+
* (which is also pushed as an `error` event onto the events array).
|
|
426
|
+
*
|
|
427
|
+
* Any unrecognized error re-throws — only the codes in `RETRYABLE_*` are
|
|
428
|
+
* tolerated. This keeps SDK-contract bugs (the 0.A class of failures) loud.
|
|
429
|
+
*/
|
|
430
|
+
async function safeRpc<T>(
|
|
431
|
+
fn: () => Promise<T>,
|
|
432
|
+
templatePath: string,
|
|
433
|
+
events: DiscoverEvent[],
|
|
434
|
+
): Promise<T | null> {
|
|
435
|
+
try {
|
|
436
|
+
return await fn();
|
|
437
|
+
} catch (err) {
|
|
438
|
+
const classified = classifyRpcError(err);
|
|
439
|
+
if (classified === null) throw err;
|
|
440
|
+
events.push({
|
|
441
|
+
kind: 'error',
|
|
442
|
+
templatePath,
|
|
443
|
+
code: classified.code,
|
|
444
|
+
message: classified.message,
|
|
445
|
+
});
|
|
446
|
+
return null;
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// ─── BFS crawler ──────────────────────────────────────────────────────────
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* Walk the Firestore tree breadth-first. Each layer issues its
|
|
454
|
+
* `listDocuments` + per-doc `listCollections` calls in parallel under a
|
|
455
|
+
* shared concurrency cap.
|
|
456
|
+
*
|
|
457
|
+
* Returns once every reachable collection (within `maxDepth`) is recorded.
|
|
458
|
+
* The returned `discovered` map is keyed by templatePath; `events` is the
|
|
459
|
+
* ordered event log emitted during the walk (currently only
|
|
460
|
+
* `collection_discovered`).
|
|
461
|
+
*/
|
|
462
|
+
export async function crawlStructure(
|
|
463
|
+
db: CrawlerFirestore,
|
|
464
|
+
options: CrawlOptions = {},
|
|
465
|
+
): Promise<CrawlResult> {
|
|
466
|
+
const maxConcurrency = options.maxConcurrency ?? DEFAULT_MAX_CONCURRENCY;
|
|
467
|
+
const maxDepth = options.maxDepth ?? DEFAULT_MAX_DEPTH;
|
|
468
|
+
const rootFilter = options.rootFilter;
|
|
469
|
+
|
|
470
|
+
const events: DiscoverEvent[] = [];
|
|
471
|
+
const discovered = new Map<string, DiscoveredCollection>();
|
|
472
|
+
let listOps = 0;
|
|
473
|
+
|
|
474
|
+
// Layer 0 — root collections. A failure here means the agent has no
|
|
475
|
+
// listCollections permission at the database root; emit `error` and
|
|
476
|
+
// return an empty result rather than throwing.
|
|
477
|
+
listOps++;
|
|
478
|
+
const rootsResult = await safeRpc(() => db.listCollections(), '', events);
|
|
479
|
+
let roots = rootsResult ?? [];
|
|
480
|
+
if (rootFilter) {
|
|
481
|
+
roots = roots.filter((c) => rootFilter(c.id));
|
|
482
|
+
}
|
|
483
|
+
for (const root of roots) {
|
|
484
|
+
recordCollection(discovered, events, root, /* depth */ 0, /* parentPath */ undefined);
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
// BFS across layers. `frontier` always holds the collection refs entering
|
|
488
|
+
// this layer; we expand each to the doc set, then per-doc subcollections,
|
|
489
|
+
// and the union of new collection refs becomes the next frontier.
|
|
490
|
+
let frontier: CrawlerCollectionRef[] = roots;
|
|
491
|
+
for (let depth = 0; depth < maxDepth && frontier.length > 0; depth++) {
|
|
492
|
+
// Step 1: list docs of each collection in the layer (parallel, capped).
|
|
493
|
+
// A per-collection failure surfaces as an `error` event and that
|
|
494
|
+
// collection contributes zero docs to the layer.
|
|
495
|
+
const docsByColl = await runWithLimit(frontier, maxConcurrency, async (coll) => {
|
|
496
|
+
listOps++;
|
|
497
|
+
const tpl = toTemplatePath(coll.path);
|
|
498
|
+
const docs = await safeRpc(() => coll.listDocuments(), tpl, events);
|
|
499
|
+
return docs ?? [];
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
// Attach docs to each frontier collection's discovered entry so the
|
|
503
|
+
// sampling pass can draw from them without re-listing.
|
|
504
|
+
for (let i = 0; i < frontier.length; i++) {
|
|
505
|
+
const coll = frontier[i]!;
|
|
506
|
+
const docs = docsByColl[i] ?? [];
|
|
507
|
+
const tpl = toTemplatePath(coll.path);
|
|
508
|
+
const entry = discovered.get(tpl);
|
|
509
|
+
if (entry) entry.docRefs.push(...docs);
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
const allDocs = docsByColl.flat();
|
|
513
|
+
if (allDocs.length === 0) {
|
|
514
|
+
frontier = [];
|
|
515
|
+
break;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
// Step 2: list subcollections under each doc (parallel, capped). A
|
|
519
|
+
// per-doc failure attributes to the parent collection's templatePath.
|
|
520
|
+
const subsByDoc = await runWithLimit(allDocs, maxConcurrency, async (doc) => {
|
|
521
|
+
listOps++;
|
|
522
|
+
// Parent collection path = doc.path with last segment stripped.
|
|
523
|
+
const parentCollPath = doc.path.slice(0, doc.path.lastIndexOf('/'));
|
|
524
|
+
const tpl = parentCollPath ? toTemplatePath(parentCollPath) : '';
|
|
525
|
+
const subs = await safeRpc(() => doc.listCollections(), tpl, events);
|
|
526
|
+
return subs ?? [];
|
|
527
|
+
});
|
|
528
|
+
|
|
529
|
+
// Step 3: record + accumulate into next frontier.
|
|
530
|
+
const next: CrawlerCollectionRef[] = [];
|
|
531
|
+
for (let i = 0; i < allDocs.length; i++) {
|
|
532
|
+
const parentDoc = allDocs[i]!;
|
|
533
|
+
const subs = subsByDoc[i] ?? [];
|
|
534
|
+
for (const sub of subs) {
|
|
535
|
+
recordCollection(discovered, events, sub, depth + 1, parentDoc.path);
|
|
536
|
+
next.push(sub);
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
frontier = next;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
return { events, discovered, listOps };
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
// ─── Sampling + merge integration (Item 2.3) ──────────────────────────────
|
|
546
|
+
|
|
547
|
+
/**
|
|
548
|
+
* Full crawl: discover structure, then sample up to `maxSamples` docs per
|
|
549
|
+
* discovered templatePath and feed them through the merge layer. Emits
|
|
550
|
+
* `schema_updated` events for every non-empty merge and `sampling_complete`
|
|
551
|
+
* once per templatePath.
|
|
552
|
+
*
|
|
553
|
+
* **Pause/resume (Item 4.2).** When a `SessionStore` is supplied, the
|
|
554
|
+
* crawler measures the persisted-state size at two pause boundaries:
|
|
555
|
+
*
|
|
556
|
+
* 1. End of every BFS layer in the structure phase
|
|
557
|
+
* 2. End of every templatePath in the sampling phase
|
|
558
|
+
*
|
|
559
|
+
* If the persisted state exceeds `maxBatchBytes` (default 1 MB), the
|
|
560
|
+
* crawler persists state and returns a `continuation` token. The agent
|
|
561
|
+
* resumes by passing `{ continuation }` on the next call. Counters
|
|
562
|
+
* (`listOps`, `readOps`) are cumulative across batches; events are
|
|
563
|
+
* per-batch only (agents accumulate them themselves).
|
|
564
|
+
*
|
|
565
|
+
* Without a `SessionStore`, the crawler runs to completion regardless of
|
|
566
|
+
* size — single-call mode is unchanged.
|
|
567
|
+
*
|
|
568
|
+
* **Continuation lifecycle.** Continuation handles are minted/validated by
|
|
569
|
+
* the supplied `SessionStore` (see `discover/session.ts`). Malformed,
|
|
570
|
+
* expired, or evicted tokens surface as a single `error` event and an
|
|
571
|
+
* otherwise-empty result — agents can re-issue without continuation
|
|
572
|
+
* per the recovery hint.
|
|
573
|
+
*/
|
|
574
|
+
export async function crawl(
|
|
575
|
+
db: CrawlerFirestore,
|
|
576
|
+
options: CrawlOptions = {},
|
|
577
|
+
sessions?: SessionStore<PersistedCrawlState>,
|
|
578
|
+
): Promise<FullCrawlResult> {
|
|
579
|
+
const maxConcurrency = options.maxConcurrency ?? DEFAULT_MAX_CONCURRENCY;
|
|
580
|
+
const maxSamples = options.maxSamples ?? DEFAULT_MAX_SAMPLES;
|
|
581
|
+
const maxErrorsPerCollection =
|
|
582
|
+
options.maxErrorsPerCollection ?? DEFAULT_MAX_ERRORS_PER_COLLECTION;
|
|
583
|
+
const stopOnStable = options.stopOnStable ?? DEFAULT_STOP_ON_STABLE;
|
|
584
|
+
const maxDepth = options.maxDepth ?? DEFAULT_MAX_DEPTH;
|
|
585
|
+
const rootFilter = options.rootFilter;
|
|
586
|
+
const maxBatchBytes = options.maxBatchBytes ?? DEFAULT_MAX_BATCH_BYTES;
|
|
587
|
+
const subtreeMultiplier =
|
|
588
|
+
options.dryRunSubtreeMultiplier ?? DEFAULT_DRY_RUN_SUBTREE_MULTIPLIER;
|
|
589
|
+
|
|
590
|
+
const events: DiscoverEvent[] = [];
|
|
591
|
+
|
|
592
|
+
// ─── dryRun branch (Item 5.1) ───────────────────────────────────────
|
|
593
|
+
// Issues exactly one RPC (root listCollections) and returns a heuristic
|
|
594
|
+
// projection. No documents are read, no per-doc listCollections, no
|
|
595
|
+
// sampling. Continuation pattern intentionally not supported — see the
|
|
596
|
+
// dryRun JSDoc for the agent-semantics rationale.
|
|
597
|
+
if (options.dryRun === true) {
|
|
598
|
+
if (options.continuation !== undefined) {
|
|
599
|
+
events.push({
|
|
600
|
+
kind: 'error',
|
|
601
|
+
templatePath: '',
|
|
602
|
+
code: 'DRYRUN_NO_CONTINUATION',
|
|
603
|
+
message:
|
|
604
|
+
'dryRun: true does not accept a continuation token — it issues no real crawl. ' +
|
|
605
|
+
'To resume a paused crawl, omit dryRun (or pass false).',
|
|
606
|
+
});
|
|
607
|
+
return emptyResult(events);
|
|
608
|
+
}
|
|
609
|
+
const rootsResult = await safeRpc(() => db.listCollections(), '', events);
|
|
610
|
+
const allRoots = rootsResult ?? [];
|
|
611
|
+
const filteredRoots = rootFilter ? allRoots.filter((c) => rootFilter(c.id)) : allRoots;
|
|
612
|
+
const rootCollectionIds = filteredRoots.map((c) => c.id);
|
|
613
|
+
for (const root of filteredRoots) {
|
|
614
|
+
events.push({ kind: 'collection_discovered', templatePath: root.id, depth: 0 });
|
|
615
|
+
}
|
|
616
|
+
const rootCount = filteredRoots.length;
|
|
617
|
+
const estimate: DryRunCostEstimate = {
|
|
618
|
+
rootCollectionCount: rootCount,
|
|
619
|
+
rootCollectionIds,
|
|
620
|
+
maxSamples,
|
|
621
|
+
subtreeMultiplier,
|
|
622
|
+
estimatedListOps: 1 + rootCount * subtreeMultiplier,
|
|
623
|
+
estimatedReadOps: rootCount * subtreeMultiplier * maxSamples,
|
|
624
|
+
};
|
|
625
|
+
return {
|
|
626
|
+
events,
|
|
627
|
+
discovered: new Map(),
|
|
628
|
+
listOps: 1,
|
|
629
|
+
readOps: 0,
|
|
630
|
+
finalizedSchemas: new Map(),
|
|
631
|
+
complete: true,
|
|
632
|
+
dryRunCostEstimate: estimate,
|
|
633
|
+
};
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
// ─── State acquisition: resume or init ──────────────────────────────
|
|
637
|
+
let state: PersistedCrawlState;
|
|
638
|
+
if (options.continuation !== undefined) {
|
|
639
|
+
if (!sessions) {
|
|
640
|
+
events.push({
|
|
641
|
+
kind: 'error',
|
|
642
|
+
templatePath: '',
|
|
643
|
+
code: 'NO_SESSION_STORE',
|
|
644
|
+
message: 'Continuation token provided but no SessionStore was passed to crawl().',
|
|
645
|
+
});
|
|
646
|
+
return emptyResult(events);
|
|
647
|
+
}
|
|
648
|
+
const lookup = sessions.get(options.continuation);
|
|
649
|
+
if (!lookup.ok) {
|
|
650
|
+
events.push(sessionErrorToEvent(lookup.error));
|
|
651
|
+
return emptyResult(events);
|
|
652
|
+
}
|
|
653
|
+
state = lookup.value.state;
|
|
654
|
+
} else {
|
|
655
|
+
state = {
|
|
656
|
+
phase: 'structure',
|
|
657
|
+
currentDepth: 0,
|
|
658
|
+
maxDepth,
|
|
659
|
+
frontierPaths: [],
|
|
660
|
+
discovered: {},
|
|
661
|
+
samplingQueue: [],
|
|
662
|
+
finalizedSchemas: {},
|
|
663
|
+
listOps: 0,
|
|
664
|
+
readOps: 0,
|
|
665
|
+
};
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
// Hydrate the persisted view into in-memory refs. Done once per batch.
|
|
669
|
+
const discovered = hydrateDiscovered(state.discovered, db);
|
|
670
|
+
let frontier: CrawlerCollectionRef[] =
|
|
671
|
+
state.phase === 'structure' && state.frontierPaths.length > 0
|
|
672
|
+
? state.frontierPaths.map((p) => requireCollection(db, p))
|
|
673
|
+
: [];
|
|
674
|
+
|
|
675
|
+
// ─── Structure phase ────────────────────────────────────────────────
|
|
676
|
+
if (state.phase === 'structure') {
|
|
677
|
+
// Layer 0 bootstrap: only on a brand-new crawl (no continuation).
|
|
678
|
+
// Resumes never enter this branch — frontierPaths is restored.
|
|
679
|
+
if (state.currentDepth === 0 && frontier.length === 0 && discovered.size === 0) {
|
|
680
|
+
state.listOps++;
|
|
681
|
+
const rootsResult = await safeRpc(() => db.listCollections(), '', events);
|
|
682
|
+
let roots = rootsResult ?? [];
|
|
683
|
+
if (rootFilter) roots = roots.filter((c) => rootFilter(c.id));
|
|
684
|
+
for (const root of roots) {
|
|
685
|
+
recordCollection(discovered, events, root, 0, undefined);
|
|
686
|
+
}
|
|
687
|
+
frontier = roots;
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
while (frontier.length > 0 && state.currentDepth < state.maxDepth) {
|
|
691
|
+
const depth = state.currentDepth;
|
|
692
|
+
|
|
693
|
+
// Step 1: list docs of each collection in this layer.
|
|
694
|
+
const docsByColl = await runWithLimit(frontier, maxConcurrency, async (coll) => {
|
|
695
|
+
state.listOps++;
|
|
696
|
+
const tpl = toTemplatePath(coll.path);
|
|
697
|
+
const docs = await safeRpc(() => coll.listDocuments(), tpl, events);
|
|
698
|
+
return docs ?? [];
|
|
699
|
+
});
|
|
700
|
+
|
|
701
|
+
for (let i = 0; i < frontier.length; i++) {
|
|
702
|
+
const coll = frontier[i]!;
|
|
703
|
+
const docs = docsByColl[i] ?? [];
|
|
704
|
+
const tpl = toTemplatePath(coll.path);
|
|
705
|
+
const entry = discovered.get(tpl);
|
|
706
|
+
if (entry) entry.docRefs.push(...docs);
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
const allDocs = docsByColl.flat();
|
|
710
|
+
if (allDocs.length === 0) {
|
|
711
|
+
frontier = [];
|
|
712
|
+
state.currentDepth++;
|
|
713
|
+
break;
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
// Step 2: list subcollections under each doc.
|
|
717
|
+
const subsByDoc = await runWithLimit(allDocs, maxConcurrency, async (doc) => {
|
|
718
|
+
state.listOps++;
|
|
719
|
+
const parentCollPath = doc.path.slice(0, doc.path.lastIndexOf('/'));
|
|
720
|
+
const tpl = parentCollPath ? toTemplatePath(parentCollPath) : '';
|
|
721
|
+
const subs = await safeRpc(() => doc.listCollections(), tpl, events);
|
|
722
|
+
return subs ?? [];
|
|
723
|
+
});
|
|
724
|
+
|
|
725
|
+
// Step 3: record + accumulate next frontier.
|
|
726
|
+
const next: CrawlerCollectionRef[] = [];
|
|
727
|
+
for (let i = 0; i < allDocs.length; i++) {
|
|
728
|
+
const parentDoc = allDocs[i]!;
|
|
729
|
+
const subs = subsByDoc[i] ?? [];
|
|
730
|
+
for (const sub of subs) {
|
|
731
|
+
recordCollection(discovered, events, sub, depth + 1, parentDoc.path);
|
|
732
|
+
next.push(sub);
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
frontier = next;
|
|
736
|
+
state.currentDepth++;
|
|
737
|
+
|
|
738
|
+
// Pause check: persisted-state size after this layer's work.
|
|
739
|
+
// We rebuild the persisted view first so the measurement matches
|
|
740
|
+
// what we'd actually store.
|
|
741
|
+
state.frontierPaths = frontier.map((c) => c.path);
|
|
742
|
+
state.discovered = serializeDiscovered(discovered);
|
|
743
|
+
|
|
744
|
+
const pause = checkPause(state, sessions, maxBatchBytes);
|
|
745
|
+
if (pause !== null) {
|
|
746
|
+
return pauseAndPersist(pause, state, events, discovered, sessions!, options);
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
// Structure phase complete; transition to sampling.
|
|
751
|
+
state.phase = 'sampling';
|
|
752
|
+
state.frontierPaths = [];
|
|
753
|
+
state.samplingQueue = Array.from(discovered.keys()).filter(
|
|
754
|
+
(tpl) => !(tpl in state.finalizedSchemas),
|
|
755
|
+
);
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
// ─── Sampling phase ─────────────────────────────────────────────────
|
|
759
|
+
while (state.samplingQueue.length > 0) {
|
|
760
|
+
const tpl = state.samplingQueue[0]!;
|
|
761
|
+
const entry = discovered.get(tpl);
|
|
762
|
+
if (!entry) {
|
|
763
|
+
// Defensive — shouldn't happen since samplingQueue is built from
|
|
764
|
+
// discovered. Skip rather than throw.
|
|
765
|
+
state.samplingQueue.shift();
|
|
766
|
+
continue;
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
const { schema: finalizedSchema, readOps: tplReadOps } = await sampleOneTemplate(
|
|
770
|
+
entry,
|
|
771
|
+
{ maxConcurrency, maxSamples, stopOnStable, maxErrorsPerCollection },
|
|
772
|
+
events,
|
|
773
|
+
);
|
|
774
|
+
state.readOps += tplReadOps;
|
|
775
|
+
|
|
776
|
+
// Build the CollectionSchema with subcollectionTemplatePaths populated
|
|
777
|
+
// now (rather than as a final pass) — `discovered` is fixed once the
|
|
778
|
+
// structure phase ends, so the subcollection set is knowable here.
|
|
779
|
+
state.finalizedSchemas[tpl] = {
|
|
780
|
+
templatePath: tpl,
|
|
781
|
+
examplePath: entry.examplePath,
|
|
782
|
+
schema: finalizedSchema.schema,
|
|
783
|
+
samplingComplete: finalizedSchema.samplingComplete,
|
|
784
|
+
declaredAt: finalizedSchema.declaredAt,
|
|
785
|
+
subcollectionTemplatePaths: directChildTemplatePaths(tpl, discovered),
|
|
786
|
+
};
|
|
787
|
+
state.samplingQueue.shift();
|
|
788
|
+
state.discovered = serializeDiscovered(discovered);
|
|
789
|
+
|
|
790
|
+
if (state.samplingQueue.length > 0) {
|
|
791
|
+
const pause = checkPause(state, sessions, maxBatchBytes);
|
|
792
|
+
if (pause !== null) {
|
|
793
|
+
return pauseAndPersist(pause, state, events, discovered, sessions!, options);
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
// ─── Crawl complete ─────────────────────────────────────────────────
|
|
799
|
+
// Clean up the session if one was provided + we resumed at least once
|
|
800
|
+
// (so the agent doesn't have a stale token). Best-effort.
|
|
801
|
+
if (sessions && options.continuation) sessions.delete(options.continuation);
|
|
802
|
+
|
|
803
|
+
const finalizedSchemas = new Map<string, CollectionSchema>();
|
|
804
|
+
for (const [k, v] of Object.entries(state.finalizedSchemas)) {
|
|
805
|
+
finalizedSchemas.set(k, v);
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
return {
|
|
809
|
+
events,
|
|
810
|
+
discovered,
|
|
811
|
+
listOps: state.listOps,
|
|
812
|
+
readOps: state.readOps,
|
|
813
|
+
finalizedSchemas,
|
|
814
|
+
complete: true,
|
|
815
|
+
};
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
// ─── Per-template sampling helper ─────────────────────────────────────────
|
|
819
|
+
|
|
820
|
+
interface SampleOpts {
|
|
821
|
+
maxConcurrency: number;
|
|
822
|
+
maxSamples: number;
|
|
823
|
+
stopOnStable: number;
|
|
824
|
+
maxErrorsPerCollection: number;
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
interface TemplateSampleResult {
|
|
828
|
+
schema: FieldSchema;
|
|
829
|
+
samplingComplete: SamplingComplete;
|
|
830
|
+
declaredAt: number | null;
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
/**
|
|
834
|
+
* Sample one templatePath: read up to `maxSamples` docs in chunks of
|
|
835
|
+
* `stopOnStable`, feed each through `mergeDoc`, classify the termination
|
|
836
|
+
* via the 4-state enum. Returns the finalized schema bundle + the read
|
|
837
|
+
* count so the caller can accumulate it onto the cumulative `readOps`.
|
|
838
|
+
*
|
|
839
|
+
* Emits `schema_updated`, `error`, and `sampling_complete` events into
|
|
840
|
+
* the shared event list.
|
|
841
|
+
*/
|
|
842
|
+
async function sampleOneTemplate(
|
|
843
|
+
entry: DiscoveredCollection,
|
|
844
|
+
opts: SampleOpts,
|
|
845
|
+
events: DiscoverEvent[],
|
|
846
|
+
): Promise<{ schema: TemplateSampleResult; readOps: number }> {
|
|
847
|
+
const { maxConcurrency, maxSamples, stopOnStable, maxErrorsPerCollection } = opts;
|
|
848
|
+
const capRefs = entry.docRefs.slice(0, maxSamples);
|
|
849
|
+
const cappedByMax = entry.docRefs.length > maxSamples;
|
|
850
|
+
|
|
851
|
+
let schema: FieldSchema = emptySchema();
|
|
852
|
+
let errorsThisCollection = 0;
|
|
853
|
+
let errorBudgetExhausted = false;
|
|
854
|
+
let consecutiveStable = 0;
|
|
855
|
+
let declaredAt: number | null = null;
|
|
856
|
+
let earlyExitViaStable = false;
|
|
857
|
+
let readOps = 0;
|
|
858
|
+
|
|
859
|
+
const chunkSize = Math.max(1, stopOnStable);
|
|
860
|
+
let i = 0;
|
|
861
|
+
while (i < capRefs.length && !earlyExitViaStable && !errorBudgetExhausted) {
|
|
862
|
+
const chunk = capRefs.slice(i, i + chunkSize);
|
|
863
|
+
const snaps = await runWithLimit(chunk, maxConcurrency, async (ref) => {
|
|
864
|
+
if (errorBudgetExhausted) return null;
|
|
865
|
+
readOps++;
|
|
866
|
+
try {
|
|
867
|
+
return await ref.get();
|
|
868
|
+
} catch (err) {
|
|
869
|
+
const classified = classifyRpcError(err);
|
|
870
|
+
if (classified === null) throw err;
|
|
871
|
+
errorsThisCollection++;
|
|
872
|
+
events.push({
|
|
873
|
+
kind: 'error',
|
|
874
|
+
templatePath: entry.templatePath,
|
|
875
|
+
code: classified.code,
|
|
876
|
+
message: classified.message,
|
|
877
|
+
});
|
|
878
|
+
if (errorsThisCollection >= maxErrorsPerCollection) {
|
|
879
|
+
errorBudgetExhausted = true;
|
|
880
|
+
}
|
|
881
|
+
return null;
|
|
882
|
+
}
|
|
883
|
+
});
|
|
884
|
+
|
|
885
|
+
for (const snap of snaps) {
|
|
886
|
+
if (errorBudgetExhausted) break;
|
|
887
|
+
if (snap === null) {
|
|
888
|
+
i++;
|
|
889
|
+
continue;
|
|
890
|
+
}
|
|
891
|
+
if (snap._fieldsProto === undefined || snap._fieldsProto === null) {
|
|
892
|
+
i++;
|
|
893
|
+
continue;
|
|
894
|
+
}
|
|
895
|
+
const { observations, reservedNames } = snapshotToObservations(snap);
|
|
896
|
+
if (Object.keys(observations).length === 0) {
|
|
897
|
+
i++;
|
|
898
|
+
continue;
|
|
899
|
+
}
|
|
900
|
+
const { next, changes } = mergeDoc(schema, observations);
|
|
901
|
+
for (const [k, reason] of Object.entries(reservedNames)) {
|
|
902
|
+
const desc = next.fields[k];
|
|
903
|
+
if (desc) desc.reservedReason = reason;
|
|
904
|
+
}
|
|
905
|
+
schema = next;
|
|
906
|
+
if (changes.length > 0) {
|
|
907
|
+
events.push({
|
|
908
|
+
kind: 'schema_updated',
|
|
909
|
+
templatePath: entry.templatePath,
|
|
910
|
+
changes,
|
|
911
|
+
});
|
|
912
|
+
consecutiveStable = 0;
|
|
913
|
+
} else {
|
|
914
|
+
consecutiveStable++;
|
|
915
|
+
if (declaredAt === null && consecutiveStable >= stopOnStable) {
|
|
916
|
+
declaredAt = next.samplesSeen - 1;
|
|
917
|
+
earlyExitViaStable = true;
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
i++;
|
|
921
|
+
if (earlyExitViaStable) break;
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
const consumedAll = i >= capRefs.length;
|
|
926
|
+
const samplingComplete: SamplingComplete = errorBudgetExhausted
|
|
927
|
+
? 'sampling_open'
|
|
928
|
+
: earlyExitViaStable
|
|
929
|
+
? 'converged_via_stable'
|
|
930
|
+
: consumedAll && !cappedByMax
|
|
931
|
+
? 'converged_via_exhausted'
|
|
932
|
+
: 'converged_via_max';
|
|
933
|
+
|
|
934
|
+
events.push({
|
|
935
|
+
kind: 'sampling_complete',
|
|
936
|
+
templatePath: entry.templatePath,
|
|
937
|
+
samplingComplete,
|
|
938
|
+
samplesSeen: schema.samplesSeen,
|
|
939
|
+
declaredAt,
|
|
940
|
+
});
|
|
941
|
+
|
|
942
|
+
return {
|
|
943
|
+
schema: { schema, samplingComplete, declaredAt },
|
|
944
|
+
readOps,
|
|
945
|
+
};
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
// ─── Persistence helpers (Item 4.2) ───────────────────────────────────────
|
|
949
|
+
|
|
950
|
+
function serializeDiscovered(
|
|
951
|
+
map: Map<string, DiscoveredCollection>,
|
|
952
|
+
): Record<string, PersistedDiscoveredCollection> {
|
|
953
|
+
const out: Record<string, PersistedDiscoveredCollection> = {};
|
|
954
|
+
for (const [tpl, d] of map) {
|
|
955
|
+
out[tpl] = {
|
|
956
|
+
templatePath: d.templatePath,
|
|
957
|
+
depth: d.depth,
|
|
958
|
+
examplePath: d.examplePath,
|
|
959
|
+
refPaths: d.refs.map((r) => r.path),
|
|
960
|
+
docRefPaths: d.docRefs.map((r) => r.path),
|
|
961
|
+
};
|
|
962
|
+
}
|
|
963
|
+
return out;
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
function hydrateDiscovered(
|
|
967
|
+
record: Record<string, PersistedDiscoveredCollection>,
|
|
968
|
+
db: CrawlerFirestore,
|
|
969
|
+
): Map<string, DiscoveredCollection> {
|
|
970
|
+
const map = new Map<string, DiscoveredCollection>();
|
|
971
|
+
for (const [tpl, p] of Object.entries(record)) {
|
|
972
|
+
map.set(tpl, {
|
|
973
|
+
templatePath: p.templatePath,
|
|
974
|
+
depth: p.depth,
|
|
975
|
+
examplePath: p.examplePath,
|
|
976
|
+
refs: p.refPaths.map((rp) => requireCollection(db, rp)),
|
|
977
|
+
docRefs: p.docRefPaths.map((dp) => requireDoc(db, dp)),
|
|
978
|
+
});
|
|
979
|
+
}
|
|
980
|
+
return map;
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
function requireCollection(db: CrawlerFirestore, path: string): CrawlerCollectionRef {
|
|
984
|
+
if (typeof db.collection !== 'function') {
|
|
985
|
+
throw new Error(
|
|
986
|
+
`Cannot resume crawl: db.collection(path) is required to reconstruct refs ` +
|
|
987
|
+
`but the supplied Firestore does not implement it.`,
|
|
988
|
+
);
|
|
989
|
+
}
|
|
990
|
+
return db.collection(path);
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
function requireDoc(db: CrawlerFirestore, path: string): CrawlerDocumentRef {
|
|
994
|
+
if (typeof db.doc !== 'function') {
|
|
995
|
+
throw new Error(
|
|
996
|
+
`Cannot resume crawl: db.doc(path) is required to reconstruct refs ` +
|
|
997
|
+
`but the supplied Firestore does not implement it.`,
|
|
998
|
+
);
|
|
999
|
+
}
|
|
1000
|
+
return db.doc(path);
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
/**
|
|
1004
|
+
* Returns `{ bytes }` if the crawl should pause now, else `null`.
|
|
1005
|
+
* Re-uses the same `JSON.stringify` work for both the threshold check
|
|
1006
|
+
* and the eventual `update`/`create` call so we don't serialize twice.
|
|
1007
|
+
*/
|
|
1008
|
+
function checkPause(
|
|
1009
|
+
state: PersistedCrawlState,
|
|
1010
|
+
sessions: SessionStore<PersistedCrawlState> | undefined,
|
|
1011
|
+
maxBatchBytes: number,
|
|
1012
|
+
): { bytes: number; serialized: string } | null {
|
|
1013
|
+
if (!sessions) return null;
|
|
1014
|
+
const serialized = JSON.stringify(state);
|
|
1015
|
+
const bytes = serialized.length;
|
|
1016
|
+
if (bytes < maxBatchBytes) return null;
|
|
1017
|
+
return { bytes, serialized };
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
function pauseAndPersist(
|
|
1021
|
+
pause: { bytes: number; serialized: string },
|
|
1022
|
+
state: PersistedCrawlState,
|
|
1023
|
+
events: DiscoverEvent[],
|
|
1024
|
+
discovered: Map<string, DiscoveredCollection>,
|
|
1025
|
+
sessions: SessionStore<PersistedCrawlState>,
|
|
1026
|
+
options: CrawlOptions,
|
|
1027
|
+
): FullCrawlResult {
|
|
1028
|
+
// Reuse-or-create: if the agent passed a continuation, keep the same
|
|
1029
|
+
// session id (so the agent's existing handle stays valid). Otherwise
|
|
1030
|
+
// mint a fresh session.
|
|
1031
|
+
let token: string;
|
|
1032
|
+
if (options.continuation !== undefined) {
|
|
1033
|
+
const r = sessions.update(options.continuation, state, pause.bytes);
|
|
1034
|
+
if (!r.ok) {
|
|
1035
|
+
events.push(sessionErrorToEvent(r.error));
|
|
1036
|
+
return emptyResult(events);
|
|
1037
|
+
}
|
|
1038
|
+
token = r.value.token;
|
|
1039
|
+
} else {
|
|
1040
|
+
const r = sessions.create(state, pause.bytes);
|
|
1041
|
+
if (!r.ok) {
|
|
1042
|
+
events.push(sessionErrorToEvent(r.error));
|
|
1043
|
+
return emptyResult(events);
|
|
1044
|
+
}
|
|
1045
|
+
token = r.value.token;
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
const finalizedSchemas = new Map<string, CollectionSchema>();
|
|
1049
|
+
for (const [k, v] of Object.entries(state.finalizedSchemas)) {
|
|
1050
|
+
finalizedSchemas.set(k, v);
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
return {
|
|
1054
|
+
events,
|
|
1055
|
+
discovered,
|
|
1056
|
+
listOps: state.listOps,
|
|
1057
|
+
readOps: state.readOps,
|
|
1058
|
+
finalizedSchemas,
|
|
1059
|
+
continuation: token,
|
|
1060
|
+
complete: false,
|
|
1061
|
+
};
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
function sessionErrorToEvent(err: SessionError): DiscoverEvent {
|
|
1065
|
+
return {
|
|
1066
|
+
kind: 'error',
|
|
1067
|
+
templatePath: '',
|
|
1068
|
+
code: err.code,
|
|
1069
|
+
message: `${err.message} (recoveryHint: ${err.recoveryHint})`,
|
|
1070
|
+
};
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
function emptyResult(events: DiscoverEvent[]): FullCrawlResult {
|
|
1074
|
+
return {
|
|
1075
|
+
events,
|
|
1076
|
+
discovered: new Map(),
|
|
1077
|
+
listOps: 0,
|
|
1078
|
+
readOps: 0,
|
|
1079
|
+
finalizedSchemas: new Map(),
|
|
1080
|
+
complete: true,
|
|
1081
|
+
};
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
function directChildTemplatePaths(
|
|
1085
|
+
parentTpl: string,
|
|
1086
|
+
discovered: Map<string, DiscoveredCollection>,
|
|
1087
|
+
): string[] {
|
|
1088
|
+
const prefix = parentTpl + '/';
|
|
1089
|
+
const out: string[] = [];
|
|
1090
|
+
for (const tpl of discovered.keys()) {
|
|
1091
|
+
if (!tpl.startsWith(prefix)) continue;
|
|
1092
|
+
const tail = tpl.slice(prefix.length);
|
|
1093
|
+
if (tail.split('/').length === 2) out.push(tpl); // {docId}/childColl
|
|
1094
|
+
}
|
|
1095
|
+
return out;
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
/**
|
|
1099
|
+
* Emit `collection_discovered` once per distinct templatePath, but
|
|
1100
|
+
* accumulate all concrete refs (so Item 2.3 can sample across every
|
|
1101
|
+
* concrete instance of a template, not just the first one encountered).
|
|
1102
|
+
*/
|
|
1103
|
+
function recordCollection(
|
|
1104
|
+
discovered: Map<string, DiscoveredCollection>,
|
|
1105
|
+
events: DiscoverEvent[],
|
|
1106
|
+
ref: CrawlerCollectionRef,
|
|
1107
|
+
depth: number,
|
|
1108
|
+
parentPath: string | undefined,
|
|
1109
|
+
): void {
|
|
1110
|
+
const templatePath = toTemplatePath(ref.path);
|
|
1111
|
+
const existing = discovered.get(templatePath);
|
|
1112
|
+
if (existing) {
|
|
1113
|
+
existing.refs.push(ref);
|
|
1114
|
+
return;
|
|
1115
|
+
}
|
|
1116
|
+
discovered.set(templatePath, {
|
|
1117
|
+
templatePath,
|
|
1118
|
+
depth,
|
|
1119
|
+
examplePath: ref.path,
|
|
1120
|
+
refs: [ref],
|
|
1121
|
+
docRefs: [],
|
|
1122
|
+
});
|
|
1123
|
+
const event: DiscoverEvent = parentPath
|
|
1124
|
+
? { kind: 'collection_discovered', templatePath, depth, parentPath }
|
|
1125
|
+
: { kind: 'collection_discovered', templatePath, depth };
|
|
1126
|
+
events.push(event);
|
|
1127
|
+
}
|