fields.js

/**
 * @namespace BaoFields
 * @description
 * This module contains the built-in fields for Bao.
 */
const { ulid, decodeTime } = require("ulid");
const { defaultLogger: logger } = require("./utils/logger");

/**
 * @class BaoBaseField
 * @memberof BaoFields
 * @description
 * Base class for all fields. Do not instantiate this class directly.
 */
class BaoBaseField {
  constructor(options = {}) {
    this.options = options;
    this.required = options.required || false;
    this.defaultValue = options.defaultValue;
  }

  /**
   * @memberof BaoFields.BaoBaseField
   * @description
   * Get the initial JS value for the field.
   * @returns {any} The initial value for the field.
   */
  getInitialValue() {
    if (this.defaultValue) {
      return typeof this.defaultValue === "function"
        ? this.defaultValue()
        : this.defaultValue;
    }
    return undefined;
  }

  /**
   * @memberof BaoFields.BaoBaseField
   * @description
   * Validate the JS field value.
   * @param {any} value - The value to validate.
   * @returns {boolean} True if the value is valid, otherwise false.
   */
  validate(value) {
    if (this.required && (value === null || value === undefined)) {
      throw new Error("Field is required");
    }
    return true;
  }

  /**
   * @memberof BaoFields.BaoBaseField
   * @description
   * Convert the field value from the JS representation to DynamoDB representation.
   * @param {any} value - The value to convert.
   * @returns {any} The converted value.
   */
  toDy(value) {
    return value;
  }

  /**
   * @memberof BaoFields.BaoBaseField
   * @description
   * Convert the field value from the DynamoDB representation to the JS representation.
   * @param {any} value - The value to convert.
   * @returns {any} The converted value.
   */
  fromDy(value) {
    return value;
  }

  /**
   * @memberof BaoFields.BaoBaseField
   * @description
   * Convert the field value from the JS representation the index format used by DynamoDB.
   * This must be a string representation of the value. Pay special attention to
   * how this value sorts since it will be used for sort keys.
   * @param {any} value - The value to convert.
   * @returns {any} The converted value.
   */
  toGsi(value) {
    return String(value);
  }

  /**
   * @memberof BaoFields.BaoBaseField
   * @description
   * Convert the field value from the index format used by DynamoDB to the JS representation.
   * @param {any} value - The value to convert.
   * @returns {any} The converted value.
   */
  fromGsi(value) {
    return this.fromDy(value);
  }

  /**
   * @memberof BaoFields.BaoBaseField
   * @description
   * Get the DynamoDB update expression for the field. You usually don't need to override this.
   * By default, it will return a SET expression, unless the value is null. If the value is null,
   * it will remove the attribute from the item .
   *
   * @param {string} fieldName - The name of the field.
   * @param {any} value - The value to update.
   * @returns {Object} The update expression.
   */
  getUpdateExpression(fieldName, value) {
    if (value === undefined) return null;

    const attributeName = `#${fieldName}`;
    const attributeValue = `:${fieldName}`;

    if (value === null) {
      return {
        type: "REMOVE",
        expression: `${attributeName}`,
        attrNameKey: attributeName,
        attrValueKey: attributeValue,
        fieldName: fieldName,
        fieldValue: null,
      };
    }

    return {
      type: "SET",
      expression: `${attributeName} = ${attributeValue}`,
      attrNameKey: attributeName,
      attrValueKey: attributeValue,
      fieldName: fieldName,
      fieldValue: value,
    };
  }

  /**
   * @memberof BaoFields.BaoBaseField
   * @description
   * Update the field value before saving. An example of where you might override this
   * is a modified date field that you want to update to the current date/time before
   * saving.
   * @param {any} value - The value to update.
   * @param {BaoModel} currentObject - The current model instance.
   * @returns {any} The updated value.
   */
  updateBeforeSave(value, currentObject) {
    // Default implementation does nothing
    return value;
  }
}

/**
 * @class StringField
 * @memberof BaoFields
 * @description
 * A field that stores a string value.
 */
class StringField extends BaoBaseField {
  // String fields are pass-through since DynamoDB handles them natively
}

/**
 * @class DateTimeField
 * @memberof BaoFields
 * @description
 * A field that stores a date/time value.
 */
class DateTimeField extends BaoBaseField {
  validate(value) {
    if (this.required && value === undefined) {
      throw new Error("Field is required");
    }
    if (
      value !== undefined &&
      !(value instanceof Date) &&
      typeof value !== "number" &&
      typeof value !== "string"
    ) {
      throw new Error(
        "DateTimeField value must be a Date object, timestamp number, or ISO string",
      );
    }
  }

