Skip to main content

@happyvertical/smrt-core

License: MIT

Core AI agent framework with standardized collections, object-relational mapping, and code generators in the HAVE SDK.

Overview​

The @happyvertical/smrt-core package provides the foundation for building vertical AI agents. It offers a comprehensive framework with object-relational mapping, AI-powered operations, code generation capabilities, and seamless integration with other HAVE SDK packages.

Key Features​

  • AI-First Object Framework: Objects with built-in AI operations (is(), do(), describe() methods)
  • Object-Relational Mapping: Automatic database schema generation from TypeScript classes
  • Standardized Collections: Advanced CRUD, batch operations, semantic search, and interceptors
  • Code Generation: CLI tools, REST APIs, and MCP servers generated from objects
  • Field System: Type-safe field definitions with validation and constraints
  • Vite Plugin Integration: Virtual modules for automatic service generation
  • AST Scanning: Automatic discovery of SMRT objects in codebases
  • Cross-Package Integration: Unified access to AI, files, database, and web capabilities
  • Vector Embeddings: Generate and manage semantic embeddings for search and similarity

Installation​

# Install with npm
npm install @happyvertical/smrt-core

# Or with yarn
yarn add @happyvertical/smrt-core

# Or with bun
bun add @happyvertical/smrt-core

Usage​

Define SMRT Objects with TypeScript Types​

import { SmrtObject, field, foreignKey, smrt } from '@happyvertical/smrt-core';

// Define objects using TypeScript types (primary pattern)
@smrt({
api: true, // Auto-generate REST API
mcp: true, // Auto-generate MCP tools
cli: true // Auto-generate CLI commands
})
class Document extends SmrtObject {
// TypeScript types → Automatic schema generation
title: string = ''; // → TEXT column
content: string = ''; // → TEXT column
wordCount: number = 0; // → INTEGER (no decimal)
rating: number = 4.5; // → DECIMAL (has decimal)
isPublished: boolean = false; // → BOOLEAN column
tags: string[] = []; // → JSON column
publishedAt: Date = new Date(); // → engine-specific timestamp column

// Use field decorators only when needed for:
// 1. Relationships
@foreignKey(Category)
categoryId: string = '';

// 2. Constraints
@field({ required: true, unique: true })
slug: string = '';

constructor(options: any = {}) {
super(options);
Object.assign(this, options);
}

// AI-powered content validation
async isHighQuality() {
return await this.is(`
- Contains more than 500 words
- Has clear structure and headings
- Uses professional language
`);
}

// AI-powered content transformation
async generateSummary() {
return await this.do(`
Create a 2-sentence summary of this document.
Focus on the key points and main conclusions.
`);
}
}

When to Use Field Decorators​

Use TypeScript types for most properties (preferred):

name: string = ''; // → TEXT
count: number = 0; // → INTEGER (no decimal point)
price: number = 0.0; // → DECIMAL (has decimal point)
active: boolean = true; // → BOOLEAN
created: Date = new Date(); // → engine-specific timestamp
tags: string[] = []; // → JSON

Add a decorator only when you need something the type cannot express:

  1. Relationships: @foreignKey(Category) above categoryId = '';
  2. Constraints: @field({ required: true, pattern: /^.+@.+$/ }) above email = '';
  3. Nullable columns: @field({ nullable: true }) above latitude: number | null = null;

The field decorators exported by @happyvertical/smrt-core are field, foreignKey, crossPackageRef, oneToMany, manyToMany, and meta (the class decorator smrt and the method decorator method come from the same package). There are no per-type helper functions — text(), integer(), decimal(), datetime(), and json() were removed in #318 and do not exist in any current version.

The 0 vs 0.0 Heuristic:

  • quantity: number = 0 → INTEGER column (no decimal point)
  • price: number = 0.0 → DECIMAL column (has decimal point)
  • rating: number = 4.5 → DECIMAL column (has decimal point)

Date and PostgreSQL timezone semantics​

SMRT persists JavaScript Date values as instants. PostgreSQL schemas therefore use TIMESTAMPTZ for manifest-backed models and framework system tables; SQLite and JSON retain their existing adapter-specific representations. Create, strict insert, upsert/update, save, and hydration all round-trip the same epoch regardless of the Node or PostgreSQL session timezone. Executable registry DDL must name its target engine, for example ObjectRegistry.getSchemaDDL('Event', 'postgres') or ObjectRegistry.getAllSchemas('postgres'). The one-argument registry forms are retained for compatibility with code that inspects cached manifests; their engine-neutral ddl must not be executed on PostgreSQL. More generally, a manifest's schema.ddl is a CREATE TABLE preview with no indexes and abstract types; db:migrate, MigrationGenerator, SchemaAggregator, and createIsolatedTestDbFromManifest all render the structured schema.columns and schema.indexes through the engine DDL strategy instead (#2358).

Schemas created before SMRT adopted TIMESTAMPTZ may contain PostgreSQL TIMESTAMP columns. By default SMRT reports these as manual drift and does not reinterpret them. After auditing DB defaults, triggers, raw SQL, and every historical writer, an operator may opt into the UTC conversion with migrateSmrtSchemas({ ..., postgresTimestampMigration: { legacyTimezone: 'UTC' } }). That migration uses column AT TIME ZONE 'UTC' because the SQL adapter wrote UTC ISO strings and the old column discarded only their offset, and it also fails unless the PostgreSQL migration session is UTC. If any writer used local wall time, use a provenance-aware application migration with that source timezone instead. Legacy framework system tables are never converted automatically: after the same historical-writer audit, an operator may explicitly call migratePostgresSystemTimestamps(db, { legacyTimezone: 'UTC' }). Databases with any non-UTC system-table writer require an application-owned, provenance-aware migration instead. Type upgrades do not receive an automatic down migration; rollback is a restore from the pre-migration backup or a reviewed forward-fix after confirming the original writer's timezone contract.

Create and Manage Collections​

import { SmrtCollection } from '@happyvertical/smrt-core';

class DocumentCollection extends SmrtCollection<Document> {
static readonly _itemClass = Document;

constructor(options: any = {}) {
super({
db: { url: 'documents.sqlite', type: 'sqlite' },
ai: { provider: 'openai', apiKey: process.env.OPENAI_API_KEY },
...options
});
}

// Custom query methods
async findPublished() {
return this.list({
where: { isPublished: true },
orderBy: 'publishedAt DESC'
});
}

// Advanced filtering with AI
async findByQuality(qualityCriteria: string) {
const docs = await this.list({});
const qualityDocs = [];

for (const doc of docs) {
if (await doc.is(qualityCriteria)) {
qualityDocs.push(doc);
}
}

return qualityDocs;
}
}

Initialize and Use the System​

// Create collection instance
const documents = new DocumentCollection();
await documents.initialize();

// Create and save a document
const doc = documents.create({
title: 'AI Agent Development Guide',
content: 'This guide covers the fundamentals of building AI agents...',
wordCount: 1250
});
await doc.save();

// Advanced querying with operators
const recentDocs = await documents.list({
where: {
'wordCount >': 1000,
'publishedAt >': '2024-01-01',
'title like': '%AI%'
},
limit: 10,
orderBy: ['wordCount DESC', 'publishedAt DESC']
});

// Use AI-powered operations
const isQuality = await doc.isHighQuality();
const summary = await doc.generateSummary();

AI-Powered Methods​

SMRT objects include built-in AI operations that enable intelligent content analysis and transformation. These methods leverage the configured AI provider to perform natural language tasks.

is(criteria) - Evaluate Criteria​

Evaluates whether the object meets specified criteria using AI analysis. Returns a boolean.

// Check if content meets quality standards
const isHighQuality = await document.is(`
- Contains more than 500 words
- Has clear structure and headings
- Uses professional language
`);

// Validate data conditions
const isValid = await order.is("Has all required fields populated and total > 0");

// Check content characteristics
const isTechnical = await article.is("Contains technical programming content");

evaluate(criteria) - Detailed Decision Evaluation​

evaluate() is an additive detailed alternative to is(). Without a typed decision provider it uses the same generative route as is(), but returns a strict { result, route } object. When smrt.decisions is configured with the optional TypeSafe provider, it returns the finite predicate probability, provider/model provenance, and provider-reported token usage when available. is() remains compatible for existing callers and returns only the final boolean on a configured decision route.

const evaluation = await document.evaluate('is appropriate for a general audience');
if (evaluation.result) {
console.log(evaluation.probability, evaluation.provenance);
}

Configure decisions separately from ai: this never replaces the generative client used by do() or describe().

import { defineConfig } from '@happyvertical/smrt-config';

export default defineConfig({
smrt: {
decisions: {
type: 'typesafe',
apiKey: process.env.TYPESAFE_API_KEY,
defaultModel: 'jev-latest',
threshold: 0.5,
// Optional: only probabilities within this inclusive distance of the
// threshold use the ordinary generative route as a tie-break.
uncertaintyFallback: { band: 0.05 },
},
},
});

The model option passed to evaluate(criteria, options) selects the typed decision model only. When a registered tool or an explicit uncertainty fallback uses the generative route, select that route's model separately with generativeModel.

