s3db.js 8.0.2 → 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/dist/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;
package/dist/s3db.es.js CHANGED
@@ -9661,7 +9661,7 @@ class Client extends EventEmitter {
9661
9661
  }) {
9662
9662
  super();
9663
9663
  this.verbose = verbose;
9664
- this.id = id ?? idGenerator();
9664
+ this.id = id ?? idGenerator(77);
9665
9665
  this.parallelism = parallelism;
9666
9666
  this.config = new ConnectionString(connectionString);
9667
9667
  this.httpClientOptions = {
@@ -11248,10 +11248,18 @@ class Resource extends EventEmitter {
11248
11248
  */
11249
11249
  constructor(config = {}) {
11250
11250
  super();
11251
- this._instanceId = Math.random().toString(36).slice(2, 8);
11251
+ this._instanceId = idGenerator(7);
11252
11252
  const validation = validateResourceConfig(config);
11253
11253
  if (!validation.isValid) {
11254
- throw new ResourceError(`Invalid Resource ${config.name} configuration`, { resourceName: config.name, validation: validation.errors, operation: "constructor", suggestion: "Check resource config and attributes." });
11254
+ const errorDetails = validation.errors.map((err) => ` \u2022 ${err}`).join("\n");
11255
+ throw new ResourceError(
11256
+ `Invalid Resource ${config.name || "[unnamed]"} configuration:
11257
+ ${errorDetails}`,
11258
+ {
11259
+ resourceName: config.name,
11260
+ validation: validation.errors
11261
+ }
11262
+ );
11255
11263
  }
11256
11264
  const {
11257
11265
  name,
@@ -13422,9 +13430,10 @@ var resource_class_default = Resource;
13422
13430
  class Database extends EventEmitter {
13423
13431
  constructor(options) {
13424
13432
  super();
13433
+ this.id = idGenerator(7);
13425
13434
  this.version = "1";
13426
13435
  this.s3dbVersion = (() => {
13427
- const [ok, err, version] = try_fn_default(() => true ? "8.0.2" : "latest");
13436
+ const [ok, err, version] = try_fn_default(() => true ? "8.1.0" : "latest");
13428
13437
  return ok ? version : "latest";
13429
13438
  })();
13430
13439
  this.resources = {};
@@ -13437,6 +13446,7 @@ class Database extends EventEmitter {
13437
13446
  this.cache = options.cache;
13438
13447
  this.passphrase = options.passphrase || "secret";
13439
13448
  this.versioningEnabled = options.versioningEnabled || false;
13449
+ this.persistHooks = options.persistHooks || false;
13440
13450
  this._initHooks();
13441
13451
  let connectionString = options.connectionString;
13442
13452
  if (!connectionString && (options.bucket || options.accessKeyId || options.secretAccessKey)) {
@@ -13483,13 +13493,42 @@ class Database extends EventEmitter {
13483
13493
  async connect() {
13484
13494
  await this.startPlugins();
13485
13495
  let metadata = null;
13496
+ let needsHealing = false;
13497
+ let healingLog = [];
13486
13498
  if (await this.client.exists(`s3db.json`)) {
13487
- const request = await this.client.getObject(`s3db.json`);
13488
- metadata = JSON.parse(await streamToString(request?.Body));
13499
+ try {
13500
+ const request = await this.client.getObject(`s3db.json`);
13501
+ const rawContent = await streamToString(request?.Body);
13502
+ try {
13503
+ metadata = JSON.parse(rawContent);
13504
+ } catch (parseError) {
13505
+ healingLog.push("JSON parsing failed - attempting recovery");
13506
+ needsHealing = true;
13507
+ metadata = await this._attemptJsonRecovery(rawContent, healingLog);
13508
+ if (!metadata) {
13509
+ await this._createCorruptedBackup(rawContent);
13510
+ healingLog.push("Created backup of corrupted file - starting with blank metadata");
13511
+ metadata = this.blankMetadataStructure();
13512
+ }
13513
+ }
13514
+ const healedMetadata = await this._validateAndHealMetadata(metadata, healingLog);
13515
+ if (healedMetadata !== metadata) {
13516
+ metadata = healedMetadata;
13517
+ needsHealing = true;
13518
+ }
13519
+ } catch (error) {
13520
+ healingLog.push(`Critical error reading s3db.json: ${error.message}`);
13521
+ await this._createCorruptedBackup();
13522
+ metadata = this.blankMetadataStructure();
13523
+ needsHealing = true;
13524
+ }
13489
13525
  } else {
13490
13526
  metadata = this.blankMetadataStructure();
13491
13527
  await this.uploadMetadataFile();
13492
13528
  }
13529
+ if (needsHealing) {
13530
+ await this._uploadHealedMetadata(metadata, healingLog);
13531
+ }
13493
13532
  this.savedMetadata = metadata;
13494
13533
  const definitionChanges = this.detectDefinitionChanges(metadata);
13495
13534
  for (const [name, resourceMetadata] of Object.entries(metadata.resources || {})) {
@@ -13525,7 +13564,7 @@ class Database extends EventEmitter {
13525
13564
  paranoid: versionData.paranoid !== void 0 ? versionData.paranoid : true,
13526
13565
  allNestedObjectsOptional: versionData.allNestedObjectsOptional !== void 0 ? versionData.allNestedObjectsOptional : true,
13527
13566
  autoDecrypt: versionData.autoDecrypt !== void 0 ? versionData.autoDecrypt : true,
13528
- hooks: versionData.hooks || {},
13567
+ hooks: this.persistHooks ? this._deserializeHooks(versionData.hooks || {}) : versionData.hooks || {},
13529
13568
  versioningEnabled: this.versioningEnabled,
13530
13569
  map: versionData.map,
13531
13570
  idGenerator: restoredIdGenerator,
@@ -13620,6 +13659,73 @@ class Database extends EventEmitter {
13620
13659
  const maxVersion = versionNumbers.length > 0 ? Math.max(...versionNumbers) : -1;
13621
13660
  return `v${maxVersion + 1}`;
13622
13661
  }
13662
+ /**
13663
+ * Serialize hooks to strings for JSON persistence
13664
+ * @param {Object} hooks - Hooks object with event names as keys and function arrays as values
13665
+ * @returns {Object} Serialized hooks object
13666
+ * @private
13667
+ */
13668
+ _serializeHooks(hooks) {
13669
+ if (!hooks || typeof hooks !== "object") return hooks;
13670
+ const serialized = {};
13671
+ for (const [event, hookArray] of Object.entries(hooks)) {
13672
+ if (Array.isArray(hookArray)) {
13673
+ serialized[event] = hookArray.map((hook) => {
13674
+ if (typeof hook === "function") {
13675
+ try {
13676
+ return {
13677
+ __s3db_serialized_function: true,
13678
+ code: hook.toString(),
13679
+ name: hook.name || "anonymous"
13680
+ };
13681
+ } catch (err) {
13682
+ if (this.verbose) {
13683
+ console.warn(`Failed to serialize hook for event '${event}':`, err.message);
13684
+ }
13685
+ return null;
13686
+ }
13687
+ }
13688
+ return hook;
13689
+ });
13690
+ } else {
13691
+ serialized[event] = hookArray;
13692
+ }
13693
+ }
13694
+ return serialized;
13695
+ }
13696
+ /**
13697
+ * Deserialize hooks from strings back to functions
13698
+ * @param {Object} serializedHooks - Serialized hooks object
13699
+ * @returns {Object} Deserialized hooks object
13700
+ * @private
13701
+ */
13702
+ _deserializeHooks(serializedHooks) {
13703
+ if (!serializedHooks || typeof serializedHooks !== "object") return serializedHooks;
13704
+ const deserialized = {};
13705
+ for (const [event, hookArray] of Object.entries(serializedHooks)) {
13706
+ if (Array.isArray(hookArray)) {
13707
+ deserialized[event] = hookArray.map((hook) => {
13708
+ if (hook && typeof hook === "object" && hook.__s3db_serialized_function) {
13709
+ try {
13710
+ const fn = new Function("return " + hook.code)();
13711
+ if (typeof fn === "function") {
13712
+ return fn;
13713
+ }
13714
+ } catch (err) {
13715
+ if (this.verbose) {
13716
+ console.warn(`Failed to deserialize hook '${hook.name}' for event '${event}':`, err.message);
13717
+ }
13718
+ }
13719
+ return null;
13720
+ }
13721
+ return hook;
13722
+ }).filter((hook) => hook !== null);
13723
+ } else {
13724
+ deserialized[event] = hookArray;
13725
+ }
13726
+ }
13727
+ return deserialized;
13728
+ }
13623
13729
  async startPlugins() {
13624
13730
  const db = this;
13625
13731
  if (!isEmpty(this.pluginList)) {
@@ -13689,7 +13795,7 @@ class Database extends EventEmitter {
13689
13795
  allNestedObjectsOptional: resource.config.allNestedObjectsOptional,
13690
13796
  autoDecrypt: resource.config.autoDecrypt,
13691
13797
  cache: resource.config.cache,
13692
- hooks: resource.config.hooks,
13798
+ hooks: this.persistHooks ? this._serializeHooks(resource.config.hooks) : resource.config.hooks,
13693
13799
  idSize: resource.idSize,
13694
13800
  idGenerator: resource.idGeneratorType,
13695
13801
  createdAt: isNewVersion ? (/* @__PURE__ */ new Date()).toISOString() : existingVersionData?.createdAt
@@ -13713,9 +13819,269 @@ class Database extends EventEmitter {
13713
13819
  return {
13714
13820
  version: `1`,
13715
13821
  s3dbVersion: this.s3dbVersion,
13822
+ lastUpdated: (/* @__PURE__ */ new Date()).toISOString(),
13716
13823
  resources: {}
13717
13824
  };
13718
13825
  }
13826
+ /**
13827
+ * Attempt to recover JSON from corrupted content
13828
+ */
13829
+ async _attemptJsonRecovery(content, healingLog) {
13830
+ if (!content || typeof content !== "string") {
13831
+ healingLog.push("Content is empty or not a string");
13832
+ return null;
13833
+ }
13834
+ const fixes = [
13835
+ // Remove trailing commas
13836
+ () => content.replace(/,(\s*[}\]])/g, "$1"),
13837
+ // Add missing quotes to keys
13838
+ () => content.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'),
13839
+ // Fix incomplete objects by adding closing braces
13840
+ () => {
13841
+ let openBraces = 0;
13842
+ let openBrackets = 0;
13843
+ let inString = false;
13844
+ let escaped = false;
13845
+ for (let i = 0; i < content.length; i++) {
13846
+ const char = content[i];
13847
+ if (escaped) {
13848
+ escaped = false;
13849
+ continue;
13850
+ }
13851
+ if (char === "\\") {
13852
+ escaped = true;
13853
+ continue;
13854
+ }
13855
+ if (char === '"') {
13856
+ inString = !inString;
13857
+ continue;
13858
+ }
13859
+ if (!inString) {
13860
+ if (char === "{") openBraces++;
13861
+ else if (char === "}") openBraces--;
13862
+ else if (char === "[") openBrackets++;
13863
+ else if (char === "]") openBrackets--;
13864
+ }
13865
+ }
13866
+ let fixed = content;
13867
+ while (openBrackets > 0) {
13868
+ fixed += "]";
13869
+ openBrackets--;
13870
+ }
13871
+ while (openBraces > 0) {
13872
+ fixed += "}";
13873
+ openBraces--;
13874
+ }
13875
+ return fixed;
13876
+ }
13877
+ ];
13878
+ for (const [index, fix] of fixes.entries()) {
13879
+ try {
13880
+ const fixedContent = fix();
13881
+ const parsed = JSON.parse(fixedContent);
13882
+ healingLog.push(`JSON recovery successful using fix #${index + 1}`);
13883
+ return parsed;
13884
+ } catch (error) {
13885
+ }
13886
+ }
13887
+ healingLog.push("All JSON recovery attempts failed");
13888
+ return null;
13889
+ }
13890
+ /**
13891
+ * Validate and heal metadata structure
13892
+ */
13893
+ async _validateAndHealMetadata(metadata, healingLog) {
13894
+ if (!metadata || typeof metadata !== "object") {
13895
+ healingLog.push("Metadata is not an object - using blank structure");
13896
+ return this.blankMetadataStructure();
13897
+ }
13898
+ let healed = { ...metadata };
13899
+ let changed = false;
13900
+ if (!healed.version || typeof healed.version !== "string") {
13901
+ if (healed.version && typeof healed.version === "number") {
13902
+ healed.version = String(healed.version);
13903
+ healingLog.push("Converted version from number to string");
13904
+ changed = true;
13905
+ } else {
13906
+ healed.version = "1";
13907
+ healingLog.push("Added missing or invalid version field");
13908
+ changed = true;
13909
+ }
13910
+ }
13911
+ if (!healed.s3dbVersion || typeof healed.s3dbVersion !== "string") {
13912
+ if (healed.s3dbVersion && typeof healed.s3dbVersion !== "string") {
13913
+ healed.s3dbVersion = String(healed.s3dbVersion);
13914
+ healingLog.push("Converted s3dbVersion to string");
13915
+ changed = true;
13916
+ } else {
13917
+ healed.s3dbVersion = this.s3dbVersion;
13918
+ healingLog.push("Added missing s3dbVersion field");
13919
+ changed = true;
13920
+ }
13921
+ }
13922
+ if (!healed.resources || typeof healed.resources !== "object" || Array.isArray(healed.resources)) {
13923
+ healed.resources = {};
13924
+ healingLog.push("Fixed invalid resources field");
13925
+ changed = true;
13926
+ }
13927
+ if (!healed.lastUpdated) {
13928
+ healed.lastUpdated = (/* @__PURE__ */ new Date()).toISOString();
13929
+ healingLog.push("Added missing lastUpdated field");
13930
+ changed = true;
13931
+ }
13932
+ const validResources = {};
13933
+ for (const [name, resource] of Object.entries(healed.resources)) {
13934
+ const healedResource = this._healResourceStructure(name, resource, healingLog);
13935
+ if (healedResource) {
13936
+ validResources[name] = healedResource;
13937
+ if (healedResource !== resource) {
13938
+ changed = true;
13939
+ }
13940
+ } else {
13941
+ healingLog.push(`Removed invalid resource: ${name}`);
13942
+ changed = true;
13943
+ }
13944
+ }
13945
+ healed.resources = validResources;
13946
+ return changed ? healed : metadata;
13947
+ }
13948
+ /**
13949
+ * Heal individual resource structure
13950
+ */
13951
+ _healResourceStructure(name, resource, healingLog) {
13952
+ if (!resource || typeof resource !== "object") {
13953
+ healingLog.push(`Resource ${name}: invalid structure`);
13954
+ return null;
13955
+ }
13956
+ let healed = { ...resource };
13957
+ let changed = false;
13958
+ if (!healed.currentVersion) {
13959
+ healed.currentVersion = "v0";
13960
+ healingLog.push(`Resource ${name}: added missing currentVersion`);
13961
+ changed = true;
13962
+ }
13963
+ if (!healed.versions || typeof healed.versions !== "object" || Array.isArray(healed.versions)) {
13964
+ healed.versions = {};
13965
+ healingLog.push(`Resource ${name}: fixed invalid versions object`);
13966
+ changed = true;
13967
+ }
13968
+ if (!healed.partitions || typeof healed.partitions !== "object" || Array.isArray(healed.partitions)) {
13969
+ healed.partitions = {};
13970
+ healingLog.push(`Resource ${name}: fixed invalid partitions object`);
13971
+ changed = true;
13972
+ }
13973
+ const currentVersion = healed.currentVersion;
13974
+ if (!healed.versions[currentVersion]) {
13975
+ const availableVersions = Object.keys(healed.versions);
13976
+ if (availableVersions.length > 0) {
13977
+ healed.currentVersion = availableVersions[0];
13978
+ healingLog.push(`Resource ${name}: changed currentVersion from ${currentVersion} to ${healed.currentVersion}`);
13979
+ changed = true;
13980
+ } else {
13981
+ healingLog.push(`Resource ${name}: no valid versions found - removing resource`);
13982
+ return null;
13983
+ }
13984
+ }
13985
+ const versionData = healed.versions[healed.currentVersion];
13986
+ if (!versionData || typeof versionData !== "object") {
13987
+ healingLog.push(`Resource ${name}: invalid version data - removing resource`);
13988
+ return null;
13989
+ }
13990
+ if (!versionData.attributes || typeof versionData.attributes !== "object") {
13991
+ healingLog.push(`Resource ${name}: missing or invalid attributes - removing resource`);
13992
+ return null;
13993
+ }
13994
+ if (versionData.hooks) {
13995
+ const healedHooks = this._healHooksStructure(versionData.hooks, name, healingLog);
13996
+ if (healedHooks !== versionData.hooks) {
13997
+ healed.versions[healed.currentVersion].hooks = healedHooks;
13998
+ changed = true;
13999
+ }
14000
+ }
14001
+ return changed ? healed : resource;
14002
+ }
14003
+ /**
14004
+ * Heal hooks structure
14005
+ */
14006
+ _healHooksStructure(hooks, resourceName, healingLog) {
14007
+ if (!hooks || typeof hooks !== "object") {
14008
+ healingLog.push(`Resource ${resourceName}: invalid hooks structure - using empty hooks`);
14009
+ return {};
14010
+ }
14011
+ const healed = {};
14012
+ let changed = false;
14013
+ for (const [event, hookArray] of Object.entries(hooks)) {
14014
+ if (Array.isArray(hookArray)) {
14015
+ const validHooks = hookArray.filter(
14016
+ (hook) => hook !== null && hook !== void 0 && hook !== ""
14017
+ );
14018
+ healed[event] = validHooks;
14019
+ if (validHooks.length !== hookArray.length) {
14020
+ healingLog.push(`Resource ${resourceName}: cleaned invalid hooks for event ${event}`);
14021
+ changed = true;
14022
+ }
14023
+ } else {
14024
+ healingLog.push(`Resource ${resourceName}: hooks for event ${event} is not an array - removing`);
14025
+ changed = true;
14026
+ }
14027
+ }
14028
+ return changed ? healed : hooks;
14029
+ }
14030
+ /**
14031
+ * Create backup of corrupted file
14032
+ */
14033
+ async _createCorruptedBackup(content = null) {
14034
+ try {
14035
+ const timestamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
14036
+ const backupKey = `s3db.json.corrupted.${timestamp}.backup`;
14037
+ if (!content) {
14038
+ try {
14039
+ const request = await this.client.getObject(`s3db.json`);
14040
+ content = await streamToString(request?.Body);
14041
+ } catch (error) {
14042
+ content = "Unable to read corrupted file content";
14043
+ }
14044
+ }
14045
+ await this.client.putObject({
14046
+ key: backupKey,
14047
+ body: content,
14048
+ contentType: "application/json"
14049
+ });
14050
+ if (this.verbose) {
14051
+ console.warn(`S3DB: Created backup of corrupted s3db.json as ${backupKey}`);
14052
+ }
14053
+ } catch (error) {
14054
+ if (this.verbose) {
14055
+ console.warn(`S3DB: Failed to create backup: ${error.message}`);
14056
+ }
14057
+ }
14058
+ }
14059
+ /**
14060
+ * Upload healed metadata with logging
14061
+ */
14062
+ async _uploadHealedMetadata(metadata, healingLog) {
14063
+ try {
14064
+ if (this.verbose && healingLog.length > 0) {
14065
+ console.warn("S3DB Self-Healing Operations:");
14066
+ healingLog.forEach((log) => console.warn(` - ${log}`));
14067
+ }
14068
+ metadata.lastUpdated = (/* @__PURE__ */ new Date()).toISOString();
14069
+ await this.client.putObject({
14070
+ key: "s3db.json",
14071
+ body: JSON.stringify(metadata, null, 2),
14072
+ contentType: "application/json"
14073
+ });
14074
+ this.emit("metadataHealed", { healingLog, metadata });
14075
+ if (this.verbose) {
14076
+ console.warn("S3DB: Successfully uploaded healed metadata");
14077
+ }
14078
+ } catch (error) {
14079
+ if (this.verbose) {
14080
+ console.error(`S3DB: Failed to upload healed metadata: ${error.message}`);
14081
+ }
14082
+ throw error;
14083
+ }
14084
+ }
13719
14085
  /**
13720
14086
  * Check if a resource exists by name
13721
14087
  * @param {string} name - Resource name