  toDy(value) {
    if (!value) return null;

    try {
      // Convert any input to timestamp
      if (value instanceof Date) {
        return value.getTime();
      }
      if (typeof value === "string") {
        return new Date(value).getTime();
      }
      if (typeof value === "number") {
        return value;
      }
      return null;
    } catch (error) {
      console.warn("Error converting date value:", error);
      return null;
    }
  }

  fromDy(value) {
    if (!value) return null;
    try {
      return new Date(Number(value));
    } catch (error) {
      console.warn("Error parsing date value:", error);
      return null;
    }
  }

  toGsi(value) {
    if (!value) return "0";
    try {
      // Convert to timestamp string with padding for correct sorting
      const timestamp = this.toDy(value);
      if (!timestamp) return "0";
      const result = timestamp.toString().padStart(20, "0");
      // logger.log('Converting to GSI:', { value, timestamp, result });
      return result;
    } catch (error) {
      console.warn("Error converting date for GSI:", error);
      return "0";
    }
  }
}

/**
 * @class IntegerField
 * @memberof BaoFields
 * @description
 * A field that stores an integer value.
 */
class IntegerField extends BaoBaseField {
  getInitialValue() {
    return this.options.defaultValue !== undefined
      ? this.options.defaultValue
      : null;
  }

  toDy(value) {
    if (value === undefined || value === null) {
      return this.getInitialValue();
    }
    return Number(value);
  }

  fromDy(value) {
    if (value === undefined || value === null) {
      return this.getInitialValue();
    }
    return parseInt(value, 10);
  }

  toGsi(value) {
    // Pad with zeros for proper string sorting
    return value != null ? value.toString().padStart(20, "0") : "";
  }
}

/**
 * @class FloatField
 * @memberof BaoFields
 * @description
 * A field that stores a floating point number value.
 */
class FloatField extends BaoBaseField {
  getInitialValue() {
    return this.options.defaultValue !== undefined
      ? this.options.defaultValue
      : null;
  }

  toDy(value) {
    if (value === undefined || value === null) {
      return this.getInitialValue();
    }
    const num = Number(value);
    if (this.options.precision !== undefined && !isNaN(num)) {
      return Number(num.toFixed(this.options.precision));
    }
    return num;
  }

  fromDy(value) {
    if (value === undefined || value === null) {
      return this.getInitialValue();
    }
    return parseFloat(value);
  }

  toGsi(value) {
    // Scientific notation with padding for consistent sorting
    return value != null ? value.toExponential(20) : "";
  }
}

/**
 * @class CreateDateField
 * @memberof BaoFields
 * @description
 * A field that stores a date/time value based on when the object was created.
 */
class CreateDateField extends DateTimeField {
  constructor(options = {}) {
    super({
      ...options,
      required: true,
    });
  }

  getInitialValue() {
    return new Date();
  }

  toDy(value) {
    if (!value) {
      return Date.now();
    }
    return value instanceof Date ? value.getTime() : value;
  }
}

/**
 * @class ModifiedDateField
 * @memberof BaoFields
 * @description
 * A field that stores a date/time value based on when the object was last modified.
 */
class ModifiedDateField extends DateTimeField {
  constructor(options = {}) {
    super({
      ...options,
      required: true,
    });
  }

  getInitialValue() {
    return new Date();
  }

  toDy(value) {
    return Date.now();
  }

  updateBeforeSave(value, currentObject) {
    // Always update modified date before save
    return Date.now();
  }
}

/**
 * @class UlidField
 * @memberof BaoFields
 * @description
 * A field that stores a {@link https://github.com/ulid/spec ULID} value.
 */
class UlidField extends BaoBaseField {
  constructor(options = {}) {
    super({
      ...options,
      required: true,
    });
    this.autoAssign = options.autoAssign || false;
  }

  getInitialValue() {
    if (this.autoAssign) {
      return ulid();
    }
    return super.getInitialValue();
  }

  validate(value) {
    if (!value && !this.autoAssign) {
      throw new Error("ULID is required");
    }

    if (value) {
      // Check if it's a valid ULID format
      // ULIDs are 26 characters, uppercase alphanumeric
      if (!/^[0-9A-Z]{26}$/.test(value)) {
        throw new Error("Invalid ULID format");
      }

      try {
        // Attempt to decode the timestamp to verify it's valid
        decodeTime(value);
      } catch (error) {
        throw new Error("Invalid ULID: could not decode timestamp");
      }
    }
    return true;
  }

  // toDy now only handles conversion, not generation
  toDy(value) {
    return value;
  }

  toGsi(value) {
    return value || "";
  }
}

