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.
- package/README.md +1 -1
- package/dist/admin.d.ts +64 -12
- package/dist/admin.d.ts.map +1 -1
- package/dist/admin.js +53 -8
- package/dist/admin.js.map +1 -1
- package/dist/app/index.d.ts +14 -1
- package/dist/app/index.d.ts.map +1 -1
- package/dist/app/index.js +58 -16
- package/dist/app/index.js.map +1 -1
- package/dist/app-check/index.d.ts +13 -1
- package/dist/app-check/index.d.ts.map +1 -1
- package/dist/app-check/index.js +14 -9
- package/dist/app-check/index.js.map +1 -1
- package/dist/auth/index.d.ts.map +1 -1
- package/dist/auth/index.js +11 -0
- package/dist/auth/index.js.map +1 -1
- package/dist/data-connect/index.d.ts +23 -1
- package/dist/data-connect/index.d.ts.map +1 -1
- package/dist/data-connect/index.js +44 -7
- package/dist/data-connect/index.js.map +1 -1
- package/dist/database/index.d.ts +24 -19
- package/dist/database/index.d.ts.map +1 -1
- package/dist/database/index.js +133 -99
- package/dist/database/index.js.map +1 -1
- package/dist/deferred.d.ts +37 -13
- package/dist/deferred.d.ts.map +1 -1
- package/dist/deferred.js +57 -15
- package/dist/deferred.js.map +1 -1
- package/dist/eventarc/index.d.ts +13 -1
- package/dist/eventarc/index.d.ts.map +1 -1
- package/dist/eventarc/index.js +15 -9
- package/dist/eventarc/index.js.map +1 -1
- package/dist/extensions/index.d.ts +13 -1
- package/dist/extensions/index.d.ts.map +1 -1
- package/dist/extensions/index.js +15 -9
- package/dist/extensions/index.js.map +1 -1
- package/dist/firestore/index.d.ts +6 -0
- package/dist/firestore/index.d.ts.map +1 -1
- package/dist/firestore/index.js +25 -3
- package/dist/firestore/index.js.map +1 -1
- package/dist/functions/index.d.ts +13 -1
- package/dist/functions/index.d.ts.map +1 -1
- package/dist/functions/index.js +15 -9
- package/dist/functions/index.js.map +1 -1
- package/dist/installations/index.d.ts +13 -1
- package/dist/installations/index.d.ts.map +1 -1
- package/dist/installations/index.js +15 -9
- package/dist/installations/index.js.map +1 -1
- package/dist/instance-id/index.d.ts +13 -1
- package/dist/instance-id/index.d.ts.map +1 -1
- package/dist/instance-id/index.js +15 -9
- package/dist/instance-id/index.js.map +1 -1
- package/dist/machine-learning/index.d.ts +19 -1
- package/dist/machine-learning/index.d.ts.map +1 -1
- package/dist/machine-learning/index.js +23 -9
- package/dist/machine-learning/index.js.map +1 -1
- package/dist/messaging/index.d.ts.map +1 -1
- package/dist/messaging/index.js +1 -0
- package/dist/messaging/index.js.map +1 -1
- package/dist/phone-number-verification/index.d.ts +12 -1
- package/dist/phone-number-verification/index.d.ts.map +1 -1
- package/dist/phone-number-verification/index.js +14 -9
- package/dist/phone-number-verification/index.js.map +1 -1
- package/dist/project-management/index.d.ts +21 -1
- package/dist/project-management/index.d.ts.map +1 -1
- package/dist/project-management/index.js +23 -9
- package/dist/project-management/index.js.map +1 -1
- package/dist/remote-config/index.d.ts +21 -1
- package/dist/remote-config/index.d.ts.map +1 -1
- package/dist/remote-config/index.js +24 -9
- package/dist/remote-config/index.js.map +1 -1
- package/dist/security-rules/index.d.ts +23 -1
- package/dist/security-rules/index.d.ts.map +1 -1
- package/dist/security-rules/index.js +27 -9
- package/dist/security-rules/index.js.map +1 -1
- package/dist/storage/deferred-members.d.ts +14 -0
- package/dist/storage/deferred-members.d.ts.map +1 -0
- package/dist/storage/deferred-members.js +55 -0
- package/dist/storage/deferred-members.js.map +1 -0
- package/dist/storage/delete-files.d.ts +13 -0
- package/dist/storage/delete-files.d.ts.map +1 -0
- package/dist/storage/delete-files.js +39 -0
- package/dist/storage/delete-files.js.map +1 -0
- package/dist/storage/file-query.d.ts +52 -0
- package/dist/storage/file-query.d.ts.map +1 -0
- package/dist/storage/file-query.js +94 -0
- package/dist/storage/file-query.js.map +1 -0
- package/dist/storage/get-files.d.ts +15 -0
- package/dist/storage/get-files.d.ts.map +1 -0
- package/dist/storage/get-files.js +33 -0
- package/dist/storage/get-files.js.map +1 -0
- package/dist/storage/index.d.ts +77 -13
- package/dist/storage/index.d.ts.map +1 -1
- package/dist/storage/index.js +359 -160
- package/dist/storage/index.js.map +1 -1
- package/dist/storage/remote-names.d.ts +19 -0
- package/dist/storage/remote-names.d.ts.map +1 -0
- package/dist/storage/remote-names.js +26 -0
- package/dist/storage/remote-names.js.map +1 -0
- package/package.json +2 -2
- package/src/admin.ts +58 -8
- package/src/app/index.ts +71 -19
- package/src/app-check/index.ts +27 -9
- package/src/auth/auth.test.ts +27 -0
- package/src/auth/index.ts +8 -0
- package/src/data-connect/index.ts +70 -9
- package/src/database/database.test.ts +1 -0
- package/src/database/index.ts +182 -108
- package/src/deferred.ts +78 -15
- package/src/eventarc/index.ts +32 -9
- package/src/extensions/index.ts +32 -9
- package/src/firestore/index.ts +30 -3
- package/src/functions/index.ts +32 -9
- package/src/installations/index.ts +33 -10
- package/src/instance-id/index.ts +33 -9
- package/src/machine-learning/index.ts +46 -9
- package/src/messaging/index.ts +1 -0
- package/src/phone-number-verification/index.ts +31 -9
- package/src/project-management/index.ts +48 -8
- package/src/remote-config/index.ts +51 -8
- package/src/security-rules/index.ts +54 -9
- package/src/storage/deferred-members.ts +67 -0
- package/src/storage/delete-files.ts +40 -0
- package/src/storage/file-query.ts +135 -0
- package/src/storage/get-files.ts +40 -0
- package/src/storage/index.ts +484 -180
- package/src/storage/remote-names.ts +34 -0
- package/src/storage/storage.test.ts +78 -5
package/dist/storage/index.js
CHANGED
|
@@ -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).
|
|
13
|
-
*
|
|
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"`):
|
|
40
|
-
*
|
|
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 {
|
|
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
|
|
119
|
-
* `WeakMap` lets a discarded `Sandbox`
|
|
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
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
-
|
|
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
|
|
154
|
-
return new SandboxStorage(
|
|
179
|
+
const state = ensureSandboxStorageState(sandbox, defaultBucket);
|
|
180
|
+
return new SandboxStorage(sandbox, state, defaultBucket);
|
|
155
181
|
}
|
|
156
182
|
/**
|
|
157
|
-
* Sandbox `Storage` implementation.
|
|
158
|
-
*
|
|
159
|
-
*
|
|
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
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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 ??
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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
|
-
|
|
180
|
-
|
|
222
|
+
storeBucket;
|
|
223
|
+
storage;
|
|
224
|
+
constructor(name, storeBucket, storage) {
|
|
181
225
|
this.name = name;
|
|
182
|
-
this.
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
199
|
-
|
|
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 =
|
|
272
|
+
const saved = savedMetadataOf(options.metadata ?? {});
|
|
203
273
|
const contentType = options.contentType ?? saved.settable.contentType;
|
|
204
|
-
const
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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
|
-
|
|
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
|
-
|
|
287
|
+
await this.bucket.storage.waitForReset();
|
|
227
288
|
const patch = patchOf(metadata, { strict: true });
|
|
228
|
-
|
|
229
|
-
|
|
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
|
-
|
|
244
|
-
const
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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
|
-
|
|
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
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
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
|
-
|
|
277
|
-
|
|
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
|
-
|
|
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.
|
|
350
|
+
await this.bucket.storage.waitForReset();
|
|
351
|
+
await storageDeleteObject(storageRef(this.bucket.adminStorage(), this.name));
|
|
296
352
|
}
|
|
297
353
|
async exists() {
|
|
298
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|