@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,1112 @@
1
+ /**
2
+ * SharedWorker protocol — message types + wire serialization.
3
+ *
4
+ * WHY THIS FILE EXISTS
5
+ * --------------------
6
+ * All traffic between an in-page client and the SharedWorker host travels
7
+ * over a `MessagePort`. Structured clone handles the envelope (plain objects,
8
+ * numbers, strings). Firestore's special scalar types (Timestamp, Bytes,
9
+ * GeoPoint/LatLng) are NOT structured-cloneable as class instances — they
10
+ * must be serialized to plain marker objects for the port crossing and
11
+ * re-hydrated on the other side.
12
+ *
13
+ * We use the persistence serializer's codec for doc DATA:
14
+ * serialize: JSON.stringify(data) — toJSON() on each wrapper emits a
15
+ * marker shape ({ __type: 'timestamp', seconds, nanos } etc.)
16
+ * deserialize: JSON.parse → rehydrateDocValue (from pyric/sandbox) — walks
17
+ * the tree and reconstructs REAL class instances (Timestamp,
18
+ * Bytes, LatLng) so instanceof checks and method calls work.
19
+ *
20
+ * This is the SAME codec the sandbox uses for IndexedDB persistence, so the
21
+ * wire format == the persistence format and values round-trip to real instances
22
+ * on BOTH sides of the port.
23
+ *
24
+ * Sentinels (serverTimestamp, increment, arrayUnion, arrayRemove, deleteField)
25
+ * are FieldValue objects that DO cross the port as plain marker objects that
26
+ * the worker's sandbox resolves natively — the sandbox sentinel-capture code
27
+ * recognizes them by shape.
28
+ *
29
+ * SURFACE SPLIT
30
+ * -------------
31
+ * Client-side only (never cross the port):
32
+ * doc, collection, collectionGroup, query, where, and, or, orderBy, limit,
33
+ * limitToLast, startAt, startAfter, endAt, endBefore
34
+ * → return plain RefDescriptor / QueryDescriptor objects.
35
+ * getFirestore → returns a ClientDb holding the MessagePort.
36
+ *
37
+ * Sentinels (client-side markers, resolved by the worker's sandbox):
38
+ * serverTimestamp, increment, arrayUnion, arrayRemove, deleteField
39
+ * → return SentinelMarker objects that embed in write data.
40
+ *
41
+ * Execution (RPC):
42
+ * getDoc, getDocs, setDoc, updateDoc, deleteDoc, addDoc,
43
+ * onSnapshot, getCountFromServer, runTransaction, writeBatch().commit()
44
+ */
45
+
46
+ // LEAF import — the value codec only, NOT `pyric/sandbox`. Importing the
47
+ // codec from `pyric/firestore/internal/value-codec` keeps the
48
+ // SharedWorker CLIENT bundle (every serve page) free of the rules/sandbox
49
+ // engine (~10 MB). The worker HOST (`entry.ts`/`host.ts`) still imports the
50
+ // full library — it IS the backend — but the client path stays lean.
51
+ import { rehydrateDocValue } from 'pyric/firestore/internal/value-codec';
52
+ // TYPE-ONLY (erased at build, so the leaf client bundle stays engine-free).
53
+ // The auth-lens contract and the cross-service event envelope are shared with
54
+ // the sandbox's event provenance — Studio's Action Center folds these verbatim.
55
+ import type { AuthLens, SandboxEvent, DenialContext } from 'pyric/sandbox';
56
+ // TYPE-ONLY (same rationale): the broker's own wire shapes for the
57
+ // `messaging.*` ops — all plain JSON, structured-clone-safe by construction.
58
+ import type {
59
+ BrokerMessage,
60
+ ClientVisibilityState,
61
+ FcmErrorEnvelope,
62
+ } from 'pyric/messaging/internal';
63
+
64
+ // ─── Ref descriptors (client-side, never cross the port directly) ──────────
65
+
66
+ /**
67
+ * A serializable reference to a Firestore document path. Produced by
68
+ * `doc()` on the client; embedded in op/sub messages for the worker to
69
+ * resolve against the real sandbox.
70
+ */
71
+ export interface DocRef {
72
+ readonly __ref: 'doc';
73
+ readonly path: string;
74
+ }
75
+
76
+ /**
77
+ * A serializable reference to a collection path. Produced by `collection()`.
78
+ */
79
+ export interface CollRef {
80
+ readonly __ref: 'collection';
81
+ readonly path: string;
82
+ }
83
+
84
+ /**
85
+ * A serializable collectionGroup descriptor. Produced by `collectionGroup()`.
86
+ */
87
+ export interface GroupRef {
88
+ readonly __ref: 'group';
89
+ readonly collectionId: string;
90
+ }
91
+
92
+ /**
93
+ * A serializable query descriptor. Wraps a source ref with a constraint list.
94
+ * Constraints encode `where`/`orderBy`/`limit`/`limitToLast`/`startAt`/
95
+ * `startAfter`/`endAt`/`endBefore` as plain data so they can be rebuilt on
96
+ * the worker side by calling the pyric/firestore constraint factories.
97
+ */
98
+ export interface QueryDescriptor {
99
+ readonly __ref: 'query';
100
+ readonly source: DocRef | CollRef | GroupRef;
101
+ readonly constraints: readonly QueryConstraintDescriptor[];
102
+ }
103
+
104
+ /**
105
+ * Plain data representation of a FILTER constraint — the subset of
106
+ * constraints valid at a query's top level AND inside composite `and`/`or`
107
+ * filters (orderBy/limit/cursors are not filters). Mirrors the modular SDK's
108
+ * `where()` / `and(...)` / `or(...)` composition: composites nest arbitrarily,
109
+ * and the worker rebuilds them with the pyric/firestore `and`/`or` factories.
110
+ */
111
+ export type FilterConstraintDescriptor =
112
+ | { kind: 'where'; field: string; op: string; value: unknown }
113
+ | { kind: 'and'; filters: readonly FilterConstraintDescriptor[] }
114
+ | { kind: 'or'; filters: readonly FilterConstraintDescriptor[] };
115
+
116
+ /** Plain data representation of a query constraint. */
117
+ export type QueryConstraintDescriptor =
118
+ | FilterConstraintDescriptor
119
+ | { kind: 'orderBy'; field: string; direction?: 'asc' | 'desc' }
120
+ | { kind: 'limit'; n: number }
121
+ | { kind: 'limitToLast'; n: number }
122
+ | { kind: 'startAt'; values: unknown[]; isSnapshot?: false }
123
+ | { kind: 'startAfter'; values: unknown[]; isSnapshot?: false }
124
+ | { kind: 'endAt'; values: unknown[]; isSnapshot?: false }
125
+ | { kind: 'endBefore'; values: unknown[]; isSnapshot?: false };
126
+
127
+ export type TargetDescriptor = DocRef | CollRef | GroupRef | QueryDescriptor;
128
+
129
+ // ─── Sentinel markers (cross the port embedded in write data) ─────────────
130
+
131
+ /**
132
+ * Wire representation of a FieldValue sentinel. The worker's sandbox
133
+ * resolves these via FieldValue factories before writing — the sentinel
134
+ * objects themselves are structurally identical to what `pyric/sandbox/
135
+ * admin-compat`'s FieldValue class produces, so they round-trip naturally.
136
+ *
137
+ * We use a `__sentinel` discriminator instead of relying on class identity
138
+ * (class instances aren't reliably transferred across message ports as class
139
+ * instances — the structured clone algorithm produces plain objects).
140
+ */
141
+ export type SentinelMarker =
142
+ | { readonly __sentinel: 'serverTimestamp' }
143
+ | { readonly __sentinel: 'increment'; readonly n: number }
144
+ | { readonly __sentinel: 'arrayUnion'; readonly values: unknown[] }
145
+ | { readonly __sentinel: 'arrayRemove'; readonly values: unknown[] }
146
+ | { readonly __sentinel: 'deleteField' };
147
+
148
+ export function isSentinelMarker(v: unknown): v is SentinelMarker {
149
+ return (
150
+ v !== null &&
151
+ typeof v === 'object' &&
152
+ '__sentinel' in (v as object) &&
153
+ typeof (v as { __sentinel: unknown }).__sentinel === 'string'
154
+ );
155
+ }
156
+
157
+ // ─── Aggregate descriptors ─────────────────────────────────────────────────
158
+
159
+ /**
160
+ * Aggregate-field descriptor for the `aggregate` op. Structurally identical
161
+ * to `pyric/firestore`'s `AggregateField` (and to admin-compat's — the
162
+ * `pyric-admin` remote arm's `Query.aggregate({ count/sum/average })`
163
+ * surface), so specs cross the wire verbatim: plain JSON, no translation.
164
+ * The host rebuilds the query and runs `getAggregateFromServer`; the reply
165
+ * is `{ data: Record<alias, number | null> }` (empty-input `average` is
166
+ * `null`, matching the SDKs).
167
+ */
168
+ export type AggregateFieldDescriptor =
169
+ | { kind: 'count' }
170
+ | { kind: 'sum'; field: string }
171
+ | { kind: 'average'; field: string };
172
+
173
+ /** Spec passed on the `aggregate` op — aliases become the result's keys. */
174
+ export type AggregateSpecDescriptor = Record<string, AggregateFieldDescriptor>;
175
+
176
+ // ─── AI wire shapes (cdd-deltas #98 — pyric/ai under pyric dev) ────────────
177
+
178
+ /**
179
+ * JSON-safe engine config for the worker host's AiBroker — the wire form of
180
+ * `pyric/ai`'s `EngineConfig` (cdd-deltas #98.4: engine choice + model mapping
181
+ * are per-sandbox config on the ai mirror). Differences from the mirror type:
182
+ *
183
+ * - `openai.baseUrl` is OPTIONAL here: absent means the serve default —
184
+ * the same-origin `/__pyric/ai-proxy` route (#98.2), so the browser
185
+ * openai engine reaches a localhost upstream with zero CORS setup.
186
+ * - `openai.fetch` (test injection) never crosses the port.
187
+ * - `scripted.script` entries are the PLAIN authoring shapes (string/regex
188
+ * matchers + JSON responds). Predicate matchers are functions and cannot
189
+ * cross the port — structured clone rejects them LOUDLY (DataCloneError),
190
+ * never silently. Author predicates host-side (ctx.aiEngine) instead.
191
+ *
192
+ * The config is honored on the FIRST ai op only (the broker is per-sandbox
193
+ * and created once — mirroring `getAI`'s first-call-wins idempotence).
194
+ */
195
+ export type AiEngineConfigWire =
196
+ | { kind: 'scripted'; script?: Array<Record<string, unknown>> }
197
+ | {
198
+ kind: 'openai';
199
+ /** OpenAI-compatible base URL. Absent ⇒ serve's `/__pyric/ai-proxy`. */
200
+ baseUrl?: string;
201
+ /** Catch-all upstream model when `modelMap` has no entry. */
202
+ model?: string;
203
+ /** Explicit Gemini-model-id → upstream-model mapping. */
204
+ modelMap?: Record<string, string>;
205
+ };
206
+
207
+ /**
208
+ * Wire form of the Gemini error envelope an `AiBrokerError` carries
209
+ * (`{ error: { code, message, status, details? } }` — every ai-error-*
210
+ * capture). Rides on {@link SerializedError} so the client mirror can mint
211
+ * the SAME `AIError('fetch-error', …)` the in-process plane would.
212
+ */
213
+ export interface AiErrorEnvelopeWire {
214
+ error: {
215
+ code: number;
216
+ message: string;
217
+ status: string;
218
+ details?: Array<Record<string, unknown>>;
219
+ };
220
+ }
221
+
222
+ // ─── Write descriptors for batch + transaction ────────────────────────────
223
+
224
+ export type WriteDescriptor =
225
+ | { method: 'set'; path: string; data: unknown; options?: { merge?: boolean; mergeFields?: string[] } }
226
+ | { method: 'update'; path: string; data: unknown }
227
+ | { method: 'delete'; path: string };
228
+
229
+ /**
230
+ * One entry in the read-set sent by the client on `txnCommit`.
231
+ *
232
+ * The client records every doc it read during `updateFn` (via `txn.get`)
233
+ * along with the serialized data it saw at read time (`data` is the
234
+ * `SerializedDocData` the worker returned, or `null` if the doc didn't
235
+ * exist). The worker re-reads each path inside a real sandbox transaction
236
+ * and deep-compares by re-serializing the current state to the same JSON
237
+ * form — any mismatch means another tab wrote the doc between our read and
238
+ * commit, so we abort and let the client retry `updateFn`.
239
+ */
240
+ export interface TxnReadEntry {
241
+ /** Firestore path of the document that was read. */
242
+ path: string;
243
+ /**
244
+ * The serialized doc data seen by the client at read time.
245
+ * `null` means the document did not exist when the client read it.
246
+ */
247
+ data: SerializedDocData | null;
248
+ }
249
+
250
+ // ─── Serialized auth user (crosses the port) ──────────────────────────────
251
+
252
+ /**
253
+ * Wire representation of a signed-in `User`. The real `pyric/auth` `User`
254
+ * carries methods (`getIdToken`, `getIdTokenResult`) that don't survive
255
+ * structured clone, so the worker flattens the fields the client mirror
256
+ * needs into a plain object. Token accessors on the client re-RPC to the
257
+ * worker (the worker holds the one real user).
258
+ *
259
+ * `null` means "signed out" — there is no current user.
260
+ */
261
+ export interface SerializedUser {
262
+ readonly uid: string;
263
+ readonly email: string | null;
264
+ readonly emailVerified: boolean;
265
+ readonly displayName: string | null;
266
+ readonly photoURL: string | null;
267
+ readonly phoneNumber: string | null;
268
+ readonly isAnonymous: boolean;
269
+ readonly providerId: string | null;
270
+ readonly providerData: ReadonlyArray<{
271
+ readonly displayName: string | null;
272
+ readonly email: string | null;
273
+ readonly phoneNumber: string | null;
274
+ readonly photoURL: string | null;
275
+ readonly providerId: string;
276
+ readonly uid: string;
277
+ }>;
278
+ }
279
+
280
+ /**
281
+ * A provider identity resolved IN-PAGE (by the `ServeAuthHelper`'s
282
+ * popup/redirect picker) and handed to the worker for sign-in. Provider flows
283
+ * (`signInWithPopup`/`signInWithRedirect`) can't cross the worker port — the
284
+ * `AuthFlowResolver` lives in-page — so the page resolves the picked identity
285
+ * and bridges it here; the worker seeds it + `restoreSession`s it (no password
286
+ * — provider users never sign in with one). See `auth.acceptIdentity`.
287
+ */
288
+ export interface ResolvedIdentity {
289
+ readonly uid: string;
290
+ readonly email: string | null;
291
+ readonly displayName: string | null;
292
+ readonly customClaims: Record<string, unknown>;
293
+ readonly providerId: string;
294
+ }
295
+
296
+ /** Wire form of a `UserCredential` returned by the sign-in/create ops. */
297
+ export interface SerializedUserCredential {
298
+ readonly user: SerializedUser;
299
+ readonly providerId: string | null;
300
+ readonly operationType: 'signIn' | 'reauthenticate' | 'link';
301
+ }
302
+
303
+ /** Wire form of `getIdTokenResult()`. */
304
+ export interface SerializedIdTokenResult {
305
+ readonly token: string;
306
+ readonly claims: Record<string, unknown>;
307
+ readonly expirationTime: string;
308
+ readonly issuedAtTime: string;
309
+ readonly authTime: string;
310
+ readonly signInProvider: string | null;
311
+ }
312
+
313
+ /**
314
+ * Flatten a live `pyric/auth` `User` (or `null`) into its wire form.
315
+ * Methods are dropped; only the structured fields cross the port.
316
+ */
317
+ export function serializeUser(
318
+ user: {
319
+ uid: string;
320
+ email: string | null;
321
+ emailVerified?: boolean;
322
+ displayName: string | null;
323
+ photoURL?: string | null;
324
+ phoneNumber?: string | null;
325
+ isAnonymous: boolean;
326
+ providerId?: string;
327
+ providerData?: ReadonlyArray<{
328
+ displayName: string | null;
329
+ email: string | null;
330
+ phoneNumber: string | null;
331
+ photoURL: string | null;
332
+ providerId: string;
333
+ uid: string;
334
+ }>;
335
+ } | null,
336
+ ): SerializedUser | null {
337
+ if (!user) return null;
338
+ return {
339
+ uid: user.uid,
340
+ email: user.email,
341
+ emailVerified: user.emailVerified ?? false,
342
+ displayName: user.displayName,
343
+ photoURL: user.photoURL ?? null,
344
+ phoneNumber: user.phoneNumber ?? null,
345
+ isAnonymous: user.isAnonymous,
346
+ providerId: user.providerId ?? null,
347
+ providerData: (user.providerData ?? []).map((p) => ({
348
+ displayName: p.displayName,
349
+ email: p.email,
350
+ phoneNumber: p.phoneNumber,
351
+ photoURL: p.photoURL,
352
+ providerId: p.providerId,
353
+ uid: p.uid,
354
+ })),
355
+ };
356
+ }
357
+
358
+ /**
359
+ * Persistence mode for the worker's shared auth session.
360
+ * Mirrors `pyric/auth`'s `Persistence.type`. `'NONE'` (inMemoryPersistence)
361
+ * disables the IndexedDB session record so a full close does NOT keep the
362
+ * user signed in; `'LOCAL'` and `'SESSION'` both persist the session in
363
+ * this single-backend model (see SESSION/LOCAL collapse note in host.ts).
364
+ */
365
+ export type AuthPersistenceMode = 'LOCAL' | 'SESSION' | 'NONE';
366
+
367
+ // ─── One-shot op messages (client → worker) ───────────────────────────────
368
+
369
+ /**
370
+ * All one-shot operation messages share the `t:'op'` discriminator and a
371
+ * correlation `id` that the worker echoes back in the `res` reply.
372
+ */
373
+ export type OpMessage = (
374
+ | {
375
+ t: 'op';
376
+ id: string;
377
+ method: 'getDoc';
378
+ path: string;
379
+ activity?: { groupKind?: 'transaction' };
380
+ }
381
+ | { t: 'op'; id: string; method: 'getDocs'; source: TargetDescriptor }
382
+ | { t: 'op'; id: string; method: 'setDoc'; path: string; data: unknown; options?: { merge?: boolean; mergeFields?: string[] } }
383
+ | { t: 'op'; id: string; method: 'updateDoc'; path: string; data: unknown }
384
+ | { t: 'op'; id: string; method: 'deleteDoc'; path: string }
385
+ | { t: 'op'; id: string; method: 'addDoc'; collectionPath: string; data: unknown }
386
+ | { t: 'op'; id: string; method: 'count'; source: TargetDescriptor }
387
+ // Multi-field aggregates (count/sum/average — spike gap 2, needed for the
388
+ // pyric-admin remote arm's `Query.aggregate` parity). `count` above stays
389
+ // for existing senders; this is the general form. Reply:
390
+ // `{ data: Record<alias, number | null> }`.
391
+ | { t: 'op'; id: string; method: 'aggregate'; source: TargetDescriptor; spec: AggregateSpecDescriptor }
392
+ | { t: 'op'; id: string; method: 'batchCommit'; writes: WriteDescriptor[] }
393
+ | { t: 'op'; id: string; method: 'txnCommit'; reads: TxnReadEntry[]; writes: WriteDescriptor[] }
394
+ | { t: 'op'; id: string; method: 'setRules'; source: string }
395
+ | { t: 'op'; id: string; method: 'setFirestoreRules'; source: string }
396
+ | { t: 'op'; id: string; method: 'setDatabaseRules'; source: unknown }
397
+ | { t: 'op'; id: string; method: 'getActiveRules'; service?: 'firestore' | 'database' }
398
+ | { t: 'op'; id: string; method: 'getRulesStatus'; service?: 'firestore' | 'database' }
399
+ | { t: 'op'; id: string; method: 'admin.getDocument'; path: string }
400
+ | { t: 'op'; id: string; method: 'admin.listDocuments'; path: string }
401
+ | { t: 'op'; id: string; method: 'admin.setDocument'; path: string; data: unknown }
402
+ | { t: 'op'; id: string; method: 'admin.deleteDocument'; path: string }
403
+ | { t: 'op'; id: string; method: 'admin.readState'; path?: string; maxDepth?: number }
404
+ | { t: 'op'; id: string; method: 'rtdb.get'; path: string }
405
+ | { t: 'op'; id: string; method: 'rtdb.set'; path: string; value: unknown }
406
+ | { t: 'op'; id: string; method: 'rtdb.update'; path: string; values: Record<string, unknown> }
407
+ | { t: 'op'; id: string; method: 'rtdb.remove'; path: string }
408
+ | { t: 'op'; id: string; method: 'rtdb.push'; path: string; key: string; value?: unknown }
409
+ | { t: 'op'; id: string; method: 'rtdb.adminSnapshot' }
410
+ // Collection enumeration (Pyric Studio data browse): the modular SDK has no
411
+ // client `listCollections`, so the host enumerates the sandbox keyspace via
412
+ // `getInternalEnv(sandbox)`. Reply: `{ ids: string[] }`.
413
+ | { t: 'op'; id: string; method: 'listRootCollections' }
414
+ | { t: 'op'; id: string; method: 'listSubcollections'; docPath: string }
415
+ // ── Auth ops (surface: 'auth') ──────────────────────────────────────────
416
+ | { t: 'op'; id: string; method: 'auth.createUser'; email: string; password: string }
417
+ | { t: 'op'; id: string; method: 'auth.signInEmail'; email: string; password: string }
418
+ | { t: 'op'; id: string; method: 'auth.signInAnonymously' }
419
+ | { t: 'op'; id: string; method: 'auth.signOut' }
420
+ | { t: 'op'; id: string; method: 'auth.getIdToken'; forceRefresh?: boolean }
421
+ | { t: 'op'; id: string; method: 'auth.getIdTokenResult'; forceRefresh?: boolean }
422
+ | { t: 'op'; id: string; method: 'auth.setPersistence'; mode: AuthPersistenceMode }
423
+ | { t: 'op'; id: string; method: 'auth.getCurrentUser' }
424
+ // Update THIS PORT's signed-in user's profile (displayName / photoURL).
425
+ // Mirrors `firebase/auth`'s `updateProfile(user, profile)`. `null` clears a
426
+ // field, an absent field leaves it untouched. Reply: the updated
427
+ // SerializedUser (so the client mirror stays consistent).
428
+ | { t: 'op'; id: string; method: 'auth.updateProfile'; displayName?: string | null; photoURL?: string | null }
429
+ // Per-tab session restore (#754): re-establish THIS PORT's session for an
430
+ // existing identity (the uid the page persisted in web storage). Soft — the
431
+ // reply value is the serialized user, or null when the uid no longer
432
+ // resolves (deleted / disabled), so a stale record just means signed out.
433
+ | { t: 'op'; id: string; method: 'auth.restorePortSession'; uid: string }
434
+ // Provider sign-in bridge: identity resolved in-page, signed in on the worker.
435
+ | { t: 'op'; id: string; method: 'auth.acceptIdentity'; identity: ResolvedIdentity }
436
+ // Admin user-DB ops (Pyric Studio data browse): mirror `pyric/auth`'s
437
+ // `sandbox.{listUsers,createUser,updateUser,deleteUser,clearUsers}` over the
438
+ // port. Records are plain JSON (AuthUserRecord); requests are plain objects.
439
+ | { t: 'op'; id: string; method: 'auth.listUsers' }
440
+ | { t: 'op'; id: string; method: 'auth.adminCreateUser'; request: Record<string, unknown> }
441
+ | { t: 'op'; id: string; method: 'auth.adminUpdateUser'; uid: string; request: Record<string, unknown> }
442
+ | { t: 'op'; id: string; method: 'auth.adminDeleteUser'; uid: string }
443
+ | { t: 'op'; id: string; method: 'auth.adminClearUsers' }
444
+ // Sign-in provider config (Pyric Studio S-AUTH "Sign-in providers" section):
445
+ // mirror `pyric/auth`'s `sandbox.{getAuthProviderConfig,setAuthProviderConfig}`
446
+ // over the port. Reply for `getProviderConfig` is `Array<{providerId, enabled}>`.
447
+ | { t: 'op'; id: string; method: 'auth.getProviderConfig' }
448
+ | { t: 'op'; id: string; method: 'auth.setProviderConfig'; providerId: string; enabled: boolean }
449
+ // Storage ops (Pyric Studio data browse + pyric-admin remote arm): mirror
450
+ // `pyric/storage` over the port. The ref is a path; `listAll` replies with
451
+ // plain `{ fullPath, name }` entries, `getMetadata` with the plain
452
+ // `FullMetadata`, and `getBlob` with a structured-cloneable browser Blob.
453
+ // All storage ops honor the shared `actAs` lens (admin ⇒ rules bypass via
454
+ // `pyric/storage/internal`'s admin plane; `{ as: uid }` ⇒ rules evaluate as
455
+ // that uid; absent ⇒ the worker's anonymous page handle).
456
+ | { t: 'op'; id: string; method: 'storage.listAll'; path: string }
457
+ | { t: 'op'; id: string; method: 'storage.getMetadata'; path: string }
458
+ | { t: 'op'; id: string; method: 'storage.getBlob'; path: string }
459
+ // Byte-carrying storage ops (remote sandbox, slice 2). Bytes travel as
460
+ // BASE64 STRINGS (`dataB64`) inside the op payload/result so ONE encoding
461
+ // survives both the MessagePort (structured clone) and the two JSON WS
462
+ // relay legs verbatim — Blob/ArrayBuffer/TypedArray silently corrupt under
463
+ // `JSON.stringify` ({} / index-keyed objects), which is why `getBlob` must
464
+ // NEVER be relayed (see the bridge client's binary-payload guard). Raw
465
+ // payloads are capped at {@link MAX_STORAGE_OP_BYTES} on BOTH the encode
466
+ // and decode ends. `metadata` carries `pyric/storage` `SettableMetadata`
467
+ // fields (contentType/cacheControl/…/customMetadata); GCS-style nested
468
+ // custom maps (`metadata.metadata`) are folded into `customMetadata`.
469
+ | { t: 'op'; id: string; method: 'storage.putBytes'; path: string; dataB64: string; contentType?: string; metadata?: Record<string, unknown> }
470
+ | { t: 'op'; id: string; method: 'storage.getBytes'; path: string }
471
+ | { t: 'op'; id: string; method: 'storage.deleteObject'; path: string }
472
+ // ── AI ops (surface: 'ai' — cdd-deltas #98.3, exactly like rtdb.*/auth.*).
473
+ // `request` is the plain Gemini-wire JSON the mirror already speaks (the
474
+ // broker's GenerateContentRequest / CountTokensRequest — no class instances,
475
+ // no codec round-trip). `model` is the model resource the mirror resolved
476
+ // (e.g. `models/gemini-flash-lite-latest`). `engine` is honored on the FIRST
477
+ // ai op only (broker creation — see AiEngineConfigWire). Replies:
478
+ // ai.generateContent → the complete WireResponse envelope (plain JSON)
479
+ // ai.countTokens → the CountTokensResponse envelope (plain JSON)
480
+ // Streaming is a SUBSCRIPTION, not an op — see AiStreamSubMessage.
481
+ | { t: 'op'; id: string; method: 'ai.generateContent'; model: string; request: Record<string, unknown>; engine?: AiEngineConfigWire }
482
+ | { t: 'op'; id: string; method: 'ai.countTokens'; model: string; request: Record<string, unknown>; engine?: AiEngineConfigWire }
483
+ // Staleness guard: report the worker's baked build version so the page can
484
+ // warn when a still-running OLD worker serves code older than what's served.
485
+ | { t: 'op'; id: string; method: 'getVersion' }
486
+ // ── Phase 2 (transfer): export/import the FULL sandbox state as a bundle
487
+ // string (the chunk format the persist layer uses). importState CLOBBERS. ──
488
+ | { t: 'op'; id: string; method: 'exportState' }
489
+ | { t: 'op'; id: string; method: 'importState'; bundle: string }
490
+ // ── Phase 3 (named branches): save/list/switch/delete named state bundles.
491
+ // switchBranch CLOBBERS the live sandbox with the branch's state. ──
492
+ | { t: 'op'; id: string; method: 'saveBranch'; name: string }
493
+ | { t: 'op'; id: string; method: 'listBranches' }
494
+ | { t: 'op'; id: string; method: 'switchBranch'; name: string }
495
+ | { t: 'op'; id: string; method: 'deleteBranch'; name: string }
496
+ // Export the sandbox snapshot (Pyric Studio rules re-run): Studio forks it
497
+ // locally to test a denied op against edited rules / re-issue as the user, all
498
+ // on a throwaway branch (no live mutation). The reply is the serializable
499
+ // `SandboxSnapshot` (the persistence format).
500
+ | { t: 'op'; id: string; method: 'getSnapshot' }
501
+ // Sandbox-owned full reset (issue #359): `sandbox.resetAll()` on the worker —
502
+ // Firestore env + signed-in session + EVERY registered persistable service
503
+ // (auth users, RTDB tree, storage objects). The reply is `{ errors }` once
504
+ // every service finished clearing — per-service reset failures are listed
505
+ // as `name: message` (empty array = clean wipe). Studio's Settings/Session reset rides this so
506
+ // served mode wipes the same surface area as the in-process path.
507
+ | { t: 'op'; id: string; method: 'resetAll' }
508
+ // ── Messaging ops (surface: 'messaging'; host-capability gated) ──
509
+ // The broker's documented worker-host seam (pyric/src/messaging/broker/
510
+ // broker.ts header): each public broker method is one op here. All payloads
511
+ // and replies are plain JSON. The host answers `messaging/disabled` unless
512
+ // its ctx has `messagingEnabled: true`; normal serve producers set that
513
+ // capability. NEVER lensed:
514
+ // FCM has no rules identity; visibility (below) is the only routing input.
515
+ //
516
+ // `registrationId` names the page's service-worker registration (token
517
+ // stability is keyed per registration — the captured contract); absent ⇒
518
+ // the port-shared default registration.
519
+ | { t: 'op'; id: string; method: 'messaging.getToken'; registrationId?: string }
520
+ | { t: 'op'; id: string; method: 'messaging.deleteToken'; registrationId?: string }
521
+ // Send-plane intake (the admin mirror's `send` crossing the transport).
522
+ // Reply: the broker's `AcceptedSend` (name/messageId/target/validateOnly).
523
+ // Rejections carry the captured google.rpc envelope on `error.envelope`.
524
+ | { t: 'op'; id: string; method: 'messaging.send'; message: BrokerMessage; validateOnly?: boolean }
525
+ | { t: 'op'; id: string; method: 'messaging.subscribeToTopic'; tokens: string[]; topic: string }
526
+ | { t: 'op'; id: string; method: 'messaging.unsubscribeFromTopic'; tokens: string[]; topic: string }
527
+ // Test/Studio delivery driver — injects straight into the client plane.
528
+ | { t: 'op'; id: string; method: 'messaging.deliver'; spec: MessagingDeliverSpec }
529
+ // THE captured routing rule crossing the transport: each port that reports
530
+ // visibility is ONE window client in the broker (`setClientVisibility(portId,
531
+ // state)`); a hidden tab's port marks its client not-visible, and routing is
532
+ // foreground iff ANY visible client. Pages send this on `visibilitychange`.
533
+ | { t: 'op'; id: string; method: 'messaging.setVisibility'; state: ClientVisibilityState }
534
+ // ── Connected-page presence (#227) ──────────────────────────────────────
535
+ // Ephemeral, worker-lifetime logical-page registry — NOT port/subscription/
536
+ // auth-session counts. Pages register with a clientId, renew a short lease
537
+ // via heartbeat, and disconnect on pagehide. Studio subscribes (target:
538
+ // 'presence') for the authoritative snapshot. See host/presence.ts.
539
+ | {
540
+ t: 'op';
541
+ id: string;
542
+ method: 'presence.register';
543
+ clientId: string;
544
+ kind: PresenceClientKind;
545
+ route: string;
546
+ visibility: PresenceVisibility;
547
+ }
548
+ | { t: 'op'; id: string; method: 'presence.heartbeat'; clientId: string }
549
+ | {
550
+ t: 'op';
551
+ id: string;
552
+ method: 'presence.update';
553
+ clientId: string;
554
+ route?: string;
555
+ visibility?: PresenceVisibility;
556
+ }
557
+ | { t: 'op'; id: string; method: 'presence.disconnect'; clientId: string }
558
+ ) & {
559
+ /**
560
+ * Per-op auth lens (Pyric Studio): `admin` bypasses rules, `{ as: uid }`
561
+ * evaluates rules as that user (impersonation), `anon` runs genuinely
562
+ * UNAUTHENTICATED (`withAuth(null)` — the remote arm's "no auth", which an
563
+ * absent lens does NOT mean: absent ⇒ the app's session, i.e. whoever the
564
+ * browser tab is signed in as). The host resolves the data handle from
565
+ * this — see `lensDb` in `host.ts`. Additive: existing senders omit it.
566
+ * Plain tagged union → structured-clones.
567
+ */
568
+ actAs?: AuthLens;
569
+ /**
570
+ * MECHANICAL op provenance (Pyric Studio traffic attribution): the
571
+ * client that CONSTRUCTS this op declares who issued it. The host maps
572
+ * it onto the unified event stream's `actor` field (`{ kind: 'studio' }`)
573
+ * via the sandbox's ambient-provenance window, so Traffic can filter
574
+ * Studio's own viewer/editor noise out of the app's stream.
575
+ *
576
+ * Declared at the issuing call site, never inferred: Studio's worker
577
+ * client stamps it on every op it builds (see `setOpIssuer` in
578
+ * `client.ts`); the bridge relay (`relayWorkerOp`) clears that issuer and
579
+ * marks remote frames separately, so a user's own admin-SDK traffic
580
+ * through the remote bridge — which also rides this port when Studio
581
+ * holds the peer slot — is never mislabeled as Studio's. Additive:
582
+ * existing senders omit it.
583
+ */
584
+ issuer?: 'studio';
585
+ /** Marks traffic relayed from a remote Node/agent consumer, never page app activity. */
586
+ relaySource?: 'remote';
587
+ };
588
+
589
+ /**
590
+ * Wire form of the broker's `deliver` spec (`messaging.deliver`) — the
591
+ * headless stand-in for "a push arrives". Mirrors
592
+ * `MessagingBroker.deliver`'s parameter exactly; plain JSON throughout.
593
+ */
594
+ export interface MessagingDeliverSpec {
595
+ /**
596
+ * Simulated visibility of THIS port's window client at delivery time — the
597
+ * transport twin of the in-page driver's `DeliverSpec.visibilityState`. When
598
+ * present the host sets the delivering port's client visibility before
599
+ * routing (`visible` → foreground/`onMessage`, `hidden` →
600
+ * background/`onBackgroundMessage`); absent leaves the last-reported
601
+ * visibility untouched.
602
+ */
603
+ visibilityState?: ClientVisibilityState;
604
+ data?: Record<string, string>;
605
+ notification?: { title?: string; body?: string; image?: string };
606
+ from?: string;
607
+ messageId?: string;
608
+ }
609
+
610
+ // ─── Subscription messages (client → worker) ─────────────────────────────
611
+
612
+ /** Register a Firestore snapshot listener for a doc or query. The worker
613
+ * fires `{ t:'snap', subId, value }` immediately (initial) and on each
614
+ * update. */
615
+ export interface FirestoreSubMessage {
616
+ t: 'sub';
617
+ subId: string;
618
+ target: TargetDescriptor;
619
+ /**
620
+ * Per-subscription auth lens (Pyric Studio F4 — "watch as user"). Mirrors
621
+ * the per-op `actAs` on {@link OpMessage}: `{ mode: 'as', uid }` registers the
622
+ * listener through the impersonation data handle so the snapshot's initial
623
+ * fire AND every re-eval evaluate security rules AS that uid; `{ mode: 'admin' }`
624
+ * watches through the rule-bypass handle; `{ mode: 'anon' }` watches genuinely
625
+ * unauthenticated (`withAuth(null)`); absent / `{ mode: 'app-session' }`
626
+ * watches as the app's own session (the unchanged default).
627
+ *
628
+ * The host resolves the listener's data handle from this via the SAME
629
+ * `lensDb` path ops use (`host.ts`), so "re-run a denied watch as the user who
630
+ * hit it" reuses the impersonation seam. Additive: existing senders omit it,
631
+ * so the wire message is byte-identical and existing subs don't regress.
632
+ */
633
+ actAs?: AuthLens;
634
+ /** Mechanical op provenance — see the field's doc on {@link OpMessage}.
635
+ * Tags the listener's REGISTRATION events (the initial rules eval); the
636
+ * listener's deferred re-evals stay attributed to the app (they fire on
637
+ * the microtask drain, outside any provenance window). */
638
+ issuer?: 'studio';
639
+ /** Marks traffic relayed from a remote Node/agent consumer, never page app activity. */
640
+ relaySource?: 'remote';
641
+ }
642
+
643
+ /**
644
+ * Register an AUTH listener (cross-tab auth — the headline of Phase 2).
645
+ *
646
+ * `target: 'authState'` mirrors `onAuthStateChanged`; `target: 'idToken'`
647
+ * mirrors `onIdTokenChanged`. The worker registers ONE real sandbox auth
648
+ * listener and fans out the new current user to EVERY subscribed port —
649
+ * so a sign-in on any tab updates every tab live. The `snap.value` is a
650
+ * `SerializedUser | null` (signed-out → null).
651
+ */
652
+ export interface AuthSubMessage {
653
+ t: 'sub';
654
+ subId: string;
655
+ target: 'authState' | 'idToken';
656
+ }
657
+
658
+ /**
659
+ * Subscribe to the sandbox's unified cross-service EVENT STREAM (Pyric Studio
660
+ * keystone — Action Center / traffic / rules-debug denial feed).
661
+ *
662
+ * `target: 'events'` mirrors `sandbox.onEvent(cb)`. On subscribe the host
663
+ * immediately delivers `sandbox.history()` (every event so far) as ONE
664
+ * `{ t:'event', subId, events: [...] }` batch, then streams each subsequent
665
+ * `SandboxEvent` as a single-element batch. Multiple Studio/app ports may
666
+ * subscribe; the host owns the ONE sandbox + ONE `onEvent` subscription and
667
+ * fans out to every subscribed port (same pattern as auth fan-out).
668
+ *
669
+ * Every `SandboxEvent` is plain JSON (provenance-stamped, marker-shaped doc
670
+ * data — no class instances), so it structured-clones across the port verbatim;
671
+ * no codec round-trip is needed (unlike Firestore doc data).
672
+ */
673
+ export interface EventSubMessage {
674
+ t: 'sub';
675
+ subId: string;
676
+ target: 'events';
677
+ }
678
+
679
+ export interface RtdbValueSubMessage {
680
+ t: 'sub';
681
+ subId: string;
682
+ target: { service: 'rtdb'; path: string };
683
+ /** Mirrors Firestore subscriptions: absent/app-session uses this port's session. */
684
+ actAs?: AuthLens;
685
+ /** Mechanical op provenance — see the field's doc on {@link OpMessage}. */
686
+ issuer?: 'studio';
687
+ /** Marks traffic relayed from a remote Node/agent consumer. */
688
+ relaySource?: 'remote';
689
+ }
690
+
691
+ /**
692
+ * Stream a `generateContent` call as a SUBSCRIPTION (cdd-deltas #98.3:
693
+ * "chunks over the existing subscription/event mechanism"). Unlike the
694
+ * persistent listeners above, this sub is FINITE — it AUTO-UNSUBSCRIBES on a
695
+ * terminal `done` snap:
696
+ *
697
+ * { t:'snap', subId, value: { chunk } } — one per streamed WireChunk,
698
+ * delivered in order
699
+ * { t:'snap', subId, value: { done: true } } — terminal; the host has
700
+ * already dropped the sub
701
+ * { t:'snap', subId, value: { __error } } — terminal failure (the shared
702
+ * snap-error convention)
703
+ *
704
+ * A client MAY still send `unsub` (early consumer abandonment); the host
705
+ * treats an unknown subId as a no-op, so the done/unsub race is benign.
706
+ */
707
+ export interface AiStreamSubMessage {
708
+ t: 'sub';
709
+ subId: string;
710
+ target: { service: 'ai'; op: 'streamGenerateContent' };
711
+ /** Model resource, as on the `ai.generateContent` op. */
712
+ model: string;
713
+ /** Plain Gemini-wire GenerateContentRequest JSON. */
714
+ request: Record<string, unknown>;
715
+ /** First-op engine config — see {@link AiEngineConfigWire}. */
716
+ engine?: AiEngineConfigWire;
717
+ }
718
+
719
+ /**
720
+ * Register a MESSAGING delivery listener (the receive plane crossing the
721
+ * transport — host-capability gated like the `messaging.*` ops).
722
+ *
723
+ * `target: 'messaging.foreground'` mirrors the client mirror's `onMessage`;
724
+ * `target: 'messaging.background'` mirrors the sw mirror's
725
+ * `onBackgroundMessage`. The host registers ONE real broker handler per sub
726
+ * and forwards each `DeliveredPayload` (plain JSON — no codec needed) as a
727
+ * `{ t:'snap', subId, value }` to the subscribing port. Which of the two
728
+ * targets fires for a given delivery is the broker's captured visibility
729
+ * rule — see the `messaging.setVisibility` op.
730
+ */
731
+ export interface MessagingSubMessage {
732
+ t: 'sub';
733
+ subId: string;
734
+ target: 'messaging.foreground' | 'messaging.background';
735
+ }
736
+
737
+ /**
738
+ * Subscribe to connected-page presence (#227). On subscribe the host delivers
739
+ * the current {@link PresenceSnapshot} as `{ t:'snap', subId, value }`, then
740
+ * re-snaps on every registry change (register / heartbeat / update /
741
+ * disconnect / lease expiry). Studio renders the worker's snapshot — it does
742
+ * not implement a second client-side expiry policy.
743
+ */
744
+ export interface PresenceSubMessage {
745
+ t: 'sub';
746
+ subId: string;
747
+ target: 'presence';
748
+ }
749
+
750
+ /** Logical page kind for presence (#227). */
751
+ export type PresenceClientKind = 'app' | 'studio';
752
+
753
+ /** Page Visibility API state carried on presence records. */
754
+ export type PresenceVisibility = 'visible' | 'hidden';
755
+
756
+ /** One logical connected page in a presence snapshot. */
757
+ export interface PresenceClientRecord {
758
+ clientId: string;
759
+ kind: PresenceClientKind;
760
+ route: string;
761
+ visibility: PresenceVisibility;
762
+ /** Epoch ms when this clientId first registered in this worker lifetime. */
763
+ connectedAt: number;
764
+ /** Epoch ms of the most recent register / heartbeat / update. */
765
+ lastSeen: number;
766
+ }
767
+
768
+ /** Authoritative presence snapshot owned by the SharedWorker host. */
769
+ export interface PresenceSnapshot {
770
+ clients: PresenceClientRecord[];
771
+ }
772
+
773
+ export type SubMessage =
774
+ | FirestoreSubMessage
775
+ | AuthSubMessage
776
+ | EventSubMessage
777
+ | RtdbValueSubMessage
778
+ | AiStreamSubMessage
779
+ | MessagingSubMessage
780
+ | PresenceSubMessage;
781
+
782
+ /** Type guard: is this an auth subscription (vs a Firestore / event one)? */
783
+ export function isAuthSub(msg: SubMessage): msg is AuthSubMessage {
784
+ return msg.target === 'authState' || msg.target === 'idToken';
785
+ }
786
+
787
+ /** Type guard: is this an event-stream subscription? */
788
+ export function isEventSub(msg: SubMessage): msg is EventSubMessage {
789
+ return msg.target === 'events';
790
+ }
791
+
792
+ /** Type guard: is this a messaging delivery subscription? */
793
+ export function isMessagingSub(msg: SubMessage): msg is MessagingSubMessage {
794
+ return msg.target === 'messaging.foreground' || msg.target === 'messaging.background';
795
+ }
796
+
797
+ /** Type guard: is this a connected-page presence subscription? */
798
+ export function isPresenceSub(msg: SubMessage): msg is PresenceSubMessage {
799
+ return msg.target === 'presence';
800
+ }
801
+
802
+ export function isRtdbSub(msg: SubMessage): msg is RtdbValueSubMessage {
803
+ return (
804
+ typeof msg.target === 'object' &&
805
+ msg.target !== null &&
806
+ 'service' in msg.target &&
807
+ msg.target.service === 'rtdb'
808
+ );
809
+ }
810
+
811
+ /** Type guard: is this an AI stream subscription (finite, auto-unsubs on done)? */
812
+ export function isAiSub(msg: SubMessage): msg is AiStreamSubMessage {
813
+ return (
814
+ typeof msg.target === 'object' &&
815
+ msg.target !== null &&
816
+ 'service' in msg.target &&
817
+ msg.target.service === 'ai'
818
+ );
819
+ }
820
+
821
+ /** Tear down a previously registered snapshot listener. */
822
+ export interface UnsubMessage {
823
+ t: 'unsub';
824
+ subId: string;
825
+ }
826
+
827
+ /** Explicit app-port teardown; MessagePort close events are unreliable in Chrome. */
828
+ export interface DisconnectMessage {
829
+ t: 'disconnect';
830
+ id: string;
831
+ }
832
+
833
+ /**
834
+ * Bind an app-owned port to the worker's one Firebase configuration.
835
+ *
836
+ * This control frame is deliberately response-free: it is posted immediately
837
+ * after opening the port, and MessagePort FIFO ordering guarantees the worker
838
+ * evaluates it before any service op/sub posted through that port. A conflict
839
+ * tombstones the port; subsequent operations receive
840
+ * `app/multiple-configs-not-supported` instead of touching the shared backend.
841
+ */
842
+ export interface AppConfigMessage {
843
+ t: 'appConfig';
844
+ options: Record<string, unknown>;
845
+ }
846
+
847
+ /**
848
+ * Agent tool-call, forwarded by the bridge peer to the worker so the agent
849
+ * executes against the SAME sandbox the app + Studio use (no separate in-page
850
+ * backend). The worker host runs the canonical sandbox tool dispatcher and
851
+ * replies with a `ResMessage` whose `value` is the `{ ok, summary, data }`
852
+ * dispatch result. Keeps app, Studio, and agent on one authoritative instance.
853
+ */
854
+ export interface ToolMessage {
855
+ t: 'tool';
856
+ id: string;
857
+ name: string;
858
+ args: Record<string, unknown>;
859
+ }
860
+
861
+ export type InboundMessage =
862
+ | OpMessage
863
+ | SubMessage
864
+ | UnsubMessage
865
+ | DisconnectMessage
866
+ | AppConfigMessage
867
+ | ToolMessage;
868
+
869
+ // ─── Worker → client messages ─────────────────────────────────────────────
870
+
871
+ /**
872
+ * Reply to a one-shot op. `value` is present on success; `error` on failure.
873
+ *
874
+ * `value` shapes by method:
875
+ * getDoc → { id, path, exists, data?: SerializedDocData }
876
+ * getDocs → { docs: Array<{ id, path, data: SerializedDocData }> }
877
+ * setDoc / updateDoc / deleteDoc / setRules → null (ack)
878
+ * addDoc → { id, path } (the minted document id + full path)
879
+ * count → { count: number }
880
+ * batchCommit / txnCommit → null (ack)
881
+ */
882
+ export type ResMessage =
883
+ | { t: 'res'; id: string; ok: true; value: unknown }
884
+ | { t: 'res'; id: string; ok: false; error: SerializedError };
885
+
886
+ /**
887
+ * Streamed snapshot delivery. `value` is the same shape as `getDoc`/`getDocs`
888
+ * result above but always present (not null) — listeners always fire with data.
889
+ *
890
+ * For a doc listener: `{ id, path, exists, data?: SerializedDocData }`
891
+ * For a query listener: `{ docs: Array<{ id, path, data: SerializedDocData }> }`
892
+ */
893
+ export interface SnapMessage {
894
+ t: 'snap';
895
+ subId: string;
896
+ value: unknown;
897
+ }
898
+
899
+ /**
900
+ * Streamed delivery for an event-stream subscription ({@link EventSubMessage}).
901
+ *
902
+ * Carries a BATCH of `SandboxEvent`s for the given `subId`:
903
+ * - the FIRST message after subscribe is the initial `sandbox.history()`
904
+ * snapshot (possibly empty);
905
+ * - each subsequent message is a single live event (`events.length === 1`).
906
+ *
907
+ * A batch keeps the initial history one structured-clone instead of N, and lets
908
+ * the client fold history then stream uniformly. Events are plain JSON — no
909
+ * rehydration needed (see {@link EventSubMessage}).
910
+ */
911
+ export interface EventStreamMessage {
912
+ t: 'event';
913
+ subId: string;
914
+ events: readonly SandboxEvent[];
915
+ }
916
+
917
+ export type OutboundMessage = ResMessage | SnapMessage | EventStreamMessage;
918
+
919
+ // ─── Serialized document data ─────────────────────────────────────────────
920
+
921
+ /**
922
+ * Document data as it crosses the port: Timestamp/Bytes/LatLng/etc. are
923
+ * serialized to their JSON marker shapes so they survive structured clone,
924
+ * then rehydrated back to REAL class instances on the receiving side.
925
+ *
926
+ * Wire format == persistence format: we use the same codec the sandbox uses
927
+ * for IndexedDB persistence (`serializeSnapshot` / `deserializeSnapshot` in
928
+ * `pyric/sandbox`). Concretely:
929
+ *
930
+ * serialize: JSON.stringify(data) — each wrapper's toJSON() emits a
931
+ * marker shape ({ __type: 'timestamp', seconds, nanos } etc.)
932
+ * deserialize: JSON.parse → rehydrateDocValue — re-wraps every marker
933
+ * shape into a real class instance (Timestamp, Bytes, LatLng).
934
+ *
935
+ * WHY REAL INSTANCES MATTER:
936
+ * Consumer code does `snap.data().createdAt.toDate()`, `instanceof Timestamp`,
937
+ * `bytes.data`, etc. Plain marker objects don't have those methods/prototype.
938
+ * The persistence serializer's `rehydrateDocValue` is the canonical codec that
939
+ * the sandbox already relies on — reusing it here ensures the wire format and
940
+ * the persistence format are identical and don't drift.
941
+ *
942
+ * WHY JSON AND NOT STRUCTURED CLONE FOR DATA:
943
+ * Structured clone can transfer Uint8Arrays, but class instances of pyric's
944
+ * Timestamp/Bytes/LatLng are NOT in the structured clone spec — they'd arrive
945
+ * as plain objects and lose their prototype chain, breaking instanceof checks.
946
+ */
947
+ export interface SerializedDocData {
948
+ /** JSON string of the document data (Timestamp/Bytes/LatLng serialized via toJSON). */
949
+ json: string;
950
+ }
951
+
952
+ /**
953
+ * Serialize document data to cross-port form.
954
+ *
955
+ * Uses `JSON.stringify` which calls `toJSON()` on each Timestamp, Bytes,
956
+ * LatLng, etc., producing the canonical marker shapes. This is identical to
957
+ * what `serializeSnapshot` does for the Firestore state before IDB writes.
958
+ */
959
+ export function serializeDocData(data: Record<string, unknown>): SerializedDocData {
960
+ return { json: JSON.stringify(data) };
961
+ }
962
+
963
+ /**
964
+ * Deserialize document data from cross-port form.
965
+ *
966
+ * Parses the JSON string and walks the result with `rehydrateDocValue`
967
+ * (from `pyric/sandbox`) to restore REAL class instances — the same codec
968
+ * `deserializeSnapshot` uses when restoring from IDB. After this call,
969
+ * Timestamp values are real `Timestamp` instances with `.seconds`/`.nanos`,
970
+ * Bytes values have `.data` (Uint8Array), LatLng values have `.lat`/`.lng`.
971
+ *
972
+ * Disambiguation: a plain user object `{ seconds: 1, nanoseconds: 0 }` is
973
+ * NOT mistaken for a Timestamp because the marker-based codec requires the
974
+ * `__type: 'timestamp'` discriminator emitted by `Timestamp.toJSON()`. Raw
975
+ * plain objects that happen to have numeric fields are passed through as-is.
976
+ */
977
+ export function deserializeDocData(serialized: SerializedDocData): unknown {
978
+ return rehydrateDocValue(JSON.parse(serialized.json));
979
+ }
980
+
981
+ // ─── Storage byte payloads (base64 + size cap) ────────────────────────────
982
+
983
+ /**
984
+ * Maximum RAW byte size a single storage op may carry (`storage.putBytes`
985
+ * payloads and `storage.getBytes` results). 8 MiB raw ≈ 11 MiB base64 —
986
+ * comfortably under `ws`'s 100 MiB default `maxPayload` while keeping the
987
+ * four-hop whole-object buffering (Node → bridge → page → worker and back)
988
+ * sane. Enforced on BOTH ends: the Node conveniences / pyric-admin remote
989
+ * arm reject before sending, and the worker host rejects oversized inputs
990
+ * and results so a big browser-side object can't blow up the relay. Bigger
991
+ * objects need the (unshipped) streaming story — do not raise the cap.
992
+ */
993
+ export const MAX_STORAGE_OP_BYTES = 8 * 1024 * 1024;
994
+
995
+ /** Base64 length ceiling for a payload within {@link MAX_STORAGE_OP_BYTES} —
996
+ * a cheap pre-decode gate so an oversized `dataB64` is rejected without
997
+ * materializing its bytes first. */
998
+ export const MAX_STORAGE_OP_B64_LENGTH = Math.ceil(MAX_STORAGE_OP_BYTES / 3) * 4;
999
+
1000
+ /** Build the canonical over-cap error (`code: 'payload-too-large'`). */
1001
+ export function storagePayloadTooLarge(
1002
+ sizeBytes: number,
1003
+ what: string,
1004
+ ): Error & { code: string } {
1005
+ const err = new Error(
1006
+ `${what} is ${sizeBytes} bytes — over the ${MAX_STORAGE_OP_BYTES / (1024 * 1024)} MiB ` +
1007
+ 'storage op cap (MAX_STORAGE_OP_BYTES). Streaming/resumable transfers are not ' +
1008
+ 'supported on the sandbox backend; split the object or keep it under the cap.',
1009
+ ) as Error & { code: string };
1010
+ err.code = 'payload-too-large';
1011
+ return err;
1012
+ }
1013
+
1014
+ /**
1015
+ * Encode bytes to standard base64. Chunked `String.fromCharCode` so a
1016
+ * multi-MiB payload doesn't overflow the argument-spread limit. `btoa` is
1017
+ * available in browsers, workers, Node ≥ 16, and Bun.
1018
+ */
1019
+ export function bytesToBase64(bytes: Uint8Array): string {
1020
+ let binary = '';
1021
+ const CHUNK = 0x8000;
1022
+ for (let i = 0; i < bytes.length; i += CHUNK) {
1023
+ const slice = bytes.subarray(i, i + CHUNK);
1024
+ binary += String.fromCharCode.apply(null, slice as unknown as number[]);
1025
+ }
1026
+ return btoa(binary);
1027
+ }
1028
+
1029
+ /** Decode standard base64 to bytes (inverse of {@link bytesToBase64}). */
1030
+ export function base64ToBytes(b64: string): Uint8Array {
1031
+ const binary = atob(b64);
1032
+ const out = new Uint8Array(binary.length);
1033
+ for (let i = 0; i < binary.length; i++) out[i] = binary.charCodeAt(i);
1034
+ return out;
1035
+ }
1036
+
1037
+ // ─── Error serialization ──────────────────────────────────────────────────
1038
+
1039
+ /**
1040
+ * Wire form of a thrown error. `denialContext` (spike gap 6) is the
1041
+ * structured "why did this deny" frame `SandboxError` carries on
1042
+ * `permission-denied` — plain JSON end to end (rule line/expression, auth
1043
+ * state, simulator reasons, eval-time request shape), so it survives both
1044
+ * structured clone AND the JSON WS relay legs verbatim. Receivers re-attach
1045
+ * it to the reconstructed error so remote `SandboxError`s match local ones.
1046
+ */
1047
+ export interface SerializedError {
1048
+ code: string;
1049
+ message: string;
1050
+ denialContext?: DenialContext;
1051
+ /**
1052
+ * The Gemini wire error envelope when the thrown value was an
1053
+ * `AiBrokerError` (pyric/ai) — plain JSON end to end, so the client
1054
+ * mirror can mint the SAME `AIError('fetch-error', …)` decoration the
1055
+ * in-process plane applies. `code` is `ai/<STATUS>` in that case.
1056
+ */
1057
+ aiEnvelope?: AiErrorEnvelopeWire;
1058
+ /**
1059
+ * Messaging send-plane rejection: the broker's captured google.rpc
1060
+ * envelope, carried VERBATIM (the seam doc: rejections cross the wire as
1061
+ * the `BrokerSendError.envelope` value — plain JSON, structured-clone-
1062
+ * safe). Present only on `messaging.*` op failures whose cause is a
1063
+ * `BrokerSendError`; a client mirror rebuilds the typed error from it.
1064
+ */
1065
+ envelope?: FcmErrorEnvelope;
1066
+ }
1067
+
1068
+ /**
1069
+ * Serialize any thrown value to a `{ code, message, denialContext? }` shape
1070
+ * suitable for structured-clone across the MessagePort (and the JSON relay).
1071
+ *
1072
+ * SandboxError (from pyric/sandbox) carries `.code` (e.g. 'permission-denied')
1073
+ * and `.message`, plus `.denialContext` on rule denials — carried through
1074
+ * whenever present. All other errors get `code: 'unknown'`. Plain strings get
1075
+ * `code: 'unknown'` and `message: String(err)`.
1076
+ *
1077
+ * Class instances don't survive structured clone as their original class —
1078
+ * the receiver sees a plain object. We normalize so the client can
1079
+ * reconstruct a typed error with `.code` (and `.denialContext`) attached.
1080
+ */
1081
+ export function serializeError(err: unknown): SerializedError {
1082
+ if (err !== null && typeof err === 'object') {
1083
+ // AiBrokerError (pyric/ai): detected STRUCTURALLY (the class is not
1084
+ // exported from `pyric/ai`'s public surface) by the wire envelope it
1085
+ // carries. The envelope rides whole so the receiving mirror re-mints the
1086
+ // exact SDK error; `code` is synthesized from the wire `status`.
1087
+ const envelope = (err as { envelope?: AiErrorEnvelopeWire }).envelope;
1088
+ if (
1089
+ envelope !== null &&
1090
+ typeof envelope === 'object' &&
1091
+ typeof envelope.error?.code === 'number' &&
1092
+ typeof envelope.error?.message === 'string' &&
1093
+ typeof envelope.error?.status === 'string'
1094
+ ) {
1095
+ return {
1096
+ code: `ai/${envelope.error.status}`,
1097
+ message: envelope.error.message,
1098
+ aiEnvelope: envelope,
1099
+ };
1100
+ }
1101
+ const e = err as { code?: unknown; message?: unknown; denialContext?: unknown };
1102
+ if (typeof e.code === 'string' && typeof e.message === 'string') {
1103
+ return e.denialContext !== null && typeof e.denialContext === 'object'
1104
+ ? { code: e.code, message: e.message, denialContext: e.denialContext as DenialContext }
1105
+ : { code: e.code, message: e.message };
1106
+ }
1107
+ if (err instanceof Error) {
1108
+ return { code: 'unknown', message: err.message };
1109
+ }
1110
+ }
1111
+ return { code: 'unknown', message: String(err) };
1112
+ }