pyric-admin 0.1.0-alpha.24 → 0.1.0-alpha.26

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (128) hide show
  1. package/README.md +1 -1
  2. package/dist/admin.d.ts +64 -12
  3. package/dist/admin.d.ts.map +1 -1
  4. package/dist/admin.js +53 -8
  5. package/dist/admin.js.map +1 -1
  6. package/dist/app/index.d.ts +14 -1
  7. package/dist/app/index.d.ts.map +1 -1
  8. package/dist/app/index.js +58 -16
  9. package/dist/app/index.js.map +1 -1
  10. package/dist/app-check/index.d.ts +13 -1
  11. package/dist/app-check/index.d.ts.map +1 -1
  12. package/dist/app-check/index.js +14 -9
  13. package/dist/app-check/index.js.map +1 -1
  14. package/dist/auth/index.d.ts.map +1 -1
  15. package/dist/auth/index.js +11 -0
  16. package/dist/auth/index.js.map +1 -1
  17. package/dist/data-connect/index.d.ts +23 -1
  18. package/dist/data-connect/index.d.ts.map +1 -1
  19. package/dist/data-connect/index.js +44 -7
  20. package/dist/data-connect/index.js.map +1 -1
  21. package/dist/database/index.d.ts +24 -19
  22. package/dist/database/index.d.ts.map +1 -1
  23. package/dist/database/index.js +133 -99
  24. package/dist/database/index.js.map +1 -1
  25. package/dist/deferred.d.ts +37 -13
  26. package/dist/deferred.d.ts.map +1 -1
  27. package/dist/deferred.js +57 -15
  28. package/dist/deferred.js.map +1 -1
  29. package/dist/eventarc/index.d.ts +13 -1
  30. package/dist/eventarc/index.d.ts.map +1 -1
  31. package/dist/eventarc/index.js +15 -9
  32. package/dist/eventarc/index.js.map +1 -1
  33. package/dist/extensions/index.d.ts +13 -1
  34. package/dist/extensions/index.d.ts.map +1 -1
  35. package/dist/extensions/index.js +15 -9
  36. package/dist/extensions/index.js.map +1 -1
  37. package/dist/firestore/index.d.ts +6 -0
  38. package/dist/firestore/index.d.ts.map +1 -1
  39. package/dist/firestore/index.js +25 -3
  40. package/dist/firestore/index.js.map +1 -1
  41. package/dist/functions/index.d.ts +13 -1
  42. package/dist/functions/index.d.ts.map +1 -1
  43. package/dist/functions/index.js +15 -9
  44. package/dist/functions/index.js.map +1 -1
  45. package/dist/installations/index.d.ts +13 -1
  46. package/dist/installations/index.d.ts.map +1 -1
  47. package/dist/installations/index.js +15 -9
  48. package/dist/installations/index.js.map +1 -1
  49. package/dist/instance-id/index.d.ts +13 -1
  50. package/dist/instance-id/index.d.ts.map +1 -1
  51. package/dist/instance-id/index.js +15 -9
  52. package/dist/instance-id/index.js.map +1 -1
  53. package/dist/machine-learning/index.d.ts +19 -1
  54. package/dist/machine-learning/index.d.ts.map +1 -1
  55. package/dist/machine-learning/index.js +23 -9
  56. package/dist/machine-learning/index.js.map +1 -1
  57. package/dist/messaging/index.d.ts.map +1 -1
  58. package/dist/messaging/index.js +1 -0
  59. package/dist/messaging/index.js.map +1 -1
  60. package/dist/phone-number-verification/index.d.ts +12 -1
  61. package/dist/phone-number-verification/index.d.ts.map +1 -1
  62. package/dist/phone-number-verification/index.js +14 -9
  63. package/dist/phone-number-verification/index.js.map +1 -1
  64. package/dist/project-management/index.d.ts +21 -1
  65. package/dist/project-management/index.d.ts.map +1 -1
  66. package/dist/project-management/index.js +23 -9
  67. package/dist/project-management/index.js.map +1 -1
  68. package/dist/remote-config/index.d.ts +21 -1
  69. package/dist/remote-config/index.d.ts.map +1 -1
  70. package/dist/remote-config/index.js +24 -9
  71. package/dist/remote-config/index.js.map +1 -1
  72. package/dist/security-rules/index.d.ts +23 -1
  73. package/dist/security-rules/index.d.ts.map +1 -1
  74. package/dist/security-rules/index.js +27 -9
  75. package/dist/security-rules/index.js.map +1 -1
  76. package/dist/storage/deferred-members.d.ts +14 -0
  77. package/dist/storage/deferred-members.d.ts.map +1 -0
  78. package/dist/storage/deferred-members.js +55 -0
  79. package/dist/storage/deferred-members.js.map +1 -0
  80. package/dist/storage/delete-files.d.ts +13 -0
  81. package/dist/storage/delete-files.d.ts.map +1 -0
  82. package/dist/storage/delete-files.js +39 -0
  83. package/dist/storage/delete-files.js.map +1 -0
  84. package/dist/storage/file-query.d.ts +52 -0
  85. package/dist/storage/file-query.d.ts.map +1 -0
  86. package/dist/storage/file-query.js +94 -0
  87. package/dist/storage/file-query.js.map +1 -0
  88. package/dist/storage/get-files.d.ts +15 -0
  89. package/dist/storage/get-files.d.ts.map +1 -0
  90. package/dist/storage/get-files.js +33 -0
  91. package/dist/storage/get-files.js.map +1 -0
  92. package/dist/storage/index.d.ts +77 -13
  93. package/dist/storage/index.d.ts.map +1 -1
  94. package/dist/storage/index.js +359 -160
  95. package/dist/storage/index.js.map +1 -1
  96. package/dist/storage/remote-names.d.ts +19 -0
  97. package/dist/storage/remote-names.d.ts.map +1 -0
  98. package/dist/storage/remote-names.js +26 -0
  99. package/dist/storage/remote-names.js.map +1 -0
  100. package/package.json +2 -2
  101. package/src/admin.ts +58 -8
  102. package/src/app/index.ts +71 -19
  103. package/src/app-check/index.ts +27 -9
  104. package/src/auth/auth.test.ts +27 -0
  105. package/src/auth/index.ts +8 -0
  106. package/src/data-connect/index.ts +70 -9
  107. package/src/database/database.test.ts +1 -0
  108. package/src/database/index.ts +182 -108
  109. package/src/deferred.ts +78 -15
  110. package/src/eventarc/index.ts +32 -9
  111. package/src/extensions/index.ts +32 -9
  112. package/src/firestore/index.ts +30 -3
  113. package/src/functions/index.ts +32 -9
  114. package/src/installations/index.ts +33 -10
  115. package/src/instance-id/index.ts +33 -9
  116. package/src/machine-learning/index.ts +46 -9
  117. package/src/messaging/index.ts +1 -0
  118. package/src/phone-number-verification/index.ts +31 -9
  119. package/src/project-management/index.ts +48 -8
  120. package/src/remote-config/index.ts +51 -8
  121. package/src/security-rules/index.ts +54 -9
  122. package/src/storage/deferred-members.ts +67 -0
  123. package/src/storage/delete-files.ts +40 -0
  124. package/src/storage/file-query.ts +135 -0
  125. package/src/storage/get-files.ts +40 -0
  126. package/src/storage/index.ts +484 -180
  127. package/src/storage/remote-names.ts +34 -0
  128. package/src/storage/storage.test.ts +78 -5
