Extending the schema
Add tables and indexes of your own with a dialect-neutral DSL, ordered hooks, and a per-dialect escape hatch that refuses what migrations cannot carry.
Extending the schema
Your collections and singles become tables automatically. This page is about the rest: a join table, a cache, an index the schema pipeline would not have thought to create.
Three ways in
| You want | Use |
|---|---|
| A field on an existing entity, with REST, admin and validation | contributes.extend (plugins) or the collection's own fields |
| A table of your own, portable across all three dialects | db.schema.extend with the DSL |
| A column or type only one dialect has | db.schema.afterDrizzle |
A table of your own
import { defineConfig } from "nextly";
import { col, defineTable } from "nextly/schema-extension";
const auditTrail = defineTable(
"audit_trail",
{
id: col.id(),
actorId: col.ref("users"),
action: col.shortText(),
payload: col.json<{ before?: unknown; after?: unknown }>({
nullable: true,
}),
...col.timestamps(),
},
{ indexes: [{ columns: ["actorId", "createdAt"] }] }
);
export default defineConfig({
db: {
schema: {
extend: [
({ schema }) => {
schema.addTable(auditTrail);
},
],
},
},
});App tables carry no prefix, and they go into your migrations like any other table.
col.id() has no database default: the code that inserts a row supplies the
id (any UUID fits; a UUIDv7 keeps new rows in key order), and InferInsert
requires it. col.timestamps() defaults both columns to the current time on
insert, but nothing refreshes updated_at afterwards, so set it in every
update.
A table can also declare checks, foreign keys, and expression indexes. They ride the same declaration, the same migrations, and (for checks and bundle-internal foreign keys) the same table rebuild on SQLite:
const orders = defineTable(
"orders",
{
id: col.id(),
userId: col.ref("users"),
status: col.shortText(),
total: col.integer({ nullable: true }),
},
{
checks: [{ name: "total_ok", sql: "total IS NULL OR total >= 0" }],
foreignKeys: [
{
columns: ["userId"],
references: { table: "users", columns: ["id"] },
onDelete: "cascade",
},
],
indexes: [{ columns: ["status"], name: "idx_orders_status" }],
}
);A foreign key's target must exist when the migration runs; within your own
bundle, creation order follows declaration. onDelete and onUpdate take
"cascade", "set null", "restrict" or "no action" (the default).
"set default" is refused, because MySQL cannot create a key that uses it.
"set null", on either side, requires every one of the key's columns to be
nullable: MySQL refuses to create the key otherwise, and PostgreSQL and SQLite
would fail the delete or update that fires it, so a key declaring it over a
column that is not nullable is refused.
A check's sql and an index's expression reach every dialect as written, so
name columns bare (qty > 0, lower(email)), which all three read as the
column. An identifier quoted with " or a backtick is refused: MySQL reads
"qty" as a string, so "qty" > 0 would compare a constant, and PostgreSQL
refuses a backtick. Quotes inside a single-quoted string literal are text and
are accepted.
Partial indexes (an index with a where predicate) are refused, on every
dialect. Every declaration is checked against PostgreSQL, MySQL and SQLite
whichever one you run, so a table that works for you works on every
deployment. MySQL has no partial indexes, and creating the index without its
predicate would enforce it over rows you did not intend, so an index carrying
where fails when the table is registered.
An explicit index or foreign key name is used exactly as written, so it may
be at most 63 characters, and so may a column's SQL name (the snake-cased key:
ownerAccountId is owner_account_id): PostgreSQL cuts a longer name short
and MySQL refuses one past 64. Derived names (and every check name, which is
ck_<table>_<name>) are shortened with a hash when they would be longer. A
foreign key's name, written or derived, must also be unique across the
tables plugins and the app declare, as MySQL requires of a database, and so
must a check's, including the one an enum column implies. An index's must be
too, as PostgreSQL and SQLite require of a schema. Two that resolve to one
name are refused when the schema compiles. Table and check names can meet in
the middle: a check a_ok on t and a check ok on t_a both resolve to
ck_t_a_ok, so rename one of them. The same index
contributed to one table twice, by two plugins or a plugin and the app, is one
index and is kept once. A foreign key's
references.columns are the target's SQL column names (owner_id, not the
authored ownerId), and a column the target does not have is refused. So is a
key column whose type cannot reference its target's on every dialect: an
integer on a text column or a uuid on a text one (PostgreSQL has no equality
between them), integers or floats of different widths (bigint on integer,
double on real), or decimals of a different precision or scale (MySQL
requires both to match), and a long text, JSON or
bytes column in a key at all (MySQL cannot index one). Text columns of
different lengths may reference each other, and a collection's relationship
column is the text id it holds. When the target is a table a plugin or the
app declares, the referenced columns must be its primary key or a unique
index over exactly those columns, in the index's order: PostgreSQL refuses
any other target, and MySQL matches the columns in order. A core table's
columns, a collection's, and an adopted table's are left to the database to
judge, since their keys are not all in a declaration.
A default must be one every dialect stores as written. Its type must be the
column's: a string for a text or uuid column, a number for a numeric one, a
boolean for col.boolean(), and only the current-timestamp token (what
col.timestamps() sets) for a timestamp. A uuid default must be a UUID in
lower-case hex, 0190c4c2-7f3a-7b9e-8d1a-3c5e7f9a1b2c. A text default may be
no wider than its column on MySQL: 255 characters for col.text() and
col.shortText(), n for col.varchar(n) and col.char(n), unbounded for
col.longText(); and no string default may hold a NUL character. A numeric
default must be a finite number. An integer column's must be a whole number
within the column's range (smallint up to 32,767, integer up to
2,147,483,647, bigint within ±2^53 − 1, the integers a JavaScript number
holds exactly); a real default must be within a 4-byte float's range; and
a decimal's default must fit its column: no more fractional digits than its
scale, and no more digits before the point than its precision leaves room for
(col.decimal(3, 1) holds up to 99.9). JSON, bytes, enum and serial
columns take no default.
A table's columns must fit in one MySQL row: 65,535 bytes, where a
varchar(n) counts up to 4 bytes a character plus its length, and a
longText or json column counts only a few bytes because its contents are
stored apart. col.varchar(n) accepts at most 16,383 characters, the widest
MySQL can declare, and a table whose columns together do not fit is refused
when the schema compiles, on every dialect. An adopted table describing a
wider existing column declares it with col.longText(), which reads and
writes the same strings.
Adopt an existing table
db: {
schema: {
extend: [
({ schema }) => {
schema.adoptTable(defineTable("legacy_orders", {
id: col.id(),
}));
},
],
},
}adoptTable claims a table Nextly does not own: the table is registered for
typed Drizzle access and relation queries, and nothing else — no DDL, no
diff, no migration, never dropped. The name is used as written, and adopting a managed
table's name is refused.
Relations
Tables declared with ref() columns carry a one-edge automatically, named for
what it points at: ownerId: col.ref("app__owners") is read with
with: { owner: true }, while the row keeps ownerId as the column. A key
without an Id suffix gets <key>Ref. A relation and a column share the row's
keys, so a declared relation may not reuse a column's name, and each relation
on a table needs a name of its own. Any table can declare more relations:
const linked = defineTable("linked", {
id: col.id(),
orderId: col.shortText(),
}, {
relations: [{ name: "order", kind: "one", targetTable: "app__orders", fromColumn: "orderId" }],
});A one edge joins its fromColumn to the target's toColumn (its id when
omitted). A many edge is the reverse — the target holds the column pointing
back — so it names that toColumn, and joins it to its own fromColumn or,
when that is omitted, its primary key. A toColumn may be written as the SQL
column or the key, and must name a column of a table in this schema or of a
core table; a relation to a collection or single joins its id and names no
toColumn.
Edges register with the schema registry, so db.query.<table>.findMany({ with })
follows them between tables you may reach. An include and a relation filter
in where obey the same rule as db.query.<table> itself, at every depth: the
app reaches its own tables, and a plugin its own and those of the plugins in
its dependsOn. An edge to a core table or a collection — the one
col.ref("users") carries included — is declared and typed, but following it
is refused like db.query.users is; read those rows through the
Direct API (or ctx.services in a plugin),
where access control, validation and hooks apply, and join on the id the
column holds.
Indexes on your collections
indexes on a collection config now reaches the database. If you declared one
before and never saw it, the next dev push or migrate:create will add it.
defineCollection({
slug: "places",
fields: [text({ name: "country" }), text({ name: "city" })],
indexes: [{ fields: ["country", "city"], unique: true }],
});A unique index over columns that already contain duplicate rows will fail to build. Find them first:
SELECT country, city, COUNT(*)
FROM dc_places
GROUP BY country, city
HAVING COUNT(*) > 1;Column order matters. Only (country, city) serves a lookup on country
alone.
The escape hatch
When you need something the neutral model has no word for — a bigint, a
char(3), a type one dialect has and the others do not — reshape the Drizzle
table directly:
db: {
schema: {
afterDrizzle: [
({ dialect, tables }) => {
if (dialect !== "postgresql") return tables;
return {
...tables,
app_ledger: pgTable("app_ledger", {
id: varchar("id", { length: 36 }).primaryKey(),
amountMinor: bigint("amount_minor", { mode: "bigint" }),
}),
};
},
],
},
}This is the app's alone; plugins have no equivalent, because a per-dialect choice about your database is yours rather than a plugin's.
What a hook may return, and what happens to it:
- A table it reshapes keeps everything its declaration says that the hook
did not change. A Drizzle table has to be rebuilt to change one column, and
the rebuilt one rarely restates the indexes, checks, foreign keys and serial
keys the declaration carries, so those are kept rather than dropped. Only
column changes and newly declared indexes are taken from the hook's table.
Removing a column also removes what exists only because of it: the
col.enum()check on it, and the foreign keys and indexes that name it. Removing a column that a declaredchecksentry names, or that a relation joins on, is refused at boot, naming the check or the relation. - A table it introduces belongs to the app and meets the naming rule every
app table meets: no managed prefix (
dc_,single_,comp_,nextly_), no_localessuffix, no core table's name and nothing inside a plugin's namespace. - Relations follow the table the hook returns. Keying the rebuilt table's columns by SQL name rather than by the declared keys is fine.
- A core, entity, adopted or plugin table is refused.
An extend hook sees the core tables, plugin and app tables, and the
collections, Singles and field groups declared in code; an entity created in
the admin's Schema Builder is not known when it runs, so it cannot target that
table. An afterDrizzle hook is narrower: the tables it receives are the
plugin and app tables only, and it may return only app tables.
Anything the schema model cannot carry into migrations and drift detection is refused at boot, naming the table and the construct — because the alternative is a constraint that exists on your machine and on no deployment, with nothing reporting the difference. Checks, foreign keys and expression indexes all carry through the DSL above; the escape hatch refuses them because the DSL is the one path that keeps them in migrations and drift.
Coming from Payload
| Payload | Nextly |
|---|---|
beforeSchemaInit | db.schema.extend, which also runs for plugins |
afterSchemaInit | db.schema.afterDrizzle, with loss checking |
extendTable | schema.extendTable, with ownership rules |
rawTables | defineTable + col.*, the primary path rather than an escape |
Collection indexes | Collection indexes, now actually emitted |
.references() / RawForeignKey | foreignKeys on defineTable |
check() | checks on defineTable |
defaultIDType | db.idType per collection (uuid or uuidv7) |
A client-supplied id on create | db.allowIdOnCreate per collection |
.where() partial index | not supported: refused on every dialect, because MySQL has none |
relations() | relations on defineTable + ref() auto-edges |
pgEnum | col.enum([...]) — stored as text (varchar(255) on MySQL), values enforced by a CHECK on every dialect |
schemaName | not yet — db.postgres.schema accepts only "public" |
generate:db-schema | not needed — types come from the declaration |
Two differences worth knowing:
Payload's hooks are app-only. Ours accept plugins too, in dependency order, with ownership recorded on every table, index, check and foreign key — so two plugins cannot quietly fight over one table.
Payload's extendTable reaches into Drizzle's internal symbols, and its hook
tables lose their types. Ours build Drizzle objects from the neutral model and
type them from the declaration (InferRow, InferInsert), with no codegen step.
The PostgreSQL schema
On PostgreSQL every managed table, index and constraint lives in public.
db.postgres.schema is reserved for choosing another schema, but only
"public" is supported today:
import { defineConfig } from "nextly";
export default defineConfig({
// The only accepted value for now; leaving it out means the same thing.
db: { postgres: { schema: "public" } },
});Any other value is refused when the server boots and by every nextly
command, with an error naming the value. The schema push cannot yet create
tables outside public, so an installation pointed at another schema would
start with none of its core tables. Remove the option, or set it to
"public". A database adapter you construct yourself must not set a schema of
its own either; one that disagrees with the config is refused at boot.
MySQL and SQLite have no equivalent (MySQL's schema IS its database, SQLite is one file). They ignore the setting and warn, so a config shared across dialects does not have to branch.
Generated integer keys
col.serial() IS the table's key — it replaces col.id() rather than
joining it. No dialect assigns a value to a serial column that is not a key:
MySQL refuses AUTO_INCREMENT on one, and SQLite assigns only to an
INTEGER PRIMARY KEY. Declaring both is refused, naming the two columns.
defineTable("counters", {
seq: col.serial(),
label: col.shortText(),
});serial on PostgreSQL, int AUTO_INCREMENT on MySQL, and on SQLite a plain
INTEGER primary key, which is the rowid alias and auto-assigns. Never
nullable and never written — the database assigns it, so a value you supply is
one the sequence does not know about.
Only on a table its own owner declares. Added to somebody else's, it would be a second auto-assigning column on a row whose identity is already decided, and MySQL will not accept a second AUTO_INCREMENT column at all.
Enums
defineTable("orders", {
id: col.id(),
state: col.enum(["open", "closed"]),
});The column is text — varchar(255) on MySQL, so it can be indexed, which
also caps each value at 255 characters on every dialect — and the values are
enforced by a CHECK constraint, on all three dialects. That is a deliberate
trade: a native PostgreSQL enum is a
schema object with its own lifecycle — created before the table, altered by
ALTER TYPE ... ADD VALUE, dropped after its last column — while MySQL spells
it inline and SQLite has nothing. A CHECK is one mechanism this pipeline
already diffs, emits and introspects everywhere.
Adding a value is an ordinary constraint change. Removing one is refused by the database while a row still holds it, which is the correct answer: the rows would otherwise violate the constraint the moment it applied.
The CHECK is named ck_<table>_<column>_enum. col.enum(values, { name })
replaces the column part only: the constraint is ck_<table>_<name>, so the
same name can be given to enums on several tables (MySQL requires check names
to be unique across the whole database). It names the constraint, not a
PostgreSQL enum type.
Columns on a table you do not own
extendTable accepts columns as well as indexes, on your own tables, on
entity tables, on the extendable core tables, and — for the app, or for a
plugin naming the owner in dependsOn — on another owner's tables.
A column added this way is hidden from the entry API: it is on the table,
so migrations and SQLite rebuilds keep it, and it is stripped from every REST
response, Direct API read, version snapshot and webhook payload, while a value
sent for it on an entry write is dropped. Both halves matter — unhidden it
leaks into every response, and absent from the runtime table the next push
proposes dropping it. A read cannot probe its values either: a where naming
it is refused with FIELD_NOT_FILTERABLE (for a column on a component's
table, under the component field's path, such as seo.searchVector), a group
key naming it is refused, and a sort by it is ignored.
What is not supported
Views; native PostgreSQL enum TYPES (the values are a CHECK instead, see
above); partial (where) indexes, on any dialect; col.serial() on a table you do not own — a generated key belongs to
the table that declares it, and MySQL will not take a second AUTO_INCREMENT
column anyway; columns added to an extendable CORE table are not created by
dev push, because core tables are migration-owned and the column arrives with
the app's migrations (nextly migrate:create in the app writes it, whoever
contributed it); col.ref() alone records a reference for documentation and validation
— a constraint comes from foreignKeys. Resizing a varchar(n) or
decimal(p, s) produces no migration — the diff normalises type modifiers
away, and collections behave the same. Add a new column and migrate the data
across.
Database support and version requirements
Minimum supported database versions, cloud-provider compatibility, and the warn-and-proceed policy for MySQL-compatible variants.
Direct API
Server-side API for querying and mutating data directly from Next.js Server Components, server actions, and route handlers. No HTTP, no serialisation overhead.