s3db.js 8.0.3 → 8.1.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "s3db.js",
3
- "version": "8.0.3",
3
+ "version": "8.1.0",
4
4
  "description": "Use AWS S3, the world's most reliable document storage, as a database with this ORM.",
5
5
  "main": "dist/s3db.cjs.js",
6
6
  "module": "dist/s3db.es.js",
@@ -33,6 +33,7 @@ export class Database extends EventEmitter {
33
33
  this.cache = options.cache;
34
34
  this.passphrase = options.passphrase || "secret";
35
35
  this.versioningEnabled = options.versioningEnabled || false;
36
+ this.persistHooks = options.persistHooks || false; // New configuration for hook persistence
36
37
 
37
38
  // Initialize hooks system
38
39
  this._initHooks();
@@ -97,15 +98,55 @@ export class Database extends EventEmitter {
97
98
  await this.startPlugins();
98
99
 
99
100
  let metadata = null;
101
+ let needsHealing = false;
102
+ let healingLog = [];
100
103
 
101
104
  if (await this.client.exists(`s3db.json`)) {
102
- const request = await this.client.getObject(`s3db.json`);
103
- metadata = JSON.parse(await streamToString(request?.Body));
105
+ try {
106
+ const request = await this.client.getObject(`s3db.json`);
107
+ const rawContent = await streamToString(request?.Body);
108
+
109
+ // Try to parse JSON
110
+ try {
111
+ metadata = JSON.parse(rawContent);
112
+ } catch (parseError) {
113
+ healingLog.push('JSON parsing failed - attempting recovery');
114
+ needsHealing = true;
115
+
116
+ // Attempt to fix common JSON issues
117
+ metadata = await this._attemptJsonRecovery(rawContent, healingLog);
118
+
119
+ if (!metadata) {
120
+ // Create backup and start fresh
121
+ await this._createCorruptedBackup(rawContent);
122
+ healingLog.push('Created backup of corrupted file - starting with blank metadata');
123
+ metadata = this.blankMetadataStructure();
124
+ }
125
+ }
126
+
127
+ // Validate and heal metadata structure
128
+ const healedMetadata = await this._validateAndHealMetadata(metadata, healingLog);
129
+ if (healedMetadata !== metadata) {
130
+ metadata = healedMetadata;
131
+ needsHealing = true;
132
+ }
133
+
134
+ } catch (error) {
135
+ healingLog.push(`Critical error reading s3db.json: ${error.message}`);
136
+ await this._createCorruptedBackup();
137
+ metadata = this.blankMetadataStructure();
138
+ needsHealing = true;
139
+ }
104
140
  } else {
105
141
  metadata = this.blankMetadataStructure();
106
142
  await this.uploadMetadataFile();
107
143
  }
108
144
 
145
+ // Upload healed metadata if needed
146
+ if (needsHealing) {
147
+ await this._uploadHealedMetadata(metadata, healingLog);
148
+ }
149
+
109
150
  this.savedMetadata = metadata;
110
151
 
111
152
  // Check for definition changes (this happens before creating resources from createResource calls)
@@ -151,7 +192,7 @@ export class Database extends EventEmitter {
151
192
  paranoid: versionData.paranoid !== undefined ? versionData.paranoid : true,
152
193
  allNestedObjectsOptional: versionData.allNestedObjectsOptional !== undefined ? versionData.allNestedObjectsOptional : true,
153
194
  autoDecrypt: versionData.autoDecrypt !== undefined ? versionData.autoDecrypt : true,
154
- hooks: versionData.hooks || {},
195
+ hooks: this.persistHooks ? this._deserializeHooks(versionData.hooks || {}) : (versionData.hooks || {}),
155
196
  versioningEnabled: this.versioningEnabled,
156
197
  map: versionData.map,
157
198
  idGenerator: restoredIdGenerator,
@@ -269,6 +310,78 @@ export class Database extends EventEmitter {
269
310
  return `v${maxVersion + 1}`;
270
311
  }
271
312
 
313
+ /**
314
+ * Serialize hooks to strings for JSON persistence
315
+ * @param {Object} hooks - Hooks object with event names as keys and function arrays as values
316
+ * @returns {Object} Serialized hooks object
317
+ * @private
318
+ */
319
+ _serializeHooks(hooks) {
320
+ if (!hooks || typeof hooks !== 'object') return hooks;
321
+
322
+ const serialized = {};
323
+ for (const [event, hookArray] of Object.entries(hooks)) {
324
+ if (Array.isArray(hookArray)) {
325
+ serialized[event] = hookArray.map(hook => {
326
+ if (typeof hook === 'function') {
327
+ try {
328
+ return {
329
+ __s3db_serialized_function: true,
330
+ code: hook.toString(),
331
+ name: hook.name || 'anonymous'
332
+ };
333
+ } catch (err) {
334
+ if (this.verbose) {
335
+ console.warn(`Failed to serialize hook for event '${event}':`, err.message);
336
+ }
337
+ return null;
338
+ }
339
+ }
340
+ return hook;
341
+ });
342
+ } else {
343
+ serialized[event] = hookArray;
344
+ }
345
+ }
346
+ return serialized;
347
+ }
348
+
349
+ /**
350
+ * Deserialize hooks from strings back to functions
351
+ * @param {Object} serializedHooks - Serialized hooks object
352
+ * @returns {Object} Deserialized hooks object
353
+ * @private
354
+ */
355
+ _deserializeHooks(serializedHooks) {
356
+ if (!serializedHooks || typeof serializedHooks !== 'object') return serializedHooks;
357
+
358
+ const deserialized = {};
359
+ for (const [event, hookArray] of Object.entries(serializedHooks)) {
360
+ if (Array.isArray(hookArray)) {
361
+ deserialized[event] = hookArray.map(hook => {
362
+ if (hook && typeof hook === 'object' && hook.__s3db_serialized_function) {
363
+ try {
364
+ // Use Function constructor instead of eval for better security
365
+ const fn = new Function('return ' + hook.code)();
366
+ if (typeof fn === 'function') {
367
+ return fn;
368
+ }
369
+ } catch (err) {
370
+ if (this.verbose) {
371
+ console.warn(`Failed to deserialize hook '${hook.name}' for event '${event}':`, err.message);
372
+ }
373
+ }
374
+ return null;
375
+ }
376
+ return hook;
377
+ }).filter(hook => hook !== null); // Remove failed deserializations
378
+ } else {
379
+ deserialized[event] = hookArray;
380
+ }
381
+ }
382
+ return deserialized;
383
+ }
384
+
272
385
  async startPlugins() {
273
386
  const db = this
274
387
 
@@ -357,7 +470,7 @@ export class Database extends EventEmitter {
357
470
  allNestedObjectsOptional: resource.config.allNestedObjectsOptional,
358
471
  autoDecrypt: resource.config.autoDecrypt,
359
472
  cache: resource.config.cache,
360
- hooks: resource.config.hooks,
473
+ hooks: this.persistHooks ? this._serializeHooks(resource.config.hooks) : resource.config.hooks,
361
474
  idSize: resource.idSize,
362
475
  idGenerator: resource.idGeneratorType,
363
476
  createdAt: isNewVersion ? new Date().toISOString() : existingVersionData?.createdAt
@@ -386,10 +499,331 @@ export class Database extends EventEmitter {
386
499
  return {
387
500
  version: `1`,
388
501
  s3dbVersion: this.s3dbVersion,
502
+ lastUpdated: new Date().toISOString(),
389
503
  resources: {},
390
504
  };
391
505
  }
392
506
 
507
+ /**
508
+ * Attempt to recover JSON from corrupted content
509
+ */
510
+ async _attemptJsonRecovery(content, healingLog) {
511
+ if (!content || typeof content !== 'string') {
512
+ healingLog.push('Content is empty or not a string');
513
+ return null;
514
+ }
515
+
516
+ // Try common JSON fixes
517
+ const fixes = [
518
+ // Remove trailing commas
519
+ () => content.replace(/,(\s*[}\]])/g, '$1'),
520
+ // Add missing quotes to keys
521
+ () => content.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'),
522
+ // Fix incomplete objects by adding closing braces
523
+ () => {
524
+ let openBraces = 0;
525
+ let openBrackets = 0;
526
+ let inString = false;
527
+ let escaped = false;
528
+
529
+ for (let i = 0; i < content.length; i++) {
530
+ const char = content[i];
531
+
532
+ if (escaped) {
533
+ escaped = false;
534
+ continue;
535
+ }
536
+
537
+ if (char === '\\') {
538
+ escaped = true;
539
+ continue;
540
+ }
541
+
542
+ if (char === '"') {
543
+ inString = !inString;
544
+ continue;
545
+ }
546
+
547
+ if (!inString) {
548
+ if (char === '{') openBraces++;
549
+ else if (char === '}') openBraces--;
550
+ else if (char === '[') openBrackets++;
551
+ else if (char === ']') openBrackets--;
552
+ }
553
+ }
554
+
555
+ let fixed = content;
556
+ while (openBrackets > 0) {
557
+ fixed += ']';
558
+ openBrackets--;
559
+ }
560
+ while (openBraces > 0) {
561
+ fixed += '}';
562
+ openBraces--;
563
+ }
564
+
565
+ return fixed;
566
+ }
567
+ ];
568
+
569
+ for (const [index, fix] of fixes.entries()) {
570
+ try {
571
+ const fixedContent = fix();
572
+ const parsed = JSON.parse(fixedContent);
573
+ healingLog.push(`JSON recovery successful using fix #${index + 1}`);
574
+ return parsed;
575
+ } catch (error) {
576
+ // Try next fix
577
+ }
578
+ }
579
+
580
+ healingLog.push('All JSON recovery attempts failed');
581
+ return null;
582
+ }
583
+
584
+ /**
585
+ * Validate and heal metadata structure
586
+ */
587
+ async _validateAndHealMetadata(metadata, healingLog) {
588
+ if (!metadata || typeof metadata !== 'object') {
589
+ healingLog.push('Metadata is not an object - using blank structure');
590
+ return this.blankMetadataStructure();
591
+ }
592
+
593
+ let healed = { ...metadata };
594
+ let changed = false;
595
+
596
+ // Ensure required fields exist and have correct types
597
+ if (!healed.version || typeof healed.version !== 'string') {
598
+ if (healed.version && typeof healed.version === 'number') {
599
+ healed.version = String(healed.version);
600
+ healingLog.push('Converted version from number to string');
601
+ changed = true;
602
+ } else {
603
+ healed.version = '1';
604
+ healingLog.push('Added missing or invalid version field');
605
+ changed = true;
606
+ }
607
+ }
608
+
609
+ if (!healed.s3dbVersion || typeof healed.s3dbVersion !== 'string') {
610
+ if (healed.s3dbVersion && typeof healed.s3dbVersion !== 'string') {
611
+ healed.s3dbVersion = String(healed.s3dbVersion);
612
+ healingLog.push('Converted s3dbVersion to string');
613
+ changed = true;
614
+ } else {
615
+ healed.s3dbVersion = this.s3dbVersion;
616
+ healingLog.push('Added missing s3dbVersion field');
617
+ changed = true;
618
+ }
619
+ }
620
+
621
+ if (!healed.resources || typeof healed.resources !== 'object' || Array.isArray(healed.resources)) {
622
+ healed.resources = {};
623
+ healingLog.push('Fixed invalid resources field');
624
+ changed = true;
625
+ }
626
+
627
+ if (!healed.lastUpdated) {
628
+ healed.lastUpdated = new Date().toISOString();
629
+ healingLog.push('Added missing lastUpdated field');
630
+ changed = true;
631
+ }
632
+
633
+ // Validate and heal resource structures
634
+ const validResources = {};
635
+ for (const [name, resource] of Object.entries(healed.resources)) {
636
+ const healedResource = this._healResourceStructure(name, resource, healingLog);
637
+ if (healedResource) {
638
+ validResources[name] = healedResource;
639
+ if (healedResource !== resource) {
640
+ changed = true;
641
+ }
642
+ } else {
643
+ healingLog.push(`Removed invalid resource: ${name}`);
644
+ changed = true;
645
+ }
646
+ }
647
+
648
+ healed.resources = validResources;
649
+
650
+ return changed ? healed : metadata;
651
+ }
652
+
653
+ /**
654
+ * Heal individual resource structure
655
+ */
656
+ _healResourceStructure(name, resource, healingLog) {
657
+ if (!resource || typeof resource !== 'object') {
658
+ healingLog.push(`Resource ${name}: invalid structure`);
659
+ return null;
660
+ }
661
+
662
+ let healed = { ...resource };
663
+ let changed = false;
664
+
665
+ // Ensure currentVersion exists
666
+ if (!healed.currentVersion) {
667
+ healed.currentVersion = 'v0';
668
+ healingLog.push(`Resource ${name}: added missing currentVersion`);
669
+ changed = true;
670
+ }
671
+
672
+ // Ensure versions object exists
673
+ if (!healed.versions || typeof healed.versions !== 'object' || Array.isArray(healed.versions)) {
674
+ healed.versions = {};
675
+ healingLog.push(`Resource ${name}: fixed invalid versions object`);
676
+ changed = true;
677
+ }
678
+
679
+ // Ensure partitions object exists
680
+ if (!healed.partitions || typeof healed.partitions !== 'object' || Array.isArray(healed.partitions)) {
681
+ healed.partitions = {};
682
+ healingLog.push(`Resource ${name}: fixed invalid partitions object`);
683
+ changed = true;
684
+ }
685
+
686
+ // Check if currentVersion exists in versions
687
+ const currentVersion = healed.currentVersion;
688
+ if (!healed.versions[currentVersion]) {
689
+ // Try to find a valid version or fall back to v0
690
+ const availableVersions = Object.keys(healed.versions);
691
+ if (availableVersions.length > 0) {
692
+ healed.currentVersion = availableVersions[0];
693
+ healingLog.push(`Resource ${name}: changed currentVersion from ${currentVersion} to ${healed.currentVersion}`);
694
+ changed = true;
695
+ } else {
696
+ // No valid versions exist - resource cannot be healed
697
+ healingLog.push(`Resource ${name}: no valid versions found - removing resource`);
698
+ return null;
699
+ }
700
+ }
701
+
702
+ // Validate version data
703
+ const versionData = healed.versions[healed.currentVersion];
704
+ if (!versionData || typeof versionData !== 'object') {
705
+ healingLog.push(`Resource ${name}: invalid version data - removing resource`);
706
+ return null;
707
+ }
708
+
709
+ // Ensure required version fields
710
+ if (!versionData.attributes || typeof versionData.attributes !== 'object') {
711
+ healingLog.push(`Resource ${name}: missing or invalid attributes - removing resource`);
712
+ return null;
713
+ }
714
+
715
+ // Heal hooks structure
716
+ if (versionData.hooks) {
717
+ const healedHooks = this._healHooksStructure(versionData.hooks, name, healingLog);
718
+ if (healedHooks !== versionData.hooks) {
719
+ healed.versions[healed.currentVersion].hooks = healedHooks;
720
+ changed = true;
721
+ }
722
+ }
723
+
724
+ return changed ? healed : resource;
725
+ }
726
+
727
+ /**
728
+ * Heal hooks structure
729
+ */
730
+ _healHooksStructure(hooks, resourceName, healingLog) {
731
+ if (!hooks || typeof hooks !== 'object') {
732
+ healingLog.push(`Resource ${resourceName}: invalid hooks structure - using empty hooks`);
733
+ return {};
734
+ }
735
+
736
+ const healed = {};
737
+ let changed = false;
738
+
739
+ for (const [event, hookArray] of Object.entries(hooks)) {
740
+ if (Array.isArray(hookArray)) {
741
+ // Filter out null, undefined, empty strings, and invalid hooks
742
+ const validHooks = hookArray.filter(hook =>
743
+ hook !== null &&
744
+ hook !== undefined &&
745
+ hook !== ""
746
+ );
747
+ healed[event] = validHooks;
748
+
749
+ if (validHooks.length !== hookArray.length) {
750
+ healingLog.push(`Resource ${resourceName}: cleaned invalid hooks for event ${event}`);
751
+ changed = true;
752
+ }
753
+ } else {
754
+ healingLog.push(`Resource ${resourceName}: hooks for event ${event} is not an array - removing`);
755
+ changed = true;
756
+ }
757
+ }
758
+
759
+ return changed ? healed : hooks;
760
+ }
761
+
762
+ /**
763
+ * Create backup of corrupted file
764
+ */
765
+ async _createCorruptedBackup(content = null) {
766
+ try {
767
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
768
+ const backupKey = `s3db.json.corrupted.${timestamp}.backup`;
769
+
770
+ if (!content) {
771
+ try {
772
+ const request = await this.client.getObject(`s3db.json`);
773
+ content = await streamToString(request?.Body);
774
+ } catch (error) {
775
+ content = 'Unable to read corrupted file content';
776
+ }
777
+ }
778
+
779
+ await this.client.putObject({
780
+ key: backupKey,
781
+ body: content,
782
+ contentType: 'application/json'
783
+ });
784
+
785
+ if (this.verbose) {
786
+ console.warn(`S3DB: Created backup of corrupted s3db.json as ${backupKey}`);
787
+ }
788
+ } catch (error) {
789
+ if (this.verbose) {
790
+ console.warn(`S3DB: Failed to create backup: ${error.message}`);
791
+ }
792
+ }
793
+ }
794
+
795
+ /**
796
+ * Upload healed metadata with logging
797
+ */
798
+ async _uploadHealedMetadata(metadata, healingLog) {
799
+ try {
800
+ if (this.verbose && healingLog.length > 0) {
801
+ console.warn('S3DB Self-Healing Operations:');
802
+ healingLog.forEach(log => console.warn(` - ${log}`));
803
+ }
804
+
805
+ // Update lastUpdated timestamp
806
+ metadata.lastUpdated = new Date().toISOString();
807
+
808
+ await this.client.putObject({
809
+ key: 's3db.json',
810
+ body: JSON.stringify(metadata, null, 2),
811
+ contentType: 'application/json'
812
+ });
813
+
814
+ this.emit('metadataHealed', { healingLog, metadata });
815
+
816
+ if (this.verbose) {
817
+ console.warn('S3DB: Successfully uploaded healed metadata');
818
+ }
819
+ } catch (error) {
820
+ if (this.verbose) {
821
+ console.error(`S3DB: Failed to upload healed metadata: ${error.message}`);
822
+ }
823
+ throw error;
824
+ }
825
+ }
826
+
393
827
  /**
394
828
  * Check if a resource exists by name
395
829
  * @param {string} name - Resource name
package/src/s3db.d.ts CHANGED
@@ -34,6 +34,7 @@ declare module 's3db.js' {
34
34
  parallelism?: number | string;
35
35
  passphrase?: string;
36
36
  versioningEnabled?: boolean;
37
+ persistHooks?: boolean;
37
38
  cache?: CacheConfig | boolean;
38
39
  plugins?: (PluginInterface | PluginFunction)[];
39
40
  client?: Client;