Schema reference
defineSchema, collection, fields — the contract that decides static vs runtime deploy.
@omg-dev/schema is pure TypeScript (no runtime side effects). The build
imports it once, derives the deploy classification, the SQL DDL, the
generated TS types, and the Zod schemas, and bundles nothing of it into the
runtime — @omg-dev/server consumes the same Schema object directly.
The file location contract
schema.ts MUST live at the project root (not src/schema.ts) and MUST
be the default export. Both rules are load-bearing.
/home/user/project/
├── schema.ts ← here
├── package.json
├── src/
│ └── App.tsx
└── functions/Why root: packages/vite-plugin/src/build.ts does
await import(path.join(root, "schema.ts")) — it never looks elsewhere.
Why default-exported: the build reads module.default?.collections.
Anything elsewhere is invisible to the classifier, hasDb resolves false,
and the deploy goes static — then every POST /api/* returns the literal
text method not allowed and the SDK's JSON.parse throws. No flag or env
var overrides this: either schema.ts default-exports collections, or the
deploy is static.
Canonical shape
import { defineSchema, collection, fields } from "@omg-dev/schema"
export default defineSchema({
collections: {
tasks: collection({
fields: {
title: fields.string(),
done: fields.boolean(),
},
}),
},
})defineSchema accepts a record of CollectionBuilder | CollectionConfig
under collections. Every builder is .build()-ed in place, producing a
plain Schema object that can be JSON-serialized.
collection(opts)
Returns a CollectionBuilder. The opts shape:
collection({
fields: Record<string, FieldDef>
})Builder methods (chainable, all return this):
| Method | Effect |
|---|---|
.scoped("user") | Adds a _owner TEXT column. The runtime auto-fills it from the request's JWT and filters every list/get by _owner = currentUser. |
.scoped("global") | Default. No row-level filtering. |
.index(...columns) | Adds CREATE INDEX idx_<table>_<columns> ON <table> (<columns>). Call once per index; pass multiple columns for a composite. |
.unique(...columns) | Adds CREATE UNIQUE INDEX uidx_<table>_<columns> ON <table> (<columns>), enforcing one row per value or column tuple. |
Example:
posts: collection({
fields: {
title: fields.string(),
body: fields.string(),
tag: fields.string(),
},
})
.scoped("user")
.index("tag")
.index("tag", "created_at"),Auto-added columns
Every collection automatically gets:
| Column | Type | Notes |
|---|---|---|
id | TEXT PRIMARY KEY | Server-assigned on create. Don't declare it in fields. |
created_at | TEXT | ISO timestamp. Server-set. |
updated_at | TEXT | ISO timestamp. Server-bumps on update. |
_owner | TEXT | Only on .scoped("user") collections. |
Redeclaring any of these in fields is a contract violation; the migrator
will append a duplicate column and the runtime will silently misbehave.
fields builders
Each returns a FieldDef ({ type: FieldType, optional?: boolean }).
fields.string() // TEXT
fields.number() // REAL
fields.boolean() // INTEGER (0/1)
fields.date() // TEXT (ISO 8601 string)
fields.array("string") // TEXT (JSON-serialized)
fields.enum(["todo", "doing"]) // TEXT, runtime-validated against the literal set
fields.ref("users") // TEXT (foreign-key reference by collection name)Optional fields
There is no .optional() chain on the field builder; spread the result and
add the flag:
fields: {
title: fields.string(),
notes: { ...fields.string(), optional: true },
}Optional only affects the generated TS types and Zod schemas (?: and
.optional() respectively). The DDL emits the same column either way.
Refs
fields.ref("users") is a discipline marker, not a constraint — the column
is plain TEXT and the TS type is string. It signals intent and gives the
codegen a hook for future relation tooling; don't assume a FOREIGN KEY
exists at runtime.
Indexes
Indexes are declarative. Re-running the migrator on an existing DB diffs
against sqlite_master and only emits CREATE INDEX IF NOT EXISTS for
ones that don't already exist. Index names are deterministic:
idx_<table>_<col1>_<col2>. You can therefore add an index in a follow-up
deploy without affecting existing data — the next cold-start migrate will
backfill it.
Drop semantics: removing an index from schema.ts does not drop the
existing index on disk. Schema diff handles add/drop of tables and columns,
but only adds for indexes.
Worked example — multi-collection app
import { defineSchema, collection, fields } from "@omg-dev/schema"
export default defineSchema({
collections: {
users: collection({
fields: {
email: fields.string(),
name: fields.string(),
role: fields.enum(["admin", "member"]),
},
}).index("email"),
projects: collection({
fields: {
name: fields.string(),
ownerId: fields.ref("users"),
tags: fields.array("string"),
archived: { ...fields.boolean(), optional: true },
},
})
.scoped("user")
.index("ownerId"),
events: collection({
fields: {
projectId: fields.ref("projects"),
type: fields.enum(["create", "update", "delete"]),
at: fields.date(),
},
})
.index("projectId", "at"),
},
})Verifying before publish
Inside the sandbox:
bun -e "import('./schema.ts').then(m => console.log(Object.keys(m.default?.collections ?? {})))"Should print the collection names. If it prints [] or throws, the deploy
will classify static and every mutation will fail in production.
What the build emits
After vibes-build runs:
| Output | Source |
|---|---|
.vibes/data.db | schemaToSQL(schema) executed against an empty SQLite file. |
src/db.generated.ts | schemaToTypes(schema) — TS interfaces for every collection. |
src/db.drizzle.ts | Drizzle sqliteTable(...) definitions generated from the same schema.ts collections for server-side read/query helpers. |
.vibes/artifacts.json | { hasDb: collections.length > 0, ... }. |
The runtime never re-imports schema.ts; the bundled server.mjs carries
the pre-resolved Schema object. That means schema changes require a
redeploy, but cold-starts skip both migrate() (already ran at build time)
and import("./schema.ts") (already inlined).
Hard rules
| Rule | Why |
|---|---|
Schema lives at project root, not src/. | Build resolves from process.cwd(). |
| Default-exported. | Build reads module.default.collections. |
| One schema per project. | Build only imports one path; multiple files don't merge. |
Don't redeclare id, created_at, updated_at, _owner. | They are appended automatically. |
Don't hand-edit src/db.generated.ts, src/db.drizzle.ts, or .vibes/data.db. | They are regenerated every build. |