@happyvertical/smrt-core
ORM, code generation, AI integration, and the DispatchBus. Everything else in the SMRT framework builds on this.
Overview
@happyvertical/smrt-core is the heart of the SMRT framework. It provides:
- SmrtObject -- base persistent object with save, delete, is(), do(), loadFromId/Slug
- SmrtCollection -- CRUD collection: list, get, create, delete, getOrUpsert
- ObjectRegistry -- global singleton on
globalThisfor class metadata, fields, STI chains, and manifests - Code Generators -- REST APIs, MCP servers, and CLI commands generated from
@smrt() - DispatchBus -- inter-agent messaging with persistent subscriptions,
wildcards, and lifecycle (
pending → processing → completed) - Single Table Inheritance -- polymorphic object hierarchies in a single table
- GlobalInterceptors -- plugin hooks for beforeList/Get/Save/Delete (used by smrt-tenancy)
Installation
npm install @happyvertical/smrt-coreSDK Dependencies
smrt-core builds on the HappyVertical SDK:
npm install @happyvertical/ai @happyvertical/sql @happyvertical/files @happyvertical/utilsQuick Start
Define a SMRT object in a few lines:
1. Define Your Object
import { smrt, SmrtObject, SmrtCollection, foreignKey } from '@happyvertical/smrt-core';
@smrt({ api: true, cli: true, mcp: true })
class Product extends SmrtObject {
name: string = '';
price: number = 0.0; // DECIMAL (has decimal point)
quantity: number = 0; // INTEGER (no decimal point)
isPublished: boolean = false;
categoryId = foreignKey(Category);
}
class ProductCollection extends SmrtCollection<Product> {
static readonly _itemClass = Product;
}2. Initialize Collection
Application tables are not created at runtime -- prepare them via the
migration tooling (smrt db:migrate) before the first DB op. The collection only
verifies the table exists and fails clearly if it doesn't.
const products = await ProductCollection.create({
db: 'products.db' // SQLite database
});3. CRUD Operations
// Create
const product = await products.create({ name: 'Widget', price: 9.99 });
await product.save();
// Query
const results = await products.list({
where: { isPublished: true, price: { op: '>', value: 5 } },
orderBy: 'price DESC',
limit: 20,
});
// Read
const one = await products.get(product.id);
// Update
product.price = 24.99;
await product.save();
// Delete
await product.delete();4. AI Operations
// Ask yes/no questions about your objects (function calling under the hood)
const isExpensive = await product.is('costs more than the average product');
// Perform AI-powered actions
const description = await product.do('Write a short marketing description');Core Classes
| Class | File | Purpose |
|---|---|---|
SmrtObject | src/object.ts | Base persistent object -- save, delete, is(), do(), loadFromId/Slug |
SmrtCollection | src/collection.ts | CRUD collection -- list, get, create, delete, getOrUpsert |
ObjectRegistry | src/registry.ts | Global singleton on globalThis -- class metadata, fields, STI chains, manifests |
DispatchBus | src/dispatch/bus.ts | Inter-agent messaging -- emit, subscribe (persistent), process |
GlobalInterceptors | src/interceptors.ts | Plugin system -- beforeList/Get/Save/Delete hooks used by smrt-tenancy |
SmrtObject Lifecycle
constructor(options) → initialize() → ready for save() / delete() / loadFromId():
initialize()loads field initializers, applies option values (options override initializers), and loads from the DB ifid/slugis providedsave()upserts with STI validation, interceptor execution, and auto-embeddingsis(criteria)/do(instructions)-- AI operations via function callinggetSlug()-- auto-generates fromname→title→label→idloadRelated(fieldName)-- lazy-loads relationships (cached in_loadedRelationshipsMap)
SmrtCollection Query
await collection.list({
where: { status: 'active', price: { op: '>', value: 10 } },
limit: 50,
offset: 0,
orderBy: 'created_at DESC',
});WHERE operators: =, >, <, >=, <=, !=, in, not in, like, is null, is not null. Arrays auto-detect IN. Dot notation drills into JSON columns: metadata.userId.
STI child collections auto-filter by _meta_type.
Eager Loading (Prevent N+1 Queries)
// Load relationships efficiently with SQL JOINs
const orders = await orderCollection.list({
limit: 100,
include: ['customerId', 'productId'] // Pre-load relationships
});
// Access without additional queries
for (const order of orders) {
const customer = order.getRelated('customerId'); // Already loaded!
const product = order.getRelated('productId');
}Direct SQL Access
// Template literal safety (SQL injection prevention)
const expensive = await collection.db.many`
SELECT * FROM products
WHERE price > ${100}
ORDER BY price DESC
`;
const count = await collection.db.pluck`
SELECT COUNT(*) FROM products WHERE category = ${'electronics'}
`;The @smrt() Decorator
Key options:
tableName-- override the default table nametableStrategy--'cti'(default) or'sti'conflictColumns-- natural-key tuple used byupsert()(required on junction / upsert tables)api/mcp/cli-- generator config (boolean or{ include: [...] })ai-- callable methods exposed duringdo()hooks-- beforeSave / afterSave / beforeDelete / afterDeleteembeddings-- auto-generate embeddings on savetenantScoped-- read by@happyvertical/smrt-tenancyagent-- mark as agent root for jobs/dispatch
Registration also sets a SMRT_TABLE_NAME static property on the class -- this survives
minification, so production bundles still resolve the right table.
@smrt({
api: { include: ['list', 'get', 'create', 'update', 'delete'] },
mcp: { include: ['list', 'get'] }, // Read-only for AI
cli: true,
conflictColumns: ['sku'],
})
export class Product extends SmrtObject {
sku: string = '';
name: string = '';
price: number = 0.0;
}DispatchBus
Persistent inter-agent messaging backed by SQL:
emit(signalType, payload, metadata)-- creates a persistent Dispatch recordon(pattern, handler)-- in-memory handler, fires immediatelysubscribe({ signalType, subscriber })-- persistent subscription that survives restartsprocess(subscriberName, handler)-- process pending dispatches- Wildcards:
campaign.*matchescampaign.completed(single segment only) - Tables:
_smrt_dispatch,_smrt_dispatch_subscriptions - Status lifecycle:
pending → processing → completed(orfailed)
import { createDispatchBus } from '@happyvertical/smrt-core';
const bus = await createDispatchBus({ db: { type: 'sqlite', url: 'app.db' } });
// Emit a signal
await bus.emit('campaign.completed', {
campaignId: 'c-123',
totalSent: 4200,
}, { source: 'suasor' });
// Persistent subscription
await bus.subscribe({
signalType: 'campaign.*',
subscriber: 'analytics-worker',
});
// Drain queue in a worker process (handler receives payload + metadata)
await bus.process('analytics-worker', async (payload, metadata) => {
await recordCampaignMetrics(payload);
});AI Integration
The is() Method
Ask yes/no questions about your objects:
const isHighQuality = await document.is(`
- Contains more than 500 words
- Has clear structure and headings
- Uses professional language
`);
if (isHighQuality) {
await document.publish();
}The do() Method
Perform AI-powered actions:
const summary = await document.do(`
Create a 2-sentence summary of this document.
Focus on the key points and main conclusions.
`);AI Tools & Function Calling
Methods listed under ai in @smrt() are exposed as tools during do() calls.
@smrt({ ai: { callable: ['summarize', 'translate'] } })
class Document extends SmrtObject {
async summarize() { /* ... */ }
async translate(language: string) { /* ... */ }
}
const result = await document.do(`
Analyze this document and translate the summary to Spanish.
`);
// AI will call summarize() and translate() as neededSingle Table Inheritance (STI)
- Base:
@smrt({ tableStrategy: 'sti' })-- children inherit, share one table - Discriminator:
_meta_typecolumn with qualified names like@happyvertical/smrt-content:Article - Child-specific fields:
@meta()decorator stores them in_meta_dataJSONB instead of as columns - Polymorphic queries: the collection reads
_meta_typeand constructs the correct subclass dynamically - Validation:
save()fails fast if_meta_typeis missing or mismatched
import { smrt, SmrtObject, meta } from '@happyvertical/smrt-core';
@smrt({ tableStrategy: 'sti' })
class Event extends SmrtObject {
title: string = ''; // Base table column
startTime: Date = new Date(); // Base table column
}
@smrt()
class Meeting extends Event {
location: string = ''; // Base table column
@meta() roomNumber: string = ''; // Stored in _meta_data JSONB
@meta() attendees: string[] = [];
}
@smrt()
class Concert extends Event {
venue: string = ''; // Base table column
@meta() artist: string = ''; // Stored in _meta_data JSONB
@meta() ticketPrice: number = 0;
}
// Polymorphic queries -- collection loads correct subclass automatically
const events = await eventCollection.list();
events.forEach(event => {
if (event instanceof Meeting) console.log(`Meeting at ${event.location}`);
if (event instanceof Concert) console.log(`Concert by ${event.artist}`);
});Code Generators
| Generator | Location | Output |
|---|---|---|
| REST API | src/generators/rest.ts | OpenAPI-compliant CRUD endpoints |
| CLI | src/generators/cli.ts | Commander commands with auto-help |
| MCP Server | src/generators/mcp.ts | Model Context Protocol tools |
CLI Commands (Auto-Generated)
# From @smrt({ cli: true })
npx smrt products:list
npx smrt products:get <id>
npx smrt products:create --name "Widget" --price 29.99
npx smrt products:update <id> --price 24.99
npx smrt products:delete <id>Vite Plugin
smrtPlugin (exported from @happyvertical/smrt-core/vite-plugin) is what makes @smrt() work. At
build/dev time it scans your src/ files, builds the object manifest (class names, fields, methods, decorator config), and exposes it to
the runtime through virtual modules and a .smrt/manifest.json file the CLI reads.
// vite.config.ts
import { defineConfig } from 'vite';
import { smrtPlugin } from '@happyvertical/smrt-core/vite-plugin';
export default defineConfig({
plugins: [
smrtPlugin(),
],
// Decorators still need esbuild configured:
esbuild: {
tsconfigRaw: {
compilerOptions: {
experimentalDecorators: true,
emitDecoratorMetadata: true,
},
},
},
});SvelteKit route generation (svelteKit.enabled)
Pass svelteKit: { enabled: true } and the plugin writes real SvelteKit +server.ts route files for every @smrt({ api: true }) object
-- regenerated on every change in dev. This is opt-in; it is off by default.
// vite.config.ts
import { sveltekit } from '@sveltejs/kit/vite';
import { smrtPlugin } from '@happyvertical/smrt-core/vite-plugin';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [
smrtPlugin({
svelteKit: {
enabled: true, // default: false
routesDir: 'src/routes/api', // where +server.ts files are written
objectsDir: 'src/lib/objects', // where your @smrt() classes live
configPath: 'src/lib/server', // dir for the generated config
configFileName: 'smrt.ts', // generated config file name
// kebabRoutes: true, // /discover-from-url vs /discoverFromUrl
},
}),
sveltekit(),
],
});What gets generated
| Path | What it is |
|---|---|
src/routes/api/<collection>/+server.ts | Collection routes: GET (list), POST (create) |
src/routes/api/<collection>/[id]/+server.ts | Item routes: GET / PUT / DELETE |
src/lib/server/smrt.ts | Central config + per-object objectOverrides (not overwritten once it exists) |
src/lib/server/smrt-register.ts | Imports every object so the @smrt() decorators run (regenerated) |
.smrt/manifest.json | Manifest for CLI discovery (smrt db:migrate, db:status) |
Generated route files are auto-added to .gitignore; the one exception is smrt.ts, which is yours to edit (the generator skips it if it already exists).
Generated routes are fail-closed
Every generated handler runs an auth guard before touching the collection. By default a
route requires an authenticated principal on locals -- it
throws 401 otherwise. You opt specific objects out per the Security defaults section below. A generated collection handler
looks like this:
// src/routes/api/products/+server.ts (generated, abridged)
import { error, json } from '@sveltejs/kit';
// Fail-closed: false = every route needs auth; true = public;
// 'read' = list/get public, writes still guarded.
const PUBLIC_ACCESS: boolean | 'read' = false;
function requireRouteAuth(locals: unknown, mutating: boolean): void {
if (PUBLIC_ACCESS === true) return;
if (PUBLIC_ACCESS === 'read' && !mutating) return;
if (!hasAuthenticatedPrincipal(locals)) {
throw error(401, 'Authentication required');
}
}
export async function GET({ locals }) {
requireRouteAuth(locals, false); // read -> non-mutating
// ... list, serialized via toPublicJSON()
}
export async function POST({ locals, request }) {
requireRouteAuth(locals, true); // write -> mutating
const data = applyWritablePolicy(await request.json());
// ... create
}Security Defaults
Generated REST / MCP / SvelteKit surfaces ship with secure defaults so a new @smrt({ api: true }) object is not accidentally an open, fully-writable
endpoint. Four mechanisms, all grounded in @smrt() / @field() config.
1. Fail-closed authorization (api.public)
Omit public and the route is protected. Opt out explicitly when data is public:
@smrt({ api: true }) // default: every route requires auth
class Invoice extends SmrtObject {}
@smrt({ api: { public: 'read' } }) // list/get public; writes still need auth
class BlogPost extends SmrtObject {}
@smrt({ api: { public: true } }) // fully public (use only for genuinely public data)
class StatusPage extends SmrtObject {}2. Sensitive fields (@field({ sensitive: true }))
Sensitive fields are still persisted, but the framework excludes them from toPublicJSON() -- the serializer used by every generated route -- so they never
appear in responses, and rejects them as where-clause filter
keys, closing the ?secret[like]=... value-probing oracle.
3. Read-only fields (@field({ readonly: true }))
Read-only fields are stripped from the request body before create/update,
so callers cannot mass-assign them. Server-side code can still set them directly.
4. Writable allowlist (api.writable)
Generated create/update handlers run the body through a
mass-assignment guard. Framework/server-managed fields (id, tenantId, timestamps, _-prefixed) and any readonly field are always stripped. Setting writable additionally restricts writes to that allowlist.
import { smrt, SmrtObject, field } from '@happyvertical/smrt-core';
@smrt({
api: {
public: 'read', // reads public, writes require auth
writable: ['name', 'price'], // only these may be set from the request body
},
})
class Product extends SmrtObject {
name: string = '';
price: number = 0.0;
@field({ sensitive: true })
supplierCost: number = 0; // never serialized; not filterable
@field({ readonly: true })
sku: string = ''; // stripped from create/update bodies
}Custom-Method Routes (api.routes)
Methods on a @smrt() class beyond the five CRUD actions are also exposed over
HTTP. By default a custom route is a POST at the method name. Use api.routes to declare scope (item vs collection), HTTP verb, and path segment.
| Field | Effect |
|---|---|
scope: 'item' | Generates /<collection>/[id]/<path> (default for instance methods) |
scope: 'collection' | Generates /<collection>/<path> (default for static methods) |
method | HTTP verb (GET/POST/PUT/PATCH/DELETE); default POST |
path | URL segment override; defaults to the method name (wins over kebabRoutes) |
@smrt({
api: {
include: ['list', 'get', 'archive', 'browseFacts'],
routes: {
// POST /articles/[id]/archive
archive: { scope: 'item', method: 'POST' },
// GET /articles/facts
browseFacts: { scope: 'collection', method: 'GET', path: 'facts' },
},
},
})
class Article extends SmrtObject {
async archive() { /* instance method -> item scope */ }
static async browseFacts() { /* static method -> collection scope */ }
}Read Cache & Write-Invalidation
SSR pages re-query read-heavy, write-rare collections on every request. smrt-core ships an opt-in read-through cache that memoizes list()/get() result rows keyed by the final SQL + parameters, for a TTL you set.
Enable per call, or per model via the decorator:
// Per call
const published = await resumes.list({
where: { status: 'published' },
cache: { ttl: 60_000 }, // memoize for 60s
});
// Per model (becomes the default for that collection's reads)
@smrt({ cache: { ttl: 60_000 } })
class Resume extends SmrtObject {}
// Force a fresh read even when the model opted in
const fresh = await resumes.list({ where: { status: 'published' }, cache: false });Automatic write-invalidation. Because SMRT owns every mutation path
(save(), delete(), getOrUpsert(), junction
attach/detach), a successful write invalidates that table's cached entries in-process with no
manual step. Even a raw write issued through collection.query() is treated as a
mutation and invalidates. Entries are scoped per database identity and per table, so
multi-DB processes and STI siblings stay coherent.
Context Memory
Every SmrtObject can persist named, scoped values to the _smrt_contexts system table via remember() / recall(). It's a lightweight learned-pattern store -- e.g. an agent caching how
to parse a given site -- keyed by (owner_class, owner_id, scope, key, version),
with an optional confidence score (0-1, default 1.0).
// Store a learned pattern (upserts on the same scope+key+version)
await agent.remember({
scope: 'parser/example.com',
key: normalizedUrl,
value: { patterns: ['regex1', 'regex2'] },
confidence: 0.9,
});
// Retrieve it; walk up parent scopes if not found at this level
const strategy = await agent.recall({
scope: 'parser/example.com/article',
key: normalizedUrl,
includeAncestors: true, // 'a/b/c' -> 'a/b' -> 'a' -> 'global'
minConfidence: 0.6,
});Related methods: recallAll(scope) to read every entry in a scope, and forget(...) to delete entries. remember()/recall() require initialize() to have run (they need the system DB).
Semantic Search & Embeddings
Declare embeddings in @smrt() and the listed fields get vector
embeddings stored in the _smrt_embeddings system table. Embeddings are
auto-generated on save() (only when content changes, via a content hash), then
you can run cosine-similarity search.
@smrt({
embeddings: {
fields: ['title', 'body'],
provider: 'auto', // 'local' (@xenova/transformers), 'ai', or 'auto'
autoGenerate: true, // embed on save (default true)
regenerateOnChange: true, // re-embed only when content changes (default true)
},
})
class Article extends SmrtObject {
title: string = '';
body: string = '';
}// Semantic search: embeds the query, ranks by cosine similarity
const results = await articles.semanticSearch('machine learning trends', {
limit: 10,
minSimilarity: 0.7, // 0-1 threshold (default 0)
});
for (const article of results) {
console.log(`${article.title} (similarity: ${article._similarity})`);
}
// "More like this" from an existing object (or its id)
const seed = await articles.get(someId);
const similar = await articles.findSimilar(seed, { limit: 5, excludeSelf: true });Cross-Package References (@crossPackageRef)
@foreignKey() links objects in the same package and emits a real SQL FK
constraint. When the target class lives in another package, @crossPackageRef() registers the relationship without a DB-level constraint --
adding one would require the classes to be visible at schema-generation time and would force
a circular package dependency. The column stays a plain TEXT (UUID) id.
import { smrt, SmrtObject, crossPackageRef } from '@happyvertical/smrt-core';
@smrt()
class Customer extends SmrtObject {
// Target named as @package/scope:ClassName
@crossPackageRef('@happyvertical/smrt-profiles:Profile')
profileId: string = '';
// Opt into save-time existence validation (catches typos / stale ids)
@crossPackageRef('@happyvertical/smrt-profiles:Profile', { validate: true })
primaryContactId: string = '';
}What you gain over a plain string id: the relationship is registered with the ObjectRegistry, so loadRelated('profileId') and Collection.list({ include: ['profileId'] }) resolve it once the target
package's manifest is loaded; and with validate: true, save() confirms the referenced object exists before the row lands.
Database Support
- SQLite --
{ type: 'sqlite', url: 'app.db' } - PostgreSQL --
{ type: 'postgres', url: 'postgres://...' } - DuckDB --
{ type: 'duckdb', url: 'data.db' } - JSON --
{ type: 'json', url: 'data.json' }(testing only)
// String shortcut (auto-detects type)
const collection = await ProductCollection.create({ db: 'products.db' });
// Explicit config
const collection = await ProductCollection.create({
db: { type: 'sqlite', url: 'products.db' },
});
// DatabaseInterface instance from @happyvertical/sql
import { getDatabase } from '@happyvertical/sql';
const db = await getDatabase({ type: 'postgres', url: process.env.DATABASE_URL });
const collection = await ProductCollection.create({ db });Gotchas
- Never override
toJSON()--toJSON()handles STI discriminator + meta field extraction. UsetransformJSON()for custom serialization. - Property init order: TypeScript initializers run first, then
initialize()applies option values (options win). - No runtime schema creation: application tables must be prepared explicitly via migrations/tooling. The runtime verifies tables exist and fails clearly if they don't.
- Retry logic:
db.get()retries 3x at 250ms;db.upsert()retries 3x at 500ms. Tune by wrapping or replacing if you need different behavior. - Field caching:
_cachedFieldsis populated duringCollection.create()-- eliminates asyncgetFields()per query. - Smart cloning: arrays/objects are shallow-cloned during property init to prevent aliasing (Issue #22).
- Table verification cache:
isTableVerified(dbUrl, tableName)avoids redundanttableExists()calls across collections. - Manifest required: build-time AST scanning produces the manifest, and it is
the Vite
smrtPlugin(see Vite Plugin) that generates it for your app -- not smrt-vitest, which only wires the same scan into the test runtime. Without a manifest you'll see"No field metadata"errors. - Vite plugin loads scanner from
dist/first: the plugin prefersdist/and only falls back tosrc/on fresh clones, so rebuild after editingsrc/scanner/*orsrc/schema/generator.ts(see the Vite Plugin note). Sniffing.tsvs.jsviaimport.meta.urlwas non-deterministic under tsx and broke 12-13 publishes (#1139).