pyric-admin 0.1.0-alpha.30.2 → 0.1.0-alpha.32

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.
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Replays the Admin SDK answers in the production capture
3
+ * `auth-request-auth-per-sign-in` (`behavior.admin`) through pyric-admin's
4
+ * in-process arm (`initializeApp({ sandbox })`).
5
+ *
6
+ * Each call's outcome is compared as `resolved`, or the error code with
7
+ * whether it threw synchronously. Every difference must be pinned with both
8
+ * answers and the finding; fixing a finding turns its pin red, and the fix
9
+ * deletes the pin.
10
+ */
11
+ import { afterEach, describe, expect, it } from 'bun:test';
12
+ import { readFileSync } from 'node:fs';
13
+ import { initializeSandbox } from 'pyric/sandbox';
14
+ import { createUserWithEmailAndPassword, getAuth as getClientAuth, sandbox as clientSandbox } from 'pyric/auth';
15
+ import { deleteApp, getApps, initializeApp } from '../app/index.js';
16
+ import { getAuth } from './index.js';
17
+
18
+ afterEach(async () => {
19
+ await Promise.all(getApps().map((app) => deleteApp(app)));
20
+ });
21
+
22
+ type Recorded = { resolved?: true; code?: string | null; threw?: string; rejected?: true };
23
+ const OBSERVATION = JSON.parse(readFileSync(new URL('../../../conformance/observations/auth/auth-request-auth-per-sign-in.json', import.meta.url), 'utf8')) as {
24
+ behavior: { admin: Record<string, Recorded> };
25
+ };
26
+
27
+ function summary(r: Recorded): string {
28
+ if (r.resolved) return 'resolved';
29
+ return `${r.threw === 'synchronously' ? 'throws' : 'rejects'} ${r.code ?? '(no code)'}`;
30
+ }
31
+ async function outcome(run: () => unknown): Promise<string> {
32
+ let promise: unknown;
33
+ try {
34
+ promise = run();
35
+ } catch (e) {
36
+ return `throws ${(e as { code?: string }).code ?? '(no code)'}`;
37
+ }
38
+ try {
39
+ await promise;
40
+ return 'resolved';
41
+ } catch (e) {
42
+ return `rejects ${(e as { code?: string }).code ?? '(no code)'}`;
43
+ }
44
+ }
45
+
46
+ /** Known differences, keyed by the observation's call name. */
47
+ const PINS: Record<string, { pyric: string; production: string; finding: string }> = {
48
+ createUserTakenEmail: { pyric: 'resolved', production: 'rejects auth/email-already-exists', finding: 'auth-claims AU-6: admin user-management errors are missing or use client codes' },
49
+ createUserTakenUid: { pyric: 'rejects (no code)', production: 'rejects auth/uid-already-exists', finding: 'auth-claims AU-6' },
50
+ createUserShortPassword: { pyric: 'resolved', production: 'rejects auth/invalid-password', finding: 'auth-claims AU-6' },
51
+ createUserInvalidEmail: { pyric: 'resolved', production: 'rejects auth/invalid-email', finding: 'auth-claims AU-6' },
52
+ getUserMissing: { pyric: 'rejects (no code)', production: 'rejects auth/user-not-found', finding: 'auth-claims AU-6' },
53
+ getUserByEmailMissing: { pyric: 'rejects (no code)', production: 'rejects auth/user-not-found', finding: 'auth-claims AU-6' },
54
+ getUserByEmailOtherCase: { pyric: 'rejects (no code)', production: 'resolved', finding: 'auth-claims CO-4: getUserByEmail matches the stored email case-sensitively' },
55
+ deleteUserMissing: { pyric: 'rejects (no code)', production: 'rejects auth/user-not-found', finding: 'auth-claims AU-6' },
56
+ setCustomUserClaimsMissing: { pyric: 'rejects (no code)', production: 'rejects auth/user-not-found', finding: 'auth-claims AU-6' },
57
+ setCustomUserClaims1100: { pyric: 'resolved', production: 'rejects auth/claims-too-large', finding: 'auth-claims AU-5: no 1000-character custom-claims limit' },
58
+ createCustomTokenEmptyUid: { pyric: 'resolved', production: 'throws auth/argument-error', finding: 'auth-claims AU-4: createCustomToken validates neither uid nor claims' },
59
+ createCustomToken129Uid: { pyric: 'resolved', production: 'throws auth/argument-error', finding: 'auth-claims AU-4' },
60
+ createCustomTokenReservedAud: { pyric: 'resolved', production: 'throws auth/argument-error', finding: 'auth-claims AU-4' },
61
+ createCustomTokenNullClaims: { pyric: 'resolved', production: 'throws auth/argument-error', finding: 'auth-claims AU-4' },
62
+ verifyIdTokenOfCustomToken: { pyric: 'resolved', production: 'rejects auth/argument-error', finding: 'auth-claims AU-9: verifyIdToken accepts a custom token' },
63
+ verifyIdTokenCheckRevokedDisabled: { pyric: 'resolved', production: 'rejects auth/user-disabled', finding: 'auth-claims AU-10: checkRevoked ignores disabled and deleted users' },
64
+ verifyIdTokenCheckRevokedDeleted: { pyric: 'resolved', production: 'rejects auth/user-not-found', finding: 'auth-claims AU-10' },
65
+ };
66
+
67
+ describe('pyric-admin auth answers as the production Admin SDK does', () => {
68
+ it('user management, custom tokens, custom claims and verifyIdToken', async () => {
69
+ const sandbox = initializeSandbox();
70
+ const admin = getAuth(initializeApp({ sandbox }, 'oracle-admin-answers'));
71
+ const email = 'ad@example.com';
72
+ const uid = 'oracle-ad';
73
+ await admin.createUser({ uid, email, password: 'secret-pw' });
74
+
75
+ // A client session on the same sandbox, for verifyIdToken.
76
+ const client = getClientAuth(sandbox);
77
+ const { user } = await createUserWithEmailAndPassword(client, 'verify@example.com', 'secret-pw');
78
+ const idToken = await user.getIdToken();
79
+
80
+ const observed: Record<string, string> = {
81
+ createUserTakenEmail: await outcome(() => admin.createUser({ email, password: 'secret-pw' })),
82
+ createUserTakenUid: await outcome(() => admin.createUser({ uid, password: 'secret-pw' })),
83
+ createUserShortPassword: await outcome(() => admin.createUser({ uid: 'oracle-short', password: '12345' })),
84
+ createUserInvalidEmail: await outcome(() => admin.createUser({ uid: 'oracle-bad', email: 'nope' })),
85
+ getUserMissing: await outcome(() => admin.getUser('oracle-missing')),
86
+ getUserByEmailMissing: await outcome(() => admin.getUserByEmail('oracle-missing@example.com')),
87
+ getUserByEmailOtherCase: await outcome(() => admin.getUserByEmail(email.toUpperCase())),
88
+ deleteUserMissing: await outcome(() => admin.deleteUser('oracle-missing')),
89
+ setCustomUserClaimsMissing: await outcome(() => admin.setCustomUserClaims('oracle-missing', { a: 1 })),
90
+ setCustomUserClaims1100: await outcome(() => admin.setCustomUserClaims(uid, { blob: 'x'.repeat(1100) })),
91
+ setCustomUserClaimsReserved: await outcome(() => admin.setCustomUserClaims(uid, { sub: 'x' })),
92
+ createCustomTokenEmptyUid: await outcome(() => admin.createCustomToken('')),
93
+ createCustomToken129Uid: await outcome(() => admin.createCustomToken('x'.repeat(129))),
94
+ createCustomToken128Uid: await outcome(() => admin.createCustomToken('x'.repeat(128))),
95
+ createCustomTokenReservedAud: await outcome(() => admin.createCustomToken('u', { aud: 'x' })),
96
+ createCustomTokenReservedFirebase: await outcome(() => admin.createCustomToken('u', { firebase: {} })),
97
+ createCustomTokenNullClaims: await outcome(() => admin.createCustomToken('u', null as never)),
98
+ verifyIdTokenOfCustomToken: await outcome(async () => admin.verifyIdToken(await admin.createCustomToken('u'))),
99
+ };
100
+ clientSandbox.updateUser(client, user.uid, { disabled: true });
101
+ observed.verifyIdTokenNoCheckDisabled = await outcome(() => admin.verifyIdToken(idToken));
102
+ observed.verifyIdTokenCheckRevokedDisabled = await outcome(() => admin.verifyIdToken(idToken, true));
103
+ clientSandbox.deleteUser(client, user.uid);
104
+ observed.verifyIdTokenCheckRevokedDeleted = await outcome(() => admin.verifyIdToken(idToken, true));
105
+
106
+ const found: Record<string, { pyric: string; production: string }> = {};
107
+ for (const [call, pyric] of Object.entries(observed)) {
108
+ const production = summary(OBSERVATION.behavior.admin[call]);
109
+ if (pyric !== production) found[call] = { pyric, production };
110
+ }
111
+ const pinned = Object.fromEntries(Object.entries(PINS).map(([call, pin]) => [call, { pyric: pin.pyric, production: pin.production }]));
112
+ expect(found).toEqual(pinned);
113
+ });
114
+ });
@@ -0,0 +1,42 @@
1
+ /**
2
+ * `verifyIdToken` on a client ID token returns the token's own `sub`, `aud`,
3
+ * `iss`, `iat`, `exp` and `auth_time`, not values made up at verify time
4
+ * (auth-claims AU-7). A server's recent-sign-in check reads `auth_time`
5
+ * (firebase.google.com/docs/auth/admin/manage-cookies: "the auth_time should
6
+ * be checked"), and production keeps it at the sign-in across refreshes
7
+ * (firebase-admin `token-verifier.d.ts`: "the same auth_time value"; capture
8
+ * `admin.verifyIdTokenDecoded.authTimeEqualsToken: true`).
9
+ */
10
+ import { afterEach, describe, expect, it } from 'bun:test';
11
+ import { initializeSandbox } from 'pyric/sandbox';
12
+ import { getClock } from 'pyric/sandbox/internal';
13
+ import { createUserWithEmailAndPassword, getAuth as getClientAuth } from 'pyric/auth';
14
+ import { deleteApp, getApps, initializeApp } from '../app/index.js';
15
+ import { getAuth } from './index.js';
16
+
17
+ afterEach(async () => {
18
+ await Promise.all(getApps().map((app) => deleteApp(app)));
19
+ });
20
+
21
+ describe('verifyIdToken returns the client token claims', () => {
22
+ it('keeps auth_time at the sign-in and iat at the refresh', async () => {
23
+ const sandbox = initializeSandbox();
24
+ const admin = getAuth(initializeApp({ sandbox }, 'verify-id-token-claims'));
25
+ const client = getClientAuth(sandbox);
26
+ // Move the sandbox clock away from the wall clock, so a value made up at
27
+ // verify time cannot match by accident.
28
+ getClock(sandbox).advance(60 * 60 * 1000);
29
+ const { user } = await createUserWithEmailAndPassword(client, 'ada@example.com', 'secret-pw');
30
+ getClock(sandbox).advance(10 * 60 * 1000);
31
+ const idToken = await user.getIdToken(true);
32
+ const { claims } = await user.getIdTokenResult();
33
+
34
+ const decoded = await admin.verifyIdToken(idToken);
35
+
36
+ for (const key of ['sub', 'aud', 'iss', 'iat', 'exp', 'auth_time'] as const) {
37
+ expect(decoded[key]).toEqual(claims[key]);
38
+ }
39
+ expect(decoded.uid).toBe(user.uid);
40
+ expect(decoded.iat - decoded.auth_time).toBe(600);
41
+ });
42
+ });
@@ -6,7 +6,12 @@
6
6
  * an in-memory RTDB implementing this load-bearing data-plane subset:
