Docs/Core SDK/Entity & Repository

    Entity & Repository

    S3WORM provides two patterns for working with data: class-based entities for hand-written TypeScript models, and schema-driven models for declarative JSON-defined entities. Both use the Repository pattern for CRUD operations.

    Entity Base Class

    All class-based entities extend the Entity abstract class:

    import { Entity } from "@decoperations/s3worm";
    
    class BlogPost extends Entity {
      title: string = "";
      content: string = "";
      author: string = "";
      published: boolean = false;
      tags: string[] = [];
    
      static getBasePath(): string {
        return "blog/posts";
      }
    }
    

    Built-in Fields

    Every entity inherits these fields from the base class:

    FieldTypeDescription
    idstringUnique identifier. Auto-generated on save if empty.
    createdAtstring | undefinedISO 8601 timestamp. Set on first save.
    updatedAtstring | undefinedISO 8601 timestamp. Updated on every save.

    Required: static getBasePath()

    Every entity class must implement this static method. It returns the S3 prefix where documents of this type are stored:

    static getBasePath(): string {
      return "blog/posts";
    }
    

    Documents are stored at {basePath}/{id}.json. For example, a BlogPost with id abc123 is stored at blog/posts/abc123.json.

    Optional: getFileName()

    Override to customize the filename. Defaults to {id}.json:

    getFileName(): string {
      return `${this.slug}.json`; // Use slug instead of id
    }
    

    Methods

    MethodReturnsDescription
    getPath()stringFull S3 key: {basePath}/{fileName}
    toJSON()objectPlain object with all non-private, non-function properties. Private fields (prefixed with _) are excluded.
    toObject()objectSame as toJSON().
    clone()thisShallow clone preserving the prototype chain.
    beforeSave()voidCalled automatically before save. Sets createdAt (first save) and updatedAt.

    Defining Custom Entities

    Add typed fields as class properties with default values:

    class Customer extends Entity {
      name: string = "";
      email: string = "";
      company: string = "";
      status: "active" | "inactive" | "churned" = "active";
      metadata: Record<string, unknown> = {};
    
      static getBasePath(): string {
        return "org/customers";
      }
    
      // Custom computed property
      get displayName(): string {
        return this.company ? `${this.name} (${this.company})` : this.name;
      }
    
      // Custom validation
      validate(): string[] {
        const errors: string[] = [];
        if (!this.name) errors.push("Name is required");
        if (!this.email) errors.push("Email is required");
        return errors;
      }
    }
    

    Inheritance

    Entities can extend other entities to share fields and behavior:

    class BaseDocument extends Entity {
      owner: string = "";
      visibility: "public" | "private" = "private";
    
      static getBasePath(): string {
        throw new Error("Subclasses must override getBasePath");
      }
    }
    
    class Invoice extends BaseDocument {
      amount: number = 0;
      currency: string = "USD";
      status: "draft" | "sent" | "paid" = "draft";
      lineItems: Array<{ description: string; amount: number }> = [];
    
      static getBasePath(): string {
        return "billing/invoices";
      }
    }
    
    class Receipt extends BaseDocument {
      invoiceId: string = "";
      paidAt: string = "";
    
      static getBasePath(): string {
        return "billing/receipts";
      }
    }
    

    Repository (Class-Based)

    The Repository<T> class provides typed CRUD operations for class-based entities.

    Getting a Repository

    import { S3Worm } from "@decoperations/s3worm";
    
    const worm = new S3Worm({ bucket: "my-bucket", /* ... */ });
    const customers = worm.getRepository(Customer);
    

    save(entity)

    Save an entity to S3. Auto-generates an id if not set. Calls beforeSave() to set timestamps. Uses multipart upload for documents larger than 5 MB.

    const customer = new Customer();
    customer.name = "Acme Corp";
    customer.email = "hello@acme.com";
    
    const saved = await customers.save(customer);
    console.log(saved.id);        // "V1StGXR8_Z5jdHi6B-myT" (auto-generated nanoid)
    console.log(saved.createdAt); // "2026-02-24T15:30:00.000Z"
    

    Returns: Promise<T> -- the saved entity with id and timestamps populated.

    findById(id)

    Find a single entity by its ID. Returns null if not found.

    const customer = await customers.findById("V1StGXR8_Z5jdHi6B-myT");
    if (customer) {
      console.log(customer.name); // "Acme Corp"
    }
    

    Returns: Promise<T | null>

    findAll(options?)

    Find all entities with optional filtering, sorting, and pagination.

    const results = await customers.findAll({
      filter: (c) => c.status === "active",
      sort: (a, b) => a.name.localeCompare(b.name),
      limit: 20,
      offset: 0,
    });
    

    Returns: Promise<T[]>

    FindAllOptions

    interface FindAllOptions<T> {
      /** Filter function -- return true to include the entity */
      filter?: (item: T) => boolean;
      /** Sort comparator function */
      sort?: (a: T, b: T) => number;
      /** Maximum number of results */
      limit?: number;
      /** Number of results to skip */
      offset?: number;
    }
    

    delete(entityOrId)

    Delete an entity by instance or ID string.

    // By ID
    await customers.delete("V1StGXR8_Z5jdHi6B-myT");
    
    // By entity instance
    await customers.delete(customer);
    

    Returns: Promise<boolean> -- true if deletion succeeded.

    create(data?)

    Create a new entity instance in memory (does not save to S3). Useful for building up an entity before saving:

    const customer = customers.create({
      name: "New Corp",
      email: "hi@new.com",
    });
    // customer is a Customer instance, but not yet saved
    await customers.save(customer);
    

    Returns: T

    Schema-Driven Models

    For projects using the schema DSL, entities are defined in JSON and accessed via worm.model(). No TypeScript class is needed.

    Defining Models in Schema

    {
      "schemaVersion": "1.0",
      "sourceOfTruth": "local",
      "storage": { "layout": "root" },
      "models": {
        "Customer": {
          "path": "#org/@customers/(id:uuid)",
          "idType": "uuid",
          "fields": {
            "name": { "type": "string", "required": true },
            "email": { "type": "string", "required": true },
            "company": { "type": "string" },
            "status": {
              "type": "string",
              "enum": ["active", "inactive", "churned"],
              "default": "active"
            },
            "tags": { "type": "string[]" },
            "createdAt": { "type": "datetime", "auto": true },
            "updatedAt": { "type": "datetime", "auto": true }
          },
          "file": "[profile].json",
          "mode": "readwrite",
          "oplog": true,
          "softDelete": true
        }
      }
    }
    

    Field Types

    TypeTypeScript EquivalentDescription
    "string"stringText value
    "number"numberNumeric value
    "boolean"booleanTrue or false
    "datetime"stringISO 8601 timestamp
    "object"Record<string, unknown>Nested object
    "string[]"string[]Array of strings
    "number[]"number[]Array of numbers
    "object[]"object[]Array of objects

    Field Options

    OptionTypeDescription
    requiredbooleanField must be present on save. Validated automatically.
    enumstring[]Restrict values to a set of allowed strings.
    defaultunknownDefault value when not provided.
    refstringReferences another model by name. Enables population.
    autobooleanAuto-populated field (e.g., timestamps).
    embeddedbooleanMarks embedded data, not a foreign reference.
    encryptedbooleanField is encrypted at rest.
    aclstring[]Principals allowed to decrypt this field.

    SchemaRepository Methods

    Access a schema repository via worm.model("ModelName"):

    worm.loadSchema(schema);
    const customers = worm.model("Customer");
    

    save(data)

    Create or update an entity. Validates fields, enforces access mode, and writes oplog entries if enabled.

    const customer = await customers.save({
      name: "Acme Corp",
      email: "hello@acme.com",
    });
    // customer.id, customer.createdAt, customer.updatedAt are auto-set
    

    findById(id, options?)

    Find by ID with optional reference population:

    const customer = await customers.findById("abc-123");
    
    // With populated references
    const invoice = await invoices.findById("inv-456", {
      populate: ["customerId"],
    });
    

    findAll(options?)

    Query with field-level filters, sorting, and pagination:

    const results = await customers.findAll({
      filter: { status: "active" },
      sort: { field: "createdAt", order: "desc" },
      limit: 50,
      offset: 0,
      populate: ["customerId"],
    });
    

    Schema findAll supports declarative filter objects instead of callback functions:

    // Exact match
    { filter: { status: "active" } }
    
    // Operator filters
    { filter: { status: { $in: ["active", "inactive"] } } }
    

    delete(id)

    Delete an entity. If softDelete is enabled on the model, moves to trash instead of permanent deletion.

    await customers.delete("abc-123");
    

    exists(id)

    Check if an entity exists:

    const found = await customers.exists("abc-123");
    

    count()

    Count all entities in the collection:

    const total = await customers.count();
    

    Access Modes

    Each model has a mode that controls which operations are allowed:

    ModeReadWriteDeleteDescription
    "readwrite"YesYesYesFull CRUD. Default.
    "readonly"YesNoNoRead-only. Write/delete operations throw.
    "append"YesCreate onlyNoWrite-once. Cannot update or delete existing entities.

    Soft Delete & Trash

    When softDelete: true on a model, deleted entities are moved to a trash directory instead of being permanently removed:

    // Soft delete
    await customers.delete("abc-123");
    
    // List trashed entities
    const trashed = await customers.listTrashed();
    
    // Restore from trash
    await customers.restore("abc-123");
    
    // Permanent delete from trash
    await customers.purge("abc-123");
    
    // Purge expired trash entries
    await customers.purgeExpired();
    

    Oplog (Operation Log)

    When oplog: true, every mutation is recorded as an immutable append-only log entry:

    // Get change history for an entity
    const history = await customers.getHistory("abc-123");
    // [{ seq: 1, op: "create", ts: "...", snapshot: {...} },
    //  { seq: 2, op: "update", ts: "...", diff: { name: ["Acme", "Acme Inc"] } }]
    
    // Rollback to a previous state
    await customers.rollback("abc-123", { toSeq: 2 });
    

    Entity References

    Models can reference each other using the ref field option:

    {
      "Invoice": {
        "fields": {
          "customerId": { "type": "string", "required": true, "ref": "Customer" }
        },
        "refs": {
          "customer": {
            "field": "customerId",
            "model": "Customer",
            "type": "belongsTo",
            "onDelete": "restrict"
          }
        }
      }
    }
    

    Relationship types:

    TypeDescription
    "belongsTo"This model has a foreign key pointing to another model.
    "hasMany"Another model has a foreign key pointing to this model.
    "hasOne"One-to-one relationship via foreign key on the other model.

    Population options:

    // Shallow: populate specific refs
    const invoice = await invoices.findById("inv-1", {
      populate: ["customer"],
    });
    
    // Selective: choose which fields to include
    const invoice = await invoices.findById("inv-1", {
      populate: {
        customer: { select: ["name", "email"] },
      },
    });
    
    // Nested: populate refs on populated entities
    const invoice = await invoices.findById("inv-1", {
      populate: {
        customer: { populate: ["organization"] },
      },
    });
    

    Views

    Views are saved queries defined in the schema:

    {
      "views": {
        "ActiveCustomers": {
          "model": "Customer",
          "filter": { "status": "active" },
          "sort": { "field": "createdAt", "order": "desc" },
          "description": "All active customers, newest first"
        }
      }
    }
    
    const active = await worm.view("ActiveCustomers").findAll();
    const page = await worm.view("ActiveCustomers").findAll({ limit: 10 });
    

    Singletons

    Models with "singleton": true store a single document without an ID segment in the path:

    {
      "OrgSettings": {
        "path": "#org/@settings",
        "singleton": true,
        "fields": {
          "orgName": { "type": "string", "required": true },
          "plan": { "type": "string", "enum": ["free", "pro", "enterprise"] }
        },
        "file": "[config].json"
      }
    }
    

    The document is stored at org/settings/config.json (no entity ID in the path).

    Choosing Between Patterns

    FeatureClass-Based EntitySchema-Driven Model
    TypeScript typesNative class typesGenerated or dynamic
    ValidationManualAutomatic from field definitions
    Access modesNot enforcedreadonly / readwrite / append
    OplogNot availableBuilt-in
    Soft deleteNot availableBuilt-in
    ReferencesManualDeclarative with population
    Setup effortWrite a classWrite a JSON definition
    Best forQuick prototyping, custom logicProduction apps, schema-first design

    Both patterns can coexist in the same project. Use getRepository() for class-based entities and model() for schema-driven ones.