Skip to main content

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.

Critical: Soft Delete and Find()

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​

PagePurpose
ConvertersValue transformation between database storage and model instances — reference, inclusion, and array converters
ValidatorsBuilt-in and custom validation rules for model fields — email, phone, regex, range, enum, and password
Backend vs FrontendHow models differ between backend (Node.js) and frontend (browser) — structure, capabilities, and patterns
EnumsClass-based enum pattern with BaseEnum — type-safe constants with runtime introspection
IndexedDBFrontend 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;
}
The declare keyword

Properties 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​

OptionTypeDescription
typeclass refField type — String, Number, Boolean, Date, model class, or enum. For arrays: [Type]
symbolstringDefines 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.
requiredbooleanDefaults to true. Enforced by the Mongoose schema when saving — mark optional fields required: false
defaultanyDefault value for new instances
validatorIValidatorCustom format validator instance (the default PropertyValidator accepts everything)
converterIConverter instanceConverter for DB ↔ model transformation, e.g. new GroupToRefConverter()
aliasstringMongoose alias of the stored field (rare)
addIdbooleanLet Mongoose generate _id in object-typed sub-fields
noJSONbooleanExclude from ToJSON() / ToInterface() output
noSerializebooleanSerialize only the referenced id instead of the full referenced data
noResolvebooleanFind() returns only the id of the reference, without resolving it
protectedbooleanSyncWith() skips the property; only an explicit setter writes it — see Protected Properties
noAutoListValidationbooleanSkip 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 PropertysymbolMongoDB 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!
Use DB field names in raw MongoDB queries

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:

FieldDB KeyDescription
Id_idString id, auto-generated (UUID v1 on the backend, Reflection.UID() in the browser) — not an ObjectId
DeleteddeletedSoft delete flag — set by Delete(). Not filtered by Find(); add deleted: {$ne: true} to queries.
LastModifiedlastModifiedLast modification timestamp
CreatedcreatedCreation timestamp
ClassNameclassNameFully-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);
Soft Delete

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):

OptionEffect
partial: trueLoads 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: trueThe opposite: a flat mirror with every reference resolved, ignoring property options. Exclusive with the rest.
offset, limitPassed 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.
sortPassed as-is to the driver ({_id: 1} style). With allowDiskUse: true a sort that outgrows the in-memory limit spills to disk.
dataTranslationApplies 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/$project stage whose output is not document-shaped loses every field that does not match a model property symbol. To read computed values back, $project them 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; Id maps to _id (Find({_id: {$in: ids}}), $project keeps _id implicitly).
  • offset/limit in the options are ignored by Aggregate — put $skip/$limit in the pipeline itself.
  • A slim $project read 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();
Remove() vs Delete()

These two methods are fundamentally different:

MethodMongoDB operationDocument after call
Delete()Save() with deleted: trueRemains 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.

The Rule

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 copies every field

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 by Save() or Update("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.
  • protected limits writes only. The value is still serialized by ToJSON() / ToInterface(); add noJSON: true to hide it from the frontend as well.
  • Loading from the database (Find, FindById, Refresh), From() and Clone() 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 by From() or the connector, not from a SyncWith() copy.
  • A manual assignment from client input (model.Role = $data.Role) bypasses the protection — never copy protected fields from $data by 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.

The Rule

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:

  1. A @Property({type: Number}) without default is undefined on a new instance — the constructor assigns only declared defaults.
  2. fromModel() (every non-partial Find() / FindById()) assigns a property only when the stored value is non-empty. A stored null is skipped, so the property keeps its constructor value: undefined, or the default when one is declared.
  3. ToInterface() — and therefore JSON.stringify() and every LCP response — omits undefined properties (unless called with preserveUndefined: true).
  4. 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:

  1. LCP deserialization — When data arrives from the frontend via LiveContentProtocol
  2. 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 @Entity with MongoDB collection mapping
  • Direct database access (Find, Save, Delete, Update)
  • Server-only fields possible (noJSON: true keeps a field out of ToJSON() / ToInterface() output)
  • Used by backend services (managers, resolvers)

Frontend Models (Gui/Models/)​

  • @Entity on models that map to DB collections (mirrors backend structure)
  • Converters use the dataTranslation pattern (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 User with 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​

ConverterPurposeDB Stores
IdentityConverterNo transformation (default)Raw value
BaseToRefConverterReference to another modelReferenced record's id (string)
BaseListToRefConverterArray of referencesstring[] of ids
InclusionConverter / BaseInclusionConverterEmbedded subdocumentFull object
ArrayConverterPrimitive arraysvalue[]

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). Use Model.From() to reconstruct it.
  • false — The value is a raw MongoDB ObjectId. Use Model.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​

ValidatorPurpose
PropertyValidatorDefault validator — accepts every value (required is enforced by the schema, not by a validator)
EmailValidatorEmail format validation
PhoneValidatorPhone number format validation
RegexValidatorMatch against a custom regex pattern
RangeValidatorNumeric range check (min, max)
EnumValidatorValue must be a member of an enum
PasswordValidatorPassword 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:

  1. BaseDbContext (extended by MongoContext in hub for the backend, and IndexedDbContext for the frontend) reads decorator metadata at startup
  2. @Property type maps to Mongoose schema types
  3. required becomes a Mongoose schema-level validation; default, alias and addId are copied to the schema field
  4. converter, validator and the no* modifiers are applied by BaseModel, not by the schema
  5. MongoContext.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​

  1. Define the class with @Entity("collectionName") and extends BaseModel<ClassName>
  2. Add properties with @Property({type: ..., required: false, ...}) — symbol only when the stored field name must differ
  3. Use declare for all @Property fields
  4. Create converters for model references (extend BaseToRefConverter, handle dataTranslation)
  5. Use InclusionConverter for embedded subdocuments
  6. Mirror on frontend — Create the same structure in Gui/Models/, extending hub-gui base
  7. 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(
  8. Verify with oidis.cmd project-cleanup test:lint

See also: Converters | Validators | Backend vs Frontend | Enums | IndexedDB