s3db.js 6.2.0 → 7.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 (60) hide show
  1. package/PLUGINS.md +2724 -0
  2. package/README.md +372 -469
  3. package/UNLICENSE +24 -0
  4. package/dist/s3db.cjs.js +30057 -18387
  5. package/dist/s3db.cjs.min.js +1 -1
  6. package/dist/s3db.d.ts +373 -72
  7. package/dist/s3db.es.js +30043 -18384
  8. package/dist/s3db.es.min.js +1 -1
  9. package/dist/s3db.iife.js +29730 -18061
  10. package/dist/s3db.iife.min.js +1 -1
  11. package/package.json +44 -69
  12. package/src/behaviors/body-only.js +110 -0
  13. package/src/behaviors/body-overflow.js +153 -0
  14. package/src/behaviors/enforce-limits.js +195 -0
  15. package/src/behaviors/index.js +39 -0
  16. package/src/behaviors/truncate-data.js +204 -0
  17. package/src/behaviors/user-managed.js +147 -0
  18. package/src/client.class.js +515 -0
  19. package/src/concerns/base62.js +61 -0
  20. package/src/concerns/calculator.js +204 -0
  21. package/src/concerns/crypto.js +142 -0
  22. package/src/concerns/id.js +8 -0
  23. package/src/concerns/index.js +5 -0
  24. package/src/concerns/try-fn.js +151 -0
  25. package/src/connection-string.class.js +75 -0
  26. package/src/database.class.js +599 -0
  27. package/src/errors.js +261 -0
  28. package/src/index.js +17 -0
  29. package/src/plugins/audit.plugin.js +442 -0
  30. package/src/plugins/cache/cache.class.js +53 -0
  31. package/src/plugins/cache/index.js +6 -0
  32. package/src/plugins/cache/memory-cache.class.js +164 -0
  33. package/src/plugins/cache/s3-cache.class.js +189 -0
  34. package/src/plugins/cache.plugin.js +275 -0
  35. package/src/plugins/consumers/index.js +24 -0
  36. package/src/plugins/consumers/rabbitmq-consumer.js +56 -0
  37. package/src/plugins/consumers/sqs-consumer.js +102 -0
  38. package/src/plugins/costs.plugin.js +81 -0
  39. package/src/plugins/fulltext.plugin.js +473 -0
  40. package/src/plugins/index.js +12 -0
  41. package/src/plugins/metrics.plugin.js +603 -0
  42. package/src/plugins/plugin.class.js +210 -0
  43. package/src/plugins/plugin.obj.js +13 -0
  44. package/src/plugins/queue-consumer.plugin.js +134 -0
  45. package/src/plugins/replicator.plugin.js +769 -0
  46. package/src/plugins/replicators/base-replicator.class.js +85 -0
  47. package/src/plugins/replicators/bigquery-replicator.class.js +328 -0
  48. package/src/plugins/replicators/index.js +44 -0
  49. package/src/plugins/replicators/postgres-replicator.class.js +427 -0
  50. package/src/plugins/replicators/s3db-replicator.class.js +352 -0
  51. package/src/plugins/replicators/sqs-replicator.class.js +427 -0
  52. package/src/resource.class.js +2626 -0
  53. package/src/s3db.d.ts +1263 -0
  54. package/src/schema.class.js +706 -0
  55. package/src/stream/index.js +16 -0
  56. package/src/stream/resource-ids-page-reader.class.js +10 -0
  57. package/src/stream/resource-ids-reader.class.js +63 -0
  58. package/src/stream/resource-reader.class.js +81 -0
  59. package/src/stream/resource-writer.class.js +92 -0
  60. package/src/validator.class.js +97 -0
