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

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 (129) hide show
  1. package/README.md +1 -1
  2. package/dist/assurance/.generated/conformance-verdicts.d.ts +5 -0
  3. package/dist/assurance/.generated/conformance-verdicts.d.ts.map +1 -1
  4. package/dist/assurance/.generated/conformance-verdicts.js +5 -0
  5. package/dist/assurance/.generated/conformance-verdicts.js.map +1 -1
  6. package/dist/assurance/capabilities.d.ts.map +1 -1
  7. package/dist/assurance/capabilities.js +24 -5
  8. package/dist/assurance/capabilities.js.map +1 -1
  9. package/dist/assurance/runner.d.ts.map +1 -1
  10. package/dist/assurance/runner.js +37 -50
  11. package/dist/assurance/runner.js.map +1 -1
  12. package/dist/assurance/types.d.ts +5 -0
  13. package/dist/assurance/types.d.ts.map +1 -1
  14. package/dist/assurance/types.js.map +1 -1
  15. package/dist/assurance/validation.d.ts +2 -16
  16. package/dist/assurance/validation.d.ts.map +1 -1
  17. package/dist/assurance/validation.js +121 -1
  18. package/dist/assurance/validation.js.map +1 -1
  19. package/dist/bridge/surface/arguments/assurance.d.ts +25 -0
  20. package/dist/bridge/surface/arguments/assurance.d.ts.map +1 -1
  21. package/dist/bridge/surface/arguments/assurance.js +1 -0
  22. package/dist/bridge/surface/arguments/assurance.js.map +1 -1
  23. package/dist/bridge/surface/methods/assurance/define.d.ts +2 -2
  24. package/dist/bridge/surface/methods/assurance/map.d.ts +23 -0
  25. package/dist/bridge/surface/methods/assurance/map.d.ts.map +1 -1
  26. package/dist/bridge/surface/methods/assurance/propose.d.ts +7 -0
  27. package/dist/bridge/surface/methods/assurance/propose.d.ts.map +1 -1
  28. package/dist/bridge/surface/render/discriminator-schemas.d.ts +2 -2
  29. package/dist/cli/database-rules.js +1 -1
  30. package/dist/cli/database-rules.js.map +1 -1
  31. package/dist/cli/index.d.ts.map +1 -1
  32. package/dist/cli/index.js +13 -2
  33. package/dist/cli/index.js.map +1 -1
  34. package/dist/cli/pyric-config.d.ts +8 -0
  35. package/dist/cli/pyric-config.d.ts.map +1 -1
  36. package/dist/cli/pyric-config.js.map +1 -1
  37. package/dist/cli/serve.d.ts.map +1 -1
  38. package/dist/cli/serve.js +5 -3
  39. package/dist/cli/serve.js.map +1 -1
  40. package/dist/cli/verify.d.ts +6 -0
  41. package/dist/cli/verify.d.ts.map +1 -1
  42. package/dist/cli/verify.js +240 -15
  43. package/dist/cli/verify.js.map +1 -1
  44. package/dist/conformance/.generated/can-i-use.d.ts.map +1 -1
  45. package/dist/conformance/.generated/can-i-use.js +1 -1
  46. package/dist/conformance/.generated/can-i-use.js.map +1 -1
  47. package/dist/conformance/.generated/conformance-docs.d.ts.map +1 -1
  48. package/dist/conformance/.generated/conformance-docs.js +1 -1
  49. package/dist/conformance/.generated/conformance-docs.js.map +1 -1
  50. package/dist/rtdb/inspect-deployed-rules.d.ts +3 -17
  51. package/dist/rtdb/inspect-deployed-rules.d.ts.map +1 -1
  52. package/dist/rtdb/inspect-deployed-rules.js +4 -54
  53. package/dist/rtdb/inspect-deployed-rules.js.map +1 -1
  54. package/dist/serve/repro/denials.d.ts.map +1 -1
  55. package/dist/serve/repro/denials.js +2 -1
  56. package/dist/serve/repro/denials.js.map +1 -1
  57. package/dist/serve/site-ui/_astro/{app.BpEphDR7.js → app.CiMHEvEW.js} +15 -15
  58. package/dist/serve/site-ui/_astro/{app.DFdWhpVY.js → app.DpzsBfos.js} +1 -1
  59. package/dist/serve/site-ui/_astro/{seed.vvXV1ioI.js → seed.BpXSRzLo.js} +1 -1
  60. package/dist/serve/site-ui/auth/index.html +1 -1
  61. package/dist/serve/site-ui/docs/database-compat/index.html +4 -0
  62. package/dist/serve/site-ui/docs/database-compat.md +4 -0
  63. package/dist/serve/site-ui/docs/get-started/start-building/index.html +1 -0
  64. package/dist/serve/site-ui/docs/get-started/start-building.md +2 -0
  65. package/dist/serve/site-ui/docs/index.json +35 -0
  66. package/dist/serve/site-ui/docs/pyric-cli-assurance-reference-api/index.html +23 -4
  67. package/dist/serve/site-ui/docs/pyric-cli-assurance-reference-api.md +11 -4
  68. package/dist/serve/site-ui/docs/pyric-cli-verify-reference-api/index.html +15 -2
  69. package/dist/serve/site-ui/docs/pyric-cli-verify-reference-api.md +11 -10
  70. package/dist/serve/site-ui/docs/pyric-rules-reference-api/index.html +372 -201
  71. package/dist/serve/site-ui/docs/pyric-rules-reference-api.md +148 -9
  72. package/dist/serve/site-ui/docs/secure/rtdb-rules-in-typescript/index.html +56 -1
  73. package/dist/serve/site-ui/docs/secure/rtdb-rules-in-typescript.md +33 -0
  74. package/dist/serve/site-ui/docs/secure/write-a-rules-test-suite/index.html +1 -1
  75. package/dist/serve/site-ui/docs/secure/write-a-rules-test-suite.md +1 -1
  76. package/dist/serve/site-ui/docs/ship/ship-to-production/index.html +4 -1
  77. package/dist/serve/site-ui/docs/ship/ship-to-production.md +7 -1
  78. package/dist/serve/site-ui/examples/chess/index.html +1 -1
  79. package/dist/serve/site-ui/firestore/index.html +1 -1
  80. package/dist/serve/site-ui/rtdb/index.html +1 -1
  81. package/dist/serve/site-ui/settings/index.html +1 -1
  82. package/dist/serve/site-ui/storage/index.html +1 -1
  83. package/dist/serve/site-ui/studio/index.html +1 -1
  84. package/dist/serve/site-ui/traffic/index.html +1 -1
  85. package/dist/serve/verify-hint.d.ts +2 -0
  86. package/dist/serve/verify-hint.d.ts.map +1 -0
  87. package/dist/serve/verify-hint.js +10 -0
  88. package/dist/serve/verify-hint.js.map +1 -0
  89. package/dist/serve/vite-generation-rules-watch.d.ts.map +1 -1
  90. package/dist/serve/vite-generation-rules-watch.js +5 -3
  91. package/dist/serve/vite-generation-rules-watch.js.map +1 -1
  92. package/dist/verify/index.d.ts +16 -2
  93. package/dist/verify/index.d.ts.map +1 -1
  94. package/dist/verify/index.js +100 -18
  95. package/dist/verify/index.js.map +1 -1
  96. package/dist/verify/staging/engine.d.ts +102 -0
  97. package/dist/verify/staging/engine.d.ts.map +1 -0
  98. package/dist/verify/staging/engine.js +371 -0
  99. package/dist/verify/staging/engine.js.map +1 -0
  100. package/dist/verify/staging/rest.d.ts +68 -0
  101. package/dist/verify/staging/rest.d.ts.map +1 -0
  102. package/dist/verify/staging/rest.js +93 -0
  103. package/dist/verify/staging/rest.js.map +1 -0
  104. package/dist/verify/staging/target.d.ts +39 -0
  105. package/dist/verify/staging/target.d.ts.map +1 -0
  106. package/dist/verify/staging/target.js +117 -0
  107. package/dist/verify/staging/target.js.map +1 -0
  108. package/package.json +4 -4
  109. package/src/assurance/.generated/conformance-verdicts.ts +5 -0
  110. package/src/assurance/capabilities.ts +22 -4
  111. package/src/assurance/runner.ts +40 -58
  112. package/src/assurance/types.ts +3 -0
  113. package/src/assurance/validation.ts +137 -0
  114. package/src/bridge/surface/arguments/assurance.ts +1 -0
  115. package/src/cli/database-rules.ts +1 -1
  116. package/src/cli/index.ts +13 -2
  117. package/src/cli/pyric-config.ts +5 -0
  118. package/src/cli/serve.ts +6 -3
  119. package/src/cli/verify.ts +244 -18
  120. package/src/conformance/.generated/can-i-use.ts +1 -1
  121. package/src/conformance/.generated/conformance-docs.ts +1 -1
  122. package/src/rtdb/inspect-deployed-rules.ts +5 -77
  123. package/src/serve/repro/denials.ts +2 -1
  124. package/src/serve/verify-hint.ts +8 -0
  125. package/src/serve/vite-generation-rules-watch.ts +6 -3
  126. package/src/verify/index.ts +119 -19
  127. package/src/verify/staging/engine.ts +455 -0
  128. package/src/verify/staging/rest.ts +129 -0
  129. package/src/verify/staging/target.ts +137 -0