/**
 * @class RelatedField
 * @memberof BaoFields
 * @description
 * A field that points to another object in the database. This field makes
 * it easy to load related objects.
 */
class RelatedField extends BaoBaseField {
  constructor(modelName, options = {}) {
    super(options);
    this.modelName = modelName;
  }

  validate(value) {
    if (this.required && !value) {
      throw new Error("Field is required");
    }
    // Allow both string IDs and model instances
    if (
      value &&
      typeof value !== "string" &&
      (!value.getPrimaryId || typeof value.getPrimaryId !== "function")
    ) {
      throw new Error(
        "Related field value must be a string ID or model instance",
      );
    }
    return true;
  }

  toDy(value) {
    if (!value) return null;
    // If we're given a model instance, get its ID
    if (typeof value === "object" && value.getPrimaryId) {
      return value.getPrimaryId();
    }
    return value;
  }

  fromDy(value) {
    return value;
  }

  toGsi(value) {
    return this.toDy(value) || "";
  }
}

/**
 * @class CounterField
 * @memberof BaoFields
 * @description
 * A field that stores an integer value that can be incremented or decremented atomically.
 */
class CounterField extends BaoBaseField {
  constructor(options = {}) {
    super(options);
    this.defaultValue = options.defaultValue || 0;
  }

  validate(value) {
    super.validate(value);
    if (value !== undefined) {
      // Accept increment/decrement operations (e.g., '+1', '-5')
      if (typeof value === "string" && /^[+-]\d+$/.test(value)) {
        return true;
      }
      // Accept regular integer values
      if (!Number.isInteger(value)) {
        throw new Error("CounterField value must be an integer");
      }
    }
    return true;
  }

  getInitialValue() {
    return this.defaultValue;
  }

  toDy(value) {
    if (
      typeof value === "string" &&
      (value.startsWith("+") || value.startsWith("-"))
    ) {
      return value;
    }
    return super.toDy(value);
  }

  fromDy(value) {
    if (value === undefined || value === null) {
      return this.getInitialValue();
    }
    return parseInt(value, 10);
  }

  getUpdateExpression(fieldName, value) {
    if (value === undefined) return null;

    const attributeName = `#${fieldName}`;
    const attributeValue = `:${fieldName}`;

    let expObj = {
      attrNameKey: attributeName,
      attrValueKey: attributeValue,
      type: "SET",
      expression: `${attributeName} = ${attributeValue}`,
      fieldName: fieldName,
      fieldValue: value,
    };

    logger.log("CounterField - getUpdateExpression", fieldName, value);

    if (value === null) {
      expObj = {
        ...expObj,
        type: "REMOVE",
        expression: `${attributeName}`,
        fieldValue: null,
      };
    }

    // If the value is relative (has + or - prefix), use ADD
    if (
      typeof value === "string" &&
      (value.startsWith("+") || value.startsWith("-"))
    ) {
      const numericValue = parseInt(value, 10);

      // Return just the expression part without the ADD keyword
      expObj = {
        ...expObj,
        type: "ADD",
        expression: `${attributeName} ${attributeValue}`,
        fieldValue: numericValue,
      };
    }

    return expObj;
  }

  toGsi(value) {
    return value != null ? value.toString().padStart(20, "0") : "";
  }
}

/**
 * @class BinaryField
 * @memberof BaoFields
 * @description
 * A field that stores a binary value.
 */
class BinaryField extends BaoBaseField {
  getInitialValue() {
    if (this.defaultValue) {
      return typeof this.defaultValue === "function"
        ? this.defaultValue()
        : this.defaultValue;
    }
    return null;
  }

  validate(value) {
    super.validate(value);
    if (
      value !== undefined &&
      value !== null &&
      !(value instanceof Buffer) &&
      !(value instanceof Uint8Array)
    ) {
      throw new Error("BinaryField value must be a Buffer or Uint8Array");
    }
    return true;
  }

  toDy(value) {
    if (!value) return null;

    return value;
  }

  fromDy(value) {
    if (!value) return null;
    return value;
  }

  toGsi(value) {
    throw new Error("BinaryField does not support GSI conversion");
  }
}

/**
 * @class VersionField
 * @memberof BaoFields
 * @description
 * A field that stores a ULID value that is used to track the version of the object.
 * You can use this field to implement optimistic locking by using the version field
 * as a condition in your update operation.
 */
class VersionField extends BaoBaseField {
  constructor(options = {}) {
    super({
      ...options,
      required: true,
    });
    this.defaultValue = options.defaultValue || ulid;
  }

