Constraints

Unique, non-null and foreign-key integrity declared on the schema and enforced at write time.

Last updated:

A constraint is an invariant your data must hold: a column is unique, a column is never null, or a foreign key always points at a row that exists. Lunora constraints are declared on the schema, on the column validator or the relation, and most are enforced at write time by the storage layer, so a violating insert / patch / delete throws rather than committing.

There is no separate constraint DSL. A constraint is just a modifier on a column (.unique(), the default-on NOT NULL) or an option on a relation (onDelete). Codegen and the runtime read these off the same table definition.

// lunora/schema.ts
import { defineSchema, defineTable, v } from "lunorash/server";

export default defineSchema({
    users: defineTable({
        email: v.string().unique(), // UNIQUE
        name: v.string(), // NOT NULL (the default)
        bio: v.string().nullable(), // nullable — opt out of NOT NULL
    })
        .global()
        .index("by_email", ["email"], { unique: true }),

    posts: defineTable({
        authorId: v.id("users"),
        title: v.string(),
    }).relations((r) => ({
        author: r.one("users", { field: "authorId", onDelete: "cascade" }),
    })),
});

Constraint kinds

KindDeclared withEnforcedOn violation
Unique (column)v.string().unique()Write time — synthesized UNIQUE indexConflictError (CONFLICT, 409)
Unique (multi-field).index(name, [..], { unique: true })Write time — UNIQUE indexConflictError (CONFLICT, 409)
Non-null (NOT NULL)default on every column; opt out with .nullable()Write time — column validator parseValidationError
Foreign-key deleter.one(table, { field, onDelete })Delete time on the referenced (parent) rowConflictError (CONFLICT, 409) for restrict

The advisor constraint_validator lint is a complementary check: it samples existing rows and flags ones that already violate a declared constraint (data inserted before the constraint existed: a migration, a raw import, a past bug). Write-time enforcement guards new writes; the advisor catches the backlog.

Unique

.unique() on a column synthesizes a single-column UNIQUE index named <table>_unique_<field>, so SQLite enforces uniqueness at insert/patch time. For a multi-column uniqueness rule, declare a unique secondary index instead:

defineTable({
    workspaceId: v.id("workspaces"),
    slug: v.string(),
}).index("by_slug", ["workspaceId", "slug"], { unique: true });

When a write would create a duplicate, the storage layer maps the engine's UNIQUE constraint failed error to a ConflictError (code CONFLICT, HTTP status 409), so callers can refetch and retry or surface the conflict.

.unique() (the column constraint) is distinct from the .unique() query helper, which asserts a read matched at most one row and throws a NotUniqueError (code NOT_UNIQUE) when more than one matches.

Non-null

Every column is NOT NULL by default: notNull is true on a fresh column validator. Calling .nullable() widens the read type to T | null and flips notNull to false, allowing SQL NULL. A write that supplies null for a non-nullable column fails the column validator's parse, raising a ValidationError before the row reaches SQL.

defineTable({
    title: v.string(), // required, non-null
    archivedAt: v.number().nullable(), // may be null
    status: v.string().default("todo"), // optional on insert; never null once stored
});

.default(value) / .$defaultFn(fn) make a column optional on insert (the write layer fills it) but it is still NOT NULL once stored. "Optional on insert" and "nullable" are independent. Use .nullable() only when NULL is a value you actually want to read back.

Foreign-key integrity

A one relation declares a many-to-one foreign key: the FK field lives on the holding table and points at the target table's references column (default _id). Pass onDelete to choose what happens to holder rows when the referenced (parent) row is deleted:

onDelete valueBehavior when the referenced row is deleted
"cascade"Each holder row is recursively deleted (chains through its relations).
"set null"The holder's FK column is patched to NULL.
"restrict"The delete is aborted with a ConflictError if any holder exists.
defineTable({
    authorId: v.id("users"),
    body: v.string(),
}).relations((r) => ({
    // deleting a user deletes their posts
    author: r.one("users", { field: "authorId", onDelete: "cascade" }),
}));

onDelete actions resolve before the physical delete commits, so a restrict aborts cleanly and cascaded child deletes still fire their own before/after triggers and broadcasts per row. There is no insert-time FK enforcement: an insert does not verify that the referenced row exists, and a non-null FK pointing at a missing row only surfaces via the advisor constraint_validator lint (which reports the dangling reference as a sampled finding).

Cross-backend caveat. When a cascade crosses storage backends (a sharded Durable Object holder of a .global() D1 parent, or vice-versa) the cascade is not transactional: the global side fires before the local delete commits. If the local delete then fails, holders may be gone while the parent remains. Add your own reconciliation (a periodic job or a queue retry) where this matters.

Error codes

ErrorcodeStatusThrown when
ConflictErrorCONFLICT409A UNIQUE index breach, an onDelete: "restrict" abort, or an OCC conflict.
NotUniqueErrorNOT_UNIQUEThe .unique() query helper matched more than one row.
ValidationErrorA non-nullable column received null/undefined, or a .check() refinement failed.

ConflictError is recognized structurally across packages (it declares code and status as own properties), so the runtime maps it to a 409 without an instanceof check.

See also

  • Schema — declaring tables, validators, indexes and relations
  • Advisors — the constraint_validator lint that samples existing rows for violations
  • Row-level security — server-side authorization over which rows a procedure may read or write