Structured collections
By default a collection is schema-free: every document is stored as JSON in a single data
column. Declare a schema and a collection becomes structured — the listed fields are
promoted to real, typed SQL columns (fast, indexable, joinable, constrainable) while any
undeclared fields overflow into the JSON data column. The CRUD and query API is identical
in both modes; structured mode only changes how the chosen fields are stored on disk.
const users = db.collection("users", {
schema: {
email: { type: "TEXT", unique: true },
age: { type: "INTEGER", index: true },
tenantId: { type: "TEXT", references: "tenants(_id)" },
role: "TEXT", // shorthand for { type: "TEXT" }
},
});
await users.create({ data: { email: "a@x.com", age: 30, role: "admin", bio: "..." } });
// email/age/role/tenantId -> native columns; `bio` -> JSON overflow.
- A collection's mode is fixed on first access. Reopening the same collection with a different schema/mode throws — the storage layout can't change underneath existing data.
- Declared fields become real columns (with optional indexes, uniqueness, defaults, and foreign keys). Undeclared fields still work transparently — they live in the JSON overflow.
- An explicit
nullin a declared column round-trips asnull(SQL columns always exist). - Introspect with
collection.mode("document"|"structured"),collection.columnNames, ordb.$schema(name)(physicalPRAGMA table_info).
Declaring columns
The schema maps a field name to either a ColumnType string or a full ColumnDef.
type ColumnType = "TEXT" | "INTEGER" | "REAL" | "BLOB" | "JSON";
interface ColumnDef {
type: ColumnType;
index?: boolean; // create a secondary index on the column
unique?: boolean; // enforce uniqueness
notNull?: boolean; // reject NULLs
default?: string | number | null; // literal default for omitted fields
references?: string; // foreign key target, e.g. "users(_id)" or "users"
}
Type notes:
TEXT,INTEGER,REAL,BLOBmap to SQLite column affinities.BLOBstores binary (Buffer/Uint8Array);Datevalues are accepted and bound natively.JSONis stored asTEXTbut is the column type to use when a declared field holds a structured value (object or array): it is serialized on write and parsed back on read. A non-JSON column rejects objects/arrays with a typed error — declare itJSONinstead.INTEGERcolumns reject JS numbers above the safe-integer range (2^53); pass aBigIntfor an exact large value, or use aTEXTcolumn for large ids — this prevents silent precision loss.- A column
defaultis applied for omitted fields and reflected in the returned document (it binds the default, not an explicitNULL, so it cooperates withnotNull). referencesadds a SQL foreign key (monlite enablesPRAGMA foreign_keyson open).
const events = db.collection("events", {
schema: {
type: { type: "TEXT", notNull: true, index: true },
payload: { type: "JSON" }, // arbitrary object, queryable
weight: { type: "REAL", default: 1 },
blob: { type: "BLOB" },
},
});
When to use structured vs document mode
Document mode is the default and the right choice for most collections — schema-free, no migrations, and JSON paths are still fully queryable (with auto-indexing of hot paths). Reach for structured columns when a field is on a hot path and you want native SQL behavior for it:
- Indexed lookups / range scans on a field that's queried or sorted constantly.
- Uniqueness enforced at the storage layer (a single column or a compound unique index).
- Foreign keys referencing another collection.
- Joins —
lookup/ aggregation over a real column avoids JSON extraction per row. - Typed defaults / NOT NULL constraints you want the database to enforce.
The trade-off: a native column is faster to filter, sort, index, and join on than a JSON path, but the collection's column set is fixed up front and schema changes need a migration (additive changes are automatic; see below). Fields you don't promote still work — they just live in the JSON overflow with the usual JSON-path query performance. You don't have to declare everything: declare the few fields that need column behavior and let the rest overflow.
Compound unique indexes & TTL
uniqueIndexes and ttl are collection options independent of the schema — they work on
document collections too (fields may be declared columns or JSON paths).
const jobs = db.collection("jobs", {
// compound unique — the idempotency / dedupe primitive
uniqueIndexes: [["tenantId", "jobId", "idempotencyKey"]],
// TTL — cap unbounded-growth tables
ttl: { field: "created_at", seconds: 90 * 24 * 60 * 60 },
});
await jobs.purgeExpired(); // delete rows whose ttl field is older than `seconds`
- A duplicate on a unique index throws
MonliteUniqueConstraintError. ttl.fieldis a numeric timestamp (epoch ms) — typicallycreated_at.purgeExpired()deletes rows older thansecondsand returns{ count }; call it periodically (e.g. from a cron tick). Missing/NULLtimestamps are never purged.
Migrations
New declared columns are added automatically and additively on next access via
ALTER TABLE ADD COLUMN — adding a field to the schema needs no manual step (a notNull column
added to a non-empty table needs a default, or the migration errors loudly).
For destructive changes — dropping a column, renaming one, or changing a column's
type/constraints — use $migrate() (structured collections only). It performs a safe,
transactional table rebuild that preserves data:
const users = db.collection("users", { schema: { name: "TEXT", age: "INTEGER" } });
// Map an existing physical column to a new declared name, and acknowledge a removed one.
await users.$migrate({ rename: { fullname: "name" }, drop: ["legacy"] });
A physical column that is neither kept, renamed, nor listed in drop is an unacknowledged
drop and throws — so data is never silently lost. See the
migrations guide for the full workflow.
Engine support
Structured collections are a SQLite-engine feature. On the
@monlite/postgres engine, collections are document (JSONB) only —
declaring a schema there raises a "structured collections are not yet supported on the postgres
engine" error. Document-mode queries, indexes on JSON paths, aggregation, and realtime all work
on Postgres.