@@ -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;
@@ -179,5 +179,6 @@ export function formatDenials(groups: DenialGroup[], loggedFrom?: number): strin
179
179
  for (const reason of group.trace) lines.push(` trace: ${reason}`);
180
180
  return lines.join('\n');
181
181
  });
182
- return `${bounded}${blocks.join('\n\n')}\n`;
182
+ const tip = '\nTip: After changing rules, run `pyric verify` to replay the captured session against them and confirm your fixes.';
183
+ return `${bounded}${blocks.join('\n\n')}\n${tip}\n`;
183
184
  }
@@ -0,0 +1,8 @@
1
+ export function createVerifyHint(): () => string {
2
+ let printedVerifyHint = false;
3
+ return () => {
4
+ if (printedVerifyHint) return '';
5
+ printedVerifyHint = true;
6
+ return ' (run `pyric verify` to replay the captured session against these rules)';
7
+ };
8
+ }
@@ -2,6 +2,7 @@ import path from 'node:path';
2
2
  import type { ViteDevServer } from 'vite';
3
3
  import { formatDatabaseRulesRemoved, formatStorageRulesRemoved } from './rules.js';
4
4
  import type { SandboxSession } from './sandbox-session.js';
5
+ import { createVerifyHint } from './verify-hint.js';
5
6
 
