@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.
- package/dist/cloudStorage.d.ts +43 -0
- package/dist/cloudStorage.js +118 -0
- package/dist/commonStorage.d.ts +74 -0
- package/dist/commonStorage.js +2 -0
- package/dist/commonStorageBucket.d.ts +60 -0
- package/dist/commonStorageBucket.js +131 -0
- package/dist/commonStorageKeyValueDB.d.ts +32 -0
- package/dist/commonStorageKeyValueDB.js +87 -0
- package/dist/inMemoryCommonStorage.d.ts +28 -0
- package/dist/inMemoryCommonStorage.js +82 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +13 -0
- package/dist/model.d.ts +5 -0
- package/dist/model.js +2 -0
- package/dist/testing/commonStorageTest.d.ts +5 -0
- package/dist/testing/commonStorageTest.js +108 -0
- package/package.json +41 -0
- package/readme.md +20 -0
- package/src/cloudStorage.ts +171 -0
- package/src/commonStorage.ts +103 -0
- package/src/commonStorageBucket.ts +171 -0
- package/src/commonStorageKeyValueDB.ts +109 -0
- package/src/inMemoryCommonStorage.ts +115 -0
- package/src/index.ts +24 -0
- package/src/model.ts +5 -0
- package/src/testing/commonStorageTest.ts +134 -0
|
@@ -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,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
|
+
}
|