Collections
Collections
A collection is one domain table plus its server behavior. Each collection is one directory at src/collections/<lower_snake_case_id>/ . The directory name is the collection ID; the model does not repeat it.
The model
import { defineModel, enums, text } from '@norbital-ai/bolt/authoring';
export default defineModel(
{
name: text().notNull(),
status: enums(['active', 'complete'])
},
{ description: 'Project site', recordLabel: 'name', icon: 'lucide:map-pin' }
); A model holds storage and data identity only: the columns, plus description , recordLabel , icon , indexes , history , exclusions , and embedding . Applications own presentation, so enum colors, default sorting, and renderer variants have no place in a model. Use enums([...]) for a closed value set, and point recordLabel at the column — or the array of columns — that names a record on screen. Save the declaration as src/collections/sites/+model.ts .
Column types
A field is a column. Bolt re-exports the base builders ( text , integer , boolean , uuid ) and adds domain column types with typed storage and matching UI behavior:
| Column | Stored as | Notes |
|---|---|---|
text() | text | Base string field |
numeric() | numeric | Read as a JS number. numeric() takes no options — how a number is displayed belongs to the application, not to the column. Use integer for whole numbers, or text when a value only looks numeric, such as a reference code. |
instant({ precision }) | timestamptz | An absolute instant at full precision ( UTC ISO ); `precision` only narrows the picker |
custom('instant_range', { multiple, precision }) | jsonb | A contiguous span of instants ( { start, end } ); `end` may be null (open span). Use multiple: true |
custom('money', { allowedCurrencies }) | jsonb | A monetary amount with its ISO 4217 currency code — a platform-owned datatype; redeclaring it is a compile error |
vector({ dimensions }) | vector | An embedding vector; dimensions are pinned at declaration time |
geolocation() | jsonb | GeoJSON-style point — { geometry: { lon, lat }, formatted_address, ... } . |
phone() | text | Telephone number with phone-specific editing semantics |
enums([...]) | text | Closed value set; values are validated at the boundary. Use .array() |
file({ mimeTypes, multiple }) | jsonb | Served entirely inline as a FileRef — its storage_key, file_name, file_size, and mime_type ride the row. Optional mimeTypes filter; use multiple: true |
custom(kind) | per custom type | Named custom values defined in src/datatypes/<name>/ — see the UI components |
Columns support the standard modifiers: .notNull() , .default(...) , .array() , and sql templates for generated defaults. Every row also carries the platform columns id , created_at , updated_at , and row_version automatically — you never declare them.
One model that puts several of them together:
import {
custom, enums, file, geolocation, instant,
numeric, phone, text, vector
} from '@norbital-ai/bolt/authoring';
export default defineModel(
{
title: text().notNull(),
status: enums(['active', 'complete']).notNull().default('active'),
progress: numeric(),
starts_on: instant({ precision: 'day' }),
window: custom('instant_range'),
budget: custom('money'),
embedding: vector({ dimensions: 1536 }),
location: geolocation(),
contact: phone(),
report: file({ mimeTypes: ['application/pdf'] })
},
{ description: 'Project site', recordLabel: 'title' }
); Relationships
The single src/collections/+relationship.ts role defines relationships for the full registry and uses its adjacent generated type:
import type { Relationships } from './$types.js';
export default ((r) => ({
sites: { site_visits: r.many.site_visits() },
site_visits: {
site: r.one.sites({ from: r.site_visits.site_id, to: r.sites.id })
}
})) satisfies Relationships; Companion roles
Server behavior lives beside the model in recognized role files. Each role default-exports one declaration and uses the adjacent generated ./$types.js ; no registration file is required.
+collection.ts— the write contract: input selections, transform, delete, notifications ( Write contract )+pipelines.ts— canonical collection import/export behavior ( Pipelines )+integrations.ts— external receive/send bindings that reuse pipelines ( Integrations )+representation.svelte— the create/display/edit form every user-facing collection must author ( UI components )
System collections
Every workspace ships with a fixed set of system collections defined in @norbital-ai/bolt . They are merged into your manifest at build time and power identity, access control, approvals, and files. You do not redefine them in tenant collections/ — you query them like any other collection.
withSystemCollections . Do not copy or override collections like user or approval_request in a tenant +model.ts — the platform depends on their exact shape. You can read and query them from apps, transforms, automations, and remote functions like any other collection.Identity & access
- user — one row per person: name, email, the admin flag (normal or admin), and the team they belong to. Who a team may DO is
src/access/+teams.ts— a `src/access/+teams.ts` map compiled into the release, never a row. - session, account, verification, auth_config — sign-in sessions, linked credentials, verification tokens, and the secret that signs sessions. The runtime is their only writer.
- Policies are not rows. A policy is a
src/access/policies/+<name>.tsmodule in workspace source, compiled into the manifest alongside the collections it grants.
These are read-only from a workspace: the runtime’s system-collections policy grants read to every authenticated subject, and never write, so the runtime that owns a table stays its only writer. user is narrowed further — a query sees only id and the name, never the address. See Policies for how grants work on domain collections.
Review
- approval_request — one open or closed approval flow over a collection mutation: which record it holds, its steps, and its status. See Approval workflows.
- requestor — links an approval request to the user who raised it.
Files
- file() — there is no platform file table. A `file()` column stores metadata inline (
FileRef{ storage_key, file_name, file_size, mime_type }) and the host’s files facility resolves the bytes by `storage_key`. The column is a field of its record, so row predicates and field masks apply to it like anything else.
Runtime tables that are not collections
The runtime also provisions platform collections and internal tables. Some are published as collections you can query; others are internal bookkeeping the schema plan creates:
bolt_collection_history— one row per create, update, or delete on a collection that keeps history, which every collection does unless its model turns it off: the operation, the subject who made it, and the snapshot. History is pruned to the most recent 256 revisions per record.bolt_audit— the platform ledger of approval events, keyed by event kind and subject.conversationandconversation_message— the conversation aggregate: a durable conversation, its ordered messages, and the fenced turns and metered usage behind them. plan and automation_run are published beside them.telemetry— one row per turn, model call, tool call, write, and failed invocation, in OpenTelemetry-log shape with severity, event, attributes, and join ids. The runtime keeps records for every tenant and prunes them to the host retention window; only an administrator may read them.bolt_notifications— in-app notification rows, written and read by the notifications facility.
vs domain collections
Domain collections are yours: payroll runs, shipments, work orders, and so on. System collections are the platform substrate every tenant shares — the runtime owns their shape, and a workspace reads them rather than writing them.
Search and similarity
Every collection has platform search commands: /text over fields opted in with search, /semantic over the platform-maintained embedding, and one /<index> command per declared similarity index. The browser sends an index and a capture-form target — never a raw vector.
Text search is opt-in per field ( text({ search: true }) ); semantic search exists where the model metadata declares embedding over named fields, which the platform embeds on every write:
import { defineModel, text } from '@norbital-ai/bolt/authoring';
export default defineModel(
{
name: text({ search: true }).notNull(),
notes: text({ search: true })
},
{
description: 'Product',
recordLabel: 'name',
// The platform maintains a record_embedding vector over the named fields on every
// write, so /semantic searches the collection without any server code.
embedding: { fields: ['name', 'notes'], dimensions: 1536 }
}
); A domain with its own notion of "nearest" declares it beside the write contract; similarity becomes the collection’s /<name> command:
import { defineCollection } from '@norbital-ai/bolt/authoring';
import model from './+model.js';
export default defineCollection({
model,
// A declared index becomes the /<name> search command in every table toolbar.
similarity: {
by_colour: {
label: 'Closest colour',
column: 'lab_vector',
metric: 'l2',
input: {
l: { kind: 'number', label: 'L*', min: 0, max: 100, step: 0.1 },
a: { kind: 'number', label: 'a*' },
b: { kind: 'number', label: 'b*' },
line: { kind: 'reference', collection: 'lines', label: 'Line' }
},
// Fills the vector column from the row on every write.
embed: (row) => ({ lab_vector: [row.lab_l, row.lab_a, row.lab_b] }),
target: (input) => ({
probe: [Number(input['l']), Number(input['a']), Number(input['b'])],
// A reference control narrows by equality instead of re-measuring.
where: { line_id: String(input['line']) }
})
}
}
}); Server code reads nearest directly with api.db.<collection>.findNearest({ column, probe, metric?, where? }), answered by the database’s vector index; a declared similarity search is one-shot, never a live prefix.
How collection data is read
In tenant apps, collection data is read through the live data layer: client.db.<collection>.findMany , findFirst , and count and findGrouped are one-shot aggregate reads; only findMany and findFirst with a contiguous limit are live prefixes. Transforms and functions on the server still use api.db — live queries and optimistic mutations are the browser read and write path for operational UIs.
Server reads also include findNearest: a vector-indexed nearest-neighbour read over a vector() column — column plus probe, optional metric and where — answered by the tenant database’s index, so rows arrive closest-first and each carries its measured distance. It is guarded by the collection’s policies exactly like any other read; the Search section covers declared indexes and the platform commands.