Docs/Advanced Features/Entity Links

    Entity Links

    S3WORM supports cross-model relationships over a flat key-value store. Entity links enable references, population, inverse lookups, cascading operations, tree traversal, and referential integrity checking.


    Relationship Types

    S3WORM supports five relationship patterns. All are declared in the schema and resolved at the SDK level.

    One-to-One (belongsTo)

    A field stores the ID of another entity. The simplest relationship type.

    Invoice.customerId --> Customer
    
    {
      "models": {
        "Invoice": {
          "fields": {
            "customerId": { "type": "string", "required": true, "ref": "Customer" }
          },
          "refs": {
            "customer": {
              "field": "customerId",
              "model": "Customer",
              "type": "belongsTo"
            }
          }
        }
      }
    }
    

    One-to-Many (hasMany)

    A model declares that another model references it. No foreign key array is stored on the parent.

    Customer <--(customerId)-- Invoice
    
    {
      "models": {
        "Customer": {
          "refs": {
            "invoices": {
              "model": "Invoice",
              "foreignKey": "customerId",
              "type": "hasMany",
              "onDelete": "cascade"
            }
          }
        }
      }
    }
    

    The relationship is resolved by scanning or indexing the Invoice collection for documents where customerId matches the Customer's ID.

    Many-to-Many (through junction)

    Two models are related through a junction model. A many-to-many is two one-to-many relationships sharing a junction.

    Item <--(itemId)-- ItemTag --(tagId)--> Tag
    
    {
      "models": {
        "Item": {
          "refs": {
            "tags": {
              "model": "ItemTag",
              "foreignKey": "itemId",
              "type": "hasMany",
              "through": {
                "model": "ItemTag",
                "foreignKey": "tagId",
                "target": "Tag"
              }
            }
          }
        },
        "ItemTag": {
          "fields": {
            "itemId": { "type": "string", "required": true, "ref": "Item" },
            "tagId": { "type": "string", "required": true, "ref": "Tag" }
          },
          "refs": {
            "item": { "field": "itemId", "model": "Item", "type": "belongsTo" },
            "tag": { "field": "tagId", "model": "Tag", "type": "belongsTo" }
          }
        }
      }
    }
    

    Self-Referencing (tree)

    A model references itself. Used for hierarchical data such as categories, org charts, or threaded comments.

    Category.parentId --> Category
    
    {
      "models": {
        "Category": {
          "fields": {
            "parentId": { "type": "string", "ref": "Category" }
          },
          "refs": {
            "parent": {
              "field": "parentId",
              "model": "Category",
              "type": "belongsTo"
            },
            "children": {
              "model": "Category",
              "foreignKey": "parentId",
              "type": "hasMany",
              "onDelete": "cascade"
            }
          }
        }
      }
    }
    

    Embedded

    Not all related data needs to be a separate entity. Embedded data is stored inline within the parent document.

    PatternWhen to UseExample
    Linked (ref)Own lifecycle, shared across models, largeCustomer, Organization
    Embedded (inline)Owned by parent, never queried independently, smallLine items, address
    {
      "fields": {
        "lineItems": { "type": "object[]", "embedded": true },
        "shippingAddress": { "type": "object", "embedded": true }
      }
    }
    

    The embedded: true flag is semantic. It tells the SDK and codegen that this field is inline data, not a reference to another entity. No population, no cascading.


    Schema Configuration

    References are declared in two places within a model definition:

    1. Field-level ref -- on forward-reference fields (the field storing the ID)
    2. Model-level refs -- named relationships with full metadata

    Ref Definition

    interface RefDefinition {
      /** Field on THIS model holding the foreign ID (belongsTo / hasOne) */
      field?: string;
      /** The related model name */
      model: string;
      /** Field on the OTHER model holding this model's ID (hasMany) */
      foreignKey?: string;
      /** Relationship type */
      type: "belongsTo" | "hasMany" | "hasOne";
      /** Cascade behavior on delete (default: "none") */
      onDelete?: "cascade" | "nullify" | "restrict" | "none";
      /** Many-to-many junction config */
      through?: {
        model: string;
        foreignKey: string;
        target: string;
      };
      /** Maintain a reverse index for fast lookups */
      index?: boolean;
      /** Auto-populate on every find (default: false) */
      eager?: boolean;
    }
    

    Full Schema Example

    {
      "models": {
        "Customer": {
          "path": "#org/@customers/(id:uuid)",
          "idType": "uuid",
          "fields": {
            "name": { "type": "string", "required": true },
            "email": { "type": "string", "required": true },
            "orgId": { "type": "string", "ref": "Organization" }
          },
          "file": "[profile].json",
          "refs": {
            "org": {
              "field": "orgId",
              "model": "Organization",
              "type": "belongsTo"
            },
            "invoices": {
              "model": "Invoice",
              "foreignKey": "customerId",
              "type": "hasMany",
              "onDelete": "cascade",
              "index": true
            },
            "payments": {
              "model": "Payment",
              "foreignKey": "customerId",
              "type": "hasMany",
              "onDelete": "nullify"
            }
          }
        },
        "Invoice": {
          "path": "#org/@invoices/(id:uuid)",
          "idType": "uuid",
          "fields": {
            "customerId": { "type": "string", "required": true, "ref": "Customer" },
            "amount": { "type": "number", "required": true },
            "status": {
              "type": "string",
              "enum": ["draft", "sent", "paid", "void"],
              "default": "draft"
            }
          },
          "file": "[data].json",
          "refs": {
            "customer": {
              "field": "customerId",
              "model": "Customer",
              "type": "belongsTo",
              "eager": true
            }
          }
        }
      }
    }
    

    Forward Population

    Population replaces an entity ID with the full referenced entity. Nothing is populated unless explicitly requested.

    Simple Population

    const invoice = await invoices.findById("inv-1", {
      populate: ["customer"]
    });
    
    invoice.customer;       // { id: "abc-123", name: "Acme Corp", ... }
    invoice.customerId;     // "abc-123" (original field preserved)
    

    The populate array accepts ref names from the refs block, not field names.

    Deep Population

    Populate refs of refs using nested object syntax:

    const invoice = await invoices.findById("inv-1", {
      populate: {
        customer: {
          populate: { org: true }
        }
      }
    });
    
    invoice.customer.name;        // "Acme Corp"
    invoice.customer.org.name;    // "Acme Holdings"
    

    Selective Field Population

    Only load certain fields from the referenced entity:

    const invoice = await invoices.findById("inv-1", {
      populate: {
        customer: {
          select: ["name", "email"]   // only these fields + id
        }
      }
    });
    
    invoice.customer;
    // { id: "abc-123", name: "Acme Corp", email: "hi@acme.com" }
    

    Since S3 does not support partial object reads, select works by fetching the full document and stripping unselected fields before attaching.

    HasMany Population

    Populating a hasMany ref triggers an inverse lookup and returns an array:

    const customer = await customers.findById("abc-123", {
      populate: ["invoices"]
    });
    
    customer.invoices;
    // [{ id: "inv-1", amount: 500, ... }, { id: "inv-2", amount: 200, ... }]
    

    For large collections, use findRelated with pagination instead.

    Many-to-Many Population

    Populating a through ref resolves the junction and returns the target entities:

    const item = await items.findById("item-1", {
      populate: ["tags"]
    });
    
    item.tags;
    // [{ id: "tag-1", name: "Electronics", color: "#3B82F6" }, ...]
    // Resolved through the ItemTag junction -- not the junction docs themselves
    

    Populate Spec Types

    // Simple: array of ref names
    populate: ["customer", "payments"]
    
    // Deep: nested object with per-ref options
    populate: {
      customer: {
        populate: { org: true },
        select: ["name", "email"]
      },
      payments: true
    }
    

    Circular Reference Handling

    The SDK tracks a visited set of ModelName:entityId strings. If an entity has already been visited during population, the raw ID is returned instead of causing an infinite loop.

    Maximum population depth is 5 levels by default, configurable via worm.config({ maxPopulateDepth: N }).

    Eager Loading

    Models can declare default population in the schema:

    {
      "refs": {
        "customer": {
          "field": "customerId",
          "model": "Customer",
          "type": "belongsTo",
          "eager": true
        }
      }
    }
    

    When eager: true, every findById and findAll call automatically populates that ref. Opt out by passing populate: [] (empty array).


    Inverse Lookups

    Inverse lookups answer: "Given entity X, find all entities of type Y that reference X."

    findRelated

    // Find all invoices for a customer
    const invoices = await customers.findRelated("abc-123", "invoices");
    
    // With pagination and sorting
    const page = await customers.findRelated("abc-123", "invoices", {
      sort: { field: "issuedAt", order: "desc" },
      limit: 20,
      offset: 0
    });
    
    // With nested population
    const page = await customers.findRelated("abc-123", "invoices", {
      populate: ["payments"],
      limit: 20
    });
    

    countRelated

    Count related entities without fetching:

    const count = await customers.countRelated("abc-123", "invoices");
    // 42
    

    Lookup Strategies

    StrategyHow It WorksWhen Used
    Scan-basedList all entities in target collection, fetch each, filter by foreign keyDefault (no index: true)
    Index-basedRead reverse index file, batch-fetch matching entity IDsWhen index: true on the ref

    Scan-based is O(N) in collection size. Index-based is O(1) lookup + O(K) fetches for K matches.

    Reverse Indexes

    Enable index-based lookups on a ref:

    {
      "refs": {
        "invoices": {
          "model": "Invoice",
          "foreignKey": "customerId",
          "type": "hasMany",
          "index": true
        }
      }
    }
    

    The SDK maintains a reverse index file:

    // .worm.indexes/invoices/customerId.json
    {
      "$field": "customerId",
      "$model": "Invoice",
      "$updatedAt": "2026-02-24T12:00:00Z",
      "entries": {
        "abc-123": ["inv-1", "inv-2", "inv-5"],
        "def-456": ["inv-3"],
        "ghi-789": ["inv-4", "inv-6", "inv-7"]
      }
    }
    

    Indexes are updated as a side effect of save() and delete(). Rebuild with worm reindex --model Invoice --field customerId.

    Many-to-Many Lookups

    // Get all tags for an item (resolved through ItemTag junction)
    const itemTags = await items.findRelated("item-1", "tags");
    // Tag[] -- the junction is transparent
    
    // Get all items with a specific tag
    const taggedItems = await tags.findRelated("tag-1", "items");
    // Item[]
    

    Cascading Deletes

    The onDelete property on a ref controls what happens to referencing entities when the referenced entity is deleted.

    ActionBehaviorExample
    "cascade"Delete all referencing entitiesCustomer deleted -> delete all Invoices
    "nullify"Set the foreign key to nullCustomer deleted -> set customerId: null on Payments
    "restrict"Prevent deletion if references existCannot delete Customer if Invoices exist
    "none"Do nothing (default)Orphaned references allowed

    Cascade Execution Order

    1. Check all restrict refs first. If any related entities exist, abort the entire delete.
    2. Execute cascade deletes (recursive -- cascades can cascade).
    3. Execute nullify updates on related entities.
    4. Delete the target entity.
    // cascade: deletes customer and all related invoices
    await customers.delete("abc-123");
    
    // restrict: throws if invoices reference this customer
    await customers.delete("abc-123");
    // Error: "Cannot delete Customer abc-123: 5 Invoice entities reference it"
    
    // Force delete (bypass restrict)
    await customers.delete("abc-123", { force: true });
    

    Cascade and Soft Delete

    • delete() with cascade: cascaded deletes respect each model's softDelete setting
    • purge() with cascade: cascaded purges permanently delete from trash
    • restore() does not cascade. Restoring a parent does not restore children.

    Cascade Depth

    Maximum cascade depth is 10 levels. Exceeding this throws an error indicating a likely cycle in cascade declarations. The SDK tracks a visited set to prevent infinite loops.


    Tree Helpers

    For self-referencing models, the SDK provides tree-walking convenience methods.

    getTree

    Build a nested tree structure from a root node:

    const tree = await categories.getTree("cat-root", {
      childrenRef: "children",
      maxDepth: 3
    });
    
    // {
    //   id: "cat-root", name: "Root",
    //   children: [
    //     { id: "cat-electronics", name: "Electronics",
    //       children: [
    //         { id: "cat-phones", name: "Phones", children: [] },
    //         { id: "cat-laptops", name: "Laptops", children: [] }
    //       ]
    //     }
    //   ]
    // }
    

    getAncestors

    Walk up the tree to the root:

    const ancestors = await categories.getAncestors("cat-phones", "parent");
    // [{ id: "cat-electronics", ... }, { id: "cat-root", ... }]
    

    getDescendants

    Walk down the tree and return a flat list:

    const descendants = await categories.getDescendants("cat-root", "children");
    // [{ id: "cat-electronics", ... }, { id: "cat-phones", ... }, ...]
    

    Default max depth for all tree operations is 10 levels.


    Reference Integrity

    Optional validation that referenced entities actually exist.

    On-Save Validation

    When enabled, save() checks that all ref fields point to existing entities:

    await invoices.save(invoices.create({
      customerId: "nonexistent-id",
      amount: 500
    }));
    // throws: "Referenced Customer 'nonexistent-id' does not exist"
    

    Enable in the schema:

    {
      "models": {
        "Invoice": {
          "integrity": {
            "validateOnSave": true
          }
        }
      }
    }
    

    checkRef

    Manually verify a single reference:

    const valid = await invoices.checkRef("inv-1", "customer");
    // true if the referenced Customer exists
    

    findOrphans

    Scan a collection for broken references:

    const orphans = await invoices.findOrphans("customer");
    // [
    //   { entityId: "inv-42", field: "customerId",
    //     refId: "xyz-999", model: "Customer" }
    // ]
    

    CLI Orphan Detection

    # Find orphaned references
    worm check-refs --model Invoice
    # ORPHAN: Invoice inv-42 references Customer xyz-999 (not found)
    # Found 2 orphaned references in 1,423 invoices.
    
    # Fix by nullifying broken refs
    worm check-refs --model Invoice --fix nullify
    
    # Fix by deleting entities with broken refs
    worm check-refs --model Invoice --fix delete
    

    Entity Refs

    Create reference objects for manual use and serialization:

    // Create a ref object
    const ref = customers.ref("abc-123");
    // { $model: "Customer", $id: "abc-123" }
    
    // Resolve a ref
    const entity = await worm.resolveRef(ref);
    // SchemaEntity | null