@toa.io/extensions.storages 0.20.0-alpha.3

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,445 @@
1
+ import { Readable } from 'node:stream'
2
+ import { match } from '@toa.io/match'
3
+ import { buffer } from '@toa.io/generic'
4
+ import { Storage } from './Storage'
5
+ import { cases, open, rnd } from './.test/util'
6
+ import { type Entry } from './Entry'
7
+ import { providers } from './providers'
8
+
9
+ let storage: Storage
10
+ let dir: string
11
+
12
+ describe.each(cases)('%s', (_, url, secrets) => {
13
+ beforeEach(() => {
14
+ dir = '/' + rnd()
15
+
16
+ const Provider = providers[url.protocol]
17
+ const provider = new Provider(url, secrets)
18
+
19
+ storage = new Storage(provider)
20
+ })
21
+
22
+ it('should be', async () => {
23
+ expect(storage).toBeInstanceOf(Storage)
24
+ })
25
+
26
+ it('should return error if entry is not found', async () => {
27
+ const result = await storage.get('not-found')
28
+
29
+ match(result,
30
+ Error, (error: Error) => expect(error.message).toBe('NOT_FOUND'))
31
+ })
32
+
33
+ describe('put', () => {
34
+ let lenna: Entry
35
+
36
+ beforeEach(async () => {
37
+ const stream = await open('lenna.png')
38
+
39
+ lenna = await storage.put(dir, stream) as Entry
40
+ })
41
+
42
+ it('should not return error', async () => {
43
+ expect(lenna).not.toBeInstanceOf(Error)
44
+ })
45
+
46
+ it('should return entry id', async () => {
47
+ expect(lenna.id).toBeDefined()
48
+ })
49
+
50
+ it('should return id as checksum', async () => {
51
+ const stream = await open('lenna.png')
52
+ const dir2 = '/' + rnd()
53
+ const copy = await storage.put(dir2, stream) as Entry
54
+
55
+ expect(copy.id).toBe(lenna.id)
56
+ })
57
+
58
+ it('should detect file type', async () => {
59
+ expect(lenna.type).toBe('image/png')
60
+ })
61
+
62
+ it('should count size', async () => {
63
+ expect(lenna.size).toBe(473831)
64
+ })
65
+
66
+ it('should return entry', async () => {
67
+ expect(lenna).toMatchObject({
68
+ id: lenna.id,
69
+ type: 'image/png',
70
+ variants: [],
71
+ meta: {}
72
+ })
73
+ })
74
+
75
+ it('should create entry', async () => {
76
+ const entry = await storage.get(`${dir}/${lenna.id}`)
77
+
78
+ match(entry,
79
+ {
80
+ id: lenna.id,
81
+ type: 'image/png',
82
+ variants: [],
83
+ meta: {}
84
+ }, undefined)
85
+ })
86
+
87
+ it('should set timestamp', async () => {
88
+ const now = Date.now()
89
+ const entry = await storage.get(`${dir}/${lenna.id}`) as Entry
90
+
91
+ expect(entry.created).toBeLessThanOrEqual(now)
92
+ expect(entry.created).toBeGreaterThan(now - 100)
93
+ })
94
+
95
+ describe('existing entry', () => {
96
+ it('should unhide existing', async () => {
97
+ const stream = await open('lenna.png')
98
+ const path = `${dir}/${lenna.id}`
99
+
100
+ await storage.conceal(path)
101
+ await storage.put(dir, stream)
102
+
103
+ const list = await storage.list(dir)
104
+
105
+ expect(list).toContainEqual(lenna.id)
106
+ })
107
+
108
+ it('should preserve meta', async () => {
109
+ const path = `${dir}/${lenna.id}`
110
+ const stream = await open('lenna.png')
111
+
112
+ await storage.annotate(path, 'foo', 'bar')
113
+ await storage.put(dir, stream)
114
+
115
+ const entry = await storage.get(path) as Entry
116
+
117
+ expect(entry.meta).toMatchObject({ foo: 'bar' })
118
+ })
119
+ })
120
+ })
121
+
122
+ describe('list', () => {
123
+ let albert: Entry
124
+ let lenna: Entry
125
+
126
+ beforeEach(async () => {
127
+ const stream0 = await open('albert.jpg')
128
+ const stream1 = await open('lenna.png')
129
+
130
+ albert = await storage.put(dir, stream0) as Entry
131
+ lenna = await storage.put(dir, stream1) as Entry
132
+ })
133
+
134
+ it('should list entries', async () => {
135
+ const list = await storage.list(dir)
136
+
137
+ expect(list).toMatchObject([albert.id, lenna.id])
138
+ })
139
+
140
+ it('should permutate', async () => {
141
+ const error = await storage.reorder(dir, [lenna.id, albert.id])
142
+
143
+ expect(error).toBeUndefined()
144
+
145
+ const list = await storage.list(dir)
146
+
147
+ expect(list).toMatchObject([lenna.id, albert.id])
148
+ })
149
+
150
+ it('should return PERMUTATION_MISMATCH', async () => {
151
+ const cases = [
152
+ [lenna.id],
153
+ [albert.id, lenna.id, 'unknown'],
154
+ [lenna.id, lenna.id],
155
+ [lenna.id, lenna.id, albert.id]
156
+ ]
157
+
158
+ for (const permutation of cases) {
159
+ const error = await storage.reorder(dir, permutation)
160
+
161
+ expect(error).toBeInstanceOf(Error)
162
+ expect(error).toMatchObject({ message: 'PERMUTATION_MISMATCH' })
163
+ }
164
+ })
165
+
166
+ it('should exclude concealed', async () => {
167
+ const path = `${dir}/${lenna.id}`
168
+
169
+ await storage.conceal(path)
170
+
171
+ const entries = await storage.list(dir)
172
+
173
+ expect(entries).toMatchObject([albert.id])
174
+ })
175
+
176
+ it('should reveal', async () => {
177
+ const path = `${dir}/${lenna.id}`
178
+
179
+ await storage.conceal(path)
180
+ await storage.reveal(path)
181
+ await storage.reveal(path) // test that no duplicates are created
182
+
183
+ const entries = await storage.list(dir)
184
+
185
+ expect(entries).toMatchObject([albert.id, lenna.id])
186
+ })
187
+
188
+ it('should return ERR_NOT_FOOUD if entry doesnt exist', async () => {
189
+ const path = `${dir}/oopsie`
190
+
191
+ const methods: Array<'reveal' | 'conceal'> = ['reveal', 'conceal']
192
+
193
+ for (const method of methods) {
194
+ const error = await storage[method](path)
195
+
196
+ expect(error).toBeInstanceOf(Error)
197
+ expect(error).toMatchObject({ message: 'NOT_FOUND' })
198
+ }
199
+ })
200
+ })
201
+
202
+ describe('annotate', () => {
203
+ let lenna: Entry
204
+
205
+ beforeEach(async () => {
206
+ const stream = await open('lenna.png')
207
+
208
+ lenna = await storage.put(dir, stream) as Entry
209
+ })
210
+
211
+ it('should set meta', async () => {
212
+ const path = `${dir}/${lenna.id}`
213
+
214
+ await storage.annotate(path, 'foo', 'bar')
215
+
216
+ const state0 = await storage.get(path) as Entry
217
+
218
+ expect(state0.meta).toMatchObject({ foo: 'bar' })
219
+
220
+ await storage.annotate(path, 'foo')
221
+
222
+ const state1 = await storage.get(path) as Entry
223
+
224
+ expect('foo' in state1.meta).toBe(false)
225
+ })
226
+ })
227
+
228
+ describe('variants', () => {
229
+ let lenna: Entry
230
+
231
+ beforeEach(async () => {
232
+ const stream = await open('lenna.png')
233
+
234
+ lenna = await storage.put(dir, stream) as Entry
235
+ })
236
+
237
+ it('should add variant', async () => {
238
+ const stream = await open('sample.jpeg')
239
+
240
+ const path = `${dir}/${lenna.id}`
241
+
242
+ await storage.diversify(path, 'foo', stream)
243
+
244
+ const state = await storage.get(path) as Entry
245
+
246
+ expect(state.variants).toMatchObject([{ name: 'foo', size: 73444, type: 'image/jpeg' }])
247
+ })
248
+
249
+ it('should replace variant', async () => {
250
+ const stream0 = await open('sample.jpeg')
251
+ const stream1 = await open('sample.webp')
252
+ const path = `${dir}/${lenna.id}`
253
+
254
+ await storage.diversify(path, 'foo', stream0)
255
+ await storage.diversify(path, 'foo', stream1)
256
+
257
+ const state = await storage.get(path) as Entry
258
+
259
+ expect(state.variants).toMatchObject([{ name: 'foo', type: 'image/webp' }])
260
+ })
261
+ })
262
+
263
+ describe('fetch', () => {
264
+ let lenna: Entry
265
+
266
+ beforeEach(async () => {
267
+ const stream = await open('lenna.png')
268
+
269
+ lenna = await storage.put(dir, stream) as Entry
270
+ })
271
+
272
+ it('should fetch', async () => {
273
+ const path = `${dir}/${lenna.id}`
274
+ const stream = await storage.fetch(path)
275
+
276
+ const stored: Buffer = await match(stream,
277
+ Readable, async (stream: Readable) => await buffer(stream))
278
+
279
+ const buf = await buffer(await open('lenna.png'))
280
+
281
+ expect(stored.compare(buf)).toBe(0)
282
+ })
283
+
284
+ it('should fetch blob by id', async () => {
285
+ const stream = await open('lenna.ascii')
286
+ const entry = await storage.put(dir, stream) as Entry
287
+ const stored = await storage.fetch(entry.id)
288
+
289
+ if (stored instanceof Error)
290
+ throw stored
291
+
292
+ const buf = await buffer(stored)
293
+ const expected = await buffer(await open('lenna.ascii'))
294
+
295
+ expect(buf.compare(expected)).toBe(0)
296
+ })
297
+
298
+ it('should fetch variant', async () => {
299
+ const stream = await open('sample.jpeg')
300
+
301
+ const buf = await buffer(stream)
302
+ const path = `${dir}/${lenna.id}`
303
+
304
+ await storage.diversify(path, '100x100.jpeg', Readable.from(buf))
305
+
306
+ const variant = await storage.fetch(`${path}.100x100.jpeg`)
307
+
308
+ const stored = await match(variant,
309
+ Readable, async (stream: Readable) => await buffer(stream))
310
+
311
+ expect(stored.compare(buf)).toBe(0)
312
+ })
313
+
314
+ it('should not fetch blob by id and fake path', async () => {
315
+ const stored = await storage.fetch(`fake/${lenna.id}`)
316
+
317
+ match(stored,
318
+ Error, (error: Error) => expect(error.message).toBe('NOT_FOUND'))
319
+ })
320
+ })
321
+
322
+ describe('delete', () => {
323
+ let lenna: Entry
324
+
325
+ beforeEach(async () => {
326
+ const stream = await open('lenna.png')
327
+
328
+ lenna = await storage.put(dir, stream) as Entry
329
+ })
330
+
331
+ it('should remove from the list', async () => {
332
+ await storage.delete(`${dir}/${lenna.id}`)
333
+
334
+ const list = await storage.list(dir)
335
+
336
+ expect(list).not.toContain(lenna.id)
337
+ })
338
+
339
+ it('should delete entry', async () => {
340
+ await storage.delete(`${dir}/${lenna.id}`)
341
+
342
+ const result = await storage.get(`${dir}/${lenna.id}`)
343
+
344
+ match(result,
345
+ Error, (error: Error) => expect(error.message).toBe('NOT_FOUND'))
346
+ })
347
+
348
+ it('should delete variants', async () => {
349
+ const stream = await open('sample.jpeg')
350
+
351
+ const path = `${dir}/${lenna.id}`
352
+
353
+ await storage.diversify(path, 'foo', stream)
354
+ await storage.delete(`${dir}/${lenna.id}`)
355
+
356
+ const variant = await storage.fetch(`${path}.foo`)
357
+
358
+ match(variant,
359
+ Error, (error: Error) => expect(error.message).toBe('NOT_FOUND'))
360
+
361
+ stream.destroy()
362
+ })
363
+
364
+ it('should throw if path is not an entry', async () => {
365
+ const result = await storage.delete(dir)
366
+
367
+ expect(result).toBeInstanceOf(Error)
368
+ expect(result).toMatchObject({ message: 'NOT_FOUND' })
369
+ })
370
+ })
371
+
372
+ describe('signatures', () => {
373
+ it.each(['jpeg', 'gif', 'webp', 'heic', 'jxl', 'avif'])('should detect image/%s',
374
+ async (type) => {
375
+ const stream = await open('sample.' + type)
376
+
377
+ const entry = await storage.put(dir, stream) as Entry
378
+
379
+ expect(entry.type).toBe('image/' + type)
380
+ })
381
+ })
382
+
383
+ it('should return error if type doesnt match', async () => {
384
+ const stream = await open('sample.jpeg')
385
+
386
+ const result = await storage.put(dir, stream, 'image/png')
387
+
388
+ match(result,
389
+ Error, (error: Error) => expect(error.message).toBe('TYPE_MISMATCH'))
390
+ })
391
+
392
+ it('should trust unknown types', async () => {
393
+ const stream = await open('lenna.ascii')
394
+
395
+ const result = await storage.put(dir, stream, 'text/plain')
396
+
397
+ expect(result).not.toBeInstanceOf(Error)
398
+ expect(result).toMatchObject({ type: 'text/plain' })
399
+ })
400
+
401
+ it('should return error if type is identifiable', async () => {
402
+ const stream = await open('lenna.ascii')
403
+
404
+ const result = await storage.put(dir, stream, 'image/jpeg')
405
+
406
+ expect(result).toBeInstanceOf(Error)
407
+ expect(result).toMatchObject({ message: 'TYPE_MISMATCH' })
408
+ })
409
+
410
+ it('should not return error if type application/octet-stream', async () => {
411
+ const stream = await open('sample.jpeg')
412
+
413
+ const result = await storage.put(dir, stream, 'application/octet-stream')
414
+
415
+ expect(result).not.toBeInstanceOf(Error)
416
+ expect(result).toMatchObject({ type: 'image/jpeg' })
417
+ })
418
+
419
+ it('should handle root entries', async () => {
420
+ const stream = await open('sample.jpeg')
421
+
422
+ const result = await storage.put('hello', stream) as Entry
423
+
424
+ expect(result).not.toBeInstanceOf(Error)
425
+
426
+ const stored = await storage.fetch(result.id)
427
+
428
+ expect(stored).not.toBeInstanceOf(Error)
429
+ })
430
+
431
+ it('should store empty file', async () => {
432
+ const stream = await open('empty.txt')
433
+ const result = await storage.put('empty', stream) as Entry
434
+
435
+ expect(result.size).toBe(0)
436
+
437
+ const stored = await storage.fetch(result.id) as Readable
438
+
439
+ expect(stored).not.toBeInstanceOf(Error)
440
+
441
+ const buf = await buffer(stored)
442
+
443
+ expect(buf.length).toBe(0)
444
+ })
445
+ })
@@ -0,0 +1,236 @@
1
+ import { Readable } from 'node:stream'
2
+ import { posix } from 'node:path'
3
+ import { decode, encode } from 'msgpackr'
4
+ import { buffer, newid } from '@toa.io/generic'
5
+ import { Scanner } from './Scanner'
6
+ import type { Provider } from './Provider'
7
+ import type { Entry } from './Entry'
8
+
9
+ export class Storage {
10
+ private readonly provider: Provider
11
+
12
+ public constructor (provider: Provider) {
13
+ this.provider = provider
14
+ }
15
+
16
+ public async put (path: string, stream: Readable, type?: string): Maybe<Entry> {
17
+ const scanner = new Scanner(type)
18
+ const pipe = stream.pipe(scanner)
19
+ const tempname = await this.transit(pipe)
20
+
21
+ if (scanner.error !== null)
22
+ return scanner.error
23
+
24
+ const id = scanner.digest()
25
+
26
+ await this.persist(tempname, id)
27
+
28
+ return await this.create(path, id, scanner.size, scanner.type)
29
+ }
30
+
31
+ public async get (path: string): Maybe<Entry> {
32
+ const metapath = posix.join(ENTRIES, path, META)
33
+ const result = await this.provider.get(metapath)
34
+
35
+ if (result === null) return ERR_NOT_FOUND
36
+ else return decode(await buffer(result))
37
+ }
38
+
39
+ public async fetch (path: string): Maybe<Readable> {
40
+ const { rel, id, variant } = this.parse(path)
41
+
42
+ if (variant === null && rel !== '') {
43
+ const entry = await this.get(path)
44
+
45
+ if (entry instanceof Error)
46
+ return entry
47
+ }
48
+
49
+ const blob = variant === null
50
+ ? posix.join(BLOBs, id)
51
+ : posix.join(ENTRIES, rel, id, variant)
52
+
53
+ const stream = await this.provider.get(blob)
54
+
55
+ if (stream === null) return ERR_NOT_FOUND
56
+ else return stream
57
+ }
58
+
59
+ public async delete (path: string): Maybe<void> {
60
+ const entry = await this.get(path)
61
+
62
+ if (entry instanceof Error)
63
+ return entry
64
+
65
+ await this.conceal(path)
66
+ await this.provider.delete(posix.join(ENTRIES, path))
67
+ }
68
+
69
+ public async list (path: string): Promise<string[]> {
70
+ const listfile = posix.join(ENTRIES, path, LIST)
71
+ const stream = await this.provider.get(listfile)
72
+
73
+ return stream === null ? [] : decode(await buffer(stream))
74
+ }
75
+
76
+ public async reorder (path: string, ids: string[]): Maybe<void> {
77
+ const unique = new Set(ids)
78
+ const dir = posix.join(ENTRIES, path)
79
+ const list = await this.list(path)
80
+
81
+ if (list.length !== ids.length || unique.size !== ids.length)
82
+ return ERR_PERMUTATION_MISMATCH
83
+
84
+ for (const id of ids)
85
+ if (!list.includes(id))
86
+ return ERR_PERMUTATION_MISMATCH
87
+
88
+ await this.provider.put(dir, LIST, Readable.from(encode(ids)))
89
+ }
90
+
91
+ public async conceal (path: string): Maybe<void> {
92
+ const { id, rel } = this.parse(path)
93
+ const dir = posix.join(ENTRIES, rel)
94
+ const list = await this.list(rel)
95
+ const index = list.indexOf(id)
96
+
97
+ if (index === -1)
98
+ return ERR_NOT_FOUND
99
+
100
+ list.splice(index, 1)
101
+
102
+ await this.provider.put(dir, LIST, Readable.from(encode(list)))
103
+ }
104
+
105
+ public async reveal (path: string): Maybe<void> {
106
+ const { id, rel } = this.parse(path)
107
+
108
+ return await this.enroll(rel, id)
109
+ }
110
+
111
+ public async diversify (path: string, name: string, stream: Readable): Maybe<void> {
112
+ const scanner = new Scanner()
113
+ const pipe = stream.pipe(scanner)
114
+
115
+ await this.provider.put(posix.join(ENTRIES, path), name, pipe)
116
+
117
+ if (scanner.error !== null)
118
+ return scanner.error
119
+
120
+ const { size, type } = scanner
121
+ const entry = await this.get(path)
122
+
123
+ if (entry instanceof Error)
124
+ return entry
125
+
126
+ entry.variants = entry.variants.filter((variant) => variant.name !== name)
127
+ entry.variants.push({ name, size, type })
128
+
129
+ await this.save(path, entry)
130
+ }
131
+
132
+ public async annotate (path: string, key: string, value?: unknown): Maybe<void> {
133
+ const entry = await this.get(path)
134
+
135
+ if (entry instanceof Error)
136
+ return entry
137
+
138
+ // eslint-disable-next-line @typescript-eslint/no-dynamic-delete
139
+ if (value === undefined) delete entry.meta[key]
140
+ else entry.meta[key] = value
141
+
142
+ await this.save(path, entry)
143
+ }
144
+
145
+ private async transit (stream: Readable): Promise<string> {
146
+ const tempname = newid()
147
+
148
+ await this.provider.put(TEMP, tempname, stream)
149
+
150
+ return tempname
151
+ }
152
+
153
+ private async persist (tempname: string, id: string): Promise<void> {
154
+ const temp = posix.join(TEMP, tempname)
155
+ const blob = posix.join(BLOBs, id)
156
+
157
+ await this.provider.move(temp, blob)
158
+ }
159
+
160
+ // eslint-disable-next-line max-params
161
+ private async create (path: string, id: string, size: number, type: string): Promise<Entry> {
162
+ const entry: Entry = {
163
+ id,
164
+ size,
165
+ type,
166
+ created: Date.now(),
167
+ variants: [],
168
+ meta: {}
169
+ }
170
+
171
+ const metafile = posix.join(path, entry.id)
172
+ const existing = await this.get(metafile)
173
+
174
+ if (existing instanceof Error)
175
+ await this.save(metafile, entry)
176
+
177
+ await this.enroll(path, id, true)
178
+
179
+ return entry
180
+ }
181
+
182
+ private async save (rel: string, entry: Entry): Promise<void> {
183
+ const buffer = encode(entry)
184
+ const stream = Readable.from(buffer)
185
+
186
+ await this.provider.put(posix.join(ENTRIES, rel), META, stream)
187
+ }
188
+
189
+ private async enroll (path: string, id: string, addition: boolean = false): Maybe<void> {
190
+ const dir = posix.join(ENTRIES, path)
191
+ const list = await this.list(path)
192
+ const index = list.indexOf(id)
193
+
194
+ if (index !== -1)
195
+ if (addition) list.splice(index, 1)
196
+ else return
197
+ else if (!addition) {
198
+ const entry = await this.get(posix.join(path, id))
199
+
200
+ if (entry instanceof Error)
201
+ return entry
202
+ }
203
+
204
+ list.push(id)
205
+
206
+ await this.provider.put(dir, LIST, Readable.from(encode(list)))
207
+ }
208
+
209
+ private parse (path: string): Path {
210
+ const [last, ...segments] = path.split('/').reverse()
211
+ const [id, ...rest] = last.split('.')
212
+ const variant = rest.length > 0 ? rest.join('.') : null
213
+ const rel = segments.reverse().join('/')
214
+
215
+ return { rel, id, variant }
216
+ }
217
+ }
218
+
219
+ const ERR_NOT_FOUND = new Error('NOT_FOUND')
220
+ const ERR_PERMUTATION_MISMATCH = new Error('PERMUTATION_MISMATCH')
221
+
222
+ const TEMP = '/temp'
223
+ const BLOBs = '/blobs'
224
+ const ENTRIES = '/entries'
225
+ const LIST = '.list'
226
+ const META = '.meta'
227
+
228
+ type Maybe<T> = Promise<T | Error>
229
+
230
+ interface Path {
231
+ rel: string
232
+ id: string
233
+ variant: string | null
234
+ }
235
+
236
+ export type Storages = Record<string, Storage>