Thresholds and uncertainty bands must be finite values in [0, 1]. A probability equal to the threshold is true. The uncertainty tie-break is explicit: a confident false remains false, and transport, authentication, or malformed-provider failures reject instead of becoming false or activating a fallback. Objects with registered AI tools use the generative route so tools are never silently dropped. includeData and maxDataLength continue to use the public, sensitive-field-excluding object representation on both routes.

Bounded Choice, Score, and Batch Decisions​

Domain objects and collections can use the protected attemptDecision() helper for a choice, score, or several independent questions over one explicitly curated JSON state. It returns undefined only when no decision client is configured; configured capability, cancellation, timeout, provider, and malformed-result errors reject. A batch emits one decision usage event.

const decision = await this.attemptDecision(
{
state: { title: this.title, labels: this.labels },
questions: {
sentiment: {
type: 'choice',
instructions: resolvedPrompt.text,
criteria: { positive: null, neutral: null, negative: null },
},
},
},
{ model: 'jev-latest', signal, timeout: 1_000 },
);

DecisionRequest, DecisionOptions, and DecisionResult are re-exported SDK types; SMRT does not define a provider wire format. Resolve registered prompt instructions and tenant overrides before building instructions, and keep prompt generation options separate from DecisionOptions. Object methods with registered tools must retain their generative route before attempting a typed decision.

For a representative-data pilot, record labeled cases, legacy and decision answers, ambiguity, failures, and usage/provenance telemetry. Contract tests do not measure model quality; live results require credentials and labeled data.

do(instructions) - Execute Instructions​

Executes AI-powered instructions on the object and returns the result. Use this for content transformation, analysis, and generation tasks.

// Generate a summary
const summary = await document.do(`
Create a 2-sentence summary of this document.
Focus on the key points and main conclusions.
`);

// Transform content
const bullets = await article.do("Convert the main points into a bulleted list");

// Extract information
const entities = await content.do("Extract all mentioned company names as a JSON array");

// Analyze sentiment
const analysis = await review.do("Analyze the sentiment and provide a score from 1-10");

describe() - Generate Description​

Generates a natural language description of the object based on its content. Returns a string description.

// Generate a product description
const description = await product.describe();
// Returns: "A high-performance wireless mouse with ergonomic design..."

// Use in content pipelines
const items = await collection.list({ where: { description: '' } });
for (const item of items) {
item.description = await item.describe();
await item.save();
}

AI Method Best Practices​

  1. Be specific with criteria: Provide clear, measurable conditions for is() calls
  2. Use structured prompts: Break complex instructions into bullet points for do() calls
  3. Cache expensive results: Store AI-generated content in object properties to avoid repeated calls
  4. Handle errors gracefully: AI methods may throw errors if the provider is unavailable
class Article extends SmrtObject {
summary: string = '';

// Cache AI-generated summary
async getSummary() {
if (!this.summary) {
this.summary = await this.do('Summarize in 2-3 sentences');
await this.save();
}
return this.summary;
}

// Combine AI methods for complex workflows
async processForPublication() {
const isReady = await this.is('Has title, content > 300 words, and proper formatting');
if (!isReady) {
throw new Error('Article not ready for publication');
}

this.description = await this.describe();
this.summary = await this.do('Create an engaging meta description under 160 characters');
await this.save();
}
}

Single Table Inheritance (STI)​

SMRT supports Single Table Inheritance where multiple classes share one database table:

Basic STI Configuration​

import { SmrtObject, smrt } from '@happyvertical/smrt-core';

// Base class with STI strategy
@smrt({ tableStrategy: 'sti' })
class Event extends SmrtObject {
title: string = '';
date: Date = new Date();
location: string = '';
}

// Child classes automatically inherit STI
@smrt()
class Meeting extends Event {
roomNumber: string = '';
attendees: string[] = [];
}

@smrt()
class Conference extends Event {
sponsorName: string = '';
ticketPrice: number = 0.0;
sessions: number = 0;
}

How STI Works​

  • Single Table: All classes in the hierarchy share the events table
  • Discriminator Column: _meta_type identifies the actual class type
  • Field Storage:
    • Base class fields → Regular columns
    • Child-specific fields → Nullable regular columns (the union of the hierarchy)
    • Fields declared with meta() → JSON _meta_data column

A subclass can declare @smrt({ collection: 'holidays', conflictColumns: ['tenant_id', 'date', 'name'] }) while sharing its root's table. The collection sets its generated route directory and permission prefix; without an explicit collection it keeps the root's collection. The child conflict key gets a partial unique index restricted to its qualified _meta_type, and the root's conflict index excludes children with their own keys. Distinct child natural keys can therefore share a slug. An empty child key inherits the root key. Explicit keys must include tenant ownership columns. Run normal schema migrations before using the new keys on an existing table; the index comparison replaces the old root index and adds the child index. Existing duplicate child keys must be repaired before that migration succeeds. Stop old writers for the index migration, then load the complete consumer manifest on every writer of the shared table before resuming. Subclass conflict keys require PostgreSQL or SQLite; DuckDB and the JSON adapter cannot enforce partial unique indexes and reject this schema.

Change-feed sensitivity remains a property of the shared table. An STI hierarchy must declare sensitive: true on every class, or omit it on every class. Mixed sensitivity fails with CONFIG_STI_MIXED_SENSITIVITY during manifest generation or registration. Use separate tables for public and sensitive models. Uniformly sensitive hierarchies preserve the existing write, historical-read and signal exclusions; sensitive: false never reopens an already sensitive table.

Polymorphic Queries​

const eventCollection = await EventCollection.create(options);

// Create different event types
const meeting = await eventCollection.create({
_meta_type: 'Meeting',
title: 'Daily Standup',
roomNumber: '101',
attendees: ['Alice', 'Bob']
});

const conference = await eventCollection.create({
_meta_type: 'Conference',
title: 'Tech Summit',
sponsorName: 'TechCorp',
ticketPrice: 299.00
});

// Query returns mixed types!
const allEvents = await eventCollection.list();

for (const event of allEvents) {
console.log(event.constructor.name); // 'Meeting' or 'Conference'

if (event instanceof Meeting) {
console.log(`Room: ${event.roomNumber}`);
} else if (event instanceof Conference) {
console.log(`Price: ${event.ticketPrice}`);
}
}

// Filter by type
const meetings = await eventCollection.list({
where: { _meta_type: 'Meeting' }
});

Multi-Level Inheritance​

// Level 1: Base class
@smrt({ tableStrategy: 'sti' })
class Content extends SmrtObject {
title: string = '';
body: string = '';
}

// Level 2: Extends Content
@smrt()
class Article extends Content {
author: string = '';
publishedDate: Date = new Date();
}

// Level 3: Extends Article
@smrt()
class NewsArticle extends Article {
newsCategory: string = '';
breakingNews: boolean = false;
}

// All three classes share the 'contents' table
const article = new NewsArticle({
title: 'Breaking News', // From Content
body: 'Story details...', // From Content
author: 'Jane Smith', // From Article
publishedDate: new Date(), // From Article
newsCategory: 'Technology', // From NewsArticle
breakingNews: true // From NewsArticle
});

When to Use STI​

✅ Use STI when:

  • Classes share most fields (80%+ overlap)
  • Need polymorphic queries (mixed types in one query)
  • Frequent queries across the hierarchy
  • Small number of subclass-specific fields

❌ Avoid STI when:

  • Many unique fields per subclass (sparse columns)
  • Deep hierarchies (3+ levels)
  • Subclasses need different indexing strategies

Code Generation​

SMRT generates CLI, REST, and MCP surfaces from @smrt() objects in two ways:

  • Build-time, via the Vite plugin (smrtPlugin(), see Vite Plugin Integration below) — scans decorated classes and emits virtual modules (routes, typed client, MCP tool definitions, manifest) consumed by the generated SvelteKit routes and @happyvertical/smrt-web. This is how a typical SMRT application ships REST/MCP today.
  • Runtime, via the generator classes below (@happyvertical/smrt-core/generators), which read whatever @smrt() objects are currently registered on ObjectRegistry and serve them directly — for a host process that wants REST/MCP without a Vite build. None of these classes accept a collections list; they discover registered objects themselves, and (MCPGenerator.generateServer() aside) none of them write files to disk.

