@travetto/model-s3 5.0.0-rc.8 → 5.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/README.md CHANGED
@@ -17,7 +17,7 @@ This module provides an [s3](https://aws.amazon.com/documentation/s3/)-based imp
17
17
 
18
18
  Supported features:
19
19
  * [CRUD](https://github.com/travetto/travetto/tree/main/module/model/src/service/crud.ts#L11)
20
- * [Streaming](https://github.com/travetto/travetto/tree/main/module/model/src/service/stream.ts#L3)
20
+ * [Blob](https://github.com/travetto/travetto/tree/main/module/model/src/service/blob.ts#L8)
21
21
  * [Expiry](https://github.com/travetto/travetto/tree/main/module/model/src/service/expiry.ts#L11)
22
22
  Out of the box, by installing the module, everything should be wired up by default.If you need to customize any aspect of the source or config, you can override and register it with the [Dependency Injection](https://github.com/travetto/travetto/tree/main/module/di#readme "Dependency registration/management and injection support.") module.
23
23
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@travetto/model-s3",
3
- "version": "5.0.0-rc.8",
3
+ "version": "5.0.0",
4
4
  "description": "S3 backing for the travetto model module.",
5
5
  "keywords": [
6
6
  "s3",
@@ -25,13 +25,13 @@
25
25
  "directory": "module/model-s3"
26
26
  },
27
27
  "dependencies": {
28
- "@aws-sdk/client-s3": "^3.609.0",
28
+ "@aws-sdk/client-s3": "^3.631.0",
29
29
  "@aws-sdk/credential-provider-ini": "^3.609.0",
30
- "@travetto/config": "^5.0.0-rc.8",
31
- "@travetto/model": "^5.0.0-rc.8"
30
+ "@travetto/config": "^5.0.0",
31
+ "@travetto/model": "^5.0.0"
32
32
  },
33
33
  "peerDependencies": {
34
- "@travetto/command": "^5.0.0-rc.8"
34
+ "@travetto/command": "^5.0.0"
35
35
  },
36
36
  "peerDependenciesMeta": {
37
37
  "@travetto/command": {
package/src/service.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { Readable } from 'node:stream';
2
- import { buffer as toBuffer } from 'node:stream/consumers';
2
+ import { text as toText } from 'node:stream/consumers';
3
3
  import { Agent } from 'node:https';
4
4
 
5
5
  import { S3, CompletedPart, type CreateMultipartUploadRequest } from '@aws-sdk/client-s3';
@@ -7,20 +7,19 @@ import type { MetadataBearer } from '@aws-sdk/types';
7
7
  import { NodeHttpHandler } from '@smithy/node-http-handler';
8
8
 
9
9
  import {
10
- ModelCrudSupport, ModelStreamSupport, ModelStorageSupport, StreamMeta,
11
- ModelType, ModelRegistry, ExistsError, NotFoundError, OptionalId,
12
- StreamRange
10
+ ModelCrudSupport, ModelStorageSupport, ModelType, ModelRegistry, ExistsError, NotFoundError, OptionalId,
11
+ ModelBlobSupport
13
12
  } from '@travetto/model';
14
13
  import { Injectable } from '@travetto/di';
15
- import { Class, AppError } from '@travetto/runtime';
14
+ import { Class, AppError, castTo, asFull, BlobMeta, ByteRange, BinaryInput, BinaryUtil } from '@travetto/runtime';
16
15
 
16
+ import { MODEL_BLOB, ModelBlobUtil } from '@travetto/model/src/internal/service/blob';
17
17
  import { ModelCrudUtil } from '@travetto/model/src/internal/service/crud';
18
18
  import { ModelExpirySupport } from '@travetto/model/src/service/expiry';
19
19
  import { ModelExpiryUtil } from '@travetto/model/src/internal/service/expiry';
20
20
  import { ModelStorageUtil } from '@travetto/model/src/internal/service/storage';
21
21
 
22
22
  import { S3ModelConfig } from './config';
23
- import { enforceRange } from '@travetto/model/src/internal/service/stream';
24
23
 
25
24
  function isMetadataBearer(o: unknown): o is MetadataBearer {
26
25
  return !!o && typeof o === 'object' && '$metadata' in o;
@@ -30,8 +29,6 @@ function hasContentType<T>(o: T): o is T & { contenttype?: string } {
30
29
  return o !== undefined && o !== null && Object.hasOwn(o, 'contenttype');
31
30
  }
32
31
 
33
- const STREAM_SPACE = '@travetto/model-s3:stream';
34
-
35
32
  type MetaBase = Pick<CreateMultipartUploadRequest,
36
33
  'ContentType' | 'Metadata' | 'ContentEncoding' | 'ContentLanguage' | 'CacheControl' | 'ContentDisposition'
37
34
  >;
@@ -40,14 +37,14 @@ type MetaBase = Pick<CreateMultipartUploadRequest,
40
37
  * Asset source backed by S3
41
38
  */
42
39
  @Injectable()
43
- export class S3ModelService implements ModelCrudSupport, ModelStreamSupport, ModelStorageSupport, ModelExpirySupport {
40
+ export class S3ModelService implements ModelCrudSupport, ModelBlobSupport, ModelStorageSupport, ModelExpirySupport {
44
41
 
45
42
  idSource = ModelCrudUtil.uuidSource();
46
43
  client: S3;
47
44
 
48
45
  constructor(public readonly config: S3ModelConfig) { }
49
46
 
50
- #getMetaBase(meta: StreamMeta): MetaBase {
47
+ #getMetaBase({ range, size, ...meta }: BlobMeta): MetaBase {
51
48
  return {
52
49
  ContentType: meta.contentType,
53
50
  ...(meta.contentEncoding ? { ContentEncoding: meta.contentEncoding } : {}),
@@ -55,14 +52,14 @@ export class S3ModelService implements ModelCrudSupport, ModelStreamSupport, Mod
55
52
  ...(meta.cacheControl ? { CacheControl: meta.cacheControl } : {}),
56
53
  Metadata: {
57
54
  ...meta,
58
- size: `${meta.size}`
55
+ ...(size ? { size: `${size}` } : {})
59
56
  }
60
57
  };
61
58
  }
62
59
 
63
60
  #resolveKey(cls: Class | string, id?: string): string {
64
61
  let key: string;
65
- if (cls === STREAM_SPACE) { // If we are streaming, treat as primary use case
62
+ if (cls === MODEL_BLOB) { // If we are streaming, treat as primary use case
66
63
  key = id!; // Store it directly at root
67
64
  } else {
68
65
  key = typeof cls === 'string' ? cls : ModelRegistry.getStore(cls);
@@ -77,8 +74,7 @@ export class S3ModelService implements ModelCrudSupport, ModelStreamSupport, Mod
77
74
  return key;
78
75
  }
79
76
 
80
- // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
81
- #q<U extends object>(cls: string | Class, id: string, extra: U = {} as U): (U & { Key: string, Bucket: string }) {
77
+ #q<U extends object>(cls: string | Class, id: string, extra: U = asFull({})): (U & { Key: string, Bucket: string }) {
82
78
  const key = this.#resolveKey(cls, id);
83
79
  return { Key: key, Bucket: this.config.bucket, ...extra };
84
80
  }
@@ -111,8 +107,8 @@ export class S3ModelService implements ModelCrudSupport, ModelStreamSupport, Mod
111
107
  /**
112
108
  * Write multipart file upload, in chunks
113
109
  */
114
- async #writeMultipart(id: string, input: Readable, meta: StreamMeta): Promise<void> {
115
- const { UploadId } = await this.client.createMultipartUpload(this.#q(STREAM_SPACE, id, this.#getMetaBase(meta)));
110
+ async #writeMultipart(id: string, input: Readable, meta: BlobMeta): Promise<void> {
111
+ const { UploadId } = await this.client.createMultipartUpload(this.#q(MODEL_BLOB, id, this.#getMetaBase(meta)));
116
112
 
117
113
  const parts: CompletedPart[] = [];
118
114
  let buffers: Buffer[] = [];
@@ -120,7 +116,7 @@ export class S3ModelService implements ModelCrudSupport, ModelStreamSupport, Mod
120
116
  let n = 1;
121
117
  const flush = async (): Promise<void> => {
122
118
  if (!total) { return; }
123
- const part = await this.client.uploadPart(this.#q(STREAM_SPACE, id, {
119
+ const part = await this.client.uploadPart(this.#q(MODEL_BLOB, id, {
124
120
  Body: Buffer.concat(buffers),
125
121
  PartNumber: n,
126
122
  UploadId
@@ -132,20 +128,21 @@ export class S3ModelService implements ModelCrudSupport, ModelStreamSupport, Mod
132
128
  };
133
129
  try {
134
130
  for await (const chunk of input) {
135
- buffers.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
136
- total += chunk.length;
131
+ const chunked = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
132
+ buffers.push(chunked);
133
+ total += chunked.length;
137
134
  if (total > this.config.chunkSize) {
138
135
  await flush();
139
136
  }
140
137
  }
141
138
  await flush();
142
139
 
143
- await this.client.completeMultipartUpload(this.#q(STREAM_SPACE, id, {
140
+ await this.client.completeMultipartUpload(this.#q(MODEL_BLOB, id, {
144
141
  UploadId,
145
142
  MultipartUpload: { Parts: parts }
146
143
  }));
147
144
  } catch (err) {
148
- await this.client.abortMultipartUpload(this.#q(STREAM_SPACE, id, { UploadId }));
145
+ await this.client.abortMultipartUpload(this.#q(MODEL_BLOB, id, { UploadId }));
149
146
  throw err;
150
147
  }
151
148
  }
@@ -203,8 +200,7 @@ export class S3ModelService implements ModelCrudSupport, ModelStreamSupport, Mod
203
200
  try {
204
201
  const result = await this.client.getObject(this.#q(cls, id));
205
202
  if (result.Body) {
206
- // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
207
- const body = (await toBuffer(result.Body as Readable)).toString('utf8');
203
+ const body = await toText(castTo(result.Body));
208
204
  const output = await ModelCrudUtil.load(cls, body);
209
205
  if (output) {
210
206
  const { expiresAt } = ModelRegistry.get(cls);
@@ -230,8 +226,7 @@ export class S3ModelService implements ModelCrudSupport, ModelStreamSupport, Mod
230
226
  }
231
227
 
232
228
  async store<T extends ModelType>(cls: Class<T>, item: OptionalId<T>, preStore = true): Promise<T> {
233
- // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
234
- let prepped: T = item as T;
229
+ let prepped: T = castTo(item);
235
230
  if (preStore) {
236
231
  prepped = await ModelCrudUtil.preStore(cls, item, this);
237
232
  }
@@ -299,22 +294,29 @@ export class S3ModelService implements ModelCrudSupport, ModelStreamSupport, Mod
299
294
  return -1;
300
295
  }
301
296
 
302
- async upsertStream(location: string, input: Readable, meta: StreamMeta): Promise<void> {
303
- if (meta.size < this.config.chunkSize) { // If smaller than chunk size
297
+ // Blob support
298
+ async upsertBlob(location: string, input: BinaryInput, meta?: BlobMeta, overwrite = true): Promise<void> {
299
+ if (!overwrite && await this.describeBlob(location).then(() => true, () => false)) {
300
+ return;
301
+ }
302
+
303
+ const [stream, blobMeta] = await ModelBlobUtil.getInput(input, meta);
304
+
305
+ if (blobMeta.size && blobMeta.size < this.config.chunkSize) { // If smaller than chunk size
304
306
  // Upload to s3
305
- await this.client.putObject(this.#q(STREAM_SPACE, location, {
306
- Body: await toBuffer(input),
307
- ContentLength: meta.size,
308
- ...this.#getMetaBase(meta),
307
+ await this.client.putObject(this.#q(MODEL_BLOB, location, {
308
+ Body: stream,
309
+ ContentLength: blobMeta.size,
310
+ ...this.#getMetaBase(blobMeta),
309
311
  }));
310
312
  } else {
311
- await this.#writeMultipart(location, input, meta);
313
+ await this.#writeMultipart(location, stream, blobMeta);
312
314
  }
313
315
  }
314
316
 
315
- async #getObject(location: string, range: Required<StreamRange>): Promise<Readable> {
317
+ async #getObject(location: string, range?: Required<ByteRange>): Promise<Readable> {
316
318
  // Read from s3
317
- const res = await this.client.getObject(this.#q(STREAM_SPACE, location, range ? {
319
+ const res = await this.client.getObject(this.#q(MODEL_BLOB, location, range ? {
318
320
  Range: `bytes=${range.start}-${range.end}`
319
321
  } : {}));
320
322
 
@@ -323,49 +325,43 @@ export class S3ModelService implements ModelCrudSupport, ModelStreamSupport, Mod
323
325
  }
324
326
 
325
327
  if (typeof res.Body === 'string') { // string
326
- // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
327
- return Readable.from(res.Body, { encoding: (res.Body as string).endsWith('=') ? 'base64' : 'utf8' });
328
+ return Readable.from(res.Body, { encoding: castTo<string>(res.Body).endsWith('=') ? 'base64' : 'utf8' });
328
329
  } else if (res.Body instanceof Buffer) { // Buffer
329
330
  return Readable.from(res.Body);
330
331
  } else if ('pipe' in res.Body) { // Stream
331
- // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
332
- return res.Body as Readable;
332
+ return castTo<Readable>(res.Body);
333
333
  }
334
334
  throw new AppError(`Unable to read type: ${typeof res.Body}`);
335
335
  }
336
336
 
337
- async getStream(location: string, range?: StreamRange): Promise<Readable> {
338
- if (range) {
339
- const meta = await this.describeStream(location);
340
- range = enforceRange(range, meta.size);
341
- }
342
- // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
343
- return this.#getObject(location, range as Required<StreamRange>);
337
+ async getBlob(location: string, range?: ByteRange): Promise<Blob> {
338
+ const meta = await this.describeBlob(location);
339
+ const final = range ? ModelBlobUtil.enforceRange(range, meta.size!) : undefined;
340
+ const res = (): Promise<Readable> => this.#getObject(location, final);
341
+ return BinaryUtil.readableBlob(res, { ...meta, range: final });
344
342
  }
345
343
 
346
- async headStream(location: string): Promise<{ Metadata?: Partial<StreamMeta>, ContentLength?: number }> {
347
- const query = this.#q(STREAM_SPACE, location);
344
+ async headBlob(location: string): Promise<{ Metadata?: BlobMeta, ContentLength?: number }> {
345
+ const query = this.#q(MODEL_BLOB, location);
348
346
  try {
349
347
  return (await this.client.headObject(query));
350
348
  } catch (err) {
351
349
  if (isMetadataBearer(err)) {
352
350
  if (err.$metadata.httpStatusCode === 404) {
353
- err = new NotFoundError(STREAM_SPACE, location);
351
+ err = new NotFoundError(MODEL_BLOB, location);
354
352
  }
355
353
  }
356
354
  throw err;
357
355
  }
358
356
  }
359
357
 
360
- async describeStream(location: string): Promise<StreamMeta> {
361
- const obj = await this.headStream(location);
358
+ async describeBlob(location: string): Promise<BlobMeta> {
359
+ const obj = await this.headBlob(location);
362
360
 
363
361
  if (obj) {
364
- const ret: StreamMeta = {
365
- // @ts-expect-error
362
+ const ret: BlobMeta = {
366
363
  contentType: '',
367
- // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
368
- ...obj.Metadata as StreamMeta,
364
+ ...obj.Metadata,
369
365
  size: obj.ContentLength!,
370
366
  };
371
367
  if (hasContentType(ret)) {
@@ -374,20 +370,21 @@ export class S3ModelService implements ModelCrudSupport, ModelStreamSupport, Mod
374
370
  }
375
371
  return ret;
376
372
  } else {
377
- throw new NotFoundError(STREAM_SPACE, location);
373
+ throw new NotFoundError(MODEL_BLOB, location);
378
374
  }
379
375
  }
380
376
 
377
+ async deleteBlob(location: string): Promise<void> {
378
+ await this.client.deleteObject(this.#q(MODEL_BLOB, location));
379
+ }
380
+
381
+ // Storage
381
382
  async truncateModel<T extends ModelType>(model: Class<T>): Promise<void> {
382
383
  for await (const items of this.#iterateBucket(model)) {
383
384
  await this.#deleteKeys(items);
384
385
  }
385
386
  }
386
387
 
387
- async deleteStream(location: string): Promise<void> {
388
- await this.client.deleteObject(this.#q(STREAM_SPACE, location));
389
- }
390
-
391
388
  async createStorage(): Promise<void> {
392
389
  try {
393
390
  await this.client.headBucket({ Bucket: this.config.bucket });