@happyvertical/smrt-core

ORM, code generation, AI integration, and the DispatchBus. Everything else in the SMRT framework builds on this.

v0.29.34Core FoundationESM

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 globalThis for 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

bash
npm install @happyvertical/smrt-core

SDK Dependencies

smrt-core builds on the HappyVertical SDK:

bash
npm install @happyvertical/ai @happyvertical/sql @happyvertical/files @happyvertical/utils

Quick Start

Define a SMRT object in a few lines:

1. Define Your Object

typescript
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.

typescript
const products = await ProductCollection.create({
  db: 'products.db'  // SQLite database
});

3. CRUD Operations

typescript
// 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

typescript
// 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

ClassFilePurpose
SmrtObjectsrc/object.tsBase persistent object -- save, delete, is(), do(), loadFromId/Slug
SmrtCollectionsrc/collection.tsCRUD collection -- list, get, create, delete, getOrUpsert
ObjectRegistrysrc/registry.tsGlobal singleton on globalThis -- class metadata, fields, STI chains, manifests
DispatchBussrc/dispatch/bus.tsInter-agent messaging -- emit, subscribe (persistent), process
GlobalInterceptorssrc/interceptors.tsPlugin 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 if id/slug is provided
  • save() upserts with STI validation, interceptor execution, and auto-embeddings
  • is(criteria) / do(instructions) -- AI operations via function calling
  • getSlug() -- auto-generates from nametitlelabelid
  • loadRelated(fieldName) -- lazy-loads relationships (cached in _loadedRelationships Map)

SmrtCollection Query

typescript
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)

typescript
// 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

typescript
// 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 name
  • tableStrategy -- 'cti' (default) or 'sti'
  • conflictColumns -- natural-key tuple used by upsert() (required on junction / upsert tables)
  • api / mcp / cli -- generator config (boolean or { include: [...] })
  • ai -- callable methods exposed during do()
  • hooks -- beforeSave / afterSave / beforeDelete / afterDelete
  • embeddings -- auto-generate embeddings on save
  • tenantScoped -- read by @happyvertical/smrt-tenancy
  • agent -- 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.

typescript
@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 record
  • on(pattern, handler) -- in-memory handler, fires immediately
  • subscribe({ signalType, subscriber }) -- persistent subscription that survives restarts
  • process(subscriberName, handler) -- process pending dispatches
  • Wildcards: campaign.* matches campaign.completed (single segment only)
  • Tables: _smrt_dispatch, _smrt_dispatch_subscriptions
  • Status lifecycle: pending → processing → completed (or failed)
typescript
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:

typescript
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:

typescript
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.

typescript
@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 needed

Single Table Inheritance (STI)

  • Base: @smrt({ tableStrategy: 'sti' }) -- children inherit, share one table
  • Discriminator: _meta_type column with qualified names like @happyvertical/smrt-content:Article
  • Child-specific fields: @meta() decorator stores them in _meta_data JSONB instead of as columns
  • Polymorphic queries: the collection reads _meta_type and constructs the correct subclass dynamically
  • Validation: save() fails fast if _meta_type is missing or mismatched
typescript
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

GeneratorLocationOutput
REST APIsrc/generators/rest.tsOpenAPI-compliant CRUD endpoints
CLIsrc/generators/cli.tsCommander commands with auto-help
MCP Serversrc/generators/mcp.tsModel Context Protocol tools

CLI Commands (Auto-Generated)

bash
# 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.

typescript
// 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.

typescript
// 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

PathWhat it is
src/routes/api/<collection>/+server.tsCollection routes: GET (list), POST (create)
src/routes/api/<collection>/[id]/+server.tsItem routes: GET / PUT / DELETE
src/lib/server/smrt.tsCentral config + per-object objectOverrides (not overwritten once it exists)
src/lib/server/smrt-register.tsImports every object so the @smrt() decorators run (regenerated)
.smrt/manifest.jsonManifest 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:

typescript
// 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:

typescript
@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.

typescript
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.

FieldEffect
scope: 'item'Generates /<collection>/[id]/<path> (default for instance methods)
scope: 'collection'Generates /<collection>/<path> (default for static methods)
methodHTTP verb (GET/POST/PUT/PATCH/DELETE); default POST
pathURL segment override; defaults to the method name (wins over kebabRoutes)
typescript
@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:

typescript
// 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).

typescript
// 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.

typescript
@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 = '';
}
typescript
// 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.

typescript
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)
typescript
// 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. Use transformJSON() 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: _cachedFields is populated during Collection.create() -- eliminates async getFields() per query.
  • Smart cloning: arrays/objects are shallow-cloned during property init to prevent aliasing (Issue #22).
  • Table verification cache: isTableVerified(dbUrl, tableName) avoids redundant tableExists() 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 prefers dist/ and only falls back to src/ on fresh clones, so rebuild after editing src/scanner/* or src/schema/generator.ts (see the Vite Plugin note). Sniffing .ts vs .js via import.meta.url was non-deterministic under tsx and broke 12-13 publishes (#1139).

Next Steps