Skip to content
Strata Sync

AI agents: fetch the documentation index at llms.txt. Markdown versions are available by appending .md to any page URL, including this page's markdown.

Model relationships

Define references, collections, and back-references between models with lazy and eager loading.

Define foreign keys, inverse collections, and ordered lists with six relationship decorators.

Relationship decorators at a glance

DecoratorDirectionCardinalityCreates foreign key?
@ReferenceModel A -> Model BOne-to-oneYes (on A)
@ManyToOneModel A -> Model BMany-to-oneYes (on A)
@OneToManyModel B -> Model A[]One-to-manyNo (uses A's foreign key)
@BackReferenceComputed inverseVariesNo
@ReferenceArrayModel A -> Model B[]Ordered listYes (array field on A)
@ReferenceCollectionAlias for @OneToManyOne-to-manyNo

@Reference: belongs to

One-to-one relationship where the current model holds the foreign key.

import { Model, ClientModel, Property, Reference } from "@stratasync/core";

@ClientModel("Task", { loadStrategy: "instant" })
export class Task extends Model {
  @Property()
  declare id: string;

  @Property()
  declare title: string;

  // Creates an `assigneeId` foreign key property automatically
  @Reference(() => User, "assignedTasks")
  declare assignee: User | null;

  // The foreign key is available as a regular property
  @Property()
  declare assigneeId: string | null;
}

You can override the default foreign key name with the foreignKey option:

@Reference(() => User, "createdTasks", { foreignKey: "creatorId" })
declare creator: User | null;

@Property()
declare creatorId: string | null;

Options

OptionTypeDefaultDescription
foreignKeystring${name}IdName of the foreign key property
nullableboolean-Whether the reference can be null
indexedbooleanautoWhether to index the foreign key
lazyboolean-Whether to lazily hydrate

See Decorators for the full API reference.

@ManyToOne: explicit many-to-one

Like @Reference but accepts a model name string.

import { Model, ClientModel, Property, ManyToOne } from "@stratasync/core";

@ClientModel("Comment", { loadStrategy: "lazy" })
export class Comment extends Model {
  @Property()
  declare id: string;

  @Property()
  declare body: string;

  // String-based model name (no factory function needed)
  @ManyToOne("Task", "comments", { foreignKey: "taskId" })
  declare task: unknown;

  @Property()
  declare taskId: string;

  // Also supports factory functions
  @ManyToOne(() => User, "comments", { foreignKey: "authorId" })
  declare author: unknown;

  @Property()
  declare authorId: string;
}

Use @ManyToOne for string-based references, @Reference for factory functions (helps with circular imports). Both produce identical metadata.

See Decorators for the full API reference.

@OneToMany: inverse collection

The inverse side of a many-to-one. Creates a LazyCollection of models pointing to the current instance via foreign key.

import { Model, ClientModel, Property, OneToMany } from "@stratasync/core";

@ClientModel("Project", { loadStrategy: "instant" })
export class Project extends Model {
  @Property()
  declare id: string;

  @Property()
  declare name: string;

  // All tasks where task.projectId === this.id
  @OneToMany({ foreignKey: "projectId" })
  declare tasks: unknown[];
}

Options

OptionTypeDefaultDescription
foreignKeystring-Foreign key on the child model
indexedboolean-Whether to use index for lookups
nullableboolean-Whether the collection can be null

See Decorators for the full API reference.

@BackReference: computed inverse

A computed inverse of a reference. Unlike @OneToMany, doesn't create a collection: it represents the inverse lookup.

import { Model, ClientModel, Property, BackReference } from "@stratasync/core";

@ClientModel("User", { loadStrategy: "instant" })
export class User extends Model {
  @Property()
  declare id: string;

  @Property()
  declare name: string;

  // Computed inverse of Task.assignee
  @BackReference({ foreignKey: "assigneeId" })
  declare assignedTasks: unknown[];

  // Computed inverse of Comment.author
  @BackReference({ foreignKey: "authorId" })
  declare comments: unknown[];
}

Options

OptionTypeDefaultDescription
foreignKeystring-Foreign key on the referencing model

See Decorators for the full API reference.

@ReferenceArray: ordered list of references

An ordered list of references. Preserves insertion order.

import { Model, ClientModel, Property, ReferenceArray } from "@stratasync/core";

@ClientModel("Board", { loadStrategy: "instant" })
export class Board extends Model {
  @Property()
  declare id: string;

  @Property()
  declare name: string;

  // Ordered list of column IDs
  @ReferenceArray()
  declare columns: unknown[];

  @Property()
  declare columnIds: string[];
}

For many-to-many relationships, use the through option to specify a join model:

@ReferenceArray({ through: "ProjectMember" })
declare members: unknown[];

Options

OptionTypeDefaultDescription
throughstring-Join model name for many-to-many

See Decorators for the full API reference.

@ReferenceCollection: unordered set

Alias for @OneToMany. Use when the collection is unordered.

import {
  Model,
  ClientModel,
  Property,
  ReferenceCollection,
} from "@stratasync/core";

@ClientModel("Team", { loadStrategy: "instant" })
export class Team extends Model {
  @Property()
  declare id: string;

  @Property()
  declare name: string;

  // Unordered set of team members
  @ReferenceCollection({ foreignKey: "teamId" })
  declare members: unknown[];
}

See Decorators for the full API reference.

Lazy loading vs. eager loading

By default, references resolve eagerly from the identity map. Use lazy to defer hydration.

// Eager (default): resolved from identity map on access
@Reference(() => User, "assignedTasks")
declare assignee: User | null;

// Lazy: not hydrated until explicitly accessed
@Reference(() => User, "assignedTasks", { lazy: true })
declare assignee: User | null;

Lazy loading helps with large reference chains, partial-load models (where the referenced data may not be available yet), and performance-sensitive views where you load models but don't display all their references. See Load Strategies for more on controlling when relationship targets load.

Circular references

The identity map and the factory function pattern handle circular references naturally. You can model self-referencing trees (such as subtask hierarchies) without extra configuration.

@ClientModel("Task", { loadStrategy: "instant" })
export class Task extends Model {
  @Property()
  declare id: string;

  @Reference(() => Task, undefined, { foreignKey: "parentId" })
  declare parent: Task | null;

  @Property()
  declare parentId: string | null;

  @OneToMany({ foreignKey: "parentId" })
  declare children: unknown[];
}

The factory function () => Task works because TypeScript hoists class declarations, so the class is available when the factory runs. The identity map guarantees that task.parent.children[0] resolves to the same object reference as task. When rendering recursive trees in React, use depth limits or lazy loading to avoid infinite loops.

Once you define relationships, you can traverse them through the identity map without extra queries.

// Get a project and traverse relationships
const project = client.getCached("Project", projectId);

// Navigate to team
const team = project.team; // Resolves via identity map

// Get all tasks in the project
const tasks = project.tasks; // LazyCollection

// Get the assignee of a task
const task = tasks[0];
const assignee = task.assignee; // Resolves via identity map

// Navigate back to the user's team
const userTeam = assignee.team; // Same team object (referential equality)

See Decorators for a complete domain model example.