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.
- package/README.md +52 -1
- package/dist/app/index.d.ts +81 -0
- package/dist/app/index.d.ts.map +1 -0
- package/dist/app/index.js +78 -0
- package/dist/app/index.js.map +1 -0
- package/dist/auth/index.d.ts +346 -0
- package/dist/auth/index.d.ts.map +1 -0
- package/dist/auth/index.js +701 -0
- package/dist/auth/index.js.map +1 -0
- package/dist/auth/prod-backend.d.ts +39 -0
- package/dist/auth/prod-backend.d.ts.map +1 -0
- package/dist/auth/prod-backend.js +179 -0
- package/dist/auth/prod-backend.js.map +1 -0
- package/dist/auth/providers.d.ts +107 -0
- package/dist/auth/providers.d.ts.map +1 -0
- package/dist/auth/providers.js +138 -0
- package/dist/auth/providers.js.map +1 -0
- package/dist/auth/sandbox-backend.d.ts +769 -0
- package/dist/auth/sandbox-backend.d.ts.map +1 -0
- package/dist/auth/sandbox-backend.js +1598 -0
- package/dist/auth/sandbox-backend.js.map +1 -0
- package/dist/auth/target.d.ts +40 -0
- package/dist/auth/target.d.ts.map +1 -0
- package/dist/auth/target.js +28 -0
- package/dist/auth/target.js.map +1 -0
- package/dist/auth/types.d.ts +252 -0
- package/dist/auth/types.d.ts.map +1 -0
- package/dist/auth/types.js +26 -0
- package/dist/auth/types.js.map +1 -0
- package/dist/database/constraints/atoms.d.ts +29 -0
- package/dist/database/constraints/atoms.d.ts.map +1 -0
- package/dist/database/constraints/atoms.js +43 -0
- package/dist/database/constraints/atoms.js.map +1 -0
- package/dist/database/constraints/compose.d.ts +16 -0
- package/dist/database/constraints/compose.d.ts.map +1 -0
- package/dist/database/constraints/compose.js +15 -0
- package/dist/database/constraints/compose.js.map +1 -0
- package/dist/database/constraints/data.d.ts +30 -0
- package/dist/database/constraints/data.d.ts.map +1 -0
- package/dist/database/constraints/data.js +46 -0
- package/dist/database/constraints/data.js.map +1 -0
- package/dist/database/constraints/document.d.ts +42 -0
- package/dist/database/constraints/document.d.ts.map +1 -0
- package/dist/database/constraints/document.js +86 -0
- package/dist/database/constraints/document.js.map +1 -0
- package/dist/database/constraints/game.d.ts +30 -0
- package/dist/database/constraints/game.d.ts.map +1 -0
- package/dist/database/constraints/game.js +49 -0
- package/dist/database/constraints/game.js.map +1 -0
- package/dist/database/constraints/index.d.ts +12 -0
- package/dist/database/constraints/index.d.ts.map +1 -0
- package/dist/database/constraints/index.js +9 -0
- package/dist/database/constraints/index.js.map +1 -0
- package/dist/database/constraints/policies.d.ts +16 -0
- package/dist/database/constraints/policies.d.ts.map +1 -0
- package/dist/database/constraints/policies.js +17 -0
- package/dist/database/constraints/policies.js.map +1 -0
- package/dist/database/constraints/ruleset.d.ts +5 -0
- package/dist/database/constraints/ruleset.d.ts.map +1 -0
- package/dist/database/constraints/ruleset.js +132 -0
- package/dist/database/constraints/ruleset.js.map +1 -0
- package/dist/database/constraints/schema.d.ts +17 -0
- package/dist/database/constraints/schema.d.ts.map +1 -0
- package/dist/database/constraints/schema.js +74 -0
- package/dist/database/constraints/schema.js.map +1 -0
- package/dist/database/constraints/types.d.ts +22 -0
- package/dist/database/constraints/types.d.ts.map +1 -0
- package/dist/database/constraints/types.js +2 -0
- package/dist/database/constraints/types.js.map +1 -0
- package/dist/database/crawl/handler.d.ts +7 -0
- package/dist/database/crawl/handler.d.ts.map +1 -0
- package/dist/database/crawl/handler.js +91 -0
- package/dist/database/crawl/handler.js.map +1 -0
- package/dist/database/crawl/semaphore.d.ts +9 -0
- package/dist/database/crawl/semaphore.d.ts.map +1 -0
- package/dist/database/crawl/semaphore.js +27 -0
- package/dist/database/crawl/semaphore.js.map +1 -0
- package/dist/database/crawl/spec.d.ts +33 -0
- package/dist/database/crawl/spec.d.ts.map +1 -0
- package/dist/database/crawl/spec.js +12 -0
- package/dist/database/crawl/spec.js.map +1 -0
- package/dist/database/data/handler.d.ts +9 -0
- package/dist/database/data/handler.d.ts.map +1 -0
- package/dist/database/data/handler.js +96 -0
- package/dist/database/data/handler.js.map +1 -0
- package/dist/database/data/spec.d.ts +77 -0
- package/dist/database/data/spec.d.ts.map +1 -0
- package/dist/database/data/spec.js +17 -0
- package/dist/database/data/spec.js.map +1 -0
- package/dist/database/data/validated.d.ts +8 -0
- package/dist/database/data/validated.d.ts.map +1 -0
- package/dist/database/data/validated.js +99 -0
- package/dist/database/data/validated.js.map +1 -0
- package/dist/database/grammar/RtdbExpr.ohm +98 -0
- package/dist/database/grammar/RtdbExpr.ohm.generated.d.ts +2 -0
- package/dist/database/grammar/RtdbExpr.ohm.generated.d.ts.map +1 -0
- package/dist/database/grammar/RtdbExpr.ohm.generated.js +103 -0
- package/dist/database/grammar/RtdbExpr.ohm.generated.js.map +1 -0
- package/dist/database/grammar/RtdbExprParser.d.ts +5 -0
- package/dist/database/grammar/RtdbExprParser.d.ts.map +1 -0
- package/dist/database/grammar/RtdbExprParser.js +53 -0
- package/dist/database/grammar/RtdbExprParser.js.map +1 -0
- package/dist/database/grammar/linter.d.ts +3 -0
- package/dist/database/grammar/linter.d.ts.map +1 -0
- package/dist/database/grammar/linter.js +66 -0
- package/dist/database/grammar/linter.js.map +1 -0
- package/dist/database/grammar/simulator.d.ts +31 -0
- package/dist/database/grammar/simulator.d.ts.map +1 -0
- package/dist/database/grammar/simulator.js +255 -0
- package/dist/database/grammar/simulator.js.map +1 -0
- package/dist/database/grammar/validator.d.ts +3 -0
- package/dist/database/grammar/validator.d.ts.map +1 -0
- package/dist/database/grammar/validator.js +84 -0
- package/dist/database/grammar/validator.js.map +1 -0
- package/dist/database/host.d.ts +50 -0
- package/dist/database/host.d.ts.map +1 -0
- package/dist/database/host.js +41 -0
- package/dist/database/host.js.map +1 -0
- package/dist/database/index.d.ts +40 -0
- package/dist/database/index.d.ts.map +1 -0
- package/dist/database/index.js +47 -0
- package/dist/database/index.js.map +1 -0
- package/dist/database/initialize-from-app.d.ts +28 -0
- package/dist/database/initialize-from-app.d.ts.map +1 -0
- package/dist/database/initialize-from-app.js +23 -0
- package/dist/database/initialize-from-app.js.map +1 -0
- package/dist/database/ir/handler.d.ts +6 -0
- package/dist/database/ir/handler.d.ts.map +1 -0
- package/dist/database/ir/handler.js +52 -0
- package/dist/database/ir/handler.js.map +1 -0
- package/dist/database/ir/spec.d.ts +25 -0
- package/dist/database/ir/spec.d.ts.map +1 -0
- package/dist/database/ir/spec.js +11 -0
- package/dist/database/ir/spec.js.map +1 -0
- package/dist/database/mapper.d.ts +9 -0
- package/dist/database/mapper.d.ts.map +1 -0
- package/dist/database/mapper.js +115 -0
- package/dist/database/mapper.js.map +1 -0
- package/dist/database/modular.d.ts +604 -0
- package/dist/database/modular.d.ts.map +1 -0
- package/dist/database/modular.js +1226 -0
- package/dist/database/modular.js.map +1 -0
- package/dist/database/replay.d.ts +33 -0
- package/dist/database/replay.d.ts.map +1 -0
- package/dist/database/replay.js +124 -0
- package/dist/database/replay.js.map +1 -0
- package/dist/database/resolver.d.ts +4 -0
- package/dist/database/resolver.d.ts.map +1 -0
- package/dist/database/resolver.js +66 -0
- package/dist/database/resolver.js.map +1 -0
- package/dist/database/sandbox/backend.d.ts +403 -0
- package/dist/database/sandbox/backend.d.ts.map +1 -0
- package/dist/database/sandbox/backend.js +1467 -0
- package/dist/database/sandbox/backend.js.map +1 -0
- package/dist/database/sandbox/data-tree.d.ts +123 -0
- package/dist/database/sandbox/data-tree.d.ts.map +1 -0
- package/dist/database/sandbox/data-tree.js +310 -0
- package/dist/database/sandbox/data-tree.js.map +1 -0
- package/dist/database/sandbox/normalize.d.ts +77 -0
- package/dist/database/sandbox/normalize.d.ts.map +1 -0
- package/dist/database/sandbox/normalize.js +165 -0
- package/dist/database/sandbox/normalize.js.map +1 -0
- package/dist/database/sandbox/push-id.d.ts +32 -0
- package/dist/database/sandbox/push-id.d.ts.map +1 -0
- package/dist/database/sandbox/push-id.js +82 -0
- package/dist/database/sandbox/push-id.js.map +1 -0
- package/dist/database/sandbox/query.d.ts +175 -0
- package/dist/database/sandbox/query.d.ts.map +1 -0
- package/dist/database/sandbox/query.js +269 -0
- package/dist/database/sandbox/query.js.map +1 -0
- package/dist/database/sandbox/rules-eval.d.ts +69 -0
- package/dist/database/sandbox/rules-eval.d.ts.map +1 -0
- package/dist/database/sandbox/rules-eval.js +124 -0
- package/dist/database/sandbox/rules-eval.js.map +1 -0
- package/dist/database/sandbox/sentinels.d.ts +71 -0
- package/dist/database/sandbox/sentinels.d.ts.map +1 -0
- package/dist/database/sandbox/sentinels.js +92 -0
- package/dist/database/sandbox/sentinels.js.map +1 -0
- package/dist/database/simulation/handler.d.ts +6 -0
- package/dist/database/simulation/handler.d.ts.map +1 -0
- package/dist/database/simulation/handler.js +236 -0
- package/dist/database/simulation/handler.js.map +1 -0
- package/dist/database/simulation/spec.d.ts +69 -0
- package/dist/database/simulation/spec.d.ts.map +1 -0
- package/dist/database/simulation/spec.js +25 -0
- package/dist/database/simulation/spec.js.map +1 -0
- package/dist/database/tools.d.ts +22 -0
- package/dist/database/tools.d.ts.map +1 -0
- package/dist/database/tools.js +272 -0
- package/dist/database/tools.js.map +1 -0
- package/dist/database/types.d.ts +210 -0
- package/dist/database/types.d.ts.map +1 -0
- package/dist/database/types.js +26 -0
- package/dist/database/types.js.map +1 -0
- package/dist/database/write/handler.d.ts +7 -0
- package/dist/database/write/handler.d.ts.map +1 -0
- package/dist/database/write/handler.js +42 -0
- package/dist/database/write/handler.js.map +1 -0
- package/dist/database/write/spec.d.ts +18 -0
- package/dist/database/write/spec.d.ts.map +1 -0
- package/dist/database/write/spec.js +7 -0
- package/dist/database/write/spec.js.map +1 -0
- package/dist/firestore/index.d.ts +533 -0
- package/dist/firestore/index.d.ts.map +1 -0
- package/dist/firestore/index.js +1536 -0
- package/dist/firestore/index.js.map +1 -0
- package/dist/firestore/tools.d.ts +67 -0
- package/dist/firestore/tools.d.ts.map +1 -0
- package/dist/firestore/tools.js +318 -0
- package/dist/firestore/tools.js.map +1 -0
- package/dist/firestore-values/index.d.ts +63 -0
- package/dist/firestore-values/index.d.ts.map +1 -0
- package/dist/firestore-values/index.js +148 -0
- package/dist/firestore-values/index.js.map +1 -0
- package/dist/project-scope.d.ts +5 -0
- package/dist/project-scope.d.ts.map +1 -0
- package/dist/project-scope.js +2 -0
- package/dist/project-scope.js.map +1 -0
- package/dist/rules/extract.d.ts +28 -0
- package/dist/rules/extract.d.ts.map +1 -0
- package/dist/rules/extract.js +25 -0
- package/dist/rules/extract.js.map +1 -0
- package/dist/rules/generators/assembler.d.ts +19 -0
- package/dist/rules/generators/assembler.d.ts.map +1 -0
- package/dist/rules/generators/assembler.js +292 -0
- package/dist/rules/generators/assembler.js.map +1 -0
- package/dist/rules/generators/expressions.d.ts +52 -0
- package/dist/rules/generators/expressions.d.ts.map +1 -0
- package/dist/rules/generators/expressions.js +117 -0
- package/dist/rules/generators/expressions.js.map +1 -0
- package/dist/rules/generators/grid.d.ts +22 -0
- package/dist/rules/generators/grid.d.ts.map +1 -0
- package/dist/rules/generators/grid.js +52 -0
- package/dist/rules/generators/grid.js.map +1 -0
- package/dist/rules/grammar/FirestoreAST.d.ts +129 -0
- package/dist/rules/grammar/FirestoreAST.d.ts.map +1 -0
- package/dist/rules/grammar/FirestoreAST.js +3 -0
- package/dist/rules/grammar/FirestoreAST.js.map +1 -0
- package/dist/rules/grammar/FirestoreAssembler.d.ts +5 -0
- package/dist/rules/grammar/FirestoreAssembler.d.ts.map +1 -0
- package/dist/rules/grammar/FirestoreAssembler.js +158 -0
- package/dist/rules/grammar/FirestoreAssembler.js.map +1 -0
- package/dist/rules/grammar/FirestoreParser.d.ts +46 -0
- package/dist/rules/grammar/FirestoreParser.d.ts.map +1 -0
- package/dist/rules/grammar/FirestoreParser.js +409 -0
- package/dist/rules/grammar/FirestoreParser.js.map +1 -0
- package/dist/rules/grammar/FirestoreRules.ohm +229 -0
- package/dist/rules/grammar/FirestoreRules.ohm.generated.d.ts +2 -0
- package/dist/rules/grammar/FirestoreRules.ohm.generated.d.ts.map +1 -0
- package/dist/rules/grammar/FirestoreRules.ohm.generated.js +234 -0
- package/dist/rules/grammar/FirestoreRules.ohm.generated.js.map +1 -0
- package/dist/rules/grammar/FirestoreValidator.d.ts +10 -0
- package/dist/rules/grammar/FirestoreValidator.d.ts.map +1 -0
- package/dist/rules/grammar/FirestoreValidator.js +482 -0
- package/dist/rules/grammar/FirestoreValidator.js.map +1 -0
- package/dist/rules/index.d.ts +67 -0
- package/dist/rules/index.d.ts.map +1 -0
- package/dist/rules/index.js +80 -0
- package/dist/rules/index.js.map +1 -0
- package/dist/rules/indexes/extract/annotation-collect.d.ts +42 -0
- package/dist/rules/indexes/extract/annotation-collect.d.ts.map +1 -0
- package/dist/rules/indexes/extract/annotation-collect.js +69 -0
- package/dist/rules/indexes/extract/annotation-collect.js.map +1 -0
- package/dist/rules/indexes/extract/annotations.d.ts +50 -0
- package/dist/rules/indexes/extract/annotations.d.ts.map +1 -0
- package/dist/rules/indexes/extract/annotations.js +144 -0
- package/dist/rules/indexes/extract/annotations.js.map +1 -0
- package/dist/rules/indexes/extract/ast.d.ts +54 -0
- package/dist/rules/indexes/extract/ast.d.ts.map +1 -0
- package/dist/rules/indexes/extract/ast.js +119 -0
- package/dist/rules/indexes/extract/ast.js.map +1 -0
- package/dist/rules/indexes/extract/classify.d.ts +41 -0
- package/dist/rules/indexes/extract/classify.d.ts.map +1 -0
- package/dist/rules/indexes/extract/classify.js +138 -0
- package/dist/rules/indexes/extract/classify.js.map +1 -0
- package/dist/rules/indexes/extract/composite.d.ts +38 -0
- package/dist/rules/indexes/extract/composite.d.ts.map +1 -0
- package/dist/rules/indexes/extract/composite.js +85 -0
- package/dist/rules/indexes/extract/composite.js.map +1 -0
- package/dist/rules/indexes/extract/dataflow.d.ts +45 -0
- package/dist/rules/indexes/extract/dataflow.d.ts.map +1 -0
- package/dist/rules/indexes/extract/dataflow.js +200 -0
- package/dist/rules/indexes/extract/dataflow.js.map +1 -0
- package/dist/rules/indexes/extract/enumerate.d.ts +41 -0
- package/dist/rules/indexes/extract/enumerate.d.ts.map +1 -0
- package/dist/rules/indexes/extract/enumerate.js +163 -0
- package/dist/rules/indexes/extract/enumerate.js.map +1 -0
- package/dist/rules/indexes/extract/extractor.d.ts +3 -0
- package/dist/rules/indexes/extract/extractor.d.ts.map +1 -0
- package/dist/rules/indexes/extract/extractor.js +274 -0
- package/dist/rules/indexes/extract/extractor.js.map +1 -0
- package/dist/rules/indexes/extract/types.d.ts +211 -0
- package/dist/rules/indexes/extract/types.d.ts.map +1 -0
- package/dist/rules/indexes/extract/types.js +14 -0
- package/dist/rules/indexes/extract/types.js.map +1 -0
- package/dist/rules/indexes/extractHandler.d.ts +16 -0
- package/dist/rules/indexes/extractHandler.d.ts.map +1 -0
- package/dist/rules/indexes/extractHandler.js +100 -0
- package/dist/rules/indexes/extractHandler.js.map +1 -0
- package/dist/rules/indexes/extractTool.d.ts +15 -0
- package/dist/rules/indexes/extractTool.d.ts.map +1 -0
- package/dist/rules/indexes/extractTool.js +55 -0
- package/dist/rules/indexes/extractTool.js.map +1 -0
- package/dist/rules/indexes/types.d.ts +71 -0
- package/dist/rules/indexes/types.d.ts.map +1 -0
- package/dist/rules/indexes/types.js +15 -0
- package/dist/rules/indexes/types.js.map +1 -0
- package/dist/rules/inspect/handler.d.ts +6 -0
- package/dist/rules/inspect/handler.d.ts.map +1 -0
- package/dist/rules/inspect/handler.js +144 -0
- package/dist/rules/inspect/handler.js.map +1 -0
- package/dist/rules/inspect/spec.d.ts +37 -0
- package/dist/rules/inspect/spec.d.ts.map +1 -0
- package/dist/rules/inspect/spec.js +2 -0
- package/dist/rules/inspect/spec.js.map +1 -0
- package/dist/rules/inspect/tools.d.ts +21 -0
- package/dist/rules/inspect/tools.d.ts.map +1 -0
- package/dist/rules/inspect/tools.js +21 -0
- package/dist/rules/inspect/tools.js.map +1 -0
- package/dist/rules/linter/ast-utils.d.ts +97 -0
- package/dist/rules/linter/ast-utils.d.ts.map +1 -0
- package/dist/rules/linter/ast-utils.js +491 -0
- package/dist/rules/linter/ast-utils.js.map +1 -0
- package/dist/rules/linter/hallucinations.d.ts +34 -0
- package/dist/rules/linter/hallucinations.d.ts.map +1 -0
- package/dist/rules/linter/hallucinations.js +334 -0
- package/dist/rules/linter/hallucinations.js.map +1 -0
- package/dist/rules/linter/linter.d.ts +84 -0
- package/dist/rules/linter/linter.d.ts.map +1 -0
- package/dist/rules/linter/linter.js +784 -0
- package/dist/rules/linter/linter.js.map +1 -0
- package/dist/rules/modules/resolver-browser.d.ts +41 -0
- package/dist/rules/modules/resolver-browser.d.ts.map +1 -0
- package/dist/rules/modules/resolver-browser.js +63 -0
- package/dist/rules/modules/resolver-browser.js.map +1 -0
- package/dist/rules/modules/resolver-core.d.ts +51 -0
- package/dist/rules/modules/resolver-core.d.ts.map +1 -0
- package/dist/rules/modules/resolver-core.js +338 -0
- package/dist/rules/modules/resolver-core.js.map +1 -0
- package/dist/rules/modules/resolver.d.ts +17 -0
- package/dist/rules/modules/resolver.d.ts.map +1 -0
- package/dist/rules/modules/resolver.js +49 -0
- package/dist/rules/modules/resolver.js.map +1 -0
- package/dist/rules/modules/stdlib/atomic.rules +52 -0
- package/dist/rules/modules/stdlib/atomic.test.json +152 -0
- package/dist/rules/modules/stdlib/auth.rules +7 -0
- package/dist/rules/modules/stdlib/auth.test.json +53 -0
- package/dist/rules/modules/stdlib/content.rules +50 -0
- package/dist/rules/modules/stdlib/content.test.json +238 -0
- package/dist/rules/modules/stdlib/counters.rules +41 -0
- package/dist/rules/modules/stdlib/counters.test.json +254 -0
- package/dist/rules/modules/stdlib/geometry.rules +43 -0
- package/dist/rules/modules/stdlib/geometry.test.json +200 -0
- package/dist/rules/modules/stdlib/joining.rules +49 -0
- package/dist/rules/modules/stdlib/joining.test.json +287 -0
- package/dist/rules/modules/stdlib/lifecycle.rules +51 -0
- package/dist/rules/modules/stdlib/lifecycle.test.json +288 -0
- package/dist/rules/modules/stdlib/lobby.rules +44 -0
- package/dist/rules/modules/stdlib/lobby.test.json +32 -0
- package/dist/rules/modules/stdlib/membership.rules +40 -0
- package/dist/rules/modules/stdlib/membership.test.json +107 -0
- package/dist/rules/modules/stdlib/spaces.rules +46 -0
- package/dist/rules/modules/stdlib/spaces.test.json +392 -0
- package/dist/rules/modules/stdlib/state.rules +28 -0
- package/dist/rules/modules/stdlib/state.test.json +55 -0
- package/dist/rules/modules/stdlib/timing.rules +20 -0
- package/dist/rules/modules/stdlib/timing.test.json +106 -0
- package/dist/rules/modules/stdlib/transitions.rules +34 -0
- package/dist/rules/modules/stdlib/transitions.test.json +86 -0
- package/dist/rules/modules/stdlib/turns.rules +27 -0
- package/dist/rules/modules/stdlib/turns.test.json +64 -0
- package/dist/rules/modules/stdlib/validation.rules +31 -0
- package/dist/rules/modules/stdlib/validation.test.json +184 -0
- package/dist/rules/modules/stdlib-content.d.ts +11 -0
- package/dist/rules/modules/stdlib-content.d.ts.map +1 -0
- package/dist/rules/modules/stdlib-content.js +590 -0
- package/dist/rules/modules/stdlib-content.js.map +1 -0
- package/dist/rules/node.d.ts +17 -0
- package/dist/rules/node.d.ts.map +1 -0
- package/dist/rules/node.js +21 -0
- package/dist/rules/node.js.map +1 -0
- package/dist/rules/rtdb.d.ts +28 -0
- package/dist/rules/rtdb.d.ts.map +1 -0
- package/dist/rules/rtdb.js +15 -0
- package/dist/rules/rtdb.js.map +1 -0
- package/dist/rules/simulator/evaluator.d.ts +179 -0
- package/dist/rules/simulator/evaluator.d.ts.map +1 -0
- package/dist/rules/simulator/evaluator.js +1221 -0
- package/dist/rules/simulator/evaluator.js.map +1 -0
- package/dist/rules/simulator/expression/eval-errors.d.ts +86 -0
- package/dist/rules/simulator/expression/eval-errors.d.ts.map +1 -0
- package/dist/rules/simulator/expression/eval-errors.js +68 -0
- package/dist/rules/simulator/expression/eval-errors.js.map +1 -0
- package/dist/rules/simulator/expression/evaluator.d.ts +43 -0
- package/dist/rules/simulator/expression/evaluator.d.ts.map +1 -0
- package/dist/rules/simulator/expression/evaluator.js +298 -0
- package/dist/rules/simulator/expression/evaluator.js.map +1 -0
- package/dist/rules/simulator/expression/lexer.d.ts +36 -0
- package/dist/rules/simulator/expression/lexer.d.ts.map +1 -0
- package/dist/rules/simulator/expression/lexer.js +318 -0
- package/dist/rules/simulator/expression/lexer.js.map +1 -0
- package/dist/rules/simulator/expression/parser.d.ts +39 -0
- package/dist/rules/simulator/expression/parser.d.ts.map +1 -0
- package/dist/rules/simulator/expression/parser.js +329 -0
- package/dist/rules/simulator/expression/parser.js.map +1 -0
- package/dist/rules/simulator/expression/types.d.ts +137 -0
- package/dist/rules/simulator/expression/types.d.ts.map +1 -0
- package/dist/rules/simulator/expression/types.js +90 -0
- package/dist/rules/simulator/expression/types.js.map +1 -0
- package/dist/rules/simulator/expression/walk-data.d.ts +52 -0
- package/dist/rules/simulator/expression/walk-data.d.ts.map +1 -0
- package/dist/rules/simulator/expression/walk-data.js +167 -0
- package/dist/rules/simulator/expression/walk-data.js.map +1 -0
- package/dist/rules/simulator/firestore-set.d.ts +25 -0
- package/dist/rules/simulator/firestore-set.d.ts.map +1 -0
- package/dist/rules/simulator/firestore-set.js +89 -0
- package/dist/rules/simulator/firestore-set.js.map +1 -0
- package/dist/rules/simulator/handler.d.ts +36 -0
- package/dist/rules/simulator/handler.d.ts.map +1 -0
- package/dist/rules/simulator/handler.js +583 -0
- package/dist/rules/simulator/handler.js.map +1 -0
- package/dist/rules/simulator/mapdiff.d.ts +29 -0
- package/dist/rules/simulator/mapdiff.d.ts.map +1 -0
- package/dist/rules/simulator/mapdiff.js +72 -0
- package/dist/rules/simulator/mapdiff.js.map +1 -0
- package/dist/rules/simulator/project-after-state.d.ts +33 -0
- package/dist/rules/simulator/project-after-state.d.ts.map +1 -0
- package/dist/rules/simulator/project-after-state.js +88 -0
- package/dist/rules/simulator/project-after-state.js.map +1 -0
- package/dist/rules/simulator/query-proof.d.ts +80 -0
- package/dist/rules/simulator/query-proof.d.ts.map +1 -0
- package/dist/rules/simulator/query-proof.js +142 -0
- package/dist/rules/simulator/query-proof.js.map +1 -0
- package/dist/rules/simulator/value-equality.d.ts +8 -0
- package/dist/rules/simulator/value-equality.d.ts.map +1 -0
- package/dist/rules/simulator/value-equality.js +40 -0
- package/dist/rules/simulator/value-equality.js.map +1 -0
- package/dist/rules/simulator/wrappers/base.d.ts +129 -0
- package/dist/rules/simulator/wrappers/base.d.ts.map +1 -0
- package/dist/rules/simulator/wrappers/base.js +90 -0
- package/dist/rules/simulator/wrappers/base.js.map +1 -0
- package/dist/rules/simulator/wrappers/bytes.d.ts +45 -0
- package/dist/rules/simulator/wrappers/bytes.d.ts.map +1 -0
- package/dist/rules/simulator/wrappers/bytes.js +130 -0
- package/dist/rules/simulator/wrappers/bytes.js.map +1 -0
- package/dist/rules/simulator/wrappers/duration.d.ts +76 -0
- package/dist/rules/simulator/wrappers/duration.d.ts.map +1 -0
- package/dist/rules/simulator/wrappers/duration.js +176 -0
- package/dist/rules/simulator/wrappers/duration.js.map +1 -0
- package/dist/rules/simulator/wrappers/float.d.ts +52 -0
- package/dist/rules/simulator/wrappers/float.d.ts.map +1 -0
- package/dist/rules/simulator/wrappers/float.js +88 -0
- package/dist/rules/simulator/wrappers/float.js.map +1 -0
- package/dist/rules/simulator/wrappers/latlng.d.ts +40 -0
- package/dist/rules/simulator/wrappers/latlng.d.ts.map +1 -0
- package/dist/rules/simulator/wrappers/latlng.js +100 -0
- package/dist/rules/simulator/wrappers/latlng.js.map +1 -0
- package/dist/rules/simulator/wrappers/path.d.ts +75 -0
- package/dist/rules/simulator/wrappers/path.d.ts.map +1 -0
- package/dist/rules/simulator/wrappers/path.js +158 -0
- package/dist/rules/simulator/wrappers/path.js.map +1 -0
- package/dist/rules/simulator/wrappers/reference.d.ts +82 -0
- package/dist/rules/simulator/wrappers/reference.d.ts.map +1 -0
- package/dist/rules/simulator/wrappers/reference.js +145 -0
- package/dist/rules/simulator/wrappers/reference.js.map +1 -0
- package/dist/rules/simulator/wrappers/timestamp.d.ts +86 -0
- package/dist/rules/simulator/wrappers/timestamp.d.ts.map +1 -0
- package/dist/rules/simulator/wrappers/timestamp.js +207 -0
- package/dist/rules/simulator/wrappers/timestamp.js.map +1 -0
- package/dist/rules/simulator/wrappers/vector.d.ts +43 -0
- package/dist/rules/simulator/wrappers/vector.d.ts.map +1 -0
- package/dist/rules/simulator/wrappers/vector.js +80 -0
- package/dist/rules/simulator/wrappers/vector.js.map +1 -0
- package/dist/rules/simulator-tools-impl.d.ts +64 -0
- package/dist/rules/simulator-tools-impl.d.ts.map +1 -0
- package/dist/rules/simulator-tools-impl.js +462 -0
- package/dist/rules/simulator-tools-impl.js.map +1 -0
- package/dist/rules/simulator.d.ts +20 -0
- package/dist/rules/simulator.d.ts.map +1 -0
- package/dist/rules/simulator.js +24 -0
- package/dist/rules/simulator.js.map +1 -0
- package/dist/rules/stdlib-modules.d.ts +73 -0
- package/dist/rules/stdlib-modules.d.ts.map +1 -0
- package/dist/rules/stdlib-modules.js +849 -0
- package/dist/rules/stdlib-modules.js.map +1 -0
- package/dist/rules/stdlib-tools.d.ts +19 -0
- package/dist/rules/stdlib-tools.d.ts.map +1 -0
- package/dist/rules/stdlib-tools.js +164 -0
- package/dist/rules/stdlib-tools.js.map +1 -0
- package/dist/rules/test/handler.d.ts +9 -0
- package/dist/rules/test/handler.d.ts.map +1 -0
- package/dist/rules/test/handler.js +124 -0
- package/dist/rules/test/handler.js.map +1 -0
- package/dist/rules/test/spec.d.ts +517 -0
- package/dist/rules/test/spec.d.ts.map +1 -0
- package/dist/rules/test/spec.js +227 -0
- package/dist/rules/test/spec.js.map +1 -0
- package/dist/rules/tools.d.ts +41 -0
- package/dist/rules/tools.d.ts.map +1 -0
- package/dist/rules/tools.js +124 -0
- package/dist/rules/tools.js.map +1 -0
- package/dist/rules/write/handler.d.ts +6 -0
- package/dist/rules/write/handler.d.ts.map +1 -0
- package/dist/rules/write/handler.js +126 -0
- package/dist/rules/write/handler.js.map +1 -0
- package/dist/rules/write/spec.d.ts +28 -0
- package/dist/rules/write/spec.d.ts.map +1 -0
- package/dist/rules/write/spec.js +2 -0
- package/dist/rules/write/spec.js.map +1 -0
- package/dist/sandbox/admin-compat.d.ts +20 -0
- package/dist/sandbox/admin-compat.d.ts.map +1 -0
- package/dist/sandbox/admin-compat.js +19 -0
- package/dist/sandbox/admin-compat.js.map +1 -0
- package/dist/sandbox/admin-firestore/error-translation.d.ts +86 -0
- package/dist/sandbox/admin-firestore/error-translation.d.ts.map +1 -0
- package/dist/sandbox/admin-firestore/error-translation.js +204 -0
- package/dist/sandbox/admin-firestore/error-translation.js.map +1 -0
- package/dist/sandbox/admin-firestore/index.d.ts +169 -0
- package/dist/sandbox/admin-firestore/index.d.ts.map +1 -0
- package/dist/sandbox/admin-firestore/index.js +456 -0
- package/dist/sandbox/admin-firestore/index.js.map +1 -0
- package/dist/sandbox/admin-firestore/remote.d.ts +97 -0
- package/dist/sandbox/admin-firestore/remote.d.ts.map +1 -0
- package/dist/sandbox/admin-firestore/remote.js +735 -0
- package/dist/sandbox/admin-firestore/remote.js.map +1 -0
- package/dist/sandbox/branches/index.d.ts +141 -0
- package/dist/sandbox/branches/index.d.ts.map +1 -0
- package/dist/sandbox/branches/index.js +327 -0
- package/dist/sandbox/branches/index.js.map +1 -0
- package/dist/sandbox/firestore/admin-compat/batch.d.ts +34 -0
- package/dist/sandbox/firestore/admin-compat/batch.d.ts.map +1 -0
- package/dist/sandbox/firestore/admin-compat/batch.js +73 -0
- package/dist/sandbox/firestore/admin-compat/batch.js.map +1 -0
- package/dist/sandbox/firestore/admin-compat/doc-ref.d.ts +38 -0
- package/dist/sandbox/firestore/admin-compat/doc-ref.d.ts.map +1 -0
- package/dist/sandbox/firestore/admin-compat/doc-ref.js +154 -0
- package/dist/sandbox/firestore/admin-compat/doc-ref.js.map +1 -0
- package/dist/sandbox/firestore/admin-compat/firestore.d.ts +45 -0
- package/dist/sandbox/firestore/admin-compat/firestore.d.ts.map +1 -0
- package/dist/sandbox/firestore/admin-compat/firestore.js +114 -0
- package/dist/sandbox/firestore/admin-compat/firestore.js.map +1 -0
- package/dist/sandbox/firestore/admin-compat/index.d.ts +45 -0
- package/dist/sandbox/firestore/admin-compat/index.d.ts.map +1 -0
- package/dist/sandbox/firestore/admin-compat/index.js +27 -0
- package/dist/sandbox/firestore/admin-compat/index.js.map +1 -0
- package/dist/sandbox/firestore/admin-compat/paths.d.ts +37 -0
- package/dist/sandbox/firestore/admin-compat/paths.d.ts.map +1 -0
- package/dist/sandbox/firestore/admin-compat/paths.js +49 -0
- package/dist/sandbox/firestore/admin-compat/paths.js.map +1 -0
- package/dist/sandbox/firestore/admin-compat/query.d.ts +261 -0
- package/dist/sandbox/firestore/admin-compat/query.d.ts.map +1 -0
- package/dist/sandbox/firestore/admin-compat/query.js +704 -0
- package/dist/sandbox/firestore/admin-compat/query.js.map +1 -0
- package/dist/sandbox/firestore/admin-compat/read-translation.d.ts +8 -0
- package/dist/sandbox/firestore/admin-compat/read-translation.d.ts.map +1 -0
- package/dist/sandbox/firestore/admin-compat/read-translation.js +59 -0
- package/dist/sandbox/firestore/admin-compat/read-translation.js.map +1 -0
- package/dist/sandbox/firestore/admin-compat/snapshots.d.ts +39 -0
- package/dist/sandbox/firestore/admin-compat/snapshots.d.ts.map +1 -0
- package/dist/sandbox/firestore/admin-compat/snapshots.js +50 -0
- package/dist/sandbox/firestore/admin-compat/snapshots.js.map +1 -0
- package/dist/sandbox/firestore/admin-compat/transaction.d.ts +35 -0
- package/dist/sandbox/firestore/admin-compat/transaction.d.ts.map +1 -0
- package/dist/sandbox/firestore/admin-compat/transaction.js +88 -0
- package/dist/sandbox/firestore/admin-compat/transaction.js.map +1 -0
- package/dist/sandbox/firestore/admin-compat/types.d.ts +297 -0
- package/dist/sandbox/firestore/admin-compat/types.d.ts.map +1 -0
- package/dist/sandbox/firestore/admin-compat/types.js +127 -0
- package/dist/sandbox/firestore/admin-compat/types.js.map +1 -0
- package/dist/sandbox/firestore/admin-compat/value-order.d.ts +49 -0
- package/dist/sandbox/firestore/admin-compat/value-order.d.ts.map +1 -0
- package/dist/sandbox/firestore/admin-compat/value-order.js +172 -0
- package/dist/sandbox/firestore/admin-compat/value-order.js.map +1 -0
- package/dist/sandbox/firestore/auto-id.d.ts +26 -0
- package/dist/sandbox/firestore/auto-id.d.ts.map +1 -0
- package/dist/sandbox/firestore/auto-id.js +36 -0
- package/dist/sandbox/firestore/auto-id.js.map +1 -0
- package/dist/sandbox/firestore/converters/bytes-geopoint.d.ts +48 -0
- package/dist/sandbox/firestore/converters/bytes-geopoint.d.ts.map +1 -0
- package/dist/sandbox/firestore/converters/bytes-geopoint.js +85 -0
- package/dist/sandbox/firestore/converters/bytes-geopoint.js.map +1 -0
- package/dist/sandbox/firestore/converters/fieldvalue.d.ts +69 -0
- package/dist/sandbox/firestore/converters/fieldvalue.d.ts.map +1 -0
- package/dist/sandbox/firestore/converters/fieldvalue.js +166 -0
- package/dist/sandbox/firestore/converters/fieldvalue.js.map +1 -0
- package/dist/sandbox/firestore/converters/reference.d.ts +31 -0
- package/dist/sandbox/firestore/converters/reference.d.ts.map +1 -0
- package/dist/sandbox/firestore/converters/reference.js +51 -0
- package/dist/sandbox/firestore/converters/reference.js.map +1 -0
- package/dist/sandbox/firestore/converters/timestamp.d.ts +32 -0
- package/dist/sandbox/firestore/converters/timestamp.d.ts.map +1 -0
- package/dist/sandbox/firestore/converters/timestamp.js +63 -0
- package/dist/sandbox/firestore/converters/timestamp.js.map +1 -0
- package/dist/sandbox/firestore/converters/user-timestamp.d.ts +46 -0
- package/dist/sandbox/firestore/converters/user-timestamp.d.ts.map +1 -0
- package/dist/sandbox/firestore/converters/user-timestamp.js +65 -0
- package/dist/sandbox/firestore/converters/user-timestamp.js.map +1 -0
- package/dist/sandbox/firestore/converters/vector.d.ts +24 -0
- package/dist/sandbox/firestore/converters/vector.d.ts.map +1 -0
- package/dist/sandbox/firestore/converters/vector.js +53 -0
- package/dist/sandbox/firestore/converters/vector.js.map +1 -0
- package/dist/sandbox/firestore/errors.d.ts +82 -0
- package/dist/sandbox/firestore/errors.d.ts.map +1 -0
- package/dist/sandbox/firestore/errors.js +54 -0
- package/dist/sandbox/firestore/errors.js.map +1 -0
- package/dist/sandbox/firestore/event-log.d.ts +99 -0
- package/dist/sandbox/firestore/event-log.d.ts.map +1 -0
- package/dist/sandbox/firestore/event-log.js +67 -0
- package/dist/sandbox/firestore/event-log.js.map +1 -0
- package/dist/sandbox/firestore/field-merge.d.ts +64 -0
- package/dist/sandbox/firestore/field-merge.d.ts.map +1 -0
- package/dist/sandbox/firestore/field-merge.js +221 -0
- package/dist/sandbox/firestore/field-merge.js.map +1 -0
- package/dist/sandbox/firestore/list-query-proof.d.ts +77 -0
- package/dist/sandbox/firestore/list-query-proof.d.ts.map +1 -0
- package/dist/sandbox/firestore/list-query-proof.js +170 -0
- package/dist/sandbox/firestore/list-query-proof.js.map +1 -0
- package/dist/sandbox/firestore/local-environment.d.ts +806 -0
- package/dist/sandbox/firestore/local-environment.d.ts.map +1 -0
- package/dist/sandbox/firestore/local-environment.js +3028 -0
- package/dist/sandbox/firestore/local-environment.js.map +1 -0
- package/dist/sandbox/firestore/local-state.d.ts +196 -0
- package/dist/sandbox/firestore/local-state.d.ts.map +1 -0
- package/dist/sandbox/firestore/local-state.js +413 -0
- package/dist/sandbox/firestore/local-state.js.map +1 -0
- package/dist/sandbox/firestore/overlay-backing.d.ts +44 -0
- package/dist/sandbox/firestore/overlay-backing.d.ts.map +1 -0
- package/dist/sandbox/firestore/overlay-backing.js +82 -0
- package/dist/sandbox/firestore/overlay-backing.js.map +1 -0
- package/dist/sandbox/firestore/sentinel-capture.d.ts +30 -0
- package/dist/sandbox/firestore/sentinel-capture.d.ts.map +1 -0
- package/dist/sandbox/firestore/sentinel-capture.js +61 -0
- package/dist/sandbox/firestore/sentinel-capture.js.map +1 -0
- package/dist/sandbox/firestore/snapshot-listeners.d.ts +284 -0
- package/dist/sandbox/firestore/snapshot-listeners.d.ts.map +1 -0
- package/dist/sandbox/firestore/snapshot-listeners.js +194 -0
- package/dist/sandbox/firestore/snapshot-listeners.js.map +1 -0
- package/dist/sandbox/firestore/topk.d.ts +12 -0
- package/dist/sandbox/firestore/topk.d.ts.map +1 -0
- package/dist/sandbox/firestore/topk.js +42 -0
- package/dist/sandbox/firestore/topk.js.map +1 -0
- package/dist/sandbox/firestore/transaction-merge.d.ts +55 -0
- package/dist/sandbox/firestore/transaction-merge.d.ts.map +1 -0
- package/dist/sandbox/firestore/transaction-merge.js +107 -0
- package/dist/sandbox/firestore/transaction-merge.js.map +1 -0
- package/dist/sandbox/firestore/transaction-types.d.ts +152 -0
- package/dist/sandbox/firestore/transaction-types.d.ts.map +1 -0
- package/dist/sandbox/firestore/transaction-types.js +10 -0
- package/dist/sandbox/firestore/transaction-types.js.map +1 -0
- package/dist/sandbox/firestore/transaction.d.ts +79 -0
- package/dist/sandbox/firestore/transaction.d.ts.map +1 -0
- package/dist/sandbox/firestore/transaction.js +95 -0
- package/dist/sandbox/firestore/transaction.js.map +1 -0
- package/dist/sandbox/firestore/value-equality.d.ts +2 -0
- package/dist/sandbox/firestore/value-equality.d.ts.map +1 -0
- package/dist/sandbox/firestore/value-equality.js +35 -0
- package/dist/sandbox/firestore/value-equality.js.map +1 -0
- package/dist/sandbox/firestore/value-resolver.d.ts +172 -0
- package/dist/sandbox/firestore/value-resolver.d.ts.map +1 -0
- package/dist/sandbox/firestore/value-resolver.js +221 -0
- package/dist/sandbox/firestore/value-resolver.js.map +1 -0
- package/dist/sandbox/firestore/wire-encoder.d.ts +14 -0
- package/dist/sandbox/firestore/wire-encoder.d.ts.map +1 -0
- package/dist/sandbox/firestore/wire-encoder.js +130 -0
- package/dist/sandbox/firestore/wire-encoder.js.map +1 -0
- package/dist/sandbox/index.d.ts +50 -0
- package/dist/sandbox/index.d.ts.map +1 -0
- package/dist/sandbox/index.js +63 -0
- package/dist/sandbox/index.js.map +1 -0
- package/dist/sandbox/internal/index.d.ts +36 -0
- package/dist/sandbox/internal/index.d.ts.map +1 -0
- package/dist/sandbox/internal/index.js +72 -0
- package/dist/sandbox/internal/index.js.map +1 -0
- package/dist/sandbox/internal/sandbox-impl.d.ts +343 -0
- package/dist/sandbox/internal/sandbox-impl.d.ts.map +1 -0
- package/dist/sandbox/internal/sandbox-impl.js +718 -0
- package/dist/sandbox/internal/sandbox-impl.js.map +1 -0
- package/dist/sandbox/persistence/backends.d.ts +37 -0
- package/dist/sandbox/persistence/backends.d.ts.map +1 -0
- package/dist/sandbox/persistence/backends.js +202 -0
- package/dist/sandbox/persistence/backends.js.map +1 -0
- package/dist/sandbox/persistence/chunk-format.d.ts +58 -0
- package/dist/sandbox/persistence/chunk-format.d.ts.map +1 -0
- package/dist/sandbox/persistence/chunk-format.js +163 -0
- package/dist/sandbox/persistence/chunk-format.js.map +1 -0
- package/dist/sandbox/persistence/controller.d.ts +39 -0
- package/dist/sandbox/persistence/controller.d.ts.map +1 -0
- package/dist/sandbox/persistence/controller.js +605 -0
- package/dist/sandbox/persistence/controller.js.map +1 -0
- package/dist/sandbox/persistence/index.d.ts +13 -0
- package/dist/sandbox/persistence/index.d.ts.map +1 -0
- package/dist/sandbox/persistence/index.js +5 -0
- package/dist/sandbox/persistence/index.js.map +1 -0
- package/dist/sandbox/persistence/serialize.d.ts +53 -0
- package/dist/sandbox/persistence/serialize.d.ts.map +1 -0
- package/dist/sandbox/persistence/serialize.js +98 -0
- package/dist/sandbox/persistence/serialize.js.map +1 -0
- package/dist/sandbox/persistence/types.d.ts +118 -0
- package/dist/sandbox/persistence/types.d.ts.map +1 -0
- package/dist/sandbox/persistence/types.js +15 -0
- package/dist/sandbox/persistence/types.js.map +1 -0
- package/dist/sandbox/remote.d.ts +118 -0
- package/dist/sandbox/remote.d.ts.map +1 -0
- package/dist/sandbox/remote.js +60 -0
- package/dist/sandbox/remote.js.map +1 -0
- package/dist/sandbox/replay/index.d.ts +106 -0
- package/dist/sandbox/replay/index.d.ts.map +1 -0
- package/dist/sandbox/replay/index.js +246 -0
- package/dist/sandbox/replay/index.js.map +1 -0
- package/dist/sandbox/sandbox-context.d.ts +40 -0
- package/dist/sandbox/sandbox-context.d.ts.map +1 -0
- package/dist/sandbox/sandbox-context.js +72 -0
- package/dist/sandbox/sandbox-context.js.map +1 -0
- package/dist/sandbox/tab-sync/index.d.ts +120 -0
- package/dist/sandbox/tab-sync/index.d.ts.map +1 -0
- package/dist/sandbox/tab-sync/index.js +272 -0
- package/dist/sandbox/tab-sync/index.js.map +1 -0
- package/dist/sandbox/types.d.ts +1235 -0
- package/dist/sandbox/types.d.ts.map +1 -0
- package/dist/sandbox/types.js +46 -0
- package/dist/sandbox/types.js.map +1 -0
- package/dist/storage/admin/api.d.ts +206 -0
- package/dist/storage/admin/api.d.ts.map +1 -0
- package/dist/storage/admin/api.js +349 -0
- package/dist/storage/admin/api.js.map +1 -0
- package/dist/storage/admin/handler.d.ts +29 -0
- package/dist/storage/admin/handler.d.ts.map +1 -0
- package/dist/storage/admin/handler.js +82 -0
- package/dist/storage/admin/handler.js.map +1 -0
- package/dist/storage/admin/spec.d.ts +64 -0
- package/dist/storage/admin/spec.d.ts.map +1 -0
- package/dist/storage/admin/spec.js +8 -0
- package/dist/storage/admin/spec.js.map +1 -0
- package/dist/storage/admin/tools.d.ts +23 -0
- package/dist/storage/admin/tools.d.ts.map +1 -0
- package/dist/storage/admin/tools.js +90 -0
- package/dist/storage/admin/tools.js.map +1 -0
- package/dist/storage/download.d.ts +27 -0
- package/dist/storage/download.d.ts.map +1 -0
- package/dist/storage/download.js +136 -0
- package/dist/storage/download.js.map +1 -0
- package/dist/storage/enforce.d.ts +26 -0
- package/dist/storage/enforce.d.ts.map +1 -0
- package/dist/storage/enforce.js +16 -0
- package/dist/storage/enforce.js.map +1 -0
- package/dist/storage/errors.d.ts +46 -0
- package/dist/storage/errors.d.ts.map +1 -0
- package/dist/storage/errors.js +62 -0
- package/dist/storage/errors.js.map +1 -0
- package/dist/storage/index.d.ts +49 -0
- package/dist/storage/index.d.ts.map +1 -0
- package/dist/storage/index.js +60 -0
- package/dist/storage/index.js.map +1 -0
- package/dist/storage/internal.d.ts +18 -0
- package/dist/storage/internal.d.ts.map +1 -0
- package/dist/storage/internal.js +18 -0
- package/dist/storage/internal.js.map +1 -0
- package/dist/storage/list.d.ts +17 -0
- package/dist/storage/list.d.ts.map +1 -0
- package/dist/storage/list.js +91 -0
- package/dist/storage/list.js.map +1 -0
- package/dist/storage/metadata.d.ts +86 -0
- package/dist/storage/metadata.d.ts.map +1 -0
- package/dist/storage/metadata.js +167 -0
- package/dist/storage/metadata.js.map +1 -0
- package/dist/storage/persistence.d.ts +134 -0
- package/dist/storage/persistence.d.ts.map +1 -0
- package/dist/storage/persistence.js +132 -0
- package/dist/storage/persistence.js.map +1 -0
- package/dist/storage/reference.d.ts +63 -0
- package/dist/storage/reference.d.ts.map +1 -0
- package/dist/storage/reference.js +156 -0
- package/dist/storage/reference.js.map +1 -0
- package/dist/storage/rules.d.ts +176 -0
- package/dist/storage/rules.d.ts.map +1 -0
- package/dist/storage/rules.js +621 -0
- package/dist/storage/rules.js.map +1 -0
- package/dist/storage/service.d.ts +139 -0
- package/dist/storage/service.d.ts.map +1 -0
- package/dist/storage/service.js +241 -0
- package/dist/storage/service.js.map +1 -0
- package/dist/storage/upload.d.ts +43 -0
- package/dist/storage/upload.d.ts.map +1 -0
- package/dist/storage/upload.js +235 -0
- package/dist/storage/upload.js.map +1 -0
- 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
|