7
7
  *
8
8
  * - `Database.ref(path?)` returns a {@link Reference}.
9
- * - `Reference#set(value)` writes into the in-memory tree.
9
+ * - `Reference#set(value)` writes into the in-memory tree, normalized
10
+ * as the host normalizes it: arrays become integer-keyed children,
11
+ * null children and empty objects are dropped, and `val()` renders
12
+ * integer keys back as an array (`ChildrenNode.val`, #1121).
13
+ * Inline `.priority` keys are dropped, since priorities are not
14
+ * modeled here.
10
15
  * - `Reference#get()` reads; returns a `DataSnapshot`-shaped
11
16
  * `{ exists(), val(), key, child(), forEach(), … }`.
12
17
  * - `Reference#update(values)` merges children (a `null` value
@@ -98,7 +103,17 @@ import {
98
103
  } from '../app/index.js';
99
104
  import { assertAdminAppActive } from '../app/lifecycle.js';
100
105
  import { increment, serverTimestamp } from 'pyric/database';
101
- import { assertGettablePath, valueAtChildPath, valueChildren } from 'pyric/database/internal';
106
+ import {
107
+ assertGettablePath,
108
+ coerceArrays,
109
+ errorPrefix,
110
+ inlinePriorities,
111
+ normalizeNode,
112
+ validateUpdate,
113
+ validateWrite,
114
+ valueAtChildPath,
115
+ valueChildren,
116
+ } from 'pyric/database/internal';
102
117
  import { deferredExport } from 'pyric/app/internal';
103
118
 
104
119
  /** `firebase-admin/database`'s `enableLogging`. The sandbox does not model
@@ -422,19 +437,9 @@ function joinPath(segments: string[]): string {
422
437
  return '/' + segments.join('/');
423
438
  }
424
439
 
425
- /** Deep-clone a JSON value so stored state doesn't share identity with
426
- * caller-held references. */
427
- function cloneJson<T extends JsonValue>(v: T): T {
428
- if (v === null || typeof v !== 'object') return v;
429
- if (Array.isArray(v)) return v.map((x) => cloneJson(x as JsonValue)) as T;
430
- const out: Record<string, JsonValue> = {};
431
- for (const [k, val] of Object.entries(v as Record<string, JsonValue>)) {
432
- out[k] = cloneJson(val);
433
- }
434
- return out as T;
435
- }
436
-
437
- /** Read the value at `path` in `root`. `null` for absent paths. */
440
+ /** Read the stored value at `path` in `root`: integer-keyed children stay
441
+ * object keys. `null` for absent paths. The result is a fresh copy, sharing
442
+ * nothing with the stored tree. */
438
443
  function readPath(root: Record<string, JsonValue>, path: string): JsonValue {
439
444
  const segs = pathSegments(path);
440
445
  let node: JsonValue = root;
@@ -448,10 +453,11 @@ function readPath(root: Record<string, JsonValue>, path: string): JsonValue {
448
453
  if (!Object.hasOwn(obj, seg)) return null;
449
454
  node = obj[seg]!;
450
455
  }
451
- return cloneJson(node);
456
+ return structuredClone(node);
452
457
  }
453
458
 
454
- /** Write `value` at `path`. `null` deletes. Trims empty ancestor objects. */
459
+ /** Write a value {@link normalizeNode} built at `path`. `null` deletes.
460
+ * Trims empty ancestor objects. */
455
461
  function writePath(
456
462
  root: Record<string, JsonValue>,
457
463
  path: string,
@@ -467,7 +473,7 @@ function writePath(
467
473
  'pyric-admin/database sandbox: root write must be an object (or null to clear).',
468
474
  );
469
475
  }
470
- Object.assign(root, cloneJson(value) as Record<string, JsonValue>);
476
+ Object.assign(root, value);
471
477
  return;
472
478
  }
473
479
  // Walk to parent, creating intermediate objects as needed.
@@ -495,7 +501,7 @@ function writePath(
495
501
  delete cursor[lastKey];
496
502
  trimEmptyAncestors(root, segs);
497
503
  } else {
498
- cursor[lastKey] = cloneJson(value);
504
+ cursor[lastKey] = value;
499
505
  }
500
506
  }