@@ -0,0 +1,599 @@
1
+ import EventEmitter from "events";
2
+ import { createHash } from "crypto";
3
+ import { isEmpty, isFunction } from "lodash-es";
4
+ import jsonStableStringify from "json-stable-stringify";
5
+
6
+ import Client from "./client.class.js";
7
+ import tryFn from "./concerns/try-fn.js";
8
+ import Resource from "./resource.class.js";
9
+ import { streamToString } from "./stream/index.js";
10
+ import { ResourceNotFound } from "./errors.js";
11
+
12
+ export class Database extends EventEmitter {
13
+ constructor(options) {
14
+ super();
15
+
16
+ this.version = "1";
17
+ // Version is injected during build, fallback to "latest" for development
18
+ this.s3dbVersion = (() => {
19
+ const [ok, err, version] = tryFn(() => (typeof __PACKAGE_VERSION__ !== 'undefined' && __PACKAGE_VERSION__ !== '__PACKAGE_VERSION__'
20
+ ? __PACKAGE_VERSION__
21
+ : "latest"));
22
+ return ok ? version : "latest";
23
+ })();
24
+ this.resources = {};
25
+ this.savedMetadata = null; // Store loaded metadata for versioning
26
+ this.options = options;
27
+ this.verbose = options.verbose || false;
28
+ this.parallelism = parseInt(options.parallelism + "") || 10;
29
+ this.plugins = options.plugins || []; // Initialize plugins array
30
+ this.pluginList = options.plugins || []; // Keep the list for backward compatibility
31
+ this.cache = options.cache;
32
+ this.passphrase = options.passphrase || "secret";
33
+ this.versioningEnabled = options.versioningEnabled || false;
34
+
35
+ // Handle both connection string and individual parameters
36
+ let connectionString = options.connectionString;
37
+ if (!connectionString && (options.bucket || options.accessKeyId || options.secretAccessKey)) {
38
+ // Build connection string manually
39
+ const { bucket, region, accessKeyId, secretAccessKey, endpoint, forcePathStyle } = options;
40
+
41
+ // If endpoint is provided, assume it's MinIO or Digital Ocean
42
+ if (endpoint) {
43
+ const url = new URL(endpoint);
44
+ if (accessKeyId) url.username = encodeURIComponent(accessKeyId);
45
+ if (secretAccessKey) url.password = encodeURIComponent(secretAccessKey);
46
+ url.pathname = `/${bucket || 's3db'}`;
47
+
48
+ // Add forcePathStyle parameter if specified
49
+ if (forcePathStyle) {
50
+ url.searchParams.set('forcePathStyle', 'true');
51
+ }
52
+
53
+ connectionString = url.toString();
54
+ } else if (accessKeyId && secretAccessKey) {
55
+ // Otherwise, build S3 connection string only if credentials are provided
56
+ const params = new URLSearchParams();
57
+ params.set('region', region || 'us-east-1');
58
+ if (forcePathStyle) {
59
+ params.set('forcePathStyle', 'true');
60
+ }
61
+ connectionString = `s3://${encodeURIComponent(accessKeyId)}:${encodeURIComponent(secretAccessKey)}@${bucket || 's3db'}?${params.toString()}`;
62
+ }
63
+ }
64
+
65
+ this.client = options.client || new Client({
66
+ verbose: this.verbose,
67
+ parallelism: this.parallelism,
68
+ connectionString: connectionString,
69
+ });
70
+
71
+ this.bucket = this.client.bucket;
72
+ this.keyPrefix = this.client.keyPrefix;
73
+
74
+ // Add process exit listener for cleanup
75
+ if (!this._exitListenerRegistered) {
76
+ this._exitListenerRegistered = true;
77
+ process.on('exit', async () => {
78
+ if (this.isConnected()) {
79
+ try {
80
+ await this.disconnect();
81
+ } catch (err) {
82
+ // Silently ignore errors on exit
83
+ }
84
+ }
85
+ });
86
+ }
87
+ }
88
+
89
+ async connect() {
90
+ await this.startPlugins();
91
+
92
+ let metadata = null;
93
+
94
+ if (await this.client.exists(`s3db.json`)) {
95
+ const request = await this.client.getObject(`s3db.json`);
96
+ metadata = JSON.parse(await streamToString(request?.Body));
97
+ } else {
98
+ metadata = this.blankMetadataStructure();
99
+ await this.uploadMetadataFile();
100
+ }
101
+
102
+ this.savedMetadata = metadata;
103
+
104
+ // Check for definition changes (this happens before creating resources from createResource calls)
105
+ const definitionChanges = this.detectDefinitionChanges(metadata);
106
+
107
+ // Create resources from saved metadata using current version
108
+ for (const [name, resourceMetadata] of Object.entries(metadata.resources || {})) {
109
+ const currentVersion = resourceMetadata.currentVersion || 'v0';
110
+ const versionData = resourceMetadata.versions?.[currentVersion];
111
+
112
+ if (versionData) {
113
+ // Extract configuration from version data at root level
114
+ this.resources[name] = new Resource({
115
+ name,
116
+ client: this.client,
117
+ database: this, // garantir referência
118
+ version: currentVersion,
119
+ attributes: versionData.attributes,
120
+ behavior: versionData.behavior || 'user-managed',
121
+ parallelism: this.parallelism,
122
+ passphrase: this.passphrase,
123
+ observers: [this],
124
+ cache: this.cache,
125
+ timestamps: versionData.timestamps !== undefined ? versionData.timestamps : false,
126
+ partitions: resourceMetadata.partitions || versionData.partitions || {},
127
+ paranoid: versionData.paranoid !== undefined ? versionData.paranoid : true,
128
+ allNestedObjectsOptional: versionData.allNestedObjectsOptional !== undefined ? versionData.allNestedObjectsOptional : true,
129
+ autoDecrypt: versionData.autoDecrypt !== undefined ? versionData.autoDecrypt : true,
130
+ hooks: versionData.hooks || {},
131
+ versioningEnabled: this.versioningEnabled,
132
+ map: versionData.map
133
+ });
134
+ }
135
+ }
136
+
137
+ // Emit definition changes if any were detected
138
+ if (definitionChanges.length > 0) {
139
+ this.emit("resourceDefinitionsChanged", {
140
+ changes: definitionChanges,
141
+ metadata: this.savedMetadata
142
+ });
143
+ }
144
+
145
+ this.emit("connected", new Date());
146
+ }
147
+
148
+ /**
149
+ * Detect changes in resource definitions compared to saved metadata
150
+ * @param {Object} savedMetadata - The metadata loaded from s3db.json
151
+ * @returns {Array} Array of change objects
152
+ */
153
+ detectDefinitionChanges(savedMetadata) {
154
+ const changes = [];
155
+
156
+ for (const [name, currentResource] of Object.entries(this.resources)) {
157
+ const currentHash = this.generateDefinitionHash(currentResource.export());
158
+ const savedResource = savedMetadata.resources?.[name];
159
+
160
+ if (!savedResource) {
161
+ changes.push({
162
+ type: 'new',
163
+ resourceName: name,
164
+ currentHash,
165
+ savedHash: null
166
+ });
167
+ } else {
168
+ // Get current version hash from saved metadata
169
+ const currentVersion = savedResource.currentVersion || 'v0';
170
+ const versionData = savedResource.versions?.[currentVersion];
171
+ const savedHash = versionData?.hash;
172
+
173
+ if (savedHash !== currentHash) {
174
+ changes.push({
175
+ type: 'changed',
176
+ resourceName: name,
177
+ currentHash,
178
+ savedHash,
179
+ fromVersion: currentVersion,
180
+ toVersion: this.getNextVersion(savedResource.versions)
181
+ });
182
+ }
183
+ }
184
+ }
185
+
186
+ // Check for deleted resources
187
+ for (const [name, savedResource] of Object.entries(savedMetadata.resources || {})) {
188
+ if (!this.resources[name]) {
189
+ const currentVersion = savedResource.currentVersion || 'v0';
190
+ const versionData = savedResource.versions?.[currentVersion];
191
+ changes.push({
192
+ type: 'deleted',
193
+ resourceName: name,
194
+ currentHash: null,
195
+ savedHash: versionData?.hash,
196
+ deletedVersion: currentVersion
197
+ });
198
+ }
199
+ }
200
+
201
+ return changes;
202
+ }
203
+
204
+ /**
205
+ * Generate a consistent hash for a resource definition
206
+ * @param {Object} definition - Resource definition to hash
207
+ * @param {string} behavior - Resource behavior
208
+ * @returns {string} SHA256 hash
209
+ */
210
+ generateDefinitionHash(definition, behavior = undefined) {
211
+ // Extract only the attributes for hashing (exclude name, version, options, etc.)
212
+ const attributes = definition.attributes;
213
+ // Create a stable version for hashing by excluding dynamic fields
214
+ const stableAttributes = { ...attributes };
215
+ // Remove timestamp fields if they were added automatically
216
+ if (definition.timestamps) {
217
+ delete stableAttributes.createdAt;
218
+ delete stableAttributes.updatedAt;
219
+ }
220
+ // Include behavior and partitions in the hash
221
+ const hashObj = {
222
+ attributes: stableAttributes,
223
+ behavior: behavior || definition.behavior || 'user-managed',
224
+ partitions: definition.partitions || {},
225
+ };
226
+ // Use jsonStableStringify to ensure consistent ordering
227
+ const stableString = jsonStableStringify(hashObj);
228
+ return `sha256:${createHash('sha256').update(stableString).digest('hex')}`;
229
+ }
230
+
231
+ /**
232
+ * Get the next version number for a resource
233
+ * @param {Object} versions - Existing versions object
234
+ * @returns {string} Next version string (e.g., 'v1', 'v2')
235
+ */
236
+ getNextVersion(versions = {}) {
237
+ const versionNumbers = Object.keys(versions)
238
+ .filter(v => v.startsWith('v'))
239
+ .map(v => parseInt(v.substring(1)))
240
+ .filter(n => !isNaN(n));
241
+
242
+ const maxVersion = versionNumbers.length > 0 ? Math.max(...versionNumbers) : -1;
243
+ return `v${maxVersion + 1}`;
244
+ }
245
+
246
+ async startPlugins() {
247
+ const db = this
248
+
249
+ if (!isEmpty(this.pluginList)) {
250
+ const plugins = this.pluginList.map(p => isFunction(p) ? new p(this) : p)
251
+
252
+ const setupProms = plugins.map(async (plugin) => {
253
+ if (plugin.beforeSetup) await plugin.beforeSetup()
254
+ await plugin.setup(db)
255
+ if (plugin.afterSetup) await plugin.afterSetup()
256
+ });
257
+
258
+ await Promise.all(setupProms);
259
+
260
+ const startProms = plugins.map(async (plugin) => {
261
+ if (plugin.beforeStart) await plugin.beforeStart()
262
+ await plugin.start()
263
+ if (plugin.afterStart) await plugin.afterStart()
264
+ });
265
+
266
+ await Promise.all(startProms);
267
+ }
268
+ }
269
+
270
+ /**
271
+ * Register and setup a plugin
272
+ * @param {Plugin} plugin - Plugin instance to register
273
+ * @param {string} [name] - Optional name for the plugin (defaults to plugin.constructor.name)
274
+ */
275
+ async usePlugin(plugin, name = null) {
276
+ const pluginName = name || plugin.constructor.name.replace('Plugin', '').toLowerCase();
277
+
278
+ // Register the plugin
279
+ this.plugins[pluginName] = plugin;
280
+
281
+ // Setup the plugin if database is connected
282
+ if (this.isConnected()) {
283
+ await plugin.setup(this);
284
+ await plugin.start();
285
+ }
286
+
287
+ return plugin;
288
+ }
289
+
290
+ async uploadMetadataFile() {
291
+ const metadata = {
292
+ version: this.version,
293
+ s3dbVersion: this.s3dbVersion,
294
+ lastUpdated: new Date().toISOString(),
295
+ resources: {}
296
+ };
297
+
298
+ // Generate versioned definition for each resource
299
+ Object.entries(this.resources).forEach(([name, resource]) => {
300
+ const resourceDef = resource.export();
301
+ const definitionHash = this.generateDefinitionHash(resourceDef);
302
+
303
+ // Check if resource exists in saved metadata
304
+ const existingResource = this.savedMetadata?.resources?.[name];
305
+ const currentVersion = existingResource?.currentVersion || 'v0';
306
+ const existingVersionData = existingResource?.versions?.[currentVersion];
307
+
308
+ let version, isNewVersion;
309
+
310
+ // If hash is different, create new version
311
+ if (!existingVersionData || existingVersionData.hash !== definitionHash) {
312
+ version = this.getNextVersion(existingResource?.versions);
313
+ isNewVersion = true;
314
+ } else {
315
+ version = currentVersion;
316
+ isNewVersion = false;
317
+ }
318
+
319
+ metadata.resources[name] = {
320
+ currentVersion: version,
321
+ partitions: resource.config.partitions || {},
322
+ versions: {
323
+ ...existingResource?.versions, // Preserve previous versions
324
+ [version]: {
325
+ hash: definitionHash,
326
+ attributes: resourceDef.attributes,
327
+ behavior: resourceDef.behavior || 'user-managed',
328
+ timestamps: resource.config.timestamps,
329
+ partitions: resource.config.partitions,
330
+ paranoid: resource.config.paranoid,
331
+ allNestedObjectsOptional: resource.config.allNestedObjectsOptional,
332
+ autoDecrypt: resource.config.autoDecrypt,
333
+ cache: resource.config.cache,
334
+ hooks: resource.config.hooks,
335
+ createdAt: isNewVersion ? new Date().toISOString() : existingVersionData?.createdAt
336
+ }
337
+ }
338
+ };
339
+
340
+ // Update resource version safely
341
+ if (resource.version !== version) {
342
+ resource.version = version;
343
+ resource.emit('versionUpdated', { oldVersion: currentVersion, newVersion: version });
344
+ }
345
+ });
346
+
347
+ await this.client.putObject({
348
+ key: 's3db.json',
349
+ body: JSON.stringify(metadata, null, 2),
350
+ contentType: 'application/json'
351
+ });
352
+
353
+ this.savedMetadata = metadata;
354
+ this.emit('metadataUploaded', metadata);
355
+ }
356
+
357
+ blankMetadataStructure() {
358
+ return {
359
+ version: `1`,
360
+ s3dbVersion: this.s3dbVersion,
361
+ resources: {},
362
+ };
363
+ }
364
+
365
+ /**
366
+ * Check if a resource exists by name
367
+ * @param {string} name - Resource name
368
+ * @returns {boolean} True if resource exists, false otherwise
369
+ */
370
+ resourceExists(name) {
371
+ return !!this.resources[name];
372
+ }
373
+
374
+ /**
375
+ * Check if a resource exists with the same definition hash
376
+ * @param {Object} config - Resource configuration
377
+ * @param {string} config.name - Resource name
378
+ * @param {Object} config.attributes - Resource attributes
379
+ * @param {string} [config.behavior] - Resource behavior
380
+ * @param {Object} [config.options] - Resource options (deprecated, use root level parameters)
381
+ * @returns {Object} Result with exists and hash information
382
+ */
383
+ resourceExistsWithSameHash({ name, attributes, behavior = 'user-managed', partitions = {}, options = {} }) {
384
+ if (!this.resources[name]) {
385
+ return { exists: false, sameHash: false, hash: null };
386
+ }
387
+
388
+ const existingResource = this.resources[name];
389
+ const existingHash = this.generateDefinitionHash(existingResource.export());
390
+
391
+ // Create a mock resource to calculate the new hash
392
+ const mockResource = new Resource({
393
+ name,
394
+ attributes,
395
+ behavior,
396
+ partitions,
397
+ client: this.client,
398
+ version: existingResource.version,
399
+ passphrase: this.passphrase,
400
+ versioningEnabled: this.versioningEnabled,
401
+ ...options
402
+ });
403
+
404
+ const newHash = this.generateDefinitionHash(mockResource.export());
405
+
406
+ return {
407
+ exists: true,
408
+ sameHash: existingHash === newHash,
409
+ hash: newHash,
410
+ existingHash
411
+ };
412
+ }
413
+
414
+ async createResource({ name, attributes, behavior = 'user-managed', hooks, ...config }) {
415
+ if (this.resources[name]) {
416
+ const existingResource = this.resources[name];
417
+ // Update configuration
418
+ Object.assign(existingResource.config, {
419
+ cache: this.cache,
420
+ ...config,
421
+ });
422
+ if (behavior) {
423
+ existingResource.behavior = behavior;
424
+ }
425
+ // Ensure versioning configuration is set
426
+ existingResource.versioningEnabled = this.versioningEnabled;
427
+ existingResource.updateAttributes(attributes);
428
+ // NOVO: Mescla hooks se fornecidos (append ao final)
429
+ if (hooks) {
430
+ for (const [event, hooksArr] of Object.entries(hooks)) {
431
+ if (Array.isArray(hooksArr) && existingResource.hooks[event]) {
432
+ for (const fn of hooksArr) {
433
+ if (typeof fn === 'function') {
434
+ existingResource.hooks[event].push(fn.bind(existingResource));
435
+ }
436
+ }
437
+ }
438
+ }
439
+ }
440
+ // Only upload metadata if hash actually changed
441
+ const newHash = this.generateDefinitionHash(existingResource.export(), existingResource.behavior);
442
+ const existingMetadata = this.savedMetadata?.resources?.[name];
443
+ const currentVersion = existingMetadata?.currentVersion || 'v0';
444
+ const existingVersionData = existingMetadata?.versions?.[currentVersion];
445
+ if (!existingVersionData || existingVersionData.hash !== newHash) {
446
+ await this.uploadMetadataFile();
447
+ }
448
+ this.emit("s3db.resourceUpdated", name);
449
+ return existingResource;
450
+ }
451
+ const existingMetadata = this.savedMetadata?.resources?.[name];
452
+ const version = existingMetadata?.currentVersion || 'v0';
453
+ const resource = new Resource({
454
+ name,
455
+ client: this.client,
456
+ version: config.version !== undefined ? config.version : version,
457
+ attributes,
458
+ behavior,
459
+ parallelism: this.parallelism,
460
+ passphrase: config.passphrase !== undefined ? config.passphrase : this.passphrase,
461
+ observers: [this],
462
+ cache: config.cache !== undefined ? config.cache : this.cache,
463
+ timestamps: config.timestamps !== undefined ? config.timestamps : false,
464
+ partitions: config.partitions || {},
465
+ paranoid: config.paranoid !== undefined ? config.paranoid : true,
466
+ allNestedObjectsOptional: config.allNestedObjectsOptional !== undefined ? config.allNestedObjectsOptional : true,
467
+ autoDecrypt: config.autoDecrypt !== undefined ? config.autoDecrypt : true,
468
+ hooks: hooks || {},
469
+ versioningEnabled: this.versioningEnabled,
470
+ map: config.map,
471
+ idGenerator: config.idGenerator,
472
+ idSize: config.idSize
473
+ });
474
+ resource.database = this;
475
+ this.resources[name] = resource;
476
+ await this.uploadMetadataFile();
477
+ this.emit("s3db.resourceCreated", name);
478
+ return resource;
479
+ }
480
+
481
+ resource(name) {
482
+ if (!this.resources[name]) {
483
+ return Promise.reject(`resource ${name} does not exist`);
484
+ }
485
+
486
+ return this.resources[name];
487
+ }
488
+
489
+ /**
490
+ * List all resource names
491
+ * @returns {Array} Array of resource names
492
+ */
493
+ async listResources() {
494
+ return Object.keys(this.resources).map(name => ({ name }));
495
+ }
496
+
497
+ /**
498
+ * Get a specific resource by name
499
+ * @param {string} name - Resource name
500
+ * @returns {Resource} Resource instance
501
+ */
502
+ async getResource(name) {
503
+ if (!this.resources[name]) {
504
+ throw new ResourceNotFound({
505
+ bucket: this.client.config.bucket,
506
+ resourceName: name,
507
+ id: name
508
+ });
509
+ }
510
+ return this.resources[name];
511
+ }
512
+
513
+ /**
514
+ * Get database configuration
515
+ * @returns {Object} Configuration object
516
+ */
517
+ get config() {
518
+ return {
519
+ version: this.version,
520
+ s3dbVersion: this.s3dbVersion,
521
+ bucket: this.bucket,
522
+ keyPrefix: this.keyPrefix,
523
+ parallelism: this.parallelism,
524
+ verbose: this.verbose
525
+ };
526
+ }
527
+
528
+ isConnected() {
529
+ return !!this.savedMetadata;
530
+ }
531
+
532
+ async disconnect() {
533
+ try {
534
+ // 1. Remove all listeners from all plugins
535
+ if (this.pluginList && this.pluginList.length > 0) {
536
+ for (const plugin of this.pluginList) {
537
+ if (plugin && typeof plugin.removeAllListeners === 'function') {
538
+ plugin.removeAllListeners();
539
+ }
540
+ }
541
+ // Also stop plugins if they have a stop method
542
+ const stopProms = this.pluginList.map(async (plugin) => {
543
+ try {
544
+ if (plugin && typeof plugin.stop === 'function') {
545
+ await plugin.stop();
546
+ }
547
+ } catch (err) {
548
+ // Silently ignore errors on exit
549
+ }
550
+ });
551
+ await Promise.all(stopProms);
552
+ }
553
+
554
+ // 2. Remove all listeners from all resources
555
+ if (this.resources && Object.keys(this.resources).length > 0) {
556
+ for (const [name, resource] of Object.entries(this.resources)) {
557
+ try {
558
+ if (resource && typeof resource.removeAllListeners === 'function') {
559
+ resource.removeAllListeners();
560
+ }
561
+ if (resource._pluginWrappers) {
562
+ resource._pluginWrappers.clear();
563
+ }
564
+ if (resource._pluginMiddlewares) {
565
+ resource._pluginMiddlewares = {};
566
+ }
567
+ if (resource.observers && Array.isArray(resource.observers)) {
568
+ resource.observers = [];
569
+ }
570
+ } catch (err) {
571
+ // Silently ignore errors on exit
572
+ }
573
+ }
574
+ // Instead of reassigning, clear in place
575
+ Object.keys(this.resources).forEach(k => delete this.resources[k]);
576
+ }
577
+
578
+ // 3. Remove all listeners from the client
579
+ if (this.client && typeof this.client.removeAllListeners === 'function') {
580
+ this.client.removeAllListeners();
581
+ }
582
+
583
+ // 4. Remove all listeners from the database itself
584
+ this.removeAllListeners();
585
+
586
+ // 5. Clear saved metadata and plugin lists
587
+ this.savedMetadata = null;
588
+ this.plugins = {};
589
+ this.pluginList = [];
590
+
591
+ this.emit('disconnected', new Date());
592
+ } catch (err) {
593
+ // Silently ignore errors on exit
594
+ }
595
+ }
596
+ }
597
+
598
+ export class S3db extends Database {}
599
+ export default S3db;