@pyric/cli 0.1.0-alpha.25 → 0.1.0-alpha.26

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.
@@ -2,6 +2,51 @@
2
2
 
3
3
  ## Classes
4
4
 
5
+ <a id="rtdbexpressionsyntaxerror"></a>
6
+
7
+ ### RtdbExpressionSyntaxError
8
+
9
+ Why [canonicalRtdbExpression](#canonicalrtdbexpression) could not read its input.
10
+
11
+ #### Extends
12
+
13
+ - `Error`
14
+
15
+ #### Constructors
16
+
17
+ <a id="constructor"></a>
18
+
19
+ ##### Constructor
20
+
21
+ ```ts
22
+ new RtdbExpressionSyntaxError(raw: string, message: string): RtdbExpressionSyntaxError;
23
+ ```
24
+
25
+ ###### Parameters
26
+
27
+ | Parameter | Type |
28
+ | :------ | :------ |
29
+ | `raw` | `string` |
30
+ | `message` | `string` |
31
+
32
+ ###### Returns
33
+
34
+ [`RtdbExpressionSyntaxError`](#rtdbexpressionsyntaxerror)
35
+
36
+ ###### Overrides
37
+
38
+ ```ts
39
+ Error.constructor
40
+ ```
41
+
42
+ #### Properties
43
+
44
+ | Property | Modifier | Type |
45
+ | :------ | :------ | :------ |
46
+ | <a id="raw"></a> `raw` | `readonly` | `string` |
47
+
48
+ ***
49
+
5
50
  <a id="rulesassertionerror"></a>
6
51
 
7
52
  ### RulesAssertionError
@@ -17,7 +62,7 @@ without extra wiring.
17
62
 
18
63
  #### Constructors
19
64
 
20
- <a id="constructor"></a>
65
+ <a id="constructor-1"></a>
21
66
 
22
67
  ##### Constructor
23
68
 
@@ -57,7 +102,7 @@ caller can surface them without re-parsing.
57
102
 
58
103
  #### Constructors
59
104
 
60
- <a id="constructor-1"></a>
105
+ <a id="constructor-2"></a>
61
106
 
62
107
  ##### Constructor
63
108
 
@@ -106,7 +151,7 @@ fail on a known simulator gap.
106
151
 
107
152
  #### Constructors
108
153
 
109
- <a id="constructor-2"></a>
154
+ <a id="constructor-3"></a>
110
155
 
111
156
  ##### Constructor
112
157
 
@@ -158,6 +203,18 @@ The outcome of running one Firestore case through `simulate`. Never a
158
203
 
159
204
  ***
160
205
 
206
+ <a id="diffrtdbrulesoptions"></a>
207
+
208
+ ### DiffRtdbRulesOptions
209
+
210
+ #### Properties
211
+
212
+ | Property | Type | Description |
213
+ | :------ | :------ | :------ |
214
+ | <a id="ignoreequivalent"></a> `ignoreEquivalent?` | `boolean` | Treat two `.read`, `.write` or `.validate` expressions as the same rule when their [canonicalRtdbExpression](#canonicalrtdbexpression) texts are equal: spellings that cannot change any decision, such as `===` for `==` or `child('a/' + $id)` for `child('a').child($id)`. A changed entry still reports both expressions as written. An expression that does not parse is compared as written. Default `false`: expressions compare as text. |
215
+
216
+ ***
217
+
161
218
  <a id="evaluatedruleinfo"></a>
162
219
 
163
220
  ### EvaluatedRuleInfo
@@ -664,6 +721,26 @@ a file system.
664
721
 
665
722
  ***
666
723
 
724
+ <a id="rtdbrulechange"></a>
725
+
726
+ ### RtdbRuleChange
727
+
728
+ One rule expression that differs. `deployed` is absent for `added` and
729
+ `local` is absent for `removed`: the change is what deploying the local
730
+ ruleset would do to the deployed one.
731
+
732
+ #### Properties
733
+
734
+ | Property | Type |
735
+ | :------ | :------ |
736
+ | <a id="change"></a> `change` | `"added"` \| `"removed"` \| `"changed"` |
737
+ | <a id="deployed"></a> `deployed?` | `string` |
738
+ | <a id="kind-3"></a> `kind` | [`RtdbRuleKind`](#rtdbrulekind) |
739
+ | <a id="local"></a> `local?` | `string` |
740
+ | <a id="path-2"></a> `path` | `string` |
741
+
742
+ ***
743
+
667
744
  <a id="rtdbrulecoverage"></a>
668
745
 
669
746
  ### RtdbRuleCoverage
@@ -678,9 +755,9 @@ a file system.
678
755
  | <a id="evaluated"></a> `evaluated` | `number` | Evaluations that produced a verdict: `allow + deny + error`. |
679
756
  | <a id="expression-1"></a> `expression` | `string` | The rule expression, or the declared index list for `indexOn`. |
680
757
  | <a id="file-3"></a> `file?` | `string` | The rules file holding the node, when the summary covers several files. |
681
- | <a id="kind-3"></a> `kind` | [`RtdbCoverageKind`](#rtdbcoveragekind) | - |
758
+ | <a id="kind-4"></a> `kind` | [`RtdbCoverageKind`](#rtdbcoveragekind) | - |
682
759
  | <a id="line-2"></a> `line?` | `number` | 1-indexed line in the rules file, when a line lookup was supplied. |
683
- | <a id="path-2"></a> `path` | `string` | The rule node's path, such as `/rooms/$roomId`. |
760
+ | <a id="path-3"></a> `path` | `string` | The rule node's path, such as `/rooms/$roomId`. |
684
761
  | <a id="status"></a> `status` | [`RtdbCoverageStatus`](#rtdbcoveragestatus) | - |
685
762
  | <a id="unsupported-5"></a> `unsupported` | `number` | - |
686
763
 
@@ -926,7 +1003,7 @@ throws when `firebase.json` deploys none to the default instance.
926
1003
  | :------ | :------ |
927
1004
  | <a id="code"></a> `code` | `string` |
928
1005
  | <a id="message"></a> `message` | `string` |
929
- | <a id="path-3"></a> `path` | `string` |
1006
+ | <a id="path-4"></a> `path` | `string` |
930
1007
  | <a id="rule-2"></a> `rule` | [`RtdbRulesFindingRule`](#rtdbrulesfindingrule-1) |
931
1008
 
932
1009
  ***
@@ -993,7 +1070,7 @@ returns an empty `trace` and surfaces the wire text on `TestResult.notes`.
993
1070
  | <a id="line-4"></a> `line?` | `number` | 1-indexed source line, when known. |
994
1071
  | <a id="message-2"></a> `message` | `string` | Human-readable description. |
995
1072
  | <a id="origin"></a> `origin` | [`RuleIssueOrigin`](#ruleissueorigin-1) | - |
996
- | <a id="path-4"></a> `path?` | `string` | Rules path the issue applies to, when known (e.g. `'/users/{uid}'`). |
1073
+ | <a id="path-5"></a> `path?` | `string` | Rules path the issue applies to, when known (e.g. `'/users/{uid}'`). |
997
1074
  | <a id="rule-3"></a> `rule?` | `".read"` \| `".write"` \| `".validate"` | The Realtime Database rule at `path` the issue applies to. Absent for Firestore issues and for an RTDB issue about the whole ruleset. |
998
1075
  | <a id="severity"></a> `severity` | [`RuleIssueSeverity`](#ruleissueseverity-1) | - |
999
1076
 
@@ -1009,7 +1086,7 @@ Context passed to the callback overload of ruleset().
1009
1086
 
1010
1087
  | Property | Type |
1011
1088
  | :------ | :------ |
1012
- | <a id="path-5"></a> `path` | (`path`: `string`, `def`: [`PathDef`](#pathdef)) => `void` |
1089
+ | <a id="path-6"></a> `path` | (`path`: `string`, `def`: [`PathDef`](#pathdef)) => `void` |
1013
1090
 
1014
1091
  ***
1015
1092
 
@@ -1035,7 +1112,7 @@ expression the request was evaluating when the budget ran out.
1035
1112
  | Property | Type |
1036
1113
  | :------ | :------ |
1037
1114
  | <a id="column-2"></a> `column?` | `number` |
1038
- | <a id="kind-4"></a> `kind` | `"expressions"` \| `"document-lookups"` |
1115
+ | <a id="kind-5"></a> `kind` | `"expressions"` \| `"document-lookups"` |
1039
1116
  | <a id="limit"></a> `limit` | `number` |
1040
1117
  | <a id="line-5"></a> `line?` | `number` |
1041
1118
  | <a id="message-3"></a> `message` | `string` |
@@ -1208,6 +1285,16 @@ type RtdbRuleEvaluation = z.infer<typeof RtdbRuleEvaluationSchema>;
1208
1285
 
1209
1286
  ***
1210
1287
 
1288
+ <a id="rtdbrulekind"></a>
1289
+
1290
+ ### RtdbRuleKind
1291
+
1292
+ ```ts
1293
+ type RtdbRuleKind = ".read" | ".write" | ".validate" | ".indexOn";
1294
+ ```
1295
+
1296
+ ***
1297
+
1211
1298
  <a id="rtdbrulesfindingrule-1"></a>
1212
1299
 
1213
1300
  ### RtdbRulesFindingRule
@@ -2363,6 +2450,31 @@ A `bytes` value.
2363
2450
 
2364
2451
  ***
2365
2452
 
2453
+ <a id="canonicalrtdbexpression"></a>
2454
+
2455
+ ### canonicalRtdbExpression()
2456
+
2457
+ ```ts
2458
+ function canonicalRtdbExpression(raw: string): string;
2459
+ ```
2460
+
2461
+ Canonical text of an RTDB rule expression: two expressions with equal
2462
+ canonical texts evaluate identically for every request. Deterministic and
2463
+ idempotent. Throws [RtdbExpressionSyntaxError](#rtdbexpressionsyntaxerror) for text that is not a
2464
+ rule expression.
2465
+
2466
+ #### Parameters
2467
+
2468
+ | Parameter | Type |
2469
+ | :------ | :------ |
2470
+ | `raw` | `string` |
2471
+
2472
+ #### Returns
2473
+
2474
+ `string`
2475
+
2476
+ ***
2477
+
2366
2478
  <a id="definertdbrules"></a>
2367
2479
 
2368
2480
  ### defineRtdbRules()
@@ -2383,6 +2495,33 @@ function defineRtdbRules(definition: RtdbRulesDefinition): RtdbRulesDocument;
2383
2495
 
2384
2496
  ***
2385
2497
 
2498
+ <a id="diffrtdbrules"></a>
2499
+
2500
+ ### diffRtdbRules()
2501
+
2502
+ ```ts
2503
+ function diffRtdbRules(
2504
+ deployed: Record<string, unknown>,
2505
+ local: Record<string, unknown>,
2506
+ options?: DiffRtdbRulesOptions): RtdbRuleChange[];
2507
+ ```
2508
+
2509
+ Node-level diff of two `rules` trees, ordered by path then rule kind.
2510
+
2511
+ #### Parameters
2512
+
2513
+ | Parameter | Type |
2514
+ | :------ | :------ |
2515
+ | `deployed` | `Record`\<`string`, `unknown`\> |
2516
+ | `local` | `Record`\<`string`, `unknown`\> |
2517
+ | `options?` | [`DiffRtdbRulesOptions`](#diffrtdbrulesoptions) |
2518
+
2519
+ #### Returns
2520
+
2521
+ [`RtdbRuleChange`](#rtdbrulechange)[]
2522
+
2523
+ ***
2524
+
2386
2525
  <a id="duration"></a>
2387
2526
 
2388
2527
  ### duration()
@@ -68,6 +68,61 @@ html{scrollbar-gutter:stable}
68
68
  <li><code>pyric database rules validate database.rules.json</code> prints the error findings only. It exits <code>2</code> when there is one or when the file is not rules JSON, <code>1</code> when the file can’t be read, and <code>0</code> otherwise.</li>
69
69
  <li><code>pyric rules simulate --service database --rules-file database.rules.json --operation read --path /notes/n1</code> evaluates one request against the file.</li>
70
70
  </ul>
71
+ <h2 id="compare-compiled-rules-with-the-rules-you-wrote-by-hand">Compare compiled rules with the rules you wrote by hand</h2>
72
+ <p>When you move hand-written rules to TypeScript, the compiled JSON spells the same rules differently: <code>auth != null</code> where you wrote <code>auth.uid !== null</code>, <code>child('a').child($id)</code> where you wrote <code>child('a/' + $id)</code>. <code>diffRtdbRules</code> with <code>ignoreEquivalent</code> reports only the rules that can decide differently:</p>
73
+ <pre class="astro-code one-dark-pro" style="background-color:#282c34;color:#abb2bf; overflow-x: auto;" tabindex="0" data-language="ts"><code><span class="line"><span style="color:#C678DD">import</span><span style="color:#ABB2BF"> { </span><span style="color:#E06C75">readFileSync</span><span style="color:#ABB2BF"> } </span><span style="color:#C678DD">from</span><span style="color:#98C379"> 'node:fs'</span><span style="color:#ABB2BF">;</span></span>
74
+ <span class="line"><span style="color:#C678DD">import</span><span style="color:#ABB2BF"> { </span><span style="color:#E06C75">diffRtdbRules</span><span style="color:#ABB2BF">, </span><span style="color:#E06C75">canonicalRtdbExpression</span><span style="color:#ABB2BF"> } </span><span style="color:#C678DD">from</span><span style="color:#98C379"> 'pyric/rules'</span><span style="color:#ABB2BF">;</span></span>
75
+ <span class="line"></span>
76
+ <span class="line"><span style="color:#C678DD">const</span><span style="color:#E5C07B"> handWritten</span><span style="color:#56B6C2"> =</span><span style="color:#E5C07B"> JSON</span><span style="color:#ABB2BF">.</span><span style="color:#61AFEF">parse</span><span style="color:#ABB2BF">(</span><span style="color:#61AFEF">readFileSync</span><span style="color:#ABB2BF">(</span><span style="color:#98C379">'legacy/database.rules.json'</span><span style="color:#ABB2BF">, </span><span style="color:#98C379">'utf8'</span><span style="color:#ABB2BF">)).</span><span style="color:#E06C75">rules</span><span style="color:#ABB2BF">;</span></span>
77
+ <span class="line"><span style="color:#C678DD">const</span><span style="color:#E5C07B"> compiled</span><span style="color:#56B6C2"> =</span><span style="color:#E5C07B"> rules</span><span style="color:#ABB2BF">.</span><span style="color:#61AFEF">toJSON</span><span style="color:#ABB2BF">().</span><span style="color:#E06C75">rules</span><span style="color:#ABB2BF">;</span></span>
78
+ <span class="line"></span>
79
+ <span class="line"><span style="color:#61AFEF">diffRtdbRules</span><span style="color:#ABB2BF">(</span><span style="color:#E06C75">handWritten</span><span style="color:#ABB2BF">, </span><span style="color:#E06C75">compiled</span><span style="color:#ABB2BF">); </span><span style="color:#7F848E;font-style:italic">// every rule whose text differs</span></span>
80
+ <span class="line"><span style="color:#61AFEF">diffRtdbRules</span><span style="color:#ABB2BF">(</span><span style="color:#E06C75">handWritten</span><span style="color:#ABB2BF">, </span><span style="color:#E06C75">compiled</span><span style="color:#ABB2BF">, { </span><span style="color:#E06C75">ignoreEquivalent</span><span style="color:#ABB2BF">: </span><span style="color:#D19A66">true</span><span style="color:#ABB2BF"> }); </span><span style="color:#7F848E;font-style:italic">// only rules that can decide differently</span></span>
81
+ <span class="line"><span style="color:#7F848E;font-style:italic">// → [{ path, kind: '.read' | '.write' | '.validate' | '.indexOn', change: 'added' | 'removed' | 'changed', deployed?, local? }]</span></span>
82
+ <span class="line"></span>
83
+ <span class="line"><span style="color:#61AFEF">canonicalRtdbExpression</span><span style="color:#ABB2BF">(</span><span style="color:#98C379">"$uid === auth.uid"</span><span style="color:#ABB2BF">); </span><span style="color:#7F848E;font-style:italic">// "$uid == auth.uid"</span></span>
84
+ <span class="line"><span style="color:#61AFEF">canonicalRtdbExpression</span><span style="color:#ABB2BF">(</span><span style="color:#98C379">"auth.uid !== null"</span><span style="color:#ABB2BF">); </span><span style="color:#7F848E;font-style:italic">// "!(auth == null)"</span></span></code></pre>
85
+ <p>A changed entry keeps both expressions exactly as written. Without the option the diff compares text.</p>
86
+ <p>Two expressions are equivalent when their canonical texts are equal. The canonical form applies only rewrites that cannot change a decision, errors included (a rule that errors fails, and <code>||</code> or <code>!</code> does not rescue it):</p>
87
+ <div class="table-scroll">
88
+
89
+
90
+
91
+
92
+
93
+
94
+
95
+
96
+
97
+
98
+
99
+
100
+
101
+
102
+
103
+
104
+
105
+
106
+
107
+
108
+
109
+
110
+
111
+
112
+
113
+
114
+
115
+
116
+
117
+
118
+
119
+
120
+
121
+
122
+
123
+
124
+ <table><thead><tr><th>Treated as the same</th><th>Why</th></tr></thead><tbody><tr><td>whitespace, grouping parentheses, <code>a &#x26;&#x26; (b &#x26;&#x26; c)</code> and <code>a &#x26;&#x26; b &#x26;&#x26; c</code></td><td>parentheses only shape the tree; <code>&#x26;&#x26;</code> and <code>||</code> evaluate left to right either way</td></tr><tr><td><code>"a"</code> and <code>'a'</code>, for a string literal written without a backslash</td><td>the same string; a literal with any escape (<code>'\d'</code>, <code>'\u0041'</code>) keeps its spelling, since production’s reading of most escapes has not been observed</td></tr><tr><td><code>===</code> and <code>==</code>, <code>!==</code> and <code>!=</code></td><td>RTDB compares without type conversion in both</td></tr><tr><td><code>auth.uid == null</code> and <code>auth == null</code></td><td><code>auth</code> is null exactly when signed out, and a signed-in <code>auth</code> always has a uid</td></tr><tr><td><code>child('a/b/' + $id + '/c')</code> and <code>child('a').child('b').child($id).child('c')</code></td><td>a <code>$</code> variable is exactly one key, and <code>child()</code> reads <code>/</code> as a separator</td></tr><tr><td><code>a != b</code> and <code>!(a == b)</code></td><td>both fail on the same inputs; otherwise <code>==</code> gives a boolean</td></tr><tr><td><code>a == b</code> and <code>b == a</code></td><td>equality is symmetric; operands that could fail other than with a rule error, or that index into an array, keep their order</td></tr></tbody></table></div>
125
+ <p>Deliberately not treated as the same: a concatenated part that is not a string literal or a <code>$</code> variable, <code>auth.uid</code> included (<code>child('m/' + auth.uid)</code> is not <code>child('m').child(auth.uid)</code>, because production has not been observed reading a uid such as <code>/x</code> or <code>a//b</code> with empty segments the same way in both forms); a literal glued to a variable (<code>'user_' + $id</code>) or written with an escape; empty segments and key-invalid characters in a path; the order of <code>&#x26;&#x26;</code> and <code>||</code> operands; <code>>=</code> and <code>></code>; double negation; and arithmetic or constant folding. A rule that falls in one of those is reported as changed, so read those entries yourself.</p>
71
126
  <h2 id="turn-enforcement-from-a-deployed-game">Turn enforcement, from a deployed game</h2>
72
127
  <p>This turn-enforcement example is built from the same parts:</p>
73
128
  <pre class="astro-code one-dark-pro" style="background-color:#282c34;color:#abb2bf; overflow-x: auto;" tabindex="0" data-language="ts"><code><span class="line"><span style="color:#C678DD">import</span><span style="color:#ABB2BF"> {</span></span>
@@ -93,4 +148,4 @@ html{scrollbar-gutter:stable}
93
148
  <h2 id="and-from-an-agent">And from an agent</h2>
94
149
  <p>An agent authors and checks the same way you do: lint and simulate locally (<code>pyric rules lint --service database</code>, <code>pyric rules simulate --service database</code>, or <code>rtdb_simulate_access</code> against the connected sandbox), generate JSON with <code>pyric database rules generate</code>, then ship with <code>firebase-tools</code>. See the RTDB task in <a href="../../agent/work-with-an-agent/">Work with an agent</a>.</p>
95
150
  <h2 id="where-to-go-next">Where to go next</h2>
96
- <p>The full builder catalog, with the exact expression each one produces, is in the <a href="../../pyric-database-reference-api/">constraints reference</a>. For matches, turns, results, counters, presence, rate limits and validated shapes, compose the <a href="../rtdb-rules-standard-library/">RTDB rules standard library</a> instead of writing the expressions yourself. For the data side of RTDB, see <a href="../../build/realtime-database/">Sync realtime data</a>.</p> </article> </main> <aside class="doc-toc" aria-label="On this page" data-astro-cid-mw7aashj> <h2 class="doc-toc-heading" data-astro-cid-mw7aashj>On this page</h2> <ul data-astro-cid-mw7aashj> <li data-depth="2" data-astro-cid-mw7aashj> <a href="#define-the-rules-in-code" data-astro-cid-mw7aashj>Define the rules in code</a> </li><li data-depth="2" data-astro-cid-mw7aashj> <a href="#check-it-before-anything-runs" data-astro-cid-mw7aashj>Check it before anything runs</a> </li><li data-depth="2" data-astro-cid-mw7aashj> <a href="#simulate-a-request" data-astro-cid-mw7aashj>Simulate a request</a> </li><li data-depth="2" data-astro-cid-mw7aashj> <a href="#ship-the-json" data-astro-cid-mw7aashj>Ship the JSON</a> </li><li data-depth="2" data-astro-cid-mw7aashj> <a href="#turn-enforcement-from-a-deployed-game" data-astro-cid-mw7aashj>Turn enforcement, from a deployed game</a> </li><li data-depth="2" data-astro-cid-mw7aashj> <a href="#and-from-an-agent" data-astro-cid-mw7aashj>And from an agent</a> </li><li data-depth="2" data-astro-cid-mw7aashj> <a href="#where-to-go-next" data-astro-cid-mw7aashj>Where to go next</a> </li> </ul> </aside> </div> <script type="module">const a=document.querySelector("[data-api-symbol-filter]"),d=[...document.querySelectorAll("[data-api-symbol-group]")],n=document.querySelector("[data-api-symbol-empty]");a?.addEventListener("input",()=>{const t=a.value.trim().toLowerCase();let o=0;for(const i of d){let e=0;i.querySelectorAll("[data-api-symbol]").forEach(l=>{const r=!t||(l.dataset.apiSymbol??"").includes(t);l.hidden=!r,r&&e++}),i.hidden=e===0,o+=e}n&&(n.hidden=o>0)});</script> <script type="module">const s=new Map;document.querySelectorAll('.doc-toc a[href^="#"]').forEach(t=>s.set(decodeURIComponent(t.hash.slice(1)),t));if(s.size>0){const t=new Set,n=()=>{let o=null;for(const e of s.keys())if(t.has(e)){o=e;break}s.forEach((e,c)=>e.classList.toggle("active",c===o))},r=new IntersectionObserver(o=>{for(const e of o)e.isIntersecting?t.add(e.target.id):t.delete(e.target.id);n()},{rootMargin:"-72px 0px -60% 0px"});s.forEach((o,e)=>{const c=document.getElementById(e);c&&r.observe(c)})}</script> <script type="module">document.querySelector('.studio__nav [aria-current="page"]')?.scrollIntoView({block:"nearest",inline:"center"});</script> </body></html>
151
+ <p>The full builder catalog, with the exact expression each one produces, is in the <a href="../../pyric-database-reference-api/">constraints reference</a>. For matches, turns, results, counters, presence, rate limits and validated shapes, compose the <a href="../rtdb-rules-standard-library/">RTDB rules standard library</a> instead of writing the expressions yourself. For the data side of RTDB, see <a href="../../build/realtime-database/">Sync realtime data</a>.</p> </article> </main> <aside class="doc-toc" aria-label="On this page" data-astro-cid-mw7aashj> <h2 class="doc-toc-heading" data-astro-cid-mw7aashj>On this page</h2> <ul data-astro-cid-mw7aashj> <li data-depth="2" data-astro-cid-mw7aashj> <a href="#define-the-rules-in-code" data-astro-cid-mw7aashj>Define the rules in code</a> </li><li data-depth="2" data-astro-cid-mw7aashj> <a href="#check-it-before-anything-runs" data-astro-cid-mw7aashj>Check it before anything runs</a> </li><li data-depth="2" data-astro-cid-mw7aashj> <a href="#simulate-a-request" data-astro-cid-mw7aashj>Simulate a request</a> </li><li data-depth="2" data-astro-cid-mw7aashj> <a href="#ship-the-json" data-astro-cid-mw7aashj>Ship the JSON</a> </li><li data-depth="2" data-astro-cid-mw7aashj> <a href="#compare-compiled-rules-with-the-rules-you-wrote-by-hand" data-astro-cid-mw7aashj>Compare compiled rules with the rules you wrote by hand</a> </li><li data-depth="2" data-astro-cid-mw7aashj> <a href="#turn-enforcement-from-a-deployed-game" data-astro-cid-mw7aashj>Turn enforcement, from a deployed game</a> </li><li data-depth="2" data-astro-cid-mw7aashj> <a href="#and-from-an-agent" data-astro-cid-mw7aashj>And from an agent</a> </li><li data-depth="2" data-astro-cid-mw7aashj> <a href="#where-to-go-next" data-astro-cid-mw7aashj>Where to go next</a> </li> </ul> </aside> </div> <script type="module">const a=document.querySelector("[data-api-symbol-filter]"),d=[...document.querySelectorAll("[data-api-symbol-group]")],n=document.querySelector("[data-api-symbol-empty]");a?.addEventListener("input",()=>{const t=a.value.trim().toLowerCase();let o=0;for(const i of d){let e=0;i.querySelectorAll("[data-api-symbol]").forEach(l=>{const r=!t||(l.dataset.apiSymbol??"").includes(t);l.hidden=!r,r&&e++}),i.hidden=e===0,o+=e}n&&(n.hidden=o>0)});</script> <script type="module">const s=new Map;document.querySelectorAll('.doc-toc a[href^="#"]').forEach(t=>s.set(decodeURIComponent(t.hash.slice(1)),t));if(s.size>0){const t=new Set,n=()=>{let o=null;for(const e of s.keys())if(t.has(e)){o=e;break}s.forEach((e,c)=>e.classList.toggle("active",c===o))},r=new IntersectionObserver(o=>{for(const e of o)e.isIntersecting?t.add(e.target.id):t.delete(e.target.id);n()},{rootMargin:"-72px 0px -60% 0px"});s.forEach((o,e)=>{const c=document.getElementById(e);c&&r.observe(c)})}</script> <script type="module">document.querySelector('.studio__nav [aria-current="page"]')?.scrollIntoView({block:"nearest",inline:"center"});</script> </body></html>
@@ -82,6 +82,39 @@ The CLI checks the JSON file itself, so CI can gate on it without TypeScript in
82
82
  - `pyric database rules validate database.rules.json` prints the error findings only. It exits `2` when there is one or when the file is not rules JSON, `1` when the file can't be read, and `0` otherwise.
83
83
  - `pyric rules simulate --service database --rules-file database.rules.json --operation read --path /notes/n1` evaluates one request against the file.
84
84
 
85
+ ## Compare compiled rules with the rules you wrote by hand
86
+
87
+ When you move hand-written rules to TypeScript, the compiled JSON spells the same rules differently: `auth != null` where you wrote `auth.uid !== null`, `child('a').child($id)` where you wrote `child('a/' + $id)`. `diffRtdbRules` with `ignoreEquivalent` reports only the rules that can decide differently:
88
+ ```ts
89
+ import { readFileSync } from 'node:fs';
90
+ import { diffRtdbRules, canonicalRtdbExpression } from 'pyric/rules';
91
+
92
+ const handWritten = JSON.parse(readFileSync('legacy/database.rules.json', 'utf8')).rules;
93
+ const compiled = rules.toJSON().rules;
94
+
95
+ diffRtdbRules(handWritten, compiled); // every rule whose text differs
96
+ diffRtdbRules(handWritten, compiled, { ignoreEquivalent: true }); // only rules that can decide differently
97
+ // → [{ path, kind: '.read' | '.write' | '.validate' | '.indexOn', change: 'added' | 'removed' | 'changed', deployed?, local? }]
98
+
99
+ canonicalRtdbExpression("$uid === auth.uid"); // "$uid == auth.uid"
100
+ canonicalRtdbExpression("auth.uid !== null"); // "!(auth == null)"
101
+ ```
102
+ A changed entry keeps both expressions exactly as written. Without the option the diff compares text.
103
+
104
+ Two expressions are equivalent when their canonical texts are equal. The canonical form applies only rewrites that cannot change a decision, errors included (a rule that errors fails, and `||` or `!` does not rescue it):
105
+
106
+ | Treated as the same | Why |
107
+ | --- | --- |
108
+ | whitespace, grouping parentheses, `a && (b && c)` and `a && b && c` | parentheses only shape the tree; `&&` and `\|\|` evaluate left to right either way |
109
+ | `"a"` and `'a'`, for a string literal written without a backslash | the same string; a literal with any escape (`'\d'`, `'\u0041'`) keeps its spelling, since production's reading of most escapes has not been observed |
110
+ | `===` and `==`, `!==` and `!=` | RTDB compares without type conversion in both |
111
+ | `auth.uid == null` and `auth == null` | `auth` is null exactly when signed out, and a signed-in `auth` always has a uid |
112
+ | `child('a/b/' + $id + '/c')` and `child('a').child('b').child($id).child('c')` | a `$` variable is exactly one key, and `child()` reads `/` as a separator |
113
+ | `a != b` and `!(a == b)` | both fail on the same inputs; otherwise `==` gives a boolean |
114
+ | `a == b` and `b == a` | equality is symmetric; operands that could fail other than with a rule error, or that index into an array, keep their order |
115
+
116
+ Deliberately not treated as the same: a concatenated part that is not a string literal or a `$` variable, `auth.uid` included (`child('m/' + auth.uid)` is not `child('m').child(auth.uid)`, because production has not been observed reading a uid such as `/x` or `a//b` with empty segments the same way in both forms); a literal glued to a variable (`'user_' + $id`) or written with an escape; empty segments and key-invalid characters in a path; the order of `&&` and `||` operands; `>=` and `>`; double negation; and arithmetic or constant folding. A rule that falls in one of those is reported as changed, so read those entries yourself.
117
+
85
118
  ## Turn enforcement, from a deployed game
86
119
 
87
120
  This turn-enforcement example is built from the same parts:
@@ -1,4 +1,4 @@
1
1
  <!DOCTYPE html><html lang="en"> <head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover"><title>Play chess against Security Rules</title><link rel="icon" type="image/svg+xml" href="/__pyric/ui/pyric-logo.svg"><link rel="preconnect" href="https://use.typekit.net" crossorigin><link rel="stylesheet" href="https://use.typekit.net/jwb3ycu.css"><link rel="preconnect" href="https://fonts.googleapis.com"><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin><link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500;700&display=swap" rel="stylesheet"><style>html{scrollbar-gutter:stable}
2
2
  </style>
3
3
  <link rel="stylesheet" href="/__pyric/ui/_astro/_example_.B9DG9HLJ.css">
4
- <link rel="stylesheet" href="/__pyric/ui/_astro/app.8TtSeQQX.css"></head> <body> <style>astro-island,astro-slot,astro-static-slot{display:contents}</style><script>(()=>{var e=async t=>{await(await t())()};(self.Astro||(self.Astro={})).only=e;window.dispatchEvent(new Event("astro:only"));})();</script><script>(()=>{var A=Object.defineProperty;var g=(i,o,a)=>o in i?A(i,o,{enumerable:!0,configurable:!0,writable:!0,value:a}):i[o]=a;var d=(i,o,a)=>g(i,typeof o!="symbol"?o+"":o,a);{let i={0:t=>m(t),1:t=>a(t),2:t=>new RegExp(t),3:t=>new Date(t),4:t=>new Map(a(t)),5:t=>new Set(a(t)),6:t=>BigInt(t),7:t=>new URL(t),8:t=>new Uint8Array(t),9:t=>new Uint16Array(t),10:t=>new Uint32Array(t),11:t=>1/0*t},o=t=>{let[l,e]=t;return l in i?i[l](e):void 0},a=t=>t.map(o),m=t=>typeof t!="object"||t===null?t:Object.fromEntries(Object.entries(t).map(([l,e])=>[l,o(e)]));class y extends HTMLElement{constructor(){super(...arguments);d(this,"Component");d(this,"hydrator");d(this,"hydrate",async()=>{var b;if(!this.hydrator||!this.isConnected)return;let e=(b=this.parentElement)==null?void 0:b.closest("astro-island[ssr]");if(e){e.addEventListener("astro:hydrate",this.hydrate,{once:!0});return}let c=this.querySelectorAll("astro-slot"),n={},h=this.querySelectorAll("template[data-astro-template]");for(let r of h){let s=r.closest(this.tagName);s!=null&&s.isSameNode(this)&&(n[r.getAttribute("data-astro-template")||"default"]=r.innerHTML,r.remove())}for(let r of c){let s=r.closest(this.tagName);s!=null&&s.isSameNode(this)&&(n[r.getAttribute("name")||"default"]=r.innerHTML)}let p;try{p=this.hasAttribute("props")?m(JSON.parse(this.getAttribute("props"))):{}}catch(r){let s=this.getAttribute("component-url")||"<unknown>",v=this.getAttribute("component-export");throw v&&(s+=` (export ${v})`),console.error(`[hydrate] Error parsing props for component ${s}`,this.getAttribute("props"),r),r}let u;await this.hydrator(this)(this.Component,p,n,{client:this.getAttribute("client")}),this.removeAttribute("ssr"),this.dispatchEvent(new CustomEvent("astro:hydrate"))});d(this,"unmount",()=>{this.isConnected||this.dispatchEvent(new CustomEvent("astro:unmount"))})}disconnectedCallback(){document.removeEventListener("astro:after-swap",this.unmount),document.addEventListener("astro:after-swap",this.unmount,{once:!0})}connectedCallback(){if(!this.hasAttribute("await-children")||document.readyState==="interactive"||document.readyState==="complete")this.childrenConnectedCallback();else{let e=()=>{document.removeEventListener("DOMContentLoaded",e),c.disconnect(),this.childrenConnectedCallback()},c=new MutationObserver(()=>{var n;((n=this.lastChild)==null?void 0:n.nodeType)===Node.COMMENT_NODE&&this.lastChild.nodeValue==="astro:end"&&(this.lastChild.remove(),e())});c.observe(this,{childList:!0}),document.addEventListener("DOMContentLoaded",e)}}async childrenConnectedCallback(){let e=this.getAttribute("before-hydration-url");e&&await import(e),this.start()}async start(){let e=JSON.parse(this.getAttribute("opts")),c=this.getAttribute("client");if(Astro[c]===void 0){window.addEventListener(`astro:${c}`,()=>this.start(),{once:!0});return}try{await Astro[c](async()=>{let n=this.getAttribute("renderer-url"),[h,{default:p}]=await Promise.all([import(this.getAttribute("component-url")),n?import(n):()=>()=>{}]),u=this.getAttribute("component-export")||"default";if(!u.includes("."))this.Component=h[u];else{this.Component=h;for(let f of u.split("."))this.Component=this.Component[f]}return this.hydrator=p,this.hydrate},e,this)}catch(n){console.error(`[astro-island] Error hydrating ${this.getAttribute("component-url")}`,n)}}attributeChangedCallback(){this.hydrate()}}d(y,"observedAttributes",["props"]),customElements.get("astro-island")||customElements.define("astro-island",y)}})();</script><astro-island uid="ZN6Jl9" component-url="/__pyric/ui/_astro/chess-showcase.CZe_3fJZ.js" component-export="ChessShowcase" renderer-url="/__pyric/ui/_astro/client.D2ORs4B-.js" props="{}" ssr client="only" opts="{&quot;name&quot;:&quot;ChessShowcase&quot;,&quot;value&quot;:&quot;react&quot;}"></astro-island> </body></html>
4
+ <link rel="stylesheet" href="/__pyric/ui/_astro/app.8TtSeQQX.css"></head> <body> <style>astro-island,astro-slot,astro-static-slot{display:contents}</style><script>(()=>{var e=async t=>{await(await t())()};(self.Astro||(self.Astro={})).only=e;window.dispatchEvent(new Event("astro:only"));})();</script><script>(()=>{var A=Object.defineProperty;var g=(i,o,a)=>o in i?A(i,o,{enumerable:!0,configurable:!0,writable:!0,value:a}):i[o]=a;var d=(i,o,a)=>g(i,typeof o!="symbol"?o+"":o,a);{let i={0:t=>m(t),1:t=>a(t),2:t=>new RegExp(t),3:t=>new Date(t),4:t=>new Map(a(t)),5:t=>new Set(a(t)),6:t=>BigInt(t),7:t=>new URL(t),8:t=>new Uint8Array(t),9:t=>new Uint16Array(t),10:t=>new Uint32Array(t),11:t=>1/0*t},o=t=>{let[l,e]=t;return l in i?i[l](e):void 0},a=t=>t.map(o),m=t=>typeof t!="object"||t===null?t:Object.fromEntries(Object.entries(t).map(([l,e])=>[l,o(e)]));class y extends HTMLElement{constructor(){super(...arguments);d(this,"Component");d(this,"hydrator");d(this,"hydrate",async()=>{var b;if(!this.hydrator||!this.isConnected)return;let e=(b=this.parentElement)==null?void 0:b.closest("astro-island[ssr]");if(e){e.addEventListener("astro:hydrate",this.hydrate,{once:!0});return}let c=this.querySelectorAll("astro-slot"),n={},h=this.querySelectorAll("template[data-astro-template]");for(let r of h){let s=r.closest(this.tagName);s!=null&&s.isSameNode(this)&&(n[r.getAttribute("data-astro-template")||"default"]=r.innerHTML,r.remove())}for(let r of c){let s=r.closest(this.tagName);s!=null&&s.isSameNode(this)&&(n[r.getAttribute("name")||"default"]=r.innerHTML)}let p;try{p=this.hasAttribute("props")?m(JSON.parse(this.getAttribute("props"))):{}}catch(r){let s=this.getAttribute("component-url")||"<unknown>",v=this.getAttribute("component-export");throw v&&(s+=` (export ${v})`),console.error(`[hydrate] Error parsing props for component ${s}`,this.getAttribute("props"),r),r}let u;await this.hydrator(this)(this.Component,p,n,{client:this.getAttribute("client")}),this.removeAttribute("ssr"),this.dispatchEvent(new CustomEvent("astro:hydrate"))});d(this,"unmount",()=>{this.isConnected||this.dispatchEvent(new CustomEvent("astro:unmount"))})}disconnectedCallback(){document.removeEventListener("astro:after-swap",this.unmount),document.addEventListener("astro:after-swap",this.unmount,{once:!0})}connectedCallback(){if(!this.hasAttribute("await-children")||document.readyState==="interactive"||document.readyState==="complete")this.childrenConnectedCallback();else{let e=()=>{document.removeEventListener("DOMContentLoaded",e),c.disconnect(),this.childrenConnectedCallback()},c=new MutationObserver(()=>{var n;((n=this.lastChild)==null?void 0:n.nodeType)===Node.COMMENT_NODE&&this.lastChild.nodeValue==="astro:end"&&(this.lastChild.remove(),e())});c.observe(this,{childList:!0}),document.addEventListener("DOMContentLoaded",e)}}async childrenConnectedCallback(){let e=this.getAttribute("before-hydration-url");e&&await import(e),this.start()}async start(){let e=JSON.parse(this.getAttribute("opts")),c=this.getAttribute("client");if(Astro[c]===void 0){window.addEventListener(`astro:${c}`,()=>this.start(),{once:!0});return}try{await Astro[c](async()=>{let n=this.getAttribute("renderer-url"),[h,{default:p}]=await Promise.all([import(this.getAttribute("component-url")),n?import(n):()=>()=>{}]),u=this.getAttribute("component-export")||"default";if(!u.includes("."))this.Component=h[u];else{this.Component=h;for(let f of u.split("."))this.Component=this.Component[f]}return this.hydrator=p,this.hydrate},e,this)}catch(n){console.error(`[astro-island] Error hydrating ${this.getAttribute("component-url")}`,n)}}attributeChangedCallback(){this.hydrate()}}d(y,"observedAttributes",["props"]),customElements.get("astro-island")||customElements.define("astro-island",y)}})();</script><astro-island uid="sFXys" component-url="/__pyric/ui/_astro/chess-showcase.CZe_3fJZ.js" component-export="ChessShowcase" renderer-url="/__pyric/ui/_astro/client.D2ORs4B-.js" props="{}" ssr client="only" opts="{&quot;name&quot;:&quot;ChessShowcase&quot;,&quot;value&quot;:&quot;react&quot;}"></astro-island> </body></html>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pyric/cli",
3
- "version": "0.1.0-alpha.25",
3
+ "version": "0.1.0-alpha.26",
4
4
  "license": "Apache-2.0",
5
5
  "homepage": "https://pyric.dev",
6
6
  "repository": {
@@ -245,12 +245,12 @@
245
245
  },
246
246
  "dependencies": {
247
247
  "@modelcontextprotocol/sdk": "^1.0.0",
248
- "create-pyric": "^0.1.0-alpha.25",
248
+ "create-pyric": "^0.1.0-alpha.26",
249
249
  "esbuild": "^0.28.0",
250
250
  "fake-indexeddb": "^6.0.0",
251
251
  "jsonc-parser": "^3.3.1",
252
- "pyric": "^0.1.0-alpha.25",
253
- "pyric-admin": "^0.1.0-alpha.25",
252
+ "pyric": "^0.1.0-alpha.26",
253
+ "pyric-admin": "^0.1.0-alpha.26",
254
254
  "ws": "^8.18.0",
255
255
  "zod": "^3.23.0"
256
256
  },
@@ -9,25 +9,14 @@
9
9
  * URL, and it is not returned in any result or message.
10
10
  */
11
11
  import type { ToolHandler } from 'pyric/sandbox/internal';
12
+ import { diffRtdbRules, type RtdbRuleChange } from 'pyric/rules/internal/rtdb';
12
13
  import { parseRtdbRulesText } from './rules-json.js';
13
14
  import { HOSTED_CREDENTIAL_SOURCES } from '../credentials/node/scope.js';
14
15
 
15
- /** The rule kinds the diff compares. */
16
- export const DIFFED_RULE_KINDS = ['.indexOn', '.read', '.validate', '.write'] as const;
17
- export type RtdbRuleKind = (typeof DIFFED_RULE_KINDS)[number];
18
-
19
- /**
20
- * One rule expression that differs. `deployed` is absent for `added` and
21
- * `local` is absent for `removed`: the change is what deploying the local
22
- * ruleset would do to the deployed one.
23
- */
24
- export interface RtdbRuleChange {
25
- path: string;
26
- kind: RtdbRuleKind;
27
- change: 'added' | 'removed' | 'changed';
28
- deployed?: string;
29
- local?: string;
30
- }
16
+ // The diff itself is pure and lives in `pyric` (public from `pyric/rules`);
17
+ // it is re-exported here so this module's exports stay as they were.
18
+ export { DIFFED_RULE_KINDS, diffRtdbRules } from 'pyric/rules/internal/rtdb';
19
+ export type { DiffRtdbRulesOptions, RtdbRuleChange, RtdbRuleKind } from 'pyric/rules/internal/rtdb';
31
20
 
32
21
  export interface InspectDeployedRtdbRulesOptions {
33
22
  scope: { resolveToken(): Promise<string> };
@@ -96,67 +85,6 @@ export async function inspectDeployedRtdbRules(
96
85
  return { ok: true, deployed, diff: diffRtdbRules(deployed.rules, local.rules) };
97
86
  }
98
87
 
99
- /** Node-level diff of two `rules` trees, ordered by path then rule kind. */
100
- export function diffRtdbRules(
101
- deployed: Record<string, unknown>,
102
- local: Record<string, unknown>,
103
- ): RtdbRuleChange[] {
104
- const deployedRules = collectRules(deployed);
105
- const localRules = collectRules(local);
106
- const changes: RtdbRuleChange[] = [];
107
- for (const key of new Set([...deployedRules.keys(), ...localRules.keys()])) {
108
- const d = deployedRules.get(key);
109
- const l = localRules.get(key);
110
- if (d?.value === l?.value) continue;
111
- const node = (d ?? l)!;
112
- const change: RtdbRuleChange = {
113
- path: node.path,
114
- kind: node.kind,
115
- change: d === undefined ? 'added' : l === undefined ? 'removed' : 'changed',
116
- };
117
- if (d !== undefined) change.deployed = d.value;
118
- if (l !== undefined) change.local = l.value;
119
- changes.push(change);
120
- }
121
- return changes.sort((a, b) => compare(a.path, b.path) || compare(a.kind, b.kind));
122
- }
123
-
124
- const compare = (a: string, b: string): number => (a < b ? -1 : a > b ? 1 : 0);
125
-
126
- interface CollectedRule {
127
- path: string;
128
- kind: RtdbRuleKind;
129
- value: string;
130
- }
131
-
132
- function formatRuleValue(kind: RtdbRuleKind, value: unknown): string {
133
- if (kind === '.indexOn') {
134
- const normalized = typeof value === 'string' ? [value] : value;
135
- return JSON.stringify(normalized);
136
- }
137
- // A boolean and its string form are one expression: `true` and "true".
138
- return typeof value === 'string' ? value : JSON.stringify(value);
139
- }
140
-
141
- function collectRules(root: Record<string, unknown>): Map<string, CollectedRule> {
142
- const out = new Map<string, CollectedRule>();
143
- const walk = (node: Record<string, unknown>, path: string): void => {
144
- for (const [key, child] of Object.entries(node)) {
145
- if (key.startsWith('.')) {
146
- if ((DIFFED_RULE_KINDS as readonly string[]).includes(key)) {
147
- const kind = key as RtdbRuleKind;
148
- const value = formatRuleValue(kind, child);
149
- out.set(`${path}\u0000${kind}`, { path, kind, value });
150
- }
151
- } else if (typeof child === 'object' && child !== null && !Array.isArray(child)) {
152
- walk(child as Record<string, unknown>, `${path === '/' ? '' : path}/${key}`);
153
- }
154
- }
155
- };
156
- walk(root, '/');
157
- return out;
158
- }
159
-
160
88
  export interface RtdbInspectRulesToolDeps {
161
89
  scope: { resolveToken(): Promise<string> };
162
90
  databaseURL: string;