501
507
 
@@ -538,6 +544,52 @@ function trimEmptyAncestors(
538
544
  }
539
545
  }
540
546
 
547
+ // ─── Write validation (both arms) ────────────────────────────────────
548
+ //
549
+ // firebase-admin validates a write before it is sent and throws the error
550
+ // synchronously, so both arms do the same here, with the SDK's text
551
+ // (`validateFirebaseDataArg`, `validateFirebaseMergeDataArg`). Both arms
552
+ // then write the value as RTDB stores it (`normalizeNode`): the remote arm
553
+ // relays that, not the raw value, so a value JSON would carry differently
554
+ // (a `Date` becomes a string) is written as firebase-admin writes it.
555
+ //
556
+ // Priorities are not modeled on either arm: a valid priority, inline or
557
+ // under an `update()` key ending in `.priority`, is accepted and dropped.
558
+
559
+ /** The value a `set` or `push` writes, as RTDB stores it, after throwing the
560
+ * SDK's error for a value it refuses. `segs` is the reference's path: for
561
+ * `push`, the parent's. */
562
+ function validateValueArg(fnName: 'set' | 'push', segs: string[], value: unknown): JsonValue {
563
+ validateWrite(value, joinPath(segs), errorPrefix(fnName, 'value'));
564
+ inlinePriorities(value);
565
+ return normalizeNode(value);
566
+ }
567
+
568
+ /** Whether an `update()` key addresses a priority, as `a/.priority` does. */
569
+ function isPriorityKey(key: string): boolean {
570
+ const segments = pathSegments(key);
571
+ return segments[segments.length - 1] === '.priority';
572
+ }
573
+
574
+ /** The patch an `update` writes, each value as RTDB stores it and priority
575
+ * keys dropped, after throwing the SDK's error for values it refuses. */
576
+ function validateValuesArg(segs: string[], values: unknown): Record<string, JsonValue> {
577
+ const entries = Object.entries(validateUpdate(values, joinPath(segs)));
578
+ for (const [, value] of entries) inlinePriorities(value);
579
+ const dataEntries = entries.filter(([key]) => !isPriorityKey(key));
580
+ return Object.fromEntries(dataEntries.map(([key, value]) => [key, normalizeNode(value)]));
581
+ }
582
+
583
+ /** Run a synchronous in-memory write as a promise that settles with it. */
584
+ function settled(write: () => void): Promise<void> {
585
+ try {
586
+ write();
587
+ return Promise.resolve();
588
+ } catch (error) {
589
+ return Promise.reject(error);
590
+ }
591
+ }
592
+
541
593
  // ─── Push-id generator ────────────────────────────────────────────────
