s3db.js 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.
Files changed (52) hide show
  1. package/.github/workflows/pipeline.yml +16 -0
  2. package/README.md +742 -0
  3. package/build/cache/avro.serializer.js +16 -0
  4. package/build/cache/json.serializer.js +7 -0
  5. package/build/cache/s3-cache.class.js +157 -0
  6. package/build/cache/s3-resource-cache.class.js +77 -0
  7. package/build/cache/serializers.type.js +8 -0
  8. package/build/errors.js +64 -0
  9. package/build/index.js +9 -0
  10. package/build/metadata.interface.js +2 -0
  11. package/build/plugin.interface.js +2 -0
  12. package/build/resource.class.js +485 -0
  13. package/build/resource.interface.js +2 -0
  14. package/build/s3-client.class.js +274 -0
  15. package/build/s3db-config.interface.js +2 -0
  16. package/build/s3db.class.js +185 -0
  17. package/build/stream/resource-ids-read-stream.class.js +100 -0
  18. package/build/stream/resource-ids-transformer.class.js +40 -0
  19. package/build/stream/resource-write-stream.class.js +76 -0
  20. package/build/validator.js +37 -0
  21. package/examples/1-bulk-insert.js +64 -0
  22. package/examples/2-read-stream.js +61 -0
  23. package/examples/3-read-stream-to-csv.js +57 -0
  24. package/examples/4-read-stream-to-zip.js +56 -0
  25. package/examples/5-write-stream.js +98 -0
  26. package/examples/6-jwt-tokens.js +124 -0
  27. package/examples/concerns/index.js +64 -0
  28. package/jest.config.ts +10 -0
  29. package/package.json +51 -0
  30. package/src/cache/avro.serializer.ts +12 -0
  31. package/src/cache/json.serializer.ts +4 -0
  32. package/src/cache/s3-cache.class.ts +155 -0
  33. package/src/cache/s3-resource-cache.class.ts +75 -0
  34. package/src/cache/serializers.type.ts +8 -0
  35. package/src/errors.ts +96 -0
  36. package/src/index.ts +4 -0
  37. package/src/metadata.interface.ts +4 -0
  38. package/src/plugin.interface.ts +4 -0
  39. package/src/resource.class.ts +531 -0
  40. package/src/resource.interface.ts +21 -0
  41. package/src/s3-client.class.ts +297 -0
  42. package/src/s3db-config.interface.ts +9 -0
  43. package/src/s3db.class.ts +215 -0
  44. package/src/stream/resource-ids-read-stream.class.ts +90 -0
  45. package/src/stream/resource-ids-transformer.class.ts +38 -0
  46. package/src/stream/resource-write-stream.class.ts +78 -0
  47. package/src/validator.ts +39 -0
  48. package/tests/cache.spec.ts +187 -0
  49. package/tests/concerns/index.ts +16 -0
  50. package/tests/config.spec.ts +29 -0
  51. package/tests/resources.spec.ts +197 -0
  52. package/tsconfig.json +111 -0
