Skip to content

Data Table Overview

The data-table package is a typed relational query toolkit that works with PostgreSQL, MySQL, and SQLite. It lets you define tables, run queries, declare relations, manage migrations, and add lifecycle hooks --- all with full TypeScript inference.

What is a query toolkit?

Unlike a full ORM, data-table does not manage entity instances or track changes behind the scenes. You define tables, write queries, and get back plain objects. The toolkit generates SQL for you and infers TypeScript types from your table definitions.

Key Features

FeatureWhat It Does
Table definitionsDefine columns, primary keys, defaults, constraints, and enums with a chainable builder API.
Query builderFluent API for SELECT, INSERT, UPDATE, DELETE with type-safe column references.
RelationsbelongsTo, hasOne, hasMany, and hasManyThrough with eager loading via .with().
Operatorseq, gt, lt, like, inList, between, and, or, and more for WHERE clauses.
Transactionsdb.transaction() with automatic rollback on error and nested savepoints.
Lifecycle hooksvalidate, beforeWrite, afterWrite, beforeDelete, afterDelete, afterRead.
MigrationscreateMigration with up/down functions for schema changes.
Multi-databaseOne API, three adapters: SQLite, PostgreSQL, MySQL.

Quick Example

Define a table and query it:

ts
import { table, column as c, createDatabase } from 'remix/data-table'
import { createSqliteDatabaseAdapter } from 'remix/data-table-sqlite'
import SQLite from 'better-sqlite3'

// Define a table
let tasks = table({
  name: 'tasks',
  columns: {
    id: c.integer().primaryKey().autoIncrement(),
    title: c.text().notNull(),
    done: c.boolean().notNull().default(false),
    createdAt: c.timestamp().notNull().defaultNow(),
  },
  timestamps: true,
})

// Connect to SQLite
let adapter = createSqliteDatabaseAdapter(new SQLite('app.db'))
let db = createDatabase(adapter)

// Create a row
await db.create(tasks, { title: 'Write docs' })

// Query rows
let pending = await db.findMany(tasks, {
  where: { done: false },
  orderBy: ['createdAt', 'desc'],
})

How Tables Work

A table definition is a schema declaration. It does not create the table in the database --- that is what migrations are for. The definition tells data-table the column names, types, and constraints so it can generate correct SQL and infer TypeScript types.

ts
let users = table({
  name: 'users',
  columns: {
    id: c.integer().primaryKey().autoIncrement(),
    email: c.varchar(255).notNull().unique('users_email_uq'),
    name: c.text().notNull(),
    role: c.enum(['admin', 'user']).notNull().default('user'),
  },
  timestamps: true,
})

What does timestamps: true do?

It auto-manages createdAt and updatedAt columns. On create, both are set to the current time. On update, updatedAt is refreshed automatically.

How Queries Work

There are two ways to query: direct methods and the query builder.

Direct methods are concise for common operations:

ts
let user = await db.find(users, 1)
let admins = await db.findMany(users, { where: { role: 'admin' } })
let count = await db.count(users)

The query builder is a fluent API for complex queries:

ts
import { gt } from 'remix/data-table/operators'

let results = await db.query(users)
  .where(gt('createdAt', new Date('2024-01-01')))
  .orderBy('name', 'asc')
  .limit(10)
  .all()

How Relations Work

Relations define how tables connect and enable eager loading:

ts
import { hasMany, belongsTo } from 'remix/data-table'

let userPosts = hasMany(users, posts)
let postAuthor = belongsTo(posts, users)

let user = await db.find(users, 1, {
  with: { posts: userPosts },
})
// user.posts is an array of Post rows

Database Adapters

data-table does not talk to databases directly. You plug in an adapter:

AdapterPackageDriver
SQLiteremix/data-table-sqlitebetter-sqlite3
PostgreSQLremix/data-table-postgrespg
MySQLremix/data-table-mysqlmysql2

Released under the MIT License.