542
594
  //
543
595
  // Lifted from `pyric/database/sandbox/push-id.ts` — the algorithm
@@ -633,9 +685,11 @@ function buildSandboxRef(
633
685
 
634
686
  // ─── Data-plane methods (implemented) ────────────────────────────
635
687
 
636
- /** Set `value` at this path. `null` deletes. */
637
- async set(value: unknown): Promise<void> {
638
- writePath(state.root, canonical, value as JsonValue);
688
+ /** Set `value` at this path. `null` deletes. A value the SDK refuses
689
+ * throws synchronously, as firebase-admin does. */
690
+ set(value: unknown): Promise<void> {
691
+ const stored = validateValueArg('set', segs, value);
692
+ return settled(() => writePath(state.root, canonical, stored));
639
693
  },
640
694
 
641
695
  /** Read this path. Resolves to a {@link DataSnapshot}-shaped value. */
@@ -663,22 +717,20 @@ function buildSandboxRef(
663
717
  },
664
718
 
665
719
  /** Shallow merge: each key in `values` replaces the corresponding
666
- * child at this path. `null` values delete. */
667
- async update(values: object): Promise<void> {
668
- if (values === null || typeof values !== 'object') {
669
- throw new TypeError(
670
- 'pyric-admin/database sandbox: update expected an object.',
671
- );
672
- }
673
- for (const [k, v] of Object.entries(values as Record<string, unknown>)) {
674
- const subSegs = [...segs, ...pathSegments(k)];
675
- writePath(state.root, joinPath(subSegs), v as JsonValue);
676
- }
720
+ * child at this path. `null` values delete. Values the SDK refuses
721
+ * throw synchronously, as firebase-admin does. */
722
+ update(values: object): Promise<void> {
723
+ const patch = validateValuesArg(segs, values);
724
+ return settled(() => {
725
+ for (const [k, v] of Object.entries(patch)) {
726
+ writePath(state.root, joinPath([...segs, ...pathSegments(k)]), v);
727
+ }
728
+ });
677
729
  },
678
730
 
679
731
  /** Delete the subtree at this path. Equivalent to `set(null)`. */
680
- async remove(): Promise<void> {
681
- writePath(state.root, canonical, null);
732
+ remove(): Promise<void> {
733
+ return settled(() => writePath(state.root, canonical, null));
682
734
  },
683
735
 
684
736
  /** Mint a 20-char push id, optionally writing `value` at the new
@@ -687,11 +739,12 @@ function buildSandboxRef(
687
739
  * once the (synchronous) write completes; the underlying ref is
688
740
  * available synchronously via the returned object's own methods. */
689
741
  push(value?: unknown, onComplete?: (err: Error | null) => void): AdminThenableReference {
742
+ const stored = value === undefined ? undefined : validateValueArg('push', segs, value);
690
743
  const id = generatePushId();
691
744
  const childPath = joinPath([...segs, id]);
692
- if (value !== undefined) {
745
+ if (stored !== undefined) {
693
746
  try {
694
- writePath(state.root, childPath, value as JsonValue);
747
+ writePath(state.root, childPath, stored);
695
748
  } catch (err) {
696
749
  if (onComplete) onComplete(err as Error);
697
750
  throw err;
@@ -813,18 +866,28 @@ function buildSandboxRef(
813
866
  * the load-bearing subset of firebase-admin's `DataSnapshot` shape.
814
867
  * Backend-agnostic: the snapshot is a pure (path, value) view; `refAt`
815
868
  * supplies backend-appropriate `Reference`s (local tree or remote), so
816
- * the local and remote arms share one snapshot implementation. */
869
+ * the local and remote arms share one snapshot implementation.
870
+ *
871
+ * `stored` is the value as RTDB stores it, children by key, with no
872
+ * arrays. `val()` renders integer keys as an array when they are dense
873
+ * enough; `exportVal()` and `toJSON()` never do (`ChildrenNode.val`
874
+ * builds an array only when not exporting). `exported`, when given, is the
875
+ * host's export of the same node, priorities included. */
817
876
  function buildSandboxSnap(
818
877
  refAt: (path: string) => AdminReference,
819
878
  path: string,
820
- val: JsonValue,
879
+ stored: JsonValue,
880
+ exported?: unknown,
821
881
  ): AdminDataSnapshot {
822
882
  const segs = pathSegments(path);
823
883
  const key = segs.length === 0 ? null : segs[segs.length - 1]!;
824
- const exists = val !== null;
825
- // An array value still has children: `val()` renders integer keys as an
826
- // array, and the SDK iterates them as '0', '1', ... (#1110).
827
- const children = valueChildren(val);
884
+ const exists = stored !== null;
885
+ // A node `val()` renders as an array still has its children, keyed
886
+ // '0', '1', ... in index order (#1110).
887
+ const children = valueChildren(stored);
888
+ const val = coerceArrays(stored);
889
+ const exportedChild = (childSegs: readonly string[]): unknown =>
890
+ exported === undefined ? undefined : valueAtChildPath(exported, childSegs);
828
891
  const snap = {
829
892
  key,
830
893
  get ref(): AdminReference {
@@ -838,8 +901,8 @@ function buildSandboxSnap(
838
901
  },
839
902
  child(p: string): AdminDataSnapshot {
840
903
  const childSegs = pathSegments(p);
841
- const childVal = valueAtChildPath(val, childSegs) as JsonValue;
842
- return buildSandboxSnap(refAt, joinPath([...segs, ...childSegs]), childVal);
904
+ const childVal = valueAtChildPath(stored, childSegs) as JsonValue;
905
+ return buildSandboxSnap(refAt, joinPath([...segs, ...childSegs]), childVal, exportedChild(childSegs));
843
906
  },
844
907
  hasChild(p: string): boolean {
845
908
  return snap.child(p).exists();
@@ -852,13 +915,13 @@ function buildSandboxSnap(
852
915
  },
853
916
  forEach(cb: (child: AdminDataSnapshot) => boolean | void): boolean {
854
917
  for (const { key: childKey, value } of children) {
855
- const childSnap = buildSandboxSnap(refAt, joinPath([...segs, childKey]), value as JsonValue);
918
+ const childSnap = buildSandboxSnap(refAt, joinPath([...segs, childKey]), value as JsonValue, exportedChild([childKey]));
856
919
  if (cb(childSnap) === true) return true;
857
920
  }
858
921
  return false;
859
922
  },
860
923
  toJSON(): unknown {
861
- return val;
924
+ return snap.exportVal();
862
925
  },
863
926
  // RTDB priority isn't modeled — return `null`, matching the SDK
864
927
  // default for a node without an explicit priority.
@@ -866,9 +929,9 @@ function buildSandboxSnap(
866
929
  return null;
867
930
  },
868
931
  exportVal(): unknown {
869
- // No priorities → `exportVal()` matches `val()`. The SDK's
870
- // exportVal includes `.priority` when set; we have none.
871
- return val;
932
+ // The SDK's export format adds `.priority` where one is set. The
933
+ // in-process arm models no priorities, so it is the stored value.
934
+ return structuredClone(exported === undefined ? stored : exported);
872
935
  },
873
936
  };
874
937
  return snap as unknown as AdminDataSnapshot;
@@ -895,6 +958,8 @@ interface RemoteWireSnapshot {
895
958
  key: string | null;
896
959
  exists: boolean;
897
960
  value: unknown;
961
+ /** `exportVal()` on the host, priorities included. */
962
+ exportValue?: unknown;
898
963
  size: number;
899
964
  }
900
965
 
@@ -971,8 +1036,12 @@ function buildRemoteRef(
971
1036
  const segs = pathSegments(canonical);
972
1037
  const key = segs.length === 0 ? null : segs[segs.length - 1]!;
973
1038
  const refAt = (p: string): AdminReference => buildRemoteRef(state, db, p);
1039
+ // The host sends `val()`, arrays included; storing it again by key
1040
+ // recovers the stored value (an array's nulls are holes, not children).
1041
+ // `exportValue` carries the priorities another plane set; this arm writes
1042
+ // none, but reads them back as the host has them.
974
1043
  const snapFromWire = (wire: RemoteWireSnapshot): AdminDataSnapshot =>
975
- buildSandboxSnap(refAt, canonical, (wire.value ?? null) as JsonValue);
1044
+ buildSandboxSnap(refAt, canonical, normalizeNode(wire.value ?? null), wire.exportValue);
976
1045
 
977
1046
  const ref = {
978
1047
  key,
@@ -992,13 +1061,14 @@ function buildRemoteRef(
992
1061
 
993
1062
  // ─── Data-plane methods (relayed worker ops) ─────────────────────
994
1063
 
995
- /** Set `value` at this path. `null` deletes. Relays `rtdb.set`. */
996
- async set(value: unknown): Promise<void> {
997
- await relayRtdbOp(state, {
1064
+ /** Set `value` at this path. `null` deletes. Relays `rtdb.set`. A
1065
+ * value the SDK refuses throws synchronously and is not relayed. */
1066
+ set(value: unknown): Promise<void> {
1067
+ return relayRtdbOp(state, {
998
1068
  method: 'rtdb.set',
999
1069
  path: canonical,
1000
- value: value ?? null,
1001
- });
1070
+ value: validateValueArg('set', segs, value),
1071
+ }).then(() => undefined);
1002
1072
  },
1003
1073
 
1004
1074
  /** Read this path (`rtdb.get`). Resolves to a `DataSnapshot`. */
@@ -1047,18 +1117,15 @@ function buildRemoteRef(
1047
1117
 
1048
1118
  /** Relays `rtdb.update` — the worker applies the FULL multi-path
1049
1119
  * update semantics (`pyric/database`), an upgrade over the
1050
- * local arm's shallow per-key merge. `null` values delete. */
1051
- async update(values: object): Promise<void> {
1052
- if (values === null || typeof values !== 'object') {
1053
- throw new TypeError(
1054
- 'pyric-admin/database sandbox: update expected an object.',
1055
- );
1056
- }
1057
- await relayRtdbOp(state, {
1120
+ * local arm's shallow per-key merge. `null` values delete. Values the
1121
+ * SDK refuses throw synchronously and are not relayed. */
1122
+ update(values: object): Promise<void> {
1123
+ const patch = validateValuesArg(segs, values);
1124
+ return relayRtdbOp(state, {
1058
1125
  method: 'rtdb.update',
1059
1126
  path: canonical,
1060
- values: values as Record<string, unknown>,
1061
- });
1127
+ values: patch,
1128
+ }).then(() => undefined);
1062
1129
  },
1063
1130
 
1064
1131
  /** Delete the subtree at this path (`rtdb.remove`). */
@@ -1079,12 +1146,13 @@ function buildRemoteRef(
1079
1146
  * rejects the thenable and reaches `onComplete`.
1080
1147
  */
1081
1148
  push(value?: unknown, onComplete?: (err: Error | null) => void): AdminThenableReference {
1149
+ const stored = value === undefined ? undefined : validateValueArg('push', segs, value);
1082
1150
  const id = generatePushId();
1083
1151
  const childPath = joinPath([...segs, id]);
1084
1152
  const write: Promise<void> =
1085
- value === undefined
1153
+ stored === undefined
1086
1154
  ? Promise.resolve()
1087
- : relayRtdbOp(state, { method: 'rtdb.push', path: canonical, key: id, value })
1155
+ : relayRtdbOp(state, { method: 'rtdb.push', path: canonical, key: id, value: stored })
1088
1156
  .then(() => undefined);
1089
1157
  // Surface completion without forcing the caller to await: `.then`'s
1090
1158
  // rejection handler also keeps a fire-and-forget push from becoming
@@ -7,7 +7,7 @@
7
7
  * started. With `force`, every file is tried, and the call rejects with the
8
8
  * array of errors when any failed, as the library's callback receives them.
9
9
  */
10
- import { everyListedName, type DeleteFilesOptions } from './file-query.js';
10
+ import { entryOf, everyListedName, resumesAfter, type DeleteFilesOptions } from './file-query.js';
11
11
 
12
12
  /** How many deletes `@google-cloud/storage` runs at once. */
13
13
  const PARALLEL_DELETES = 10;
@@ -38,3 +38,22 @@ export async function deleteListedFiles(
38
38
  if (!force) throw errors[0];
39
39
  throw errors;
40
40
  }
41
+
42
+ /**
43
+ * A stop rule for names arriving in name order: true once they hold the
44
+ * `maxResults` files `deleteFiles(query)` deletes. Without `maxResults` it
45
+ * deletes every listed file, so it needs every name.
46
+ */
47
+ export function deletedNamesFound(query: DeleteFilesOptions): (name: string) => boolean {
48
+ const maxResults = query.maxResults ?? 0;
49
+ const limited = maxResults > 0;
50
+ if (!limited) return () => false;
51
+ const after = resumesAfter(query);
52
+ let found = 0;
53
+ return (name) => {
54
+ const entry = entryOf(name, query);
55
+ const listedFile = entry !== undefined && !entry.rolledUp && (after === undefined || entry.key > after);
56
+ if (listedFile) found += 1;
57
+ return found >= maxResults;
58
+ };
59
+ }
@@ -64,7 +64,7 @@ export function readsEveryPage(query: GetFilesOptions): boolean {
64
64
  /** The page of `names` that `query` asks for, `pageSize` entries at most. */
65
65
  export function listingPage(names: readonly string[], query: GetFilesOptions, pageSize = pageSizeOf(query)): ListingPage {
66
66
  const entries = entriesOf(names, query);
67
- const after = query.pageToken === undefined ? undefined : decodeToken(query.pageToken);
67
+ const after = resumesAfter(query);
68
68
  const remaining = after === undefined ? entries : entries.filter(entry => entry.key > after);
69
69
  const page = remaining.slice(0, pageSize);
70
70
  const more = remaining.length > page.length;
@@ -93,34 +93,46 @@ export function apiResponseOf(page: ListingPage): GetFilesApiResponse {
93
93
  };
94
94
  }
95
95
 
96
- function pageSizeOf(query: GetFilesOptions): number {
96
+ /** The entry key a query's `pageToken` resumes after, if it has one. */
97
+ export function resumesAfter(query: GetFilesOptions): string | undefined {
98
+ return query.pageToken === undefined ? undefined : decodeToken(query.pageToken);
99
+ }
100
+
101
+ export function pageSizeOf(query: GetFilesOptions): number {
97
102
  const limited = typeof query.maxResults === 'number' && query.maxResults > 0;
98
103
  return limited ? Math.min(query.maxResults!, DEFAULT_PAGE_SIZE) : DEFAULT_PAGE_SIZE;
99
104
  }
100
105
 
101
- interface Entry {
106
+ /** A listed entry: a file name, or a prefix that names roll up to at the delimiter. */
107
+ export interface Entry {
102
108
  key: string;
103
109
  rolledUp: boolean;
104
110
  }
105
111
 
106
- /** The names under the prefix, with the delimiter's roll-up applied, in name order. */
107
- function entriesOf(names: readonly string[], query: GetFilesOptions): Entry[] {
112
+ /** The entry `name` lists as under `query`'s prefix and delimiter, or `undefined` outside the prefix. */
113
+ export function entryOf(name: string, query: GetFilesOptions): Entry | undefined {
108
114
  const prefix = query.prefix ?? '';
109
115
  const delimiter = query.delimiter ?? '';
116
+ if (!name.startsWith(prefix)) return undefined;
117
+ const rest = name.slice(prefix.length);
118
+ const cut = delimiter === '' ? -1 : rest.indexOf(delimiter);
119
+ const rolledUp = cut !== -1;
120
+ const key = rolledUp ? prefix + rest.slice(0, cut + delimiter.length) : name;
121
+ return { key, rolledUp };
122
+ }
123
+
124
+ /** The names under the prefix, with the delimiter's roll-up applied, in name order. */
125
+ function entriesOf(names: readonly string[], query: GetFilesOptions): Entry[] {
110
126
  const byKey = new Map<string, Entry>();
111
127
  for (const name of names) {
112
- if (!name.startsWith(prefix)) continue;
113
- const rest = name.slice(prefix.length);
114
- const cut = delimiter === '' ? -1 : rest.indexOf(delimiter);
115
- const rolledUp = cut !== -1;
116
- const key = rolledUp ? prefix + rest.slice(0, cut + delimiter.length) : name;
117
- byKey.set(key, { key, rolledUp });
128
+ const entry = entryOf(name, query);
129
+ if (entry !== undefined) byKey.set(entry.key, entry);
118
130
  }
119
131
  return [...byKey.values()].sort((a, b) => compareKeys(a.key, b.key));
120
132
  }
121
133
 
122
134
  /** Name order, as the JSON API lists objects. */
123
- function compareKeys(a: string, b: string): number {
135
+ export function compareKeys(a: string, b: string): number {
124
136
  if (a < b) return -1;
125
137
  if (a > b) return 1;
126
138
  return 0;
@@ -10,8 +10,11 @@
10
10
  */
11
11
  import {
12
12
  apiResponseOf,
13
+ entryOf,
13
14
  listingPage,
15
+ pageSizeOf,
14
16
  readsEveryPage,
17
+ resumesAfter,
15
18
  type GetFilesApiResponse,
16
19
  type GetFilesOptions,
17
20
  } from './file-query.js';
@@ -38,3 +41,25 @@ function onePage<F>(names: readonly string[], query: GetFilesOptions, fileOf: (n
38
41
  const nextQuery = token === undefined ? null : { ...query, pageToken: token };
39
42
  return [page.names.map(fileOf), nextQuery, apiResponseOf(page)];
40
43
  }
44
+
45
+ /**
46
+ * A stop rule for names arriving in name order: true once they hold every
47
+ * entry the result for `query` needs. A single page needs one entry past its
48
+ * size, to know there is a next page; reading every page needs every name.
49
+ */
50
+ export function pageNamesFound(query: GetFilesOptions): (name: string) => boolean {
51
+ if (readsEveryPage(query)) return () => false;
52
+ const after = resumesAfter(query);
53
+ const wanted = pageSizeOf(query) + 1;
54
+ let found = 0;
55
+ let lastKey: string | undefined;
56
+ return (name) => {
57
+ const key = entryOf(name, query)?.key;
58
+ const listed = key !== undefined && (after === undefined || key > after);
59
+ const isNewEntry = listed && key !== lastKey;
60
+ if (!isNewEntry) return false;
61
+ lastKey = key;
62
+ found += 1;
63
+ return found >= wanted;
64
+ };
65
+ }