pyric 0.0.1 → 0.1.0-alpha.7

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 (786) hide show
  1. package/README.md +52 -1
  2. package/dist/app/index.d.ts +81 -0
  3. package/dist/app/index.d.ts.map +1 -0
  4. package/dist/app/index.js +78 -0
  5. package/dist/app/index.js.map +1 -0
  6. package/dist/auth/index.d.ts +346 -0
  7. package/dist/auth/index.d.ts.map +1 -0
  8. package/dist/auth/index.js +701 -0
  9. package/dist/auth/index.js.map +1 -0
  10. package/dist/auth/prod-backend.d.ts +39 -0
  11. package/dist/auth/prod-backend.d.ts.map +1 -0
  12. package/dist/auth/prod-backend.js +179 -0
  13. package/dist/auth/prod-backend.js.map +1 -0
  14. package/dist/auth/providers.d.ts +107 -0
  15. package/dist/auth/providers.d.ts.map +1 -0
  16. package/dist/auth/providers.js +138 -0
  17. package/dist/auth/providers.js.map +1 -0
  18. package/dist/auth/sandbox-backend.d.ts +769 -0
  19. package/dist/auth/sandbox-backend.d.ts.map +1 -0
  20. package/dist/auth/sandbox-backend.js +1598 -0
  21. package/dist/auth/sandbox-backend.js.map +1 -0
  22. package/dist/auth/target.d.ts +40 -0
  23. package/dist/auth/target.d.ts.map +1 -0
  24. package/dist/auth/target.js +28 -0
  25. package/dist/auth/target.js.map +1 -0
  26. package/dist/auth/types.d.ts +252 -0
  27. package/dist/auth/types.d.ts.map +1 -0
  28. package/dist/auth/types.js +26 -0
  29. package/dist/auth/types.js.map +1 -0
  30. package/dist/database/constraints/atoms.d.ts +29 -0
  31. package/dist/database/constraints/atoms.d.ts.map +1 -0
  32. package/dist/database/constraints/atoms.js +43 -0
  33. package/dist/database/constraints/atoms.js.map +1 -0
  34. package/dist/database/constraints/compose.d.ts +16 -0
  35. package/dist/database/constraints/compose.d.ts.map +1 -0
  36. package/dist/database/constraints/compose.js +15 -0
  37. package/dist/database/constraints/compose.js.map +1 -0
  38. package/dist/database/constraints/data.d.ts +30 -0
  39. package/dist/database/constraints/data.d.ts.map +1 -0
  40. package/dist/database/constraints/data.js +46 -0
  41. package/dist/database/constraints/data.js.map +1 -0
  42. package/dist/database/constraints/document.d.ts +42 -0
  43. package/dist/database/constraints/document.d.ts.map +1 -0
  44. package/dist/database/constraints/document.js +86 -0
  45. package/dist/database/constraints/document.js.map +1 -0
  46. package/dist/database/constraints/game.d.ts +30 -0
  47. package/dist/database/constraints/game.d.ts.map +1 -0
  48. package/dist/database/constraints/game.js +49 -0
  49. package/dist/database/constraints/game.js.map +1 -0
  50. package/dist/database/constraints/index.d.ts +12 -0
  51. package/dist/database/constraints/index.d.ts.map +1 -0
  52. package/dist/database/constraints/index.js +9 -0
  53. package/dist/database/constraints/index.js.map +1 -0
  54. package/dist/database/constraints/policies.d.ts +16 -0
  55. package/dist/database/constraints/policies.d.ts.map +1 -0
  56. package/dist/database/constraints/policies.js +17 -0
  57. package/dist/database/constraints/policies.js.map +1 -0
  58. package/dist/database/constraints/ruleset.d.ts +5 -0
  59. package/dist/database/constraints/ruleset.d.ts.map +1 -0
  60. package/dist/database/constraints/ruleset.js +132 -0
  61. package/dist/database/constraints/ruleset.js.map +1 -0
  62. package/dist/database/constraints/schema.d.ts +17 -0
  63. package/dist/database/constraints/schema.d.ts.map +1 -0
  64. package/dist/database/constraints/schema.js +74 -0
  65. package/dist/database/constraints/schema.js.map +1 -0
  66. package/dist/database/constraints/types.d.ts +22 -0
  67. package/dist/database/constraints/types.d.ts.map +1 -0
  68. package/dist/database/constraints/types.js +2 -0
  69. package/dist/database/constraints/types.js.map +1 -0
  70. package/dist/database/crawl/handler.d.ts +7 -0
  71. package/dist/database/crawl/handler.d.ts.map +1 -0
  72. package/dist/database/crawl/handler.js +91 -0
  73. package/dist/database/crawl/handler.js.map +1 -0
  74. package/dist/database/crawl/semaphore.d.ts +9 -0
  75. package/dist/database/crawl/semaphore.d.ts.map +1 -0
  76. package/dist/database/crawl/semaphore.js +27 -0
  77. package/dist/database/crawl/semaphore.js.map +1 -0
  78. package/dist/database/crawl/spec.d.ts +33 -0
  79. package/dist/database/crawl/spec.d.ts.map +1 -0
  80. package/dist/database/crawl/spec.js +12 -0
  81. package/dist/database/crawl/spec.js.map +1 -0
  82. package/dist/database/data/handler.d.ts +9 -0
  83. package/dist/database/data/handler.d.ts.map +1 -0
  84. package/dist/database/data/handler.js +96 -0
  85. package/dist/database/data/handler.js.map +1 -0
  86. package/dist/database/data/spec.d.ts +77 -0
  87. package/dist/database/data/spec.d.ts.map +1 -0
  88. package/dist/database/data/spec.js +17 -0
  89. package/dist/database/data/spec.js.map +1 -0
  90. package/dist/database/data/validated.d.ts +8 -0
  91. package/dist/database/data/validated.d.ts.map +1 -0
  92. package/dist/database/data/validated.js +99 -0
  93. package/dist/database/data/validated.js.map +1 -0
  94. package/dist/database/grammar/RtdbExpr.ohm +98 -0
  95. package/dist/database/grammar/RtdbExpr.ohm.generated.d.ts +2 -0
  96. package/dist/database/grammar/RtdbExpr.ohm.generated.d.ts.map +1 -0
  97. package/dist/database/grammar/RtdbExpr.ohm.generated.js +103 -0
  98. package/dist/database/grammar/RtdbExpr.ohm.generated.js.map +1 -0
  99. package/dist/database/grammar/RtdbExprParser.d.ts +5 -0
  100. package/dist/database/grammar/RtdbExprParser.d.ts.map +1 -0
  101. package/dist/database/grammar/RtdbExprParser.js +53 -0
  102. package/dist/database/grammar/RtdbExprParser.js.map +1 -0
  103. package/dist/database/grammar/linter.d.ts +3 -0
  104. package/dist/database/grammar/linter.d.ts.map +1 -0
  105. package/dist/database/grammar/linter.js +66 -0
  106. package/dist/database/grammar/linter.js.map +1 -0
  107. package/dist/database/grammar/simulator.d.ts +31 -0
  108. package/dist/database/grammar/simulator.d.ts.map +1 -0
  109. package/dist/database/grammar/simulator.js +255 -0
  110. package/dist/database/grammar/simulator.js.map +1 -0
  111. package/dist/database/grammar/validator.d.ts +3 -0
  112. package/dist/database/grammar/validator.d.ts.map +1 -0
  113. package/dist/database/grammar/validator.js +84 -0
  114. package/dist/database/grammar/validator.js.map +1 -0
  115. package/dist/database/host.d.ts +50 -0
  116. package/dist/database/host.d.ts.map +1 -0
  117. package/dist/database/host.js +41 -0
  118. package/dist/database/host.js.map +1 -0
  119. package/dist/database/index.d.ts +40 -0
  120. package/dist/database/index.d.ts.map +1 -0
  121. package/dist/database/index.js +47 -0
  122. package/dist/database/index.js.map +1 -0
  123. package/dist/database/initialize-from-app.d.ts +28 -0
  124. package/dist/database/initialize-from-app.d.ts.map +1 -0
  125. package/dist/database/initialize-from-app.js +23 -0
  126. package/dist/database/initialize-from-app.js.map +1 -0
  127. package/dist/database/ir/handler.d.ts +6 -0
  128. package/dist/database/ir/handler.d.ts.map +1 -0
  129. package/dist/database/ir/handler.js +52 -0
  130. package/dist/database/ir/handler.js.map +1 -0
  131. package/dist/database/ir/spec.d.ts +25 -0
  132. package/dist/database/ir/spec.d.ts.map +1 -0
  133. package/dist/database/ir/spec.js +11 -0
  134. package/dist/database/ir/spec.js.map +1 -0
  135. package/dist/database/mapper.d.ts +9 -0
  136. package/dist/database/mapper.d.ts.map +1 -0
  137. package/dist/database/mapper.js +115 -0
  138. package/dist/database/mapper.js.map +1 -0
  139. package/dist/database/modular.d.ts +604 -0
  140. package/dist/database/modular.d.ts.map +1 -0
  141. package/dist/database/modular.js +1226 -0
  142. package/dist/database/modular.js.map +1 -0
  143. package/dist/database/replay.d.ts +33 -0
  144. package/dist/database/replay.d.ts.map +1 -0
  145. package/dist/database/replay.js +124 -0
  146. package/dist/database/replay.js.map +1 -0
  147. package/dist/database/resolver.d.ts +4 -0
  148. package/dist/database/resolver.d.ts.map +1 -0
  149. package/dist/database/resolver.js +66 -0
  150. package/dist/database/resolver.js.map +1 -0
  151. package/dist/database/sandbox/backend.d.ts +403 -0
  152. package/dist/database/sandbox/backend.d.ts.map +1 -0
  153. package/dist/database/sandbox/backend.js +1467 -0
  154. package/dist/database/sandbox/backend.js.map +1 -0
  155. package/dist/database/sandbox/data-tree.d.ts +123 -0
  156. package/dist/database/sandbox/data-tree.d.ts.map +1 -0
  157. package/dist/database/sandbox/data-tree.js +310 -0
  158. package/dist/database/sandbox/data-tree.js.map +1 -0
  159. package/dist/database/sandbox/normalize.d.ts +77 -0
  160. package/dist/database/sandbox/normalize.d.ts.map +1 -0
  161. package/dist/database/sandbox/normalize.js +165 -0
  162. package/dist/database/sandbox/normalize.js.map +1 -0
  163. package/dist/database/sandbox/push-id.d.ts +32 -0
  164. package/dist/database/sandbox/push-id.d.ts.map +1 -0
  165. package/dist/database/sandbox/push-id.js +82 -0
  166. package/dist/database/sandbox/push-id.js.map +1 -0
  167. package/dist/database/sandbox/query.d.ts +175 -0
  168. package/dist/database/sandbox/query.d.ts.map +1 -0
  169. package/dist/database/sandbox/query.js +269 -0
  170. package/dist/database/sandbox/query.js.map +1 -0
  171. package/dist/database/sandbox/rules-eval.d.ts +69 -0
  172. package/dist/database/sandbox/rules-eval.d.ts.map +1 -0
  173. package/dist/database/sandbox/rules-eval.js +124 -0
  174. package/dist/database/sandbox/rules-eval.js.map +1 -0
  175. package/dist/database/sandbox/sentinels.d.ts +71 -0
  176. package/dist/database/sandbox/sentinels.d.ts.map +1 -0
  177. package/dist/database/sandbox/sentinels.js +92 -0
  178. package/dist/database/sandbox/sentinels.js.map +1 -0
  179. package/dist/database/simulation/handler.d.ts +6 -0
  180. package/dist/database/simulation/handler.d.ts.map +1 -0
  181. package/dist/database/simulation/handler.js +236 -0
  182. package/dist/database/simulation/handler.js.map +1 -0
  183. package/dist/database/simulation/spec.d.ts +69 -0
  184. package/dist/database/simulation/spec.d.ts.map +1 -0
  185. package/dist/database/simulation/spec.js +25 -0
  186. package/dist/database/simulation/spec.js.map +1 -0
  187. package/dist/database/tools.d.ts +22 -0
  188. package/dist/database/tools.d.ts.map +1 -0
  189. package/dist/database/tools.js +272 -0
  190. package/dist/database/tools.js.map +1 -0
  191. package/dist/database/types.d.ts +210 -0
  192. package/dist/database/types.d.ts.map +1 -0
  193. package/dist/database/types.js +26 -0
  194. package/dist/database/types.js.map +1 -0
  195. package/dist/database/write/handler.d.ts +7 -0
  196. package/dist/database/write/handler.d.ts.map +1 -0
  197. package/dist/database/write/handler.js +42 -0
  198. package/dist/database/write/handler.js.map +1 -0
  199. package/dist/database/write/spec.d.ts +18 -0
  200. package/dist/database/write/spec.d.ts.map +1 -0
  201. package/dist/database/write/spec.js +7 -0
  202. package/dist/database/write/spec.js.map +1 -0
  203. package/dist/firestore/index.d.ts +533 -0
  204. package/dist/firestore/index.d.ts.map +1 -0
  205. package/dist/firestore/index.js +1536 -0
  206. package/dist/firestore/index.js.map +1 -0
  207. package/dist/firestore/tools.d.ts +67 -0
  208. package/dist/firestore/tools.d.ts.map +1 -0
  209. package/dist/firestore/tools.js +318 -0
  210. package/dist/firestore/tools.js.map +1 -0
  211. package/dist/firestore-values/index.d.ts +63 -0
  212. package/dist/firestore-values/index.d.ts.map +1 -0
  213. package/dist/firestore-values/index.js +148 -0
  214. package/dist/firestore-values/index.js.map +1 -0
  215. package/dist/project-scope.d.ts +5 -0
  216. package/dist/project-scope.d.ts.map +1 -0
  217. package/dist/project-scope.js +2 -0
  218. package/dist/project-scope.js.map +1 -0
  219. package/dist/rules/extract.d.ts +28 -0
  220. package/dist/rules/extract.d.ts.map +1 -0
  221. package/dist/rules/extract.js +25 -0
  222. package/dist/rules/extract.js.map +1 -0
  223. package/dist/rules/generators/assembler.d.ts +19 -0
  224. package/dist/rules/generators/assembler.d.ts.map +1 -0
  225. package/dist/rules/generators/assembler.js +292 -0
  226. package/dist/rules/generators/assembler.js.map +1 -0
  227. package/dist/rules/generators/expressions.d.ts +52 -0
  228. package/dist/rules/generators/expressions.d.ts.map +1 -0
  229. package/dist/rules/generators/expressions.js +117 -0
  230. package/dist/rules/generators/expressions.js.map +1 -0
  231. package/dist/rules/generators/grid.d.ts +22 -0
  232. package/dist/rules/generators/grid.d.ts.map +1 -0
  233. package/dist/rules/generators/grid.js +52 -0
  234. package/dist/rules/generators/grid.js.map +1 -0
  235. package/dist/rules/grammar/FirestoreAST.d.ts +129 -0
  236. package/dist/rules/grammar/FirestoreAST.d.ts.map +1 -0
  237. package/dist/rules/grammar/FirestoreAST.js +3 -0
  238. package/dist/rules/grammar/FirestoreAST.js.map +1 -0
  239. package/dist/rules/grammar/FirestoreAssembler.d.ts +5 -0
  240. package/dist/rules/grammar/FirestoreAssembler.d.ts.map +1 -0
  241. package/dist/rules/grammar/FirestoreAssembler.js +158 -0
  242. package/dist/rules/grammar/FirestoreAssembler.js.map +1 -0
  243. package/dist/rules/grammar/FirestoreParser.d.ts +46 -0
  244. package/dist/rules/grammar/FirestoreParser.d.ts.map +1 -0
  245. package/dist/rules/grammar/FirestoreParser.js +409 -0
  246. package/dist/rules/grammar/FirestoreParser.js.map +1 -0
  247. package/dist/rules/grammar/FirestoreRules.ohm +229 -0
  248. package/dist/rules/grammar/FirestoreRules.ohm.generated.d.ts +2 -0
  249. package/dist/rules/grammar/FirestoreRules.ohm.generated.d.ts.map +1 -0
  250. package/dist/rules/grammar/FirestoreRules.ohm.generated.js +234 -0
  251. package/dist/rules/grammar/FirestoreRules.ohm.generated.js.map +1 -0
  252. package/dist/rules/grammar/FirestoreValidator.d.ts +10 -0
  253. package/dist/rules/grammar/FirestoreValidator.d.ts.map +1 -0
  254. package/dist/rules/grammar/FirestoreValidator.js +482 -0
  255. package/dist/rules/grammar/FirestoreValidator.js.map +1 -0
  256. package/dist/rules/index.d.ts +67 -0
  257. package/dist/rules/index.d.ts.map +1 -0
  258. package/dist/rules/index.js +80 -0
  259. package/dist/rules/index.js.map +1 -0
  260. package/dist/rules/indexes/extract/annotation-collect.d.ts +42 -0
  261. package/dist/rules/indexes/extract/annotation-collect.d.ts.map +1 -0
  262. package/dist/rules/indexes/extract/annotation-collect.js +69 -0
  263. package/dist/rules/indexes/extract/annotation-collect.js.map +1 -0
  264. package/dist/rules/indexes/extract/annotations.d.ts +50 -0
  265. package/dist/rules/indexes/extract/annotations.d.ts.map +1 -0
  266. package/dist/rules/indexes/extract/annotations.js +144 -0
  267. package/dist/rules/indexes/extract/annotations.js.map +1 -0
  268. package/dist/rules/indexes/extract/ast.d.ts +54 -0
  269. package/dist/rules/indexes/extract/ast.d.ts.map +1 -0
  270. package/dist/rules/indexes/extract/ast.js +119 -0
  271. package/dist/rules/indexes/extract/ast.js.map +1 -0
  272. package/dist/rules/indexes/extract/classify.d.ts +41 -0
  273. package/dist/rules/indexes/extract/classify.d.ts.map +1 -0
  274. package/dist/rules/indexes/extract/classify.js +138 -0
  275. package/dist/rules/indexes/extract/classify.js.map +1 -0
  276. package/dist/rules/indexes/extract/composite.d.ts +38 -0
  277. package/dist/rules/indexes/extract/composite.d.ts.map +1 -0
  278. package/dist/rules/indexes/extract/composite.js +85 -0
  279. package/dist/rules/indexes/extract/composite.js.map +1 -0
  280. package/dist/rules/indexes/extract/dataflow.d.ts +45 -0
  281. package/dist/rules/indexes/extract/dataflow.d.ts.map +1 -0
  282. package/dist/rules/indexes/extract/dataflow.js +200 -0
  283. package/dist/rules/indexes/extract/dataflow.js.map +1 -0
  284. package/dist/rules/indexes/extract/enumerate.d.ts +41 -0
  285. package/dist/rules/indexes/extract/enumerate.d.ts.map +1 -0
  286. package/dist/rules/indexes/extract/enumerate.js +163 -0
  287. package/dist/rules/indexes/extract/enumerate.js.map +1 -0
  288. package/dist/rules/indexes/extract/extractor.d.ts +3 -0
  289. package/dist/rules/indexes/extract/extractor.d.ts.map +1 -0
  290. package/dist/rules/indexes/extract/extractor.js +274 -0
  291. package/dist/rules/indexes/extract/extractor.js.map +1 -0
  292. package/dist/rules/indexes/extract/types.d.ts +211 -0
  293. package/dist/rules/indexes/extract/types.d.ts.map +1 -0
  294. package/dist/rules/indexes/extract/types.js +14 -0
  295. package/dist/rules/indexes/extract/types.js.map +1 -0
  296. package/dist/rules/indexes/extractHandler.d.ts +16 -0
  297. package/dist/rules/indexes/extractHandler.d.ts.map +1 -0
  298. package/dist/rules/indexes/extractHandler.js +100 -0
  299. package/dist/rules/indexes/extractHandler.js.map +1 -0
  300. package/dist/rules/indexes/extractTool.d.ts +15 -0
  301. package/dist/rules/indexes/extractTool.d.ts.map +1 -0
  302. package/dist/rules/indexes/extractTool.js +55 -0
  303. package/dist/rules/indexes/extractTool.js.map +1 -0
  304. package/dist/rules/indexes/types.d.ts +71 -0
  305. package/dist/rules/indexes/types.d.ts.map +1 -0
  306. package/dist/rules/indexes/types.js +15 -0
  307. package/dist/rules/indexes/types.js.map +1 -0
  308. package/dist/rules/inspect/handler.d.ts +6 -0
  309. package/dist/rules/inspect/handler.d.ts.map +1 -0
  310. package/dist/rules/inspect/handler.js +144 -0
  311. package/dist/rules/inspect/handler.js.map +1 -0
  312. package/dist/rules/inspect/spec.d.ts +37 -0
  313. package/dist/rules/inspect/spec.d.ts.map +1 -0
  314. package/dist/rules/inspect/spec.js +2 -0
  315. package/dist/rules/inspect/spec.js.map +1 -0
  316. package/dist/rules/inspect/tools.d.ts +21 -0
  317. package/dist/rules/inspect/tools.d.ts.map +1 -0
  318. package/dist/rules/inspect/tools.js +21 -0
  319. package/dist/rules/inspect/tools.js.map +1 -0
  320. package/dist/rules/linter/ast-utils.d.ts +97 -0
  321. package/dist/rules/linter/ast-utils.d.ts.map +1 -0
  322. package/dist/rules/linter/ast-utils.js +491 -0
  323. package/dist/rules/linter/ast-utils.js.map +1 -0
  324. package/dist/rules/linter/hallucinations.d.ts +34 -0
  325. package/dist/rules/linter/hallucinations.d.ts.map +1 -0
  326. package/dist/rules/linter/hallucinations.js +334 -0
  327. package/dist/rules/linter/hallucinations.js.map +1 -0
  328. package/dist/rules/linter/linter.d.ts +84 -0
  329. package/dist/rules/linter/linter.d.ts.map +1 -0
  330. package/dist/rules/linter/linter.js +784 -0
  331. package/dist/rules/linter/linter.js.map +1 -0
  332. package/dist/rules/modules/resolver-browser.d.ts +41 -0
  333. package/dist/rules/modules/resolver-browser.d.ts.map +1 -0
  334. package/dist/rules/modules/resolver-browser.js +63 -0
  335. package/dist/rules/modules/resolver-browser.js.map +1 -0
  336. package/dist/rules/modules/resolver-core.d.ts +51 -0
  337. package/dist/rules/modules/resolver-core.d.ts.map +1 -0
  338. package/dist/rules/modules/resolver-core.js +338 -0
  339. package/dist/rules/modules/resolver-core.js.map +1 -0
  340. package/dist/rules/modules/resolver.d.ts +17 -0
  341. package/dist/rules/modules/resolver.d.ts.map +1 -0
  342. package/dist/rules/modules/resolver.js +49 -0
  343. package/dist/rules/modules/resolver.js.map +1 -0
  344. package/dist/rules/modules/stdlib/atomic.rules +52 -0
  345. package/dist/rules/modules/stdlib/atomic.test.json +152 -0
  346. package/dist/rules/modules/stdlib/auth.rules +7 -0
  347. package/dist/rules/modules/stdlib/auth.test.json +53 -0
  348. package/dist/rules/modules/stdlib/content.rules +50 -0
  349. package/dist/rules/modules/stdlib/content.test.json +238 -0
  350. package/dist/rules/modules/stdlib/counters.rules +41 -0
  351. package/dist/rules/modules/stdlib/counters.test.json +254 -0
  352. package/dist/rules/modules/stdlib/geometry.rules +43 -0
  353. package/dist/rules/modules/stdlib/geometry.test.json +200 -0
  354. package/dist/rules/modules/stdlib/joining.rules +49 -0
  355. package/dist/rules/modules/stdlib/joining.test.json +287 -0
  356. package/dist/rules/modules/stdlib/lifecycle.rules +51 -0
  357. package/dist/rules/modules/stdlib/lifecycle.test.json +288 -0
  358. package/dist/rules/modules/stdlib/lobby.rules +44 -0
  359. package/dist/rules/modules/stdlib/lobby.test.json +32 -0
  360. package/dist/rules/modules/stdlib/membership.rules +40 -0
  361. package/dist/rules/modules/stdlib/membership.test.json +107 -0
  362. package/dist/rules/modules/stdlib/spaces.rules +46 -0
  363. package/dist/rules/modules/stdlib/spaces.test.json +392 -0
  364. package/dist/rules/modules/stdlib/state.rules +28 -0
  365. package/dist/rules/modules/stdlib/state.test.json +55 -0
  366. package/dist/rules/modules/stdlib/timing.rules +20 -0
  367. package/dist/rules/modules/stdlib/timing.test.json +106 -0
  368. package/dist/rules/modules/stdlib/transitions.rules +34 -0
  369. package/dist/rules/modules/stdlib/transitions.test.json +86 -0
  370. package/dist/rules/modules/stdlib/turns.rules +27 -0
  371. package/dist/rules/modules/stdlib/turns.test.json +64 -0
  372. package/dist/rules/modules/stdlib/validation.rules +31 -0
  373. package/dist/rules/modules/stdlib/validation.test.json +184 -0
  374. package/dist/rules/modules/stdlib-content.d.ts +11 -0
  375. package/dist/rules/modules/stdlib-content.d.ts.map +1 -0
  376. package/dist/rules/modules/stdlib-content.js +590 -0
  377. package/dist/rules/modules/stdlib-content.js.map +1 -0
  378. package/dist/rules/node.d.ts +17 -0
  379. package/dist/rules/node.d.ts.map +1 -0
  380. package/dist/rules/node.js +21 -0
  381. package/dist/rules/node.js.map +1 -0
  382. package/dist/rules/rtdb.d.ts +28 -0
  383. package/dist/rules/rtdb.d.ts.map +1 -0
  384. package/dist/rules/rtdb.js +15 -0
  385. package/dist/rules/rtdb.js.map +1 -0
  386. package/dist/rules/simulator/evaluator.d.ts +179 -0
  387. package/dist/rules/simulator/evaluator.d.ts.map +1 -0
  388. package/dist/rules/simulator/evaluator.js +1221 -0
  389. package/dist/rules/simulator/evaluator.js.map +1 -0
  390. package/dist/rules/simulator/expression/eval-errors.d.ts +86 -0
  391. package/dist/rules/simulator/expression/eval-errors.d.ts.map +1 -0
  392. package/dist/rules/simulator/expression/eval-errors.js +68 -0
  393. package/dist/rules/simulator/expression/eval-errors.js.map +1 -0
  394. package/dist/rules/simulator/expression/evaluator.d.ts +43 -0
  395. package/dist/rules/simulator/expression/evaluator.d.ts.map +1 -0
  396. package/dist/rules/simulator/expression/evaluator.js +298 -0
  397. package/dist/rules/simulator/expression/evaluator.js.map +1 -0
  398. package/dist/rules/simulator/expression/lexer.d.ts +36 -0
  399. package/dist/rules/simulator/expression/lexer.d.ts.map +1 -0
  400. package/dist/rules/simulator/expression/lexer.js +318 -0
  401. package/dist/rules/simulator/expression/lexer.js.map +1 -0
  402. package/dist/rules/simulator/expression/parser.d.ts +39 -0
  403. package/dist/rules/simulator/expression/parser.d.ts.map +1 -0
  404. package/dist/rules/simulator/expression/parser.js +329 -0
  405. package/dist/rules/simulator/expression/parser.js.map +1 -0
  406. package/dist/rules/simulator/expression/types.d.ts +137 -0
  407. package/dist/rules/simulator/expression/types.d.ts.map +1 -0
  408. package/dist/rules/simulator/expression/types.js +90 -0
  409. package/dist/rules/simulator/expression/types.js.map +1 -0
  410. package/dist/rules/simulator/expression/walk-data.d.ts +52 -0
  411. package/dist/rules/simulator/expression/walk-data.d.ts.map +1 -0
  412. package/dist/rules/simulator/expression/walk-data.js +167 -0
  413. package/dist/rules/simulator/expression/walk-data.js.map +1 -0
  414. package/dist/rules/simulator/firestore-set.d.ts +25 -0
  415. package/dist/rules/simulator/firestore-set.d.ts.map +1 -0
  416. package/dist/rules/simulator/firestore-set.js +89 -0
  417. package/dist/rules/simulator/firestore-set.js.map +1 -0
  418. package/dist/rules/simulator/handler.d.ts +36 -0
  419. package/dist/rules/simulator/handler.d.ts.map +1 -0
  420. package/dist/rules/simulator/handler.js +583 -0
  421. package/dist/rules/simulator/handler.js.map +1 -0
  422. package/dist/rules/simulator/mapdiff.d.ts +29 -0
  423. package/dist/rules/simulator/mapdiff.d.ts.map +1 -0
  424. package/dist/rules/simulator/mapdiff.js +72 -0
  425. package/dist/rules/simulator/mapdiff.js.map +1 -0
  426. package/dist/rules/simulator/project-after-state.d.ts +33 -0
  427. package/dist/rules/simulator/project-after-state.d.ts.map +1 -0
  428. package/dist/rules/simulator/project-after-state.js +88 -0
  429. package/dist/rules/simulator/project-after-state.js.map +1 -0
  430. package/dist/rules/simulator/query-proof.d.ts +80 -0
  431. package/dist/rules/simulator/query-proof.d.ts.map +1 -0
  432. package/dist/rules/simulator/query-proof.js +142 -0
  433. package/dist/rules/simulator/query-proof.js.map +1 -0
  434. package/dist/rules/simulator/value-equality.d.ts +8 -0
  435. package/dist/rules/simulator/value-equality.d.ts.map +1 -0
  436. package/dist/rules/simulator/value-equality.js +40 -0
  437. package/dist/rules/simulator/value-equality.js.map +1 -0
  438. package/dist/rules/simulator/wrappers/base.d.ts +129 -0
  439. package/dist/rules/simulator/wrappers/base.d.ts.map +1 -0
  440. package/dist/rules/simulator/wrappers/base.js +90 -0
  441. package/dist/rules/simulator/wrappers/base.js.map +1 -0
  442. package/dist/rules/simulator/wrappers/bytes.d.ts +45 -0
  443. package/dist/rules/simulator/wrappers/bytes.d.ts.map +1 -0
  444. package/dist/rules/simulator/wrappers/bytes.js +130 -0
  445. package/dist/rules/simulator/wrappers/bytes.js.map +1 -0
  446. package/dist/rules/simulator/wrappers/duration.d.ts +76 -0
  447. package/dist/rules/simulator/wrappers/duration.d.ts.map +1 -0
  448. package/dist/rules/simulator/wrappers/duration.js +176 -0
  449. package/dist/rules/simulator/wrappers/duration.js.map +1 -0
  450. package/dist/rules/simulator/wrappers/float.d.ts +52 -0
  451. package/dist/rules/simulator/wrappers/float.d.ts.map +1 -0
  452. package/dist/rules/simulator/wrappers/float.js +88 -0
  453. package/dist/rules/simulator/wrappers/float.js.map +1 -0
  454. package/dist/rules/simulator/wrappers/latlng.d.ts +40 -0
  455. package/dist/rules/simulator/wrappers/latlng.d.ts.map +1 -0
  456. package/dist/rules/simulator/wrappers/latlng.js +100 -0
  457. package/dist/rules/simulator/wrappers/latlng.js.map +1 -0
  458. package/dist/rules/simulator/wrappers/path.d.ts +75 -0
  459. package/dist/rules/simulator/wrappers/path.d.ts.map +1 -0
  460. package/dist/rules/simulator/wrappers/path.js +158 -0
  461. package/dist/rules/simulator/wrappers/path.js.map +1 -0
  462. package/dist/rules/simulator/wrappers/reference.d.ts +82 -0
  463. package/dist/rules/simulator/wrappers/reference.d.ts.map +1 -0
  464. package/dist/rules/simulator/wrappers/reference.js +145 -0
  465. package/dist/rules/simulator/wrappers/reference.js.map +1 -0
  466. package/dist/rules/simulator/wrappers/timestamp.d.ts +86 -0
  467. package/dist/rules/simulator/wrappers/timestamp.d.ts.map +1 -0
  468. package/dist/rules/simulator/wrappers/timestamp.js +207 -0
  469. package/dist/rules/simulator/wrappers/timestamp.js.map +1 -0
  470. package/dist/rules/simulator/wrappers/vector.d.ts +43 -0
  471. package/dist/rules/simulator/wrappers/vector.d.ts.map +1 -0
  472. package/dist/rules/simulator/wrappers/vector.js +80 -0
  473. package/dist/rules/simulator/wrappers/vector.js.map +1 -0
  474. package/dist/rules/simulator-tools-impl.d.ts +64 -0
  475. package/dist/rules/simulator-tools-impl.d.ts.map +1 -0
  476. package/dist/rules/simulator-tools-impl.js +462 -0
  477. package/dist/rules/simulator-tools-impl.js.map +1 -0
  478. package/dist/rules/simulator.d.ts +20 -0
  479. package/dist/rules/simulator.d.ts.map +1 -0
  480. package/dist/rules/simulator.js +24 -0
  481. package/dist/rules/simulator.js.map +1 -0
  482. package/dist/rules/stdlib-modules.d.ts +73 -0
  483. package/dist/rules/stdlib-modules.d.ts.map +1 -0
  484. package/dist/rules/stdlib-modules.js +849 -0
  485. package/dist/rules/stdlib-modules.js.map +1 -0
  486. package/dist/rules/stdlib-tools.d.ts +19 -0
  487. package/dist/rules/stdlib-tools.d.ts.map +1 -0
  488. package/dist/rules/stdlib-tools.js +164 -0
  489. package/dist/rules/stdlib-tools.js.map +1 -0
  490. package/dist/rules/test/handler.d.ts +9 -0
  491. package/dist/rules/test/handler.d.ts.map +1 -0
  492. package/dist/rules/test/handler.js +124 -0
  493. package/dist/rules/test/handler.js.map +1 -0
  494. package/dist/rules/test/spec.d.ts +517 -0
  495. package/dist/rules/test/spec.d.ts.map +1 -0
  496. package/dist/rules/test/spec.js +227 -0
  497. package/dist/rules/test/spec.js.map +1 -0
  498. package/dist/rules/tools.d.ts +41 -0
  499. package/dist/rules/tools.d.ts.map +1 -0
  500. package/dist/rules/tools.js +124 -0
  501. package/dist/rules/tools.js.map +1 -0
  502. package/dist/rules/write/handler.d.ts +6 -0
  503. package/dist/rules/write/handler.d.ts.map +1 -0
  504. package/dist/rules/write/handler.js +126 -0
  505. package/dist/rules/write/handler.js.map +1 -0
  506. package/dist/rules/write/spec.d.ts +28 -0
  507. package/dist/rules/write/spec.d.ts.map +1 -0
  508. package/dist/rules/write/spec.js +2 -0
  509. package/dist/rules/write/spec.js.map +1 -0
  510. package/dist/sandbox/admin-compat.d.ts +20 -0
  511. package/dist/sandbox/admin-compat.d.ts.map +1 -0
  512. package/dist/sandbox/admin-compat.js +19 -0
  513. package/dist/sandbox/admin-compat.js.map +1 -0
  514. package/dist/sandbox/admin-firestore/error-translation.d.ts +86 -0
  515. package/dist/sandbox/admin-firestore/error-translation.d.ts.map +1 -0
  516. package/dist/sandbox/admin-firestore/error-translation.js +204 -0
  517. package/dist/sandbox/admin-firestore/error-translation.js.map +1 -0
  518. package/dist/sandbox/admin-firestore/index.d.ts +169 -0
  519. package/dist/sandbox/admin-firestore/index.d.ts.map +1 -0
  520. package/dist/sandbox/admin-firestore/index.js +456 -0
  521. package/dist/sandbox/admin-firestore/index.js.map +1 -0
  522. package/dist/sandbox/admin-firestore/remote.d.ts +97 -0
  523. package/dist/sandbox/admin-firestore/remote.d.ts.map +1 -0
  524. package/dist/sandbox/admin-firestore/remote.js +735 -0
  525. package/dist/sandbox/admin-firestore/remote.js.map +1 -0
  526. package/dist/sandbox/branches/index.d.ts +141 -0
  527. package/dist/sandbox/branches/index.d.ts.map +1 -0
  528. package/dist/sandbox/branches/index.js +327 -0
  529. package/dist/sandbox/branches/index.js.map +1 -0
  530. package/dist/sandbox/firestore/admin-compat/batch.d.ts +34 -0
  531. package/dist/sandbox/firestore/admin-compat/batch.d.ts.map +1 -0
  532. package/dist/sandbox/firestore/admin-compat/batch.js +73 -0
  533. package/dist/sandbox/firestore/admin-compat/batch.js.map +1 -0
  534. package/dist/sandbox/firestore/admin-compat/doc-ref.d.ts +38 -0
  535. package/dist/sandbox/firestore/admin-compat/doc-ref.d.ts.map +1 -0
  536. package/dist/sandbox/firestore/admin-compat/doc-ref.js +154 -0
  537. package/dist/sandbox/firestore/admin-compat/doc-ref.js.map +1 -0
  538. package/dist/sandbox/firestore/admin-compat/firestore.d.ts +45 -0
  539. package/dist/sandbox/firestore/admin-compat/firestore.d.ts.map +1 -0
  540. package/dist/sandbox/firestore/admin-compat/firestore.js +114 -0
  541. package/dist/sandbox/firestore/admin-compat/firestore.js.map +1 -0
  542. package/dist/sandbox/firestore/admin-compat/index.d.ts +45 -0
  543. package/dist/sandbox/firestore/admin-compat/index.d.ts.map +1 -0
  544. package/dist/sandbox/firestore/admin-compat/index.js +27 -0
  545. package/dist/sandbox/firestore/admin-compat/index.js.map +1 -0
  546. package/dist/sandbox/firestore/admin-compat/paths.d.ts +37 -0
  547. package/dist/sandbox/firestore/admin-compat/paths.d.ts.map +1 -0
  548. package/dist/sandbox/firestore/admin-compat/paths.js +49 -0
  549. package/dist/sandbox/firestore/admin-compat/paths.js.map +1 -0
  550. package/dist/sandbox/firestore/admin-compat/query.d.ts +261 -0
  551. package/dist/sandbox/firestore/admin-compat/query.d.ts.map +1 -0
  552. package/dist/sandbox/firestore/admin-compat/query.js +704 -0
  553. package/dist/sandbox/firestore/admin-compat/query.js.map +1 -0
  554. package/dist/sandbox/firestore/admin-compat/read-translation.d.ts +8 -0
  555. package/dist/sandbox/firestore/admin-compat/read-translation.d.ts.map +1 -0
  556. package/dist/sandbox/firestore/admin-compat/read-translation.js +59 -0
  557. package/dist/sandbox/firestore/admin-compat/read-translation.js.map +1 -0
  558. package/dist/sandbox/firestore/admin-compat/snapshots.d.ts +39 -0
  559. package/dist/sandbox/firestore/admin-compat/snapshots.d.ts.map +1 -0
  560. package/dist/sandbox/firestore/admin-compat/snapshots.js +50 -0
  561. package/dist/sandbox/firestore/admin-compat/snapshots.js.map +1 -0
  562. package/dist/sandbox/firestore/admin-compat/transaction.d.ts +35 -0
  563. package/dist/sandbox/firestore/admin-compat/transaction.d.ts.map +1 -0
  564. package/dist/sandbox/firestore/admin-compat/transaction.js +88 -0
  565. package/dist/sandbox/firestore/admin-compat/transaction.js.map +1 -0
  566. package/dist/sandbox/firestore/admin-compat/types.d.ts +297 -0
  567. package/dist/sandbox/firestore/admin-compat/types.d.ts.map +1 -0
  568. package/dist/sandbox/firestore/admin-compat/types.js +127 -0
  569. package/dist/sandbox/firestore/admin-compat/types.js.map +1 -0
  570. package/dist/sandbox/firestore/admin-compat/value-order.d.ts +49 -0
  571. package/dist/sandbox/firestore/admin-compat/value-order.d.ts.map +1 -0
  572. package/dist/sandbox/firestore/admin-compat/value-order.js +172 -0
  573. package/dist/sandbox/firestore/admin-compat/value-order.js.map +1 -0
  574. package/dist/sandbox/firestore/auto-id.d.ts +26 -0
  575. package/dist/sandbox/firestore/auto-id.d.ts.map +1 -0
  576. package/dist/sandbox/firestore/auto-id.js +36 -0
  577. package/dist/sandbox/firestore/auto-id.js.map +1 -0
  578. package/dist/sandbox/firestore/converters/bytes-geopoint.d.ts +48 -0
  579. package/dist/sandbox/firestore/converters/bytes-geopoint.d.ts.map +1 -0
  580. package/dist/sandbox/firestore/converters/bytes-geopoint.js +85 -0
  581. package/dist/sandbox/firestore/converters/bytes-geopoint.js.map +1 -0
  582. package/dist/sandbox/firestore/converters/fieldvalue.d.ts +69 -0
  583. package/dist/sandbox/firestore/converters/fieldvalue.d.ts.map +1 -0
  584. package/dist/sandbox/firestore/converters/fieldvalue.js +166 -0
  585. package/dist/sandbox/firestore/converters/fieldvalue.js.map +1 -0
  586. package/dist/sandbox/firestore/converters/reference.d.ts +31 -0
  587. package/dist/sandbox/firestore/converters/reference.d.ts.map +1 -0
  588. package/dist/sandbox/firestore/converters/reference.js +51 -0
  589. package/dist/sandbox/firestore/converters/reference.js.map +1 -0
  590. package/dist/sandbox/firestore/converters/timestamp.d.ts +32 -0
  591. package/dist/sandbox/firestore/converters/timestamp.d.ts.map +1 -0
  592. package/dist/sandbox/firestore/converters/timestamp.js +63 -0
  593. package/dist/sandbox/firestore/converters/timestamp.js.map +1 -0
  594. package/dist/sandbox/firestore/converters/user-timestamp.d.ts +46 -0
  595. package/dist/sandbox/firestore/converters/user-timestamp.d.ts.map +1 -0
  596. package/dist/sandbox/firestore/converters/user-timestamp.js +65 -0
  597. package/dist/sandbox/firestore/converters/user-timestamp.js.map +1 -0
  598. package/dist/sandbox/firestore/converters/vector.d.ts +24 -0
  599. package/dist/sandbox/firestore/converters/vector.d.ts.map +1 -0
  600. package/dist/sandbox/firestore/converters/vector.js +53 -0
  601. package/dist/sandbox/firestore/converters/vector.js.map +1 -0
  602. package/dist/sandbox/firestore/errors.d.ts +82 -0
  603. package/dist/sandbox/firestore/errors.d.ts.map +1 -0
  604. package/dist/sandbox/firestore/errors.js +54 -0
  605. package/dist/sandbox/firestore/errors.js.map +1 -0
  606. package/dist/sandbox/firestore/event-log.d.ts +99 -0
  607. package/dist/sandbox/firestore/event-log.d.ts.map +1 -0
  608. package/dist/sandbox/firestore/event-log.js +67 -0
  609. package/dist/sandbox/firestore/event-log.js.map +1 -0
  610. package/dist/sandbox/firestore/field-merge.d.ts +64 -0
  611. package/dist/sandbox/firestore/field-merge.d.ts.map +1 -0
  612. package/dist/sandbox/firestore/field-merge.js +221 -0
  613. package/dist/sandbox/firestore/field-merge.js.map +1 -0
  614. package/dist/sandbox/firestore/list-query-proof.d.ts +77 -0
  615. package/dist/sandbox/firestore/list-query-proof.d.ts.map +1 -0
  616. package/dist/sandbox/firestore/list-query-proof.js +170 -0
  617. package/dist/sandbox/firestore/list-query-proof.js.map +1 -0
  618. package/dist/sandbox/firestore/local-environment.d.ts +806 -0
  619. package/dist/sandbox/firestore/local-environment.d.ts.map +1 -0
  620. package/dist/sandbox/firestore/local-environment.js +3028 -0
  621. package/dist/sandbox/firestore/local-environment.js.map +1 -0
  622. package/dist/sandbox/firestore/local-state.d.ts +196 -0
  623. package/dist/sandbox/firestore/local-state.d.ts.map +1 -0
  624. package/dist/sandbox/firestore/local-state.js +413 -0
  625. package/dist/sandbox/firestore/local-state.js.map +1 -0
  626. package/dist/sandbox/firestore/overlay-backing.d.ts +44 -0
  627. package/dist/sandbox/firestore/overlay-backing.d.ts.map +1 -0
  628. package/dist/sandbox/firestore/overlay-backing.js +82 -0
  629. package/dist/sandbox/firestore/overlay-backing.js.map +1 -0
  630. package/dist/sandbox/firestore/sentinel-capture.d.ts +30 -0
  631. package/dist/sandbox/firestore/sentinel-capture.d.ts.map +1 -0
  632. package/dist/sandbox/firestore/sentinel-capture.js +61 -0
  633. package/dist/sandbox/firestore/sentinel-capture.js.map +1 -0
  634. package/dist/sandbox/firestore/snapshot-listeners.d.ts +284 -0
  635. package/dist/sandbox/firestore/snapshot-listeners.d.ts.map +1 -0
  636. package/dist/sandbox/firestore/snapshot-listeners.js +194 -0
  637. package/dist/sandbox/firestore/snapshot-listeners.js.map +1 -0
  638. package/dist/sandbox/firestore/topk.d.ts +12 -0
  639. package/dist/sandbox/firestore/topk.d.ts.map +1 -0
  640. package/dist/sandbox/firestore/topk.js +42 -0
  641. package/dist/sandbox/firestore/topk.js.map +1 -0
  642. package/dist/sandbox/firestore/transaction-merge.d.ts +55 -0
  643. package/dist/sandbox/firestore/transaction-merge.d.ts.map +1 -0
  644. package/dist/sandbox/firestore/transaction-merge.js +107 -0
  645. package/dist/sandbox/firestore/transaction-merge.js.map +1 -0
  646. package/dist/sandbox/firestore/transaction-types.d.ts +152 -0
  647. package/dist/sandbox/firestore/transaction-types.d.ts.map +1 -0
  648. package/dist/sandbox/firestore/transaction-types.js +10 -0
  649. package/dist/sandbox/firestore/transaction-types.js.map +1 -0
  650. package/dist/sandbox/firestore/transaction.d.ts +79 -0
  651. package/dist/sandbox/firestore/transaction.d.ts.map +1 -0
  652. package/dist/sandbox/firestore/transaction.js +95 -0
  653. package/dist/sandbox/firestore/transaction.js.map +1 -0
  654. package/dist/sandbox/firestore/value-equality.d.ts +2 -0
  655. package/dist/sandbox/firestore/value-equality.d.ts.map +1 -0
  656. package/dist/sandbox/firestore/value-equality.js +35 -0
  657. package/dist/sandbox/firestore/value-equality.js.map +1 -0
  658. package/dist/sandbox/firestore/value-resolver.d.ts +172 -0
  659. package/dist/sandbox/firestore/value-resolver.d.ts.map +1 -0
  660. package/dist/sandbox/firestore/value-resolver.js +221 -0
  661. package/dist/sandbox/firestore/value-resolver.js.map +1 -0
  662. package/dist/sandbox/firestore/wire-encoder.d.ts +14 -0
  663. package/dist/sandbox/firestore/wire-encoder.d.ts.map +1 -0
  664. package/dist/sandbox/firestore/wire-encoder.js +130 -0
  665. package/dist/sandbox/firestore/wire-encoder.js.map +1 -0
  666. package/dist/sandbox/index.d.ts +50 -0
  667. package/dist/sandbox/index.d.ts.map +1 -0
  668. package/dist/sandbox/index.js +63 -0
  669. package/dist/sandbox/index.js.map +1 -0
  670. package/dist/sandbox/internal/index.d.ts +36 -0
  671. package/dist/sandbox/internal/index.d.ts.map +1 -0
  672. package/dist/sandbox/internal/index.js +72 -0
  673. package/dist/sandbox/internal/index.js.map +1 -0
  674. package/dist/sandbox/internal/sandbox-impl.d.ts +343 -0
  675. package/dist/sandbox/internal/sandbox-impl.d.ts.map +1 -0
  676. package/dist/sandbox/internal/sandbox-impl.js +718 -0
  677. package/dist/sandbox/internal/sandbox-impl.js.map +1 -0
  678. package/dist/sandbox/persistence/backends.d.ts +37 -0
  679. package/dist/sandbox/persistence/backends.d.ts.map +1 -0
  680. package/dist/sandbox/persistence/backends.js +202 -0
  681. package/dist/sandbox/persistence/backends.js.map +1 -0
  682. package/dist/sandbox/persistence/chunk-format.d.ts +58 -0
  683. package/dist/sandbox/persistence/chunk-format.d.ts.map +1 -0
  684. package/dist/sandbox/persistence/chunk-format.js +163 -0
  685. package/dist/sandbox/persistence/chunk-format.js.map +1 -0
  686. package/dist/sandbox/persistence/controller.d.ts +39 -0
  687. package/dist/sandbox/persistence/controller.d.ts.map +1 -0
  688. package/dist/sandbox/persistence/controller.js +605 -0
  689. package/dist/sandbox/persistence/controller.js.map +1 -0
  690. package/dist/sandbox/persistence/index.d.ts +13 -0
  691. package/dist/sandbox/persistence/index.d.ts.map +1 -0
  692. package/dist/sandbox/persistence/index.js +5 -0
  693. package/dist/sandbox/persistence/index.js.map +1 -0
  694. package/dist/sandbox/persistence/serialize.d.ts +53 -0
  695. package/dist/sandbox/persistence/serialize.d.ts.map +1 -0
  696. package/dist/sandbox/persistence/serialize.js +98 -0
  697. package/dist/sandbox/persistence/serialize.js.map +1 -0
  698. package/dist/sandbox/persistence/types.d.ts +118 -0
  699. package/dist/sandbox/persistence/types.d.ts.map +1 -0
  700. package/dist/sandbox/persistence/types.js +15 -0
  701. package/dist/sandbox/persistence/types.js.map +1 -0
  702. package/dist/sandbox/remote.d.ts +118 -0
  703. package/dist/sandbox/remote.d.ts.map +1 -0
  704. package/dist/sandbox/remote.js +60 -0
  705. package/dist/sandbox/remote.js.map +1 -0
  706. package/dist/sandbox/replay/index.d.ts +106 -0
  707. package/dist/sandbox/replay/index.d.ts.map +1 -0
  708. package/dist/sandbox/replay/index.js +246 -0
  709. package/dist/sandbox/replay/index.js.map +1 -0
  710. package/dist/sandbox/sandbox-context.d.ts +40 -0
  711. package/dist/sandbox/sandbox-context.d.ts.map +1 -0
  712. package/dist/sandbox/sandbox-context.js +72 -0
  713. package/dist/sandbox/sandbox-context.js.map +1 -0
  714. package/dist/sandbox/tab-sync/index.d.ts +120 -0
  715. package/dist/sandbox/tab-sync/index.d.ts.map +1 -0
  716. package/dist/sandbox/tab-sync/index.js +272 -0
  717. package/dist/sandbox/tab-sync/index.js.map +1 -0
  718. package/dist/sandbox/types.d.ts +1235 -0
  719. package/dist/sandbox/types.d.ts.map +1 -0
  720. package/dist/sandbox/types.js +46 -0
  721. package/dist/sandbox/types.js.map +1 -0
  722. package/dist/storage/admin/api.d.ts +206 -0
  723. package/dist/storage/admin/api.d.ts.map +1 -0
  724. package/dist/storage/admin/api.js +349 -0
  725. package/dist/storage/admin/api.js.map +1 -0
  726. package/dist/storage/admin/handler.d.ts +29 -0
  727. package/dist/storage/admin/handler.d.ts.map +1 -0
  728. package/dist/storage/admin/handler.js +82 -0
  729. package/dist/storage/admin/handler.js.map +1 -0
  730. package/dist/storage/admin/spec.d.ts +64 -0
  731. package/dist/storage/admin/spec.d.ts.map +1 -0
  732. package/dist/storage/admin/spec.js +8 -0
  733. package/dist/storage/admin/spec.js.map +1 -0
  734. package/dist/storage/admin/tools.d.ts +23 -0
  735. package/dist/storage/admin/tools.d.ts.map +1 -0
  736. package/dist/storage/admin/tools.js +90 -0
  737. package/dist/storage/admin/tools.js.map +1 -0
  738. package/dist/storage/download.d.ts +27 -0
  739. package/dist/storage/download.d.ts.map +1 -0
  740. package/dist/storage/download.js +136 -0
  741. package/dist/storage/download.js.map +1 -0
  742. package/dist/storage/enforce.d.ts +26 -0
  743. package/dist/storage/enforce.d.ts.map +1 -0
  744. package/dist/storage/enforce.js +16 -0
  745. package/dist/storage/enforce.js.map +1 -0
  746. package/dist/storage/errors.d.ts +46 -0
  747. package/dist/storage/errors.d.ts.map +1 -0
  748. package/dist/storage/errors.js +62 -0
  749. package/dist/storage/errors.js.map +1 -0
  750. package/dist/storage/index.d.ts +49 -0
  751. package/dist/storage/index.d.ts.map +1 -0
  752. package/dist/storage/index.js +60 -0
  753. package/dist/storage/index.js.map +1 -0
  754. package/dist/storage/internal.d.ts +18 -0
  755. package/dist/storage/internal.d.ts.map +1 -0
  756. package/dist/storage/internal.js +18 -0
  757. package/dist/storage/internal.js.map +1 -0
  758. package/dist/storage/list.d.ts +17 -0
  759. package/dist/storage/list.d.ts.map +1 -0
  760. package/dist/storage/list.js +91 -0
  761. package/dist/storage/list.js.map +1 -0
  762. package/dist/storage/metadata.d.ts +86 -0
  763. package/dist/storage/metadata.d.ts.map +1 -0
  764. package/dist/storage/metadata.js +167 -0
  765. package/dist/storage/metadata.js.map +1 -0
  766. package/dist/storage/persistence.d.ts +134 -0
  767. package/dist/storage/persistence.d.ts.map +1 -0
  768. package/dist/storage/persistence.js +132 -0
  769. package/dist/storage/persistence.js.map +1 -0
  770. package/dist/storage/reference.d.ts +63 -0
  771. package/dist/storage/reference.d.ts.map +1 -0
  772. package/dist/storage/reference.js +156 -0
  773. package/dist/storage/reference.js.map +1 -0
  774. package/dist/storage/rules.d.ts +176 -0
  775. package/dist/storage/rules.d.ts.map +1 -0
  776. package/dist/storage/rules.js +621 -0
  777. package/dist/storage/rules.js.map +1 -0
  778. package/dist/storage/service.d.ts +139 -0
  779. package/dist/storage/service.d.ts.map +1 -0
  780. package/dist/storage/service.js +241 -0
  781. package/dist/storage/service.js.map +1 -0
  782. package/dist/storage/upload.d.ts +43 -0
  783. package/dist/storage/upload.d.ts.map +1 -0
  784. package/dist/storage/upload.js +235 -0
  785. package/dist/storage/upload.js.map +1 -0
  786. package/package.json +109 -3
