Schema

Column behavior and write types

Separate selected values from insert and update inputs, then record the database rules that make fields optional or generated.

Give each operation its own type

Every column has a selected output type. It can also describe what inserts and updates accept:

Option Selected output Insert input Update input
nullable: true T or null accepts T or null accepts T or null
hasDefault: true unchanged key is optional unchanged
generated: true unchanged key is omitted key is omitted

Use column<Output, Insert, Update>() when the driver returns a different type from the type the application writes:

import { column, integer, table, text } from "qubu"

const accounts = table("accounts", {
  id: integer({ generated: true }),
  email: text(),
  nickname: text({ nullable: true, hasDefault: true }),
  externalScore: column<number, string, number>({ nullable: true }),
})

The selected externalScore is number | null. Inserts accept string | null, and updates accept number | null.

Describe defaults and generated columns

The legacy hasDefault and generated flags describe the write contract. Use complete metadata when schema tooling also needs the database fact:

import { boolean, generatedColumn, identityColumn, integer, table, text, value } from "qubu"
import { defineSchemaExpression } from "qubu/schema"

const currentTimestamp = defineSchemaExpression("function", (context) => {
  context.append("CURRENT_TIMESTAMP")
})

const accounts = table("accounts", {
  id: integer({ identity: identityColumn("always") }),
  status: text({ default: "pending" }),
  active: boolean({ default: true }),
  score: integer({
    generatedColumn: generatedColumn(value(1), "stored"),
  }),
  createdAt: text({
    default: currentTimestamp,
  }),
})

Primitive values in default are canonical literals. Strings are never interpreted as SQL, and booleans remain semantic values so each dialect can choose its own spelling. Pass a branded deterministic schema expression directly when the default is SQL, and use unsafeSchemaSql() only for trusted syntax Qubu does not model. Generated expressions record stored or virtual mode. An identity descriptor stays separate because identity behavior is not an ordinary generated expression.

Complete defaults cannot be combined with generated or identity metadata. Contradictory flags fail with a structured ColumnBehaviorError. Use externalDefault() or externalGeneratedColumn() when another schema authority owns the missing detail.

Use defaultFn when Qubu should supply an omitted insert value at runtime:

const sessions = table("sessions", {
  token: text({ defaultFn: () => crypto.randomUUID() }),
})

Runtime defaults make the insert key optional and run once for each omitted row value. They remain live column behavior: snapshots and emitted DDL do not record a database default. A column may declare both default and defaultFn; Qubu writes use the runtime value while the database default remains available to other clients.

Dialect-owned identity details stay on the identity descriptor. SQLite's autoIncrement requires an exact INTEGER rowid alias that is the sole column of a primary key. MySQL's AUTO_INCREMENT is a column-level identity extension, and MySQL's ON UPDATE clause accepts a branded deterministic expression. The database-specific restrictions are listed in the snapshot overview and its dialect matrices.

Narrow an application type

Use $type<T>() to narrow a helper's TypeScript type without changing its runtime column definition:

import { table, text } from "qubu"

const users = table("users", {
  status: text().$type<"active" | "disabled">(),
})

The narrowed type applies to selected values and insert and update inputs. It does not validate values at runtime or add a database constraint.

Derive write input types

TableInsertInput and TableUpdateInput expose the same rules to application code. The following example uses the accounts table from the earlier example:

import type { TableInsertInput, TableUpdateInput } from "qubu"

type AccountInsert = TableInsertInput<typeof accounts.definitions>
type AccountUpdate = TableUpdateInput<typeof accounts.definitions>

const insert: AccountInsert = {
  email: "ada@example.com",
  externalScore: "10",
}

const update: AccountUpdate = {
  nickname: null,
  externalScore: 10,
}

id is not accepted because it is generated. nickname is optional on insert because the database supplies a default, but it remains a valid nullable update field.

Continue with Write mutations for typed INSERT, UPDATE, and DELETE statements.