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
addSdnaRPC carries ashaclJsonstring;addShacland model registration sendshape.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
fromTurtleround-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 stringA 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
| Option | Type | Default | Description |
|---|---|---|---|
through | string | (required) | Predicate URI for the link |
required | boolean | false | If true, a placeholder link is created on save even if the value is empty |
readOnly | boolean | false | Prevents updates after creation |
resolveLanguage | string | "literal" | Language used to resolve the stored value. Use "literal" for simple values, or a Language address for richer content |
initial | any | — | Default value written on creation. When required: true with no initial, the framework uses a sentinel value ("literal:string:uninitialized") |
local | boolean | false | Store locally only — not synced to the network. Useful for user preferences or draft state |
getter | string | — | SPARQL SELECT expression for computed values. <Base> is replaced with the instance's URI at query time. Mutually exclusive with regular storage |
transform | NodeExpression | — | 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
| Builder | Produces | Notes |
|---|---|---|
focus() | The current node's value | The input being transformed. Useful as a base case. |
literal(v) | A constant value | Strings, numbers, booleans. |
path('predicate://uri') | Value reached by traversing a predicate from the focus node | One hop. |
exists(expr) | Boolean | Whether expr resolves to a non-empty value. |
ifExpr(cond).then(a).else(b) | a if cond is truthy, otherwise b | All three are themselves NodeExpressions. |
concat(a, b, …) | String concatenation of the arguments | Each argument is a NodeExpression. |
coalesce(a, b, …) | First non-null argument | Argument-order fallback. |
fn('name', …args) | Server-side function call | Used by built-ins like fileToDataUri (using ad4m://fn/defaultFileDecode fn) |
When to use transforms
- Decode wire formats: turn a
FileDatapayload into adata: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
| Decorator | Cardinality | Generated methods | Ownership |
|---|---|---|---|
@HasMany | One-to-many | add*, remove*, set* | Parent owns |
@HasOne | One-to-one | set* (also add*, remove*), or assign and save() | Parent owns |
@BelongsToOne | Many-to-one | Read-only | Child references |
@BelongsToMany | Many-to-many | Read-only | Child 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 itThe 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
targetclass that has a flag or a required property, and does not setfilter: false: the query carries no order, so their order among themselves is unspecified. A collection with no entries at all, such as one written beforeorderingwas 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://sugarEvery 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:45Social 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 Concept | AD4M Equivalent |
|---|---|
sh:NodeShape | Subject Class definition |
sh:PropertyShape | Property or Relation definition |
sh:maxCount 1 | Scalar property (single value, generates set_ tool) |
No sh:maxCount or > 1 | Relation (generates add_/remove_ tools) |
sh:minCount 1 | Required 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:datatype | Value type constraint |
sh:class | Reference to another Subject Class (target model shape) |
sh:node | Parent shape reference (model inheritance) |
ad4m://initial | Default value on instance creation |
ad4m://resolveLanguage | Expression language for value resolution |
ad4m://readOnly | Read-only computed property |
ad4m://getter | SPARQL getter for conformance filtering |
ad4m://conformanceConditions | Structured 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 writtenInstance 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:
| Property | Type | Description |
|---|---|---|
.id | string | Unique URI identifier |
.author | string | DID of the agent who created the earliest link (e.g. "did:key:z6Mk...") |
.createdAt | string | number | Timestamp of the earliest link (epoch ms or ISO string) |
.updatedAt | string | number | Timestamp 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); // 1709136000000Querying
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 | nullWhere 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.
| Form | Example | Matches 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 someagentlink, whatever its value.authorcombines with the other operators:{ note: { contains: "ok", author: admin } }is "admin wrote anotelink containing ok",{ status: { not: "archived", author: admin } }is "admin wrote astatuslink other than archived".- On relations it works the same way:
{ reviews: { author: admin } }is "admin wrote somereviewslink". 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 noagent -> didlink.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 defaultsPer-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?
createdAtis 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
LinkExpressionthatperspective.get()returns (LinkRowin the SDK). That includesproof.valid/proof.invalid, the signature verdict the executor recorded when it stored the link.validistrueonly when the signature verifies againstauthor. Links whose signature did not verify are left out unless the query setsincludeUnverified(see Unverified Links), so by default every row readsvalid: true. WithincludeUnverified, a link stored without a proof arrives with an emptykeyandsignatureandvalid: false.invalidis always!valid(nevernull), as inperspective.get(), soinvalid: truecovers both a failed signature and a link nobody signed; to tell them apart, check whetherproof.signatureis empty. Anything that acts on a row, such as counting a vote or honouring a grant, should still requireproof.valid, so it stays correct when the query setsincludeUnverified.data.targetis the target exactly as it was signed (a literal written in another encoding thanLiteral.from(v).toUrl()keeps its original bytes), and the verdict was computed over those bytes, so verifyingproofyourself 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 plainmembersarray 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__linkskey. - 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, andlinksreads 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,updatedAtor any property value.linksworks withwhere,order, pagination,propertiesand inside anincludesub-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 anincludesub-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 tocreatedAt, 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, rawliteral:json:{"a":1}, or-_.~escaped byexpression.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 setsincludeUnverified. 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:
linkStatus | includeUnverified | Read? |
|---|---|---|
| unset | unset | no (unverified) |
| unset | true | yes |
"local" | unset | no (unverified) |
"local" | true | yes |
"shared" | unset or true | no (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 link | linkStatus unset | "local" | "shared" |
|---|---|---|---|
| Shared only | returned | not returned | returned |
| Local only | returned | returned | not returned |
| both | returned | returned | returned |
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 thrownFor lower-level batch control, see the Batch Operations Guide.
Static Convenience Methods
| Method | Signature | Description |
|---|---|---|
create | create(perspective, data?, options?) | Create and save in one step. options supports parent and batchId |
update | update(perspective, id, data) | Fetch by ID, merge data, save |
delete | delete(perspective, id) | Delete by ID with incoming link cleanup |
remove | remove(perspective, id) | Deprecated — alias for delete |
findAll | findAll(perspective, query?) | Query all matching instances |
findOne | findOne(perspective, query?) | First matching instance or null |
findAllAndCount | findAllAndCount(perspective, query?) | Results + total count |
paginate | paginate(perspective, pageSize, page, query?) | Paginated results |
register | register(perspective) | Install the SHACL schema in the perspective |
transaction | transaction(perspective, fn) | Atomic batch operation |
query | query(perspective) | Returns a ModelQueryBuilder |
getModelMetadata | getModelMetadata() | Introspect property & relation metadata |
generateSDNA | generateSDNA() | Returns the generated Prolog SDNA rules (injected by @Model) |
generateSHACL | generateSHACL() | 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
| Option | Effect |
|---|---|
target: () => Model | Auto-generates conformance filter from model shape |
where: { ... } | Value-based filtering using query DSL |
filter: false | Opt 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:
| Setting | Priority 1 (highest) | Priority 2 | Priority 3 | Priority 4 |
|---|---|---|---|---|
| Class name | options.name | x-ad4m.className | schema.title | schema.$id |
| Namespace | options.namespace | x-ad4m.namespace | schema.title | schema.$id |
| Predicate | propertyMapping[name] | x-ad4m.through | predicateTemplate | predicateGenerator → 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 type | AD4M treatment |
|---|---|
string, number, boolean | Scalar @Property |
object | Stored as JSON literal (limited semantic querying) |
array | Relation (@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
authorproperty: Conflicts with the built-in AD4Mauthorfield - 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
-
Use Meaningful Predicates: Structure your predicate URIs logically:
through: "recipe://name" // Good through: "myapp://x" // Bad -
Type Safety: Always define proper TypeScript types:
@Property({ through: "recipe://servings" }) servings: number = 0; // Good @Property({ through: "recipe://servings" }) servings: any; // Bad -
Relation Filtering: Use typed
targetto auto-filter relations:@HasMany(() => Review, { through: "recipe://review" }) reviews: string[] = []; -
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); -
Use the
optionsParameter for Enums: Define constrained choices withsh:insemantics:@Property({ through: "recipe://difficulty", options: [ { value: "easy", label: "Easy" }, { value: "medium", label: "Medium" }, { value: "hard", label: "Hard" } ] }) difficulty: string = "medium"; -
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(); -
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 = ""; -
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
}