  validate(value) {
    super.validate(value);
    if (value !== undefined) {
      // Verify it's a valid ULID string
      if (typeof value !== "string" || value.length !== 26) {
        throw new Error("VersionField value must be a valid ULID");
      }
    }
    return true;
  }

  getInitialValue() {
    return typeof this.defaultValue === "function"
      ? this.defaultValue()
      : this.defaultValue;
  }

  updateBeforeSave(value) {
    return ulid();
  }

  fromDy(value) {
    return value || this.getInitialValue();
  }

  toDy(value) {
    return value || this.getInitialValue();
  }
}

/**
 * @class BooleanField
 * @memberof BaoFields
 * @description
 * A field that stores a boolean value.
 */
class BooleanField extends BaoBaseField {
  validate(value) {
    super.validate(value);
    if (value !== undefined && value !== null && typeof value !== "boolean") {
      throw new Error("BooleanField value must be a boolean");
    }
    return true;
  }

  getInitialValue() {
    return this.defaultValue !== undefined ? this.defaultValue : null;
  }

  toDy(value) {
    if (value === undefined || value === null) {
      return this.getInitialValue();
    }
    return Boolean(value);
  }

  fromDy(value) {
    if (value === undefined || value === null) {
      return this.getInitialValue();
    }
    return Boolean(value);
  }

  toGsi(value) {
    if (value === undefined || value === null) {
      return "";
    }
    // Convert to '0' or '1' for consistent string sorting
    return value ? "1" : "0";
  }
}

/**
 * @class TtlField
 * @memberof BaoFields
 * @description
 * A field that stores a Unix timestamp in seconds that indicates when the object should be deleted.
 * DynamoDB will automatically delete the item at the specified time. This
 * field must be named "ttl" for DynamoDB to automatically delete the item.
 */
class TtlField extends DateTimeField {
  validate(value) {
    if (value === null || value === undefined) {
      return true; // Allow null/undefined for field removal
    }
    return super.validate(value); // Use parent validation for dates
  }

  toDy(value) {
    logger.log("TTL toDy", value);
    if (value === undefined) return undefined; // Skip field
    if (value === null) return null; // Remove field

    // Convert any valid date input to Unix timestamp in seconds
    let date;
    if (value instanceof Date) {
      date = value;
    } else {
      date = new Date(value);
      if (isNaN(date.getTime())) {
        throw new Error("Invalid date value provided for TTL field");
      }
    }

    const timestamp = Math.floor(date.getTime() / 1000);
    return timestamp;
  }

  fromDy(value) {
    logger.log("TTL fromDy", value);
    if (value === undefined || value === null) {
      return null; // Convert both undefined and null to null
    }
    return new Date(value * 1000);
  }
}

// Factory functions for creating field instances
const createStringField = (options) => new StringField(options);
const createDateTimeField = (options) => new DateTimeField(options);
const createCreateDateField = (options) => new CreateDateField(options);
const createModifiedDateField = (options) => new ModifiedDateField(options);
const createUlidField = (options) => new UlidField(options);
const createRelatedField = (modelName, options) =>
  new RelatedField(modelName, options);
const createIntegerField = (options) => new IntegerField(options);
const createFloatField = (options) => new FloatField(options);
const createCounterField = (options) => new CounterField(options);
const createBinaryField = (options) => new BinaryField(options);
const createVersionField = (options) => new VersionField(options);
const createBooleanField = (options) => new BooleanField(options);
const createTtlField = (options) => new TtlField(options);

// Export both the factory functions and the classes
module.exports = {
  // Factory functions
  StringField: createStringField,
  DateTimeField: createDateTimeField,
  CreateDateField: createCreateDateField,
  ModifiedDateField: createModifiedDateField,
  UlidField: createUlidField,
  RelatedField: createRelatedField,
  IntegerField: createIntegerField,
  FloatField: createFloatField,
  CounterField: createCounterField,
  BinaryField: createBinaryField,
  VersionField: createVersionField,
  BooleanField: createBooleanField,
  TtlField: createTtlField,

  // Classes (for instanceof checks)
  StringFieldClass: StringField,
  DateTimeFieldClass: DateTimeField,
  CreateDateFieldClass: CreateDateField,
  ModifiedDateFieldClass: ModifiedDateField,
  UlidFieldClass: UlidField,
  RelatedFieldClass: RelatedField,
  IntegerFieldClass: IntegerField,
  FloatFieldClass: FloatField,
  CounterFieldClass: CounterField,
  BinaryFieldClass: BinaryField,
  VersionFieldClass: VersionField,
  BooleanFieldClass: BooleanField,
  TtlFieldClass: TtlField,
};