@@ -0,0 +1,297 @@
1
+ import * as path from "path";
2
+ import { chunk } from "lodash";
3
+ import { nanoid } from "nanoid";
4
+ import { Stream } from "stream";
5
+ import EventEmitter from "events";
6
+ import { S3, Credentials } from "aws-sdk";
7
+ import PromisePool from "@supercharge/promise-pool";
8
+
9
+ import { ClientNoSuchKey } from "./errors";
10
+
11
+ export default class S3Client extends EventEmitter {
12
+ id: string;
13
+ client: S3;
14
+ bucket: string;
15
+ keyPrefix: string;
16
+ parallelism: number;
17
+
18
+ constructor({
19
+ connectionString,
20
+ parallelism = 10,
21
+ AwsS3,
22
+ }: {
23
+ connectionString: string;
24
+ parallelism?: number;
25
+ AwsS3?: S3;
26
+ }) {
27
+ super();
28
+ this.id = nanoid(7);
29
+
30
+ const uri = new URL(connectionString);
31
+ this.bucket = uri.hostname;
32
+ this.parallelism = parallelism;
33
+
34
+ let [, ...subpath] = uri.pathname.split("/");
35
+ this.keyPrefix = [...(subpath || [])].join("/");
36
+
37
+ this.client =
38
+ AwsS3 ||
39
+ new S3({
40
+ credentials: new Credentials({
41
+ accessKeyId: uri.username,
42
+ secretAccessKey: uri.password,
43
+ }),
44
+ });
45
+ }
46
+
47
+ /**
48
+ *
49
+ * @param param0
50
+ * @returns
51
+ */
52
+ async getObject({ key }: { key: string }) {
53
+ try {
54
+ const options = {
55
+ Bucket: this.bucket,
56
+ Key: path.join(this.keyPrefix, key),
57
+ };
58
+
59
+ const response = await this.client.getObject(options).promise();
60
+ this.emit("request", "getObject", options);
61
+
62
+ return response;
63
+ } catch (error: unknown) {
64
+ if (error instanceof Error) {
65
+ if (error.name === "NoSuchKey") {
66
+ return Promise.reject(
67
+ new ClientNoSuchKey({ bucket: this.bucket, key })
68
+ );
69
+ }
70
+ }
71
+
72
+ return Promise.reject(error);
73
+ }
74
+ }
75
+
76
+ /**
77
+ *
78
+ * @param param0
79
+ * @returns
80
+ */
81
+ async putObject({
82
+ key,
83
+ metadata,
84
+ contentType,
85
+ body,
86
+ contentEncoding,
87
+ }: {
88
+ key: string;
89
+ metadata?: object;
90
+ contentType?: string;
91
+ body: string | Stream | Uint8Array;
92
+ contentEncoding?: string | null | undefined;
93
+ }) {
94
+ try {
95
+ const options: any = {
96
+ Bucket: this.bucket,
97
+ Key: path.join(this.keyPrefix, key),
98
+ Metadata: { ...metadata },
99
+ Body: body,
100
+ ContentType: contentType,
101
+ ContentEncoding: contentEncoding,
102
+ };
103
+
104
+ const response = await this.client.putObject(options).promise();
105
+ this.emit("request", "putObject", options);
106
+
107
+ return response;
108
+ } catch (error) {
109
+ this.emit("error", error);
110
+ return Promise.reject(error);
111
+ }
112
+ }
113
+
114
+ /**
115
+ * Proxy to AWS S3's headObject
116
+ * @param {Object} param
117
+ * @param {string} param.key
118
+ * @returns
119
+ */
120
+ async headObject({ key }: { key: string }) {
121
+ try {
122
+ const options: any = {
123
+ Bucket: this.bucket,
124
+ Key: path.join(this.keyPrefix, key),
125
+ };
126
+
127
+ const response = await this.client.headObject(options).promise();
128
+ this.emit("request", "headObject", options);
129
+
130
+ return response;
131
+ } catch (error: unknown) {
132
+ if (error instanceof Error) {
133
+ if (error.name === "NoSuchKey" || error.name === "NotFound") {
134
+ return Promise.reject(
135
+ new ClientNoSuchKey({ bucket: this.bucket, key })
136
+ );
137
+ }
138
+ }
139
+
140
+ this.emit("error", error);
141
+ return Promise.reject(error);
142
+ }
143
+ }
144
+
145
+ /**
146
+ * Proxy to AWS S3's deleteObject
147
+ * @param {Object} param
148
+ * @param {string} param.key
149
+ * @returns
150
+ */
151
+ async deleteObject(key: string) {
152
+ try {
153
+ const options: any = {
154
+ Bucket: this.bucket,
155
+ Key: path.join(this.keyPrefix, key),
156
+ };
157
+
158
+ const response = await this.client.deleteObject(options).promise();
159
+ this.emit("request", "deleteObject", options);
160
+
161
+ return response;
162
+ } catch (error: unknown) {
163
+ this.emit("error", error);
164
+
165
+ if (error instanceof Error) {
166
+ if (error.name === "NoSuchKey") {
167
+ return Promise.reject(
168
+ new ClientNoSuchKey({ bucket: this.bucket, key })
169
+ );
170
+ }
171
+ }
172
+
173
+ return Promise.reject(error);
174
+ }
175
+ }
176
+
177
+ /**
178
+ * Proxy to AWS S3's deleteObjects
179
+ * @param {Object} param
180
+ * @param {string} param.keys
181
+ * @returns
182
+ */
183
+ async deleteObjects(keys: string[]) {
184
+ const packages = chunk(keys, 1000);
185
+
186
+ const { results, errors } = await PromisePool.for(packages)
187
+ .withConcurrency(this.parallelism)
188
+ .process(async (keys: string[]) => {
189
+ try {
190
+ const options = {
191
+ Bucket: this.bucket,
192
+ Delete: {
193
+ Objects: keys.map((key) => ({
194
+ Key: path.join(this.keyPrefix, key),
195
+ })),
196
+ },
197
+ };
198
+
199
+ const response = await this.client.deleteObjects(options).promise();
200
+ this.emit("request", "deleteObjects", options);
201
+
202
+ return response;
203
+ } catch (error: unknown) {
204
+ this.emit("error", error);
205
+ return Promise.reject(error);
206
+ }
207
+ });
208
+
209
+ return {
210
+ deleted: results,
211
+ notFound: errors,
212
+ };
213
+ }
214
+
215
+ /**
216
+ *
217
+ * @param param0
218
+ * @returns
219
+ */
220
+ async listObjects({
221
+ prefix,
222
+ maxKeys = 1000,
223
+ continuationToken,
224
+ }: {
225
+ prefix?: string;
226
+ maxKeys?: number;
227
+ continuationToken: any;
228
+ }): Promise<S3.ListObjectsV2Output> {
229
+ try {
230
+ const options = {
231
+ Bucket: this.bucket,
232
+ MaxKeys: maxKeys,
233
+ ContinuationToken: continuationToken,
234
+ Prefix: path.join(this.keyPrefix, prefix || ""),
235
+ };
236
+
237
+ const response = await this.client.listObjectsV2(options).promise();
238
+ this.emit("request", "listObjectsV2", options);
239
+
240
+ return response;
241
+ } catch (error: unknown) {
242
+ this.emit("error", error);
243
+ return Promise.reject(error);
244
+ }
245
+ }
246
+
247
+ async count({ prefix }: { prefix?: string } = {}) {
248
+ this.emit("request", "count", { prefix });
249
+
250
+ let count = 0;
251
+ let truncated = true;
252
+ let continuationToken;
253
+
254
+ while (truncated) {
255
+ const options = {
256
+ prefix,
257
+ continuationToken,
258
+ };
259
+
260
+ const res: S3.ListObjectsV2Output = await this.listObjects(options);
261
+
262
+ count += res.KeyCount || 0;
263
+ truncated = res.IsTruncated || false;
264
+ continuationToken = res.NextContinuationToken;
265
+ }
266
+
267
+ return count;
268
+ }
269
+
270
+ async getAllKeys({ prefix }: { prefix?: string } = {}) {
271
+ this.emit("request", "getAllKeys", { prefix });
272
+
273
+ let keys: any[] = [];
274
+ let truncated = true;
275
+ let continuationToken;
276
+
277
+ while (truncated) {
278
+ const options: any = {
279
+ prefix,
280
+ continuationToken,
281
+ };
282
+
283
+ const res = await this.listObjects(options);
284
+
285
+ if (res.Contents) {
286
+ keys = keys.concat(res.Contents.map((x) => x.Key));
287
+ }
288
+
289
+ truncated = res.IsTruncated || false;
290
+ continuationToken = res.NextContinuationToken;
291
+ }
292
+
293
+ return keys
294
+ .map((x) => x.replace(this.keyPrefix, ""))
295
+ .map((x) => (x.startsWith("/") ? x.replace(`/`, "") : x));
296
+ }
297
+ }
@@ -0,0 +1,9 @@
1
+ import PluginInterface from "./plugin.interface";
2
+
3
+ export default interface S3dbConfigInterface {
4
+ uri: string;
5
+ cache?: boolean;
6
+ parallelism?: number;
7
+ plugins?: PluginInterface[];
8
+ passphrase?: string | undefined;
9
+ }
@@ -0,0 +1,215 @@
1
+ import { flatten } from "flat";
2
+ import { isEmpty } from "lodash";
3
+ import EventEmitter from "events";
4
+
5
+ import Resource from "./resource.class";
6
+ import S3Client from "./s3-client.class";
7
+ import { ValidatorFactory } from "./validator";
8
+ import PluginInterface from "./plugin.interface";
9
+ import S3dbConfigInterface from "./s3db-config.interface";
10
+ import MetadataInterface from "./metadata.interface";
11
+ import { S3dbMissingMetadata, ClientNoSuchKey } from "./errors";
12
+ import { MetadataResourceInterface } from "./resource.interface";
13
+
14
+ export default class S3db extends EventEmitter {
15
+ options: S3dbConfigInterface;
16
+ client: S3Client;
17
+ keyPrefix: string = "";
18
+ bucket: string = "s3db";
19
+ version: string;
20
+ validatorInstance: any;
21
+ parallelism: number;
22
+ resources: any;
23
+ passphrase: string;
24
+ plugins: PluginInterface[];
25
+ cache: boolean | undefined = false;
26
+
27
+ /**
28
+ * Constructor
29
+ */
30
+ constructor(options: S3dbConfigInterface) {
31
+ super();
32
+
33
+ this.version = "1";
34
+ this.resources = {};
35
+ this.options = options;
36
+ this.parallelism = parseInt(options.parallelism + "") || 10;
37
+ this.plugins = options.plugins || [];
38
+ this.cache = options.cache;
39
+ this.passphrase = options.passphrase || ""
40
+
41
+ this.validatorInstance = ValidatorFactory({
42
+ passphrase: options?.passphrase,
43
+ });
44
+
45
+ this.client = new S3Client({
46
+ connectionString: options.uri,
47
+ parallelism: this.parallelism,
48
+ });
49
+
50
+ this.bucket = this.client.bucket;
51
+ this.keyPrefix = this.client.keyPrefix;
52
+
53
+ this.startPlugins();
54
+ }
55
+
56
+ /**
57
+ * Remotely setups s3db file.
58
+ */
59
+ async connect(): Promise<void> {
60
+ let metadata = null;
61
+
62
+ try {
63
+ metadata = await this.getMetadataFile();
64
+ } catch (error) {
65
+ if (error instanceof S3dbMissingMetadata) {
66
+ metadata = this.blankMetadataStructure();
67
+ await this.uploadMetadataFile();
68
+ } else {
69
+ this.emit("error", error);
70
+ return Promise.reject(error);
71
+ }
72
+ }
73
+
74
+ for (const resource of Object.entries(metadata.resources)) {
75
+ const [name, definition]: [string, any] = resource;
76
+
77
+ this.resources[name] = new Resource({
78
+ name,
79
+ s3db: this,
80
+ s3Client: this.client,
81
+ schema: definition.schema,
82
+ options: definition.options,
83
+ validatorInstance: this.validatorInstance,
84
+ });
85
+ }
86
+
87
+ this.emit("connected", new Date());
88
+ }
89
+
90
+ async startPlugins() {
91
+ if (this.plugins && !isEmpty(this.plugins)) {
92
+ const startProms = this.plugins.map((plugin) => plugin.setup(this));
93
+ await Promise.all(startProms);
94
+ this.plugins.map((plugin) => plugin.start());
95
+ }
96
+ }
97
+
98
+ /**
99
+ * Downloads current metadata.
100
+ * If there isnt any file, creates an empty metadata.
101
+ * @returns MetadataInterface
102
+ */
103
+ private async getMetadataFile() {
104
+ try {
105
+ const request = await this.client.getObject({ key: `s3db.json` });
106
+ const metadata = JSON.parse(String(request?.Body));
107
+ return this.unserializeMetadata(metadata);
108
+ } catch (error: unknown) {
109
+ if (error instanceof ClientNoSuchKey) {
110
+ return Promise.reject(
111
+ new S3dbMissingMetadata({ bucket: this.bucket, cause: error })
112
+ );
113
+ } else {
114
+ return Promise.reject(error);
115
+ }
116
+ }
117
+ }
118
+
119
+ private unserializeMetadata(metadata: any) {
120
+ const file = { ...metadata };
121
+ if (isEmpty(file.resources)) return file;
122
+
123
+ for (const [name, structure] of Object.entries(
124
+ file.resources as MetadataResourceInterface[]
125
+ )) {
126
+ for (const [attr, value] of Object.entries(structure.schema)) {
127
+ file.resources[name].schema[attr] = JSON.parse(value as any);
128
+ }
129
+ }
130
+
131
+ return file;
132
+ }
133
+
134
+ async uploadMetadataFile() {
135
+ const file = {
136
+ version: this.version,
137
+ resources: Object.entries(this.resources).reduce(
138
+ (acc: any, definition) => {
139
+ const [name, resource] = definition;
140
+ acc[name] = (resource as Resource).export();
141
+ return acc;
142
+ },
143
+ {}
144
+ ),
145
+ };
146
+
147
+ await this.client.putObject({
148
+ key: `s3db.json`,
149
+ body: JSON.stringify(file, null, 2),
150
+ });
151
+ }
152
+
153
+ /**
154
+ * Generates empty metadata structure.
155
+ * @returns MetadataInterface
156
+ */
157
+ private blankMetadataStructure(): MetadataInterface {
158
+ return {
159
+ version: `1`,
160
+ resources: {},
161
+ };
162
+ }
163
+
164
+ /**
165
+ * Generates a new resorce with its translators and validatos.
166
+ * @param {Object} param
167
+ * @param {string} param.resourceName
168
+ * @param {Object} param.attributes
169
+ * @param {Object} param.options
170
+ */
171
+ async createResource({
172
+ resourceName,
173
+ attributes,
174
+ options = {},
175
+ }: {
176
+ resourceName: string;
177
+ attributes: any;
178
+ options?: any;
179
+ }) {
180
+ const schema: any = flatten(attributes, { safe: true });
181
+
182
+ const resource = new Resource({
183
+ schema,
184
+ s3db: this,
185
+ name: resourceName,
186
+ s3Client: this.client,
187
+ validatorInstance: this.validatorInstance,
188
+
189
+ options: {
190
+ autoDecrypt: true,
191
+ cache: this.cache,
192
+ ...options,
193
+ },
194
+ });
195
+
196
+ this.resources[resourceName] = resource;
197
+
198
+ await this.uploadMetadataFile();
199
+
200
+ return resource;
201
+ }
202
+
203
+ /**
204
+ * Looper
205
+ * @param {string} resourceName
206
+ * @returns
207
+ */
208
+ resource(resourceName: string): Resource | any {
209
+ if (!this.resources[resourceName]) {
210
+ return Promise.reject(`resource ${resourceName} does not exist`);
211
+ }
212
+
213
+ return this.resources[resourceName];
214
+ }
215
+ }
@@ -0,0 +1,90 @@
1
+ import * as path from "path";
2
+ import { S3 } from "aws-sdk";
3
+ import { chunk } from "lodash";
4
+ import { Readable } from "node:stream";
5
+ import { PromisePool } from "@supercharge/promise-pool";
6
+
7
+ import Resource from "../resource.class";
8
+
9
+ export default class ResourceIdsReadStream extends Readable {
10
+ resource: Resource;
11
+ finishedReadingResource: boolean;
12
+ content: any[];
13
+ loading: Promise<void> | null;
14
+ pagesCount: number;
15
+
16
+ constructor({ resource }: { resource: Resource }) {
17
+ super({
18
+ objectMode: true,
19
+ highWaterMark: resource.s3Client.parallelism * 3,
20
+ });
21
+
22
+ this.resource = resource;
23
+ this.pagesCount = 0;
24
+ this.content = [];
25
+ this.finishedReadingResource = false;
26
+ this.loading = this.getItems();
27
+ }
28
+
29
+ async _read(size: number): Promise<void> {
30
+ if (this.content.length === 0) {
31
+ if (this.loading) {
32
+ await this.loading;
33
+ } else if (this.finishedReadingResource) {
34
+ this.push(null);
35
+ return;
36
+ }
37
+ }
38
+
39
+ const data = this.content.shift();
40
+ this.push(data);
41
+ }
42
+
43
+ async getItems({
44
+ continuationToken = null,
45
+ }: {
46
+ continuationToken?: string | null;
47
+ } = {}) {
48
+ this.emit("page", this.pagesCount++);
49
+
50
+ const res: S3.ListObjectsV2Output = await this.resource.s3Client.listObjects({
51
+ prefix: `resource=${this.resource.name}`,
52
+ continuationToken,
53
+ });
54
+
55
+ if (res.Contents) {
56
+ const contents = chunk(res.Contents, this.resource.s3Client.parallelism);
57
+
58
+ await PromisePool.for(contents)
59
+ .withConcurrency(5)
60
+ .handleError(async (error, content) => {
61
+ this.emit("error", error, content);
62
+ })
63
+ .process((pkg: any[]) => {
64
+ const ids = pkg.map((obj) => {
65
+ return (obj.Key || "").replace(
66
+ path.join(
67
+ this.resource.s3Client.keyPrefix,
68
+ `resource=${this.resource.name}`,
69
+ "id="
70
+ ),
71
+ ""
72
+ );
73
+ });
74
+
75
+ this.content.push(ids);
76
+ ids.forEach((id: string) => this.emit("id", this.resource.name, id));
77
+ });
78
+ }
79
+
80
+ this.finishedReadingResource = !res.IsTruncated;
81
+
82
+ if (res.NextContinuationToken) {
83
+ this.loading = this.getItems({
84
+ continuationToken: res.NextContinuationToken,
85
+ });
86
+ } else {
87
+ this.loading = null;
88
+ }
89
+ }
90
+ }
@@ -0,0 +1,38 @@
1
+ import { isArray } from "lodash";
2
+ import { PromisePool } from "@supercharge/promise-pool";
3
+ import { Transform, TransformCallback } from "node:stream";
4
+
5
+ import Resource from "../resource.class";
6
+
7
+ export default class ResourceIdsToDataTransformer extends Transform {
8
+ resource: Resource;
9
+
10
+ constructor({ resource }: { resource: Resource }) {
11
+ super({ objectMode: true, highWaterMark: resource.s3Client.parallelism * 2 });
12
+
13
+ this.resource = resource;
14
+ }
15
+
16
+ async _transform(
17
+ chunk: any,
18
+ encoding: BufferEncoding,
19
+ callback: TransformCallback
20
+ ): Promise<void> {
21
+ if (!isArray(chunk)) this.push(null);
22
+ this.emit("page", chunk);
23
+
24
+ await PromisePool.for(chunk)
25
+ .withConcurrency(this.resource.s3Client.parallelism)
26
+ .handleError(async (error, content) => {
27
+ this.emit("error", error, content);
28
+ })
29
+ .process(async (id: any) => {
30
+ this.emit("id", id);
31
+ const data = await this.resource.getById(id);
32
+ this.push(data);
33
+ return data;
34
+ });
35
+
36
+ callback(null);
37
+ }
38
+ }