@pyric/cli 0.1.0-alpha.11 → 0.1.0-alpha.12

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 (301) hide show
  1. package/dist/conformance/.generated/can-i-use-browser.d.ts.map +1 -1
  2. package/dist/conformance/.generated/can-i-use-browser.js +1 -1
  3. package/dist/conformance/.generated/can-i-use-browser.js.map +1 -1
  4. package/dist/conformance/.generated/can-i-use.d.ts.map +1 -1
  5. package/dist/conformance/.generated/can-i-use.js +2 -2
  6. package/dist/conformance/.generated/can-i-use.js.map +1 -1
  7. package/dist/serve/docs-ui/docs/_rhythm/index.html +1 -1
  8. package/dist/serve/docs-ui/docs/agent/set-up-your-agent/index.html +1 -1
  9. package/dist/serve/docs-ui/docs/agent/watch-and-review/index.html +1 -1
  10. package/dist/serve/docs-ui/docs/agent/work-with-an-agent/index.html +1 -1
  11. package/dist/serve/docs-ui/docs/ai-compat/index.html +1 -1
  12. package/dist/serve/docs-ui/docs/api-reference/index.html +2 -2
  13. package/dist/serve/docs-ui/docs/api-reference.md +1 -1
  14. package/dist/serve/docs-ui/docs/app-compat/index.html +1 -1
  15. package/dist/serve/docs-ui/docs/auth-compat/index.html +1 -1
  16. package/dist/serve/docs-ui/docs/build/ai-logic/index.html +1 -1
  17. package/dist/serve/docs-ui/docs/build/authentication/index.html +1 -1
  18. package/dist/serve/docs-ui/docs/build/cloud-firestore/index.html +1 -1
  19. package/dist/serve/docs-ui/docs/build/cloud-messaging/index.html +1 -1
  20. package/dist/serve/docs-ui/docs/build/cloud-storage/index.html +1 -1
  21. package/dist/serve/docs-ui/docs/build/realtime-database/index.html +1 -1
  22. package/dist/serve/docs-ui/docs/conformance-scores/index.html +1 -1
  23. package/dist/serve/docs-ui/docs/create-pyric-reference-api/index.html +1 -1
  24. package/dist/serve/docs-ui/docs/database-compat/index.html +1 -1
  25. package/dist/serve/docs-ui/docs/firestore-compat/index.html +1 -1
  26. package/dist/serve/docs-ui/docs/functions-rtdb-compat/index.html +1 -1
  27. package/dist/serve/docs-ui/docs/get-started/how-the-swap-works/index.html +1 -1
  28. package/dist/serve/docs-ui/docs/get-started/start-building/index.html +1 -1
  29. package/dist/serve/docs-ui/docs/index.json +200 -25
  30. package/dist/serve/docs-ui/docs/messaging-compat/index.html +1 -1
  31. package/dist/serve/docs-ui/docs/observe/see-whats-happening/index.html +1 -1
  32. package/dist/serve/docs-ui/docs/observe/shape-your-data/index.html +1 -1
  33. package/dist/serve/docs-ui/docs/overview/index.html +1 -1
  34. package/dist/serve/docs-ui/docs/pyric-admin-app-reference-api/index.html +1 -1
  35. package/dist/serve/docs-ui/docs/pyric-admin-auth-reference-api/index.html +1 -1
  36. package/dist/serve/docs-ui/docs/pyric-admin-database-reference-api/index.html +1 -1
  37. package/dist/serve/docs-ui/docs/pyric-admin-firestore-reference-api/index.html +1 -1
  38. package/dist/serve/docs-ui/docs/pyric-admin-messaging-reference-api/index.html +916 -0
  39. package/dist/serve/docs-ui/docs/pyric-admin-messaging-reference-api.md +706 -0
  40. package/dist/serve/docs-ui/docs/pyric-admin-storage-reference-api/index.html +1 -1
  41. package/dist/serve/docs-ui/docs/pyric-ai-reference-api/index.html +1 -1
  42. package/dist/serve/docs-ui/docs/pyric-ai-scripting-reference-api/index.html +1 -1
  43. package/dist/serve/docs-ui/docs/pyric-app-reference-api/index.html +1 -1
  44. package/dist/serve/docs-ui/docs/pyric-auth-reference-api/index.html +1 -1
  45. package/dist/serve/docs-ui/docs/pyric-cli-assurance-browser-reference-api/index.html +1 -1
  46. package/dist/serve/docs-ui/docs/pyric-cli-assurance-reference-api/index.html +1 -1
  47. package/dist/serve/docs-ui/docs/pyric-cli-bridge-client-reference-api/index.html +1 -1
  48. package/dist/serve/docs-ui/docs/pyric-cli-bridge-reference-api/index.html +1 -1
  49. package/dist/serve/docs-ui/docs/pyric-cli-conformance-browser-reference-api/index.html +1 -1
  50. package/dist/serve/docs-ui/docs/pyric-cli-conformance-docs-reference-api/index.html +1 -1
  51. package/dist/serve/docs-ui/docs/pyric-cli-conformance-reference-api/index.html +1 -1
  52. package/dist/serve/docs-ui/docs/pyric-cli-credentials-node-reference-api/index.html +1 -1
  53. package/dist/serve/docs-ui/docs/pyric-cli-discover-reference-api/index.html +1 -1
  54. package/dist/serve/docs-ui/docs/pyric-cli-register-reference-api/index.html +1 -1
  55. package/dist/serve/docs-ui/docs/pyric-cli-remote-reference-api/index.html +1 -1
  56. package/dist/serve/docs-ui/docs/pyric-cli-serve-worker-reference-api/index.html +131 -66
  57. package/dist/serve/docs-ui/docs/pyric-cli-serve-worker-reference-api.md +130 -65
  58. package/dist/serve/docs-ui/docs/pyric-cli-verify-reference-api/index.html +1 -1
  59. package/dist/serve/docs-ui/docs/pyric-cli-vite-reference-api/index.html +1 -1
  60. package/dist/serve/docs-ui/docs/pyric-database-reference-api/index.html +1 -1
  61. package/dist/serve/docs-ui/docs/pyric-firestore-reference-api/index.html +1 -1
  62. package/dist/serve/docs-ui/docs/pyric-messaging-reference-api/index.html +1 -1
  63. package/dist/serve/docs-ui/docs/pyric-messaging-sw-reference-api/index.html +1 -1
  64. package/dist/serve/docs-ui/docs/pyric-rules-reference-api/index.html +1 -1
  65. package/dist/serve/docs-ui/docs/pyric-sandbox-database-reference-api/index.html +1 -1
  66. package/dist/serve/docs-ui/docs/pyric-sandbox-firestore-reference-api/index.html +1 -1
  67. package/dist/serve/docs-ui/docs/pyric-sandbox-reference-api/index.html +37 -14
  68. package/dist/serve/docs-ui/docs/pyric-sandbox-reference-api.md +28 -8
  69. package/dist/serve/docs-ui/docs/pyric-storage-reference-api/index.html +1 -1
  70. package/dist/serve/docs-ui/docs/pyric-ui-agents-reference-api/index.html +1 -1
  71. package/dist/serve/docs-ui/docs/pyric-ui-auth-hooks-reference-api/index.html +1 -1
  72. package/dist/serve/docs-ui/docs/pyric-ui-auth-reference-api/index.html +1 -1
  73. package/dist/serve/docs-ui/docs/pyric-ui-events-hooks-reference-api/index.html +1 -1
  74. package/dist/serve/docs-ui/docs/pyric-ui-events-reference-api/index.html +1 -1
  75. package/dist/serve/docs-ui/docs/pyric-ui-firestore-hooks-reference-api/index.html +1 -1
  76. package/dist/serve/docs-ui/docs/pyric-ui-firestore-reference-api/index.html +1 -1
  77. package/dist/serve/docs-ui/docs/pyric-ui-primitives-reference-api/index.html +1 -1
  78. package/dist/serve/docs-ui/docs/pyric-ui-rtdb-reference-api/index.html +1 -1
  79. package/dist/serve/docs-ui/docs/pyric-ui-rules-hooks-reference-api/index.html +1 -1
  80. package/dist/serve/docs-ui/docs/pyric-ui-rules-reference-api/index.html +1 -1
  81. package/dist/serve/docs-ui/docs/pyric-ui-storage-hooks-reference-api/index.html +1 -1
  82. package/dist/serve/docs-ui/docs/pyric-ui-storage-reference-api/index.html +1 -1
  83. package/dist/serve/docs-ui/docs/pyric-ui-traffic-hooks-reference-api/index.html +1 -1
  84. package/dist/serve/docs-ui/docs/pyric-ui-traffic-reference-api/index.html +1 -1
  85. package/dist/serve/docs-ui/docs/rules-compat/index.html +1 -1
  86. package/dist/serve/docs-ui/docs/secure/audit-your-rules/index.html +1 -1
  87. package/dist/serve/docs-ui/docs/secure/firestore-rules-limits/index.html +1 -1
  88. package/dist/serve/docs-ui/docs/secure/read-a-denial/index.html +1 -1
  89. package/dist/serve/docs-ui/docs/secure/rtdb-rules-in-typescript/index.html +1 -1
  90. package/dist/serve/docs-ui/docs/secure/rules-standard-library/index.html +1 -1
  91. package/dist/serve/docs-ui/docs/secure/secure-it-with-rules/index.html +1 -1
  92. package/dist/serve/docs-ui/docs/secure/simulate-and-lint/index.html +1 -1
  93. package/dist/serve/docs-ui/docs/secure/write-a-rules-test-suite/index.html +1 -1
  94. package/dist/serve/docs-ui/docs/ship/ship-to-production/index.html +1 -1
  95. package/dist/serve/docs-ui/docs/ship/test-in-node/index.html +1 -1
  96. package/dist/serve/docs-ui/docs/storage-compat/index.html +1 -1
  97. package/dist/serve/docs-ui/docs/trust/how-we-know-it-matches-firebase/index.html +1 -1
  98. package/dist/serve/docs-ui/docs/trust/versioning-and-compatibility/index.html +1 -1
  99. package/dist/serve/docs-ui/llms.txt +1 -1
  100. package/dist/serve/worker/host/core.js +1 -1
  101. package/dist/serve/worker/host/core.js.map +1 -1
  102. package/dist/serve/worker/host/firestore-writes.d.ts.map +1 -1
  103. package/dist/serve/worker/host/firestore-writes.js +3 -2
  104. package/dist/serve/worker/host/firestore-writes.js.map +1 -1
  105. package/dist/serve/worker/index.d.ts +2 -1
  106. package/dist/serve/worker/index.d.ts.map +1 -1
  107. package/dist/serve/worker/index.js +2 -1
  108. package/dist/serve/worker/index.js.map +1 -1
  109. package/dist/serve/worker/protocol.js +2 -2
  110. package/dist/serve/worker/protocol.js.map +1 -1
  111. package/package.json +5 -4
  112. package/src/assurance/.generated/conformance-verdicts.ts +1090 -0
  113. package/src/assurance/attachment.ts +211 -0
  114. package/src/assurance/browser.ts +80 -0
  115. package/src/assurance/campaign.ts +496 -0
  116. package/src/assurance/capabilities.ts +446 -0
  117. package/src/assurance/cases.ts +43 -0
  118. package/src/assurance/index.ts +75 -0
  119. package/src/assurance/runner.ts +885 -0
  120. package/src/assurance/tool-names.ts +15 -0
  121. package/src/assurance/tools.ts +830 -0
  122. package/src/assurance/types.ts +352 -0
  123. package/src/assurance/validation.ts +434 -0
  124. package/src/bridge/client/bridge.ts +550 -0
  125. package/src/bridge/client/dispatch.ts +137 -0
  126. package/src/bridge/client.ts +45 -0
  127. package/src/bridge/protocol.ts +351 -0
  128. package/src/bridge/server/audit.ts +53 -0
  129. package/src/bridge/server/bridge.ts +597 -0
  130. package/src/bridge/server/headless.ts +160 -0
  131. package/src/bridge/server/json-schema-to-zod.ts +108 -0
  132. package/src/bridge/server/local-bridge.ts +66 -0
  133. package/src/bridge/server/logger.ts +50 -0
  134. package/src/bridge/server/mcp-contract.ts +93 -0
  135. package/src/bridge/server/mcp.ts +129 -0
  136. package/src/bridge/server/peer.ts +232 -0
  137. package/src/bridge/server/standalone.ts +347 -0
  138. package/src/bridge/server/tool-metadata.ts +99 -0
  139. package/src/bridge/server.ts +28 -0
  140. package/src/cli/can-i-use.ts +50 -0
  141. package/src/cli/cli.test.ts +493 -0
  142. package/src/cli/database-rules.ts +329 -0
  143. package/src/cli/dev-runner.ts +282 -0
  144. package/src/cli/firebase-json.ts +100 -0
  145. package/src/cli/firestore-indexes.ts +70 -0
  146. package/src/cli/index.ts +425 -0
  147. package/src/cli/init.ts +260 -0
  148. package/src/cli/mcp-proxy.ts +196 -0
  149. package/src/cli/parse-args.ts +80 -0
  150. package/src/cli/rules.ts +259 -0
  151. package/src/cli/scope.ts +67 -0
  152. package/src/cli/serve.ts +996 -0
  153. package/src/cli/service-commands.ts +73 -0
  154. package/src/cli/snapshot.ts +164 -0
  155. package/src/cli/storage-rules.ts +172 -0
  156. package/src/cli/verify.ts +416 -0
  157. package/src/conformance/.generated/can-i-use-browser.ts +113 -0
  158. package/src/conformance/.generated/can-i-use.ts +117 -0
  159. package/src/conformance/.generated/conformance-docs.ts +14 -0
  160. package/src/conformance/browser.ts +24 -0
  161. package/src/conformance/can-i-use-tool.ts +51 -0
  162. package/src/conformance/can-i-use.ts +18 -0
  163. package/src/conformance/docs.ts +8 -0
  164. package/src/conformance/index.ts +20 -0
  165. package/src/conformance/tools.ts +16 -0
  166. package/src/credentials/core/memoize-ttl.ts +142 -0
  167. package/src/credentials/core/types.ts +10 -0
  168. package/src/credentials/node/from-adc.ts +96 -0
  169. package/src/credentials/node/from-service-account.ts +133 -0
  170. package/src/credentials/node/index.ts +6 -0
  171. package/src/discover/concurrency.ts +127 -0
  172. package/src/discover/crawler-adapter.ts +142 -0
  173. package/src/discover/crawler.ts +1127 -0
  174. package/src/discover/credential-free.ts +27 -0
  175. package/src/discover/findCollectionGroup.ts +131 -0
  176. package/src/discover/firestore-source.ts +59 -0
  177. package/src/discover/index.ts +5 -0
  178. package/src/discover/merge.ts +523 -0
  179. package/src/discover/session.ts +402 -0
  180. package/src/discover/tools.ts +201 -0
  181. package/src/discover/types.ts +187 -0
  182. package/src/discover/wire.ts +324 -0
  183. package/src/functions-rtdb/child.ts +382 -0
  184. package/src/functions-rtdb/delivery.ts +7 -0
  185. package/src/functions-rtdb/discovery.ts +136 -0
  186. package/src/functions-rtdb/event.ts +64 -0
  187. package/src/functions-rtdb/execution.ts +111 -0
  188. package/src/functions-rtdb/in-memory-delivery.ts +40 -0
  189. package/src/functions-rtdb/project.ts +74 -0
  190. package/src/functions-rtdb/projection.ts +104 -0
  191. package/src/functions-rtdb/reference-pattern.ts +20 -0
  192. package/src/functions-rtdb/remote-delivery.ts +23 -0
  193. package/src/pkg-version.ts +49 -0
  194. package/src/register/esm-exports.ts +63 -0
  195. package/src/register/hooks.ts +30 -0
  196. package/src/register/index.ts +150 -0
  197. package/src/register/mapping.ts +33 -0
  198. package/src/remote/index.ts +1050 -0
  199. package/src/rtdb/crawl-snapshot.ts +118 -0
  200. package/src/rtdb/inspection.ts +137 -0
  201. package/src/rtdb/load-rules-document.ts +56 -0
  202. package/src/rtdb/rules-generation-tool.ts +38 -0
  203. package/src/rtdb/rules-json.ts +29 -0
  204. package/src/serve/activity-guard.ts +29 -0
  205. package/src/serve/activity-route.ts +164 -0
  206. package/src/serve/activity-warning.ts +26 -0
  207. package/src/serve/bridge-mount.ts +200 -0
  208. package/src/serve/bundler.ts +493 -0
  209. package/src/serve/capture-store.ts +65 -0
  210. package/src/serve/discovery.ts +199 -0
  211. package/src/serve/entries/ai.ts +292 -0
  212. package/src/serve/entries/app-backend.ts +4 -0
  213. package/src/serve/entries/app-client.ts +26 -0
  214. package/src/serve/entries/app-session-store.ts +47 -0
  215. package/src/serve/entries/app.ts +11 -0
  216. package/src/serve/entries/auth-helper-core.ts +181 -0
  217. package/src/serve/entries/auth-helper-dom.ts +134 -0
  218. package/src/serve/entries/auth-helper-runtime.ts +20 -0
  219. package/src/serve/entries/auth.ts +255 -0
  220. package/src/serve/entries/bridge-url.ts +31 -0
  221. package/src/serve/entries/database.ts +129 -0
  222. package/src/serve/entries/firestore.ts +232 -0
  223. package/src/serve/entries/init.ts +46 -0
  224. package/src/serve/entries/keepalive.ts +46 -0
  225. package/src/serve/entries/messaging-sw.ts +75 -0
  226. package/src/serve/entries/messaging.ts +96 -0
  227. package/src/serve/entries/runtime.ts +540 -0
  228. package/src/serve/entries/session-store.ts +97 -0
  229. package/src/serve/entries/storage.ts +92 -0
  230. package/src/serve/entries/tab-sync-wiring.ts +274 -0
  231. package/src/serve/entries/worker-runtime.ts +75 -0
  232. package/src/serve/init-payload.ts +42 -0
  233. package/src/serve/namespace.ts +675 -0
  234. package/src/serve/open-browser.ts +68 -0
  235. package/src/serve/rules.ts +272 -0
  236. package/src/serve/sandbox-marker.ts +31 -0
  237. package/src/serve/server.ts +428 -0
  238. package/src/serve/standalone-assets.ts +180 -0
  239. package/src/serve/state-store.ts +166 -0
  240. package/src/serve/studio/disk-project-store.ts +185 -0
  241. package/src/serve/studio/disk-workspace.ts +162 -0
  242. package/src/serve/studio/index.ts +20 -0
  243. package/src/serve/studio/routes.ts +237 -0
  244. package/src/serve/studio/store-types.ts +48 -0
  245. package/src/serve/studio/studio-storage.test.ts +289 -0
  246. package/src/serve/vite-plugin.ts +1102 -0
  247. package/src/serve/worker/activity-bootstrap.ts +24 -0
  248. package/src/serve/worker/client/admin-firestore.ts +38 -0
  249. package/src/serve/worker/client/ai.ts +124 -0
  250. package/src/serve/worker/client/auth.ts +489 -0
  251. package/src/serve/worker/client/connection.ts +202 -0
  252. package/src/serve/worker/client/core.ts +350 -0
  253. package/src/serve/worker/client/disconnect.ts +45 -0
  254. package/src/serve/worker/client/firestore-reads.ts +181 -0
  255. package/src/serve/worker/client/firestore-refs.ts +226 -0
  256. package/src/serve/worker/client/firestore-writes.ts +232 -0
  257. package/src/serve/worker/client/handles.ts +91 -0
  258. package/src/serve/worker/client/messaging.ts +119 -0
  259. package/src/serve/worker/client/presence.ts +174 -0
  260. package/src/serve/worker/client/rtdb.ts +311 -0
  261. package/src/serve/worker/client/rules.ts +63 -0
  262. package/src/serve/worker/client/service-worker-connection.ts +59 -0
  263. package/src/serve/worker/client/snapshots.ts +86 -0
  264. package/src/serve/worker/client/storage.ts +195 -0
  265. package/src/serve/worker/client/studio.ts +88 -0
  266. package/src/serve/worker/client.ts +57 -0
  267. package/src/serve/worker/durable-persistence.ts +137 -0
  268. package/src/serve/worker/entry.ts +179 -0
  269. package/src/serve/worker/host/admin-firestore.ts +88 -0
  270. package/src/serve/worker/host/connection.ts +179 -0
  271. package/src/serve/worker/host/core.ts +387 -0
  272. package/src/serve/worker/host/dispatch.ts +316 -0
  273. package/src/serve/worker/host/firestore-reads.ts +117 -0
  274. package/src/serve/worker/host/firestore-writes.ts +456 -0
  275. package/src/serve/worker/host/presence.ts +312 -0
  276. package/src/serve/worker/host/rtdb.ts +136 -0
  277. package/src/serve/worker/host/rules.ts +128 -0
  278. package/src/serve/worker/host/storage.ts +312 -0
  279. package/src/serve/worker/host/studio.ts +75 -0
  280. package/src/serve/worker/host/subscriptions.ts +238 -0
  281. package/src/serve/worker/host-ai.ts +165 -0
  282. package/src/serve/worker/host-auth.ts +469 -0
  283. package/src/serve/worker/host-context.ts +273 -0
  284. package/src/serve/worker/host-events.ts +94 -0
  285. package/src/serve/worker/host-messaging.ts +240 -0
  286. package/src/serve/worker/host.ts +47 -0
  287. package/src/serve/worker/index.ts +196 -0
  288. package/src/serve/worker/presence-timing.ts +15 -0
  289. package/src/serve/worker/protocol.ts +1112 -0
  290. package/src/serve/worker/serve-init.ts +606 -0
  291. package/src/serve/worker/service-worker-channel.ts +33 -0
  292. package/src/serve/worker/service-worker-relay.ts +86 -0
  293. package/src/serve/writer-lock.ts +53 -0
  294. package/src/verify/cases.ts +233 -0
  295. package/src/verify/fixture.ts +258 -0
  296. package/src/verify/index.ts +519 -0
  297. package/src/verify/tools.ts +108 -0
  298. package/src/version/compat-target.ts +17 -0
  299. package/src/vite.ts +17 -0
  300. package/dist/serve/docs-ui/docs/pyric-firestore-values-reference-api/index.html +0 -31
  301. package/dist/serve/docs-ui/docs/pyric-firestore-values-reference-api.md +0 -29
