@naturalcycles/cloud-storage-lib 1.0.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.
@@ -0,0 +1,109 @@
1
+ import * as Buffer from 'buffer'
2
+ import { CommonDBCreateOptions, CommonKeyValueDB, KeyValueDBTuple } from '@naturalcycles/db-lib'
3
+ import { pMap, StringMap } from '@naturalcycles/js-lib'
4
+ import { ReadableTyped, transformMapSimple } from '@naturalcycles/nodejs-lib'
5
+ import { CommonStorage, FileEntry } from './commonStorage'
6
+
7
+ export interface CommonStorageKeyValueDBCfg {
8
+ storage: CommonStorage
9
+ bucketName: string
10
+ }
11
+
12
+ /**
13
+ * CommonKeyValueDB, backed up by a CommonStorage implementation.
14
+ *
15
+ * Each Table is represented as a Folder.
16
+ * Each Item is represented as a File:
17
+ * fileName is ${id} (without extension)
18
+ * file contents is ${v} (Buffer)
19
+ */
20
+ export class CommonStorageKeyValueDB implements CommonKeyValueDB {
21
+ constructor(public cfg: CommonStorageKeyValueDBCfg) {}
22
+
23
+ async ping(): Promise<void> {
24
+ await this.cfg.storage.ping(this.cfg.bucketName)
25
+ }
26
+
27
+ async createTable(_table: string, _opt?: CommonDBCreateOptions): Promise<void> {
28
+ // no-op
29
+ }
30
+
31
+ /**
32
+ * Allows to pass `SomeBucket.SomeTable` in `table`, to override a Bucket.
33
+ */
34
+ private getBucketAndPrefix(table: string): { bucketName: string; prefix: string } {
35
+ const [part1, part2] = table.split('.')
36
+
37
+ if (part2) {
38
+ return {
39
+ bucketName: part1!,
40
+ prefix: part2,
41
+ }
42
+ }
43
+
44
+ // As is
45
+ return {
46
+ bucketName: this.cfg.bucketName,
47
+ prefix: table,
48
+ }
49
+ }
50
+
51
+ async deleteByIds(table: string, ids: string[]): Promise<void> {
52
+ const { bucketName, prefix } = this.getBucketAndPrefix(table)
53
+ await pMap(ids, async id => {
54
+ await this.cfg.storage.deletePath(bucketName, [prefix, id].join('/'))
55
+ })
56
+ }
57
+
58
+ async getByIds(table: string, ids: string[]): Promise<KeyValueDBTuple[]> {
59
+ const { bucketName, prefix } = this.getBucketAndPrefix(table)
60
+
61
+ const map: StringMap<Buffer> = {}
62
+
63
+ await pMap(ids, async id => {
64
+ const buf = await this.cfg.storage.getFile(bucketName, [prefix, id].join('/'))
65
+ if (buf) map[id] = buf
66
+ })
67
+
68
+ return ids.map(id => [id, map[id]] as KeyValueDBTuple).filter(t => t[1])
69
+ }
70
+
71
+ async saveBatch(table: string, entries: KeyValueDBTuple[]): Promise<void> {
72
+ const { bucketName, prefix } = this.getBucketAndPrefix(table)
73
+
74
+ await pMap(entries, async ([id, content]) => {
75
+ await this.cfg.storage.saveFile(bucketName, [prefix, id].join('/'), content)
76
+ })
77
+ }
78
+
79
+ streamIds(table: string, limit?: number): ReadableTyped<string> {
80
+ const { bucketName, prefix } = this.getBucketAndPrefix(table)
81
+ const index = prefix.length + 1
82
+
83
+ return this.cfg.storage
84
+ .getFileNamesStream(bucketName, prefix, { limit })
85
+ .pipe(transformMapSimple<string, string>(f => f.slice(index)))
86
+ }
87
+
88
+ streamValues(table: string, limit?: number): ReadableTyped<Buffer> {
89
+ const { bucketName, prefix } = this.getBucketAndPrefix(table)
90
+
91
+ return this.cfg.storage
92
+ .getFilesStream(bucketName, prefix, { limit })
93
+ .pipe(transformMapSimple<FileEntry, Buffer>(f => f.content))
94
+ }
95
+
96
+ streamEntries(table: string, limit?: number): ReadableTyped<KeyValueDBTuple> {
97
+ const { bucketName, prefix } = this.getBucketAndPrefix(table)
98
+ const index = prefix.length + 1
99
+
100
+ return this.cfg.storage
101
+ .getFilesStream(bucketName, prefix, { limit })
102
+ .pipe(
103
+ transformMapSimple<FileEntry, KeyValueDBTuple>(({ filePath, content }) => [
104
+ filePath.slice(index),
105
+ content,
106
+ ]),
107
+ )
108
+ }
109
+ }
@@ -0,0 +1,115 @@
1
+ import { Readable, Writable } from 'stream'
2
+ import { StringMap } from '@naturalcycles/js-lib'
3
+ import { ReadableTyped } from '@naturalcycles/nodejs-lib'
4
+ import { CommonStorage, CommonStorageGetOptions, FileEntry } from './commonStorage'
5
+
6
+ export class InMemoryCommonStorage implements CommonStorage {
7
+ /**
8
+ * data[bucketName][filePath] = Buffer
9
+ */
10
+ data: StringMap<StringMap<Buffer>> = {}
11
+
12
+ publicMap: StringMap<StringMap<boolean>> = {}
13
+
14
+ async ping(): Promise<void> {}
15
+
16
+ async getBucketNames(): Promise<string[]> {
17
+ return Object.keys(this.data)
18
+ }
19
+
20
+ getBucketNamesStream(): ReadableTyped<string> {
21
+ return Readable.from(Object.keys(this.data))
22
+ }
23
+
24
+ async fileExists(bucketName: string, filePath: string): Promise<boolean> {
25
+ return !!this.data[bucketName]?.[filePath]
26
+ }
27
+
28
+ async getFile(bucketName: string, filePath: string): Promise<Buffer | null> {
29
+ return this.data[bucketName]?.[filePath] || null
30
+ }
31
+
32
+ async saveFile(bucketName: string, filePath: string, content: Buffer): Promise<void> {
33
+ this.data[bucketName] ||= {}
34
+ this.data[bucketName]![filePath] = content
35
+ }
36
+
37
+ async deletePath(bucketName: string, prefix: string): Promise<void> {
38
+ Object.keys(this.data[bucketName] || {}).forEach(filePath => {
39
+ if (filePath.startsWith(prefix)) {
40
+ delete this.data[bucketName]![filePath]
41
+ }
42
+ })
43
+ }
44
+
45
+ async getFileNames(bucketName: string, prefix: string): Promise<string[]> {
46
+ return Object.keys(this.data[bucketName] || {}).filter(filePath => filePath.startsWith(prefix))
47
+ }
48
+
49
+ getFileNamesStream(
50
+ bucketName: string,
51
+ prefix: string,
52
+ opt: CommonStorageGetOptions = {},
53
+ ): ReadableTyped<string> {
54
+ return Readable.from(
55
+ Object.keys(this.data[bucketName] || {})
56
+ .filter(filePath => filePath.startsWith(prefix))
57
+ .slice(0, opt.limit),
58
+ )
59
+ }
60
+
61
+ getFilesStream(
62
+ bucketName: string,
63
+ prefix: string,
64
+ opt: CommonStorageGetOptions = {},
65
+ ): ReadableTyped<FileEntry> {
66
+ return Readable.from(
67
+ Object.entries(this.data[bucketName] || {})
68
+ .map(([filePath, content]) => ({ filePath, content }))
69
+ .filter(f => f.filePath.startsWith(prefix))
70
+ .slice(0, opt.limit),
71
+ )
72
+ }
73
+
74
+ getFileReadStream(bucketName: string, filePath: string): Readable {
75
+ return Readable.from(this.data[bucketName]![filePath]!)
76
+ }
77
+
78
+ getFileWriteStream(_bucketName: string, _filePath: string): Writable {
79
+ throw new Error('Method not implemented.')
80
+ }
81
+
82
+ async setFileVisibility(bucketName: string, filePath: string, isPublic: boolean): Promise<void> {
83
+ this.publicMap[bucketName] ||= {}
84
+ this.publicMap[bucketName]![filePath] = isPublic
85
+ }
86
+
87
+ async getFileVisibility(bucketName: string, filePath: string): Promise<boolean> {
88
+ return !!this.publicMap[bucketName]?.[filePath]
89
+ }
90
+
91
+ async copyFile(
92
+ fromBucket: string,
93
+ fromPath: string,
94
+ toPath: string,
95
+ toBucket?: string,
96
+ ): Promise<void> {
97
+ const tob = toBucket || fromBucket
98
+ this.data[fromBucket] ||= {}
99
+ this.data[tob] ||= {}
100
+ this.data[tob]![toPath] = this.data[fromBucket]![fromPath]
101
+ }
102
+
103
+ async moveFile(
104
+ fromBucket: string,
105
+ fromPath: string,
106
+ toPath: string,
107
+ toBucket?: string,
108
+ ): Promise<void> {
109
+ const tob = toBucket || fromBucket
110
+ this.data[fromBucket] ||= {}
111
+ this.data[tob] ||= {}
112
+ this.data[tob]![toPath] = this.data[fromBucket]![fromPath]
113
+ delete this.data[fromBucket]![fromPath]
114
+ }
115
+ }
package/src/index.ts ADDED
@@ -0,0 +1,24 @@
1
+ import { CloudStorage, CloudStorageCfg } from './cloudStorage'
2
+ import { CommonStorage, CommonStorageGetOptions } from './commonStorage'
3
+ import { CommonStorageBucket, CommonStorageBucketCfg } from './commonStorageBucket'
4
+ import { CommonStorageKeyValueDB, CommonStorageKeyValueDBCfg } from './commonStorageKeyValueDB'
5
+ import { InMemoryCommonStorage } from './inMemoryCommonStorage'
6
+ import { GCPServiceAccount } from './model'
7
+ import { runCommonStorageTest } from './testing/commonStorageTest'
8
+
9
+ export type {
10
+ CommonStorage,
11
+ CloudStorageCfg,
12
+ CommonStorageGetOptions,
13
+ GCPServiceAccount,
14
+ CommonStorageBucketCfg,
15
+ CommonStorageKeyValueDBCfg,
16
+ }
17
+
18
+ export {
19
+ CloudStorage,
20
+ CommonStorageKeyValueDB,
21
+ CommonStorageBucket,
22
+ InMemoryCommonStorage,
23
+ runCommonStorageTest,
24
+ }
package/src/model.ts ADDED
@@ -0,0 +1,5 @@
1
+ export interface GCPServiceAccount {
2
+ client_email: string
3
+ private_key: string
4
+ project_id: string
5
+ }
@@ -0,0 +1,134 @@
1
+ import { _range, pMap, StringMap } from '@naturalcycles/js-lib'
2
+ import { readableToArray } from '@naturalcycles/nodejs-lib'
3
+ import { CommonStorage, FileEntry } from '../commonStorage'
4
+
5
+ const TEST_FOLDER = 'test/subdir'
6
+
7
+ const TEST_ITEMS = _range(10).map(n => ({
8
+ id: `id_${n + 1}`,
9
+ n,
10
+ even: n % 2 === 0,
11
+ }))
12
+
13
+ const TEST_ITEMS2 = _range(10).map(n => ({
14
+ fileType: 2,
15
+ id: `id_${n + 1}`,
16
+ n,
17
+ even: n % 2 === 0,
18
+ }))
19
+
20
+ const TEST_ITEMS3 = _range(10).map(n => ({
21
+ fileType: 3,
22
+ id: `id_${n + 1}`,
23
+ n,
24
+ even: n % 2 === 0,
25
+ }))
26
+
27
+ const TEST_FILES: FileEntry[] = [TEST_ITEMS, TEST_ITEMS2, TEST_ITEMS3].map((obj, i) => ({
28
+ filePath: `${TEST_FOLDER}/file_${i + 1}.json`,
29
+ content: Buffer.from(JSON.stringify(obj)),
30
+ }))
31
+
32
+ /**
33
+ * This test suite must be idempotent.
34
+ */
35
+ export function runCommonStorageTest(storage: CommonStorage, bucketName: string): void {
36
+ // test('createBucket', async () => {
37
+ // await storage.createBucket(bucketName)
38
+ // })
39
+
40
+ test('ping', async () => {
41
+ await storage.ping()
42
+ })
43
+
44
+ test('listBuckets', async () => {
45
+ const buckets = await storage.getBucketNames()
46
+ console.log(buckets)
47
+ })
48
+
49
+ test('streamBuckets', async () => {
50
+ const buckets = await readableToArray(storage.getBucketNamesStream())
51
+ console.log(buckets)
52
+ })
53
+
54
+ test('prepare: clear bucket', async () => {
55
+ await pMap(
56
+ TEST_FILES.map(f => f.filePath),
57
+ async filePath => await storage.deletePath(bucketName, filePath),
58
+ )
59
+ })
60
+
61
+ test('listFileNames on root should return empty', async () => {
62
+ const fileNames = await storage.getFileNames(bucketName, '')
63
+ expect(fileNames).toEqual([])
64
+ })
65
+
66
+ test(`listFileNames on ${TEST_FOLDER} should return empty`, async () => {
67
+ const fileNames = await storage.getFileNames(bucketName, TEST_FOLDER)
68
+ expect(fileNames).toEqual([])
69
+ })
70
+
71
+ test('streamFileNames on root should return empty', async () => {
72
+ const fileNames = await readableToArray(storage.getFileNamesStream(bucketName, ''))
73
+ expect(fileNames).toEqual([])
74
+ })
75
+
76
+ test(`exists should return empty array`, async () => {
77
+ await pMap(TEST_FILES, async f => {
78
+ const exists = await storage.fileExists(bucketName, f.filePath)
79
+ expect(exists).toBe(false)
80
+ })
81
+ })
82
+
83
+ test(`saveFiles, then listFileNames, streamFileNames and getFiles should return just saved files`, async () => {
84
+ const testFilesMap = Object.fromEntries(TEST_FILES.map(f => [f.filePath, f.content]))
85
+
86
+ // It's done in the same test to ensure "strong consistency"
87
+ await pMap(TEST_FILES, async f => await storage.saveFile(bucketName, f.filePath, f.content))
88
+
89
+ const fileNames = await storage.getFileNames(bucketName, TEST_FOLDER)
90
+ expect(fileNames.sort()).toEqual(TEST_FILES.map(f => f.filePath).sort())
91
+
92
+ const streamedFileNames = await readableToArray(
93
+ storage.getFileNamesStream(bucketName, TEST_FOLDER),
94
+ )
95
+ expect(streamedFileNames.sort()).toEqual(TEST_FILES.map(f => f.filePath).sort())
96
+
97
+ const filesMap: StringMap<Buffer> = {}
98
+
99
+ await pMap(fileNames, async filePath => {
100
+ filesMap[filePath] = (await storage.getFile(bucketName, filePath))!
101
+ })
102
+
103
+ expect(filesMap).toEqual(testFilesMap)
104
+
105
+ await pMap(fileNames, async filePath => {
106
+ const exists = await storage.fileExists(bucketName, filePath)
107
+ expect(exists).toBe(true)
108
+ })
109
+ })
110
+
111
+ test('cleanup', async () => {
112
+ await storage.deletePath(bucketName, '')
113
+ })
114
+
115
+ // Cannot update access control for an object when uniform bucket-level access is enabled. Read more at https://cloud.google.com/storage/docs/uniform-bucket-level-access
116
+ /*
117
+ test(`get/set FilesVisibility`, async () => {
118
+ const fileNames = TEST_FILES.map(f => f.filePath)
119
+
120
+ let map = await storage.getFilesVisibility(bucketName, fileNames)
121
+ expect(map).toEqual(Object.fromEntries(fileNames.map(f => [f, false])))
122
+
123
+ await storage.setFilesVisibility(bucketName, fileNames, true)
124
+
125
+ map = await storage.getFilesVisibility(bucketName, fileNames)
126
+ expect(map).toEqual(Object.fromEntries(fileNames.map(f => [f, true])))
127
+
128
+ await storage.setFilesVisibility(bucketName, fileNames, false)
129
+
130
+ map = await storage.getFilesVisibility(bucketName, fileNames)
131
+ expect(map).toEqual(Object.fromEntries(fileNames.map(f => [f, false])))
132
+ })
133
+ */
134
+ }