Developer Guides
Defining and using Model Classes

Model Classes in AD4M

Model classes in AD4M provide a way to define, store, and query structured data in your application. They follow familiar ORM conventions (ActiveRecord-style) and generate SHACL schemas automatically.

Quick Start

There are three ways to create model classes in AD4M:

Option 1: Using Decorators (Recommended)

import { Ad4mModel, Model, Property, HasMany } from '@coasys/ad4m';
 
@Model({ name: "Recipe" })
class Recipe extends Ad4mModel {
  @Property({ through: "recipe://name" })
  name: string = "";
 
  @HasMany({ through: "recipe://ingredient" })
  ingredients: string[] = [];
}
 
// Register the model with a perspective (once at app startup)
await Recipe.register(perspective);
 
// Using the model
const recipe = new Recipe(perspective);
recipe.name = "Chocolate Cake";
recipe.ingredients = ["flour", "sugar", "cocoa"];
await recipe.save();
 
// Reading it back
const recipes = await Recipe.findAll(perspective);
console.log(recipes[0].name); // "Chocolate Cake"
 
// Or use the static create shorthand
const cake = await Recipe.create(perspective, {
  name: "Chocolate Cake",
  ingredients: ["flour", "sugar", "cocoa"]
});

Option 2: From a SHACL Shape (Dynamic)

When a perspective already carries a SHACL shape — for example, a shape installed by another app or recovered via perspective.getClassShape() / perspective.getAllShacl() — you can hydrate a working model class straight from it. No predicate inference is needed: every property's URI is read directly from the shape.

import { Ad4mModel } from '@coasys/ad4m';
 
// Fetch the SHACL shape from a perspective that already has it registered
const { shape } = (await perspective.getAllShacl())
  .find(s => s.name === "Recipe")!;
 
// Synthesise a fully functional model class from the shape
const Recipe = Ad4mModel.fromSHACL(shape, "Recipe");
 
// Use exactly like decorator-based models
const recipes = await Recipe.findAll(perspective);

This is how AD4M tooling (the MCP server, the CLI, the management UI) discovers models at runtime without ever importing application code.

The Wire Format: JSON and Turtle

A SHACLShape is not just an in-process JavaScript object. It also has two serialized forms that make a shape portable beyond the app that defined it:

  • JSON — the format used over the wire between the client and the AD4M executor. Every addSdna RPC carries a shaclJson string; addShacl and model registration send shape.toJSON(). This is the format you want to ship a shape between apps, store it in a config file, paste it into a Neighbourhood definition, or generate from another tool.
  • Turtle — the standard W3C SHACL serialization (RDF Turtle). Export-only in the client today (no fromTurtle round-trip yet), but useful for sharing shapes with non-AD4M tooling and for human-readable review.

Both are produced by the same SHACLShape:

import { SHACLShape } from '@coasys/ad4m';
 
const { shape } = (await perspective.getAllShacl()).find(s => s.name === "Recipe")!;
 
const json    = shape.toJSON();     // structured object, JSON.stringify-able
const turtle  = shape.toTurtle();   // W3C SHACL Turtle string

A minimal shape's JSON form looks like this:

{
  "node_shape_uri": "shacl://Recipe",
  "target_class": "recipe://Recipe",
  "properties": [
    {
      "path": "recipe://name",
      "name": "name",
      "datatype": "xsd:string",
      "min_count": 1,
      "max_count": 1
    },
    {
      "path": "recipe://ingredient",
      "name": "ingredients"
    }
  ],
  "constructor_actions": [
    { "action": "addLink", "source": "this", "predicate": "rdf://type", "target": "recipe://Recipe" }
  ]
}

The same shape in Turtle:

@prefix sh:   <http://www.w3.org/ns/shacl#> .
@prefix xsd:  <http://www.w3.org/2001/XMLSchema#> .
@prefix ad4m: <ad4m://> .
 
<shacl://Recipe>
  a sh:NodeShape ;
  sh:targetClass <recipe://Recipe> ;
  sh:property [
    sh:path <recipe://name> ;
    sh:datatype <xsd:string> ;
    sh:minCount 1 ;
    sh:maxCount 1
  ] ;
  sh:property [
    sh:path <recipe://ingredient>
  ] .

Installing a Shape From JSON (No Decorated Class)

Because the JSON form is exactly what the executor consumes over RPC, an app — or a CLI script, or a foreign client written in another language — can install a subject class without ever importing a decorated TypeScript model:

// shape received over the wire / loaded from a file / fetched from a registry
const shape = SHACLShape.fromJSON(JSON.parse(await readFile("recipe.shacl.json", "utf8")));
 
// Install it directly on a perspective
await perspective.addShacl("Recipe", shape);
 
// Optionally, hydrate a model class from it for ergonomic access
const Recipe = Ad4mModel.fromSHACL(shape, "Recipe");
const recipes = await Recipe.findAll(perspective);

This is the canonical path for sharing schemas across apps: the publisher serializes a shape to JSON (or Turtle for documentation), the consumer installs it with addShacl, and from then on both apps read and write the same Subject Class against the same Perspective.

Option 3: From JSON Schema (Dynamic)

Perfect for integrating with external systems or when you have existing JSON Schema definitions:

import { Ad4mModel } from '@coasys/ad4m';
 
// Define your data structure using JSON Schema
const recipeSchema = {
  "$schema": "http://json-schema.org/draft-07/schema#",
  "title": "Recipe",
  "type": "object",
  "properties": {
    "name": { "type": "string" },
    "ingredients": { 
      "type": "array", 
      "items": { "type": "string" } 
    },
    "difficulty": { "type": "number" }
  },
  "required": ["name"]
};
 
// Dynamically create the model class
const Recipe = Ad4mModel.fromJSONSchema(recipeSchema, {
  name: "Recipe",
  namespace: "recipe://",
  resolveLanguage: "literal"
});
 
// Use exactly like decorator-based models
const recipe = new Recipe(perspective);
recipe.name = "Chocolate Cake";
recipe.ingredients = ["flour", "sugar", "cocoa"];
recipe.difficulty = 3;
await recipe.save();

Register your model before use:

await Recipe.register(perspective);
⚠️

Always register your model classes with the perspective before use (typically once at app startup). This installs the SHACL schema so that all consumers (JS, Rust, MCP, CLI) can discover and work with your model's shape.

Basic Concepts

Properties

Properties are the basic building blocks of your models. They are optional by default — no link is created in the graph until you explicitly set a value.