6
7
  /**
7
8
  * Adapt Vite's watcher to the session's last-good rules reload operations.
@@ -17,6 +18,8 @@ export function watchViteGenerationRules(input: {
17
18
  server: ViteDevServer;
18
19
  session: SandboxSession;
19
20
  }): () => void {
21
+ const getVerifyHint = createVerifyHint();
22
+
20
23
  const { server, session } = input;
21
24
  const databaseFiles = session.databaseRulesFiles();
22
25
  const databaseFileByResolved = new Map(databaseFiles.map((file) => [path.resolve(file), file]));
@@ -53,7 +56,7 @@ export function watchViteGenerationRules(input: {
53
56
  server.watcher.add([...session.firestoreRulesFiles()]);
54
57
  const isReloaded = result.kind === 'reloaded';
55
58
  if (isReloaded) {
56
- server.config.logger.info(` ↻ [pyric] rules reloaded (${result.rulesHash})`);
59
+ server.config.logger.info(` ↻ [pyric] rules reloaded (${result.rulesHash})${getVerifyHint()}`);
57
60
  if (result.fixtureCheck && result.fixtureCheck.ran > 0) {
58
61
  const logFn = result.fixtureCheck.ok
59
62
  ? (m: string) => server.config.logger.info(m)
@@ -74,7 +77,7 @@ export function watchViteGenerationRules(input: {
74
77
  void session.reloadDatabaseRules(databaseFile).then((results) => {
75
78
  for (const { instance, result } of results) {
76
79
  if (result.kind === 'reloaded') {
77
- server.config.logger.info(` ↻ [pyric] rtdb rules reloaded for ${instance} (${result.rulesHash})`);
80
+ server.config.logger.info(` ↻ [pyric] rtdb rules reloaded for ${instance} (${result.rulesHash})${getVerifyHint()}`);
78
81
  if (result.fixtureCheck && result.fixtureCheck.ran > 0) {
79
82
  const logFn = result.fixtureCheck.ok
80
83
  ? (m: string) => server.config.logger.info(m)
@@ -94,7 +97,7 @@ export function watchViteGenerationRules(input: {
94
97
  if (isStorageMatch) {
95
98
  void session.reloadStorageRules().then((result) => {
96
99
  if (result.kind === 'reloaded') {
97
- server.config.logger.info(` ↻ [pyric] storage rules reloaded (${result.rulesHash})`);
100
+ server.config.logger.info(` ↻ [pyric] storage rules reloaded (${result.rulesHash})${getVerifyHint()}`);
98
101
  } else if (result.kind === 'rejected') {
99
102
  server.config.logger.warn(
100
103
  ` ⚠ [pyric] storage rules NOT reloaded (last-good stays live): ${result.error.message}`,
@@ -39,6 +39,8 @@ import {
39
39
  VERIFY_FIXTURE_SCHEMA,
40
40
  type PyricVerifyFixture,
41
41
  } from './fixture.js';
42
+ import { runStagingInstance, StagingDeployRefusedError, withStagingInstance, type StagingRunHooks } from './staging/engine.js';
43
+ import type { RtdbRestClient } from './staging/rest.js';
42
44
  import {
43
45
  recordedRtdbInstances,
44
46
  recordsDefaultRtdbInstance,
@@ -94,7 +96,7 @@ export {
94
96
  } from './hosted-rules-test.js';
95
97
 
96
98
  export type VerifiableService = 'firestore' | 'rtdb' | 'storage';
97
- export type VerifyEngine = 'sandbox' | 'rulesTestApi';
99
+ export type VerifyEngine = 'sandbox' | 'rulesTestApi' | 'staging';
98
100
 
99
101
  type RtdbCandidateRules = { rules: Record<string, unknown> } | RtdbRulesDocument;
100
102
 
@@ -120,6 +122,12 @@ export interface VerifyFixtureOptions {
120
122
  expressionReportLevel?: ExpressionReportLevel;
121
123
  };
122
124
  caseDerivation?: Omit<DeriveRulesTestCasesOptions, 'service'>;
125
+ /**
126
+ * The staging engine's instance: a client for a dedicated RTDB instance
127
+ * enrolled with `pyric verify staging init`, and the project it is
128
+ * enrolled for. Required when `engines` includes `staging`.
129
+ */
130
+ staging?: { client: RtdbRestClient; projectId: string } & StagingRunHooks;
123
131
  }
