--- URL: "db.atscript.dev/" LLMS_URL: "db.atscript.dev/index.md" layout: "home" hero2: text: "Your schema is your entire backend" tagline: "Tables, relations, views, sync, and REST — from a single .as file. No ORM configuration. No migration files. No boilerplate." actions: - theme: "brand" text: "Quick Start" link: "/guide/quick-start" - theme: "alt" text: "View on GitHub" link: "https://github.com/moostjs/atscript-db" --- --- URL: "db.atscript.dev/_experimental-warning" LLMS_URL: "db.atscript.dev/_experimental-warning.md" --- --- URL: "db.atscript.dev/adapters" LLMS_URL: "db.atscript.dev/adapters.md" outline: "deep" --- # Adapter Overview Atscript's DB layer is adapter-agnostic. The same `.as` definitions, queries, and operations work across all supported adapters. Each adapter translates the generic API to a specific database engine, handling differences in SQL dialects, type systems, indexing strategies, and schema management transparently. ## Quick Decision Guide **PostgreSQL** is the best choice for production workloads. It offers the most complete feature set: native foreign key enforcement, transactional DDL (schema changes are atomic), pgvector for vector similarity search, CITEXT for case-insensitive text, and GIN-based fulltext search. If you need a battle-tested relational database with advanced capabilities, PostgreSQL is the recommended adapter. **SQLite** is ideal for development and testing. Zero-config, single-file storage, and no server process make it the fastest way to get started. SQLite supports foreign keys via `PRAGMA foreign_keys` and fulltext search via FTS5 virtual tables. Its main limitation is ALTER TABLE support — column type changes and some modifications require table recreation. **MongoDB** is best for flexible schemas and document-oriented data. It stores nested objects natively (no flattening), supports native array patch operations (`$push`, `$pull`), and loads relations via `$lookup` in a single pipeline. Atlas Search enables text and vector search. Transactions require a replica set. **MySQL** is a solid choice for existing MySQL infrastructure. It offers wide hosting support, native FULLTEXT indexing, and `VECTOR(N)` support in MySQL 9.0+. In-place column modification via `ALTER TABLE MODIFY COLUMN` means schema changes are straightforward. ## Feature Comparison Matrix | Feature | PostgreSQL | SQLite | MongoDB | MySQL | | ---------------------------- | ----------------- | ------------------- | -------------------- | -------------- | | Native FK constraints | Yes | Yes (PRAGMA) | Emulated | Yes | | Transactional DDL | Yes | No | N/A | No | | Text search | GIN + tsvector | FTS5 | Atlas Search | FULLTEXT | | Vector search | pgvector | sqlite-vec (opt-in) | Atlas vectorSearch | VECTOR(N) 9.0+ | | JSON storage | JSONB | TEXT | Native | JSON | | Boolean type | Native | INTEGER 0/1 | Native | TINYINT(1) | | UUID generation | gen_random_uuid() | App-side | App-side | App-side | | Nested objects | Flattened | Flattened | Native | Flattened | | Native patch ops | No | No | Yes ($push/$pull) | No | | Native relations | No | No | Yes ($lookup) | No | | In-place column modify | Yes | No (recreate) | N/A | Yes | | Transactions | Full (incl. DDL) | Yes | Replica set required | Yes (no DDL) | | Schema namespaces | Schemas | No | No | Databases | | Adapter-specific annotations | `@db.pg.*` | None | `@db.mongo.*` | `@db.mysql.*` | ## Installation Quick Reference | Adapter | Package | Peer Dependency | Import | | ---------- | ----------------------- | ---------------- | ----------------- | | PostgreSQL | `@atscript/db-postgres` | `pg` | `PostgresAdapter` | | SQLite | `@atscript/db-sqlite` | `better-sqlite3` | `SqliteAdapter` | | MongoDB | `@atscript/db-mongo` | `mongodb` | `MongoAdapter` | | MySQL | `@atscript/db-mysql` | `mysql2` | `MysqlAdapter` | All adapters follow the same `DbSpace` + factory pattern: ```typescript import { DbSpace } from "@atscript/db"; import { PgDriver, PostgresAdapter } from "@atscript/db-postgres"; const driver = new PgDriver("postgresql://localhost:5432/mydb"); const db = new DbSpace(() => new PostgresAdapter(driver)); ``` Or use the shorthand `createAdapter` helper where available: ```typescript import { createAdapter } from "@atscript/db-postgres"; const db = createAdapter("postgresql://localhost:5432/mydb"); ``` ::: tip Each adapter page covers detailed setup, driver options, and adapter-specific configuration. See the individual pages linked in [Next Steps](#next-steps). ::: ## Capability Flags Adapters declare their capabilities via boolean flags on the `BaseDbAdapter` class. The generic DB layer reads these flags and adapts its behavior automatically — for example, skipping client-side object flattening when the adapter stores nested objects natively, or falling back to application-level cascade logic when the database does not enforce foreign keys. | Flag | Description | PG | SQLite | Mongo | MySQL | | ----------------------------- | ------------------------------ | :-: | :----: | :---: | :---: | | `supportsNativeForeignKeys` | DB enforces FK constraints | Yes | Yes | No | Yes | | `supportsNestedObjects` | Stores nested objects natively | No | No | Yes | No | | `supportsNativePatch` | Has native array operations | No | No | Yes | No | | `supportsNativeRelations` | Joins relations in one query | No | No | Yes | No | | `supportsNativeValueDefaults` | DB handles default values | Yes | Yes | No | Yes | | `supportsColumnModify` | ALTER COLUMN type changes | Yes | No | N/A | Yes | ::: info How the generic layer uses these flags When `supportsNativeForeignKeys` is `false`, the generic layer implements cascade and set-null behavior in application code before deleting parent records. When `supportsNestedObjects` is `false`, nested objects are automatically flattened to `__`-separated column names (e.g., `address__city`). When `supportsNativePatch` is `true`, patch operations like array `$push`/`$pull` are delegated directly to the adapter instead of being decomposed into read-modify-write cycles. ::: ## Next Steps - [PostgreSQL](./postgresql) — full setup, pgvector, CITEXT, connection pooling - [SQLite](./sqlite) — driver setup, FTS5 search, table recreation - [MongoDB](./mongodb) — Atlas Search, vector search, capped collections - [MySQL](./mysql) — engine/charset options, FULLTEXT, VECTOR support - [Creating Custom Adapters](./creating-adapters) — extend `BaseDbAdapter` for other databases - [Annotations Reference](./annotations) — all `@db.*` annotations in one place For general usage that applies to all adapters: - [Setup & Configuration](/guide/setup) — initial project setup - [CRUD Operations](/api/crud) — create, read, update, delete - [Query Filters](/api/queries) — filtering, sorting, pagination --- URL: "db.atscript.dev/adapters/annotations" LLMS_URL: "db.atscript.dev/adapters/annotations.md" outline: "deep" --- # Annotations Reference Complete reference for all `@db.*` annotations available in `.as` files. Generic annotations are provided by `@atscript/db/plugin` via `dbPlugin()` and work with every adapter. Adapter-specific annotations (PostgreSQL, MySQL, MongoDB) require the corresponding adapter plugin. ## Tables & Columns | Annotation | Applies To | Arguments | Description | | ----------------------- | ---------- | -------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `@db.table` | Interface | `name?` (string) | Mark as database table (defaults to interface name) | | `@db.table.renamed` | Interface | `oldName` (string) | Previous table name for [schema sync](../sync/what-gets-synced) migration | | `@db.schema` | Interface | `name` (string) | Assign to a database schema/namespace | | `@db.depth.limit` | Interface | `depth` (number) | Security guard on nested writes. Default `0` rejects any nested insert / replace / patch with HTTP 400. Raise `N` to allow deep writes up to that depth. Does not affect `/meta` shape. | | `@db.column` | Field | `name` (string) | Override the physical column name ([perf note](#db-column-perf)) | | `@db.column.renamed` | Field | `oldName` (string) | Previous column name for [schema sync](../sync/what-gets-synced) migration | | `@db.column.collate` | Field | `collation` (string) | Portable collation: `'binary'`, `'nocase'`, or `'unicode'` | | `@db.column.precision` | Field | `precision` (number), `scale` (number) | Decimal precision/scale for DB storage (e.g., `DECIMAL(10,2)`) | | `@db.column.dimension` | Field | — | Mark as dimension field — groupable in [aggregate queries](../views/aggregations) | | `@db.column.measure` | Field | — | Mark as measure field — aggregatable (sum, avg, count, min, max). Numeric/decimal only | | `@db.column.filterable` | Field | — | Allow this field in client-side filter clauses when the table is in `@db.table.filterable 'manual'` mode (see below) | | `@db.column.sortable` | Field | — | Allow this field in client-side sort keys when the table is in `@db.table.sortable 'manual'` mode (see below) | | `@db.column.version` | Field | — | Mark a non-optional `int` field as the row's OCC version. Auto-bumped on every write; combine with inline `$cas` for conflict detection. See [Optimistic Concurrency](/api/versioning) | | `@db.table.filterable` | Interface | `mode?` (`'auto'` \| `'manual'`) | Filter-gating mode. `'auto'` (default) keeps all columns filterable; `'manual'` requires `@db.column.filterable` on each filterable field | | `@db.table.sortable` | Interface | `mode?` (`'auto'` \| `'manual'`) | Sort-gating mode. `'auto'` (default) keeps all columns sortable; `'manual'` requires `@db.column.sortable` on each sortable field | | `@db.json` | Field | — | Store as a single JSON column instead of flattening | | `@db.ignore` | Field | — | Exclude field from the database schema entirely | ```atscript @db.table 'users' @db.schema 'auth' interface User { @db.column 'full_name' name: string @db.json preferences: Preferences @db.ignore computedField: string @db.column.collate 'nocase' username: string @db.column.precision 10, 2 price: number } ``` ::: warning @db.column performance {#db-column-perf} Only use `@db.column` when you have a genuine reason — such as mapping to a legacy schema or meeting an external naming convention you cannot change. When a table has no `@db.column`, nested objects, or `@db.json` fields, the read, filter, and patch paths take a zero-allocation fast path that skips key translation entirely. Adding even one `@db.column` activates per-row key remapping on every read, write, filter, and patch operation for that table. In high-throughput scenarios this overhead is measurable. If you control the database schema, prefer naming your Atscript fields to match the desired column names directly. See [Custom Column Names](../api/tables#custom-column-names) for more details. ::: ## Query Gate By default, every column on a `@db.table` is filterable and sortable — this preserves back-compat for tables where the author hasn't thought about the query surface. When a table should expose a **narrow** query surface (e.g., a customer-facing reports endpoint), opt into manual mode: ```atscript @db.table 'users' @db.table.filterable 'manual' @db.table.sortable 'manual' interface User { @meta.id id: number @db.column.filterable email: string @db.column.sortable createdAt: number.timestamp } ``` Semantics: - `@db.table.filterable 'manual'` makes the readable controller reject (HTTP 400) any filter clause that references a field without `@db.column.filterable`. - `@db.table.sortable 'manual'` does the same for sort keys against `@db.column.sortable`. - `@db.table.filterable 'auto'` / `@db.table.sortable 'auto'` are documentary no-ops — they match the default behaviour when the annotation is absent, but make the author's intent explicit. - The two gates are independent: a table can opt into strict filtering while leaving sort open, or vice versa. - Without the annotation, behaviour is unchanged from previous releases — all columns are filterable/sortable. The `/meta` endpoint reflects the gate: `fields[].filterable` and `.sortable` mirror the permissions that the server will enforce, so clients can hide controls for restricted columns. ## HTTP | Annotation | Applies To | Arguments | Description | | --------------- | ---------- | --------------- | ------------------------------------------------------------------------------------------------------------------------------------- | | `@db.http.path` | Interface | `path` (string) | HTTP endpoint path for this table. Used by UI for value-help on FK fields. Overwritten at runtime by the controller's computed prefix | ```atscript @db.table 'authors' @db.http.path '/authors' interface Author { @meta.id id: number name: string } ``` When a controller is registered without an explicit prefix, `@db.http.path` is used as the route. At runtime, the final computed prefix (including parent routes) is written back to `@db.http.path` on the type metadata, so FK references always carry the correct URL. ### Normalization contract The value carried in `type.metadata["db.http.path"]` has distinct semantics for writers and readers: - **Writers** (annotation at compile time): the value is an optional path hint. The controller's computed prefix takes precedence at runtime, so the annotation may be omitted or overridden by the mount point. - **Readers** (UI / client code / custom consumers): the runtime value is always (a) prefixed with a leading `/`, (b) inclusive of the Moost `globalPrefix`, and (c) the final public URL — usable verbatim with `fetch()` or `new Client(url)`. Example: an author writes `@db.http.path '/authors'`; a consumer reading `type.metadata["db.http.path"]` at runtime sees `/api/db/tables/authors` when the controller is mounted under `globalPrefix: '/api'` at `/db/tables/authors`. ## Depth Limit (security guard) `@db.depth.limit N` is a **security guard** on nested-write payloads — a declared ceiling on how deep a client can send nested inserts, replaces, or patches through `@db.rel.from` relations. Payloads deeper than `N` are rejected at the server boundary with HTTP 400 before any database access. Default when the annotation is absent is `0`, meaning no nested writes are accepted at all; authors opt in explicitly to `N >= 1` when they want the server to accept deep writes. This annotation affects **only write acceptance**. It does not change `/meta` serialization, read/query behaviour, or wire shape (see the separate note below). ```atscript @db.table 'authors' @db.depth.limit 2 interface Author { @meta.id id: number name: string @db.rel.from posts?: Post[] } ``` ::: danger BREAKING CHANGE Tables **without** `@db.depth.limit` are now treated as `@db.depth.limit 0`: the server **rejects any nested-write payload** (HTTP 400) before reaching the database. Previously the server accepted arbitrary-depth nested writes via the implicit `nested-writer`. That implicit behaviour has been removed: tables that require nested writes must opt in explicitly with `@db.depth.limit N` for the appropriate `N`. ::: ::: info `/meta` FK ref shape is independent `/meta` always ships FK fields as shallow refs (`{ id, metadata }`) regardless of `@db.depth.limit`. The target's `db.http.path` is carried in the ref metadata, so clients can resolve value-help URLs and fetch the target's own `/meta` on demand when they need deeper structure. Nav-prop trees (`@db.rel.from` / `@db.rel.to` / `@db.rel.via`) are fully expanded in meta regardless of this annotation, so the write-payload shape clients need is unaffected. (Prior releases shipped `refDepth: 1` and coupled meta expansion to `@db.depth.limit`; that coupling has been removed.) ::: ## Defaults | Annotation | Applies To | Arguments | Description | | ----------------------- | ---------- | ----------------- | -------------------------------------------------- | | `@db.default` | Field | `value` (string) | Static default value | | `@db.default.increment` | Field | `start?` (number) | Auto-incrementing integer (requires number type) | | `@db.default.uuid` | Field | — | Random UUID string (requires string type) | | `@db.default.now` | Field | — | Current timestamp (requires number or string type) | ```atscript @db.table interface Product { @meta.id @db.default.uuid id: string @db.default 'untitled' name: string @db.default.now createdAt: number } ``` ## Indexes | Annotation | Applies To | Arguments | Description | | -------------------- | ---------- | ------------------------------------ | ---------------------------------------------------------- | | `@db.index.plain` | Field | `name?` (string), `sort?` (string) | Standard index, optional sort direction (`'asc'`/`'desc'`) | | `@db.index.unique` | Field | `name?` (string) | Unique constraint index | | `@db.index.fulltext` | Field | `name?` (string), `weight?` (number) | Full-text search index with optional weight | Use the same index name on multiple fields to create a composite index. ```atscript @db.table interface Article { @db.index.unique slug: string @db.index.plain 'date_idx', 'desc' publishedAt: number // Composite index across two fields @db.index.plain 'author_cat' authorId: string @db.index.plain 'author_cat' category: string @db.index.fulltext 'search', 3 title: string @db.index.fulltext 'search', 1 body: string } ``` ## Search | Annotation | Applies To | Arguments | Description | | ----------------------------- | ---------- | -------------------------------------------------------------------- | ------------------------------------------- | | `@db.search.vector` | Field | `dimensions` (number), `similarity?` (string), `indexName?` (string) | Vector search field | | `@db.search.vector.threshold` | Field | `value` (number) | Default minimum similarity threshold (0--1) | | `@db.search.filter` | Field | `indexName` (string) | Pre-filter field for vector search | Similarity options: `'cosine'` (default), `'euclidean'`, `'dotProduct'`. Each adapter maps to its native vector type — see [Text Search](../search/) and [Vector Search](../search/vector-search). Allowed `dimensions` values (whitelisted at compile time): `256`, `384`, `512`, `768`, `1024`, `1536`, `2048`, `3072`, `4096`, `6144`, `8192`, `16384`. ```atscript @db.table interface Document { @db.search.vector 1536, 'cosine', 'doc_vec' @db.search.vector.threshold 0.7 embedding: db.vector @db.search.filter 'doc_vec' category: string } ``` ## Relations | Annotation | Applies To | Arguments | Description | | ------------------ | ---------- | ------------------ | --------------------------------------------------------------------------------------- | | `@db.rel.FK` | Field | `alias?` (string) | [Foreign key](../relations/) (field must use chain ref). **Dual role** — see note below | | `@db.rel.to` | Field | `alias?` (string) | Forward [navigation](../relations/navigation) (N:1, FK on this table) | | `@db.rel.from` | Field | `alias?` (string) | Reverse [navigation](../relations/navigation) (1:N, FK on other table) | | `@db.rel.via` | Field | `junction` (ref) | Many-to-many [navigation](../relations/navigation) through a junction table | | `@db.rel.onDelete` | Field | `action` (string) | Referential action on parent delete | | `@db.rel.onUpdate` | Field | `action` (string) | Referential action on parent update | | `@db.rel.filter` | Field | `condition` (expr) | Static filter condition on navigation property | ```atscript @db.table interface Task { @db.rel.FK @db.rel.onDelete 'cascade' projectId: Project.id @db.rel.to project: Project @db.rel.from comments: Comment[] @db.rel.via TaskTag tags: Tag[] @db.rel.from @db.rel.filter `status = 'open'` openSubtasks: Task[] } ``` ### `@db.rel.FK` dual role `@db.rel.FK` serves two purposes depending on the host interface: - On a `@db.table` interface it drives **DB-relation semantics** — the relation loader pairs it with `@db.rel.to` / `@db.rel.from`, it participates in `@db.rel.via` junction resolution, and the integrity layer validates it at write time. - On any other interface (value-help dictionaries, WF forms, plain interfaces) it acts purely as the **value-help indicator**: the client-side picker resolver reads `@db.rel.FK` to decide which fields render a value-help picker, and the URL for the picker comes from the target's `@db.http.path`. The host-restriction rule was relaxed so the same annotation covers both cases — authors don't need a separate marker for value-help. All other validation rules still apply (the target must be a chain reference to a `@meta.id` or `@db.index.unique` field). ### Referential Action Values For `@db.rel.onDelete` and `@db.rel.onUpdate`: | Action | Description | | -------------- | --------------------------------------------- | | `'cascade'` | Propagate delete/update to related rows | | `'restrict'` | Prevent operation if related rows exist | | `'noAction'` | Database default behavior (no action) | | `'setNull'` | Set FK to null (field must be optional) | | `'setDefault'` | Set FK to default value (needs `@db.default`) | ## Views | Annotation | Applies To | Arguments | Description | | ----------------------- | ---------- | ---------------------------------- | ------------------------------------------------------------------------ | | `@db.view` | Interface | `name?` (string) | Mark as database [view](../views/) (defaults to interface name) | | `@db.view.for` | Interface | `entry` (ref) | Entry/primary table for a managed view | | `@db.view.joins` | Interface | `target` (ref), `condition` (expr) | Explicit join clause (repeatable) | | `@db.view.filter` | Interface | `condition` (expr) | View WHERE clause | | `@db.view.having` | Interface | `condition` (expr) | Post-aggregation HAVING clause | | `@db.view.materialized` | Interface | — | Mark the view as materialized | | `@db.view.renamed` | Interface | `oldName` (string) | Previous view name for [schema sync](../sync/what-gets-synced) migration | ```atscript @db.view @db.view.for Task @db.view.joins Project, `Project.id = Task.projectId` @db.view.filter `Task.status = 'active'` interface ActiveTaskView { taskName: Task.name projectName: Project.name dueDate: Task.dueDate } ``` ## Aggregation | Annotation | Applies To | Arguments | Description | | --------------- | ---------- | ----------------- | --------------------------------------------------------------------------- | | `@db.agg.sum` | Field | `field` (string) | SUM of a source column (numeric/decimal only) | | `@db.agg.avg` | Field | `field` (string) | AVG of a source column (numeric/decimal only) | | `@db.agg.count` | Field | `field?` (string) | COUNT — omit argument for `COUNT(*)`, provide field name for non-null count | | `@db.agg.min` | Field | `field` (string) | MIN of a source column | | `@db.agg.max` | Field | `field` (string) | MAX of a source column | Use aggregation annotations on [view](../views/aggregations) fields together with `@db.column.dimension` on grouping fields. ```atscript @db.view @db.view.for Order @db.view.having `totalRevenue > 100` interface CategoryStats { @db.column.dimension category: Order.category @db.agg.sum 'amount' totalRevenue: number @db.agg.count orderCount: number @db.agg.avg 'amount' avgOrderValue: number } ``` ## Quantity Tagging (currency & unit) Bind a numeric field to its **dimension** (currency code, unit of measure) so the runtime can enforce correct aggregation grouping and the readable controller can guarantee the dimension is always shipped alongside the value. | Annotation | Applies To | Arguments | Description | | ------------------------- | ------------------------- | -------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `@db.amount.currency` | Field (`decimal`) | `code` (string) | Hard-coded ISO-style currency code (`'EUR'`, `'USD'`, `'BTC'`). Validated against `^[A-Z0-9]{2,10}$`. Schema-wide constant — no runtime aggregation constraint. UI reads it from `/meta`. | | `@db.amount.currency.ref` | Field (`decimal`) | `fieldName` (string) | Bind this amount to a sibling field that holds the per-row currency code. Sibling must exist and resolve to a `string` (preferably `db.currencyCode`). | | `@db.unit` | Field (`decimal\|number`) | `code` (string) | Hard-coded unit of measure (`'kg'`, `'rpm'`, `'qps'`, `'requests/sec'`). Free-form string — no shape validation. Schema-wide constant. | | `@db.unit.ref` | Field (`decimal\|number`) | `fieldName` (string) | Bind this quantity to a sibling field holding the per-row unit. Sibling must exist and resolve to a `string`. | The two forms (literal vs `.ref`) are mutually exclusive on the same field. Money-bearing fields must be `decimal` — floats lose cents. Quantity fields accept both `decimal` (weights, lengths) and `number` (counts, rates). ### `db.currencyCode` primitive Companion type for the `.ref` target — a `string` constrained to `^[A-Z0-9]{2,10}$` so non-currency strings can't be silently used as the dimension. ```atscript @db.table 'orders' interface Order { @meta.id @db.default.uuid id: string // Per-row currency: each line carries its own code. currency: db.currencyCode @db.amount.currency.ref 'currency' @db.column.measure amount: decimal } @db.table 'metrics' interface Metric { @meta.id @db.default.uuid id: string // Single-currency table: literal form, no sibling field. @db.amount.currency 'EUR' @db.column.measure fee: decimal // Mixed-unit measurement: ref form. unit: string @db.unit.ref 'unit' @db.column.measure weight: decimal // Single-unit metric: literal form. @db.unit 'qps' @db.column.measure rate: number } ``` ### Runtime behavior - **Aggregation guard.** When `aggregate()` is called against a field carrying `@db.amount.currency.ref` or `@db.unit.ref`, the referenced field MUST appear in `$groupBy`. Otherwise `DbError("INVALID_QUERY")`. Literal forms (`@db.amount.currency 'EUR'`, `@db.unit 'kg'`) impose no runtime constraint — the dimension is satisfied schema-wide. `COUNT(*)` is exempt. - **`$select` auto-widening.** The `moost-db` readable controller automatically adds the referenced sibling to `$select` whenever its tagged value is requested. UI can ask for `$select=amount` and still receive `currency` in the response. Literal forms are NOT widened — the constant is on the field descriptor, not row data. - **Field descriptors.** `TDbFieldMeta` exposes `currencyCode` / `currencyRefField` / `unitCode` / `unitRefField` so clients can format quantities correctly without inspecting raw annotations. ## Schema Sync | Annotation | Applies To | Arguments | Description | | ----------------- | ---------- | ----------------- | --------------------------------------- | | `@db.sync.method` | Interface | `method` (string) | Sync strategy: `'drop'` or `'recreate'` | - **`'drop'`** — Drop and recreate the table on structural changes (lossy, data is deleted). - **`'recreate'`** — Recreate with data preservation on structural changes. ## Patch Behavior | Annotation | Applies To | Arguments | Description | | -------------------- | ---------- | ------------------- | ---------------------------------- | | `@db.patch.strategy` | Field | `strategy` (string) | `'replace'` (default) or `'merge'` | Controls how nested objects are handled during PATCH/update operations. With `'replace'`, the entire nested object is overwritten. With `'merge'`, individual sub-fields are deep-merged. ## PostgreSQL-Specific {#postgresql} These annotations require the `@atscript/db-postgres` plugin. See [PostgreSQL adapter](./postgresql). | Annotation | Applies To | Arguments | Description | | ---------------- | ----------------- | -------------------- | ------------------------------------------------------------------ | | `@db.pg.type` | Field | `type` (string) | Override native PG column type (e.g., `CITEXT`, `INET`, `MACADDR`) | | `@db.pg.schema` | Interface | `schema` (string) | PostgreSQL schema (default: `public`) | | `@db.pg.collate` | Interface / Field | `collation` (string) | Native PG collation (overrides portable `@db.column.collate`) | ```atscript use '@atscript/db-postgres' @db.table 'users' @db.pg.schema 'auth' interface User { @meta.id @db.default.uuid id: string @db.pg.type 'CITEXT' email: string @db.pg.collate 'tr-x-icu' name: string } ``` ## MySQL-Specific {#mysql} These annotations require the `@atscript/db-mysql` plugin. See [MySQL adapter](./mysql). | Annotation | Applies To | Arguments | Description | | -------------------- | ----------------- | --------------------- | ------------------------------------------------------------------ | | `@db.mysql.engine` | Interface | `engine` (string) | Storage engine (default: `InnoDB`) | | `@db.mysql.charset` | Interface / Field | `charset` (string) | Character set (default: `utf8mb4`) | | `@db.mysql.collate` | Interface / Field | `collation` (string) | Native MySQL collation (overrides portable `@db.column.collate`) | | `@db.mysql.unsigned` | Field | — | UNSIGNED modifier for integer columns | | `@db.mysql.type` | Field | `type` (string) | Override native MySQL column type (e.g., `MEDIUMTEXT`, `TINYTEXT`) | | `@db.mysql.onUpdate` | Field | `expression` (string) | ON UPDATE expression (e.g., `CURRENT_TIMESTAMP`) | ```atscript use '@atscript/db-mysql' @db.table 'events' @db.mysql.engine 'InnoDB' @db.mysql.charset 'utf8mb4' interface Event { @meta.id @db.default.increment id: number @db.mysql.type 'MEDIUMTEXT' description: string @db.mysql.unsigned viewCount: number @db.default.now @db.mysql.onUpdate 'CURRENT_TIMESTAMP' updatedAt: number } ``` ## MongoDB-Specific {#mongodb} These annotations require the `@atscript/db-mongo` plugin. See [MongoDB adapter](./mongodb). | Annotation | Applies To | Arguments | Description | | ------------------------------- | ---------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | | `@db.mongo.collection` | Interface | — | Mark as MongoDB collection (auto-injects `_id`) | | `@db.mongo.capped` | Interface | `size` (number), `max?` (number) | Capped collection with max byte size and optional doc limit | | `@db.mongo.search.dynamic` | Interface | `analyzer?` (string), `fuzzy?` (number) | Dynamic Atlas Search index | | `@db.mongo.search.static` | Interface | `analyzer?` (string), `fuzzy?` (number), `indexName?` (string), `strategy?` (string) | Named static Atlas Search index. `strategy`: `compound` (default) / `autocomplete` / `text` | | `@db.mongo.search.text` | Field | `analyzer?` (string), `indexName?` (string) | Include field in a search index (word match) | | `@db.mongo.search.autocomplete` | Field | `indexName?` (string), `tokenization?` (string), `minGrams?` (number), `maxGrams?` (number), `foldDiacritics?` (boolean), `analyzer?` (string) | Prefix/typeahead field (double-mapped as `string`). `tokenization`: `edgeGram` (prefix, default) / `nGram` (substring) / `rightEdgeGram` (suffix) | When `indexName` is omitted it defaults to `"DEFAULT"` — interface-level and field-level annotations referencing the same name are merged into one Atlas Search index. See [MongoDB adapter — Atlas Search](./mongodb#atlas-search). ```atscript use '@atscript/db-mongo' @db.table 'products' @db.mongo.collection @db.mongo.search.static 'lucene.english', 1, 'main_search' interface Product { @meta.id _id: mongo.objectId @db.mongo.search.text 'lucene.english', 'main_search' name: string @db.search.vector 1536, 'cosine', 'vec_idx' embedding: number[] @db.search.filter 'vec_idx' category: string } ``` ::: info Generic search annotations `@db.search.vector` and `@db.search.filter` are generic annotations (not MongoDB-specific) and work across all adapters that support vector search. See the [Search](#search) section above. ::: ## Related Annotations {#related} These are not `@db.*` annotations but are commonly used alongside the database layer. | Annotation | Applies To | Arguments | Description | | --------------------------- | ---------- | --------- | ---------------------------------------------------------------- | | `@meta.id` | Field | — | Mark as primary key field (multiple fields form a composite key) | | `@expect.array.key` | Field | — | Array element key field for patch matching | | `@expect.array.uniqueItems` | Field | — | Enforce unique items in an array | ```atscript @db.table interface OrderLine { // Composite primary key @meta.id orderId: Order.id @meta.id productId: Product.id quantity: number } ``` --- URL: "db.atscript.dev/adapters/creating-adapters" LLMS_URL: "db.atscript.dev/adapters/creating-adapters.md" outline: "deep" --- # Creating Custom Adapters ::: warning Advanced / contributor reference This page is for authors writing a **new** Atscript DB adapter for a database that does not have one yet. If you are building an application on top of an existing adapter (PostgreSQL, SQLite, MongoDB, MySQL), **you do not need this page** — start with the relevant adapter page and the [CRUD docs](/api/crud) instead. The interfaces and hooks documented below are not part of the consumer-facing public API: they are stable enough to write adapters against, but they describe how to extend the generic layer, not how to use it. ::: You can create adapters for any database by extending `BaseDbAdapter` from `@atscript/db`. This guide covers the full interface — every abstract method you must implement, every optional hook you can override, and how your adapter plugs into the rest of the system. ## Architecture Your adapter sits between the table API and the database: ``` AtscriptDbTable → BaseDbAdapter (your adapter) → Database ``` The table handles query translation, field flattening, relation orchestration, validation, and default values. Your adapter handles raw CRUD operations and DDL — it receives pre-processed data and query objects, and returns results in a standard format. When an `AtscriptDbTable` is created with your adapter, it registers itself via `registerReadable()`. From that point, you can access all computed table metadata through `this._table`. ## Getting Started Extend `BaseDbAdapter` and implement the abstract methods: ```typescript import { BaseDbAdapter } from "@atscript/db"; export class PostgresAdapter extends BaseDbAdapter { constructor(private pool: Pool) { super(); } // implement abstract methods (see below) } ``` Each adapter instance is bound to a single table or view. The `DbSpace` factory creates one instance per table, keeping adapter state (cached queries, table metadata) isolated. ## Required Methods These are abstract — every adapter must implement all of them. ### Insert - **`insertOne(data)`** — Insert a single record. Returns `TDbInsertResult` with `{ insertedId }`. - **`insertMany(data)`** — Insert multiple records. Returns `TDbInsertManyResult` with `{ insertedCount, insertedIds }`. Data is already validated, defaults applied, and columns mapped by the table layer. Your adapter only needs to translate to the database's native insert syntax. ::: tip Use `this._resolveInsertedId(data, dbGeneratedId)` in your `insertOne` implementation to return the correct inserted ID. It prefers the user-supplied primary key value from the data over the DB-generated fallback (e.g., `RETURNING id`, `lastInsertRowid`). ::: ### Read - **`findOne(query)`** — Find a single record matching the query. Returns the record or `null`. The `query` object contains `filter` (WHERE conditions) and `controls` (sort, limit, skip, select). - **`findMany(query)`** — Find all records matching the query. Returns an array of records. - **`count(query)`** — Count records matching the query filter. Returns a number. ### Update - **`updateOne(filter, data)`** — Update a single record matching the filter. Returns `TDbUpdateResult` with `{ matchedCount, modifiedCount }`. - **`updateMany(filter, data)`** — Update all records matching the filter. - **`replaceOne(filter, data)`** — Full replacement of a single record (all columns overwritten). - **`replaceMany(filter, data)`** — Full replacement of all matching records. ### Delete - **`deleteOne(filter)`** — Delete a single record matching the filter. Returns `TDbDeleteResult` with `{ deletedCount }`. - **`deleteMany(filter)`** — Delete all records matching the filter. ### Schema - **`ensureTable()`** — Create the table/collection if it does not exist. Use `this._table.tableName`, `this._table.fieldDescriptors`, and `this._table.foreignKeys` to build the DDL. - **`syncIndexes()`** — Synchronize indexes between Atscript definitions and the database. Use `this._table.indexes` for the desired index state. ::: tip Data passed to insert/update/replace methods is **already processed** by the table layer — defaults applied, `@db.ignore` fields stripped, column names mapped. Your adapter only needs to translate to the database's native query language. ::: ## Capability Flags Override these methods to declare what your database supports. All return `false` by default. The generic DB layer reads these flags and adapts its behavior automatically. ### `supportsNativePatch()` Return `true` if your database handles array patch operators natively (e.g., MongoDB's `$push`, `$pull`). When `false`, the table layer decomposes patch operations into read-modify-write cycles using standard `updateOne`. ### `supportsNestedObjects()` Return `true` if your database stores nested objects natively (e.g., MongoDB embedded documents). When `true`, the table layer skips flattening and passes nested objects as-is. When `false`, nested objects are flattened to `__`-separated column names (e.g., `address__city`). ### `supportsNativeForeignKeys()` Return `true` if your database enforces FK constraints at the engine level (e.g., SQLite with `PRAGMA foreign_keys = ON`, PostgreSQL). When `true`, the table layer skips application-level cascade/setNull logic on delete. When `false`, the table layer handles cascade by finding and deleting/nullifying child records before the parent. ### `supportsNativeRelations()` Return `true` to handle `$with` relation loading natively via database features like SQL JOINs or MongoDB `$lookup`. When `false`, the table layer uses application-level batch loading — issuing separate queries per relation and stitching results together. ### `supportsNativeValueDefaults()` Return `true` if the database handles static `@db.default "value"` natively via column-level `DEFAULT` clauses in `CREATE TABLE`. When `true`, the table layer's `_applyDefaults()` skips client-side value defaults, letting the DB apply its own DEFAULT. SQL adapters typically return `true`; document stores (MongoDB) return `false`. ### `nativeDefaultFns()` Return a `ReadonlySet` of default function names that the database handles natively. Fields with these defaults are omitted from INSERT when no value is provided, letting the DB apply its own DEFAULT expression (e.g., `CURRENT_TIMESTAMP`, `gen_random_uuid()`). ```typescript nativeDefaultFns(): ReadonlySet { return new Set(['now', 'uuid']) // DB handles NOW() and UUID() natively } ``` The generic layer checks this in `_applyDefaults()` to decide whether to generate the value client-side or leave it for the DB. ### `supportsColumnModify` This is a **property** (not a method). Set to `true` if the adapter can handle column type changes in-place via `ALTER TABLE MODIFY COLUMN` (e.g., MySQL, PostgreSQL) without requiring table recreation. The generic sync layer will delegate type changes to `syncColumns()` instead of requiring `@db.sync.method "recreate"` or `"drop"`. ```typescript supportsColumnModify = true; ``` ## Transaction Support Override three protected methods to enable transactions: ```typescript protected async _beginTransaction(): Promise { // Start a transaction, return opaque state (e.g., a session or client object) const client = await this.pool.connect() await client.query('BEGIN') return client } protected async _commitTransaction(state: unknown): Promise { const client = state as PoolClient await client.query('COMMIT') client.release() } protected async _rollbackTransaction(state: unknown): Promise { const client = state as PoolClient await client.query('ROLLBACK') client.release() } ``` The `state` value you return from `_beginTransaction` is passed to commit and rollback. Use it to carry database-specific context (e.g., a MongoDB `ClientSession`, a dedicated connection from a pool). Transaction context is tracked via `AsyncLocalStorage` — nested `withTransaction()` calls within the same async chain automatically reuse the existing transaction. Inside any method, call `this._getTransactionState()` to retrieve the current transaction state. ### Advanced: Custom Transaction Flow If your database has a specialized transaction API (e.g., MongoDB's `session.withTransaction()`), override `withTransaction()` directly and use `_runInTransactionContext(state, fn)` to set up the shared context. This ensures that nested adapters within the same async chain see the same transaction state. If a context already exists (nesting), it is reused. ## Adapter Hooks These optional methods are called during table initialization when the table scans its type metadata. ### `onBeforeFlatten(type)` Called before field scanning begins. Use this to extract table-level adapter-specific annotations. ```typescript onBeforeFlatten(type: TAtscriptAnnotatedType): void { // Example: read a table-level annotation const engine = type.metadata?.get('db.mysql.engine') if (engine) this.tableEngine = engine as string } ``` ### `onFieldScanned(field, type, metadata)` Called for each field during the scanning process. Fields nested under navigation relations (`@db.rel.to/from/via`) are never delivered to this callback — adapters do not need to filter them. ```typescript onFieldScanned( field: string, type: TAtscriptAnnotatedType, metadata: TMetadataMap ): void { // Example: register vector search fields const vector = metadata.get('db.search.vector') if (vector) this.vectorFields.set(field, vector) } ``` ### `onAfterFlatten()` Called after all fields are scanned. Finalize any computed state here. You can access the fully populated `this._table` at this point. ```typescript onAfterFlatten(): void { // Example: build search index configuration from collected fields this.searchConfig = buildSearchConfig(this.textFields, this.vectorFields) } ``` ### `getAdapterTableName(type)` Return an adapter-specific table name, or `undefined` to fall back to `@db.table` or the interface name. ```typescript getAdapterTableName(type: TAtscriptAnnotatedType): string | undefined { // Example: read from a custom annotation return type.metadata?.get('db.postgres.table') as string | undefined } ``` ### `getMetadataOverrides()` Return metadata overrides applied during the build pipeline. Called after field scanning/classification, before field descriptors are built. Use this to adjust primary keys, inject synthetic fields, or register unique constraints — instead of mutating metadata via back-references. ```typescript getMetadataOverrides(meta: TableMetadata): TMetadataOverrides | undefined { // Example: MongoDB always uses _id as primary key return { primaryKeys: new Set(['_id']), } } ``` ## ID Preparation Override `prepareId(id, fieldType)` to transform primary key values before they are used in queries. This is called when building filters for `findById`, relation loading, and other ID-based lookups. ```typescript prepareId(id: unknown, fieldType: TAtscriptAnnotatedType): unknown { // Example: convert string IDs to MongoDB ObjectId return new ObjectId(id as string) } ``` The default implementation returns `id` unchanged. ## Native Operations ### Native Patch If `supportsNativePatch()` returns `true`, implement `nativePatch(filter, patch)`. Convert patch operators (e.g., `{ $push: { tags: 'new' } }`) to your database's native update operations and return `TDbUpdateResult` with `{ matchedCount, modifiedCount }`. When `supportsNativePatch()` returns `false` (the default), the table layer decomposes patch operations into read-modify-write cycles. ### Native Relation Loading If `supportsNativeRelations()` returns `true`, implement `loadRelations(rows, withRelations, relations, foreignKeys, tableResolver?)`. Enrich the provided rows in place with related data using your database's native features (e.g., MongoDB `$lookup`, SQL JOINs). When `supportsNativeRelations()` returns `false` (the default), the table layer handles relation loading by issuing separate queries per relation. ## Schema Sync Methods These optional methods enable the schema sync system (`asc db sync`) to introspect, diff, and apply changes to your database. Implement them if you want automatic schema migration support. ### Introspection #### `getExistingColumns()` Return the current table structure as an array of `TExistingColumn` (name, type, nullability, default, PK status). The sync system diffs these against the current Atscript field descriptors to determine what has changed. Query `information_schema.columns` or your database's equivalent to build the result. #### `getExistingColumnsForTable(tableName)` Same as `getExistingColumns()` but for an arbitrary table name (not the adapter's own table). Used by schema sync's `plan()` to inspect a table under its old name before a rename operation. #### `tableExists()` Return whether the table/collection exists in the database. Used by schema-less adapters (e.g., MongoDB) that skip column introspection. The sync system uses this to determine create vs. in-sync status. #### `getExistingTableOptions()` Return the current table-level options from the live database. This is the primary source for option diffing (DB-first strategy). Returns an array of `TExistingTableOption` (key-value pairs) or `undefined` if the adapter cannot introspect table options. #### `getDesiredTableOptions()` Return table-level options as declared by Atscript annotations. Called after `onBeforeFlatten`/`onAfterFlatten`, so adapter-specific state (e.g., engine, charset, capped options) is already populated. Values are stringified for consistent comparison against existing options. #### `destructiveOptionKeys()` Return a `ReadonlySet` of option keys where a value change requires full table recreation (drop + recreate). Option keys not in this set are treated as non-destructive changes and handled by `applyTableOptions()`. ```typescript destructiveOptionKeys(): ReadonlySet { // Changing the engine requires recreation return new Set(['engine']) } ``` ### Applying Changes #### `syncColumns(diff)` Apply column-level changes from a computed diff. The diff object contains `added`, `renamed`, and `typeChanged` arrays. Execute `ALTER TABLE ADD COLUMN`, `RENAME COLUMN`, and `ALTER COLUMN TYPE` statements (or equivalent DDL) for each entry. Returns a `TSyncColumnResult` indicating what was applied. #### `recreateTable()` Full table recreation with data migration. Used when structural changes cannot be handled by `ALTER TABLE` (e.g., column drops in SQLite, or when `@db.sync.method "recreate"` is specified). Typical pattern: create a temporary table with the new schema, copy data (only columns that exist in both old and new), drop the old table, rename the temp table to the original name. #### `renameTable(oldName)` Rename the table from `oldName` to the adapter's current table name. Used when `@db.table.renamed` is present. #### `afterSyncTable()` Post-sync hook called after all table operations (columns, indexes, FKs) are complete. Adapters can use this for finalization work such as resetting auto-increment sequences to match existing data. #### `applyTableOptions(changes)` Apply non-destructive table option changes. Called for each changed option that is not in `destructiveOptionKeys()`. Destructive changes go through `dropTable()` + `ensureTable()` or `recreateTable()` instead. ### Destructive Operations #### `dropTable()` Drop the adapter's own table. Used by `@db.sync.method "drop"` for tables with ephemeral data. #### `dropTableByName(name)` Drop a table by name, without needing a registered readable. Used by schema sync to remove tables that are no longer present in the schema. #### `dropColumns(columns)` Drop specific columns from the table. Used by schema sync to remove stale columns no longer in the Atscript definitions. #### `dropIndexesForColumns(columns)` Drop managed (`atscript__`-prefixed) indexes that reference any of the given columns. Called by schema sync **before** `dropColumns` — engines like SQLite refuse to drop a column while an index still references it, and a composite index that survives by name must be rebuilt without the removed column (`syncIndexes` recreates it afterwards). Implement this if your engine does not cascade index drops on `DROP COLUMN`. ### Views #### `ensureView(view)` Create or update a database view. Called when the adapter's readable is an `AtscriptDbView`. The `view` parameter contains the view definition, including the source table, joins, and filter expressions. #### `dropViewByName(name)` Drop a view by name. Used by schema sync to remove views that are no longer present in the schema. ### Foreign Keys #### `syncForeignKeys()` Synchronize foreign key constraints between Atscript definitions and the database. Uses `this._table.foreignKeys` for the full FK definitions. #### `dropForeignKeys(fkFieldKeys)` Drop FK constraints identified by their canonical local column key (sorted local field names, comma-joined). Called by the sync executor before column operations to remove stale FKs that would otherwise block `ALTER COLUMN`. ### Type Mapping #### `typeMapper(field)` Map a field's metadata to the adapter's native column type string. Receives the full field descriptor (`TDbFieldMeta` with design type, annotations, PK status, etc.) for context-aware type decisions — e.g., `VARCHAR(255)` from `maxLength`, `SERIAL` for numeric PKs, `JSONB` for `@db.json` fields. Used by schema sync to detect column type changes (comparing the desired type from `typeMapper` against the existing type from `getExistingColumns`). #### `prepareTypeMapper()` Resolve any runtime state `typeMapper` depends on — e.g. detecting whether a vector extension is installed. Schema sync calls this **before** hashing the desired schema. The contract: `typeMapper` must return the same string at hash time and at DDL time; if its output can change based on lazily-detected capabilities, perform that detection here, or the stored hash will never match and sync will re-run on every boot. #### `formatValue(field)` Return a value formatter for a field, or `undefined` if no formatting is needed. Called once per field during the build phase. The returned formatter(s) are cached and applied during write preparation, filter translation, and read reconstruction. Can return: - A bare function: used as `toStorage` only (write + filter paths) - A `TValueFormatterPair` with `toStorage` and `fromStorage`: bidirectional formatting - `undefined`: no formatting needed ```typescript formatValue(field: TDbFieldMeta): TValueFormatterPair | undefined { // Example: MySQL TIMESTAMP stored as datetime string, exposed as epoch ms if (field.designType === 'date') { return { toStorage: (v: unknown) => new Date(v as number).toISOString(), fromStorage: (v: unknown) => new Date(v as string).getTime(), } } return undefined } ``` This avoids per-value method dispatch — only fields that need formatting get a formatter function, and the generic layer skips fields without one. ## Index Sync Helper `BaseDbAdapter` provides `syncIndexesWithDiff()` — a template method that handles the diff algorithm for index synchronization. You provide the three database-specific primitives: ```typescript async syncIndexes(): Promise { await this.syncIndexesWithDiff({ listExisting: async () => { // Return existing indexes as [{ name: string, columns?: string[] }]. // Include the ordered column list so the helper can detect definition // drift (e.g. a composite index that gained a member keeps its name). return this.pool.query( 'SELECT indexname AS name FROM pg_indexes WHERE tablename = $1', [this._table.tableName] ) }, createIndex: async (index) => { // Create a single index — index has key, fields, type ('plain'|'unique') const cols = index.fields.map(f => `"${f.name}" ${f.sort}`).join(', ') await this.pool.query( `CREATE ${index.type === 'unique' ? 'UNIQUE ' : ''}INDEX "${index.key}" ON ${this.resolveTableName()} (${cols})` ) }, dropIndex: async (name) => { await this.pool.query(`DROP INDEX "${name}"`) }, // Optional: skip index types your DB doesn't support shouldSkipType: (type) => type === 'fulltext', }) } ``` The helper: 1. Lists existing indexes via `listExisting` 2. Filters to managed ones (those with the `atscript__` prefix) 3. Creates missing indexes via `createIndex` 4. Rebuilds plain/unique indexes whose column list no longer matches the model (only when `listExisting` provides `columns`) 5. Drops stale indexes via `dropIndex` You can override the prefix via the `prefix` option (defaults to `'atscript__'`). ## Search and Vector Search Override these methods to add text search and vector similarity search capabilities to your adapter. ### Text Search #### `search(text, query, indexName?)` Full-text search. Receives the search text, a standard `DbQuery` for additional filtering/pagination, and an optional index name to target a specific search index. Build a search query using your database's text search capabilities (e.g., PostgreSQL `ts_query`, MongoDB `$text`, MySQL `MATCH...AGAINST`). #### `searchWithCount(text, query, indexName?)` Same as `search()` but also returns the total count (for paginated search results). Returns `{ data, count }`. ### Vector Search #### `vectorSearch(vector, query, indexName?)` Vector similarity search. Receives a pre-computed embedding vector (`number[]`), a standard `DbQuery`, and an optional index name for multi-vector documents. Build a similarity query using your database's vector capabilities (e.g., pgvector `<->` operator, MongoDB `$vectorSearch`). #### `vectorSearchWithCount(vector, query, indexName?)` Same as `vectorSearch()` but also returns the total count. Returns `{ data, count }`. ### Geo Search #### `geoSearch(point, query, indexName?)` / `geoSearchWithCount(...)` Distance-ranked geospatial search. Receives a `[lng, lat]` query point; must return rows sorted by distance ascending, each carrying a `$distance` field in meters. `$maxDistance`/`$minDistance` arrive in `query.controls`. The default implementations throw `GEO_NOT_SUPPORTED`. SQL adapters can build the query with the shared helpers from `@atscript/db-sql-tools` (`buildGeoSearchSelect`, `buildGeoSearchCount`, `renameGeoDistance`) plus a dialect-specific distance expression; `_resolveGeoColumn(indexName?)` (protected, on `BaseDbAdapter`) resolves the geo index to its physical column. #### `isGeoSearchable()` Whether the adapter supports `geoSearch()` and the `$geoWithin` filter operator. Defaults to `false`. The core query guards consult this before translating `$geoWithin`, so a dialect without a `geoWithin` hook never sees the operator. #### `SqlDialect.geoWithin(quotedCol, circle)` (db-sql-tools) Optional dialect hook translating `$geoWithin: { center, radius }` into a SQL predicate fragment. Dialects without it cause the shared filter visitor to throw `GEO_NOT_SUPPORTED` — never a silent scan. ### Search Metadata #### `isSearchable()` Whether the adapter supports text search. Defaults to `true` when `getSearchIndexes()` returns any entries. Override for custom logic. #### `isVectorSearchable()` Whether the adapter supports vector similarity search. Defaults to `false`. Override in adapters that support vector search. #### `getSearchIndexes()` Return available search indexes for this adapter as `TSearchIndexInfo[]`. Used by UI to show an index picker and by the generic layer to validate search requests. ```typescript getSearchIndexes(): TSearchIndexInfo[] { return [ { name: 'default', type: 'text', fields: ['title', 'body'] }, { name: 'embedding', type: 'vector', fields: ['embedding'] }, ] } ``` ## Optimized Pagination ### `findManyWithCount(query)` Fetches records and total count in one call. The default implementation issues two parallel calls (`findMany` + `count`). Override for single-query optimization if your database supports it (e.g., `COUNT(*) OVER()` window function in PostgreSQL). Returns `{ data, count }`. ## Validation Plugins Override `getValidatorPlugins()` to return adapter-specific `TValidatorPlugin[]` rules that are merged with the built-in Atscript validators. Each plugin has a `name` and a `validate(value, type, path)` function that can transform values (e.g., auto-generate MongoDB `ObjectId` for `_id` fields) or reject invalid input. ## Accessing Table Metadata Inside your adapter, `this._table` provides access to all computed metadata: | Property | Description | | ---------------------------------- | -------------------------------------------------------- | | `this._table.tableName` | Resolved table/collection name | | `this._table.schema` | Database schema (if applicable) | | `this._table.flatMap` | All fields after flattening (dot-notation paths) | | `this._table.primaryKeys` | Set of primary key field names | | `this._table.columnMap` | Logical field name to physical column name mappings | | `this._table.indexes` | Computed index definitions from `@db.index` annotations | | `this._table.foreignKeys` | FK definitions from `@db.rel.FK` annotations | | `this._table.defaults` | Default value configurations from `@db.default` | | `this._table.fieldDescriptors` | Full field metadata (type, nullability, PK, storage) | | `this._table.ignoredFields` | Fields excluded from the database via `@db.ignore` | | `this._table.uniqueProps` | Single-field unique index properties | | `this._table.isView` | Whether this readable is a view (vs a table) | | `this._table.originalMetaIdFields` | Fields annotated with `@meta.id` (before column mapping) | The `resolveTableName()` method on the adapter itself returns the full table name, optionally including the schema prefix. Override it for databases that don't support schemas: ```typescript override resolveTableName(): string { return super.resolveTableName(false) // exclude schema prefix } ``` ### Logging The adapter includes a built-in logging facility. Call `this._log(...)` to emit debug-level messages when verbose mode is enabled. Verbose mode is toggled via `setVerbose(enabled)`. When disabled, no log strings are constructed — zero overhead. ## Registration Use your adapter with `DbSpace` to create tables: ```typescript import { DbSpace } from "@atscript/db"; const db = new DbSpace(() => new PostgresAdapter(pool)); // Create typed tables const users = db.getTable(UsersType); const posts = db.getTable(PostsType); // Tables share the adapter factory — each gets its own instance await users.ensureTable(); await posts.ensureTable(); ``` `DbSpace` calls your factory function for each table, so every table gets its own adapter instance. This keeps adapter state (table metadata, cached queries) isolated per table. ## Next Steps - [PostgreSQL](./postgresql) — reference implementation for a full-featured SQL adapter - [MongoDB](./mongodb) — advanced implementation with native nested objects, patch operators, and search - [Schema Sync](../sync/) — how the sync system uses adapter methods to manage migrations --- URL: "db.atscript.dev/adapters/mongodb" LLMS_URL: "db.atscript.dev/adapters/mongodb.md" outline: "deep" --- # MongoDB The MongoDB adapter (`@atscript/db-mongo`) connects your `.as` models to MongoDB with native nested object storage, aggregation pipelines, Atlas Search, and vector search. It translates annotation-driven CRUD operations into native MongoDB queries while preserving the same `AtscriptDbTable` API used by all adapters. ## Installation ```bash pnpm add @atscript/db-mongo mongodb ``` Register the MongoDB plugin in your `atscript.config.mts` to enable `@db.mongo.*` annotations and `mongo.*` primitives: ```typescript import { defineConfig } from "@atscript/core"; import ts from "@atscript/typescript"; import { dbPlugin } from "@atscript/db/plugin"; import { MongoPlugin } from "@atscript/db-mongo"; export default defineConfig({ plugins: [ts(), dbPlugin(), MongoPlugin()], }); ``` `dbPlugin()` is **required** — it registers all portable `@db.*` annotations. See [Setup](/guide/setup) for full configuration details. ## Setup Create a `DbSpace` with a `MongoAdapter` factory: ```typescript import { DbSpace } from "@atscript/db"; import { MongoAdapter } from "@atscript/db-mongo"; import { MongoClient } from "mongodb"; const client = new MongoClient("mongodb://localhost:27017"); const mongoDb = client.db("myapp"); const db = new DbSpace(() => new MongoAdapter(mongoDb, client)); ``` The second constructor argument (`client`) enables transaction support. If you do not need transactions, `new MongoAdapter(mongoDb)` without the client is sufficient. ::: tip Optional `mongodb` peer deps The `mongodb` driver declares several optional peers (e.g. `aws4` for `MONGODB-AWS`, `kerberos`, `mongodb-client-encryption`) that pnpm won't install for you. If you hit `MongoMissingDependencyError` in production but not locally, see the [mongodb optional dependencies docs](https://www.mongodb.com/docs/drivers/node/current/get-started/installation/) — this is upstream, not an atscript-db concern. ::: Or use the convenience helper: ```typescript import { createAdapter } from "@atscript/db-mongo"; const db = createAdapter("mongodb://localhost:27017/myapp"); ``` `createAdapter` creates a `MongoClient` (connection is lazy — established on first query), extracts the database from the connection string, and returns a ready-to-use `DbSpace`. Once you have a `DbSpace`, get a table handle for any `.as` type: ```typescript import { User } from "./schema/user.as"; const users = db.getTable(User); const user = await users.findById(1); ``` Run `npx asc db sync` to create or update collections and indexes. See [Schema Sync](../sync/) for details. ### Connection recipes **Local replica set (transactions enabled).** MongoDB transactions require a replica set or sharded topology. The simplest local setup is a single-node replica set: ```typescript const client = new MongoClient("mongodb://localhost:27017/myapp?replicaSet=rs0"); const db = new DbSpace(() => new MongoAdapter(client.db(), client)); ``` **Single-node test container (`directConnection`).** When pointing at a single-instance container that advertises itself under a different hostname (e.g., a Testcontainers MongoDB), set `directConnection=true` to skip topology discovery: ```typescript const client = new MongoClient("mongodb://localhost:27017/test?directConnection=true"); ``` **In-process MongoDB for tests (`mongodb-memory-server`).** Spin up an ephemeral MongoDB inside the test process — no Docker required: ```bash pnpm add -D mongodb-memory-server ``` ```typescript import { MongoMemoryServer } from "mongodb-memory-server"; import { MongoClient } from "mongodb"; import { DbSpace } from "@atscript/db"; import { MongoAdapter } from "@atscript/db-mongo"; const mongod = await MongoMemoryServer.create(); const client = new MongoClient(mongod.getUri()); const db = new DbSpace(() => new MongoAdapter(client.db("test"), client)); // after tests: await client.close(); await mongod.stop(); ``` For transaction support inside tests, use `MongoMemoryReplSet.create()` instead. ## MongoDB-Specific Annotations These annotations are available when the MongoDB plugin is registered. They extend the generic `@db.*` namespace with MongoDB-specific behavior. | Annotation | Level | Purpose | | ----------------------------------------------------------------------------------------------------------- | --------- | -------------------------------------------------------------------------------------------------------------------------------- | | `@db.mongo.collection` | Interface | Mark as MongoDB collection, auto-inject `_id` | | `@db.mongo.capped size, max?` | Interface | Capped collection with size limit | | `@db.mongo.search.dynamic analyzer?, fuzzy?` | Interface | Dynamic Atlas Search index | | `@db.mongo.search.static analyzer?, fuzzy?, indexName?, strategy?` | Interface | Named static Atlas Search index. Repeatable. `indexName?` defaults to `"DEFAULT"`. `strategy?`: `compound`/`autocomplete`/`text` | | `@db.mongo.search.text analyzer?, indexName?` | Field | Include field in a search index as a **word-matched** field. Repeatable. `indexName?` defaults to `"DEFAULT"` | | `@db.mongo.search.autocomplete indexName?, tokenization?, minGrams?, maxGrams?, foldDiacritics?, analyzer?` | Field | Include field as a **prefix/typeahead** field (double-mapped as `string`) | All generic `@db.*` annotations (`@db.table`, `@db.index.*`, `@db.default.*`, `@db.rel.*`, `@db.json`, `@db.search.vector`, `@db.search.filter`, etc.) work with MongoDB as well. See the [Annotations Reference](./annotations) for the full list. ## Primitives ### `mongo.objectId` A string type constrained to 24-character hex strings matching the MongoDB ObjectId format. Used for `_id` fields. At runtime, the adapter converts these strings to native `ObjectId` instances automatically. ```atscript @db.table 'users' @db.mongo.collection export interface User { // _id: mongo.objectId is auto-injected by @db.mongo.collection name: string } ``` ### `db.vector` Vector embedding fields use the core `db.vector` primitive (`number[]`, registered by `dbPlugin()`) — there is no Mongo-specific vector primitive. Pair it with `@db.search.vector` to declare a vector search index. ```atscript @db.search.vector 1536, 'dotProduct', 'embeddings_idx' embedding: db.vector ``` ## Primary Keys & \_id MongoDB always uses `_id` as the document primary key. The adapter enforces this regardless of your schema: - **Auto-injection** — `@db.mongo.collection` adds `_id: mongo.objectId` if not declared. The `_id` field is always non-optional. - **Custom `@meta.id` fields** — Marking a non-`_id` field with `@meta.id` does not make it a MongoDB primary key. Instead, the adapter creates a unique index on it and registers it for fallback lookups. - **`findById` resolution** — First tries `_id`, then falls back to fields marked with `@meta.id`. So `findById(42)` works when `42` is an auto-incremented `id` field rather than an ObjectId. - **`prepareId()` conversion** — Automatically converts string IDs to `ObjectId` instances (for `mongo.objectId` fields) or to numbers (for numeric `_id` fields), so you can pass string values from URL parameters directly. ```typescript // All of these work: await users.findById(new ObjectId("507f1f77bcf86cd799439011")); // by _id await users.findById("507f1f77bcf86cd799439011"); // string -> ObjectId await users.findById(42); // by @meta.id field ``` **ID types**: ObjectId (default), string, or number. ## Auto-Increment The `@db.default.increment` annotation enables auto-increment behavior for numeric fields: ```atscript @meta.id @db.default.increment id: number ``` The adapter uses an `__atscript_counters` collection for atomic sequence allocation via `findOneAndUpdate` with `$inc`. Each counter is keyed by `{collection}.{field}`. - On `insertOne`, the counter is atomically incremented by 1 and the value is assigned. - On `insertMany`, the counter is incremented by the batch size to pre-allocate a range. Values are assigned in order. - If a document already has an explicit value for the field, that value is used as-is and no counter allocation occurs. Note: this does **not** advance the counter, so subsequent auto-incremented values may collide with manually provided ones. Pair with `@db.index.unique` to catch duplicates. ::: warning Concurrent inserts under high contention could produce duplicate values in rare cases. For guaranteed uniqueness, combine `@db.default.increment` with `@db.index.unique`. ::: ## Nested Objects Unlike relational databases where nested objects are flattened into `__`-separated columns, MongoDB stores nested objects natively. The adapter skips flattening entirely — nested JavaScript objects are passed through to MongoDB as-is and read back without reconstruction. ```atscript @db.table 'users' @db.mongo.collection export interface User { @meta.id @db.default.increment id: number name: string contact: { email: string phone?: string } } ``` Dot-notation queries work directly: ```typescript const result = await users.findMany({ filter: { "contact.email": "alice@example.com" }, controls: { $sort: { "contact.phone": 1 } }, }); ``` ::: tip The `@db.json` annotation has no effect on MongoDB — there is no flattening to override. You can still use it for documentation purposes, but it does not change storage behavior. ::: ## Native Patch Pipelines MongoDB uses aggregation pipelines for array patch operations instead of the read-modify-write cycle used by relational adapters. All five patch operators are supported: - **`$insert`** — Append items to an array - **`$remove`** — Remove items matching a condition - **`$update`** — Update matching items in place - **`$upsert`** — Update if exists, insert if not - **`$replace`** — Replace the entire array This is transparent to your code — the same patch API works across all adapters, but MongoDB executes updates atomically on the server using `$concatArrays`, `$filter`, `$map`, and other aggregation operators. See [Patch Operations](/api/update-patch) for the full API. ## Native Relation Loading The adapter uses MongoDB `$lookup` aggregation stages for TO, FROM, and VIA relations instead of issuing separate queries. This means relation loading happens in a single round-trip to the database. - **TO relations** — `$lookup` with `localField` / `foreignField` - **FROM relations** — Reverse `$lookup` from the related collection - **VIA relations** — Two-stage `$lookup` through the junction collection Relation controls (`$sort`, `$limit`, `$filter`) are applied as pipeline stages within the `$lookup`. Nested lookups (relations of relations) are supported. See [Relations](/relations/) for details. ## Text Search Standard MongoDB text search uses the generic `@db.index.fulltext` annotation. This works on **all MongoDB deployments** — standalone, replica sets, and Atlas. ```atscript @db.table 'articles' @db.mongo.collection export interface Article { @meta.id _id: mongo.objectId @db.index.fulltext 'content_idx' title: string @db.index.fulltext 'content_idx', 2 body: string } ``` Fields sharing the same index name (`'content_idx'`) form a **composite text index**. The optional second argument is a weight — here `body` has weight `2`, making matches in it score twice as high as `title` (default weight `1`). Query with `search()`: ```typescript const results = await articles.search("mongodb tutorial"); ``` See [Text Search](/search/) for the full guide. ## Atlas Search Atlas Search brings full-text search powered by **Apache Lucene** to your MongoDB collections. It supports fuzzy matching, language-aware analyzers, and custom scoring — but requires a **MongoDB Atlas** deployment. ### Dynamic Atlas Search `@db.mongo.search.dynamic` auto-indexes every string field in the collection: ```atscript @db.table 'products' @db.mongo.collection @db.mongo.search.dynamic 'lucene.english', 1 export interface Product { @meta.id _id: mongo.objectId title: string description: string category: string } ``` Arguments: 1. **Analyzer** — the Lucene analyzer to use (e.g., `'lucene.english'`) 2. **Fuzzy level** — typo tolerance (`0`, `1`, or `2`) All string fields are searchable immediately with no per-field annotations needed. ### Static Atlas Search `@db.mongo.search.static` creates a named index where you control exactly which fields are searchable and which analyzer each uses: ```atscript @db.table 'products' @db.mongo.collection @db.mongo.search.static 'lucene.english', 0, 'product_search' export interface Product { @meta.id _id: mongo.objectId @db.mongo.search.text 'lucene.english', 'product_search' title: string @db.mongo.search.text 'lucene.standard', 'product_search' description: string // Not included in the search index sku: string price: number } ``` Arguments for `@db.mongo.search.static`: 1. **Default analyzer** — fallback analyzer for the index 2. **Fuzzy level** — query-time typo tolerance (see [Fuzzy Search](#fuzzy-search)) 3. **Index name** — identifies the index for queries (the `$index` control) 4. **Strategy** — the query shape: `compound` (default), `autocomplete`, or `text` (see [Match Strategy](#match-strategy)) Each `@db.mongo.search.text` field can use a different analyzer while belonging to the same named index. ### Autocomplete & Typeahead A plain search index matches whole words — `"art"` will not match `"Artem"`. For **as-you-type** matching, annotate the field with `@db.mongo.search.autocomplete`: ```atscript @db.table 'users' @db.mongo.collection @db.mongo.search.static 'lucene.english', 0, 'people' export interface User { @meta.id _id: mongo.objectId @db.mongo.search.autocomplete 'people' username: string } ``` This indexes the field as an Atlas `autocomplete` type **and** double-maps it as a plain `string`, so exact-word hits still rank. Now `?$search=art` matches `"Artem"` as you type. The `tokenization` argument picks how partial matching works: | `tokenization` | Matches | Example | | -------------------- | ----------------------------- | --------------------- | | `edgeGram` (default) | **prefix** — start of a word | `"art"` → `"Artem"` | | `nGram` | **substring** — inside a word | `"tem"` → `"Artem"` | | `rightEdgeGram` | **suffix** — end of a word | `"sev"` → `"Maltsev"` | `edgeGram` (prefix) covers the typical search-box case at the lowest index cost; reach for `nGram` only when you need true mid-word matching (larger index, slower builds). Other arguments default to `minGrams: 2`, `maxGrams: 15`, `foldDiacritics: true` (so `"cafe"` matches `"café"`). ### Match Strategy The `strategy` argument on `@db.mongo.search.static` locks how a term is matched against the index — there is no per-query mode switching: | `strategy` | Query shape | | -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `compound` (default) | Word match **and** prefix — exact-word hits rank above prefix hits. Falls back to plain word match when the index has no autocomplete field (so unset behaves like before). | | `autocomplete` | Prefix/typeahead **only** — no word-match clause. | | `text` | Word matching **only** — ignores autocomplete tokenization. | ```atscript @db.mongo.search.static 'lucene.english', 0, 'people_prefix', 'autocomplete' ``` `strategy` affects only the query — the Atlas index definition is identical regardless. ### Search Variants Each index encodes one behavior. To match the **same field** different ways, declare a second index and select it per request with `$index` — one field can join several indexes: ```atscript @db.table 'users' @db.mongo.collection @db.mongo.search.static 'lucene.english', 0, 'users_exact' // word match @db.mongo.search.static 'lucene.english', 1, 'users_prefix', 'autocomplete' // typeahead + fuzzy export interface User { @meta.id _id: mongo.objectId @db.mongo.search.text 'lucene.english', 'users_exact' @db.mongo.search.autocomplete 'users_prefix' username: string } ``` `?$search=art` → the first-declared index (`users_exact`, word match). `?$search=art&$index=users_prefix` → the typeahead variant. Same data, two locked behaviors, no query-time modes. ### Supported Analyzers Atlas Search uses Apache Lucene analyzers. The plugin whitelists the following values: | Analyzer | Description | | ------------------- | --------------------------------------------------------- | | `lucene.standard` | General-purpose tokenizer, lowercases, removes stop words | | `lucene.simple` | Lowercases and splits on non-letter characters | | `lucene.whitespace` | Splits on whitespace only, no lowercasing | | `lucene.english` | English-specific with stemming ("running" matches "run") | | `lucene.french` | French stemming and stop words | | `lucene.german` | German stemming and stop words | | `lucene.italian` | Italian stemming and stop words | | `lucene.portuguese` | Portuguese stemming and stop words | | `lucene.spanish` | Spanish stemming and stop words | | `lucene.chinese` | Chinese tokenization | | `lucene.hindi` | Hindi tokenization | | `lucene.bengali` | Bengali tokenization | | `lucene.russian` | Russian stemming and stop words | | `lucene.arabic` | Arabic stemming and stop words | See the [MongoDB Atlas docs](https://www.mongodb.com/docs/atlas/atlas-search/analyzers/) for descriptions and tokenization rules. ### Fuzzy Search The fuzzy parameter controls typo tolerance using Levenshtein edit distance, applied **at query time** to the search operator. Declare it on the index (`@db.mongo.search.static`/`.dynamic`) and it applies to every `$search` automatically: - **`0`** (default) — no fuzzy, exact tokens only - **`1`** — one edit allowed (e.g., "mango" matches "mongo") - **`2`** — two edits allowed (e.g., "saerch" matches "search") Atlas only honors an edit distance of `1` or `2`; `0` simply disables it. Higher values increase recall at the cost of precision — `1` is a good default. Callers can override the declared value per request with the `$fuzzy` control: `?$search=mongo&$fuzzy=2` widens tolerance, `?$search=mongo&$fuzzy=0` disables it for that one request. See [HTTP — Relations & Search](/http/advanced#text-search). ### Searching at Runtime Both text indexes and Atlas Search use the same API: ```typescript // Basic search (uses the best available index) const results = await table.search("search query", {}); // Search with filters and pagination const { data, count } = await table.searchWithCount("query", { filter: { category: "tech" }, controls: { $limit: 20, $skip: 0 }, }); // Target a specific named index const results = await table.search("query", {}, "product_search"); ``` ## Vector Search MongoDB supports vector similarity search via Atlas `$vectorSearch`. Use the generic `@db.search.vector` annotation with the `db.vector` primitive: ```atscript @db.search.vector 1536, 'cosine', 'doc_vectors' embedding: db.vector @db.search.filter 'doc_vectors' category: string ``` The adapter builds `$vectorSearch` aggregation pipelines from your schema. No subclassing or callbacks needed — pass a pre-computed embedding vector directly to `vectorSearch()`. See [Vector Search](/search/vector-search) for the full annotation reference, programmatic API, and HTTP access. ### Index Priority When multiple search indexes exist on a collection, the adapter selects the default in this order: 1. **Dynamic Atlas Search** index (highest priority) 2. **Static Atlas Search** index 3. **MongoDB text index** (lowest priority) You can always bypass the priority by passing an explicit index name to `search()`. ## Capped Collections Capped collections have a fixed maximum size and maintain insertion order (FIFO). They are ideal for logs, event streams, and cache-like data. Once the collection reaches its size limit, the oldest documents are automatically removed. ```atscript @db.table 'logs' @db.mongo.collection @db.mongo.capped 10485760, 10000 @db.sync.method 'drop' export interface LogEntry { message: string level: string @db.default.now timestamp: number.timestamp.created } ``` The first argument is the maximum size in bytes (10 MB above), and the optional second argument is the maximum number of documents (10,000 above). Changing cap size requires collection recreation. Use `@db.sync.method 'recreate'` to preserve data — sync copies the collection server-side into a temporary collection (`__tmp_`) via `$out`, drops the original, recreates it with the new options, then merges the temp data back via `$merge` and drops the temp collection. Use `@db.sync.method 'drop'` if data loss is acceptable (the collection is dropped and recreated empty). ::: warning Capped collections do not support document deletion or updates that increase document size. They are append-only by design. ::: ## Transactions MongoDB transactions require a replica set or mongos topology. On standalone instances, the adapter gracefully skips transactional wrapping — operations run normally without guarantees. See [Transactions](/api/transactions#mongodb) for usage and behavioral details. ## Schema Sync Notes MongoDB uses **snapshot-based** schema sync (Path B — no column introspection): - Collections are created on demand when first accessed - Schema sync creates and manages **indexes only** — there are no column-level migrations - Capped collection option drift (size/max changes) is detected and flagged - Standard indexes use the `atscript__` prefix so sync only touches managed indexes - Atlas Search indexes are managed separately from standard MongoDB indexes - **Unique indexes over optional fields are partial.** A `@db.index.unique` that includes an optional field gets a `partialFilterExpression` restricting it to documents where the optional field is present — so many documents may lack the field while present values stay unique, matching SQL's `NULLS DISTINCT` behavior. Changing a field's optionality changes the filter, which drops and recreates the index on the next sync. See [Schema Sync](../sync/) for the full sync workflow. ## Accessing the Adapter For operations beyond the standard CRUD interface, access the underlying `MongoAdapter` to use native MongoDB driver methods: ```typescript const adapter = db.getAdapter(User) as MongoAdapter // Run an aggregation pipeline const cursor = adapter.collection.aggregate([ { $match: { status: 'active' } }, { $group: { _id: '$department', count: { $sum: 1 } } }, ]) const results = await cursor.toArray() // Use any MongoDB driver method await adapter.collection.distinct('status') await adapter.collection.bulkWrite([...]) ``` You can also access the adapter through a table handle: ```typescript const users = db.getTable(User); const adapter = users.getAdapter(); const collection = adapter.collection; // native MongoDB Collection ``` ## Limitations - **FK constraints emulated** — referential integrity is enforced in the generic layer, not by MongoDB itself - **Atlas Search requires Atlas** — not available on self-hosted MongoDB - **Vector search requires Atlas M10+** — minimum tier for vector search indexes - **No SQL views** — MongoDB does not have SQL-style views; use aggregation pipelines instead - **Transactions require replica set** — standalone MongoDB instances cannot use transactions - **Embeddings are external** — pass pre-computed vectors to `vectorSearch()`, the adapter does not generate them - **Atlas Search indexes build asynchronously** — they may take a few seconds to become available after creation ## Next Steps - [Adapter Overview](./) — feature comparison across all adapters - [PostgreSQL](./postgresql) — full-featured adapter with pgvector and transactional DDL - [SQLite](./sqlite) — zero-config adapter for development and testing - [CRUD Operations](/api/crud) — full `AtscriptDbTable` API reference - [Schema Sync](../sync/) — sync workflow, CLI, and CI/CD --- URL: "db.atscript.dev/adapters/mysql" LLMS_URL: "db.atscript.dev/adapters/mysql.md" outline: "deep" --- # MySQL The MySQL adapter (`@atscript/db-mysql`) connects your `.as` models to MySQL and MariaDB databases via the `mysql2` driver. MySQL offers wide hosting availability, native FULLTEXT indexes for text search, `VECTOR(N)` columns in MySQL 9.0+, and in-place column modification through `ALTER TABLE MODIFY COLUMN`. ## Installation ```bash pnpm add @atscript/db-mysql mysql2 ``` `mysql2` is an optional peer dependency. The adapter dynamically imports it when the driver is first used. ## Setup ### Driver and Adapter Create a `Mysql2Driver` with your connection details, then wrap it in a `MysqlAdapter` factory via `DbSpace`: ```typescript import { DbSpace } from "@atscript/db"; import { MysqlAdapter, Mysql2Driver } from "@atscript/db-mysql"; const driver = new Mysql2Driver({ host: "localhost", port: 3306, user: "root", password: "password", database: "myapp", }); const db = new DbSpace(() => new MysqlAdapter(driver)); ``` `Mysql2Driver` accepts three input forms: ```typescript // Connection URI string const driver = new Mysql2Driver("mysql://root:pass@localhost:3306/mydb"); // Pool options object const driver = new Mysql2Driver({ host: "localhost", user: "root", database: "mydb", waitForConnections: true, connectionLimit: 10, }); // Pre-created mysql2/promise Pool instance import mysql from "mysql2/promise"; const pool = mysql.createPool({ host: "localhost", database: "mydb" }); const driver = new Mysql2Driver(pool); ``` ### Convenience Helper For quick setup, use the `createAdapter` shortcut that creates both the driver and `DbSpace` in one call: ```typescript import { createAdapter } from "@atscript/db-mysql"; const db = createAdapter("mysql://root:pass@localhost:3306/mydb"); ``` You can pass additional pool options as the second argument: ```typescript const db = createAdapter("mysql://localhost:3306/mydb", { connectionLimit: 20 }); ``` ### Plugin Registration To use MySQL-specific annotations (`@db.mysql.*`), register the plugin in your Atscript configuration: ```typescript import ts from "@atscript/typescript"; import { dbPlugin } from "@atscript/db/plugin"; import mysql from "@atscript/db-mysql/plugin"; export default { plugins: [ts(), dbPlugin(), mysql()], }; ``` `dbPlugin()` is **required** — it registers all portable `@db.*` annotations. The MySQL plugin (`mysql()`) is optional and only needed if you use `@db.mysql.engine`, `@db.mysql.charset`, `@db.mysql.collate`, `@db.mysql.unsigned`, `@db.mysql.type`, or `@db.mysql.onUpdate`. See [Setup](/guide/setup) for full configuration details. ## MySQL-Specific Annotations These annotations opt into MySQL-specific behavior. Files using only portable `@db.*` annotations remain adapter-agnostic. | Annotation | Level | Purpose | | ------------------------------- | ---------------- | ------------------------------------------------------------------------------------------- | | `@db.mysql.engine "ENGINE"` | Interface | Storage engine (default: `InnoDB`). Allowed: `InnoDB`, `MyISAM`, `MEMORY`, `CSV`, `ARCHIVE` | | `@db.mysql.charset "CHARSET"` | Interface, Field | Character set (default: `utf8mb4`) | | `@db.mysql.collate "COLLATION"` | Interface, Field | Native MySQL collation (overrides portable `@db.column.collate`) | | `@db.mysql.unsigned` | Field | Unsigned integer modifier | | `@db.mysql.type "TYPE"` | Field | Override the native column type (e.g., `"MEDIUMTEXT"`) | | `@db.mysql.onUpdate "EXPR"` | Field | ON UPDATE expression. Only `"CURRENT_TIMESTAMP"` is accepted | Example: ```atscript @db.mysql.engine "InnoDB" @db.mysql.charset "utf8mb4" @db.table "users" export interface User { @meta.id @db.default.increment id: number.int @db.mysql.collate "utf8mb4_turkish_ci" name: string @db.mysql.unsigned age: number.int @db.mysql.type "MEDIUMTEXT" bio: string @db.default.now @db.mysql.onUpdate "CURRENT_TIMESTAMP" updatedAt: number.timestamp } ``` ## Type Mapping | Atscript Type | MySQL Type | Notes | | ------------------------------------- | ----------------------------------------- | --------------------------------------------------------------------------------------------- | | `string` | `TEXT` | `VARCHAR(N)` when `@expect.maxLength` is set; `VARCHAR(255)` for PKs and fields with defaults | | `string` with `char` tag | `CHAR(1)` | | | `string` with maxLength > 65535 | `LONGTEXT` | | | `number` | `DOUBLE` | | | `number` (integer tags) | `TINYINT` / `SMALLINT` / `INT` / `BIGINT` | Based on int8/int16/int32/int64 tags | | `number` with `@db.mysql.unsigned` | `INT UNSIGNED` / `BIGINT UNSIGNED` / etc. | Appends `UNSIGNED` to the integer type | | `number` with `@db.column.precision` | `DECIMAL(p,s)` | | | `number` with `@db.default.increment` | `BIGINT` | `AUTO_INCREMENT` | | `number` with `@db.default.now` | `TIMESTAMP` | `DEFAULT CURRENT_TIMESTAMP` | | `boolean` | `TINYINT(1)` | Stored as `0` / `1` | | `decimal` | `DECIMAL(p,s)` | Defaults to `DECIMAL(10,2)` | | Nested objects | Flattened `__` columns | `address.city` becomes `address__city` | | `@db.json` | `JSON` | Stored as a single JSON column | | Arrays | `JSON` | | | `@db.default.uuid` | `CHAR(36)` | Generated client-side via `crypto.randomUUID()` | | `@db.search.vector` | `VECTOR(N)` | MySQL 9.0+; falls back to `JSON` on older versions | | `db.geoPoint` | `POINT SRID 4326` | Native geographic point. See [Geo Search](/search/geo-search) | ### Unsigned Integers MySQL supports unsigned integer types natively. Use `@db.mysql.unsigned` or unsigned primitive tags to produce the appropriate column type: | Atscript Tag | MySQL Type | | ----------------- | ------------------- | | `uint8` / `byte` | `TINYINT UNSIGNED` | | `uint16` / `port` | `SMALLINT UNSIGNED` | | `uint32` | `INT UNSIGNED` | | `uint64` | `BIGINT UNSIGNED` | These can also be triggered by combining an integer primitive with `@db.mysql.unsigned`: ```atscript @db.mysql.unsigned viewCount: number.int // → INT UNSIGNED ``` ## Table Options MySQL tables support table-level options that control the storage engine, character set, and collation. Defaults are applied automatically: | Option | Default | Annotation | | ------------- | -------------------- | ------------------------------------- | | Engine | `InnoDB` | `@db.mysql.engine` | | Character set | `utf8mb4` | `@db.mysql.charset` | | Collation | `utf8mb4_unicode_ci` | `@db.mysql.collate` (interface-level) | Schema sync detects changes to table options and applies them via `ALTER TABLE`: ```sql ALTER TABLE `users` ENGINE = MyISAM, CHARACTER SET = latin1, COLLATE = latin1_swedish_ci ``` ## FULLTEXT Indexes MySQL supports native FULLTEXT indexes for text search. Annotate fields with `@db.index.fulltext` to create a FULLTEXT index: ```atscript @db.table "articles" export interface Article { @meta.id id: string @db.index.fulltext "search_idx" title: string @db.index.fulltext "search_idx" body: string } ``` Multiple fields sharing the same index name are combined into a composite FULLTEXT index. The `search()` API generates `MATCH ... AGAINST` queries in natural language mode: ```typescript const results = await articles.search("database optimization", { filter: { published: true }, controls: { $limit: 20 }, }); ``` This produces: ```sql SELECT * FROM `articles` WHERE `published` = ? AND MATCH(`title`, `body`) AGAINST(? IN NATURAL LANGUAGE MODE) ``` ::: info FULLTEXT column ordering MySQL FULLTEXT indexes do not support explicit column ordering. Atscript omits the ASC/DESC modifiers for FULLTEXT index fields automatically. ::: ## Vector Support (MySQL 9.0+) MySQL 9.0 introduced native `VECTOR(N)` columns for storing fixed-dimension vectors. The adapter auto-detects the server version and uses native vector columns when available. ```atscript @db.table "documents" export interface Document { @meta.id id: string title: string @db.search.vector 1536 "cosine" embedding: number[] } ``` ### Distance Metrics | Similarity | MySQL Function | Description | | ------------------ | ------------------------ | ----------------------- | | `cosine` (default) | `VEC_DISTANCE_COSINE` | Cosine distance | | `euclidean` | `VEC_DISTANCE_EUCLIDEAN` | L2 / Euclidean distance | | `dotProduct` | `VEC_DISTANCE_DOT` | Dot product distance | ### Runtime Search ```typescript const results = await table.vectorSearch(queryEmbedding, { filter: { status: "published" }, controls: { $limit: 10, $threshold: 0.8 }, }); ``` The `$threshold` parameter is a normalized similarity score (0--1) matching MongoDB Atlas semantics. The adapter converts it to the appropriate MySQL distance value internally (for cosine: `distance = 2 * (1 - score)`). ### Graceful Fallback On MySQL versions prior to 9.0, vector fields are stored as `JSON` instead. The data is preserved, but indexed similarity search is not available — `vectorSearch()` will throw an error. ::: tip Check your MySQL version with `SELECT VERSION()`. Vector support requires MySQL 9.0 or later. ::: ## In-Place Column Modification The MySQL adapter sets `supportsColumnModify = true`, allowing column type changes, nullable changes, and default value changes to be applied in-place via `ALTER TABLE MODIFY COLUMN`: ```sql ALTER TABLE `users` MODIFY COLUMN `age` INT UNSIGNED NOT NULL ``` This means most schema changes do not require full table recreation. You only need `@db.sync.method 'recreate'` for rare structural changes that MySQL cannot handle in-place (e.g., reordering primary key columns). ## Driver Type Casting `Mysql2Driver` installs a custom `typeCast` and a few pool defaults so query results are predictable JS values, not driver-default strings: - **`TIMESTAMP` / `DATETIME` → `number`** (epoch milliseconds). Reads parse the UTC datetime string back to a number; writes accept epoch ms and emit `'YYYY-MM-DD HH:MM:SS'`. - **`DECIMAL` / `NEWDECIMAL` → `number`** instead of `string`. Be aware that values outside JS safe-number range may lose precision — keep `decimal` columns within `~15` significant digits if you rely on this. - **`timezone: '+00:00'`** is set on the pool so all timestamp operations are UTC. - **`supportBigNumbers: true`**, **`bigNumberStrings: false`** — `BIGINT` values within `Number.MAX_SAFE_INTEGER` come back as `number`; out-of-range values come back as **`string`** (preserves full precision without truncation). Coerce to `BigInt` yourself if you need arithmetic on those values. If you pre-create your own `mysql2/promise` `Pool` and pass it to `Mysql2Driver`, type casting becomes the caller's responsibility — replicate the settings above if you want the same behavior. Use `@db.mysql.onUpdate "CURRENT_TIMESTAMP"` for auto-updating timestamps: ```atscript @db.default.now createdAt: number.timestamp @db.default.now @db.mysql.onUpdate "CURRENT_TIMESTAMP" updatedAt: number.timestamp ``` ## Foreign Key Sync MySQL InnoDB enforces foreign key constraints natively. The adapter manages FK lifecycle during schema sync: 1. **Before column operations**: Existing FK constraints are dropped to unblock `ALTER TABLE` operations that would otherwise fail due to FK dependencies 2. **After column sync**: FK constraints are re-added based on the current schema definition Standalone FK sync is available via `syncForeignKeys()`, which reconciles existing FK constraints against the desired schema — dropping stale constraints and adding missing ones. When a foreign key constraint is violated, the adapter raises a `DbError` with the appropriate code: - `CONFLICT` (errno 1062) — duplicate key / unique constraint violation - `FK_VIOLATION` (errno 1451/1452) — foreign key constraint violation ## Batched Inserts The `insertMany` method uses multi-row `INSERT INTO ... VALUES (...), (...)` for optimal performance. MySQL has a `max_allowed_packet` limit, so the adapter automatically chunks large batches (~60,000 parameters per chunk): ```typescript // 10,000 rows with 8 columns = 80,000 params // Adapter splits into 2 chunks: 7500 rows + 2500 rows const result = await table.insertMany(largeDataset); ``` All rows within a batch insert are wrapped in a transaction for atomicity. ## Limitations - **UUID generated client-side** — MySQL's `DEFAULT (UUID())` generates the value server-side, but the adapter cannot retrieve it via `insertId` (which only works for `AUTO_INCREMENT` columns). UUIDs are generated client-side via `crypto.randomUUID()` to ensure the generated ID is immediately available in the insert result - **No transactional DDL** — DDL statements (`CREATE TABLE`, `ALTER TABLE`, `DROP TABLE`) cause an implicit `COMMIT`. Schema sync operations are not atomic; a failure mid-sync can leave the schema in a partially applied state - **No RETURNING clause** — MySQL does not support `RETURNING` on INSERT. The adapter uses `insertId` from the result header for auto-increment columns and client-side IDs for everything else - **Auto-increment gaps in batch inserts** — with `innodb_autoinc_lock_mode=2` (the MySQL 8.0+ default), concurrent inserts may cause gaps in auto-increment sequences during multi-row inserts - **No native boolean** — booleans are stored as `TINYINT(1)` (`0`/`1`) - **Key length prefix for TEXT indexes** — non-FULLTEXT indexes on string fields that map to `TEXT` columns require a key length prefix, which the adapter adds automatically (`(255)`) ## See Also - [Adapter Overview](./) — feature comparison across all adapters - [Schema Sync](/sync/) — automatic schema migration - [CRUD Operations](/api/crud) — create, read, update, delete - [Vector Search](/search/vector-search) — vector similarity search guide - [Text Search](/search/) — fulltext search guide --- URL: "db.atscript.dev/adapters/postgresql" LLMS_URL: "db.atscript.dev/adapters/postgresql.md" outline: "deep" --- # PostgreSQL PostgreSQL is the most complete Atscript DB adapter. It supports transactional DDL, native foreign key enforcement, pgvector for vector similarity search, CITEXT for case-insensitive text, GIN-based fulltext search, and JSONB for structured data. If you need a production-grade relational database with advanced capabilities, PostgreSQL is the recommended adapter. ## Installation ```bash pnpm add @atscript/db-postgres pg ``` The adapter uses [node-postgres](https://node-postgres.com/) (`pg`) as its driver. Install `@types/pg` as a dev dependency if you need TypeScript types for the underlying `pg` library. ## Setup ### Driver and Adapter Create a `PgDriver` with your connection details, then wrap it in a `PostgresAdapter` factory via `DbSpace`: ```typescript import { DbSpace } from "@atscript/db"; import { PgDriver, PostgresAdapter } from "@atscript/db-postgres"; const driver = new PgDriver("postgresql://user:pass@localhost:5432/mydb"); const db = new DbSpace(() => new PostgresAdapter(driver)); ``` `PgDriver` accepts three input forms: ```typescript // Connection URI string const driver = new PgDriver("postgresql://user:pass@localhost:5432/mydb"); // Pool configuration object const driver = new PgDriver({ host: "localhost", user: "postgres", database: "mydb", max: 10, }); // Pre-created pg.Pool instance import pg from "pg"; const pool = new pg.Pool({ connectionString: "..." }); const driver = new PgDriver(pool); ``` ### Convenience Helper For quick setup, use the `createAdapter` shortcut that creates both the driver and `DbSpace` in one call: ```typescript import { createAdapter } from "@atscript/db-postgres"; const db = createAdapter("postgresql://user:pass@localhost:5432/mydb"); ``` You can pass additional pool options as the second argument: ```typescript const db = createAdapter("postgresql://localhost:5432/mydb", { max: 20 }); ``` ### Plugin Registration To use PostgreSQL-specific annotations (`@db.pg.*`), register the plugin in your Atscript configuration: ```typescript import ts from "@atscript/typescript"; import { dbPlugin } from "@atscript/db/plugin"; import pg from "@atscript/db-postgres/plugin"; export default { plugins: [ts(), dbPlugin(), pg()], }; ``` `dbPlugin()` is **required** — it registers all portable `@db.*` annotations. The PostgreSQL plugin (`pg()`) is optional and only needed if you use `@db.pg.type`, `@db.pg.schema`, or `@db.pg.collate`. See [Setup](/guide/setup) for full configuration details. ## PostgreSQL-Specific Annotations These annotations opt into PostgreSQL-specific behavior. Files using only portable `@db.*` annotations remain adapter-agnostic. | Annotation | Level | Purpose | | ---------------------------- | ----------------- | ------------------------------------------------------------------------- | | `@db.pg.type "TYPE"` | Field | Override the native column type (e.g., `"CITEXT"`, `"INET"`, `"MACADDR"`) | | `@db.pg.schema "name"` | Interface | Set the database schema (default: `"public"`) | | `@db.pg.collate "collation"` | Interface / Field | Native PostgreSQL collation override (e.g., `"tr-x-icu"`, `"C"`) | Example: ```atscript @db.pg.schema "analytics" @db.table "events" export interface Event { @meta.id id: string @db.pg.type "INET" ip: string @db.pg.collate "tr-x-icu" name: string } ``` ## Type Mapping | Atscript Type | PostgreSQL Type | Notes | | ------------------------------------------- | --------------------------------- | ------------------------------------------------------------------------------ | | `string` | `TEXT` | `VARCHAR(N)` when `@expect.maxLength` is set | | `string` (PK or has default) | `VARCHAR(255)` | | | `string` with `@db.column.collate 'nocase'` | `CITEXT` | Case-insensitive text extension | | `string` with `char` tag | `CHAR(1)` | | | `number` | `DOUBLE PRECISION` | | | `number` (integer tags) | `SMALLINT` / `INTEGER` / `BIGINT` | Based on int8/int16/int32/int64 tags | | `number` with `@db.column.precision` | `NUMERIC(p,s)` | | | `number` with `@db.default.increment` | `BIGINT` | `GENERATED BY DEFAULT AS IDENTITY` | | `number` with `@db.default.now` | `BIGINT` | Epoch milliseconds | | `boolean` | `BOOLEAN` | Native boolean (not INTEGER) | | `decimal` | `NUMERIC(p,s)` | Defaults to `NUMERIC(10,2)` | | Nested objects | Flattened `__` columns | `address.city` becomes `address__city` | | `@db.json` | `JSONB` | Stored as a single JSONB column | | Arrays | `JSONB` | | | `@db.default.uuid` | `VARCHAR(255)` | `DEFAULT gen_random_uuid()` | | `@db.search.vector` | `vector(N)` | pgvector extension; falls back to `JSONB` | | `db.geoPoint` | `geography(Point,4326)` | PostGIS extension; falls back to `JSONB`. See [Geo Search](/search/geo-search) | ### Unsigned Integer Promotion PostgreSQL has no unsigned integer types. Unsigned types are promoted to the next-larger signed type to preserve the full value range: | Atscript Tag | PostgreSQL Type | Reason | | ------------ | --------------- | ----------------------------------------------- | | `uint8` | `SMALLINT` | 0--255 fits in SMALLINT | | `uint16` | `INTEGER` | 0--65535 exceeds SMALLINT max (32767) | | `uint32` | `BIGINT` | 0--4.3B exceeds INTEGER max (~2.1B) | | `uint64` | `BIGINT` | Best available; large values may lose precision | ## CITEXT — Case-Insensitive Text The portable `@db.column.collate 'nocase'` annotation maps to PostgreSQL's `CITEXT` column type, which handles case-insensitive equality, sorting, `UNIQUE` constraints, `LIKE`, range queries, and aggregation at the storage layer. ```atscript @db.table "users" export interface User { @meta.id id: string @db.column.collate 'nocase' @db.index.unique email: string } ``` The adapter auto-provisions the extension when CITEXT fields are detected: ```sql CREATE EXTENSION IF NOT EXISTS citext ``` No query-side wrapping (e.g., `LOWER()`) is needed — case insensitivity is handled entirely by the column type. ::: info Managed PostgreSQL services Some managed PostgreSQL services (e.g., certain AWS RDS configurations) require manual extension provisioning by an admin. If auto-provisioning fails, create the extension manually: ```sql CREATE EXTENSION IF NOT EXISTS citext; ``` Then use `@db.pg.type "CITEXT"` as a direct type override, which skips the auto-provisioning step. ::: ## pgvector — Vector Search The adapter supports vector similarity search via the [pgvector](https://github.com/pgvector/pgvector) extension. Annotate a field with `@db.search.vector` to define a vector embedding column: ```atscript @db.table "documents" export interface Document { @meta.id id: string title: string content: string @db.search.vector 1536 "cosine" embedding: number[] } ``` ### How It Works - The field is stored as `vector(N)` where N is the dimension count - An HNSW index is created automatically for efficient approximate nearest-neighbor search - The adapter auto-provisions the pgvector extension: `CREATE EXTENSION IF NOT EXISTS vector` ### Distance Metrics | Similarity | PG Operator | Index Ops Class | Description | | ------------------ | ----------- | ------------------- | ----------------------- | | `cosine` (default) | `<=>` | `vector_cosine_ops` | Cosine distance | | `euclidean` | `<->` | `vector_l2_ops` | L2 / Euclidean distance | | `dotProduct` | `<#>` | `vector_ip_ops` | Negative inner product | ### Runtime Search ```typescript const results = await table.vectorSearch(queryEmbedding, { filter: { status: "published" }, controls: { $limit: 10, $threshold: 0.8 }, }); ``` The `$threshold` parameter is a normalized similarity score (0--1) matching MongoDB Atlas semantics. The adapter converts it to the appropriate pgvector distance value internally: - **Cosine**: `distance = 2 * (1 - score)` (score range [0, 1] maps to distance range [2, 0]) - **Euclidean**: threshold is used as max distance directly - **Dot product**: threshold is negated ### Graceful Fallback When the pgvector extension is not available (not installed on the server), vector fields are stored as `JSONB` instead. The data is preserved, but indexed similarity search is not available — `vectorSearch()` will throw an error. ::: tip Install pgvector on your PostgreSQL server before using vector search features. On most systems: `sudo apt install postgresql-16-pgvector` or build from source. See the [pgvector installation guide](https://github.com/pgvector/pgvector#installation). ::: ## GIN Fulltext Search The `@db.index.fulltext` annotation creates a GIN index on the `to_tsvector()` expression for efficient text search: ```atscript @db.table "articles" export interface Article { @meta.id id: string @db.index.fulltext "search_idx" title: string @db.index.fulltext "search_idx" body: string } ``` This generates a GIN index on the concatenated tsvector: ```sql CREATE INDEX "search_idx" ON "articles" USING gin(to_tsvector('english', coalesce("title", '') || ' ' || coalesce("body", ''))) ``` Search queries use `plainto_tsquery()`: ```typescript const results = await table.search("database optimization", { filter: { published: true }, controls: { $limit: 20 }, }); ``` Multiple fields in the same fulltext index group are concatenated for combined search. ## Transactional DDL PostgreSQL DDL is fully transactional, unlike MySQL and SQLite. Schema sync changes are wrapped in `BEGIN`/`COMMIT` blocks — if any DDL statement fails, the entire sync rolls back atomically. This means: - Adding columns, changing types, and renaming tables are all-or-nothing operations - Table recreation (for changes that require rebuilding the table) uses a transaction for safe data migration: create temp table, copy data, drop old, rename new - No risk of half-applied schema changes after a failure ::: tip This is a significant advantage over SQLite (no transactional DDL) and MySQL (implicit COMMIT before each DDL statement). If schema reliability matters, PostgreSQL is the safest choice. ::: ## Batched Inserts The `insertMany` method uses multi-row `INSERT INTO ... VALUES (...), (...), ... RETURNING *` for optimal performance. PostgreSQL has a protocol-level limit of approximately 65,535 parameters per query. The adapter automatically chunks large batches to stay within this limit (~60,000 parameters per chunk): ```typescript // 10,000 rows with 8 columns = 80,000 params // Adapter splits into 2 chunks: 7500 rows + 2500 rows const result = await table.insertMany(largeDataset); ``` The `RETURNING` clause returns inserted rows including any generated defaults (auto-increment IDs, UUIDs, timestamps), so `insertedIds` are always populated correctly. ## Schema Support Tables can be placed in PostgreSQL schemas (namespaces) using `@db.pg.schema`: ```atscript @db.pg.schema "analytics" @db.table "events" export interface Event { @meta.id id: string type: string payload: object } ``` The table is created as `"analytics"."events"` and all queries are schema-qualified. The schema is created automatically during schema sync if it does not exist. You can also use the portable `@db.schema` annotation, which has the same effect. ## Auto-Increment The `@db.default.increment` annotation uses PostgreSQL 10+ identity column syntax: ```sql "id" BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY ``` This is the modern PostgreSQL approach (not the legacy `SERIAL` pseudo-type). After table recreation during schema sync, the adapter automatically resets the identity sequence to `MAX(id) + 1` so new inserts don't conflict with existing data. ::: info `GENERATED BY DEFAULT` (not `GENERATED ALWAYS`) allows explicit ID values during inserts, which is needed for data migration and seeding. ::: ## Connection Pooling `PgDriver` uses a `pg.Pool` internally. Each CRUD operation acquires a connection from the pool, executes the query, and releases the connection automatically. For transactions, a dedicated connection is acquired via `getConnection()` and held for the duration of the transaction. ```typescript const driver = new PgDriver({ connectionString: "postgresql://localhost:5432/mydb", max: 20, // maximum pool size idleTimeoutMillis: 30000, }); ``` The pool is initialized lazily — the `pg` module is dynamically imported on first use, so the package works in both ESM and CJS environments without top-level import issues. To shut down cleanly, call `driver.close()` which ends the pool and all its connections. ### SSL for managed Postgres Most managed providers (Neon, Supabase, RDS, Heroku, etc.) require TLS. SSL options are passed straight through to `pg.Pool` — see the [node-postgres SSL docs](https://node-postgres.com/features/ssl) for the full option set: ```typescript const driver = new PgDriver({ connectionString: process.env.DATABASE_URL, ssl: { rejectUnauthorized: true }, }); ``` For providers that require self-signed certs (e.g., RDS with the bundled CA), pass the certificate via `ssl: { ca: fs.readFileSync('./rds-ca.pem') }`. When using a pre-created `pg.Pool`, configure SSL on the pool directly. ## Error Handling The adapter maps PostgreSQL constraint errors to structured `DbError` instances: | PG SQLSTATE | Error Type | Description | | ----------- | -------------- | ---------------------------------------------------------------------------- | | `23505` | `CONFLICT` | Unique constraint violation — includes the field path and constraint detail | | `23503` | `FK_VIOLATION` | Foreign key violation — extracts the offending column from PG's error detail | These structured errors are the same across all adapters, so error handling code is portable: ```typescript import { DbError } from "@atscript/db"; try { await table.insertOne({ email: "duplicate@example.com" }); } catch (err) { if (err instanceof DbError && err.code === "CONFLICT") { console.log("Duplicate:", err.errors[0].path); // "email" } } ``` ## Custom Type Parsers The `PgDriver` configures custom type parsers for consistent JavaScript value handling: | PostgreSQL Type | Parsed As | Why | | --------------------------- | ------------------- | ---------------------------------------------------------------- | | `TIMESTAMP` / `TIMESTAMPTZ` | `number` (epoch ms) | Consistent with SQLite and MongoDB adapters | | `NUMERIC` | `number` | Default pg driver returns `string` | | `INT8` / `BIGINT` | `number` | Returns `string` only if value exceeds `Number.MAX_SAFE_INTEGER` | | `BOOLEAN` | `boolean` | Native (no conversion needed) | These parsers are applied per-pool (not globally), so they don't affect other `pg` usage in the same process. When using a pre-created `pg.Pool`, type parsing is the caller's responsibility. ## Limitations - **No unsigned integer types** — unsigned types are promoted to the next-larger signed type (see [Unsigned Integer Promotion](#unsigned-integer-promotion)) - **Extension privileges required** — `CREATE EXTENSION` for citext and pgvector requires database-level `CREATE` privilege. Managed services may need manual provisioning by an admin - **UPDATE/DELETE with LIMIT** — PostgreSQL does not support `LIMIT` on `UPDATE` or `DELETE` statements. The adapter uses a `ctid` subquery (`WHERE ctid = (SELECT ctid FROM t WHERE ... LIMIT 1)`) to achieve single-row update/delete - **pgvector installation** — the pgvector extension must be installed separately on the PostgreSQL server; it is not bundled with PostgreSQL - **Parameter limit** — PostgreSQL supports ~65,535 bind parameters per query. Large `insertMany` calls are auto-chunked, but custom raw queries should be aware of this limit ## See Also - [Adapter Overview](./) — feature comparison across all adapters - [Schema Sync](/sync/) — automatic schema migration - [CRUD Operations](/api/crud) — create, read, update, delete - [Vector Search](/search/vector-search) — vector similarity search guide - [Text Search](/search/) — fulltext search guide --- URL: "db.atscript.dev/adapters/sqlite" LLMS_URL: "db.atscript.dev/adapters/sqlite.md" outline: "deep" --- # SQLite The SQLite adapter (`@atscript/db-sqlite`) connects your `.as` models to SQLite databases. Zero-config, single-file storage, and no server process make SQLite the fastest way to get started with Atscript's DB layer. Best suited for development, testing, and lightweight production workloads. Uses `better-sqlite3` by default, but any driver implementing the `TSqliteDriver` interface works — including Node.js built-in `node:sqlite`. ## Installation ```bash pnpm add @atscript/db-sqlite better-sqlite3 ``` `better-sqlite3` is an optional peer dependency. You can substitute any SQLite driver that implements the `TSqliteDriver` interface. ## Setup Create a driver, wrap it in an adapter, and pass the adapter factory to `DbSpace`: ```typescript import { DbSpace } from "@atscript/db"; import { SqliteAdapter, BetterSqlite3Driver } from "@atscript/db-sqlite"; import { User } from "./user.as.js"; // 1. Create driver const driver = new BetterSqlite3Driver("./myapp.db"); // 2. Create DbSpace with adapter factory const db = new DbSpace(() => new SqliteAdapter(driver)); // 3. Get typed tables const users = db.getTable(User); ``` Or use the convenience shorthand: ```typescript import { createAdapter } from "@atscript/db-sqlite"; import { User } from "./user.as.js"; const db = createAdapter("./myapp.db"); const users = db.getTable(User); ``` Once you have a table, run `npx asc db sync` to create or update the database schema, then use `users.insertOne(...)`, `users.findMany(...)`, etc. See [CRUD Operations](/api/crud) for the full API. ## Adapter-Specific Annotations SQLite has **no adapter-specific annotations**. All generic `@db.*` annotations work as documented in the [Annotations Reference](./annotations). There is no `@db.sqlite.*` namespace. ## Type Mapping Atscript types map to SQLite column types as follows: | Atscript Type | SQLite Type | Notes | | ----------------- | ----------------- | ------------------------------------------------------------------- | | `string` | `TEXT` | | | `number` | `REAL` | `INTEGER` for primary keys (aliases `rowid`) | | `decimal` | `REAL` | Runtime value is string; coerced on read | | `boolean` | `INTEGER` | Stored as `0` / `1` | | arrays | `TEXT` | JSON-serialized | | nested objects | flattened columns | `parent__child` naming convention | | `@db.json` fields | `TEXT` | JSON-serialized | | `db.geoPoint` | `TEXT` | JSON `[lng, lat]`; haversine-based [geo search](/search/geo-search) | ## Features ### Nested Objects Nested object fields are automatically flattened into `__`-separated columns. You query with dot-notation and the adapter translates: ```atscript @db.table 'contacts' export interface Contact { @meta.id id: number name: string // Becomes columns: address__city, address__zip address: { city: string zip: string } } ``` ```typescript // Insert — pass the nested structure naturally await contacts.insertOne({ id: 1, name: "Alice", address: { city: "Portland", zip: "97201" }, }); // Query — use dot-notation for nested fields const results = await contacts.findMany({ filter: { "address.city": "Portland" }, controls: { $sort: { "address.zip": 1 } }, }); // Read — nested objects are reconstructed automatically // results[0].address -> { city: 'Portland', zip: '97201' } ``` To store an entire nested object as a single JSON column instead of flattening, annotate it with `@db.json`. Arrays are always stored as JSON. ### Foreign Key Enforcement SQLite foreign keys are enforced natively. The adapter enables `PRAGMA foreign_keys = ON` at connection time, so referential integrity is always active. Cascade and set-null behaviors are controlled via `@db.rel.onDelete` and `@db.rel.onUpdate` — see [Referential Actions](/relations/referential-actions). ### Fulltext Search (FTS5) SQLite supports fulltext search through FTS5 virtual tables. When you annotate fields with `@db.index.fulltext`, the adapter automatically creates FTS5 virtual tables with sync triggers that keep the index up to date on inserts, updates, and deletes. ```atscript @db.table 'articles' export interface Article { @meta.id id: number @db.index.fulltext title: string @db.index.fulltext body: string } ``` The adapter creates a companion `articles__fts__` virtual table and triggers for automatic synchronization. Use the `search()` method to query: ```typescript const results = await articles.search("database optimization", {}); ``` ::: info FTS5 Query Syntax FTS5 uses its own match syntax (e.g., `"exact phrase"`, `term1 AND term2`, `prefix*`). This differs from the simple text search APIs of PostgreSQL or MongoDB. See the [SQLite FTS5 documentation](https://www.sqlite.org/fts5.html) for query syntax details. ::: ### Vector Search SQLite supports vector similarity search through the optional [`sqlite-vec`](https://github.com/asg017/sqlite-vec) extension. Vector search is **opt-in** — you must install the peer dependency and tell the driver to load the extension. #### Install and enable ```bash pnpm add sqlite-vec ``` ```typescript import { BetterSqlite3Driver, SqliteAdapter } from "@atscript/db-sqlite"; import { DbSpace } from "@atscript/db"; const driver = new BetterSqlite3Driver("./data.db", { vector: true }); const db = new DbSpace(() => new SqliteAdapter(driver)); ``` When using a custom driver, set `hasVectorExt = true` after the extension is loaded so the adapter skips its capability probe. If the flag is omitted, the adapter runs `SELECT vec_version()` once and caches the result. #### Declaring a vector field Use the portable `@db.search.vector` annotation: ```atscript @db.table "documents" export interface Document { @meta.id id: number title: string @db.search.vector 1536, "cosine" embedding: number[] } ``` Allowed dimension values (whitelisted by the annotation): `256`, `384`, `512`, `768`, `1024`, `1536`, `2048`, `3072`, `4096`, `6144`, `8192`, `16384`. Similarity options: `cosine` (default), `euclidean`, `dotProduct`. #### Storage - **With `sqlite-vec` loaded** — the adapter creates a companion `vec0` virtual table per index (`__vec__`) with sync triggers, and routes `vectorSearch()` through KNN queries on that shadow table. - **Without the extension** — vector fields are stored as JSON `TEXT` on the main table. The data is preserved, but `vectorSearch()` throws because no indexed similarity search is available. #### Querying ```typescript const results = await documents.vectorSearch(queryEmbedding, { filter: { status: "published" }, controls: { $limit: 10, $threshold: 0.8 }, }); ``` `$threshold` is a normalized similarity score (`0`–`1`) matching MongoDB Atlas semantics; the adapter converts it to the appropriate distance for the chosen metric. ::: tip `sqlite-vec` is a native module — make sure your build target matches the platform you deploy to. ::: ### Filters All standard filter operators are supported (`$eq`, `$ne`, `$gt`, `$gte`, `$lt`, `$lte`, `$in`, `$nin`, `$and`, `$or`, `$not`). Regex patterns are converted to SQL `LIKE` expressions: | Regex Pattern | SQL LIKE | Matches | | ------------- | -------- | ----------------- | | `^abc` | `abc%` | Starts with "abc" | | `end$` | `%end` | Ends with "end" | | `^exact$` | `exact` | Exact match | | `mid` | `%mid%` | Contains "mid" | ```typescript // Pattern matching await users.findMany({ filter: { name: { $regex: "^Ali" } }, controls: {}, }); // -> WHERE name LIKE 'Ali%' ``` ### Table Recreation SQLite does not support `ALTER COLUMN` for type changes. When schema sync detects a column type change, the adapter performs a safe table recreation: 1. Creates a new table with the updated schema 2. Copies data from the old table (with `COALESCE` for new NOT NULL columns) 3. Renames old table out of the way, renames new table into place 4. Drops the old table Foreign key checks are temporarily disabled during recreation to avoid constraint errors on intermediate states. To opt a table into this behavior, annotate it with `@db.sync.method 'recreate'`. See [Schema Sync](/sync/) for details. ### In-Memory Databases Pass `':memory:'` as the path to create an in-memory database — useful for tests and ephemeral data: ```typescript const driver = new BetterSqlite3Driver(":memory:"); const db = new DbSpace(() => new SqliteAdapter(driver)); ``` In-memory databases are lost when the process exits or the driver is closed. ### Custom Drivers The `SqliteAdapter` accepts any object implementing `TSqliteDriver`. This lets you use `node:sqlite`, `sql.js`, or any other SQLite binding: ```typescript interface TSqliteDriver { run(sql: string, params?: unknown[]): { changes: number; lastInsertRowid: number | bigint }; all(sql: string, params?: unknown[]): T[]; get(sql: string, params?: unknown[]): T | null; exec(sql: string): void; close(): void; /** Optional — set to `true` if the driver has the `sqlite-vec` extension loaded. */ readonly hasVectorExt?: boolean; } ``` When `hasVectorExt` is omitted, the adapter probes the connection by running `SELECT vec_version()` on first vector access. Set the flag explicitly to skip the probe. Example using Node.js built-in `node:sqlite`: ```typescript import { SqliteAdapter } from "@atscript/db-sqlite"; import { DatabaseSync } from "node:sqlite"; const nodeDb = new DatabaseSync(":memory:"); const driver = { run(sql, params) { const stmt = nodeDb.prepare(sql); return stmt.run(...(params ?? [])); }, all(sql, params) { const stmt = nodeDb.prepare(sql); return stmt.all(...(params ?? [])); }, get(sql, params) { const stmt = nodeDb.prepare(sql); return stmt.get(...(params ?? [])) ?? null; }, exec(sql) { nodeDb.exec(sql); }, close() { nodeDb.close(); }, }; const adapter = new SqliteAdapter(driver); ``` ### BetterSqlite3Driver The built-in `BetterSqlite3Driver` accepts either a file path (string) or a pre-created `better-sqlite3` `Database` instance: ```typescript // From file path const driver = new BetterSqlite3Driver("./data.db"); // From existing instance import Database from "better-sqlite3"; const instance = new Database("./data.db", { verbose: console.log }); const driver = new BetterSqlite3Driver(instance); ``` #### Driver options The second constructor argument accepts a few extra options on top of the standard `better-sqlite3` `Database` options: ```typescript new BetterSqlite3Driver("./data.db", { vector: true, // load the optional `sqlite-vec` extension loadExtensions: ["/path/to/ext1.so"], // pass each path to `Database.loadExtension` // any other `better-sqlite3` Database options are forwarded }); ``` The driver uses `createRequire` internally, so `better-sqlite3` (and `sqlite-vec`) remain optional dependencies — they are only loaded when `BetterSqlite3Driver` is instantiated with the corresponding option. #### WAL mode and pragma tuning The adapter sets `PRAGMA foreign_keys = ON` at connection time (required for FK enforcement) and toggles `foreign_keys` / `legacy_alter_table` during `recreate`-mode schema sync. It does **not** override journaling, synchronous, or busy-timeout pragmas — configure those on the underlying `better-sqlite3` `Database` before wrapping it: ```typescript import Database from "better-sqlite3"; import { BetterSqlite3Driver, SqliteAdapter } from "@atscript/db-sqlite"; const sqlite = new Database("./data.db"); sqlite.pragma("journal_mode = WAL"); sqlite.pragma("synchronous = NORMAL"); sqlite.pragma("busy_timeout = 5000"); const driver = new BetterSqlite3Driver(sqlite); const adapter = new SqliteAdapter(driver); ``` See the [better-sqlite3 README](https://github.com/WiseLibs/better-sqlite3) for the full list of pragmas. ## Limitations - **No ALTER COLUMN type changes** — column type modifications require full table recreation. Use `@db.sync.method 'recreate'` to opt in. See [Schema Sync](/sync/) for details. - **FTS5-based fulltext search** — fulltext indexes are managed automatically, but FTS5 uses its own match syntax rather than standard SQL pattern matching. - **No database schemas** — the `@db.schema` annotation is ignored (SQLite has no schema namespaces). - **Vector search is opt-in via `sqlite-vec`** — see [Vector Search](#vector-search). Without the extension, `@db.search.vector` fields are stored as JSON `TEXT` and `vectorSearch()` throws. - **No native boolean type** — booleans are stored as `INTEGER` (`0`/`1`). - **No native array/JSON operations** — array patch operators (`$push`, `$pull`) use generic read-modify-write instead of native operations. - **Synchronous driver** — both `better-sqlite3` and `node:sqlite` are synchronous; the adapter wraps calls in promises for the async `BaseDbAdapter` contract. - **No native UUID generation** — UUIDs must be generated application-side. ## Utilities The package exports `buildWhere` for constructing SQL WHERE clauses from filter objects — useful when writing custom queries outside the standard CRUD flow: ```typescript import { buildWhere } from "@atscript/db-sqlite"; const { sql, params } = buildWhere({ status: "active", age: { $gte: 18 } }); // sql -> 'WHERE "status" = ? AND "age" >= ?' // params -> ['active', 18] ``` ## Next Steps - [PostgreSQL](./postgresql) — full-featured adapter with pgvector and transactional DDL - [MongoDB](./mongodb) — document-oriented adapter with Atlas Search - [Adapter Overview](./) — feature comparison across all adapters --- URL: "db.atscript.dev/adapters/value-help" LLMS_URL: "db.atscript.dev/adapters/value-help.md" outline: "deep" --- # Value-Help Controllers Value help is the dropdown/autocomplete/row-picker UI that renders on FK fields. The source can be a DB table, a static JSON array, a read-only view of a legacy system, or any custom source — as long as it implements the small wire contract defined by `AsReadableController`. ## What changed Before this release, value help only fired for fields whose `.ref` resolved to a `@db.table` interface — anything else (static enums, external lookups, view-backed entities) had no path through. Now: - **Any interface can be a value-help source**, as long as it is bound to a controller that registers the shared `/query`, `/pages`, `/one(/:id)`, `/meta` surface and stamps `@db.http.path` on the interface metadata. - **`@db.rel.FK` is the explicit marker** on the field side. The client-side picker looks for this annotation to decide whether a field should render a value-help picker. See the [annotations page](annotations#db-rel-fk-dual-role) for the dual-role semantics. - **Capability hints live on the bound interface** via `@ui.dict.filterable`, `@ui.dict.sortable`, and `@ui.dict.searchable` — the client picker reads these from `/meta` to decide which controls to render. They are **hints only**: the server accepts any filter/sort the client sends. `$search` uses `@ui.dict.searchable` to pick which fields to match (falling back to every string prop when absent). ## Controllers Three classes in `@atscript/moost-db`: - **`AsReadableController`** — abstract base. Handles `@db.http.path` stamping, the shared `/meta` route, serialization options, Uniquery control validation, and the helper surface reused by every subclass. - **`AsValueHelpController`** — abstract subclass for read-only value-help sources. Adds `/query`, `/pages`, `/one(/:id)`, `/one` routes. Subclasses implement `query(controls)` and `getOne(id)`. - **`AsJsonValueHelpController`** — concrete subclass backed by a static in-memory array. Handy for enum-style dictionaries that ship with the application and don't warrant a DB table. `AsDbReadableController` / `AsDbController` now extend `AsReadableController` too; DB-backed tables and views participate in the same contract. See the [CRUD docs](../http/crud) for the DB-side details. ## Wire contract Every value-help controller exposes the same four routes: | Route | Method | Purpose | | ----------- | ------ | ------------------------------------------------------------------------------------ | | `/query` | GET | Filter + sort + search + (optionally skip) a window of rows. Returns `T[]`. | | `/pages` | GET | Same query surface, plus `$page` / `$size` pagination. Returns `{ data, count, … }`. | | `/one/:id` | GET | Look up a single row by primary key. 404 on miss. | | `/one?pk=…` | GET | Look up by primary-key query param (falls back when the PK is not URL-safe). | | `/meta` | GET | Returns the bound interface's serialized type plus capability hints (see below). | Clients rely on the `/meta` response for both the field contract (label, description, attribute projection) and the **capability hints** (`fields[path].filterable`, `.sortable`, and the top-level `.searchable`). The client picker uses these to decide which controls to render. ## Capability annotations These are defined in `@atscript/ui` (one-time server-agnostic import) and surfaced by the value-help controller in its `/meta` response so the client picker can wire them into its UI. **They are client-side hints only** — the server does not reject requests for fields that lack them. | Annotation | Applies To | Effect | | --------------------- | ------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `@ui.dict.filterable` | Field | Picker renders a filter chip for this field. Server still accepts filters on any field. | | `@ui.dict.sortable` | Field | Picker offers this field in the sort dropdown. Server still accepts sorts on any field. | | `@ui.dict.searchable` | Field OR Interface | Picker renders a search input. `AsJsonValueHelpController.query` uses the annotation to pick which fields to match; if absent, every `string`-typed prop is searched. | ## Example — static JSON dictionary ```ts import { AsJsonValueHelpController } from "@atscript/moost-db"; import { Controller } from "moost"; import { Moost } from "moost"; import { StatusDict } from "./value-help/status-dict.as"; const STATUSES = [ { id: "active", label: "Active" }, { id: "archived", label: "Archived" }, { id: "draft", label: "Draft" }, ]; @Controller("/api/dicts/status") export class StatusDictController extends AsJsonValueHelpController { constructor(app: Moost) { // `'status'` is the controller name — used for `db.http.path` stamping and // diagnostics. Required unless the bound type carries a `@db.table` annotation. super(StatusDict, STATUSES, app, "status"); } } ``` `AsJsonValueHelpController` already carries an `@Inherit()` decorator on the base class, so subclasses **do not** need to repeat it. The constructor signature is `(boundType, rows, app, controllerName?)`. The fourth argument is optional — when omitted, the controller falls back to `boundType.metadata.get('db.table')` and finally to the literal `"value-help"`. If your bound interface is a plain dictionary (no `@db.table`), pass an explicit `controllerName` so the stamped `db.http.path` is meaningful. On the Atscript side: ```atscript export interface StatusDict { @meta.id id: string @ui.dict.filterable @ui.dict.sortable label: string } ``` Elsewhere — e.g., in a form schema — you reference the dictionary via `@db.rel.FK`: ```atscript export interface InviteForm { email: string @db.rel.FK status: StatusDict.id } ``` The picker resolves via `prop.ref.type().metadata.get('db.http.path')` (stamped by the controller at registration) → `/api/dicts/status`. It fetches `/api/dicts/status/meta` once, caches it app-wide, and uses the capability hints to drive its UI. ## JSON-source semantics The built-in `AsJsonValueHelpController.query` implementation iterates the constructor-provided array and applies Uniquery controls in this order: 1. **Filter** — MongoDB-style comparison operators (`$eq`, `$ne`, `$in`, `$nin`, `$gt`, `$gte`, `$lt`, `$lte`, `$regex`) and logical combinators (`$and`, `$or`, `$not`, `$nor`). Unknown operators fall through to strict equality. Any field can be filtered — no gate. 2. **Search** — case-insensitive substring match. Fields to match come from `@ui.dict.searchable`: field-level annotation narrows to those props; absent or interface-level defaults to every `string`-typed prop. 3. **Sort** — stable, multi-key, lexicographic. Direction via a leading `-` or via the explicit `{ [field]: 'asc' \| 'desc' }` form. Any field can be sorted — no gate. 4. **Pagination** — `$skip` + `$limit` applied after filter/search/sort. `/pages` returns the full total count. If you need richer semantics (locale-aware sort, tokenized search, FTS-style ranking), subclass `AsValueHelpController` directly and implement `query` / `getOne` yourself — everything above the data source (routing, meta serialization) stays the same. ## Client resolution order On the client (`@atscript/ui`), value help resolves from the FK prop as follows: 1. `extractValueHelp(prop)` returns `undefined` unless `prop.metadata.has('db.rel.FK')`. 2. It reads `prop.ref.type().metadata.get('db.http.path')` to get the picker URL. 3. On picker open, the client fetches `{url}/meta` once and caches it app-wide. 4. The picker calls `{url}/query` / `{url}/pages` with the user's filter/sort/search input. See the UI docs for the full client-side story. --- URL: "db.atscript.dev/api/crud" LLMS_URL: "db.atscript.dev/api/crud.md" outline: "deep" --- # CRUD Operations Atscript's DB layer provides a type-safe API for creating, reading, updating, and deleting records. All operations go through `AtscriptDbTable`, which handles validation, default values, nested object flattening, and adapter translation automatically. ## Getting a Table Instance ```typescript import { DbSpace } from "@atscript/db"; import { User } from "./schema/user.as"; const users = db.getTable(User); // AtscriptDbTable ``` `getTable()` returns a cached instance — calling it again with the same type returns the same table. See [Setup](/guide/setup) for how to create a `DbSpace`. ## Inserting Records ### Insert One Insert a single record and get back the generated primary key: ```typescript const result = await users.insertOne({ email: "alice@example.com", name: "Alice", status: "active", }); // result: { insertedId: 1 } ``` Fields with `@db.default.*` annotations (`@db.default.increment`, `@db.default.uuid`, `@db.default.now`, or `@db.default 'value'`) are applied automatically — you can omit them from the input. ::: info `insertedId` typing `insertedId` is typed as `unknown` (the PK type isn't always inferable — UUID, ObjectId, composite, etc.). Cast it to your PK type when you need a typed value: `result.insertedId as number`. ::: ### Insert Many Insert multiple records in a single transaction: ```typescript const result = await users.insertMany([ { email: "alice@example.com", name: "Alice" }, { email: "bob@example.com", name: "Bob" }, { email: "charlie@example.com", name: "Charlie" }, ]); // result: { insertedCount: 3, insertedIds: [1, 2, 3] } ``` ::: info Nested Creation Both `insertOne` and `insertMany` support nested relation data — inserting related records across foreign keys in a single call. This is covered in [Relations — Deep Operations](/relations/deep-operations). ::: ## Reading Records ### Find by ID Look up a single record by primary key: ```typescript const user = await users.findById(1); // Returns the record or null ``` `findById` is flexible — it accepts: - A scalar — tried against the primary key and every single-field unique index - An object with primary-key fields, or with all fields of a compound unique index Add [`@db.table.preferredId.uniqueIndex`](/api/tables#preferred-identifier) to a table to make a non-PK unique index the canonical id (e.g., `slug`). Scalar ids then resolve **only** against that index — no PK fallback — which keeps URLs and external references deterministic. ### Find One Return the first record matching a filter: ```typescript const user = await users.findOne({ filter: { email: "alice@example.com" }, }); // Returns the first match or null ``` ### Find Many Return all records matching a filter, with optional sorting and pagination: ```typescript const active = await users.findMany({ filter: { status: "active" }, controls: { $sort: { name: 1 }, $limit: 10, $skip: 0, }, }); ``` For a full reference on filter operators and controls, see [Queries & Filters](/api/queries). ### Count Count matching records without fetching data: ```typescript const total = await users.count({ filter: { status: "active" }, }); ``` Pass no arguments to count all records: ```typescript const allUsers = await users.count(); ``` ### Find Many with Count Get both data and total count in one call — useful for paginated UIs: ```typescript const { data, count } = await users.findManyWithCount({ filter: { status: "active" }, controls: { $limit: 10, $skip: 20 }, }); // data: first 10 records after skipping 20 // count: total matching records (ignoring $limit/$skip) ``` ## Updating Records ### Update One Partially update a record. The primary key field(s) must be included to identify the record — only the other provided fields are changed: ```typescript const result = await users.updateOne({ id: 1, name: "Alice Smith", }); // result: { matchedCount: 1, modifiedCount: 1 } ``` ::: info Patch Operators & Field Operations For atomic increments/decrements (`$inc`, `$dec`, `$mul`) and embedded array patch operators (`$insert`, `$remove`, etc.), see [Update & Patch](/api/update-patch). ::: ::: tip Optimistic concurrency For tables that declare [`@db.column.version`](/api/versioning), add `$cas` to make the update conditional on the current row version: ```typescript const ok = await users.updateOne({ id: 1, status: "active", $cas: { version: row.version }, }); // ok.matchedCount === 0 on stale-read OR missing row — caller retries. ``` See [Optimistic Concurrency (Row Versioning)](/api/versioning) for the full reference. ::: ### Update Many Update all records matching a filter: ```typescript const result = await users.updateMany( { status: "inactive" }, // filter { status: "archived" }, // data to set ); // result: { matchedCount: 5, modifiedCount: 5 } ``` `updateMany` does not support nested relation operations — only own fields. ## Replacing Records ### Replace One Replace an entire record by primary key. Unlike `updateOne`, **all fields must be provided** — missing fields are not preserved: ```typescript const result = await users.replaceOne({ id: 1, email: "alice.new@example.com", name: "Alice Smith", status: "active", }); ``` ::: tip Replace vs. Update - **`updateOne`** — sends only the fields you want to change (partial) - **`replaceOne`** — replaces the entire record with new data (full) ::: `replaceOne` also supports `$cas` on [versioned tables](/api/versioning) — same semantics as `updateOne`. ### Replace Many Replace every record matching a filter with the **same** full payload — the replace counterpart of `updateMany`. As with `replaceOne`, all required fields must be provided; omitted optional fields are not preserved: ```typescript const result = await users.replaceMany( { status: "inactive" }, { email: "archived@example.com", name: "Archived User", status: "archived" }, ); // result: { matchedCount, modifiedCount } ``` For replacing many records with **different** payloads (each identified by its primary key), use [`bulkReplace`](#bulk-operations) instead. ## Deleting Records ### Delete One Delete a single record by ID: ```typescript const result = await users.deleteOne(1); // result: { deletedCount: 1 } ``` `deleteOne` accepts the same flexible ID format as `findById` — primary key, composite key object, or unique index value. ### Delete Many Delete all records matching a filter: ```typescript const result = await users.deleteMany({ status: "archived", }); // result: { deletedCount: 12 } ``` ::: info Cascade & Set-Null When a deleted record is referenced by other tables via foreign keys, cascade and set-null behaviors are handled automatically based on `@db.rel.onDelete` annotations. See [Relations](/relations/deep-operations) for details. ::: ## Bulk Operations For batched writes that apply different changes to each record (vs. `updateMany`, which applies the same change to many rows), use `bulkUpdate` and `bulkReplace`. Both accept an array of payloads (each identified by its primary key) and an optional `{ maxDepth }` option, and they participate in the surrounding transaction. ```typescript import { $dec } from "@atscript/db/ops"; await products.bulkUpdate([ { id: 1, stock: $dec(2) }, { id: 2, stock: $dec(5) }, { id: 3, stock: $dec(1) }, ]); await users.bulkReplace([ { id: 1, email: "alice.new@example.com", name: "Alice" /* …all fields */ }, { id: 2, email: "bob.new@example.com", name: "Bob" /* …all fields */ }, ]); ``` See [Update & Patch](/api/update-patch) for the full operator catalog and per-payload options. ::: warning Nested writes need `@db.depth.limit` Insert / replace / patch payloads that nest into `@db.rel.from` or `@db.rel.via` children are rejected at the validator boundary unless the table declares [`@db.depth.limit N`](/relations/deep-operations) with `N >= 1`. The default — annotation absent — is `0`, which blocks every nested write. See [Relations — Deep Operations](/relations/deep-operations). ::: ## Validation Tables automatically validate data on every write operation using constraints from your `.as` definitions (`@expect.*` annotations). Validation is purpose-aware: | Purpose | Used by | Behavior | | --------------- | --------------------------- | ------------------------------------------------------------------------------------------------- | | `'insert'` | `insertOne`, `insertMany` | PK, defaulted, and FK fields become optional | | `'bulkUpdate'` | `updateOne`, `bulkUpdate` | Top level is partial; merge-strategy objects partial, replace-strategy objects require all fields | | `'bulkReplace'` | `replaceOne`, `bulkReplace` | All non-optional fields required | | `'patch'` | Available for manual checks | Fully partial; useful when you need to validate a partial payload yourself | `updateMany` does **not** run a validator on the data payload — only foreign-key references are checked. If you want strict validation of a partial update, build a `'patch'` validator and run it yourself before calling `updateMany`. You can access validators directly for manual checks: ```typescript const validator = users.getValidator("insert"); if (!validator.validate(data, true)) { // safe = true → returns false instead of throwing console.log(validator.errors); // [{ path: 'email', message: 'Required field' }, ...] } ``` ## Error Handling Database operations throw `DbError` with a `code` property indicating the error type: | Code | Meaning | | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------- | | `CONFLICT` | Unique constraint violation | | `FK_VIOLATION` | Foreign key constraint violated | | `NOT_FOUND` | Record not found | | `CASCADE_CYCLE` | Circular cascade detected | | `INVALID_QUERY` | Malformed query or filter | | `DEPTH_EXCEEDED` | Nested-write payload deeper than `@db.depth.limit N` (also a `DepthLimitExceededError`) | | `VERSION_COLUMN_WRITE` | Direct write to a `@db.column.version` column — use `$cas` instead. See [Versioning](/api/versioning#direct-write-rejection) | | `CAS_EXHAUSTED` | `withOptimisticRetry` exhausted `maxAttempts` (also a `CasExhaustedError`). See [Versioning](/api/versioning#casexhaustederror) | Handle errors by checking the code: ```typescript import { DbError } from "@atscript/db"; try { await users.insertOne({ email: "alice@example.com", name: "Alice" }); } catch (err) { if (err instanceof DbError) { switch (err.code) { case "CONFLICT": console.log("Email already exists:", err.errors); break; case "FK_VIOLATION": console.log("Referenced record missing:", err.errors); break; } } } ``` Each error includes an `errors` array with `{ path, message }` entries for detailed diagnostics. ### Error Paths in Nested Data When validation fails inside nested or array payloads, error paths use dot notation to pinpoint the exact location: | Context | Example path | Meaning | | ------------------ | ------------------------- | -------------------------------------------------- | | Top-level field | `"title"` | The `title` field failed validation | | TO navigation | `"project.title"` | The `title` field inside inline `project` data | | FROM array element | `"comments.0.body"` | The `body` field of the first comment in the array | | Deep nesting | `"tasks.2.project.title"` | The `title` of the project in the third task | This makes it straightforward to map errors back to specific fields in complex nested payloads — useful for building form validation UIs. ## Next Steps - [Queries & Filters](/api/queries) — Advanced filtering, sorting, and projection - [Update & Patch](/api/update-patch) — Embedded array and object patch operators - [Transactions](/api/transactions) — Atomic multi-table operations - [Relations — Deep Operations](/relations/deep-operations) — Nested creation and replacement across relations --- URL: "db.atscript.dev/api/defaults" LLMS_URL: "db.atscript.dev/api/defaults.md" outline: "deep" --- # Defaults & Generated Values Atscript lets you set default values directly in your `.as` schema. Defaults ensure fields are populated automatically on insert — you define them once, and every adapter handles the rest. ## Static Defaults Use `@db.default` to assign a fixed value when a field is not provided at insert time. The argument is always a string — non-string values are parsed as JSON: ```atscript // String default — used as-is @db.default 'pending' status: string // Boolean default — parsed from JSON @db.default 'false' isArchived: boolean // Number default — parsed from JSON @db.default '0' retryCount: number ``` ## Generated Defaults Some defaults need to be computed at insert time. Atscript provides three portable generated-default annotations: ### `@db.default.increment` — Auto-Incrementing Integer Generates sequential integers (1, 2, 3, ...). The field must be a number type. An optional argument sets the starting value: ```atscript @db.default.increment id: number // With optional start value: @db.default.increment 1000 id: number ``` ### `@db.default.uuid` — Random UUID Generates a random UUID v4 string. The field must be a string type: ```atscript @db.default.uuid id: string ``` ### `@db.default.now` — Current Timestamp Captures the current time at insert. Works with number (Unix epoch milliseconds) and string (ISO format) types: ```atscript @db.default.now createdAt?: number ``` Use `number` (epoch ms) for timestamps so they cross HTTP boundaries without any serialization step. ::: tip Semantic Types Include Defaults Semantic types like `number.timestamp.created` already include `@db.default.now` — you don't need to add it manually: ```atscript // Concise — semantic type handles the default createdAt?: number.timestamp.created // Equivalent verbose form @db.default.now createdAt?: number ``` ::: ## Version Defaults The [`@db.column.version`](/api/versioning) annotation implies a `0` default — you don't need to add `@db.default '0'` (and you shouldn't try to write the column directly anyway; see [Direct-write rejection](/api/versioning#direct-write-rejection)): ```atscript @db.column.version version: int // Inserted rows get version = 0 automatically. // SQL adapters emit DDL with NOT NULL DEFAULT 0 — ALTER TABLE backfills // existing rows when the column is added later. // Mongo writers inject version: 0 at insert when the field is missing. ``` Every successful write thereafter increments the column by `1`, server-side. See [Optimistic Concurrency (Row Versioning)](/api/versioning) for the full feature. ## How Defaults Interact with Inserts Understanding when defaults apply: - **Omitted fields** — the default value is used. This is the primary use case. - **Explicit values** — if you pass a value for a field with a default, your value takes precedence. The default is only a fallback. - **Optional fields without defaults** — become `NULL` if omitted from the insert. - **Fields with `@db.default.increment`** — typically omitted from inserts entirely. The database generates the next value. - **Non-optional fields without defaults** — must always be provided. `@db.default` does not make a field optional in TypeScript — you still need `?` if you want to omit it from inserts. ```typescript // Only 'title' is required — all other fields are optional (marked with ?) await todos.insertOne({ title: "Learn Atscript" }); // Result: // { // id: 1, ← @db.default.increment // title: 'Learn Atscript', // completed: false, ← @db.default 'false' (field is optional with ?) // description: null, ← optional, no default // createdAt: 1710500000000 ← @db.default.now // } ``` ## Next Steps - [Indexes & Constraints](/api/indexes) — database indexes, precision, and collation - [Tables & Fields](/api/tables) — declaring tables, primary keys, and field types - [CRUD Operations](/api/crud) — insert, query, update, and delete data --- URL: "db.atscript.dev/api/encryption" LLMS_URL: "db.atscript.dev/api/encryption.md" outline: "deep" --- # Field Encryption `@db.encrypted` encrypts a field **at rest**: the core layer AES-256-GCM-encrypts the value before it reaches the adapter and decrypts it on read — completely transparent to application code. Ciphertext is stored as a plain string, so the feature works identically on **all four adapters** with no engine-specific setup. Typical use: credential blocks on operational documents — API tokens, feed auth headers, provider passwords — where the invariant "never store this in cleartext" belongs in the model, not in a hand-rolled service layer. ## Declaring Encrypted Fields ```atscript @db.table 'partners' export interface Partner { @meta.id id: string legalName: string @db.encrypted apiToken?: string @db.encrypted creditCredentials?: { user: string pwd: string } } ``` Any JSON-serializable type works — string, number, boolean, nested object, array. The declared type keeps governing validation and TypeScript typing; only the storage representation changes. An encrypted nested object is stored as **one opaque text column** (no flattening into child columns). ## Configuring Keys Pass an `encryption` block in the `DbSpace` options bag. Without it, any model declaring `@db.encrypted` fails fast with `ENC_CONFIG_MISSING` — plaintext is never silently stored. ```typescript import { DbSpace } from "@atscript/db"; const db = new DbSpace(() => new MongoAdapter(mongo, client), { encryption: { defaultKeyId: "k2", keys: { k1: process.env.DB_ENC_KEY_1!, // old key — kept for existing rows k2: process.env.DB_ENC_KEY_2!, // used for all new writes }, }, }); ``` | Option | Description | | --------------- | --------------------------------------------------------------------------------------------------------------- | | `defaultKeyId` | Key used for all new writes | | `keys` | Key registry: keyId → 32-byte key (`Buffer`, 64-char hex, base64, or 32-char string) | | `resolveKey` | Async resolver (KMS, Vault, env indirection) — called once per keyId, cached for the process lifetime | | `onUnencrypted` | `'error'` (default) or `'passthrough'` — what to do when a stored value is not an encryption envelope (§ below) | Key material is validated eagerly at `DbSpace` construction — a wrong-size key throws `ENC_KEY_INVALID` immediately, not at first write. ### AWS KMS / Vault Use `resolveKey` with the envelope-encryption pattern — keep a KMS-encrypted 32-byte data key in config and decrypt it once at boot: ```typescript import { KMSClient, DecryptCommand } from "@aws-sdk/client-kms"; const kms = new KMSClient({}); const db = new DbSpace(adapterFactory, { encryption: { defaultKeyId: "k1", resolveKey: async (keyId) => { const { Plaintext } = await kms.send( new DecryptCommand({ CiphertextBlob: Buffer.from(process.env[`DB_ENC_KEY_${keyId}`]!, "base64"), }), ); return Buffer.from(Plaintext!); // must be 32 bytes }, }, }); ``` Note the threat-model boundary: keys live **app-side by design**. The key must exist in process memory; this protects against database dumps, backups, and DB operators — not against an attacker who controls the application process. If you need a no-key-in-process guarantee on MongoDB, use driver-level CSFLE/Queryable Encryption independently (construct the `MongoClient` with `autoEncryption` and don't combine it with `@db.encrypted` on the same fields). ## How Values Are Stored Stored values are single ASCII envelope strings: ``` aes1$$$$ ``` - Fresh random IV per write — two writes of the same plaintext produce different ciphertexts. - The plaintext is JSON-serialized before encryption, so round-trips are type-exact (`"42"` and `42` stay distinguishable). - Column types: `TEXT` on SQLite/PostgreSQL/MySQL, plain string on MongoDB. Declared-type column sizing does not apply — ciphertext length depends on the plaintext. Reads, list queries, search results, and relation loads all decrypt transparently. A tampered or corrupted value fails with `ENC_DECRYPT_FAILED` (carrying table/field/keyId — never partial plaintext); an envelope referencing a key missing from the registry fails the same way, naming the keyId. ## What's Not Allowed Ciphertext is opaque, so anything that needs to _interpret_ the stored value is rejected loudly: **At compile/build time** — `@db.encrypted` cannot combine with: `@meta.id`, `@db.rel.FK` (or being an FK target), any `@db.index.*` (incl. [geo](/search/geo-search)), `@db.search.vector`/`@db.search.filter`, `@db.mongo.search.*`, [`@db.column.version`](/api/versioning), `@db.default.increment`/`@db.default.now`, or `@db.patch.strategy "merge"`. (A plain `@db.default 'literal'` is fine — it's applied app-side before encryption.) **At query/patch time** — engine-agnostic `DbError` rejections before any SQL/pipeline is built: | Attempt | Error | | ----------------------------------------------------------------------------- | -------------------- | | Filtering on an encrypted field (incl. nested paths into an encrypted object) | `ENC_FIELD_FILTER` | | `$sort` on an encrypted field | `ENC_FIELD_SORT` | | `$groupBy` / aggregate references | `ENC_FIELD_AGG` | | Arithmetic/array patch ops (`$inc`, `$insert`, …) | `ENC_FIELD_PATCH_OP` | Plain assignment in updates is allowed — the new value is simply re-encrypted. Over HTTP all of these surface as `400`. `/meta` reports `encrypted: true` on the field with `filterable: false` and `sortable: false` (the adapter veto wins over any `@db.column.filterable` annotation). ::: warning HTTP responses return decrypted values Encryption is **at-rest** protection, not transport-level redaction. Read endpoints return plaintext to authorized callers. Who may see the field over HTTP remains an authorization concern — use projections, `transformProjection()`, or permission guards exactly as you would for any sensitive field. ::: ## Key Rotation Rotation is a config change: flip `defaultKeyId` to the new key and **keep old keys in the registry**. Each envelope records the keyId it was encrypted with, so old rows keep decrypting; they re-encrypt under the new key on their next natural write. To rotate eagerly, run a read-and-rewrite loop over affected rows. Drop an old key only after no envelopes reference it — afterwards those rows fail with `ENC_DECRYPT_FAILED`. ## Migrating Existing Plaintext Columns Adding `@db.encrypted` to a live, populated column does **not** encrypt existing rows (schema sync never rewrites data). Until they're migrated, reads of plaintext rows fail with `ENC_NOT_ENCRYPTED` under the default policy. The migration recipe: 1. Deploy with `onUnencrypted: 'passthrough'` — plaintext rows are returned as-is; anything written goes out encrypted. 2. Run a read-and-rewrite loop over the table (each write re-encrypts). 3. Switch back to `onUnencrypted: 'error'` (the default) so stray plaintext fails loudly again. Schema-sync notes: toggling `@db.encrypted` changes the field's storage type and the schema hash → sync runs (a column-type migration on SQL, metadata-only on MongoDB). Key material is **never** persisted, hashed into the snapshot, or otherwise written to the database. See [What Gets Synced](/sync/what-gets-synced). ## DOs and DON'Ts - **DO** keep every key that still has live envelopes in the registry — removing a key bricks its rows. - **DO** treat HTTP exposure separately — encrypted ≠ hidden; wire projections/permissions for read access. - **DON'T** filter, sort, group, or index on encrypted fields — design lookups around other fields (or wait for the planned blind-index support). - **DON'T** put `@meta.id`, FKs, or version columns under encryption — addressing and OCC need cleartext equality. - **DON'T** combine with MongoDB CSFLE on the same field — you'd double-encrypt. ## Next Steps - [Storage & Nested Objects](/api/storage) — the storage modes encrypted fields bypass - [Geo Search](/search/geo-search) — mutually exclusive with `@db.encrypted` - [What Gets Synced](/sync/what-gets-synced) — schema-sync behavior on annotation toggles --- URL: "db.atscript.dev/api/indexes" LLMS_URL: "db.atscript.dev/api/indexes.md" outline: "deep" --- # Indexes & Constraints Indexes improve query performance and can enforce constraints. Atscript supports three index types and two column constraint annotations — all declared in your `.as` schema. ## Plain Index Create a standard index for faster lookups with `@db.index.plain`. The first argument is the index name, and an optional second argument sets the sort direction (`'asc'` or `'desc'`): ```atscript @db.index.plain 'name_idx' name: string @db.index.plain 'created_idx', 'desc' createdAt: number ``` ## Unique Index Enforce that no two records share the same value with `@db.index.unique`: ```atscript @db.index.unique 'email_idx' email: string ``` Any attempt to insert a duplicate value will result in a constraint violation error. ::: info Unique indexes on optional fields When a unique-indexed field is **optional** (`email?: string`), uniqueness is enforced only among records where the value is present — any number of records may omit it. SQL adapters get this from the engine's `NULLS DISTINCT` default; MongoDB emits a partial index scoped to present values (see [MongoDB — Schema Sync Notes](/adapters/mongodb#schema-sync-notes)). For composite unique indexes a record is exempt as soon as any of its optional indexed fields is missing. ::: ## Full-Text Search Index Mark fields for full-text search with `@db.index.fulltext`. An optional second argument sets the field's **weight** — higher weight means greater relevance in search results: ```atscript @db.index.fulltext 'search_idx', 10 title: string @db.index.fulltext 'search_idx', 1 body?: string ``` The weight defaults to `1` when omitted. Weighted full-text search is supported by MongoDB and PostgreSQL. SQLite uses FTS5 virtual tables with auto-managed sync triggers — schema sync creates and maintains them automatically. ::: tip `@db.index.fulltext` sets up the index in the database. For search usage (querying against full-text indexes), see [Text Search](/search/). ::: ## Geospatial Index Mark a `db.geoPoint` field with `@db.index.geo` to enable distance-ranked search (`geoSearch()`) and accelerate `$geoWithin` radius filters: ```atscript @db.index.geo geo: db.geoPoint ``` Runs natively on every adapter (2dsphere on MongoDB, GiST/PostGIS on PostgreSQL, SPATIAL on MySQL for required fields, scan-based haversine on SQLite). Annotation rules, the query API, and the `GET /geo` endpoint are covered in [Geo Search](/search/geo-search). ## Composite Indexes When multiple fields share the same index name, they form a **composite index**. This is useful for queries that filter or sort on multiple columns together: ```atscript @db.index.plain 'name_email_idx' name: string @db.index.plain 'name_email_idx' email: string ``` This creates a single index spanning both `name` and `email`, which speeds up queries that filter on both fields simultaneously. ## Multiple Indexes Per Field A single field can participate in more than one index. Simply stack multiple `@db.index.*` annotations: ```atscript @db.index.unique 'email_idx' @db.index.plain 'name_email_idx' email: string ``` Here `email` has its own unique index and also participates in a composite index with another field. ## Column Precision Use `@db.column.precision` to set decimal precision and scale for database storage. Adapters map this to their native decimal type (e.g., `DECIMAL(10,2)` in MySQL, `NUMERIC(10,2)` in PostgreSQL): ```atscript @db.column.precision 10, 2 price: decimal ``` The `decimal` type stores values as strings at runtime (e.g., `"19.99"`) to preserve exact precision. This also means decimal values pass through JSON transport (client ↔ server) without any loss — no serialization or hydration step is needed. Use `decimal` for prices, financial amounts, and any field where floating-point rounding is unacceptable. `@db.column.precision` also works on `number` fields for cases where you want a database-level decimal column but don't need string precision at runtime. ## Collation Use `@db.column.collate` to control how string comparison and sorting work. The value is portable — each adapter maps it to its native collation: ```atscript @db.column.collate 'nocase' username: string ``` | Value | Behavior | | ----------- | -------------------------------------- | | `'binary'` | Exact byte comparison (case-sensitive) | | `'nocase'` | Case-insensitive comparison | | `'unicode'` | Full Unicode-aware sorting | Each adapter translates these to its native collation. For example, PostgreSQL maps `'nocase'` to the `CITEXT` type, while SQLite uses the `NOCASE` collation. See [Adapters](/adapters/) for adapter-specific collation details. ## Complete Example Putting it all together — a `User` table with several index types, precision, and collation: ```atscript @db.table 'users' export interface User { // Primary key with auto-increment @meta.id @db.default.increment id: number // Unique index ensures no duplicate emails @db.index.unique 'email_idx' @db.column.collate 'nocase' email: string // Plain index for fast name lookups, also part of a composite index @db.index.plain 'name_idx' @db.index.plain 'name_status_idx' name: string // Part of a composite index with name @db.index.plain 'name_status_idx' status: string // Full-text search on bio @db.index.fulltext 'search_idx' bio?: string // Decimal precision for financial data @db.column.precision 10, 2 balance?: decimal } ``` This gives you a unique case-insensitive email constraint, composite and full-text indexes, and precise decimal storage — all declared in one place. ## Next Steps - [CRUD Operations](/api/crud) — insert, read, update, and delete records - [Queries & Filters](/api/queries) — filter, sort, and paginate results - [Schema Sync](/sync/) — apply schema changes to your database --- URL: "db.atscript.dev/api/queries" LLMS_URL: "db.atscript.dev/api/queries.md" outline: "deep" --- # Queries & Filters Every query in Atscript's DB layer follows the same shape: a **filter** that selects which records to return, and a **controls** object that determines how they come back (sorting, pagination, projection). This syntax is consistent across all adapters. ```typescript const results = await table.findMany({ filter: { /* which records */ }, controls: { /* how to return them */ }, }); ``` ## Filter Syntax Filters use a MongoDB-inspired expression language. At its simplest, you pass an object whose keys are field names and whose values are the conditions to match. ### Equality The most common filter is a direct equality check: ```typescript // Shorthand — value is the match target { filter: { status: "active"; } } // Explicit operator form { filter: { status: { $eq: "active"; } } } ``` Multiple fields in the same object are combined with AND: ```typescript { filter: { status: 'active', role: 'admin' } } // WHERE status = 'active' AND role = 'admin' ``` ### Not Equal ```typescript { filter: { status: { $ne: "done"; } } } // WHERE status != 'done' ``` ### Comparisons ```typescript { filter: { age: { $gt: 18; } } } // greater than { filter: { age: { $gte: 18; } } } // greater than or equal { filter: { age: { $lt: 65; } } } // less than { filter: { age: { $lte: 65; } } } // less than or equal ``` These operators are available on `number`, `string`, and `Date` fields. ### Set Operators Check whether a value belongs (or does not belong) to a set: ```typescript { filter: { role: { $in: ["admin", "editor"]; } } } // WHERE role IN ('admin', 'editor') { filter: { status: { $nin: ["archived", "deleted"]; } } } // WHERE status NOT IN ('archived', 'deleted') ``` ### Pattern Matching ```typescript { filter: { name: { $regex: "^Al"; } } } // SQLite/PostgreSQL: WHERE name LIKE 'Al%' (or REGEXP) // MongoDB: WHERE name matches /^Al/ ``` `$regex` is available on `string` fields and accepts a `RegExp` or `string`. ### Existence Test whether a field is present (non-null) or absent (null): ```typescript { filter: { email: { $exists: true; } } } // WHERE email IS NOT NULL { filter: { email: { $exists: false; } } } // WHERE email IS NULL ``` ### Null Values You can also filter for null directly: ```typescript { filter: { assigneeId: null; } } // WHERE assigneeId IS NULL ``` ## Logical Operators ### Implicit AND When you put multiple fields in a single filter object, they are ANDed together automatically: ```typescript { filter: { status: 'active', role: 'admin' } } ``` ### Explicit AND Use `$and` when you need multiple conditions on the same field, or just prefer being explicit: ```typescript { filter: { $and: [{ age: { $gte: 18 } }, { age: { $lt: 65 } }]; } } ``` ### OR ```typescript { filter: { $or: [{ status: "active" }, { role: "admin" }]; } } ``` ### NOT Negate a set of conditions: ```typescript { filter: { $not: { status: "archived"; } } } ``` ### Nested Combinations Logical operators compose naturally: ```typescript { filter: { $and: [ { $or: [ { priority: 'high' }, { priority: 'critical' }, ] }, { $not: { status: 'done' } }, ], } } ``` ## Nested Field Filters Atscript automatically flattens nested objects into `__`-separated column names (e.g., a `contact.email` field becomes the `contact__email` column). When filtering, use **dot notation** — the adapter translates it to the physical column name: ```typescript { filter: { 'contact.email': 'alice@example.com' } } // SQL: WHERE contact__email = 'alice@example.com' { filter: { 'address.city': { $in: ['Berlin', 'Paris'] } } } // SQL: WHERE address__city IN ('Berlin', 'Paris') ``` This works with all operators — comparisons, `$regex`, `$exists`, and logical combinators. ## Query Controls The `controls` object determines how the result set is shaped. ### Sorting Use `$sort` with `1` for ascending and `-1` for descending: ```typescript controls: { $sort: { name: 1; } } // A → Z controls: { $sort: { createdAt: -1; } } // newest first ``` Multiple sort keys are applied in order: ```typescript controls: { $sort: { status: 1, name: -1 } } // ORDER BY status ASC, name DESC ``` ### Pagination ```typescript controls: { $limit: 10, // return at most 10 records $skip: 20, // skip the first 20 records } ``` ### Field Selection Include specific fields using array form: ```typescript controls: { $select: ["id", "name", "email"]; } ``` Or exclude fields with an object where `0` means exclude: ```typescript controls: { $select: { password: 0, internalNotes: 0 } } ``` When selecting a nested object parent, all its child fields are included: ```typescript controls: { $select: ["id", "contact"]; } // Includes contact.email, contact.phone, etc. ``` ::: info Aggregate expressions The include array can also contain `AggregateExpr` objects from `@uniqu/core` (`{ $fn: 'sum', $field: 'amount' }` etc.) for computed columns. They are extracted and routed to the adapter's aggregation pipeline. See the [@uniqu/core types](https://github.com/moostjs/uniqu) for the full operator list. ::: ::: tip FK Fields Auto-Included When using `$select` with relation loading (`$with`), foreign key fields needed for relation resolution (e.g., `assigneeId` for an `assignee` relation) are automatically included even if not listed in `$select`. ::: ### Paginated Results Use `findManyWithCount()` to get both data and total count in one call — see [CRUD Operations — Find Many with Count](/api/crud#find-many-with-count) for the API and examples. ## Type-Safe Generics Queries are fully typed. `findOne` and `findMany` accept a `Uniquery` that constrains filter fields to own (non-navigation) properties. The return type `DbResponse` automatically strips navigation properties from the result unless you request them via `$with`. When the query type is a literal (not widened), TypeScript infers exactly which navigation properties are returned: ```typescript // result type includes `assignee` but not other nav props const tasks = await taskTable.findMany({ controls: { $with: [{ name: "assignee" }] }, }); ``` ## Query Expressions Query expressions are a **compile-time** syntax used inside `.as` files to define view filters, join conditions, and relation filters. They are _not_ used in runtime TypeScript queries — they are embedded in annotations and compiled into the schema. ### Syntax Expressions are wrapped in backticks inside `.as` files: ```atscript @db.view.filter `Task.status != 'done'` ``` ### Field References Reference fields using `TableName.fieldName`: ```atscript @db.view.filter `Task.priority = 'high'` @db.view.joins Project, `Project.id = Task.projectId` ``` ### Operators | Operator | Meaning | Example | | -------- | --------------------- | ------------------------------ | | `=` | equals | `` `Task.status = 'active'` `` | | `!=` | not equals | `` `Task.status != 'done'` `` | | `>` | greater than | `` `Task.priority > 3` `` | | `>=` | greater than or equal | `` `Task.priority >= 3` `` | | `<` | less than | `` `Task.age < 65` `` | | `<=` | less than or equal | `` `Task.age <= 65` `` | | `~=` | regex match | `` `User.name ~= '^Al'` `` | | `?` | exists (non-null) | `` `Task.assigneeId ?` `` | | `!?` | not exists (null) | `` `Task.deletedAt !?` `` | ### Set Membership Use curly braces for IN / NOT IN: ```atscript @db.view.filter `Task.status {active, pending}` @db.view.filter `Task.role !{guest, bot}` ``` ### Logical Combinators Combine conditions with `&&` (and), `||` (or), and `!()` (not). Use parentheses for grouping: ```atscript @db.view.filter `Task.status != 'done' && Task.priority >= 3` @db.view.filter `(Task.status = 'active' || Task.status = 'pending') && Task.assigneeId ?` @db.view.filter `!(Task.status = 'archived')` ``` ### Where They Are Used Query expressions appear in these annotations: - **`@db.view.filter`** — row-level filter for a [view](/views/) - **`@db.view.joins`** — join condition between tables in a view - **`@db.view.having`** — having clause for aggregation views - **`@db.rel.filter`** — static filter applied when loading a relation Example in a view definition: ```atscript @db.view @db.view.for Task @db.view.joins Project, `Project.id = Task.projectId` @db.view.filter `Task.status != 'done' && Task.priority >= 3` type ActiveHighPriorityTasks { taskId: Task.id title: Task.title projectName: Project.name } ``` ## Combining It All A practical example that brings filters, sorting, pagination, and field selection together: ```typescript const tasks = await taskTable.findMany({ filter: { status: { $ne: "done" }, priority: { $in: ["high", "critical"] }, "project.active": true, }, controls: { $sort: { priority: -1, createdAt: 1 }, $limit: 20, $skip: 0, $select: ["id", "title", "status", "priority"], }, }); ``` This returns the first 20 non-done tasks with high or critical priority from active projects, sorted by priority descending then creation date ascending, with only the selected fields. ## Next Steps - [CRUD Operations](/api/crud) — Insert, read, update, delete - [Update & Patch](/api/update-patch) — Embedded array and object patch operators - [Views](/views/) — Managed, external, and materialized views - [Relations](/relations/deep-operations) — Navigation property loading and deep operations --- URL: "db.atscript.dev/api/storage" LLMS_URL: "db.atscript.dev/api/storage.md" outline: "deep" --- # Storage & Nested Objects Atscript fields can be stored in the database in three different ways. Understanding these storage modes is key to designing queryable, efficient schemas. ## Three Storage Modes | Mode | Applies To | What Happens | Queryable? | | ------------- | -------------------------------------------------------- | ------------------------------------------- | ----------------- | | **Column** | Scalar fields (`string`, `number`, `boolean`, `decimal`) | One field → one database column | Yes | | **Flattened** | Nested objects (default) | Each nested field → a `__`-separated column | Yes | | **JSON** | `@db.json` objects, all arrays | Entire value → single JSON column | Adapter-dependent | ## Column Storage Scalar fields map directly to database columns — one field, one column. This is the default behavior for all scalar types and requires no annotation: ```atscript @db.table 'users' export interface User { @meta.id id: number name: string // → column: name email: string // → column: email active: boolean // → column: active } ``` ## Flattened Storage By default, nested objects are **flattened** into separate columns using `__` (double underscore) as a separator: ```atscript @db.table 'profiles' export interface Profile { @meta.id id: number name: string contact: { email: string phone?: string } } ``` This creates four columns: `id`, `name`, `contact__email`, and `contact__phone`. When you read data back, the flat columns are automatically reconstructed into the nested object structure. ### Deep Nesting Flattening works recursively at any depth: ```atscript settings: { notifications: { email: boolean sms: boolean } } // Columns: settings__notifications__email, settings__notifications__sms ``` ### Querying Flattened Fields Flattened fields are real database columns — you can filter and sort on them using dot notation. The path is translated to the physical column name automatically: ```typescript const results = await profiles.findMany({ filter: { "contact.email": "alice@example.com" }, }); // Translates to: WHERE contact__email = 'alice@example.com' ``` ::: tip Flattened fields give you the best of both worlds: you work with nested objects in your code, but each field is a real, indexed, queryable column in the database. ::: ## JSON Storage Use `@db.json` to store a nested object as a single JSON column instead of flattening it: ```atscript @db.json preferences: { theme: string lang: string shortcuts: string[] } // Single column: preferences (stored as JSON string in SQLite, JSONB in PostgreSQL, native object in MongoDB) ``` When to use `@db.json`: - **Complex objects** you don't need to query by individual sub-fields - **Dynamic or loosely-structured data** where flattening creates too many columns - **Highly nested structures** where deep flattening is impractical ::: tip Arrays are always stored as JSON regardless of `@db.json`. You only need the annotation for plain objects you want to keep as a single column. ::: ## Queryability The storage mode determines what you can query: **Flattened fields** are fully queryable — they are real columns with their own types, indexes, and constraints. You can filter, sort, and index them like any other field. **JSON fields** have limited, adapter-dependent queryability — see your [adapter docs](/adapters/) for the specifics. ::: info If you need to filter on a field, prefer flattened storage (the default for objects). Use `@db.json` only when you treat the object as an opaque blob that is read and written as a whole. ::: ## Example: Same Schema, Different Storage Consider a `Product` type with two nested objects — one flattened, one stored as JSON: ```atscript @db.table 'products' export interface Product { @meta.id id: number name: string // Flattened (default) — each field becomes a column dimensions: { width: number height: number weight: number } // JSON — stored as a single column @db.json metadata: { tags: string[] attributes: { key: string, value: string }[] } } ``` This produces the following database columns: | Column | Source | Storage Mode | | -------------------- | ------------------- | ------------ | | `id` | `id` | Column | | `name` | `name` | Column | | `dimensions__width` | `dimensions.width` | Flattened | | `dimensions__height` | `dimensions.height` | Flattened | | `dimensions__weight` | `dimensions.weight` | Flattened | | `metadata` | `metadata` | JSON | You can filter on `dimensions.width` (it's a real column), but querying inside `metadata` requires adapter-specific JSON functions. ## Next Steps - [Defaults & Generated Values](/api/defaults) — auto-generated values and static defaults - [Indexes & Constraints](/api/indexes) — database indexes, precision, and collation - [Field Encryption](/api/encryption) — `@db.encrypted` fields bypass these modes and store as one opaque text column - [Tables & Fields](/api/tables) — declaring tables, primary keys, and field types --- URL: "db.atscript.dev/api/tables" LLMS_URL: "db.atscript.dev/api/tables.md" outline: "deep" --- # Tables & Fields Atscript lets you define database tables directly in `.as` files. Each table is an interface annotated with `@db.table` — the fields become columns, and annotations control how they map to the underlying database. ## Declaring a Table Add `@db.table` to an interface to make it a database table: ```atscript @db.table 'users' export interface User { @meta.id id: number name: string email: string bio?: string } ``` The string argument sets the physical table name in the database. Optional fields (marked with `?`) become nullable columns. If you omit the name, the interface name is used directly: ```atscript @db.table export interface User { ... } // Table name: "User" ``` Providing an explicit name is recommended — it gives you control over casing and pluralization regardless of how you name your interface. ## Primary Keys Mark the primary key with `@meta.id`: ```atscript @db.table 'tasks' export interface Task { @meta.id id: number title: string done: boolean } ``` The `@meta.id` annotation takes no arguments. Every table should have at least one primary key field. `@meta.id` only marks the field as the primary key — it does **not** generate values on its own. Add `@db.default.increment` (numeric, auto-incrementing) or `@db.default.uuid` (random UUID string) when you want the database to populate the key for you. See [Defaults & Generated Values](/api/defaults). When a `number` field is the primary key, SQLite stores it as `INTEGER` rather than the default `REAL` so it can act as the table's row id. ## Composite Primary Keys When multiple fields are annotated with `@meta.id`, they form a composite primary key. This is common for junction tables in many-to-many relationships: ```atscript @db.table 'task_tags' export interface TaskTag { @meta.id taskId: number @meta.id tagId: number assignedAt?: number.timestamp.created } ``` Here, the combination of `taskId` and `tagId` uniquely identifies each row. Neither field alone is unique — only the pair together serves as the key. ### Composite Key Operations All CRUD operations work with composite keys. For programmatic usage, pass an object with all key fields: ```typescript // Find by composite key const entry = await taskTags.findById({ taskId: 1, tagId: 2 }); // Delete by composite key await taskTags.deleteOne({ taskId: 1, tagId: 2 }); // Replace by composite key (all fields required) await taskTags.replaceOne({ taskId: 1, tagId: 2, assignedAt: Date.now() }); // Update by composite key (partial) await taskTags.updateOne({ taskId: 1, tagId: 2, assignedAt: Date.now() }); ``` Providing only some key fields results in a `400` error for operations that require the full key (findById, deleteOne, replaceOne, updateOne). For `findMany`, partial key fields act as regular filters. ::: tip HTTP usage See the [HTTP CRUD endpoints](/http/crud#get-one) for how composite keys map to URL query parameters. ::: ## Field Types Atscript types map to database column types automatically: | Atscript Type | SQLite | PostgreSQL | MySQL | MongoDB | | -------------- | --------------------- | --------------------------------------------------------------- | ------------------------ | ------------- | | `string` | TEXT | TEXT / VARCHAR | VARCHAR / TEXT | string | | `number` | REAL (INTEGER for PK) | DOUBLE PRECISION (BIGINT with `@db.default.increment` / `.now`) | DOUBLE (INT for PK) | number | | `decimal` | REAL | NUMERIC(p,s) | DECIMAL(p,s) | string | | `boolean` | INTEGER (0/1) | BOOLEAN | TINYINT(1) | boolean | | Arrays | TEXT (JSON) | JSONB | JSON | native array | | Nested objects | Flattened columns | Flattened columns / JSONB | Flattened columns / JSON | native object | Semantic subtypes like `string.email`, `number.int`, and `number.timestamp` map to the same base column types. They carry meaning for validation and code generation, but the storage type follows the base type. The `decimal` type is stored as a string at runtime to preserve exact precision. This also means it passes through JSON transport without any loss. Use `@db.column.precision` to control the database column's precision and scale — see [Indexes & Constraints](/api/indexes#column-precision). Nested objects and arrays have special storage modes — see [Storage & Nested Objects](/api/storage) for details. ## Custom Column Names Override the physical column name with `@db.column`: ```atscript @db.column 'email_address' email: string // Column in DB: email_address // Field in code: email ``` ::: tip When to use `@db.column` Reach for `@db.column` when you need a custom physical name — typically to map to a legacy schema or to satisfy an external naming convention. If you control the schema, prefer naming your Atscript fields to match the desired column names so no remapping is needed. ::: For nested objects that are flattened, the parent prefix is prepended automatically. If you rename a parent field, all its flattened children reflect the new prefix. ## Excluding Fields Use `@db.ignore` to keep a field in the type but exclude it from the database: ```atscript @db.table 'users' export interface User { @meta.id id: number name: string @db.ignore displayName?: string // Exists in TypeScript types, no column in DB } ``` An ignored field cannot also be a primary key — `@db.ignore` and `@meta.id` on the same field is an error. ## Preferred Identifier By default, `findById` and `deleteOne` resolve scalar ids against the primary key and every **single-field** unique index, and object ids against the primary key plus compound unique indexes. If you want a non-PK unique index (such as `slug` or `email`) to be the canonical "id" for a table, add `@db.table.preferredId.uniqueIndex`: ```atscript @db.table 'posts' @db.table.preferredId.uniqueIndex 'by_slug' export interface Post { @meta.id @db.default.increment id: number @db.index.unique 'by_slug' slug: string title: string } ``` ```typescript await posts.findById("hello-world"); // resolves against slug await posts.findById({ id: 42 }); // PK still works ``` With an explicit preferred identifier, scalar ids are routed **only** to the preferred field (deterministic) — they no longer fall back to other single-field unique indexes. Pass the argument-less form when the table has a single unique index group; the name is required only when there are multiple. ::: warning Same-interface constraint The `@db.index.unique` group MUST be declared on a property of **this** interface, not inherited. `asc` walks the local property list only; if the only matching unique index lives on an `extends`-parent you get `@db.table.preferredId.uniqueIndex requires at least one @db.index.unique on a prop of this interface.` Declare both annotations on the same interface (the one carrying `@db.table`), or keep the PK as the preferred id for action addressing. ::: ## Filter & Sort Gating By default every column can be filtered or sorted via the auto-generated REST controller. To lock that down, switch the table to manual mode and opt fields in explicitly: ```atscript @db.table 'users' @db.table.filterable 'manual' @db.table.sortable 'manual' export interface User { @meta.id id: number @db.column.filterable @db.column.sortable email: string // not exposed to ?filter= / ?sort= passwordHash: string } ``` The HTTP layer rejects any filter or sort against a non-allowed column with `400`. This is purely a controller-level concern — the programmatic table API is unaffected. ## Database Schemas Assign a table to a schema or namespace with `@db.schema`: ```atscript @db.table 'users' @db.schema 'auth' export interface User { @meta.id id: number name: string } // Full table path: auth.users ``` This is useful for organizing tables into logical groups, particularly with databases that support schemas natively (like PostgreSQL). SQLite adapters typically prefix the table name. On PostgreSQL, schema sync creates the namespace automatically (`CREATE SCHEMA IF NOT EXISTS`) before creating the table — a fresh database needs no manual schema provisioning. ## Rename Tracking When you rename a table or column, the schema sync system needs to know the old name to perform a rename migration rather than dropping and recreating: ```atscript @db.table 'team_members' @db.table.renamed 'users' export interface TeamMember { @meta.id id: number @db.column.renamed 'name' fullName: string email: string } ``` - `@db.table.renamed 'users'` — tells sync that this table was previously called `users` - `@db.column.renamed 'name'` — tells sync that `fullName` was previously called `name` These annotations are consumed during [Schema Sync](/sync/) and can be removed after the migration has been applied to all environments. ## Next Steps - [Storage & Nested Objects](/api/storage) — how nested objects and arrays are stored - [Defaults & Generated Values](/api/defaults) — auto-generated values and static defaults - [Indexes & Constraints](/api/indexes) — database indexes, precision, and collation - [CRUD Operations](/api/crud) — reading and writing data - [Relations](/relations/) — foreign keys and table relationships --- URL: "db.atscript.dev/api/transactions" LLMS_URL: "db.atscript.dev/api/transactions.md" outline: "deep" --- # Transactions Transactions ensure that multiple database operations either all succeed or all roll back together. If any operation within a transaction fails, every change is reverted, leaving your database in a consistent state. ## Basic Usage `withTransaction` lives on the **adapter**, not on the table. Access it via `space.getAdapter()` or `table.getAdapter()`: ```typescript const adapter = db.getAdapter(User); await adapter.withTransaction(async () => { await users.insertOne({ email: "alice@example.com", name: "Alice" }); await todos.insertOne({ title: "Welcome task", ownerId: 1 }); }); ``` If any operation throws, the entire transaction rolls back. Neither the user nor the todo will be inserted. The return value of the callback is propagated: ```typescript const id = await adapter.withTransaction(async () => { const result = await users.insertOne({ email: "alice@example.com", name: "Alice" }); return result.insertedId; }); // id is the inserted user's primary key ``` ## Cross-Table Transactions Each table in a `DbSpace` has its own adapter instance, but transactions are shared across all adapters in the same async context via `AsyncLocalStorage`. Start a transaction on **any** adapter — all operations in the callback automatically participate: ```typescript const users = db.getTable(User); const projects = db.getTable(Project); const tasks = db.getTable(Task); const adapter = db.getAdapter(User); await adapter.withTransaction(async () => { const { insertedId } = await users.insertOne({ name: "Alice", email: "alice@example.com", }); await projects.insertOne({ title: "New Project", ownerId: insertedId }); await tasks.insertMany([ { title: "Setup", projectId: 1 }, { title: "Deploy", projectId: 1 }, ]); }); ``` Even though `users`, `projects`, and `tasks` have separate adapter instances, the `AsyncLocalStorage` context ensures they all use the same underlying transaction. ## Automatic Nesting Nested `withTransaction()` calls reuse the outer transaction — no savepoints are created, and no extra `BEGIN`/`COMMIT` pairs are issued: ```typescript await adapter.withTransaction(async () => { await users.insertOne({ name: "Alice", email: "alice@example.com" }); // Inner transaction reuses outer — no extra BEGIN/COMMIT await adapter.withTransaction(async () => { await tasks.insertOne({ title: "Welcome task", ownerId: 1 }); }); }); ``` This means library code can safely call `withTransaction()` without worrying about whether the caller has already started one. If a transaction is active, the inner call joins it; otherwise, a new one begins. ## Deep Operations Are Transactional All deep operations automatically wrap themselves in a transaction. You don't need explicit `withTransaction()` for: - `insertOne` / `insertMany` with nested relation data - `replaceOne` / `bulkReplace` with nested data - `updateOne` / `bulkUpdate` with nested data - `deleteOne` with cascade behavior For example, inserting a user with related tasks and project references runs as a single atomic operation internally — if any part fails, all changes roll back. See [Relations — Deep Operations](/relations/deep-operations) for details. ## Error Handling and Rollback When an error is thrown inside `withTransaction()`, the transaction rolls back and the error propagates to the caller: ```typescript try { await adapter.withTransaction(async () => { await users.insertOne({ email: "alice@example.com", name: "Alice" }); throw new Error("Something went wrong"); // User is NOT inserted — entire transaction rolls back }); } catch (error) { console.log("Transaction rolled back:", error.message); } ``` This applies to any kind of failure — validation errors, constraint violations, or application-level errors. The database remains in the state it was in before the transaction began. If the rollback itself fails, the rollback error is swallowed and the original error is preserved. ## Adapter Behavior | Adapter | DML transactions | Transactional DDL | Notes | | ---------- | ---------------- | ----------------- | ------------------------------------------------------------------------------------------ | | PostgreSQL | ✅ | ✅ | Best support; CREATE / ALTER TABLE roll back on failure | | SQLite | ✅ | ❌ | Schema changes commit immediately | | MySQL | ✅ (InnoDB) | ❌ | DDL auto-commits | | MongoDB | ✅ | n/a | **Requires a replica set**; falls back to no-op on standalone (same code works dev → prod) | ## When to Use Explicit Transactions **Use `withTransaction()` when:** - Multiple independent writes must be atomic - Custom business logic spans multiple tables - Batch operations where partial completion is unacceptable **You do NOT need explicit transactions for:** - Single record operations (already atomic) - Deep operations with nested data (auto-wrapped) - Read-only queries (no mutations to protect) ## Next Steps - [Relations — Deep Operations](/relations/deep-operations) — Auto-transactional nested CRUD - [Schema Sync](/sync/) — Automatic schema migrations - [Adapters](/adapters/) — Full adapter configuration and features --- URL: "db.atscript.dev/api/update-patch" LLMS_URL: "db.atscript.dev/api/update-patch.md" outline: "deep" --- # Update & Patch This page covers fine-grained update operations on **single-table data** — embedded objects and embedded arrays stored directly on a record. For patching related records across foreign keys (FROM and VIA navigation properties), see [Relations — Relational Patches](/relations/patches). ::: warning Nested writes need `@db.depth.limit` `bulkUpdate`, `bulkReplace`, and `insertMany` validate nested-write depth against [`@db.depth.limit N`](/relations/deep-operations) on the target table. The default — annotation absent — is `0`, which rejects every payload that crosses a `@db.rel.from` / `@db.rel.via` relation. Opt in explicitly when you want the server to accept deep writes. ::: ## Simple Updates The simplest update sets scalar fields directly. The primary key must be in the payload to identify the record: ```typescript await users.updateOne({ id: 1, name: "Alice Smith", status: "active", }); ``` Only the provided fields are changed — other fields remain untouched. ## Field Operations {#field-ops} Field operations let you atomically increment, decrement, or multiply numeric fields without reading the current value first. This is essential for counters, stock levels, scores, and any field where concurrent updates must not lose data. ### Available Operations | Operator | Effect | SQL | MongoDB | | -------- | -------------------------------- | ------------------- | ----------------- | | `$inc` | Add a number to the field | `SET col = col + ?` | `$inc` | | `$dec` | Subtract a number from the field | `SET col = col - ?` | `$inc` (negative) | | `$mul` | Multiply the field by a number | `SET col = col * ?` | `$mul` | All operations are **atomic** — they execute as a single database operation, safe for concurrent use. ### Usage Import the operation helpers from `@atscript/db/ops` — this module has **zero dependencies** and is safe for frontend use: ```typescript import { $inc, $dec, $mul } from "@atscript/db/ops"; // Increment views by 1 await posts.updateOne({ id: 1, views: $inc() }); // Decrement stock by 5 await products.updateOne({ id: 42, stock: $dec(5) }); // Apply a 10% price increase await products.updateOne({ id: 42, price: $mul(1.1) }); ``` Field operations can be mixed with regular field assignments in the same update: ```typescript await posts.updateOne({ id: 1, views: $inc(), title: "Updated Title", status: "published", }); ``` ### With `updateMany` Field operations also work with filter-based batch updates: ```typescript // Give all active users 100 bonus points await users.updateMany({ status: "active" }, { points: $inc(100) }); ``` ### With `bulkUpdate` Multiple records can receive different operations in one call: ```typescript await products.bulkUpdate([ { id: 1, stock: $dec(2) }, { id: 2, stock: $dec(5) }, { id: 3, price: $mul(0.9) }, ]); ``` ::: tip HTTP usage The helpers return plain JSON objects (`$inc(5)` → `{ $inc: 5 }`), so they serialize naturally. See the [HTTP PATCH endpoint](/http/crud#patch-update) for REST examples. ::: ## `$cas` — Optimistic Concurrency {#cas-operator} `$cas` is a top-level payload operator (sibling to plain SET fields and the field ops above) that turns any `updateOne`, `replaceOne`, or per-item `bulkUpdate` into a **conditional** write. It is the opt-in surface for optimistic concurrency control on tables that declare a [`@db.column.version`](./versioning#the-db-column-version-annotation) column. ```typescript const ok = await tasks.updateOne({ id: 1, status: "done", $cas: { version: row.version }, // ← only commits if the row is still at this version }); if (ok.matchedCount === 0) { // Either the row doesn't exist OR another writer touched it. Retry. } ``` On a mismatch the call returns `{ matchedCount: 0, modifiedCount: 0 }` — no exception, no partial write. `$cas` composes atomically with `$inc` / `$dec` / `$mul` in the same payload (single statement on SQL, single `findOneAndUpdate` on Mongo). `$cas` is **not** supported on `updateMany` (a single `expectedVersion` cannot sensibly match N rows). Per-row version locking goes through `bulkUpdate` where each payload carries its own `$cas`. See [Optimistic Concurrency (Row Versioning)](./versioning) for the full reference — annotation constraints, the `withOptimisticRetry` helper, `CasExhaustedError`, direct-write rejection, and the [HTTP wire contract](/http/crud#occ-over-http). ### Operation Helpers The `@atscript/db/ops` module also exports helpers for [array patch operators](#embedded-array-patches): ```typescript import { $insert, $remove, $replace, $upsert, $update } from "@atscript/db/ops"; await posts.updateOne({ id: 1, tags: $insert(["urgent"]) }); await posts.updateOne({ id: 1, tags: $remove(["draft"]) }); ``` ### Where Field Ops Work Field operations require that the target field maps to its own database column (or document key) so the database engine can apply the arithmetic atomically. This means: | Context | Works? | Why | | ---------------------------------------------- | ------ | ------------------------------------------------------------------------------------ | | Top-level numeric field | Yes | Maps directly to a column / document key | | Nested field with `@db.patch.strategy 'merge'` | Yes | Each sub-field is stored as its own column (SQL) or dot-path key (MongoDB) | | Inside a navigation property (relation) | Yes | The related table's `bulkUpdate` handles ops on its own fields | | `updateMany` — same contexts as `updateOne` | Yes | The patch is decomposed the same way — nested merge-strategy ops become dot-path ops | ### Where Field Ops Do NOT Work | Context | Result | Why | | --------------------------------------------------------------- | -------------------- | ----------------------------------------------------------------------------------------------------- | | Inside a `@db.json` field | **Validation error** | JSON fields are stored as an opaque blob — the database cannot reach inside to increment a single key | | Inside a nested object **without** `@db.patch.strategy 'merge'` | **Validation error** | Without merge strategy the entire object is replaced — there is no individual column to increment | ```typescript import { $inc } from "@atscript/db/ops"; // ❌ Validation error — @db.json field await table.updateOne({ id: 1, metadata: { clicks: $inc() } }); // ❌ Validation error — no merge strategy await table.updateOne({ id: 1, address: { zip: $inc() } }); // ✅ Works — merge strategy present await table.updateOne({ id: 1, stats: { views: $inc() } }); ``` ::: tip If you need atomic operations on nested fields, add `@db.patch.strategy 'merge'` to the parent field. This stores each sub-field as a separate column (SQL) or allows dot-path updates (MongoDB). ::: ## Embedded Object Patches Nested objects stored on a record (not navigation properties) use a strategy-based approach controlled by `@db.patch.strategy`. The mental model is: - **Default = replace = strict.** Every branch is treated as replace unless explicitly annotated otherwise. The validator requires every **required** child to be present on a replace branch — partial sub-shapes that omit required fields are rejected with a 400 to keep the database constraint and the API contract aligned. - **`@db.patch.strategy 'merge'` is a one-level opt-in.** It applies only to the level it's annotated on; descendants revert to default replace unless they too carry the annotation. Merge does not propagate. - **Optional children of a replace branch are null-filled when omitted.** That's what distinguishes replace from merge — replace clears unspecified optional sub-fields, merge preserves them. - **`@db.json` is always strict** — the column is an opaque blob; partial sub-shapes can't be decomposed. ### Replace Strategy (Default) Without `@db.patch.strategy`, the nested object is **overwritten**: every required child must be supplied, and any optional child the user omits is explicitly set to `null` so the storage matches the input shape on both SQL (column-decomposed) and MongoDB (sub-document): ```atscript // schema address: { line1: string line2?: string // optional city: string state: string zip: string } ``` ```typescript // Current: { address: { line1: '123 Main St', line2: 'Apt 4', city: 'Portland', state: 'OR', zip: '97201' } } // ✅ Full required shape — optional line2 omitted, gets null-filled await table.updateOne({ id: 1, address: { line1: "1 Pike Pl", city: "Seattle", state: "WA", zip: "98101" }, }); // Result: address.line1='1 Pike Pl', address.line2=null, address.city='Seattle', … // ❌ Missing a required child — rejected at the validator await table.updateOne({ id: 1, address: { city: "Seattle" } }); // ValidatorError: address.line1 is required, address.state is required, … // (replace = strict; required children must be supplied) ``` If you want to update one field of `address` and preserve the rest, switch the parent to merge: ```atscript @db.patch.strategy 'merge' address: { ... } ``` [Field operations](#field-ops) (`$inc`, `$dec`, `$mul`) are **not allowed** inside replace-strategy objects — the validator will reject them. Use `@db.patch.strategy 'merge'` if you need atomic operations on nested fields, or to preserve omitted siblings. ### Merge Strategy With `@db.patch.strategy 'merge'`, only the provided nested fields are updated — others are preserved. The annotation applies to that one level only; nested objects under a merge parent revert to default replace unless they also carry the annotation: ```atscript @db.patch.strategy 'merge' address: { line1: string line2?: string city: string } ``` ```typescript // Current: { address: { line1: '123 Main St', line2: 'Apt 4', city: 'Portland' } } await table.updateOne({ id: 1, address: { city: "Seattle" } }); // Result: { address: { line1: '123 Main St', line2: 'Apt 4', city: 'Seattle' } } // ✅ line1 and line2 preserved ``` Merge-strategy fields also support [field operations](#field-ops) on their nested numeric sub-fields: ```typescript import { $inc } from "@atscript/db/ops"; await products.updateOne({ id: 1, stats: { views: $inc(), rating: 4.5 }, }); // views is atomically incremented, rating is set to 4.5, other stats fields preserved ``` ## Embedded Array Patches Arrays stored directly on the record support five patch operators for fine-grained manipulation: | Operator | Effect | | ---------- | ----------------------------- | | `$replace` | Replace the entire array | | `$insert` | Append new items | | `$upsert` | Insert or update items by key | | `$update` | Update existing items by key | | `$remove` | Remove items by key or value | When multiple operators appear on the same field, they are always applied in order: **remove → update → upsert → insert** — regardless of the order they appear in the object. ::: warning SQL Adapters In relational databases (SQLite, PostgreSQL, MySQL), arrays are stored as JSON columns. Patch operators work via read-modify-write. For collections that need frequent partial updates in SQL, consider modeling them as separate tables with [FROM or VIA relations](/relations/patches) instead. ::: ## Primitive Arrays For simple value arrays like `tags: string[]`, operators work by **value equality** — no key fields are needed: ```typescript import { $insert, $remove, $replace } from "@atscript/db/ops"; // Append items await table.updateOne({ id: 1, tags: $insert(["urgent", "reviewed"]) }); // Remove by value await table.updateOne({ id: 1, tags: $remove(["draft"]) }); // Full replacement await table.updateOne({ id: 1, tags: $replace(["final", "approved"]) }); ``` ### Unique Primitive Arrays When `@expect.array.uniqueItems` is set, `$insert` automatically skips duplicates: ```atscript @expect.array.uniqueItems tags: string[] ``` ```typescript import { $insert } from "@atscript/db/ops"; // Current tags: ['api', 'backend'] await table.updateOne({ id: 1, tags: $insert(["api", "frontend"]) }); // Result: ['api', 'backend', 'frontend'] — 'api' was silently skipped ``` ## Keyed Object Arrays `@expect.array.key` marks which properties identify an element inside an embedded object array. Keys are required for `$update`, `$upsert`, and key-based `$remove`: ```atscript variants: { @expect.array.key sku: string color: string stock: number }[] ``` Multiple fields can be marked as keys to form a **composite key** — an element matches only when all key fields match. ### Operations with Replace Strategy (Default) ```typescript import { $insert, $update, $remove, $upsert } from "@atscript/db/ops"; // Insert a new variant await table.updateOne({ id: 1, variants: $insert([{ sku: "B2", color: "blue", stock: 10 }]), }); // Update — replaces the entire matched element await table.updateOne({ id: 1, variants: $update([{ sku: "B2", color: "navy", stock: 8 }]), }); // Remove by key await table.updateOne({ id: 1, variants: $remove([{ sku: "B2" }]), }); // Upsert — insert if not found, replace if found await table.updateOne({ id: 1, variants: $upsert([{ sku: "C3", color: "green", stock: 3 }]), }); ``` Under replace strategy, `$update` and `$upsert` replace the **entire** matched element — every required field must be present. ### Operations with Merge Strategy With `@db.patch.strategy 'merge'`, updates merge into the existing element, preserving fields not explicitly provided: ```atscript @db.patch.strategy 'merge' attributes: { @expect.array.key name: string value: string visible: boolean }[] ``` ```typescript import { $update } from "@atscript/db/ops"; // Current: [{ name: 'size', value: 'M', visible: true }] await table.updateOne({ id: 1, attributes: $update([{ name: "size", value: "XL" }]), }); // Result: [{ name: 'size', value: 'XL', visible: true }] — 'visible' preserved ``` ## Keyless Object Arrays For object arrays without `@expect.array.key`, matching falls back to **full deep value equality**. This means `$remove` works (match entire objects), but `$update` is effectively a no-op (there are no key fields to locate a target element for partial update): ```typescript import { $insert, $remove, $replace } from "@atscript/db/ops"; // Append await table.updateOne({ id: 1, logs: $insert([{ message: "Deployed", ts: 1710000000 }]), }); // Remove by exact match await table.updateOne({ id: 1, logs: $remove([{ message: "Deployed", ts: 1710000000 }]), }); // Full replacement await table.updateOne({ id: 1, logs: $replace([]) }); ``` For anything beyond simple append/remove, add `@expect.array.key` to enable key-based matching. ## JSON Fields Fields annotated with `@db.json` reject **all** patch operators and **field operations** (`$inc`, `$dec`, `$mul`). The field is stored as a single opaque JSON column — the database cannot operate on individual keys inside it. Only plain replacement is allowed: ```atscript @db.json settings: { theme: string notifications: boolean } ``` ```typescript // ✅ Works — plain replacement await table.updateOne({ id: 1, settings: { theme: "dark", notifications: false }, }); // ❌ Fails — patch operators rejected on @db.json fields await table.updateOne({ id: 1, settings: $replace({ theme: "dark" }), }); // ❌ Fails — field ops rejected inside @db.json fields await table.updateOne({ id: 1, settings: { notifications: $inc() }, }); ``` The same applies to `@db.json` arrays — use a plain array value instead of patch operators. ## Combining Operators Multiple operators can be used on the same field, and multiple fields can be patched in one request. When combining operators on a single field, use the raw object form — sentinel helpers return single-operator objects and cannot be merged: ```typescript import { $insert, $inc } from "@atscript/db/ops"; await table.updateOne({ id: 1, // Multiple ops on one field — use raw object variants: { $remove: [{ sku: "OLD" }], $update: [{ sku: "A1", color: "red", stock: 5 }], $insert: [{ sku: "NEW", color: "green", stock: 10 }], }, // Single op on a field — use sentinel helper tags: $insert(["reviewed"]), views: $inc(), title: "Updated title", }); ``` Operators are always applied in order: **remove → update → upsert → insert**. ## `maxDepth` Option `bulkUpdate` and `bulkReplace` (and through them, `updateOne` / `replaceOne`) accept an optional second argument that caps how deep the runtime is allowed to recurse into nested relation payloads: ```typescript await users.bulkUpdate(payloads, { maxDepth: 5 }); ``` - The default is `3` — enough for typical "user → profile → address" chains. - This is a **runtime** cap on recursion; it sits below `@db.depth.limit`, which is the **declarative** server-boundary check. Both have to allow the depth for a deep write to succeed. - Setting `maxDepth` lower than your payload's depth raises a `DepthLimitExceededError` (`DEPTH_EXCEEDED`) before any database write. ## What This Page Does NOT Cover Navigation property patches — operating on related records across foreign keys using the same `$insert`, `$update`, `$remove`, `$upsert`, and `$replace` operators on FROM and VIA relations — are covered in [Relations — Relational Patches](/relations/patches). ## Next Steps - [CRUD Operations](/api/crud) — Basic insert, read, update, delete - [Queries & Filters](/api/queries) — Filtering, sorting, and projection - [Transactions](/api/transactions) — Atomic multi-table operations - [Relations — Relational Patches](/relations/patches) — Patching navigation properties --- URL: "db.atscript.dev/api/versioning" LLMS_URL: "db.atscript.dev/api/versioning.md" outline: "deep" --- # Optimistic Concurrency (Row Versioning) Atscript DB supports first-class **optimistic concurrency control (OCC)** via a server-managed integer version column. A single annotation makes the column auto-bump on every write; the inline `$cas` operator turns any write into a conditional one that rejects stale read-modify-write submissions. ## When You Need This Atscript DB has two atomic primitives out of the box: **insert** and [field ops](./update-patch#field-ops) (`$inc`, `$dec`, `$mul`). Every other write is last-write-wins — a caller reads a row, computes a patch in JS, and submits it via `updateOne`. Between read and write there is a race window in which another writer can change the row, and the second writer silently overwrites the first. That window is fine for many fields (display name, profile bio). It is **not** fine for state machines with read-modify-write semantics: - Consuming a single-use credential (backup code, magic-link token) — two concurrent consumers can both succeed and use the credential twice. - Adding to a JSON-column collection (`mfa.methods[]`, `trustedDevices[]`) — concurrent appenders each compute their own union and one entry is lost. - Confirming an MFA enrollment or transitioning a workflow step stored in a row column. - Inventory decrement with business rules (no-go-below-zero) — `$inc(-1)` is atomic but the rule check on the resulting value is not. The common pattern is "read state, compute next state, write state, fail if state changed under us." That is OCC, and it is the de-facto enterprise primitive in every major ORM. Atscript DB exposes it via a single annotation, an inline `$cas` operator, and a thin auto-lift in [`@atscript/moost-db`](/http/crud#occ-over-http) so REST clients get OCC seamlessly. ## The `@db.column.version` Annotation Mark a numeric column as the row's version: ```atscript interface Task { @meta.id @db.default.increment id: int title: string status: 'open' | 'done' @db.column.version version: int } ``` Constraints (enforced at compile time): - **At most one** version column per table — composite versioning makes no semantic sense. - The field must resolve to a SQL `INTEGER` (or Mongo `Number`). String / timestamp versioning is not supported. - The annotation is **boolean only** — column renaming uses the existing [`@db.column.renamed`](/api/tables) annotation. - The field is **server-managed**: the adapter sets it to `0` on insert and increments it by `1` on every successful update. See [defaults](./defaults#version-defaults). ::: tip Auto-bump is mandatory Every successful write to a versioned row increments `version` by 1, whether or not `$cas` was supplied. This is the property that makes OCC actually work — if the version column did not auto-increment, opting in to `$cas` would silently degrade to no protection. ::: Callers MAY read the version (and SHOULD, in order to pass `$cas`) but **MUST NOT** write it directly. See [Direct-write rejection](#direct-write-rejection). ## The `$cas` Operator `$cas` is a top-level payload operator — a sibling to plain SET fields and `$inc`/`$mul` field ops. It is the only opt-in surface for CAS. ```typescript const ok = await tasks.updateOne({ id: 1, status: "done", $cas: { version: row.version }, // ← opt-in conflict detection }); if (ok.matchedCount === 0) { // Either the row doesn't exist OR another writer touched it. Retry. } ``` The map shape (`{ [versionColumn]: N }`) keys by the table's version column name. No exception is thrown on mismatch — the call returns `{ matchedCount: 0, modifiedCount: 0 }` and the caller decides what to do. ::: tip No throw on mismatch Throwing creates an asymmetry where every retry path needs `try/catch` instead of a clean `if (!result.matchedCount)`. Distinguish "row missing" from "version mismatch" with an extra `findOne` if you care; for the dominant retry-on-conflict use case both states warrant the same response. ::: ### `$cas` with `bulkUpdate` Each payload in `bulkUpdate` carries its own `$cas`. Rows with matching versions are updated; rows that mismatch (or do not exist) are **silently skipped**. `modifiedCount` reflects how many actually applied. ```typescript await tasks.bulkUpdate([ { id: 1, status: "done", $cas: { version: 7 } }, { id: 2, status: "done", $cas: { version: 3 } }, { id: 3, status: "done" }, // no $cas — always applies ]); ``` This is the version-locked batch primitive — partial success is the point. Detect partial failure via `matchedCount < items.length`. ### `$cas` with `replaceOne` `replaceOne` supports `$cas` with the same semantics as `updateOne`: predicate filters by version, bump on success. ```typescript await tasks.replaceOne({ id: 1, title: "Bake bread", status: "done", $cas: { version: 4 }, }); ``` ### `$cas` is NOT supported on `updateMany` `updateMany(filter, data)` always writes through, auto-bumping the version but never checking it. A single `expectedVersion` cannot sensibly match N rows with different versions. Per-row version locking is the job of `bulkUpdate` (see above). ```typescript // ✅ Auto-bumps every matched row's version await tasks.updateMany({ status: "open" }, { status: "done" }); // ❌ Throws — `$cas` is not allowed on updateMany await tasks.updateMany({ status: "open" }, { status: "done", $cas: { version: 1 } }); ``` ### Composition with field operations `$cas` composes atomically with `$inc` / `$dec` / `$mul`. The adapter generates a single statement like `UPDATE … SET counter = counter + 1, version = version + 1 WHERE id = ? AND version = ?` — either everything applies or nothing does. ```typescript import { $inc } from "@atscript/db/ops"; await tasks.updateOne({ id: 1, counter: $inc(), $cas: { version: 4 }, }); // Both the counter increment AND the version bump happen atomically, // gated by the version predicate. ``` ## `withOptimisticRetry` — The Retry Helper Raw CAS works, but every consumer writing a retry loop is friction. `withOptimisticRetry` covers the 90% case: ```typescript import { withOptimisticRetry } from "@atscript/db"; await withOptimisticRetry( users, { id }, // filter — typically the primary key async (row) => { // Pure function: receives the current row, returns the patch. return { backupCodes: row.backupCodes.filter((h) => h !== usedHash), }; }, { maxAttempts: 5 }, ); ``` What it does, in order: 1. `findOne(filter)` — read the current row (throws `DbError("NOT_FOUND")` if missing). 2. Call `mutator(row)` to compute the patch. 3. `updateOne({ ...filter, ...patch, $cas: { [versionColumn]: row.version } })`. 4. On `matchedCount === 0`, retry from step 1 up to `maxAttempts` times. 5. After `maxAttempts` consecutive conflicts, throw [`CasExhaustedError`](#casexhaustederror). The second parameter is a **filter** (not just an id) so composite-key and non-id tables work without contortion. The helper requires the table to declare `@db.column.version` — otherwise it throws `DbError("INVALID_QUERY")` (silently degrading to last-write-wins would defeat the purpose). ### Options | Option | Type | Default | Effect | | ------------- | ------------------------------------ | ------- | --------------------------------------------------------------------------------------------------------------------- | | `maxAttempts` | `number` | `5` | How many times to re-read and retry before giving up. | | `delay` | `(attempt: number) => Promise` | none | Hook invoked between failed attempts. Receives the 1-based attempt number that just failed. Use for backoff + jitter. | ```typescript await withOptimisticRetry(users, { id }, mutator, { maxAttempts: 10, delay: async (attempt) => { const ms = Math.min(100 * 2 ** attempt, 1000) + Math.random() * 50; await new Promise((r) => setTimeout(r, ms)); }, }); ``` This helper is optional sugar — the raw `$cas` API is fine for callers that want explicit control over retry policy. ## Direct-Write Rejection The version column is server-managed. Any attempt to write it from a payload is rejected at the patch-decomposer layer, **regardless of which write method is used**: ```typescript // ❌ All three throw DbError("VERSION_COLUMN_WRITE") await tasks.updateOne({ id: 1, version: 5 }); await tasks.updateOne({ id: 1, version: $inc() }); await tasks.updateOne({ id: 1, version: $mul(2) }); await tasks.replaceOne({ id: 1, title: "x", status: "open", version: 9 }); await tasks.bulkUpdate([{ id: 1, version: 5 }]); ``` The error code is `VERSION_COLUMN_WRITE`. Catch it like any other [`DbError`](./crud#error-handling): ```typescript import { DbError } from "@atscript/db"; try { await tasks.updateOne({ id: 1, version: 5 }); } catch (err) { if (err instanceof DbError && err.code === "VERSION_COLUMN_WRITE") { // Caller tried to write the version column. Use $cas instead. } } ``` ## `CasExhaustedError` Thrown by [`withOptimisticRetry`](#withoptimisticretry-the-retry-helper) when `maxAttempts` is reached without a successful commit — the target row kept changing under the read-modify-write loop. The error carries the attempt count and the last-observed version, useful for logging hot-row contention: ```typescript import { CasExhaustedError, withOptimisticRetry } from "@atscript/db"; try { await withOptimisticRetry(tokens, { id }, consumeToken); } catch (err) { if (err instanceof CasExhaustedError) { console.warn( `Token ${id} contended out after ${err.attempts} attempts; ` + `last seen version=${err.lastSeenVersion}`, ); } } ``` `CasExhaustedError` extends `DbError` with `code === "CAS_EXHAUSTED"`. ## Edge Cases & Gotchas ### `updateMany` never CAS-checks This is a [locked design decision](#alternatives-considered). A single `expectedVersion` cannot sensibly match N rows with different versions; per-row version locking belongs in `bulkUpdate`. Passing `$cas` to `updateMany` throws. ### `replaceOne` supports CAS `replaceOne({ …, $cas: { version: N } })` behaves identically to `updateOne` from a CAS perspective: predicate filters by version, bump on success, `matchedCount: 0` on mismatch. ### `$cas` + field ops compose atomically `{ id, counter: $inc(), $cas: { version: 4 } }` produces a single statement that either applies both the increment and the version bump or applies neither — there is no intermediate state. ### Version on insert `@db.column.version` implies a `0` default at insert time — see [Version defaults](./defaults#version-defaults). You do not need to add `@db.default '0'` explicitly. ### External writers do not auto-bump Any process that writes to a versioned row **outside** the adapter (raw SQL migrations, ETL pipelines, ops scripts, replication catchup) will NOT bump the version column. A subsequent CAS-protected caller will then succeed against a stale state without knowing. This is a known limitation of the application-layer approach. Consumers that need to defend against this should install a per-engine database trigger on the versioned table that auto-bumps `version` on every UPDATE. Atscript DB does not install such triggers. ### Counter overflow `int32` saturates after ~2.1B updates. For tables that turn over that often, use `int64` via the existing precision semantics. Default `int32` is plenty for nearly every realistic scenario (2.1B writes to a single row is ~70 years of one write per second). ### JSON columns [`@db.json`](./update-patch#json-fields) columns are independent of CAS. Version operates at the row level; the JSON sub-document is replaced wholesale on update, and the version bump applies to the row regardless of which fields changed. ## End-to-End Example: Consuming a Backup Code A classic single-use-credential race — two concurrent consumers must not both succeed: ```atscript interface User { @meta.id @db.default.uuid id: string email: string backupCodes: string[] @db.column.version version: int } ``` ```typescript import { withOptimisticRetry } from "@atscript/db"; import { sha256 } from "./crypto"; async function consumeBackupCode(userId: string, code: string): Promise { const hash = sha256(code); let consumed = false; await withOptimisticRetry( users, { id: userId }, (user) => { if (!user.backupCodes.includes(hash)) { return {}; // No-op patch — code not found. Still bumps version (harmless). } consumed = true; return { backupCodes: user.backupCodes.filter((h) => h !== hash), }; }, { maxAttempts: 5 }, ); return consumed; } ``` Two concurrent `consumeBackupCode` calls with the same code: exactly one returns `true`, the other re-reads after the first commits, finds the code gone, and returns `false`. Guaranteed by the `$cas` predicate. ## REST Clients When using [`@atscript/moost-db`](/http/crud#occ-over-http), HTTP clients get OCC for free: round-trip the `version` field in your PATCH / PUT body and the controller auto-lifts it to `$cas`. Mismatches surface as `409 Conflict`; missing rows surface as `404 Not Found`. See [CRUD Endpoints — OCC over HTTP](/http/crud#occ-over-http). ### Handling 409 in `@atscript/db-client` {#handling-409} Since `@atscript/db-client` 0.1.84, the client throws a typed `VersionMismatchError` subclass automatically whenever the server response carries `kind: "version_mismatch"` — no manual body inspection required: ```ts import { VersionMismatchError } from "@atscript/db-client"; try { await users.update({ id, name: "X", version: row.version }); } catch (e) { if (e instanceof VersionMismatchError) { // e.currentVersion is the row's now-stored version — refresh + retry. } } ``` `instanceof VersionMismatchError` is the recommended discriminator. On older db-client releases (pre-0.1.84), fall back to inspecting `err.body?.kind === "version_mismatch"` and reading `err.body.currentVersion` directly — the wire shape is unchanged, only the typed marker is new. ## Alternatives Considered These are intentionally **not** supported in v1 — listed so you know they were considered and rejected: - **Timestamp-based versioning** — suffers from clock skew on distributed writers and millisecond collision under high load. - **Hash-based versioning** — pushes hashing into every read+write; harder to reason about than monotonic integers. - **Per-field versioning** — strictly more granular (concurrent edits to disjoint fields succeed) but significantly more complex; the 80% use case is "this row's state machine moved on," which is row-level by nature. - **Pessimistic locking (`SELECT FOR UPDATE`)** — separate concept; could be a future addition but not OCC. - **`If-Match` header** — body-embedded `version` is simpler for SPA clients that already round-trip the row. - **`opts.expectedVersion` parameter** — duplicate surface for the same semantics; `$cas` is the only way to opt in. ## Next Steps - [Update & Patch](./update-patch#cas-operator) — operator catalog, including `$cas` alongside `$inc` / `$dec` / `$mul`. - [CRUD Operations](./crud#updating-records) — basic `updateOne` / `replaceOne` reference. - [Defaults & Generated Values](./defaults#version-defaults) — how `@db.column.version` implies its default. - [HTTP CRUD — OCC over HTTP](/http/crud#occ-over-http) — REST behavior, 409 body shape, bulk semantics. --- URL: "db.atscript.dev/guide" LLMS_URL: "db.atscript.dev/guide.md" outline: "deep" --- # Database Layer Atscript's DB layer extends the `.as` model with database annotations — define tables, relations, views, and constraints in the same files that drive your TypeScript types. One model powers your types, validation, schema, and runtime queries. ::: info New to Atscript? Start with the [TypeScript Quick Start](https://atscript.dev/packages/typescript/quick-start) to learn `.as` syntax and project setup. The DB layer builds on the same model. ::: ## How It Works Add `@db.*` annotations to your `.as` definitions and the DB layer takes it from there: ```atscript @db.table 'users' export interface User { @meta.id @db.default.increment id: number @db.index.unique 'email_idx' email: string name: string @db.default.now createdAt?: number.timestamp } ``` From this single definition you get: - **TypeScript types** — fully typed interfaces and runtime metadata - **Database schema** — tables, columns, indexes, and constraints - **Validation** — automatic data validation from the same annotations - **CRUD operations** — type-safe insert, find, update, and delete - **Schema sync** — drift detection and automatic migrations via CLI ## Architecture The DB layer is organized in three tiers: | Layer | Role | Example | | --------------------------------- | ---------------------------------------------------------------- | -------------------------------------------------- | | **Annotations** (`@db.*`) | Declare schema, indexes, relations, and views inside `.as` files | `@db.table`, `@db.rel.to`, `@db.view` | | **Table API** (`AtscriptDbTable`) | Type-safe CRUD, relation loading, query translation, schema sync | `table.find()`, `table.insert()` | | **Adapters** (`BaseDbAdapter`) | Database-specific drivers that implement the adapter interface | `SqliteAdapter`, `PostgresAdapter`, `MongoAdapter` | Your application code talks to the Table API. The adapter handles SQL generation, document mapping, or whatever your database needs — you never write driver-level code directly. ## What's Included | Package | Purpose | | ----------------------- | ------------------------------------------------------------------ | | `@atscript/db/plugin` | Provides all generic `@db.*` annotations via `dbPlugin()` | | `@atscript/db` | Table API, views, relations, query translation, schema sync engine | | `@atscript/db-sqlite` | SQLite adapter (better-sqlite3 or node:sqlite) | | `@atscript/db-postgres` | PostgreSQL adapter with pgvector and CITEXT support | | `@atscript/db-mongo` | MongoDB adapter with Atlas Search and vector search support | | `@atscript/db-mysql` | MySQL adapter | | `@atscript/moost-db` | REST API controller for the [Moost](https://moost.org) framework | ::: tip Used in production [**Aooth**](https://aooth.moost.org) — auth + RBAC for the Moost / atscript ecosystem — uses `@atscript/db` for credential storage and session tokens. Ships ready-to-use `.as` models you can extend with your own application fields. ::: ## Feature Highlights - **[Relations](/relations/)** — TO (foreign key), FROM (reverse 1:N), and VIA (M:N junction table) with explicit `$with` loading - **[Views](/views/)** — managed, materialized, and external views defined with `@db.view` annotations - **[Text search](/search/)** — full-text search across indexed fields - **[Vector search](/search/vector-search)** — similarity search with pgvector and MongoDB Atlas - **Array patch operators** — `$insert`, `$remove`, `$update`, `$upsert`, and `$replace` work across all adapters - **[Schema sync](/sync/)** — CLI-driven migrations with FNV-1a drift detection, column renames, and distributed locking - **[Transactions](/api/transactions)** — adapter-agnostic transaction support via `AsyncLocalStorage` - **Adapter-agnostic design** — swap SQLite for PostgreSQL (or any other adapter) without changing application code ## Model-First, Not ORM-First Atscript is a **model-first data layer**, not a traditional ORM. The `.as` model is the center of the system — the database is one consumer of that model, alongside TypeScript types, validators, and API metadata. | | Traditional ORM | Atscript DB Layer | | -------------------- | ------------------------------ | ---------------------------------------------------------- | | **Source of truth** | Entity classes or ORM config | Shared `.as` model | | **Optimized for** | Object mapping and DB access | Reusing one model across types, validation, DB, and APIs | | **Validation** | Separate library or DTO layer | Built into the same model | | **Schema evolution** | ORM-specific migrations | Schema sync from `@db.*` annotations | | **Relations** | Object graph with lazy loading | Explicit relation loading via `$with` | | **Metadata reuse** | Mostly DB-focused | Same model powers validators, JSON Schema, and UI metadata | ## Next Steps - [Quick Start](/guide/quick-start) — build your first table in five minutes - [Tables & Fields](/api/tables) — define columns, primary keys, and field types - [Relations](/relations/) — connect tables with TO, FROM, and VIA relations - [CRUD Operations](/api/crud) — insert, query, update, and delete data --- URL: "db.atscript.dev/guide/_experimental-warning" LLMS_URL: "db.atscript.dev/guide/_experimental-warning.md" --- --- URL: "db.atscript.dev/guide/quick-start" LLMS_URL: "db.atscript.dev/guide/quick-start.md" outline: "deep" --- # Quick Start This guide builds on the [TypeScript Quick Start](https://atscript.dev/packages/typescript/quick-start) — you will use the same `.as` model-driven workflow to create a database-backed application with typed CRUD operations. ::: tip What You Will Build A **Todo app** backed by a single database table. At the end, a brief two-table example shows how relations work. ::: ::: info Recommended Reading If you are new to Atscript, start with the [TypeScript Quick Start](https://atscript.dev/packages/typescript/quick-start) first. This guide assumes you are familiar with `.as` syntax and the compilation workflow. ::: ## 1. Install Dependencies ::: code-group ```bash [SQLite] pnpm add @atscript/core @atscript/typescript @atscript/db @atscript/db-sqlite better-sqlite3 ``` ```bash [PostgreSQL] pnpm add @atscript/core @atscript/typescript @atscript/db @atscript/db-postgres pg ``` ```bash [MongoDB] pnpm add @atscript/core @atscript/typescript @atscript/db @atscript/db-mongo mongodb ``` ```bash [MySQL] pnpm add @atscript/core @atscript/typescript @atscript/db @atscript/db-mysql mysql2 ``` ::: ## 2. Configure Atscript Create `atscript.config.mts` in your project root: ::: code-group ```typescript [SQLite] import { defineConfig } from "@atscript/core"; import ts from "@atscript/typescript"; import { dbPlugin } from "@atscript/db/plugin"; export default defineConfig({ rootDir: "src", plugins: [ts(), dbPlugin()], format: "dts", db: { adapter: "@atscript/db-sqlite", connection: "./myapp.db", }, }); ``` ```typescript [PostgreSQL] import { defineConfig } from "@atscript/core"; import ts from "@atscript/typescript"; import { dbPlugin } from "@atscript/db/plugin"; export default defineConfig({ rootDir: "src", plugins: [ts(), dbPlugin()], format: "dts", db: { adapter: "@atscript/db-postgres", connection: "postgresql://user:password@localhost:5432/myapp", }, }); ``` ```typescript [MongoDB] import { defineConfig } from "@atscript/core"; import ts from "@atscript/typescript"; import { dbPlugin } from "@atscript/db/plugin"; export default defineConfig({ rootDir: "src", plugins: [ts(), dbPlugin()], format: "dts", db: { adapter: "@atscript/db-mongo", connection: "mongodb://localhost:27017/myapp", }, }); ``` ```typescript [MySQL] import { defineConfig } from "@atscript/core"; import ts from "@atscript/typescript"; import { dbPlugin } from "@atscript/db/plugin"; export default defineConfig({ rootDir: "src", plugins: [ts(), dbPlugin()], format: "dts", db: { adapter: "@atscript/db-mysql", connection: "mysql://user:password@localhost:3306/myapp", }, }); ``` ::: The `db` section tells the CLI which adapter to use for schema sync and where to find your database. ::: warning Runtime build setup required `.as` files are compiled by your bundler at runtime — TypeScript on its own cannot import them. Install and wire up [`unplugin-atscript`](https://atscript.dev/packages/typescript/build-setup) for Vite, Webpack, Rollup, esbuild, or your runner of choice before running the code below. Without it, `import { Todo } from "./schema/todo.as"` will fail at module load. ::: ## 3. Define Your Schema Create `src/schema/todo.as`: ```atscript @db.table 'todos' export interface Todo { @meta.id @db.default.increment id: number title: string description?: string @db.default 'false' completed?: boolean @db.default.now createdAt?: number.timestamp } ``` This defines a `todos` table with an auto-incrementing primary key, a required `title`, an optional `description`, a `completed` flag that defaults to `false` when omitted, and a `createdAt` timestamp set automatically on insert. ## 4. Compile ```bash npx asc ``` This generates type declarations (`.as.d.ts`) from your `.as` files so TypeScript can resolve types in `.ts` imports. Runtime code (`.as.js`) is not needed here — the bundler generates it in-memory via [unplugin-atscript](https://atscript.dev/packages/typescript/build-setup). ## 5. Sync Your Schema ```bash npx asc db sync ``` Schema sync inspects your `@db.*` annotations, compares them against the live database, and applies any changes — creating tables, adding columns, and syncing indexes. See [Schema Sync](/sync/) for details. ## 6. Use in Your Application ::: code-group ```typescript [SQLite] import { DbSpace } from "@atscript/db"; import { SqliteAdapter, BetterSqlite3Driver } from "@atscript/db-sqlite"; import { Todo } from "./schema/todo.as"; // Create a database space with a SQLite adapter const driver = new BetterSqlite3Driver("./myapp.db"); const db = new DbSpace(() => new SqliteAdapter(driver)); ``` ```typescript [PostgreSQL] import { DbSpace } from "@atscript/db"; import { PostgresAdapter, PgDriver } from "@atscript/db-postgres"; import { Todo } from "./schema/todo.as"; // Create a database space with a PostgreSQL adapter const driver = new PgDriver({ connectionString: "postgresql://user:password@localhost:5432/myapp", }); const db = new DbSpace(() => new PostgresAdapter(driver)); ``` ```typescript [MongoDB] import { DbSpace } from "@atscript/db"; import { MongoAdapter } from "@atscript/db-mongo"; import { MongoClient } from "mongodb"; import { Todo } from "./schema/todo.as"; // Create a database space with a MongoDB adapter const client = new MongoClient("mongodb://localhost:27017"); await client.connect(); const db = new DbSpace(() => new MongoAdapter(client.db("myapp"), client)); ``` ```typescript [MySQL] import { DbSpace } from "@atscript/db"; import { MysqlAdapter, Mysql2Driver } from "@atscript/db-mysql"; import { Todo } from "./schema/todo.as"; // Create a database space with a MySQL adapter const driver = new Mysql2Driver("mysql://user:password@localhost:3306/myapp"); const db = new DbSpace(() => new MysqlAdapter(driver)); ``` ::: Now use the table API — this is the same regardless of adapter: ```typescript // Get a typed table const todos = db.getTable(Todo); // Insert await todos.insertOne({ title: "Learn Atscript" }); // Query with filter and sort const pending = await todos.findMany({ filter: { completed: false }, controls: { $sort: { createdAt: -1 } }, }); // Update await todos.updateOne({ id: 1, completed: true }); // Delete await todos.deleteOne(1); ``` Every operation is fully typed — `insertOne` requires `title` (the only non-optional, non-defaulted field), and `findMany` returns `Todo[]` with the correct shape. ## 7. Bonus: Adding Relations Suppose each todo belongs to a category. Add a second `.as` file: ```atscript @db.table 'categories' export interface Category { @meta.id @db.default.increment id: number name: string } ``` Then update `todo.as` to reference it: ```atscript import { Category } from './category.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.now createdAt?: number.timestamp @db.rel.FK categoryId?: Category.id @db.rel.to category?: Category } ``` Now you can load todos with their category in a single query: ```typescript const todosWithCategory = await todos.findMany({ controls: { $with: [{ name: "category" }] }, }); // todosWithCategory[0].category?.name → 'Work' ``` See [Relations](/relations/) for the full guide on TO, FROM, and VIA relation types. ## Next Steps - [Tables & Fields](/api/tables) — field types, primary keys, column mappings - [Storage & Nested Objects](/api/storage) — how nested objects and arrays are stored - [Defaults & Generated Values](/api/defaults) — auto-generated values and static defaults - [Relations](/relations/) — foreign keys, reverse relations, and many-to-many - [Queries & Filters](/api/queries) — advanced filtering, sorting, and pagination - [HTTP API](/http/) — expose your tables as REST endpoints --- URL: "db.atscript.dev/guide/setup" LLMS_URL: "db.atscript.dev/guide/setup.md" outline: "deep" --- # Setup This page is the canonical setup reference for Atscript's DB layer. It covers package installation, plugin registration, adapter configuration, and how to create a `DbSpace` — the entry point for all database operations. ::: warning Runtime build setup required Application code that imports compiled `.as` files (`import { User } from "./schema/user.as"`) needs [`unplugin-atscript`](https://atscript.dev/packages/typescript/build-setup) wired into your bundler / dev server (Vite, Webpack, Rollup, esbuild, Node loader, etc.). The CLI generates type declarations only — the runtime code is produced by the plugin. ::: ## Installing Packages Every project needs the core trio plus one adapter package for your database: ```bash # Core packages (always required) pnpm add @atscript/core @atscript/typescript @atscript/db ``` Then add the adapter for your database: ::: code-group ```bash [SQLite] pnpm add @atscript/db-sqlite better-sqlite3 ``` ```bash [PostgreSQL] pnpm add @atscript/db-postgres pg ``` ```bash [MongoDB] pnpm add @atscript/db-mongo mongodb ``` ```bash [MySQL] pnpm add @atscript/db-mysql mysql2 ``` ::: ## Registering the DB Plugin The `dbPlugin()` function registers all `@db.*` annotations (`@db.table`, `@db.index.*`, `@db.column.*`, `@db.default.*`, `@db.rel.*`, `@db.view.*`, `@db.search.*`, `@db.agg.*`, `@db.amount.currency.*`, `@db.unit.*`, `@db.patch.*`, etc.) and the `db.vector` and `db.currencyCode` primitive types. Call it in your config **before** compiling `.as` files: ```typescript import { defineConfig } from "@atscript/core"; import ts from "@atscript/typescript"; import { dbPlugin } from "@atscript/db/plugin"; export default defineConfig({ plugins: [ts(), dbPlugin()], // ... }); ``` ::: info Adapter-Specific Plugins PostgreSQL, MySQL, and MongoDB ship their own plugins for adapter-specific annotations: - **PostgreSQL**: `PostgresPlugin()` from `@atscript/db-postgres` — registers `@db.pg.*` annotations - **MySQL**: `MysqlPlugin()` from `@atscript/db-mysql` — registers `@db.mysql.*` annotations - **MongoDB**: `MongoPlugin()` from `@atscript/db-mongo` — registers `@db.mongo.*` annotations and the `mongo.objectId` primitive Add them alongside `dbPlugin()` if you use adapter-specific features. ::: ## Configuration File Create `atscript.config.mts` (or `atscript.config.ts`) in your project root: ::: code-group ```typescript [SQLite] import { defineConfig } from "@atscript/core"; import ts from "@atscript/typescript"; import { dbPlugin } from "@atscript/db/plugin"; export default defineConfig({ rootDir: "src", plugins: [ts(), dbPlugin()], format: "dts", db: { adapter: "@atscript/db-sqlite", connection: "./myapp.db", }, }); ``` ```typescript [PostgreSQL] import { defineConfig } from "@atscript/core"; import ts from "@atscript/typescript"; import { dbPlugin } from "@atscript/db/plugin"; import { PostgresPlugin } from "@atscript/db-postgres"; export default defineConfig({ rootDir: "src", plugins: [ts(), dbPlugin(), PostgresPlugin()], format: "dts", db: { adapter: "@atscript/db-postgres", connection: "postgresql://user@localhost:5432/mydb", }, }); ``` ```typescript [MongoDB] import { defineConfig } from "@atscript/core"; import ts from "@atscript/typescript"; import { dbPlugin } from "@atscript/db/plugin"; import { MongoPlugin } from "@atscript/db-mongo"; export default defineConfig({ rootDir: "src", plugins: [ts(), dbPlugin(), MongoPlugin()], format: "dts", db: { adapter: "@atscript/db-mongo", connection: "mongodb://localhost:27017/mydb", }, }); ``` ```typescript [MySQL] import { defineConfig } from "@atscript/core"; import ts from "@atscript/typescript"; import { dbPlugin } from "@atscript/db/plugin"; import { MysqlPlugin } from "@atscript/db-mysql"; export default defineConfig({ rootDir: "src", plugins: [ts(), dbPlugin(), MysqlPlugin()], format: "dts", db: { adapter: "@atscript/db-mysql", connection: "mysql://root@localhost:3306/mydb", }, }); ``` ::: The `db` section tells the CLI which adapter to use for [schema sync](/sync/) and how to connect. It accepts either a declarative object: ```typescript db: { adapter: '@atscript/db-sqlite', // adapter package name connection: './myapp.db', // connection string or factory options: { /* adapter-specific */ }, include: ['src/schema/**/*.as'], // optional: limit which .as files are synced exclude: [], } ``` Or a factory function for advanced setups: ```typescript db: () => createCustomDbSpace(); ``` ## Creating a DbSpace `DbSpace` is the runtime entry point. It manages adapter lifecycle, table/view instances, and cross-table discovery for relations. ```typescript import { DbSpace } from "@atscript/db"; ``` The constructor takes an **adapter factory** — a function that returns a new `BaseDbAdapter` instance. Share the driver / client across the factory so all tables in the space talk to the same connection. ::: code-group ```typescript [SQLite] import { SqliteAdapter, BetterSqlite3Driver } from "@atscript/db-sqlite"; const driver = new BetterSqlite3Driver("./myapp.db"); const db = new DbSpace(() => new SqliteAdapter(driver)); ``` ```typescript [PostgreSQL] import { PostgresAdapter, PgDriver } from "@atscript/db-postgres"; const driver = new PgDriver("postgresql://user@localhost:5432/mydb"); const db = new DbSpace(() => new PostgresAdapter(driver)); ``` ```typescript [MongoDB] import { MongoAdapter } from "@atscript/db-mongo"; import { MongoClient } from "mongodb"; const client = new MongoClient("mongodb://localhost:27017"); await client.connect(); const db = new DbSpace(() => new MongoAdapter(client.db("mydb"), client)); ``` Pick the DB name explicitly via `client.db("name")` — calling `client.db()` without an argument returns the default DB from the URI, which is easy to forget when the URI is built dynamically. ```typescript [MySQL] import { MysqlAdapter, Mysql2Driver } from "@atscript/db-mysql"; const driver = new Mysql2Driver("mysql://root@localhost:3306/mydb"); const db = new DbSpace(() => new MysqlAdapter(driver)); ``` ::: ::: tip createAdapter Shorthand Each adapter package exports a `createAdapter()` function that creates the driver and `DbSpace` in one call: ```typescript import { createAdapter } from "@atscript/db-sqlite"; const db = createAdapter("./myapp.db"); import { createAdapter } from "@atscript/db-postgres"; const db = createAdapter("postgresql://user@localhost:5432/mydb"); import { createAdapter } from "@atscript/db-mongo"; const db = createAdapter("mongodb://localhost:27017/mydb"); import { createAdapter } from "@atscript/db-mysql"; const db = createAdapter("mysql://root@localhost:3306/mydb"); ``` ::: ## Registering Types Get typed table and view instances by passing compiled `.as` types to the space: ```typescript import { User } from "./schema/user.as"; import { ActiveUsers } from "./schema/active-users.as"; const users = db.getTable(User); // AtscriptDbTable const activeUsers = db.getView(ActiveUsers); // AtscriptDbView ``` If you don't know whether a type is a table or view, use `get()` — it auto-detects by checking for `@db.view` or `@db.view.for` metadata: ```typescript const readable = db.get(SomeType); // table or view, auto-detected ``` All three methods are **lazy** — the table/view and its adapter are created on first access and cached (via `WeakMap`) for subsequent calls. ## Accessing the Adapter Use `db.getAdapter(Type)` when you need the underlying adapter — for example, to start a [transaction](/api/transactions) or call adapter-specific helpers: ```typescript import { User } from "./schema/user.as"; const adapter = db.getAdapter(User); await adapter.withTransaction(async () => { // ... }); ``` `table.getAdapter()` returns the same instance. Any adapter in the space can drive a transaction — all tables in the same async context automatically join it. ## Next Steps - [Tables & Fields](/api/tables) — Field types, nested objects, column mappings - [Storage & Nested Objects](/api/storage) — How nested objects are stored - [CRUD Operations](/api/crud) — Insert, read, update, delete - [Schema Sync](/sync/) — Automatic schema migrations - [Adapters](/adapters/) — Adapter-specific configuration and features --- URL: "db.atscript.dev/http" LLMS_URL: "db.atscript.dev/http.md" outline: "deep" --- # HTTP Setup `@atscript/moost-db` provides zero-boilerplate REST controllers that expose your [tables](/api/tables) and [views](/views/) as HTTP endpoints via the [Moost](https://moost.org) 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 {} ``` 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: | 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: ```typescript @TableController(todosTable) export class TodoController extends AsDbController {} ``` See [CRUD Endpoints](./crud) for detailed documentation of each endpoint. ### AsDbReadableController (Read-Only) Provides only read endpoints — no write operations. Use for [views](/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`): ```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 {} ``` `POST`, `PUT`, `PATCH`, and `DELETE` are not registered — clients receive 404 for these methods. ::: tip 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 {} ``` **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 {} ``` **Precedence:** decorator prefix > `@db.http.path` annotation > table name. At runtime, the controller's final computed prefix (including any parent route nesting) is always written back to `@db.http.path` on the type metadata. This ensures FK references in serialized types carry the correct URL for value-help resolution in the UI. ### 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](/guide/setup) for details on configuring `DbSpace`. ## 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](./crud) — Detailed documentation for each endpoint - [URL Query Syntax](./query-syntax) — Filter, sort, and paginate via URL parameters - [Relations & Search in URLs](./advanced) — Relation loading, text search, vector search - [Customization](./customization) — Hooks for access control, data transformation, and extending controllers - [Actions](./actions) — Declare row, batch, and table-scoped operations that surface in `/meta` - [Permissions](./permissions) — `/meta.crud` shape and the `applyMetaOverlay` per-request hook - [HTTP Client](./client) — Browser-compatible TypeScript client for these endpoints --- URL: "db.atscript.dev/http/actions" LLMS_URL: "db.atscript.dev/http/actions.md" outline: "deep" --- # Actions Actions are domain operations that live alongside CRUD — "Block User", "Approve Order", "Export CSV", "Edit (navigate)". `@atscript/moost-db` exposes a decorator family that lets you declare these once on a controller and have them surface in `GET /meta` so any UI client can render row buttons, batch toolbars, header buttons, and double-click gestures generically. A declared action is one of three kinds (`processor`): - **`backend`** — server-side POST handler (your method). - **`navigate`** — UI route push (URL template, no server call). - **`custom`** — UI-dispatched event (no server call, no navigation). Actions also carry a **level** — `'row'`, `'rows'`, or `'table'` — telling the UI where the affordance belongs. Backend actions can additionally declare a **form input** via [`@InputForm()`](#input-form): the controller advertises a `.as` interface as the action's payload schema, the UI fetches it from a per-controller endpoint, renders a form, and submits the user-filled data alongside the identifier — no manual modal plumbing. > See [Permissions](./permissions) for the built-in CRUD surface (`/meta.crud`). > Actions and CRUD permissions are sibling fields on `/meta` with the same > overlay strategy but distinct dispatch paths — typed client methods for > CRUD, `Client.action()` for actions. ## Quick Example A row-level "Block" action that POSTs to a server handler: ```atscript // schema/user.as @db.table 'users' export interface User { @meta.id id: string name: string @db.default 'false' blocked: boolean } ``` ```typescript import { AsDbController, TableController, DbAction, DbActionID } from "@atscript/moost-db"; import { Post } from "@moostjs/event-http"; import { User } from "./schema/user.as"; import { usersTable } from "./db"; @TableController(usersTable) export class UsersController extends AsDbController { @Post("actions/block") @DbAction("block", { label: "Block", icon: "i-as-block", intent: "negative" }) async blockUser(@DbActionID() id: { id: string }) { await this.table.updateOne({ id: id.id, blocked: true }); return { message: `User ${id.id} blocked` }; } } ``` Fetch `GET /users/meta` and the `actions` array now contains: ```json { "actions": [ { "name": "block", "label": "Block", "level": "row", "processor": "backend", "value": "/users/actions/block", "icon": "i-as-block", "intent": "negative" } ] } ``` A UI consuming `/meta` renders a per-row "Block" button. When the user clicks it, the client POSTs the row's identifier wrapped in the action **envelope**: ```bash curl -X POST http://localhost:3000/users/actions/block \ -H "Content-Type: application/json" \ -d '{"ids":{"id":"abc123"}}' # → { "message": "User abc123 blocked" } ``` ::: tip The body is an envelope: `{ ids?, input? }` Every action request body is an object envelope. `ids` carries the identifier(s); `input` carries the optional `@InputForm` payload (see [Form input](#input-form)). Both fields are optional: a `'table'`-level action with no form declares no `ids`, and an action without `@InputForm` carries no `input`. Even single-field PK tables send `{ "ids": { "id": "abc" } }`, never the bare scalar. See [Body envelope](#body-envelope). ::: ## Action Levels The `level` tells the UI where the action belongs. It is **inferred** from the parameter decorators of the handler — you never set it directly on `@DbAction`: | Parameter decorator(s) | Inferred level | Body envelope (JSON) | | ------------------------------------- | -------------- | ---------------------------------------------------------------- | | `@DbActionID()` or `@DbActionRow()` | `row` | `{ "ids": { ... } }` — identifier object as the `ids` field | | `@DbActionIDs()` or `@DbActionRows()` | `rows` | `{ "ids": [ ... ] }` — array of identifier objects | | _(none)_ | `table` | empty body (or `{ "input": ... }` when paired with `@InputForm`) | | Both row + rows cardinality | _illegal_ | action dropped from `/meta` with a `[moost-db actions]` warning | `@InputForm()` is **orthogonal** to level — it adds an `input` field to the envelope without affecting whether the action is row/rows/table. `@DbActionRow()` / `@DbActionRows()` inject the actual row(s) (already loaded by the gate); they are described under [Server-side Gate § Row injection](#row-injection). For class-level actions (declared via `@DbActions` family), you set `level` on the dict entry — see [Class-level actions](#class-level-actions) below. ## Body envelope {#body-envelope} Every action POST body is a JSON object **envelope** with two optional fields: ```ts { ids?: object | object[], // identifier(s) — see Identifier shape below input?: unknown, // payload for @InputForm — see Form input below } ``` The envelope shape is fixed: arrays or scalars at the body root are rejected with HTTP 400 `ValidatorError`. This is a **breaking change** from the pre-`@InputForm()` shape that placed identifiers at the root — older clients sending the bare identifier (e.g. `{"id":"abc"}` or `[{"id":"a"}]`) need to be updated to wrap them in `ids`. Empty `{}` is always valid — it means "table-level action, no input, no identifier". A `'table'`-level action with no `@InputForm` may be invoked with no body at all; the client SHOULD omit the body in that case (and `@atscript/db-client` does). ## Identifier shape {#identifier-shape} The `ids` field is **always an object** (single) or **array of objects** (multi) — never a scalar. Each object's field set must EXACTLY match one **legitimate identification** on the table: - the **primary key** (`primaryKeys`), or - any declared `@db.index.unique` group (single-field or compound). The validator is **strict** — unknown fields are rejected with HTTP 400. Precedence: PK first, then unique-index groups in declaration order. The same `@DbActionIDs()` array MAY mix shapes per-element (one element by PK, another by `email`, etc.). ```json { "ids": { "id": "abc123" } } // row, single-field PK { "ids": { "tenantId": "acme", "userId": "u1" } } // row, composite PK { "ids": { "email": "jane@example.com" } } // row, unique-index addressing { "ids": [{ "id": "a" }, { "id": "b" }] } // rows, single-field PK { "ids": [{ "id": 1 }, { "email": "x@y" }] } // rows, mixed identifier shapes ``` Even single-field PK tables MUST send `{ "ids": { "id": "abc" } }`, never bare `"abc"`. `Content-Type: application/json` only. Field names are **logical** (the `.as` prop names) — never physical column names from `@db.column "..."`. The matcher always operates in logical-name space. ## Preferred row identifier {#preferred-id} The interface-level annotation `@db.table.preferredId.uniqueIndex(name?: string)` picks a unique-index group as the row's display/addressing identifier. When omitted, `preferredId` defaults to `primaryKeys`. ```atscript @db.table 'users' @db.table.preferredId.uniqueIndex 'by_slug' interface User { @meta.id @db.default.uuid id: string @db.index.unique 'by_slug' slug: string name: string } ``` `/meta.preferredId: string[]` is always populated and always logical names. Used by: - **Navigate URLs** — `$1` substitution walks `preferredId` field declaration order. - **Backend action body** — clients can POST the preferred-id shape (`{ slug: 'alpha' }`) instead of the PK. - **Reactive list keys** — guaranteed present on every read response (see [Read-response baseline](./crud#read-response-baseline)). The table API exposes the same fields via `readable.preferredId: readonly string[]` alongside `readable.primaryKeys`. ## Three Processors ### `'backend'` — server-side POST handler The most common case. Decorate a method with `@DbAction(name, opts)` plus `@Post(path)` and Moost binds the route normally: ```typescript @Post("actions/approve") @DbAction("approve", { label: "Approve", intent: "positive" }) async approve(@DbActionID() id: { id: string }) { await this.table.updateOne({ id: id.id, approved: true }) return { message: 'Approved' } } ``` The `value` field in `/meta` is filled in by the meta builder with the bound HTTP path (controller prefix + method path). You don't compute it. ### `'navigate'` — UI route push For "Edit", "View Details", or any action that just routes to another page. Declared at the class level only: ```typescript import { DbRowActions } from "@atscript/moost-db"; @TableController(usersTable) @DbRowActions({ edit: { label: "Edit", processor: "navigate", value: "/users/$1/edit" }, }) export class UsersController extends AsDbController {} ``` The `$1` placeholder is substituted client-side with the row's `preferredId` field values, walking `meta.preferredId` declaration order (NOT object-key insertion order). Each value is `encodeURIComponent`'d; compound preferred-ids are joined with `/`; missing fields render as empty segments (`acme//jane`), not the literal `"undefined"`. The server emits `value` verbatim. See [Preferred row identifier](#preferred-id) and the [identifier rendering helpers](./client#identifier-helpers) (`formatIdentifier` / `encodeNavigateId`) the client exports for use outside `Client.action()`. `'rows'`- and `'table'`-level navigate entries do NOT substitute `$1` — `value` is sent verbatim. ### `'custom'` — UI-dispatched event For actions whose entire behaviour lives in the UI (open a modal, copy to clipboard, kick off a client-only export). No server call, no navigation: ```typescript @DbTableActions({ exportCsv: { label: "Export CSV", processor: "custom" }, }) export class OrdersController extends AsDbController {} ``` The UI receives `processor: 'custom'` and `value: 'exportCsv'` (the dict key). It dispatches an event with that name and your client code handles it. `value` is **forbidden** in `'custom'` entries — the meta builder fills it. ## Method Decorators Use these when the action has a server-side handler. ### `@DbAction(name, opts?)` Marks a method as an action. Does **not** register an HTTP route — pair it with `@Post(...)`. The `name` is the action's stable identifier surfaced to the UI. The decorator is generic over `TRow` (the bound table's row type) and `R` (the literal `requiredFields` tuple). Annotate `` at the call site — TS decorators can't infer it from the enclosing controller's class generic. `R` is captured via `const R` from the `requiredFields` literal. | Option | Type | Description | | ---------------- | ------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `label` | `string` | Human-readable label. Required (or use `@Label('...')`). | | `icon` | `string` | Icon name; UI maps to its own icon set. | | `intent` | `'positive' \| 'negative' \| 'warning' \| 'primary' \| 'secondary'` | Semantic colour/prominence hint. Suggested ordering (most → least): `negative` (destructive) > `warning` (risky-but-not-destructive: retry, force-recompute) > `primary` > `positive` > `secondary`. | | `description` | `string` | Tooltip / longer description. | | `order` | `number` | Display order hint. | | `default` | `boolean` | Marks this as the level's default (e.g. row dblclick handler). | | `promptText` | `string \| [string, string]` | Confirmation prompt. Tuple form is `[singular, plural]` — UI picks `[0]` when executing against a single ID, `[1]` otherwise. UI substitutes `$1` (preferred-id values) and `$N` (count). | | `shortcut` | `string` | Single-character keyboard hint. Modifier prefix (Alt+, Ctrl+, bare key) and activation scope are UI/UX concerns; server forwards the character verbatim and does no conflict resolution. | | `requiredFields` | `readonly FlatKey[]` (literal tuple) | **Required when `disabled` is set.** Dot-notation paths the predicate references. Server-internal — never on the wire. Type-narrows `disabled`'s row argument and drives projection widening (`@DbActionRow*` fetch + `$actions` augmentation). Listing a relation field is a TS error. See [`requiredFields`](#required-fields). | | `disabled` | `(rows: Pick, R[number]>[]) => boolean[]` | Sync batch gate predicate. One verdict per input row, parallel by index. Without `requiredFields` → action dropped at discovery. See [Server-side Gate](#server-side-gate). | | `onDisabledRows` | `'reject' \| 'skip'` | `'rows'`-level batch policy. Default `'reject'`. See [Batch mode](#rows-batch-mode). | | `table` | `AtscriptDbTable` | Required when declaring `disabled` or any `@DbActionRow*` on a class that does **not** extend `AsDbReadableController`. Silently ignored on subclasses (the bound table wins). See [Bound-table requirement](#bound-table-requirement). | `FlatKey = keyof FlatOf & string` — dot-paths over scalars; relations excluded. When `TRow = unknown` (no `` generic), all string keys are accepted at the type level and `disabled`'s row arg falls back to `any[]`. The runtime still drops `disabled` without `requiredFields`. ::: tip Label resolution The label resolves in this order: `opts.label` > `@Label('...')` decorator > drop-with-warning. Pick one — both with the same value is benign; mismatched values let `opts.label` win. ::: ### `@DbActionDefault()` Sugar for `default: true`. Equivalent to passing `opts.default = true` on `@DbAction`. Decorator order does not matter: ```typescript @Post("actions/edit") @DbAction("edit", { label: "Edit" }) @DbActionDefault() async edit(@DbActionID() id: { id: string }) { /* ... */ } ``` The default action is what UIs invoke on row double-click (or the default key in batch toolbars). At most one default per `(controller × level)` — extra defaults are demoted with a warning. ### `@DbActionID()` / `@DbActionIDs()` Parameter resolvers that read the identifier object(s) from the JSON request body and validate them against the table's legitimate identifications (PK or any `@db.index.unique` group): ```typescript // Single row, single-field PK @Post("actions/block") @DbAction("block", { label: "Block" }) async block(@DbActionID() id: { id: string }) { // body: { "ids": { "id": "abc" } } } // Single row, composite PK @Post("actions/promote") @DbAction("promote", { label: "Promote" }) async promote(@DbActionID() id: { tenantId: string; userId: string }) { // body: { "ids": { "tenantId": "acme", "userId": "u1" } } } // Single row, unique-index addressing (same controller, same endpoint) @Post("actions/promote") async promoteByEmail(@DbActionID() id: { email: string }) { // body: { "ids": { "email": "jane@example.com" } } — works as long as `email` is `@db.index.unique` } // Multiple rows @Post("actions/lock") @DbAction("lock", { label: "Lock Selected" }) async lock(@DbActionIDs() ids: Array<{ id: string }>) { // body: { "ids": [{ "id": "a" }, { "id": "b" }] } (mixed shapes per element are allowed) } ``` Validation is **strict** — unknown fields are rejected, no coercion. The identifier object's field set must EXACTLY match one legitimate identification on the table. See [Identifier shape](#identifier-shape) for precedence rules and the full contract. ::: warning `rows`-level `ids` is always an array A `'rows'` action MUST receive a JSON array under `ids`, even when the client invokes it on a single row. Send `{ "ids": [{"id":"a"}] }`, not `{ "ids": {"id":"a"} }`. The `@DbActionIDs()` resolver rejects non-array `ids` with HTTP 400. An empty array `[]` is accepted — `client.action(name, [])` posts `{ "ids": [] }`, and your handler runs with `ids === []`. ::: ::: danger `@DbActionID*` requires a bound table `@DbActionID()` and `@DbActionIDs()` validate the body against the controller's bound table schema. The bound table is resolved in this order: 1. `opts.table` (any controller class) — declare it on `@DbAction(name, { table })`. 2. Subclass of `AsDbController` / `AsDbReadableController` (wired with `@TableController` / `@ReadableController`) — bound table comes from the controller automatically. 3. Duck-type fallback — a `readable` or `table` instance property on the controller (legacy support). If none resolves at request time, the resolver throws **HTTP 500** — a server-misconfiguration signal, not a client error. For controllers that genuinely have no typed table, use Moost's `@Body()` and parse / validate the identifier yourself. When you also declare `disabled` or any `@DbActionRow*` decorator on a non-`AsDbReadableController` class, the duck-type fallback is **NOT** sufficient — you must pass `opts.table` explicitly so discovery can validate at first `/meta` (see [Bound-table requirement](#bound-table-requirement)). ::: Validation errors flow through the existing validation interceptor and emit the same envelope as DTO failures: ```json { "statusCode": 400, "message": "...", "errors": [{ "path": "userId", "message": "Missing field \"userId\"" }] } ``` ::: warning No `@Body()` alongside `@DbActionID*` Mixing `@DbActionID()` or `@DbActionIDs()` with `@Body()` on the same method drops the action with a warning. If your action needs additional input beyond the identifier, model it as `processor: 'custom'` and POST to a regular `@Post`-decorated handler from your UI client. ::: ## Form input — `@InputForm()` {#input-form} Some actions need more than just an identifier — "Approve with comment", "Transfer amount", "Update status to one of N values". Without a declarative form contract, this falls back to a custom modal hand-rolled per action: the UI doesn't know what fields to render, the server doesn't know how to validate them, and the two drift over time. `@InputForm(FormType)` collapses this into a single declaration. You point the parameter at a `.as` interface; the discoverer surfaces the form's name on `/meta`; a UI client fetches the schema from a per-controller `GET /meta/form/:name` endpoint and renders a form generically; the user-filled payload arrives at your handler in the action envelope's `input` field. ### Quick example ```atscript // schema/comment.as export interface CommentForm { note: string visibility?: 'public' | 'internal' } ``` ```typescript import { AsDbController, TableController, DbAction, DbActionID, InputForm, } from "@atscript/moost-db"; import { Post } from "@moostjs/event-http"; import { Order } from "./schema/order.as"; import { CommentForm } from "./schema/comment.as"; import { ordersTable } from "./db"; @TableController(ordersTable) export class OrdersController extends AsDbController { @Post("actions/approve") @DbAction("approve", { label: "Approve", intent: "positive" }) async approve(@DbActionID() id: { id: string }, @InputForm(CommentForm) input: CommentForm) { await this.table.updateOne({ id: id.id, status: "approved", note: input.note }); return { message: `Approved order ${id.id}` }; } } ``` `/meta` for `OrdersController` carries `inputForm: "CommentForm"` on the `approve` action: ```json { "actions": [ { "name": "approve", "label": "Approve", "level": "row", "processor": "backend", "value": "/orders/actions/approve", "intent": "positive", "inputForm": "CommentForm" } ] } ``` The client (or your UI) fetches the form schema from `GET /orders/meta/form/CommentForm`, renders a form (the `@atscript/ui` form components consume `TSerializedAnnotatedType` directly), then submits the envelope: ```bash curl -X POST http://localhost:3000/orders/actions/approve \ -H "Content-Type: application/json" \ -d '{"ids":{"id":"o1"},"input":{"note":"looks good","visibility":"internal"}}' ``` ### Form name resolution The form's wire name is `FormType.name` — the compiled `.as` class's identifier. Compiled atscript interfaces are real classes with stable names, so `InputForm(CommentForm)` is enough; the decorator never asks for an explicit string. A single `FormType` may be reused across multiple actions on the same controller — this is fine, the registry maps `name → type`. If two actions on the same controller declare different type refs but share the same `name` (e.g. via two anonymous classes that compile to the same identifier), discovery emits a `[moost-db actions]` warning and **drops** the second action: the discovery endpoint can only serve one schema per name. ### `GET /meta/form/:name` {#meta-form-endpoint} Every `AsReadableController` subclass automatically exposes: ``` GET //meta/form/:name → TSerializedAnnotatedType ``` Returns the serialized form schema for the named `@InputForm` type. Annotation allowlist matches `/meta.type` (kept: `meta.*`, `expect.*`, `db.rel.*`, plus a small `db.json` / `db.patch.strategy` / `db.default*` / `db.http.path` whitelist). Schemas are serialized once and cached per `(controller, name)`. Discovery is lazy: hitting `/meta/form/:name` triggers `discoverActions` if `/meta` hasn't been called yet on this process. Unknown names → HTTP 404. ### Validation — pluggable, not built-in `@InputForm()` is **intentionally validation-free**. The decorator stamps two pieces of param metadata: - `atscript_db_action_input_form` — `{ type: FormType, name: }`. Consumed by discovery. - `atscript_type` — the type ref alone. A generic, atscript-aware Moost pipe reads this and runs `FormType.validator()` against the resolved value. Without a pipe installed, `input` arrives at your handler as raw JSON — no validation, no coercion. To enable validation, install an atscript validator pipe globally on the Moost app or scope it per-controller / per-method: ```typescript import { Moost } from "moost"; import { atscriptValidatorPipe } from ""; // not bundled with moost-db const app = new Moost(); app.applyGlobalPipes(atscriptValidatorPipe()); ``` A validator pipe that throws `ValidatorError` from `@atscript/typescript/utils` flows through the existing `validationErrorTransform()` interceptor and surfaces as HTTP 400 with the same envelope as DTO failures — no extra wiring needed. This separation is deliberate: validation policy varies (replace mode, partial mode, custom plugins, schema dialect), and forcing one default into the decorator would lock in choices the consumer may not want. ### Constraints - **One `@InputForm` per action.** For multiple structured inputs, compose them into a single `.as` interface (object with sub-objects, or an array field for repeated items). - **No class-level support.** `@DbActions` / `@DbRowActions` / `@DbRowsActions` cannot bind a form — class-level entries point at endpoints that may live in other controllers, so the form binding can't be derived. If the target endpoint needs a form, declare it with `@DbAction(name, ...) + @InputForm(...)` on the actual handler. - **Body shape stays the envelope.** With or without `@InputForm`, the body is still `{ ids?, input? }`. Mixing `@InputForm` with `@Body()` on the same method is allowed but unusual: `@Body()` would receive the full envelope while `@InputForm` resolves to `body.input`. ### Composable ```typescript import { useDbActionInput } from "@atscript/moost-db"; const input = await useDbActionInput().load(); // body.input — `unknown`, no validation ``` For interceptors that need to inspect the form payload alongside the gate-cached identifier or row. ## Class-level Actions Use these for `'navigate'` and `'custom'` actions that never need a server method, and as an escape hatch for `'backend'` actions that point to a shared or legacy endpoint. ### `@DbActions(dict)` Generic — every entry must specify `level`: ```typescript @DbActions({ edit: { level: "row", label: "Edit", processor: "navigate", value: "/users/$1/edit" }, refresh: { level: "table", label: "Refresh", processor: "custom" }, block: { level: "row", label: "Block", processor: "backend", value: "/admin/block" }, }) ``` ### Level-pinned shortcuts `@DbTableActions`, `@DbRowActions`, `@DbRowsActions` inject `level` into every entry of the dict — purely a DX optimisation: ```typescript import { DbRowActions, DbTableActions, DbRowsActions } from "@atscript/moost-db"; @TableController(usersTable) @DbRowActions({ edit: { label: "Edit", processor: "navigate", value: "/users/$1/edit" }, view: { label: "View", processor: "navigate", value: "/users/$1" }, }) @DbTableActions({ importCsv: { label: "Import CSV", processor: "custom" }, }) @DbRowsActions({ bulkBlock: { label: "Block Selected", processor: "backend", value: "/admin/users/bulk-block" }, }) export class UsersController extends AsDbController {} ``` The dictionary key serves as the action `name`. Class-level entries do **not** bind any HTTP route — they are surfaced in `/meta` only. ### `value` rules per processor | Processor | `value` at definition time | `value` in `/meta` | | ------------ | ------------------------------------ | ----------------------------------- | | `'navigate'` | required, non-empty (URL template) | passes through unchanged | | `'backend'` | required, non-empty (full HTTP path) | passes through unchanged | | `'custom'` | **forbidden** at definition time | filled by builder with the dict key | For `'navigate'` and `'backend'`, `undefined`, `null`, and `''` are all treated as missing — the entry is dropped with a `[moost-db actions]` warning. For `'custom'`, supplying any `value` drops the entry. ::: tip Class-level `'backend'` is the escape hatch Use `processor: 'backend'` at the class level to point an action at a shared or legacy path. The dev-supplied path **must** be served by a `@Post`-bound handler somewhere — typically a method using `@DbActionID()` / `@DbActionIDs()` so the identifier-shaped JSON body is parsed and validated. The meta builder does not validate that the path resolves; that's your contract. ::: ### When to use class- vs. method-level - **Method decorator** (`@DbAction`): the action has a server handler living on this controller. The path, validation, and label all live in one place. - **Class decorator** (`@DbActions` family): the action is `'navigate'` or `'custom'` (no server handler at all), or it points at a shared/legacy `'backend'` endpoint that lives elsewhere. Also useful for declaring many actions compactly. ## Server-side Gate {#server-side-gate} Many actions only apply under specific row state — "Ship" should run only on orders with `status === 'processing'`, "Approve" only on pending requests. Without a declarative gate, the controller has to guard the handler manually and return `{ ok: false, message: 'Already shipped' }` after the user clicks; the UI button stays live for every row because the UI has no machine-readable predicate. The same predicate ends up duplicated (or, today, missing) in the UI, and the two drift. The gate collapses this to **one declaration**: the server enforces it via an interceptor, and the wire emits the predicate's source so a UI can grey-out / hide the button per row. ### Basic recipe A row-level "Ship" action gated on order status: ```typescript import { AsDbController, TableController, DbAction, DbActionID } from "@atscript/moost-db"; import { Post } from "@moostjs/event-http"; import { Order } from "./schema/order.as"; import { ordersTable } from "./db"; @TableController(ordersTable) export class OrdersController extends AsDbController { @Post("actions/ship") @DbAction("ship", { label: "Ship", intent: "primary", requiredFields: ["status"], disabled: (orders) => orders.map((o) => o.status !== "processing"), }) async ship(@DbActionID() id: { id: string }) { await this.table.updateOne({ id: id.id, status: "shipped" }); return { message: "Shipped" }; } } ``` The gate interceptor runs **after** auth guards and **before** the handler. When `disabled[i]` is `true`, the request is rejected with `ActionDisabledError` (HTTP 409) and the handler never runs. No guard code in the handler body — by the time `ship()` executes, the gate has already vetted the row. The predicate signature is `(rows: Pick, R[number]>[]) => boolean[]`: - **Sync** — `Promise` is rejected by the type system. - **Batched** — for `'row'`-level the gate calls `disabled([row])` and reads `verdicts[0]`; for `'rows'`-level it calls `disabled(survivorRows)` once. - **Parallel by index** — verdict array length MUST equal input length. Length mismatch (e.g. `() => [true]` ignoring inputs, or `rows.filter(...).map(...)`) throws HTTP 500 — the gate cannot map verdicts back to rows. - **Type-narrowed row arg** — only fields listed in `requiredFields` are visible. Reading another field is a TS error. ::: tip Annotate `` and `requiredFields` at the call site TypeScript decorators can't infer `TRow` from the enclosing class generic. Use the explicit form `@DbAction("ship", { ... })` so the predicate's row arg is type-narrowed and `requiredFields` becomes a literal tuple. Without ``, the row arg falls back to `any[]` and you lose the field-narrowing safety net. ::: ::: warning `requiredFields` is mandatory when `disabled` is set Setting `disabled` without a non-empty `requiredFields` tuple drops the action at discovery with a warning. Field-deps must be declared explicitly — the system uses them to widen `@DbActionRow*` projection AND to widen `$select` for the [`$actions=true`](#actions-augmentation) augmentation. See [`requiredFields`](#required-fields). ::: ### `perRow()` helper {#perrow} Most predicates are per-row in spirit; `perRow()` lifts a per-row function into the batch shape required by `disabled`. Polarity is preserved — `true` from the inner function means "disabled for that row": ```typescript import { perRow } from "@atscript/moost-db"; @DbAction("archive", { label: "Archive", requiredFields: ["status"], disabled: perRow((o) => o.status === "archived"), }) ``` Equivalent to `disabled: (rows) => rows.map(o => o.status === "archived")`. Use the explicit batch form when the predicate genuinely needs the whole list (e.g. cross-row checks). ### Row injection — `@DbActionRow()` / `@DbActionRows()` {#row-injection} The gate already loaded the row(s) to evaluate `disabled`. The same loaded row(s) can be injected into the handler — no second fetch: ```typescript import { DbAction, DbActionID, DbActionRow } from "@atscript/moost-db"; @Post("actions/ship") @DbAction("ship", { label: "Ship", requiredFields: ["status"], disabled: (orders) => orders.map((o) => o.status !== "processing"), }) async ship(@DbActionID() id: { id: string }, @DbActionRow() order: Order) { // `order` is the same row the gate evaluated. No re-fetch. await this.table.updateOne({ id: id.id, status: "shipped", shippedAt: Date.now() }); return { message: `Shipped order ${id.id}` }; } ``` `@DbActionRow()` / `@DbActionRows()` are also recognized as level signals (see [Action Levels](#action-levels)) — `@DbActionRow()` infers `'row'`, `@DbActionRows()` infers `'rows'`. They are interchangeable with `@DbActionID*` for level inference; mixing row-cardinality and rows-cardinality decorators on the same method drops the action with a warning. ::: tip Row projection is narrowed The injected row(s) are projected to `identifier-shape ∪ preferredId ∪ requiredFields`. Other table columns are absent. To access fields the gate doesn't read, add them to `requiredFields` (or re-fetch with `findOne`). There is no auto-deps tracker — the field set is exactly what you declare. ::: In `'rows'` + `'skip'` mode, `@DbActionRows()` resolves to filtered survivors only — the original request rows are not retrievable post-filter. ### `'rows'`-level batch mode {#rows-batch-mode} For `@DbActionIDs()` / `@DbActionRows()` actions, `onDisabledRows` controls how the gate handles partial failures: | Mode | Predicate evaluated | On any failure | Handler runs with | | -------------------- | ----------------------- | ----------------------------------------------------------------- | ------------------ | | `'reject'` (default) | every survivor row once | throws `ActionDisabledError` listing **all** failing IDs | n/a | | `'skip'` | every survivor row once | filters cached IDs + rows to passing-only; zero survivors → throw | only the survivors | Identifiers whose row didn't resolve (no DB match) are treated as failing without invoking `disabled` against `undefined`. Surviving rows are passed in one batched `disabled` call. ```typescript @Post("actions/archive") @DbAction("archive", { label: "Archive Selected", requiredFields: ["archived"], disabled: (orders) => orders.map((o) => o.archived === true), onDisabledRows: "skip", // archive only un-archived rows; ignore the rest }) async archive(@DbActionIDs() ids: Array<{ id: string }>) { // `ids` contains only survivors when `onDisabledRows: 'skip'`. await this.table.bulkUpdate(ids.map(({ id }) => ({ id, archived: true }))); return { message: `${ids.length} orders archived` }; } ``` Two notes: - `'reject'` is the default because it preserves request-atomicity — partial success is opt-in. - The cached identifier slot stores **the original submitted object references** — `'skip'`-mode filtering preserves reference equality; `useDbActionIds().load()` returns the filtered subset. ### Bound-table requirement {#bound-table-requirement} The gate / `@DbActionRow*` need a typed table at request time to load the row(s). Discovery enforces this at first `/meta`: | Controller | What's required for `disabled` / `@DbActionRow*` | | ------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------- | | `AsDbController` / `AsDbReadableController` subclass | nothing — bound table comes from `@TableController` / `@ReadableController`; `opts.table` is silently ignored | | Plain Moost controller (no extends, no `readable`/`table` field) | **MUST** declare `opts.table` on `@DbAction(name, { table })` | | Plain Moost controller WITH `readable` / `table` instance field (legacy) | duck-type fallback covers plain `@DbActionID*` only — gates and `@DbActionRow*` still need explicit `opts.table` | When the requirement isn't met, discovery emits a `[moost-db actions]` warning and **drops** the action from `/meta`. Plain `@DbActionID()` / `@DbActionIDs()` (no gate, no row injection) still works on any controller via the existing duck-type fallback — only gated / row-injecting actions need the explicit `table` opt. ```typescript // Plain controller — gated action MUST pass opts.table @Controller() export class AdminController { @Post("orders/ship") @DbAction("ship", { label: "Ship", table: ordersTable, // ← required requiredFields: ["status"], disabled: (orders) => orders.map((o) => o.status !== "processing"), }) async ship(@DbActionID() id: { id: string }, @DbActionRow() row: Order) { /* ... */ } } ``` ### `requiredFields` {#required-fields} `requiredFields` declares the dot-notation field paths the `disabled` predicate reads. It is **server-internal only** — the array never crosses the `/meta` wire. The system uses it for three things: 1. **Type narrowing** — `disabled`'s row argument is `Pick, R[number]>[]`. Reading a field not listed in `requiredFields` is a TS error. 2. **`@DbActionRow*` projection widening** — the row(s) injected into the handler include `requiredFields` (in addition to identifier-shape + `preferredId` fields). Other columns are absent. 3. **`$actions=true` augmentation** — when a read endpoint is asked to compute `$actions`, the server widens `$select` to include all `requiredFields` across the controller's row/rows-level actions, runs the predicates, then strips fields the caller didn't request. See [`$actions=true`](#actions-augmentation). ```typescript @DbAction("ship", { label: "Ship", requiredFields: ["status", "tenantId"], disabled: (orders) => orders.map((o) => o.status !== "processing" || o.tenantId !== currentTenant.value), }) ``` Without (non-empty) `requiredFields`, `disabled` is dropped at discovery with a `[moost-db actions]` warning. ### Closure-emission pitfall The wire emits `fn.toString()` of the predicate **verbatim** — captured outer-scope identifiers come along. The server doesn't validate closure-cleanliness; it runs the original closure successfully. The UI, on the other hand, evaluates the stringified source in a different scope and throws `ReferenceError` on captured names. ::: warning Predicate body must reference only the rows arg Outer-scope identifiers (constants, helpers, imports, `this.*`) work server-side but break UI mirroring. Keep predicates pure and self-contained. ```typescript // ✅ self-contained — works server + UI disabled: (orders) => orders.map((o) => o.status !== "processing"); // ❌ captures outer-scope SHIPPED — server runs, UI throws ReferenceError const SHIPPED = "shipped"; disabled: (orders) => orders.map((o) => o.status === SHIPPED); // ❌ captures `this` — same problem disabled: (orders) => orders.map((o) => o.tenantId === this.currentTenant); ``` ::: ### `ActionDisabledError` (HTTP 409) When the gate rejects, the response is HTTP 409 with this body: ```json { "name": "ActionDisabledError", "message": "Action \"ship\" is disabled for this row", "statusCode": 409, "action": "ship", "id": { "id": "abc" } } ``` For `'rows'`-level rejections, `ids: [...]` replaces `id` — each entry is the originally-submitted identifier object (`Record` in PK or unique-index form): - `'reject'` mode: `ids` lists ALL failing identifiers in original request order (predicate-rejected + missing-row both included). - `'skip'` mode with zero survivors: `ids` lists ALL request identifiers. The error class lives in `@atscript/moost-db` (`ActionDisabledError extends HttpError`). The discriminator `name: 'ActionDisabledError'` lets `@atscript/db-client` construct the typed `ActionDisabledError` subclass on the consumer side — see [HTTP Client — Actions § Error cases](./client#error-cases). ### Class-level dict entries Class-level `@DbActions` / `@DbRowActions` / `@DbRowsActions` accept `disabled` (with required `requiredFields`) but they do **not** register a server interceptor — the dict entry's `value` may point at an endpoint in another controller (or a method that doesn't exist in this scope). The predicate still runs in two places: 1. **`$actions=true` augmentation** on the read endpoints of the controller declaring the dict — rows get `$actions` reflecting the dict-level predicates. 2. **UI mirror** via the wire `disabled` string — the UI greys out the button. POSTs to the dict's `value` endpoint are **NOT** blocked here. For symmetric server enforcement at the actual `@Post`-bound handler, also declare `@DbAction(name, { requiredFields, disabled })` on that method. ### Composables Useful when composing custom interceptors that need access to the gate's cached identifiers / rows without re-parsing the body or re-fetching: ```typescript import { useDbActionId, useDbActionIds, useDbActionRow, useDbActionRows } from "@atscript/moost-db"; const id = await useDbActionId().load(); // cached, validated single identifier object const ids = await useDbActionIds().load(); // cached, validated identifier-object array const row = await useDbActionRow().load(); // cached single row (gate-loaded) const rows = await useDbActionRows().load(); // cached row array (gate-loaded; filtered in skip mode) ``` All four follow the Wooks `defineWook` pattern and return `{ load() }`. The gate runs at `AFTER_GUARD` priority so reads are safe inside any `INTERCEPTOR`-priority custom interceptor. In `'skip'` mode, `useDbActionIds().load()` returns the **filtered subset of original objects** (reference-equal to the entries the client posted), and `useDbActionRows().load()` returns the parallel-aligned filtered rows — no `undefined` gaps. ## Request and Response Contracts ### Request body All action requests use `Content-Type: application/json`. The body is always the envelope `{ ids?, input? }` — see [Body envelope](#body-envelope). | Level | Identification (`ids` field) | Form input (`input` field) | JSON body | | ---------------------- | ---------------------------- | -------------------------- | --------------------------------------------------------------- | | `row` | single-field PK | n/a | `{ "ids": { "id": "abc" } }` | | `row` | composite PK | n/a | `{ "ids": { "tenantId": "acme", "userId": "u1" } }` | | `row` | unique-index addr. | n/a | `{ "ids": { "email": "jane@example.com" } }` | | `row` + `@InputForm` | single-field PK | form payload | `{ "ids": { "id": "abc" }, "input": { "note": "looks good" } }` | | `rows` | single-field PK | n/a | `{ "ids": [{ "id": "a" }, { "id": "b" }] }` | | `rows` | mixed identifications | n/a | `{ "ids": [{ "id": 1 }, { "email": "x@y" }] }` | | `table` | none | n/a | empty body (or `{}`) | | `table` + `@InputForm` | none | form payload | `{ "input": { "msg": "hi" } }` | Strict typing on `ids` — no coercion, unknown fields rejected. Schema mismatches return HTTP 400 with the same envelope as DTO validation failures. **`rows`-level `ids` is always an array** even for a single identifier — send `{ "ids": [{"id":"a"}] }`, never `{ "ids": {"id":"a"} }`. Validation of `input` depends on a user-installed atscript validator pipe — see [Form input — Validation](#input-form). ### Success response Backend actions may return any JSON. There is one **convention** UI clients SHOULD honour: > If the response body has a top-level `"message": string`, the UI displays it (toast, banner, etc.). Otherwise the UI uses a generic per-level message ("Action completed", "5 rows updated", …). This is a documented convention, not a runtime contract — no `TDbActionResult` type, no server-side validation. You're free to return whatever shape your client expects: ```typescript @Post("actions/block") @DbAction("block", { label: "Block" }) async block(@DbActionID() id: { id: string }) { await this.table.updateOne({ id: id.id, blocked: true }) return { message: `User ${id.id} blocked` } // ← UI toasts this } @Post("actions/lock") @DbAction("lock", { label: "Lock Selected" }) async lock(@DbActionIDs() ids: Array<{ id: string }>) { await this.table.bulkUpdate(ids.map(({ id }) => ({ id, locked: true }))) return { message: `${ids.length} users locked`, locked: ids } } @Post("actions/refresh-cache") @DbAction("refresh-cache", { label: "Refresh" }) async refreshCache() { await this.warmCache() // No "message" → UI falls back to a generic toast. return { ok: true } } ``` ### Error response Errors flow through the existing validation interceptor — same envelope as CRUD endpoints. PK validation failures, missing fields, and wrong types all emit HTTP 400 with structured `errors[]`. See [CRUD — Error Handling](./crud#error-handling). Gate rejections are HTTP **409** with the `ActionDisabledError` body shape — see [Server-side Gate § `ActionDisabledError`](#actiondisablederror-http-409). ## Composing with Auth and Interceptors `@DbAction` does not interfere with any other Moost decorator. `@Authenticate`, `@Intercept`, pipe decorators, and parameter decorators all behave as if `@DbAction` were absent: ```typescript import { Authenticate } from "@moostjs/event-http"; @Post("actions/block") @Authenticate(adminGuard) @DbAction("block", { label: "Block" }) async block(@DbActionID() id: { id: string }) { /* runs only if adminGuard passes */ } ``` If the guard rejects the request, the handler body never runs and the auth-failure response is returned — exactly as for any other Moost handler. The internal gate interceptor (registered automatically when `disabled` or `@DbActionRow*` is present) runs at `AFTER_GUARD` priority — so auth guards run first, the gate runs after, then any custom `INTERCEPTOR`-priority interceptors, then the handler. ### Worked recipe — auth + gate + `requiredFields` {#auth-gate-recipe} A full row-level action that combines an `@Authenticate` admin guard, a `disabled` predicate gated on row state, `@DbActionRow()` row injection, and audited fields: ```typescript import { AsDbController, TableController, DbAction, DbActionID, DbActionRow, } from "@atscript/moost-db"; import { Post, Authenticate } from "@moostjs/event-http"; import { Order } from "./schema/order.as"; import { ordersTable } from "./db"; @TableController(ordersTable) export class OrdersController extends AsDbController { @Post("actions/refund") @Authenticate(adminGuard) @DbAction("refund", { label: "Refund", intent: "negative", requiredFields: ["status", "paidAmount"], disabled: (orders) => orders.map((o) => o.status !== "paid" || o.paidAmount <= 0), promptText: ["Refund order $1?", "Refund $N orders?"], }) async refund( @DbActionID() id: { id: string }, @DbActionRow() order: Pick, ) { // adminGuard passed, the gate already verified status === 'paid' // and paidAmount > 0 against this exact row. await this.processRefund(order); await this.table.updateOne({ id: id.id, status: "refunded" }); return { message: `Refunded order ${id.id}` }; } } ``` What happens per request: 1. `@Authenticate(adminGuard)` — Moost runs the auth guard. On failure: standard auth-failure response, handler never runs. 2. `requiredFields` widens the projection used to load the row. 3. The gate interceptor (`AFTER_GUARD`) loads the row, evaluates `disabled([row])`. On failure: HTTP 409 `ActionDisabledError`. 4. `@DbActionRow()` injects the gate-loaded row into the handler — no second fetch. 5. Handler runs. The same `disabled` predicate also drives: - **`/meta.actions[].disabled`** — emitted as `fn.toString()` for the UI to grey out the button. - **`$actions=true` augmentation** on read endpoints — each row carries `$actions: string[]` listing the actions whose predicate did NOT reject it. ::: tip Keep `disabled` self-contained The predicate's source string is shipped to UI clients verbatim. Outer-scope identifiers work server-side but break UI evaluation — see [Closure-emission pitfall](#closure-emission-pitfall). ::: ## The `/meta` Surface The `actions` field of the `/meta` response is an array of `TDbActionInfo`: ```typescript interface TDbActionInfo { name: string; label: string; level: "table" | "row" | "rows"; processor: "backend" | "navigate" | "custom"; value: string; icon?: string; intent?: "positive" | "negative" | "warning" | "primary" | "secondary"; description?: string; order?: number; default?: boolean; promptText?: string | [string, string]; // [singular, plural] shortcut?: string; // single character; UI binds the modifier disabled?: string; // fn.toString() — UI mirror only; server-evaluated availability is in row-level $actions inputForm?: string; // FormType.name when @InputForm declared; client fetches GET /meta/form/ } ``` `requiredFields` is **server-internal only** — it never crosses the wire. Predicate field-deps are declared by the dev, drive server-side projection widening, and reach UI clients implicitly via the row-level [`$actions`](#actions-augmentation) overlay rather than as an explicit `$select` hint. The server emits `fn.toString()` of `disabled` verbatim — closures and outer-scope references included. No parsing, no AST transform. The UI evaluates the source against a level-specific scope (the row for `'row'`-level, each row for `'rows'`-level) to grey-out / hide the button. Server enforcement is authoritative; this field is purely a UI hint. Discovery is **lazy** — it runs on the first `GET /meta` request and the result is cached alongside the rest of the meta envelope. Startup is unaffected. Warnings (missing label, missing `@Post`, `@Body` co-occurrence, duplicate `default`, …) emit on that first call, not at `app.init()` time. The `@atscript/db-client` consumer reads `actions` off the meta response — see [HTTP Client — Metadata](./client#meta) — and exposes a typed `client.action(name, id?)` helper that resolves and dispatches actions for you. See [HTTP Client — Actions](./client#actions) for the consumer-side API. ## `$actions=true` — server-evaluated row availability {#actions-augmentation} Asking which actions a row qualifies for can be answered server-side as part of the read. Set `$actions=true` on any read endpoint and every returned row carries an additional `$actions: string[]` field — the names of `'row'` and `'rows'`-level actions whose `disabled` predicate did NOT reject this row: ```bash GET /users/query?status=active&$actions=true # → [{ "id": "u1", "status": "active", "$actions": ["edit", "block"] }, ...] ``` Available on `/query`, `/pages`, `/one`, `/one/:id` (including `$search` and vector-search paths). NOT augmented on `$count` and `$groupBy` responses (no row shape). Action ordering follows `/meta.actions[]` declaration order. `'table'`-level actions never appear in `$actions`. Actions without a `disabled` predicate are unconditionally present in every row's array. ### Pipeline For each request that sets `$actions=true` on a controller extending `AsDbReadableController`: 1. Discover row/rows-level action envelopes (memoized per controller ctor). 2. Filter through the per-request `applyMetaOverlay()` hook — actions stripped by the overlay are absent from `$actions`. The `meta()` call is skipped when `applyMetaOverlay` is the default no-op. 3. Pre-widen `$select` to union all `requiredFields` across the surviving envelopes (only when the caller restricted projection). 4. Run the underlying read (find / pages / search / vector / findById). 5. Run each `disabled` predicate **once** against the full result set (length-mismatch verdict → HTTP 500, same contract as the gate). 6. Strip widened-only fields the caller didn't ask for, so the response shape matches the original `$select`. ### Programmatic use from `@atscript/db-client` ```typescript const r = await users.query({ filter: { active: true }, controls: { $actions: true } as const, }); r[0].$actions; // typed `string[] | undefined` via ClientResponse ``` The control is also accepted on the URL (`?$actions=true` or `?$actions=1`); the server coerces the string back to a boolean before DTO validation. ### Same predicate, three call sites The `disabled` predicate runs in three places per request lifecycle: - **`$actions=true` augmentation** — against the full result set on the read endpoints. - **Server-side gate** — against the loaded row(s) at POST time, blocking the handler with HTTP 409 on any rejection. - **UI mirror** — the `fn.toString()` source is evaluated client-side to grey out the button before invocation. Server enforcement on POST is authoritative. `$actions` and the UI mirror are availability hints used to render correctly without an extra round-trip. ## Validation Rules and Warnings The meta builder enforces several rules. Every violation emits a console warning prefixed `[moost-db actions]` and **drops** the offending action from `/meta` rather than throwing — this keeps `/meta` deliverable even with misconfigurations. | Rule | Outcome | | ----------------------------------------------------------------------------------- | -------------------------------- | | `@DbAction` method has no `@Post(...)` | warn + drop | | `@DbAction` method's only verb is non-POST (`@Get`, `@Put`, …) | warn + drop | | Both `@DbActionID()` and `@DbActionIDs()` on the same method | warn + drop | | `@DbActionID*` / `@DbActionRow*` co-occurs with `@Body()` | warn + drop | | Method has no label (no `opts.label`, no `@Label`) | warn + drop | | `@DbActionDefault()` applied without a corresponding `@DbAction(name)` | warn + drop | | Class-level `'navigate'` or `'backend'` entry has missing/empty `value` | warn + drop | | Class-level `'custom'` entry supplies a `value` | warn + drop | | Two actions with `default: true` at the same level | first wins, second demoted, warn | | `'table'`-level action declares `disabled` | warn + drop | | Gated / row-injecting on a non-`AsDbReadableController` class without `opts.table` | warn + drop | | `disabled` set without (non-empty) `requiredFields` | warn + drop | | Mixing row + rows cardinality (`@DbActionID*` / `@DbActionRow*`) on the same method | warn + drop | | Duplicate action name within the same controller | warn + drop second declaration | | Two actions sharing the same `@InputForm` form name with **different** type refs | warn + drop second declaration | The single greppable prefix `[moost-db actions]` makes it easy to detect issues in CI logs. ## Value-Help Controllers Are Excluded `AsValueHelpController` and `AsJsonValueHelpController` (used for FK pickers and dictionary surfaces) do **not** participate in action discovery. Action decorators applied to them are silently ignored — `actions` is always emitted as `[]` for shape uniformity. Adding actions to a value-help picker doesn't make sense; the contract is intentionally narrow. ## Inspecting Action Metadata — `getAtscriptDbMate()` `@atscript/moost-db` writes its action metadata to the standard Moost mate workspace, but with a typed accessor so consumers don't have to retype string keys or hand-cast results: ```typescript import { getAtscriptDbMate } from "@atscript/moost-db"; const mate = getAtscriptDbMate(); // Class- / method-level const meta = mate.read(OrdersController.prototype, "approve"); meta?.atscript_db_action; // { name, opts } | undefined — written by @DbAction meta?.atscript_db_actions; // class-level dict entries written by @DbActions / @DbRowActions / … // Param-level meta?.params?.[0]?.atscript_db_action_param; // 'id' | 'ids' — written by @DbActionID / @DbActionIDs meta?.params?.[0]?.atscript_db_action_row; // true — written by @DbActionRow meta?.params?.[0]?.atscript_db_action_rows; // true — written by @DbActionRows meta?.params?.[0]?.atscript_db_action_input_form; // { type, name } — written by @InputForm meta?.params?.[0]?.atscript_type; // FormType — also written by @InputForm ``` The returned `Mate` is the same singleton as `getMoostMate()` from `moost`, but narrowed via TypeScript declaration merging to every key `@atscript/moost-db` writes. Reach for it when: - Writing a custom Moost pipe that reads `atscript_type` to validate `@InputForm` payloads. - Building tooling that introspects the action surface without going through `/meta`. - Composing your own decorator on top of `@DbAction` / `@InputForm` and needing to read what they wrote. ### Companion type exports | Export | Use for | | ------------------------ | --------------------------------------------------------------------------------------------------- | | `AtscriptDbMate` | Return type of `getAtscriptDbMate()` — the fully-typed `Mate` shape. | | `AtscriptDbMeta` | Class- / method-level keys (`atscript_db_action`, `atscript_db_actions`, …). | | `AtscriptDbParamsMeta` | Param-level keys (`atscript_db_action_param`, `atscript_db_action_input_form`, `atscript_type`, …). | | `TDbActionMeta` | `{ name, opts }` payload written by `@DbAction`. | | `TDbActionInputFormMeta` | `{ type, name }` payload written by `@InputForm`. | | `TDbActionParamKind` | `'id' \| 'ids'` written by `@DbActionID` / `@DbActionIDs`. | | `TDbClassActionMeta` | Class-level dict entry written by `@DbActions` and the level-pinned shortcuts. | ## Next Steps - [HTTP Client](./client) — Consume the `actions` field from `@atscript/db-client` - [Customization](./customization) — Hooks for intercepting CRUD (different concept; complements actions) - [HTTP Setup](./) — Controller installation and wiring --- URL: "db.atscript.dev/http/advanced" LLMS_URL: "db.atscript.dev/http/advanced.md" outline: "deep" --- # Relations & Search in URLs This page covers the advanced URL query parameters for relation loading, text search, vector search, and aggregation. These work on top of the [basic query syntax](./query-syntax). ## Relation Loading ($with) {#with} Load related data inline using `$with`. Relations must be declared with `@db.rel.to`, `@db.rel.from`, or `@db.rel.via` in your `.as` schema. See [Relations — Foreign Keys](/relations/) for how to define them. ### Basic Usage Load a single relation: ```bash curl "http://localhost:3000/todos/query?\$with=author" ``` **Response:** ```json [ { "id": 1, "title": "Buy milk", "authorId": 5, "author": { "id": 5, "name": "Alice" } } ] ``` ### Multiple Relations Comma-separate relation names: ```bash curl "http://localhost:3000/todos/query?\$with=author,comments" ``` **Response:** ```json [ { "id": 1, "title": "Buy milk", "authorId": 5, "author": { "id": 5, "name": "Alice" }, "comments": [{ "id": 10, "text": "Great task!", "todoId": 1 }] } ] ``` ### With Controls Apply sorting, limits, or projection to a loaded relation using parentheses: ```bash curl "http://localhost:3000/todos/query?\$with=comments(\$limit=5&\$sort=-createdAt)" ``` **Response:** ```json [ { "id": 1, "title": "Buy milk", "comments": [ { "id": 15, "text": "Latest comment", "createdAt": 1710000000 }, { "id": 14, "text": "Earlier comment", "createdAt": 1709900000 } ] } ] ``` Inside parentheses, you can use any control parameter: `$sort`, `$limit`, `$skip`, `$select`. ### Nested Relations Load relations of relations: ```bash curl "http://localhost:3000/todos/query?\$with=comments(\$with=author)" ``` **Response:** ```json [ { "id": 1, "title": "Buy milk", "comments": [ { "id": 10, "text": "Great task!", "author": { "id": 5, "name": "Alice" } } ] } ] ``` ### Filtered Relations Combine filters, controls, and nesting inside parentheses: ```bash curl "http://localhost:3000/todos/query?\$with=comments(status=approved&\$sort=-createdAt&\$limit=3&\$with=author(\$select=name))" ``` All [filter operators](./query-syntax) and control parameters work inside relation sub-queries. ### Validation The controller validates relation names against your schema: - **Unknown relation** — returns `400` with a list of available relations - **FK field used as relation** — `$with=projectId` returns `400` (use the navigation property name instead, e.g., `$with=project`) ::: tip FK fields auto-included When using `$select` alongside `$with`, foreign key fields needed for joining are automatically included — even if not explicitly listed or explicitly excluded. This ensures relations resolve correctly. ::: For the programmatic equivalent, see [Relations — Loading](/relations/loading). ## Text Search ($search, $index, $fuzzy) {#text-search} Perform full-text search on fields annotated with `@db.index.fulltext` or similar search indexes. ### Basic Search Search across all fulltext-indexed fields: ```bash curl "http://localhost:3000/articles/query?\$search=mongodb+tutorial" ``` **Response:** ```json [ { "id": 3, "title": "MongoDB Tutorial for Beginners", "body": "..." }, { "id": 7, "title": "Advanced MongoDB Patterns", "body": "..." } ] ``` ### Named Search Index Target a specific search index with `$index`: ```bash curl "http://localhost:3000/products/query?\$search=wireless+headphones&\$index=product_search" ``` On MongoDB this is also how you pick a search **variant** — when the same field is indexed both ways (e.g. word match vs typeahead), `$index` selects which behavior runs. See [MongoDB → Search Variants](/adapters/mongodb#search-variants). ### Fuzzy Tolerance (MongoDB Atlas) `$fuzzy` overrides the index's declared typo tolerance for a single request — `1` or `2` edits, or `0` to disable: ```bash curl "http://localhost:3000/users/query?\$search=mngo&\$fuzzy=1" ``` The default comes from the index annotation (`@db.mongo.search.static`/`.dynamic`); see [MongoDB → Fuzzy Search](/adapters/mongodb#fuzzy-search). ### Combining with Filters Search and filter can be used together: ```bash curl "http://localhost:3000/articles/query?\$search=mongodb&status=published&\$sort=-createdAt" ``` ### Paginated Search Search works with both `/query` and `/pages`: ```bash curl "http://localhost:3000/articles/pages?\$search=typescript&\$page=1&\$size=10" ``` ::: info Adapter support Full-text search support varies by adapter. MongoDB supports Atlas Search with named indexes. SQLite provides FTS5-based search. Check your [adapter's documentation](/adapters/) for details. ::: For annotations and programmatic API, see [Text Search](/search/). ## Vector Search ($vector, $threshold, $search) {#vector-search} Perform similarity search on fields annotated with `@db.search.vector`. ### How It Works Vector search via URL requires three components: 1. **`$vector`** — the name of the vector field to search on 2. **`$search`** — the text query (converted to an embedding by the controller) 3. **`$threshold`** (optional) — minimum similarity threshold The controller calls the `computeEmbedding()` hook to convert the search text into a vector, then passes it to the adapter's vector search. ### Basic Usage ```bash curl "http://localhost:3000/articles/query?\$vector=embedding&\$search=machine+learning+basics" ``` **Response:** ```json [ { "id": 12, "title": "Introduction to ML", "score": 0.95 }, { "id": 8, "title": "Deep Learning Guide", "score": 0.87 } ] ``` ### With Threshold Set a minimum similarity score: ```bash curl "http://localhost:3000/articles/query?\$vector=embedding&\$search=machine+learning&\$threshold=0.8" ``` Only results with similarity >= 0.8 are returned. ### Combining with Filters ```bash curl "http://localhost:3000/articles/query?\$vector=embedding&\$search=machine+learning&status=published&\$limit=10" ``` ### The computeEmbedding Hook Vector search via HTTP requires the `computeEmbedding()` hook to convert the search text into a vector. Without this override, vector search returns HTTP `501 Not Implemented`. See [Customization — computeEmbedding](./customization#computeembedding) for the full implementation guide. For annotations and programmatic API, see [Vector Search](/search/vector-search). ## Geo Search ($center, $maxDistance, $minDistance) {#geo-search} Tables with a [`@db.index.geo`](/search/geo-search) field expose a dedicated `GET /geo` endpoint for distance-ranked queries (available on every adapter): ```bash curl "http://localhost:3000/listings/geo?\$center=-122.42,37.77&\$maxDistance=50000&status=ACTIVE" curl "http://localhost:3000/listings/geo?\$center=-122.42,37.77&\$page=1&\$size=20" ``` `$center` (required) is `lng,lat`; `$maxDistance`/`$minDistance` are meters. Rows come back distance-ordered, each carrying `$distance` (meters); with `$page`/`$size` the response uses the `/pages` envelope. Standard filters, `$select`, and `$with` compose normally. See [Geo Search](/search/geo-search) for the annotation, the programmatic `geoSearch()` API, the `$geoWithin` filter operator, and per-adapter support. ## Aggregation ($groupBy) {#groupby} Group records and compute aggregate values using `$groupBy`. ### Basic Usage Group by a field and select aggregate functions: ```bash curl "http://localhost:3000/orders/query?\$groupBy=status&\$select=status,count(*):total" ``` **Response:** ```json [ { "status": "pending", "total": 12 }, { "status": "shipped", "total": 45 }, { "status": "delivered", "total": 128 } ] ``` Aggregate functions in `$select` use the syntax `fn(field):alias`. ### With Filters Apply filters before aggregation: ```bash curl "http://localhost:3000/orders/query?createdAt>1700000000&\$groupBy=region&\$select=region,sum(amount):total,count(*):orders" ``` **Response:** ```json [ { "region": "US", "total": 15420.5, "orders": 87 }, { "region": "EU", "total": 8930.0, "orders": 42 } ] ``` ### With Sorting Sort aggregated results: ```bash curl "http://localhost:3000/orders/query?\$groupBy=status&\$select=status,sum(amount):total&\$sort=-total&\$limit=5" ``` ### Aggregate Functions The standard SQL aggregate functions are available: `count(*)`, `sum(field)`, `avg(field)`, `min(field)`, `max(field)`. See [Aggregation Annotations](/views/aggregations) for the full reference. ### Limitations ::: warning Restrictions - **Cannot combine with `$with`** — using `$groupBy` and `$with` together returns `400` - Plain fields in `$select` must also appear in `$groupBy` ::: ## Nested Writes via HTTP {#nested-writes} `POST`, `PUT`, and `PATCH` request bodies can contain nested relation data. The controller processes nested objects through the [deep operations](/relations/deep-operations) pipeline. **Example — insert a project with tasks:** ```bash curl -X POST http://localhost:3000/projects/ \ -H "Content-Type: application/json" \ -d '{ "title": "New Project", "tasks": [ { "title": "Task 1", "status": "todo" }, { "title": "Task 2", "status": "todo" } ] }' ``` The controller inserts the project first, then creates the related tasks with the correct foreign key values. For full details on how nested data is processed across foreign keys, see [Relations — Deep Operations](/relations/deep-operations). ## Next Steps - [Customization](./customization) — Hooks for search, access control, and extending controllers - [Relations — Loading](/relations/loading) — Programmatic relation loading - [Text Search](/search/) — Search annotations and programmatic API - [Vector Search](/search/vector-search) — Vector search annotations and programmatic API --- URL: "db.atscript.dev/http/client" LLMS_URL: "db.atscript.dev/http/client.md" outline: "deep" --- # HTTP Client `@atscript/db-client` is an HTTP client that maps 1:1 to moost-db controller endpoints. Each method corresponds to a specific HTTP request — `query()` is `GET /query`, `insert()` is `POST /`, and so on. Works in browsers, Node.js, and any runtime with `fetch`. In SSR environments, Moost's `fetch` automatically routes local requests to handlers in-process, so the same `Client` instance works on both server and browser with zero configuration. ## Installation ```bash pnpm add @atscript/db-client ``` ## Creating a Client ```typescript import { Client } from "@atscript/db-client"; // Untyped — Record generics const users = new Client("/api/users"); // Type-safe — pass the Atscript model as generic import type { User } from "./models/user.as"; const users = new Client("/api/users"); ``` When you provide ``, all methods become fully typed: - **Filters** check field names against the model's own properties - **`$sort`** keys are constrained to valid field names - **`$with`** entries are constrained to declared navigation properties - **Primary key** type flows through `one()` and `remove()` - **Insert/update data** is checked against the model's field types ### Options ```typescript const users = new Client("/api/users", { // Base URL for all requests baseUrl: "https://api.example.com", // Static headers headers: { Authorization: "Bearer token123" }, // Async header factory (e.g. token refresh) headers: async () => ({ Authorization: `Bearer ${await getToken()}`, }), // Custom fetch (e.g. for testing or interceptors) fetch: myCustomFetch, }); ``` | Option | Type | Description | | ---------- | ------------------------------------------------------------------- | --------------------------------------------------------------------------------------- | | `baseUrl` | `string` | Prepended to the path for every request | | `headers` | `Record` or `() => Promise>` | Default headers for every request — factory is called **before each request** | | `fetch` | `typeof fetch` | Custom fetch implementation | | `navigate` | `(url: string) => void \| Promise` | SPA-router hook for `processor: 'navigate'` actions (see [Navigate dispatch](#actions)) | ### SSR and auth — async headers factory {#ssr-auth} The `headers` option accepts an **async factory** that the client awaits on every request — ideal for token refresh, SSR cookie forwarding, and Authorization rotation. **Per-request token refresh:** ```typescript import { Client } from "@atscript/db-client"; const users = new Client("/api/users", { headers: async () => { const token = await getAccessToken(); // your refresh logic return { Authorization: `Bearer ${token}` }; }, }); ``` `getAccessToken()` runs on every method call (`query`, `insert`, `action`, ...) — keep it cheap or memoize internally. The client never caches the resolved headers. **SSR cookie forwarding** — on the server, forward the incoming request's cookies to the moost-db endpoint so authenticated session reuse works in-process: ```typescript import { Client } from "@atscript/db-client"; function makeServerClient(req: { headers: { cookie?: string } }) { return new Client("/api/users", { headers: async () => ({ cookie: req.headers.cookie ?? "", }), }); } ``` Moost's `fetch` automatically routes local requests to handlers in-process — the same `Client` instance works on both server and browser; only the `headers` factory needs to change. **Refreshing on 401** — the client does not implement automatic retry; wrap with a thin helper: ```typescript async function withRefresh(call: () => Promise): Promise { try { return await call(); } catch (e) { if (e instanceof ClientError && e.status === 401) { await refreshAccessToken(); // your refresh implementation return call(); // headers factory re-runs and picks up the new token } throw e; } } const rows = await withRefresh(() => users.query({ filter: { active: true } })); ``` Because `headers` is awaited on every call, the retried request automatically picks up the new token — no need to recreate the `Client`. ## Querying All query methods accept a [Uniquery](./query-syntax) object with `filter` and `controls`. ### query {#query} `GET /query` — returns all matching records. See [CRUD — GET /query](./crud#get-query). ```typescript const active = await users.query({ filter: { status: "active" }, controls: { $sort: { createdAt: -1 }, $limit: 50 }, }); ``` The `$search`, `$vector`, `$index`, and `$threshold` controls are also passed through `query()`: ```typescript // Text search const results = await users.query({ controls: { $search: "alice" }, }); // Vector search const similar = await posts.query({ controls: { $vector: "embedding", $search: "machine learning" }, }); ``` ### count {#count} `GET /query` with `$count: true` — returns the number of matching records. ```typescript const total = await users.count({ filter: { role: "admin" } }); ``` ### aggregate {#aggregate} `GET /query` with `$groupBy` — typed aggregation. See [Relations & Search — Aggregation](./advanced#groupby). ```typescript const stats = await orders.aggregate({ controls: { $groupBy: ["status"], $select: [ "status", { $fn: "count", $field: "*", $as: "total" }, { $fn: "sum", $field: "amount", $as: "revenue" }, ], }, }); ``` When `$groupBy` fields and `$select` are typed, the result type is inferred — `stats[0].total` is `number`, `stats[0].status` preserves the original field type. ### pages {#pages} `GET /pages` — page-based pagination. See [CRUD — GET /pages](./crud#get-pages). ```typescript const page = await users.pages( { filter: { active: true } }, 2, // page (default: 1) 25, // size (default: 10) ); // → { data: [...], page: 2, itemsPerPage: 25, pages: 10, count: 243 } ``` ### one {#one} `GET /one/:id` (scalar) or `GET /one?key=val` (object) — fetch by any registered identification: primary key, single-field unique index, or compound unique index. Returns `null` on 404. See [CRUD — GET /one](./crud#get-one). ```typescript // Scalar — resolves through every identification (PK + every unique index). // When the table declares an explicit `@db.table.preferredId.uniqueIndex`, // the scalar lookup is restricted to that field for determinism. const user = await users.one("abc-123"); // Object — recommended for non-PK lookups; deterministic (avoids any ambiguity // when the same scalar could match multiple unique fields). The client routes // any object id to the named-form `/one?key=val` endpoint. const userByName = await users.one({ username: "admin" }); const row = await users.one({ tenantId: "t1", userId: "u1" }); ``` Supports `controls` for projection and relation loading: ```typescript const user = await users.one("abc", { controls: { $select: ["id", "name"], $with: ["posts"] }, }); ``` ## Relation Loading Load relations using `$with` in controls. See [Relations & Search](./advanced#with) for full syntax. ```typescript const orders = await client.query({ controls: { $with: ["customer", "items"] }, }); ``` ## Write Operations {#writes} Write methods are available when the server uses `AsDbController` (not `AsDbReadableController`). ### insert {#insert} `POST /` — insert one or many records. See [CRUD — POST /](./crud#post-insert). ```typescript // Single insert → { insertedId } const { insertedId } = await users.insert({ name: "Alice", email: "alice@example.com", }); // Batch insert → { insertedCount, insertedIds } const { insertedCount } = await users.insert([{ name: "Alice" }, { name: "Bob" }]); ``` ### update {#update} `PATCH /` — partial update. Include the primary key and changed fields only. See [CRUD — PATCH /](./crud#patch-update). ```typescript // Single or bulk → { matchedCount, modifiedCount } await users.update({ id: "abc", name: "Updated" }); // Bulk await users.update([ { id: "a", status: "active" }, { id: "b", status: "active" }, ]); ``` Supports [field operations](/api/update-patch#field-operations) like `$inc`, `$dec`, `$mul`. For tables with `@db.column.version`, round-trip the `version` field — the server auto-lifts it to `$cas` and returns `409` on conflict. See [OCC over HTTP](./crud#occ-over-http) and the [versioning guide](/api/versioning). ```typescript import { VersionMismatchError, ClientError } from "@atscript/db-client"; const row = await users.one("abc"); try { await users.update({ id: "abc", name: "Updated", version: row.version }); } catch (err) { if (err instanceof VersionMismatchError) { // Row moved on; re-read and retry. err.currentVersion is the new version. } else if (err instanceof ClientError && err.status === 404) { // Row was deleted. } else { throw err; } } ``` The client throws `VersionMismatchError` (a `ClientError` subclass) automatically whenever the server response carries `kind: "version_mismatch"` — `instanceof` is the recommended discriminator since `@atscript/db-client` 0.1.84. On older versions, inspect `err.body?.kind === "version_mismatch"` and `err.body.currentVersion` directly. ### replace {#replace} `PUT /` — full document replace. All required fields must be present. See [CRUD — PUT /](./crud#put-replace). ```typescript await users.replace({ id: "abc", name: "Alice", email: "new@example.com", role: "admin", }); // Bulk await users.replace([...]); ``` `replace` accepts the same `version` round-trip as `update` — same 409 behavior, same `$cas` semantics. Catch conflicts with `instanceof VersionMismatchError` exactly as shown above. ### remove {#remove} `DELETE /:id` — remove by primary key. See [CRUD — DELETE](./crud#delete). ```typescript // Scalar PK const { deletedCount } = await users.remove("abc"); // Composite PK await users.remove({ tenantId: "t1", userId: "u1" }); ``` ## Metadata {#meta} `GET /meta` — fetch table/view metadata. The result is cached after the first call. ```typescript const meta = await users.meta(); ``` **Response shape:** ```json { "searchable": true, "vectorSearchable": false, "searchIndexes": [{ "name": "title_idx", "type": "text" }], "primaryKeys": ["id"], "preferredId": ["id"], "relations": [{ "name": "posts", "direction": "from", "isArray": true }], "fields": { "id": { "sortable": true, "filterable": true }, "name": { "sortable": false, "filterable": true } }, "type": { "...": "serialized Atscript type schema" }, "actions": [ { "name": "ship", "label": "Ship", "level": "row", "processor": "backend", "value": "/orders/actions/ship", "intent": "primary", "disabled": "(orders) => orders.map((o) => o.status !== \"processing\")" } ], "crud": { "query": [ "filter", "insights", "skip", "limit", "count", "sort", "select", "search", "index", "vector", "threshold", "with", "groupBy", "actions" ], "pages": [ "filter", "page", "size", "sort", "select", "search", "index", "vector", "threshold", "with", "actions" ], "one": ["select", "with", "actions"], "insert": [], "update": [], "replace": [], "remove": [] } } ``` | Field | Description | | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `searchable` | Table has fulltext search indexes | | `vectorSearchable` | Table has vector search indexes | | `searchIndexes` | Available search index definitions | | `primaryKeys` | Primary key field names | | `preferredId` | Logical field names of the table's preferred identifier (PK or a `@db.index.unique` group via `@db.table.preferredId.uniqueIndex`). Always populated; defaults to `primaryKeys`. Used for navigate `$1` substitution and as a guaranteed read-response baseline (see [Read-response baseline](./crud#read-response-baseline)). | | `relations` | Available navigation properties | | `fields` | Per-field capability flags (sortable, filterable) | | `type` | Full serialized Atscript type definition | | `actions` | Declared domain actions — see [Actions](./actions) for the wire shape and how UIs consume the `processor` / `value` / `level` fields | | `crud` | Built-in CRUD permissions — see [Permissions](./permissions). Key absent = denied; value is the accepted UniQuery control whitelist (`[]` for write ops). | > **Read-only check:** consumers derive the boolean from `crud` inline: > `!('insert' in meta.crud) && !('update' in meta.crud) && !('replace' in meta.crud) && !('remove' in meta.crud)`. ## Actions {#actions} `action()` invokes any [declared action](./actions) on the controller by name. The client reads `/meta` (cached), looks up the action descriptor, then dispatches based on `processor`. ```typescript client.action(name: string, id?: ..., input?: unknown): Promise ``` The identifier is **object-only** — single object for `'row'` actions, array of objects for `'rows'` actions, omitted for `'table'` actions. Even single-field PK tables send `{ id: "abc" }`, never bare `"abc"`. The third `input` argument carries the action's `@InputForm` payload — see [Form input](./actions#input-form). The wire body is the envelope `{ ids?, input? }`; the client wraps your call's args into it. ```typescript // processor: 'backend', level: 'row' — POST { "ids": { "id": "abc123" } } const result = await users.action("block", { id: "abc123" }); // → { message: "User abc123 blocked" } // level: 'rows' — pass an array of identifier objects await users.action("lock", [{ id: "a" }, { id: "b" }]); // composite PK await members.action("promote", { tenantId: "acme", userId: "u1" }); // unique-index addressing (same controller, different identification) await users.action("promote", { email: "jane@example.com" }); // level: 'table' — no identifier, no input → no body sent await users.action("refresh-cache"); // processor: 'navigate' — substitutes $1 with preferredId and navigates await users.action("edit", { slug: "alpha" }); // → /users/alpha/edit // @InputForm payload (third arg) — POST { "ids": ..., "input": ... } await users.action("approve", { id: "o1" }, { note: "looks good" }); // Table-level + @InputForm (no id) — POST { "input": ... } await users.action("broadcast", undefined, { message: "hi" }); // Typed return shape const r = await users.action<{ message: string }>("block", { id: "abc" }); r.message; // typed ``` The `` return-type generic asserts the server handler's response shape (commonly `{ message?: string, ... }` per convention). Default `R = unknown`. `action()` is always POST for `processor: 'backend'`. The path comes from the meta builder — method-decorator actions resolve to the bound HTTP path; class-level backend actions use the dev-supplied path verbatim. ### Form-schema discovery — `getActionForm()` {#get-action-form} When an action declares [`@InputForm()`](./actions#input-form), `/meta` carries an `inputForm` field with the form's name. `getActionForm(name)` lazily fetches the schema from `GET /meta/form/`, deserializes it via `deserializeAnnotatedType`, and returns the `TAtscriptAnnotatedType` ready to hand to a form renderer. ```typescript const meta = await users.meta(); const action = meta.actions.find((a) => a.name === "approve"); if (action?.inputForm) { const form = await users.getActionForm("approve"); // → TAtscriptAnnotatedType, ready for @atscript/ui form components // Render the form, collect input, then: await users.action("approve", { id: "o1" }, collectedInput); } ``` Returns `null` when the action has no `inputForm`, or the action name isn't on `/meta`. Cached per form name on the client instance — repeated calls for the same form make only one HTTP request. Failed fetches are evicted from the cache so retries can re-fetch. ### Client-side validation The client refuses obviously-wrong shapes BEFORE the network round-trip: - `'row'` level + non-object (scalar, `null`, array) for `id` → `TypeError`. - `'rows'` level + non-array (single object included — no auto-wrap) for `id` → `TypeError`. - `input` is `unknown` at the type level — the client does **not** validate it. The caller's responsibility is to match the action's `inputForm` schema; server-side validation depends on a Moost atscript validator pipe being installed (see [Actions — Validation](./actions#input-form)). The TypeScript signature catches the `id`-shape cases at compile time when `Client` is used; untyped `Client<>` clients fall back to `Partial>` and get only the runtime guard. When the server's [disabled gate](./actions#server-side-gate) rejects, `action()` throws `ActionDisabledError` (HTTP 409) — see [Error cases](#error-cases) below. ### Navigate dispatch By default, navigate actions call `window.location.assign(url)`. Inject a SPA router via the `navigate` option: ```typescript import { useRouter } from "vue-router"; const router = useRouter(); const users = new Client("/api/users", { navigate: (url) => router.push(url), }); await users.action("edit", { slug: "alpha" }); // → router.push('/users/alpha/edit') ``` For `'row'`-level navigate, the client substitutes `$1` by walking `meta.preferredId` declaration order — NOT object-key insertion order. Each value is `encodeURIComponent`'d, compound preferred-ids are joined with `/`. Missing fields render as empty segments (e.g. `acme//jane`), not the literal `"undefined"`. ```typescript // preferredId = ['tenantId', 'userId'] await users.action("edit", { userId: "jane", tenantId: "acme/co" }); // → navigate('/members/acme%2Fco/jane/edit') — order from preferredId, not object keys ``` For `level: 'rows'` and `level: 'table'` navigate actions, `value` is used verbatim — no `$1` substitution. ### Identifier rendering helpers {#identifier-helpers} The same identifier-to-string logic the client uses internally for `$1` substitution is exported as standalone helpers. Reach for these when you need to render a row identifier outside `Client.action()` — prompt text in a confirm dialog, log lines, deep-link copy, audit messages. ```typescript import { formatIdentifier, encodeNavigateId, formatIdentifierField } from "@atscript/db-client"; // Raw form (no URL encoding) — for prompt text, error messages, logs. formatIdentifier({ tenantId: "acme/co", userId: "jane" }, ["tenantId", "userId"]); // → "acme/co/jane" // URL-encoded form — same logic Client.action() applies for navigate $1. encodeNavigateId({ tenantId: "acme/co", userId: "jane" }, ["tenantId", "userId"]); // → "acme%2Fco/jane" // Single-value coercion (null / undefined → "", primitives via String, // objects/arrays via JSON.stringify). formatIdentifierField(undefined); // "" formatIdentifierField(123n); // "123" formatIdentifierField({ a: 1 }); // '{"a":1}' ``` | Helper | Encoding | Use for | | ----------------------- | ---------- | ---------------------------------------------------------------------- | | `formatIdentifier` | none | Prompt text, error messages, log lines, dialog titles | | `encodeNavigateId` | URL-encode | Navigate-URL templates (only when building deep links outside actions) | | `formatIdentifierField` | none | Single-value coercion with `null`/`undefined` → `""` semantics | ### Error cases {#error-cases} ```typescript import { ActionNotFoundError, ActionUnsupportedError, ActionDisabledError, ClientError, } from "@atscript/db-client"; try { await users.action("ship", { id: "abc" }); } catch (e) { if (e instanceof ActionNotFoundError) { /* action name not in /meta */ } if (e instanceof ActionUnsupportedError) { /* processor: 'custom' (handle the event yourself), or processor: 'navigate' with no browser env and no navigate option */ } if (e instanceof ActionDisabledError) { /* HTTP 409 — server-side disabled gate rejected the row(s). Typed accessors layered on top of ClientError: */ e.action; // "ship" e.id; // { id: "abc" } (row-level rejection — submitted identifier object) e.ids; // [...] (rows-level rejection — full list of failing identifier objects) } else if (e instanceof ClientError) { /* any other server non-2xx — same shape as other endpoints */ } } ``` `ActionDisabledError extends ClientError`, so a generic `instanceof ClientError` catch still handles gate rejections — use the typed branch when you want `e.action` / `e.id` / `e.ids` without indexing into `body`. See [Actions — Server-side Gate](./actions#server-side-gate) for the server-side declaration. `processor: 'custom'` actions cannot be invoked through the client — those describe UI events your application dispatches itself. The client throws `ActionUnsupportedError` in that case. ### Success response convention Backend action handlers may return any JSON. Convention: if the response has `{ message: string }`, the UI toasts it; otherwise the UI uses a generic per-level message. See [Actions — Success response](./actions#success-response) for the server side. ```typescript const result = await users.action<{ message?: string }>("block", { id: "abc" }); if (result?.message) toast(result.message); else toast("Action completed"); ``` ## Per-row action availability — `$actions=true` {#dollar-actions} Add `$actions: true` to any read-method `controls` to ask the server which row/rows-level actions each returned row qualifies for. The server runs every row/rows-level `disabled` predicate against the result set and attaches `$actions: string[]` (action names that did NOT reject the row) to each row. ```typescript const r = await users.query({ filter: { active: true }, controls: { $actions: true } as const, }); r[0].$actions; // string[] | undefined (typed via ClientResponse) // Pages and one() too const page = await users.pages({ controls: { $actions: true } as const }, 1, 25); page.data[0].$actions; const single = await users.one({ id: "abc" }, { controls: { $actions: true } as const }); single?.$actions; ``` NOT augmented on `count()` and `aggregate()` — no row shape. `'table'`-level actions never appear in `$actions`. Action ordering follows `/meta.actions[]` declaration order. See [Actions — `$actions=true`](./actions#actions-augmentation) for the full server-side pipeline (overlay filtering, `requiredFields`-driven projection widening, length-mismatch handling). ## Error Handling {#errors} Non-2xx responses throw a `ClientError` with the HTTP status and structured error body. The error shape matches the server's [error response format](./crud#error-handling). ```typescript import { Client, ClientError } from "@atscript/db-client"; try { await users.insert({ name: "" }); } catch (e) { if (e instanceof ClientError) { e.status; // 400 e.message; // "Validation failed" e.errors; // [{ path: "name", message: "required" }] e.body; // full server error response } } ``` `one()` is the exception — it returns `null` on 404 instead of throwing. ## Client-Side Validation {#validation} Write methods (`insert`, `update`, `replace`) automatically validate data client-side against the Atscript type fetched from `/meta`. This catches type errors before they reach the server. ```typescript // Throws ClientValidationError before sending the request await users.insert({ name: 123 }); // name must be string ``` Access the validator directly for form generation or custom validation: ```typescript const validator = await users.getValidator(); validator.flatMap; // Map of field paths → annotated types validator.navFields; // Set of navigation field names validator.validate(data, "insert"); // throws on failure ``` ## Re-exports ### Query types (from `@uniqu/core`) - `Uniquery`, `UniqueryControls` — query and control types - `FilterExpr` — filter expression type - `AggregateQuery`, `AggregateResult` — aggregation types - `TypedWithRelation` — relation loading type ```typescript import type { FilterExpr, Uniquery } from "@atscript/db-client"; ``` ### Wire / shape types (from `@atscript/db`) - `TDbActionInfo`, `TDbActionLevel`, `TDbActionIntent`, `TDbActionProcessor` — `/meta.actions[]` entry shape - `TCrudOp`, `TCrudPermissions` — `/meta.crud` shape (see [Permissions](./permissions)) - `TDbInsertResult`, `TDbInsertManyResult`, `TDbUpdateResult`, `TDbDeleteResult` — write-method return shapes ### Identifier helpers Standalone exports of the same logic used internally for navigate `$1` substitution — handy when rendering identifiers outside `Client.action()`: - `formatIdentifier(id, preferredId)` — raw `/`-joined identifier (no URL encoding) - `encodeNavigateId(id, preferredId)` — URL-encoded `/`-joined identifier - `formatIdentifierField(value)` — single-value coercion (`null` / `undefined` → `""`) See [Identifier rendering helpers](#identifier-helpers) for usage. ### Errors All error classes — generic and action-specific — are exported as runtime values for `instanceof` discrimination: - `ClientError` — base class for every non-2xx response. `status`, `body`, `errors` accessors. - `ActionDisabledError extends ClientError` — HTTP 409 from the server-side action gate. Typed `action`, `id`, `ids` accessors. - `ActionNotFoundError` — `Client.action(name)` called with a name not present in `/meta`. - `ActionUnsupportedError` — `processor: 'custom'`, or `processor: 'navigate'` with no browser env and no `navigate` option. - `ClientValidationError` (type) — thrown by client-side validation on `insert` / `update` / `replace` before sending. Type export — the runtime class lives in `@atscript/db-client/validator`. ```typescript import { ClientError, ActionDisabledError, ActionNotFoundError, ActionUnsupportedError, } from "@atscript/db-client"; import type { ClientValidationError } from "@atscript/db-client"; ``` ### Action error body shape `ActionDisabledError.body` matches this wire envelope (HTTP 409): ```ts { name: "ActionDisabledError"; statusCode: 409; message: string; action: string; // action name that rejected id?: Record; // 'row'-level rejections ids?: Record[]; // 'rows'-level rejections } ``` ## Method ↔ Endpoint Reference | Method | HTTP | Endpoint | Returns | | ----------------- | ------ | --------------------------- | ------------------------------------------------------- | | `query()` | GET | `/query` | `DataOf[]` | | `count()` | GET | `/query` (`$count`) | `number` | | `aggregate()` | GET | `/query` (`$groupBy`) | `AggregateResult[]` | | `pages()` | GET | `/pages` | `PageResult>` | | `one()` | GET | `/one/:id` or `/one?k=v` | `DataOf \| null` | | `insert()` | POST | `/` | `TDbInsertResult` or `TDbInsertManyResult` | | `update()` | PATCH | `/` | `TDbUpdateResult` | | `replace()` | PUT | `/` | `TDbUpdateResult` | | `remove()` | DELETE | `/:id` or `/?k=v` | `TDbDeleteResult` | | `meta()` | GET | `/meta` | `MetaResponse` | | `getActionForm()` | GET | `/meta/form/:name` | `TAtscriptAnnotatedType \| null` | | `getValidator()` | — | _client-side; uses `/meta`_ | `ClientValidator` (lazy, cached) | | `action()` | POST | _resolved from `/meta`_ | `unknown` (server response, or `void` for `'navigate'`) | --- URL: "db.atscript.dev/http/crud" LLMS_URL: "db.atscript.dev/http/crud.md" outline: "deep" --- # CRUD Endpoints This page documents every endpoint generated by `AsDbController` and `AsDbReadableController`. All paths are relative to the controller prefix (e.g., `/todos/`). See [HTTP Setup](./) for installation and wiring. ## Reading Records ### Read-response baseline {#read-response-baseline} Every row-returning read endpoint (`/query`, `/pages`, `/one`, `/one/:id`, including `$search` and vector-search paths) silently widens the projection so each row carries the table's `preferredId` field set, regardless of `$select`. | `$select` shape | Behaviour | | -------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | | absent / `undefined` | full projection — preferred-id fields already present | | `string[]` inclusion | dedupe + append missing preferred-id fields | | pure inclusion map (`{ name: 1 }`) | add missing preferred-id keys with value `1` | | pure exclusion map (`{ id: 0 }`) | rewritten to inclusion (all non-ignored own-table fields minus excluded) + every preferred-id field — exclusion CANNOT remove a preferred-id field | | mixed inclusion/exclusion (`{ a: 1, b: 0 }`) | rejected before the readable call (HTTP 400) | **Not** widened: `$groupBy` aggregate (group keys are the only fields) and `$count` (returns a number). `preferredId` defaults to `primaryKeys`. Declare `@db.table.preferredId.uniqueIndex(name?)` to use a specific `@db.index.unique` group as the preferred identifier — see [Actions — Preferred row identifier](./actions#preferred-id). The widening happens AFTER any `transformProjection()` override resolves; preferred-id fields cannot be suppressed via projection. Hide identifiers at the network/authz layer instead. ### GET /query {#get-query} Returns an array of records. Supports filtering, sorting, pagination, projection, relation loading, and search. ```bash curl "http://localhost:3000/todos/query?completed=false&\$sort=-createdAt&\$limit=10" ``` **Query parameters:** | Parameter | Type | Default | Description | | ------------ | --------- | ------- | --------------------------------------------------- | | `$sort` | `string` | — | Sort expression (e.g., `-createdAt` for descending) | | `$limit` | `number` | `1000` | Maximum records to return | | `$skip` | `number` | `0` | Number of records to skip | | `$select` | `string` | — | Comma-separated field names for projection | | `$count` | `boolean` | — | Return count instead of records | | `$search` | `string` | — | Fulltext search term | | `$index` | `string` | — | Named search index | | `$vector` | `string` | — | Vector field name for similarity search | | `$threshold` | `string` | — | Similarity threshold for vector search | | `$with` | `string` | — | Load relations (e.g., `$with=author,comments`) | | `$groupBy` | `string` | — | Group by fields for aggregation | | _(other)_ | _any_ | — | Filter fields (e.g., `status=active`) | **Response (array):** ```json [ { "id": 1, "title": "Buy milk", "completed": false, "priority": "high" }, { "id": 2, "title": "Write docs", "completed": false, "priority": "medium" } ] ``` When `$count` is set, returns a number instead of an array: ```bash curl "http://localhost:3000/todos/query?completed=true&\$count" ``` ```json 5 ``` See [URL Query Syntax](./query-syntax) for the full filter syntax and [Relations & Search](./advanced) for `$with`, `$search`, `$vector`, and `$groupBy`. ### GET /pages {#get-pages} Returns paginated results with metadata. Uses page-based pagination instead of offset-based. ```bash curl "http://localhost:3000/todos/pages?\$page=2&\$size=10&status=active" ``` **Additional parameters:** | Parameter | Type | Default | Description | | --------- | -------- | ------- | --------------------- | | `$page` | `number` | `1` | Page number (1-based) | | `$size` | `number` | `10` | Items per page | All other query parameters from `GET /query` (filters, `$sort`, `$select`, `$search`, `$with`) are also supported, except `$skip`, `$limit`, and `$count`. **Response:** ```json { "data": [{ "id": 11, "title": "Task 11", "status": "active" }], "page": 2, "itemsPerPage": 10, "pages": 5, "count": 47 } ``` | Field | Description | | -------------- | ------------------------------------- | | `data` | Array of records for the current page | | `page` | Current page number | | `itemsPerPage` | Page size | | `pages` | Total number of pages | | `count` | Total number of matching records | ### GET /one/:id {#get-one} Retrieves a single record by primary key (or any single-field unique index — the path resolver walks every legitimate identification). Returns `404` if not found. ```bash curl http://localhost:3000/todos/one/42 curl http://localhost:3000/users/one/admin # by username unique index ``` **Response:** ```json { "id": 42, "title": "Buy milk", "completed": false, "priority": "high" } ``` Supports `$select` and `$with` in the query string: ```bash curl "http://localhost:3000/todos/one/42?\$select=id,title&\$with=project" ``` ::: warning No filters Filter parameters (like `status=active`) are **not allowed** on this endpoint. They return a `400` error. Use `GET /query` with filters instead. ::: When the table declares `@db.table.preferredId.uniqueIndex` (the canonical addressing identifier), scalar lookups via `/one/:id` resolve against the preferredId field first; PK and other unique indexes are not retried for the same scalar (the named form below covers them when needed). **Named-form (object) addressing** — use query parameters when you want to be explicit about which identification you are addressing, or when the identifier is compound: ```bash curl "http://localhost:3000/users/one?username=admin" # single-field unique index curl "http://localhost:3000/task-tags/one?taskId=1&tagId=2" # composite primary key ``` The controller walks every registered identification (primary key + every unique index, single-field and compound) in declaration order and picks the first whose fields are all present in the query. ### GET /meta {#get-meta} Returns table or view metadata for use by UI tooling or client libraries. ```bash curl http://localhost:3000/todos/meta ``` **Response (abbreviated):** ```json { "searchable": true, "vectorSearchable": false, "searchIndexes": [{ "name": "DEFAULT", "description": "dynamic_text index" }], "primaryKeys": ["id"], "preferredId": ["id"], "relations": [{ "name": "comments", "direction": "from", "isArray": true }], "fields": { "id": { "sortable": true, "filterable": true }, "title": { "sortable": true, "filterable": true } }, "type": { "...": "serialized Atscript type definition" }, "actions": [], "crud": { "query": ["filter", "insights", "...", "actions", "groupBy"], "pages": ["filter", "page", "size", "...", "actions"], "one": ["select", "with", "actions"], "insert": [], "update": [], "replace": [], "remove": [] } } ``` | Field | Description | | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `searchable` | Whether the table has fulltext search indexes | | `vectorSearchable` | Whether the table has vector search indexes | | `searchIndexes` | Array of available search index definitions | | `primaryKeys` | Primary key field names (logical, not column names) | | `preferredId` | Logical field names of the preferred identifier (PK or `@db.table.preferredId.uniqueIndex` group). See [Preferred row identifier](./actions#preferred-id) | | `versionColumn` | Logical field name of the `@db.column.version` column, if the table declares one. **Absent** (undefined) otherwise. See [OCC over HTTP](#occ-over-http) | | `relations` | Available navigation properties | | `fields` | Per-field capability flags (`sortable`, `filterable`) | | `type` | Full serialized Atscript type (field names, types, annotations, metadata). FK fields ship as shallow refs (`{ id, metadata }`) — enough to resolve the target's URL via `db.http.path`; deeper structure is reachable through the target's own `/meta` (see below) | | `actions` | Declared domain actions — see [Actions](./actions) | | `crud` | Built-in CRUD permissions / control whitelists — see [Permissions](./permissions) | For the full payload shape including `actions[]` entries and complete `crud` whitelists, see [HTTP Client — Metadata](./client#meta). The `type.metadata["db.http.path"]` carried in this payload follows the [normalization contract](../adapters/annotations#normalization-contract) — it is always the final public URL, prefixed with `/` and inclusive of the Moost `globalPrefix`, safe to use verbatim with `fetch()` or `new Client(url)`. #### FK ref shape in meta `/meta` always serializes FK fields as shallow refs — `ref.type` is the `{ id, metadata }` shape, independent of `@db.depth.limit` (which is a security guard on nested writes, not a serialization policy). The target's `db.http.path` is carried in `metadata`, so clients can: - resolve the target endpoint for value-help pickers, and - fetch the target's own `/meta` on demand when they need the target's structural body. Nav-prop trees (`@db.rel.from` / `@db.rel.to` / `@db.rel.via`) are fully expanded in meta regardless — they are not `.ref` nodes — so the write-payload shape clients need for nested inserts is always present. Only the FK pointer bodies are shallow, and those are recoverable via a cached per-target `/meta` fetch. ## Creating Records ### POST / {#post-insert} Insert one or many records. The request body determines single vs. batch mode. **Single insert:** ```bash curl -X POST http://localhost:3000/todos/ \ -H "Content-Type: application/json" \ -d '{"title": "Buy milk", "priority": "high"}' ``` **Response:** ```json { "insertedId": 1 } ``` **Batch insert:** ```bash curl -X POST http://localhost:3000/todos/ \ -H "Content-Type: application/json" \ -d '[{"title": "Buy milk"}, {"title": "Write docs"}]' ``` **Response:** ```json { "insertedCount": 2, "insertedIds": [1, 2] } ``` Default values from `@db.default` and generated defaults (`@db.default.increment`, `@db.default.uuid`, `@db.default.now`) are applied automatically. Request bodies can contain nested relation data for [deep insert operations](/relations/deep-operations). ::: details Batch edge cases - **Empty array** `[]` — behavior is adapter-dependent (may return 200, 201, 400, or 500) - **Single-item array** `[{...}]` — treated as a batch insert, returns `insertedCount` / `insertedIds` - **Large batches** (100+ items) — supported; the entire batch runs in a single transaction - **Partial failure** — if any item fails validation or violates a constraint, the entire batch is rolled back ::: ## Updating Records ### PUT / {#put-replace} Full replace by primary key. The body must include all required fields and the primary key field(s). **Single replace:** ```bash curl -X PUT http://localhost:3000/todos/ \ -H "Content-Type: application/json" \ -d '{"id": 1, "title": "Buy oat milk", "completed": true, "priority": "high"}' ``` **Response:** ```json { "matchedCount": 1, "modifiedCount": 1 } ``` **Bulk replace:** ```bash curl -X PUT http://localhost:3000/todos/ \ -H "Content-Type: application/json" \ -d '[ {"id": 1, "title": "Buy oat milk", "completed": true, "priority": "high"}, {"id": 2, "title": "Write tests", "completed": false, "priority": "medium"} ]' ``` **Response:** ```json { "matchedCount": 2, "modifiedCount": 2 } ``` Nested relation data is supported per item — each record goes through the [deep replace](/relations/deep-operations) process. ### PATCH / {#patch-update} Partial update by primary key. Only the provided fields are changed. **Single update:** ```bash curl -X PATCH http://localhost:3000/todos/ \ -H "Content-Type: application/json" \ -d '{"id": 1, "completed": true}' ``` **Response:** ```json { "matchedCount": 1, "modifiedCount": 1 } ``` **Bulk update:** ```bash curl -X PATCH http://localhost:3000/todos/ \ -H "Content-Type: application/json" \ -d '[{"id": 1, "completed": true}, {"id": 2, "priority": "high"}]' ``` **Response:** ```json { "matchedCount": 2, "modifiedCount": 2 } ``` **Field operations** (`$inc`, `$dec`, `$mul`) work as plain JSON objects in the body: ```bash curl -X PATCH http://localhost:3000/products/ \ -H "Content-Type: application/json" \ -d '{"id": 42, "views": {"$inc": 1}, "stock": {"$dec": 1}}' ``` Also supports [array patch operators](/api/update-patch#embedded-array-patches). See [Update & Patch](/api/update-patch) for the full programmatic API. ## OCC over HTTP {#occ-over-http} Tables that declare [`@db.column.version`](/api/versioning) get optimistic concurrency control over HTTP automatically. Clients **round-trip the `version` field** — read the row, send it back as-is on PATCH or PUT, and the controller auto-lifts `version` from the body into `$cas`. No special headers, no special endpoints. ### Auto-lift policy The controller intercepts every write payload on a table whose `/meta` exposes a `versionColumn`: - **If `version` is present in the body:** it is **stripped** from the SET payload and lifted to `$cas: { version: N }`. The auto-bump still applies — the stored row ends up at `N + 1`, never at the value the client sent. - **If `version` is absent:** the write proceeds with no `$cas` — last-write-wins semantics (client opted out by stripping it). Policy is **presence-based**, not enforced. There is no `428 Precondition Required` gate. ```bash # Read the row — version comes back. curl http://localhost:3000/tasks/one/1 # → { "id": 1, "title": "Bake bread", "status": "open", "version": 4 } # Send it back. version: 4 is auto-lifted to $cas: { version: 4 }. curl -X PATCH http://localhost:3000/tasks/ \ -H "Content-Type: application/json" \ -d '{"id": 1, "status": "done", "version": 4}' # → 200 OK { "matchedCount": 1, "modifiedCount": 1 } # Row is now { …, status: "done", version: 5 }. ``` ### 409 Conflict — version mismatch When the row's stored version no longer matches what the client submitted, the controller returns `409 Conflict`: ```jsonc { "statusCode": 409, "error": "Conflict", "message": "version_mismatch", "kind": "version_mismatch", "currentVersion": 6, } ``` ::: warning Discriminate on `kind`, not `error` The proposal called for `{ error: "version_mismatch", currentVersion: N }`. The actual response carries `error: "Conflict"` — the Wooks framework that powers Moost overrides the `error` field with the standard HTTP reason phrase, and `@atscript/moost-db` cannot reclaim it. The OCC discriminator lives in **`kind`** instead, and `currentVersion` is preserved at the top level. Clients should test `kind === "version_mismatch"` and read `currentVersion` to refresh their view of the row, then retry. ::: Clients using `@atscript/db-client` get a typed `VersionMismatchError` subclass thrown automatically — no need to inspect `body.kind`. See the [client doc](./client#writes) for the pattern. Clients on `409` typically: 1. Re-read the row via `GET /one/:id` (or use `currentVersion` directly if they have the rest of the row cached). 2. Re-apply their changes against the refreshed state. 3. Retry the PATCH / PUT. ### 404 Not Found — missing row A CAS-bearing write on a row that does **not** exist returns `404`, not `409`. The controller disambiguates by issuing a single `findOne(id)` after the adapter reports `matchedCount === 0`: - Row missing → `404 Not Found`. - Row present → `409 Conflict` with the body above. The extra `findOne` is paid only on the conflict path, never on the happy path. ### PUT (replace) — same contract PUT behaves identically to PATCH at this layer: round-trip `version` → `$cas`, mismatch → `409` with `kind: "version_mismatch"` + `currentVersion`, missing row → `404`. ### Bulk PATCH / PUT — per-item CAS Each item in an array body carries its own optional `version`. Behavior: - Items with `version` get per-row CAS. - Items without `version` are last-write-wins. - Mismatches are **silently skipped**, not failed — never "fail all on first conflict". - Response is the aggregate shape: `{ matchedCount, modifiedCount }`. Detect partial failure via `matchedCount < items.length`. ```bash curl -X PATCH http://localhost:3000/tasks/ \ -H "Content-Type: application/json" \ -d '[ {"id": 1, "title": "a", "version": 5}, {"id": 2, "title": "b", "version": 9}, {"id": 3, "title": "c"} ]' # → 200 OK { "matchedCount": 2, "modifiedCount": 2 } # Item 2 was silently skipped (its stored version was not 9). ``` ::: warning Per-item conflict status is deferred v1 returns aggregate `{ matchedCount, modifiedCount }` only. Per-item 207 Multi-Status responses — which would tell the client exactly which items conflicted — are not yet implemented. Clients that need to know which items lost the race must re-read affected rows after a partial result. ::: ### Non-versioned tables — no change Tables without `@db.column.version` are unaffected. `/meta` omits the `versionColumn` field; the auto-lift short-circuits when `versionColumn` is undefined; PATCH / PUT behave exactly as before. A `version` field on a non-versioned table's payload is treated like any other field — passed through to the validator, which accepts it if the field exists on the type and rejects it as unknown otherwise. The OCC path is never engaged. See the [Versioning reference](/api/versioning) for the SDK-level API, error codes, and the `withOptimisticRetry` helper. ## Deleting Records ### DELETE /:id {#delete} Removes a single record by primary key. Returns `404` if the record is not found. ```bash curl -X DELETE http://localhost:3000/todos/42 ``` **Response:** ```json { "deletedCount": 1 } ``` **Composite keys** — use query parameters: ```bash curl -X DELETE "http://localhost:3000/task-tags/?taskId=1&tagId=2" ``` **Response:** ```json { "deletedCount": 1 } ``` ## Error Handling The controller automatically transforms errors into appropriate HTTP responses: | Error | HTTP Status | Response Body | | ---------------------- | ----------- | ---------------------------------------------------------------------------------------------------------------- | | `ValidatorError` | 400 | `{ message, statusCode, errors: [{ path, message }] }` | | `DbError` (CONFLICT) | 409 | `{ message, statusCode, errors }` | | `DbError` (other) | 400 | `{ message, statusCode, errors }` | | Version mismatch (OCC) | 409 | `{ statusCode, error, message: "version_mismatch", kind, currentVersion }` — see [OCC over HTTP](#occ-over-http) | | Not found | 404 | Standard 404 | **Validation error example:** ```json { "message": "Validation failed", "statusCode": 400, "errors": [ { "path": "title", "message": "Required field" }, { "path": "project.title", "message": "Expected string, got number" }, { "path": "tasks.0.status", "message": "Required field" } ] } ``` Validation runs automatically on `POST`, `PUT`, and `PATCH` using the constraints defined in your `.as` schema. ## Query Validation Invalid query parameters return `400` errors with descriptive messages: | Invalid query | Error reason | | -------------------------------- | -------------------------------------- | | `$with=nonexistent` | Navigation property does not exist | | `$with=projectId` | FK field, not a navigation property | | `$with=tasks($with=nonexistent)` | Nested relation does not exist | | `$select=fakefield` | Field does not exist on the type | | `$sort=nonexistent` | Cannot sort by unknown field | | `GET /one/1?status=todo` | Filters not allowed on getOne endpoint | These validations apply to all endpoints that accept query controls — `/query`, `/pages`, and `/one/:id`. ## Next Steps - [URL Query Syntax](./query-syntax) — Full filter, sort, and pagination syntax - [Relations & Search in URLs](./advanced) — `$with`, `$search`, `$vector`, `$groupBy` in query strings - [Customization](./customization) — Override hooks for access control and data transformation - [CRUD Operations](/api/crud) — Programmatic `AtscriptDbTable` API (non-HTTP) - [HTTP Client](./client) — TypeScript client for consuming these endpoints --- URL: "db.atscript.dev/http/customization" LLMS_URL: "db.atscript.dev/http/customization.md" outline: "deep" --- # Customization `AsDbController` and `AsDbReadableController` expose protected hooks that let you extend default behavior without reimplementing endpoints. Override the hooks you need to add access control, data transformation, and business logic. ::: tip Looking for "Block User", "Approve", or "Edit" buttons? This page is about **intercepting existing CRUD**. For exposing **new domain operations** alongside CRUD — and having them surface in `/meta` so any UI can render row buttons, batch toolbars, and navigation entries generically — see [Actions](./actions). ::: ## Available Hooks All hooks are protected methods with sensible defaults (pass-through or no-op). Override only the ones you need. | Hook | Available On | Called When | Purpose | | -------------------------------------- | -------------- | -------------------------------- | ----------------------------------------------------------------- | | `transformFilter(filter)` | Both | Before `/query` / `/pages` reads | Modify filters (add tenant, soft-delete) | | `transformOne(filter)` | Both | Before `/one` / `/one/:id` reads | Filter overlay for id-based reads (defaults to `transformFilter`) | | `transformProjection(projection)` | Both | Before every read | Restrict visible fields | | `validateInsights(insights)` | Both | After query parsing | Field-level access control | | `computeEmbedding(search, fieldName?)` | Both | When `$vector` is present | Convert text to embedding vector | | `onWrite(action, data)` | AsDbController | Before insert/replace/update | Transform or reject write data | | `onRemove(id)` | AsDbController | Before delete | Allow or prevent deletion | | `meta()` | Both | On `GET /meta` request | Enrich the metadata response (cached) | | `applyMetaOverlay(meta)` | Both | Per request, after `meta()` | Per-principal `crud` / `actions` filtering (returns a clone) | | `init()` | Both | On controller construction | One-time setup | ## Read Hooks ### transformFilter {#transformfilter} Receives the parsed filter expression and returns a modified one. Every query passes through this hook, making it ideal for cross-cutting read concerns. **Multi-tenant filtering:** ```typescript @TableController(todosTable) export class TodoController extends AsDbController { protected transformFilter(filter: FilterExpr): FilterExpr { const tenantId = this.getCurrentTenantId(); return { $and: [filter, { tenantId }] }; } } ``` The returned filter replaces the original for all read endpoints (`/query`, `/pages`, `/one/:id`). **Soft deletes:** ```typescript protected transformFilter(filter: FilterExpr): FilterExpr { return { $and: [filter, { deletedAt: { $exists: false } }] } } ``` **Async lookups:** the hook may return a `Promise` — useful when the filter additions depend on session, permissions, or a remote lookup. ```typescript protected async transformFilter(filter: FilterExpr): Promise { const tenantId = await this.resolveTenantFromSession() return { $and: [filter, { tenantId }] } } ``` ### transformOne {#transformone} Filter overlay applied to `GET /one/:id` and `GET /one?...`. Defaults to calling `transformFilter` so existence is not leaked through id-based reads. Override only when `/one` needs different scoping than `/query` (rare): ```typescript protected transformOne(filter: FilterExpr): FilterExpr { // Allow self-lookup by id without the tenant scope `/query` enforces if (this.isCurrentUser()) return filter return this.transformFilter(filter) } ``` If you only need to scope BOTH `/query` and `/one` the same way (the common case), override `transformFilter` alone — `transformOne` will pick it up automatically. ### transformProjection {#transformprojection} Intercepts the projection before every read. If the client sends `$select`, `projection` contains it; otherwise it is `undefined`. Use this to enforce field exclusions: ```typescript protected transformProjection(projection?: UniqueryControls['$select']) { return projection ?? { password: 0, apiKey: 0, secret: 0 } } ``` When `projection` is `undefined` (no `$select` from the client), the hook supplies a default exclusion list. When the client does send `$select`, you can merge or override as needed. This hook may also return a `Promise` for async decisions (e.g. per-user field visibility). ### validateInsights {#validateinsights} Runs after the URL query string is parsed. The `insights` map contains every field referenced in the query — whether in a filter, projection, or sort order. Return a string to reject with HTTP `400`, or `undefined` to allow. ```typescript const RESTRICTED_FIELDS = new Set(['salary', 'ssn', 'internalNotes']) protected validateInsights(insights: Map): string | undefined { // Run the default validation first (rejects unknown fields) const base = super.validateInsights(insights) if (base) return base const user = this.getCurrentUser() if (!user?.isAdmin) { for (const field of insights.keys()) { if (RESTRICTED_FIELDS.has(field)) { return `Access denied: cannot query field "${field}"` } } } return undefined } ``` This catches every reference to a restricted field — whether in a filter (`salary>=100000`), a projection (`$select=ssn`), or a sort order (`$sort=salary`). ### computeEmbedding {#computeembedding} Called when `$vector` is present in query controls. Receives the search text and an optional field name. Must return a `number[]` (the embedding vector). The default implementation throws HTTP `501 Not Implemented`. ```typescript @TableController(articlesTable) export class ArticlesController extends AsDbController { protected async computeEmbedding(search: string, fieldName?: string): Promise { // Use any embedding provider (OpenAI, Cohere, local model, etc.) const response = await openai.embeddings.create({ model: "text-embedding-3-small", input: search, }); return response.data[0].embedding; } } ``` The `fieldName` parameter identifies which vector field was specified in `$vector`, allowing different embedding models per field if needed. See [Vector Search in URLs](./advanced#vector-search) for how this hook integrates with the URL parameters. ## Write Hooks ### onWrite {#onwrite} Intercepts all write operations before they reach the database. Return the (possibly modified) data to proceed, or `undefined` to abort (returns HTTP `500`). The `action` parameter identifies the operation: | Action | Triggered By | | ------------- | -------------------------- | | `insert` | `POST /` with object body | | `insertMany` | `POST /` with array body | | `replace` | `PUT /` with object body | | `replaceMany` | `PUT /` with array body | | `update` | `PATCH /` with object body | | `updateMany` | `PATCH /` with array body | **Audit fields:** ```typescript protected onWrite( action: 'insert' | 'insertMany' | 'replace' | 'replaceMany' | 'update' | 'updateMany', data: unknown ) { const record = data as Record if (action === 'insert') { return { ...record, createdBy: this.getCurrentUserId() } } if (action === 'update') { return { ...record, updatedBy: this.getCurrentUserId() } } return data } ``` **Authorization:** ```typescript protected onWrite(action: string, data: unknown) { if (!this.getCurrentUser()?.canWrite) { return undefined // abort — returns HTTP 500 } return data } ``` `onWrite` may also be `async` — every call site already awaits the result: ```typescript protected async onWrite(action: string, data: unknown) { const approved = await this.approveWithAuditService(action, data) return approved ? data : undefined } ``` ### onRemove {#onremove} Intercepts DELETE requests. Receives the record ID (a string for single-key tables, or an object for composite keys). Return the ID to proceed with deletion, or `undefined` to abort (returns HTTP `500`). **Delete guard:** ```typescript protected async onRemove(id: unknown) { const record = await this.table.findById(id as string) if (record?.protected) { return undefined // abort } return id } ``` **Soft delete (replace DELETE with UPDATE):** ::: warning Returning `undefined` from `onRemove` produces HTTP 500 The abort path always emits HTTP `500 "Not deleted"` to the client — there is no "soft success" return slot. If you simply soft-delete in `onRemove` and `return undefined`, the row is marked deleted but the client sees a 500 and may retry. Prefer one of the patterns below. ::: **Recommended pattern A — intercept DELETE with a custom route**, hide the generated one behind a guard, and expose your own soft-delete handler: ```typescript import { Delete, Param } from "@moostjs/event-http"; @TableController(todosTable) export class TodoController extends AsDbController { // Returning undefined here aborts the built-in DELETE with HTTP 500; // route DELETEs through a sibling endpoint instead. protected onRemove() { return undefined; } @Delete(":id/soft") async softDelete(@Param("id") id: string) { await this.table.updateOne({ id, deletedAt: Date.now() } as any); return { deletedCount: 1 }; } } ``` **Recommended pattern B — model soft-delete as a `'row'`-level [action](./actions)**, which gives you a typed `client.action('archive', { id })`, server-side `disabled` gating, and `/meta`-driven UI buttons: ```typescript @Post("actions/archive") @DbAction("archive", { label: "Archive", intent: "negative", requiredFields: ["deletedAt"], disabled: (rows) => rows.map((r) => r.deletedAt != null), }) async archive(@DbActionID() id: { id: string }) { await this.table.updateOne({ id: id.id, deletedAt: Date.now() } as any) return { message: "Archived" } } ``` In either case, combine with `transformFilter` to exclude soft-deleted records from reads: ```typescript protected transformFilter(filter: FilterExpr): FilterExpr { return { $and: [filter, { deletedAt: { $exists: false } }] } } ``` ::: tip Why not just `return undefined` from `onRemove`? The original "soft delete inside `onRemove`, return `undefined`" recipe still succeeds at the DB level — the row IS soft-deleted — but the HTTP response is `500 "Not deleted"`. Clients (including `@atscript/db-client`) treat that as a server error and may surface a generic failure toast. Use one of the patterns above to keep the wire response consistent with the actual outcome. ::: ## Metadata Hook ### meta {#meta} Returns the payload emitted by `GET /meta`. Override to enrich the response with derived or remote data — the method may be sync or `async`. ```typescript protected async meta() { const base = await super.meta() return { ...base, featureFlags: await this.loadFlags() } } ``` The base implementation caches its own result. Subclasses overriding with async enrichment should cache their own computation if they need per-request dedup. Use `meta()` for **static** enrichment that is the same for every caller. For **per-principal** filtering of `crud` / `actions`, override `applyMetaOverlay()` instead — it runs after caching, on every request. ### applyMetaOverlay {#applymetaoverlay} Runs on every `GET /meta` request, **after** `meta()` has resolved (and the result has been cached). Use it to prune `crud` keys, narrow `crud[op]` control whitelists, or filter the `actions[]` array based on the current request principal (user role, tenant, etc.). ```typescript protected applyMetaOverlay(meta: TMetaResponse): TMetaResponse { const user = this.getCurrentUser() if (user?.isAdmin) return meta // Shallow clone — DO NOT mutate the cached envelope. const out: TMetaResponse = { ...meta, crud: { ...meta.crud } } // Hide write operations from non-admin users. delete out.crud.insert delete out.crud.update delete out.crud.replace delete out.crud.remove // Drop the "Delete All" table-level action from this principal. out.actions = meta.actions.filter((a) => a.name !== "deleteAll") return out } ``` ::: warning Always return a shallow clone — never mutate `meta` The argument is the cached envelope shared across all requests. Mutating it leaks the per-request overlay to every subsequent caller. Spread (`{ ...meta }`) and re-spread nested objects you modify (`crud: { ...meta.crud }`, `actions: meta.actions.filter(...)`). ::: ::: warning Discoverability, not security `applyMetaOverlay` controls what the UI **renders**. It does NOT stop a client from hitting the underlying route — for real per-principal route enforcement, use Moost auth guards (`@Authenticate`) and the [server-side action gate](./actions#server-side-gate). See [Permissions](./permissions) for the broader contract. ::: May return a `Promise`. The hook is invoked even when other meta-derived endpoints (`/meta/form/:name`, `$actions=true` augmentation) consult the meta envelope. ## Initialization ### init {#init} Runs once during controller construction. Use it for schema setup, seeding, or registering watchers. Can be async — errors are caught and logged automatically. ```typescript protected async init() { await this.table.ensureTable() await this.table.syncIndexes() } ``` ## Accessing the Underlying Table Inside any hook or custom method, you have access to the underlying table instance: ```typescript this.table; // AtscriptDbTable — full read/write access (AsDbController only) this.readable; // AtscriptDbReadable — read-only access (both controllers) ``` Both controllers expose `this.readable`. The writable `this.table` property is only available on `AsDbController`. ```typescript // In any hook or custom method const count = await this.readable.count({ filter: {}, controls: {} }); this.logger.info(`Primary keys: ${this.table.primaryKeys}`); this.logger.info(`Indexes: ${this.table.indexes.size}`); ``` ## Extending Controllers ### Adding Custom Routes Add methods with Moost decorators to create custom endpoints alongside the generated ones: ```typescript import { Get } from "@moostjs/event-http"; @TableController(todosTable) export class TodoController extends AsDbController { @Get("stats") async getStats() { const total = await this.readable.count({ filter: {}, controls: {} }); const completed = await this.readable.count({ filter: { completed: true }, controls: {}, }); return { total, completed, pending: total - completed }; } } ``` This adds `GET /todos/stats` alongside the generated CRUD endpoints. ### Multiple Controllers Mount multiple tables and views on different prefixes: ```typescript app.registerControllers( ["todos", TodoController], ["projects", ProjectController], ["active-tasks", ActiveTasksViewController], // read-only view ); ``` ## Combined Example A complete controller combining multi-tenancy, audit fields, soft deletes, and field restrictions: ```typescript const RESTRICTED = new Set(["internalNotes", "costPrice"]); @TableController(productsTable, "api/products") export class ProductController extends AsDbController { protected async init() { await this.table.ensureTable(); } protected transformFilter(filter: FilterExpr): FilterExpr { return { $and: [ filter, { tenantId: this.getTenantId(), deletedAt: { $exists: false }, }, ], }; } protected transformProjection(projection?: UniqueryControls["$select"]) { return projection ?? { costPrice: 0 }; } protected onWrite(action: string, data: unknown) { const record = data as Record; if (action === "insert") { return { ...record, tenantId: this.getTenantId(), createdBy: this.getUserId(), }; } return { ...record, tenantId: this.getTenantId(), updatedBy: this.getUserId(), }; } // Block the built-in DELETE — soft delete is exposed via a custom route // below. Returning undefined here produces HTTP 500 if a client still hits // DELETE /:id, which is the desired outcome. protected onRemove() { return undefined; } @Delete(":id/soft") async softDelete(@Param("id") id: string) { await this.table.updateOne({ id, deletedAt: Date.now() } as any); return { deletedCount: 1 }; } protected validateInsights(insights: Map): string | undefined { const base = super.validateInsights(insights); if (base) return base; for (const field of insights.keys()) { if (RESTRICTED.has(field) && !this.isAdmin()) { return `Access denied: cannot query field "${field}"`; } } return undefined; } } ``` ## Multi-Tenant Route Recipe {#multi-tenant} A common pattern: mount the same controller under a `/tenant/:tenantId/...` prefix and scope every read/write to the URL-supplied tenant. The route prefix is supplied at registration time; the tenant scope is enforced in `transformFilter` / `transformOne` / `onWrite`. **Read the tenant from the route param via Moost composables** — the table API knows nothing about routing, so the hooks bridge them: ```typescript import { useRouteParams } from "@wooksjs/event-core"; import { HttpError } from "@moostjs/event-http"; import { AsDbController, TableController, type FilterExpr } from "@atscript/moost-db"; import { Todo } from "./schema/todo.as"; import { todosTable } from "./db"; @TableController(todosTable) export class TenantTodoController extends AsDbController { private getTenantId(): string { const { tenantId } = useRouteParams<{ tenantId: string }>().get(); if (!tenantId) throw new HttpError(400, "tenantId route param missing"); return tenantId; } protected transformFilter(filter: FilterExpr): FilterExpr { return { $and: [filter, { tenantId: this.getTenantId() }] }; } // No override needed for `/one` — `transformOne` falls through to `transformFilter`. protected onWrite(_action: string, data: unknown) { const record = data as Record; return { ...record, tenantId: this.getTenantId() }; } } ``` Register under a nested route prefix at app boot: ```typescript app.registerControllers(["tenant/:tenantId/todos", TenantTodoController]); await app.init(); ``` The generated endpoints become: - `GET /tenant/acme/todos/query` - `GET /tenant/acme/todos/one/42` - `POST /tenant/acme/todos/` - `PATCH /tenant/acme/todos/` - ... Every operation is automatically scoped to `tenantId = "acme"`. Cross-tenant access through the API is not possible because the filter overlay is applied unconditionally — even direct PK lookups like `GET /tenant/acme/todos/one/42` are rejected with 404 if record `42` belongs to a different tenant. ::: tip Combine with `transformOne` for asymmetric scoping If `/one` should bypass the tenant scope (e.g. when an admin can address any row by PK), override `transformOne` separately and leave `transformFilter` strict. ::: ## Next Steps - [HTTP Setup](./) — Controller installation and wiring - [CRUD Endpoints](./crud) — Endpoint reference - [CRUD Operations](/api/crud) — Programmatic `AtscriptDbTable` API - [Transactions](/api/transactions) — Transaction integration --- URL: "db.atscript.dev/http/permissions" LLMS_URL: "db.atscript.dev/http/permissions.md" outline: "deep" --- # Permissions Every `/meta` response carries a `crud` field that advertises which built-in CRUD operations the controller exposes. UI clients read it to decide which edit / delete / insert affordances to render. ```typescript type TCrudOp = "query" | "pages" | "one" | "insert" | "update" | "replace" | "remove"; type TCrudPermissions = Partial>; ``` - **Key absent** → operation is denied / not exposed. - **Key present** → operation is allowed; the `string[]` value is the accepted UniQuery control whitelist for read ops (`[]` for write ops, which take no controls — presence still signals "allowed"). A typical writable controller emits all seven keys: ```json { "crud": { "query": [ "filter", "insights", "skip", "limit", "count", "sort", "select", "search", "index", "vector", "threshold", "with", "actions", "groupBy" ], "pages": [ "filter", "page", "size", "sort", "select", "search", "index", "vector", "threshold", "with", "actions" ], "one": ["select", "with", "actions"], "insert": [], "update": [], "replace": [], "remove": [] } } ``` ## Default emission per controller class | Class | Emitted keys | | --------------------------- | -------------------------------------------------- | | `AsDbReadableController` | `query`, `pages`, `one` | | `AsDbController` | inherits + `insert`, `update`, `replace`, `remove` | | `AsValueHelpController` | `query`, `pages`, `one` | | `AsJsonValueHelpController` | `query`, `pages`, `one` | The read-op control whitelists are static per handler. Importable as constants: ```typescript import { QUERY_CONTROLS, PAGES_CONTROLS, ONE_CONTROLS } from "@atscript/moost-db"; ``` | Op | Controls | | ------- | --------------------------------------------------------------------------------------------------------------------- | | `query` | `filter, insights, skip, limit, count, sort, select, search, index, fuzzy, vector, threshold, with, actions, groupBy` | | `pages` | `filter, page, size, sort, select, search, index, fuzzy, vector, threshold, with, actions` | | `one` | `select, with, actions` | `actions` is the URL-control name for [`$actions=true`](./actions#actions-augmentation) — when the caller asks the server to compute per-row action availability. ## Read-only check `readOnly` was removed in favor of `crud`. Derive the boolean inline: ```typescript const isReadOnly = !("insert" in meta.crud) && !("update" in meta.crud) && !("replace" in meta.crud) && !("remove" in meta.crud); ``` ::: warning Discoverability only — not a security boundary The `crud` field tells the UI what to render. It does **NOT** stop a client from hitting the underlying route. Real per-principal enforcement is the job of the upcoming **ARBAC** package, which wires the same permission set into the dispatchers and keeps `/meta` in sync automatically. ::: ## Relationship to `actions[]` `crud` and `actions[]` are sibling fields on `/meta` with distinct dispatch paths: - `crud[op]` → typed client methods (`client.query()`, `client.insert()`, …). - `actions[]` → `Client.action(name, pk?)`. POST-locked, single-PK-per-call. See [Actions](./actions) for the actions wire shape. --- URL: "db.atscript.dev/http/query-syntax" LLMS_URL: "db.atscript.dev/http/query-syntax.md" outline: "deep" --- # URL Query Syntax The HTTP controllers accept a rich URL query syntax for filtering, sorting, pagination, and projection — powered by [`@uniqu/url`](https://github.com/moostjs/uniqu). Filters and controls are encoded directly into the query string using a compact, expressive format that all [database adapters](/adapters/) understand. ## How It Works URL query strings are parsed into three components: 1. **filter** — field conditions (equality, comparison, logical operators) 2. **controls** — `$`-prefixed parameters (`$sort`, `$limit`, `$select`, etc.) 3. **insights** — field usage metadata (for validation and access control) The parsed query is then executed identically by any adapter. ## Filter Operators ### Equality Match a field against an exact value: ```bash curl "http://localhost:3000/todos/query?status=active" curl "http://localhost:3000/todos/query?status=active&priority=high" ``` Multiple conditions are combined with AND by default. ### Not Equal ```bash curl "http://localhost:3000/todos/query?status!=done" ``` ### Comparison Operators ```bash curl "http://localhost:3000/todos/query?priority>3" # greater than curl "http://localhost:3000/todos/query?priority>=3" # greater than or equal curl "http://localhost:3000/todos/query?priority<5" # less than curl "http://localhost:3000/todos/query?priority<=5" # less than or equal ``` These work with numeric fields, dates, and any comparable type supported by the adapter. ### Set Operators (IN / NOT IN) Match against a set of values using curly braces: ```bash curl "http://localhost:3000/todos/query?role{Admin,Editor}" # IN curl "http://localhost:3000/todos/query?status!{Draft,Deleted}" # NOT IN ``` The IN operator matches records where the field equals any value in the comma-separated list. ### Range (Between) Filter a field within a range: ```bash curl "http://localhost:3000/todos/query?25=3&\$select=id,title,status&\$with=assignee,tags&\$sort=-priority,title&\$limit=50" ``` **Nested relation loading** — projects with comments and their authors: ```bash curl "http://localhost:3000/todos/query?\$with=project(\$select=id,title),comments(\$sort=-createdAt&\$limit=5&\$with=author(\$select=name))" ``` **Count with filter:** ```bash curl "http://localhost:3000/todos/query?completed=true&\$count" ``` **Excluding sensitive fields:** ```bash curl "http://localhost:3000/users/query?\$select=-password,-secret,-internalNotes" ``` ## Next Steps - [Relations & Search in URLs](./advanced) — `$with`, `$search`, `$vector`, `$groupBy` details - [Queries & Filters](/api/queries) — Programmatic query API (non-HTTP) - [CRUD Endpoints](./crud) — Endpoint reference --- URL: "db.atscript.dev/relations" LLMS_URL: "db.atscript.dev/relations.md" outline: "deep" --- # Foreign Keys Relational data rarely lives in a single table. Atscript lets you define foreign keys directly in your `.as` schema so that relationships between tables are explicit, type-safe, and portable across database adapters. This page covers how to declare foreign keys. Once FKs are in place, you can add [navigation properties](./navigation) to traverse relationships at query time, define [referential actions](./referential-actions) that control cascade behavior, and [load related data](./loading) in your queries. ## Declaring a Foreign Key A foreign key links a field in one table to the primary key (or unique field) of another table. Use `@db.rel.FK` and a **chain reference** to declare it: ```atscript @db.table 'tasks' export interface Task { @meta.id id: number title: string @db.rel.FK ownerId: User.id } ``` The chain reference `User.id` tells Atscript that `ownerId` points to the `id` field on the `User` table. The referenced field must be marked with `@meta.id` or `@db.index.unique`. ::: info What a chain reference is A chain reference is a dotted path like `User.id` used as a field type. It resolves to the scalar type of the target field (here, `number`) while also carrying the relationship information that `@db.rel.FK` needs. ::: ## What Foreign Keys Give You Declaring `@db.rel.FK` on a field provides: - **DB-level constraint enforcement** — the database rejects inserts or updates that reference a non-existent parent record (adapters without native FK support emulate this at the application level) - **Cascade and restrict behavior** — control what happens when a parent is deleted or updated via [referential actions](./referential-actions) - **Relation loading** — define [navigation properties](./navigation) that use the FK to traverse between tables Without `@db.rel.FK`, a field with a chain reference type is just a regular scalar field — it has no relational semantics. ## Optional Foreign Keys Not every relationship is mandatory. Use `?` to make a foreign key nullable — the field can hold a valid reference or `null`: ```atscript @db.rel.FK assigneeId?: User.id ``` This is common for fields like "assignee" or "reviewer" where a record may not have a related parent yet. See [Loading Relations — Nullable FK Lifecycle](./loading#nullable-fk-lifecycle) for how null FKs behave at query time. ## FK Aliases When a table has multiple foreign keys pointing to the same target type, you must provide aliases to distinguish them: ```atscript @db.table 'articles' export interface Article { @meta.id id: number title: string @db.rel.FK 'author' authorId: User.id @db.rel.FK 'reviewer' reviewerId?: User.id } ``` The alias string (`'author'`, `'reviewer'`) becomes important when you define [navigation properties](./navigation) — it tells Atscript which FK to follow. ::: warning Disambiguation required If two or more unaliased `@db.rel.FK` fields point to the same target type, Atscript reports an error. Always add aliases when multiple FKs reference the same table. ::: ## Composite Foreign Keys When a target table has a composite primary key (multiple `@meta.id` fields), declare one FK per key field. They automatically combine into a single composite foreign key: ```atscript @db.table 'order_items' export interface OrderItem { @meta.id id: number @db.rel.FK orderId: Order.id @db.rel.FK productId: Order.productId quantity: number } ``` Both `orderId` and `productId` reference fields on `Order`, so they form a single composite FK that matches the composite primary key of the `Order` table. ## Complete Example Here is a three-table schema that demonstrates different FK patterns: ```atscript @db.table 'users' export interface User { @meta.id @db.default.increment id: number name: string email: string } @db.table 'projects' export interface Project { @meta.id @db.default.increment id: number name: string @db.rel.FK ownerId: User.id } @db.table 'tasks' export interface Task { @meta.id @db.default.increment id: number title: string done: boolean // Required FK — every task belongs to a project @db.rel.FK @db.rel.onDelete 'cascade' projectId: Project.id // Optional FK — task may or may not have an assignee @db.rel.FK 'assignee' @db.rel.onDelete 'setNull' assigneeId?: User.id } ``` ``` ┌──────────┐ ┌──────────────┐ ┌──────────┐ │ users │ │ tasks │ │ projects │ ├──────────┤ ├──────────────┤ ├──────────┤ │ id (PK) │◄──────│ assigneeId? │ │ id (PK) │ │ name │ │ projectId ───┼──────►│ name │ │ email │ │ id (PK) │ │ ownerId ─┼──►users.id │ │ │ title │ │ │ │ │ │ done │ │ │ └──────────┘ └──────────────┘ └──────────┘ ``` The `projectId` FK is required (every task must belong to a project) with cascade delete. The `assigneeId` FK is optional and aliased, with set-null on delete. The `ownerId` FK on `Project` is a simple required FK with no explicit referential action. ::: warning Nested writes require `@db.depth.limit` If you plan to insert/replace/patch a parent together with its 1:N (`@db.rel.from`) or M:N (`@db.rel.via`) children in a single payload, you must add `@db.depth.limit N` on the parent table. Without it, nested writes are rejected with HTTP 400 (`DEPTH_EXCEEDED`). See [Deep Operations § `@db.depth.limit`](./deep-operations#depth-limit). ::: ## Next Steps - [Navigation Properties](./navigation) — define `@db.rel.to`, `@db.rel.from`, and `@db.rel.via` to traverse relationships - [Referential Actions](./referential-actions) — control cascade, restrict, and set-null behavior - [Loading Relations](./loading) — query related data with `$with` controls - [Deep Operations](./deep-operations) — nested writes and the `@db.depth.limit` gate --- URL: "db.atscript.dev/relations/deep-operations" LLMS_URL: "db.atscript.dev/relations/deep-operations.md" outline: "deep" --- # Deep Operations When inserting, replacing, or updating records, you can include related data inline — the DB layer handles the multi-phase orchestration automatically. Instead of manually creating parent records, wiring up foreign keys, and inserting children one by one, you pass a single nested payload and Atscript takes care of the rest. ## How Deep Operations Work The table API detects navigation properties (fields defined with `@db.rel.to`, `@db.rel.from`, or `@db.rel.via`) in your payload and creates, updates, or deletes related records in the correct order. The entire operation is wrapped in a transaction, so if any phase fails, everything rolls back. Navigation properties are the relation fields you define in your `.as` schema — the same ones used for [querying relations](./navigation). When they appear in a write payload, they trigger deep processing instead of being stored directly. ### Schema Context The examples on this page use the following schema. It includes TO, FROM, and VIA relations on a `Task` interface: ```atscript @db.table 'projects' export interface Project { @meta.id @db.default.increment id: number title: string @db.rel.FK ownerId: User.id } @db.table 'tasks' export interface Task { @meta.id @db.default.increment id: number title: string status: string @db.rel.FK @db.rel.onDelete 'cascade' projectId: Project.id @db.rel.to project: Project @db.rel.from comments: Comment[] @db.rel.via TaskTag tags: Tag[] } @db.table 'comments' export interface Comment { @meta.id @db.default.increment id: number body: string @db.rel.FK authorId: User.id @db.rel.FK @db.rel.onDelete 'cascade' taskId: Task.id } @db.table 'tags' export interface Tag { @meta.id @db.default.increment id: number name: string @db.rel.via TaskTag tasks: Task[] } @db.table 'task_tags' export interface TaskTag { @meta.id @db.default.increment id: number @db.rel.FK @db.rel.onDelete 'cascade' taskId: Task.id @db.rel.FK @db.rel.onDelete 'cascade' tagId: Tag.id } ``` ## Nested Inserts The DB layer orchestrates multi-phase writes so foreign keys resolve in the correct order — TO parents first, the main record next, FROM children and VIA junction entries last. Failures roll back all phases. Inserting a Task with an inline Project (TO), Comments (FROM), and Tags (VIA): ```typescript await taskTable.insertOne({ title: "Design homepage", project: { title: "Website Redesign", ownerId: 1 }, // TO: created first comments: [ // FROM: created after { body: "Looks good!", authorId: 2 }, ], tags: [{ name: "design" }, { name: "frontend" }], // VIA: targets + junctions }); ``` You never need to manually set `projectId` on the task or `taskId` on the comments — Atscript resolves the FK chain references from your schema and wires them up automatically. ## Nested Replaces (PUT) A deep replace fully swaps the record and its relations. FROM children are **diff-synced by primary key** rather than wiped: children whose `@meta.id` appears in the new payload are updated in place (preserving any downstream relations), absent ones are cascade-deleted per their referential rules, and unrecognized entries are inserted. VIA junction entries are recomputed against the new target set. ```typescript await taskTable.replaceOne({ id: 1, title: "Redesign homepage", status: "in-progress", projectId: 1, comments: [{ body: "Updated scope", authorId: 1 }], tags: [ { id: 5 }, // existing tag — junction entry created { name: "urgent" }, // new tag — record + junction entry created ], }); ``` Sending an empty array removes all related records for that relation: ```typescript await taskTable.replaceOne({ id: 1, title: "Solo task", status: "done", projectId: 1, comments: [], // deletes all comments for this task tags: [], // removes all junction entries }); ``` ## Nested Updates (PATCH) Partial updates support relations, but with important constraints: - **TO relations**: Send changed fields plus the PK — the parent record is partially updated - **FROM and VIA relations**: You **must** use patch operators (`$insert`, `$remove`, `$update`, `$upsert`, `$replace`) — plain arrays are rejected with a `400` error - **Nested FROM inside TO**: Not supported — you cannot patch a TO parent's FROM children in a single call. This returns a `400` error. The reason for requiring operators is straightforward: a partial update cannot infer intent from a plain array. Should `comments: [{ body: 'Hi' }]` add a comment, replace all comments, or something else? Patch operators make your intent explicit. ```typescript await taskTable.updateOne({ id: 1, project: { id: 2, title: "Updated Title" }, // TO: partial update comments: { // FROM: requires operators $insert: [{ body: "New comment", authorId: 1 }], }, tags: { // VIA: requires operators $insert: [{ name: "urgent" }], $remove: [{ id: 3 }], }, }); ``` You can combine multiple operators in a single relation field — for example, inserting new items and removing others in one call. See [Relational Patches](./patches) for the full list of array operators and their behavior. ## Depth Control Nested writes are gated by **two independent limits**: the `@db.depth.limit` annotation on the parent table (the schema-level gate) and the `maxDepth` runtime option (the per-call cap). ### `@db.depth.limit` — Required to Allow Nested Writes {#depth-limit} By default, **no nested writes through 1:N (`@db.rel.from`) or M:N (`@db.rel.via`) navigation are allowed**. Any payload containing such nested data on a parent without `@db.depth.limit` is rejected with `DEPTH_EXCEEDED` (HTTP 400): ```atscript // No @db.depth.limit — nested writes through `comments` are rejected. @db.table 'tasks' export interface Task { @meta.id id: number title: string @db.rel.from comments: Comment[] } ``` Add `@db.depth.limit N` on the parent table to allow nested writes up to N levels of `@db.rel.from` / `@db.rel.via` chaining: ```atscript @db.table 'tasks' @db.depth.limit 2 // ← enables nested writes up to 2 levels deep export interface Task { @meta.id id: number title: string @db.rel.from comments: Comment[] } ``` ::: info Why FROM/VIA and not TO? Forward references (`@db.rel.to`) point to a single parent record and don't fan out — they aren't subject to the `@db.depth.limit` gate. The gate exists to prevent unbounded child-tree writes from a single payload (e.g. a deeply nested `posts → comments → replies` chain). ::: ### `maxDepth` — Runtime Per-Call Cap Independently of `@db.depth.limit`, you can cap the runtime cost of a single nested-write call: ```typescript await taskTable.insertOne(data, { maxDepth: 5 }); ``` The default is **3**. `maxDepth` only lowers the ceiling for one call — it does **not** override the schema-level `@db.depth.limit` gate. If the payload exceeds `maxDepth`: ``` Error: Nested data in 'comments' exceeds maxDepth (1). Increase maxDepth or strip nested data before writing. ``` This applies to all write operations — `insertOne`, `insertMany`, `replaceOne`, and `updateOne` — via both the programmatic API and the HTTP controller. ## Automatic Transactions All deep operations are wrapped in `adapter.withTransaction()`. If any phase fails — whether creating a parent record, inserting the main record, or adding children — the entire operation rolls back. No partial writes are left behind. If you are already inside a transaction (e.g., from an explicit `withTransaction()` call), deep operations participate in the existing transaction rather than creating a nested one. ```typescript await adapter.withTransaction(async () => { // Both deep inserts share the same transaction await taskTable.insertOne({ title: "Task A", comments: [{ body: "Note" }] }); await taskTable.insertOne({ title: "Task B", tags: [{ name: "urgent" }] }); }); ``` See [Transactions](/api/transactions) for explicit transaction management and nesting behavior. ## Batch Deep Operations `insertMany`, `bulkReplace`, and `bulkUpdate` all support nested data per item in the array. Each item goes through the same multi-phase process: ```typescript await taskTable.insertMany([ { title: "Task A", project: { title: "Project X", ownerId: 1 }, comments: [{ body: "Comment on A", authorId: 1 }], }, { title: "Task B", project: { title: "Project Y", ownerId: 2 }, tags: [{ name: "backend" }], }, ]); ``` All records and their related data are processed within a single transaction. ## Next Steps - [Relational Patches](./patches) — fine-grained updates with `$insert`, `$remove`, `$update`, `$upsert`, `$replace` - [Foreign Keys](./index) — declaring FK constraints and chain references - [Loading Relations](./loading) — querying related data with `$with` controls --- URL: "db.atscript.dev/relations/loading" LLMS_URL: "db.atscript.dev/relations/loading.md" outline: "deep" --- # Loading Relations Navigation properties are **not** populated by default. They are only loaded when you explicitly request them via `$with` in your query controls. This design is intentional — it avoids the N+1 problem common in ORMs with lazy loading and gives you full control over which relations are fetched and how. Throughout this page, we use the following schema as a running example: ```atscript @db.table 'users' export interface User { @meta.id @db.default.increment id: number name: string } @db.table 'projects' export interface Project { @meta.id @db.default.increment id: number name: string @db.rel.FK ownerId: User.id @db.rel.to owner: User @db.rel.from tasks: Task[] } @db.table 'tasks' export interface Task { @meta.id @db.default.increment id: number title: string done: boolean @db.rel.FK projectId: Project.id @db.rel.FK 'assignee' assigneeId?: User.id @db.rel.to project: Project @db.rel.to 'assignee' assignee?: User @db.rel.via TaskTag tags: Tag[] } ``` (Plus the supporting `Tag` and `TaskTag` junction types from the [Navigation Properties](./navigation) page.) ## Explicit Loading with `$with` To load a navigation property, include it in the `$with` array inside `controls`: ```typescript const tasks = await taskTable.findMany({ controls: { $with: [{ name: "project" }], }, }); // tasks[0].project → { id: 1, name: 'Website Redesign', ownerId: 3 } ``` Without `$with`, navigation properties are `undefined` on returned objects — they simply don't exist: ```typescript const tasks = await taskTable.findMany(); // tasks[0].project === undefined ``` This applies to all query methods that accept controls: `findMany`, `findOne`, and `findById`: ```typescript const task = await taskTable.findOne({ filter: { id: 42 }, controls: { $with: [{ name: "project" }] }, }); const task = await taskTable.findById(42, { controls: { $with: [{ name: "project" }] }, }); ``` ## Loading Multiple Relations Pass multiple entries in the `$with` array to load several relations at once: ```typescript const tasks = await taskTable.findMany({ controls: { $with: [{ name: "project" }, { name: "assignee" }, { name: "tags" }], }, }); // tasks[0].project → Project object // tasks[0].assignee → User object or null (FK is optional) // tasks[0].tags → Tag[] (may be empty) ``` All relations in a single `$with` array are loaded **in parallel** — the queries for `project`, `assignee`, and `tags` run concurrently, not sequentially. The strategy is adapter-specific: SQL adapters issue a separate batched follow-up query per relation (no JOINs emitted by the loader); MongoDB executes a single `$lookup` aggregation pipeline per relation. See [How It Works Internally](#how-it-works-internally) below. ## Nested (Deep) Loading Load relations of relations by nesting `$with` controls inside a relation entry: ```typescript const projects = await projectTable.findMany({ controls: { $with: [ { name: "owner" }, { name: "tasks", controls: { $with: [{ name: "assignee" }, { name: "tags" }], }, }, ], }, }); // projects[0].owner → User // projects[0].tasks[0].assignee → User | null // projects[0].tasks[0].tags → Tag[] ``` There is no hard limit on nesting depth for reads. Each level of nesting adds one or more batched queries, so keep depth reasonable for performance. (Nested **writes** are a separate concern — see [Deep Operations § Depth Control](./deep-operations#depth-control).) ## Per-Relation Controls Each `$with` entry accepts its own `controls` object. You can sort, filter, paginate, and project on loaded relations independently of the parent query: ```typescript const projects = await projectTable.findMany({ controls: { $with: [ { name: "tasks", controls: { $sort: { done: 1, title: 1 }, $limit: 10, $filter: { done: false }, $with: [{ name: "tags" }], }, }, ], }, }); ``` This loads each project's tasks sorted by `done` then `title`, limited to 10 incomplete tasks, each with their tags attached. ::: tip Per-relation controls are especially useful for FROM and VIA relations where you want to limit the number of loaded children. For example, loading only the 5 most recent comments on a post, or only active tags on a task. ::: ## Field Selection on Relations Use `$select` within relation controls to load only specific fields from related records: ```typescript const tasks = await taskTable.findMany({ controls: { $with: [ { name: "project", controls: { $select: ["name"] }, }, ], }, }); // tasks[0].project → { id: 1, name: 'Website Redesign' } ``` ::: info Primary key and foreign key fields used for joining are always included in the query, even if not listed in `$select`. They are needed internally to match related records to their parents. In the example above, `id` appears in the result even though only `name` was selected. ::: ## Filtering Loaded Relations Use `filter` on a `$with` entry to restrict which related records are returned: ```typescript const projects = await projectTable.findMany({ controls: { $with: [ { name: "tasks", filter: { done: false }, }, ], }, }); // projects[0].tasks → only incomplete tasks ``` This is a query-time filter applied when loading the relation. It is different from `@db.rel.filter`, which is a permanent filter baked into the schema definition. Both can be active at the same time — the schema filter and the query-time filter are combined with `$and`. ## Behavior by Relation Type Different relation types return different shapes when loaded: | Relation type | No matches | Null FK | Return type | | -------------------------- | ------------------- | ------- | ---------------- | | `@db.rel.to` (required FK) | N/A — FK must exist | N/A | Object | | `@db.rel.to` (optional FK) | N/A | `null` | Object or `null` | | `@db.rel.from` (array) | `[]` | N/A | Array | | `@db.rel.from` (singular) | `null` | N/A | Object or `null` | | `@db.rel.via` | `[]` | N/A | Array | Key behaviors to remember: - **Null FK produces null navigation**: When an optional FK field is `null`, the corresponding `@db.rel.to` property returns `null` — not `undefined`, not an error. - **Empty collections, not null**: `@db.rel.from` and `@db.rel.via` always return `[]` when no matching records exist, never `null`. - **Without `$with`**: Navigation properties are `undefined` — they are not present on the returned object at all. This is distinct from `null`, which means "loaded but no match." ## Nullable FK Lifecycle {#nullable-fk-lifecycle} Optional foreign keys follow a clear lifecycle through insert, query, update, and relation loading: ```typescript // 1. Insert with null FK await taskTable.insertOne({ title: "Unassigned task", done: false, projectId: 1, assigneeId: null, }); // 2. Query for null FKs const unassigned = await taskTable.findMany({ filter: { assigneeId: null }, }); // 3. Load relation on null FK — returns null, not an error const task = await taskTable.findOne({ filter: { assigneeId: null }, controls: { $with: [{ name: "assignee" }] }, }); // task.assignee === null // 4. Assign a user await taskTable.updateOne({ id: task.id, assigneeId: 5 }); // 5. Unassign (set back to null) await taskTable.updateOne({ id: task.id, assigneeId: null }); ``` ::: warning Setting an FK to a non-existent ID results in a foreign key violation error. Via the HTTP controller, this surfaces as a `400` response with an `FK_VIOLATION` error code. Programmatically, the adapter throws an error. ::: ## How It Works Internally Under the hood, relation loading is adapter-specific: - **SQL adapters** (SQLite / PostgreSQL / MySQL) — **separate batched queries**. Each relation in `$with` triggers one additional query (two for VIA). No JOINs are emitted by the loader. - **MongoDB** — a single `$lookup` aggregation pipeline per relation (the adapter overrides `supportsNativeRelations()`). VIA relations chain two `$lookup` stages. The descriptions below cover the SQL batching strategy. MongoDB's `$lookup` follows the same logical shape — same FK direction semantics, same composite-key support — but executes server-side. ### TO relations FK values are collected from all result rows, deduplicated, and sent as a single `$in` query to the target table. Results are indexed by primary key and assigned back to each row. If 100 tasks all reference 5 distinct projects, only 1 query runs against the `projects` table with `{ id: { $in: [1, 2, 3, 4, 5] } }`. ### FROM relations Primary key values are collected from the result set, and the target table is queried with `{ fkField: { $in: [pkValues] } }`. Results are grouped by foreign key and assigned as arrays (or singular values for 1:1 FROM relations). ### VIA relations Two queries run in sequence: 1. The junction table is queried for all junction rows matching the local PKs 2. The target table is queried for the collected target FK values from step 1 Results are grouped through the junction mapping and assigned to each row. ### Composite keys When a relation involves composite primary or foreign keys, the loader uses `$or` filters with all unique key combinations instead of `$in`. The matching logic uses composite key indexing internally. ::: tip Performance This batching strategy means loading a relation on 100 records requires exactly **1 extra query** (or 2 for VIA), not 100. The N+1 problem is avoided by design. Relations within the same `$with` level are loaded in parallel. ::: ## HTTP Equivalent When using the HTTP controller (`AsDbController`), relation loading maps to the `$with` query parameter: ```bash # Load a single relation curl "http://localhost:3000/tasks/query?\$with=project" # Load multiple relations curl "http://localhost:3000/tasks/query?\$with=project,assignee,tags" ``` For nested loading and per-relation controls in URLs, see [CRUD Endpoints](/http/crud). ## Next Steps - [Navigation Properties](./navigation) — defining TO, FROM, VIA, and filter relations in the schema - [Referential Actions](./referential-actions) — cascade, restrict, and set-null behavior on delete/update - [CRUD Operations](/api/crud) — the full programmatic API for inserts, reads, updates, and deletes --- URL: "db.atscript.dev/relations/navigation" LLMS_URL: "db.atscript.dev/relations/navigation.md" outline: "deep" --- # Navigation Properties Navigation properties define how you traverse relationships between tables. While [foreign keys](./index) declare the physical link, navigation properties let you load related records by name. A foreign key says "this field points to that table." A navigation property says "give me the related record(s)." You declare both in the same `.as` schema, and Atscript wires them together automatically. ## `@db.rel.to` — Forward Navigation (N:1, 1:1) A `@db.rel.to` property loads the **single parent record** that a foreign key points to. The field type must be the target interface (not an array), since a foreign key always references exactly one row. ```atscript @db.table 'tasks' export interface Task { @meta.id id: number title: string @db.rel.FK ownerId: User.id @db.rel.to owner: User } ``` Atscript matches `owner: User` to the FK that points to `User` — in this case `ownerId`. You don't need to specify which FK to follow when there's only one FK targeting that type. When the FK is optional, the navigation property should be optional too: ```atscript @db.rel.FK assigneeId?: User.id @db.rel.to assignee?: User ``` ### Alias Matching for TO When multiple FKs point to the same target type, Atscript can't infer which FK a navigation property should follow. Use aliases to disambiguate: ```atscript @db.rel.FK 'author' authorId: User.id @db.rel.FK 'reviewer' reviewerId?: User.id @db.rel.to 'author' author: User @db.rel.to 'reviewer' reviewer?: User ``` The alias on `@db.rel.to` must match the alias on the corresponding `@db.rel.FK`. Without aliases, Atscript reports an error because it can't determine which FK each navigation property refers to. ::: tip When is an alias required? Only when a table has two or more FKs pointing to the **same** target type. If each FK targets a different type, Atscript resolves the match by type alone and no alias is needed. ::: ## `@db.rel.from` — Inverse Navigation (1:N) A `@db.rel.from` property navigates from a parent to its children. The foreign key lives on the **target** table, not the current one. The field type is an array because one parent can have many children. ```atscript @db.table 'projects' export interface Project { @meta.id id: number name: string @db.rel.from tasks: Task[] } ``` ``` ┌──────────────┐ ┌──────────────┐ │ projects │ │ tasks │ ├──────────────┤ ├──────────────┤ │ id (PK) │◄──────────│ projectId(FK)│ │ name │ │ id (PK) │ │ │ 1:N │ title │ │ tasks[] ◄───┼───────────│ │ └──────────────┘ └──────────────┘ ``` The FK is on `Task` (e.g., `projectId: Project.id`). Atscript resolves the reverse relationship automatically — it finds the FK on the target table that references the current table. ### Alias Matching for FROM When the target table has multiple FKs pointing back to this type, use aliases to specify which FK the inverse navigation follows: ```atscript @db.table 'users' export interface User { @meta.id id: number name: string @db.rel.from 'assignee' assignedTasks: Task[] @db.rel.from 'reporter' reportedTasks: Task[] } ``` This assumes `Task` has two FKs pointing to `User`: ```atscript @db.rel.FK 'assignee' assigneeId?: User.id @db.rel.FK 'reporter' reporterId: User.id ``` The alias on `@db.rel.from` matches the alias on the corresponding `@db.rel.FK` on the target table. ### Singular FROM (1:1 Inverse) For one-to-one inverse relations, use a singular type instead of an array: ```atscript @db.table 'users' export interface User { @meta.id id: number @db.rel.from profile: UserProfile } ``` This tells Atscript to expect at most one `UserProfile` per `User`. The FK on `UserProfile` should have `@db.index.unique` to enforce the 1:1 constraint at the database level: ```atscript @db.table 'user_profiles' export interface UserProfile { @meta.id id: number @db.rel.FK @db.index.unique userId: User.id bio: string } ``` When no matching record exists, loading a singular `@db.rel.from` returns `null` (instead of an empty array as you'd get with an array type). ## `@db.rel.via` — Many-to-Many A `@db.rel.via` property traverses a **junction table** to reach records on the other side of a many-to-many relationship. The junction type is required as an argument, and the field type is always an array. Here is a complete M:N example linking tasks and tags through a junction table: ```atscript @db.table 'task_tags' export interface TaskTag { @meta.id id: number @db.rel.FK @db.rel.onDelete 'cascade' taskId: Task.id @db.rel.FK @db.rel.onDelete 'cascade' tagId: Tag.id } @db.table 'tasks' export interface Task { @meta.id id: number title: string @db.rel.via TaskTag tags: Tag[] } @db.table 'tags' export interface Tag { @meta.id id: number label: string @db.rel.via TaskTag tasks: Task[] } ``` ``` ┌──────────┐ ┌──────────────┐ ┌──────────┐ │ tasks │ │ task_tags │ │ tags │ ├──────────┤ ├──────────────┤ ├──────────┤ │ id (PK) │◄──────│ taskId (FK) │ │ id (PK) │ │ title │ │ tagId (FK) ──┼──────►│ label │ │ │ │ id (PK) │ │ │ └──────────┘ └──────────────┘ └──────────┘ ``` Atscript resolves the path automatically: `Task.tags` follows `TaskTag.taskId` → `TaskTag.tagId` → `Tag`, and `Tag.tasks` follows the reverse direction. The junction table must have FKs to both sides. ::: info Junction table requirements The junction table must have at least two `@db.rel.FK` fields — one pointing to each side of the relationship. It can also contain additional data fields (e.g., `sortOrder`, `createdAt`) that describe the relationship itself. ::: ## `@db.rel.filter` — Filtering Navigation Properties The `@db.rel.filter` annotation restricts which related records are loaded. It accepts a backtick-delimited query expression that is applied as a `WHERE` condition when loading the relation. ```atscript @db.table 'posts' export interface Post { @meta.id id: number title: string @db.rel.from comments: Comment[] @db.rel.from @db.rel.filter `Comment.visible = true` visibleComments: Comment[] } ``` Loading `comments` returns all comments for a post. Loading `visibleComments` only returns comments where `visible` is `true`. The filter is applied at the database level, so filtered-out records are never fetched. `@db.rel.filter` works with all navigation types — `@db.rel.to`, `@db.rel.from`, and `@db.rel.via`. ::: tip Query expression syntax The backtick-delimited syntax (`\`Comment.visible = true\``) follows the same expression format used in view filters and join conditions. See [Queries & Filters](/api/queries) for the full syntax reference. ::: ## Complete Example Here is a full five-type schema that combines `@db.rel.to`, `@db.rel.from`, and `@db.rel.via` into a coherent data model: ```atscript @db.table 'users' export interface User { @meta.id @db.default.increment id: number name: string email: string @db.rel.from projects: Project[] } @db.table 'projects' export interface Project { @meta.id @db.default.increment id: number name: string @db.rel.FK ownerId: User.id @db.rel.to owner: User @db.rel.from tasks: Task[] } @db.table 'tasks' export interface Task { @meta.id @db.default.increment id: number title: string done: boolean @db.rel.FK @db.rel.onDelete 'cascade' projectId: Project.id @db.rel.FK 'assignee' @db.rel.onDelete 'setNull' assigneeId?: User.id @db.rel.to project: Project @db.rel.to 'assignee' assignee?: User @db.rel.via TaskTag tags: Tag[] } @db.table 'tags' export interface Tag { @meta.id @db.default.increment id: number label: string @db.rel.via TaskTag tasks: Task[] } @db.table 'task_tags' export interface TaskTag { @meta.id @db.default.increment id: number @db.rel.FK @db.rel.onDelete 'cascade' taskId: Task.id @db.rel.FK @db.rel.onDelete 'cascade' tagId: Tag.id } ``` This gives you: | Navigation | Type | Direction | Resolved via | | --------------- | ---- | -------------- | ------------------------------------ | | `Project.owner` | TO | Project → User | `ownerId` FK | | `User.projects` | FROM | User ← Project | `ownerId` FK on Project | | `Project.tasks` | FROM | Project ← Task | `projectId` FK on Task | | `Task.project` | TO | Task → Project | `projectId` FK | | `Task.assignee` | TO | Task → User | `assigneeId` FK (alias `'assignee'`) | | `Task.tags` | VIA | Task ↔ Tag | through `TaskTag` junction | | `Tag.tasks` | VIA | Tag ↔ Task | through `TaskTag` junction | ### Loading the Relations in TypeScript With navigation properties defined, you can load related data using `$with` controls: ```typescript const projects = await projectTable.findMany({ controls: { $with: [ { name: "owner" }, { name: "tasks", controls: { $with: [{ name: "assignee" }, { name: "tags" }], }, }, ], }, }); ``` This loads all projects, each with its `owner` record, and each project's `tasks` loaded with their `assignee` and `tags`. The loader runs relations in parallel within each `$with` level. The execution strategy is adapter-specific: SQL adapters issue a separate batched query per relation (no JOINs); MongoDB executes a `$lookup` aggregation pipeline per relation. See [Loading Relations § How It Works Internally](./loading#how-it-works-internally) for the full picture. ## Next Steps - [Loading Relations](./loading) — `$with` controls, nested loading, per-relation controls - [Referential Actions](./referential-actions) — control cascade, restrict, set-null behavior - [Deep Operations](./deep-operations) — insert, replace, and update across related tables --- URL: "db.atscript.dev/relations/patches" LLMS_URL: "db.atscript.dev/relations/patches.md" outline: "deep" --- # Relational Patches When using `updateOne` or `bulkUpdate` (PATCH), FROM and VIA navigation properties require explicit **patch operators** instead of plain arrays. This gives you fine-grained control over which related records to create, update, or remove. For single-table patch operations and embedded array patches, see [Update & Patch](/api/update-patch). ::: warning Requires `@db.depth.limit` on the parent table Relational patches on `@db.rel.from` and `@db.rel.via` are nested writes — the parent table **must** declare `@db.depth.limit N`. Otherwise the patch is rejected with HTTP 400 (`DEPTH_EXCEEDED`). See [Deep Operations § `@db.depth.limit`](./deep-operations#depth-limit) for the gate, and [§ Depth Control § `maxDepth`](./deep-operations#depth-control) for the runtime cap. ::: ## Where Relational Patches Apply Relational patches work on **navigation properties** — fields declared with `@db.rel.from` or `@db.rel.via`. They translate to real INSERT, UPDATE, and DELETE operations on the related tables and junction rows. | Relation type | Operators available | What happens | | -------------- | ------------------- | ------------------------------------------------ | | `@db.rel.to` | None needed | Send partial object — parent is updated directly | | `@db.rel.from` | All five operators | INSERT / UPDATE / DELETE on child table | | `@db.rel.via` | All five operators | Manages both target records and junction entries | ## Operators The same five patch operators used for [embedded array patches](/api/update-patch#embedded-array-patches) apply to relational patches — but instead of modifying JSON data in a single column, they translate to real INSERT, UPDATE, and DELETE operations on related tables. When multiple operators appear on the same field, they are always applied in order: **remove -> update -> upsert -> insert** — regardless of the order they appear in the payload object. ## FROM Relation Patches {#from} Operators on 1:N relations (`@db.rel.from`) translate to real database operations on the child table. The FK is automatically wired to the parent. Elements are identified by their **primary key** (`@meta.id`). The following schema is used for FROM examples: ```atscript import { Comment } from './comment.as' @db.table 'tasks' export interface Task { @meta.id @db.default.increment id: number title: string @db.rel.from comments: Comment[] } ``` ### `$insert` — Create New Children ```typescript await tasks.updateOne({ id: 1, comments: { $insert: [{ body: "Looks good!", authorId: 3 }], }, }); // INSERT INTO comments (body, authorId, taskId) VALUES ('Looks good!', 3, 1) ``` The FK (`taskId`) is set automatically from the parent's PK. ### `$remove` — Delete Children by PK ```typescript await tasks.updateOne({ id: 1, comments: { $remove: [{ id: 5 }], }, }); // DELETE FROM comments WHERE id = 5 AND taskId = 1 ``` ### `$update` — Patch Children by PK ```typescript await tasks.updateOne({ id: 1, comments: { $update: [{ id: 7, body: "Edited comment" }], }, }); // UPDATE comments SET body = 'Edited comment' WHERE id = 7 ``` ### `$upsert` — Insert or Update by PK ```typescript await tasks.updateOne({ id: 1, comments: { $upsert: [ { id: 7, body: "Updated" }, // Has PK → update { body: "Brand new", authorId: 2 }, // No PK → insert with FK wired ], }, }); ``` Items with a primary key are updated. Items without a primary key are inserted, with the FK automatically set to the parent's PK. ### `$replace` — Replace All Children Deletes all existing children and inserts the new set: ```typescript await tasks.updateOne({ id: 1, comments: { $replace: [{ body: "Only comment", authorId: 1 }] }, }); ``` This uses identity-preserving diff-sync — children with matching PKs are updated in place, not deleted and re-created. Only children whose PKs are absent from the new set are deleted. ## VIA Relation Patches {#via} Operators on M:N relations (`@db.rel.via`) manage both the **target records** and the **junction table entries**: ```atscript import { Tag } from './tag.as' import { TaskTag } from './task-tag.as' @db.table 'tasks' export interface Task { @meta.id @db.default.increment id: number title: string @db.rel.via TaskTag tags: Tag[] } ``` ### `$insert` — Add Tags ```typescript // Create new target + junction entry await tasks.updateOne({ id: 1, tags: { $insert: [{ name: "new-tag" }] } }); // Reference existing target — creates junction row only await tasks.updateOne({ id: 1, tags: { $insert: [{ id: 5 }] } }); ``` When a PK is provided, the target record is assumed to exist — only the junction entry is created. Without a PK, a new target record is inserted first. ### `$remove` — Unlink Tags ```typescript await tasks.updateOne({ id: 1, tags: { $remove: [{ id: 5 }] } }); ``` Removes the **junction entry** only — the target record (`Tag`) is preserved. To delete the target record itself, delete it from its own table. ### `$update` — Update Target Records ```typescript await tasks.updateOne({ id: 1, tags: { $update: [{ id: 5, name: "renamed" }] } }); ``` Updates the target record. The junction entry is untouched. ### `$upsert` — Insert or Update Targets ```typescript await tasks.updateOne({ id: 1, tags: { $upsert: [ { id: 5, name: "renamed" }, // Has PK → update target + ensure junction exists { name: "brand-new" }, // No PK → insert target + create junction ], }, }); ``` For items with a PK: the target record is updated, and a junction entry is created if one does not already exist. For items without a PK: a new target record is inserted and a junction entry is created. ### `$replace` — Replace All Links ```typescript await tasks.updateOne({ id: 1, tags: { $replace: [{ name: "only-tag" }] } }); ``` Clears all existing junction entries for this parent, then creates new ones. Target records with a PK are updated in place; target records without a PK are inserted as new rows. ## TO Relation Patches TO relations don't need operators — send a partial object and the parent is updated directly: ```typescript await tasks.updateOne({ id: 1, project: { id: 2, title: "Updated Title" }, }); // UPDATE projects SET title = 'Updated Title' WHERE id = 2 ``` If the FK value is not present in the patch payload, it is read from the database before the update. If the FK is `null`, the patch returns an error — you cannot patch a TO relation when the FK has no target. ::: warning Nested FROM inside TO Patching a TO parent's FROM children in a single call is not supported. The nested relation data exceeds the allowed depth and returns a `400` error: ```typescript // NOT SUPPORTED — will error await tasks.updateOne({ id: 1, project: { id: 2, tasks: { $insert: [{ title: "New" }] } }, }); ``` Update the parent and its children in separate calls instead. ::: ## Plain Arrays Rejected on PATCH ::: warning Passing a plain array on a FROM or VIA navigation property during `updateOne` / `bulkUpdate` returns a `400` validation error: ```typescript // ERROR — plain array on FROM relation await tasks.updateOne({ id: 1, comments: [{ body: "Hi" }] }); // Error: Cannot patch 1:N relation 'comments' with a plain value // — use patch operators ({ $insert, $remove, $replace, $update, $upsert }) ``` Plain arrays are only accepted on `replaceOne` (PUT), where they trigger the diff-based sync described in [Deep Operations](./deep-operations). For partial updates, always use explicit operators. ::: ## Combining Operators Multiple operators on the same field and across multiple fields in one request: ```typescript await tasks.updateOne({ id: 1, comments: { $remove: [{ id: 3 }], $update: [{ id: 7, body: "Revised" }], $insert: [{ body: "New comment", authorId: 1 }], }, tags: { $insert: [{ name: "reviewed" }] }, title: "Updated title", // scalar field — updated directly }); ``` Remember: operators execute in order **remove -> update -> upsert -> insert**, not in the order they appear in the object. ::: info Scalar fields alongside operators You can mix scalar field updates with relational patch operators in the same `updateOne` call. Scalar fields are updated on the main table; relational operators are applied to their respective related tables. All operations run within the same transaction (where the adapter supports transactions). ::: ## Next Steps - [Deep Operations](./deep-operations) — nested inserts, replaces, and full relation writes - [Navigation Properties](./navigation) — defining TO, FROM, and VIA relations - [Update & Patch](/api/update-patch) — single-table patches, embedded arrays, `@db.patch.strategy` --- URL: "db.atscript.dev/relations/referential-actions" LLMS_URL: "db.atscript.dev/relations/referential-actions.md" outline: "deep" --- # Referential Actions Referential actions control what happens to child records when a parent record is deleted or its primary key is updated. Use `@db.rel.onDelete` and `@db.rel.onUpdate` alongside `@db.rel.FK` to define this behavior. ## `@db.rel.onDelete` `@db.rel.onDelete` specifies what the database (or the application layer) should do with child records when a parent record is deleted. | Action | Behavior | | -------------- | ---------------------------------------------------------- | | `'cascade'` | Delete child records when the parent is deleted | | `'restrict'` | Prevent parent deletion if child records exist | | `'setNull'` | Set FK field to `null` (field must be optional) | | `'setDefault'` | Set FK field to its default value (requires `@db.default`) | | `'noAction'` | Database default behavior (adapter-dependent) | Here is an example combining cascade and setNull on different FK fields: ```atscript @db.table 'comments' export interface Comment { @meta.id id: number body: string // Delete comments when the task is deleted @db.rel.FK @db.rel.onDelete 'cascade' taskId: Task.id // Keep comment but clear author if user is deleted @db.rel.FK @db.rel.onDelete 'setNull' authorId?: User.id } ``` The `taskId` FK uses cascade — when a task is deleted, all its comments are deleted too. The `authorId` FK uses setNull — when a user is deleted, comments are preserved but their `authorId` is set to `null`. ## `@db.rel.onUpdate` `@db.rel.onUpdate` controls what happens when the parent's primary key value changes. It accepts the same five actions as `@db.rel.onDelete`: `'cascade'`, `'restrict'`, `'setNull'`, `'setDefault'`, `'noAction'`. ```atscript @db.rel.FK @db.rel.onDelete 'cascade' @db.rel.onUpdate 'cascade' projectId: Project.id ``` ::: tip In most applications, primary keys don't change after creation. `@db.rel.onUpdate` is primarily useful for tables with natural keys (like email addresses or codes) that might be updated. ::: ## Default Behavior When no `@db.rel.onDelete` or `@db.rel.onUpdate` is specified, the behavior depends on the adapter: - **SQL adapters** (SQLite, MySQL, PostgreSQL) use the database engine's default — typically `'noAction'` or `'restrict'` depending on the engine - **MongoDB adapter** also defaults to `'noAction'` — there is no native FK constraint, so unmanaged child documents are left with the now-dangling reference ::: warning Without explicit referential actions, deleting a parent record may leave orphaned FK values in child tables. Always specify `@db.rel.onDelete` for production schemas. ::: ## Cascade Delete Cascade is the most common action for dependent child records. When a parent is deleted, all children with `@db.rel.onDelete 'cascade'` are automatically deleted too. ```atscript @db.table 'projects' export interface Project { @meta.id id: number name: string @db.rel.from tasks: Task[] } @db.table 'tasks' export interface Task { @meta.id id: number title: string @db.rel.FK @db.rel.onDelete 'cascade' projectId: Project.id } ``` ```typescript // Deleting a project also deletes all its tasks await projects.deleteOne(1); ``` Cascades are recursive — if a task has comments with `onDelete: 'cascade'`, deleting the project deletes the tasks, which deletes the comments. ::: info Cascade depth limit The application-level cascade engine (used by the MongoDB adapter) enforces a maximum cascade depth of 100 to prevent infinite loops in misconfigured schemas. SQL adapters delegate cascade enforcement to the database engine, which has its own limits. ::: ## Restrict Use `'restrict'` when child records should prevent parent deletion. This protects important data from accidental cascading deletes. ```atscript @db.table 'departments' export interface Department { @meta.id id: number name: string @db.rel.from employees: Employee[] } @db.table 'employees' export interface Employee { @meta.id id: number name: string @db.rel.FK @db.rel.onDelete 'restrict' departmentId: Department.id } ``` ```typescript await departments.deleteOne(1); // Throws DbError { code: 'CONFLICT', errors: [...] } // because employees with departmentId=1 still exist ``` You must delete or reassign all employees before deleting the department. Via the HTTP controller, this returns a `409 Conflict` response. ## Set Null `'setNull'` keeps the child record but clears the FK reference. The FK field **must** be optional (`?`): ```atscript @db.rel.FK @db.rel.onDelete 'setNull' assigneeId?: User.id ``` ```typescript // Before: task.assigneeId = 5 await users.deleteOne(5); // After: task.assigneeId = null ``` ::: warning Using `'setNull'` on a non-optional FK field causes a validation error. The field must be declared with `?` to allow the null state. ::: ## Set Default `'setDefault'` sets the FK to its default value when the parent is deleted. The FK field must have a `@db.default.*` annotation: ```atscript @db.default.value 0 @db.rel.FK @db.rel.onDelete 'setDefault' categoryId: Category.id ``` If the field has no `@db.default.*` annotation, Atscript reports a warning at schema validation time — the field will have no fallback value when the parent is deleted. ::: info `'setDefault'` is rarely used in practice. Most schemas use `'cascade'` for dependent data, `'restrict'` for protected references, or `'setNull'` for optional associations. ::: ## Composite Foreign Keys When a composite FK (multiple fields referencing the same target with the same alias) needs a referential action, declare the action on exactly one of the FK fields: ```atscript @db.table 'order_items' export interface OrderItem { @meta.id id: number @db.rel.FK 'order' @db.rel.onDelete 'cascade' orderId: Order.id @db.rel.FK 'order' orderProductId: Order.productId quantity: number } ``` Placing `@db.rel.onDelete` on multiple fields within the same composite FK group causes a validation error — the action applies to the entire composite FK, not individual fields. ## Adapter Considerations Referential actions are enforced differently depending on the adapter: - **SQL adapters** (SQLite, MySQL, PostgreSQL) — actions are defined as native SQL `ON DELETE` / `ON UPDATE` clauses on the foreign key constraint. The database engine enforces them atomically. - **MongoDB adapter** — there are no native FK constraints. The generic DB layer emulates referential actions at the application level: before deleting a record, it queries for children and applies the configured action (cascade delete, restrict with error, or set null). The behavior is identical from the application's perspective — the same `@db.rel.onDelete 'cascade'` annotation works the same way regardless of adapter. ## Next Steps - [Foreign Keys](./index) — declaring FK fields with `@db.rel.FK` - [Navigation Properties](./navigation) — defining TO, FROM, and VIA relations - [Deep Operations](./deep-operations) — how referential actions interact with nested writes --- URL: "db.atscript.dev/search" LLMS_URL: "db.atscript.dev/search.md" outline: "deep" --- # Text Search Full-text search lets you search across one or more string fields with ranked results and optional field weighting. Define your search indexes in the `.as` schema, then query with a simple API — the adapter handles the engine-specific implementation. ## Defining a Fulltext Index Fulltext search indexes are defined with `@db.index.fulltext` in your `.as` schema. See [Indexes & Constraints — Full-Text Search Index](/api/indexes#full-text-search-index) for the annotation syntax, composite indexes, and field weighting. Here's a quick example: ```atscript @db.table 'articles' export interface Article { @meta.id id: number @db.index.fulltext "content_idx", 3 title: string @db.index.fulltext "content_idx" body: string } ``` Fields sharing the same index name form a **composite** full-text index. The optional weight argument controls relevance ranking — here, matches in `title` score 3× higher. ## Programmatic API ### Checking Capabilities ```typescript const articles = db.getTable(Article); // Does this table have fulltext indexes? articles.isSearchable(); // true // List all search indexes articles.getSearchIndexes(); // [{ name: 'content_idx', description: '...', type: 'text' }] ``` ### Basic Search ```typescript const results = await articles.search("typescript tutorial", { filter: {}, controls: { $limit: 20 }, }); ``` The `search()` method returns records ranked by relevance. ### Search with Count For paginated search results: ```typescript const result = await articles.searchWithCount("typescript tutorial", { filter: {}, controls: { $skip: 20, $limit: 10 }, }); console.log(result.data); // matching articles console.log(result.count); // total matches ``` ### Targeting a Specific Index When a table has multiple fulltext indexes, pass the index name: ```typescript const results = await articles.search( "tutorial", { filter: {}, controls: {}, }, "content_idx", ); ``` If omitted, the first available fulltext index is used. ## Combining Search with Filters Search results can be further filtered by regular query conditions: ```typescript const results = await articles.search("typescript", { filter: { status: "published", category: "tutorials" }, controls: { $limit: 10 }, }); ``` The text search narrows by relevance, then the filter conditions narrow further. Sorting, pagination, and field selection all work as usual. ## HTTP Access Text search is available via HTTP using the `$search` URL parameter on the `/query` or `/pages` endpoints: ``` GET /articles/query?$search=typescript%20tutorial GET /articles/query?$search=typescript%20tutorial&$index=content_idx GET /articles/query?$search=database&category=tech GET /articles/pages?$search=typescript&$page=1&$size=20 ``` The `$search` parameter provides the search text. Add `$index` to target a specific fulltext index when multiple exist. Regular filter parameters (like `category=tech`) combine with search results using AND logic. See [HTTP — Advanced](/http/advanced) for the full URL query syntax. ## Adapter Implementations Each adapter maps `@db.index.fulltext` to its native full-text search engine: | Adapter | Index type | Search mechanism | Per-field weights? | | -------------- | --------------------------------------------- | -------------------------------------- | ------------------------------------------------ | | **PostgreSQL** | GIN index on `to_tsvector('english', ...)` | `plainto_tsquery()` | No — weights in `@db.index.fulltext` are ignored | | **MongoDB** | Classic text index (works on all deployments) | `$text` | Yes — field weights | | **SQLite** | FTS5 virtual table | `MATCH` | No | | **MySQL** | `FULLTEXT` index | `MATCH ... AGAINST` (natural language) | No | ::: info All adapters expose the same `search()` and `searchWithCount()` API — engine differences are handled internally. See individual adapter pages for engine-specific details and configuration options. ::: MongoDB Atlas goes beyond word matching with **autocomplete/typeahead**, query-time **fuzzy** tolerance, and a per-index **match strategy** — declared with the `@db.mongo.search.*` annotations (separate from `@db.index.fulltext`, Atlas-only, queried via `$search`). See [MongoDB → Atlas Search](/adapters/mongodb#atlas-search). ## Next Steps - [Vector Search](./vector-search) — similarity search with embedding vectors - [Geo Search](./geo-search) — distance-ranked search over coordinates - [Indexes & Constraints](/api/indexes) — other index types (plain, unique) - [HTTP — Advanced](/http/advanced) — search and vector search URL parameters --- URL: "db.atscript.dev/search/geo-search" LLMS_URL: "db.atscript.dev/search/geo-search.md" outline: "deep" --- # Geo Search Geo search enables "find things near me" queries: declare a coordinate field with `db.geoPoint`, index it with `@db.index.geo`, then run distance-ranked searches with `geoSearch()` or radius predicates with the `$geoWithin` filter operator. The contract is portable **and runs natively on every adapter**: MongoDB stores GeoJSON `Point` with a `2dsphere` index, PostgreSQL uses PostGIS `geography(Point,4326)` with a GiST index, MySQL uses `POINT SRID 4326`, and SQLite computes haversine distances over the JSON-stored tuple. Your application always reads and writes plain `[lng, lat]` tuples — each adapter converts at the storage boundary. Adapters report availability via capability flags (only PostgreSQL without the PostGIS extension degrades to a non-searchable JSONB fallback). ## Defining a Geo Field ```atscript @db.table 'listings' export interface Listing { @meta.id id: number title: string status: string @db.index.geo geo: db.geoPoint } ``` - `db.geoPoint` is a `[longitude, latitude]` tuple — a semantic alias for `number[]` of length 2. - `@db.index.geo` declares the geospatial index. An optional string argument names the index (defaults to the field name) — relevant only when a table has several geo fields. ::: warning Coordinate order is GeoJSON order: longitude first `[lng, lat]`, not `[lat, lng]`. This is the single most common geo bug. San Francisco is `[-122.42, 37.77]`. ::: Writes validate coordinates automatically: exactly two finite numbers, `lng ∈ [-180, 180]`, `lat ∈ [-90, 90]`. Out-of-range values are rejected with a validation error (HTTP 400 over REST). ### Constraints `@db.index.geo` requires a `db.geoPoint` (or structurally identical `number[]`) field, must be **top-level** (not nested), and cannot combine with `@meta.id`, `@db.index.unique`, `@db.rel.FK`, `@db.json`, or [`@db.encrypted`](/api/encryption). Violations fail at compile/build time. ## Distance-Ranked Search: `geoSearch()` Mirrors [`vectorSearch()`](/search/vector-search): results are sorted by distance ascending, and every row carries a computed `$distance` field (meters from the query point). ```typescript const listings = db.getTable(Listing); const near = await listings.geoSearch([-122.42, 37.77], { filter: { status: "ACTIVE" }, controls: { $maxDistance: 50_000, $limit: 20 }, }); near[0].$distance; // meters from the query point ``` Controls (all distances in **meters**, spherical/great-circle): | Control | Description | | ------------------- | -------------------------------------------------- | | `$maxDistance` | Exclude rows farther than this | | `$minDistance` | Exclude rows closer than this (ring queries) | | `$skip` / `$limit` | Pagination — compose normally | | `$select` / `$with` | Projection and relation loading — compose normally | `$sort` is **rejected** on this path — results are always distance-ordered (same posture as vector search being score-sorted). For paginated results use `geoSearchWithCount()`: ```typescript const { data, count } = await listings.geoSearchWithCount([-122.42, 37.77], { filter: { status: "ACTIVE" }, controls: { $limit: 10 }, }); ``` ### Multiple Geo Fields Name the indexes and target one by passing the index name first (same overload shape as `vectorSearch`): ```atscript @db.index.geo 'pickup' pickupPoint: db.geoPoint @db.index.geo 'dropoff' dropoffPoint: db.geoPoint ``` ```typescript await rides.geoSearch("dropoff", [-122.42, 37.77], { filter: {}, controls: {} }); ``` ### Capability Checks & Errors ```typescript listings.isGeoSearchable(); // true everywhere except PostgreSQL without PostGIS ``` | Error code | When | | ---------------------- | ------------------------------------------------------------------------ | | `GEO_NOT_SUPPORTED` | `geoSearch()` or `$geoWithin` on an adapter without geo support | | `GEO_INDEX_MISSING` | `geoSearch()` on a table with no `@db.index.geo` (or unknown index name) | | `FILTER_TYPE_MISMATCH` | `$geoWithin` targeting a non-`geoPoint` field | | `INVALID_QUERY` | Bad query point / negative distances / `$sort` on the geo path | ## Radius Predicate: `$geoWithin` A boolean filter operator usable in **any** read path (`findMany`, `count`, `updateMany`/`deleteMany` filters). It does not sort and does not compute `$distance` — use `geoSearch()` for ranking: ```typescript const within = await listings.findMany({ filter: { status: "ACTIVE", geo: { $geoWithin: { center: [-122.42, 37.77], radius: 50_000 } }, }, controls: {}, }); ``` - `center` is `[lng, lat]`, `radius` is meters, both required. Circles only in v1. - Works without a geo index (the engine scans); `@db.index.geo` accelerates it. - Composes under `$and` / `$or` / `$not` like any other operator. ## HTTP Access: `GET /geo` The `moost-db` controller exposes a dedicated endpoint mirroring the search/vector read endpoints: ``` GET /listings/geo?$center=-122.42,37.77&$maxDistance=50000&status=ACTIVE GET /listings/geo?$center=-122.42,37.77&$maxDistance=50000&$page=1&$size=20 ``` | Parameter | Description | | -------------- | ----------------------------------------------- | | `$center` | **Required.** `lng,lat` (GeoJSON order) | | `$maxDistance` | Meters — exclude rows farther than this | | `$minDistance` | Meters — exclude rows closer than this | | `$index` | Geo index name (tables with several geo fields) | Everything else is the standard [URL query syntax](/http/query-syntax) — filters, `$select`, `$with`, `$skip`/`$limit`. With `$page`/`$size` the response is the `/pages` envelope (`{ data, page, itemsPerPage, pages, count }`); otherwise a plain row array. Each row carries `$distance` (meters). Missing/malformed `$center` → 400; on a non-geo adapter the endpoint returns 400 with `GEO_NOT_SUPPORTED`. `/meta` reports `geo: true` on geo-indexed fields (with `sortable: false` — distance ordering goes through `/geo`, not `$sort`) and a top-level `geoSearchable` flag. From the browser client: ```typescript const rows = await client.geoSearch([-122.42, 37.77], { filter: { status: "ACTIVE" }, controls: { $maxDistance: 50_000 }, }); rows[0].$distance; // meters const page = await client.geoPages([-122.42, 37.77], {}, 1, 20); ``` ## Adapter Support | Adapter | Storage | Index | Distance engine | | -------------- | ------------------------------------------------ | --------------------------------------------- | --------------------------------------------- | | **MongoDB** | GeoJSON `Point` | Managed `2dsphere` | `$geoNear` / `$centerSphere` (sphere) | | **PostgreSQL** | PostGIS `geography(Point,4326)` (JSONB fallback) | Managed GiST | `ST_Distance` / `ST_DWithin` (WGS84 spheroid) | | **MySQL** | `POINT SRID 4326` | Managed `SPATIAL` (requires a required field) | `ST_Distance_Sphere` (sphere) | | **SQLite** | JSON `[lng, lat]` text | None (scan-based) | Haversine in SQL (sphere, R = 6 371 km) | Adapter notes: - **PostgreSQL** — the adapter runs `CREATE EXTENSION IF NOT EXISTS postgis` during sync. If the extension can't be enabled (no superuser, not installed), `db.geoPoint` columns fall back to JSONB, the index is skipped with a warning, and geo queries fail with `GEO_NOT_SUPPORTED` — never a silent scan. Distances use the WGS84 spheroid (PostGIS default), so they can differ from the spherical engines by up to ~0.5%. - **MySQL** — `SPATIAL` indexes require `NOT NULL` columns. On an optional `geo?: db.geoPoint` field the index is skipped with a warning; `geoSearch`/`$geoWithin` still work (scan-based). Make the field required to get the index. - **SQLite** — needs SQLite math functions (`SQLITE_ENABLE_MATH_FUNCTIONS`; on by default in `better-sqlite3` builds). Declared geo indexes have no physical artifact; every geo query computes haversine per row — perfect for tests and small datasets. - **Distance values** — `$distance` and `$maxDistance`/`radius` are always meters, but each engine uses its own earth model (see table). Don't assert exact cross-adapter equality; allow ~0.5% tolerance. ### Migrating from v1 (JSON storage) Tables created before native geo support stored `db.geoPoint` as JSON. Schema sync detects the type drift and migrates the column **in place, preserving data**: - **PostgreSQL** — `ALTER COLUMN ... TYPE geography(Point,4326) USING ST_SetSRID(ST_MakePoint(...), 4326)` built from the JSONB tuple (NULLs preserved). - **MySQL** — a temp `POINT SRID 4326` column is added, populated from the JSON tuple, then swapped in for the original column. - **SQLite** — no change; storage was already the final form. Schema sync notes: adding/removing `@db.index.geo` changes the schema hash and triggers a sync; the managed index (`atscript__geo__*`) is created/dropped via drift correction on every adapter that materializes one. See [What Gets Synced](/sync/what-gets-synced). ## Next Steps - [Vector Search](./vector-search) — the sibling similarity-search path this API mirrors - [Field Encryption](/api/encryption) — mutually exclusive with geo indexes - [URL Query Syntax](/http/query-syntax) — the standard controls `/geo` composes with --- URL: "db.atscript.dev/search/vector-search" LLMS_URL: "db.atscript.dev/search/vector-search.md" outline: "deep" --- # Vector Search Vector search enables similarity retrieval using embedding vectors — useful for AI-powered search, recommendations, and RAG applications. Define vector fields and indexes in your `.as` schema, generate embeddings externally, then search with a simple API. ## Defining a Vector Field Use `@db.search.vector` on a field to mark it as a vector embedding: ```atscript @db.table 'documents' export interface Document { @meta.id id: number title: string @db.search.vector 1536, "cosine" embedding: db.vector } ``` The annotation takes up to three arguments: | Argument | Type | Required | Description | | ------------ | ------ | -------- | ----------------------------------------------------------------------------------------------------- | | `dimensions` | number | Yes | Vector size — must match your embedding model output (e.g., 1536 for OpenAI `text-embedding-3-small`) | | `similarity` | string | No | Distance metric: `"cosine"` (default), `"euclidean"`, or `"dotProduct"` | | `indexName` | string | No | Index name — defaults to the field name | ::: tip Allowed dimensions `dimensions` must be one of the whitelisted sizes that real-world embedding models actually emit: `256, 384, 512, 768, 1024, 1536, 2048, 3072, 4096, 6144, 8192, 16384` Any other value is rejected at compile time. Common choices: 384 (Sentence-Transformers MiniLM), 768 (BERT, Cohere `embed-multilingual-v3.0`), 1024 (Cohere `embed-english-v3.0`), 1536 (OpenAI `text-embedding-3-small`, `text-embedding-ada-002`), 3072 (OpenAI `text-embedding-3-large`). ::: The field type can be `db.vector` (a semantic alias for `number[]`) or `number[]` directly. ::: tip Use `db.vector` as the field type to make the intent clearer — it compiles to `number[]` but signals that this field holds an embedding vector. ::: ## Setting a Default Threshold Use `@db.search.vector.threshold` to set a minimum similarity score. Results below this threshold are excluded from search results: ```atscript @db.search.vector 1536, "cosine" @db.search.vector.threshold 0.7 embedding: db.vector ``` The threshold range is `0` to `1`, where `1` means exact match. This default can be overridden at query time via the `$threshold` control. ## Pre-Filtering Use `@db.search.filter` to mark fields as pre-filters for vector search. Pre-filters narrow the candidate set **before** similarity computation, improving both performance and relevance: ```atscript @db.table 'documents' export interface Document { @meta.id id: number title: string @db.search.vector 1536, "cosine" @db.search.vector.threshold 0.7 embedding: db.vector @db.search.filter "embedding" category: string @db.search.filter "embedding" status: string } ``` The `indexName` argument in `@db.search.filter` must match the name of a `@db.search.vector` index (the field name or explicit `indexName`). A field can be a pre-filter for multiple vector indexes. ## Programmatic API ### Checking Capabilities ```typescript const docs = db.getTable(Document); docs.isVectorSearchable(); // true docs.getSearchIndexes(); // [{ name: 'embedding', description: '...', type: 'vector' }] ``` ### Basic Vector Search Pass a pre-computed embedding vector to find similar records: ```typescript // Generate embedding externally (OpenAI, Cohere, etc.) const queryVector = await generateEmbedding("search query text"); const results = await docs.vectorSearch(queryVector, { filter: {}, controls: { $limit: 20 }, }); ``` Results are ordered by similarity (most similar first). ### Vector Search with Count For paginated results: ```typescript const result = await docs.vectorSearchWithCount(queryVector, { filter: {}, controls: { $limit: 10 }, }); console.log(result.data); // similar documents console.log(result.count); // total matches above threshold ``` ### Overriding Threshold at Query Time Use the `$threshold` control to override the schema-level default: ```typescript const results = await docs.vectorSearch(queryVector, { filter: {}, controls: { $threshold: 0.9, $limit: 10 }, // stricter than default }); ``` ### Targeting a Specific Index When a table has multiple vector fields, pass the index name as the first argument: ```typescript const results = await docs.vectorSearch("contentEmbedding", queryVector, { filter: {}, controls: { $limit: 10 }, }); ``` ## Combining with Filters ### Pre-filters Fields marked with `@db.search.filter` narrow candidates **before** similarity computation. Pass them in the query filter: ```typescript const results = await docs.vectorSearch(queryVector, { filter: { category: "tutorials" }, controls: { $limit: 10 }, }); ``` ### Post-filters Additional filter conditions that are not declared as `@db.search.filter` apply **after** vector search as standard query filters: ```typescript const results = await docs.vectorSearch(queryVector, { filter: { category: "tutorials", // pre-filter (declared with @db.search.filter) status: "published", // pre-filter (declared with @db.search.filter) }, controls: { $limit: 10 }, }); ``` ::: info Pre-filtering is more efficient than post-filtering because it reduces the candidate set before computing distances. Mark frequently-filtered fields with `@db.search.filter` for best performance. ::: ## Multiple Vector Fields Use `indexName` to distinguish multiple embedding fields on the same record: ```atscript @db.table 'documents' export interface Document { @meta.id id: number title: string body: string @db.search.vector 1536, "cosine", "content" contentEmbedding: db.vector @db.search.vector 384, "cosine", "title" titleEmbedding: db.vector @db.search.filter "content" @db.search.filter "title" category: string } ``` Target each index by name: ```typescript // Search by content similarity const byContent = await docs.vectorSearch("content", contentVector); // Search by title similarity const byTitle = await docs.vectorSearch("title", titleVector); ``` ## HTTP Access Vector search over HTTP uses `$search` (the text query) combined with `$vector` (the vector field name). The controller converts the text to an embedding internally via `computeEmbedding()`: ``` GET /documents/query?$search=machine+learning&$vector=embedding GET /documents/query?$search=machine+learning&$vector=embedding&$threshold=0.8 GET /documents/query?$search=cooking&$vector=embedding&category=food&$limit=10 GET /documents/pages?$search=optimization&$vector=embedding&$page=1&$size=20 ``` | Parameter | Description | | ------------ | ------------------------------------------------------------------------- | | `$search` | Text query (required — the controller converts it to an embedding) | | `$vector` | Vector field name to search on (e.g., `embedding`) | | `$threshold` | Minimum similarity score override (0–1); `0` disables threshold filtering | When a table has only one vector field, `$vector=embedding` (the field name) is the minimal form. For multiple vector fields, `$vector` selects which one to search. ::: warning Your controller must override `computeEmbedding()` to convert the `$search` text into a vector. Without this override, vector search via HTTP returns `501 Not Implemented`. See [HTTP — Advanced](/http/advanced) for setup details. ::: ## Similarity Metrics | Metric | Description | Best for | | --------------- | --------------------------------------------- | ------------------------------------------------- | | **Cosine** | Angle between vectors (normalized, range 0–1) | Text embeddings, most common choice | | **Euclidean** | Straight-line distance (lower = more similar) | Spatial data, image features | | **Dot product** | Inner product (requires normalized vectors) | Recommendation systems, pre-normalized embeddings | ::: tip Cosine similarity is the default and works well for most text embedding models (OpenAI, Cohere, etc.). Only change the metric if your embedding model specifically recommends a different one. ::: ## Adapter Support | Adapter | Column type | Index type | Notes | | -------------- | --------------------------------------------------------------- | --------------------- | -------------------------------------------------------------------------- | | **PostgreSQL** | `vector(N)` via pgvector | HNSW index | Auto-provisions pgvector extension; distance operators `<->`, `<#>`, `<=>` | | **MongoDB** | Embedded array | Atlas `$vectorSearch` | Requires Atlas M10+ cluster | | **MySQL** | `VECTOR(N)` | Native (9.0+) | `VEC_DISTANCE_*` functions | | **SQLite** | `BLOB` (`vec0`) when `sqlite-vec` loaded; JSON `TEXT` otherwise | `vec0` virtual table | Requires the `sqlite-vec` extension; falls back to JSON storage without it | ::: warning SQLite vector search requires `sqlite-vec` SQLite supports vector search only when the [`sqlite-vec`](https://github.com/asg017/sqlite-vec) extension is loaded. Construct the driver with `new BetterSqlite3Driver(path, { vector: true })` and ensure the extension is installed. Without `sqlite-vec`, vectors are stored as JSON text and `vectorSearch()` throws — schema sync, inserts, and reads still work, but similarity queries are unavailable. ::: ## Next Steps - [Text Search](./) — full-text search with ranked results and field weighting - [Geo Search](./geo-search) — distance-ranked search over coordinates (same API shape) - [HTTP — Advanced](/http/advanced) — vector search URL parameters - [Indexes & Constraints](/api/indexes) — other index types --- URL: "db.atscript.dev/sync" LLMS_URL: "db.atscript.dev/sync.md" outline: "deep" --- # How Schema Sync Works Schema sync compares your `.as` definitions against the live database and generates DDL to bring them in sync. There are no migration files to write, review, or track — your `.as` files _are_ the schema, and `asc db sync` brings the database in line with them. ## When to Use Schema Sync Schema sync fits naturally into every stage of your workflow: - **Development** — run sync on application startup. The hash check makes it effectively free when nothing has changed, so you can call it on every boot without penalty. - **Staging** — use `--dry-run` to preview the planned changes, then apply with `--yes` after review. - **Production** — integrate into your CI/CD pipeline. Use `--safe` to block destructive changes and require manual approval for anything beyond additive modifications. ::: tip Because sync is hash-gated, calling it on every deployment or application startup adds negligible overhead when the schema has not changed. There is no need to conditionally skip it. ::: ## How It Works ``` .as files → compile → hash check → (if changed) lock → diff → apply → store hash ``` On every run, schema sync hashes the full compiled schema and compares against the hash from the last successful sync. If it matches, sync exits as `up-to-date` after a single lightweight read — no introspection, no DDL, no lock acquired. Otherwise it acquires a distributed lock in `__atscript_control`, diffs the desired schema against either live introspection (SQL adapters) or the stored per-table snapshot (MongoDB), applies the DDL, and writes the new hash. The `__atscript_control` table is created and maintained automatically — you never need to touch it. It stores the current schema hash, the lock entry, the tracked-table list, and per-table snapshots used for diffing on snapshot-based adapters. ::: tip Because the hash check skips all introspection and DDL when nothing has changed, repeated syncs (on every deployment, every cold start, every CI run) are essentially free. ::: Use `--force` to bypass the hash check and re-introspect — useful when the database was modified outside of schema sync. ## Distributed Locking When multiple instances of your application start simultaneously (Kubernetes rolling deploys, serverless cold starts, parallel CI runners), the distributed lock prevents concurrent migrations: 1. **Quick hash check** — if the stored hash matches, sync returns `up-to-date` without touching the lock. 2. **Lock acquisition** — the first instance writes a lock row to `__atscript_control` keyed by `podId`. Other instances wait, polling at `pollIntervalMs`. 3. **Peer sync detection** — after the holder finishes, waiting instances re-check the hash. If it now matches, they return `synced-by-peer` without running any DDL. This is the common case in multi-pod deployments. A background heartbeat keeps the lock alive while sync runs, so long-running migrations don't lose their lock to TTL expiry. If a process crashes, the lock expires naturally and the next instance picks up. ::: info Lock knobs (programmatic only) | Parameter | Default | When to change | | ---------------- | ----------- | ------------------------------------------------------------------------------ | | `lockTtlMs` | `30000` | Increase only if you expect heartbeat misses (very slow DB). 30s is plenty. | | `waitTimeoutMs` | `60000` | Increase for large schemas or slow DBs where the first pod's sync takes long. | | `pollIntervalMs` | `500` | Lower for faster startup races; higher to reduce DB load on the control table. | | `podId` | random UUID | Set explicitly to make logs identifiable across restarts. | See the [programmatic API](./programmatic) for usage. ::: ## Change Categories Each table or view in the sync plan receives a status indicating what action will be taken: | Status | Meaning | | --------- | --------------------------------------------------------------------------- | | `create` | New table or view — will be created from scratch | | `alter` | Existing table or view — columns, indexes, FKs, or options will be modified | | `drop` | No longer in the schema — will be removed | | `in-sync` | No changes needed | | `error` | Conflicts detected that prevent sync | ### What Triggers `error` Status A sync entry is marked as `error` when sync cannot proceed safely: - **Rename collision** — a `@db.column.renamed` annotation attempts to rename column `A` to `B`, but column `B` already exists in the database. - **Type change without sync method** — a column's type changed (e.g., `TEXT` to `INTEGER`) but the table has no `@db.sync.method` annotation and the adapter does not support in-place column modification. Sync cannot determine whether to drop the table (`'drop'`) or recreate it with data preservation (`'recreate'`), so it flags the entry for manual resolution. ### Alter Details For entries with `alter` status, the plan provides a detailed breakdown of changes: - **Columns to add** — new fields with their types and constraints - **Columns to rename** — old name to new name mappings (via [`@db.column.renamed`](./what-gets-synced)) - **Type changes** — column type mismatches requiring `@db.sync.method` or adapter support - **Nullable changes** — fields changing between required and optional - **Default changes** — updated default values - **Columns to drop** — fields no longer in the schema - **FK changes** — foreign keys added, removed, or modified (fields, target table, cascade actions) - **Table option changes** — adapter-specific options (e.g., MySQL engine/charset) ::: danger Destructive Operations Entries involving destructive operations — column drops, table drops, type changes requiring table recreation, or destructive table option changes — are flagged with a `destructive` marker in the sync plan. Always review these carefully before confirming, especially in staging and production environments. ::: ### Views Views follow a simpler lifecycle than tables. They are categorized by type: | View Type | Label | Behavior | | ------------ | ----- | ------------------------------------------------------------------- | | Managed | `[V]` | Created, dropped, and recreated by sync when the definition changes | | Materialized | `[M]` | Like managed, but uses `CREATE MATERIALIZED VIEW` where supported | | External | `[E]` | Validated (existence + column check) but never modified or dropped | When a managed view's definition changes (different entry table, joins, filter, or fields), sync drops the old view and recreates it. External views are never dropped by sync — even when they are removed from the schema. They are validated: if a declared external view is missing from the database, sync reports an `error` status. ## Safe Mode The `--safe` flag suppresses all destructive operations during sync: - Column drops are skipped - Table and view drops are skipped - Type changes that require table recreation are skipped - Table option changes that require recreation are skipped - Nullable and default changes that would require table recreation are skipped Only additive and non-destructive changes are applied: new tables, new columns, column renames, index updates, and foreign key additions. Safe mode is designed for production CI/CD pipelines where you want automatic sync for additive changes but want to manually review and approve any destructive operations. ```bash # Safe mode in CI/CD — only additive changes applied automatically npx asc db sync --safe --yes ``` ::: warning Safe mode does not prevent all data loss scenarios. Column renames are still applied (they preserve data), and new non-nullable columns without defaults may cause insert failures on existing rows. Use `--dry-run` alongside `--safe` to review the full plan before applying. ::: ## Sync Result Statuses The `run()` method returns a result object with one of three statuses: | Status | Meaning | | ---------------- | --------------------------------------------------------------------------- | | `up-to-date` | Schema hash matched — no introspection or DDL was performed | | `synced` | Changes were detected and applied successfully | | `synced-by-peer` | Another instance completed the sync while this one was waiting for the lock | Both `up-to-date` and `synced-by-peer` are success statuses that indicate no work was needed by the current instance. The `synced` status includes a list of `SyncEntry` objects detailing what was changed. ## Next Steps - [CLI](./cli) — command-line usage and flags - [What Gets Synced](./what-gets-synced) — detailed change categories, renames, and structural changes - [Configuration](./configuration) — config file setup - [Programmatic API](./programmatic) — using sync from code - [CI/CD Integration](./ci-cd) — deployment strategies --- URL: "db.atscript.dev/sync/ci-cd" LLMS_URL: "db.atscript.dev/sync/ci-cd.md" outline: "deep" --- # CI/CD Integration Schema sync integrates into deployment pipelines at multiple levels — from automatic dev sync to controlled production rollouts. ## Deployment Strategies ### Development — Auto-Sync on Startup In development, sync on every application start. The [hash-based change detection](./#how-it-works) makes this effectively free when nothing has changed: ```typescript if (process.env.NODE_ENV === "development") { const { syncSchema } = await import("@atscript/db/sync"); await syncSchema(db, allTypes); } ``` Or via CLI before starting the dev server: ```bash npx asc db sync --yes && node server.js ``` ### Staging — Dry-Run + Review In staging, use `--dry-run` to preview changes, then apply with approval: ```bash # CI step 1: preview npx asc db sync --dry-run # CI step 2: apply (after review) npx asc db sync --yes ``` ### Production — Safe Mode or Manual For production, use `--safe` to ensure only additive changes are applied automatically. Destructive changes require manual intervention: ```bash npx asc db sync --yes --safe ``` ::: warning Atscript does not generate rollback DDL. Destructive operations (column drops, type changes, table drops) cannot be undone automatically. Plan these carefully and consider backup strategies. ::: ## Dry-Run in CI Pipeline Run `asc db sync --dry-run` as a CI check to detect unexpected schema changes early: ```yaml # GitHub Actions example - name: Schema sync check run: npx asc db sync --dry-run env: DATABASE_URL: ${{ secrets.STAGING_DATABASE_URL }} ``` The command exits with code 0 whether or not changes are detected. To fail the pipeline on pending changes, use the [programmatic API](./programmatic): ```typescript import { SchemaSync } from "@atscript/db/sync"; const sync = new SchemaSync(db); const plan = await sync.plan(types); if (plan.status === "changes-needed") { const hasDestructive = plan.entries.some((e) => e.destructive); if (hasDestructive) { console.error("Destructive schema changes detected — manual review required"); process.exit(1); } } ``` ## Distributed Locking Across Pods When multiple pods start simultaneously (Kubernetes rolling deploys, serverless cold starts), the [distributed lock](./#distributed-locking) ensures only one process runs sync. The typical flow: 1. First pod acquires the lock, a heartbeat keeps it alive 2. Other pods wait, polling every 500ms 3. First pod completes sync, stores the new hash, releases the lock 4. Waiting pods see the updated hash → `synced-by-peer`, skip sync See [Distributed Locking](./#distributed-locking) for the full flow. ### Tuning lock parameters For large deployments or slow databases, adjust via the [programmatic API](./programmatic): ```typescript await syncSchema(db, types, { lockTtlMs: 60_000, // 60s TTL (default 30s) — heartbeat extends every 20s waitTimeoutMs: 180_000, // 3 min wait (default 60s) pollIntervalMs: 2000, // 2s poll (default 500ms) }); ``` ## Serverless Considerations Serverless functions (AWS Lambda, Vercel, Cloudflare Workers) present unique challenges: - **Cold starts** — multiple instances may start simultaneously. The distributed lock handles this. - **Short execution time** — sync must complete within the function timeout. The hash check (a single lightweight DB read when schema is unchanged) helps. - **Connection limits** — each function instance opens a connection for sync. Consider connection pooling (e.g., RDS Proxy, PgBouncer). For serverless, the hash check is critical — it ensures that the vast majority of cold starts skip the expensive introspection and DDL phases, requiring only a lightweight hash comparison against the control table. ## Example CI Pipeline ### GitHub Actions ```yaml name: Deploy on: push: branches: [main] jobs: deploy: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v4 - uses: actions/setup-node@v4 with: node-version: 22 cache: pnpm - run: pnpm install # Preview schema changes - name: Schema sync dry-run run: npx asc db sync --dry-run env: DATABASE_URL: ${{ secrets.DATABASE_URL }} # Apply only additive changes - name: Apply schema sync run: npx asc db sync --yes --safe env: DATABASE_URL: ${{ secrets.DATABASE_URL }} # Deploy application - name: Deploy run: ./deploy.sh ``` ### Generic CI ```bash #!/bin/bash set -e # Step 1: Preview echo "=== Schema sync preview ===" npx asc db sync --dry-run # Step 2: Apply in safe mode echo "=== Applying safe changes ===" npx asc db sync --yes --safe # Step 3: Deploy echo "=== Deploying ===" # ... your deployment commands ``` ## Rollback Considerations Atscript schema sync is **forward-only** — there is no built-in down-migration or rollback mechanism. **Additive changes** (new tables, new columns, new indexes) are generally safe — the old application version simply ignores the new columns. **Destructive changes** require planning: | Change | Rollback Approach | | ------------------- | -------------------------------------- | | Add column | Safe — old code ignores it | | Drop column | Restore from backup or re-add manually | | Rename column/table | Apply reverse rename annotation | | Type change | Depends on data compatibility | | Drop table | Restore from backup | ::: danger Always back up your database before applying destructive schema changes in production. `--safe` mode prevents accidental destructive changes from reaching production. ::: ### Rename annotation lifecycle See [Rename Tracking](./what-gets-synced#rename-tracking) for the full lifecycle of `@db.table.renamed`, `@db.column.renamed`, and `@db.view.renamed` annotations — including when to add and remove them. ## Next Steps - [CLI](./cli) — command-line usage and flags - [Programmatic API](./programmatic) — using sync from code - [What Gets Synced](./what-gets-synced) — detailed change categories --- URL: "db.atscript.dev/sync/cli" LLMS_URL: "db.atscript.dev/sync/cli.md" outline: "deep" --- # CLI The `asc db sync` command compares your `.as` definitions against the live database and applies changes. It is part of the `@atscript/typescript` CLI. ## Basic Usage ```bash npx asc db sync ``` This will: 1. Compile your `.as` files 2. Connect to the database using your [config](./configuration) 3. Show a detailed plan of what will change 4. Ask for confirmation before applying destructive changes 5. Apply changes and show the result If the schema hash is unchanged since the last sync, the command exits after a quick hash comparison — no schema introspection, no DDL, no locking. ## Flags | Flag | Description | | -------------- | --------------------------------------------------------------------- | | `--dry-run` | Show the plan without applying any changes | | `--yes` | Skip the confirmation prompt (CI mode) | | `--force` | Re-sync even if the schema hash matches | | `--safe` | Skip destructive operations (column drops, table drops, type changes) | | `-c, --config` | Path to `atscript.config.mts` (auto-resolved if omitted) | ### `--dry-run` Shows exactly what would change without touching the database. Use this to preview changes before applying: ```bash npx asc db sync --dry-run ``` ### `--yes` Skips the interactive confirmation prompt. Non-destructive changes are always applied without prompting; `--yes` extends this to destructive changes as well: ```bash npx asc db sync --yes ``` ### `--force` Bypasses the [hash-based change detection](./#how-it-works). The command will introspect the database and diff against your `.as` definitions regardless of whether the stored hash matches: ```bash npx asc db sync --force ``` ### `--safe` Suppresses all destructive operations (column drops, table drops, type changes requiring recreation). Only additive changes are applied. See [Safe Mode](./#safe-mode) for details. ```bash npx asc db sync --safe ``` ## Output Format The CLI displays a structured plan grouped by tables and views. Each entry shows a status indicator: | Symbol | Color | Meaning | | ------ | ----- | -------------------------------------------- | | `+` | Green | New table/column/FK will be created | | `~` | Cyan | Existing table will be modified | | `-` | Red | Table/column/FK will be dropped | | `!` | Red | Type change or destructive operation | | `✓` | Green | Already in sync | | `✗` | Red | Error (rename conflict, missing sync method) | ### Example output ``` Tables: + users — create + id (number) PK — add + name (string) — add + email (string) — add ~ posts — alter + published_at (number) — add ~ title_text → title — rename - old_column — drop ✓ comments — in sync Views: + active_users — create ✓ [V] user_stats — in sync ``` After applying changes, the result summary shows what was actually done: ``` Schema synced successfully. Hash: a1b2c3d4 Tables: + users — created + id — added + name — added + email — added ~ posts — altered + published_at — added ~ title_text — renamed - old_column — dropped ``` ## Examples Preview changes without applying: ```bash npx asc db sync --dry-run ``` Auto-approve for CI/CD pipelines: ```bash npx asc db sync --yes ``` Safe mode — only additive changes: ```bash npx asc db sync --safe ``` Force a full re-sync, ignoring the stored hash: ```bash npx asc db sync --force ``` Use a specific config file: ```bash npx asc db sync -c ./config/atscript.config.mts ``` Combine flags for CI with safe mode: ```bash npx asc db sync --yes --safe ``` ## Error Handling The CLI exits with a non-zero code when: - **No `db` config** — the `atscript.config.mts` has no `db` field - **Schema errors** — rename conflicts, type changes without `@db.sync.method`, or external view validation failures - **Adapter not found** — the specified adapter package is not installed When errors are detected in the plan, the CLI prints the errors and exits without applying any changes. ### Exit Codes | Code | Meaning | | ----- | ---------------------------------------------------------------- | | `0` | Success — sync completed, was up-to-date, or `--dry-run` printed | | Non-0 | Schema errors, connection failure, missing config, or user abort | `asc` exposes `--help` and `--version` from the underlying `@atscript/typescript` CLI; both exit with `0`. ## Next Steps - [Configuration](./configuration) — config file setup for schema sync - [What Gets Synced](./what-gets-synced) — detailed change categories - [How Sync Works](./) — architecture and internals --- URL: "db.atscript.dev/sync/configuration" LLMS_URL: "db.atscript.dev/sync/configuration.md" outline: "deep" --- # Configuration Schema sync reads its database connection from the `db` section of your `atscript.config.mts`. This page covers all configuration options. ## Config File Setup Add a `db` section to your `atscript.config.mts`: ```typescript import { defineConfig } from "@atscript/core"; import ts from "@atscript/typescript"; import { dbPlugin } from "@atscript/db/plugin"; export default defineConfig({ rootDir: "src", plugins: [ts(), dbPlugin()], format: "dts", db: { adapter: "@atscript/db-sqlite", connection: "./myapp.db", }, }); ``` ::: info `dbPlugin()` is required to register `@db.*` annotations. The full base config is covered in [Setup](/guide/setup). This page focuses on the `db` section only. ::: ## Declarative Config The declarative format specifies the adapter package and connection: ```typescript db: { adapter: '@atscript/db-sqlite', connection: './myapp.db', } ``` | Option | Type | Description | | ------------ | ------------------------- | ------------------------------------------------------------------- | | `adapter` | `string` | Package name of the DB adapter (e.g., `'@atscript/db-sqlite'`) | | `connection` | `string \| () => string` | Connection string, file path, or factory function | | `options` | `Record` | Additional options passed to `createAdapter()` | | `include` | `string[]` | Glob patterns for `.as` files to include (overrides root `include`) | | `exclude` | `string[]` | Glob patterns for `.as` files to exclude (overrides root `exclude`) | The CLI resolves the adapter by importing the package and calling its `createAdapter(connection, options)` function. ### Connection strings by adapter | Adapter | Example Connection | | ----------------------- | ------------------------------------------------------------------- | | `@atscript/db-sqlite` | `'./data.db'` or `':memory:'` | | `@atscript/db-postgres` | `'postgresql://user:pass@localhost:5432/mydb'` | | `@atscript/db-mysql` | `{ host: 'localhost', port: 3306, user: 'root', database: 'mydb' }` | | `@atscript/db-mongo` | `'mongodb://localhost:27017/mydb'` | ### Dynamic connection The `connection` option can be a function that returns a connection string. This is useful for reading environment variables or secrets at sync time: ```typescript db: { adapter: '@atscript/db-postgres', connection: () => process.env.DATABASE_URL!, } ``` ## Callback Config For full control, pass a callback that returns a configured `DbSpace`: ```typescript import { DbSpace } from "@atscript/db"; import { PostgresAdapter, PgDriver } from "@atscript/db-postgres"; export default defineConfig({ // ... db: async () => { const driver = new PgDriver({ connectionString: process.env.DATABASE_URL, max: 5, }); return new DbSpace(() => new PostgresAdapter(driver)); }, }); ``` This bypasses adapter auto-resolution entirely — you construct the `DbSpace` yourself with any driver configuration you need. ## Scoping Sync to Specific Files Use `include` and `exclude` to control which `.as` files are compiled for sync. This is useful when your project has `.as` files that aren't database tables (e.g., validation-only types): ```typescript db: { adapter: '@atscript/db-sqlite', connection: './myapp.db', include: ['src/db/**/*.as'], exclude: ['src/db/deprecated/**'], } ``` When omitted, sync compiles all `.as` files matching the root config's patterns and extracts types with `@db.table` or `@db.view` annotations. ## Environment-Based Config Use standard Node.js environment detection for different sync settings: ```typescript const isProd = process.env.NODE_ENV === "production"; export default defineConfig({ // ... db: { adapter: "@atscript/db-postgres", connection: isProd ? process.env.DATABASE_URL! : "postgresql://localhost:5432/myapp_dev", }, }); ``` ::: tip For production deployments, consider using the [programmatic API](./programmatic) instead of the CLI. This gives you full control over sync options, error handling, and integration with your deployment pipeline. ::: ## Lock Configuration Distributed-lock parameters are not exposed in the config file — defaults are sane for most setups (30s TTL, 60s wait, 500ms poll). To tune them, use the [programmatic API](./programmatic): ```typescript import { syncSchema } from "@atscript/db/sync"; await syncSchema(db, types, { podId: process.env.HOSTNAME, // identifiable in logs lockTtlMs: 60_000, waitTimeoutMs: 120_000, pollIntervalMs: 1000, }); ``` ## Next Steps - [CLI](./cli) — command-line usage and flags - [Programmatic API](./programmatic) — using sync from code - [CI/CD Integration](./ci-cd) — deployment strategies --- URL: "db.atscript.dev/sync/programmatic" LLMS_URL: "db.atscript.dev/sync/programmatic.md" outline: "deep" --- # Programmatic API You can run schema sync from your own code using the `syncSchema` convenience function or the `SchemaSync` class directly. This is useful for application startup, custom deployment scripts, and CI/CD integration. ## Quick Start The simplest way to sync your schema: ```typescript import { DbSpace } from "@atscript/db"; import { syncSchema } from "@atscript/db/sync"; import { User, Post, Comment } from "./schema/index.as.js"; const db = new DbSpace(adapterFactory); const result = await syncSchema(db, [User, Post, Comment]); console.log(result.status); // 'up-to-date' | 'synced' | 'synced-by-peer' ``` `syncSchema` is a one-liner that creates a `SchemaSync` instance and calls `run()`. It handles locking, hashing, diffing, and DDL execution in a single call. ## SchemaSync Class For more control, use the `SchemaSync` class directly. It provides separate `plan()` and `run()` methods: ```typescript import { SchemaSync } from "@atscript/db/sync"; const sync = new SchemaSync(db); // Plan first — see what would change without applying const plan = await sync.plan(types); console.log(plan.status); // 'up-to-date' | 'changes-needed' for (const entry of plan.entries) { console.log(entry.name, entry.status, entry.destructive); } // Then run — apply changes with distributed locking const result = await sync.run(types, { force: true, safe: false }); console.log(result.status); // 'up-to-date' | 'synced' | 'synced-by-peer' ``` The constructor accepts an optional logger: ```typescript const sync = new SchemaSync(db, console); ``` ## TSyncOptions Options for both `syncSchema()` and `sync.run()`: | Option | Type | Default | Description | | ---------------- | --------- | ----------- | ----------------------------------------------------------------- | | `force` | `boolean` | `false` | Ignore hash check, always introspect the database | | `safe` | `boolean` | `false` | Skip destructive operations (column drops, table drops) | | `podId` | `string` | Random UUID | Identifier for distributed locking | | `lockTtlMs` | `number` | `30000` | Lock time-to-live in milliseconds (auto-extended while sync runs) | | `waitTimeoutMs` | `number` | `60000` | Max wait time for another pod's lock | | `pollIntervalMs` | `number` | `500` | Poll interval when waiting for lock release | The `plan()` method accepts only `force` and `safe`. ## TSyncPlan Returned by `sync.plan()`: ```typescript interface TSyncPlan { status: "up-to-date" | "changes-needed"; schemaHash: string; entries: SyncEntry[]; } ``` When `status` is `'up-to-date'`, the entries still list all tables/views with their `'in-sync'` status — useful for displaying a full schema overview. ## TSyncResult Returned by `sync.run()` and `syncSchema()`: ```typescript interface TSyncResult { status: "up-to-date" | "synced" | "synced-by-peer"; schemaHash: string; entries: SyncEntry[]; } ``` | Status | Meaning | | ------------------ | ---------------------------------------------------------------------- | | `'up-to-date'` | Schema hash matched, no sync needed | | `'synced'` | This process applied changes | | `'synced-by-peer'` | Another process completed sync while this one was waiting for the lock | ## SyncEntry Each entry in the plan/result represents a table or view: ```typescript interface SyncEntry { name: string; status: "create" | "alter" | "drop" | "in-sync" | "error"; viewType?: "V" | "M" | "E"; // V=virtual, M=materialized, E=external // Plan fields — what will change columnsToAdd: TDbFieldMeta[]; columnsToRename: Array<{ from: string; to: string }>; typeChanges: Array<{ column: string; fromType: string; toType: string }>; nullableChanges: Array<{ column: string; toNullable: boolean }>; defaultChanges: Array<{ column: string; oldDefault?: string; newDefault?: string }>; columnsToDrop: string[]; optionChanges: Array<{ key: string; oldValue: string; newValue: string; destructive: boolean }>; fkAdded: Array<{ fields: string[]; targetTable: string }>; fkRemoved: Array<{ fields: string[]; targetTable: string }>; fkChanged: Array<{ fields: string[]; targetTable: string; details: string }>; // Result fields — what was applied columnsAdded: string[]; columnsRenamed: string[]; columnsDropped: string[]; recreated: boolean; errors: string[]; renamedFrom?: string; // Computed properties destructive: boolean; // involves drops, type changes, or recreation hasChanges: boolean; // status is not 'in-sync' or 'error' hasErrors: boolean; // status is 'error' or errors array is non-empty } ``` ## Plan-Then-Decide Pattern Use `plan()` to inspect changes before deciding whether to apply: ```typescript const sync = new SchemaSync(db); const plan = await sync.plan(types); if (plan.status === "up-to-date") { console.log("Nothing to do"); return; } // Check for destructive changes const hasDestructive = plan.entries.some((e) => e.destructive); if (hasDestructive) { console.log("Destructive changes detected:"); for (const entry of plan.entries.filter((e) => e.destructive)) { console.log(` ${entry.name}: ${entry.status}`); for (const col of entry.columnsToDrop) { console.log(` - drop column: ${col}`); } } // Require explicit approval in production if (!approved) return; } // Apply with force to skip hash check (we already know changes are needed) const result = await sync.run(types, { force: true }); ``` ## Integration with Application Startup A common pattern is to sync on startup in development and skip in production: ```typescript const db = new DbSpace(adapterFactory); if (process.env.NODE_ENV !== "production") { const { syncSchema } = await import("@atscript/db/sync"); await syncSchema(db, allTypes); } ``` The hash check makes this very cheap when nothing has changed — only a lightweight control table read occurs, skipping all schema introspection and DDL. ## Error Handling Sync can fail for several reasons: ```typescript try { await syncSchema(db, types); } catch (error) { if (error.message.includes("lock wait timed out")) { // Another process holds the lock and didn't release in time // Increase waitTimeoutMs or investigate the other process } if (error.message.includes("Failed to acquire")) { // Lock acquisition failed after waiting } if (error.message.includes("lock was stolen")) { // Another pod took over the lock mid-sync — the current sync aborted safely. // Usually transient (network partition recovery). Retry or rely on `synced-by-peer`. } // Other errors: DB connection issues, DDL failures } ``` Schema-level errors don't throw — they appear as entries with `status: 'error'` and populated `errors` arrays. This covers rename conflicts, type changes without a sync method, and index/FK DDL failures (e.g. adding a unique index over duplicate data). Error entries have retry semantics: the schema hash and the errored table's snapshot are **not** persisted, so the next `run()` (or application boot) attempts the same changes again instead of reporting `'up-to-date'` over a diverged schema. Once the underlying conflict is resolved (data cleaned up, annotation fixed), the retry converges and the hash settles: ```typescript const result = await syncSchema(db, types); const failed = result.entries.filter((e) => e.hasErrors); for (const entry of failed) { console.error(`${entry.name}: ${entry.errors.join("; ")}`); } // failed entries re-attempt on every subsequent run until they succeed ``` ## Printing the Plan `SyncEntry` instances render themselves to coloured terminal output via `entry.print(mode, colors?)` — `mode` is `'plan'` (preview) or `'result'` (after `run()`). It returns an array of lines you can `console.log` directly: ```typescript const plan = await sync.plan(types); for (const entry of plan.entries) { for (const line of entry.print("plan")) { console.log(line); } } ``` Pass a colour adapter (matching the `TSyncColors` shape — `green/red/cyan/yellow/bold/dim/underline`) as the second argument to control styling. Omit it for plain text output suitable for logs. ## Exported Utilities The `@atscript/db/sync` sub-entry exports the building blocks used by the CLI: | Export | Purpose | | -------------- | ------------------------------------------------------ | | `SchemaSync` | Full sync class with `plan()` and `run()` | | `SyncEntry` | Plan/result entry with `print()` and computed flags | | `syncSchema()` | One-liner convenience: `new SchemaSync(db).run(types)` | Lower-level diff/snapshot helpers are also exported for building custom dashboards or sync UIs — consult the package source if you need them. ## Next Steps - [CI/CD Integration](./ci-cd) — deployment strategies and pipeline examples - [What Gets Synced](./what-gets-synced) — detailed change categories - [How Sync Works](./) — architecture and internals --- URL: "db.atscript.dev/sync/what-gets-synced" LLMS_URL: "db.atscript.dev/sync/what-gets-synced.md" outline: "deep" --- # What Gets Synced Schema sync detects and applies changes across tables, columns, indexes, foreign keys, views, and table options. This page details each category — what sync looks for, how it handles each change, and what you need to know about destructive operations. ## Tables Schema sync manages three table lifecycle operations: ### Create Any type annotated with `@db.table` that does not yet exist in the database triggers a `CREATE TABLE` statement. All columns, constraints, indexes, and foreign keys are created in a single operation. ```atscript @db.table 'products' export interface Product { @meta.id id: number name: string price: number } ``` ### Rename The `@db.table.renamed` annotation tells sync to rename an existing table instead of dropping the old one and creating a new one: ```atscript @db.table 'app_users' @db.table.renamed 'old_users' export interface User { @meta.id id: number email: string } ``` Sync will execute `ALTER TABLE old_users RENAME TO app_users` rather than destroying the original table and its data. ::: tip Keep `@db.table.renamed` in your `.as` file until the rename has been deployed to all environments (dev, staging, production). Once every environment has the new name, you can safely remove the annotation. ::: ### Drop Tables present in the database but no longer defined in your `.as` schema are dropped during sync. ::: danger Dropping a table destroys all data in it. Use `--safe` mode to prevent table drops, or use `--dry-run` to preview what will be removed before applying changes. ::: ## Columns Column-level changes are detected by `computeColumnDiff()`, which compares the desired field definitions from your `.as` types against the existing columns in the database. Six change types are tracked: ### Add New fields in a type generate `ALTER TABLE ADD COLUMN` statements. Added columns are nullable by default unless a default value is specified via `@db.default.*`: ```atscript @db.table 'users' export interface User { @meta.id id: number email: string // New column — added on next sync @db.default.value 'free' plan: string } ``` ### Rename The `@db.column.renamed` annotation triggers a column rename instead of a drop-and-add: ```atscript @db.table 'users' export interface User { @meta.id id: number // Rename email_address → email @db.column.renamed 'email_address' email: string } ``` Sync executes `ALTER TABLE users RENAME COLUMN email_address TO email`, preserving the existing data. ### Type Change When an existing column's type no longer matches the desired type (e.g., `TEXT` to `INTEGER`), sync detects the mismatch. How it handles the change depends on the adapter: - **Adapters with `supportsColumnModify`** (MySQL, PostgreSQL) can modify the column type in-place via `ALTER TABLE ... MODIFY COLUMN`. - **Other adapters** (SQLite) cannot modify columns. You must specify `@db.sync.method 'recreate'` or `'drop'` on the table, or the sync will produce an error. ::: warning Without either adapter support for in-place modification or a `@db.sync.method` annotation, type changes result in an error status. Sync will not apply any changes to the table until you resolve the conflict. See [Structural Changes](#structural-changes) below. ::: ### Nullable Change Sync detects when a field changes between optional and required: ```atscript // Before: optional bio?: string // After: required bio: string ``` Adapters with `supportsColumnModify` handle this in-place. On SQLite, nullable changes require table recreation (see [Structural Changes](#structural-changes)). ### Default Change Changes to `@db.default.*` values are detected when the existing column already has a recorded default. If the column had no default previously (no baseline exists), changes cannot be detected. Some adapters handle default changes in-place; others require table recreation. ### Drop Fields removed from your `.as` type are dropped from the database table. ::: danger Dropping a column permanently deletes all data stored in it. Use `--safe` mode to suppress column drops, or review carefully with `--dry-run`. ::: ## Indexes Sync manages indexes prefixed with `atscript__`. Indexes you create with any other name are left alone — useful for custom partial / expression indexes that schema sync shouldn't touch. ```atscript @db.table 'users' export interface User { @meta.id id: number @db.index.unique email: string @db.index created_at: string } ``` Adding `@db.index.*` creates the matching managed index; removing the annotation drops it. ### Definition Changes Index identity is the index name, but sync compares the **definition** too: if a named index's column list changed — a composite index gained or lost a member, or the column order changed — the index is dropped and recreated to match the model. Renaming the index (changing the annotation argument) also drops the old index and creates the new one. ### Dropping Indexed Columns When a column is removed, sync drops the managed indexes that reference it **before** dropping the column (engines like SQLite refuse `DROP COLUMN` while an index still references it). A composite index that loses one member this way is recreated afterwards with only the surviving columns. On SQLite the same pre-drop cleanup covers FTS5 fulltext artifacts and `vec0` vector shadow tables whose triggers reference the column. ### Index Creation Failures Index DDL can fail on data conflicts — most commonly adding `@db.index.unique` to a column that already contains duplicate values. Sync does not throw: the table's entry reports `status: 'error'`, the data is left untouched, and the schema hash is **not** persisted — so the next sync run retries automatically. Clean up the conflicting data and re-run. Two annotation-driven special cases participate in the schema hash like any other change: - [`@db.index.geo`](/search/geo-search) syncs to a managed `2dsphere` index on MongoDB and a GiST index on PostgreSQL (PostGIS). MySQL creates a `SPATIAL` index only for required fields (`NOT NULL` rule — optional fields warn and skip); SQLite creates no physical index (haversine search is scan-based). Tables that stored `db.geoPoint` as JSON before native geo support are migrated in place on the next sync (PostgreSQL `ALTER ... USING`, MySQL temp-column swap), preserving data and NULLs. - Toggling [`@db.encrypted`](/api/encryption) changes the field's storage type (ciphertext is unbounded text), so sync runs — but sync **never** encrypts/decrypts existing data, and key material is never written to the database. ## Foreign Keys Foreign key changes are detected by `computeForeignKeyDiff()`, which compares the desired FK constraints (from `@db.rel.FK` annotations) against a stored snapshot. Three change types are tracked: ### Add New `@db.rel.FK` fields generate foreign key constraints: ```atscript @db.table 'tasks' export interface Task { @meta.id id: number // FK target is encoded in the chain reference type (User.id). // The optional string argument is an *alias* (used to pair with @db.rel.to / @db.rel.from // when multiple FKs point to the same target type), NOT the target field. @db.rel.FK user_id: User.id } ``` ### Remove FK fields removed from the schema cause the corresponding constraint to be dropped. ### Change When a foreign key is retargeted (different table or different fields) or its `@db.rel.onDelete` / `@db.rel.onUpdate` actions change, sync detects the property difference and updates the constraint. ### Adapter Differences How FK changes are applied depends on the adapter: - **MySQL, PostgreSQL** — support standalone FK operations via `syncForeignKeys()`. Stale or changed FKs are dropped first (to unblock column alterations), then all desired FKs are synced after column operations complete. - **SQLite** — cannot `ALTER` foreign keys. Any FK change requires full table recreation. Sync handles this automatically when FK changes are detected on an adapter without `syncForeignKeys` support. For more on foreign key annotations, see [Foreign Keys](/relations/). ## Table Options Table-level options are adapter-specific settings detected by `computeTableOptionDiff()`. Changes are only detected when both the desired value (from annotations) and the existing value (from introspection or snapshot) are present — new options without a prior baseline are treated as initial state and do not trigger changes. Table option changes fall into two categories: - **Non-destructive** — applied in-place via `ALTER TABLE` (e.g., changing MySQL engine or charset). These are safe and do not affect data. - **Destructive** — require the table to be recreated (e.g., changing MongoDB capped collection size). With `@db.sync.method 'recreate'`, data is preserved via server-side copy; with `'drop'`, data is lost. See [Structural Changes](#structural-changes). ### MySQL MySQL tracks engine, charset, and collation as table options. Non-destructive changes (e.g., switching from InnoDB to MyISAM) are applied via `ALTER TABLE`. Destructive changes require table recreation. ### MongoDB MongoDB tracks capped collection parameters (size, max documents). Since these cannot be modified in place, changes require recreation. With `@db.sync.method 'recreate'`, **data is preserved**: it is copied server-side to a temporary collection via `$out`, the original is dropped and recreated with the new options, then data is copied back via `$merge`. With `@db.sync.method 'drop'`, data is lost (the collection is dropped and recreated empty). ## Views Schema sync manages views according to their type (see [View Types](/views/view-types) for definitions): - **Managed views** — created, dropped, and recreated by sync when the definition changes. Since most databases do not support `ALTER VIEW`, changes trigger a drop + recreate. - **Materialized views** — same lifecycle as managed views, but created with the materialized flag where supported. - **External views** — validated only (existence + column check). Never created, modified, or dropped by sync. A failed check reports an `'error'` entry but is advisory — it does not block hash persistence or wedge re-runs. Views whose definition changed (or that are being renamed) are dropped **before** table changes apply and recreated after. This matters when a sync both drops a column and updates a view that referenced it: without the early drop, SQLite and PostgreSQL would refuse the column drop while the old view definition still depends on it. Update the view definition in the same deploy that removes the column. ### View Renames The `@db.view.renamed` annotation handles view renames by dropping the old view and creating a new one with the updated name: ```atscript @db.view 'premium_users' @db.view.renamed 'vip_users' @db.view.for User @db.view.filter `tier = 'premium'` export interface PremiumUser { id: User.id email: User.email } ``` For more on view definitions and types, see [Views](/views/). ## Rename Tracking Renames require explicit tracking because, without it, a renamed field looks like a deletion followed by an addition — which means data loss. Three annotations handle renames across different schema objects: | Annotation | Target | Effect | | ------------------------------ | ------ | -------------------------------------------------- | | `@db.table.renamed 'oldName'` | Table | `ALTER TABLE oldName RENAME TO newName` | | `@db.column.renamed 'oldName'` | Column | `ALTER TABLE ... RENAME COLUMN oldName TO newName` | | `@db.view.renamed 'oldName'` | View | `DROP VIEW oldName` + `CREATE VIEW newName` | ### Lifecycle The lifecycle for all rename annotations follows the same pattern: 1. **Add the annotation** with the old name as the argument. 2. **Deploy to all environments** — sync will execute the rename on each database it runs against. 3. **Remove the annotation** once every environment has been updated. ::: tip Do not remove a `@db.*.renamed` annotation prematurely. If an environment has not yet been synced, removing the annotation will cause sync to treat the old and new names as unrelated — dropping the old object (and its data) and creating a new empty one. ::: ### Rename Conflicts If both the old name and the new name exist in the database simultaneously, sync produces an error status instead of proceeding. This protects against situations where a rename was partially applied or where two columns genuinely exist with both names. ``` Column rename conflict on users: cannot rename "email_address" → "email" because "email" already exists. ``` You must resolve the conflict manually (drop the stale column, or adjust the annotation) before sync will proceed. ### Example: Full Rename Workflow ```atscript // Step 1: Rename the column @db.table 'users' export interface User { @meta.id id: number @db.column.renamed 'email_address' email: string } ``` After deploying to all environments: ```atscript // Step 2: Remove the annotation @db.table 'users' export interface User { @meta.id id: number email: string } ``` ## Structural Changes Some changes cannot be applied with a simple `ALTER TABLE` statement — for example, changing a column type on SQLite, modifying nullable constraints, or changing destructive table options. The `@db.sync.method` annotation controls how sync handles these cases. ### `'drop'` — Drop and Recreate ```atscript @db.table 'sessions' @db.sync.method 'drop' export interface Session { @meta.id id: string data: string expires_at: string } ``` ::: tip Recipe: dropping a column safely Atscript never auto-rolls-back, so a column you remove in code is removed from the DB on the next sync. Two-step pattern for a live system: 1. **Stop reading/writing the column** — deploy the application change first, leaving the column in the schema (no `.as` change yet). 2. **Drop the column** — remove the field from the `.as` file and run sync. Use `--safe` in production to require manual approval; pair with `--dry-run` to preview. ::: ::: tip Recipe: changing an FK target 1. Add the new FK field (with chain reference to the new target) alongside the old one. 2. Backfill rows so the new FK is populated. 3. Drop the old FK field on the next deploy. Changing the chain-reference type of an existing FK field is a drop+add; do it in two deploys when downtime is not acceptable. ::: The table is dropped and recreated from scratch. ::: danger All data in the table is permanently destroyed. Use `'drop'` only for ephemeral data like sessions, caches, or temporary tables where data loss is acceptable. ::: ### `'recreate'` — Copy and Swap ```atscript @db.table 'users' @db.sync.method 'recreate' export interface User { @meta.id id: number email: string age: number // was: string } ``` Sync copies the data into a new table with the updated schema and swaps it in. Data is preserved wherever the old and new types are compatible; incompatible columns may lose data during the copy. ### When Required Structural changes are required in the following scenarios: | Scenario | Adapters Affected | Required Method | | ------------------------------- | ----------------- | ------------------------ | | Column type change | SQLite | `'recreate'` or `'drop'` | | Nullable change | SQLite | `'recreate'` or `'drop'` | | Default value change | SQLite | `'recreate'` or `'drop'` | | FK constraint change | SQLite | Automatic recreation | | Destructive table option change | All | `'recreate'` or `'drop'` | Adapters with `supportsColumnModify` (MySQL, PostgreSQL) can handle type, nullable, and default changes in-place without requiring `@db.sync.method`. ### Method Comparison | Method | Data | Use Case | | ------------ | -------------------------------------------------------------- | ---------------------------------------------------------------------------------------- | | `'drop'` | **Lost** — table is destroyed and recreated empty | Caches, sessions, temporary data | | `'recreate'` | **Preserved** — copied to new table where types are compatible | Important data with schema changes | | _(none)_ | **Error** — sync refuses to proceed | Default behavior when structural changes are needed on adapters without in-place support | ## Recipes ### Many-to-Many (Junction Table) Define an explicit junction `@db.table` with one `@db.rel.FK` per side; use `@db.rel.via` on each side to navigate through it: ```atscript @db.table 'tags' export interface Tag { @meta.id id: number label: string @db.rel.via PostTag posts: Post[] } @db.table 'posts' export interface Post { @meta.id id: number title: string @db.rel.via PostTag tags: Tag[] } // Junction table — must be its own @db.table with FKs on each side. @db.table 'post_tags' export interface PostTag { @meta.id id: number @db.rel.FK @db.rel.onDelete 'cascade' postId: Post.id @db.rel.FK @db.rel.onDelete 'cascade' tagId: Tag.id } ``` The junction must be a `@db.table`. `@db.rel.via` cannot be combined with `@db.rel.to` or `@db.rel.from` — it is self-sufficient. See [Navigation Properties](/relations/navigation#db-rel-via-many-to-many) for the runtime semantics. ### Vector Search Mark a field with `@db.search.vector , ""`. The dimensions must match the embedding model output and must be one of: `256, 384, 512, 768, 1024, 1536, 2048, 3072, 4096, 6144, 8192, 16384`. ```atscript @db.table 'documents' export interface Document { @meta.id id: number title: string @db.search.vector 1536, "cosine" @db.search.vector.threshold 0.7 embedding: db.vector @db.search.filter "embedding" category: string } ``` On sync, the adapter provisions its native vector type/index (pgvector for PostgreSQL, Atlas vector index for MongoDB, `VECTOR(N)` for MySQL 9+, `vec0` shadow table for SQLite with `sqlite-vec` loaded). See [Vector Search](/search/vector-search) for the full query API. ## What Is NOT Synced Schema sync handles structural schema changes only. The following are outside its scope: - **Data migration and seed data** — sync creates and modifies schema, but does not transform or populate data - **Stored procedures, triggers, and functions** — database-level logic is not managed by `.as` definitions - **Database users, roles, and permissions** — access control must be managed through your database administration tools - **Database-level settings** — charset, timezone, connection limits, and other server-level configuration - **Custom DDL** — any database objects not representable through Atscript annotations (e.g., custom types, sequences, partitions) ## Next Steps - [CLI](./cli) — command-line usage and flags - [Configuration](./configuration) — config file setup and adapter options - [Tables & Fields](/api/tables) — defining tables, columns, and constraints - [Indexes](/api/indexes) — index annotations and types - [Foreign Keys](/relations/) — FK annotations and relation patterns - [Views](/views/) — view definitions and types --- URL: "db.atscript.dev/views" LLMS_URL: "db.atscript.dev/views.md" outline: "deep" --- # Defining Views Views are read-only computed datasets derived from one or more tables. Like tables, they are defined in `.as` files — but instead of full CRUD, views produce read-only query interfaces with joins, filters, and computed columns declared right in your schema. ## Marking an Interface as a View Add `@db.view` to an interface to declare it as a database view: ```atscript @db.view export interface ActiveTask { // ... } ``` You can optionally provide a name for the view in the database: ```atscript @db.view 'active_tasks' export interface ActiveTask { // ... } ``` If omitted, the interface name is used directly. ::: info An interface cannot be both `@db.table` and `@db.view` — it's one or the other. ::: ## Entry Table The `@db.view.for` annotation specifies the primary (entry) table for the view. This is the table that drives the query — all joins are relative to it: ```atscript @db.view 'active_tasks' @db.view.for Task export interface ActiveTask { id: Task.id title: Task.title status: Task.status } ``` Every managed view requires `@db.view.for`. Without it, the view is treated as [external](./view-types#external-views) — a reference to a pre-existing database view not managed by Atscript. ## Joins Use `@db.view.joins` to bring in columns from related tables. Each join takes a target type and a condition written as a [query expression](/api/queries): ```atscript @db.view 'active_tasks' @db.view.for Task @db.view.joins User, `User.id = Task.assigneeId` @db.view.joins Project, `Project.id = Task.projectId` export interface ActiveTask { id: Task.id title: Task.title assigneeName: User.name projectTitle: Project.title } ``` The annotation is repeatable — add as many joins as you need. Each generates a `JOIN` in the resulting SQL (MongoDB uses `$lookup` with left-join semantics). Fields from joined tables can be marked optional since the join may not match every row: ```atscript assigneeName?: User.name // optional — task may have no assignee projectTitle: Project.title // required — every task has a project ``` ## View Filters The `@db.view.filter` annotation adds a `WHERE` clause using backtick [query expression](/api/queries) syntax: ```atscript @db.view.filter `Task.status != 'done'` ``` You can reference any table in scope — both the entry table and all joined tables: ```atscript @db.view.filter `Task.status != 'done' && Task.priority = 'high'` ``` ### Simple Views (No Joins) A view can filter a single table without any joins: ```atscript @db.view 'active_users' @db.view.for User @db.view.filter `User.status = 'active'` export interface ActiveUser { id: User.id name: User.name email: User.email } ``` ## HAVING Clause The `@db.view.having` annotation adds a post-aggregation filter (SQL `HAVING` clause). It references **view field aliases**, not source table columns: ```atscript @db.view 'category_stats' @db.view.for Order export interface CategoryStats { category: Order.category @db.agg.sum "amount" totalRevenue: number @db.agg.count orderCount: number } @db.view.having `totalRevenue > 1000` ``` The SQL builder resolves view aliases to their aggregate expressions — `totalRevenue` becomes `SUM(orders.amount)` in the generated `HAVING` clause. ::: tip The `@db.view.having` annotation is only meaningful when aggregation annotations are present. See [Aggregation Views](./aggregation-views) for the full pattern. ::: ## Field Mapping View fields map to source table columns via chain references. The view field name can differ from the source column name, creating an alias: ```atscript @db.view.for Task @db.view.joins User, `User.id = Task.assigneeId` export interface TaskSummary { taskId: Task.id // aliased — "taskId" maps to Task.id title: Task.title // same name assignee: User.name // aliased — "assignee" maps to User.name } ``` ## Complete Example A full view definition with an entry table, two joins, a filter, and field mapping from multiple tables: ```atscript import { Task } from './task' import { User } from './user' import { Project } from './project' @db.view 'high_priority_tasks' @db.view.for Task @db.view.joins User, `User.id = Task.assigneeId` @db.view.joins Project, `Project.id = Task.projectId` @db.view.filter `Task.priority = 'high' && Task.status != 'done'` export interface HighPriorityTask { id: Task.id title: Task.title status: Task.status priority: Task.priority createdAt: Task.createdAt assigneeName?: User.name projectTitle: Project.title } ``` Schema sync translates this into a `CREATE VIEW` statement with the appropriate `SELECT`, `JOIN`, and `WHERE` clauses. See [View Types](./view-types) for how sync manages the view lifecycle, and [Querying Views](./querying-views) for how to read data from views at runtime. ## Next Steps - [View Types](./view-types) — managed, materialized, and external views - [Aggregation Annotations](./aggregations) — computing sums, averages, and counts - [Querying Views](./querying-views) — read-only API for accessing view data - [Queries & Filters](/api/queries) — query expression syntax used in joins and filters --- URL: "db.atscript.dev/views/aggregation-views" LLMS_URL: "db.atscript.dev/views/aggregation-views.md" outline: "deep" --- # Aggregation Views The primary way to use aggregations is by combining `@db.view` with `@db.agg.*` annotations. This creates database views that compute grouped statistics — sums, averages, counts — automatically. ## How It Works 1. Define your source table(s) with `@db.table` 2. Create a view interface with `@db.view` and `@db.view.for` 3. Add plain fields — these become `GROUP BY` columns 4. Add aggregated fields with `@db.agg.*` annotations 5. Schema sync generates a `CREATE VIEW` with `SELECT ... GROUP BY ...` ## Step-by-Step Example Start with a source table: ```atscript @db.table 'orders' export interface Order { @meta.id id: number category: string amount: number status: string createdAt: number.timestamp } ``` Define an aggregation view over it: ```atscript import { Order } from './order' @db.view 'order_stats' @db.view.for Order export interface OrderStats { category: Order.category // GROUP BY column @db.agg.sum "amount" totalRevenue: number // SUM(amount) @db.agg.count orderCount: number // COUNT(*) @db.agg.avg "amount" avgOrderValue: number // AVG(amount) } ``` Schema sync generates SQL equivalent to: ```sql CREATE VIEW order_stats AS SELECT category, SUM(amount) AS totalRevenue, COUNT(*) AS orderCount, AVG(amount) AS avgOrderValue FROM orders GROUP BY category ``` ## Pre-Aggregation Filtering Use `@db.view.filter` to add a `WHERE` clause that filters rows **before** aggregation: ```atscript @db.view 'active_order_stats' @db.view.for Order @db.view.filter `Order.status = 'completed'` export interface ActiveOrderStats { category: Order.category @db.agg.sum "amount" totalRevenue: number @db.agg.count orderCount: number } ``` Only completed orders are included in the sums and counts. The filter runs as a SQL `WHERE` clause before grouping. ## Post-Aggregation Filtering (HAVING) Use `@db.view.having` to filter **after** aggregation — on the computed values themselves: ```atscript @db.view 'top_categories' @db.view.for Order @db.view.having `totalRevenue > 1000` export interface TopCategories { category: Order.category @db.agg.sum "amount" totalRevenue: number @db.agg.count orderCount: number } ``` The `HAVING` clause references **view field aliases** (`totalRevenue`), not source table columns. The SQL builder resolves these to their aggregate expressions: ```sql ... GROUP BY category HAVING SUM(amount) > 1000 ``` ::: info You can combine `@db.view.filter` and `@db.view.having` — the filter narrows rows before grouping, and the having clause filters the aggregated results. ::: ## Multi-Table Aggregation Views Use `@db.view.joins` to aggregate across joined tables: ```atscript import { Order } from './order' import { Category } from './category' @db.view 'category_revenue' @db.view.for Order @db.view.joins Category, `Category.id = Order.categoryId` export interface CategoryRevenue { categoryName: Category.name // GROUP BY — from joined table @db.agg.sum "amount" totalRevenue: number @db.agg.count orderCount: number @db.agg.max "amount" largestOrder: number } ``` This joins `orders` with `categories` and groups by the category name, computing revenue aggregates per category. ## Querying Aggregation Views Aggregation views are queried with the same API as regular views — via `AtscriptDbView`: ```typescript import { DbSpace } from "@atscript/db"; import { OrderStats } from "./schema/order-stats.as"; const db = new DbSpace(adapterFactory); const stats = db.getView(OrderStats); // All categories const all = await stats.findMany({}); // Sort by revenue, descending const topCategories = await stats.findMany({ controls: { $sort: { totalRevenue: -1 }, $limit: 10 }, }); // Count categories with orders const categoryCount = await stats.count({}); ``` Via HTTP, aggregation views use `AsDbReadableController` — the same read-only endpoints as regular views. See [HTTP — Advanced](/http/advanced) for URL query syntax. ## Complete Example Putting it all together — source table, aggregation view with joins, filter, and having: ```atscript import { Order } from './order' import { Category } from './category' @db.view 'top_category_stats' @db.view.for Order @db.view.joins Category, `Category.id = Order.categoryId` @db.view.filter `Order.status = 'completed'` @db.view.having `totalRevenue > 500` export interface TopCategoryStats { categoryName: Category.name @db.agg.sum "amount" totalRevenue: number @db.agg.count orderCount: number @db.agg.avg "amount" avgOrderValue: number } ``` Generated SQL (conceptual): ```sql CREATE VIEW top_category_stats AS SELECT categories.name AS categoryName, SUM(orders.amount) AS totalRevenue, COUNT(*) AS orderCount, AVG(orders.amount) AS avgOrderValue FROM orders JOIN categories ON categories.id = orders.categoryId WHERE orders.status = 'completed' GROUP BY categories.name HAVING SUM(orders.amount) > 500 ``` TypeScript usage: ```typescript const stats = db.getView(TopCategoryStats); const results = await stats.findManyWithCount({ controls: { $sort: { totalRevenue: -1 }, $limit: 20 }, }); console.log(results.data); // top categories by revenue console.log(results.count); // total matching categories ``` ## Next Steps - [Querying Views](./querying-views) — full API for reading view data - [Aggregation Annotations](./aggregations) — annotation reference for all aggregate functions - [Defining Views](./) — view structure, joins, and filters - [HTTP — Advanced](/http/advanced) — accessing views over HTTP --- URL: "db.atscript.dev/views/aggregations" LLMS_URL: "db.atscript.dev/views/aggregations.md" outline: "deep" --- # Aggregation Annotations Aggregations compute values like sums, averages, and counts across groups of rows. In Atscript, you declare aggregations directly on view fields using `@db.agg.*` annotations — the database handles the computation. ## Available Functions ### `@db.agg.sum` Computes the SUM of a numeric source column: ```atscript @db.agg.sum "amount" totalAmount: number ``` The field argument is the **source column name** from the entry table. The annotated field must be `number` or `decimal`. ### `@db.agg.avg` Computes the AVG (average) of a numeric source column: ```atscript @db.agg.avg "amount" averageAmount: number ``` Like `sum`, the field must be `number` or `decimal`. ### `@db.agg.count` Counts rows. Without an argument, it produces `COUNT(*)` — counting all rows. With a field name, it produces `COUNT(field)` — counting non-null values only: ```atscript @db.agg.count totalOrders: number // COUNT(*) @db.agg.count "assigneeId" assignedOrders: number // COUNT(assigneeId) — excludes nulls ``` The annotated field must be `number`. ::: tip `COUNT(*)` counts all rows in each group, including those with null values. `COUNT(field)` only counts rows where the specified field is not null. ::: ### `@db.agg.min` Minimum value of a source column: ```atscript @db.agg.min "amount" smallestOrder: number ``` Accepts any comparable type — numbers, strings, dates. ### `@db.agg.max` Maximum value of a source column: ```atscript @db.agg.max "createdAt" latestOrder: number.timestamp ``` Accepts any comparable type. Annotate the result field with the source column's primitive (here `number.timestamp`) so the view's TypeScript output preserves type fidelity. ## The GROUP BY Pattern When a view contains aggregation annotations, non-aggregated fields automatically become `GROUP BY` columns. This is how the database knows how to group the data before computing aggregates. ```atscript @db.view 'category_stats' @db.view.for Order export interface CategoryStats { category: Order.category // plain field → GROUP BY @db.agg.sum "amount" totalRevenue: number // aggregated @db.agg.count orderCount: number // aggregated @db.agg.avg "amount" avgOrderValue: number // aggregated } ``` This produces SQL equivalent to: ```sql SELECT category, SUM(amount) AS totalRevenue, COUNT(*) AS orderCount, AVG(amount) AS avgOrderValue FROM orders GROUP BY category ``` Multiple plain fields create multi-column grouping: ```atscript category: Order.category // GROUP BY column 1 region: Order.region // GROUP BY column 2 @db.agg.sum "amount" totalRevenue: number // aggregated per (category, region) ``` When **all** fields are aggregated (no plain fields), there is no `GROUP BY` — the aggregation runs across the entire table, producing a single result row. ## Type Constraints | Annotation | Allowed field types | Validates at | | --------------- | ------------------- | ------------ | | `@db.agg.sum` | `number`, `decimal` | Compile time | | `@db.agg.avg` | `number`, `decimal` | Compile time | | `@db.agg.count` | `number` | Compile time | | `@db.agg.min` | Any comparable | — | | `@db.agg.max` | Any comparable | — | Atscript validates type compatibility at build time — annotating a `string` field with `@db.agg.sum` produces a compile error. ## Runtime Aggregation: Quantity Dimensions The annotations above define **view-time** aggregations: shape baked into the schema. For **ad-hoc** aggregations via `table.aggregate()` at runtime, see [Queries & Filters § Runtime Aggregation](/api/queries). One constraint worth knowing here, because it surfaces the same way against view aggregates: when the source column carries `@db.amount.currency.ref` or `@db.unit.ref` (see [Annotations § Quantity Tagging](../adapters/annotations#quantity-tagging-currency-unit)), the runtime rejects ad-hoc aggregations that don't include the referenced dimension in `$groupBy`. Summing rows that mix currencies — or kg with lb — is meaningless, and the guard catches it before it reaches the database. ```ts // Schema: amount carries @db.amount.currency.ref 'currency' await orders.aggregate({ filter: {}, controls: { $groupBy: ["status"], // ← missing 'currency' $select: ["status", { $fn: "sum", $field: "amount", $as: "total" }], }, }); // → DbError("INVALID_QUERY"): Aggregate "sum(amount)" requires "currency" in $groupBy ``` Add the ref field to `$groupBy` (`["status", "currency"]`) and the query proceeds. Literal forms (`@db.amount.currency 'EUR'`, `@db.unit 'qps'`) impose no constraint — the dimension is fixed schema-wide. `COUNT(*)` is exempt either way. ## Annotation Reference | Annotation | Argument | Required? | SQL Equivalent | | ----------------------- | ------------------ | --------- | -------------- | | `@db.agg.sum "field"` | Source column name | Yes | `SUM(field)` | | `@db.agg.avg "field"` | Source column name | Yes | `AVG(field)` | | `@db.agg.count` | None | — | `COUNT(*)` | | `@db.agg.count "field"` | Source column name | Optional | `COUNT(field)` | | `@db.agg.min "field"` | Source column name | Yes | `MIN(field)` | | `@db.agg.max "field"` | Source column name | Yes | `MAX(field)` | ## Next Steps - [Aggregation Views](./aggregation-views) — combining views with aggregation annotations - [Defining Views](./) — view structure, joins, and filters - [Querying Views](./querying-views) — reading aggregation results at runtime --- URL: "db.atscript.dev/views/querying-views" LLMS_URL: "db.atscript.dev/views/querying-views.md" outline: "deep" --- # Querying Views Views are read-only — you can query them with the same filter, sort, and pagination controls as tables, but no write operations are available. ## Registering Views Use `db.getView()` to get a read-only `AtscriptDbView` instance: ```typescript import { DbSpace } from "@atscript/db"; import { ActiveTask } from "./schema/active-task.as"; const db = new DbSpace(adapterFactory); const view = db.getView(ActiveTask); ``` `getView()` returns a cached instance — calling it again with the same type returns the same view. See [Setup](/guide/setup) for how to create a `DbSpace`. ## Read Operations `AtscriptDbView` provides all the read operations from `AtscriptDbReadable`: ### findMany Retrieve multiple records matching a query: ```typescript const tasks = await view.findMany({ filter: { projectTitle: "Website" }, controls: { $sort: { title: 1 }, $limit: 20 }, }); ``` ### findOne Retrieve a single record: ```typescript const task = await view.findOne({ filter: { assigneeName: "Alice" }, }); ``` ### findManyWithCount Retrieve records and total count in a single call — useful for pagination: ```typescript const result = await view.findManyWithCount({ filter: { status: "in_progress" }, controls: { $skip: 20, $limit: 10 }, }); console.log(result.data); // 10 records (page 3) console.log(result.count); // total matching records ``` ### count Count records matching a query: ```typescript const total = await view.count({ filter: { status: "in_progress" }, }); ``` ### findById Look up a single record by primary key (if the view has `@meta.id`): ```typescript const task = await view.findById(42); ``` ## Filtering and Sorting Filters apply to the view's output columns, not directly to source tables: ```typescript // Filter on a view field (even if it's aggregated) const topCategories = await stats.findMany({ filter: { orderCount: { $gte: 100 } }, controls: { $sort: { totalRevenue: -1 } }, }); ``` - **Sorting** works on any view field, including aggregated fields - **Pagination** via `$skip` and `$limit` works as expected - **Field selection** via `$select` picks specific columns from the view output For the full query syntax, see [Queries & Filters](/api/queries). ## HTTP Access Use `AsDbReadableController` to expose a view as a read-only HTTP endpoint: ```typescript import { AsDbReadableController } from "@atscript/moost-db"; import { ActiveTask } from "./schema/active-task.as"; @Controller("active-tasks") export class ActiveTaskController extends AsDbReadableController(ActiveTask) {} ``` This provides: - `GET /active-tasks` — list with filter, sort, pagination - `GET /active-tasks/:id` — single record by ID No `POST`, `PUT`, `PATCH`, or `DELETE` endpoints — views are read-only. The same URL query syntax applies (`$sort`, `$skip`, `$limit`, `$select`, `$filter`). See [HTTP — CRUD Endpoints](/http/crud) for details. ## Refreshing Materialized Views Materialized views store precomputed results that need periodic refreshing. Refresh behavior is adapter-specific: | Adapter | Refresh method | | -------------- | --------------------------------------------- | | **PostgreSQL** | `REFRESH MATERIALIZED VIEW` (native) | | **MongoDB** | Re-run aggregation pipeline with `$merge` | | **SQLite** | Not applicable (no materialized view support) | | **MySQL** | Not applicable (no materialized view support) | ::: info Materialized view refresh is currently an adapter-level operation — there is no high-level API on `AtscriptDbView`. Consult your adapter's documentation for refresh mechanics. ::: ## See Also: Ad-Hoc Aggregation Views are the right tool when the aggregation shape is stable. For dynamic, query-time aggregations against a regular `@db.table`, use `table.aggregate()` — pass `$groupBy` and `$select` (with `$fn: 'sum' | 'avg' | 'min' | 'max' | 'count'`) inline per call. See [Queries & Filters](/api/queries) for the runtime-aggregation API. ## Next Steps - [Defining Views](./) — how to define views in `.as` files - [View Types](./view-types) — managed, materialized, and external views - [Aggregation Views](./aggregation-views) — views with computed aggregates - [CRUD Operations](/api/crud) — table read operations (same API) - [HTTP — CRUD Endpoints](/http/crud) — HTTP endpoint reference --- URL: "db.atscript.dev/views/view-types" LLMS_URL: "db.atscript.dev/views/view-types.md" outline: "deep" --- # View Types Atscript supports three kinds of database views, differing in whether schema sync manages them and whether results are physically stored. ## Overview | Type | Created by sync? | Stored? | Use case | | --------------------- | ---------------- | ----------------- | ----------------------------------------------- | | **Managed** (virtual) | Yes | No — query-based | Joins, filters, projections you define in `.as` | | **Materialized** | Yes | Yes — precomputed | Performance-critical aggregations and summaries | | **External** | No | Already exists | Pre-existing views not managed by Atscript | ## Managed Views A managed view has `@db.view.for` — schema sync creates, updates, and drops it automatically. The view SQL is generated from your annotations (joins, filter, having): ```atscript @db.view 'active_tasks' @db.view.for Task @db.view.joins User, `User.id = Task.assigneeId` @db.view.filter `Task.status != 'done'` export interface ActiveTask { id: Task.id title: Task.title assigneeName?: User.name } ``` Managed views are the most common type. Schema sync tracks changes to the view definition and recreates the view when it detects a difference. ::: tip Use `@db.view.renamed` when renaming a view so sync maps the old name to the new one and cleans up the old view — see [Schema Sync Behavior](#schema-sync-behavior) below. ::: ## Materialized Views Add `@db.view.materialized` to store the view's computed results in the database. Materialized views trade storage space for faster reads — especially useful for complex aggregations: ```atscript @db.view 'order_stats' @db.view.for Order @db.view.materialized export interface OrderStats { category: Order.category @db.agg.sum "amount" totalRevenue: number @db.agg.count orderCount: number } ``` ### Adapter Support Materialized view support varies by adapter: | Adapter | Support | Implementation | | -------------- | --------------- | ------------------------------------------------------------------- | | **PostgreSQL** | Native | `CREATE MATERIALIZED VIEW`, refresh via `REFRESH MATERIALIZED VIEW` | | **MongoDB** | Via aggregation | `$merge` / `$out` aggregation stage | | **SQLite** | Not supported | Falls back to standard `CREATE VIEW` | | **MySQL** | Not supported | Falls back to standard `CREATE VIEW` | ::: warning When an adapter does not support materialized views natively, `@db.view.materialized` is silently ignored and a standard view is created instead. Check your adapter's documentation for details. ::: See [Querying Views — Refreshing Materialized Views](./querying-views#refreshing-materialized-views) for how to refresh stored results. ## External Views When you have a pre-existing view in your database that Atscript should not manage, declare it with `@db.view` alone — without `@db.view.for`: ```atscript @db.view 'legacy_report' export interface LegacyReport { @meta.id reportId: number title: string total: number } ``` External views: - Are **not** created, modified, or dropped by [schema sync](/sync/) - Can be queried with the same API as managed views (see [Querying Views](./querying-views)) - Declare field types directly — no chain references to source tables This is useful when you have views created by migration scripts, DBAs, or other systems that Atscript should read but not manage. ## Schema Sync Behavior Schema sync manages the lifecycle of managed and materialized views: - **Creation** — managed views are created as `CREATE VIEW` statements during sync - **Updates** — views are dropped and recreated when their definition changes (there is no `ALTER VIEW`) - **Renames** — track view renames with `@db.view.renamed` so sync drops the view under its old name and creates it under the new one (instead of leaving the old view behind): ```atscript @db.view 'premium_users' @db.view.renamed 'vip_users' @db.view.for User @db.view.filter `User.status = 'active'` export interface PremiumUsers { id: User.id name: User.name } ``` - **External views** — ignored by sync entirely For full details on the sync process, see [Schema Sync](/sync/). ## Next Steps - [Defining Views](./) — annotation reference for building views - [Aggregation Annotations](./aggregations) — computing sums, averages, and counts in views - [Querying Views](./querying-views) — read-only API for accessing view data - [Schema Sync](/sync/) — how sync manages the full lifecycle of views and tables