@Model({ name: "Recipe" })
class Recipe extends Ad4mModel {
  // Optional property (default) — only stored when explicitly set
  @Property({ through: "recipe://name" })
  name: string = "";
 
  // Optional property — same behavior
  @Property({ through: "recipe://description" })
  description?: string;
 
  // Required property — a placeholder link is created on save
  @Property({ through: "recipe://category", required: true })
  category: string = "";
 
  // Read-only computed property — `through` acts as the SHACL predicate
  // identifier; the actual value comes from the custom `getter` expression.
  @ReadOnly({
    through: "recipe://rating",
    getter: `SELECT (AVG(xsd:decimal(?rating)) AS ?value) WHERE { <Base> <recipe://user_rating> ?rating . }`
  })
  averageRating: number = 0;
}
ℹ️

@Property smart defaults: required: false, readOnly: false, resolveLanguage: "literal". You only need to specify through in the common case.

ℹ️

through on computed properties: Even when using a custom getter, through is still required — it serves as the SHACL predicate identifier in the generated shape, making the property discoverable by other consumers (MCP, CLI, other apps). No link is written at this predicate; the value is entirely derived from the getter expression.

All property options

OptionTypeDefaultDescription
throughstring(required)Predicate URI for the link
requiredbooleanfalseIf true, a placeholder link is created on save even if the value is empty
readOnlybooleanfalsePrevents updates after creation
resolveLanguagestring"literal"Language used to resolve the stored value. Use "literal" for simple values, or a Language address for richer content
initialany—Default value written on creation. When required: true with no initial, the framework uses a sentinel value ("literal:string:uninitialized")
localbooleanfalseStore locally only — not synced to the network. Useful for user preferences or draft state
getterstring—SPARQL SELECT expression for computed values. <Base> is replaced with the instance's URI at query time. Mutually exclusive with regular storage
transformNodeExpression—Declarative post-retrieval transform expressed as a SHACL-AF Node Expression. Evaluated in the Rust model query engine, not in JavaScript. See Transforms

You can also use the @Optional decorator as an alias for @Property with required: false (the default), if you want to be explicit:

@Optional({ through: "recipe://description" })
description?: string;

The getter option in detail

The getter option accepts a SPARQL SELECT expression that returns a single ?value binding. The placeholder <Base> is replaced with the instance's URI at query time:

@ReadOnly({
  through: "recipe://rating",
  getter: `SELECT (AVG(xsd:decimal(?rating)) AS ?value) WHERE { <Base> <recipe://user_rating> ?rating . }`
})
averageRating: number = 0;

The transform option

transform declares a post-hydration transformation as a SHACL-AF Node Expression — a small JSON-serialisable DSL that the Rust model query engine evaluates server-side. Transforms are part of the model's SHACL shape, so every consumer (JS, Rust, MCP, CLI) sees the same shape applied the same way and pays no per-instance JS lambda cost during query hydration.

Compose transforms with builder functions exported from the package root:

import {
  Ad4mModel, Model, Property,
  // SHACL Node Expression builders
  focus, literal, path, exists, ifExpr, concat, coalesce, fn,
  // Built-in transform
  fileToDataUri,
} from '@coasys/ad4m';
 
@Model({ name: "Avatar" })
class Avatar extends Ad4mModel {
  // FileData stored via the file-storage language → decoded to a `data:` URI
  // server-side using the shipped `fileToDataUri` built-in.
  @Property({
    through: "we://image",
    resolveLanguage: FILE_STORAGE_LANGUAGE,
    transform: fileToDataUri,
  })
  image?: string;
 
  // Compose your own: prefix the stored value with a literal.
  @Property({
    through: "user://handle",
    transform: concat(literal('@'), focus()),
  })
  handle: string = "";
 
