Models Overview
Models in Oidis represent your application's data. They map TypeScript classes to a database collection — MongoDB on the backend, IndexedDB on the frontend — using decorators, provide built-in CRUD operations, and handle serialization between backend, frontend, and database.
Oidis uses soft delete by default — Delete() sets deleted: true instead of removing the document. However, Find() does not automatically filter out soft-deleted records. You must always include { deleted: { $ne: true } } in your query filter. Calling Remove() is a permanent hard delete — there is no undo. See Delete vs Remove below for details.
Pages in This Section
| Page | Purpose |
|---|---|
| Converters | Value transformation between database storage and model instances — reference, inclusion, and array converters |
| Validators | Built-in and custom validation rules for model fields — email, phone, regex, range, enum, and password |
| Backend vs Frontend | How models differ between backend (Node.js) and frontend (browser) — structure, capabilities, and patterns |
| Enums | Class-based enum pattern with BaseEnum — type-safe constants with runtime introspection |
| IndexedDB | Frontend persistence driver — open lifecycle, version handling, object store registration, and gotchas |
Defining a Model
Every model needs two things: the @Entity decorator to map it to a collection, and @Property decorators to define its fields.
import { BaseModel } from "@io-oidis-commons/Io/Oidis/Commons/Primitives/BaseModel.js";
import { Entity, Property } from "@io-oidis-commons/Io/Oidis/Commons/Primitives/Decorators.js";
@Entity("products")
export class Product extends BaseModel<Product> {
@Property({type: String}) // stored as "name", required
public declare Name : string;
@Property({type: Number}) // "price", required
public declare Price : number;
@Property({type: String, required: false}) // "description", optional
public declare Description : string;
@Property({type: Boolean, required: false, default: true}) // "isActive"
public declare IsActive : boolean;
@Property({type: Date, required: false}) // "expiresAt"
public declare ExpiresAt : Date;
}
declare keywordProperties decorated with @Property are initialized by the decorator system, not by TypeScript's standard field initialization. Use declare to tell TypeScript the property exists without generating initialization code that would conflict with the decorator.
@Entity Decorator
@Entity("collectionName") maps a model class to a MongoDB collection:
@Entity("products") // → MongoDB collection "products"
export class Product extends BaseModel<Product> { ... }
@Entity("products") // → MongoDB collection "products"
export class ProductRecord extends BaseModel<ProductRecord> { ... }
The collection name is a plain string — it should be lowercase, typically plural. The class name can differ from the collection name.
@Property Decorator
@Property(options) declares a model field with its type, validation, and behavior.
Property Options
| Option | Type | Description |
|---|---|---|
type | class ref | Field type — String, Number, Boolean, Date, model class, or enum. For arrays: [Type] |
symbol | string | Defines the MongoDB field name for this property. When omitted, it defaults to camelCase of the TypeScript property name (first letter lowercased). Never use labels or spaces here. |
required | boolean | Defaults to true. Enforced by the Mongoose schema when saving — mark optional fields required: false |
default | any | Default value for new instances |
validator | IValidator | Custom format validator instance (the default PropertyValidator accepts everything) |
converter | IConverter instance | Converter for DB ↔ model transformation, e.g. new GroupToRefConverter() |
alias | string | Mongoose alias of the stored field (rare) |
addId | boolean | Let Mongoose generate _id in object-typed sub-fields |
noJSON | boolean | Exclude from ToJSON() / ToInterface() output |
noSerialize | boolean | Serialize only the referenced id instead of the full referenced data |
noResolve | boolean | Find() returns only the id of the reference, without resolving it |
protected | boolean | SyncWith() skips the property; only an explicit setter writes it — see Protected Properties |
noAutoListValidation | boolean | Skip automatic validation of array items |
There is no unique or index option. Create indexes explicitly via Model().collection.createIndex(...) with the
stored field names.
MongoDB Field Naming
The symbol option controls how the property is stored in MongoDB. When writing raw MongoDB filters or sort objects (e.g. in Model.Find(filter, options)), always use the DB field name, not the TypeScript property name.
| TypeScript Property | symbol | MongoDB field name |
|---|---|---|
public declare Name : string | (none) | name (camelCase default) |
public declare EmptyWeight : number | (none) | emptyWeight (camelCase default) |
public declare SPZ : string | "spz" | spz (explicit symbol) |
public declare ICO : string | "ico" | ico (explicit symbol) |
// Correct — use the DB field name (camelCase or explicit symbol)
const filter : any = {name: {$regex: ".*test.*", $options: "i"}};
const records = await Company.Find(filter, {sort: {name: 1}});
// Wrong — PascalCase property names do not match MongoDB field names
const filter : any = {Name: {$regex: ".*test.*", $options: "i"}}; // 0 results!
When symbol is set, use its value as the DB field name. When symbol is omitted, use the camelCase version of the property name (first letter lowercased). Using PascalCase TypeScript property names in MongoDB filters will silently return no results because MongoDB field names are case-sensitive.
Type Examples
// Primitive types
@Property({type: String})
public declare Name : string;
@Property({type: Number, required: false, default: 0})
public declare Count : number;
@Property({type: Boolean, required: false})
public declare IsActive : boolean;
@Property({type: Date, required: false})
public declare ExpiresAt : Date;
// Array of primitives
@Property({type: [String], default: [], converter: new ArrayConverter()})
public declare Tags : string[];
// Reference to another model (needs a converter instance)
@Property({type: ProductRecord, required: false, converter: new ProductToRefConverter()})
public declare Product : ProductRecord;
// Array of references
@Property({type: [OrderRecord], default: [], converter: new OrderListToRefConverter()})
public declare Orders : OrderRecord[];
// Embedded subdocument
@Property({type: Address, required: false, converter: new InclusionConverter(Address)})
public declare Address : Address;
// Enum (BaseEnum string constants)
@Property({type: String, required: false, default: RecordStatus.ACTIVE})
public declare Status : string;
BaseModel<T> — Built-in Features
Every model extends BaseModel<T>, which provides automatic fields and a complete set of data operations.
Auto-Managed Fields
These fields exist on every model without explicit declaration:
| Field | DB Key | Description |
|---|---|---|
Id | _id | String id, auto-generated (UUID v1 on the backend, Reflection.UID() in the browser) — not an ObjectId |
Deleted | deleted | Soft delete flag — set by Delete(). Not filtered by Find(); add deleted: {$ne: true} to queries. |
LastModified | lastModified | Last modification timestamp |
Created | created | Creation timestamp |
ClassName | className | Fully-qualified class name for deserialization |
An Id assigned before the first Save() is kept, so seeds and imports can write deterministic ids (group.Id = "seed-group-1"). Created and LastModified are not: on the backend the Mongoose pre-save hook stamps created on insert and lastModified on every modifying save, overwriting assigned values. Write through the native collection (Model().collection) when a record has to keep a historical timestamp.
Static Methods (Querying)
// Find documents matching a filter — stored field names, soft-deleted records excluded explicitly
const activeProducts : Product[] = await Product.Find({deleted: {$ne: true}, isActive: true});
// Paging and sorting (there is no projection option)
const page : Product[] = await Product.Find({deleted: {$ne: true}}, {limit: 50, offset: 100, sort: {name: 1}});
// Count — no Count() helper, use the Mongoose model
const total : number = await Product.Model().countDocuments({deleted: {$ne: true}});
// Find by id (string)
const product : Product = await Product.FindById("0b3a4f10-6a1b-11f1-9c1e-3d2c6a7b8c9d");
// Aggregation pipeline — see "Aggregate and hydration" below for the result shape
const stats = await Product.Aggregate([
{ $match: { isActive: true } },
{ $group: { _id: "$category", total: { $sum: "$price" } } }
]);
// Reconstruct from serialized data (LCP transport, JSON)
const product : Product = await Product.From(serializedObject);
Find() does not automatically filter out soft-deleted documents. You must explicitly include { deleted: { $ne: true } } in your query filter to exclude them. The Delete() method sets deleted: true on the document — but Find() passes your query directly to MongoDB without modification.
Find Options (IModelFindOptions)
Find() and FindById() take an optional second argument (Io.Oidis.Commons.Primitives.IModelFindOptions,
extended by the hub with allowDiskUse):
| Option | Effect |
|---|---|
partial: true | Loads only the model's own fields; reference properties stay plain ids and are not resolved. Cuts an N+1 query per reference per record — the difference between a user list that scales with logins ever made and one that does not. Caveat: @Property defaults are not applied on a partial read, so a field absent from the stored document reads empty instead of its default. |
expanded: true | The opposite: a flat mirror with every reference resolved, ignoring property options. Exclusive with the rest. |
offset, limit | Passed straight to the driver as skip/limit, so a collection can be walked a page at a time instead of loaded whole. offset applies only when > 0, limit only when > 0. |
sort | Passed as-is to the driver ({_id: 1} style). With allowDiskUse: true a sort that outgrows the in-memory limit spills to disk. |
dataTranslation | Applies the model's data translation on read. |
There is no projection option — a model is read with all of its fields. When only a few fields of a
large collection are needed, or when the collection is large at all, use Aggregate() with a $project
stage or walk it with offset/limit and sort: {_id: 1}; never Find({}) over a collection that grows
with the business.
// A full pass over a large collection, one page at a time, never the whole collection in memory.
let offset : number = 0;
let page : LeadRadarFinding[] = [];
do {
page = await LeadRadarFinding.Find({mergedInto: {$in: [null, ""]}},
{offset, limit: 500, sort: {_id: 1}});
await handle(page);
offset += page.length;
} while (page.length === 500);
Aggregate and hydration
Aggregate() accepts either a raw pipeline array or an IModelAggregateOptions object
({filterQuery, resultQuery, sortQuery?}), which the driver expands into
$match → $sort → $facet and returns as {results, total}. Facts that decide whether a pipeline
works at all:
- Every result row is hydrated into a model instance via
fromModel— a$group/$projectstage whose output is not document-shaped loses every field that does not match a model property symbol. To read computed values back,$projectthem onto existing model symbols:
// Quadrant counts per set, delivered through the model's own Score property.
const counts = await LeadRadarScore.Aggregate([
{$group: {_id: {setId: "$setId", quadrant: "$quadrant"}, total: {$sum: 1}}},
{$project: {_id: 0, setId: "$_id.setId", quadrant: "$_id.quadrant", score: "$total"}}
], {partial: true}); // counts[i].SetId / .Quadrant / .Score (= the count)
- Pass
{partial: true}for projected subsets, so absent fields stay empty instead of pulling reference resolution and defaults. - Document symbols are the property names with a lowercased first letter;
Idmaps to_id(Find({_id: {$in: ids}}),$projectkeeps_idimplicitly). offset/limitin the options are ignored byAggregate— put$skip/$limitin the pipeline itself.- A slim
$projectread is the sanctioned substitute for the missing projection option: hydrating a large collection whole costs hundreds of MB, the same rows projected to a handful of fields cost a few — the difference between a request path that survives a container limit and one that does not.
Instance Methods
const product = new Product();
product.Name = "Energy Monitor";
product.Price = 299;
// Persist to database (validates first; no transaction/session support)
await product.Save();
// Write one property of an already loaded model ($set of its current value)
product.Price = 249;
await product.Update("Price");
// Reload from database
await product.Refresh();
// Soft delete (sets Deleted = true, keeps document in MongoDB)
await product.Delete();
// Hard delete — permanently removes the document from MongoDB
await product.Remove();
// Unset a specific field (removes the field, keeps the document)
await product.Remove("Description");
// Copy properties from another object
product.SyncWith(updatedData);
// Deep clone
const copy = product.Clone();
// Run property validators (synchronous; `required` is enforced by the schema on Save)
const isValid : boolean = product.Validate();
// Same check, returns the symbols of invalid properties (including nested models)
const findings : string[] = product.ValidateDeep();
// Serialize to plain object (asynchronous — resolves references)
const json : any = await product.ToJSON();
// Convert to interface representation
const iface = product.ToInterface();
These two methods are fundamentally different:
| Method | MongoDB operation | Document after call |
|---|---|---|
Delete() | Save() with deleted: true | Remains in collection; filter it out with deleted: {$ne: true} |
Remove() | deleteOne({_id}) | Permanently gone |
Remove("field") | updateOne({$unset}) | Remains, field is removed |
Delete() is the standard application-level delete. It sets deleted: true and calls Save() (so validation, including required, runs). Find() does not filter soft-deleted documents — every query that should hide them must include deleted: {$ne: true}. The record stays in the collection, which enables audit trails, recovery, and referential integrity.
Remove() is a hard delete — use it only when you explicitly need permanent removal (e.g., test teardown, cleanup scripts). For all application-level deletes, always prefer Delete().
Updating Models with Reference Fields
When creating or updating a model that has reference properties (@Property with a converter), use the SyncWith() pattern to ensure references are correctly serialized to MongoDB.
Correct pattern:
let model : AllocationRecord = await AllocationRecord.FindById($data.Id);
if (ObjectValidator.IsEmptyOrNull(model)) {
delete $data.Id;
model = new AllocationRecord();
}
model.SyncWith($data);
model.Product = await ProductRecord.FindById($data.Product.Id);
await model.Save();
Why not direct assignment?
// ⚠️ Problematic — bypasses converter serialization
const model = new AllocationRecord();
model.Product = productRecord; // reference from Find(), not FindById()
await model.Save();
Direct assignment of a reference without SyncWith() can lead to incorrect serialization in MongoDB — nested references (e.g., Product.Group) may not be properly resolved by the converter, causing empty or incomplete data when the document is loaded later.
When creating or updating a model on the backend, always follow this sequence: FindById → SyncWith → re-resolve references via FindById → Save. This ensures all @Property setters and converters run correctly.
SyncWith($data) copies all properties present in $data except protected ones. When $data
comes from a client, it can overwrite fields the user must not control (owner, organization, flags). Mark such fields
protected: true, or assign the allowed fields explicitly.
Protected Properties
A property declared with protected: true is skipped by SyncWith(). A client that sends {Authorized: true} to an
@Extern save method using the FindById → SyncWith → Save pattern cannot change the stored value — the injected
field is ignored and the model keeps its current (or default) value.
@Property({type: Boolean, required: false, default: false, protected: true})
public declare Authorized : boolean;
// Role managed by BE code only and never sent to the frontend
@Property({type: String, required: false, protected: true, noJSON: true})
public declare Role : string;
- The only way to write the value is an explicit setter call in backend code (
user.Authorized = true), followed bySave()orUpdate("Authorized"). Keep that code behind its own authorization check. - Protection applies recursively: nested models and model lists synchronized by
SyncWith()skip their protected fields too. protectedlimits writes only. The value is still serialized byToJSON()/ToInterface(); addnoJSON: trueto hide it from the frontend as well.- Loading from the database (
Find,FindById,Refresh),From()andClone()are not affected — they keep the value. SyncWith()behaves the same on the frontend: a protected field is not copied there either, so read such fields from instances created byFrom()or the connector, not from aSyncWith()copy.- A manual assignment from client input (
model.Role = $data.Role) bypasses the protection — never copy protected fields from$databy hand.
Clearing String Properties — Empty String Is Silently Dropped
BaseModel.toModel() (the property→document sync that runs inside Save()) writes a property into the document only when its value is non-empty — and clears the stored value only when the property is exactly null. An empty string "" matches neither branch, so the write is silently dropped and the document keeps its previous value:
model.Note = "some text";
await model.Save(); // ✅ persists "some text"
model.Note = "";
await model.Save(); // ⚠️ SILENTLY IGNORED — document still holds "some text"
model.Note = null;
await model.Save(); // ✅ clears the stored value
This bites hardest in strip/clean helpers that rebuild a string via split/filter/join — the result is "" once the last line is removed, and the "clear" never reaches the database while partial removals (non-empty result) work fine, which makes the bug look intermittent.
Any backend code path that may CLEAR a string property must normalize the empty result to null before assignment, e.g. model.Note = ObjectValidator.IsEmptyOrNull(text) ? null : text;.
null Does Not Round-Trip — Use a Sentinel Default
null clears a stored value, but it cannot carry a meaning ("inherit", "not configured") through a load and back:
- A
@Property({type: Number})withoutdefaultisundefinedon a new instance — the constructor assigns only declared defaults. fromModel()(every non-partialFind()/FindById()) assigns a property only when the stored value is non-empty. A storednullis skipped, so the property keeps its constructor value:undefined, or thedefaultwhen one is declared.ToInterface()— and thereforeJSON.stringify()and every LCP response — omitsundefinedproperties (unless called withpreserveUndefined: true).SyncWith()skips keys missing from the source object, so the omitted key clears nothing on the receiving side either.
A convention like "null means inherit" therefore holds only until the record is saved and loaded again: the value comes back
undefined (or as the default), the key is missing in the client's object, and a === null check never matches. Give such
properties an explicit sentinel default that survives every step:
@Property({type: Number, required: false, default: -1}) // -1 = inherit the global setting
public declare MaxRetries : number;
const maxRetries : number = task.MaxRetries >= 0 ? task.MaxRetries : settings.MaxRetries;
Compare with the sentinel explicitly: task.MaxRetries || settings.MaxRetries replaces a valid 0, and
ObjectValidator.IsEmptyOrNull() returns false for both 0 and -1.
The From() Factory Method
From() is a static factory method on BaseModel that reconstructs a model instance from a serialized plain object. It's used in two main scenarios:
- LCP deserialization — When data arrives from the frontend via LiveContentProtocol
- Converter context — When a reference converter receives an already-serialized object instead of a raw ObjectId
// Deserialize a plain object back into a typed model instance (asynchronous)
const product : Product = await Product.From({
Name: "Energy Monitor",
Price: 299,
IsActive: true
});
// product is now a full Product instance with all BaseModel methods
Backend vs Frontend Models
Models exist on both sides of the application, but with different capabilities.
Backend Models (Models/)
- Full
@Entitywith MongoDB collection mapping - Direct database access (
Find,Save,Delete,Update) - Server-only fields possible (
noJSON: truekeeps a field out ofToJSON()/ToInterface()output) - Used by backend services (managers, resolvers)
Frontend Models (Gui/Models/)
@Entityon models that map to DB collections (mirrors backend structure)- Converters use the
dataTranslationpattern (see below) - No direct database access — all data flows through connectors
- May extend hub-gui base models
- FE-only models (e.g., extending a hub-gui
Userwith project fields) use no@Entity
Example: Backend vs Frontend Model
Backend (Models/ProductRecord.ts):
@Entity("products")
export class ProductRecord extends BaseModel<ProductRecord> {
@Property({type: String})
public declare Code : string;
@Property({type: ProductGroup, required: false, converter: new ProductGroupToRefConverter()})
public declare Group : ProductGroup;
@Property({type: Number, required: false})
public declare TaxRate : number;
}
Frontend (Gui/Models/ProductRecord.ts):
@Entity("products")
export class ProductRecord extends HubGuiBaseModel<ProductRecord> {
@Property({type: String})
public declare Code : string;
@Property({type: ProductGroup, required: false, converter: new ProductGroupToRefConverter()})
public declare Group : ProductGroup;
@Property({type: Number, required: false})
public declare TaxRate : number;
}
The structure mirrors the backend, but the frontend model extends from HubGuiBaseModel and its converters handle deserialization differently.
Converter System
Converters transform values between database storage and model instances. They implement the from() and to() methods.
Built-in Converters
| Converter | Purpose | DB Stores |
|---|---|---|
IdentityConverter | No transformation (default) | Raw value |
BaseToRefConverter | Reference to another model | Referenced record's id (string) |
BaseListToRefConverter | Array of references | string[] of ids |
InclusionConverter / BaseInclusionConverter | Embedded subdocument | Full object |
ArrayConverter | Primitive arrays | value[] |
Writing a Reference Converter
When a model references another model, you need a converter that handles two contexts:
export class ProductGroupToRefConverter extends BaseToRefConverter {
public async from($value : any, $context : IContext) : Promise<ProductGroup> {
if ($context.dataTranslation) {
// Data comes from LCP (already-serialized object)
return ProductGroup.From($value);
}
// Data comes from DB (stored id string)
return ProductGroup.FindById($value);
}
}
The dataTranslation context is key:
true— The value is an already-serialized object (from LCP transport or JSON). UseModel.From()to reconstruct it.false— The value is a raw MongoDB ObjectId. UseModel.FindById()to load it from the database.
This dual behavior lets the same converter work for both database reads and frontend-to-backend data transfer.
Validator System
Validators enforce constraints on model fields. They implement the IValidator interface.
Built-in Validators
| Validator | Purpose |
|---|---|
PropertyValidator | Default validator — accepts every value (required is enforced by the schema, not by a validator) |
EmailValidator | Email format validation |
PhoneValidator | Phone number format validation |
RegexValidator | Match against a custom regex pattern |
RangeValidator | Numeric range check (min, max) |
EnumValidator | Value must be a member of an enum |
PasswordValidator | Password strength rules |
Usage
@Property({type: String, validator: new EmailValidator()})
public declare Email : string;
@Property({type: Number, required: false, validator: new RangeValidator<number>(0, 100)})
public declare Percentage : number;
@Property({type: String, validator: new RegexValidator(/^[A-Z]{3}-\d{4}$/)})
public declare Code : string;
Schema Generation
Under the hood, Oidis generates Mongoose schemas from your @Entity and @Property metadata:
BaseDbContext(extended byMongoContextin hub for the backend, andIndexedDbContextfor the frontend) reads decorator metadata at startup@Propertytypemaps to Mongoose schema typesrequiredbecomes a Mongoose schema-level validation;default,aliasandaddIdare copied to the schema fieldconverter,validatorand theno*modifiers are applied byBaseModel, not by the schemaMongoContext.getModel()returns a typed Mongoose Model for CRUD operations
You rarely interact with Mongoose directly — BaseModel<T> abstracts it all away.
Creating a New Model — Checklist
- Define the class with
@Entity("collectionName")andextends BaseModel<ClassName> - Add properties with
@Property({type: ..., required: false, ...})—symbolonly when the stored field name must differ - Use
declarefor all@Propertyfields - Create converters for model references (extend
BaseToRefConverter, handledataTranslation) - Use
InclusionConverterfor embedded subdocuments - Mirror on frontend — Create the same structure in
Gui/Models/, extending hub-gui base - Add to ForceCompile if the model is referenced via LCP and nothing imports it — import it manually outside the generated section, which lists only files containing
@Extern(,@Resolver(or@Route( - Verify with
oidis.cmd project-cleanup test:lint
See also: Converters | Validators | Backend vs Frontend | Enums | IndexedDB