CLI generation does not live here. @happyvertical/smrt-core shipped an in-process CLIGenerator/setupCLI() (plus the @happyvertical/smrt-virt-cli Vite virtual module) for admin objectname:action commands, but it had zero consumers and was retired (#2664). Two live CLI transports remain, both outside this package:

  • The local smrt <object>:<action> binary (packages/cli/src/cli-generator.ts), against a database connection from smrt.config.
  • @happyvertical/smrt-app-cli, a distributable command-line application that discovers commands over authenticated HTTP from a running app.

REST API Generation​

APIGenerator serves REST for whatever @smrt() objects are registered. APIConfig is { basePath?, enableCors?, allowedOrigins?, allowCredentials?, customRoutes?, authMiddleware?, port?, hostname?, eventsRoute?, manifestHash?, playbookPreflight? } — there is no collections, outputDir, includeSwagger, or middleware option, and no generate() method.

import { APIGenerator, startRestServer } from '@happyvertical/smrt-core/generators';

// Low-level: a Web-standard fetch handler you mount yourself
const generator = new APIGenerator({ basePath: '/api/v1' });
const handler = generator.generateHandler(); // (req: Request) => Promise<Response>

// Or registerCollection() + a batteries-included Node http.Server:
generator.registerCollection('products', productCollection);
const { server, url } = generator.createServer();

// Or the startRestServer() convenience wrapper: warns for any of the passed
// classes not already registered by their own @smrt() decorator (it does not
// register them itself), starts a Node http.Server, and resolves a
// graceful-shutdown function.
const shutdown = await startRestServer([Product, Order], { db }, { basePath: '/api/v1' });

OpenAPI documentation is a separate concern, generated by generateOpenAPISpec() (@happyvertical/smrt-core/generators/swagger) rather than an includeSwagger option on APIGenerator.

Which methods get a route​

A public method becomes a custom REST action by default when it is wire-able: every parameter can be built from a JSON request body or a query string. This is a gate on the signature, not on intent — a method that takes a live model instance has no route because no HTTP caller could ever invoke it.

Wire-able parameter types:

  • primitives and literal types (string, number, boolean, 'a' | 'b');
  • any, unknown, and an inline object literal ({ limit?: number });
  • arrays, Record<…>, Partial<…>, Pick<…>, Omit<…>;
  • a named interface or type alias — accepted heuristically, since resolving it needs type information the AST scanner does not have;
  • Date, which generated handlers and the runtime REST transport hydrate from an ISO string or epoch number;
  • a union with at least one wire-able branch — addReference(c: Content | string) is routed, because the string branch already accepts an id.

NOT wire-able, so not routed by default:

  • a manifest model class instance (addAsset(asset: Asset));
  • a function or callback, including one nested inside an inline object literal;
  • a stream, buffer, Request/Response, Map/Set, or similar runtime value;
  • a bare type parameter (T);
  • a rest parameter, which no transport can name in a body;
  • a type the scanner could not resolve — an intersection, tuple, conditional, mapped, typeof, or indexed-access type. This fails closed: the manifest records the parameter as any for compatibility, so the gate reads separate provenance rather than trusting that any.

CRUD verbs and framework lifecycle methods (save, initialize, toJSON, …) are never custom actions, even when a subclass declares its own override — they are the mechanism behind generated CRUD, matching what the CLI and MCP surfaces already do.

Every withheld method is reported with its reason in .smrt/smrt-knowledge.json under withheldSurfaces, so a missing route is explained rather than silent.

Compatibility, precisely. A method named in api.include, or carrying an api.routes[method] entry, is an explicit declaration made before this gate existed and bypasses the heuristic entirely — those routes are preserved unchanged. A default-routed method (public, no declaration) can lose its route on signature, and across this monorepo 88 did. Audit before upgrading: every withheld method is listed with its reason in .smrt/smrt-knowledge.json under withheldSurfaces, and @method({ expose: true }) restores any one of them.

One case fails the build rather than the request: cli.include naming a method the heuristic now withholds trips the cli↔api coherence check, because the CLI would be advertising a command whose route no longer exists. The error lists its remedies; @method({ expose: true }) is usually the one you want, since it keeps the route without widening api.include.

The @method() decorator​

@method() refines how a method is exposed, the way @field() refines how a property is stored. Neither declares its member: a property is a field because it is a property, and a public method is a candidate action because it is public. Both refine what the framework already inferred.

import { method, smrt, SmrtObject } from '@happyvertical/smrt-core';

@smrt()
class Content extends SmrtObject {
@method({ httpMethod: 'POST', path: 'reviews', effect: 'write' })
async runReview(options?: RunContentReviewOptions) { /* … */ }

@method({ expose: false, reason: 'callback registration, not a wire operation' })
static registerValidator(validator: ValidatorFunction) { /* … */ }

// The heuristic rejects this; force it back on and accept the JSON yourself.
@method({ expose: true })
async addAsset(asset: Asset | string) { /* … */ }
}
optionmeaningmigrates from
exposefalse withholds a method the heuristic accepted; true exposes one it rejected—
reasonwhy it is withheld; surfaced by the knowledge artifact and smrt doctor—
httpMethod'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'api.routes[m].method
pathroute path segment(s)api.routes[m].path
scope'item' | 'collection'api.routes[m].scope
effect / idempotent / openWorldtool semanticsapi.routes[m]
descriptionAI/tool descriptionai.descriptions[m]

Rules worth knowing:

  • expose: true bypasses the heuristic and nothing else. It cannot undo api: false, cross an include/exclude boundary, reach a non-public method, claim a CRUD verb, or manufacture a receiver — and it does not hydrate a value the transport cannot build. A forced addAsset(asset: Asset) receives whatever JSON the caller sent.
  • expose: false outranks everything else, including a legacy api.routes entry or an api.include listing for the same method.
  • Merging is field by field. @method({ description }) on a class that already declares routes: { m: { method: 'POST', path: 'x' } } overrides only the description; the verb and path survive.
  • scope is a declaration, not a relocation. The executable receiver decides: an instance method is item-scoped, a static or collection-class method is collection-scoped. A contradicting scope is ignored and reported at build time.
  • api.routes and ai.descriptions keep working unchanged. Where both are present, @method() wins.
  • The runtime REST transport is declaration-gated. APIGenerator (the standalone fetch handler, as opposed to the generated SvelteKit routes) serves a custom collection action only where one was DECLARED — historically an api.routes entry, and now also a @method() carrying any option that migrates from that map (httpMethod, path, scope, effect, idempotent, openWorld) or an explicit expose: true. A bare @method() or a description-only one does not declare a route there, because neither migrates from api.routes. Browser-plane preflight predicts exactly this gate, and honors expose: false with it — a withheld action is predicted unroutable, and a request to its declared URL gets an explicit 404 rather than falling through into a create. The same refusal covers an ITEM-scoped declaration, which this transport does not serve at all: POST /<collection>/<action> for one answers 404, not a silent create. The generated SvelteKit surface serves those under [id].

Date hydration is top-level only. A parameter declared exactly Date (or Date | null / Date | undefined) is converted from its ISO string or epoch number before the method is called. Date | string is left alone — that signature already accepts the string a caller sends. A Date nested inside a named options bag is invisible to the manifest and is not hydrated; such a method must accept the string itself.

Caching Headers (Conditional GET)​

Generated read routes (list and get) support conditional GET out of the box, on both the REST generator and the generated SvelteKit routes:

  • Every 200 read response carries a strong ETag — a SHA-256 hash of the serialized JSON body — plus a Cache-Control header.
  • A request whose If-None-Match matches the current ETag is answered 304 Not Modified with an empty body, saving transfer, parse, and re-render. (v1 still runs the query; a later slice derives the ETag from the change feed for zero-query 304s.)
  • Any mutation that changes the serialized data changes the ETag, so stale validators revalidate to a fresh 200.

The Cache-Control policy is resolved from the object's @smrt({ api }) config:

Model configRead Cache-Control
Default (any non-public model)private, no-cache — browsers may store but must revalidate; shared caches never store
api: { public: true } (no cache opt-in)private, no-cache
api: { public: true | 'read', cache: { sMaxage: n } }public, max-age=0, s-maxage=n — CDNs serve for n seconds; browsers still revalidate
Tenant-scoped model (@smrt({ tenantScoped }) or @TenantScoped(), any mode)private, no-cache — always; sMaxage is ignored with a one-time warning
// Opt a genuinely public model into CDN caching for 5 minutes:
@smrt({ api: { public: 'read', cache: { sMaxage: 300 } } })
export class Article extends SmrtObject { /* ... */ }

Notes:

  • api.cache.sMaxage is only honored when reads are public (public: true or public: 'read'). Non-public models never emit shared-cache headers, even if sMaxage is configured.
  • Tenant-scoped models never emit shared-cache headers (any mode, including 'optional'): the response body varies with the tenant context resolved from the session cookie, which URL-keyed shared caches cannot see — honoring sMaxage would serve one tenant's rows to other tenants or anonymous visitors. The knob is neutralized to private, no-cache and a one-time warning is logged.
  • max-age=0 keeps browsers revalidating (cheap 304s), so end users see edits immediately while shared caches absorb anonymous traffic.
  • Reserve the opt-in for responses that are identical for every requester; it is distinct from the top-level @smrt({ cache }) knob, which configures the server-side collection read-through cache.
  • Mutations (create/update/delete) and custom action routes are unaffected.

Tenant-Scoped Public Reads Fail Closed to Global Rows​

A tenant-scoped model (@smrt({ tenantScoped }) or @TenantScoped()) that is also marked api: { public: true | 'read' } can be read without authentication. Such a request carries no tenant context, so — left unguarded — the generated read would return rows from every tenant to an anonymous caller.

Generated reads fail closed instead. When tenancy is enabled but no tenant context is active, list, get /:id, and count are restricted to NULL-tenant (global) rows only on both transports (the REST generator and the generated SvelteKit routes), mirroring the dispatch resolver and _changes convention: tenancy enforced with no context → global rows only. A client-supplied ?tenantId= / ?tenant_id= query param cannot widen the scope.

Request against a tenant-scoped public modelRows returned
No tenant context (anonymous), tenancy enabledNULL-tenant (global) rows only
Active tenant context (authenticated)that tenant's rows (interceptor-filtered)
Tenancy disabledall rows (no isolation to enforce)

Registering the tenantScoped + api.public combination logs a one-time warning so the global-only behavior is not mistaken for a broken endpoint. If per-tenant public reads are intended, resolve a tenant from the request itself (host, subdomain, or path) and enter that tenant context before the read. This scoping applies to reads only; mutations always require authentication.

MCP Server Generation​

MCPGenerator exposes registered objects as MCP tools. MCPConfig is { name?, version?, description?, cache? } — there is no collections, outputDir, or tools constructor option; the tool set is always derived from ObjectRegistry, not passed in.

import { MCPGenerator } from '@happyvertical/smrt-core/generators';

const generator = new MCPGenerator({ name: 'my-app-mcp', version: '1.0.0' });

// In-process: list tools and handle a JSON-RPC-style call directly
const tools = await generator.generateTools();
const response = await generator.handleToolCall(jsonRpcRequest);

// Or write a runnable stdio server to disk — the only generator that does:
await generator.generateServer({
outputPath: '.smrt/mcp-server/index.js',
serverName: 'my-app-mcp',
generateClaudeConfigFile: true,
});

Generated tools/list results are sorted by tool name and advertise a one-day private cache lifetime. Only a reviewed global, unauthenticated catalog may opt into shared caching, and the opt-in is rejected for a server that exposes tenant-scoped tools:

const generator = new MCPGenerator({
cache: {
toolsList: { cacheScope: 'public', publicCatalog: true },
},
});

SMRT Development MCP​

For the end-to-end workflow (install, the calls that matter in order, provenance labels, live-database and HTTP options, CLI equivalents) see Developing a s-m-r-t app with an agent.

Use @happyvertical/smrt-dev-mcp for development-time code generation, project introspection, ecosystem review, architecture context, and deterministic SMRT knowledge. Runtime tool generation remains available from @happyvertical/smrt-core/generators/mcp, while application-level MCP policy belongs to @happyvertical/smrt-app-mcp.

Configure the development server in your .mcp.json file:

{
"mcpServers": {
"smrt-dev-mcp": {
"type": "stdio",
"command": "npx",
"args": [
"-y",
"@happyvertical/smrt-dev-mcp"
],
"env": {
"DEBUG": "false"
}
}
}
}

After restarting your MCP client, use generate-smrt-class for model scaffolding, introspect-project for object and schema discovery, and build-context with task: "review" for file-anchored findings, package hints, and a review prompt bundle (smrt-review remains as a deprecated name for one release).

The same server also carries an optional runtime plane, labelled separately from static facts. With SMRT_DEV_DB_URL (or cli.database config) set, migration-status, job-health, schedule-health, dispatch-health, recent-changes, and registry-drift read the _smrt_* system tables with SELECT-only queries and redacted output. runtime-registry, runtime-object, and runtime-schema-diff boot the project's manifests into an in-process registry (no project code is imported) and report a sanitized snapshot, per-object DDL, and a live schema diff. smrt-dev-mcp --http serves exactly that read-only runtime catalog over stateless Streamable HTTP on loopback with a bearer token; it never exposes generated CRUD, custom actions, or do(). See the package README for parameters and the security boundary.

Former source-checkout helper tool names are not exposed as compatibility aliases. When using generate-smrt-class to scaffold a collection, add its item import and required _itemClass; for curl examples, use the public REST or OpenAPI generator output.

Vite Plugin Integration​

Use the Vite plugin for automatic service generation during development:

// vite.config.js
import { smrtPlugin } from '@happyvertical/smrt-core/vite-plugin';

export default {
plugins: [
smrtPlugin({
include: ['src/**/*.ts'],
exclude: ['**/*.test.ts'],
generateTypes: true,
hmr: true
})
]
};

// Access virtual modules in your code:
import { setupRoutes } from '@smrt/routes'; // REST routes
import { createClient } from '@smrt/client'; // API client
import { tools } from '@smrt/mcp'; // MCP tools
import { manifest } from '@smrt/manifest'; // Object manifest

Field Types​

The field system provides type-safe database schema generation:

import {
SmrtObject,
field,
foreignKey,
oneToMany,
manyToMany,
} from '@happyvertical/smrt-core';

class Product extends SmrtObject {
@field({ required: true, maxLength: 100 })
name: string = '';

@field({ min: 0, required: true })
price: number = 0.0;

inStock: boolean = true;
tags: string[] = [];

@field({ required: true })
createdAt: Date = new Date();

// Relationships
@foreignKey(Category)
categoryId: string = '';

@oneToMany(Review)
reviews: Review[] = [];

@manyToMany(Product)
relatedProducts: Product[] = [];
}

Adding a required column to a table that already has rows​

smrt db:migrate adds a new @field({ required: true }) column to a populated table only when it can give every existing row a value first:

  • A default fills existing rows with that constant.
  • A backfill fills them per row. It is a SQL expression, evaluated once per existing row inside the migration transaction, and it may reference the row's other columns by their snake_case names:
@smrt({ conflictColumns: ['openKey'] })
class Punch extends SmrtObject {
@field({ required: true, maxLength: 80, backfill: "'closed:' || id" })
openKey: string = '';
}

The migration adds the column, runs UPDATE punches SET open_key = ('closed:' || id) WHERE open_key IS NULL, enforces NOT NULL, and then builds the conflictColumns unique index over the filled values. All of it runs in one migration unit. SQLite cannot add or tighten NOT NULL in place, so there the column is added by a table rebuild whose copy step evaluates the expression. When a field declares both, the backfill fills existing rows and the default applies only to later inserts. A constant default alone would give every existing row the same value and collide with a unique index.

If there is neither, or if the backfill yields NULL for some row or fails to evaluate, db:migrate exits 1 and names the table and column. It does not add the column, and it holds back any index or foreign key on that column. Nothing is left half-applied. A column that an older release already added as nullable is repaired the same way: the backfill fills its NULLs, and then NOT NULL is enforced.

Write the backfill in SQL that every engine you run accepts (||, CASE, COALESCE, CAST). It must be a string literal, because the build-time scanner reads it from source. A backfill is not a default: the model still has to supply the value on every insert.

Foreign-key delete actions​

@foreignKey(Target, { onDelete }) sets the constraint's ON DELETE action (CASCADE, SET NULL, RESTRICT or NO ACTION). Without one, a column listed in conflictColumns defaults to CASCADE and any other reference to NO ACTION. A declared action always wins, including on a conflictColumns column, and the build-time manifest carries it.

When you change the action of an existing relationship, db:migrate on PostgreSQL replaces the constraint in the same transaction: it drops it, adds it back NOT VALID with the new action, and validates it. It does this only for a constraint SMRT created: the canonical <table>_<column>_<target>_<target column>_fkey name, one column, MATCH SIMPLE, not deferrable, already validated. db:diff lists it under "Foreign keys" with the old and new action. Any other constraint on that column is left for you to drop, and db:diff shows it as foreign-key drift needing a manual step.

Advanced Querying​

Collections support flexible querying with multiple operators:

const results = await collection.list({
where: {
'price >': 10, // Greater than
'price <=': 100, // Less than or equal
'name like': '%widget%', // Pattern matching
'category in': ['A', 'B'], // IN operator
'active': true, // Equals (default)
'deleted_at !=': null // Not equals
},
orderBy: ['price DESC', 'name ASC'],
limit: 20,
offset: 0
});

// Count records with same filtering
const total = await collection.count({
where: { 'price >': 50 }
});

Query bounds​

limit and offset must be non-negative integers; anything else (NaN, a negative, a fraction) is rejected as a client error rather than bound into the query. orderBy terms are validated against the model's fields and refused for @field({ sensitive: true }) and @field({ readPermission }) columns — the same rail where and select use, because ordering by a value you may not read is an oracle over it — and for fields that exist but have no column (oneToMany/manyToMany/@meta()/transient).

list() applies no default page size, because relationship, junction and listByIds() callers rely on receiving every matching row. Opt into bounds per collection when a collection's reads should always be paged:

const products = await Products.create({
db,
defaultListLimit: 50, // used when a caller passes no limit
maxListLimit: 500, // every limit is clamped down to this
});

The generated REST, SvelteKit and MCP surfaces always bound their own input: they default to 50, clamp to 1000, reject malformed values with a 400, and page with a deterministic ORDER BY created_at DESC, <primary key> ASC (id unless the model declares its own @field({ primaryKey: true }) column).

Eager Loading (Preventing N+1 Queries)​

SMRT supports eager loading to optimize queries that access related objects, solving the common "N+1 query problem":

// Define relationships
class Order extends SmrtObject {
@foreignKey(Customer)
customerId: string = '';

@foreignKey(Product)
productId: string = '';

status: string = 'pending';
}

// ❌ Without eager loading: N+1 queries (slow)
const orders = await orderCollection.list({ limit: 100 });
for (const order of orders) {
const customer = await order.loadRelated('customerId'); // 100 separate queries!
console.log(customer.name);
}

// ✅ With eager loading: Single query (fast)
const orders = await orderCollection.list({
limit: 100,
include: ['customerId', 'productId'] // Pre-load relationships
});

for (const order of orders) {
const customer = order.getRelated('customerId'); // Already loaded!
console.log(customer.name);
}

Performance Impact: 40-70% faster for relationship-heavy queries

How it works:

  • SQL adapters: Generates efficient LEFT JOIN queries
  • REST adapters: Uses batch loading for related objects
  • Only works with foreignKey relationships
  • Access pre-loaded data with object.getRelated(fieldName)

For more details, see the package AGENTS.md documentation.

Direct SQL Access​

All SMRT objects have public db property for direct database access via @happyvertical/sql. This enables custom queries, transactions, and advanced database operations:

import { field, SmrtObject, SmrtCollection } from '@happyvertical/smrt-core';

class Product extends SmrtObject {
@field({ required: true })
name = '';

@field({ required: true })
price = 0.0;

@field({ required: true })
category = '';
}

const products = await ProductCollection.create({ db: 'products.db' });

// Direct SQL queries
const expensive = await products.db.many`
SELECT * FROM products
WHERE price > ${100}
ORDER BY price DESC
LIMIT 10
`;

// Execute custom updates
await products.db.execute`
UPDATE products
SET price = price * 0.9
WHERE category = ${'electronics'}
`;

// Check query results
const count = await products.db.pluck`
SELECT COUNT(*) FROM products WHERE price > ${50}
`;

// Use template literals for safe parameterization
const category = 'books';
const results = await products.db.query`
SELECT * FROM products WHERE category = ${category}
`;

Key Benefits:

  • Direct database access: Use any SQL query, not limited to ORM methods
  • Template literal safety: Automatic SQL injection protection via tagged templates
  • Full @happyvertical/sql power: Access all DatabaseInterface methods (many, single, pluck, execute)
  • Transaction support: Use db.transaction() for atomic operations
  • Performance: Direct queries can be more efficient for complex operations

Configuration Options:

// String shortcut (auto-detects database type)
const collection = await ProductCollection.create({
db: 'products.db'
});

// Config object (explicit type)
const collection = await ProductCollection.create({
db: {
type: 'sqlite',
url: 'products.db'
}
});

// DatabaseInterface instance (pre-configured)
import { getDatabase } from '@happyvertical/sql';
const db = await getDatabase({ type: 'postgres', url: 'postgres://...' });
const collection = await ProductCollection.create({ db });

PostgreSQL runtime timeouts​

Every PostgreSQL pool SMRT opens is bounded by default. Without these, pg waits forever for a free client, a runaway query holds its connection until it finishes, and a request that stalls mid-transaction holds its locks until the process exits.

KeyApplied asDefaultEffect
connectionTimeoutconnectionTimeoutMillis pool option10sFail an acquisition instead of queueing forever
statementTimeoutstatement_timeout30sCancel a single runaway statement
idleInTransactionSessionTimeoutidle_in_transaction_session_timeout60sKill a transaction left open between statements, releasing its locks
lockTimeoutlock_timeout10sStop waiting for a lock someone else holds

Values are milliseconds or a duration string ('250ms', '30s', '2min', '1h') — the same spelling migrations.postgres.* uses. 0 disables a timeout explicitly. An unparseable value falls back to that key's default.

// Per connection
const collection = await ProductCollection.create({
db: {
type: 'postgres',
url: process.env.DATABASE_URL,
timeouts: { statementTimeout: '10s', lockTimeout: '2s' },
},
});

Deployments that only hand SMRT a connection URL configure the same four keys through the environment: SMRT_PG_CONNECTION_TIMEOUT, SMRT_PG_STATEMENT_TIMEOUT, SMRT_PG_IDLE_IN_TRANSACTION_TIMEOUT, and SMRT_PG_LOCK_TIMEOUT. Precedence is: a parameter already spelled into the connection URL, then timeouts, then the environment variable, then the default — so postgres://…/app?statement_timeout=120000 keeps its 120 s while still picking up bounded lock and idle-in-transaction settings. A connectionTimeoutMillis spelled directly on the config ranks just below timeouts.connectionTimeout, above the environment variable.

Pool options and pool identity​

max, connectionTimeoutMillis, and idleTimeoutMillis on a PostgreSQL config are forwarded to the pg.Pool, and they are part of the pool's identity: SMRT passes no derived dbid for PostgreSQL, so @happyvertical/sql keys the pool on its effective connection and pool options. Two configurations that agree share one pool; a configuration with max: 5 gets its own 5-client pool even after another caller resolved the same URL with the default (20) first (#3046). An explicit dbid (on the config or resolveDatabase(config, { dbid })) is caller-owned identity and shares the pool unconditionally. SQLite and DuckDB keep the smrt:<url> identity. createDispatchBus({ db: config }) forwards the whole config, and the smrt-jobs liveness thread opens a one-client bounded pool.

Three things these do not cover. smrt db:migrate opens its own connection and bounds each statement from migrations.postgres.lockTimeout / .statementTimeout, so a long migration keeps its own budget. A db option that is already a live DatabaseInterface or a pre-created client is used as given — configure timeouts where that pool is built. And the three session timeouts ride the connection URL, so a PostgreSQL config with no url (discrete host/database fields, or a bare { type: 'postgres' } that lets the adapter read HAVE_SQL_URL) receives only the acquisition timeout; pass the URL through the config to get the rest.

Vector Embeddings​

SMRT objects support vector embeddings for semantic search and similarity comparisons. Embeddings convert object content into numerical vectors that capture semantic meaning.

Configuration​

Configure which fields to embed in the @smrt() decorator:

@smrt({
embeddings: {
fields: ['title', 'content', 'summary'], // Fields to embed (required)
provider: 'local', // Override the project provider (optional)
autoGenerate: true // Allow save-time generation (optional, default true)
}
})
class Article extends SmrtObject {
title: string = '';
content: string = '';
summary: string = '';
}

The decorator's embeddings object accepts exactly fields, provider, autoGenerate, regenerateOnChange, and combinedField.

There is no model key here. Because smrt() takes a typed config, writing one in a TypeScript object literal is an excess-property error; in plain JavaScript, or wherever that check does not apply, it is simply ignored, since nothing reads it. The model is a project-level setting:

// smrt.config.js
import { defineConfig } from '@happyvertical/smrt-config';

export default defineConfig({
smrt: {
embeddings: {
provider: 'local', // 'local' | 'ai' | 'auto' (default 'local')
localModel: 'Xenova/bge-base-en-v1.5', // used by 'local', and by 'auto' with no AI client
aiModel: 'text-embedding-3-small', // used by 'ai', and by 'auto' when an AI client exists
dimensions: 768 // default 768
}
}
});

The project config and the class config are merged into a ResolvedEmbeddingConfig at runtime. getEmbedding() does take a per-call model argument, but it selects which stored vector to read rather than which model to generate with — see below.

Two caveats on the class-level flags:

  • autoGenerate permits save-time generation but does not guarantee it. save() only kicks off a background generateEmbeddings() when an AI client is configured, so that a save never quietly loads a local transformer model. With provider: 'local' and no AI client, call generateEmbeddings() yourself.
  • regenerateOnChange is resolved onto ResolvedEmbeddingConfig but is not currently consulted anywhere. Unchanged content is skipped regardless; use generateEmbeddings({ force: true }) to re-embed it.

generateEmbeddings(options?) - Generate Vectors​

Generates vector embeddings for all configured fields. Call this after creating or updating content.

const article = await collection.create({
title: 'Introduction to Vector Search',
content: 'Vector embeddings enable semantic search...'
});

// Generate embeddings for all configured fields
await article.generateEmbeddings();

// With options
await article.generateEmbeddings({
force: true, // Regenerate even if content unchanged
fields: ['title'] // Only embed specific fields
});

getEmbedding(fieldName, model?) - Retrieve Embedding​

Retrieves the stored embedding vector for a specific field. Returns null when no embedding is stored for that field yet — callers must handle that case.

// Signature: getEmbedding(fieldName: string, model?: string): Promise<number[] | null>

// Get the embedding for a field
const titleVector = await article.getEmbedding('title');
if (titleVector) {
console.log(`Embedding has ${titleVector.length} dimensions`);
} else {
await article.generateEmbeddings({ fields: ['title'] });
}

// The model name is part of the storage key, so pass it to read the vector
// stored under a specific model (defaults to the resolved project model)
const vector = await article.getEmbedding('content', 'text-embedding-3-large');

hasStaleEmbeddings() - Check for Updates​

Checks if the object's content has changed since embeddings were last generated.

// Check if embeddings need regeneration
if (await article.hasStaleEmbeddings()) {
await article.generateEmbeddings();
}

// Use in batch processing
const articles = await collection.list({});
for (const article of articles) {
if (await article.hasStaleEmbeddings()) {
await article.generateEmbeddings();
}
}

clearEmbeddings() - Remove Embeddings​

Removes all stored embeddings for the object.

// Clear all embeddings
await article.clearEmbeddings();

// Useful when content is significantly changed
article.content = newContent;
await article.clearEmbeddings();
await article.generateEmbeddings();

Embedding Best Practices​

  1. Choose appropriate fields: Embed fields with meaningful text content, not IDs or numeric values
  2. Regenerate on content changes: Use hasStaleEmbeddings() to keep embeddings current
  3. Batch processing: Generate embeddings in batches to manage API rate limits
  4. Select the right model: Use smaller models for speed, larger models for accuracy
// Batch embedding generation with rate limiting
async function embedAllArticles(collection: ArticleCollection) {
const articles = await collection.list({});

for (const article of articles) {
if (await article.hasStaleEmbeddings()) {
await article.generateEmbeddings();
await new Promise(resolve => setTimeout(resolve, 100)); // Rate limit
}
}
}

Raw Query with Hydration​

Execute complex SQL queries while still getting back fully-hydrated SMRT objects:

// Complex queries with JOINs, CTEs, subqueries - results are still SMRT objects
const featured = await products.query(`
SELECT p.* FROM products p
JOIN categories c ON p.category_id = c.id
WHERE c.featured = true
AND NOT EXISTS (
SELECT 1 FROM inventory i
WHERE i.product_id = p.id AND i.quantity = 0
)
ORDER BY p.created_at DESC
LIMIT $1
`, [10]);

// Each result is a fully-functional Product instance
for (const product of featured) {
console.log(product.name); // Access properties
await product.save(); // Use SMRT methods
const valid = await product.is('Is this product well-described?'); // AI methods work
}

Batch Operations​

Efficiently work with multiple records:

// Batch fetch by IDs (single query, avoids N+1)
const items = await collection.listByIds(['id1', 'id2', 'id3']);

// Find or create with defaults (upsert pattern)
const user = await users.getOrUpsert(
{ email: 'user@example.com' }, // Find by these fields
{ name: 'New User', role: 'member' } // Use these defaults if creating
);

// Get only changed fields between objects
const changes = collection.getDiff(existingUser, updatedData);
// Returns: { name: 'New Name' } if only name changed

Collections provide semantic search capabilities using vector embeddings. This enables finding similar content based on meaning rather than exact text matching.

Search by Text Query​

// Search for articles similar to a text query
const results = await articles.semanticSearch('machine learning basics', {
field: 'content', // Which embedded field to search
limit: 10, // Max results
minSimilarity: 0.7 // Minimum cosine similarity (0-1)
});

for (const article of results) {
console.log(article.title, article._similarity); // _similarity score attached
}

Find Similar Objects​

// Find articles similar to an existing article
const similar = await articles.findSimilar(article, {
field: 'content',
limit: 5,
excludeSelf: true // Don't include the source article
});
// Search with a pre-computed embedding vector
const queryEmbedding = await collection.ai.embed('search query');
const results = await articles.findSimilarToEmbedding(queryEmbedding, {
field: 'content',
limit: 10
});

Batch Generate Embeddings​

// Generate embeddings for all objects missing them
const stats = await articles.generateMissingEmbeddings({
batchSize: 50, // Process in batches
onProgress: (done, total) => {
console.log(`Progress: ${done}/${total}`);
}
});

console.log(`Generated: ${stats.generated}, Skipped: ${stats.skipped}`);

Interceptors​

Interceptors allow you to hook into collection operations for cross-cutting concerns like tenancy, auditing, caching, and access control.

Registering Interceptors​

import { GlobalInterceptors } from '@happyvertical/smrt-core';

GlobalInterceptors.register({
name: 'tenancy',
priority: 100, // Higher priority runs first

// Intercept list operations
beforeList(className, options, context) {
// Add tenant filter to all queries
return {
...options,
where: { ...options.where, tenantId: getCurrentTenantId() }
};
},

// Intercept after data is fetched
afterList(className, results, context) {
// Log or transform results
logQuery(className, results.length);
return results;
}
});

Available Hooks​

HookDescriptionReturn Value
beforeGetBefore fetching single recordModified options or void
afterGetAfter fetching single recordModified result or void
beforeListBefore listing recordsModified options or void
afterListAfter listing recordsModified results or void
beforeQueryBefore raw SQL queryModified query/params or void
afterQueryAfter raw SQL queryModified results or void
beforeSaveBefore saving recordModified instance or void
afterSaveAfter saving recordvoid
beforeDeleteBefore deleting recordBoolean (false cancels) or void
afterDeleteAfter deleting recordvoid

Interceptor Context​

All hooks receive a context object with metadata:

interface InterceptorContext {
className: string; // SMRT class name (e.g., 'Product')
collectionName: string; // Collection class name
timestamp: Date; // When operation started
operation: string; // 'list' | 'get' | 'query' | 'save' | 'delete'
metadata: Record<string, any>; // Custom data
}

Example: Audit Logging​

GlobalInterceptors.register({
name: 'audit',
priority: 50,

afterSave(className, instance, context) {
auditLog.write({
action: 'save',
entity: className,
entityId: instance.id,
timestamp: context.timestamp,
changes: instance._changedFields
});
},

afterDelete(className, id, context) {
auditLog.write({
action: 'delete',
entity: className,
entityId: id,
timestamp: context.timestamp
});
}
});

Example: Soft Deletes​

GlobalInterceptors.register({
name: 'soft-delete',
priority: 90,

// Filter out soft-deleted records from all queries
beforeList(className, options, context) {
return {
...options,
where: { ...options.where, 'deletedAt': null }
};
},

// Convert delete to soft delete
beforeDelete(className, id, context) {
const collection = getCollection(className);
collection.db.execute`
UPDATE ${collection.tableName}
SET deleted_at = ${new Date()}
WHERE id = ${id}
`;
return false; // Cancel the actual delete
}
});

Context Memory System​

The Context Memory System enables SMRT objects to remember and recall operational knowledge, learned patterns, and parsing strategies. This is essential for AI agents that discover effective approaches and need to reuse them across sessions.

Why Context Memory?​

When an AI agent discovers that a specific CSS selector works for extracting content from a website, or learns that a particular date format is used consistently, it should remember this for future use. The Context Memory System provides persistent storage for these learned patterns with:

  • Hierarchical scoping: Organize patterns by domain, context, and specificity
  • Confidence filtering: Store reliability (0–1) and apply a minimum confidence on recall
  • Opt-in fallback: Query from specific to general scopes with includeAncestors: true
  • Stored expiration metadata: Record expiresAt; use LearningMemory when you need enforced TTL and outcome counters

Core Methods​

Object-Level Context​

Store and retrieve context specific to an individual object instance:

import { SmrtObject, field } from '@happyvertical/smrt-core';

class WebScraper extends SmrtObject {
@field({ required: true })
url: string = '';

async discoverContentSelector() {
const url = this.url;

// Try to recall previously discovered selector
const remembered = await this.recall({
scope: `parser/content/${new URL(url).hostname}`,
key: 'article-selector',
includeAncestors: true // Falls back to parent scopes
}) as string | null;

if (remembered) {
return remembered;
}

// Discover selector using AI
const selector = await this.do(`
Analyze this webpage and determine the best CSS selector
for the main article content: ${url}
`);

// Remember for future use
await this.remember({
scope: `parser/content/${new URL(url).hostname}`,
key: 'article-selector',
value: selector,
confidence: 0.9,
metadata: { discoveredAt: new Date() }
});

return selector;
}
}

Collection-Level Context​

Store context shared across all instances of a collection:

class ScraperCollection extends SmrtCollection<WebScraper> {
static readonly _itemClass = WebScraper;

async getDefaultUserAgent() {
// Recall collection-wide default
const remembered = await this.recall({
scope: 'config/http',
key: 'user-agent'
}) as string | null;

if (remembered) {
return remembered;
}

// Set default for all instances
const userAgent = 'Mozilla/5.0 (compatible; MyBot/1.0)';
await this.remember({
scope: 'config/http',
key: 'user-agent',
value: userAgent,
confidence: 1.0
});

return userAgent;
}
}

Hierarchical Scoping​

Context scopes follow a hierarchical pattern for intelligent fallback:

// Most specific scope
await scraper.remember({
scope: 'parser/date/example.com/news',
key: 'format',
value: 'MM/DD/YYYY'
});

// Broader scope (fallback for other sections)
await scraper.remember({
scope: 'parser/date/example.com',
key: 'format',
value: 'ISO-8601'
});

// General scope (fallback for all domains)
await scraper.remember({
scope: 'parser/date',
key: 'format',
value: 'YYYY-MM-DD'
});

// Query with ancestor fallback
const format = await scraper.recall({
scope: 'parser/date/example.com/events',
key: 'format',
includeAncestors: true
});
// Returns 'ISO-8601' (parent scope match)

Scope Hierarchy Example:

parser/ # Root: General patterns
└── date/ # Date parsing strategies
├── example.com/ # Domain-specific patterns
│ ├── news/ # Section-specific patterns
│ └── events/ # Section-specific patterns
└── another-site.com/

All Context Methods​

remember(options) - Store Context​

await object.remember({
id?: string, // Optional: Update existing entry
scope: string, // Hierarchical scope (e.g., 'parser/html/domain.com')
key: string, // Context key within scope
value: any, // Context value (JSON-serializable)
metadata?: any, // Additional metadata
confidence?: number, // Confidence score 0-1 (default: 1.0)
version?: number, // Version number (default: 1)
expiresAt?: Date // Stored on the row; NOT enforced by recall() (see below)
});

recall(options) - Retrieve Context​

const context = await object.recall({
scope: string, // Scope to query
key: string, // Context key
includeAncestors?: boolean, // Search parent scopes (default: false)
minConfidence?: number // Minimum confidence threshold (default: 0)
});

// Returns: the stored value, JSON-parsed, or null if not found

recallAll(options) - Retrieve Multiple Contexts​

const contexts = await object.recallAll({
scope: string, // Scope to query
includeDescendants?: boolean, // Include child scopes (default: false)
minConfidence?: number // Minimum confidence threshold (default: 0)
});

// Returns: Map<string, unknown> of key → stored value

forget(options) - Delete Context​

await object.forget({
scope: string, // Scope containing the context
key: string // Context key to delete
});

forgetScope(options) - Delete Entire Scope​

// Delete all contexts in a scope
await object.forgetScope({
scope: string, // Scope to delete
includeDescendants?: boolean // Delete child scopes too (default: false)
});

Practical Use Cases​

1. Website Parsing Patterns​

class ArticleScraper extends SmrtObject {
async extractArticle(url: string) {
const domain = new URL(url).hostname;

// Try to recall parsing strategy
const strategy = await this.recall({
scope: `parser/article/${domain}`,
key: 'extraction-strategy',
includeAncestors: true
});

if (strategy) {
return await this.applyStrategy(url, strategy);
}

// Discover new strategy with AI
const newStrategy = await this.discoverStrategy(url);

// Remember for next time
await this.remember({
scope: `parser/article/${domain}`,
key: 'extraction-strategy',
value: newStrategy,
confidence: 0.85
});

return await this.applyStrategy(url, newStrategy);
}
}

2. API Response Patterns​

class APIClient extends SmrtObject {
async fetchData(endpoint: string) {
const response = await fetch(endpoint);

// Recall known response structure
const structure = await this.recall({
scope: `api/response/${endpoint}`,
key: 'structure'
});

if (structure) {
// Use known structure for efficient parsing
return this.parseResponse(response, structure);
}

// Analyze and remember response structure
const discoveredStructure = await this.analyzeStructure(response);

await this.remember({
scope: `api/response/${endpoint}`,
key: 'structure',
value: discoveredStructure,
confidence: 1.0
});

return this.parseResponse(response, discoveredStructure);
}
}

3. Configuration Defaults​

class DocumentProcessor extends SmrtObject {
async initialize() {
await super.initialize();

// Recall processing preferences
const preferences = await this.recallAll({
scope: 'config/processing',
includeDescendants: true
});

// Apply remembered preferences
for (const [key, value] of preferences) {
this.applyPreference(key, value);
}
}

async updatePreference(key: string, value: any) {
await this.remember({
scope: 'config/processing',
key,
value,
confidence: 1.0
});
}
}

4. Pattern Evolution and Versioning​

class PatternLearner extends SmrtObject {
async evolvePattern(scope: string, key: string) {
// Get current pattern version
const current = await this.recall({ scope, key }) as {
pattern: unknown;
version: number;
} | null;

if (!current) return;

// Create improved version
const improved = await this.improvePattern(current.pattern);
const nextVersion = current.version + 1;

// Store as new version
await this.remember({
scope,
key,
value: { pattern: improved, version: nextVersion },
version: nextVersion,
confidence: 1.0,
metadata: {
previousVersion: current.version,
improvedAt: new Date()
}
});
}
}

Database Storage​

Context is stored in the _smrt_contexts system table alongside your application data. The table includes:

  • Hierarchical scopes: For organizing patterns by domain and specificity
  • Confidence tracking: To prioritize reliable patterns
  • Version support: For pattern evolution over time
  • Usage metrics: success_count / failure_count columns, maintained only by LearningMemory (see below)
  • Timestamps: Created, updated, and last used dates
  • Expiration: An expires_at column that stores the expiresAt you pass to remember()

The system table is automatically created when you initialize any SMRT object with database configuration.

Expiry is stored, not enforced by recall(). remember() writes expires_at, but recall() and recallAll() have no expiry predicate — an entry whose expiry has passed is still returned, and the primitive recall APIs do not expose the stored expiry. If you stay on the primitive API, include the expiry inside the stored value and enforce it before use (then call forget() to delete the stale entry). Do not query the private _smrt_contexts table directly. success_count / failure_count are the same shape of promise: the columns exist on the table, but nothing in the primitive path ever increments them. SmrtObject.remember() does not write them at all, and SmrtCollection.remember() writes literal zeros — so a collection-level remember() resets whatever counters had accumulated on that row.

If you want expiry, a confidence floor, and reinforcement counters applied for you, use LearningMemory (exported from @happyvertical/smrt-core) instead of calling remember()/recall() directly. It reads and writes the same _smrt_contexts rows, but it drops expired rows, applies a confidence floor (0.7 by default), and updates success_count/failure_count from the outcomes you report. It can also decay confidence by age, but that is opt-in: time decay is off unless you set decayHalfLifeMs.

Best Practices​

  1. Use hierarchical scopes: Organize from general to specific (e.g., parser/date/domain.com/section)
  2. Include confidence scores: Track how reliable each pattern is
  3. Set appropriate confidence thresholds: Filter out low-confidence patterns with minConfidence
  4. Use metadata for debugging: Store discovery timestamps, AI model used, etc.
  5. Clean up old patterns: Use forgetScope() to remove outdated contexts
  6. Version critical patterns: Use version numbers for pattern evolution
  7. Handle expiration explicitly: expiresAt is recorded but never applied or returned by recall(); include an expiry in the stored value and enforce it before use, or use LearningMemory, which applies expiry through a public API

Cross-Package Integration​

SMRT integrates seamlessly with other HAVE SDK packages:

// With @happyvertical/spider for web content
import { SpiderAdapter } from '@happyvertical/spider';

class WebDocument extends SmrtObject {
@field({ required: true })
url = '';

content = '';

async scrapeContent() {
const spider = new SpiderAdapter(this.options.spider);
this.content = await spider.getTextContent(this.url);
await this.save();
}
}

// With @happyvertical/pdf for document processing
import { PDFProcessor } from '@happyvertical/pdf';

class PDFDocument extends SmrtObject {
@field({ required: true })
filePath = '';

extractedText = '';

async extractText() {
const pdf = new PDFProcessor(this.options.pdf);
this.extractedText = await pdf.extractText(this.filePath);
await this.save();
}
}

// With @happyvertical/files for file management
class FileDocument extends SmrtObject {
async saveToFile(filename: string) {
await this.fs.writeText(filename, this.content);
}
}

Error Handling​

SMRT provides comprehensive error handling with specific error types:

Every error carries a stable machine-readable code and a structured details object. There are no ad-hoc field / sql / operation properties — read details.

import { ValidationError, DatabaseError, RuntimeError } from '@happyvertical/smrt-core';

try {
await document.save();
} catch (error) {
if (error instanceof ValidationError) {
// 'VALIDATION_UNIQUE_CONSTRAINT' | 'VALIDATION_REQUIRED_FIELD' | ...
console.log('Validation failed:', error.code, error.details);
} else if (error instanceof DatabaseError) {
// The driver error is preserved on `cause`.
console.log('Database error:', error.code, error.details, error.cause);
} else if (error instanceof RuntimeError) {
console.log('Runtime error:', error.code, error.details);
}
}

Constraint violations are typed, and never retried​

save() classifies the driver failure through its whole cause chain, so a unique or NOT NULL violation arrives as a ValidationError on every adapter — SQLite, PostgreSQL and DuckDB alike — raised on the first attempt with no retry backoff. Other database failures stay DatabaseError with the driver error on cause.

Do not match on error.message. @happyvertical/sql wraps every driver error as DatabaseError('Failed to upsert record into table', …) and stringifies the driver text into context.originalError, so the constraint wording is not on the outer message. Use the classifier instead:

import {
classifyDatabaseError,
isUniqueViolationError,
} from '@happyvertical/smrt-core';

try {
await document.save();
} catch (error) {
if (isUniqueViolationError(error)) {
// Losing side of a race — re-read the winner.
}
const { kind, deterministic, retryable } = classifyDatabaseError(error);
}

Retries are transient-only: serialization failures, deadlocks, lock timeouts and dropped connections are retried; constraint violations, invalid input syntax, missing tables and statements issued inside an aborted PostgreSQL transaction (25P02) fail fast.

Performance Tips​

SMRT includes several optimizations for building high-performance AI agents:

1. Use Eager Loading for Relationships​

When accessing related objects for most/all items in a list, use eager loading to avoid N+1 queries:

// 40-70% faster for relationship-heavy queries
const orders = await orderCollection.list({
where: { status: 'pending' },
include: ['customerId', 'productId'],
limit: 50
});

2. Collection Instances are Cached Automatically​

Collections are automatically cached and reused when loading relationships, providing 60-80% reduction in initialization overhead:

// First access initializes and caches the collection
const customer = await order.loadRelated('customerId');

// Subsequent accesses reuse the cached collection instance
const product = await order.loadRelated('productId');

3. Batch Operations​

For inserting or updating many objects, use transactions when supported:

await db.transaction(async () => {
for (const data of items) {
const obj = await collection.create(data);
await obj.save();
}
});

4. Use Indexes for Frequently Queried Fields​

Add indexes to fields that are commonly used in WHERE clauses:

class Product extends SmrtObject {
@field({ required: true, unique: true, indexed: true })
sku = '';

@field({ indexed: true }) // Frequently queried
category = '';

@field({ min: 0 })
price = 0.0;
}

Reference columns need no opt-in: every @foreignKey, @crossPackageRef and tenant column is indexed automatically on every schema path (test databases, manifest.json, smrt db:migrate) unless an index already leads with it — for example a conflictColumns unique index that starts on that column. The primary key is not indexed twice, unique: true on a single-table-inheritance field becomes a unique index (partial by discriminator when only a subclass declares it), and classes with custom conflictColumns keep a plain (slug, context) index for slug loading. smrt db:migrate adds the missing indexes to existing databases and drops the legacy <table>_id_idx, and the narrower unique index of a conflict key a class has since widened (for example ['key'] to ['tenant_id', 'key']).

The ordering every generated list route uses is indexed automatically too: (tenant_id, created_at) on a tenant-scoped table and (created_at) otherwise, so the default page (ORDER BY created_at DESC behind the tenant filter) is an ordered index scan rather than a full scan plus a sort. The composite stands in for the standalone tenant index rather than adding to it.

A list workload usually needs more than one column: it filters on one or more columns and sorts on another, and only a composite index in that order serves it as an ordered scan. Declare those with @smrt({ indexes }) (#2357):

@smrt({
indexes: [
// Serves: WHERE tenant_id = ? AND ... ORDER BY publish_date DESC LIMIT 10
{
name: 'articles_tenant_id_publish_date_idx',
columns: ['tenantId', 'publish_date'],
},
// Partial + unique indexes are supported too.
{ name: 'articles_active_sku_idx', columns: ['sku'], unique: true, where: 'archived = false' },
],
})
class Article extends SmrtObject {}

columns accepts SMRT field names or column names, in index order — filter columns first, sort column last. Declare columns, not a direction: PostgreSQL scans a btree either way, so an ascending index also serves the matching ORDER BY ... DESC without a sort step. A column the object does not have, or a name that collides with a different generated index, fails schema generation rather than silently dropping the index. An index leading with a reference column (tenant_id, a foreign key, a cross-package ref) also stands in for that column's automatic index, and one leading with (tenant_id, created_at) stands in for the default list-ordering index.

5. Cache AI Responses​

For expensive AI operations, cache results in object properties:

class Document extends SmrtObject {
summary: string = '';

async getSummary() {
if (!this.summary) {
this.summary = await this.do('Summarize this document');
await this.save(); // Cache result
}
return this.summary;
}
}

Learn more: See the performance considerations in package AGENTS.md for detailed optimization strategies.

Troubleshooting​

Collection Table Names​

Issue: Collections query incorrect table names (e.g., place_collections instead of places).

Cause: In versions before v0.32.1, collections used their own class name for table naming instead of the item class name.

Fixed in v0.32.1: Collections now correctly use the item class name for table naming:

class PlaceCollection extends SmrtCollection<Place> {
static readonly _itemClass = Place;
// Table name: 'places' (from Place class), not 'place_collections'
}

Migration: If you have data in the incorrectly-named table:

-- Rename the table to match the item class name
ALTER TABLE place_collections RENAME TO places;

Static Factory Pattern Required​

Issue: TypeError when trying to instantiate collections with new.

Cause: Collection constructors are protected to prevent partially-initialized instances.

Solution: Always use the static create() factory method:

// ✅ CORRECT - Fully initialized collection
const collection = await ProductCollection.create({
db: { type: 'sqlite', url: 'products.db' }
});

// ❌ WRONG - Constructor is protected
const collection = new ProductCollection(options); // Error!

The static factory method ensures collections are fully initialized with database connections, AI clients, and file system access before use.

Collection _itemClass Requirement​

Issue: Error "Collection must define a static _itemClass property".

Cause: Collections require a static _itemClass property to know which object type they manage.

Solution: Always define the static _itemClass:

class DocumentCollection extends SmrtCollection<Document> {
static readonly _itemClass = Document; // Required!
}

Nullable Columns in conflictColumns​

Issue: on PostgreSQL a second row with a NULL value in a nullable conflictColumns column is merged into (or refused against) the first, while the same design appears to work on SQLite.

Cause: conflictColumns is the upsert identity. save() matches it with IS NOT DISTINCT FROM on every engine (NULL equals NULL), and on PostgreSQL 15+ its unique index over a nullable column is emitted NULLS NOT DISTINCT so the index agrees with the upsert. SQLite has no such index clause, so a raw insert that bypasses the upsert is not refused there — the engines differ at the index, not at save().

Solution: keep conflictColumns for identity keys. To constrain a nullable column — any number of NULLs, at most one row per non-NULL value — declare a unique index, which is NULLs-distinct on every engine (#2979):

@smrt({
indexes: [
{ name: 'submissions_amends_id_uidx', columns: ['amendsId'], unique: true },
],
})
class Submission extends SmrtObject {
@field({ required: false, nullable: true })
amendsId: string | null = null;
}

Slug and Context Uniqueness​

Issue: UNIQUE constraint violation when saving objects.

Cause: SMRT enforces a unique constraint on (slug, context) pairs.

Understanding: Objects can have the same slug if they have different contexts:

// These are DIFFERENT objects (different contexts)
const blog = await collection.create({ slug: 'intro', context: '/blog' });
const docs = await collection.create({ slug: 'intro', context: '/docs' });

// This FAILS (same slug + context)
const blog2 = await collection.create({ slug: 'intro', context: '/blog' });
// throws ValidationError, code 'VALIDATION_UNIQUE_CONSTRAINT'

Tenant-owned tables: a class with a tenancy declaration or a tenantId field keys on (tenant_id, slug, context) instead. A slug is then unique only within one owner: two tenants, or a tenant and the global (NULL-tenant) scope, may each hold intro in /blog. A slug-only lookup that is not tenant-filtered (withSystemContext(), tenancy disabled, a hand-written query) can match several rows and get({ slug }) returns whichever comes first, so scope it: run it inside withTenant(...), or put tenantId (or tenantId: null for the global row) in the where, and treat more than one match as ambiguous.

Upgrading an existing database (expand, then contract). Old code upserts on ON CONFLICT (slug, context) and new code on ON CONFLICT (tenant_id, slug, context); PostgreSQL binds each only to a unique index over exactly those columns (otherwise every save fails with 42P10), so during a rolling deploy both indexes must exist:

  1. Expand — before the new code rolls out: run smrt db:migrate with the new release's manifests. It builds the tenant-led unique under its own name (<table>_tenant_id_slug_idx) and keeps the global <table>_slug_context_idx. The new key is a superset of the old one, so the build cannot fail on existing rows. Old and new code both keep saving.
  2. Roll out the new release. While the legacy index stands, a second tenant cannot store a slug another tenant already uses: that save fails with a unique violation (nothing is overwritten).
  3. Contract — once no old code is running: run smrt db:migrate --drop-legacy-natural-key to drop the global index.

Until step 1 runs, smrt db:status reports the table as an error and exits 1; between steps 1 and 3 it reports the legacy index as a warning. Locking: in the default atomic mode the build holds a SHARE lock (writes to the table wait, reads continue) for the length of the build. --postgres-safe builds it CONCURRENTLY instead, but refuses a key with a nullable column (a nullable tenant_id needs the NULL-equal index, which only the atomic mode can build); schedule a quiet window for large tables in that case.

Detecting it: match the typed error, not the driver text — the adapter wraps the driver error, so the constraint wording is not on error.message.

import { isUniqueViolationError } from '@happyvertical/smrt-core';

if (isUniqueViolationError(error)) { /* … */ }

Solution: Ensure unique slugs within the same context, or use different contexts for objects with the same slug.

API Reference​

See the API documentation for detailed information on all available methods and options.

License​

This package is part of the HAVE SDK and is licensed under the MIT License - see the LICENSE file for details.