Core
Schema definition, a typed query builder, migrations, and the `DB` protocol drivers plug into.
@ozaco/db@ozaco/db is a small, Drizzle-backed database layer. You describe tables with the col.* builders and defineTable/defineSchema, then run fully typed queries through a QueryBuilder — from/insert/update/delete, plus transaction and escape-hatch raw SQL. Row and insert shapes are inferred from the schema, so where clauses and returned rows stay in sync with your columns.
The layer is transport-agnostic. DB is an std:plugin protocol whose context is the QueryBuilder and whose only action is close. The concrete drivers — @ozaco/db/impl/sqlite and @ozaco/db/impl/postgres — implement it; install one into an effect scope and useDatabase() hands back its query builder anywhere downstream.
Installing a driver also runs applyMigrations, an additive schema sync: it creates any missing tables and adds any missing columns from your SchemaDef (never dropping or altering existing ones) and records a content hash in _ozaco_migrations. The dialect-aware DDL builders behind it (createTableStatement, addColumnStatement, columnType, …) are exported for tooling and tests. Driver failures surface as tagged Result failures using the DbErrorCode tags.
@ozaco/db/impl/sqlite`SqliteDB` — SQLite driver over `better-sqlite3`/`bun:sqlite` (`file:` URLs).@ozaco/db/impl/postgres`PostgresDB` — Postgres driver with a connection pool (`max` connections).
Schema
Declare columns, tables and schemas as plain, inferable data.
col
Column-builder factories, one per supported `ColumnType`.
const col: {
int(): ColumnBuilder<number>
bigint(): ColumnBuilder<bigint>
text(): ColumnBuilder<string>
varchar(length: number): ColumnBuilder<string>
boolean(): ColumnBuilder<boolean>
timestamp(): ColumnBuilder<Date>
json<T = unknown>(): ColumnBuilder<T>
blob(): ColumnBuilder<Uint8Array>
}Each factory returns a ColumnBuilder carrying the column’s JS type, which you refine with chainable modifiers (primary, unique, notNull, default, …). The JS type flows into InferRow/InferInsert, so query results and inputs are typed from the column definitions.
import { col } from '@ozaco/db'
const id = col.int().primary().autoIncrement()
const email = col.text().unique()
const meta = col.json<{ tags: string[] }>().optional()ColumnBuilder
A `ColumnDef` plus chainable modifiers; each call returns a new builder.
interface ColumnBuilder<TJs> extends ColumnDef<TJs> {
notNull(): ColumnBuilder<TJs>
optional(): ColumnBuilder<TJs | null>
primary(): ColumnBuilder<TJs>
autoIncrement(): ColumnBuilder<TJs>
unique(): ColumnBuilder<TJs>
default(value: TJs): ColumnBuilder<TJs>
defaultNow(): ColumnBuilder<TJs>
references(ref: ColumnRef): ColumnBuilder<TJs>
}Modifiers are immutable — each returns a fresh builder — so definitions can be composed freely. optional widens the JS type to TJs | null; primary implies non-null; autoIncrement and defaultNow set hasDefault. references records a foreign key used to order table creation during migration.
Because ColumnBuilder extends ColumnDef, a built column can be dropped straight into a defineTable column map.
defineTable
Create a `TableDef` from a name and a map of column definitions.
function defineTable<TName extends string, TColumns extends ColumnMap>(
name: TName,
columns: TColumns,
): TableDef<TName, TColumns>import { col, defineTable } from '@ozaco/db'
const users = defineTable('users', {
id: col.int().primary().autoIncrement(),
email: col.text().unique(),
createdAt: col.timestamp().defaultNow(),
})defineSchema
Group tables into a `SchemaDef` passed to a driver on install.
function defineSchema<TTables extends TableMap>(tables: TTables): SchemaDef<TTables>The resulting SchemaDef is what a driver migrates against and what the query builder resolves tables from. Keys are logical names; each value is a TableDef from defineTable.
import { defineSchema } from '@ozaco/db'
const schema = defineSchema({ users, posts })Query
The typed query surface exposed by an installed driver.
QueryBuilder
The database context: entry points for selects, writes, transactions and raw SQL.
interface QueryBuilder {
from<TTable extends TableDef>(table: TTable): SelectQuery<TTable>
insert<TTable extends TableDef>(table: TTable): InsertQuery<TTable>
update<TTable extends TableDef>(table: TTable): UpdateQuery<TTable>
delete<TTable extends TableDef>(table: TTable): DeleteQuery<TTable>
transaction<T>(fn: (tx: QueryBuilder) => Future<T>): Operation<T>
raw<T = unknown>(sql: string, params?: unknown[]): Future<T[]>
}QueryBuilder is the DB protocol’s context: whichever driver is installed provides one, and useDatabase() returns it. Each starter (from, insert, …) yields a chainable, table-typed query object.
transaction runs fn with a scoped builder and commits on success or rolls back on failure. raw is the escape hatch for driver-specific SQL, returning rows as T[].
import { useDatabase } from '@ozaco/db'
function* recent() {
const db = yield* useDatabase()
return yield* db.from(users).orderBy('createdAt', 'desc').limit(10).all()
}SelectQuery
A chainable read: filter, order, paginate, then materialize rows.
interface SelectQuery<TTable extends TableDef> {
where(clause: WhereClause<TTable>): SelectQuery<TTable>
limit(count: number): SelectQuery<TTable>
offset(count: number): SelectQuery<TTable>
orderBy(column: keyof InferRow<TTable> & string, direction?: OrderDirection): SelectQuery<TTable>
all(): Future<InferRow<TTable>[]>
first(): Future<InferRow<TTable> | null>
firstOrFail(): Future<InferRow<TTable>>
}where matches by equality on the given columns. all returns every matching row, first returns the first or null, and firstOrFail fails with a not-found DbErrorCode when there is no match.
const admin = yield* db
.from(users)
.where({ role: 'admin' })
.firstOrFail()InsertQuery
Stage one or many rows and either execute or return the inserted rows.
interface InsertQuery<TTable extends TableDef> {
values(row: InferInsert<TTable>): InsertQuery<TTable>
valuesMany(rows: InferInsert<TTable>[]): InsertQuery<TTable>
returning(): InsertReturning<TTable>
execute(): Future<void>
}InferInsert makes columns with defaults optional. Call execute() for a fire-and-forget write, or returning() to read back the inserted rows.
yield* db.insert(users).values({ email: 'a@b.co' }).execute()
const [created] = yield* db
.insert(users)
.values({ email: 'c@d.co' })
.returning()
.all()InsertReturning
Materialize the rows produced by an `INSERT ... RETURNING`.
interface InsertReturning<TTable extends TableDef> {
all(): Future<InferRow<TTable>[]>
first(): Future<InferRow<TTable> | null>
firstOrFail(): Future<InferRow<TTable>>
}UpdateQuery
Assign new column values, filter the target rows, then execute or return.
interface UpdateQuery<TTable extends TableDef> {
set(values: Partial<InferRow<TTable>>): UpdateQuery<TTable>
where(clause: WhereClause<TTable>): UpdateQuery<TTable>
returning(): UpdateReturning<TTable>
execute(): Future<number>
}execute() resolves to the number of affected rows. Use returning() when you need the updated rows themselves.
const changed = yield* db
.update(users)
.set({ active: false })
.where({ id: 7 })
.execute()UpdateReturning
Materialize the rows produced by an `UPDATE ... RETURNING`.
interface UpdateReturning<TTable extends TableDef> {
all(): Future<InferRow<TTable>[]>
first(): Future<InferRow<TTable> | null>
}DeleteQuery
Filter rows and delete them, resolving to the affected count.
interface DeleteQuery<TTable extends TableDef> {
where(clause: WhereClause<TTable>): DeleteQuery<TTable>
execute(): Operation<number>
}const removed = yield* db.delete(users).where({ id: 7 }).execute()WhereClause
A partial row used for equality filtering: `Partial<InferRow<TTable>>`.
type WhereClause<TTable extends TableDef> = Partial<InferRow<TTable>>Every supplied column is combined with AND equality. Omitting a column leaves it unconstrained.
OrderDirection
Sort direction for `orderBy`: `'asc' | 'desc'`.
type OrderDirection = 'asc' | 'desc'Protocol & Hooks
The `std:plugin` protocol drivers implement, and how to reach the builder.
DB
The database protocol — context is a `QueryBuilder`, with a single `close` action.
const DB: Protocol<QueryBuilder, [unknown], DBActions>
// name 'db', version '0.0.1', subtype Symbol.for('@ozaco/db.protocol')
// The protocol's context and action shapes (from types/db):
type DBContext = QueryBuilder
interface DBActions {
close(): Future<void>
}
// Against whichever driver is installed:
DB.actions.close(): Future<void>DB is defined with defineProtocol from std:plugin. Drivers call DB.implement(...) to refine the setup argument (SqliteConfig, PostgresConfig) and produce a Plugin you install; installing one sets the QueryBuilder as the protocol context for the scope.
The only routed action is close, which releases the underlying connection or pool. Because DB is Hookable, you can layer before/after/around/error hooks onto its actions.
import { DB } from '@ozaco/db'
import { install } from '@ozaco/std/plugin'
import { SqliteDB } from '@ozaco/db/impl/sqlite'
const db = yield* install(SqliteDB, { url: 'file:app.db', schema })
// ... use db ...
yield* DB.actions.close()useDatabase
Read the installed driver’s `QueryBuilder` from the current effect scope.
function useDatabase(): Operation<QueryBuilder>A thin wrapper over useContext(DB.context). Yields the QueryBuilder set by whichever driver was installed in the scope chain; fails if no driver is installed.
import { useDatabase } from '@ozaco/db'
function* countUsers() {
const db = yield* useDatabase()
return (yield* db.from(users).all()).length
}Migrations
Additive schema sync and the dialect-aware DDL builders behind it. Drivers call these on install; they are exported for tooling.
applyMigrations
Create missing tables and add missing columns to match a `SchemaDef`.
function applyMigrations(
exec: RawExec,
schema: SchemaDef,
dialect: Dialect,
): Promise<void>Runs the additive migration a driver performs at install time. It ensures the _ozaco_migrations bookkeeping table exists, orders tables so foreign-key targets are created first (falling back to definition order on cycles), then for each table either creates it or ALTER TABLE ... ADD COLUMNs any columns absent from the live schema.
It never drops or alters existing columns — only additive changes are applied. On completion it records the schema’s schemaTag in _ozaco_migrations (ignoring conflicts), so re-running against an unchanged schema is a no-op write.
schemaTag
Compute a stable content hash string identifying a schema’s shape.
function schemaTag(schema: SchemaDef): stringSerializes every table, its sorted columns, and each column’s type/nullable/primary flags into a deterministic v1:... string. Two schemas with the same structure produce the same tag; used as the unique key recorded in _ozaco_migrations.
RawExec
The raw SQL executor `applyMigrations` runs against.
type RawExec = (sql: string, params?: unknown[]) => Promise<unknown[]>A driver-supplied function that runs a SQL string with optional bound params and resolves to result rows. Each driver adapts its client (better-sqlite3, bun:sqlite, postgres pool) to this shape.
Dialect
Target SQL dialect for DDL generation: `'sqlite' | 'postgres'`.
type Dialect = 'sqlite' | 'postgres'createTableStatement
Render a `CREATE TABLE IF NOT EXISTS` statement for a table.
function createTableStatement(table: TableDef, dialect: Dialect): stringEmits each column with its dialect type and constraints (primary key, NOT NULL, UNIQUE, defaults, REFERENCES). Identifiers are quoted and escaped.
addColumnStatement
Render an `ALTER TABLE ... ADD COLUMN` statement for one column.
function addColumnStatement(args: {
tableName: string
columnName: string
column: ColumnDef
dialect: Dialect
}): stringUsed to backfill columns added to a table that already exists. PRIMARY KEY constraints are stripped, since primary keys cannot be added via ADD COLUMN.
existingColumnsQuery
Build the query that lists a table’s current column names.
function existingColumnsQuery(tableName: string, dialect: Dialect): stringOn SQLite this reads pragma_table_info; on Postgres it queries information_schema.columns. Rows expose a name field, letting the migrator diff live columns against the schema.
migrationsTableDdl
DDL for the `_ozaco_migrations` bookkeeping table.
function migrationsTableDdl(dialect: Dialect): stringCreates a table with an auto-increment id, a unique tag, and an applied_at timestamp (integer epoch on SQLite, TIMESTAMP WITH TIME ZONE DEFAULT NOW() on Postgres).
recordMigrationSql
DDL that inserts a schema tag row, ignoring conflicts.
function recordMigrationSql(dialect: Dialect): stringAn INSERT ... ON CONFLICT DO NOTHING. SQLite binds (tag, applied_at) positionally; Postgres binds (tag) via $1 and lets applied_at default.
columnType
Map a `ColumnDef` to its concrete SQL type for a dialect.
function columnType(column: ColumnDef, dialect: Dialect): stringFor example int → INTEGER (or SERIAL for an auto-increment primary key on Postgres), timestamp → INTEGER on SQLite / TIMESTAMP WITH TIME ZONE on Postgres, varchar(n) → VARCHAR(n), blob → BLOB/BYTEA.
formatDefault
Render a JS default value as a SQL literal.
function formatDefault(value: unknown): stringNumbers and bigints pass through, booleans become TRUE/FALSE, null becomes NULL, and strings (or JSON-encoded objects) are single-quoted with embedded quotes escaped.
Errors
DbErrorCode
The tag map failures are classified with by the drivers.
const DbErrorCode: {
ConnectionLost: 'connection-lost'
UniqueViolation: 'unique-violation'
ForeignKeyViolation: 'foreign-key-violation'
CheckViolation: 'check-violation'
NotFound: 'not-found'
Validation: 'validation'
TxConflict: 'tx-conflict'
Driver: 'driver'
}Built with createTags(null, ...), so each key maps to its bare kebab-case tag. Drivers translate raw client errors into Result failures carrying one of these tags (e.g. a duplicate key becomes unique-violation; firstOrFail on no rows becomes not-found), letting callers pattern-match instead of parsing driver messages.
Types
The schema data model and row inference helpers.
ColumnDef
The immutable shape of a single column, carrying its JS type in `TJs`.
interface ColumnDef<TJs = unknown> {
readonly _t: typeof COLUMN
readonly type: ColumnType
readonly isNullable: boolean
readonly isPrimary: boolean
readonly isUnique: boolean
readonly isAutoIncrement: boolean
readonly hasDefault: boolean
readonly isDefaultNow: boolean
readonly defaultValue: unknown
readonly foreignKey: ColumnRef | null
readonly length: number | null
readonly __js: TJs
}Produced by col.* and mutated (immutably) by ColumnBuilder modifiers. __js is a phantom field carrying the TypeScript type used by InferRow/InferInsert; it holds no runtime value.
ColumnType
The set of supported logical column types.
type ColumnType =
| 'int'
| 'bigint'
| 'text'
| 'varchar'
| 'boolean'
| 'timestamp'
| 'json'
| 'blob'ColumnRef
A foreign-key target: a table and column name.
interface ColumnRef {
readonly table: string
readonly column: string
}Set via ColumnBuilder.references. Used during migration to render REFERENCES clauses and to order table creation.
ColumnMap
A map of column name to `ColumnDef`.
type ColumnMap = Record<string, ColumnDef>TableDef
A named table with its column map, tagged by the `TABLE` symbol.
interface TableDef<TName extends string = string, TColumns extends ColumnMap = ColumnMap> {
readonly _t: typeof TABLE
readonly name: TName
readonly columns: TColumns
}TableMap
A map of logical name to `TableDef`.
type TableMap = Record<string, TableDef>SchemaDef
A set of tables, tagged by the `SCHEMA` symbol.
interface SchemaDef<TTables extends TableMap = TableMap> {
readonly _t: typeof SCHEMA
readonly tables: TTables
}InferRow
The row type read back from a table: each column mapped to its JS type.
type InferRow<TTable extends TableDef> =
TTable extends TableDef<infer _TName, infer TCols>
? { [K in keyof TCols]: ColumnJs<TCols[K]> }
: neverDrives the types returned by all/first/firstOrFail and accepted by where/set.
InferInsert
The insert type for a table: the row type made fully partial.
type InferInsert<TTable extends TableDef> =
TTable extends TableDef<infer _TName, infer TCols>
? Partial<{ [K in keyof TCols]: ColumnJs<TCols[K]> }>
: neverAll columns are optional at the type level, so columns with database defaults (auto-increment ids, defaultNow timestamps) may be omitted.
Constants
Registered symbols used as discriminant `_t` tags on schema data.
COLUMN
Tag for a `ColumnDef`.
const COLUMN: unique symbol // Symbol.for('@ozaco/db.column')TABLE
Tag for a `TableDef`.
const TABLE: unique symbol // Symbol.for('@ozaco/db.table')SCHEMA
Tag for a `SchemaDef`.
const SCHEMA: unique symbol // Symbol.for('@ozaco/db.schema')