  // Fall back to a default when a related field is missing.
  @Property({
    through: "user://timezone",
    transform: coalesce(focus(), literal('UTC')),
  })
  timezone: string = "";
}
Builder reference
BuilderProducesNotes
focus()The current node's valueThe input being transformed. Useful as a base case.
literal(v)A constant valueStrings, numbers, booleans.
path('predicate://uri')Value reached by traversing a predicate from the focus nodeOne hop.
exists(expr)BooleanWhether expr resolves to a non-empty value.
ifExpr(cond).then(a).else(b)a if cond is truthy, otherwise bAll three are themselves NodeExpressions.
concat(a, b, …)String concatenation of the argumentsEach argument is a NodeExpression.
coalesce(a, b, …)First non-null argumentArgument-order fallback.
fn('name', …args)Server-side function callUsed by built-ins like fileToDataUri (using ad4m://fn/defaultFileDecode fn)
When to use transforms
  • Decode wire formats: turn a FileData payload into a data: URI, base64 → bytes, etc.
  • Apply defaults at query time: coalesce(focus(), literal('default')) instead of writing the default to every record.
  • Cheap derived values: prefixes, suffixes, joins — anything you'd otherwise compute in every consumer.

Use a custom Prolog/SPARQL getter (getter:) instead when the transformation needs other links from the graph; transforms operate on the property's resolved value, not on the wider perspective.

Relations

Relations represent associations between models. AD4M provides four relation decorators that make the relationship semantics explicit:

import {
  Ad4mModel, Model, Property,
  HasMany, HasOne, BelongsToOne, BelongsToMany
} from '@coasys/ad4m';
 
@Model({ name: "Comment" })
class Comment extends Ad4mModel {
  @Property({ through: "comment://body" })
  body: string = "";
 
  @BelongsToOne(() => Chef, { through: "comment://chef" })
  chef: string = "";
}
 
@Model({ name: "Chef" })
class Chef extends Ad4mModel {
  @Property({ through: "chef://name" })
  name: string = "";
}
 
@Model({ name: "Image" })
class Image extends Ad4mModel {
  @Property({ through: "image://url" })
  url: string = "";
}
 
@Model({ name: "Category" })
class Category extends Ad4mModel {
  @Property({ through: "category://name" })
  name: string = "";
}
 
@Model({ name: "Recipe" })
class Recipe extends Ad4mModel {
  @Property({ through: "recipe://name" })
  name: string = "";
 
  // One-to-many: this recipe owns many comments
  @HasMany(() => Comment, { through: "recipe://comment" })
  comments: string[] = [];
 
  // One-to-one: this recipe has one featured image
  @HasOne({ through: "recipe://featured_image", target: () => Image })
  featuredImage: string = "";
 
  // Many-to-one: this recipe belongs to one chef (read-only reference)
  @BelongsToOne(() => Chef, { through: "recipe://chef" })
  chef: string = "";
 
  // Many-to-many: this recipe belongs to many categories (read-only)
  @BelongsToMany(() => Category, { through: "recipe://category" })
  categories: string[] = [];
}

Each relation decorator supports two calling conventions:

// Target-first shorthand (recommended)
@HasMany(() => Comment, { through: "recipe://comment" })
 
// Options-object style
@HasMany({ through: "recipe://comment", target: () => Comment })

Relation types at a glance

DecoratorCardinalityGenerated methodsOwnership
@HasManyOne-to-manyadd*, remove*, set*Parent owns
@HasOneOne-to-oneset* (also add*, remove*), or assign and save()Parent owns
@BelongsToOneMany-to-oneRead-onlyChild references
@BelongsToManyMany-to-manyRead-onlyChild references
ℹ️

Default through: When you omit through on a relation decorator, it defaults to 'ad4m://has_child'. This is the standard AD4M parent-child predicate, so it works well for hierarchical data — but make sure it's intentional.

Relation decorators also accept a local option (local: true) to store the relation links locally without syncing to the network.

Using relation methods

@HasMany generates typed helper methods on the model instance:

const recipe = await Recipe.findOne(perspective, { where: { name: "Chocolate Cake" } });
 
// Add a comment — accepts a model instance or a string ID
const comment = await Comment.create(perspective, { body: "Delicious!" });
await recipe.addComments(comment);       // pass instance
await recipe.addComments(comment.id);    // or pass ID
 
// Remove
await recipe.removeComments(comment);
 
// Replace all
await recipe.setComments([comment1.id, comment2.id]);
 
// With batch support
const batchId = await perspective.createBatch();
await recipe.addComments(comment, batchId);
await perspective.commitBatch(batchId);

@HasOne with a through gets the same three methods. Use set* to point it somewhere: it takes an ID or a model instance and replaces whatever the relation held. Writing works the same whether or not the relation names a target class, so a polymorphic to-one is written the same way.

add* writes a second link and leaves the first in place. A read then returns the newest link, unless the relation names a target class that has a flag or a required property, and does not set filter: false. Such a read goes through a type-checking query and returns whichever row that query yields first. Either way the old link stays in the graph.

@Model({ name: "Note" })
class Note extends Ad4mModel {
  @HasOne({ through: "note://about", polymorphic: true })
  about: string = "";
}
 
// At creation: an ID, an instance, or a one-element array
const note = await Note.create(perspective, { about: recipe.id });
 
// Later
await note.setAbout(otherRecipe);
await note.setAbout([]); // clears it

The generated methods are added at runtime and are not declared on the class, so TypeScript does not know about them. Declare them on the class or call them through a cast.

Ordered relations

A @HasMany is a set by default. Assigning the same members in another order is not a change, and reads come back in link order (or in no stated order, when the relation is read through a type-checking query, below), never in an order someone chose. Add ordering to make the sequence part of the data:

@HasMany({
  through: "board://task",
  target: () => Task,
  ordering: { strategy: "linkedList" },
})
tasks: string[] = [];

Assigning the array (board.tasks = [c, a, b] then save(), or setTasks([c, a, b])) writes ordering entries alongside the member links. Concurrent reorders by different agents merge rather than overwrite each other.

The member links decide what is in the relation; the entries decide only where things go. A member with no entry is still returned, after every positioned member. A member has no entry when it was written:

  • by add*, which writes the member link only
  • by raw addLink, an MCP tool, or a peer that does not know the strategy
  • before the relation had ordering

Where those members go depends on how the relation is read:

  • Through a type-checking query, which is the case when the relation names a target class that has a flag or a required property, and does not set filter: false: the query carries no order, so their order among themselves is unspecified. A collection with no entries at all, such as one written before ordering was added, therefore reads in an unspecified order until the next assignment writes a full sequence.
  • Otherwise: oldest link first.

Flags

Flags are immutable type markers. They are automatically readOnly and required, and cannot be changed after creation:

@Flag({ through: "ad4m://type", value: "recipe://main-course" })
type: string = "";

Attempting to update a flag after creation throws an error.

How It Works Under the Hood

Graph-Based Storage

Each model instance is stored as a subgraph in an AD4M perspective. For example, our recipe is stored as:

recipe://chocolate-cake-123 -[recipe://name]-> literal:string:"Chocolate Cake"
recipe://chocolate-cake-123 -[recipe://ingredient]-> ingredient://flour
recipe://chocolate-cake-123 -[recipe://ingredient]-> ingredient://sugar

Every node in this graph is a URL pointing to an Expression in some language.

Instance Identity

Each model instance has a unique id that serves as the root node of its subgraph. You can specify it or let AD4M generate one:

// Auto-generated ID
const recipe = new Recipe(perspective);
 
// Custom ID
const recipe = new Recipe(perspective, "recipe://chocolate-cake-2024-03");

Literals for Simple Values

AD4M provides Literals for storing simple values without needing a full language:

// These are equivalent:
recipe.name = "Chocolate Cake";
// Stored as: literal:string:Chocolate Cake
 
recipe.cookingTime = 45;
// Stored as: literal:number:45

Social DNA and SHACL

When you define a model class, AD4M generates a SHACL (Shapes Constraint Language) representation — a W3C standard for describing and validating RDF graph shapes:

@Model({ name: "Recipe" })
class Recipe extends Ad4mModel {
  @Property({
    through: "recipe://name",
    required: true
  })
  name: string = "";
 
  @HasMany({ through: "recipe://ingredient" })
  ingredients: string[] = [];
}

The SHACL shape is stored as links in the perspective graph, making it discoverable and queryable by any agent or tool that joins the perspective.

How SHACL Maps to AD4M

SHACL ConceptAD4M Equivalent
sh:NodeShapeSubject Class definition
sh:PropertyShapeProperty or Relation definition
sh:maxCount 1Scalar property (single value, generates set_ tool)
No sh:maxCount or > 1Relation (generates add_/remove_ tools)
sh:minCount 1Required property (must exist on creation — when required: true is used without an explicit initial, the @Property decorator auto-supplies a "literal:string:uninitialized" placeholder)
sh:datatypeValue type constraint
sh:classReference to another Subject Class (target model shape)
sh:nodeParent shape reference (model inheritance)
ad4m://initialDefault value on instance creation
ad4m://resolveLanguageExpression language for value resolution
ad4m://readOnlyRead-only computed property
ad4m://getterSPARQL getter for conformance filtering
ad4m://conformanceConditionsStructured filter conditions for relation targets

AD4M extends standard SHACL with ad4m:// predicates for features specific to the AD4M runtime (initial values, language resolution, write permissions).

Working with Models

Creating

// Option 1: Instantiate and save
const recipe = new Recipe(perspective);
recipe.name = "Chocolate Cake";
await recipe.save();
 
// Option 2: Static create (one-step)
const recipe = await Recipe.create(perspective, { name: "Chocolate Cake" });
 
// Create with a specific ID
const recipe = new Recipe(perspective, "recipe://chocolate-cake");
await recipe.save();
 
// Create with a parent relationship
const recipe = await Recipe.create(perspective, { name: "Cake" }, {
  parent: { model: Cookbook, id: cookbook.id }
});

Reading

// Get by ID
const recipe = new Recipe(perspective, existingId);
await recipe.get();
 
// Get with eager-loaded relations
await recipe.get({ include: { comments: true } });
 
// Get with sparse fieldset (only hydrate specific properties)
await recipe.get({ properties: ["name", "category"] });

Updating

save() handles both creation and updates automatically. For existing instances (fetched via get() or a query), it only writes changed fields thanks to built-in dirty tracking:

const recipe = await Recipe.findOne(perspective, {
  where: { name: "Chocolate Cake" }
});
 
recipe.name = "Dark Chocolate Cake";
await recipe.save();  // Only the name field is updated
 
// Static update shorthand
await Recipe.update(perspective, recipe.id, { name: "White Chocolate Cake" });

Deleting

// Instance method
await recipe.delete();
 
// Static method
await Recipe.delete(perspective, recipeId);

The instance delete() also cleans up incoming links (e.g. a parent's hasMany reference to this instance).

Dirty Tracking

After fetching an instance, AD4M takes a snapshot of all field values. You can inspect what changed:

const recipe = await Recipe.findOne(perspective);
recipe.name = "Updated Name";
 
recipe.isDirty();       // true
recipe.changedFields(); // ["name"]
 
await recipe.save();    // Only "name" is written

Instance Identity

Every model instance has a unique id (a URI string):

const recipe = await Recipe.create(perspective, { name: "Cake" });
console.log(recipe.id); // "literal:..."
 
// Auto-generated when not specified
const recipe2 = new Recipe(perspective);
console.log(recipe2.id); // random literal URI
 
// Custom ID
const recipe3 = new Recipe(perspective, "recipe://my-cake");
console.log(recipe3.id); // "recipe://my-cake"
ℹ️

The legacy .baseExpression getter still works but is deprecated. Use .id instead.

Built-in Instance Properties

Every model instance automatically gets these properties, populated from link metadata:

PropertyTypeDescription
.idstringUnique URI identifier
.authorstringDID of the agent who created the earliest link (e.g. "did:key:z6Mk...")
.createdAtstring | numberTimestamp of the earliest link (epoch ms or ISO string)
.updatedAtstring | numberTimestamp of the most recent link
.timestamp(deprecated)Alias for .createdAt — use .createdAt instead

These are available after any fetch operation (get(), findAll(), findOne(), etc.) and can be used in queries:

// Order by creation date
const recent = await Recipe.query(perspective)
  .order({ createdAt: "DESC" })
  .limit(10)
  .get();
 
console.log(recent[0].author);    // "did:key:z6Mk..."
console.log(recent[0].createdAt); // 1709049600000
console.log(recent[0].updatedAt); // 1709136000000

Querying

Basic Queries

// Get all
const allRecipes = await Recipe.findAll(perspective);
 
// With conditions
const cakes = await Recipe.findAll(perspective, { where: { category: "Dessert" } });
 
// Find one
const recipe = await Recipe.findOne(perspective, { where: { name: "Chocolate Cake" } });
 
// Count
const { results, totalCount } = await Recipe.findAllAndCount(perspective, {
  where: { category: "Dessert" }
});
 
// Pagination
const page = await Recipe.paginate(perspective, 10, 1, { where: { category: "Dessert" } });

Query Builder

The fluent query builder provides a chainable interface:

const recipes = await Recipe.query(perspective)
  .where({ category: "MainCourse", rating: { gt: 4 } })
  .order({ createdAt: "DESC" })
  .limit(5)
  .get();
 
// Get first match
const topRecipe = await Recipe.query(perspective)
  .where({ rating: { gt: 4 } })
  .order({ rating: "DESC" })
  .first();  // Returns Recipe | null

Where Operators

const recipes = await Recipe.findAll(perspective, {
  where: {
    name: "Exact Match",             // Equality
    rating: { gt: 4 },               // Greater than
    difficulty: { lte: 3 },          // Less than or equal
    servings: { between: [2, 6] },   // Range
    category: ["Dessert", "Snack"],  // IN (array membership)
    status: { not: "archived" },     // Not equal
    tags: { contains: "vegan" },     // Contains
    cuisine: { eq: "Italian" },      // Equality as an operator (same as "Italian")
  }
});

eq exists so a value can sit beside author in one operator object (see below). It takes a value or an array (any of), and cannot be combined with the other value operators.

Filtering by Author

Every link records who wrote it. author in a where can ask about one link or about the whole instance, and where you put it decides which.

FormExampleMatches when
Nested (per link){ agent: { eq: did, author: admin } }admin wrote an agent -> did link
Bare (per instance){ author: admin }admin wrote the instance's earliest link (its .author)
Side by side (both){ agent: did, author: admin }admin wrote the earliest link and an agent -> did link

Nested: ask who wrote the link. Put author inside the property's operator object. The author and the value condition must hold on the same link. That link need not hold the current value: { agent: { eq: alice, author: admin } } still matches after someone writes agent -> bob, and the returned instance then shows agent: bob.

// Reviewers that admin appointed: admin must have written the `agent` link itself.
const appointed = await Reviewer.findAll(perspective, {
  where: { agent: { eq: candidateDid, author: adminDid } }
});

This matters wherever author works as an authority check, such as role gating. Any peer can add a link to an instance someone else created. If the check only looked at who created the instance, a peer could add agent -> themselves to an admin's role instance and pass it. The check reads the author each link claims, and only on links whose signature verified (see Unverified Links), so a forged link that claims admin as its author does not pass it.

  • { agent: { author: admin } } with no value operator: admin wrote some agent link, whatever its value.
  • author combines with the other operators: { note: { contains: "ok", author: admin } } is "admin wrote a note link containing ok", { status: { not: "archived", author: admin } } is "admin wrote a status link other than archived".
  • On relations it works the same way: { reviews: { author: admin } } is "admin wrote some reviews link". Beside a quantifier it scopes the relation link: { reviews: { some: { verdict: "ok" }, author: admin } } is "admin linked a review whose verdict is ok", and { reviews: { none: {}, author: admin } } is "admin linked no review".
  • Author values: a DID, an array of DIDs (any of them), { not: did } or { not: [dids] }, and { contains: "..." }.
  • Negation is explicit. NOT: { agent: { eq: did, author: admin } } means admin wrote no agent -> did link. agent: { eq: did, author: { not: admin } } means someone other than admin wrote one. If both admin and a peer wrote it, the first fails and the second passes.

Bare: ask who created the instance. { author: me } with no link-backed property beside it matches instances whose earliest link I wrote, the same value as the built-in .author. Mute lists ({ author: { not: [...] } }) work this way. Beside only a getter property or timestamp the author is bare too, because those have no link.

Side by side: both. A top-level author beside property or relation conditions in the same object is short for the bare author plus the same author nested under each of those properties. { agent: did, note: "x", author: admin } needs admin to have created the instance and written both links; author: { not: admin } negates both halves. For an instance one agent created and wrote alone, this gives the same answer as the bare reading. It is narrower otherwise, so it never matches more than either reading. A none quantifier beside it is not scoped, since "admin linked none" would be wider than "none".

Scope stays in its object. A top-level author does not reach into AND/OR/NOT sub-clauses, and an author inside a sub-clause applies only to its own object's properties. { agent: did, OR: [{ author: admin }, { author: lead }] } is a bare agent condition and a bare author. Write { agent: { eq: did, author: [admin, lead] } } for "admin or lead wrote the agent link".

Where it runs, and when it is refused. A per-link author (nested, or the per-link half of side by side) is checked in the store, on each link's own author, so findAll, count and paginated queries agree. If any part of the query can only be evaluated after it runs (timestamp, a getter property, a relation some/none whose clause the store cannot express, or an operator the store cannot express), a per-link author makes the query fail with an error. At that stage each instance has only its single .author, and the engine refuses rather than check the wrong author. author nested under a getter property, timestamp or id is an error too, since there is no link to check.

A condition on the value alone, such as { agent: did }, accepts a link from any author. The instance JSON does not change: .author is still the author of the earliest link, and a property still reads its latest value.

Eager Loading with include

By default, relation fields contain raw URI strings. Use include to hydrate them into full model instances:

// Eager-load comments on query results
const recipes = await Recipe.query(perspective)
  .where({ category: "Dessert" })
  .include({ comments: true })
  .get();
 
// recipes[0].comments is now Comment[] instead of string[]
console.log(recipes[0].comments[0].body); // "Delicious!"
 
// Nested includes
const recipes = await Recipe.query(perspective)
  .include({ 
    comments: { 
      include: { chef: true }  // Load comment chefs too
    } 
  })
  .get();
 
// Also works on findAll / findOne
const recipe = await Recipe.findOne(perspective, {
  where: { name: "Cake" },
  include: { comments: true }
});
 
// And on instance get()
const recipe = new Recipe(perspective, existingId);
await recipe.get({ include: { comments: true } });
// Shorthand:
await recipe.get({ comments: true });

Filtering and sorting eager-loaded relations

Instead of true, pass a sub-query object to filter, sort, or paginate the eager-loaded relation:

// Only load the 5 most recent comments
const recipes = await Recipe.query(perspective)
  .include({
    comments: {
      where: { status: "approved" },
      order: { createdAt: "DESC" },
      limit: 5
    }
  })
  .get();
 
// Combine with nested includes
const recipes = await Recipe.query(perspective)
  .include({
    comments: {
      order: { createdAt: "DESC" },
      limit: 10,
      include: { chef: true }  // Also hydrate each comment's chef
    }
  })
  .get();

The sub-query accepts: where, order, limit, offset, include, and properties.

Sparse Fieldsets with properties

Only hydrate specific properties to avoid unnecessary work (especially useful for properties with non-literal resolveLanguage that trigger network calls):

const recipes = await Recipe.query(perspective)
  .properties(["name", "category"])
  .get();
// Only name and category are hydrated; other fields stay at defaults

Per-Link Rows with links

Hydration folds an instance's links into one object: one value per property, one array of ids per relation, and author / createdAt / updatedAt computed across all links. Two questions don't survive that fold:

  • When was this member added, and by whom? createdAt is the instance's earliest link. A comment attached to a post a week later has its own link with its own timestamp and author.
  • Links on predicates the model doesn't declare. Annotations such as a revocation tombstone sit on the instance but are not fetched by the model at all.

links returns the stored links behind each requested entry under instance.__links:

const [post] = await Post.findAll(perspective, {
  where: { id: postId },
  links: ["comments", "app://revoked"],
});
 
post.__links.comments;
// [
//   { author: "did:key:z6Mk…", timestamp: "2026-09-23T10:00:00.000Z",
//     data: { source: postId, predicate: "app://has_comment", target: "…" },
//     proof: { key: "…", signature: "…", valid: true, invalid: false } },
//   …
// ]
post.__links["app://revoked"]; // [] when there is no such link
 
// Fluent form
const posts = await Post.query(perspective).links(["comments"]).get();
  • Entries are property or relation names the model declares, or absolute predicate IRIs. An IRI may be one the model does not declare. If an entry matches both, the name wins.
  • Rows have the shape of the LinkExpression that perspective.get() returns (LinkRow in the SDK). That includes proof.valid / proof.invalid, the signature verdict the executor recorded when it stored the link. valid is true only when the signature verifies against author. Links whose signature did not verify are left out unless the query sets includeUnverified (see Unverified Links), so by default every row reads valid: true. With includeUnverified, a link stored without a proof arrives with an empty key and signature and valid: false. invalid is always !valid (never null), as in perspective.get(), so invalid: true covers both a failed signature and a link nobody signed; to tell them apart, check whether proof.signature is empty. Anything that acts on a row, such as counting a vote or honouring a grant, should still require proof.valid, so it stays correct when the query sets includeUnverified. data.target is the target exactly as it was signed (a literal written in another encoding than Literal.from(v).toUrl() keeps its original bytes), and the verdict was computed over those bytes, so verifying proof yourself gives the same answer. Rows are sorted oldest first, and ties have a fixed order, so every replica returns the same array.
  • Per-item provenance for collections: links: ["members"] answers "who added each member, when, and does their signature hold". The plain members array stays as it is.
  • Every requested entry is present on every returned instance, as [] when it has no such link. A query that doesn't ask has no __links key.
  • Errors instead of []: the query fails for an entry that is neither a declared name nor an absolute IRI, such as a typo. It also fails for a reverse relation (@BelongsToOne / @BelongsToMany). Those links point at the instance, and links reads outgoing links only, so ask for the relation on the class at the other end.
  • Additive: the rows are read by a separate query over the returned page. Asking for them never changes createdAt, updatedAt or any property value. links works with where, order, pagination, properties and inside an include sub-query (include: { comments: { links: ["app://flag"] } }).
  • Subscriptions: subscribe() re-runs when a link on any requested entry is added or removed, including an IRI the class does not declare and entries inside an include sub-query. A subscribed revocation check therefore updates when the tombstone is written.
  • Local properties: for a property declared local: true, only the agent's own local links are returned, the same rule hydration applies.
  • Not one snapshot: the rows come from a second read. A member added or removed between the two reads can appear in the relation but not in __links, or the other way round. If you date members by their own link, treat a member with no row as an error. Don't fall back to createdAt, which is earlier and so would widen any "since" window.

The option is also accepted by the raw RPC: perspective.modelQuery("Post", JSON.stringify({ links: ["comments"] })).

Parent-Scoped Queries

Query instances that are linked from a specific parent:

// From a model instance
const comments = await Comment.query(perspective)
  .parent(recipe)
  .get();
 
// From an ID + model class (predicate auto-resolved from metadata)
const comments = await Comment.query(perspective)
  .parent(recipeId, Recipe)
  .get();
 
// Raw predicate escape hatch
const comments = await Comment.query(perspective)
  .parent(recipeId, "recipe://comment")
  .get();

Unverified Links

Every link carries its author's signature, and the executor checks it when the link is stored. Queries only hydrate from links whose signature verified. A link that fails the check is withheld, for example a gossiped link whose data was changed after signing. Such a link never becomes a property value, a relation target, the instance's author or its updatedAt, even when it is newer than the signed value. Last-write-wins would otherwise let it win. It is also left out of the rows under __links and of the sort keys behind order with limit/offset.

This includes a typed relation such as @HasMany(() => Comment, { through: "recipe://comment" }), whose generated conformance getter the executor filters too. A relation where, as in @HasMany(() => Comment, { through: "recipe://comment", where: { status: "approved" } }), reads each target's values through the same filter, so an unverified status link neither admits a comment nor drops one. A getter you write yourself is different: the executor runs it as written, so includeUnverified does not apply to it and it reads unverified links too. To skip them, join the link's verdict in the getter:

@HasMany({
  getter: `SELECT ?target WHERE {
    <Base> <recipe://comment> ?target .
    ?r <http://www.w3.org/1999/02/22-rdf-syntax-ns#reifies> <<( <Base> <recipe://comment> ?target )>> ;
       <ad4m://ontology/proofValid> "true" .
  }`,
})
comments: string[] = [];

To show an unverified claim, for example a UI that marks a value as unverified, opt in:

const recipes = await Recipe.findAll(perspective, { includeUnverified: true });
 
// or with the query builder
const recipes = await Recipe.query(perspective).includeUnverified().get();

Included relations inherit the setting unless their own sub-query sets includeUnverified. Never use the opt-in for data you act on, such as vote counts or role checks.

Selection follows the same rule. Unverified links are skipped both by what hydrates an instance and by what decides which instances match: where conditions (a nested or side-by-side author, relation membership, NOT), the class's flags, count/totalCount, the sort order behind limit/offset, a parent scope's link, and what a $ projection counts or lists, transitive projections included. So a forged link cannot:

  • make an existing node an instance of a class it was never flagged as,
  • select an instance by a value it does not hydrate with, or satisfy { agent: { eq: did, author: admin } } by claiming admin as its author,
  • exclude an instance through NOT,
  • change a count, reorder a page or push a genuine instance off it.

With includeUnverified: true, selection reads unverified links as well, the same as hydration. The one exception is a hand-written getter (see above).

Links stored before #1141 (opens in a new tab): a link whose literal target was not encoded the way the SDK encodes it (a raw space as in literal:string:Write the guide, raw literal:json:{"a":1}, or -_.~ escaped by expression.create(_, "literal")) and that was stored before #1141 reads back in the canonical encoding. The node that signed it still returns it, but the copy it publishes to a Neighbourhood does not verify, so joiners withhold it unless the query sets includeUnverified. Literals written by the SDK (Literal.from(v).toUrl()) and links written after #1141 are not affected.

Reading by Link Status

Every link is either Shared (gossiped to the neighbourhood) or Local (kept on this executor). A query normally reads both. A property declared local: true reads only its Local links. To read instances as they exist in links of one status only, for every property, set linkStatus:

// Values only from links the neighbourhood sees
const cards = await Card.findAll(perspective, { linkStatus: "shared" });
 
// or with the query builder
const cards = await Card.query(perspective).linkStatus("shared").get();
 
// Only this executor's Local links
const drafts = await Card.findAll(perspective, { linkStatus: "local" });

Use linkStatus: "shared" when the result is shown to another user: no Local link is used for the values returned or to decide which instances are returned (where, the class's flags, count/totalCount, the order behind limit/offset, parent scopes and $ projections). An instance whose flag links are all Local is not an instance under "shared", and under "local" an instance needs a Local flag link to be returned at all. The same holds for the targets of a typed relation: under "local" it lists only targets flagged in a Local link. Under "shared", a local: true property always comes back empty. Typed relations are filtered too, including the ones filled by the SDK's generated conformance getter; a hand-written getter runs as written and reads links of any status. Included relations and $ projection targets inherit the setting, unless an include's own sub-query sets linkStatus. The __links rows are restricted the same way: under "shared" they list only Shared links.

linkStatus and includeUnverified are independent and both apply to the same link: a link is read only when it has the requested status and its signature verified (or the query sets includeUnverified). For a Local link whose signature did not verify:

linkStatusincludeUnverifiedRead?
unsetunsetno (unverified)
unsettrueyes
"local"unsetno (unverified)
"local"trueyes
"shared"unset or trueno (Local)

The class's flag is read the same way, so which status the flag link has decides whether an instance is returned at all. A Local note on an instance flagged only in a Shared link (your private note on a shared card) is read with no linkStatus, but the instance is not returned under "local":

Flag linklinkStatus unset"local""shared"
Shared onlyreturnednot returnedreturned
Local onlyreturnedreturnednot returned
bothreturnedreturnedreturned

Query Engine

Ad4mModel uses the SPARQL query engine for all query operations. The executor compiles queries to SPARQL for Oxigraph to execute, then hydrates results back into model instances.

// All queries execute server-side via SPARQL
const recipes = await Recipe.findAll(perspective, { where: { rating: { gt: 4 } } });

For advanced graph traversal and direct SPARQL access, see the SPARQL Queries Guide.

PerspectiveProxy Introspection APIs

You can introspect registered model classes at runtime:

// List all registered model classes in this perspective
const classes = await perspective.listRegisteredClasses();
// ["Recipe", "Comment", "Chef"]
 
// Get the SHACL shape for a class
const shape = await perspective.getClassShape("Recipe");
// Returns the full SHACL shape definition
 
// Get enum options for a property (sh:in constraints)
const options = await perspective.getClassOptions("Recipe", "difficulty");
// [{ value: "easy", label: "Easy" }, { value: "medium", label: "Medium" }, ...]

These APIs are useful for building dynamic UIs, tooling, and AI agent integrations that need to discover available data models at runtime.

Real-time Subscriptions

Subscribe to changes in your data:

const builder = Recipe.query(perspective)
  .where({ status: "cooking" });
 
// Subscribe — callback fires immediately with initial results,
// then again whenever matching data changes
const initialRecipes = await builder.subscribe(recipes => {
  console.log("Currently cooking:", recipes);
});
 
// Count subscription
const initialCount = await builder.countSubscribe(count => {
  console.log("Number cooking:", count);
});
 
// Paginated subscription
const initialPage = await builder.paginateSubscribe(10, 1, page => {
  console.log("Page 1:", page.results);
});
 
// Important: Clean up when done
builder.dispose();

Always call dispose() when you're done with a subscription to prevent memory leaks.

Subscription sharing and dispose()

The executor keeps one live subscription per distinct query and agent. Two builders that subscribe to the same query (two components showing the same list, for example) share that subscription: the executor re-evaluates the query once per change and pushes one update that both builders receive.

dispose() releases only the calling builder's hold. Other subscribers of the same query keep receiving updates, and the executor drops the subscription when the last holder disposes, or when no holder has sent a keepalive for a while (a page that was closed without disposing).

The local cleanup, stopping the keepalive and the update listener, happens synchronously, so a plain builder.dispose() is sufficient. The method also returns a promise that resolves once the executor has acknowledged the release; await it when a following step depends on that, as subscribe() itself does before registering a replacement subscription.

Transactions

Group multiple operations into an atomic batch:

await Ad4mModel.transaction(perspective, async (tx) => {
  const recipe = await Recipe.create(perspective, { name: "Cake" }, {
    batchId: tx.batchId
  });
 
  const comment = new Comment(perspective);
  comment.body = "Looks great!";
  await comment.save(tx.batchId);
 
  await recipe.addComments(comment, tx.batchId);
});
// All operations commit together, or none do if an error is thrown

For lower-level batch control, see the Batch Operations Guide.

Static Convenience Methods

MethodSignatureDescription
createcreate(perspective, data?, options?)Create and save in one step. options supports parent and batchId
updateupdate(perspective, id, data)Fetch by ID, merge data, save
deletedelete(perspective, id)Delete by ID with incoming link cleanup
removeremove(perspective, id)Deprecated — alias for delete
findAllfindAll(perspective, query?)Query all matching instances
findOnefindOne(perspective, query?)First matching instance or null
findAllAndCountfindAllAndCount(perspective, query?)Results + total count
paginatepaginate(perspective, pageSize, page, query?)Paginated results
registerregister(perspective)Install the SHACL schema in the perspective
transactiontransaction(perspective, fn)Atomic batch operation
queryquery(perspective)Returns a ModelQueryBuilder
getModelMetadatagetModelMetadata()Introspect property & relation metadata
generateSDNAgenerateSDNA()Returns the generated Prolog SDNA rules (injected by @Model)
generateSHACLgenerateSHACL()Returns the generated SHACL shape graph (injected by @Model)

Introspection

Use getModelMetadata() to inspect a model's structure at runtime — useful for tooling, debugging, or dynamic UIs:

const meta = Recipe.getModelMetadata();
console.log(meta.name);        // "Recipe"
console.log(meta.properties);  // [{ name: "name", predicate: "recipe://name", ... }, ...]
console.log(meta.relations);   // [{ name: "comments", predicate: "recipe://comment", ... }, ...]

generateSDNA() and generateSHACL() return the raw Prolog rules and SHACL shape respectively — primarily useful for debugging schema registration issues.

Relation Filtering

When a relation has a target model, AD4M can auto-generate a conformance filter that only returns linked instances matching the target model's shape (checking flags and required properties):

@Model({ name: "MainCourse" })
class MainCourse extends Ad4mModel {
  @Flag({ through: "ad4m://type", value: "recipe://main-course" })
  type: string = "";
 
  @Property({ through: "recipe://name", required: true })
  name: string = "";
}
 
@Model({ name: "Cookbook" })
class Cookbook extends Ad4mModel {
  // Auto-filters: only returns linked items that match the MainCourse shape
  @HasMany(() => MainCourse, { through: "cookbook://recipe" })
  mainCourses: string[] = [];
}

where on Relations

Add value-based filtering using the same where syntax as queries:

@Model({ name: "TaskBoard" })
class TaskBoard extends Ad4mModel {
  // Only return tasks where status is "active"
  @HasMany(() => Task, { through: "board://task", where: { status: "active" } })
  activeTasks: string[] = [];
}

Filtering options

OptionEffect
target: () => ModelAuto-generates conformance filter from model shape
where: { ... }Value-based filtering using query DSL
filter: falseOpt out of auto-filtering even when target is set
getter: "..."Raw SPARQL getter (escape hatch, mutually exclusive with target/through)

Model Inheritance

Models can extend other models. The child's SHACL shape references the parent via sh:node, ensuring inherited constraints are validated:

@Model({ name: "BaseRecipe" })
class BaseRecipe extends Ad4mModel {
  @Property({ through: "recipe://name" })
  name: string = "";
}
 
@Model({ name: "DetailedRecipe" })
class DetailedRecipe extends BaseRecipe {
  @Property({ through: "recipe://instructions" })
  instructions: string = "";
}
 
// DetailedRecipe inherits the "name" property from BaseRecipe
const recipe = await DetailedRecipe.create(perspective, {
  name: "Cake",
  instructions: "Mix and bake"
});

Dynamic Models from JSON Schema

The fromJSONSchema() method dynamically creates AD4M model classes from JSON Schema definitions. This is perfect for integrating with external systems, runtime model generation, partner app integration (like Holons), and rapid prototyping.

Basic Usage

import { Ad4mModel } from '@coasys/ad4m';
 
const schema = {
  "$schema": "http://json-schema.org/draft-07/schema#",
  "title": "BlogPost",
  "type": "object",
  "properties": {
    "title": { "type": "string" },
    "content": { "type": "string" },
    "tags": { 
      "type": "array", 
      "items": { "type": "string" } 
    },
    "metadata": {
      "type": "object",
      "properties": {
        "author": { "type": "string" },
        "publishedAt": { "type": "string" }
      }
    }
  },
  "required": ["title"]
};
 
const BlogPost = Ad4mModel.fromJSONSchema(schema, {
  name: "BlogPost",
  namespace: "blog://",
  resolveLanguage: "literal"
});
 
// Use like any other Ad4mModel
const post = new BlogPost(perspective);
post.title = "My First Post";
post.tags = ["ad4m", "tutorial"];
post.metadata = { author: "Alice", publishedAt: "2025-09-23" };
await post.save();

Configuration Options

The second parameter to fromJSONSchema() is optional:

interface JSONSchemaToModelOptions {
  // Model configuration
  name?: string;              // Class name override (falls back to x-ad4m.className → schema.title → schema.$id)
  namespace?: string;         // Base namespace for predicates (falls back to x-ad4m.namespace → schema.title → schema.$id)
 
  // Predicate generation helpers (in order of precedence)
  propertyMapping?: Record<string, string>;    // Direct propertyName → predicate URI mapping
  predicateTemplate?: string;                   // Template: ${scheme}, ${namespace}/${ns}, ${title}, ${property}
  predicateGenerator?: (title: string, property: string) => string;  // Custom callback
 
  // Global property settings
  resolveLanguage?: string;   // Default language for all properties
  local?: boolean;            // Whether properties are stored locally
 
  // Property-specific overrides (accepts any PropertyOptions fields)
  propertyOptions?: Record<string, Partial<PropertyOptions>>;
}

Resolution Cascades

The class name, namespace, and per-property predicate are each resolved with a cascading priority:

SettingPriority 1 (highest)Priority 2Priority 3Priority 4
Class nameoptions.namex-ad4m.classNameschema.titleschema.$id
Namespaceoptions.namespacex-ad4m.namespaceschema.titleschema.$id
PredicatepropertyMapping[name]x-ad4m.throughpredicateTemplatepredicateGenerator → default ${namespace}/${property}

For example, to control predicate URIs directly:

const Product = Ad4mModel.fromJSONSchema(schema, {
  name: "Product",
  namespace: "product://",
  // Direct mapping: property name → predicate URI
  propertyMapping: {
    name: "product://title",
    price: "product://cost"
  },
  // Or use a template (lower priority than propertyMapping)
  // predicateTemplate: "${namespace}/${property}"
});

x-ad4m Schema Extensions

Add AD4M-specific metadata directly to your JSON Schema:

const schema = {
  "title": "User",
  "x-ad4m": {
    "namespace": "user://",
    "resolveLanguage": "literal"
  },
  "properties": {
    "email": { 
      "type": "string",
      "x-ad4m": { "through": "user://email_address", "local": true }
    },
    "tags": {
      "type": "array",
      "items": { "type": "string" },
      "x-ad4m": { "through": "user://interests" }
    }
  },
  "required": ["email"]
};

Schema Type Mapping

JSON Schema typeAD4M treatment
string, number, booleanScalar @Property
objectStored as JSON literal (limited semantic querying)
arrayRelation (@HasMany-style), with add*/remove* methods

Arrays automatically get relation helper methods:

const post = new BlogPost(perspective);
post.tags = ["ad4m", "tutorial"];
await post.save();
 
// Relation methods generated from the property name
post.addTags("blockchain");
post.removeTags("tutorial");

Type Safety

Since properties are added dynamically, TypeScript won't know about them at compile time. Use type assertions:

const BlogPost = Ad4mModel.fromJSONSchema(schema) as typeof Ad4mModel & {
  new(perspective: PerspectiveProxy): Ad4mModel & {
    title: string;
    tags: string[];
    metadata: { author: string; publishedAt: string };
  }
};

Limitations

  • No author property: Conflicts with the built-in AD4M author field
  • Required properties: At least one property must be required or have an initial value
  • Complex objects: Nested objects are stored as JSON literals, limiting semantic querying
  • Performance: For better querying, consider flattening complex objects into separate properties

Best Practices

  1. Use Meaningful Predicates: Structure your predicate URIs logically:

    through: "recipe://name"      // Good
    through: "myapp://x"          // Bad
  2. Type Safety: Always define proper TypeScript types:

    @Property({ through: "recipe://servings" })
    servings: number = 0;         // Good
     
    @Property({ through: "recipe://servings" })
    servings: any;               // Bad
  3. Relation Filtering: Use typed target to auto-filter relations:

    @HasMany(() => Review, { through: "recipe://review" })
    reviews: string[] = [];
  4. Query Optimization: Use specific queries instead of filtering in memory:

    // Good - Let the query engine do the filtering server-side
    const topRated = await Recipe.query(perspective)
      .where({ rating: { gt: 4 } })
      .get();
     
    // Bad - Fetching all and filtering in memory
    const all = await Recipe.findAll(perspective);
    const topRated = all.filter(r => r.rating > 4);
  5. Use the options Parameter for Enums: Define constrained choices with sh:in semantics:

    @Property({
      through: "recipe://difficulty",
      options: [
        { value: "easy", label: "Easy" },
        { value: "medium", label: "Medium" },
        { value: "hard", label: "Hard" }
      ]
    })
    difficulty: string = "medium";
  6. Subscriptions: Clean up subscriptions when they're no longer needed:

    const builder = Recipe.query(perspective)
      .where({ status: "active" });
      
    await builder.subscribe(recipes => {
      // Handle updates
    });
     
    // Later...
    builder.dispose();
  7. Use Literals Appropriately:

    // Good - Using literals for simple values (default resolveLanguage)
    @Property({ through: "recipe://name" })
    name: string = "";
     
    // Good - Using specific language for rich content
    @Property({
      through: "recipe://instructions",
      resolveLanguage: "markdown"
    })
    instructions: string = "";
  8. Meaningful IDs:

    // Good - Descriptive ID
    const recipe = new Recipe(perspective, "recipe://chocolate-cake-2024-03");
     
    // Fine - Auto-generated ID (most common)
    const recipe = new Recipe(perspective);

Error Handling

Always handle potential errors when working with models:

try {
  const recipe = new Recipe(perspective);
  recipe.name = "Failed Recipe";
  await recipe.save();
} catch (error) {
  console.error("Failed to save recipe:", error);
  // Handle error appropriately
}