124
132
 
125
133
  export interface VerifyResult {
@@ -198,7 +206,10 @@ type VerifyDivergenceKind =
198
206
  path?: string;
199
207
  method?: string;
200
208
  sandbox: string;
201
- rulesTestApi: string;
209
+ /** Firestore: the hosted Rules Test API's decision. */
210
+ rulesTestApi?: string;
211
+ /** RTDB: what production did on the staging instance. */
212
+ staging?: string;
202
213
  reason?: string;
203
214
  };
204
215
 
@@ -224,10 +235,15 @@ export async function verifyFixture(
224
235
 
225
236
  const services: Partial<Record<VerifiableService, VerifyServiceResult>> = {};
226
237
  for (const service of selected) {
238
+ if (engines.includes('staging') && service !== 'rtdb') {
239
+ throw new VerifyInputError(
240
+ `the staging engine checks RTDB only; ${service} is not supported by it yet. Select --service rtdb, or the sandbox engine for ${service}.`,
241
+ );
242
+ }
227
243
  if (service === 'firestore') {
228
244
  services.firestore = await verifyFirestore(fixture, requireFirestoreRules(opts.rules), engines, opts);
229
245
  } else if (service === 'rtdb') {
230
- services.rtdb = await verifyRtdb(fixture, opts.rules, engines);
246
+ services.rtdb = await verifyRtdb(fixture, opts.rules, engines, opts.staging);
231
247
  } else if (service === 'storage') {
232
248
  services.storage = await verifyStorage(fixture, opts.rules, engines);
233
249
  } else {
@@ -578,19 +594,24 @@ async function verifyRtdb(
578
594
  fixture: PyricVerifyFixture,
579
595
  rules: VerifyRulesInput,
580
596
  engines: VerifyEngine[],
597
+ staging: VerifyFixtureOptions['staging'],
581
598
  ): Promise<VerifyServiceResult> {
582
599
  const rtdb = fixture.services.rtdb;
583
600
  if (!rtdb) {
584
601
  throw new VerifyInputError('fixture does not contain services.rtdb.');
585
602
  }
586
603
  if (engines.includes('rulesTestApi')) {
587
- throw new VerifyInputError('rulesTestApi verification is Firestore-only; select --service firestore or use the sandbox engine for RTDB.');
604
+ throw new VerifyInputError('rulesTestApi verification is Firestore-only; select --service firestore or use the sandbox or staging engine for RTDB.');
605
+ }
606
+ if (engines.includes('staging') && staging === undefined) {
607
+ throw new VerifyInputError('the staging engine needs a staging instance (opts.staging).');
588
608
  }
589
609
 
590
- const replays = [];
610
+ // The instances to check, each with its candidate rules.
611
+ const checks: Array<{ instance?: string; rules: RtdbRulesJson; capturedState: unknown }> = [];
591
612
  const checksDefault = rules.rtdb !== undefined || recordsDefaultRtdbInstance(fixture);
592
613
  if (checksDefault) {
593
- replays.push(await replayRtdbInstance(fixture, requireRtdbRules(rules.rtdb, 'missing candidate RTDB rules. Pass rules.rtdb.')));
614
+ checks.push({ rules: requireRtdbRules(rules.rtdb, 'missing candidate RTDB rules. Pass rules.rtdb.'), capturedState: rtdb.state.tree });
594
615
  }
595
616
  const skippedInstances: string[] = [];
596
617
  for (const instance of recordedRtdbInstances(fixture)) {
@@ -600,28 +621,107 @@ async function verifyRtdb(
600
621
  continue;
601
622
  }
602
623
  const parsed = requireRtdbRules(candidate, `missing candidate RTDB rules for instance "${instance}".`, instance);
603
- replays.push(await replayRtdbInstance(fixture, parsed, instance));
624
+ checks.push({ instance, rules: parsed, capturedState: rtdb.instances?.[instance]?.state.tree });
604
625
  }
605
626
 
606
- const divergences = replays.flatMap((replayed) => replayed.divergences.map(mapRtdbReplayDivergence));
607
- const checkedEvents = replays.reduce((total, replayed) => total + replayed.checkedEvents, 0);
608
- const sandbox: VerifyEngineResult = {
609
- engine: 'sandbox',
610
- ok: divergences.every(isInformational),
611
- checkedEvents,
612
- divergences,
613
- };
627
+ const engineResults: Partial<Record<VerifyEngine, VerifyEngineResult>> = {};
628
+ if (engines.includes('sandbox')) {
629
+ const replays = [];
630
+ for (const check of checks) replays.push(await replayRtdbInstance(fixture, check.rules, check.instance));
631
+ const divergences = replays.flatMap((replayed) => replayed.divergences.map(mapRtdbReplayDivergence));
632
+ engineResults.sandbox = {
633
+ engine: 'sandbox',
634
+ ok: divergences.every(isInformational),
635
+ checkedEvents: replays.reduce((total, replayed) => total + replayed.checkedEvents, 0),
636
+ divergences,
637
+ };
638
+ }
639
+ if (engines.includes('staging') && staging !== undefined) {
640
+ engineResults.staging = await verifyRtdbOnStaging(fixture, checks, staging);
641
+ }
642
+ if (engineResults.sandbox && engineResults.staging) {
643
+ addRtdbEngineDrift(engineResults.sandbox, engineResults.staging);
644
+ }
614
645
 
646
+ const divergences = collectEngineDivergences(engineResults);
615
647
  return {
616
648
  service: 'rtdb',
617
649
  ok: divergences.every(isInformational),
618
- checkedEvents,
650
+ checkedEvents: engineResults.sandbox?.checkedEvents ?? engineResults.staging?.checkedEvents ?? 0,
619
651
  divergences,
620
- engines: { sandbox },
652
+ engines: engineResults,
621
653
  ...(skippedInstances.length > 0 ? { skippedInstances } : {}),
622
654
  };
623
655
  }
624
656
 
657
+ /**
658
+ * Replay each instance on the staging instance in turn, each run guarded and
659
+ * the instance put back before the next. Production refusing to deploy an
660
+ * instance's candidate rules is reported for that instance; the others run.
661
+ */
662
+ async function verifyRtdbOnStaging(
663
+ fixture: PyricVerifyFixture,
664
+ checks: Array<{ instance?: string; rules: RtdbRulesJson; capturedState: unknown }>,
665
+ staging: NonNullable<VerifyFixtureOptions['staging']>,
666
+ ): Promise<VerifyEngineResult> {
667
+ const divergences: VerifyDivergence[] = [];
668
+ let checkedEvents = 0;
669
+ for (const check of checks) {
670
+ const tag = check.instance === undefined ? {} : { instance: check.instance };
671
+ try {
672
+ const { result } = await withStagingInstance(staging.client, staging.projectId, () =>
673
+ runStagingInstance({
674
+ client: staging.client,
675
+ events: fixture.events,
676
+ rules: check.rules,
677
+ capturedState: check.capturedState,
678
+ ...(check.instance === undefined ? {} : { instance: check.instance }),
679
+ ...(staging.signal ? { signal: staging.signal } : {}),
680
+ }), staging);
681
+ checkedEvents += result.checkedEvents;
682
+ divergences.push(...result.divergences.map((divergence) => ({ service: 'rtdb', ...divergence, ...tag })));
683
+ } catch (error) {
684
+ if (!(error instanceof StagingDeployRefusedError)) throw error;
685
+ divergences.push({ service: 'rtdb', kind: 'unsupported', reason: error.message, ...tag });
686
+ }
687
+ }
688
+ return { engine: 'staging', ok: divergences.every(isInformational), checkedEvents, divergences };
689
+ }
690
+
691
+ /**
692
+ * Where the sandbox and production decided a captured step differently: one
693
+ * engine reports `now-denied`, `now-allowed` or `unsupported` for a step and
694
+ * the other reports something else, or nothing. Steps are matched by their
695
+ * index in the replay both engines walk.
696
+ */
697
+ function addRtdbEngineDrift(sandbox: VerifyEngineResult, staging: VerifyEngineResult): void {
698
+ const decisive = new Set(['now-denied', 'now-allowed', 'unsupported']);
699
+ const keyOf = (divergence: VerifyDivergence) =>
700
+ `${divergence.instance ?? ''}|${(divergence as { step?: number }).step ?? `${(divergence as { method?: string }).method}|${divergence.path}`}`;
701
+ const byKey = (divergences: VerifyDivergence[]) =>
702
+ new Map(divergences.filter((divergence) => decisive.has(divergence.kind)).map((divergence) => [keyOf(divergence), divergence]));
703
+ const local = byKey(sandbox.divergences);
704
+ const remote = byKey(staging.divergences);
705
+ for (const key of new Set([...local.keys(), ...remote.keys()])) {
706
+ const sandboxKind = local.get(key)?.kind ?? 'unchanged';
707
+ const stagingKind = remote.get(key)?.kind ?? 'unchanged';
708
+ if (sandboxKind === stagingKind) continue;
709
+ const sample = (local.get(key) ?? remote.get(key))!;
710
+ staging.divergences.push({
711
+ service: 'rtdb',
712
+ kind: 'engine-drift',
713
+ path: sample.path,
714
+ method: (sample as { method?: string }).method,
715
+ sandbox: sandboxKind,
716
+ staging: stagingKind,
717
+ reason: `the sandbox reported ${sandboxKind}; production reported ${stagingKind}.`,
718
+ ...(sample.instance === undefined ? {} : { instance: sample.instance }),
719
+ ...((sample as { step?: number }).step === undefined ? {} : { step: (sample as { step?: number }).step }),
720
+ });
721
+ }
722
+ staging.ok = staging.divergences.every(isInformational);
723
+ }
724
+
625
725
  async function verifyStorage(
626
726
  fixture: PyricVerifyFixture,
627
727
  rules: VerifyRulesInput,
@@ -864,7 +964,7 @@ function normalizeEngines(input: VerifyEngine[] | undefined): VerifyEngine[] {
864
964
  const engines = input ?? ['sandbox'];
865
965
  const out: VerifyEngine[] = [];
866
966
  for (const engine of engines) {
867
- if (engine !== 'sandbox' && engine !== 'rulesTestApi') {
967
+ if (engine !== 'sandbox' && engine !== 'rulesTestApi' && engine !== 'staging') {
868
968
  throw new VerifyInputError(`unsupported verify engine: ${String(engine)}`);
869
969
  }
870
970
  if (!out.includes(engine)) out.push(engine);
@@ -881,7 +981,7 @@ function collectEngineDivergences(
881
981
  function serviceCheckedEvents(
882
982
  engines: Partial<Record<VerifyEngine, VerifyEngineResult>>,
883
983
  ): number {
884
- return engines.sandbox?.checkedEvents ?? engines.rulesTestApi?.checkedEvents ?? 0;
984
+ return engines.sandbox?.checkedEvents ?? engines.rulesTestApi?.checkedEvents ?? engines.staging?.checkedEvents ?? 0;
885
985
  }
886
986
 
887
987
  function mapUnsupportedEvent(event: VerifyUnsupportedEvent): VerifyDivergence {