@@ -0,0 +1,3028 @@
1
+ /**
2
+ * LocalEnvironment — stateful Firestore sandbox.
3
+ *
4
+ * Wraps LocalState + SimulateFirestoreRulesHandler + EventLog into a
5
+ * complete local development environment. Agents can seed data, deploy
6
+ * rules, execute operations, and undo — all without touching production.
7
+ */
8
+ import { LocalState, } from './local-state.js';
9
+ import { OverlayBacking } from './overlay-backing.js';
10
+ import { EventLog } from './event-log.js';
11
+ import { SimulateFirestoreRulesHandler, renderLegacyDebugMessages, projectEvaluatedRule } from 'pyric/rules';
12
+ import { lintFirestoreRules, parseToAST } from 'pyric/rules';
13
+ // RULES-B11 — query-proof gate for list reads ("rules are not filters").
14
+ import { proveListQuery } from './list-query-proof.js';
15
+ import { resolveValueTree, partitionDeletes, registerDefaultConverters, } from './value-resolver.js';
16
+ import { assertNoNestedDeleteField } from './field-merge.js';
17
+ import { Timestamp } from 'pyric/rules';
18
+ import { makeError } from './errors.js';
19
+ import { generateAutoId } from './auto-id.js';
20
+ import { walkForSentinels } from './sentinel-capture.js';
21
+ import { TransactionContext } from './transaction.js';
22
+ import { mergeQueuedWrites } from './transaction-merge.js';
23
+ import { buildDocumentSnapshot, buildQuerySnapshot, SANDBOX_METADATA, SANDBOX_METADATA_PENDING, } from './snapshot-listeners.js';
24
+ // Register every shipped converter exactly once on module load. Idempotent
25
+ // per-converter, so re-imports are safe. Item 0 ships an empty registry;
26
+ // Items 1+ add converters here.
27
+ registerDefaultConverters();
28
+ /**
29
+ * Wallclock-aligned ISO string for `tc.requestTime`. Both `request.time`
30
+ * and any `serverTimestamp()` sentinel in this write must resolve to a
31
+ * field-equal Timestamp; we accomplish that by computing a single
32
+ * Timestamp here and forwarding the millisecond-precise ISO to handler.ts
33
+ * (which parses it back into a Timestamp via `Timestamp.fromIsoString`,
34
+ * lossless on the millisecond grid).
35
+ */
36
+ function isoFromTimestamp(ts) {
37
+ return new Date(ts.toMillis()).toISOString();
38
+ }
39
+ /**
40
+ * Thrown by `LocalEnvironment.execute` / `.batch` when the simulator
41
+ * abstained on a rule (state: UNSUPPORTED). The agent's rule may be
42
+ * correct — the simulator just doesn't implement the feature it uses.
43
+ *
44
+ * Returning `allowed: false` here would silently re-create the
45
+ * misleading-DENY pattern that Item 0.A is designed to prevent (the
46
+ * agent can't tell sim-gap apart from real rule bug). Throwing forces
47
+ * the test to fail loudly with an actionable message pointing at the
48
+ * production Test API as the workaround.
49
+ */
50
+ export class SimulatorUnsupportedError extends Error {
51
+ method;
52
+ path;
53
+ debugMessages;
54
+ constructor(message, method, path, debugMessages) {
55
+ super(message);
56
+ this.method = method;
57
+ this.path = path;
58
+ this.debugMessages = debugMessages;
59
+ this.name = 'SimulatorUnsupportedError';
60
+ }
61
+ }
62
+ function unsupportedMessage(method, path, debugMessages) {
63
+ const reasons = debugMessages
64
+ .filter(m => m.includes('unsupported:'))
65
+ .map(m => m.replace(/^.*unsupported:\s*/, ''))
66
+ .join('; ');
67
+ const reasonClause = reasons ? ` Reason(s): ${reasons}.` : '';
68
+ return (`Simulator cannot decide ${method} on ${path} — the rule uses a feature ` +
69
+ `the local simulator does not yet implement.${reasonClause} ` +
70
+ `Verify this rule against production using TestFirestoreRulesHandler, ` +
71
+ `or file a sim-gap entry in REBUILD_PLAN.md.`);
72
+ }
73
+ /**
74
+ * Compare two doc payloads for snapshot-suppression purposes. `null`
75
+ * means the doc is absent. Equality test uses `JSON.stringify` to
76
+ * mirror `computeChanges` in `snapshot-listeners.ts` — keeps the two
77
+ * change-detection paths consistent and good enough for sandbox data
78
+ * (all `DocumentData` is JSON-serialisable post-sentinel-resolution).
79
+ */
80
+ function docDataEqual(a, b) {
81
+ if (a === null && b === null)
82
+ return true;
83
+ if (a === null || b === null)
84
+ return false;
85
+ return JSON.stringify(a) === JSON.stringify(b);
86
+ }
87
+ /**
88
+ * True if any path in `paths` is a direct child document of
89
+ * `collection`. Used as a cheap pre-filter for query-listener
90
+ * notifications: we only re-read the collection when something it
91
+ * could plausibly contain was just touched. Slice 6 may revisit when
92
+ * subcollection-aware queries land — current shape keeps the filter
93
+ * conservative (no false negatives) at the cost of an occasional
94
+ * false positive that the change-set diff then suppresses.
95
+ */
96
+ function anyPathInCollection(paths, collection) {
97
+ const prefix = `${collection}/`;
98
+ for (const p of paths) {
99
+ if (!p.startsWith(prefix))
100
+ continue;
101
+ const remaining = p.slice(prefix.length);
102
+ if (remaining.length > 0 && !remaining.includes('/'))
103
+ return true;
104
+ }
105
+ return false;
106
+ }
107
+ let _requestEventSeq = 0;
108
+ function nextRequestEventId() {
109
+ // Monotonic + random tail. Stable for the lifetime of the JS process;
110
+ // doesn't try to be cryptographically unique because consumers use it
111
+ // as a React list key, not a security token.
112
+ _requestEventSeq = (_requestEventSeq + 1) >>> 0;
113
+ return `req-${_requestEventSeq.toString(36)}-${Math.random().toString(36).slice(2, 7)}`;
114
+ }
115
+ /**
116
+ * Parse `Rule #N (ops...) → ALLOW/deny` lines out of the simulator's
117
+ * debug messages. The simulator emits one such line per evaluated rule
118
+ * in the matched match block (see `evaluateRules` in
119
+ * `pyric/rules/handler.ts`). For allowed outcomes
120
+ * the last `→ ALLOW` rule wins; for denials we surface the first rule
121
+ * that even tried to match this op-set.
122
+ */
123
+ function parseMatchedRule(debugMessages, result) {
124
+ const wantAllow = result === 'allow';
125
+ let last;
126
+ for (const msg of debugMessages) {
127
+ const m = /^Rule #(\d+) \(([^)]+)\) → (ALLOW|deny|unsupported)/.exec(msg);
128
+ if (!m)
129
+ continue;
130
+ const candidate = {
131
+ ruleIndex: Number(m[1]),
132
+ operations: m[2].split(',').map((s) => s.trim()),
133
+ };
134
+ if (wantAllow && m[3] === 'ALLOW')
135
+ return candidate;
136
+ last = candidate;
137
+ }
138
+ return last;
139
+ }
140
+ function buildRequestEvent(input) {
141
+ const out = {
142
+ kind: 'request',
143
+ id: nextRequestEventId(),
144
+ at: input.at,
145
+ evalMs: input.evalMs,
146
+ method: input.method,
147
+ path: input.path,
148
+ auth: input.auth ? { uid: input.auth.uid, ...(input.auth.token ? { token: input.auth.token } : {}) } : null,
149
+ result: input.result,
150
+ reasons: input.debugMessages,
151
+ origin: input.origin,
152
+ };
153
+ if (input.resourceData !== undefined) {
154
+ out.request = { resourceData: input.resourceData };
155
+ }
156
+ if (input.resourceBefore !== undefined) {
157
+ out.resourceBefore = input.resourceBefore;
158
+ }
159
+ if (input.resourceAfter !== undefined) {
160
+ out.resourceAfter = input.resourceAfter;
161
+ }
162
+ const matched = parseMatchedRule(input.debugMessages, input.result);
163
+ if (matched)
164
+ out.matchedRule = matched;
165
+ // The structured deciding-rule projection (verdict + line + expression
166
+ // trace) rides alongside the flat `reasons` on rules-evaluated results —
167
+ // the allowing rule on an allow, the denying rule on a deny. Unsupported
168
+ // results have no deciding rule (the simulator abstained).
169
+ if (input.result !== 'unsupported' && input.evaluatedRule) {
170
+ out.evaluatedRule = input.evaluatedRule;
171
+ }
172
+ if (input.groupId !== undefined) {
173
+ out.groupId = input.groupId;
174
+ // Disambiguates 'origin' for consumers that want the group kind
175
+ // without re-parsing the prefix.
176
+ if (input.origin === 'batch')
177
+ out.groupKind = 'batch';
178
+ else if (input.origin === 'transaction')
179
+ out.groupKind = 'transaction';
180
+ }
181
+ if (input.triggeredBy !== undefined)
182
+ out.triggeredBy = input.triggeredBy;
183
+ if (input.detail !== undefined)
184
+ out.detail = input.detail;
185
+ return out;
186
+ }
187
+ function listQueryFromStructured(structured) {
188
+ if (structured.limit == null && structured.offset == null && structured.orderBy == null) {
189
+ return undefined;
190
+ }
191
+ return {
192
+ ...(structured.limit != null ? { limit: structured.limit } : {}),
193
+ ...(structured.offset != null ? { offset: structured.offset } : {}),
194
+ ...(structured.orderBy != null ? { orderBy: structured.orderBy } : {}),
195
+ };
196
+ }
197
+ /**
198
+ * A synthetic all-ALLOW {@link TestResult} for the admin-bypass path
199
+ * (Pyric Studio Gap #2). Returned by {@link LocalEnvironment.runSimulate}
200
+ * instead of calling the rules engine when an op carries `bypassRules`.
201
+ * `state: 'PASSED'` + `decision: 'ALLOW'` is exactly the shape every
202
+ * write/read site downstream of a `simulate()` call already branches on,
203
+ * so the bypass reuses the entire existing execute/batch/transaction
204
+ * apply + emit machinery unchanged — only the rule decision is forced.
205
+ * The `notes` line makes the bypass legible in the `debugMessages` trail
206
+ * that surfaces on the traffic log.
207
+ */
208
+ function adminBypassResult(description = '') {
209
+ return {
210
+ description,
211
+ expectation: 'ALLOW',
212
+ state: 'PASSED',
213
+ decision: 'ALLOW',
214
+ trace: [],
215
+ notes: ['admin lens — rules bypassed (Studio Gap #2)'],
216
+ };
217
+ }
218
+ /**
219
+ * Default ruleset for a freshly-constructed sandbox. Open read+write
220
+ * on every path — the right behavior for the quickstart / local dev
221
+ * loop where rules haven't been considered yet. Callers tighten this
222
+ * via `setRules(...)`; production code never relies on the default.
223
+ */
224
+ const DEFAULT_OPEN_RULES = `rules_version = '2';
225
+ service cloud.firestore {
226
+ match /databases/{database}/documents {
227
+ match /{document=**} {
228
+ allow read, write: if true;
229
+ }
230
+ }
231
+ }
232
+ `;
233
+ export class LocalEnvironment {
234
+ state;
235
+ eventLog;
236
+ simulator;
237
+ rulesSource;
238
+ seedSnapshot;
239
+ /**
240
+ * Subscribers notified after every `permission-denied` is constructed,
241
+ * regardless of whether downstream user code catches the resulting
242
+ * throw. Lets host environments (the playground runner, tests) surface
243
+ * denials with full eval context even when test code wraps the call
244
+ * in try/catch — the catch otherwise hides everything past `e.code`.
245
+ */
246
+ denialListeners = new Set();
247
+ /**
248
+ * Subscribers notified for every evaluated op (issue #307). Each
249
+ * receives a public-shape `RequestEvent`. Re-shape from the internal
250
+ * eval payload happens in {@link emitRequest} so the env's hot path
251
+ * doesn't allocate the event object until we know someone's listening.
252
+ *
253
+ * Listener throws are swallowed (same rationale as `denialListeners`).
254
+ */
255
+ requestListeners = new Set();
256
+ /**
257
+ * Subscribers notified for every COMMITTED write (issue #307). Fires
258
+ * after the keyspace successfully applied the write — denied or
259
+ * rolled-back writes don't emit here (they surface as a request-deny
260
+ * RequestEvent instead). Bridged to `Sandbox.onEvent` consumers via
261
+ * SandboxImpl's attachToEnv.
262
+ */
263
+ writeListeners = new Set();
264
+ /**
265
+ * Subscribers notified for every snapshot DELIVERED to a user
266
+ * `onSnapshot` callback. Fires after the suppress-check in
267
+ * notify*Listener, so this count tracks real callback invocations
268
+ * (in contrast to `requestListeners[origin='listener']`, which used
269
+ * to over-count before the step-5 refactor).
270
+ */
271
+ deliveryListeners = new Set();
272
+ /**
273
+ * Subscribers notified for every listener re-eval that was suppressed
274
+ * before reaching the user callback — Slice 3's no-op suppression
275
+ * surfaces here. Useful for "why didn't my listener fire" debugging.
276
+ */
277
+ suppressedListeners = new Set();
278
+ /**
279
+ * Subscribers notified for listener attach / detach lifecycle. Errored
280
+ * still routes through onSnapshotError (bridged to listener_errored
281
+ * in SandboxImpl) so this channel only carries attach + detach.
282
+ */
283
+ lifecycleListeners = new Set();
284
+ /**
285
+ * The user-origin op that's currently triggering a listener re-eval,
286
+ * if any. Set by the execute / batch / transaction call sites
287
+ * immediately before `notifyListenersForPaths`. Call sites use a
288
+ * **save/restore** pattern — a listener callback may itself issue a
289
+ * write, recursing through execute and setting up its own trigger; we
290
+ * must put the outer trigger back when the nested call returns so the
291
+ * remaining listeners in the outer fan-out still attribute correctly.
292
+ *
293
+ * Listener-origin RequestEvents copy this into `triggeredBy`. Undefined
294
+ * for the initial-fire path and for deployRules re-evaluation.
295
+ */
296
+ currentTrigger;
297
+ /**
298
+ * RULES-B11 — parsed-AST cache for the query-proof gate. The proof
299
+ * needs the matched `list` rule's condition AST on EVERY list read;
300
+ * re-parsing the (unchanging) rules source per read would be O(source)
301
+ * on the listener hot path. Keyed on the exact source string so
302
+ * `deployRules` / `seed` invalidate it for free.
303
+ */
304
+ parsedRulesCache = null;
305
+ /**
306
+ * Subscribers notified when a snapshot listener is marked errored
307
+ * (Slice 7). Mirrors `denialListeners` but fires from the listener
308
+ * dispatch path, not from one-shot operation evaluation. Two-level
309
+ * model per source survey section 9: the listener's own `errorCallback`
310
+ * receives the error AND every env-level subscriber receives it —
311
+ * the playground subscribes here to surface stream errors as toasts
312
+ * the same way it surfaces denials today.
313
+ */
314
+ snapshotErrorListeners = new Set();
315
+ /**
316
+ * Active `onSnapshot` listeners. Slice 1 — registry only; the dispatch
317
+ * path is wired in Slices 2 (initial fire), 3 (change detection), and
318
+ * 5 (transaction/batch deferral). Stored as a flat `Map<id, record>`
319
+ * per the implementation plan; query-canonicalization-based dedup
320
+ * (production's `EventManager` shape) is layered on later when caching
321
+ * actually saves work — see source survey section 2 for the eventual target
322
+ * shape. Each record carries its own target so future slices can scan
323
+ * and group on demand without restructuring the registry first.
324
+ */
325
+ snapshotListeners = new Map();
326
+ nextListenerId = 0;
327
+ /**
328
+ * Deferred listener deliveries — the shared delivery scheduler (items
329
+ * 3 + 5). Production never invokes an `onSnapshot` callback synchronously
330
+ * on the registering/writing stack: the initial snapshot arrives after
331
+ * the listen round-trip (COMPAT firestore#80 — "asynchronous, never
332
+ * during register"), and a local write's echo + server ack arrive on the
333
+ * async event queue (firestore#85). The sandbox mirrors that by enqueuing
334
+ * every user-facing delivery here and draining it off-stack, on a
335
+ * `queueMicrotask` boundary — which satisfies the "asynchronous" contract
336
+ * without a macrotask's extra latency (the prototype in the deep-divergence
337
+ * review measured identical behavior for micro- vs macro-task deferral).
338
+ *
339
+ * Per-listener FIFO order is preserved: deliveries enqueued *during* a
340
+ * drain — a callback that itself writes, or the item-3 metadata ack a
341
+ * write echo schedules — are appended and drained in the same pass, so a
342
+ * write settles fully before control returns to the microtask loop.
343
+ */
344
+ deliveryQueue = [];
345
+ deliveryScheduled = false;
346
+ constructor() {
347
+ this.state = new LocalState();
348
+ this.eventLog = new EventLog();
349
+ this.simulator = new SimulateFirestoreRulesHandler();
350
+ // Default to an allow-all ruleset so a freshly-constructed sandbox
351
+ // works for the quickstart `addDoc` / `getDoc` flow without forcing
352
+ // the caller to call `setRules(...)` first. The empty string used
353
+ // to live here and made the simulator throw `Failed to parse rules
354
+ // source` on first write — the most common failure mode for new
355
+ // users running `bun start` from `pyric init`. Callers who care
356
+ // about real rule enforcement still call `setRules(...)` explicitly;
357
+ // the default is just "don't blow up before you've thought about
358
+ // rules."
359
+ this.rulesSource = DEFAULT_OPEN_RULES;
360
+ this.seedSnapshot = {};
361
+ }
362
+ /**
363
+ * Subscribe to permission-denied events. Returns an unsubscribe fn.
364
+ * The callback receives the structured `FirestoreSimError` carrying
365
+ * `request` / `resource` (when populated) — so subscribers can render
366
+ * a debugger-style frame without re-deriving any state.
367
+ *
368
+ * Listener throws are swallowed to keep the simulator hot path
369
+ * resilient — a faulty subscriber should not change rule semantics.
370
+ */
371
+ onDenial(cb) {
372
+ this.denialListeners.add(cb);
373
+ return () => { this.denialListeners.delete(cb); };
374
+ }
375
+ emitDenial(err) {
376
+ if (this.denialListeners.size === 0)
377
+ return;
378
+ for (const cb of this.denialListeners) {
379
+ try {
380
+ cb(err);
381
+ }
382
+ catch { /* ignore — see onDenial doc */ }
383
+ }
384
+ }
385
+ /**
386
+ * Subscribe to every evaluated op (issue #307). Returns an unsubscribe
387
+ * fn. The emit sites in `execute`, `batch`, `silentReadDoc`,
388
+ * `silentReadCollection` build the public-shape event lazily — when
389
+ * no subscribers are attached, eval doesn't pay the allocation cost.
390
+ */
391
+ onRequest(cb) {
392
+ this.requestListeners.add(cb);
393
+ return () => { this.requestListeners.delete(cb); };
394
+ }
395
+ /**
396
+ * Subscribe to committed-write events. Internal — bridged to the
397
+ * public `Sandbox.onEvent` channel by SandboxImpl. Fires AFTER the
398
+ * keyspace applies the write; denied / rolled-back writes don't
399
+ * emit here.
400
+ */
401
+ onWrite(cb) {
402
+ this.writeListeners.add(cb);
403
+ return () => { this.writeListeners.delete(cb); };
404
+ }
405
+ /** Internal — bridge for sandbox-level `onEvent` to receive
406
+ * snapshot-delivery events. Fires after the user callback runs. */
407
+ onSnapshotDelivery(cb) {
408
+ this.deliveryListeners.add(cb);
409
+ return () => { this.deliveryListeners.delete(cb); };
410
+ }
411
+ /** Internal bridge for snapshot_suppressed events — re-evals that
412
+ * didn't deliver because diffing found no observable change. */
413
+ onSnapshotSuppressed(cb) {
414
+ this.suppressedListeners.add(cb);
415
+ return () => { this.suppressedListeners.delete(cb); };
416
+ }
417
+ /** Internal bridge for listener attach/detach lifecycle. Errored
418
+ * routes through onSnapshotError separately. */
419
+ onListenerLifecycle(cb) {
420
+ this.lifecycleListeners.add(cb);
421
+ return () => { this.lifecycleListeners.delete(cb); };
422
+ }
423
+ emitSnapshotDelivery(input) {
424
+ if (this.deliveryListeners.size === 0)
425
+ return;
426
+ const event = {
427
+ kind: 'snapshot_delivery',
428
+ id: nextRequestEventId().replace(/^req-/, 'snd-'),
429
+ at: Date.now(),
430
+ listenerId: input.listenerId,
431
+ target: input.target,
432
+ auth: input.auth
433
+ ? { uid: input.auth.uid, ...(input.auth.token ? { token: input.auth.token } : {}) }
434
+ : null,
435
+ addedCount: input.addedCount,
436
+ modifiedCount: input.modifiedCount,
437
+ removedCount: input.removedCount,
438
+ size: input.size,
439
+ ...(input.sample ? { sample: input.sample } : {}),
440
+ ...(input.triggeredBy ? { triggeredBy: input.triggeredBy } : {}),
441
+ };
442
+ for (const cb of this.deliveryListeners) {
443
+ try {
444
+ cb(event);
445
+ }
446
+ catch { /* swallow */ }
447
+ }
448
+ }
449
+ emitSnapshotSuppressed(input) {
450
+ if (this.suppressedListeners.size === 0)
451
+ return;
452
+ const event = {
453
+ kind: 'snapshot_suppressed',
454
+ id: nextRequestEventId().replace(/^req-/, 'sup-'),
455
+ at: Date.now(),
456
+ listenerId: input.listenerId,
457
+ target: input.target,
458
+ auth: input.auth
459
+ ? { uid: input.auth.uid, ...(input.auth.token ? { token: input.auth.token } : {}) }
460
+ : null,
461
+ reason: 'no-op',
462
+ ...(input.triggeredBy ? { triggeredBy: input.triggeredBy } : {}),
463
+ };
464
+ for (const cb of this.suppressedListeners) {
465
+ try {
466
+ cb(event);
467
+ }
468
+ catch { /* swallow */ }
469
+ }
470
+ }
471
+ emitLifecycle(input) {
472
+ if (this.lifecycleListeners.size === 0)
473
+ return;
474
+ const event = {
475
+ kind: input.phase,
476
+ id: nextRequestEventId().replace(/^req-/, 'lc-'),
477
+ at: Date.now(),
478
+ listenerId: input.listenerId,
479
+ target: input.target,
480
+ auth: input.auth
481
+ ? { uid: input.auth.uid, ...(input.auth.token ? { token: input.auth.token } : {}) }
482
+ : null,
483
+ };
484
+ for (const cb of this.lifecycleListeners) {
485
+ try {
486
+ cb(event);
487
+ }
488
+ catch { /* swallow */ }
489
+ }
490
+ }
491
+ /**
492
+ * Build and dispatch a `WriteSandboxEvent`. Same sync-throw +
493
+ * async-rejection isolation as emitRequest. Bails early when no
494
+ * subscribers are attached so the hot path stays allocation-free.
495
+ *
496
+ * `sentinels` and `autoId` are reserved fields the caller can
497
+ * populate when the replay-engine work lands. v1 always passes
498
+ * undefined for both.
499
+ */
500
+ emitWrite(input) {
501
+ if (this.writeListeners.size === 0)
502
+ return;
503
+ const event = {
504
+ kind: 'write',
505
+ id: nextRequestEventId().replace(/^req-/, 'wr-'),
506
+ at: Date.now(),
507
+ method: input.method,
508
+ path: input.path,
509
+ auth: input.auth
510
+ ? { uid: input.auth.uid, ...(input.auth.token ? { token: input.auth.token } : {}) }
511
+ : null,
512
+ ...(input.data !== undefined ? { data: input.data } : {}),
513
+ priorState: input.priorState,
514
+ nextState: input.nextState,
515
+ ...(input.groupId !== undefined ? { groupId: input.groupId } : {}),
516
+ ...(input.groupKind !== undefined ? { groupKind: input.groupKind } : {}),
517
+ ...(input.sentinels && input.sentinels.length > 0 ? { sentinels: input.sentinels } : {}),
518
+ ...(input.autoId !== undefined ? { autoId: input.autoId } : {}),
519
+ requestTime: { seconds: input.requestTime.seconds, nanoseconds: input.requestTime.nanos },
520
+ ...(input.detail !== undefined ? { detail: input.detail } : {}),
521
+ };
522
+ for (const cb of this.writeListeners) {
523
+ try {
524
+ const result = cb(event);
525
+ if (result && typeof result.then === 'function') {
526
+ result.catch(() => { });
527
+ }
528
+ }
529
+ catch { /* see emitRequest doc */ }
530
+ }
531
+ }
532
+ /**
533
+ * Build and dispatch a `RequestEvent` to all `onRequest` subscribers.
534
+ * Bails early when no one's listening so the hot path doesn't allocate
535
+ * an event object. Per the V1 probe numbers, eval rate can reach
536
+ * ~4500/sec in listener-storm scenarios — every cycle matters.
537
+ *
538
+ * Subscriber-throw isolation has two layers:
539
+ * - Sync throws are caught by the try/catch around the invocation.
540
+ * - Async subscribers that return a rejected Promise (e.g.
541
+ * `async (e) => { throw }`) have the rejection silently swallowed
542
+ * by attaching a noop `.catch`. Without this, an
543
+ * `unhandledRejection` would terminate the sandbox process on
544
+ * Node ≥15 default config — one bad subscriber would kill every
545
+ * other observer.
546
+ *
547
+ * Subscriber callbacks are observational; the sandbox doesn't await
548
+ * them and doesn't propagate their errors.
549
+ */
550
+ emitRequest(input) {
551
+ if (this.requestListeners.size === 0)
552
+ return;
553
+ const event = buildRequestEvent(input);
554
+ for (const cb of this.requestListeners) {
555
+ try {
556
+ const result = cb(event);
557
+ if (result && typeof result.then === 'function') {
558
+ result.catch(() => { });
559
+ }
560
+ }
561
+ catch { /* see method doc */ }
562
+ }
563
+ }
564
+ /**
565
+ * Subscribe to snapshot-listener stream errors (Slice 7). Returns an
566
+ * unsubscribe fn. Fires every time a snapshot listener is marked
567
+ * errored — initial fire denial, change-driven re-read denial, or
568
+ * `deployRules` re-evaluation flipping a listener allowed → denied.
569
+ *
570
+ * The callback receives the structured `FirestoreSimError` plus the
571
+ * listener's `SnapshotTarget` so the host UI can attribute the error
572
+ * to a specific watch (e.g. "listener on `games/g1` errored").
573
+ *
574
+ * Per source survey section 9, this is the playground-side channel: stream
575
+ * errors fan out to both the listener's own `errorCallback` AND every
576
+ * env-level subscriber here. Subscriber throws are swallowed so a
577
+ * faulty UI handler can't destabilize the simulator.
578
+ */
579
+ onSnapshotError(cb) {
580
+ this.snapshotErrorListeners.add(cb);
581
+ return () => { this.snapshotErrorListeners.delete(cb); };
582
+ }
583
+ emitSnapshotError(err, target, listenerId) {
584
+ if (this.snapshotErrorListeners.size === 0)
585
+ return;
586
+ for (const cb of this.snapshotErrorListeners) {
587
+ try {
588
+ cb(err, target, listenerId);
589
+ }
590
+ catch { /* ignore — see onSnapshotError doc */ }
591
+ }
592
+ }
593
+ // ═══ Snapshot listeners (Slices 1+2) ═══
594
+ /**
595
+ * Register a snapshot listener. Returns an `Unsubscribe` function
596
+ * matching the Web SDK's contract — a zero-arg call that detaches
597
+ * the listener. Idempotent: calling the returned function more than
598
+ * once after the first detach is a no-op.
599
+ *
600
+ * Slice 2: fires the **initial snapshot** synchronously after the
601
+ * record is registered. The current matching docs are read under
602
+ * `auth`'s rules — denied reads invoke `errorCallback` and mark the
603
+ * listener `errored` (no further notifications). Slice 3 will add
604
+ * change-driven fires; Slice 5 batches them through `applyBatch`.
605
+ *
606
+ * `auth` is captured at registration so notifications later evaluate
607
+ * rules under the auth that subscribed — not whatever auth happens
608
+ * to be active when a write triggers the dispatch.
609
+ */
610
+ addSnapshotListener(target, callback, options = {}, auth = null, errorCallback,
611
+ /**
612
+ * `true` when the registering Firestore handle was a `sandbox-live`
613
+ * (`getFirestore(sandbox)`) target — its identity follows
614
+ * `sandbox.currentUser`, so this listener re-evaluates on a
615
+ * `currentUser` change (see {@link reevaluateLiveListeners}).
616
+ * `false` (default) for frozen-ctx (`getFirestore(ctx)`) listeners,
617
+ * which stay pinned to the auth they captured at registration.
618
+ */
619
+ followsCurrentUser = false) {
620
+ const id = String(this.nextListenerId++);
621
+ const record = {
622
+ id,
623
+ target,
624
+ callback,
625
+ auth,
626
+ followsCurrentUser,
627
+ options,
628
+ currentSnapshot: undefined,
629
+ errored: false,
630
+ ...(errorCallback ? { errorCallback } : {}),
631
+ };
632
+ this.snapshotListeners.set(id, record);
633
+ // Issue #307 — emit lifecycle BEFORE the initial fire so observers
634
+ // see attach → delivery in causal order.
635
+ this.emitLifecycle({
636
+ phase: 'listener_attach',
637
+ listenerId: id,
638
+ target: target.kind === 'doc'
639
+ ? { kind: 'doc', path: target.path }
640
+ : { kind: 'query', collection: target.collection },
641
+ auth,
642
+ });
643
+ // Items 3 + 5 — the initial snapshot is delivered off-stack through the
644
+ // delivery scheduler, never synchronously during register. Production's
645
+ // event queue schedules even a *cached* initial event asynchronously
646
+ // (COMPAT firestore#80: "asynchronous, never during register"), so the
647
+ // register-then-read-synchronously agent pattern that returns `undefined`
648
+ // on prod also returns `undefined` here — the sandbox no longer trains
649
+ // users into a pattern prod breaks. The unsubscribe-before-drain guard
650
+ // mirrors prod: a listener detached before its first fire never sees one.
651
+ // Errors still route through the listener's `errorCallback` (inside
652
+ // `fireInitialSnapshot`), never thrown out of `addSnapshotListener`.
653
+ this.scheduleDelivery(() => {
654
+ if (!this.snapshotListeners.has(id))
655
+ return;
656
+ this.fireInitialSnapshot(record);
657
+ });
658
+ return () => {
659
+ const stillRegistered = this.snapshotListeners.has(id);
660
+ this.snapshotListeners.delete(id);
661
+ // Only emit detach if the listener was actually registered when
662
+ // the unsubscribe was called. Idempotent calls and listeners
663
+ // dropped by `reset()` don't double-emit.
664
+ if (stillRegistered) {
665
+ this.emitLifecycle({
666
+ phase: 'listener_detach',
667
+ listenerId: id,
668
+ target: target.kind === 'doc'
669
+ ? { kind: 'doc', path: target.path }
670
+ : { kind: 'query', collection: target.collection },
671
+ auth,
672
+ });
673
+ }
674
+ };
675
+ }
676
+ /**
677
+ * Compute and deliver the initial snapshot for a freshly-registered
678
+ * listener. Reads under the listener's `auth` and respects the
679
+ * deployed rules — denied reads route to `errorCallback` and mark
680
+ * the record `errored`. Splits doc vs query targets along the same
681
+ * seam {@link execute} uses, but does **not** append to the event
682
+ * log: listener reads are bookkeeping, not user-visible operations,
683
+ * and would otherwise drown the event log under any non-trivial UI.
684
+ */
685
+ fireInitialSnapshot(record) {
686
+ if (record.target.kind === 'doc') {
687
+ const result = this.silentReadDoc(record.target.path, record.auth);
688
+ if (!result.allowed) {
689
+ this.markErrored(record, result.error);
690
+ return;
691
+ }
692
+ const snap = buildDocumentSnapshot(record.target.path, result.data);
693
+ record.currentSnapshot = snap;
694
+ record.currentDocData = result.data;
695
+ try {
696
+ record.callback(snap);
697
+ }
698
+ catch {
699
+ /* swallow — same rationale as emitDenial; a faulty consumer
700
+ * callback must not destabilize the simulator. */
701
+ }
702
+ // Issue #307 — initial fire counts as a delivery. No triggeredBy
703
+ // because there was no user op that caused this; the listener
704
+ // just attached.
705
+ this.emitSnapshotDelivery({
706
+ listenerId: record.id,
707
+ target: { kind: 'doc', path: record.target.path },
708
+ auth: record.auth,
709
+ addedCount: result.data !== null ? 1 : 0,
710
+ modifiedCount: 0,
711
+ removedCount: 0,
712
+ size: result.data !== null ? 1 : 0,
713
+ sample: { docs: [{ path: record.target.path, data: result.data }] },
714
+ });
715
+ return;
716
+ }
717
+ // Query target.
718
+ const result = this.silentReadCollection(record.target.collection, record.auth, record.target.constraints);
719
+ if (!result.allowed) {
720
+ this.markErrored(record, result.error);
721
+ return;
722
+ }
723
+ const snap = buildQuerySnapshot({ path: record.target.collection }, result.docs, { excludesMetadataChanges: !record.options.includeMetadataChanges });
724
+ record.currentSnapshot = snap;
725
+ record.currentDocs = result.docs;
726
+ try {
727
+ record.callback(snap);
728
+ }
729
+ catch {
730
+ /* swallow — see above */
731
+ }
732
+ this.emitSnapshotDelivery({
733
+ listenerId: record.id,
734
+ target: { kind: 'query', collection: record.target.collection },
735
+ auth: record.auth,
736
+ // Initial fire: every doc surfaces as `added`.
737
+ addedCount: result.docs.length,
738
+ modifiedCount: 0,
739
+ removedCount: 0,
740
+ size: result.docs.length,
741
+ sample: { docs: result.docs.map((d) => ({ path: d.path, data: d.data })) },
742
+ });
743
+ }
744
+ // ═══ Delivery scheduler (items 3 + 5) ═══
745
+ /**
746
+ * Enqueue a listener delivery and ensure an off-stack drain is pending.
747
+ * See {@link deliveryQueue}.
748
+ */
749
+ scheduleDelivery(deliver) {
750
+ this.deliveryQueue.push(deliver);
751
+ if (this.deliveryScheduled)
752
+ return;
753
+ this.deliveryScheduled = true;
754
+ queueMicrotask(() => this.drainDeliveries());
755
+ }
756
+ /**
757
+ * Enqueue a write-driven delivery, restoring the triggering op while it
758
+ * runs so listener-origin RequestEvents / delivery events still attribute
759
+ * to the write (`triggeredBy`) even though the callback now fires off the
760
+ * writing stack. See {@link currentTrigger}.
761
+ */
762
+ scheduleTriggeredDelivery(trigger, deliver) {
763
+ this.scheduleDelivery(() => {
764
+ const prevTrigger = this.currentTrigger;
765
+ this.currentTrigger = trigger;
766
+ try {
767
+ deliver();
768
+ }
769
+ finally {
770
+ this.currentTrigger = prevTrigger;
771
+ }
772
+ });
773
+ }
774
+ /**
775
+ * Drain queued deliveries in FIFO order. A delivery may enqueue more (a
776
+ * callback that writes; the item-3 metadata ack a write echo schedules) —
777
+ * those are appended and drained in the same pass.
778
+ */
779
+ drainDeliveries() {
780
+ this.deliveryScheduled = false;
781
+ while (this.deliveryQueue.length > 0) {
782
+ const deliver = this.deliveryQueue.shift();
783
+ deliver();
784
+ }
785
+ }
786
+ /**
787
+ * Synchronously deliver all pending snapshot fires. Test-only seam:
788
+ * production consumers observe deliveries via the microtask drain, but a
789
+ * synchronous test body calls this to settle the queue deterministically
790
+ * before asserting fire counts / snapshot contents. Idempotent — a no-op
791
+ * on an empty queue, and safe when a microtask drain is also pending (that
792
+ * drain then finds the queue already empty).
793
+ */
794
+ flushListeners() {
795
+ this.drainDeliveries();
796
+ }
797
+ // ═══ Slice 3 — change-driven notification ═══
798
+ /**
799
+ * Walk every active snapshot listener and fire those whose target
800
+ * intersects `touchedPaths`. Called by the write-path commit hooks
801
+ * — `execute` (single write) and the two `applyBatch` call-sites
802
+ * (batch + transaction). Suppresses no-op snapshots per findings section 5
803
+ * (View-level suppression rather than `isEqual`): doc listeners only
804
+ * fire when the underlying data shape changes; query listeners only
805
+ * fire when the change list is non-empty.
806
+ *
807
+ * Iteration walks a snapshotted list of records — a callback is
808
+ * allowed to add or remove listeners (StrictMode + HMR routinely do)
809
+ * and we must not iterate a mutating Map.
810
+ *
811
+ * Items 3 + 5 — each per-listener fire is enqueued on the delivery
812
+ * scheduler rather than run inline, so the write echo lands off the
813
+ * writing stack (like prod's async event queue) and stays ordered behind
814
+ * any still-pending initial fire for the same listener. The errored /
815
+ * unsubscribe checks are re-run at delivery time because a listener may
816
+ * detach or error between this write and the drain.
817
+ */
818
+ notifyListenersForPaths(touchedPaths) {
819
+ if (touchedPaths.size === 0)
820
+ return;
821
+ if (this.snapshotListeners.size === 0)
822
+ return;
823
+ // Capture the triggering op now; the deliveries run off-stack, by which
824
+ // time `currentTrigger` has been restored to the microtask loop's state.
825
+ const trigger = this.currentTrigger;
826
+ const records = Array.from(this.snapshotListeners.values());
827
+ for (const record of records) {
828
+ this.scheduleTriggeredDelivery(trigger, () => {
829
+ if (!this.snapshotListeners.has(record.id))
830
+ return;
831
+ if (record.errored)
832
+ return;
833
+ if (record.target.kind === 'doc') {
834
+ this.notifyDocListener(record, touchedPaths);
835
+ }
836
+ else {
837
+ this.notifyQueryListener(record, touchedPaths);
838
+ }
839
+ });
840
+ }
841
+ }
842
+ notifyDocListener(record, touchedPaths) {
843
+ if (record.target.kind !== 'doc')
844
+ return;
845
+ if (!touchedPaths.has(record.target.path))
846
+ return;
847
+ const result = this.silentReadDoc(record.target.path, record.auth);
848
+ if (!result.allowed) {
849
+ this.markErrored(record, result.error);
850
+ return;
851
+ }
852
+ // Suppression: identical underlying data (existence + shape) ⇒ no
853
+ // fire. Production's View suppresses by absence rather than by
854
+ // building-then-comparing snapshots; we approximate the same shape
855
+ // by comparing the raw data we'd hand to `buildDocumentSnapshot`.
856
+ const prev = record.currentDocData ?? null;
857
+ if (docDataEqual(prev, result.data)) {
858
+ // Issue #307 — surface the suppressed re-eval so inspector-style
859
+ // consumers can answer "the listener woke up but had nothing to
860
+ // deliver".
861
+ this.emitSnapshotSuppressed({
862
+ listenerId: record.id,
863
+ target: { kind: 'doc', path: record.target.path },
864
+ auth: record.auth,
865
+ ...(this.currentTrigger ? { triggeredBy: this.currentTrigger } : {}),
866
+ });
867
+ return;
868
+ }
869
+ // Item 3 — the local write echo carries hasPendingWrites:true (prod's
870
+ // optimistic local fire, delivered before the server round-trip). The
871
+ // settled server ack (hasPendingWrites:false) is scheduled below, but
872
+ // only includeMetadataChanges listeners observe it — a default listener's
873
+ // last-seen snapshot stays `pending:true` (COMPAT firestore#85).
874
+ const path = record.target.path;
875
+ const snap = buildDocumentSnapshot(path, result.data, SANDBOX_METADATA_PENDING);
876
+ record.currentSnapshot = snap;
877
+ record.currentDocData = result.data;
878
+ // Compute change shape for the delivery event. Doc listeners deliver
879
+ // exactly one of added / modified / removed per fire.
880
+ const wasExists = prev !== null;
881
+ const isExists = result.data !== null;
882
+ const addedCount = !wasExists && isExists ? 1 : 0;
883
+ const removedCount = wasExists && !isExists ? 1 : 0;
884
+ const modifiedCount = wasExists && isExists ? 1 : 0;
885
+ try {
886
+ record.callback(snap);
887
+ }
888
+ catch {
889
+ /* swallow — see fireInitialSnapshot doc */
890
+ }
891
+ // Emit delivery AFTER the user callback runs so subscribers see
892
+ // the same ordering as the user code: callback first, observer second.
893
+ this.emitSnapshotDelivery({
894
+ listenerId: record.id,
895
+ target: { kind: 'doc', path },
896
+ auth: record.auth,
897
+ addedCount,
898
+ modifiedCount,
899
+ removedCount,
900
+ size: isExists ? 1 : 0,
901
+ sample: { docs: [{ path, data: result.data }] },
902
+ ...(this.currentTrigger ? { triggeredBy: this.currentTrigger } : {}),
903
+ });
904
+ this.scheduleDocMetadataAck(record, path, result.data);
905
+ }
906
+ /**
907
+ * Item 3 — schedule the server-ack fire that follows a write echo. Only
908
+ * fires for includeMetadataChanges listeners (default listeners never see
909
+ * the metadata-only ack; their snapshot stays `pending:true`). Re-delivers
910
+ * the just-echoed data with `hasPendingWrites:false`, as a metadata-only
911
+ * change (no added/modified/removed). Rides the delivery scheduler so it
912
+ * lands off the echo's stack, matching prod's async ack rather than a
913
+ * synchronous same-tick fire. `data` is captured from the echo so a later
914
+ * write can't retroactively change what this ack reports. COMPAT firestore#85.
915
+ */
916
+ scheduleDocMetadataAck(record, path, data) {
917
+ if (!record.options.includeMetadataChanges)
918
+ return;
919
+ this.scheduleTriggeredDelivery(this.currentTrigger, () => {
920
+ if (!this.snapshotListeners.has(record.id))
921
+ return;
922
+ if (record.errored)
923
+ return;
924
+ const ack = buildDocumentSnapshot(path, data, SANDBOX_METADATA);
925
+ record.currentSnapshot = ack;
926
+ try {
927
+ record.callback(ack);
928
+ }
929
+ catch {
930
+ /* swallow — see fireInitialSnapshot doc */
931
+ }
932
+ this.emitSnapshotDelivery({
933
+ listenerId: record.id,
934
+ target: { kind: 'doc', path },
935
+ auth: record.auth,
936
+ addedCount: 0,
937
+ modifiedCount: 0,
938
+ removedCount: 0,
939
+ size: data !== null ? 1 : 0,
940
+ sample: { docs: [{ path, data }] },
941
+ ...(this.currentTrigger ? { triggeredBy: this.currentTrigger } : {}),
942
+ });
943
+ });
944
+ }
945
+ notifyQueryListener(record, touchedPaths) {
946
+ if (record.target.kind !== 'query')
947
+ return;
948
+ // Cheap pre-filter: if no touched path lives in this collection,
949
+ // skip the rules eval entirely. {@link silentReadCollection}'s
950
+ // query-proof gate handles read-side visibility; this filter is
951
+ // purely a write-path optimization.
952
+ if (!anyPathInCollection(touchedPaths, record.target.collection))
953
+ return;
954
+ const result = this.silentReadCollection(record.target.collection, record.auth, record.target.constraints);
955
+ if (!result.allowed) {
956
+ this.markErrored(record, result.error);
957
+ return;
958
+ }
959
+ const collection = record.target.collection;
960
+ const prevDocs = record.currentDocs ?? [];
961
+ // Item 3 — the write echo carries hasPendingWrites:true; the settled ack
962
+ // (scheduled below for includeMetadataChanges listeners) carries false.
963
+ const snap = buildQuerySnapshot({ path: collection }, result.docs, { excludesMetadataChanges: !record.options.includeMetadataChanges }, prevDocs, SANDBOX_METADATA_PENDING);
964
+ // Suppression: empty change set ⇒ nothing observable changed for
965
+ // this listener (e.g., a write that landed under a different
966
+ // collection but tripped the cheap pre-filter, or a write whose
967
+ // post-image equals its pre-image). Match findings section 5.
968
+ const changes = snap.docChanges();
969
+ if (changes.length === 0) {
970
+ this.emitSnapshotSuppressed({
971
+ listenerId: record.id,
972
+ target: { kind: 'query', collection },
973
+ auth: record.auth,
974
+ ...(this.currentTrigger ? { triggeredBy: this.currentTrigger } : {}),
975
+ });
976
+ return;
977
+ }
978
+ record.currentSnapshot = snap;
979
+ record.currentDocs = result.docs;
980
+ let addedCount = 0, modifiedCount = 0, removedCount = 0;
981
+ for (const c of changes) {
982
+ if (c.type === 'added')
983
+ addedCount++;
984
+ else if (c.type === 'modified')
985
+ modifiedCount++;
986
+ else if (c.type === 'removed')
987
+ removedCount++;
988
+ }
989
+ try {
990
+ record.callback(snap);
991
+ }
992
+ catch {
993
+ /* swallow — see fireInitialSnapshot doc */
994
+ }
995
+ this.emitSnapshotDelivery({
996
+ listenerId: record.id,
997
+ target: { kind: 'query', collection },
998
+ auth: record.auth,
999
+ addedCount,
1000
+ modifiedCount,
1001
+ removedCount,
1002
+ size: result.docs.length,
1003
+ sample: { docs: result.docs.map((d) => ({ path: d.path, data: d.data })) },
1004
+ ...(this.currentTrigger ? { triggeredBy: this.currentTrigger } : {}),
1005
+ });
1006
+ this.scheduleQueryMetadataAck(record, collection, result.docs);
1007
+ }
1008
+ /**
1009
+ * Item 3 — query counterpart of {@link scheduleDocMetadataAck}. Re-delivers
1010
+ * the echoed doc set with `hasPendingWrites:false` as a metadata-only change
1011
+ * (no added/modified/removed — `prevDocs` equals the current docs), for
1012
+ * includeMetadataChanges listeners only. COMPAT firestore#85.
1013
+ */
1014
+ scheduleQueryMetadataAck(record, collection, docs) {
1015
+ if (!record.options.includeMetadataChanges)
1016
+ return;
1017
+ this.scheduleTriggeredDelivery(this.currentTrigger, () => {
1018
+ if (!this.snapshotListeners.has(record.id))
1019
+ return;
1020
+ if (record.errored)
1021
+ return;
1022
+ const ack = buildQuerySnapshot({ path: collection }, docs, { excludesMetadataChanges: false }, docs, SANDBOX_METADATA);
1023
+ record.currentSnapshot = ack;
1024
+ try {
1025
+ record.callback(ack);
1026
+ }
1027
+ catch {
1028
+ /* swallow — see fireInitialSnapshot doc */
1029
+ }
1030
+ this.emitSnapshotDelivery({
1031
+ listenerId: record.id,
1032
+ target: { kind: 'query', collection },
1033
+ auth: record.auth,
1034
+ addedCount: 0,
1035
+ modifiedCount: 0,
1036
+ removedCount: 0,
1037
+ size: docs.length,
1038
+ sample: { docs: docs.map((d) => ({ path: d.path, data: d.data })) },
1039
+ ...(this.currentTrigger ? { triggeredBy: this.currentTrigger } : {}),
1040
+ });
1041
+ });
1042
+ }
1043
+ /**
1044
+ * Run a `get` through the rules without touching the event log.
1045
+ * Returns the read shape used by listener-snapshot construction.
1046
+ *
1047
+ * Throws `SimulatorUnsupportedError` on UNSUPPORTED — same loud
1048
+ * surface as {@link execute}; the caller is then responsible for
1049
+ * propagating it. Listener-init paths catch nothing: an unsupported
1050
+ * rule is a sandbox limitation worth surfacing to the agent
1051
+ * verbatim, not silently rerouting through `errorCallback`.
1052
+ */
1053
+ silentReadDoc(path, auth) {
1054
+ const readServerTime = Timestamp.fromMillis(Date.now());
1055
+ const testCase = this.buildTestCase({ method: 'get', path, auth }, readServerTime);
1056
+ // Issue #307 — time the simulate call for listener-origin RequestEvents.
1057
+ const evalAt = Date.now();
1058
+ const evalStart = performance.now();
1059
+ const simResult = this.simulator.simulate(this.rulesSource, [testCase], {
1060
+ getDoc: (path) => this.state.get(path),
1061
+ });
1062
+ const evalMs = performance.now() - evalStart;
1063
+ if (!simResult.success) {
1064
+ this.emitRequest({
1065
+ at: evalAt, evalMs, method: 'get', path, auth, result: 'deny',
1066
+ debugMessages: [`Simulation error: ${simResult.error.message}`],
1067
+ origin: 'listener',
1068
+ ...(this.currentTrigger ? { triggeredBy: this.currentTrigger } : {}),
1069
+ });
1070
+ return {
1071
+ allowed: false,
1072
+ error: makeError('permission-denied', `get ${path} simulator error`, {
1073
+ request: { method: 'get', path, auth },
1074
+ }),
1075
+ };
1076
+ }
1077
+ const result = simResult.data.results[0];
1078
+ if (result.state === 'UNSUPPORTED') {
1079
+ this.emitRequest({
1080
+ at: evalAt, evalMs, method: 'get', path, auth, result: 'unsupported',
1081
+ debugMessages: renderLegacyDebugMessages(result), origin: 'listener',
1082
+ ...(this.currentTrigger ? { triggeredBy: this.currentTrigger } : {}),
1083
+ });
1084
+ throw new SimulatorUnsupportedError(unsupportedMessage('get', path, renderLegacyDebugMessages(result)), 'get', path, renderLegacyDebugMessages(result));
1085
+ }
1086
+ const isAllowed = result.state === 'PASSED';
1087
+ const data = this.state.get(path);
1088
+ this.emitRequest({
1089
+ at: evalAt, evalMs, method: 'get', path, auth,
1090
+ result: isAllowed ? 'allow' : 'deny',
1091
+ debugMessages: renderLegacyDebugMessages(result),
1092
+ evaluatedRule: projectEvaluatedRule(result), origin: 'listener',
1093
+ resourceBefore: { data, exists: data !== null },
1094
+ ...(this.currentTrigger ? { triggeredBy: this.currentTrigger } : {}),
1095
+ });
1096
+ if (!isAllowed) {
1097
+ return {
1098
+ allowed: false,
1099
+ error: makeError('permission-denied', `get ${path} denied by rules`, {
1100
+ request: { method: 'get', path, auth },
1101
+ resource: { data, exists: data !== null },
1102
+ }),
1103
+ };
1104
+ }
1105
+ return { allowed: true, data };
1106
+ }
1107
+ /**
1108
+ * RULES-B11 — the parsed rules AST for the query-proof gate, cached
1109
+ * per source string. `null` when the source doesn't parse (the
1110
+ * simulate() call then reports the failure on its own).
1111
+ */
1112
+ rulesAst() {
1113
+ if (this.parsedRulesCache?.source !== this.rulesSource) {
1114
+ this.parsedRulesCache = {
1115
+ source: this.rulesSource,
1116
+ ast: parseToAST(this.rulesSource),
1117
+ };
1118
+ }
1119
+ return this.parsedRulesCache.ast;
1120
+ }
1121
+ /**
1122
+ * Collection variant of {@link silentReadDoc}. Returns docs in
1123
+ * `LocalState.list` order. Phantom parents are dropped here;
1124
+ * agent-visible snapshots only contain real docs.
1125
+ *
1126
+ * **RULES-B11 — query-proof enforcement ("rules are not filters").**
1127
+ * Production never filters a query down to the readable subset: the
1128
+ * `list` rule is proven against the query's constraints, and an
1129
+ * unprovable query is DENIED whole (firebase.google.com/docs/firestore/
1130
+ * security/rules-query). The gate here:
1131
+ * 1. `proveListQuery` decides PROVABLE / UNPROVABLE from the matched
1132
+ * `list`/`read` rule conditions + the structured `where`
1133
+ * constraints carried on the applier (FS-B2 threading).
1134
+ * 2. UNPROVABLE → one deny request event + `permission-denied` for
1135
+ * the WHOLE query — no silent truncation.
1136
+ * 3. PROVABLE → the ordinary simulate() run evaluates the residual
1137
+ * (auth / time / request.query) condition; doc-data conjuncts the
1138
+ * query pins are evaluated against the synthetic representative
1139
+ * resource (see `list-query-proof.ts`).
1140
+ * Per-doc `get` rules do NOT filter query results — prod queries are
1141
+ * governed by the `list` rule alone (the old per-doc filter loop was
1142
+ * the rules-as-filters divergence this replaces). UNSUPPORTED still
1143
+ * bubbles through unchanged.
1144
+ */
1145
+ silentReadCollection(collection, auth, constraints) {
1146
+ const readServerTime = Timestamp.fromMillis(Date.now());
1147
+ // List rules are defined at the document-match level, so the
1148
+ // simulator expects a document-style path with a synthetic
1149
+ // placeholder segment (matches the convention used by
1150
+ // local-env-reads tests). Without it, a `list` against a bare
1151
+ // collection path falls through to no match and is denied.
1152
+ const listPath = `${collection}/__listPlaceholder__`;
1153
+ const structured = constraints?.structured ?? {};
1154
+ const requestQuery = listQueryFromStructured(structured);
1155
+ const requestDetail = requestQuery ? { query: requestQuery } : undefined;
1156
+ const evalAt = Date.now();
1157
+ const evalStart = performance.now();
1158
+ // ── RULES-B11 gate: prove the query before evaluating the rule. ──
1159
+ const proof = proveListQuery(this.rulesAst(), listPath, auth, structured);
1160
+ if (proof.kind === 'unprovable') {
1161
+ const evalMs = performance.now() - evalStart;
1162
+ const message = `list ${collection} denied: unprovable query — rules are not filters (${proof.reason})`;
1163
+ this.emitRequest({
1164
+ at: evalAt, evalMs, method: 'list', path: collection, auth, result: 'deny',
1165
+ debugMessages: [message], origin: 'listener',
1166
+ ...(requestDetail ? { detail: requestDetail } : {}),
1167
+ ...(this.currentTrigger ? { triggeredBy: this.currentTrigger } : {}),
1168
+ });
1169
+ return {
1170
+ allowed: false,
1171
+ error: makeError('permission-denied', message, {
1172
+ request: { method: 'list', path: collection, auth },
1173
+ }),
1174
+ };
1175
+ }
1176
+ const testCase = this.buildTestCase({ method: 'list', path: listPath, auth }, readServerTime);
1177
+ this.applyListProof(testCase, proof, structured);
1178
+ // Issue #307 — time the outer list eval.
1179
+ const simResult = this.simulator.simulate(this.rulesSource, [testCase], {
1180
+ getDoc: (path) => this.state.get(path),
1181
+ });
1182
+ const evalMs = performance.now() - evalStart;
1183
+ if (!simResult.success) {
1184
+ this.emitRequest({
1185
+ at: evalAt, evalMs, method: 'list', path: collection, auth, result: 'deny',
1186
+ debugMessages: [`Simulation error: ${simResult.error.message}`],
1187
+ origin: 'listener',
1188
+ ...(requestDetail ? { detail: requestDetail } : {}),
1189
+ ...(this.currentTrigger ? { triggeredBy: this.currentTrigger } : {}),
1190
+ });
1191
+ return {
1192
+ allowed: false,
1193
+ error: makeError('permission-denied', `list ${collection} simulator error`, {
1194
+ request: { method: 'list', path: collection, auth },
1195
+ }),
1196
+ };
1197
+ }
1198
+ const result = simResult.data.results[0];
1199
+ if (result.state === 'UNSUPPORTED') {
1200
+ this.emitRequest({
1201
+ at: evalAt, evalMs, method: 'list', path: collection, auth, result: 'unsupported',
1202
+ debugMessages: renderLegacyDebugMessages(result), origin: 'listener',
1203
+ ...(requestDetail ? { detail: requestDetail } : {}),
1204
+ ...(this.currentTrigger ? { triggeredBy: this.currentTrigger } : {}),
1205
+ });
1206
+ throw new SimulatorUnsupportedError(unsupportedMessage('list', collection, renderLegacyDebugMessages(result)), 'list', collection, renderLegacyDebugMessages(result));
1207
+ }
1208
+ if (result.state !== 'PASSED') {
1209
+ this.emitRequest({
1210
+ at: evalAt, evalMs, method: 'list', path: collection, auth, result: 'deny',
1211
+ debugMessages: renderLegacyDebugMessages(result),
1212
+ evaluatedRule: projectEvaluatedRule(result), origin: 'listener',
1213
+ ...(requestDetail ? { detail: requestDetail } : {}),
1214
+ ...(this.currentTrigger ? { triggeredBy: this.currentTrigger } : {}),
1215
+ });
1216
+ return {
1217
+ allowed: false,
1218
+ error: makeError('permission-denied', `list ${collection} denied by rules`, {
1219
+ request: { method: 'list', path: collection, auth },
1220
+ }),
1221
+ };
1222
+ }
1223
+ // One allow event for the whole list — one query listener fire =
1224
+ // one event in the consumer's stream.
1225
+ this.emitRequest({
1226
+ at: evalAt, evalMs, method: 'list', path: collection, auth, result: 'allow',
1227
+ debugMessages: renderLegacyDebugMessages(result),
1228
+ evaluatedRule: projectEvaluatedRule(result), origin: 'listener',
1229
+ ...(requestDetail ? { detail: requestDetail } : {}),
1230
+ ...(this.currentTrigger ? { triggeredBy: this.currentTrigger } : {}),
1231
+ });
1232
+ // RULES-B11 — the proof + list-rule eval decided the WHOLE query; no
1233
+ // per-doc `get` re-evaluation. Production queries are governed by the
1234
+ // `list` rule alone — a per-doc filter here was the rules-as-filters
1235
+ // divergence (docs the user couldn't `get` were silently omitted
1236
+ // where prod would have returned them, list rule permitting).
1237
+ const docs = this.state.list(collection)
1238
+ .filter((d) => !d.phantom)
1239
+ .map((d) => ({ path: d.path, data: d.data }));
1240
+ // FS-B2 — apply the query's where / orderBy / cursor / limit
1241
+ // constraints, so a filtered listener delivers the same membership a
1242
+ // one-shot `getDocs(query(...))` would (instead of the whole
1243
+ // collection). The proof above guaranteed the rule holds for every
1244
+ // doc the constraints admit.
1245
+ const constrained = constraints ? constraints(docs) : docs;
1246
+ return { allowed: true, docs: constrained };
1247
+ }
1248
+ /**
1249
+ * RULES-B11 — apply a PROVABLE proof to the `list` test case before the
1250
+ * residual simulate() run:
1251
+ * - inject the synthetic representative `resource` (the fields the
1252
+ * query pins with `where(field, ==, value)`) so doc-data conjuncts
1253
+ * evaluate against what every returnable doc is guaranteed to carry;
1254
+ * - populate `request.query` from the structured constraints so rules
1255
+ * reading `request.query.limit` / `.orderBy` see the real values.
1256
+ */
1257
+ applyListProof(testCase, proof, structured) {
1258
+ if (proof.kind === 'provable' && proof.syntheticResource) {
1259
+ testCase.resource = proof.syntheticResource;
1260
+ }
1261
+ if (structured.limit != null || structured.offset != null || structured.orderBy != null) {
1262
+ testCase.query = {
1263
+ ...(structured.limit != null ? { limit: structured.limit } : {}),
1264
+ ...(structured.offset != null ? { offset: structured.offset } : {}),
1265
+ ...(structured.orderBy != null ? { orderBy: structured.orderBy } : {}),
1266
+ };
1267
+ }
1268
+ }
1269
+ /**
1270
+ * Mark a listener as errored and fan the error out to two channels:
1271
+ * 1. The listener's own `errorCallback` (per-listener handler the
1272
+ * Web SDK consumer registered via `onSnapshot(next, error)`).
1273
+ * 2. Every env-level `onSnapshotError` subscriber (Slice 7) — the
1274
+ * playground UI surfaces stream errors here without each
1275
+ * listener needing to register its own toast handler.
1276
+ *
1277
+ * Fan-out is unconditional even when `errorCallback` is missing: the
1278
+ * env-level channel is the catch-all so the host environment can
1279
+ * surface errors from listeners that didn't supply their own handler.
1280
+ */
1281
+ markErrored(record, error) {
1282
+ record.errored = true;
1283
+ this.emitSnapshotError(error, record.target, record.id);
1284
+ if (!record.errorCallback)
1285
+ return;
1286
+ try {
1287
+ record.errorCallback(error);
1288
+ }
1289
+ catch {
1290
+ /* see emitDenial doc */
1291
+ }
1292
+ }
1293
+ /**
1294
+ * Test seam — exposes registry size without leaking the records.
1295
+ * Slice 2+ may add a richer accessor when the diff path needs to
1296
+ * iterate; for now this is enough to assert add/remove correctness.
1297
+ */
1298
+ getSnapshotListenerCount() {
1299
+ return this.snapshotListeners.size;
1300
+ }
1301
+ /**
1302
+ * Tear down the environment's listener registries (Slice 8).
1303
+ *
1304
+ * Clears every snapshot listener, denial subscriber, and snapshot-error
1305
+ * subscriber so the environment drops its references to consumer
1306
+ * callbacks. Use case: a host (e.g. the playground runner) is about to
1307
+ * discard this `LocalEnvironment` in favor of a fresh one — calling
1308
+ * `dispose()` first guarantees that any orphan callbacks held by the
1309
+ * outgoing env can no longer be invoked, even if some external code path
1310
+ * still holds a reference to the old instance.
1311
+ *
1312
+ * Idempotent: clearing already-empty sets is a no-op. Does not touch
1313
+ * data state (`state` / `eventLog` / `rulesSource`) — `dispose()` is
1314
+ * about *callback ownership*, not data lifecycle. Construct a fresh
1315
+ * `LocalEnvironment` (or call `seed()`) to reset data.
1316
+ */
1317
+ dispose() {
1318
+ this.snapshotListeners.clear();
1319
+ this.denialListeners.clear();
1320
+ this.snapshotErrorListeners.clear();
1321
+ this.requestListeners.clear();
1322
+ this.writeListeners.clear();
1323
+ this.deliveryListeners.clear();
1324
+ this.suppressedListeners.clear();
1325
+ this.lifecycleListeners.clear();
1326
+ // Drop any queued-but-undelivered fires so a disposed env can't invoke
1327
+ // an outgoing consumer's callback on a later microtask drain.
1328
+ this.deliveryQueue.length = 0;
1329
+ this.deliveryScheduled = false;
1330
+ }
1331
+ /** Seed the environment with rules and initial data. */
1332
+ seed(options) {
1333
+ this.rulesSource = options.rules;
1334
+ // `baseDocuments` (branch fork): wrap the snapshot as an immutable CoW base
1335
+ // instead of cloning it, so the fork is O(1). Reads fall through to the base;
1336
+ // branch writes land in the overlay.
1337
+ this.state = options.baseDocuments
1338
+ ? new LocalState({}, new OverlayBacking(options.baseDocuments))
1339
+ : new LocalState(options.documents ?? {});
1340
+ // The reset baseline. For a branch it aliases the immutable base (no clone;
1341
+ // it is already a stable snapshot). seedSnapshot has no reader today; if a
1342
+ // future reset does state.restore(seedSnapshot), clone it first so the base's
1343
+ // nested refs are not aliased back into the live keyspace.
1344
+ this.seedSnapshot = options.baseDocuments ?? this.state.snapshot();
1345
+ this.eventLog.clear();
1346
+ return lintFirestoreRules(options.rules);
1347
+ }
1348
+ /**
1349
+ * Deploy new rules (re-lint, swap for next operation).
1350
+ *
1351
+ * **Slice 6 — re-evaluation.** After a successful swap, every active
1352
+ * listener is re-evaluated under the new rules. This **diverges from
1353
+ * production** (where rule changes don't affect already-attached
1354
+ * listeners) and is intentional per the design rationale
1355
+ * section 4.1 — the playground's value is seeing rule changes reflected
1356
+ * immediately in live UI. Concretely:
1357
+ * - A doc that was unreadable but is now readable fires a snapshot
1358
+ * (and clears the listener's `errored` flag if applicable).
1359
+ * - A doc that was readable but is now unreadable marks the listener
1360
+ * errored via {@link markErrored}.
1361
+ * - Query listeners diff old↔new readable doc sets; flips surface as
1362
+ * `added` / `removed` change entries.
1363
+ */
1364
+ deployRules(source) {
1365
+ const lint = lintFirestoreRules(source);
1366
+ // **Always install.** The previous behavior gated install on
1367
+ // `errors.length === 0` and silently no-op'd otherwise — that
1368
+ // caused a 51-tool-call debug session where an agent couldn't
1369
+ // figure out why its rules weren't applied (CLAUDE_DEBUG_SESSION.md).
1370
+ // Lint is *diagnosis*, not enforcement. The dev-loop sandbox
1371
+ // installs whatever the caller asks for; the production deploy
1372
+ // gate (`pyric deploy rules`) re-lints at stricter severity and
1373
+ // refuses to ship genuinely bad rules. Callers that care about
1374
+ // the lint result still get it back; the `sandbox_inspect`
1375
+ // MCP tool surfaces it for agents.
1376
+ this.rulesSource = source;
1377
+ this.reEvaluateAllListeners();
1378
+ return lint;
1379
+ }
1380
+ /**
1381
+ * Walk every active listener and recompute its snapshot under the
1382
+ * current rules. Called by {@link deployRules} after a successful
1383
+ * rules swap (Slice 6 section 4.1). Iteration follows the same
1384
+ * snapshot-then-skip-orphans pattern as {@link notifyListenersForPaths}
1385
+ * — a callback may add or remove listeners (StrictMode + HMR both
1386
+ * routinely do this), and the dispatch loop must not iterate a
1387
+ * mutating Map.
1388
+ */
1389
+ reEvaluateAllListeners() {
1390
+ if (this.snapshotListeners.size === 0)
1391
+ return;
1392
+ const records = Array.from(this.snapshotListeners.values());
1393
+ for (const record of records) {
1394
+ if (!this.snapshotListeners.has(record.id))
1395
+ continue;
1396
+ if (record.target.kind === 'doc') {
1397
+ this.reEvaluateDocListener(record);
1398
+ }
1399
+ else {
1400
+ this.reEvaluateQueryListener(record);
1401
+ }
1402
+ }
1403
+ }
1404
+ /**
1405
+ * Re-evaluate every LIVE listener against a new session auth.
1406
+ *
1407
+ * Called when the sandbox's `currentUser` changes (sign-out / sign-in
1408
+ * as a different user). Production re-establishes the listen stream on
1409
+ * a session auth change — an auth-gated listener loses access on
1410
+ * sign-out and re-reads under the new identity on sign-in. The sandbox
1411
+ * matches that here: for each listener with `followsCurrentUser`, we
1412
+ * set `record.auth = newAuth` and re-run the SAME per-listener
1413
+ * evaluation `deployRules` uses ({@link reEvaluateDocListener} /
1414
+ * {@link reEvaluateQueryListener}) — which re-reads under the new auth
1415
+ * and flips allowed↔denied (delivering a fresh snapshot, or marking
1416
+ * the listener errored with `permission-denied` when the new auth
1417
+ * can't read).
1418
+ *
1419
+ * Frozen-ctx listeners (`followsCurrentUser === false`) are left
1420
+ * untouched — they stay pinned to the identity chosen at
1421
+ * `getFirestore(ctx)` time. WRITE-driven re-eval is unaffected: a
1422
+ * write by another user still re-evaluates each listener against ITS
1423
+ * OWN captured `auth` (this method only runs on auth change, and only
1424
+ * touches live listeners' captured auth).
1425
+ *
1426
+ * No-op when there are no live listeners. Iteration follows the same
1427
+ * snapshot-then-skip-orphans pattern as {@link notifyListenersForPaths}
1428
+ * — a callback may add or remove listeners during dispatch.
1429
+ */
1430
+ reevaluateLiveListeners(newAuth) {
1431
+ if (this.snapshotListeners.size === 0)
1432
+ return;
1433
+ const records = Array.from(this.snapshotListeners.values());
1434
+ for (const record of records) {
1435
+ if (!record.followsCurrentUser)
1436
+ continue;
1437
+ if (!this.snapshotListeners.has(record.id))
1438
+ continue;
1439
+ // Re-capture the session's new auth, then re-read under it. This is
1440
+ // the live-listener counterpart to prod re-establishing the stream
1441
+ // under the new identity.
1442
+ record.auth = newAuth;
1443
+ if (record.target.kind === 'doc') {
1444
+ this.reEvaluateDocListener(record);
1445
+ }
1446
+ else {
1447
+ this.reEvaluateQueryListener(record);
1448
+ }
1449
+ }
1450
+ }
1451
+ /**
1452
+ * Doc-listener re-evaluation. Three flip cases matter:
1453
+ * - Allowed → denied: mark errored (unless already errored, in which
1454
+ * case the error is not re-delivered — matches production's
1455
+ * once-per-stream error contract).
1456
+ * - Errored → allowed: clear `errored` and fire as an initial
1457
+ * snapshot (the listener gets a fresh baseline; suppression cannot
1458
+ * apply because there is no comparable `currentDocData` from the
1459
+ * errored state).
1460
+ * - Allowed → allowed: behaves like a write-driven re-fire — diff
1461
+ * against `currentDocData` and suppress if unchanged.
1462
+ */
1463
+ reEvaluateDocListener(record) {
1464
+ if (record.target.kind !== 'doc')
1465
+ return;
1466
+ const result = this.silentReadDoc(record.target.path, record.auth);
1467
+ if (!result.allowed) {
1468
+ if (record.errored)
1469
+ return;
1470
+ this.markErrored(record, result.error);
1471
+ return;
1472
+ }
1473
+ if (record.errored) {
1474
+ record.errored = false;
1475
+ const snap = buildDocumentSnapshot(record.target.path, result.data);
1476
+ record.currentSnapshot = snap;
1477
+ record.currentDocData = result.data;
1478
+ try {
1479
+ record.callback(snap);
1480
+ }
1481
+ catch {
1482
+ /* swallow — see fireInitialSnapshot doc */
1483
+ }
1484
+ return;
1485
+ }
1486
+ const prev = record.currentDocData ?? null;
1487
+ if (docDataEqual(prev, result.data))
1488
+ return;
1489
+ const snap = buildDocumentSnapshot(record.target.path, result.data);
1490
+ record.currentSnapshot = snap;
1491
+ record.currentDocData = result.data;
1492
+ try {
1493
+ record.callback(snap);
1494
+ }
1495
+ catch {
1496
+ /* swallow — see fireInitialSnapshot doc */
1497
+ }
1498
+ }
1499
+ /**
1500
+ * Query-listener re-evaluation. Flip semantics mirror the doc path:
1501
+ * {@link silentReadCollection} re-runs the query-proof gate + `list`
1502
+ * rule under the new rules — a query that flipped unprovable/denied
1503
+ * surfaces as a stream error, one that flipped allowed re-delivers,
1504
+ * and the diff against `currentDocs` is computed by the same
1505
+ * `buildQuerySnapshot` path the write-driven notifier uses.
1506
+ */
1507
+ reEvaluateQueryListener(record) {
1508
+ if (record.target.kind !== 'query')
1509
+ return;
1510
+ const result = this.silentReadCollection(record.target.collection, record.auth, record.target.constraints);
1511
+ if (!result.allowed) {
1512
+ if (record.errored)
1513
+ return;
1514
+ this.markErrored(record, result.error);
1515
+ return;
1516
+ }
1517
+ if (record.errored) {
1518
+ record.errored = false;
1519
+ // No prevDocs — every readable doc surfaces as `added`, matching
1520
+ // initial-fire semantics. The errored state had no comparable
1521
+ // baseline, so a clean reset is the correct contract.
1522
+ const snap = buildQuerySnapshot({ path: record.target.collection }, result.docs, { excludesMetadataChanges: !record.options.includeMetadataChanges });
1523
+ record.currentSnapshot = snap;
1524
+ record.currentDocs = result.docs;
1525
+ try {
1526
+ record.callback(snap);
1527
+ }
1528
+ catch {
1529
+ /* swallow — see fireInitialSnapshot doc */
1530
+ }
1531
+ return;
1532
+ }
1533
+ const prevDocs = record.currentDocs ?? [];
1534
+ const snap = buildQuerySnapshot({ path: record.target.collection }, result.docs, { excludesMetadataChanges: !record.options.includeMetadataChanges }, prevDocs);
1535
+ const changes = snap.docChanges();
1536
+ if (changes.length === 0)
1537
+ return;
1538
+ record.currentSnapshot = snap;
1539
+ record.currentDocs = result.docs;
1540
+ try {
1541
+ record.callback(snap);
1542
+ }
1543
+ catch {
1544
+ /* swallow — see fireInitialSnapshot doc */
1545
+ }
1546
+ }
1547
+ /** Get current rules source. */
1548
+ getRules() {
1549
+ return this.rulesSource;
1550
+ }
1551
+ // ═══ Read operations (bypass rules — admin access) ═══
1552
+ /** Read a document from local state (admin, no rules). */
1553
+ getDocument(path) {
1554
+ return this.state.get(path);
1555
+ }
1556
+ /**
1557
+ * List documents in a collection (admin, no rules).
1558
+ *
1559
+ * Includes phantom parent docs (records synthesized for any parent path
1560
+ * that has descendants but no stored data of its own). Phantom records
1561
+ * carry `phantom: true` and `data: {}` so the discover crawler — which
1562
+ * walks structure rather than data — sees the same shape live Firestore
1563
+ * would expose. See {@link LocalState#list} for the contract.
1564
+ */
1565
+ listDocuments(collection) {
1566
+ return this.state.list(collection);
1567
+ }
1568
+ /**
1569
+ * Scan documents under a path through the {@link DocStore} seam (admin, no
1570
+ * rules). The query engine gathers candidates with `{ directOnly: true }` for
1571
+ * a collection's real direct children (no phantoms); the discover crawler
1572
+ * still uses {@link listDocuments}, which adds phantom parents.
1573
+ */
1574
+ scanDocuments(prefix, opts) {
1575
+ return this.state.scan(prefix, opts);
1576
+ }
1577
+ /**
1578
+ * Rule-enforced collection read — the query (`getDocs` / `Query.get` /
1579
+ * aggregate) read path for the web-modular + auth-scoped admin-compat
1580
+ * surfaces. **Unlike {@link listDocuments} this evaluates security
1581
+ * rules** (FS-B1 / RULES-B1): query reads must not be a silent total
1582
+ * bypass when single-doc reads (`DocumentReference.get`) are enforced.
1583
+ *
1584
+ * Semantics mirror the listener read path ({@link silentReadCollection}):
1585
+ * 1. RULES-B11 — prove the query against the matched `list` rule
1586
+ * ("rules are not filters"): an unprovable query returns
1587
+ * `{ allowed: false, error }` for the WHOLE query — the caller
1588
+ * throws `permission-denied`. No silent truncation.
1589
+ * 2. Evaluate the `list` rule's residual condition once under `auth`
1590
+ * (with the synthetic representative resource when the proof
1591
+ * pinned doc-data fields). A denial (or simulator error) returns
1592
+ * `{ allowed: false, error }`.
1593
+ * 3. On PASS every candidate is returned — production queries are
1594
+ * governed by the `list` rule alone; per-doc `get` rules do not
1595
+ * filter query results.
1596
+ *
1597
+ * `candidates` is supplied by the caller so collection-group queries
1598
+ * (whose candidate set is a cross-collection scan, not a single
1599
+ * `state.list`) can reuse the same enforcement. `listPath` is the
1600
+ * collection path the `list` rule evaluates against; for
1601
+ * collection-group reads the caller passes the group id's match path.
1602
+ * `query` is the structured `where`/`limit`/`orderBy` view the proof
1603
+ * consumes (the caller still applies the actual row filtering).
1604
+ *
1605
+ * Emits `origin: 'user'` request events (one per `list`) so inspector
1606
+ * consumers see query reads the same way they see writes. UNSUPPORTED
1607
+ * rules still bubble as {@link SimulatorUnsupportedError}.
1608
+ */
1609
+ readQueryCandidates(candidates, listPath, auth, query, bypassRules) {
1610
+ const structured = query ?? {};
1611
+ const requestQuery = listQueryFromStructured(structured);
1612
+ const requestDetail = {
1613
+ ...(bypassRules ? { admin: true } : {}),
1614
+ ...(requestQuery ? { query: requestQuery } : {}),
1615
+ };
1616
+ const detail = Object.keys(requestDetail).length > 0 ? requestDetail : undefined;
1617
+ // Studio admin lens (Gap #2): skip the query-proof gate + `list` rule
1618
+ // eval entirely and return every candidate. The proof model ("rules
1619
+ // are not filters") doesn't apply when rules are off — admin sees the
1620
+ // whole collection. Emit a single `allow` list event so the read still
1621
+ // shows up on the traffic log, mirroring the rule-allowed branch below.
1622
+ if (bypassRules) {
1623
+ this.emitRequest({
1624
+ at: Date.now(), evalMs: 0, method: 'list', path: listPath, auth, result: 'allow',
1625
+ debugMessages: ['admin lens — rules bypassed (Studio Gap #2)'], origin: 'user',
1626
+ ...(detail ? { detail } : {}),
1627
+ });
1628
+ return { allowed: true, docs: candidates };
1629
+ }
1630
+ const readServerTime = Timestamp.fromMillis(Date.now());
1631
+ // List rules match at the document level, so the `list` eval needs a
1632
+ // document-style path with a synthetic placeholder segment (same
1633
+ // convention as silentReadCollection).
1634
+ const placeholderPath = `${listPath}/__listPlaceholder__`;
1635
+ const evalAt = Date.now();
1636
+ const evalStart = performance.now();
1637
+ // ── RULES-B11 gate: prove the query before evaluating the rule. ──
1638
+ const proof = proveListQuery(this.rulesAst(), placeholderPath, auth, structured);
1639
+ if (proof.kind === 'unprovable') {
1640
+ const evalMs = performance.now() - evalStart;
1641
+ const message = `list ${listPath} denied: unprovable query — rules are not filters (${proof.reason})`;
1642
+ this.emitRequest({
1643
+ at: evalAt, evalMs, method: 'list', path: listPath, auth, result: 'deny',
1644
+ debugMessages: [message], origin: 'user',
1645
+ ...(detail ? { detail } : {}),
1646
+ });
1647
+ const error = makeError('permission-denied', message, {
1648
+ request: { method: 'list', path: listPath, auth },
1649
+ });
1650
+ this.emitDenial(error);
1651
+ return { allowed: false, error };
1652
+ }
1653
+ const testCase = this.buildTestCase({ method: 'list', path: placeholderPath, auth }, readServerTime);
1654
+ this.applyListProof(testCase, proof, structured);
1655
+ const simResult = this.simulator.simulate(this.rulesSource, [testCase], {
1656
+ getDoc: (path) => this.state.get(path),
1657
+ });
1658
+ const evalMs = performance.now() - evalStart;
1659
+ if (!simResult.success) {
1660
+ this.emitRequest({
1661
+ at: evalAt, evalMs, method: 'list', path: listPath, auth, result: 'deny',
1662
+ debugMessages: [`Simulation error: ${simResult.error.message}`], origin: 'user',
1663
+ ...(detail ? { detail } : {}),
1664
+ });
1665
+ return {
1666
+ allowed: false,
1667
+ error: makeError('permission-denied', `list ${listPath} simulator error`, {
1668
+ request: { method: 'list', path: listPath, auth },
1669
+ }),
1670
+ };
1671
+ }
1672
+ const result = simResult.data.results[0];
1673
+ if (result.state === 'UNSUPPORTED') {
1674
+ this.emitRequest({
1675
+ at: evalAt, evalMs, method: 'list', path: listPath, auth, result: 'unsupported',
1676
+ debugMessages: renderLegacyDebugMessages(result), origin: 'user',
1677
+ ...(detail ? { detail } : {}),
1678
+ });
1679
+ throw new SimulatorUnsupportedError(unsupportedMessage('list', listPath, renderLegacyDebugMessages(result)), 'list', listPath, renderLegacyDebugMessages(result));
1680
+ }
1681
+ if (result.state !== 'PASSED') {
1682
+ const debugMessages = renderLegacyDebugMessages(result);
1683
+ this.emitRequest({
1684
+ at: evalAt, evalMs, method: 'list', path: listPath, auth, result: 'deny',
1685
+ debugMessages, evaluatedRule: projectEvaluatedRule(result), origin: 'user',
1686
+ ...(detail ? { detail } : {}),
1687
+ });
1688
+ const error = makeError('permission-denied', `list ${listPath} denied by rules`, {
1689
+ request: { method: 'list', path: listPath, auth },
1690
+ });
1691
+ this.emitDenial(error);
1692
+ return { allowed: false, error };
1693
+ }
1694
+ // List allowed — one allow event covers the whole query read.
1695
+ this.emitRequest({
1696
+ at: evalAt, evalMs, method: 'list', path: listPath, auth, result: 'allow',
1697
+ debugMessages: renderLegacyDebugMessages(result),
1698
+ evaluatedRule: projectEvaluatedRule(result), origin: 'user',
1699
+ ...(detail ? { detail } : {}),
1700
+ });
1701
+ // RULES-B11 — no per-doc `get` filtering: the proof + list rule
1702
+ // decided the whole query (prod's model — the old per-doc loop was
1703
+ // the rules-as-filters divergence this replaces).
1704
+ return { allowed: true, docs: candidates };
1705
+ }
1706
+ /**
1707
+ * List root collection IDs derived from the in-memory keyspace.
1708
+ * Used by the discover/crawler adapter so `firestore_discover_paths`
1709
+ * can run against the simulator without hitting live Firestore.
1710
+ */
1711
+ listRootCollections() {
1712
+ return this.state.listRootCollections();
1713
+ }
1714
+ /**
1715
+ * List subcollection IDs underneath the given document path.
1716
+ * Companion to {@link listRootCollections} for the discover/crawler adapter.
1717
+ */
1718
+ listSubcollections(docPath) {
1719
+ return this.state.listSubcollections(docPath);
1720
+ }
1721
+ /** Get full state snapshot. */
1722
+ snapshot() {
1723
+ return this.state.snapshot();
1724
+ }
1725
+ /**
1726
+ * Capture the prior state of just the given paths (for single-write / batch
1727
+ * undo), as `{ path: priorData | null }` where `null` records a doc that did
1728
+ * not exist. The affected-path alternative to a whole-keyspace
1729
+ * {@link snapshot}, so the undo stack stays O(affected) not O(keyspace).
1730
+ * Must be called BEFORE the write applies.
1731
+ */
1732
+ capturePriors(paths) {
1733
+ const priors = {};
1734
+ for (const path of paths) {
1735
+ const prior = this.state.get(path);
1736
+ priors[path] = prior ? { ...prior } : null;
1737
+ }
1738
+ return priors;
1739
+ }
1740
+ // ═══ Write operations (bypass rules — admin access) ═══
1741
+ /**
1742
+ * Replace a document at `path` (admin, no rules). `set` semantics —
1743
+ * creates if absent, overwrites if present, no merge. Wakes any
1744
+ * active listeners attached to `path` so subscriptions see the
1745
+ * change live, mirroring what a rule-allowed `set` would do.
1746
+ */
1747
+ adminSetDocument(path, data) {
1748
+ this.state.set(path, data ?? {});
1749
+ this.notifyListenersForPaths(new Set([path]));
1750
+ }
1751
+ /**
1752
+ * Delete a document at `path` (admin, no rules). Returns
1753
+ * `{ deleted }` reflecting whether a doc was actually removed
1754
+ * (`false` if the path didn't exist). Idempotent — a no-op on a
1755
+ * missing path does not throw. Wakes listeners on `path`.
1756
+ */
1757
+ adminDeleteDocument(path) {
1758
+ const r = this.state.delete(path);
1759
+ if (r.success) {
1760
+ this.notifyListenersForPaths(new Set([path]));
1761
+ }
1762
+ return { deleted: r.success };
1763
+ }
1764
+ /**
1765
+ * Run the rules engine for the given test cases — UNLESS `bypassRules`
1766
+ * is set, in which case the rules engine is skipped entirely and a
1767
+ * synthetic all-ALLOW result is returned (Pyric Studio Gap #2 admin
1768
+ * lens). Every `simulate()` call site in `execute` / `batch` /
1769
+ * `transaction` routes through here so the bypass is centralized and
1770
+ * the rules-enforced path is byte-for-byte unchanged when `bypassRules`
1771
+ * is absent/false.
1772
+ *
1773
+ * The synthetic result mirrors the engine's success shape
1774
+ * (`{ success: true, data: { results, passed, failed, unsupported } }`)
1775
+ * with one PASSED entry per test case, so callers that read
1776
+ * `simResult.data.results[i]` see a normal ALLOW with no special-casing.
1777
+ */
1778
+ runSimulate(testCases, bypassRules) {
1779
+ if (bypassRules) {
1780
+ const results = testCases.map((tc) => adminBypassResult(tc.description));
1781
+ return {
1782
+ success: true,
1783
+ data: { passed: results.length, failed: 0, unsupported: 0, results },
1784
+ };
1785
+ }
1786
+ return this.simulator.simulate(this.rulesSource, testCases, {
1787
+ getDoc: (path) => this.state.get(path),
1788
+ });
1789
+ }
1790
+ // ═══ Single operation (rules evaluated) ═══
1791
+ /** Execute a single operation. Rules are evaluated against local state.
1792
+ * When `operation.bypassRules` is set, rule evaluation is skipped (admin
1793
+ * lens — Studio Gap #2): the op is treated as ALLOW and routed through
1794
+ * the same apply + emit path, so structural preconditions, events, and
1795
+ * listeners behave exactly as a rule-allowed op would. */
1796
+ execute(operation) {
1797
+ const { method, path, auth, data, autoId, requestTime: pinnedRequestTime, merge, bypassRules } = operation;
1798
+ const detail = bypassRules ? { admin: true } : undefined;
1799
+ // Reads — evaluate rules (denied reads return no data)
1800
+ if (method === 'get' || method === 'list') {
1801
+ // No data to resolve on reads, but still pin a serverTime so the
1802
+ // handler's `request.time` is deterministic relative to anything
1803
+ // observed by debug messages (Item 1).
1804
+ const readServerTime = Timestamp.fromMillis(Date.now());
1805
+ const testCase = this.buildTestCase(operation, readServerTime);
1806
+ // Issue #307 — time the simulate call for RequestEvent.evalMs.
1807
+ const evalAt = Date.now();
1808
+ const evalStart = performance.now();
1809
+ const simResult = this.runSimulate([testCase], bypassRules);
1810
+ const evalMs = performance.now() - evalStart;
1811
+ if (!simResult.success) {
1812
+ const event = this.eventLog.append({
1813
+ type: 'single', method, path, auth: auth ? { uid: auth.uid } : null,
1814
+ allowed: false, debugMessages: [`Simulation error: ${simResult.error.message}`],
1815
+ });
1816
+ // Issue #307 — simulator failures are still requests worth surfacing.
1817
+ this.emitRequest({
1818
+ at: evalAt, evalMs, method, path, auth, result: 'deny',
1819
+ debugMessages: [`Simulation error: ${simResult.error.message}`],
1820
+ origin: 'user',
1821
+ ...(detail ? { detail } : {}),
1822
+ });
1823
+ return { allowed: false, debugMessages: [simResult.error.message], event };
1824
+ }
1825
+ const result = simResult.data.results[0];
1826
+ if (result.state === 'UNSUPPORTED') {
1827
+ // Issue #307 — surface the eval-time event BEFORE throwing so
1828
+ // subscribers see the unsupported request alongside everything else.
1829
+ this.emitRequest({
1830
+ at: evalAt, evalMs, method, path, auth, result: 'unsupported',
1831
+ debugMessages: renderLegacyDebugMessages(result), origin: 'user',
1832
+ ...(detail ? { detail } : {}),
1833
+ });
1834
+ throw new SimulatorUnsupportedError(unsupportedMessage(method, path, renderLegacyDebugMessages(result)), method, path, renderLegacyDebugMessages(result));
1835
+ }
1836
+ const isAllowed = result.state === 'PASSED';
1837
+ let readData;
1838
+ if (isAllowed) {
1839
+ readData = method === 'get' ? this.state.get(path) : this.state.list(path);
1840
+ }
1841
+ const event = this.eventLog.append({
1842
+ type: 'single', method, path, auth: auth ? { uid: auth.uid } : null,
1843
+ allowed: isAllowed, debugMessages: renderLegacyDebugMessages(result),
1844
+ });
1845
+ // Item 6: reads only fail with permission-denied (no structural
1846
+ // not-found here — read of a missing doc is allowed-with-empty
1847
+ // by Firestore's contract; the rule decides visibility).
1848
+ const out = {
1849
+ allowed: isAllowed,
1850
+ data: isAllowed ? readData : undefined,
1851
+ debugMessages: renderLegacyDebugMessages(result),
1852
+ event,
1853
+ };
1854
+ if (!isAllowed) {
1855
+ // Item 6+: surface the eval-time request + resource on the
1856
+ // error so callers (sandbox / playground) can render a "why
1857
+ // did this denial happen" frame without re-deriving state.
1858
+ // For `list`, `resource` is intentionally omitted — the rule
1859
+ // evaluated against a collection, not a single doc.
1860
+ const reqRead = { method, path, auth };
1861
+ const resRead = method === 'get'
1862
+ ? { data: this.state.get(path), exists: this.state.get(path) !== null }
1863
+ : undefined;
1864
+ out.error = makeError('permission-denied', `${method} ${path} denied by rules`, { request: reqRead, ...(resRead ? { resource: resRead } : {}) });
1865
+ this.emitDenial(out.error);
1866
+ }
1867
+ // Issue #307 — emit the request event for every read, allow or deny.
1868
+ // resourceBefore mirrors what the rule saw on `resource`: populated for
1869
+ // `get` (the single doc); omitted for `list` (the rule didn't evaluate
1870
+ // against a single resource).
1871
+ this.emitRequest({
1872
+ at: evalAt, evalMs, method, path, auth,
1873
+ result: isAllowed ? 'allow' : 'deny',
1874
+ debugMessages: renderLegacyDebugMessages(result),
1875
+ evaluatedRule: projectEvaluatedRule(result),
1876
+ origin: 'user',
1877
+ ...(method === 'get'
1878
+ ? { resourceBefore: { data: this.state.get(path), exists: this.state.get(path) !== null } }
1879
+ : {}),
1880
+ ...(detail ? { detail } : {}),
1881
+ });
1882
+ return out;
1883
+ }
1884
+ // Write operations: evaluate rules. Capture only this path's prior state
1885
+ // for undo (single write touches one doc); `snapshot[path]` reads below stay
1886
+ // valid since the affected path is present, and undo stays O(1) not O(keyspace).
1887
+ const snapshot = this.capturePriors([path]);
1888
+ // Item 1: pin a single serverTime for this write. Both the resolver
1889
+ // (for any serverTimestamp sentinels in `data`) and the handler (for
1890
+ // `request.time`) must see field-equal values, otherwise rules like
1891
+ // `data.createdAt == request.time` flake on sub-millisecond drift.
1892
+ // Replay engine: `operation.requestTime` (when provided) overrides
1893
+ // Date.now() so the rule eval re-evaluates against the captured
1894
+ // wall-clock instant, eliminating time-drift on replay.
1895
+ const serverTime = pinnedRequestTime ?? Timestamp.fromMillis(Date.now());
1896
+ // Resolve the write payload BEFORE rule evaluation so rules see the
1897
+ // same shape storage will see (Item 0: write-boundary value-resolve).
1898
+ // LocalState will resolve again in applyWrite — converters are
1899
+ // required to be idempotent so the second pass is a no-op.
1900
+ //
1901
+ // Item 2: a converter (e.g., `increment` against a string-typed
1902
+ // prior) may throw. Surface as a denial — the agent's rule was
1903
+ // never given a chance to evaluate, but the operation is rejected.
1904
+ let resolvedData;
1905
+ try {
1906
+ resolvedData = data
1907
+ ? resolveValueTree({ ...data }, {
1908
+ path,
1909
+ method: method,
1910
+ prior: this.state.get(path),
1911
+ serverTime,
1912
+ })
1913
+ : data;
1914
+ // FS-B13 — `deleteField()` may only appear at the top level of an
1915
+ // `update` (whole field value or dot-path key); nested in a map
1916
+ // literal it is invalid and prod throws `invalid-argument` rather than
1917
+ // silently destroying the sibling map. (`set`/`create` without merge
1918
+ // resolve deleteField via partitionDeletes, which the dispatch below
1919
+ // handles; the merge path adds it to the field mask.)
1920
+ if (method === 'update' && resolvedData) {
1921
+ assertNoNestedDeleteField(resolvedData);
1922
+ }
1923
+ }
1924
+ catch (e) {
1925
+ const msg = e.message;
1926
+ const event = this.eventLog.append({
1927
+ type: 'single', method, path, auth: auth ? { uid: auth.uid } : null,
1928
+ data, allowed: false, priorDocs: snapshot,
1929
+ debugMessages: [`FieldValue resolve error: ${msg}`],
1930
+ });
1931
+ // Issue #307 — sentinel-resolution failures never reached the rules
1932
+ // engine but the user's op still produced a denial. evalMs is 0
1933
+ // because no simulate call happened.
1934
+ this.emitRequest({
1935
+ at: Date.now(), evalMs: 0, method, path, auth, result: 'deny',
1936
+ debugMessages: [`FieldValue resolve error: ${msg}`],
1937
+ ...(resolvedData ? { resourceData: resolvedData } : data ? { resourceData: data } : {}),
1938
+ resourceBefore: { data: snapshot[path] ?? null, exists: (snapshot[path] ?? null) !== null },
1939
+ origin: 'user',
1940
+ ...(detail ? { detail } : {}),
1941
+ });
1942
+ // Item 6: a sentinel-resolution throw maps to `invalid-argument`.
1943
+ // The admin SDK throws the same code when a FieldValue is malformed
1944
+ // for the prior data shape (e.g., increment on a non-number).
1945
+ return {
1946
+ allowed: false,
1947
+ debugMessages: [msg],
1948
+ event,
1949
+ error: makeError('invalid-argument', msg),
1950
+ };
1951
+ }
1952
+ const testCase = this.buildTestCase({ ...operation, data: resolvedData }, serverTime);
1953
+ // Issue #307 — time the simulate call for RequestEvent.evalMs.
1954
+ const evalAt = Date.now();
1955
+ const evalStart = performance.now();
1956
+ const simResult = this.runSimulate([testCase], bypassRules);
1957
+ const evalMs = performance.now() - evalStart;
1958
+ if (!simResult.success) {
1959
+ const event = this.eventLog.append({
1960
+ type: 'single', method, path, auth: auth ? { uid: auth.uid } : null,
1961
+ data: resolvedData, allowed: false, priorDocs: snapshot,
1962
+ debugMessages: [`Simulation error: ${simResult.error.message}`],
1963
+ });
1964
+ this.emitRequest({
1965
+ at: evalAt, evalMs, method, path, auth, result: 'deny',
1966
+ debugMessages: [`Simulation error: ${simResult.error.message}`],
1967
+ ...(data ? { resourceData: data } : {}),
1968
+ resourceBefore: { data: snapshot[path] ?? null, exists: (snapshot[path] ?? null) !== null },
1969
+ origin: 'user',
1970
+ ...(detail ? { detail } : {}),
1971
+ });
1972
+ // Item 6: a simulator-internal failure isn't a rules denial — map
1973
+ // it to invalid-argument so callers can distinguish "the rule
1974
+ // text or test case is wrong" from "the rule denied your write".
1975
+ return {
1976
+ allowed: false,
1977
+ debugMessages: [simResult.error.message],
1978
+ event,
1979
+ error: makeError('invalid-argument', simResult.error.message),
1980
+ };
1981
+ }
1982
+ const result = simResult.data.results[0];
1983
+ if (result.state === 'UNSUPPORTED') {
1984
+ this.emitRequest({
1985
+ at: evalAt, evalMs, method, path, auth, result: 'unsupported',
1986
+ debugMessages: renderLegacyDebugMessages(result),
1987
+ ...(data ? { resourceData: data } : {}),
1988
+ resourceBefore: { data: snapshot[path] ?? null, exists: (snapshot[path] ?? null) !== null },
1989
+ origin: 'user',
1990
+ ...(detail ? { detail } : {}),
1991
+ });
1992
+ throw new SimulatorUnsupportedError(unsupportedMessage(method, path, renderLegacyDebugMessages(result)), method, path, renderLegacyDebugMessages(result));
1993
+ }
1994
+ // The simulation returns PASSED if the outcome matches the expectation.
1995
+ // Since we always set expectation to ALLOW, PASSED = allowed, FAILED = denied.
1996
+ let isAllowed = result.state === 'PASSED';
1997
+ let writeError = null;
1998
+ if (isAllowed) {
1999
+ // Item 6: rules said yes; the keyspace may still say no (create-
2000
+ // already-exists, update/delete-missing). applyWrite returns the
2001
+ // structural error if so. Demote `allowed` and surface the code
2002
+ // — matches prod, which evaluates rules then preconditions and
2003
+ // returns the precondition error when it loses.
2004
+ writeError = this.applyWrite(method, path, resolvedData, merge);
2005
+ if (writeError)
2006
+ isAllowed = false;
2007
+ }
2008
+ const event = this.eventLog.append({
2009
+ type: 'single', method, path, auth: auth ? { uid: auth.uid } : null,
2010
+ data: resolvedData, allowed: isAllowed, priorDocs: isAllowed ? snapshot : undefined,
2011
+ debugMessages: renderLegacyDebugMessages(result),
2012
+ });
2013
+ const out = {
2014
+ allowed: isAllowed,
2015
+ debugMessages: renderLegacyDebugMessages(result),
2016
+ event,
2017
+ };
2018
+ if (!isAllowed) {
2019
+ // Structural error wins over a synthesized permission-denied —
2020
+ // it's the more specific signal. The structural-error branch
2021
+ // skips eval context (already-exists / not-found don't depend on
2022
+ // auth or resource shape).
2023
+ if (writeError) {
2024
+ out.error = writeError;
2025
+ }
2026
+ else {
2027
+ const priorDoc = snapshot[path] ?? null;
2028
+ // `set` denials surface under the rule clause that actually
2029
+ // ran — `create` for absent docs, `update` for existing ones
2030
+ // — so downstream consumers reading `error.request.method`
2031
+ // see the same value the rules engine saw.
2032
+ const evalMethod = method === 'set'
2033
+ ? (priorDoc !== null ? 'update' : 'create')
2034
+ : method;
2035
+ out.error = makeError('permission-denied', `${method} ${path} denied by rules`, {
2036
+ request: {
2037
+ method: evalMethod,
2038
+ path,
2039
+ auth,
2040
+ ...(data ? { resourceData: data } : {}),
2041
+ },
2042
+ resource: { data: priorDoc, exists: priorDoc !== null },
2043
+ });
2044
+ this.emitDenial(out.error);
2045
+ }
2046
+ }
2047
+ // Issue #307 — emit the request event before fan-out so subscribers
2048
+ // see the user-origin event before any listener-origin events that
2049
+ // notifyListenersForPaths will spawn. resourceAfter is the post-write
2050
+ // state when the write committed; for denials/structural-errors it's
2051
+ // the unchanged prior (matches what callers see on rollback).
2052
+ const priorDoc = snapshot[path] ?? null;
2053
+ const finalDoc = isAllowed ? this.state.get(path) : priorDoc;
2054
+ this.emitRequest({
2055
+ at: evalAt, evalMs, method, path, auth,
2056
+ result: isAllowed ? 'allow' : 'deny',
2057
+ debugMessages: renderLegacyDebugMessages(result),
2058
+ evaluatedRule: projectEvaluatedRule(result),
2059
+ ...(data ? { resourceData: data } : {}),
2060
+ resourceBefore: { data: priorDoc, exists: priorDoc !== null },
2061
+ ...(method !== 'delete'
2062
+ ? { resourceAfter: { data: finalDoc, exists: finalDoc !== null } }
2063
+ : { resourceAfter: { data: null, exists: false } }),
2064
+ origin: 'user',
2065
+ ...(detail ? { detail } : {}),
2066
+ });
2067
+ // Issue #307 — emit a committed-write event for the post-apply state.
2068
+ // Only fires on successful commit; rule denials and structural errors
2069
+ // surface as the request-deny RequestEvent above. `method` is already
2070
+ // narrowed to write verbs by this point (reads return earlier).
2071
+ if (isAllowed) {
2072
+ // Sentinels extracted from the PRE-resolution `data` (in scope from
2073
+ // the operation destructure); needed for replay so the engine can
2074
+ // re-issue the same FieldValue.* markers without consulting the
2075
+ // resolved values.
2076
+ const sentinels = data ? walkForSentinels(data) : undefined;
2077
+ // Auto-id signal: createWithAutoId sets operation.autoId=true.
2078
+ // The last path segment IS the minted id; capture it so replay
2079
+ // mints a fresh one.
2080
+ const mintedAutoId = autoId && method === 'create' ? path.split('/').pop() : undefined;
2081
+ this.emitWrite({
2082
+ method: method,
2083
+ path,
2084
+ auth,
2085
+ ...(method !== 'delete' && data ? { data } : {}),
2086
+ priorState: priorDoc,
2087
+ nextState: method === 'delete' ? null : finalDoc,
2088
+ ...(sentinels && sentinels.length > 0 ? { sentinels } : {}),
2089
+ ...(mintedAutoId ? { autoId: mintedAutoId } : {}),
2090
+ requestTime: serverTime,
2091
+ ...(detail ? { detail } : {}),
2092
+ });
2093
+ }
2094
+ // Slice 3 — fan out the write to any matching snapshot listeners.
2095
+ // Only fires on a successful commit; rule denials and structural
2096
+ // errors leave state unchanged so listeners have nothing to see.
2097
+ // Method-aware: list/get never reach this branch (the early
2098
+ // read-return above), so anything getting here is a write whose
2099
+ // path is the touched key.
2100
+ if (isAllowed) {
2101
+ // Issue #307 — set the trigger so listener re-eval emits can
2102
+ // attribute themselves to this user op via `triggeredBy`.
2103
+ // Save/restore (not clear-on-finally) because a listener callback
2104
+ // may itself call execute() — that nested call's finally would
2105
+ // otherwise wipe our trigger before subsequent listeners fire.
2106
+ const prevTrigger = this.currentTrigger;
2107
+ this.currentTrigger = { method, path };
2108
+ try {
2109
+ this.notifyListenersForPaths(new Set([path]));
2110
+ }
2111
+ finally {
2112
+ this.currentTrigger = prevTrigger;
2113
+ }
2114
+ }
2115
+ return out;
2116
+ }
2117
+ // ═══ Auto-ID create ═══
2118
+ /**
2119
+ * Item 7 — `addDoc()`-style create with a Firestore-compatible auto ID.
2120
+ *
2121
+ * Mirrors the live SDK's `addDoc(collection(db, c), data)` flow: mint a
2122
+ * 20-char alphanumeric document ID, append it to the collection path,
2123
+ * then run the same `execute({ method: 'create', ... })` pipeline that
2124
+ * an explicit-ID create would (rules, sentinel resolution, applyWrite).
2125
+ * The minted path is returned so callers can re-read the doc without
2126
+ * having to capture the ID via a side channel.
2127
+ */
2128
+ createWithAutoId(collection, data, auth, bypassRules) {
2129
+ const trimmed = collection.endsWith('/') ? collection.slice(0, -1) : collection;
2130
+ const id = generateAutoId();
2131
+ const path = `${trimmed}/${id}`;
2132
+ // Signal that this create came via auto-id minting so emitWrite
2133
+ // populates WriteSandboxEvent.autoId. The replay engine reads this
2134
+ // to know the path's last segment should alias to a fresh mint on
2135
+ // replay rather than reuse the original ID.
2136
+ const result = this.execute({ method: 'create', path, auth, data, autoId: true, bypassRules });
2137
+ return { path, result };
2138
+ }
2139
+ // ═══ Batch operations ═══
2140
+ /** Execute multiple writes atomically. All must pass rules or none apply. */
2141
+ batch(operations, auth, bypassRules) {
2142
+ const detail = bypassRules ? { admin: true } : undefined;
2143
+ // Capture priors for just the operations' paths (undo is O(affected)).
2144
+ const snapshot = this.capturePriors(operations.map((o) => o.path));
2145
+ const results = [];
2146
+ // Item 1: one serverTime for the whole batch — all sentinels and
2147
+ // every per-op `request.time` resolve to the same wall-clock instant.
2148
+ const serverTime = Timestamp.fromMillis(Date.now());
2149
+ // Issue #307 — shared groupId so the consumer can fold sub-ops into
2150
+ // a single batch row.
2151
+ const groupId = `batch-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`;
2152
+ // Track per-op events to emit (one per resolved op). We build them
2153
+ // lazily and dispatch after applyBatch so resourceAfter reflects the
2154
+ // committed (or rolled-back) state, matching execute()'s ordering.
2155
+ const pendingEmits = [];
2156
+ // Resolve every op's payload up front against CURRENT state (no
2157
+ // cross-visibility, mirroring how the rules pass evaluates them).
2158
+ // Item 0: write-boundary value-resolve. Item 2: sentinels in batch
2159
+ // ops resolve against the right prior; a converter throw rejects
2160
+ // the whole batch (atomic semantics).
2161
+ const resolvedOps = [];
2162
+ for (const op of operations) {
2163
+ try {
2164
+ resolvedOps.push({
2165
+ ...op,
2166
+ data: op.data
2167
+ ? resolveValueTree({ ...op.data }, {
2168
+ path: op.path,
2169
+ method: op.method,
2170
+ prior: this.state.get(op.path),
2171
+ serverTime,
2172
+ })
2173
+ : op.data,
2174
+ });
2175
+ }
2176
+ catch (e) {
2177
+ const msg = e.message;
2178
+ const event = this.eventLog.append({
2179
+ type: 'batch', method: 'batch', path: '',
2180
+ auth: auth ? { uid: auth.uid } : null,
2181
+ allowed: false,
2182
+ operations: operations.map((o) => ({
2183
+ method: o.method, path: o.path, data: o.data, allowed: false,
2184
+ })),
2185
+ debugMessages: [`FieldValue resolve error on '${op.path}': ${msg}`],
2186
+ });
2187
+ // Issue #307 — emit the failing op (and only the failing op;
2188
+ // earlier ops in the loop succeeded and their events were
2189
+ // queued, later ops never reached evaluation).
2190
+ this.emitRequest({
2191
+ at: Date.now(), evalMs: 0,
2192
+ method: op.method, path: op.path, auth, result: 'deny',
2193
+ debugMessages: [`FieldValue resolve error: ${msg}`],
2194
+ ...(op.data ? { resourceData: op.data } : {}),
2195
+ resourceBefore: { data: snapshot[op.path] ?? null, exists: (snapshot[op.path] ?? null) !== null },
2196
+ origin: 'batch', groupId,
2197
+ ...(detail ? { detail } : {}),
2198
+ });
2199
+ // Item 6: same code as the single-op resolver throw —
2200
+ // invalid-argument is the admin-SDK signal for malformed
2201
+ // FieldValue. The whole batch rolls back atomically; only the
2202
+ // failing op gets the per-op error attached.
2203
+ const batchError = makeError('invalid-argument', msg);
2204
+ return {
2205
+ allowed: false,
2206
+ results: operations.map((o) => ({
2207
+ path: o.path,
2208
+ allowed: false,
2209
+ debugMessages: [msg],
2210
+ ...(o.path === op.path ? { error: batchError } : {}),
2211
+ })),
2212
+ event,
2213
+ error: batchError,
2214
+ };
2215
+ }
2216
+ }
2217
+ // Evaluate rules for each operation against CURRENT state (no cross-visibility)
2218
+ let allAllowed = true;
2219
+ for (let i = 0; i < resolvedOps.length; i++) {
2220
+ const op = resolvedOps[i];
2221
+ // Pre-resolution payload from the original operations array (parallel
2222
+ // to resolvedOps by index). Emitted on RequestEvent/WriteSandboxEvent
2223
+ // so consumers see the user's INTENT (with FieldValue.* markers),
2224
+ // not the materialized values.
2225
+ const preData = operations[i]?.data;
2226
+ const testCase = this.buildTestCase({ method: op.method, path: op.path, auth, data: op.data }, serverTime);
2227
+ const evalAt = Date.now();
2228
+ const evalStart = performance.now();
2229
+ const simResult = this.runSimulate([testCase], bypassRules);
2230
+ const evalMs = performance.now() - evalStart;
2231
+ if (!simResult.success) {
2232
+ // Item 6: per-op simulator failure — same invalid-argument signal
2233
+ // as the single-op path uses.
2234
+ results.push({
2235
+ path: op.path,
2236
+ allowed: false,
2237
+ debugMessages: [simResult.error.message],
2238
+ error: makeError('invalid-argument', simResult.error.message),
2239
+ });
2240
+ pendingEmits.push({
2241
+ at: evalAt, evalMs, method: op.method, path: op.path, auth,
2242
+ result: 'deny',
2243
+ debugMessages: [`Simulation error: ${simResult.error.message}`],
2244
+ ...(preData ? { resourceData: preData } : {}),
2245
+ resourceBefore: { data: snapshot[op.path] ?? null, exists: (snapshot[op.path] ?? null) !== null },
2246
+ origin: 'batch', groupId,
2247
+ ...(detail ? { detail } : {}),
2248
+ });
2249
+ allAllowed = false;
2250
+ continue;
2251
+ }
2252
+ const r = simResult.data.results[0];
2253
+ if (r.state === 'UNSUPPORTED') {
2254
+ // Emit the unsupported event before throwing — same contract as execute().
2255
+ this.emitRequest({
2256
+ at: evalAt, evalMs, method: op.method, path: op.path, auth,
2257
+ result: 'unsupported', debugMessages: renderLegacyDebugMessages(r),
2258
+ ...(preData ? { resourceData: preData } : {}),
2259
+ resourceBefore: { data: snapshot[op.path] ?? null, exists: (snapshot[op.path] ?? null) !== null },
2260
+ origin: 'batch', groupId,
2261
+ ...(detail ? { detail } : {}),
2262
+ });
2263
+ throw new SimulatorUnsupportedError(unsupportedMessage(op.method, op.path, renderLegacyDebugMessages(r)), op.method, op.path, renderLegacyDebugMessages(r));
2264
+ }
2265
+ const isAllowed = r.state === 'PASSED';
2266
+ const entry = {
2267
+ path: op.path,
2268
+ allowed: isAllowed,
2269
+ debugMessages: renderLegacyDebugMessages(r),
2270
+ };
2271
+ if (!isAllowed) {
2272
+ // Item 6: rule denied this op — permission-denied. Structural
2273
+ // errors are surfaced separately below if rules pass. The
2274
+ // per-op `request`/`resource` is captured against the pre-batch
2275
+ // snapshot since rules eval has no inter-write visibility
2276
+ // (matches `batch()` semantics).
2277
+ const priorDoc = snapshot[op.path] ?? null;
2278
+ entry.error = makeError('permission-denied', `${op.method} ${op.path} denied by rules`, {
2279
+ request: {
2280
+ method: op.method,
2281
+ path: op.path,
2282
+ auth,
2283
+ ...(preData ? { resourceData: preData } : {}),
2284
+ },
2285
+ resource: { data: priorDoc, exists: priorDoc !== null },
2286
+ });
2287
+ this.emitDenial(entry.error);
2288
+ allAllowed = false;
2289
+ }
2290
+ // Issue #307 — queue per-op event. resourceAfter is filled in
2291
+ // below once we know whether applyBatch committed (allAllowed) or
2292
+ // rolled back.
2293
+ pendingEmits.push({
2294
+ at: evalAt, evalMs, method: op.method, path: op.path, auth,
2295
+ result: isAllowed ? 'allow' : 'deny',
2296
+ debugMessages: renderLegacyDebugMessages(r),
2297
+ ...(preData ? { resourceData: preData } : {}),
2298
+ resourceBefore: { data: snapshot[op.path] ?? null, exists: (snapshot[op.path] ?? null) !== null },
2299
+ origin: 'batch', groupId,
2300
+ ...(detail ? { detail } : {}),
2301
+ });
2302
+ results.push(entry);
2303
+ }
2304
+ // Apply all or none
2305
+ let batchStructuralError = null;
2306
+ if (allAllowed) {
2307
+ const batchOps = resolvedOps.map(op => ({
2308
+ method: op.method,
2309
+ path: op.path,
2310
+ data: op.data,
2311
+ }));
2312
+ const batchResult = this.state.applyBatch(batchOps);
2313
+ if (!batchResult.success) {
2314
+ allAllowed = false;
2315
+ // Item 6: applyBatch returns indexed structural errors. Map the
2316
+ // first one and pin it to the offending per-op result. Mirrors
2317
+ // single-op precondition mapping (create→already-exists,
2318
+ // update/delete→not-found).
2319
+ const first = batchResult.errors?.[0];
2320
+ if (first !== undefined) {
2321
+ const failingOp = resolvedOps[first.index];
2322
+ const code = failingOp?.method === 'create' ? 'already-exists' : 'not-found';
2323
+ batchStructuralError = makeError(code, first.error);
2324
+ // Demote the per-op result for the offender — its rules said
2325
+ // PASSED but the keyspace overruled.
2326
+ const failingResult = results[first.index];
2327
+ if (failingResult) {
2328
+ failingResult.allowed = false;
2329
+ failingResult.error = batchStructuralError;
2330
+ }
2331
+ }
2332
+ }
2333
+ }
2334
+ const event = this.eventLog.append({
2335
+ type: 'batch', method: 'batch', path: '',
2336
+ auth: auth ? { uid: auth.uid } : null,
2337
+ allowed: allAllowed,
2338
+ priorDocs: allAllowed ? snapshot : undefined,
2339
+ operations: resolvedOps.map((op, i) => ({
2340
+ method: op.method, path: op.path, data: op.data,
2341
+ allowed: results[i]?.allowed ?? false,
2342
+ })),
2343
+ debugMessages: allAllowed ? ['Batch committed'] : ['Batch rolled back — one or more operations denied'],
2344
+ });
2345
+ // Item 6: top-level batch error — pick the first per-op error if
2346
+ // any, or a structural error if rules passed but applyBatch rejected.
2347
+ let topError;
2348
+ if (!allAllowed) {
2349
+ topError =
2350
+ batchStructuralError ??
2351
+ results.find((r) => r.error)?.error ??
2352
+ makeError('permission-denied', 'Batch denied');
2353
+ }
2354
+ // Issue #307 — flush per-op events now that applyBatch has decided.
2355
+ // resourceAfter reflects the post-commit state on success; for
2356
+ // rollbacks (allAllowed false, or structural error demoting a
2357
+ // PASSED op) it mirrors the prior — no change happened. Delete
2358
+ // ops always end up exists:false on commit; on rollback they revert.
2359
+ for (let i = 0; i < pendingEmits.length; i++) {
2360
+ const e = pendingEmits[i];
2361
+ if (!e)
2362
+ continue;
2363
+ const opCommitted = allAllowed && results[i]?.allowed === true;
2364
+ if (opCommitted) {
2365
+ const finalDoc = this.state.get(e.path);
2366
+ if (e.method !== 'delete') {
2367
+ e.resourceAfter = { data: finalDoc, exists: finalDoc !== null };
2368
+ }
2369
+ else {
2370
+ e.resourceAfter = { data: null, exists: false };
2371
+ }
2372
+ }
2373
+ else {
2374
+ // rollback or structural-error demotion — state didn't change.
2375
+ const priorDoc = snapshot[e.path] ?? null;
2376
+ e.resourceAfter = { data: priorDoc, exists: priorDoc !== null };
2377
+ }
2378
+ this.emitRequest(e);
2379
+ // Issue #307 — committed-write event for sub-ops that actually
2380
+ // applied. Mirrors the per-sub-op groupId on the RequestEvent.
2381
+ if (opCommitted && e.method !== 'get' && e.method !== 'list') {
2382
+ const priorDoc = snapshot[e.path] ?? null;
2383
+ // Sentinels: walk the PRE-resolution data from `operations[i]`
2384
+ // (parallel to resolvedOps and pendingEmits — built in order
2385
+ // earlier in this method).
2386
+ const preOp = operations[i];
2387
+ const sentinels = preOp && 'data' in preOp && preOp.data ? walkForSentinels(preOp.data) : undefined;
2388
+ this.emitWrite({
2389
+ method: e.method,
2390
+ path: e.path,
2391
+ auth,
2392
+ ...(e.method !== 'delete' && e.resourceData ? { data: e.resourceData } : {}),
2393
+ priorState: priorDoc,
2394
+ nextState: e.method === 'delete' ? null : this.state.get(e.path),
2395
+ ...(e.groupId ? { groupId: e.groupId, groupKind: 'batch' } : {}),
2396
+ ...(sentinels && sentinels.length > 0 ? { sentinels } : {}),
2397
+ requestTime: serverTime,
2398
+ ...(detail ? { detail } : {}),
2399
+ });
2400
+ }
2401
+ }
2402
+ // Slice 3 — fan out batch writes to listeners. Single fire after
2403
+ // commit (matches the Slice 5 design — fire-once-per-batch is what
2404
+ // we want long-term; doing it here means Slice 5 only needs to
2405
+ // hoist the call point, not invent it). Skipped on rollback —
2406
+ // nothing in `state` actually changed.
2407
+ if (allAllowed) {
2408
+ const touched = new Set();
2409
+ for (const op of resolvedOps)
2410
+ touched.add(op.path);
2411
+ // Issue #307 — listener re-evals during this fan-out attribute
2412
+ // themselves to the batch as a whole. Path is the first sub-op
2413
+ // (best-effort — batches touch N paths, the UI can join via groupId
2414
+ // if it needs to show the full set).
2415
+ const firstOp = resolvedOps[0];
2416
+ const prevTrigger = this.currentTrigger;
2417
+ this.currentTrigger = firstOp
2418
+ ? { method: 'batch', path: firstOp.path }
2419
+ : { method: 'batch', path: '' };
2420
+ try {
2421
+ this.notifyListenersForPaths(touched);
2422
+ }
2423
+ finally {
2424
+ this.currentTrigger = prevTrigger;
2425
+ }
2426
+ }
2427
+ return {
2428
+ allowed: allAllowed,
2429
+ results,
2430
+ event,
2431
+ ...(topError ? { error: topError } : {}),
2432
+ };
2433
+ }
2434
+ transaction(fn, options) {
2435
+ const auth = options.auth;
2436
+ const snapshot = this.state.snapshot();
2437
+ const reader = (path) => this.state.get(path);
2438
+ const ctx = new TransactionContext(reader);
2439
+ // ─── Step 2 — run the callback ───────────────────────────────────
2440
+ let cbResult;
2441
+ try {
2442
+ cbResult = fn(ctx);
2443
+ }
2444
+ catch (e) {
2445
+ // Sync throw before any await: log + re-throw immediately.
2446
+ this.logAbortedTransaction(ctx, auth, e);
2447
+ throw e;
2448
+ }
2449
+ // Async path — await, then commit. Reject mirrors the sync throw
2450
+ // case (probe 0.G: original error reference re-thrown).
2451
+ if (cbResult !== null && typeof cbResult?.then === 'function') {
2452
+ return cbResult.then((returnValue) => this.commitTransaction(ctx, auth, snapshot, options, returnValue), (e) => {
2453
+ this.logAbortedTransaction(ctx, auth, e);
2454
+ throw e;
2455
+ });
2456
+ }
2457
+ // Sync path.
2458
+ return this.commitTransaction(ctx, auth, snapshot, options, cbResult);
2459
+ }
2460
+ /**
2461
+ * Commit phase shared by sync + async transaction paths. Runs after
2462
+ * the callback has fully completed (sync return or awaited resolve).
2463
+ * Splitting this out keeps `transaction()` readable and keeps the
2464
+ * commit logic from being duplicated across the two branches.
2465
+ */
2466
+ commitTransaction(ctx, auth, snapshot, options, returnValue) {
2467
+ const { reads, writes } = ctx.consume();
2468
+ const detail = options.bypassRules ? { admin: true } : undefined;
2469
+ // Issue #307 — shared groupId so consumers can fold tx sub-ops
2470
+ // together the same way they fold batch sub-ops.
2471
+ const txId = `tx-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`;
2472
+ // Per-write `RequestEvent`s queued during evaluation; emitted at
2473
+ // the end of step 6 with finalized `resourceAfter`. Mirrors the
2474
+ // pattern used in `batch()` (see line ~1410).
2475
+ const pendingEmits = [];
2476
+ // ─── Read-only short-circuit ─────────────────────────────────────
2477
+ // Zero queued writes is the locked happy path for read-only
2478
+ // transactions (probe 0.F): commit cleanly with `writes: []`.
2479
+ if (writes.length === 0) {
2480
+ const event = this.eventLog.append({
2481
+ type: 'transaction',
2482
+ method: 'transaction',
2483
+ path: '',
2484
+ auth: auth ? { uid: auth.uid } : null,
2485
+ allowed: true,
2486
+ reads: reads.map((r) => ({ path: r.path, data: r.data })),
2487
+ operations: [],
2488
+ snapshot,
2489
+ debugMessages: ['Transaction committed (read-only — no writes queued)'],
2490
+ });
2491
+ return {
2492
+ allowed: true,
2493
+ reads: [...reads],
2494
+ writes: [],
2495
+ returnValue,
2496
+ event,
2497
+ };
2498
+ }
2499
+ // ─── Step 3 — merge same-path queued writes ──────────────────────
2500
+ let mergedOps;
2501
+ try {
2502
+ mergedOps = mergeQueuedWrites(writes);
2503
+ }
2504
+ catch (e) {
2505
+ const err = e;
2506
+ this.eventLog.append({
2507
+ type: 'transaction',
2508
+ method: 'transaction',
2509
+ path: '',
2510
+ auth: auth ? { uid: auth.uid } : null,
2511
+ allowed: false,
2512
+ aborted: true,
2513
+ reads: reads.map((r) => ({ path: r.path, data: r.data })),
2514
+ error: { name: err.name, message: err.message, code: 'failed-precondition' },
2515
+ debugMessages: [`Transaction aborted at merge: ${err.message}`],
2516
+ });
2517
+ // Re-throw the original — callers that want a typed code can
2518
+ // catch `AmbiguousPostDeleteWriteError` from transaction-merge.ts.
2519
+ throw e;
2520
+ }
2521
+ // ─── Step 4 — resolve sentinels ──────────────────────────────────
2522
+ // One serverTime for the whole tx; matches batch() and single-op
2523
+ // semantics so `request.time` and any `serverTimestamp()` resolve
2524
+ // to the same wall-clock instant within a tx.
2525
+ const serverTime = Timestamp.fromMillis(Date.now());
2526
+ const resolvedOps = [];
2527
+ for (const op of mergedOps) {
2528
+ if (op.method === 'delete') {
2529
+ resolvedOps.push(op);
2530
+ continue;
2531
+ }
2532
+ try {
2533
+ const resolved = resolveValueTree({ ...op.data }, {
2534
+ path: op.path,
2535
+ method: op.method,
2536
+ prior: this.state.get(op.path),
2537
+ serverTime,
2538
+ });
2539
+ resolvedOps.push({ ...op, data: resolved });
2540
+ }
2541
+ catch (e) {
2542
+ const msg = e.message;
2543
+ const wrapped = makeError('invalid-argument', msg);
2544
+ const event = this.eventLog.append({
2545
+ type: 'transaction',
2546
+ method: 'transaction',
2547
+ path: '',
2548
+ auth: auth ? { uid: auth.uid } : null,
2549
+ allowed: false,
2550
+ reads: reads.map((r) => ({ path: r.path, data: r.data })),
2551
+ operations: mergedOps.map((o) => ({
2552
+ method: o.method,
2553
+ path: o.path,
2554
+ data: o.data,
2555
+ allowed: false,
2556
+ })),
2557
+ debugMessages: [`FieldValue resolve error on '${op.path}': ${msg}`],
2558
+ });
2559
+ // Issue #307 — surface the failing op as a denied request
2560
+ // (mirrors the batch() resolve-error path at line ~1438).
2561
+ // Earlier ops in the loop succeeded silently; later ops never
2562
+ // reached evaluation. Only the failing op emits.
2563
+ const opRuleMethod = (op.method === 'set'
2564
+ ? this.state.get(op.path) !== null
2565
+ ? 'update'
2566
+ : 'create'
2567
+ : op.method);
2568
+ const priorDoc = snapshot[op.path] ?? null;
2569
+ this.emitRequest({
2570
+ at: Date.now(), evalMs: 0,
2571
+ method: opRuleMethod, path: op.path, auth, result: 'deny',
2572
+ debugMessages: [`FieldValue resolve error: ${msg}`],
2573
+ ...(op.data && opRuleMethod !== 'delete' ? { resourceData: op.data } : {}),
2574
+ resourceBefore: { data: priorDoc, exists: priorDoc !== null },
2575
+ origin: 'transaction', groupId: txId,
2576
+ ...(detail ? { detail } : {}),
2577
+ });
2578
+ return {
2579
+ allowed: false,
2580
+ reads: [...reads],
2581
+ writes: mergedOps.map((o) => {
2582
+ const ruleMethod = o.method === 'set'
2583
+ ? this.state.get(o.path) !== null
2584
+ ? 'update'
2585
+ : 'create'
2586
+ : o.method;
2587
+ return {
2588
+ path: o.path,
2589
+ method: ruleMethod,
2590
+ allowed: false,
2591
+ debugMessages: o.path === op.path ? [msg] : [],
2592
+ ...(o.path === op.path ? { error: wrapped } : {}),
2593
+ };
2594
+ }),
2595
+ returnValue,
2596
+ event,
2597
+ error: wrapped,
2598
+ };
2599
+ }
2600
+ }
2601
+ // ─── Step 5 — per-op rules evaluation against pre-tx state ───────
2602
+ const writeResults = [];
2603
+ let allAllowed = true;
2604
+ for (let i = 0; i < resolvedOps.length; i++) {
2605
+ const op = resolvedOps[i];
2606
+ // Pre-resolution payload (parallel to resolvedOps by index).
2607
+ // Emitted on RequestEvent/WriteSandboxEvent so consumers see the
2608
+ // user's INTENT with FieldValue.* markers, not materialized values.
2609
+ const preData = mergedOps[i]?.data;
2610
+ // Translate `set` → `create`/`update` for rules-eval purposes
2611
+ // only; applyBatch keeps `set` semantics. Admin's rules engine
2612
+ // dispatches a `set` to whichever lifecycle matches the pre-tx
2613
+ // state, and we mirror that.
2614
+ const exists = this.state.get(op.path) !== null;
2615
+ const ruleMethod = (op.method === 'set'
2616
+ ? exists
2617
+ ? 'update'
2618
+ : 'create'
2619
+ : op.method);
2620
+ const priorDoc = snapshot[op.path] ?? null;
2621
+ const testCase = this.buildTestCase({ method: ruleMethod, path: op.path, auth, data: op.data }, serverTime);
2622
+ const evalAt = Date.now();
2623
+ const evalStart = performance.now();
2624
+ const sim = this.runSimulate([testCase], options.bypassRules);
2625
+ const evalMs = performance.now() - evalStart;
2626
+ if (!sim.success) {
2627
+ writeResults.push({
2628
+ path: op.path,
2629
+ method: ruleMethod,
2630
+ allowed: false,
2631
+ debugMessages: [sim.error.message],
2632
+ error: makeError('invalid-argument', sim.error.message),
2633
+ });
2634
+ pendingEmits.push({
2635
+ at: evalAt, evalMs, method: ruleMethod, path: op.path, auth,
2636
+ result: 'deny',
2637
+ debugMessages: [`Simulation error: ${sim.error.message}`],
2638
+ ...(preData && ruleMethod !== 'delete' ? { resourceData: preData } : {}),
2639
+ resourceBefore: { data: priorDoc, exists: priorDoc !== null },
2640
+ origin: 'transaction', groupId: txId,
2641
+ ...(detail ? { detail } : {}),
2642
+ });
2643
+ allAllowed = false;
2644
+ continue;
2645
+ }
2646
+ const r = sim.data.results[0];
2647
+ if (r.state === 'UNSUPPORTED') {
2648
+ // Surface the unsupported event before throwing — same
2649
+ // contract as execute() / batch().
2650
+ this.emitRequest({
2651
+ at: evalAt, evalMs, method: ruleMethod, path: op.path, auth,
2652
+ result: 'unsupported', debugMessages: renderLegacyDebugMessages(r),
2653
+ ...(preData && ruleMethod !== 'delete' ? { resourceData: preData } : {}),
2654
+ resourceBefore: { data: priorDoc, exists: priorDoc !== null },
2655
+ origin: 'transaction', groupId: txId,
2656
+ ...(detail ? { detail } : {}),
2657
+ });
2658
+ throw new SimulatorUnsupportedError(unsupportedMessage(ruleMethod, op.path, renderLegacyDebugMessages(r)), ruleMethod, op.path, renderLegacyDebugMessages(r));
2659
+ }
2660
+ const isAllowed = r.state === 'PASSED';
2661
+ const entry = {
2662
+ path: op.path,
2663
+ method: ruleMethod,
2664
+ allowed: isAllowed,
2665
+ debugMessages: renderLegacyDebugMessages(r),
2666
+ };
2667
+ // Queue the per-write RequestEvent. resourceAfter is filled in
2668
+ // after the atomic apply below (step 6) so denied ops show the
2669
+ // pre-tx state and committed ops show the post-tx state.
2670
+ pendingEmits.push({
2671
+ at: evalAt, evalMs, method: ruleMethod, path: op.path, auth,
2672
+ result: isAllowed ? 'allow' : 'deny',
2673
+ debugMessages: renderLegacyDebugMessages(r),
2674
+ ...(preData && ruleMethod !== 'delete' ? { resourceData: preData } : {}),
2675
+ resourceBefore: { data: priorDoc, exists: priorDoc !== null },
2676
+ origin: 'transaction', groupId: txId,
2677
+ ...(detail ? { detail } : {}),
2678
+ });
2679
+ if (!isAllowed) {
2680
+ // Per-op `request`/`resource` captured against pre-tx snapshot
2681
+ // so a denial inside a transaction shows the same shape as a
2682
+ // single-op denial (auth + resourceData + existing doc).
2683
+ const priorDoc = snapshot[op.path] ?? null;
2684
+ entry.error = makeError('permission-denied', `${ruleMethod} ${op.path} denied by rules`, {
2685
+ request: {
2686
+ method: ruleMethod,
2687
+ path: op.path,
2688
+ auth,
2689
+ ...(preData && ruleMethod !== 'delete' ? { resourceData: preData } : {}),
2690
+ },
2691
+ resource: { data: priorDoc, exists: priorDoc !== null },
2692
+ });
2693
+ this.emitDenial(entry.error);
2694
+ allAllowed = false;
2695
+ }
2696
+ writeResults.push(entry);
2697
+ }
2698
+ // ─── Step 6 — atomic apply (only if all rules passed) ────────────
2699
+ let structuralError = null;
2700
+ if (allAllowed) {
2701
+ const applyResult = this.state.applyBatch(resolvedOps);
2702
+ if (!applyResult.success) {
2703
+ allAllowed = false;
2704
+ const first = applyResult.errors?.[0];
2705
+ if (first !== undefined) {
2706
+ const failingOp = resolvedOps[first.index];
2707
+ // `set` cannot raise a structural error in applyBatch; only
2708
+ // create/update/delete can — so this branch hits only those
2709
+ // methods. The ternary keeps the type narrow.
2710
+ const code = failingOp?.method === 'create' ? 'already-exists' : 'not-found';
2711
+ structuralError = makeError(code, first.error);
2712
+ const failingResult = writeResults[first.index];
2713
+ if (failingResult) {
2714
+ failingResult.allowed = false;
2715
+ failingResult.error = structuralError;
2716
+ }
2717
+ }
2718
+ }
2719
+ }
2720
+ // ─── Step 7 — log event + assemble result ────────────────────────
2721
+ const event = this.eventLog.append({
2722
+ type: 'transaction',
2723
+ method: 'transaction',
2724
+ path: '',
2725
+ auth: auth ? { uid: auth.uid } : null,
2726
+ allowed: allAllowed,
2727
+ reads: reads.map((r) => ({ path: r.path, data: r.data })),
2728
+ operations: resolvedOps.map((op, i) => ({
2729
+ method: op.method,
2730
+ path: op.path,
2731
+ data: op.data,
2732
+ allowed: writeResults[i]?.allowed ?? false,
2733
+ })),
2734
+ // Snapshot is captured for undo only on success — a rolled-back
2735
+ // tx mutated nothing, so there's nothing to restore. Matches
2736
+ // batch() behavior exactly.
2737
+ snapshot: allAllowed ? snapshot : undefined,
2738
+ debugMessages: allAllowed
2739
+ ? ['Transaction committed']
2740
+ : ['Transaction rolled back — one or more operations denied'],
2741
+ });
2742
+ // Issue #307 — fire the per-write RequestEvents + WriteSandboxEvents
2743
+ // queued in step 5. resourceAfter mirrors `batch()`: committed ops
2744
+ // show the post-apply doc; denied/rolled-back ops show the pre-tx
2745
+ // state. Only committed writes emit a WriteSandboxEvent.
2746
+ for (let i = 0; i < pendingEmits.length; i++) {
2747
+ const e = pendingEmits[i];
2748
+ const opCommitted = allAllowed && writeResults[i]?.allowed === true;
2749
+ if (opCommitted) {
2750
+ const finalDoc = this.state.get(e.path);
2751
+ if (e.method !== 'delete') {
2752
+ e.resourceAfter = { data: finalDoc, exists: finalDoc !== null };
2753
+ }
2754
+ else {
2755
+ e.resourceAfter = { data: null, exists: false };
2756
+ }
2757
+ }
2758
+ else {
2759
+ const priorDoc = snapshot[e.path] ?? null;
2760
+ e.resourceAfter = { data: priorDoc, exists: priorDoc !== null };
2761
+ }
2762
+ this.emitRequest(e);
2763
+ if (opCommitted && e.method !== 'get' && e.method !== 'list') {
2764
+ const priorDoc = snapshot[e.path] ?? null;
2765
+ // Sentinels: walk the PRE-resolution data from `mergedOps[i]`
2766
+ // (parallel to resolvedOps and pendingEmits).
2767
+ const preOp = mergedOps[i];
2768
+ const sentinels = preOp && preOp.method !== 'delete' && preOp.data
2769
+ ? walkForSentinels(preOp.data)
2770
+ : undefined;
2771
+ this.emitWrite({
2772
+ method: e.method,
2773
+ path: e.path,
2774
+ auth,
2775
+ ...(e.method !== 'delete' && e.resourceData ? { data: e.resourceData } : {}),
2776
+ priorState: priorDoc,
2777
+ nextState: e.method === 'delete' ? null : this.state.get(e.path),
2778
+ ...(e.groupId ? { groupId: e.groupId, groupKind: 'transaction' } : {}),
2779
+ ...(sentinels && sentinels.length > 0 ? { sentinels } : {}),
2780
+ requestTime: serverTime,
2781
+ ...(detail ? { detail } : {}),
2782
+ });
2783
+ }
2784
+ }
2785
+ let topError;
2786
+ if (!allAllowed) {
2787
+ topError =
2788
+ structuralError ??
2789
+ writeResults.find((w) => w.error)?.error ??
2790
+ makeError('permission-denied', 'Transaction denied');
2791
+ }
2792
+ const result = {
2793
+ allowed: allAllowed,
2794
+ reads: [...reads],
2795
+ writes: writeResults,
2796
+ returnValue,
2797
+ event,
2798
+ ...(topError ? { error: topError } : {}),
2799
+ };
2800
+ // Probe 0.H side-finding: warn-not-throw on writes inside a
2801
+ // readOnly tx. v1 still queues + commits the write; v2 may flip to
2802
+ // strict (throw at the call site).
2803
+ if (options.readOnly && ctx.hadWrites()) {
2804
+ result.readOnlyViolation = true;
2805
+ }
2806
+ // Slice 3 — fan out transaction writes after a successful commit.
2807
+ // Aborted transactions never reach here (the throw in `transaction`
2808
+ // bypasses commit entirely), and rolled-back commits leave state
2809
+ // unchanged so we'd suppress everything anyway. Single fire per
2810
+ // transaction matches the Slice 5 design.
2811
+ if (allAllowed) {
2812
+ const touched = new Set();
2813
+ for (const op of resolvedOps)
2814
+ touched.add(op.path);
2815
+ // Issue #307 — listener re-evals attribute to the transaction.
2816
+ const firstOp = resolvedOps[0];
2817
+ const prevTrigger = this.currentTrigger;
2818
+ this.currentTrigger = firstOp
2819
+ ? { method: 'transaction', path: firstOp.path }
2820
+ : { method: 'transaction', path: '' };
2821
+ try {
2822
+ this.notifyListenersForPaths(touched);
2823
+ }
2824
+ finally {
2825
+ this.currentTrigger = prevTrigger;
2826
+ }
2827
+ }
2828
+ return result;
2829
+ }
2830
+ /**
2831
+ * Append an aborted-transaction event for a callback throw (sync or
2832
+ * async path). The original Error is re-thrown by the caller —
2833
+ * probe 0.G locks "exceptions propagate unchanged", so this helper
2834
+ * never throws on its own.
2835
+ */
2836
+ logAbortedTransaction(ctx, auth, err) {
2837
+ const errWithCode = err;
2838
+ const { reads } = ctx.consume();
2839
+ this.eventLog.append({
2840
+ type: 'transaction',
2841
+ method: 'transaction',
2842
+ path: '',
2843
+ auth: auth ? { uid: auth.uid } : null,
2844
+ allowed: false,
2845
+ aborted: true,
2846
+ reads: reads.map((r) => ({ path: r.path, data: r.data })),
2847
+ error: {
2848
+ name: err.name,
2849
+ message: err.message,
2850
+ ...(errWithCode.code !== undefined ? { code: String(errWithCode.code) } : {}),
2851
+ },
2852
+ debugMessages: [`Transaction aborted: ${err.message}`],
2853
+ });
2854
+ }
2855
+ // ═══ Undo / Redo ═══
2856
+ /** Undo the last write operation. Restores the affected paths (single-write /
2857
+ * batch) or the whole keyspace (transaction) to their pre-write state. */
2858
+ undo() {
2859
+ const event = this.eventLog.popLastWrite();
2860
+ if (!event)
2861
+ return null;
2862
+ if (event.priorDocs)
2863
+ this.state.restorePaths(event.priorDocs);
2864
+ else if (event.snapshot)
2865
+ this.state.restore(event.snapshot);
2866
+ else
2867
+ return null;
2868
+ return event;
2869
+ }
2870
+ /** Redo the last undone operation. Re-applies the write directly
2871
+ * without going through execute() (which would clear the redo stack). */
2872
+ redo() {
2873
+ const event = this.eventLog.popLastUndo();
2874
+ if (!event)
2875
+ return null;
2876
+ // Capture prior state BEFORE re-applying (for a future undo of this redo),
2877
+ // matching the kind the event used: affected paths for single-write / batch,
2878
+ // the whole keyspace for a transaction.
2879
+ const affectedPaths = (event.type === 'batch' && event.operations)
2880
+ ? event.operations.map((op) => op.path)
2881
+ : event.path ? [event.path] : [];
2882
+ const useFullSnapshot = !!event.snapshot;
2883
+ const priorDocs = useFullSnapshot ? undefined : this.capturePriors(affectedPaths);
2884
+ const snapshot = useFullSnapshot ? this.state.snapshot() : undefined;
2885
+ // Re-apply the write directly to state
2886
+ if (event.type === 'batch' && event.operations) {
2887
+ for (const op of event.operations) {
2888
+ if (op.allowed)
2889
+ this.applyWrite(op.method, op.path, op.data);
2890
+ }
2891
+ }
2892
+ else if (event.allowed) {
2893
+ this.applyWrite(event.method, event.path, event.data);
2894
+ }
2895
+ // Re-append with preserveRedo=true so remaining redos aren't lost
2896
+ const newEvent = this.eventLog.append({
2897
+ ...event,
2898
+ snapshot,
2899
+ priorDocs,
2900
+ }, true);
2901
+ return {
2902
+ allowed: event.allowed,
2903
+ debugMessages: ['Redo: ' + (event.allowed ? 'applied' : 'skipped (was denied)')],
2904
+ event: newEvent,
2905
+ };
2906
+ }
2907
+ // ═══ Event log access ═══
2908
+ /** Get all events. */
2909
+ getEvents() {
2910
+ return this.eventLog.getEvents();
2911
+ }
2912
+ /** Get event count. */
2913
+ getEventCount() {
2914
+ return this.eventLog.size();
2915
+ }
2916
+ // ═══ Private helpers ═══
2917
+ buildTestCase(operation, serverTime) {
2918
+ const existingDoc = this.state.get(operation.path);
2919
+ // Translate `set` to the rules-engine clause it routes through —
2920
+ // `create` for missing docs, `update` for existing. Storage stays
2921
+ // `set` (handled in applyWrite) so the post-write doc is the
2922
+ // replacement payload, not a merge.
2923
+ const ruleMethod = operation.method === 'set'
2924
+ ? (existingDoc !== null ? 'update' : 'create')
2925
+ : operation.method;
2926
+ // For reads: no request data (reads don't send data)
2927
+ // For writes: build the FULL post-write document
2928
+ let requestData = operation.data;
2929
+ if (operation.method === 'get' || operation.method === 'list') {
2930
+ requestData = undefined;
2931
+ }
2932
+ else if (operation.method === 'update' && existingDoc && operation.data) {
2933
+ // Merge: existing + updates = full post-write document. Item 2:
2934
+ // partition DELETE_FIELD markers so the rules see the same shape
2935
+ // storage will see (deleted keys absent, not present-with-symbol).
2936
+ // The data has already been resolved upstream — partitionDeletes
2937
+ // is idempotent on already-partitioned trees.
2938
+ const { writes, deletedKeys } = partitionDeletes(operation.data);
2939
+ const merged = { ...existingDoc, ...writes };
2940
+ for (const k of deletedKeys)
2941
+ delete merged[k];
2942
+ requestData = merged;
2943
+ }
2944
+ else if (operation.data) {
2945
+ // create / set: the resolved data IS the full post-write doc
2946
+ // (set replaces). Strip any DELETE_FIELD markers so they don't
2947
+ // reach the handler.
2948
+ requestData = partitionDeletes(operation.data).writes;
2949
+ }
2950
+ return {
2951
+ description: `${operation.method} ${operation.path}`,
2952
+ expectation: 'ALLOW', // We always test against ALLOW; FAILED = denied
2953
+ method: ruleMethod,
2954
+ path: operation.path,
2955
+ auth: operation.auth ? { uid: operation.auth.uid, token: operation.auth.token } : null,
2956
+ data: requestData,
2957
+ resource: existingDoc ?? undefined,
2958
+ // Phase 2: no whole-keyspace functionMocks dump. get()/exists() in rules
2959
+ // fault in lazily through the `getDoc` resolver passed to simulate() (a
2960
+ // DocStore point-read), resolving only the paths a ruleset actually touches.
2961
+ // Item 1: forward the resolver's serverTime as ISO so handler.ts's
2962
+ // `request.time` is field-equal to any resolved sentinel.
2963
+ // Millisecond-precise round-trip via Date(ms).toISOString() ↔
2964
+ // Timestamp.fromIsoString.
2965
+ ...(serverTime ? { requestTime: isoFromTimestamp(serverTime) } : {}),
2966
+ };
2967
+ }
2968
+ /**
2969
+ * Apply a write to local state, returning a structural error when
2970
+ * the underlying state operation rejects (Item 6). Rules have already
2971
+ * decided to ALLOW by the time we get here; what's left are the
2972
+ * preconditions only the keyspace knows about — `create` of an
2973
+ * existing path, `update`/`delete` of a missing path. The simulator
2974
+ * previously dropped these silently (state.create returned
2975
+ * `{success:false}` and we ignored it), which let `allowed:true`
2976
+ * results coexist with no actual mutation. Returning the failure
2977
+ * lets `execute()` demote `allowed` and surface the right error code.
2978
+ */
2979
+ applyWrite(method, path, data, merge) {
2980
+ // FS-B6: a merge write (`setDoc(data, {merge})`) deep-merges into the
2981
+ // existing doc (creating it when absent), regardless of whether rule
2982
+ // eval ran as create or update. Route both to `setMerge`.
2983
+ if (merge !== undefined && merge !== false && (method === 'create' || method === 'update')) {
2984
+ const mergeFields = merge === true ? undefined : merge.mergeFields;
2985
+ this.state.setMerge(path, data ?? {}, mergeFields);
2986
+ return null;
2987
+ }
2988
+ switch (method) {
2989
+ case 'create': {
2990
+ const r = this.state.create(path, data ?? {});
2991
+ if (!r.success) {
2992
+ return makeError('already-exists', r.error ?? `Document '${path}' already exists`);
2993
+ }
2994
+ return null;
2995
+ }
2996
+ case 'update': {
2997
+ const r = this.state.update(path, data ?? {});
2998
+ if (!r.success) {
2999
+ return makeError('not-found', r.error ?? `Document '${path}' does not exist`);
3000
+ }
3001
+ return null;
3002
+ }
3003
+ case 'set': {
3004
+ // Replace semantics. `state.set` always succeeds (creates if
3005
+ // absent, replaces if present) — matches Firestore `set()`
3006
+ // without merge options.
3007
+ this.state.set(path, data ?? {});
3008
+ return null;
3009
+ }
3010
+ case 'delete': {
3011
+ // `deleteDoc` on a missing doc is a no-op in production
3012
+ // `firebase/firestore` (and the Admin SDK): rules already
3013
+ // allowed, the doc isn't there to remove, and the call
3014
+ // resolves without throwing. Locked by oracle observation
3015
+ // `scripts/oracle/observations/firestore-deletedoc-missing.json`
3016
+ // (matrix row Firestore #39). `state.delete` returns
3017
+ // `success:false` for a missing path; we collapse that into
3018
+ // null (no error) so `execute()` reports the delete as
3019
+ // allowed with no mutation, matching prod.
3020
+ this.state.delete(path);
3021
+ return null;
3022
+ }
3023
+ default:
3024
+ return null;
3025
+ }
3026
+ }
3027
+ }
3028
+ //# sourceMappingURL=local-environment.js.map