omg/docs
SDK

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):

MethodEffect
.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:

ColumnTypeNotes
idTEXT PRIMARY KEYServer-assigned on create. Don't declare it in fields.
created_atTEXTISO timestamp. Server-set.
updated_atTEXTISO timestamp. Server-bumps on update.
_ownerTEXTOnly 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:

OutputSource
.vibes/data.dbschemaToSQL(schema) executed against an empty SQLite file.
src/db.generated.tsschemaToTypes(schema) — TS interfaces for every collection.
src/db.drizzle.tsDrizzle 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

RuleWhy
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.