@@ -0,0 +1,523 @@
1
+ /**
2
+ * Pure-data schema merge / inference for `firestore_discover_paths`.
3
+ *
4
+ * Validation-grade implementation promoted to production with the following
5
+ * additions:
6
+ *
7
+ * 1. Enum-candidate tracking on string/integer/double scalar fields
8
+ * (Phase 3.2 lock — drives <select> codegen and discriminated unions).
9
+ * 2. Per-field example value (Phase 3.2 lock — drives form placeholders,
10
+ * fixtures, README payloads).
11
+ * 3. `null` is never a peer entry in `types[]`; only `nullable` carries the
12
+ * annotation (Phase 2.1 lock — suppresses redundant `type_expanded`
13
+ * events for null observations).
14
+ * 4. SchemaChange enum aligned with the Phase 5 frozen list (`field_added`
15
+ * replaces `new_key`; `array_grew` and `presence_dropped` removed —
16
+ * array element growth surfaces as `type_expanded` with `'[]'` path
17
+ * segment; presence shifts surface as `presence_changed`).
18
+ *
19
+ * Pure functions only — no Firestore SDK imports. Wire-format conversion
20
+ * lives in `wire.ts`; this module operates on already-typed observations.
21
+ */
22
+
23
+ import type {
24
+ FieldDescriptor,
25
+ FieldPath,
26
+ FieldSchema,
27
+ FieldType,
28
+ FirestoreScalarType,
29
+ SchemaChange,
30
+ } from './types.js';
31
+
32
+ // ─── Constants ────────────────────────────────────────────────────────────
33
+
34
+ /** Default enum-candidate distinct-value cap (Phase 3.2 lock). */
35
+ export const DEFAULT_ENUM_THRESHOLD = 10;
36
+
37
+ // ─── Empty schema ─────────────────────────────────────────────────────────
38
+
39
+ export function emptySchema(): FieldSchema {
40
+ return { fields: {}, samplesSeen: 0 };
41
+ }
42
+
43
+ // ─── Type key (for dedup; NaN-safe by construction) ───────────────────────
44
+
45
+ /**
46
+ * Stable key for FieldType used in dedup. NaN/±Infinity all collapse to
47
+ * `s:double` so no special handling needed (Phase 1.2 lock).
48
+ */
49
+ export function fieldTypeKey(t: FieldType): string {
50
+ switch (t.kind) {
51
+ case 'scalar':
52
+ return `s:${t.type}`;
53
+ case 'reference':
54
+ return `r:${t.targetCollection}`;
55
+ case 'vector':
56
+ return `v:${t.dimension}`;
57
+ case 'array': {
58
+ const inner = t.elementTypes.map(fieldTypeKey).sort().join('|');
59
+ return `a:[${inner}]`;
60
+ }
61
+ case 'map': {
62
+ const keys = Object.keys(t.fields).sort().join(',');
63
+ return `m:{${keys}}`;
64
+ }
65
+ }
66
+ }
67
+
68
+ function dedupTypes(types: FieldType[]): FieldType[] {
69
+ const seen = new Map<string, FieldType>();
70
+ for (const t of types) seen.set(fieldTypeKey(t), t);
71
+ return Array.from(seen.values());
72
+ }
73
+
74
+ // ─── Enum candidate helpers ───────────────────────────────────────────────
75
+
76
+ /** True if a scalar field type is enum-eligible (string/int/double only). */
77
+ function isEnumEligibleScalar(t: FieldType): t is { kind: 'scalar'; type: FirestoreScalarType } {
78
+ if (t.kind !== 'scalar') return false;
79
+ return t.type === 'string' || t.type === 'integer' || t.type === 'double';
80
+ }
81
+
82
+ /**
83
+ * Extract a hashable enum-value sample from a typed observation. Only
84
+ * callable when `isEnumEligibleScalar(type)` is true; the actual value lives
85
+ * on the observation, not the type.
86
+ */
87
+ type EnumSample = string | number;
88
+
89
+ // ─── Example-value helpers ────────────────────────────────────────────────
90
+
91
+ /**
92
+ * Captured example for a field. JSON-serializable shallow projection of
93
+ * the wire-decoded value. Strict scalars only at the top level; map fields
94
+ * project recursively but skip vector/reference/bytes (those are wire-typed,
95
+ * not codegen-friendly placeholders).
96
+ */
97
+ type ExampleValue =
98
+ | string
99
+ | number
100
+ | boolean
101
+ | null
102
+ | ExampleValue[]
103
+ | { [k: string]: ExampleValue };
104
+
105
+ // ─── Observation contract ─────────────────────────────────────────────────
106
+
107
+ /**
108
+ * A single field observation passed into `mergeDoc`. The wire layer
109
+ * (`wire.ts`) is responsible for producing this shape.
110
+ */
111
+ export interface FieldObservation {
112
+ /** The inferred FieldType for this observation. `null` is allowed and is
113
+ * surfaced via `isNull`; the `type` itself is `{kind:'scalar', type:'null'}`
114
+ * by convention but is NOT added to the descriptor's types[] union. */
115
+ type: FieldType;
116
+ /** True iff the wire value was a null literal. */
117
+ isNull: boolean;
118
+ /** A JSON-safe sample of the wire value. Used to populate `example` on
119
+ * the descriptor when no example exists yet. Optional — wire layer omits
120
+ * for kinds with no codegen-friendly representation. */
121
+ example?: ExampleValue;
122
+ /** For enum-eligible scalars (string/int/double), the raw value. Used
123
+ * to update enumCandidate. */
124
+ enumSample?: EnumSample;
125
+ }
126
+
127
+ // ─── Per-field merge ──────────────────────────────────────────────────────
128
+
129
+ interface MergeFieldResult {
130
+ merged: FieldDescriptor;
131
+ changes: SchemaChange[];
132
+ }
133
+
134
+ /**
135
+ * Merge a single field observation into an existing descriptor.
136
+ * `observation === 'absent'` means the field was missing from the doc.
137
+ * `newTotal` is the doc count after this observation.
138
+ */
139
+ export function mergeDescriptorWithObservation(
140
+ prev: FieldDescriptor,
141
+ observation: FieldObservation | 'absent',
142
+ newTotal: number,
143
+ path: FieldPath,
144
+ ): MergeFieldResult {
145
+ if (observation === 'absent') {
146
+ // Field absent — bump total only. presence_changed is NOT emitted on
147
+ // every absence (would be noisy); callers fold presence into a separate
148
+ // sweep at finalization if needed.
149
+ return { merged: { ...prev, presenceTotal: newTotal }, changes: [] };
150
+ }
151
+
152
+ const { type: obs, isNull, example, enumSample } = observation;
153
+ const changes: SchemaChange[] = [];
154
+
155
+ // Null observation: bump nullable + presenceSeen. Do NOT add `null` to
156
+ // types[] (Phase 2.1 lock). Emit `became_nullable` once, on the transition.
157
+ if (isNull) {
158
+ const becameNullable = !prev.nullable;
159
+ return {
160
+ merged: {
161
+ ...prev,
162
+ presenceSeen: prev.presenceSeen + 1,
163
+ presenceTotal: newTotal,
164
+ nullable: true,
165
+ },
166
+ changes: becameNullable ? [{ kind: 'became_nullable', path }] : [],
167
+ };
168
+ }
169
+
170
+ // Type union management (non-null observation).
171
+ //
172
+ // Container collapse rule: array and map kinds are SINGLETONS in the
173
+ // descriptor's types[] union — at most one array entry, at most one map
174
+ // entry. Element-type / field growth across observations merges into the
175
+ // existing container entry rather than creating a parallel union member.
176
+ // This is the Phase 5 lock: dropping `array_grew` in favor of
177
+ // `type_expanded` at `[..., '[]']` presumes arrays collapse this way.
178
+ // Vectors are NOT singletons — distinct dimensions stay distinct per
179
+ // Phase 1.2 vector-drift lock.
180
+ let mergedTypes = prev.types;
181
+
182
+ if (obs.kind === 'array') {
183
+ const existingIdx = prev.types.findIndex((t) => t.kind === 'array');
184
+ if (existingIdx !== -1) {
185
+ const existing = prev.types[existingIdx]!;
186
+ if (existing.kind === 'array') {
187
+ const result = mergeArrays(existing, obs, path);
188
+ const next = [...prev.types];
189
+ next[existingIdx] = result.merged;
190
+ mergedTypes = next;
191
+ changes.push(...result.changes);
192
+ }
193
+ } else {
194
+ mergedTypes = [...prev.types, obs];
195
+ changes.push({ kind: 'type_expanded', path, addedType: obs });
196
+ }
197
+ } else if (obs.kind === 'map') {
198
+ const existingIdx = prev.types.findIndex((t) => t.kind === 'map');
199
+ if (existingIdx !== -1) {
200
+ const existing = prev.types[existingIdx]!;
201
+ if (existing.kind === 'map') {
202
+ const result = mergeMaps(existing, obs, path);
203
+ const next = [...prev.types];
204
+ next[existingIdx] = result.merged;
205
+ mergedTypes = next;
206
+ changes.push(...result.changes);
207
+ }
208
+ } else {
209
+ mergedTypes = [...prev.types, obs];
210
+ changes.push({ kind: 'type_expanded', path, addedType: obs });
211
+ }
212
+ } else {
213
+ // Scalar / reference / vector union management. Vectors stay distinct
214
+ // per dimension (different keys for different dims) — that's the lock.
215
+ const obsKey = fieldTypeKey(obs);
216
+ const prevKeys = new Set(prev.types.map(fieldTypeKey));
217
+ if (!prevKeys.has(obsKey)) {
218
+ mergedTypes = dedupTypes([...prev.types, obs]);
219
+ if (obs.kind === 'vector' && prev.types.some((t) => t.kind === 'vector')) {
220
+ changes.push({
221
+ kind: 'vector_dim_drift',
222
+ path,
223
+ addedDimension: obs.dimension as number,
224
+ });
225
+ } else {
226
+ changes.push({ kind: 'type_expanded', path, addedType: obs });
227
+ }
228
+ }
229
+ }
230
+
231
+ // Enum candidate update. Drop if the type union widened past enum
232
+ // eligibility (more than one type, or non-eligible type added).
233
+ let enumCandidate = prev.enumCandidate;
234
+ const stillEnumEligible =
235
+ mergedTypes.length === 1 && isEnumEligibleScalar(mergedTypes[0]!);
236
+ if (!stillEnumEligible && enumCandidate) {
237
+ changes.push({ kind: 'enum_dropped', path, reason: 'type_widened' });
238
+ enumCandidate = undefined;
239
+ } else if (stillEnumEligible && enumSample !== undefined) {
240
+ const result = updateEnumCandidate(enumCandidate, enumSample, path);
241
+ enumCandidate = result.next;
242
+ changes.push(...result.changes);
243
+ }
244
+
245
+ // Example: capture on first non-null observation. Don't overwrite once set
246
+ // (deterministic for codegen).
247
+ const nextExample =
248
+ prev.example === undefined && example !== undefined ? example : prev.example;
249
+
250
+ return {
251
+ merged: {
252
+ types: mergedTypes,
253
+ presenceSeen: prev.presenceSeen + 1,
254
+ presenceTotal: newTotal,
255
+ nullable: prev.nullable,
256
+ enumCandidate,
257
+ example: nextExample,
258
+ reservedReason: prev.reservedReason,
259
+ },
260
+ changes,
261
+ };
262
+ }
263
+
264
+ // ─── Enum candidate state machine ─────────────────────────────────────────
265
+
266
+ /**
267
+ * Progress an enum candidate by one observation. State transitions:
268
+ *
269
+ * undefined + new sample → tracking (1 value, no event)
270
+ * tracking(1) + same → tracking (no event)
271
+ * tracking(1) + new → tracking (2 values), emit `enum_added`
272
+ * tracking(N>1) + new (N+1<=threshold) → tracking, emit `enum_widened`
273
+ * tracking(N=threshold) + new → undefined, emit `enum_dropped(over_threshold)`
274
+ */
275
+ function updateEnumCandidate(
276
+ prev: FieldDescriptor['enumCandidate'],
277
+ sample: EnumSample,
278
+ path: FieldPath,
279
+ ): { next: FieldDescriptor['enumCandidate']; changes: SchemaChange[] } {
280
+ if (prev === undefined) {
281
+ // First sighting — start tracking silently. Single value isn't an enum yet.
282
+ return {
283
+ next: { qualifies: false, values: [sample], threshold: DEFAULT_ENUM_THRESHOLD },
284
+ changes: [],
285
+ };
286
+ }
287
+ if (prev.values.includes(sample)) {
288
+ return { next: prev, changes: [] };
289
+ }
290
+ // New value — does it fit?
291
+ if (prev.values.length >= prev.threshold) {
292
+ return {
293
+ next: undefined,
294
+ changes: [{ kind: 'enum_dropped', path, reason: 'over_threshold' }],
295
+ };
296
+ }
297
+ const nextValues = [...prev.values, sample];
298
+ const wasFirstAdd = prev.values.length === 1;
299
+ // Mark qualifies once we have 2+ distinct values (still under threshold).
300
+ const next = { qualifies: true, values: nextValues, threshold: prev.threshold };
301
+ if (wasFirstAdd) {
302
+ return {
303
+ next,
304
+ changes: [{ kind: 'enum_added', path, values: nextValues }],
305
+ };
306
+ }
307
+ return {
308
+ next,
309
+ changes: [{ kind: 'enum_widened', path, addedValue: sample }],
310
+ };
311
+ }
312
+
313
+ // ─── Container-type recursive merges ──────────────────────────────────────
314
+
315
+ function mergeArrays(
316
+ prev: { kind: 'array'; elementTypes: FieldType[] },
317
+ obs: { kind: 'array'; elementTypes: FieldType[] },
318
+ path: FieldPath,
319
+ ): { merged: FieldType; changes: SchemaChange[] } {
320
+ // Empty array no-op merge (Phase 1.2 lock).
321
+ if (obs.elementTypes.length === 0) return { merged: prev, changes: [] };
322
+ if (prev.elementTypes.length === 0) {
323
+ return { merged: { kind: 'array', elementTypes: obs.elementTypes }, changes: [] };
324
+ }
325
+ const prevKeys = new Set(prev.elementTypes.map(fieldTypeKey));
326
+ const changes: SchemaChange[] = [];
327
+ const merged = [...prev.elementTypes];
328
+ const elementPath: FieldPath = [...path, '[]'];
329
+ for (const et of obs.elementTypes) {
330
+ if (!prevKeys.has(fieldTypeKey(et))) {
331
+ merged.push(et);
332
+ // Array element-type expansion surfaces as `type_expanded` at the
333
+ // `[]` path segment per Phase 5 SchemaChange enum.
334
+ changes.push({ kind: 'type_expanded', path: elementPath, addedType: et });
335
+ }
336
+ }
337
+ return { merged: { kind: 'array', elementTypes: dedupTypes(merged) }, changes };
338
+ }
339
+
340
+ function mergeMaps(
341
+ prev: { kind: 'map'; fields: Record<string, FieldDescriptor> },
342
+ obs: { kind: 'map'; fields: Record<string, FieldDescriptor> },
343
+ path: FieldPath,
344
+ ): { merged: FieldType; changes: SchemaChange[] } {
345
+ // Empty map no-op merge (Phase 1.2 lock).
346
+ if (Object.keys(obs.fields).length === 0) return { merged: prev, changes: [] };
347
+ if (Object.keys(prev.fields).length === 0) {
348
+ return { merged: { kind: 'map', fields: { ...obs.fields } }, changes: [] };
349
+ }
350
+ const changes: SchemaChange[] = [];
351
+ const fields: Record<string, FieldDescriptor> = { ...prev.fields };
352
+ for (const [k, obsDesc] of Object.entries(obs.fields)) {
353
+ const prevDesc = fields[k];
354
+ if (!prevDesc) {
355
+ fields[k] = obsDesc;
356
+ changes.push({ kind: 'field_added', path: [...path, k], type: obsDesc.types[0]! });
357
+ continue;
358
+ }
359
+ // Recurse on the single observed type.
360
+ const obsType = obsDesc.types[0]!;
361
+ const isNull = obsDesc.nullable && obsDesc.types.length === 0;
362
+ const subResult = mergeDescriptorWithObservation(
363
+ prevDesc,
364
+ {
365
+ type: obsType ?? { kind: 'scalar', type: 'null' },
366
+ isNull,
367
+ example: obsDesc.example,
368
+ enumSample: extractEnumSample(obsType, obsDesc.example),
369
+ },
370
+ prevDesc.presenceTotal + 1,
371
+ [...path, k],
372
+ );
373
+ fields[k] = subResult.merged;
374
+ changes.push(...subResult.changes);
375
+ }
376
+ return { merged: { kind: 'map', fields }, changes };
377
+ }
378
+
379
+ /** Best-effort extraction of an enum sample from a nested-map observation. */
380
+ function extractEnumSample(
381
+ type: FieldType | undefined,
382
+ example: ExampleValue | undefined,
383
+ ): EnumSample | undefined {
384
+ if (!type || !isEnumEligibleScalar(type)) return undefined;
385
+ if (typeof example === 'string' || typeof example === 'number') return example;
386
+ return undefined;
387
+ }
388
+
389
+ // ─── Top-level mergeDoc ───────────────────────────────────────────────────
390
+
391
+ /**
392
+ * Merge a single document's typed field observations into a collection-level
393
+ * schema. Returns the next schema and the changes emitted.
394
+ *
395
+ * The wire layer (`wire.ts`) is responsible for converting Firestore wire
396
+ * values into the `Record<string, FieldObservation>` shape expected here.
397
+ */
398
+ export function mergeDoc(
399
+ prev: FieldSchema,
400
+ doc: Record<string, FieldObservation>,
401
+ ): { next: FieldSchema; changes: SchemaChange[] } {
402
+ const newTotal = prev.samplesSeen + 1;
403
+ const allKeys = new Set([...Object.keys(prev.fields), ...Object.keys(doc)]);
404
+ const fields: Record<string, FieldDescriptor> = {};
405
+ const changes: SchemaChange[] = [];
406
+
407
+ for (const k of allKeys) {
408
+ const prevDesc = prev.fields[k];
409
+ const obs = doc[k];
410
+
411
+ if (!prevDesc && obs) {
412
+ // First time seeing this key.
413
+ const isNull = obs.isNull;
414
+ const enumEligible = !isNull && isEnumEligibleScalar(obs.type);
415
+ const enumCandidate =
416
+ enumEligible && obs.enumSample !== undefined
417
+ ? {
418
+ qualifies: false,
419
+ values: [obs.enumSample],
420
+ threshold: DEFAULT_ENUM_THRESHOLD,
421
+ }
422
+ : undefined;
423
+ fields[k] = {
424
+ types: isNull ? [] : [obs.type],
425
+ presenceSeen: 1,
426
+ presenceTotal: newTotal,
427
+ nullable: isNull,
428
+ enumCandidate,
429
+ example: obs.example,
430
+ };
431
+ if (!isNull) {
432
+ changes.push({ kind: 'field_added', path: [k], type: obs.type });
433
+ } else {
434
+ // Null-only first observation: emit field_added with null kind so
435
+ // agents see the field surfaced; nullable annotation handles the
436
+ // rest. No became_nullable on first observation — there's no prior
437
+ // non-null state to flip from.
438
+ changes.push({
439
+ kind: 'field_added',
440
+ path: [k],
441
+ type: { kind: 'scalar', type: 'null' },
442
+ });
443
+ }
444
+ continue;
445
+ }
446
+
447
+ if (prevDesc && !obs) {
448
+ const result = mergeDescriptorWithObservation(prevDesc, 'absent', newTotal, [k]);
449
+ fields[k] = result.merged;
450
+ changes.push(...result.changes);
451
+ continue;
452
+ }
453
+
454
+ if (prevDesc && obs) {
455
+ const result = mergeDescriptorWithObservation(prevDesc, obs, newTotal, [k]);
456
+ fields[k] = result.merged;
457
+ changes.push(...result.changes);
458
+ }
459
+ }
460
+
461
+ return { next: { fields, samplesSeen: newTotal }, changes };
462
+ }
463
+
464
+ // ─── Convergence runner ───────────────────────────────────────────────────
465
+
466
+ export interface ConvergenceResult {
467
+ /** Doc index where `stopOnStable` fired (0-based), or null if never. */
468
+ declaredAt: number | null;
469
+ /** Total docs consumed (≤ stream length). */
470
+ totalDocs: number;
471
+ /** Total change count across the stream. */
472
+ totalChanges: number;
473
+ /** Final accumulated schema. */
474
+ finalSchema: FieldSchema;
475
+ /** Changes emitted *after* convergence was declared — caller-visible
476
+ * for test-time assertion that `stopOnStable` would not have lost data. */
477
+ missedChangesAfterDeclared: SchemaChange[];
478
+ }
479
+
480
+ /**
481
+ * Stream-driven convergence runner. Used by the production crawler's
482
+ * sampling loop and by Phase 2.x tests that replay corpus snapshots.
483
+ *
484
+ * `stopOnStable` is the optimistic early-exit signal (Phase 2.1 lock — must
485
+ * be paired with a `maxSamples` hard cap in the crawler, not here).
486
+ */
487
+ export function runConvergence(
488
+ docs: Iterable<Record<string, FieldObservation>>,
489
+ stopOnStable: number,
490
+ ): ConvergenceResult {
491
+ let schema = emptySchema();
492
+ let consecutiveStable = 0;
493
+ let declaredAt: number | null = null;
494
+ let totalChanges = 0;
495
+ const missedChangesAfterDeclared: SchemaChange[] = [];
496
+ let i = 0;
497
+
498
+ for (const doc of docs) {
499
+ const { next, changes } = mergeDoc(schema, doc);
500
+ schema = next;
501
+ totalChanges += changes.length;
502
+ if (changes.length === 0) {
503
+ consecutiveStable++;
504
+ if (declaredAt === null && consecutiveStable >= stopOnStable) {
505
+ declaredAt = i;
506
+ }
507
+ } else {
508
+ consecutiveStable = 0;
509
+ if (declaredAt !== null) {
510
+ missedChangesAfterDeclared.push(...changes);
511
+ }
512
+ }
513
+ i++;
514
+ }
515
+
516
+ return {
517
+ declaredAt,
518
+ totalDocs: i,
519
+ totalChanges,
520
+ finalSchema: schema,
521
+ missedChangesAfterDeclared,
522
+ };
523
+ }