@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.
Files changed (424) hide show
  1. package/dist/cli/serve.js +4 -4
  2. package/dist/cli/serve.js.map +1 -1
  3. package/dist/conformance/.generated/can-i-use-browser.d.ts.map +1 -1
  4. package/dist/conformance/.generated/can-i-use-browser.js +1 -1
  5. package/dist/conformance/.generated/can-i-use-browser.js.map +1 -1
  6. package/dist/conformance/.generated/can-i-use.d.ts.map +1 -1
  7. package/dist/conformance/.generated/can-i-use.js +2 -2
  8. package/dist/conformance/.generated/can-i-use.js.map +1 -1
  9. package/dist/serve/bundler.d.ts +13 -8
  10. package/dist/serve/bundler.d.ts.map +1 -1
  11. package/dist/serve/bundler.js +31 -36
  12. package/dist/serve/bundler.js.map +1 -1
  13. package/dist/serve/docs-ui/docs/_rhythm/index.html +1 -1
  14. package/dist/serve/docs-ui/docs/agent/set-up-your-agent/index.html +1 -1
  15. package/dist/serve/docs-ui/docs/agent/watch-and-review/index.html +1 -1
  16. package/dist/serve/docs-ui/docs/agent/work-with-an-agent/index.html +1 -1
  17. package/dist/serve/docs-ui/docs/ai-compat/index.html +1 -1
  18. package/dist/serve/docs-ui/docs/api-reference/index.html +2 -2
  19. package/dist/serve/docs-ui/docs/api-reference.md +1 -1
  20. package/dist/serve/docs-ui/docs/app-compat/index.html +1 -1
  21. package/dist/serve/docs-ui/docs/auth-compat/index.html +1 -1
  22. package/dist/serve/docs-ui/docs/build/ai-logic/index.html +44 -22
  23. package/dist/serve/docs-ui/docs/build/ai-logic.md +69 -21
  24. package/dist/serve/docs-ui/docs/build/authentication/index.html +1 -1
  25. package/dist/serve/docs-ui/docs/build/cloud-firestore/index.html +1 -1
  26. package/dist/serve/docs-ui/docs/build/cloud-messaging/index.html +1 -1
  27. package/dist/serve/docs-ui/docs/build/cloud-storage/index.html +1 -1
  28. package/dist/serve/docs-ui/docs/build/realtime-database/index.html +1 -1
  29. package/dist/serve/docs-ui/docs/conformance-scores/index.html +1 -1
  30. package/dist/serve/docs-ui/docs/create-pyric-reference-api/index.html +1 -1
  31. package/dist/serve/docs-ui/docs/database-compat/index.html +1 -1
  32. package/dist/serve/docs-ui/docs/firestore-compat/index.html +1 -1
  33. package/dist/serve/docs-ui/docs/functions-rtdb-compat/index.html +1 -1
  34. package/dist/serve/docs-ui/docs/get-started/how-the-swap-works/index.html +2 -1
  35. package/dist/serve/docs-ui/docs/get-started/how-the-swap-works.md +2 -0
  36. package/dist/serve/docs-ui/docs/get-started/start-building/index.html +3 -2
  37. package/dist/serve/docs-ui/docs/get-started/start-building.md +3 -1
  38. package/dist/serve/docs-ui/docs/index.json +349 -29
  39. package/dist/serve/docs-ui/docs/messaging-compat/index.html +1 -1
  40. package/dist/serve/docs-ui/docs/observe/resolve-runtime-status/index.html +28 -0
  41. package/dist/serve/docs-ui/docs/observe/resolve-runtime-status.md +47 -0
  42. package/dist/serve/docs-ui/docs/observe/see-whats-happening/index.html +1 -1
  43. package/dist/serve/docs-ui/docs/observe/shape-your-data/index.html +1 -1
  44. package/dist/serve/docs-ui/docs/overview/index.html +1 -1
  45. package/dist/serve/docs-ui/docs/pyric-admin-app-reference-api/index.html +1 -1
  46. package/dist/serve/docs-ui/docs/pyric-admin-auth-reference-api/index.html +1 -1
  47. package/dist/serve/docs-ui/docs/pyric-admin-database-reference-api/index.html +1 -1
  48. package/dist/serve/docs-ui/docs/pyric-admin-firestore-reference-api/index.html +1 -1
  49. package/dist/serve/docs-ui/docs/pyric-admin-messaging-reference-api/index.html +916 -0
  50. package/dist/serve/docs-ui/docs/pyric-admin-messaging-reference-api.md +706 -0
  51. package/dist/serve/docs-ui/docs/pyric-admin-storage-reference-api/index.html +1 -1
  52. package/dist/serve/docs-ui/docs/pyric-ai-reference-api/index.html +1 -1
  53. package/dist/serve/docs-ui/docs/pyric-ai-scripting-reference-api/index.html +1 -1
  54. package/dist/serve/docs-ui/docs/pyric-app-reference-api/index.html +1 -1
  55. package/dist/serve/docs-ui/docs/pyric-auth-reference-api/index.html +1 -1
  56. package/dist/serve/docs-ui/docs/pyric-cli-assurance-browser-reference-api/index.html +1 -1
  57. package/dist/serve/docs-ui/docs/pyric-cli-assurance-reference-api/index.html +1 -1
  58. package/dist/serve/docs-ui/docs/pyric-cli-bridge-client-reference-api/index.html +1 -1
  59. package/dist/serve/docs-ui/docs/pyric-cli-bridge-reference-api/index.html +1 -1
  60. package/dist/serve/docs-ui/docs/pyric-cli-conformance-browser-reference-api/index.html +1 -1
  61. package/dist/serve/docs-ui/docs/pyric-cli-conformance-docs-reference-api/index.html +1 -1
  62. package/dist/serve/docs-ui/docs/pyric-cli-conformance-reference-api/index.html +1 -1
  63. package/dist/serve/docs-ui/docs/pyric-cli-credentials-node-reference-api/index.html +1 -1
  64. package/dist/serve/docs-ui/docs/pyric-cli-discover-reference-api/index.html +1 -1
  65. package/dist/serve/docs-ui/docs/pyric-cli-register-reference-api/index.html +1 -1
  66. package/dist/serve/docs-ui/docs/pyric-cli-remote-reference-api/index.html +1 -1
  67. package/dist/serve/docs-ui/docs/pyric-cli-serve-worker-reference-api/index.html +168 -76
  68. package/dist/serve/docs-ui/docs/pyric-cli-serve-worker-reference-api.md +156 -73
  69. package/dist/serve/docs-ui/docs/pyric-cli-verify-reference-api/index.html +1 -1
  70. package/dist/serve/docs-ui/docs/pyric-cli-vite-reference-api/index.html +11 -8
  71. package/dist/serve/docs-ui/docs/pyric-cli-vite-reference-api.md +17 -4
  72. package/dist/serve/docs-ui/docs/pyric-database-reference-api/index.html +1 -1
  73. package/dist/serve/docs-ui/docs/pyric-firestore-reference-api/index.html +1 -1
  74. package/dist/serve/docs-ui/docs/pyric-messaging-reference-api/index.html +1 -1
  75. package/dist/serve/docs-ui/docs/pyric-messaging-sw-reference-api/index.html +1 -1
  76. package/dist/serve/docs-ui/docs/pyric-rules-reference-api/index.html +1 -1
  77. package/dist/serve/docs-ui/docs/pyric-sandbox-database-reference-api/index.html +1 -1
  78. package/dist/serve/docs-ui/docs/pyric-sandbox-firestore-reference-api/index.html +1 -1
  79. package/dist/serve/docs-ui/docs/pyric-sandbox-reference-api/index.html +37 -14
  80. package/dist/serve/docs-ui/docs/pyric-sandbox-reference-api.md +28 -8
  81. package/dist/serve/docs-ui/docs/pyric-storage-reference-api/index.html +1 -1
  82. package/dist/serve/docs-ui/docs/pyric-ui-agents-reference-api/index.html +1 -1
  83. package/dist/serve/docs-ui/docs/pyric-ui-auth-hooks-reference-api/index.html +1 -1
  84. package/dist/serve/docs-ui/docs/pyric-ui-auth-reference-api/index.html +1 -1
  85. package/dist/serve/docs-ui/docs/pyric-ui-events-hooks-reference-api/index.html +1 -1
  86. package/dist/serve/docs-ui/docs/pyric-ui-events-reference-api/index.html +1 -1
  87. package/dist/serve/docs-ui/docs/pyric-ui-firestore-hooks-reference-api/index.html +1 -1
  88. package/dist/serve/docs-ui/docs/pyric-ui-firestore-reference-api/index.html +1 -1
  89. package/dist/serve/docs-ui/docs/pyric-ui-primitives-reference-api/index.html +1 -1
  90. package/dist/serve/docs-ui/docs/pyric-ui-rtdb-reference-api/index.html +1 -1
  91. package/dist/serve/docs-ui/docs/pyric-ui-rules-hooks-reference-api/index.html +1 -1
  92. package/dist/serve/docs-ui/docs/pyric-ui-rules-reference-api/index.html +1 -1
  93. package/dist/serve/docs-ui/docs/pyric-ui-storage-hooks-reference-api/index.html +1 -1
  94. package/dist/serve/docs-ui/docs/pyric-ui-storage-reference-api/index.html +1 -1
  95. package/dist/serve/docs-ui/docs/pyric-ui-traffic-hooks-reference-api/index.html +1 -1
  96. package/dist/serve/docs-ui/docs/pyric-ui-traffic-reference-api/index.html +1 -1
  97. package/dist/serve/docs-ui/docs/rules-compat/index.html +1 -1
  98. package/dist/serve/docs-ui/docs/secure/audit-your-rules/index.html +1 -1
  99. package/dist/serve/docs-ui/docs/secure/firestore-rules-limits/index.html +1 -1
  100. package/dist/serve/docs-ui/docs/secure/read-a-denial/index.html +1 -1
  101. package/dist/serve/docs-ui/docs/secure/rtdb-rules-in-typescript/index.html +1 -1
  102. package/dist/serve/docs-ui/docs/secure/rules-standard-library/index.html +1 -1
  103. package/dist/serve/docs-ui/docs/secure/secure-it-with-rules/index.html +1 -1
  104. package/dist/serve/docs-ui/docs/secure/simulate-and-lint/index.html +1 -1
  105. package/dist/serve/docs-ui/docs/secure/write-a-rules-test-suite/index.html +1 -1
  106. package/dist/serve/docs-ui/docs/ship/ship-to-production/index.html +1 -1
  107. package/dist/serve/docs-ui/docs/ship/test-in-node/index.html +1 -1
  108. package/dist/serve/docs-ui/docs/storage-compat/index.html +1 -1
  109. package/dist/serve/docs-ui/docs/trust/how-we-know-it-matches-firebase/index.html +1 -1
  110. package/dist/serve/docs-ui/docs/trust/versioning-and-compatibility/index.html +1 -1
  111. package/dist/serve/docs-ui/docs/tutorial/index.html +429 -0
  112. package/dist/serve/docs-ui/docs/tutorial.md +701 -0
  113. package/dist/serve/docs-ui/llms.txt +3 -1
  114. package/dist/serve/entries/init.d.ts.map +1 -1
  115. package/dist/serve/entries/init.js +3 -0
  116. package/dist/serve/entries/init.js.map +1 -1
  117. package/dist/serve/entries/runtime.d.ts.map +1 -1
  118. package/dist/serve/entries/runtime.js +20 -5
  119. package/dist/serve/entries/runtime.js.map +1 -1
  120. package/dist/serve/entries/worker-runtime.d.ts +2 -2
  121. package/dist/serve/entries/worker-runtime.d.ts.map +1 -1
  122. package/dist/serve/entries/worker-runtime.js +45 -10
  123. package/dist/serve/entries/worker-runtime.js.map +1 -1
  124. package/dist/serve/runtime/chip-config.d.ts +19 -0
  125. package/dist/serve/runtime/chip-config.d.ts.map +1 -0
  126. package/dist/serve/runtime/chip-config.js +19 -0
  127. package/dist/serve/runtime/chip-config.js.map +1 -0
  128. package/dist/serve/runtime/chip-install.d.ts +10 -0
  129. package/dist/serve/runtime/chip-install.d.ts.map +1 -0
  130. package/dist/serve/runtime/chip-install.js +15 -0
  131. package/dist/serve/runtime/chip-install.js.map +1 -0
  132. package/dist/serve/runtime/chip.d.ts +17 -0
  133. package/dist/serve/runtime/chip.d.ts.map +1 -0
  134. package/dist/serve/runtime/chip.js +257 -0
  135. package/dist/serve/runtime/chip.js.map +1 -0
  136. package/dist/serve/runtime/manifest.d.ts +22 -0
  137. package/dist/serve/runtime/manifest.d.ts.map +1 -0
  138. package/dist/serve/runtime/manifest.js +20 -0
  139. package/dist/serve/runtime/manifest.js.map +1 -0
  140. package/dist/serve/runtime/status.d.ts +49 -0
  141. package/dist/serve/runtime/status.d.ts.map +1 -0
  142. package/dist/serve/runtime/status.js +164 -0
  143. package/dist/serve/runtime/status.js.map +1 -0
  144. package/dist/serve/runtime/worker-connection.d.ts +4 -0
  145. package/dist/serve/runtime/worker-connection.d.ts.map +1 -0
  146. package/dist/serve/runtime/worker-connection.js +11 -0
  147. package/dist/serve/runtime/worker-connection.js.map +1 -0
  148. package/dist/serve/runtime/worker-generation.d.ts +14 -0
  149. package/dist/serve/runtime/worker-generation.d.ts.map +1 -0
  150. package/dist/serve/runtime/worker-generation.js +56 -0
  151. package/dist/serve/runtime/worker-generation.js.map +1 -0
  152. package/dist/serve/runtime/worker-replacement.d.ts +18 -0
  153. package/dist/serve/runtime/worker-replacement.d.ts.map +1 -0
  154. package/dist/serve/runtime/worker-replacement.js +29 -0
  155. package/dist/serve/runtime/worker-replacement.js.map +1 -0
  156. package/dist/serve/standalone-assets.d.ts +2 -2
  157. package/dist/serve/standalone-assets.d.ts.map +1 -1
  158. package/dist/serve/standalone-assets.js +1 -1
  159. package/dist/serve/standalone-assets.js.map +1 -1
  160. package/dist/serve/studio-ui/assets/{RulesCodeEditor-CxVYcZXw.js → RulesCodeEditor-BDlH-5lp.js} +1 -1
  161. package/dist/serve/studio-ui/assets/{index-cpSfgQ7H.js → index-Bf9SSE8L.js} +5 -5
  162. package/dist/serve/studio-ui/assets/{seed-DeuFKi4J.js → seed-D_UjxfOU.js} +1 -1
  163. package/dist/serve/studio-ui/index.html +1 -1
  164. package/dist/serve/vite-ai-config.d.ts +25 -0
  165. package/dist/serve/vite-ai-config.d.ts.map +1 -0
  166. package/dist/serve/vite-ai-config.js +45 -0
  167. package/dist/serve/vite-ai-config.js.map +1 -0
  168. package/dist/serve/vite-plugin.d.ts +17 -36
  169. package/dist/serve/vite-plugin.d.ts.map +1 -1
  170. package/dist/serve/vite-plugin.js +38 -57
  171. package/dist/serve/vite-plugin.js.map +1 -1
  172. package/dist/serve/vite-rules-source.d.ts +8 -0
  173. package/dist/serve/vite-rules-source.d.ts.map +1 -0
  174. package/dist/serve/vite-rules-source.js +23 -0
  175. package/dist/serve/vite-rules-source.js.map +1 -0
  176. package/dist/serve/vite-worker-runtime.d.ts +20 -0
  177. package/dist/serve/vite-worker-runtime.d.ts.map +1 -0
  178. package/dist/serve/vite-worker-runtime.js +25 -0
  179. package/dist/serve/vite-worker-runtime.js.map +1 -0
  180. package/dist/serve/worker/client/connection.d.ts +7 -2
  181. package/dist/serve/worker/client/connection.d.ts.map +1 -1
  182. package/dist/serve/worker/client/connection.js +13 -16
  183. package/dist/serve/worker/client/connection.js.map +1 -1
  184. package/dist/serve/worker/client/core.d.ts +7 -0
  185. package/dist/serve/worker/client/core.d.ts.map +1 -1
  186. package/dist/serve/worker/client/core.js +28 -0
  187. package/dist/serve/worker/client/core.js.map +1 -1
  188. package/dist/serve/worker/client/runtime-control.d.ts +8 -0
  189. package/dist/serve/worker/client/runtime-control.d.ts.map +1 -0
  190. package/dist/serve/worker/client/runtime-control.js +11 -0
  191. package/dist/serve/worker/client/runtime-control.js.map +1 -0
  192. package/dist/serve/worker/entry.js +44 -2
  193. package/dist/serve/worker/entry.js.map +1 -1
  194. package/dist/serve/worker/host/core.js +1 -1
  195. package/dist/serve/worker/host/core.js.map +1 -1
  196. package/dist/serve/worker/host/firestore-writes.d.ts.map +1 -1
  197. package/dist/serve/worker/host/firestore-writes.js +3 -2
  198. package/dist/serve/worker/host/firestore-writes.js.map +1 -1
  199. package/dist/serve/worker/host/studio.js +1 -1
  200. package/dist/serve/worker/host/studio.js.map +1 -1
  201. package/dist/serve/worker/host-context.d.ts +1 -1
  202. package/dist/serve/worker/host-context.d.ts.map +1 -1
  203. package/dist/serve/worker/index.d.ts +2 -1
  204. package/dist/serve/worker/index.d.ts.map +1 -1
  205. package/dist/serve/worker/index.js +2 -1
  206. package/dist/serve/worker/index.js.map +1 -1
  207. package/dist/serve/worker/protocol.d.ts +15 -1
  208. package/dist/serve/worker/protocol.d.ts.map +1 -1
  209. package/dist/serve/worker/protocol.js +2 -2
  210. package/dist/serve/worker/protocol.js.map +1 -1
  211. package/dist/serve/worker/retirement.d.ts +21 -0
  212. package/dist/serve/worker/retirement.d.ts.map +1 -0
  213. package/dist/serve/worker/retirement.js +97 -0
  214. package/dist/serve/worker/retirement.js.map +1 -0
  215. package/dist/serve/worker/serve-init.d.ts.map +1 -1
  216. package/dist/serve/worker/serve-init.js +5 -6
  217. package/dist/serve/worker/serve-init.js.map +1 -1
  218. package/dist/vite.d.ts +1 -0
  219. package/dist/vite.d.ts.map +1 -1
  220. package/dist/vite.js.map +1 -1
  221. package/package.json +6 -4
  222. package/src/assurance/.generated/conformance-verdicts.ts +1090 -0
  223. package/src/assurance/attachment.ts +211 -0
  224. package/src/assurance/browser.ts +80 -0
  225. package/src/assurance/campaign.ts +496 -0
  226. package/src/assurance/capabilities.ts +446 -0
  227. package/src/assurance/cases.ts +43 -0
  228. package/src/assurance/index.ts +75 -0
  229. package/src/assurance/runner.ts +885 -0
  230. package/src/assurance/tool-names.ts +15 -0
  231. package/src/assurance/tools.ts +830 -0
  232. package/src/assurance/types.ts +352 -0
  233. package/src/assurance/validation.ts +434 -0
  234. package/src/bridge/client/bridge.ts +550 -0
  235. package/src/bridge/client/dispatch.ts +137 -0
  236. package/src/bridge/client.ts +45 -0
  237. package/src/bridge/protocol.ts +351 -0
  238. package/src/bridge/server/audit.ts +53 -0
  239. package/src/bridge/server/bridge.ts +597 -0
  240. package/src/bridge/server/headless.ts +160 -0
  241. package/src/bridge/server/json-schema-to-zod.ts +108 -0
  242. package/src/bridge/server/local-bridge.ts +66 -0
  243. package/src/bridge/server/logger.ts +50 -0
  244. package/src/bridge/server/mcp-contract.ts +93 -0
  245. package/src/bridge/server/mcp.ts +129 -0
  246. package/src/bridge/server/peer.ts +232 -0
  247. package/src/bridge/server/standalone.ts +347 -0
  248. package/src/bridge/server/tool-metadata.ts +99 -0
  249. package/src/bridge/server.ts +28 -0
  250. package/src/cli/can-i-use.ts +50 -0
  251. package/src/cli/cli.test.ts +493 -0
  252. package/src/cli/database-rules.ts +329 -0
  253. package/src/cli/dev-runner.ts +282 -0
  254. package/src/cli/firebase-json.ts +100 -0
  255. package/src/cli/firestore-indexes.ts +70 -0
  256. package/src/cli/index.ts +425 -0
  257. package/src/cli/init.ts +260 -0
  258. package/src/cli/mcp-proxy.ts +196 -0
  259. package/src/cli/parse-args.ts +80 -0
  260. package/src/cli/rules.ts +259 -0
  261. package/src/cli/scope.ts +67 -0
  262. package/src/cli/serve.ts +996 -0
  263. package/src/cli/service-commands.ts +73 -0
  264. package/src/cli/snapshot.ts +164 -0
  265. package/src/cli/storage-rules.ts +172 -0
  266. package/src/cli/verify.ts +416 -0
  267. package/src/conformance/.generated/can-i-use-browser.ts +113 -0
  268. package/src/conformance/.generated/can-i-use.ts +117 -0
  269. package/src/conformance/.generated/conformance-docs.ts +14 -0
  270. package/src/conformance/browser.ts +24 -0
  271. package/src/conformance/can-i-use-tool.ts +51 -0
  272. package/src/conformance/can-i-use.ts +18 -0
  273. package/src/conformance/docs.ts +8 -0
  274. package/src/conformance/index.ts +20 -0
  275. package/src/conformance/tools.ts +16 -0
  276. package/src/credentials/core/memoize-ttl.ts +142 -0
  277. package/src/credentials/core/types.ts +10 -0
  278. package/src/credentials/node/from-adc.ts +96 -0
  279. package/src/credentials/node/from-service-account.ts +133 -0
  280. package/src/credentials/node/index.ts +6 -0
  281. package/src/discover/concurrency.ts +127 -0
  282. package/src/discover/crawler-adapter.ts +142 -0
  283. package/src/discover/crawler.ts +1127 -0
  284. package/src/discover/credential-free.ts +27 -0
  285. package/src/discover/findCollectionGroup.ts +131 -0
  286. package/src/discover/firestore-source.ts +59 -0
  287. package/src/discover/index.ts +5 -0
  288. package/src/discover/merge.ts +523 -0
  289. package/src/discover/session.ts +402 -0
  290. package/src/discover/tools.ts +201 -0
  291. package/src/discover/types.ts +187 -0
  292. package/src/discover/wire.ts +324 -0
  293. package/src/functions-rtdb/child.ts +382 -0
  294. package/src/functions-rtdb/delivery.ts +7 -0
  295. package/src/functions-rtdb/discovery.ts +136 -0
  296. package/src/functions-rtdb/event.ts +64 -0
  297. package/src/functions-rtdb/execution.ts +111 -0
  298. package/src/functions-rtdb/in-memory-delivery.ts +40 -0
  299. package/src/functions-rtdb/project.ts +74 -0
  300. package/src/functions-rtdb/projection.ts +104 -0
  301. package/src/functions-rtdb/reference-pattern.ts +20 -0
  302. package/src/functions-rtdb/remote-delivery.ts +23 -0
  303. package/src/pkg-version.ts +49 -0
  304. package/src/register/esm-exports.ts +63 -0
  305. package/src/register/hooks.ts +30 -0
  306. package/src/register/index.ts +150 -0
  307. package/src/register/mapping.ts +33 -0
  308. package/src/remote/index.ts +1050 -0
  309. package/src/rtdb/crawl-snapshot.ts +118 -0
  310. package/src/rtdb/inspection.ts +137 -0
  311. package/src/rtdb/load-rules-document.ts +56 -0
  312. package/src/rtdb/rules-generation-tool.ts +38 -0
  313. package/src/rtdb/rules-json.ts +29 -0
  314. package/src/serve/activity-guard.ts +29 -0
  315. package/src/serve/activity-route.ts +164 -0
  316. package/src/serve/activity-warning.ts +26 -0
  317. package/src/serve/bridge-mount.ts +200 -0
  318. package/src/serve/bundler.ts +519 -0
  319. package/src/serve/capture-store.ts +65 -0
  320. package/src/serve/discovery.ts +199 -0
  321. package/src/serve/entries/ai.ts +292 -0
  322. package/src/serve/entries/app-backend.ts +4 -0
  323. package/src/serve/entries/app-client.ts +26 -0
  324. package/src/serve/entries/app-session-store.ts +47 -0
  325. package/src/serve/entries/app.ts +11 -0
  326. package/src/serve/entries/auth-helper-core.ts +181 -0
  327. package/src/serve/entries/auth-helper-dom.ts +134 -0
  328. package/src/serve/entries/auth-helper-runtime.ts +20 -0
  329. package/src/serve/entries/auth.ts +255 -0
  330. package/src/serve/entries/bridge-url.ts +31 -0
  331. package/src/serve/entries/database.ts +129 -0
  332. package/src/serve/entries/firestore.ts +232 -0
  333. package/src/serve/entries/init.ts +49 -0
  334. package/src/serve/entries/keepalive.ts +46 -0
  335. package/src/serve/entries/messaging-sw.ts +75 -0
  336. package/src/serve/entries/messaging.ts +96 -0
  337. package/src/serve/entries/runtime.ts +556 -0
  338. package/src/serve/entries/session-store.ts +97 -0
  339. package/src/serve/entries/storage.ts +92 -0
  340. package/src/serve/entries/tab-sync-wiring.ts +274 -0
  341. package/src/serve/entries/worker-runtime.ts +135 -0
  342. package/src/serve/init-payload.ts +42 -0
  343. package/src/serve/namespace.ts +675 -0
  344. package/src/serve/open-browser.ts +68 -0
  345. package/src/serve/rules.ts +272 -0
  346. package/src/serve/runtime/chip-config.ts +30 -0
  347. package/src/serve/runtime/chip-install.ts +27 -0
  348. package/src/serve/runtime/chip.ts +282 -0
  349. package/src/serve/runtime/manifest.ts +37 -0
  350. package/src/serve/runtime/status.ts +216 -0
  351. package/src/serve/runtime/worker-connection.ts +14 -0
  352. package/src/serve/runtime/worker-generation.ts +75 -0
  353. package/src/serve/runtime/worker-replacement.ts +42 -0
  354. package/src/serve/sandbox-marker.ts +31 -0
  355. package/src/serve/server.ts +428 -0
  356. package/src/serve/standalone-assets.ts +180 -0
  357. package/src/serve/state-store.ts +166 -0
  358. package/src/serve/studio/disk-project-store.ts +185 -0
  359. package/src/serve/studio/disk-workspace.ts +162 -0
  360. package/src/serve/studio/index.ts +20 -0
  361. package/src/serve/studio/routes.ts +237 -0
  362. package/src/serve/studio/store-types.ts +48 -0
  363. package/src/serve/studio/studio-storage.test.ts +289 -0
  364. package/src/serve/vite-ai-config.ts +77 -0
  365. package/src/serve/vite-plugin.ts +1075 -0
  366. package/src/serve/vite-rules-source.ts +28 -0
  367. package/src/serve/vite-worker-runtime.ts +51 -0
  368. package/src/serve/worker/activity-bootstrap.ts +24 -0
  369. package/src/serve/worker/client/admin-firestore.ts +38 -0
  370. package/src/serve/worker/client/ai.ts +124 -0
  371. package/src/serve/worker/client/auth.ts +489 -0
  372. package/src/serve/worker/client/connection.ts +227 -0
  373. package/src/serve/worker/client/core.ts +384 -0
  374. package/src/serve/worker/client/disconnect.ts +45 -0
  375. package/src/serve/worker/client/firestore-reads.ts +181 -0
  376. package/src/serve/worker/client/firestore-refs.ts +226 -0
  377. package/src/serve/worker/client/firestore-writes.ts +232 -0
  378. package/src/serve/worker/client/handles.ts +91 -0
  379. package/src/serve/worker/client/messaging.ts +119 -0
  380. package/src/serve/worker/client/presence.ts +174 -0
  381. package/src/serve/worker/client/rtdb.ts +311 -0
  382. package/src/serve/worker/client/rules.ts +63 -0
  383. package/src/serve/worker/client/runtime-control.ts +22 -0
  384. package/src/serve/worker/client/service-worker-connection.ts +59 -0
  385. package/src/serve/worker/client/snapshots.ts +86 -0
  386. package/src/serve/worker/client/storage.ts +195 -0
  387. package/src/serve/worker/client/studio.ts +88 -0
  388. package/src/serve/worker/client.ts +57 -0
  389. package/src/serve/worker/durable-persistence.ts +137 -0
  390. package/src/serve/worker/entry.ts +223 -0
  391. package/src/serve/worker/host/admin-firestore.ts +88 -0
  392. package/src/serve/worker/host/connection.ts +179 -0
  393. package/src/serve/worker/host/core.ts +387 -0
  394. package/src/serve/worker/host/dispatch.ts +316 -0
  395. package/src/serve/worker/host/firestore-reads.ts +117 -0
  396. package/src/serve/worker/host/firestore-writes.ts +456 -0
  397. package/src/serve/worker/host/presence.ts +312 -0
  398. package/src/serve/worker/host/rtdb.ts +136 -0
  399. package/src/serve/worker/host/rules.ts +128 -0
  400. package/src/serve/worker/host/storage.ts +312 -0
  401. package/src/serve/worker/host/studio.ts +75 -0
  402. package/src/serve/worker/host/subscriptions.ts +238 -0
  403. package/src/serve/worker/host-ai.ts +165 -0
  404. package/src/serve/worker/host-auth.ts +469 -0
  405. package/src/serve/worker/host-context.ts +273 -0
  406. package/src/serve/worker/host-events.ts +94 -0
  407. package/src/serve/worker/host-messaging.ts +240 -0
  408. package/src/serve/worker/host.ts +47 -0
  409. package/src/serve/worker/index.ts +196 -0
  410. package/src/serve/worker/presence-timing.ts +15 -0
  411. package/src/serve/worker/protocol.ts +1124 -0
  412. package/src/serve/worker/retirement.ts +125 -0
  413. package/src/serve/worker/serve-init.ts +605 -0
  414. package/src/serve/worker/service-worker-channel.ts +33 -0
  415. package/src/serve/worker/service-worker-relay.ts +86 -0
  416. package/src/serve/writer-lock.ts +53 -0
  417. package/src/verify/cases.ts +233 -0
  418. package/src/verify/fixture.ts +258 -0
  419. package/src/verify/index.ts +519 -0
  420. package/src/verify/tools.ts +108 -0
  421. package/src/version/compat-target.ts +17 -0
  422. package/src/vite.ts +18 -0
  423. package/dist/serve/docs-ui/docs/pyric-firestore-values-reference-api/index.html +0 -31
  424. 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
+ }