Skip to content

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 ​

bash
pnpm add @atscript/moost-db @moostjs/event-http moost

You also need a database adapter:

bash
# Pick one (or more)
pnpm add @atscript/db-sqlite better-sqlite3   # SQLite
pnpm add @atscript/db-mongo mongodb           # MongoDB

Minimal Working Example ​

1. Define Your Schema ​

Create a .as file with @db.* annotations:

atscript
// 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 ​

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

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

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

bash
# 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/query

Controller Types ​

AsDbController (Full CRUD) ​

Provides both read and write endpoints for tables:

MethodPathDescription
GET/queryList records with filtering, sorting, pagination
GET/pagesPaginated results with metadata
GET/one/:idSingle record by primary key
POST/Insert one or many records
PUT/Replace one or many records
PATCH/Update one or many records
DELETE/:idDelete by primary key
GET/metaTable metadata for UI tooling

Use @TableController to wire it up:

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

MethodPathDescription
GET/queryList records with filtering, sorting, pagination
GET/pagesPaginated results with metadata
GET/one/:idSingle record by primary key
GET/metaTable/view metadata

Use @ReadableController (or the alias @ViewController):

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

typescript
@TableController(todosTable, "api/v1/todos")
export class TodoController extends AsDbController<typeof Todo> {}

Override via registration:

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

atscript
@db.table 'todos'
@db.http.path '/api/v1/todos'
export interface Todo {
  @meta.id
  id: number
  title: string
}
typescript
// 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:

  1. canonical: true on 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: false takes a mount out of consideration.
  2. Without a marker, a single mount wins.
  3. Otherwise the mount whose prefix came from the model's own @db.http.path (the decorator was used without an explicit prefix) wins.
  4. 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.
typescript
@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 /meta always carries that controller's own route, whichever mount is canonical.
  • Two @ImportController sites for the same class can only be told apart by subclassing one of them.
  • Two canonical: true mounts 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:

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

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

typescript
// 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.crud shape and the applyMetaOverlay per-request hook
  • HTTP Client — Browser-compatible TypeScript client for these endpoints

Released under the MIT License.