Overview
data-table-postgres provides a PostgreSQL-specific adapter for data-table. It supports the full data-table API with PostgreSQL-optimized features including RETURNING clauses, savepoints, upserts, and transactional DDL.
Key Features:
- Full
RETURNING clause support for inserts, updates, and deletes
- Savepoint support for nested transactions
- Native upsert (
ON CONFLICT) support
- Transactional DDL for safe migrations
- Advisory lock support for migration coordination
- Connection pooling support
Installation
API Reference
createPostgresDatabaseAdapter
Creates a PostgreSQL database adapter.
client
PostgresDatabasePool
required
PostgreSQL client or pool instance from the pg package.
options.capabilities
AdapterCapabilityOverrides
Optional capability overrides.
options.capabilities.returning
Enable/disable RETURNING clause support. Defaults to true.
options.capabilities.savepoints
Enable/disable savepoint support. Defaults to true.
options.capabilities.upsert
Enable/disable upsert support. Defaults to true.
options.capabilities.transactionalDdl
Enable/disable transactional DDL. Defaults to true.
options.capabilities.migrationLock
Enable/disable advisory lock for migrations. Defaults to true.
A PostgreSQL database adapter instance.
import { Pool } from 'pg'
import { createDatabase } from 'remix/data-table'
import { createPostgresDatabaseAdapter } from 'remix/data-table-postgres'
let pool = new Pool({
connectionString: process.env.DATABASE_URL,
})
let adapter = createPostgresDatabaseAdapter(pool)
let db = createDatabase(adapter)
PostgresDatabaseAdapter
PostgreSQL adapter class implementing the DatabaseAdapter interface.
class PostgresDatabaseAdapter implements DatabaseAdapter {
dialect: 'postgres'
capabilities: AdapterCapabilities
compileSql(
operation: DataManipulationOperation | DataMigrationOperation
): SqlStatement[]
execute(
request: DataManipulationRequest
): Promise<DataManipulationResult>
migrate(
request: DataMigrationRequest
): Promise<DataMigrationResult>
hasTable(
table: TableRef,
transaction?: TransactionToken
): Promise<boolean>
hasColumn(
table: TableRef,
column: string,
transaction?: TransactionToken
): Promise<boolean>
beginTransaction(
options?: TransactionOptions
): Promise<TransactionToken>
commitTransaction(
token: TransactionToken
): Promise<void>
rollbackTransaction(
token: TransactionToken
): Promise<void>
createSavepoint(
token: TransactionToken,
name: string
): Promise<void>
rollbackToSavepoint(
token: TransactionToken,
name: string
): Promise<void>
releaseSavepoint(
token: TransactionToken,
name: string
): Promise<void>
}
Type Definitions
PostgresDatabaseClient
Minimal PostgreSQL client contract.
type PostgresDatabaseClient = {
query(text: string, values?: unknown[]): Promise<PostgresQueryResult>
}
PostgresDatabasePool
PostgreSQL pool-like client with optional connection pooling.
type PostgresDatabasePool = PostgresDatabaseClient & {
connect?: () => Promise<PostgresTransactionClient>
}
PostgresTransactionClient
PostgreSQL transaction client with optional connection release.
type PostgresTransactionClient = PostgresDatabaseClient & {
release?: () => void
}
PostgresQueryResult
Result shape returned by PostgreSQL client query() calls.
type PostgresQueryResult = {
rows: unknown[]
rowCount: number | null
}
PostgresDatabaseAdapterOptions
PostgreSQL adapter configuration.
type PostgresDatabaseAdapterOptions = {
capabilities?: AdapterCapabilityOverrides
}
Capabilities
The PostgreSQL adapter has the following default capabilities:
| Capability | Default | Description |
|---|
returning | true | Support for RETURNING clauses in INSERT/UPDATE/DELETE |
savepoints | true | Support for nested transactions via savepoints |
upsert | true | Support for ON CONFLICT upsert operations |
transactionalDdl | true | DDL statements can be run in transactions |
migrationLock | true | Advisory locks for migration coordination |
Usage Examples
Basic Setup
import { Pool } from 'pg'
import { column as c, createDatabase, table } from 'remix/data-table'
import { createPostgresDatabaseAdapter } from 'remix/data-table-postgres'
// Define tables
let users = table({
name: 'users',
columns: {
id: c.uuid().primaryKey(),
email: c.varchar(255).notNull().unique(),
name: c.varchar(255).notNull(),
created_at: c.timestamp().defaultNow(),
},
})
// Create database
let pool = new Pool({ connectionString: process.env.DATABASE_URL })
let db = createDatabase(createPostgresDatabaseAdapter(pool))
// Use the database
let user = await db.create(users, {
id: crypto.randomUUID(),
email: '[email protected]',
name: 'Ada Lovelace',
})
Using RETURNING Clauses
PostgreSQL’s RETURNING support allows you to get inserted/updated rows without additional queries.
// Insert with RETURNING
let createdUser = await db.create(
users,
{
id: crypto.randomUUID(),
email: '[email protected]',
name: 'Grace Hopper',
},
{ returnRow: true }
)
// Bulk insert with RETURNING
let insertedUsers = await db.createMany(
users,
[
{ id: crypto.randomUUID(), email: '[email protected]', name: 'Alan Turing' },
{ id: crypto.randomUUID(), email: '[email protected]', name: 'John von Neumann' },
],
{ returnRows: true }
)
// Update with RETURNING
let result = await db
.query(users)
.where({ email: '[email protected]' })
.update({ name: 'Ada King' }, { returning: '*' })
Transactions and Savepoints
PostgreSQL supports nested transactions via savepoints.
// Simple transaction
await db.transaction(async (tx) => {
await tx.create(users, { id: crypto.randomUUID(), email: '[email protected]' })
await tx.update(users, userId, { name: 'Updated Name' })
})
// Nested transactions with savepoints
await db.transaction(async (tx) => {
await tx.create(users, { id: crypto.randomUUID(), email: '[email protected]' })
// Inner transaction uses a savepoint
await tx.transaction(async (inner) => {
await inner.create(users, { id: crypto.randomUUID(), email: '[email protected]' })
// If this throws, only the inner transaction is rolled back
})
})
Upserts
PostgreSQL’s ON CONFLICT is exposed via the upsert method.
// Upsert by primary key (implicit conflict target)
await db.query(users).upsert(
{
id: userId,
email: '[email protected]',
name: 'Updated Name',
},
{
update: { name: 'Updated Name' },
}
)
// Upsert with explicit conflict target
await db.query(users).upsert(
{
email: '[email protected]',
name: 'New Name',
},
{
conflictTarget: ['email'],
update: { name: 'New Name' },
}
)
// Upsert with RETURNING
let result = await db.query(users).upsert(
{ id: userId, email: '[email protected]' },
{
update: { email: '[email protected]' },
returning: '*',
}
)
Transaction Options
PostgreSQL supports transaction isolation levels and read-only mode.
import type { TransactionOptions } from 'remix/data-table'
// Read-only transaction
await db.transaction(async (tx) => {
let users = await tx.findMany(users)
return users
}, { readOnly: true })
// Custom isolation level
await db.transaction(async (tx) => {
await tx.create(users, { id: crypto.randomUUID(), email: '[email protected]' })
}, { isolationLevel: 'serializable' })
Schema Support
PostgreSQL adapter fully supports schema-qualified table names.
let usersTable = table({
name: 'public.users', // Schema-qualified name
columns: {
id: c.uuid(),
email: c.varchar(255),
},
})
// Alternatively, define schema separately in migrations
let orders = table({
name: 'orders',
columns: { id: c.integer() },
})
// In migrations, you can reference schemas
import { createTable } from 'remix/data-table/migrations'
export let up = createTable({
name: 'tenant_orders',
schema: 'tenant_1',
columns: {
id: c.integer(),
},
})
Connection Pooling
The adapter works seamlessly with pg’s connection pooling.
import { Pool } from 'pg'
let pool = new Pool({
host: 'localhost',
database: 'mydb',
user: 'user',
password: 'password',
max: 20,
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 2000,
})
let db = createDatabase(createPostgresDatabaseAdapter(pool))
// Pool automatically manages connections
let users = await db.findMany(users)
// Transactions automatically acquire and release connections
await db.transaction(async (tx) => {
await tx.create(users, { id: crypto.randomUUID(), email: '[email protected]' })
})
// Clean up pool when done
await pool.end()
PostgreSQL-Specific Features
Array Types
PostgreSQL arrays are supported through JSON column serialization or custom types.
let posts = table({
name: 'posts',
columns: {
id: c.integer(),
tags: c.json(), // Store arrays as JSON
},
})
await db.create(posts, {
id: 1,
tags: ['typescript', 'postgres', 'remix'],
})
Generated Columns
PostgreSQL computed columns are supported.
let products = table({
name: 'products',
columns: {
id: c.integer(),
price: c.decimal(10, 2),
tax_rate: c.decimal(5, 4),
// Generated column
price_with_tax: c.decimal(10, 2).computed('price * (1 + tax_rate)', { stored: true }),
},
})
Identity Columns
PostgreSQL identity columns provide better serial key support.
let users = table({
name: 'users',
columns: {
id: c.integer().identity({ start: 1000, increment: 1 }),
email: c.varchar(255),
},
})
Differences from Other Adapters
vs. MySQL Adapter
| Feature | PostgreSQL | MySQL |
|---|
| RETURNING | ✅ Yes | ❌ No |
| Savepoints | ✅ Yes | ✅ Yes |
| Transactional DDL | ✅ Yes | ❌ No |
| Migration Lock | Advisory Lock | Table Lock |
| Upsert | ON CONFLICT | ON DUPLICATE KEY |
| Identity | IDENTITY | AUTO_INCREMENT |
vs. SQLite Adapter
| Feature | PostgreSQL | SQLite |
|---|
| RETURNING | ✅ Yes | ✅ Yes |
| Savepoints | ✅ Yes | ✅ Yes |
| Transactional DDL | ✅ Yes | ✅ Yes |
| Migration Lock | Advisory Lock | ❌ No |
| Concurrent Access | ✅ Excellent | ⚠️ Limited |
| Connection Pooling | ✅ Yes | ❌ N/A |
Best Practices
Use Connection Pooling
Always use a connection pool for production applications.
// Good: Use a pool
let pool = new Pool({ connectionString: process.env.DATABASE_URL })
let db = createDatabase(createPostgresDatabaseAdapter(pool))
// Bad: Use a single client
import { Client } from 'pg'
let client = new Client({ connectionString: process.env.DATABASE_URL })
await client.connect()
let db = createDatabase(createPostgresDatabaseAdapter(client))
Handle Connection Errors
Monitor pool events for connection issues.
pool.on('error', (err) => {
console.error('Unexpected pool error', err)
})
pool.on('connect', () => {
console.log('Client connected to pool')
})
Use Transactions for Multiple Writes
Wrap multiple related writes in transactions.
// Good: Use transaction
await db.transaction(async (tx) => {
let user = await tx.create(users, { ... }, { returnRow: true })
await tx.create(profiles, { user_id: user.id, ... })
})
// Bad: Separate operations
let user = await db.create(users, { ... }, { returnRow: true })
await db.create(profiles, { user_id: user.id, ... })
Leverage RETURNING Clauses
Use returnRow and returnRows to avoid extra queries.
// Good: Single query with RETURNING
let user = await db.create(users, { ... }, { returnRow: true })
// Bad: Insert then query
await db.create(users, { id: userId, ... })
let user = await db.find(users, userId)