Navigation: packages/AGENTS.md | Root AGENTS.md
This package is the persistence layer. It owns all database tables, schema definitions, migrations, and the DBModel base class.
The package supports two database backends. The active dialect is set at startup and cannot change at runtime:
| Dialect | Init function | Schema path | Driver |
|---|---|---|---|
| SQLite | initDb(path) |
src/schema/ |
better-sqlite3 |
| PostgreSQL | await initPostgresDb(url) |
src/schema-pg/ |
postgres (postgres.js) |
Use getDbType() → "sqlite" | "postgres" to branch on dialect if unavoidable.
-
Add the column to both schema files:
src/schema/<table>.ts—sqliteTable, e.g.text("my_col")src/schema-pg/<table>.ts—pgTable, same column name and semantics
-
Add the column to the
CREATE TABLEfor that table ingetCreateSchemaSql()insrc/db.ts— the DDL a fresh SQLite database is created from. -
Add a
declare my_col: ...field to the model class. -
Update the constructor to set a default:
this.my_col ??= null; -
Add a migration entry in
src/migrations/versions.ts. This is not optional on PostgreSQL:initPostgresDbcreates no tables and runs no column repair, so the migration chain is the whole cloud schema.
TABLE_COLUMNS — the map addMissingColumns() uses to repair a legacy SQLite install —
is derived from the Drizzle tables, so there is nothing to update there.
Three tests relate the remaining declaration sites, each by building the schema and reading it back rather than by matching text:
tests/schema-parity.test.ts— bootstrap DDL andTABLE_COLUMNSagainst the Drizzle tables: column names, types, NOT NULL, primary keys, defaults, indexes, foreign keys. Forget step 2 and it fails.tests/schema-dialect-parity.test.ts—src/schema/againstsrc/schema-pg/: tables, columns, constraints, defaults, index names. Forget half of step 1 and it fails.tests/migration-schema-parity.test.ts— applies the migration chain to a real database and checks it creates every Drizzle table and column. Forget step 5 and it fails.
- Create
src/schema/<name>.ts(SQLite) andsrc/schema-pg/<name>.ts(PostgreSQL). - Export both from their respective
index.tsbarrel files. - Add the
CREATE TABLE IF NOT EXISTSand index SQL togetCreateSchemaSql()insrc/db.ts, and acreatesTablesmigration entry insrc/migrations/versions.ts— the DDL covers a fresh SQLite database, the migration covers every existing one and all of PostgreSQL.TABLE_COLUMNSis derived from the schema and needs no edit. - Create
src/<model-name>.tsextendingDBModel. Setstatic override table = <sqliteTable>. - Export the new model from
src/index.ts.
All query methods must be async. Use Drizzle's promise-based API — it works on both dialects:
// Fetch one row
const [row] = await db.select().from(myTable).where(eq(myTable.id, id)).limit(1);
return row ? new MyModel(row as Record<string, unknown>) : null;
// Fetch many rows
const rows = await db.select().from(myTable).where(eq(myTable.user_id, userId));
return rows.map((r: Record<string, unknown>) => new MyModel(r));
// Insert / upsert — handled by DBModel.save() via onConflictDoUpdate
// Delete — handled by DBModel.delete()Never use .get(), .run(), or .all() — those are synchronous SQLite-only methods.
When you need to know if an UPDATE matched a row (e.g. optimistic locking), use .returning():
const updated = await db
.update(myTable)
.set({ version: newVersion })
.where(and(eq(myTable.id, id), eq(myTable.version, expected)))
.returning({ id: myTable.id });
if (updated.length === 0) return false; // row was already modifiedBoth schemas use a jsonText<T>() custom column that stores JSON as plain TEXT. Do not use json() or jsonb() — they behave differently across dialects and complicate cross-backend data sharing.
// SQLite schema:
import { jsonText } from "./helpers.js";
graph: jsonText<WorkflowGraph>()("graph").notNull()
// PostgreSQL schema (same helper, pg-core version):
import { jsonText } from "./helpers.js";
graph: jsonText<WorkflowGraph>()("graph").notNull()SQLite schema uses integer("col", { mode: "boolean" }) — TypeScript type is boolean, comparisons use true/false.
PostgreSQL schema uses plain integer("col") — TypeScript type is number | null, comparisons use 0/1 or filter in application code.
If your query filters on a boolean-like column, pick the right literal for the schema you're querying against.
Migrations live in src/migrations/versions.ts as an ordered list of MigrationDef objects. Each migration has a version string, a name, the createsTables / modifiesTables it touches, and up / down functions that take a MigrationDBAdapter.
The MigrationRunner applies pending migrations in order and records them in _nodetool_migrations (MIGRATION_TRACKING_TABLE in src/migrations/state.ts). It works on both dialects via the MigrationDBAdapter interface:
SQLiteMigrationAdapter— usesbetter-sqlite3synchronous APIPostgresMigrationAdapter— usespg(node-postgres) poolPostgresJsMigrationAdapter— usespostgres.jsreserved connection (preferred for Supabase)
Use drizzle-kit to introspect schema changes and auto-generate migration SQL:
# Generate migration SQL from schema changes
DATABASE_URL=postgres://... npx drizzle-kit generate --config packages/models/drizzle.pg.config.ts
# Push schema directly (dev/staging only — never production without review)
DATABASE_URL=postgres://... npx drizzle-kit push --config packages/models/drizzle.pg.config.tsThe generated SQL in src/drizzle-migrations-pg/ should be reviewed and then added as a MigrationDef entry in versions.ts for auditability.
Tests live in tests/. All tests use initTestDb() which creates an in-memory SQLite database. No PostgreSQL instance is required.
npm run test --workspace=packages/modelsWhen writing tests for new models, call initTestDb() in beforeEach to reset state between tests.
- All public query methods must be
async. - Annotate
.map()callbacks explicitly:(r: Record<string, unknown>) => new Model(r)—getDb()returnsany, so TypeScript cannot infer row types. - Never import from
dist/. Use@nodetool-ai/modelsfor cross-package imports. - Keep
src/schema/(SQLite) andsrc/schema-pg/(PostgreSQL) in sync — columns, names, and types must match. TABLE_COLUMNSindb.tsis generated fromsrc/schema/— never hand-edit it. The DDL ingetCreateSchemaSql()is still hand-written and must matchsrc/schema/;tests/schema-parity.test.tsenforces that.