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.
- package/dist/database/index.d.ts +6 -1
- package/dist/database/index.d.ts.map +1 -1
- package/dist/database/index.js +119 -65
- package/dist/database/index.js.map +1 -1
- package/dist/storage/delete-files.d.ts +6 -0
- package/dist/storage/delete-files.d.ts.map +1 -1
- package/dist/storage/delete-files.js +21 -1
- package/dist/storage/delete-files.js.map +1 -1
- package/dist/storage/file-query.d.ts +12 -0
- package/dist/storage/file-query.d.ts.map +1 -1
- package/dist/storage/file-query.js +22 -12
- package/dist/storage/file-query.js.map +1 -1
- package/dist/storage/get-files.d.ts +6 -0
- package/dist/storage/get-files.d.ts.map +1 -1
- package/dist/storage/get-files.js +24 -1
- package/dist/storage/get-files.js.map +1 -1
- package/dist/storage/index.d.ts.map +1 -1
- package/dist/storage/index.js +11 -9
- package/dist/storage/index.js.map +1 -1
- package/dist/storage/remote-names.d.ts +28 -4
- package/dist/storage/remote-names.d.ts.map +1 -1
- package/dist/storage/remote-names.js +125 -13
- package/dist/storage/remote-names.js.map +1 -1
- package/package.json +2 -2
- package/src/auth/oracle-admin-answers.test.ts +114 -0
- package/src/auth/verify-id-token-claims.test.ts +42 -0
- package/src/database/index.ts +136 -68
- package/src/storage/delete-files.ts +20 -1
- package/src/storage/file-query.ts +24 -12
- package/src/storage/get-files.ts +25 -0
- package/src/storage/index.ts +11 -9
- package/src/storage/remote-names.ts +144 -13
|
@@ -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
|
+
});
|
package/src/database/index.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
/**
|
|
426
|
-
*
|
|
427
|
-
|
|
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
|
|
456
|
+
return structuredClone(node);
|
|
452
457
|
}
|
|
453
458
|
|
|
454
|
-
/** Write
|
|
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,
|
|
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] =
|
|
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
|
-
|
|
638
|
-
|
|
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
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
)
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
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
|
-
|
|
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 (
|
|
745
|
+
if (stored !== undefined) {
|
|
693
746
|
try {
|
|
694
|
-
writePath(state.root, childPath,
|
|
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
|
-
|
|
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 =
|
|
825
|
-
//
|
|
826
|
-
//
|
|
827
|
-
const children = valueChildren(
|
|
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(
|
|
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
|
|
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
|
-
//
|
|
870
|
-
//
|
|
871
|
-
return
|
|
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)
|
|
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
|
-
|
|
997
|
-
|
|
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:
|
|
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
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
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:
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
107
|
-
function
|
|
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
|
-
|
|
113
|
-
|
|
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;
|
package/src/storage/get-files.ts
CHANGED
|
@@ -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
|
+
}
|