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.
| Pattern | When to Use | Example |
|---|---|---|
| Linked (ref) | Own lifecycle, shared across models, large | Customer, Organization |
| Embedded (inline) | Owned by parent, never queried independently, small | Line 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:
- Field-level
ref-- on forward-reference fields (the field storing the ID) - 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
| Strategy | How It Works | When Used |
|---|---|---|
| Scan-based | List all entities in target collection, fetch each, filter by foreign key | Default (no index: true) |
| Index-based | Read reverse index file, batch-fetch matching entity IDs | When 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.
| Action | Behavior | Example |
|---|---|---|
"cascade" | Delete all referencing entities | Customer deleted -> delete all Invoices |
"nullify" | Set the foreign key to null | Customer deleted -> set customerId: null on Payments |
"restrict" | Prevent deletion if references exist | Cannot delete Customer if Invoices exist |
"none" | Do nothing (default) | Orphaned references allowed |
Cascade Execution Order
- Check all
restrictrefs first. If any related entities exist, abort the entire delete. - Execute
cascadedeletes (recursive -- cascades can cascade). - Execute
nullifyupdates on related entities. - 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'ssoftDeletesettingpurge()with cascade: cascaded purges permanently delete from trashrestore()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