HTTP Setup
@atscript/moost-db provides zero-boilerplate REST controllers that expose your tables and views as HTTP endpoints via the Moost framework. Define your schema once in a .as file, wire up a table, and get a full CRUD API with no endpoint code to write.
Installation
pnpm add @atscript/moost-db @moostjs/event-http moostYou also need a database adapter:
# Pick one (or more)
pnpm add @atscript/db-sqlite better-sqlite3 # SQLite
pnpm add @atscript/db-mongo mongodb # MongoDBMinimal Working Example
1. Define Your Schema
Create a .as file with @db.* annotations:
// schema/todo.as
@db.table 'todos'
export interface Todo {
@meta.id
@db.default.increment
id: number
title: string
description?: string
@db.default false
completed: boolean
@db.default 'medium'
priority: string
createdAt?: number.timestamp.created
}2. Create DbSpace and Table
import { DbSpace } from "@atscript/db";
import { BetterSqlite3Driver, SqliteAdapter } from "@atscript/db-sqlite";
import { Todo } from "./schema/todo.as";
const driver = new BetterSqlite3Driver("./todos.db");
const dbSpace = new DbSpace(() => new SqliteAdapter(driver));
const todosTable = dbSpace.getTable(Todo);3. Create the Controller
Extend AsDbController and apply the @TableController decorator:
import { AsDbController, TableController } from "@atscript/moost-db";
import { Todo } from "./schema/todo.as";
import { todosTable } from "./db";
@TableController(todosTable)
export class TodoController extends AsDbController<typeof Todo> {}That single line gives you a complete CRUD API — no endpoint methods to write.
4. Register in Moost App
import { Moost } from "moost";
import { MoostHttp } from "@moostjs/event-http";
import { TodoController } from "./controllers/todo.controller";
const app = new Moost();
app.adapter(new MoostHttp()).listen(3000);
app.registerControllers(["todos", TodoController]);
await app.init();5. Try It
# Insert a record
curl -X POST http://localhost:3000/todos/ \
-H "Content-Type: application/json" \
-d '{"title": "Buy milk", "priority": "high"}'
# List all records
curl http://localhost:3000/todos/queryController Types
AsDbController (Full CRUD)
Provides both read and write endpoints for tables:
| Method | Path | Description |
|---|---|---|
GET | /query | List records with filtering, sorting, pagination |
GET | /pages | Paginated results with metadata |
GET | /one/:id | Single record by primary key |
POST | / | Insert one or many records |
PUT | / | Replace one or many records |
PATCH | / | Update one or many records |
DELETE | /:id | Delete by primary key |
GET | /meta | Table metadata for UI tooling |
Use @TableController to wire it up:
@TableController(todosTable)
export class TodoController extends AsDbController<typeof Todo> {}See CRUD Endpoints for detailed documentation of each endpoint.
AsDbReadableController (Read-Only)
Provides only read endpoints — no write operations. Use for views or tables where writes aren't needed:
| Method | Path | Description |
|---|---|---|
GET | /query | List records with filtering, sorting, pagination |
GET | /pages | Paginated results with metadata |
GET | /one/:id | Single record by primary key |
GET | /meta | Table/view metadata |
Use @ReadableController (or the alias @ViewController):
import { AsDbReadableController, ReadableController } from "@atscript/moost-db";
import { ActiveTask } from "./schema/active-tasks.as";
import { activeTasksView } from "./db";
@ReadableController(activeTasksView)
export class ActiveTasksController extends AsDbReadableController<typeof ActiveTask> {}POST, PUT, PATCH, and DELETE are not registered — clients receive 404 for these methods.
ViewController
@ViewController is an alias for @ReadableController — they are interchangeable. Use whichever reads better in context.
Route Configuration
By default, the controller route prefix is derived from the @db.table (or @db.view) name.
Override via decorator:
@TableController(todosTable, "api/v1/todos")
export class TodoController extends AsDbController<typeof Todo> {}Override via registration:
app.registerControllers(["api/v1/todos", TodoController]);Both @ReadableController and @ViewController accept the same optional prefix argument.
Override via schema annotation:
You can set the route path directly in the .as schema file using @db.http.path:
@db.table 'todos'
@db.http.path '/api/v1/todos'
export interface Todo {
@meta.id
id: number
title: string
}// No prefix needed — uses @db.http.path from the schema
@TableController(todosTable)
export class TodoController extends AsDbController<typeof Todo> {}Precedence: decorator prefix > @db.http.path annotation > table name.
After app.init(), each app publishes the model's value-help URL — the controller's final computed route, including parent route nesting and globalPrefix — as @db.http.path. It is derived from the route the controller is actually mounted on, never from whichever controller happened to be binding when it was constructed, so registration order and constructor injection don't matter. FK references in serialized types (/meta, /meta/form/:name) carry that URL for value-help resolution in the UI. The prefix the decorators derive uses the value you wrote in the schema, not a path published earlier in the same process.
Several controllers over one model
When one model is served on several routes in an app (a primary table plus an archive mount, a dictionary served by two controllers, …), exactly one route is its value-help URL. The app resolves it like this:
canonical: trueon a controller picks it. Set the option on the binding decorator (@TableController(Model, { canonical: true }), also@ReadableController/@ViewController) or, for a value-help controller, as the trailing constructor option (super(Dict, rows, app, "dict", { canonical: true })).canonical: falsetakes a mount out of consideration.- Without a marker, a single mount wins.
- Otherwise the mount whose prefix came from the model's own
@db.http.path(the decorator was used without an explicit prefix) wins. - Otherwise the model is ambiguous: no URL is published (references render no picker) and the app logs one warning naming the controllers and routes. Nothing throws.
@TableController(Ticket, { canonical: true })
class TicketController extends AsDbController<typeof Ticket> {}
@TableController(Ticket, "archive/tickets")
class TicketArchiveController extends AsDbController<typeof Ticket> {}
// references to Ticket use TicketController's route; the archive keeps its own route as the
// root of its own /meta- A route with a parameter or wildcard segment (
/db/:tenant/issues) never publishes a URL. - The root of a controller's own
/metaalways carries that controller's own route, whichever mount is canonical. - Two
@ImportControllersites for the same class can only be told apart by subclassing one of them. - Two
canonical: truemounts on different routes are a conflict (a warning and no URL). - A model no controller serves in the app keeps the value from the schema.
The value on the model's runtime metadata (type.metadata["db.http.path"]) mirrors the last app that published it; the wire (/meta) is always resolved for the app serving the request. A FOR_EVENT controller is published together with the app's singletons; an app whose readable controllers are all FOR_EVENT publishes on the first request to any readable controller or /meta. If you used an app-side @MoostInit hook to repair db.http.path, delete it (see Upgrading).
Multiple Controllers
Mount multiple tables and views on different route prefixes:
app.registerControllers(
["todos", TodoController],
["projects", ProjectController],
["stats", StatsViewController],
);Relationship to DbSpace
The controller operates on the same DbSpace that your programmatic code uses. This means:
- Shared transactions — HTTP operations participate in the same transaction context (via
AsyncLocalStorage) - Shared adapter instances — no separate connection pools for HTTP vs programmatic access
- Consistent behavior — the same filters, validations, and relation loading work identically
See Setup for details on configuring DbSpace.
Closing Spaces on Shutdown
closeDbSpaces() (since 0.1.148) closes every distinct space registered with provideDbSpace() — see Closing the space — and clears the registry. It is not hooked into Moost.dispose() automatically: the registry outlives app instances (under @moostjs/vite each hot reload disposes the old app while the next one reuses the registered space), so an automatic close would end the connection on every reload. Wire it into your shutdown path:
import { closeDbSpaces } from "@atscript/moost-db";
app.disposeOnSignals();
// in any DI singleton:
@MoostDispose()
close() {
return closeDbSpaces();
}For a single space, call space.close() after await app.dispose(). Requests that arrive after the close answer 503.
Adapter Agnostic
The same controller code works identically regardless of which database adapter backs the table. Swap the adapter in your table setup and the HTTP API stays unchanged:
// Switch from SQLite to MongoDB — no controller changes needed
import { MongoAdapter } from "@atscript/db-mongo";
const dbSpace = new DbSpace(() => new MongoAdapter(db, client));
const todosTable = dbSpace.getTable(Todo);Next Steps
- CRUD Endpoints — Detailed documentation for each endpoint
- URL Query Syntax — Filter, sort, and paginate via URL parameters
- Relations & Search in URLs — Relation loading, text search, vector search
- Customization — Hooks for access control, data transformation, and extending controllers
- Actions — Declare row, batch, and table-scoped operations that surface in
/meta - Permissions —
/meta.crudshape and theapplyMetaOverlayper-request hook - HTTP Client — Browser-compatible TypeScript client for these endpoints