pyric 0.1.0-alpha.19 → 0.1.0-alpha.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (281) hide show
  1. package/README.md +64 -89
  2. package/dist/ai/blocked.d.ts +52 -0
  3. package/dist/ai/blocked.d.ts.map +1 -0
  4. package/dist/ai/blocked.js +83 -0
  5. package/dist/ai/blocked.js.map +1 -0
  6. package/dist/ai/broker/broker.d.ts +50 -0
  7. package/dist/ai/broker/broker.d.ts.map +1 -1
  8. package/dist/ai/broker/broker.js +157 -21
  9. package/dist/ai/broker/broker.js.map +1 -1
  10. package/dist/ai/broker/gemini-engine.d.ts +56 -0
  11. package/dist/ai/broker/gemini-engine.d.ts.map +1 -0
  12. package/dist/ai/broker/gemini-engine.js +235 -0
  13. package/dist/ai/broker/gemini-engine.js.map +1 -0
  14. package/dist/ai/broker/index.d.ts +2 -1
  15. package/dist/ai/broker/index.d.ts.map +1 -1
  16. package/dist/ai/broker/index.js +2 -1
  17. package/dist/ai/broker/index.js.map +1 -1
  18. package/dist/ai/broker/openai-engine.d.ts +10 -1
  19. package/dist/ai/broker/openai-engine.d.ts.map +1 -1
  20. package/dist/ai/broker/openai-engine.js +26 -4
  21. package/dist/ai/broker/openai-engine.js.map +1 -1
  22. package/dist/ai/broker/synthesizer.d.ts +14 -0
  23. package/dist/ai/broker/synthesizer.d.ts.map +1 -1
  24. package/dist/ai/broker/synthesizer.js +31 -0
  25. package/dist/ai/broker/synthesizer.js.map +1 -1
  26. package/dist/ai/broker/types.d.ts +29 -0
  27. package/dist/ai/broker/types.d.ts.map +1 -1
  28. package/dist/ai/instances.js +1 -1
  29. package/dist/ai/instances.js.map +1 -1
  30. package/dist/ai/internal.d.ts +5 -0
  31. package/dist/ai/internal.d.ts.map +1 -1
  32. package/dist/ai/internal.js +5 -0
  33. package/dist/ai/internal.js.map +1 -1
  34. package/dist/ai/response-helpers.d.ts.map +1 -1
  35. package/dist/ai/response-helpers.js +6 -24
  36. package/dist/ai/response-helpers.js.map +1 -1
  37. package/dist/analytics/index.d.ts +40 -0
  38. package/dist/analytics/index.d.ts.map +1 -0
  39. package/dist/analytics/index.js +25 -0
  40. package/dist/analytics/index.js.map +1 -0
  41. package/dist/app-check/index.d.ts +31 -0
  42. package/dist/app-check/index.d.ts.map +1 -0
  43. package/dist/app-check/index.js +20 -0
  44. package/dist/app-check/index.js.map +1 -0
  45. package/dist/auth/instances.js +1 -1
  46. package/dist/auth/instances.js.map +1 -1
  47. package/dist/database/controls.d.ts +1 -1
  48. package/dist/database/controls.js +1 -1
  49. package/dist/database/instances.js +1 -1
  50. package/dist/database/instances.js.map +1 -1
  51. package/dist/database/sandbox/backend.d.ts +1 -0
  52. package/dist/database/sandbox/backend.d.ts.map +1 -1
  53. package/dist/database/sandbox/backend.js +1 -0
  54. package/dist/database/sandbox/backend.js.map +1 -1
  55. package/dist/database/sandbox/rules-eval.d.ts +6 -2
  56. package/dist/database/sandbox/rules-eval.d.ts.map +1 -1
  57. package/dist/database/sandbox/rules-eval.js +15 -2
  58. package/dist/database/sandbox/rules-eval.js.map +1 -1
  59. package/dist/database/sandbox/write-plane.d.ts +1 -0
  60. package/dist/database/sandbox/write-plane.d.ts.map +1 -1
  61. package/dist/database/sandbox/write-plane.js +7 -2
  62. package/dist/database/sandbox/write-plane.js.map +1 -1
  63. package/dist/database/sandbox-controls.d.ts +12 -5
  64. package/dist/database/sandbox-controls.d.ts.map +1 -1
  65. package/dist/database/sandbox-controls.js +20 -9
  66. package/dist/database/sandbox-controls.js.map +1 -1
  67. package/dist/database/sandbox-namespace.d.ts +19 -1
  68. package/dist/database/sandbox-namespace.d.ts.map +1 -1
  69. package/dist/database/sandbox-namespace.js +24 -1
  70. package/dist/database/sandbox-namespace.js.map +1 -1
  71. package/dist/deferred/entry.d.ts +75 -0
  72. package/dist/deferred/entry.d.ts.map +1 -0
  73. package/dist/deferred/entry.js +143 -0
  74. package/dist/deferred/entry.js.map +1 -0
  75. package/dist/firestore/instances.js +1 -1
  76. package/dist/firestore/instances.js.map +1 -1
  77. package/dist/firestore/lite.d.ts +72 -0
  78. package/dist/firestore/lite.d.ts.map +1 -0
  79. package/dist/firestore/lite.js +22 -0
  80. package/dist/firestore/lite.js.map +1 -0
  81. package/dist/firestore/persistence.d.ts +2 -2
  82. package/dist/firestore/persistence.js +1 -1
  83. package/dist/firestore/sandbox-controls.d.ts +2 -0
  84. package/dist/firestore/sandbox-controls.d.ts.map +1 -1
  85. package/dist/firestore/sandbox-controls.js +8 -0
  86. package/dist/firestore/sandbox-controls.js.map +1 -1
  87. package/dist/functions/index.d.ts +29 -0
  88. package/dist/functions/index.d.ts.map +1 -0
  89. package/dist/functions/index.js +20 -0
  90. package/dist/functions/index.js.map +1 -0
  91. package/dist/messaging/client.d.ts +2 -1
  92. package/dist/messaging/client.d.ts.map +1 -1
  93. package/dist/messaging/client.js +2 -2
  94. package/dist/messaging/client.js.map +1 -1
  95. package/dist/messaging/instance.d.ts +2 -2
  96. package/dist/messaging/instance.d.ts.map +1 -1
  97. package/dist/messaging/instance.js +40 -6
  98. package/dist/messaging/instance.js.map +1 -1
  99. package/dist/messaging/sw.d.ts +2 -1
  100. package/dist/messaging/sw.d.ts.map +1 -1
  101. package/dist/messaging/sw.js +2 -2
  102. package/dist/messaging/sw.js.map +1 -1
  103. package/dist/performance/index.d.ts +22 -0
  104. package/dist/performance/index.d.ts.map +1 -0
  105. package/dist/performance/index.js +19 -0
  106. package/dist/performance/index.js.map +1 -0
  107. package/dist/remote-config/index.d.ts +40 -0
  108. package/dist/remote-config/index.d.ts.map +1 -0
  109. package/dist/remote-config/index.js +25 -0
  110. package/dist/remote-config/index.js.map +1 -0
  111. package/dist/rules/grammar/FirestoreValidator.d.ts.map +1 -1
  112. package/dist/rules/grammar/FirestoreValidator.js +43 -27
  113. package/dist/rules/grammar/FirestoreValidator.js.map +1 -1
  114. package/dist/rules/grammar/document-access-count.d.ts +24 -0
  115. package/dist/rules/grammar/document-access-count.d.ts.map +1 -0
  116. package/dist/rules/grammar/document-access-count.js +100 -0
  117. package/dist/rules/grammar/document-access-count.js.map +1 -0
  118. package/dist/rules/linter/ast-utils.d.ts +2 -6
  119. package/dist/rules/linter/ast-utils.d.ts.map +1 -1
  120. package/dist/rules/linter/ast-utils.js +2 -63
  121. package/dist/rules/linter/ast-utils.js.map +1 -1
  122. package/dist/rules/linter/hallucinations.d.ts +8 -0
  123. package/dist/rules/linter/hallucinations.d.ts.map +1 -1
  124. package/dist/rules/linter/hallucinations.js +121 -31
  125. package/dist/rules/linter/hallucinations.js.map +1 -1
  126. package/dist/rules/linter/linter.d.ts +10 -1
  127. package/dist/rules/linter/linter.d.ts.map +1 -1
  128. package/dist/rules/linter/linter.js +23 -9
  129. package/dist/rules/linter/linter.js.map +1 -1
  130. package/dist/rules/rtdb/grammar/RtdbExpr.ohm +5 -2
  131. package/dist/rules/rtdb/grammar/RtdbExpr.ohm.generated.d.ts +1 -1
  132. package/dist/rules/rtdb/grammar/RtdbExpr.ohm.generated.d.ts.map +1 -1
  133. package/dist/rules/rtdb/grammar/RtdbExpr.ohm.generated.js +5 -2
  134. package/dist/rules/rtdb/grammar/RtdbExpr.ohm.generated.js.map +1 -1
  135. package/dist/rules/rtdb/grammar/simulator.d.ts.map +1 -1
  136. package/dist/rules/rtdb/grammar/simulator.js +61 -15
  137. package/dist/rules/rtdb/grammar/simulator.js.map +1 -1
  138. package/dist/rules/rtdb/simulation/handler.d.ts.map +1 -1
  139. package/dist/rules/rtdb/simulation/handler.js +96 -35
  140. package/dist/rules/rtdb/simulation/handler.js.map +1 -1
  141. package/dist/rules/simulator/document-lookups.d.ts +4 -0
  142. package/dist/rules/simulator/document-lookups.d.ts.map +1 -1
  143. package/dist/rules/simulator/document-lookups.js +42 -3
  144. package/dist/rules/simulator/document-lookups.js.map +1 -1
  145. package/dist/rules/simulator/eval-error.d.ts +11 -0
  146. package/dist/rules/simulator/eval-error.d.ts.map +1 -1
  147. package/dist/rules/simulator/eval-error.js +14 -0
  148. package/dist/rules/simulator/eval-error.js.map +1 -1
  149. package/dist/rules/simulator/evaluation-builtins.d.ts.map +1 -1
  150. package/dist/rules/simulator/evaluation-builtins.js +33 -8
  151. package/dist/rules/simulator/evaluation-builtins.js.map +1 -1
  152. package/dist/rules/simulator/evaluation-context.d.ts +12 -0
  153. package/dist/rules/simulator/evaluation-context.d.ts.map +1 -1
  154. package/dist/rules/simulator/evaluator.d.ts +1 -1
  155. package/dist/rules/simulator/evaluator.d.ts.map +1 -1
  156. package/dist/rules/simulator/evaluator.js +16 -4
  157. package/dist/rules/simulator/evaluator.js.map +1 -1
  158. package/dist/rules/simulator/handler.d.ts.map +1 -1
  159. package/dist/rules/simulator/handler.js +52 -5
  160. package/dist/rules/simulator/handler.js.map +1 -1
  161. package/dist/rules/simulator/lookup-budget.d.ts +37 -0
  162. package/dist/rules/simulator/lookup-budget.d.ts.map +1 -0
  163. package/dist/rules/simulator/lookup-budget.js +96 -0
  164. package/dist/rules/simulator/lookup-budget.js.map +1 -0
  165. package/dist/rules/stdlib-modules.d.ts +7 -0
  166. package/dist/rules/stdlib-modules.d.ts.map +1 -1
  167. package/dist/rules/stdlib-modules.js +11 -11
  168. package/dist/rules/stdlib-modules.js.map +1 -1
  169. package/dist/sandbox/admin-firestore/get-firestore.d.ts +6 -0
  170. package/dist/sandbox/admin-firestore/get-firestore.d.ts.map +1 -1
  171. package/dist/sandbox/admin-firestore/get-firestore.js +22 -3
  172. package/dist/sandbox/admin-firestore/get-firestore.js.map +1 -1
  173. package/dist/sandbox/index.d.ts +1 -1
  174. package/dist/sandbox/index.d.ts.map +1 -1
  175. package/dist/sandbox/index.js +1 -1
  176. package/dist/sandbox/index.js.map +1 -1
  177. package/dist/sandbox/persistence/types.d.ts +1 -1
  178. package/dist/sandbox/remote.d.ts +2 -2
  179. package/dist/sandbox/sandbox-context.d.ts +8 -1
  180. package/dist/sandbox/sandbox-context.d.ts.map +1 -1
  181. package/dist/sandbox/sandbox-context.js +59 -8
  182. package/dist/sandbox/sandbox-context.js.map +1 -1
  183. package/dist/sandbox/types/auth-state.d.ts +1 -0
  184. package/dist/sandbox/types/auth-state.d.ts.map +1 -1
  185. package/dist/sandbox/types/events.d.ts +2 -1
  186. package/dist/sandbox/types/events.d.ts.map +1 -1
  187. package/dist/sandbox/types/operation.d.ts +1 -0
  188. package/dist/sandbox/types/operation.d.ts.map +1 -1
  189. package/dist/storage/enforce.d.ts +20 -1
  190. package/dist/storage/enforce.d.ts.map +1 -1
  191. package/dist/storage/enforce.js +47 -14
  192. package/dist/storage/enforce.js.map +1 -1
  193. package/dist/storage/index.d.ts +1 -1
  194. package/dist/storage/index.d.ts.map +1 -1
  195. package/dist/storage/list.js +1 -1
  196. package/dist/storage/list.js.map +1 -1
  197. package/dist/storage/persistence.d.ts.map +1 -1
  198. package/dist/storage/persistence.js +66 -14
  199. package/dist/storage/persistence.js.map +1 -1
  200. package/dist/storage/sandbox/rules-evaluation-error.d.ts +25 -0
  201. package/dist/storage/sandbox/rules-evaluation-error.d.ts.map +1 -1
  202. package/dist/storage/sandbox/rules-evaluation-error.js +29 -0
  203. package/dist/storage/sandbox/rules-evaluation-error.js.map +1 -1
  204. package/dist/storage/sandbox/rules-evaluator.d.ts +10 -5
  205. package/dist/storage/sandbox/rules-evaluator.d.ts.map +1 -1
  206. package/dist/storage/sandbox/rules-evaluator.js +105 -33
  207. package/dist/storage/sandbox/rules-evaluator.js.map +1 -1
  208. package/dist/storage/sandbox/rules-methods.d.ts.map +1 -1
  209. package/dist/storage/sandbox/rules-methods.js +17 -7
  210. package/dist/storage/sandbox/rules-methods.js.map +1 -1
  211. package/dist/storage/service.d.ts +23 -2
  212. package/dist/storage/service.d.ts.map +1 -1
  213. package/dist/storage/service.js +37 -26
  214. package/dist/storage/service.js.map +1 -1
  215. package/package.json +42 -2
  216. package/src/ai/blocked.ts +97 -0
  217. package/src/ai/broker/broker.ts +153 -22
  218. package/src/ai/broker/gemini-engine.ts +311 -0
  219. package/src/ai/broker/index.ts +2 -0
  220. package/src/ai/broker/openai-engine.ts +31 -3
  221. package/src/ai/broker/synthesizer.ts +34 -0
  222. package/src/ai/broker/types.ts +27 -1
  223. package/src/ai/instances.ts +1 -1
  224. package/src/ai/internal.ts +5 -0
  225. package/src/ai/response-helpers.ts +6 -25
  226. package/src/analytics/index.ts +53 -0
  227. package/src/app/dispatch.test.ts +3 -1
  228. package/src/app-check/index.ts +41 -0
  229. package/src/auth/instances.ts +1 -1
  230. package/src/database/controls.ts +1 -1
  231. package/src/database/instances.ts +1 -1
  232. package/src/database/sandbox/backend.ts +1 -0
  233. package/src/database/sandbox/rules-eval.ts +18 -2
  234. package/src/database/sandbox/write-plane.ts +8 -2
  235. package/src/database/sandbox-controls.ts +29 -9
  236. package/src/database/sandbox-namespace.ts +28 -1
  237. package/src/deferred/entry.ts +167 -0
  238. package/src/firestore/instances.ts +1 -1
  239. package/src/firestore/lite.ts +91 -0
  240. package/src/firestore/persistence.ts +2 -2
  241. package/src/firestore/sandbox-controls.ts +10 -0
  242. package/src/functions/index.ts +38 -0
  243. package/src/messaging/client.ts +3 -2
  244. package/src/messaging/instance.ts +44 -7
  245. package/src/messaging/sw.ts +3 -2
  246. package/src/performance/index.ts +31 -0
  247. package/src/remote-config/index.ts +53 -0
  248. package/src/rules/grammar/FirestoreValidator.ts +44 -28
  249. package/src/rules/grammar/document-access-count.ts +75 -0
  250. package/src/rules/linter/ast-utils.ts +2 -36
  251. package/src/rules/linter/hallucinations.ts +123 -25
  252. package/src/rules/linter/linter.ts +32 -10
  253. package/src/rules/rtdb/grammar/RtdbExpr.ohm +5 -2
  254. package/src/rules/rtdb/grammar/RtdbExpr.ohm.generated.ts +5 -2
  255. package/src/rules/rtdb/grammar/simulator.ts +41 -15
  256. package/src/rules/rtdb/simulation/handler.ts +149 -46
  257. package/src/rules/simulator/document-lookups.ts +46 -3
  258. package/src/rules/simulator/eval-error.ts +16 -0
  259. package/src/rules/simulator/evaluation-builtins.ts +35 -8
  260. package/src/rules/simulator/evaluation-context.ts +12 -0
  261. package/src/rules/simulator/evaluator.ts +14 -3
  262. package/src/rules/simulator/handler.ts +68 -4
  263. package/src/rules/simulator/lookup-budget.ts +101 -0
  264. package/src/rules/stdlib-modules.ts +19 -11
  265. package/src/sandbox/admin-firestore/get-firestore.ts +19 -3
  266. package/src/sandbox/index.ts +5 -1
  267. package/src/sandbox/persistence/types.ts +1 -1
  268. package/src/sandbox/remote.ts +2 -2
  269. package/src/sandbox/sandbox-context.ts +69 -9
  270. package/src/sandbox/types/auth-state.ts +1 -1
  271. package/src/sandbox/types/events.ts +2 -1
  272. package/src/sandbox/types/operation.ts +1 -1
  273. package/src/storage/enforce.ts +53 -14
  274. package/src/storage/index.ts +1 -1
  275. package/src/storage/list.ts +1 -1
  276. package/src/storage/persistence.ts +74 -16
  277. package/src/storage/sandbox/rules-evaluation-error.ts +32 -0
  278. package/src/storage/sandbox/rules-evaluator.ts +109 -32
  279. package/src/storage/sandbox/rules-methods.ts +21 -7
  280. package/src/storage/service.ts +93 -27
  281. package/README.md.orig +0 -53
