@naturalcycles/cloud-storage-lib 1.9.2 → 1.10.0

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.
@@ -2,7 +2,7 @@
2
2
  /// <reference types="node" />
3
3
  import { Readable, Writable } from 'node:stream';
4
4
  import { Storage, StorageOptions } from '@google-cloud/storage';
5
- import { LocalTimeInput } from '@naturalcycles/js-lib';
5
+ import { CommonLogger, LocalTimeInput } from '@naturalcycles/js-lib';
6
6
  import { ReadableTyped } from '@naturalcycles/nodejs-lib';
7
7
  import { CommonStorage, CommonStorageGetOptions, FileEntry } from './commonStorage';
8
8
  import { GCPServiceAccount } from './model';
@@ -18,9 +18,13 @@ export { Storage, type StorageOptions, };
18
18
  */
19
19
  export interface CloudStorageCfg {
20
20
  /**
21
- * It's optional, to allow automatic credentials in AppEngine, or GOOGLE_APPLICATION_CREDENTIALS.
21
+ * Default is console
22
22
  */
23
- credentials?: GCPServiceAccount;
23
+ logger?: CommonLogger;
24
+ /**
25
+ * Pass true for extra debugging
26
+ */
27
+ debug?: boolean;
24
28
  }
25
29
  /**
26
30
  * CloudStorage implementation of CommonStorage API.
@@ -29,13 +33,17 @@ export interface CloudStorageCfg {
29
33
  */