@@ -9,8 +9,13 @@
9
9
  * `connectRemoteSandbox()`/`remoteSandbox()` relays every data
10
10
  * operation to the sandbox's host (admin lens pinned — rules bypass).
11
11
  * Bytes move over the host's HTTP byte route when it has one, and as
12
- * frames when it does not (a browser tab's SharedWorker). Single bucket;
13
- * `getSignedUrl` stays the local stub. See the remote arm section below.
12
+ * frames when it does not (a browser tab's SharedWorker). Each bucket
13
+ * name is its own bucket on the host; the default bucket is the host's
14
+ * default one. `createResumableUpload` gives a session URL on the byte
15
+ * route. `getSignedUrl` stays the local stub. See the remote arm section below.
16
+ *
17
+ * - **Default bucket**: `bucket()` is the bucket the app's `storageBucket`
18
+ * option names, or `'pyric-default'` without one, on both paths.
14
19
  *
15
20
  * - **Sandbox path** — returns an in-process {@link Storage} backed
16
21
  * by an in-memory `Map<bucketName, Map<path, FileEntry>>`. State
@@ -26,6 +31,9 @@
26
31
  * - `file.download(options?)` → `[Buffer]`, whole or `start..end`
27
32
  * - `file.createReadStream(options?)`, whole or `start..end`
28
33
  * - `file.createWriteStream(options?)`
34
+ * - `save` and `createWriteStream` take `firebaseStorageDownloadTokens`
35
+ * under `metadata.metadata` as the file's download tokens, which grant
36
+ * its download URL as `getDownloadURL` mints them
29
37
  * - `file.getMetadata()` / `file.setMetadata(metadata)`: settable fields
30
38
  * and custom metadata; a custom key set to `null` is removed
31
39
  * - `getDownloadURL(file)`: mints a token into
@@ -34,11 +42,17 @@
34
42
  * - `file.delete()` — idempotent
35
43
  * - `file.exists()` → `[boolean]`
36
44
  * - `file.getSignedUrl(options)` → `['pyric-sandbox-storage://…']`
45
+ * - `file.copy(destination, options?)`, within a bucket or across buckets
46
+ * - `bucket.getFiles(query?)` → `[files, nextQuery, apiResponse]`, with
47
+ * `prefix`, `delimiter`, `maxResults`, `pageToken`, `autoPaginate`
48
+ * - `bucket.deleteFiles(query?)`, honoring `force`
37
49
  *
38
50
  * **Deferred in the sandbox backend** (throws `"not implemented in
39
- * pyric-admin/storage sandbox backend"`): resumable uploads, signed
40
- * cookies, IAM policies, lifecycle rules, ACLs, copy/move,
41
- * notifications.
51
+ * pyric-admin/storage sandbox backend"`): `file.createResumableUpload`
52
+ * upload sessions, which need an HTTP host, signed cookies, IAM
53
+ * policies, lifecycle rules, ACLs, move, notifications. Every other
54
+ * `Bucket` and `File` method is a deferred member listed in
55
+ * `deferred-members.ts`.
42
56
  */
43
57
  import { createWriteStream as createFileWriteStream, openAsBlob } from 'node:fs';
44
58
  import { randomUUID } from 'node:crypto';
@@ -47,9 +61,16 @@ import { tmpdir } from 'node:os';
47
61
  import { join } from 'node:path';
48
62
  import { Readable, Writable } from 'node:stream';
49
63
  import { isRemoteSandbox, } from 'pyric/sandbox';
50
- import { fetchFromByteRoute, uploadOverByteRoute } from 'pyric/storage/internal';
64
+ import { deleteObject as storageDeleteObject, getMetadata as storageGetMetadata, ref as storageRef, } from 'pyric/storage';
65
+ import { copyObject, fetchFromByteRoute, getAdminStorageSandbox, getStorageService, patchObjectMetadata, uploadObject, uploadOverByteRoute, } from 'pyric/storage/internal';
51
66
  import { ADMIN_APP_TARGET, getApp, } from '../app/index.js';
52
67
  import { assertAdminAppActive } from '../app/lifecycle.js';
68
+ import { defineDeferredMembers } from 'pyric/app/internal';
69
+ import { BUCKET_DEFERRED_MEMBERS, FILE_DEFERRED_MEMBERS } from './deferred-members.js';
70
+ import { deleteListedFiles } from './delete-files.js';
71
+ import { assertQuery } from './file-query.js';
72
+ import { getFilesResponse } from './get-files.js';
73
+ import { namesUnderPrefix } from './remote-names.js';
53
74
  /** The custom metadata key holding a file's download tokens, comma-separated. */
54
75
  const DOWNLOAD_TOKENS_KEY = 'firebaseStorageDownloadTokens';
55
76
  /** Settable fields the sandbox keeps, besides custom metadata. */
@@ -71,10 +92,11 @@ export function getStorage(app) {
71
92
  // must never touch a remote handle — local state keyed off a remote
72
93
  // handle would be a private server-side store the browser never sees,
73
94
  // and `onEvent` throws on remote handles by design.
95
+ const defaultBucket = resolved.options.storageBucket ?? DEFAULT_SANDBOX_BUCKET;
74
96
  if (isRemoteSandbox(resolved.sandbox)) {
75
- return getRemoteStorage(resolved.sandbox);
97
+ return getRemoteStorage(resolved.sandbox, defaultBucket);
76
98
  }
77
- return getSandboxStorage(resolved);
99
+ return getSandboxStorage(resolved, defaultBucket);
78
100
  }
79
101
  // Defensive: the union is closed at the type level. A runtime value
80
102
  // that lands here means a caller forged a handle without going
@@ -107,179 +129,198 @@ export async function getDownloadURL(file) {
107
129
  * surfaces don't see an unexpected bucket name change.
108
130
  */
109
131
  const DEFAULT_SANDBOX_BUCKET = 'pyric-default';
110
- let generationSequence = 0;
111
- /** A generation in the form `@google-cloud/storage` reports: microseconds since the epoch. */
112
- function nextGeneration() {
113
- generationSequence = (generationSequence + 1) % 1000;
114
- return `${Date.now()}${String(generationSequence).padStart(3, '0')}`;
115
- }
116
132
  /**
117
133
  * State + reset-handler registry keyed on `Sandbox` so a single
118
- * Sandbox shares its storage across every `getStorage` call. The
119
- * `WeakMap` lets a discarded `Sandbox` (and its state) be GC'd
134
+ * Sandbox shares its storage across every `getStorage` call and with
135
+ * `pyric/storage`. The `WeakMap` lets a discarded `Sandbox` be GC'd
120
136
  * naturally.
121
137
  */
122
138
  const SANDBOX_STATE = new WeakMap();
123
- /**
124
- * Reset-subscription bookkeeping. We subscribe to `sandbox.onEvent`
125
- * once per Sandbox and re-create the bucket map on
126
- * `session_boundary` events with `phase: 'reset'`. Without this,
127
- * `sandbox.reset()` would wipe Firestore but leave storage untouched —
128
- * a leak the sandbox model deliberately avoids.
129
- */
130
- const RESET_HOOKED = new WeakSet();
131
- function ensureBucketMap(sandbox) {
132
- let map = SANDBOX_STATE.get(sandbox);
133
- if (!map) {
134
- map = new Map();
135
- SANDBOX_STATE.set(sandbox, map);
136
- }
137
- if (!RESET_HOOKED.has(sandbox)) {
138
- RESET_HOOKED.add(sandbox);
139
+ function ensureSandboxStorageState(sandbox, defaultBucket) {
140
+ let state = SANDBOX_STATE.get(sandbox);
141
+ if (!state) {
142
+ state = {
143
+ dbName: `pyric-admin-storage:${randomUUID()}`,
144
+ defaultBucketNames: new Set([DEFAULT_SANDBOX_BUCKET]),
145
+ buckets: new Set([DEFAULT_SANDBOX_BUCKET]),
146
+ pendingReset: undefined,
147
+ };
148
+ SANDBOX_STATE.set(sandbox, state);
139
149
  sandbox.onEvent((event) => {
140
150
  if (event.kind === 'session_boundary' && event.phase === 'reset') {
141
- // Replace the map in place so existing Storage / Bucket / File
142
- // handles keep working but observe an empty state.
143
- const existing = SANDBOX_STATE.get(sandbox);
144
- if (existing)
145
- existing.clear();
151
+ const current = SANDBOX_STATE.get(sandbox);
152
+ if (!current)
153
+ return;
154
+ const previous = current.pendingReset ?? Promise.resolve();
155
+ const bucketsToReset = [...current.buckets];
156
+ const nextReset = previous
157
+ .then(async () => {
158
+ await Promise.all(bucketsToReset.map(async (bucket) => {
159
+ const handle = getAdminStorageSandbox(sandbox, {
160
+ dbName: current.dbName,
161
+ bucket,
162
+ });
163
+ const service = await getStorageService(handle);
164
+ await service.backend.reset(bucket);
165
+ }));
166
+ })
167
+ .catch(() => {
168
+ // Best-effort reset across open bucket handles
169
+ });
170
+ current.pendingReset = nextReset;
146
171
  }
147
172
  });
148
173
  }
149
- return map;
174
+ state.defaultBucketNames.add(defaultBucket);
175
+ return state;
150
176
  }
151
- function getSandboxStorage(app) {
177
+ function getSandboxStorage(app, defaultBucket) {
152
178
  const sandbox = app.sandbox;
153
- const buckets = ensureBucketMap(sandbox);
154
- return new SandboxStorage(buckets);
179
+ const state = ensureSandboxStorageState(sandbox, defaultBucket);
180
+ return new SandboxStorage(sandbox, state, defaultBucket);
155
181
  }
156
182
  /**
157
- * Sandbox `Storage` implementation. Holds a reference to the per-
158
- * sandbox bucket map; each `bucket()` call returns a fresh `Bucket`
159
- * handle bound to the same underlying map, mirroring how
160
- * `@google-cloud/storage` returns lightweight per-call handles.
183
+ * Sandbox `Storage` implementation. Delegates to the per-sandbox
184
+ * `pyric/storage` admin plane (`getAdminStorageSandbox`) so `pyric-admin/storage`,
185
+ * `pyric/storage`, and `captureFullState` share one object store.
161
186
  */
162
187
  class SandboxStorage {
163
- buckets;
164
- constructor(buckets) {
165
- this.buckets = buckets;
188
+ sandbox;
189
+ state;
190
+ defaultBucket;
191
+ constructor(sandbox, state, defaultBucket) {
192
+ this.sandbox = sandbox;
193
+ this.state = state;
194
+ this.defaultBucket = defaultBucket;
195
+ }
196
+ storeBucketOf(bucketName) {
197
+ if (bucketName === DEFAULT_SANDBOX_BUCKET ||
198
+ bucketName === this.defaultBucket ||
199
+ this.state.defaultBucketNames.has(bucketName)) {
200
+ return DEFAULT_SANDBOX_BUCKET;
201
+ }
202
+ return bucketName;
203
+ }
204
+ async waitForReset() {
205
+ while (this.state.pendingReset !== undefined) {
206
+ const pending = this.state.pendingReset;
207
+ await pending;
208
+ if (this.state.pendingReset === pending) {
209
+ this.state.pendingReset = undefined;
210
+ }
211
+ }
166
212
  }
167
213
  bucket(name) {
168
- const bucketName = name ?? DEFAULT_SANDBOX_BUCKET;
169
- let files = this.buckets.get(bucketName);
170
- if (!files) {
171
- files = new Map();
172
- this.buckets.set(bucketName, files);
173
- }
174
- return new SandboxBucket(bucketName, files);
214
+ const bucketName = name ?? this.defaultBucket;
215
+ const storeBucket = this.storeBucketOf(bucketName);
216
+ this.state.buckets.add(storeBucket);
217
+ return new SandboxBucket(bucketName, storeBucket, this);
175
218
  }
176
219
  }
177
220
  class SandboxBucket {
178
221
  name;
179
- files;
180
- constructor(name, files) {
222
+ storeBucket;
223
+ storage;
224
+ constructor(name, storeBucket, storage) {
181
225
  this.name = name;
182
- this.files = files;
226
+ this.storeBucket = storeBucket;
227
+ this.storage = storage;
228
+ }
229
+ adminStorage() {
230
+ this.storage.state.buckets.add(this.storeBucket);
231
+ return getAdminStorageSandbox(this.storage.sandbox, {
232
+ dbName: this.storage.state.dbName,
233
+ bucket: this.storeBucket,
234
+ });
183
235
  }
184
236
  file(path) {
185
- return new SandboxFile(path, this, this.files);
237
+ return new SandboxFile(path, this);
238
+ }
239
+ async getFiles(query = {}) {
240
+ assertQuery(query, 'getFiles');
241
+ return getFilesResponse(await this.namesUnder(query.prefix ?? ''), query, (name) => this.file(name));
242
+ }
243
+ async deleteFiles(query = {}) {
244
+ assertQuery(query, 'deleteFiles');
245
+ await deleteListedFiles(await this.namesUnder(query.prefix ?? ''), query, (name) => this.file(name).delete());
246
+ }
247
+ async namesUnder(prefix) {
248
+ await this.storage.waitForReset();
249
+ const service = await getStorageService(this.adminStorage());
250
+ const items = await service.backend.listByPrefix(prefix, this.storeBucket);
251
+ return items.map((item) => item.fullPath);
186
252
  }
187
253
  }
188
254
  class SandboxFile {
189
255
  name;
190
256
  bucket;
191
- files;
192
- constructor(name, bucket, files) {
257
+ constructor(name, bucket) {
193
258
  this.name = name;
194
259
  this.bucket = bucket;
195
- this.files = files;
196
260
  }
261
+ /**
262
+ * Store `data` in one write. `resumable` only chooses how
263
+ * `@google-cloud/storage` sends the bytes; the stored object is the same.
264
+ */
197
265
  async save(data, options = {}) {
198
- if (options.resumable === true) {
199
- throw new Error('not implemented in pyric-admin/storage sandbox backend: resumable uploads');
266
+ const rawLength = typeof data === 'string' ? Buffer.byteLength(data, 'utf8') : data.byteLength;
267
+ if (rawLength > MAX_STORAGE_OBJECT_BYTES) {
268
+ throw quotaExceeded(rawLength, `save() payload for '${this.name}'`);
200
269
  }
270
+ await this.bucket.storage.waitForReset();
201
271
  const bytes = toBytes(data);
202
- const saved = patchOf(options.metadata ?? {}, { strict: false });
272
+ const saved = savedMetadataOf(options.metadata ?? {});
203
273
  const contentType = options.contentType ?? saved.settable.contentType;
204
- const now = new Date().toISOString();
205
- const custom = {};
206
- for (const [key, value] of Object.entries(saved.customMetadata))
207
- if (value !== null)
208
- custom[key] = value;
209
- const hasTokens = typeof saved.downloadTokens === 'string';
210
- if (hasTokens)
211
- custom[DOWNLOAD_TOKENS_KEY] = saved.downloadTokens;
212
- this.files.set(this.name, {
213
- data: bytes,
214
- settable: { ...saved.settable, ...(contentType !== undefined ? { contentType } : {}) },
215
- custom,
216
- generation: nextGeneration(),
217
- metageneration: 1,
218
- timeCreated: now,
219
- updated: now,
220
- });
274
+ const settable = {
275
+ ...saved.settable,
276
+ ...(contentType !== undefined ? { contentType } : {}),
277
+ ...(Object.keys(saved.custom).length > 0 ? { customMetadata: saved.custom } : {}),
278
+ };
279
+ await uploadObject(storageRef(this.bucket.adminStorage(), this.name), bytes, settable, { ...(saved.downloadTokens !== undefined ? { downloadTokens: saved.downloadTokens } : {}) });
221
280
  }
222
281
  async getMetadata() {
223
- return [this.metadataOf(this.entry())];
282
+ await this.bucket.storage.waitForReset();
283
+ const full = (await this.missingAsNoSuchObject(storageGetMetadata(storageRef(this.bucket.adminStorage(), this.name))));
284
+ return [fileMetadataOf({ ...full, bucket: this.bucket.name })];
224
285
  }
225
286
  async setMetadata(metadata) {
226
- const entry = this.entry();
287
+ await this.bucket.storage.waitForReset();
227
288
  const patch = patchOf(metadata, { strict: true });
228
- Object.assign(entry.settable, patch.settable);
229
- for (const [key, value] of Object.entries(patch.customMetadata)) {
230
- if (value === null)
231
- delete entry.custom[key];
232
- else
233
- entry.custom[key] = value;
234
- }
235
- if (patch.downloadTokens === null)
236
- delete entry.custom[DOWNLOAD_TOKENS_KEY];
237
- else if (patch.downloadTokens !== undefined)
238
- entry.custom[DOWNLOAD_TOKENS_KEY] = patch.downloadTokens;
239
- this.touch(entry);
240
- return [this.metadataOf(entry)];
289
+ const full = (await this.missingAsNoSuchObject(patchObjectMetadata(storageRef(this.bucket.adminStorage(), this.name), patch)));
290
+ return [fileMetadataOf({ ...full, bucket: this.bucket.name })];
241
291
  }
242
292
  async [DOWNLOAD_URL]() {
243
- const entry = this.entry();
244
- const tokenless = !entry.custom[DOWNLOAD_TOKENS_KEY];
245
- if (tokenless) {
246
- entry.custom[DOWNLOAD_TOKENS_KEY] = randomUUID();
247
- this.touch(entry);
293
+ await this.bucket.storage.waitForReset();
294
+ const r = storageRef(this.bucket.adminStorage(), this.name);
295
+ const full = (await this.missingAsNoSuchObject(storageGetMetadata(r)));
296
+ const token = (full.downloadTokens ?? '').split(',').find((existing) => existing !== '');
297
+ if (token === undefined) {
298
+ await patchObjectMetadata(r, { downloadTokens: randomUUID() });
248
299
  }
249
- return dataUri(entry.data, entry.settable.contentType);
250
- }
251
- entry() {
252
- const entry = this.files.get(this.name);
253
- if (!entry)
254
- throw new Error(`No such object: ${this.bucket.name}/${this.name}`);
255
- return entry;
256
- }
257
- touch(entry) {
258
- entry.metageneration += 1;
259
- entry.updated = new Date().toISOString();
300
+ const [bytes] = await this.download();
301
+ return dataUri(bytes, full.contentType);
260
302
  }
261
- metadataOf(entry) {
262
- const hasCustom = Object.keys(entry.custom).length > 0;
263
- return {
264
- name: this.name,
265
- bucket: this.bucket.name,
266
- generation: entry.generation,
267
- metageneration: String(entry.metageneration),
268
- size: String(entry.data.byteLength),
269
- timeCreated: entry.timeCreated,
270
- updated: entry.updated,
271
- ...entry.settable,
272
- ...(hasCustom ? { metadata: { ...entry.custom } } : {}),
273
- };
303
+ async missingAsNoSuchObject(pending) {
304
+ try {
305
+ return await pending;
306
+ }
307
+ catch (err) {
308
+ if (isObjectNotFound(err))
309
+ throw new Error(`No such object: ${this.bucket.name}/${this.name}`);
310
+ throw err;
311
+ }
274
312
  }
275
313
  async download(options = {}) {
276
- const entry = this.files.get(this.name);
277
- if (!entry) {
314
+ await this.bucket.storage.waitForReset();
315
+ const service = await getStorageService(this.bucket.adminStorage());
316
+ const object = await service.backend.getObject(this.name, this.bucket.storeBucket);
317
+ if (!object) {
278
318
  // Mirror the gcs/firebase-admin error message shape so consumer
279
319
  // catch-blocks that string-match `No such object` keep working.
280
320
  throw new Error(`No such object: ${this.bucket.name}/${this.name}`);
281
321
  }
282
- return [Buffer.from(byteRange(entry.data, options))];
322
+ const bytes = new Uint8Array(await object.blob.arrayBuffer());
323
+ return [Buffer.from(byteRange(bytes, options))];
283
324
  }
284
325
  createReadStream(options = {}) {
285
326
  const read = async function* (file) {
@@ -291,11 +332,29 @@ class SandboxFile {
291
332
  createWriteStream(options = {}) {
292
333
  return spooledWriteStream(async (spooled) => this.save(await readFile(spooled), options));
293
334
  }
335
+ async createResumableUpload() {
336
+ throw new Error('not implemented in pyric-admin/storage sandbox backend: resumable upload sessions (createResumableUpload). ' +
337
+ 'The in-process sandbox has no HTTP host to take the bytes; upload with file.save() or file.createWriteStream().');
338
+ }
339
+ async copy(destination, options = {}) {
340
+ const target = copyTargetOf(this, destination, this.bucket.storage);
341
+ const local = target instanceof SandboxFile && target.bucket.storage.sandbox === this.bucket.storage.sandbox;
342
+ if (!local)
343
+ throw new Error('pyric-admin/storage: File.copy copies to a File from the same getStorage() sandbox.');
344
+ await this.bucket.storage.waitForReset();
345
+ const patch = patchOf(copyMetadataOf(options), { strict: false });
346
+ const full = (await this.missingAsNoSuchObject(copyObject(storageRef(this.bucket.adminStorage(), this.name), storageRef(target.bucket.adminStorage(), target.name), patch)));
347
+ return [target, fileMetadataOf({ ...full, bucket: target.bucket.name })];
348
+ }
294
349
  async delete() {
295
- this.files.delete(this.name);
350
+ await this.bucket.storage.waitForReset();
351
+ await storageDeleteObject(storageRef(this.bucket.adminStorage(), this.name));
296
352
  }
297
353
  async exists() {
298
- return [this.files.has(this.name)];
354
+ await this.bucket.storage.waitForReset();
355
+ const service = await getStorageService(this.bucket.adminStorage());
356
+ const stored = await service.backend.getMetadata(this.name, this.bucket.storeBucket);
357
+ return [stored !== undefined];
299
358
  }
300
359
  async getSignedUrl(options) {
301
360
  return [stubSignedUrl(this.bucket.name, this.name, options)];
@@ -313,13 +372,15 @@ class SandboxFile {
313
372
  // the browser never sees, and the local arm's `onEvent` reset hook throws
314
373
  // on remote handles by design.
315
374
  //
375
+ // Buckets: every op names its bucket, and gives the app's name for the
376
+ // default bucket, so the host stores each named bucket apart and answers to
377
+ // the configured default name, in ops and in byte route URLs alike.
378
+ //
316
379
  // Divergences from the local arm, all LOUD:
317
- // - single bucket: the worker's `pyric/storage` store is single-bucket
318
- // ("the data store is shared" — bucket names only round-trip in
319
- // metadata), so `bucket('non-default')` throws instead of silently
320
- // merging buckets. The default bucket name matches the local arm.
321
380
  // - a host with an HTTP byte route takes and serves bytes there; a
322
381
  // SharedWorker host has none and takes frames, in 4 MiB parts past that.
382
+ // - a host with a byte route gives `createResumableUpload` session URLs;
383
+ // the local arm and a SharedWorker host reject it.
323
384
  // `getSignedUrl` does NOT relay: it stays the byte-identical local stub.
324
385
  /** firebase-admin's rules-bypass lens, pinned on every relayed operation. */
325
386
  const STORAGE_REMOTE_ADMIN_LENS = { mode: 'admin' };
@@ -333,44 +394,75 @@ const STORAGE_REMOTE_ADMIN_LENS = { mode: 'admin' };
333
394
  const MAX_STORAGE_PART_BYTES = 4 * 1024 * 1024;
334
395
  /** Maximum whole-object size supported by the sandbox backend (512 MiB). Matches MAX_STORAGE_OBJECT_BYTES. */
335
396
  const MAX_STORAGE_OBJECT_BYTES = 512 * 1024 * 1024;
336
- /** One remote `Storage` per remote handle (handles only — never data). */
397
+ /** One remote `Storage` per remote handle and default bucket name (handles only, never data). */
337
398
  const remoteStorageBySandbox = new WeakMap();
338
- function getRemoteStorage(sandbox) {
339
- let storage = remoteStorageBySandbox.get(sandbox);
399
+ function getRemoteStorage(sandbox, defaultBucket) {
400
+ let byDefault = remoteStorageBySandbox.get(sandbox);
401
+ if (!byDefault) {
402
+ byDefault = new Map();
403
+ remoteStorageBySandbox.set(sandbox, byDefault);
404
+ }
405
+ let storage = byDefault.get(defaultBucket);
340
406
  if (!storage) {
341
- storage = new RemoteStorage(sandbox.channel);
342
- remoteStorageBySandbox.set(sandbox, storage);
407
+ storage = new RemoteStorage(sandbox.channel, defaultBucket);
408
+ byDefault.set(defaultBucket, storage);
343
409
  }
344
410
  return storage;
345
411
  }
346
412
  class RemoteStorage {
347
413
  channel;
348
- constructor(channel) {
414
+ defaultBucket;
415
+ constructor(channel, defaultBucket) {
349
416
  this.channel = channel;
417
+ this.defaultBucket = defaultBucket;
350
418
  }
419
+ /** Each bucket name is its own bucket on the host; the default one is the host's default bucket. */
351
420
  bucket(name) {
352
- // The worker's object store is single-bucket. A non-default name can't
353
- // be faithfully relayed — throw loudly instead of silently merging
354
- // buckets (the local arm has REAL multi-bucket isolation; this is the
355
- // sharpest local/remote divergence, so it must be explicit).
356
- if (name !== undefined && name !== DEFAULT_SANDBOX_BUCKET) {
357
- throw new Error(`pyric-admin/storage: the remote (browser) sandbox has a single bucket — ` +
358
- `bucket('${name}') cannot be isolated. Use bucket() (the default ` +
359
- `'${DEFAULT_SANDBOX_BUCKET}' bucket) instead.`);
360
- }
361
- return new RemoteBucket(DEFAULT_SANDBOX_BUCKET, this.channel);
421
+ return new RemoteBucket(name ?? this.defaultBucket, this.channel, this);
362
422
  }
363
423
  }
364
424
  class RemoteBucket {
365
425
  name;
366
426
  channel;
367
- constructor(name, channel) {
427
+ storage;
428
+ constructor(name, channel, storage) {
368
429
  this.name = name;
369
430
  this.channel = channel;
431
+ this.storage = storage;
432
+ }
433
+ /**
434
+ * How ops name this bucket: the default bucket has no `bucket`, and every op
435
+ * gives the app's name for the default bucket, so the host answers to it.
436
+ */
437
+ get wire() {
438
+ const defaultName = this.storage.defaultBucket;
439
+ const namesDefault = defaultName !== DEFAULT_SANDBOX_BUCKET;
440
+ const isDefault = this.name === defaultName;
441
+ return {
442
+ ...(isDefault ? {} : { bucket: this.name }),
443
+ ...(namesDefault ? { defaultBucket: defaultName } : {}),
444
+ };
370
445
  }
371
446
  file(path) {
372
447
  return new RemoteFile(path, this, this.channel);
373
448
  }
449
+ async getFiles(query = {}) {
450
+ assertQuery(query, 'getFiles');
451
+ return getFilesResponse(await this.namesUnder(query.prefix ?? ''), query, name => this.file(name));
452
+ }
453
+ async deleteFiles(query = {}) {
454
+ assertQuery(query, 'deleteFiles');
455
+ await deleteListedFiles(await this.namesUnder(query.prefix ?? ''), query, name => this.file(name).delete());
456
+ }
457
+ /** The host's object names under `prefix`, walked folder by folder with `storage.listAll`. */
458
+ namesUnder(prefix) {
459
+ return namesUnderPrefix(prefix, async (path) => (await this.channel.op({
460
+ method: 'storage.listAll',
461
+ ...this.wire,
462
+ path,
463
+ actAs: STORAGE_REMOTE_ADMIN_LENS,
464
+ })));
465
+ }
374
466
  }
375
467
  class RemoteFile {
376
468
  name;
@@ -381,10 +473,11 @@ class RemoteFile {
381
473
  this.bucket = bucket;
382
474
  this.channel = channel;
383
475
  }
476
+ /**
477
+ * Store `data` in one upload. `resumable` only chooses how
478
+ * `@google-cloud/storage` sends the bytes; the stored object is the same.
479
+ */
384
480
  async save(data, options = {}) {
385
- if (options.resumable === true) {
386
- throw new Error('not implemented in pyric-admin/storage remote sandbox backend: resumable uploads');
387
- }
388
481
  const rawLength = typeof data === 'string' ? Buffer.byteLength(data, 'utf8') : data.byteLength;
389
482
  if (rawLength > MAX_STORAGE_OBJECT_BYTES) {
390
483
  throw quotaExceeded(rawLength, `save() payload for '${this.name}'`);
@@ -409,12 +502,57 @@ class RemoteFile {
409
502
  await this.upload(data, options);
410
503
  });
411
504
  }
412
- /** Store `data` over the host's byte route, or as one frame when the host has none. */
505
+ /**
506
+ * A session URL on the host's byte route that takes this object's bytes,
507
+ * as `@google-cloud/storage` returns a resumable upload URI. A browser sends
508
+ * them in one `PUT`, or in `Content-Range` slices whose first one names the
509
+ * total size, and the object is created with `metadata` when the last byte
510
+ * arrives. With `origin`, the URL answers that page origin's cross-origin
511
+ * requests. A host without a byte route (a browser tab's SharedWorker) has
512
+ * no URL to give, so this rejects there.
513
+ */
514
+ async createResumableUpload(options = {}) {
515
+ const route = await this.channel.byteRoute?.();
516
+ const routed = route !== undefined;
517
+ if (!routed) {
518
+ throw new Error('not implemented in pyric-admin/storage remote sandbox backend: resumable upload sessions (createResumableUpload) ' +
519
+ 'on a host without an HTTP byte route. Upload with file.save() or file.createWriteStream().');
520
+ }
521
+ const { uploadUrl } = (await this.channel.op({
522
+ method: 'storage.createUploadSession',
523
+ ...this.bucket.wire,
524
+ path: this.name,
525
+ ...uploadRequestOf({ ...(options.metadata !== undefined ? { metadata: options.metadata } : {}) }),
526
+ ...(options.origin !== undefined ? { origin: options.origin } : {}),
527
+ actAs: STORAGE_REMOTE_ADMIN_LENS,
528
+ }));
529
+ return [new URL(uploadUrl, route.baseUrl).href];
530
+ }
531
+ /** Copy this object, with its metadata, to `destination`; `options` change the copy's metadata. */
532
+ async copy(destination, options = {}) {
533
+ const target = copyTargetOf(this, destination, this.bucket.storage);
534
+ const remote = target instanceof RemoteFile && target.channel === this.channel;
535
+ if (!remote)
536
+ throw new Error('pyric-admin/storage: File.copy copies to a File from the same remote sandbox.');
537
+ const destinationWire = target.bucket.wire;
538
+ const full = await this.missingAsNoSuchObject(this.channel.op({
539
+ method: 'storage.copyObject',
540
+ ...this.bucket.wire,
541
+ path: this.name,
542
+ ...(destinationWire.bucket !== undefined ? { destinationBucket: destinationWire.bucket } : {}),
543
+ destinationPath: target.name,
544
+ patch: patchOf(copyMetadataOf(options), { strict: false }),
545
+ actAs: STORAGE_REMOTE_ADMIN_LENS,
546
+ }));
547
+ return [target, fileMetadataOf(full)];
548
+ }
549
+ /**
550
+ * Store `data` over the host's byte route, or as one frame when the host has
551
+ * none. `firebaseStorageDownloadTokens` becomes the object's download tokens,
552
+ * as `setMetadata` sends it, and the other custom keys stay custom metadata.
553
+ */
413
554
  async upload(data, options) {
414
- const request = {
415
- ...(options.contentType !== undefined ? { contentType: options.contentType } : {}),
416
- ...(options.metadata !== undefined ? { metadata: options.metadata } : {}),
417
- };
555
+ const request = { ...this.bucket.wire, ...uploadRequestOf(options) };
418
556
  const route = await this.channel.byteRoute?.();
419
557
  const routed = route !== undefined;
420
558
  if (routed) {
@@ -466,6 +604,7 @@ class RemoteFile {
466
604
  async *read(options) {
467
605
  const metadata = await this.missingAsNoSuchObject(this.channel.op({
468
606
  method: 'storage.getMetadata',
607
+ ...this.bucket.wire,
469
608
  path: this.name,
470
609
  actAs: STORAGE_REMOTE_ADMIN_LENS,
471
610
  }));
@@ -477,6 +616,7 @@ class RemoteFile {
477
616
  }
478
617
  const response = await this.missingAsNoSuchObject(fetchFromByteRoute(route, {
479
618
  bucket: metadata.bucket,
619
+ ...this.bucket.wire,
480
620
  path: this.name,
481
621
  start: options.start,
482
622
  end: options.end,
@@ -502,6 +642,7 @@ class RemoteFile {
502
642
  if (metadata.size <= MAX_STORAGE_PART_BYTES) {
503
643
  const wire = await this.missingAsNoSuchObject(this.channel.op({
504
644
  method: 'storage.getBytes',
645
+ ...this.bucket.wire,
505
646
  path: this.name,
506
647
  actAs: STORAGE_REMOTE_ADMIN_LENS,
507
648
  }));
@@ -512,6 +653,7 @@ class RemoteFile {
512
653
  for (let offset = options.start ?? 0; offset < last; offset += MAX_STORAGE_PART_BYTES) {
513
654
  const wire = await this.missingAsNoSuchObject(this.channel.op({
514
655
  method: 'storage.getBytes',
656
+ ...this.bucket.wire,
515
657
  path: this.name,
516
658
  offset,
517
659
  length: Math.min(MAX_STORAGE_PART_BYTES, last - offset),
@@ -524,6 +666,7 @@ class RemoteFile {
524
666
  async getMetadata() {
525
667
  const full = await this.missingAsNoSuchObject(this.channel.op({
526
668
  method: 'storage.getMetadata',
669
+ ...this.bucket.wire,
527
670
  path: this.name,
528
671
  actAs: STORAGE_REMOTE_ADMIN_LENS,
529
672
  }));
@@ -532,6 +675,7 @@ class RemoteFile {
532
675
  async setMetadata(metadata) {
533
676
  const full = await this.missingAsNoSuchObject(this.channel.op({
534
677
  method: 'storage.setMetadata',
678
+ ...this.bucket.wire,
535
679
  path: this.name,
536
680
  patch: patchOf(metadata, { strict: true }),
537
681
  actAs: STORAGE_REMOTE_ADMIN_LENS,
@@ -542,6 +686,7 @@ class RemoteFile {
542
686
  async [DOWNLOAD_URL]() {
543
687
  const { path } = await this.missingAsNoSuchObject(this.channel.op({
544
688
  method: 'storage.getDownloadURL',
689
+ ...this.bucket.wire,
545
690
  path: this.name,
546
691
  actAs: STORAGE_REMOTE_ADMIN_LENS,
547
692
  }));
@@ -567,6 +712,7 @@ class RemoteFile {
567
712
  try {
568
713
  await this.channel.op({
569
714
  method: 'storage.deleteObject',
715
+ ...this.bucket.wire,
570
716
  path: this.name,
571
717
  actAs: STORAGE_REMOTE_ADMIN_LENS,
572
718
  });
@@ -584,6 +730,7 @@ class RemoteFile {
584
730
  try {
585
731
  await this.channel.op({
586
732
  method: 'storage.getMetadata',
733
+ ...this.bucket.wire,
587
734
  path: this.name,
588
735
  actAs: STORAGE_REMOTE_ADMIN_LENS,
589
736
  });
@@ -637,6 +784,34 @@ function quotaExceeded(sizeBytes, what) {
637
784
  err.code = 'storage/quota-exceeded';
638
785
  return err;
639
786
  }
787
+ // ─── Helpers ────────────────────────────────────────────────────────────
788
+ /**
789
+ * The File a copy writes: a path in the source's bucket, a `gs://` URL, the
790
+ * source's path in another Bucket, or a File.
791
+ */
792
+ function copyTargetOf(source, destination, storage) {
793
+ if (typeof destination === 'string') {
794
+ const url = /^gs:\/\/([^/]+)\/(.+)$/.exec(destination);
795
+ return url === null ? source.bucket.file(destination) : storage.bucket(url[1]).file(url[2]);
796
+ }
797
+ const isFile = 'save' in destination;
798
+ return isFile ? destination : destination.file(source.name);
799
+ }
800
+ /** {@link CopyOptions} as the metadata `save` takes: settable fields, and custom keys under `metadata`. */
801
+ function copyMetadataOf(options) {
802
+ const { metadata, ...settable } = options;
803
+ return { ...settable, ...(metadata !== undefined ? { metadata } : {}) };
804
+ }
805
+ /** What an upload sends besides its bytes: settable fields and custom metadata, with the download tokens apart. */
806
+ function uploadRequestOf(options) {
807
+ const saved = options.metadata === undefined ? undefined : savedMetadataOf(options.metadata);
808
+ const hasCustom = saved !== undefined && Object.keys(saved.custom).length > 0;
809
+ return {
810
+ ...(options.contentType !== undefined ? { contentType: options.contentType } : {}),
811
+ ...(saved !== undefined ? { metadata: { ...saved.settable, ...(hasCustom ? { metadata: saved.custom } : {}) } } : {}),
812
+ ...(saved?.downloadTokens !== undefined ? { downloadTokens: saved.downloadTokens } : {}),
813
+ };
814
+ }
640
815
  /**
641
816
  * Split `@google-cloud/storage`-shaped metadata into a {@link MetadataPatch}.
642
817
  * `setMetadata` is strict and refuses fields the sandbox does not keep;
@@ -671,6 +846,20 @@ function patchOf(metadata, { strict }) {
671
846
  }
672
847
  return patch;
673
848
  }
849
+ /**
850
+ * The metadata `save` and `createWriteStream` create an object with. It is the
851
+ * {@link patchOf} split `setMetadata` uses, so `firebaseStorageDownloadTokens`
852
+ * becomes the object's download tokens on every path; a `null` key is not stored.
853
+ */
854
+ function savedMetadataOf(metadata) {
855
+ const patch = patchOf(metadata, { strict: false });
856
+ const custom = {};
857
+ for (const [key, value] of Object.entries(patch.customMetadata))
858
+ if (value !== null)
859
+ custom[key] = value;
860
+ const tokens = patch.downloadTokens;
861
+ return { settable: patch.settable, custom, ...(typeof tokens === 'string' ? { downloadTokens: tokens } : {}) };
862
+ }
674
863
  /** A `data:` URI carrying `bytes`, the download URL where no host serves them over HTTP. */
675
864
  function dataUri(bytes, contentType) {
676
865
  return `data:${contentType ?? 'application/octet-stream'};base64,${base64Of(bytes)}`;
@@ -758,4 +947,14 @@ function normalizeExpires(expires) {
758
947
  // strictness because the value only feeds the deterministic sandbox stub.
759
948
  return new Date(expires).getTime();
760
949
  }
950
+ // ─── Deferred members ────────────────────────────────────────────────────
951
+ //
952
+ // The `Bucket` and `File` methods neither arm models fail with an error that
953
+ // names the method instead of reading as `undefined`. See deferred-members.ts.
954
+ for (const bucket of [SandboxBucket, RemoteBucket]) {
955
+ defineDeferredMembers(bucket.prototype, 'pyric-admin/storage', 'Bucket', BUCKET_DEFERRED_MEMBERS);
956
+ }
957
+ for (const file of [SandboxFile, RemoteFile]) {
958
+ defineDeferredMembers(file.prototype, 'pyric-admin/storage', 'File', FILE_DEFERRED_MEMBERS);
959
+ }
761
960
  //# sourceMappingURL=index.js.map