@@ -59,8 +59,11 @@ RtdbExpr {
59
59
  | null
60
60
 
61
61
  number
62
- = digit+ "." digit+ -- float
63
- | digit+ -- int
62
+ = digit+ "." digit+ exponent? -- float
63
+ | digit+ exponent -- exp
64
+ | digit+ -- int
65
+
66
+ exponent = ("e" | "E") ("+" | "-")? digit+
64
67
 
65
68
  string
66
69
  = "\"" doubleStringChar* "\"" -- double
@@ -63,8 +63,11 @@ export const RTDB_EXPR_OHM_SOURCE = `RtdbExpr {
63
63
  | null
64
64
 
65
65
  number
66
- = digit+ "." digit+ -- float
67
- | digit+ -- int
66
+ = digit+ "." digit+ exponent? -- float
67
+ | digit+ exponent -- exp
68
+ | digit+ -- int
69
+
70
+ exponent = ("e" | "E") ("+" | "-")? digit+
68
71
 
69
72
  string
70
73
  = "\\"" doubleStringChar* "\\"" -- double
@@ -29,6 +29,32 @@ function safeMemberRead(recv: unknown, key: string): unknown {
29
29
  return (recv as Record<string, unknown>)[key] ?? null;
30
30
  }
31
31
 
32
+ /** Process standard escape sequences in single-quoted string literals. */
33
+ function processStringEscapes(raw: string): string {
34
+ let out = '';
35
+ for (let i = 0; i < raw.length; i++) {
36
+ const c = raw[i];
37
+ if (c !== '\\' || i === raw.length - 1) {
38
+ out += c;
39
+ continue;
40
+ }
41
+ const next = raw[++i];
42
+ switch (next) {
43
+ case '\\': out += '\\'; break;
44
+ case '\'': out += '\''; break;
45
+ case '"': out += '"'; break;
46
+ case 'n': out += '\n'; break;
47
+ case 'r': out += '\r'; break;
48
+ case 't': out += '\t'; break;
49
+ case 'b': out += '\b'; break;
50
+ case 'f': out += '\f'; break;
51
+ case '/': out += '/'; break;
52
+ default: out += '\\' + next; break;
53
+ }
54
+ }
55
+ return out;
56
+ }
57
+
32
58
  export interface EvalContext {
33
59
  auth: SimulatedAuth | null;
34
60
  data: DataSnapshot;
@@ -89,23 +115,21 @@ export class DataSnapshot {
89
115
 
90
116
  child(path: string): DataSnapshot {
91
117
  const parts = path.split('/').filter(p => p.length > 0);
92
- let current: unknown = this._value;
93
- let currentPath = this._path;
118
+ if (parts.length === 0) return this;
94
119
 
120
+ let current: unknown = this._value;
95
121
  for (const part of parts) {
96
- if (current === null || current === undefined || typeof current !== 'object') {
122
+ if (current !== null && current !== undefined && typeof current === 'object') {
123
+ current = Object.hasOwn(current as object, part)
124
+ ? ((current as Record<string, unknown>)[part] ?? null)
125
+ : null;
126
+ } else {
97
127
  current = null;
98
- currentPath = `${currentPath}/${part}`.replace(/\/+/g, '/');
99
- break;
100
128
  }
101
- // Own-property access only: a `__proto__`/`constructor` child must
102
- // not resolve to the JS prototype chain.
103
- current = Object.hasOwn(current as object, part)
104
- ? ((current as Record<string, unknown>)[part] ?? null)
105
- : null;
106
- currentPath = `${currentPath === '/' ? '' : currentPath}/${part}`;
107
129
  }
108
130
 
131
+ const prefix = this._path === '/' ? '' : this._path;
132
+ const currentPath = `${prefix}/${parts.join('/')}`;
109
133
  return new DataSnapshot(current, currentPath, this._root);
110
134
  }
111
135
 
@@ -113,9 +137,10 @@ export class DataSnapshot {
113
137
  if (this._path === '/') return null;
114
138
  const parts = this._path.split('/').filter(p => p.length > 0);
115
139
  parts.pop();
116
- const parentPath = '/' + parts.join('/');
140
+ if (parts.length === 0) {
141
+ return new DataSnapshot(this._root, '/');
142
+ }
117
143
  const rootSnap = new DataSnapshot(this._root, '/');
118
- if (parentPath === '/') return rootSnap;
119
144
  return rootSnap.child(parts.join('/'));
120
145
  }
121
146
 
@@ -289,7 +314,8 @@ function getEvalSemantics(): Semantics {
289
314
 
290
315
  literal(node) { return (node as any).eval(); },
291
316
 
292
- number_float(_int, _dot, _frac) { return parseFloat(this.sourceString); },
317
+ number_float(_int, _dot, _frac, _exp) { return parseFloat(this.sourceString); },
318
+ number_exp(_digits, _exp) { return parseFloat(this.sourceString); },
293
319
  number_int(_digits) { return parseInt(this.sourceString, 10); },
294
320
  number(node) { return (node as any).eval(); },
295
321
 
@@ -297,7 +323,7 @@ function getEvalSemantics(): Semantics {
297
323
  return JSON.parse(this.sourceString);
298
324
  },
299
325
  string_single(_open, chars, _close) {
300
- return chars.children.map((c: any) => c.sourceString).join('');
326
+ return processStringEscapes(chars.sourceString);
301
327
  },
302
328
  string(node) { return (node as any).eval(); },
303
329
 
@@ -58,6 +58,23 @@ function snapshotChildKeys(snap: DataSnapshot): string[] {
58
58
  * the first unsupported node, else `null` when every applicable `.validate`
59
59
  * passes.
60
60
  */
61
+ function hasValidateRule(n: RtdbNode): boolean {
62
+ if (n.validate) return true;
63
+ return n.children.some(hasValidateRule);
64
+ }
65
+
66
+ function shouldValidateSiblingSubtree(
67
+ currentSegments: readonly string[],
68
+ hasLocalDeletion: boolean,
69
+ allWritePaths: readonly string[][],
70
+ ): boolean {
71
+ return (
72
+ currentSegments.length > 0 ||
73
+ hasLocalDeletion ||
74
+ allWritePaths.some((wp) => wp.length === 1)
75
+ );
76
+ }
77
+
61
78
  function findFailingValidate(
62
79
  node: RtdbNode,
63
80
  data: DataSnapshot,
@@ -65,31 +82,47 @@ function findFailingValidate(
65
82
  bindings: Record<string, string>,
66
83
  buildContext: ContextBuilder,
67
84
  pathToWrite: string[],
85
+ updates?: readonly { path: string; value?: unknown }[],
68
86
  ): ValidateFailure | null {
69
87
  let firstUnsupported: ValidateFailure | null = null;
70
88
 
89
+ const writePathStrings = new Set<string>();
90
+ if (updates && updates.length > 0) {
91
+ for (const u of updates) {
92
+ writePathStrings.add(u.path.split('/').filter(Boolean).join('/'));
93
+ }
94
+ }
95
+ writePathStrings.add(pathToWrite.filter(Boolean).join('/'));
96
+ const allWritePaths = Array.from(writePathStrings).map((p) => p.split('/').filter(Boolean));
97
+
71
98
  function walk(
72
99
  node: RtdbNode,
73
100
  data: DataSnapshot,
74
101
  newData: DataSnapshot,
75
102
  bindings: Record<string, string>,
76
- remainingPath: string[],
103
+ currentSegments: string[],
104
+ isUnderModifiedSubtree: boolean,
77
105
  ): ValidateFailure | null {
78
- // A null proposed value is a delete — RTDB does not validate deletes.
79
- if (!newData.exists()) return null;
80
-
81
- const rule = node.validate;
82
- if (rule) {
83
- if (!rule.parsed.valid) {
84
- if (!firstUnsupported) {
85
- firstUnsupported = { node, rule, bindings, unsupported: true };
106
+ if (newData.exists()) {
107
+ const rule = node.validate;
108
+ if (rule) {
109
+ if (!rule.parsed.valid) {
110
+ if (!firstUnsupported) {
111
+ firstUnsupported = { node, rule, bindings, unsupported: true };
112
+ }
113
+ } else {
114
+ const result = evaluateRtdbExpression(rule.raw, buildContext(data, newData, bindings));
115
+ if (!result) return { node, rule, bindings };
86
116
  }
87
- } else {
88
- const result = evaluateRtdbExpression(rule.raw, buildContext(data, newData, bindings));
89
- if (!result) return { node, rule, bindings };
90
117
  }
91
118
  }
92
119
 
120
+ const isAtOrBelowWriteTarget = allWritePaths.some(
121
+ (wp) => wp.length <= currentSegments.length && wp.every((seg, idx) => seg === currentSegments[idx]),
122
+ );
123
+
124
+ const hasLocalDeletion = snapshotChildKeys(data).some((k) => !newData.child(k).exists());
125
+
93
126
  for (const child of node.children) {
94
127
  const childSegments = child.path.split('/').filter(Boolean);
95
128
  if (childSegments.length === 0) continue;
@@ -97,49 +130,118 @@ function findFailingValidate(
97
130
  const lastSegment = childSegments[childSegments.length - 1];
98
131
  const isPathVar = lastSegment.startsWith('$');
99
132
 
100
- // Above the write location, validation follows only the operation path.
101
- // Once the write location is reached, it fans out through every child
102
- // present in the proposed value.
103
- if (remainingPath.length > 0) {
104
- if (!isPathVar && remainingPath[0] !== lastSegment) continue;
105
- return walk(
106
- child,
107
- data.child(remainingPath[0]),
108
- newData.child(remainingPath[0]),
109
- isPathVar ? { ...bindings, [lastSegment]: remainingPath[0] } : bindings,
110
- remainingPath.slice(1),
111
- );
112
- }
133
+ if (isAtOrBelowWriteTarget || isUnderModifiedSubtree) {
134
+ if (isPathVar) {
135
+ for (const key of snapshotChildKeys(newData)) {
136
+ const failure = walk(
137
+ child,
138
+ data.child(key),
139
+ newData.child(key),
140
+ { ...bindings, [lastSegment]: key },
141
+ [...currentSegments, key],
142
+ true,
143
+ );
144
+ if (failure) return failure;
145
+ }
146
+ } else {
147
+ if (newData.child(lastSegment).exists()) {
148
+ const failure = walk(
149
+ child,
150
+ data.child(lastSegment),
151
+ newData.child(lastSegment),
152
+ bindings,
153
+ [...currentSegments, lastSegment],
154
+ true,
155
+ );
156
+ if (failure) return failure;
157
+ }
158
+ }
159
+ } else {
160
+ // We are at an ancestor of write path(s)
161
+ if (isPathVar) {
162
+ const writeKeys = new Set<string>();
163
+ for (const wp of allWritePaths) {
164
+ if (
165
+ wp.length > currentSegments.length &&
166
+ currentSegments.every((seg, idx) => seg === wp[idx])
167
+ ) {
168
+ writeKeys.add(wp[currentSegments.length]);
169
+ }
170
+ }
171
+ for (const key of writeKeys) {
172
+ const failure = walk(
173
+ child,
174
+ data.child(key),
175
+ newData.child(key),
176
+ { ...bindings, [lastSegment]: key },
177
+ [...currentSegments, key],
178
+ false,
179
+ );
180
+ if (failure) return failure;
181
+ }
113
182
 
114
- if (isPathVar) {
115
- // Bind the path variable to each key actually present in the new
116
- // data — a `$var` node validates every child of the written value.
117
- for (const key of snapshotChildKeys(newData)) {
118
- const failure = walk(
119
- child,
120
- data.child(key),
121
- newData.child(key),
122
- { ...bindings, [lastSegment]: key },
123
- [],
183
+ if (
184
+ hasValidateRule(child) &&
185
+ shouldValidateSiblingSubtree(currentSegments, hasLocalDeletion, allWritePaths)
186
+ ) {
187
+ for (const key of snapshotChildKeys(newData)) {
188
+ if (writeKeys.has(key)) continue;
189
+ const failure = walk(
190
+ child,
191
+ data.child(key),
192
+ newData.child(key),
193
+ { ...bindings, [lastSegment]: key },
194
+ [...currentSegments, key],
195
+ true,
196
+ );
197
+ if (failure) return failure;
198
+ }
199
+ }
200
+ } else {
201
+ const key = lastSegment;
202
+ const isChildOnWritePath = allWritePaths.some(
203
+ (wp) =>
204
+ wp.length > currentSegments.length &&
205
+ wp[currentSegments.length] === key &&
206
+ currentSegments.every((seg, idx) => seg === wp[idx]),
124
207
  );
125
- if (failure) return failure;
208
+
209
+ if (isChildOnWritePath) {
210
+ const failure = walk(
211
+ child,
212
+ data.child(key),
213
+ newData.child(key),
214
+ bindings,
215
+ [...currentSegments, key],
216
+ false,
217
+ );
218
+ if (failure) return failure;
219
+ } else {
220
+ // Sibling branch
221
+ if (
222
+ newData.child(key).exists() &&
223
+ hasValidateRule(child) &&
224
+ shouldValidateSiblingSubtree(currentSegments, hasLocalDeletion, allWritePaths)
225
+ ) {
226
+ const failure = walk(
227
+ child,
228
+ data.child(key),
229
+ newData.child(key),
230
+ bindings,
231
+ [...currentSegments, key],
232
+ true,
233
+ );
234
+ if (failure) return failure;
235
+ }
236
+ }
126
237
  }
127
- } else {
128
- const failure = walk(
129
- child,
130
- data.child(lastSegment),
131
- newData.child(lastSegment),
132
- bindings,
133
- [],
134
- );
135
- if (failure) return failure;
136
238
  }
137
239
  }
138
240
 
139
241
  return null;
140
242
  }
141
243
 
142
- const realFailure = walk(node, data, newData, bindings, pathToWrite);
244
+ const realFailure = walk(node, data, newData, bindings, [], false);
143
245
  return realFailure ?? firstUnsupported;
144
246
  }
145
247
 
@@ -343,6 +445,7 @@ export class SimulateHandler {
343
445
  {},
344
446
  buildContext,
345
447
  pathSegments,
448
+ updates,
346
449
  );
347
450
  if (failure) {
348
451
  return {
@@ -2,10 +2,43 @@ import type { SimulationContext, SimResource } from './evaluation-context.js';
2
2
  import { EvalError } from './eval-error.js';
3
3
  import { Path } from './wrappers/path.js';
4
4
 
5
+ /**
6
+ * Check if a path or segment list points to a document (non-empty, even segment count).
7
+ */
8
+ export function isDocumentPath(segmentsOrPath: string | readonly string[]): boolean {
9
+ const segments = typeof segmentsOrPath === 'string'
10
+ ? segmentsOrPath.split('/').filter(Boolean)
11
+ : segmentsOrPath;
12
+ return segments.length > 0 && segments.length % 2 === 0;
13
+ }
14
+
5
15
  export function normalizeDocumentPath(rawPath: string): string {
6
- return rawPath
7
- .replace(/\$\(database\)/g, '(default)')
8
- .replace(/^\/databases\/\(default\)\/documents\//, '');
16
+ let cleaned = rawPath.replace(/\$\(database\)/g, '(default)');
17
+ const dbPrefix = '/databases/(default)/documents/';
18
+ if (cleaned.startsWith(dbPrefix)) {
19
+ cleaned = cleaned.slice(dbPrefix.length);
20
+ } else if (cleaned.startsWith('/databases/(default)/documents')) {
21
+ cleaned = cleaned.slice('/databases/(default)/documents'.length);
22
+ }
23
+ if (cleaned.startsWith('/')) {
24
+ cleaned = cleaned.slice(1);
25
+ }
26
+
27
+ const rawSegments = cleaned.split('/').filter((s) => s.length > 0 && s !== '.');
28
+ const stack: string[] = [];
29
+
30
+ for (const seg of rawSegments) {
31
+ if (seg === '..') {
32
+ if (stack.length > 1) {
33
+ stack.pop();
34
+ }
35
+ // When stack.length <= 1, .. is clamped to prevent escaping collection or document root.
36
+ } else {
37
+ stack.push(seg);
38
+ }
39
+ }
40
+
41
+ return stack.join('/');
9
42
  }
10
43
 
11
44
  /** Build the identity-bearing value returned by a real document lookup. */
@@ -20,6 +53,12 @@ export function makeGetResource(relPath: string, data: Record<string, unknown>):
20
53
 
21
54
  export function resolveGet(rawPath: string, context: SimulationContext): SimResource {
22
55
  const path = normalizeDocumentPath(rawPath);
56
+ const segments = path.split('/').filter(Boolean);
57
+ if (!isDocumentPath(segments)) {
58
+ throw new EvalError(
59
+ `get() requires a path pointing to a document (even segment count), got '${path}'`,
60
+ );
61
+ }
23
62
  let document = context.mockDocuments.get(path);
24
63
  if (!document && context.getDoc) {
25
64
  const loaded = context.getDoc(path);
@@ -38,6 +77,10 @@ export function resolveGet(rawPath: string, context: SimulationContext): SimReso
38
77
 
39
78
  export function resolveExists(rawPath: string, context: SimulationContext): boolean {
40
79
  const path = normalizeDocumentPath(rawPath);
80
+ const segments = path.split('/').filter(Boolean);
81
+ if (!isDocumentPath(segments)) {
82
+ return false;
83
+ }
41
84
  if (context.mockDocuments.has(path)) return true;
42
85
  if (context.getDoc) {
43
86
  const loaded = context.getDoc(path);
@@ -6,3 +6,19 @@ export class EvalError extends Error {
6
6
  this.name = 'EvalError';
7
7
  }
8
8
  }
9
+
10
+ /**
11
+ * A resource limit the whole request ran into: the per-request document
12
+ * access budget today, and any future per-request cap. Production fails the
13
+ * entire request closed on these instead of producing a CEL error value, so
14
+ * they are never absorbed by a determining `&&`/`||` operand and never
15
+ * confined to the allow rule that raised them.
16
+ */
17
+ export class ResourceLimitError extends EvalError {
18
+ constructor(message: string, expr?: Expression) {
19
+ super(message, expr);
20
+ this.name = 'ResourceLimitError';
21
+ }
22
+ }
23
+
24
+ export { EvalError as RuleEvalError };
@@ -11,7 +11,8 @@ import { Path } from './wrappers/path.js';
11
11
  import { RulesFloat } from './wrappers/float.js';
12
12
  import { EvalError } from './eval-error.js';
13
13
  import { UnsupportedError } from './unsupported-error.js';
14
- import { makeGetResource, normalizeDocumentPath, resolveExists, resolveGet } from './document-lookups.js';
14
+ import { isDocumentPath, makeGetResource, normalizeDocumentPath, resolveExists, resolveGet } from './document-lookups.js';
15
+ import { chargeLookup } from './lookup-budget.js';
15
16
  import type { SimulationContext } from './evaluation-context.js';
16
17
  import { evaluate, isKnownGlobal, resolveIdentifier } from './evaluator.js';
17
18
  import { evaluateHashingMethod } from './hashing-builtins.js';
@@ -53,10 +54,14 @@ export function evaluateFunctionCall(
53
54
  switch (name) {
54
55
  case 'get': {
55
56
  const path = String(evaluate(args[0], ctx, scope));
57
+ // Charge the per-request lookup budget BEFORE resolving so the 11th
58
+ // distinct access fails closed even when the doc would resolve.
59
+ chargeLookup(ctx, 'doc', path);
56
60
  return resolveGet(path, ctx);
57
61
  }
58
62
  case 'exists': {
59
63
  const path = String(evaluate(args[0], ctx, scope));
64
+ chargeLookup(ctx, 'doc', path); // exists() shares get()'s per-path slot
60
65
  return resolveExists(path, ctx);
61
66
  }
62
67
  case 'getAfter': {
@@ -71,15 +76,27 @@ export function evaluateFunctionCall(
71
76
  // doesn't touch.
72
77
  const argVal = evaluate(args[0], ctx, scope);
73
78
  const pathStr = String(argVal);
79
+ const normalized = normalizeDocumentPath(pathStr);
80
+ const segments = normalized.split('/').filter(Boolean);
81
+ if (!isDocumentPath(segments)) {
82
+ throw new EvalError(
83
+ `getAfter() requires a path pointing to a document (even segment count), got '${normalized}'`,
84
+ );
85
+ }
86
+ // getAfter() is a document access call and counts against the
87
+ // per-request budget. 'after' keying: the post-write projection is a
88
+ // different snapshot than get()'s pre-write read, so a path read by
89
+ // both charges two slots. The fall-through resolveGet below does NOT
90
+ // re-charge, because charging lives only here.
91
+ chargeLookup(ctx, 'after', normalized);
74
92
  if (pathStr === ctx.afterStatePath.toString()) {
75
93
  // RULES-B8: getAfter() of a doc that won't exist post-write (delete,
76
94
  // or projected-null) ERRORS like get() — guard with existsAfter().
77
95
  if (ctx.afterState === null) {
78
96
  throw new EvalError(`getAfter() of non-existent document '${pathStr}' (guard with existsAfter() first)`);
79
97
  }
80
- return makeGetResource(normalizeDocumentPath(pathStr), ctx.afterState);
98
+ return makeGetResource(normalized, ctx.afterState);
81
99
  }
82
- const normalized = normalizeDocumentPath(pathStr);
83
100
  if (ctx.batchProjection?.has(normalized)) {
84
101
  const projected = ctx.batchProjection.get(normalized)!;
85
102
  if (projected === null) {
@@ -96,19 +113,29 @@ export function evaluateFunctionCall(
96
113
  // through to exists().
97
114
  const argVal = evaluate(args[0], ctx, scope);
98
115
  const pathStr = String(argVal);
116
+ const normalized = normalizeDocumentPath(pathStr);
117
+ const segments = normalized.split('/').filter(Boolean);
118
+ if (!isDocumentPath(segments)) {
119
+ return false;
120
+ }
121
+ // existsAfter() counts against the per-request lookup budget and
122
+ // shares getAfter()'s post-write slot for the same path.
123
+ chargeLookup(ctx, 'after', normalized);
99
124
  if (pathStr === ctx.afterStatePath.toString()) {
100
125
  return ctx.existsAfter;
101
126
  }
102
- const normalized = normalizeDocumentPath(pathStr);
103
127
  if (ctx.batchProjection?.has(normalized)) {
104
128
  return ctx.batchProjection.get(normalized) !== null;
105
129
  }
106
130
  return resolveExists(pathStr, ctx);
107
131
  }
108
- case 'debug': {
109
- const val = evaluate(args[0], ctx, scope);
110
- return val; // debug() returns its argument
111
- }
132
+ // NOTE: `debug()` is deliberately NOT handled here. Production Firestore
133
+ // rejects it at compile ("Function not found error: Name: [debug]", the
134
+ // captured acceptance probe for firestore.function.debug), so the local
135
+ // evaluator must fail it the same way. Falling through to the
136
+ // user-defined lookup below yields `EvalError('Unknown function: debug')`,
137
+ // the exact message the conformance rejection-signature normalizer maps
138
+ // to `function-not-found:debug`.
112
139
  // RULES-B5 / RULES-B6: type-conversion builtins follow CEL's strict
113
140
  // semantics, not JS coercion. `String()` routes a RulesFloat through its
114
141
  // `.toString()` so `string(1.0)` → "1.0" (decimal preserved); bare ints
@@ -1,4 +1,5 @@
1
1
  import type { FunctionDef } from '../grammar/FirestoreAST.js';
2
+ import type { LookupBudget } from './lookup-budget.js';
2
3
  import type { TraceRecorder } from './trace-recorder.js';
3
4
  import { Path } from './wrappers/path.js';
4
5
  import { Timestamp } from './wrappers/timestamp.js';
@@ -108,6 +109,17 @@ export interface SimulationContext {
108
109
  * through to `get()` exactly as before.
109
110
  */
110
111
  batchProjection?: Map<string, Record<string, unknown> | null>;
112
+ /**
113
+ * Per-request document access budget (production: 10 distinct
114
+ * get/exists/getAfter/existsAfter reads per single-document request; the
115
+ * 11th fails the evaluation closed). The handler creates one per test
116
+ * case and shares it across every match block and allow rule evaluated
117
+ * for that request; a fresh budget per test case is the "resets between
118
+ * requests" semantics. Optional so non-handler evaluation paths are
119
+ * unaffected. See LookupBudget in lookup-budget.ts for the counting
120
+ * semantics and the in-repo production evidence.
121
+ */
122
+ lookupBudget?: LookupBudget;
111
123
  /** Optional per-rule expression-trace recorder. When set, the evaluator
112
124
  * wraps every `evaluate()` call and records the sub-expression tree;
113
125
  * when absent (the default), the evaluator is unchanged. The handler
@@ -22,10 +22,10 @@ import { Timestamp } from './wrappers/timestamp.js';
22
22
  import { Bytes } from './wrappers/bytes.js';
23
23
  import { Path } from './wrappers/path.js';
24
24
  import { RulesFloat } from './wrappers/float.js';
25
- import { EvalError } from './eval-error.js';
25
+ import { EvalError, ResourceLimitError } from './eval-error.js';
26
26
  import { UnsupportedError } from './unsupported-error.js';
27
27
 
28
- export { EvalError } from './eval-error.js';
28
+ export { EvalError, EvalError as RuleEvalError } from './eval-error.js';
29
29
  export { UnsupportedError } from './unsupported-error.js';
30
30
 
31
31
  // ═══ Simulation Context ═══
@@ -71,7 +71,9 @@ function evaluateExpr(expr: Expression, ctx: SimulationContext, scope: Record<st
71
71
  return evaluateBinaryOp(expr.op, expr.left, expr.right, ctx, scope);
72
72
 
73
73
  case 'unaryOp': {
74
- if (expr.op === '!') return !evaluate(expr.operand, ctx, scope);
74
+ if (expr.op === '!') {
75
+ return !requireBoolean(evaluate(expr.operand, ctx, scope), expr.operand);
76
+ }
75
77
  if (expr.op === '-') {
76
78
  const v = evaluate(expr.operand, ctx, scope);
77
79
  // RULES-B5: negating a float stays a float (`-1.5 is float`); negating
@@ -363,6 +365,13 @@ function evaluateBinaryOp(
363
365
  if (op === '&&') {
364
366
  let lv: unknown, lErr: unknown;
365
367
  try { lv = requireBoolean(evaluate(left, ctx, scope), left); } catch (e) { lErr = e; }
368
+ // A resource-limit failure (the document access budget) fails the WHOLE
369
+ // request, it is not a CEL error value: a determining RHS must not
370
+ // absorb it into an ALLOW. Fail closed immediately.
371
+ // TODO(unverified): confirm against a live production capture that the
372
+ // 11th-access error is not absorbable. The Rules Test API does not
373
+ // enforce the budget, so the capture has to be a deployed ruleset.
374
+ if (lErr instanceof ResourceLimitError) throw lErr;
366
375
  if (lErr === undefined && lv === false) {
367
376
  // LHS is false → determines the result; RHS need not be evaluated.
368
377
  ctx.trace?.skip(right);
@@ -380,6 +389,8 @@ function evaluateBinaryOp(
380
389
  if (op === '||') {
381
390
  let lv: unknown, lErr: unknown;
382
391
  try { lv = requireBoolean(evaluate(left, ctx, scope), left); } catch (e) { lErr = e; }
392
+ // See the `&&` branch: a resource-limit failure is not absorbable.
393
+ if (lErr instanceof ResourceLimitError) throw lErr;
383
394
  if (lErr === undefined && lv === true) {
384
395
  // LHS is true → determines the result; RHS need not be evaluated.
385
396
  ctx.trace?.skip(right);