30
34
  export declare class CloudStorage implements CommonStorage {
31
35
  storage: Storage;
36
+ private constructor();
37
+ cfg: CloudStorageCfg & {
38
+ logger: CommonLogger;
39
+ };
40
+ static createFromGCPServiceAccount(credentials?: GCPServiceAccount, cfg?: CloudStorageCfg): CloudStorage;
41
+ static createFromStorageOptions(storageOptions?: StorageOptions, cfg?: CloudStorageCfg): CloudStorage;
32
42
  /**
33
43
  * Passing the pre-created Storage allows to instantiate it from both
34
44
  * GCP Storage and FirebaseStorage.
35
45
  */
36
- constructor(storage: Storage);
37
- static createFromGCPServiceAccount(cfg: CloudStorageCfg): CloudStorage;
38
- static createFromStorageOptions(storageOptions?: StorageOptions): CloudStorage;
46
+ static createFromStorage(storage: Storage, cfg?: CloudStorageCfg): CloudStorage;
39
47
  ping(bucketName?: string): Promise<void>;
40
48
  deletePath(bucketName: string, prefix: string): Promise<void>;
41
49
  deletePaths(bucketName: string, prefixes: string[]): Promise<void>;
@@ -57,7 +65,9 @@ export declare class CloudStorage implements CommonStorage {
57
65
  copyFile(fromBucket: string, fromPath: string, toPath: string, toBucket?: string): Promise<void>;
58
66
  moveFile(fromBucket: string, fromPath: string, toPath: string, toBucket?: string): Promise<void>;
59
67
  movePath(fromBucket: string, fromPrefix: string, toPrefix: string, toBucket?: string): Promise<void>;
60
- combine(bucketName: string, filePaths: string[], toPath: string, toBucket?: string): Promise<void>;
68
+ deleteFiles(bucketName: string, filePaths: string[]): Promise<void>;
69
+ combineFiles(bucketName: string, filePaths: string[], toPath: string, toBucket?: string, currentRecursionDepth?: number): Promise<void>;
70
+ combine(bucketName: string, prefix: string, toPath: string, toBucket?: string): Promise<void>;
61
71
  /**
62
72
  * Acquires a "signed url", which allows bearer to use it to download ('read') the file.
63
73
  *
@@ -4,34 +4,43 @@ exports.CloudStorage = exports.Storage = void 0;
4
4
  const storage_1 = require("@google-cloud/storage");
5
5
  Object.defineProperty(exports, "Storage", { enumerable: true, get: function () { return storage_1.Storage; } });
6
6
  const js_lib_1 = require("@naturalcycles/js-lib");
7
+ const MAX_RECURSION_DEPTH = 10;
8
+ const BATCH_SIZE = 32;
7
9
  /**
8
10
  * CloudStorage implementation of CommonStorage API.
9
11
  *
10
12
  * API: https://googleapis.dev/nodejs/storage/latest/index.html
11
13
  */
12
14
  class CloudStorage {
13
- /**
14
- * Passing the pre-created Storage allows to instantiate it from both
15
- * GCP Storage and FirebaseStorage.
16
- */
17
- constructor(storage) {
15
+ constructor(storage, cfg = {}) {
18
16
  this.storage = storage;
17
+ this.cfg = {
18
+ logger: console,
19
+ ...cfg,
20
+ };
19
21
  }
20
- static createFromGCPServiceAccount(cfg) {
22
+ static createFromGCPServiceAccount(credentials, cfg) {
21
23
  const storage = new storage_1.Storage({
22
- credentials: cfg.credentials,
24
+ credentials,
23
25
  // Explicitly passing it here to fix this error:
24
26
  // Error: Unable to detect a Project Id in the current environment.
25
27
  // To learn more about authentication and Google APIs, visit:
26
28
  // https://cloud.google.com/docs/authentication/getting-started
27
29
  // at /root/repo/node_modules/google-auth-library/build/src/auth/googleauth.js:95:31
28
- projectId: cfg.credentials?.project_id,
30
+ projectId: credentials?.project_id,
29
31
  });
30
- return new CloudStorage(storage);
32
+ return new CloudStorage(storage, cfg);
31
33
  }
32
- static createFromStorageOptions(storageOptions) {
34
+ static createFromStorageOptions(storageOptions, cfg) {
33
35
  const storage = new storage_1.Storage(storageOptions);
34
- return new CloudStorage(storage);
36
+ return new CloudStorage(storage, cfg);
37
+ }
38
+ /**
39
+ * Passing the pre-created Storage allows to instantiate it from both
40
+ * GCP Storage and FirebaseStorage.
41
+ */
42
+ static createFromStorage(storage, cfg) {
43
+ return new CloudStorage(storage, cfg);
35
44
  }
36
45
  async ping(bucketName) {
37
46
  await this.storage.bucket(bucketName || 'non-existing-for-sure').exists();
@@ -155,14 +164,48 @@ class CloudStorage {
155
164
  await file.move(this.storage.bucket(toBucket || fromBucket).file(newName));
156
165
  });
157
166
  }
158
- async combine(bucketName, filePaths, toPath, toBucket) {
159
- // todo: if (filePaths.length > 32) - use recursive algorithm
160
- (0, js_lib_1._assert)(filePaths.length <= 32, 'combine supports up to 32 input files');
161
- await this.storage
162
- .bucket(bucketName)
163
- .combine(filePaths, this.storage.bucket(toBucket || bucketName).file(toPath));
164
- // Delete original files
165
- await this.deletePaths(bucketName, filePaths);
167
+ async deleteFiles(bucketName, filePaths) {
168
+ await (0, js_lib_1.pMap)(filePaths, async (filePath) => {
169
+ await this.storage.bucket(bucketName).file(filePath).delete();
170
+ });
171
+ }
172
+ async combineFiles(bucketName, filePaths, toPath, toBucket, currentRecursionDepth = 0) {
173
+ (0, js_lib_1._assert)(currentRecursionDepth <= MAX_RECURSION_DEPTH, `combineFiles reached max recursion depth of ${MAX_RECURSION_DEPTH}`);
174
+ const { logger, debug } = this.cfg;
175
+ if (debug) {
176
+ logger.log(`[${currentRecursionDepth}] Will compose ${filePaths.length} files, by batches of ${BATCH_SIZE}`);
177
+ }
178
+ const intermediateFiles = [];
179
+ if (filePaths.length <= BATCH_SIZE) {
180
+ await this.storage
181
+ .bucket(bucketName)
182
+ .combine(filePaths, this.storage.bucket(toBucket || bucketName).file(toPath));
183
+ if (debug) {
184
+ logger.log(`[${currentRecursionDepth}] Composed into ${toPath}!`);
185
+ }
186
+ await this.deleteFiles(bucketName, filePaths);
187
+ return;
188
+ }
189
+ const started = Date.now();
190
+ await (0, js_lib_1.pMap)((0, js_lib_1._chunk)(filePaths, BATCH_SIZE), async (fileBatch, i) => {
191
+ if (debug) {
192
+ logger.log(`[${currentRecursionDepth}] Composing batch ${i + 1}...`);
193
+ }
194
+ const intermediateFile = `temp_${currentRecursionDepth}_${i}`;
195
+ await this.storage
196
+ .bucket(bucketName)
197
+ .combine(fileBatch, this.storage.bucket(toBucket || bucketName).file(intermediateFile));
198
+ intermediateFiles.push(intermediateFile);
199
+ await this.deleteFiles(bucketName, fileBatch);
200
+ });
201
+ if (debug) {
202
+ logger.log(`[${currentRecursionDepth}] Batch composed into ${intermediateFiles.length} files, in ${(0, js_lib_1._since)(started)}`);
203
+ }
204
+ await this.combineFiles(toBucket || bucketName, intermediateFiles, toPath, toBucket, currentRecursionDepth + 1);
205
+ }
206
+ async combine(bucketName, prefix, toPath, toBucket) {
207
+ const filePaths = await this.getFileNames(bucketName, { prefix });
208
+ await this.combineFiles(bucketName, filePaths, toPath, toBucket);
166
209
  }
167
210
  /**
168
211
  * Acquires a "signed url", which allows bearer to use it to download ('read') the file.
@@ -56,6 +56,10 @@ export interface CommonStorage {
56
56
  */
57
57
  deletePath: (bucketName: string, prefix: string) => Promise<void>;
58
58
  deletePaths: (bucketName: string, prefixes: string[]) => Promise<void>;
59
+ /**
60
+ * Should delete all files by their paths.
61
+ */
62
+ deleteFiles: (bucketName: string, filePaths: string[]) => Promise<void>;
59
63
  /**
60
64
  * Returns an array of strings which are file paths.
61
65
  * Files that are not found by the path are not present in the map.
@@ -95,7 +99,11 @@ export interface CommonStorage {
95
99
  *
96
100
  * @experimental
97
101
  */
98
- combine: (bucketName: string, filePaths: string[], toPath: string, toBucket?: string) => Promise<void>;
102
+ combineFiles: (bucketName: string, filePaths: string[], toPath: string, toBucket?: string) => Promise<void>;
103
+ /**
104
+ * Like `combineFiles`, but for a `prefix`.
105
+ */
106
+ combine: (bucketName: string, prefix: string, toPath: string, toBucket?: string) => Promise<void>;
99
107
  /**
100
108
  * Acquire a "signed url", which allows bearer to use it to download ('read') the file.
101
109
  *
@@ -18,6 +18,7 @@ export declare class InMemoryCommonStorage implements CommonStorage {
18
18
  saveFile(bucketName: string, filePath: string, content: Buffer): Promise<void>;
19
19
  deletePath(bucketName: string, prefix: string): Promise<void>;
20
20
  deletePaths(bucketName: string, prefixes: string[]): Promise<void>;
21
+ deleteFiles(bucketName: string, filePaths: string[]): Promise<void>;
21
22
  getFileNames(bucketName: string, opt?: CommonStorageGetOptions): Promise<string[]>;
22
23
  getFileNamesStream(bucketName: string, opt?: CommonStorageGetOptions): ReadableTyped<string>;
23
24
  getFilesStream(bucketName: string, opt?: CommonStorageGetOptions): ReadableTyped<FileEntry>;
@@ -29,6 +30,7 @@ export declare class InMemoryCommonStorage implements CommonStorage {
29
30
  copyFile(fromBucket: string, fromPath: string, toPath: string, toBucket?: string): Promise<void>;
30
31
  moveFile(fromBucket: string, fromPath: string, toPath: string, toBucket?: string): Promise<void>;
31
32
  movePath(fromBucket: string, fromPrefix: string, toPrefix: string, toBucket?: string): Promise<void>;
32
- combine(bucketName: string, filePaths: string[], toPath: string, toBucket?: string): Promise<void>;
33
+ combine(bucketName: string, prefix: string, toPath: string, toBucket?: string): Promise<void>;
34
+ combineFiles(bucketName: string, filePaths: string[], toPath: string, toBucket?: string): Promise<void>;
33
35
  getSignedUrl(bucketName: string, filePath: string, expires: LocalTimeInput): Promise<string>;
34
36
  }
@@ -39,6 +39,11 @@ class InMemoryCommonStorage {
39
39
  }
40
40
  });
41
41
  }
42
+ async deleteFiles(bucketName, filePaths) {
43
+ if (!this.data[bucketName])
44
+ return;
45
+ filePaths.forEach(filePath => delete this.data[bucketName][filePath]);
46
+ }
42
47
  async getFileNames(bucketName, opt = {}) {
43
48
  const { prefix = '', fullPaths = true } = opt;
44
49
  return Object.keys(this.data[bucketName] || {})
@@ -104,7 +109,11 @@ class InMemoryCommonStorage {
104
109
  delete this.data[fromBucket][filePath];
105
110
  });
106
111
  }
107
- async combine(bucketName, filePaths, toPath, toBucket) {
112
+ async combine(bucketName, prefix, toPath, toBucket) {
113
+ const filePaths = await this.getFileNames(bucketName, { prefix });
114
+ await this.combineFiles(bucketName, filePaths, toPath, toBucket);
115
+ }
116
+ async combineFiles(bucketName, filePaths, toPath, toBucket) {
108
117
  if (!this.data[bucketName])
109
118
  return;
110
119
  const tob = toBucket || bucketName;
package/package.json CHANGED
@@ -35,7 +35,7 @@
35
35
  "engines": {
36
36
  "node": ">=18.12.0"
37
37
  },
38
- "version": "1.9.2",
38
+ "version": "1.10.0",
39
39
  "description": "CommonStorage implementation based on Google Cloud Storage",
40
40
  "author": "Natural Cycles Team",
41
41
  "license": "MIT"
@@ -2,7 +2,10 @@ import { Readable, Writable } from 'node:stream'
2
2
  import { File, Storage, StorageOptions } from '@google-cloud/storage'
3
3
  import {
4
4
  _assert,
5
+ _chunk,
6
+ _since,
5
7
  _substringAfterLast,
8
+ CommonLogger,
6
9
  localTime,
7
10
  LocalTimeInput,
8
11
  pMap,
@@ -18,6 +21,9 @@ export {
18
21
  type StorageOptions,
19
22
  }
20
23
 
24
+ const MAX_RECURSION_DEPTH = 10
25
+ const BATCH_SIZE = 32
26
+
21
27
  /**
22
28
  * This object is intentionally made to NOT extend StorageOptions,
23
29
  * because StorageOptions is complicated and provides just too many ways
@@ -29,9 +35,14 @@ export {
29
35
  */
30
36
  export interface CloudStorageCfg {
31
37
  /**
32
- * It's optional, to allow automatic credentials in AppEngine, or GOOGLE_APPLICATION_CREDENTIALS.
38
+ * Default is console
39
+ */
40
+ logger?: CommonLogger
41
+
42
+ /**
43
+ * Pass true for extra debugging
33
44
  */
34
- credentials?: GCPServiceAccount
45
+ debug?: boolean
35
46
  }
36
47
 
37
48
  /**
@@ -40,29 +51,51 @@ export interface CloudStorageCfg {
40
51
  * API: https://googleapis.dev/nodejs/storage/latest/index.html
41
52
  */
42
53
  export class CloudStorage implements CommonStorage {
43
- /**
44
- * Passing the pre-created Storage allows to instantiate it from both
45
- * GCP Storage and FirebaseStorage.
46
- */
47
- constructor(public storage: Storage) {}
54
+ private constructor(
55
+ public storage: Storage,
56
+ cfg: CloudStorageCfg = {},
57
+ ) {
58
+ this.cfg = {
59
+ logger: console,
60
+ ...cfg,
61
+ }
62
+ }
48
63
 
49
- static createFromGCPServiceAccount(cfg: CloudStorageCfg): CloudStorage {
64
+ cfg: CloudStorageCfg & {
65
+ logger: CommonLogger
66
+ }
67
+
68
+ static createFromGCPServiceAccount(
69
+ credentials?: GCPServiceAccount,
70
+ cfg?: CloudStorageCfg,
71
+ ): CloudStorage {
50
72
  const storage = new Storage({
51
- credentials: cfg.credentials,
73
+ credentials,
52
74
  // Explicitly passing it here to fix this error:
53
75
  // Error: Unable to detect a Project Id in the current environment.
54
76
  // To learn more about authentication and Google APIs, visit:
55
77
  // https://cloud.google.com/docs/authentication/getting-started
56
78
  // at /root/repo/node_modules/google-auth-library/build/src/auth/googleauth.js:95:31
57
- projectId: cfg.credentials?.project_id,
79
+ projectId: credentials?.project_id,
58
80
  })
59
81
 
60
- return new CloudStorage(storage)
82
+ return new CloudStorage(storage, cfg)
61
83
  }
62
84
 
63
- static createFromStorageOptions(storageOptions?: StorageOptions): CloudStorage {
85
+ static createFromStorageOptions(
86
+ storageOptions?: StorageOptions,
87
+ cfg?: CloudStorageCfg,
88
+ ): CloudStorage {
64
89
  const storage = new Storage(storageOptions)
65
- return new CloudStorage(storage)
90
+ return new CloudStorage(storage, cfg)
91
+ }
92
+
93
+ /**
94
+ * Passing the pre-created Storage allows to instantiate it from both
95
+ * GCP Storage and FirebaseStorage.
96
+ */
97
+ static createFromStorage(storage: Storage, cfg?: CloudStorageCfg): CloudStorage {
98
+ return new CloudStorage(storage, cfg)
66
99
  }
67
100
 
68
101
  async ping(bucketName?: string): Promise<void> {
@@ -235,21 +268,81 @@ export class CloudStorage implements CommonStorage {
235
268
  })
236
269
  }
237
270
 
238
- async combine(
271
+ async deleteFiles(bucketName: string, filePaths: string[]): Promise<void> {
272
+ await pMap(filePaths, async filePath => {
273
+ await this.storage.bucket(bucketName).file(filePath).delete()
274
+ })
275
+ }
276
+
277
+ async combineFiles(
239
278
  bucketName: string,
240
279
  filePaths: string[],
241
280
  toPath: string,
242
281
  toBucket?: string,
282
+ currentRecursionDepth = 0, // not to be set publicly, only used internally
243
283
  ): Promise<void> {
244
- // todo: if (filePaths.length > 32) - use recursive algorithm
245
- _assert(filePaths.length <= 32, 'combine supports up to 32 input files')
284
+ _assert(
285
+ currentRecursionDepth <= MAX_RECURSION_DEPTH,
286
+ `combineFiles reached max recursion depth of ${MAX_RECURSION_DEPTH}`,
287
+ )
288
+ const { logger, debug } = this.cfg
246
289
 
247
- await this.storage
248
- .bucket(bucketName)
249
- .combine(filePaths, this.storage.bucket(toBucket || bucketName).file(toPath))
290
+ if (debug) {
291
+ logger.log(
292
+ `[${currentRecursionDepth}] Will compose ${filePaths.length} files, by batches of ${BATCH_SIZE}`,
293
+ )
294
+ }
295
+
296
+ const intermediateFiles: string[] = []
250
297
 
251
- // Delete original files
252
- await this.deletePaths(bucketName, filePaths)
298
+ if (filePaths.length <= BATCH_SIZE) {
299
+ await this.storage
300
+ .bucket(bucketName)
301
+ .combine(filePaths, this.storage.bucket(toBucket || bucketName).file(toPath))
302
+
303
+ if (debug) {
304
+ logger.log(`[${currentRecursionDepth}] Composed into ${toPath}!`)
305
+ }
306
+
307
+ await this.deleteFiles(bucketName, filePaths)
308
+ return
309
+ }
310
+
311
+ const started = Date.now()
312
+ await pMap(_chunk(filePaths, BATCH_SIZE), async (fileBatch, i) => {
313
+ if (debug) {
314
+ logger.log(`[${currentRecursionDepth}] Composing batch ${i + 1}...`)
315
+ }
316
+ const intermediateFile = `temp_${currentRecursionDepth}_${i}`
317
+ await this.storage
318
+ .bucket(bucketName)
319
+ .combine(fileBatch, this.storage.bucket(toBucket || bucketName).file(intermediateFile))
320
+ intermediateFiles.push(intermediateFile)
321
+ await this.deleteFiles(bucketName, fileBatch)
322
+ })
323
+ if (debug) {
324
+ logger.log(
325
+ `[${currentRecursionDepth}] Batch composed into ${intermediateFiles.length} files, in ${_since(started)}`,
326
+ )
327
+ }
328
+
329
+ await this.combineFiles(
330
+ toBucket || bucketName,
331
+ intermediateFiles,
332
+ toPath,
333
+ toBucket,
334
+ currentRecursionDepth + 1,
335
+ )
336
+ }
337
+
338
+ async combine(
339
+ bucketName: string,
340
+ prefix: string,
341
+ toPath: string,
342
+ toBucket?: string,
343
+ ): Promise<void> {
344
+ const filePaths = await this.getFileNames(bucketName, { prefix })
345
+ await this.combineFiles(bucketName, filePaths, toPath, toBucket)
253
346
  }
254
347
 
255
348
  /**
@@ -69,6 +69,11 @@ export interface CommonStorage {
69
69
 
70
70
  deletePaths: (bucketName: string, prefixes: string[]) => Promise<void>
71
71
 
72
+ /**
73
+ * Should delete all files by their paths.
74
+ */
75
+ deleteFiles: (bucketName: string, filePaths: string[]) => Promise<void>
76
+
72
77
  /**
73
78
  * Returns an array of strings which are file paths.
74
79
  * Files that are not found by the path are not present in the map.
@@ -134,13 +139,18 @@ export interface CommonStorage {
134
139
  *
135
140
  * @experimental
136
141
  */
137
- combine: (
142
+ combineFiles: (
138
143
  bucketName: string,
139
144
  filePaths: string[],
140
145
  toPath: string,
141
146
  toBucket?: string,
142
147
  ) => Promise<void>
143
148
 
149
+ /**
150
+ * Like `combineFiles`, but for a `prefix`.
151
+ */
152
+ combine: (bucketName: string, prefix: string, toPath: string, toBucket?: string) => Promise<void>
153
+
144
154
  /**
145
155
  * Acquire a "signed url", which allows bearer to use it to download ('read') the file.
146
156
  *
@@ -54,6 +54,11 @@ export class InMemoryCommonStorage implements CommonStorage {
54
54
  })
55
55
  }
56
56
 
57
+ async deleteFiles(bucketName: string, filePaths: string[]): Promise<void> {
58
+ if (!this.data[bucketName]) return
59
+ filePaths.forEach(filePath => delete this.data[bucketName]![filePath])
60
+ }
61
+
57
62
  async getFileNames(bucketName: string, opt: CommonStorageGetOptions = {}): Promise<string[]> {
58
63
  const { prefix = '', fullPaths = true } = opt
59
64
  return Object.keys(this.data[bucketName] || {})
@@ -156,6 +161,16 @@ export class InMemoryCommonStorage implements CommonStorage {
156
161
  }
157
162
 
158
163
  async combine(
164
+ bucketName: string,
165
+ prefix: string,
166
+ toPath: string,
167
+ toBucket?: string,
168
+ ): Promise<void> {
169
+ const filePaths = await this.getFileNames(bucketName, { prefix })
170
+ await this.combineFiles(bucketName, filePaths, toPath, toBucket)
171
+ }
172
+
173
+ async combineFiles(
159
174
  bucketName: string,
160
175
  filePaths: string[],
161
176
  toPath: string,