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:
| Field | Type | Description |
|---|---|---|
id | string | Unique identifier. Auto-generated on save if empty. |
createdAt | string | undefined | ISO 8601 timestamp. Set on first save. |
updatedAt | string | undefined | ISO 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
| Method | Returns | Description |
|---|---|---|
getPath() | string | Full S3 key: {basePath}/{fileName} |
toJSON() | object | Plain object with all non-private, non-function properties. Private fields (prefixed with _) are excluded. |
toObject() | object | Same as toJSON(). |
clone() | this | Shallow clone preserving the prototype chain. |
beforeSave() | void | Called 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
| Type | TypeScript Equivalent | Description |
|---|---|---|
"string" | string | Text value |
"number" | number | Numeric value |
"boolean" | boolean | True or false |
"datetime" | string | ISO 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
| Option | Type | Description |
|---|---|---|
required | boolean | Field must be present on save. Validated automatically. |
enum | string[] | Restrict values to a set of allowed strings. |
default | unknown | Default value when not provided. |
ref | string | References another model by name. Enables population. |
auto | boolean | Auto-populated field (e.g., timestamps). |
embedded | boolean | Marks embedded data, not a foreign reference. |
encrypted | boolean | Field is encrypted at rest. |
acl | string[] | 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:
| Mode | Read | Write | Delete | Description |
|---|---|---|---|---|
"readwrite" | Yes | Yes | Yes | Full CRUD. Default. |
"readonly" | Yes | No | No | Read-only. Write/delete operations throw. |
"append" | Yes | Create only | No | Write-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:
| Type | Description |
|---|---|
"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
| Feature | Class-Based Entity | Schema-Driven Model |
|---|---|---|
| TypeScript types | Native class types | Generated or dynamic |
| Validation | Manual | Automatic from field definitions |
| Access modes | Not enforced | readonly / readwrite / append |
| Oplog | Not available | Built-in |
| Soft delete | Not available | Built-in |
| References | Manual | Declarative with population |
| Setup effort | Write a class | Write a JSON definition |
| Best for | Quick prototyping, custom logic | Production apps, schema-first design |
Both patterns can coexist in the same project. Use getRepository() for class-based entities and model() for schema-driven ones.