Typed relational query toolkit for JavaScript runtimes.
- One API Across Databases: Same query and relation APIs across PostgreSQL, MySQL, and SQLite adapters
- One Query API: Build reusable
Queryobjects withquery(table)and execute them withdb.exec(...), or usedb.query(table)as shorthand - Type-Safe Reads: Typed
select, relation loading, and predicate keys - Optional Runtime Validation: Add
validate(context)at the table level for create/update validation and coercion - Relation-First Queries:
hasMany,hasOne,belongsTo,hasManyThrough, and nested eager loading - Safe Scoped Writes:
update/deletewithorderBy/limitrun safely in a transaction - First-Class Migrations: Plain SQL
up.sql/down.sqlfiles with a journaling runner and dry-run planning - Database Lifecycle Commands: Wipe, migrate, inspect, seed, and reset through
remix db - Raw SQL Escape Hatch: Execute SQL directly with
db.exec(sql\...`)`
data-table gives you two complementary APIs:
- Query Objects for expressive joins, aggregates, eager loading, and scoped writes
- CRUD Helpers for common create/read/update/delete flows (
find,create,update,delete)
Both APIs are type-safe. Runtime validation is opt-in with table-level validate(context).
npm i remix
npm i pg
# or
npm i mysql2
# or
# use the SQLite client built into your runtimeDefine tables once, then create a database with an adapter.
import { column as c, createDatabase, hasMany, query, table } from 'remix/data-table'
import { createPostgresDatabaseAdapter } from 'remix/data-table/postgres'
let users = table({
name: 'users',
columns: {
id: c.uuid(),
email: c.varchar(255),
role: c.enum(['customer', 'admin']),
created_at: c.integer(),
},
})
let orders = table({
name: 'orders',
columns: {
id: c.uuid(),
user_id: c.uuid(),
status: c.enum(['pending', 'processing', 'shipped', 'delivered']),
total: c.decimal(10, 2),
created_at: c.integer(),
},
})
let userOrders = hasMany(users, orders)
let db = createDatabase(
createPostgresDatabaseAdapter({ connectionString: process.env.DATABASE_URL }),
)Use query(table) when you want to build a standalone reusable query object. Execute it later with db.exec(query). Use db.query(table) when you want the same chainable Query already bound to a database instance.
query(table) is the primary query-builder API. It gives you an unbound Query value that can be composed, stored, reused, and executed against any compatible database instance.
import { eq, ilike, query } from 'remix/data-table'
let pendingOrdersForExampleUsers = query(orders)
.join(users, eq(orders.user_id, users.id))
.where({ status: 'pending' })
.where(ilike(users.email, '%@example.com'))
.select({
orderId: orders.id,
customerEmail: users.email,
total: orders.total,
placedAt: orders.created_at,
})
.orderBy(orders.created_at, 'desc')
.limit(20)
let recentPendingOrders = await db.exec(pendingOrdersForExampleUsers)Unbound queries stay lazy until you pass them to db.exec(...):
let shippedCustomerQuery = query(users)
.where({ role: 'customer' })
.with({
recentOrders: userOrders.where({ status: 'shipped' }).orderBy('created_at', 'desc').limit(3),
})
let customers = await db.exec(shippedCustomerQuery)
// customers[0].recentOrders is fully typedThe same standalone query builder also handles terminal read and write operations:
let nextPendingOrder = await db.exec(
query(orders).where({ status: 'pending' }).orderBy('created_at', 'asc').first(),
)
await db.exec(
query(orders)
.where({ status: 'pending' })
.orderBy('created_at', 'asc')
.limit(100)
.update({ status: 'processing' }),
)If you already have a db instance in hand and do not need a standalone query value, db.query(table) returns the same query builder already bound to that database:
let recentPendingOrders = await db
.query(orders)
.where({ status: 'pending' })
.orderBy('created_at', 'desc')
.limit(20)
.all()data-table provides helpers for common create/read/update/delete operations. Use these helpers for common operations without building a full query chain.
import { or } from 'remix/data-table'
let user = await db.find(users, 'u_001')
let firstPending = await db.findOne(orders, {
where: { status: 'pending' },
orderBy: ['created_at', 'asc'],
})
let page = await db.findMany(orders, {
where: or({ status: 'pending' }, { status: 'processing' }),
orderBy: [
['status', 'asc'],
['created_at', 'desc'],
],
limit: 50,
offset: 0,
})where accepts the same single-table object/predicate inputs as query().where(...), and orderBy uses tuple form:
['column', 'asc' | 'desc'][['columnA', 'asc'], ['columnB', 'desc']]
// Default: metadata (affectedRows/insertId)
let createResult = await db.create(users, {
id: 'u_002',
email: 'sam@example.com',
role: 'customer',
created_at: Date.now(),
})
// Return a typed row (with optional relations)
let createdUser = await db.create(
users,
{
id: 'u_003',
email: 'pat@example.com',
role: 'customer',
created_at: Date.now(),
},
{
returnRow: true,
with: { recentOrders: userOrders.orderBy('created_at', 'desc').limit(1) },
},
)
// Bulk insert metadata
let createManyResult = await db.createMany(orders, [
{ id: 'o_101', user_id: 'u_002', status: 'pending', total: 24.99, created_at: Date.now() },
{ id: 'o_102', user_id: 'u_003', status: 'pending', total: 48.5, created_at: Date.now() },
])
// Return inserted rows (requires adapter RETURNING support)
let insertedRows = await db.createMany(
orders,
[{ id: 'o_103', user_id: 'u_003', status: 'pending', total: 12, created_at: Date.now() }],
{ returnRows: true },
)createMany/insertMany throw when every row in the batch is empty (no explicit values).
let updatedUser = await db.update(users, 'u_003', { role: 'admin' })
let updateManyResult = await db.updateMany(
orders,
{ status: 'processing' },
{
where: { status: 'pending' },
orderBy: ['created_at', 'asc'],
limit: 25,
},
)
let deletedUser = await db.delete(users, 'u_002')
let deleteManyResult = await db.deleteMany(orders, {
where: { status: 'delivered' },
orderBy: [['created_at', 'asc']],
limit: 200,
})db.update(...) throws when the target row cannot be found.
Return behavior:
find/findOne-> row ornullfindMany-> rowscreate->WriteResultby default, row whenreturnRow: truecreateMany->WriteResultby default, rows whenreturnRows: true(not supported in MySQL because it doesn't supportRETURNING)update-> updated row (throws when target row is missing)updateMany/deleteMany->WriteResultdelete->boolean
Validation is optional and table-scoped. Define validate(context) to validate/coerce write payloads, and add lifecycle callbacks when you need custom read/write/delete behavior.
import { column as c, fail, table } from 'remix/data-table'
let payments = table({
name: 'payments',
columns: {
id: c.uuid(),
amount: c.decimal(10, 2),
},
beforeWrite({ value }) {
return {
value: {
...value,
amount: typeof value.amount === 'string' ? value.amount.trim() : value.amount,
},
}
},
validate({ operation, value }) {
if (operation === 'create' && typeof value.amount === 'string') {
let amount = Number(value.amount)
if (!Number.isFinite(amount)) {
return fail('Expected a numeric amount', ['amount'])
}
return { value: { ...value, amount } }
}
return { value }
},
beforeDelete({ where }) {
if (where.length === 0) {
return fail('Refusing unscoped delete')
}
},
afterRead({ value }) {
if (!('amount' in value)) {
return { value }
}
return {
value: {
...value,
// Example read-time shaping
amount:
typeof value.amount === 'number' ? Math.round(value.amount * 100) / 100 : value.amount,
},
}
},
})Use fail(...) in hooks when you want to return issues without manually building { issues: [...] }.
Validation and lifecycle semantics:
- Write order is
beforeWrite -> validate -> timestamp/default touch -> execute -> afterWrite validateruns for writes (create,createMany,insert,insertMany,update,updateMany,upsert)- Hook context includes
{ operation: 'create' | 'update', tableName, value } - Write payloads are partial objects
- Unknown columns fail validation before and after hook processing
beforeDeletecan veto deletes by returning{ issues }afterDeleteruns after successful deletes withaffectedRowsafterReadruns for each loaded row (root rows, eager-loaded relation rows, and write-returning rows)afterReadreceives the current read shape, which may be partial/projection rows; guard field access accordingly- Predicate values (
where,having, join predicates) are not runtime-validated - Lifecycle callbacks are synchronous; returning a Promise throws a validation error
- Callback validation errors include
metadata.source(beforeWrite,validate,beforeDelete,afterRead, etc.) for easier debugging - Callbacks do not introduce implicit transactions (use
db.transaction(...)when you need rollback guarantees)
await db.transaction(async (tx) => {
let user = await tx.create(
users,
{ id: 'u_010', email: 'new@example.com', role: 'customer', created_at: Date.now() },
{ returnRow: true },
)
await tx.create(orders, {
id: 'o_500',
user_id: user.id,
status: 'pending',
total: 79,
created_at: Date.now(),
})
})data-table ships a SQL-first migration system under remix/data-table/migrations. Each migration is a directory containing hand-written up.sql and (optionally) down.sql. The runner journals applied migrations, detects checksum drift and missing applied migrations, and wraps each migration in a transaction when the adapter supports transactional DDL.
app/
db/
migrations/
20260228090000_create_users/
up.sql
down.sql
20260301113000_add_user_status/
up.sql
down.sql
db.ts- Keep migration directories in one parent directory (for example
app/db/migrations). - Each directory is named
YYYYMMDDHHmmss_<slug>. up.sqlis required.down.sqlis optional (omit for irreversible migrations).- Scripts may contain multiple statements.
idandnameare inferred from the directory name.
20260228090000_create_users/up.sql:
create table users (
id serial primary key,
email varchar(255) not null unique,
created_at timestamptz not null default now()
);
create unique index users_email_idx on users (email);20260228090000_create_users/down.sql:
drop index if exists users_email_idx;
drop table if exists users;The runner sends each migration to the adapter as a single multi-statement script. Make sure the underlying driver accepts multiple statements:
better-sqlite3: works out of the box (db.exec).pg: works out of the box when no parameter array is passed.mysql2: requiresmultipleStatements: trueon the connection/pool.
import { createMysqlDatabaseAdapter } from 'remix/data-table/mysql'
let adapter = createMysqlDatabaseAdapter({
uri: process.env.DATABASE_URL,
multipleStatements: true,
})Export the database, migration loader, and optional seed function from app/db.ts:
import * as path from 'node:path'
import { createDatabase, type GetMigrations, type Seed } from 'remix/data-table'
import { createPostgresDatabaseAdapter } from 'remix/data-table/postgres'
import { loadMigrations } from 'remix/data-table/migrations/node'
export let db = createDatabase(
createPostgresDatabaseAdapter({ connectionString: process.env.DATABASE_URL }),
)
export let getMigrations: GetMigrations = () =>
loadMigrations(path.join(import.meta.dirname, 'db/migrations'))
export let seed: Seed = async (db) => {
// Initialize application data.
}Run lifecycle commands through the Remix CLI:
remix db status
remix db migrate
remix db migrate --to 20260301113000_add_user_status
remix db seed
remix db reset
remix db wipe--to accepts a bare migration id (20260301113000) or the full directory name (20260301113000_add_user_status).
remix db status reports applied migrations whose files are no longer present as missing. Forward migration runs (remix db migrate, runner.up()) stop before executing SQL when an applied journal entry is missing from the current migration set. Rollbacks (runner.down()) skip those orphaned journal entries so migrations that are still present can be reverted.
wipe and reset are destructive. They require a config-backed adapter so the adapter can close, recreate, and reconnect to the configured database.
Use createMigrationRunner() directly when you need rollback, step, or dry-run behavior that is not exposed by remix db:
import { createMigrationRunner } from 'remix/data-table/migrations'
let migrations = await getMigrations()
let runner = createMigrationRunner(db.adapter, migrations, {
journalTable: 'app_migrations',
})Omit journalTable to use data_table_migrations.
Adapters with migration locking run the complete migration and journal lifecycle through the connection that owns the lock. This keeps advisory locks correctly paired when the adapter uses a connection pool, including pools configured with a single connection.
Use step for bounded rollforward/rollback behavior instead of a target id:
await runner.up({ step: 1 })
await runner.down({ step: 1 })to and step are mutually exclusive within a single run. Like --to on the CLI, to accepts a bare migration id or the full id_name directory form.
Use dryRun to inspect the SQL plan without applying or journaling anything:
let plan = await runner.up({ dryRun: true })
for (let script of plan.sql) {
console.log(script)
}By default each migration is wrapped in a transaction when the adapter supports transactional DDL. Override per migration with a directive on the first non-blank line of up.sql:
-- data-table/transaction: none
create index concurrently users_email_active_idx on users (email) where status = 'active';Supported modes:
auto(default): wrap when the adapter supports transactional DDL.required: wrap; the runner throws if the adapter cannot support it.none: never wrap. Use this for statements like postgresCREATE INDEX CONCURRENTLYthat cannot run inside a transaction.
You can also set transaction directly on a MigrationDescriptor when registering migrations programmatically.
For non-filesystem runtimes, register migrations directly:
import { createMigrationRegistry, createMigrationRunner } from 'remix/data-table/migrations'
let registry = createMigrationRegistry()
registry.register({
id: '20260228090000',
name: 'create_users',
up: 'create table users (id serial primary key, email text not null);',
down: 'drop table users;',
})
let runner = createMigrationRunner(adapter, registry)
await runner.up()import { rawSql, sql } from 'remix/data-table'
await db.exec(sql`select * from users where id = ${'u_001'}`)
await db.exec(rawSql('update users set role = ? where id = ?', ['admin', 'u_001']))Use sql when you need raw SQL plus safe value interpolation:
import { sql } from 'remix/data-table'
let email = input.email
let minCreatedAt = input.minCreatedAt
let result = await db.exec(sql`
select id, email
from users
where email = ${email}
and created_at >= ${minCreatedAt}
`)sql keeps values parameterized per adapter dialect, so you can avoid manual string concatenation.
data-schema- Optional schema parsing you can use inside table-levelvalidate(...)hooksdata-table-postgres- PostgreSQL adapterdata-table-mysql- MySQL adapterdata-table-sqlite- SQLite adapter
See LICENSE