PostgreSQL Overview
PostgreSQL adapter for persistent storage in JustScale
The PostgreSQL adapter provides a production-ready storage backend for JustScale applications. It wraps domain models with PostgreSQL-specific configuration, separating domain concerns from storage implementation details.
Installation
Install the PostgreSQL adapter package:
pnpm add @justscale/postgresCore Concepts
Domain Models vs Storage Models
JustScale separates domain models (pure business logic) from storage models (database-specific configuration):
- Domain Model - Defined with
defineModel, contains field types and validation, no storage details - Storage Model - Created with
createPgModel, wraps the domain model with table name, indexes, column overrides - Repository - Created with
createPgRepository, provides query methods and integrates with DI
Quick Start
Here is a complete example showing all three layers:
import { defineModel, field } from '@justscale/core/models';
import { createPgModel, createPgRepository } from '@justscale/postgres';
// 1. Define domain model (pure, no storage details)
class User extends defineModel({
email: field.string().max(255).unique(),
name: field.string().max(100),
status: field.enum('UserStatus', ['active', 'inactive', 'banned'] as const).default('active'),
balance: field.decimal(10, 2).default('0.00'),
}) {}
// 2. Create storage model with PostgreSQL config
const PgUser = createPgModel(User, {
table: 'users', // Optional: defaults to snake_case + 's'
indexes: [
{ fields: ['email'], unique: true },
{ fields: ['status'] },
],
});
// 3. Create repository service
const UserRepository = createPgRepository(PgUser);
export { User, UserRepository };Storage Modes
The PostgreSQL adapter supports two storage modes:
Columnar Mode (Default)
Each field maps to a database column. Provides best query performance and full SQL capabilities.
import { createPgModel } from '@justscale/postgres';
import { User } from './user-model';
const PgUser = createPgModel(User, {
table: 'users',
storageMode: 'columnar', // Default, can be omitted
});
// Creates table: users (id, email, name, status, balance, created_at, updated_at, version)JSONB Mode
Fields are stored in a JSONB column. Useful for schema flexibility and rapid iteration. System fields (id, createdAt, updatedAt, version) remain as columns.
import { createPgModel } from '@justscale/postgres';
import { Product } from './product-model';
const PgProduct = createPgModel(Product, {
table: 'products',
storageMode: 'jsonb',
dataColumn: 'data', // Optional: defaults to 'data'
});
// Creates table: products (id, data, created_at, updated_at, version)
// All domain fields stored in the 'data' JSONB columnTable Naming Conventions
By default, table names are automatically generated from model names using snake_case and pluralization:
import { defineModel, field } from '@justscale/core/models';
import { createPgModel } from '@justscale/postgres';
class User extends defineModel({ name: field.string() }) {}
const PgUser = createPgModel(User);
// Table: 'users'
class BlogPost extends defineModel({ title: field.string() }) {}
const PgBlogPost = createPgModel(BlogPost);
// Table: 'blog_posts'
class Category extends defineModel({ name: field.string() }) {}
const PgCategory = createPgModel(Category, { table: 'categories' });
// Table: 'categories' (explicit override)Column Overrides
Override inferred PostgreSQL types or add constraints for specific fields:
import { defineModel, field } from '@justscale/core/models';
import { createPgModel } from '@justscale/postgres';
class User extends defineModel({
email: field.string().max(255),
tags: field.array(field.string()),
metadata: field.jsonb(),
}) {}
const PgUser = createPgModel(User, {
table: 'users',
overrides: {
email: { type: 'CITEXT', unique: true }, // Case-insensitive text
tags: { type: 'TEXT[]' }, // Native PostgreSQL array
},
});Connecting to PostgreSQL
Supply the connection string through a secret provider and add PostgresFeature — it provides AbstractPostgresClient for DI. The channel and lock features layer on LISTEN/NOTIFY pub/sub and distributed advisory locks over the same connection:
import JustScale, { createSecretProvider } from '@justscale/core';
import {
PostgresFeature,
PostgresChannelFeature,
PostgresLockFeature,
PostgresSecrets,
} from '@justscale/postgres';
const Secrets = createSecretProvider({
provides: [PostgresSecrets],
factory: () => ({
[PostgresSecrets.key]: { connectionString: process.env.DATABASE_URL! },
}),
});
const app = JustScale()
.add(Secrets)
.add(PostgresFeature) // provides AbstractPostgresClient
.add(PostgresChannelFeature) // provides AbstractChannelBackend (LISTEN/NOTIFY)
.add(PostgresLockFeature) // distributed advisory locks
.build();Tune the connection pool with a PostgresClientConfig partial (max, idleTimeout, connectTimeout) — or adjust it at runtime with the config CLI:
import { createConfig } from '@justscale/core';
import { PostgresClientConfig } from '@justscale/postgres';
const PoolConfig = createConfig({
provides: [PostgresClientConfig],
factory: () => ({
[PostgresClientConfig.key]: {
max: 25, // Connection pool size
idleTimeout: 20, // Seconds
connectTimeout: 10, // Seconds
},
}),
});
// Or at runtime: just config set postgres:client max 25Need a custom secret shape, multiple databases, or hand-built wiring? The low-level createPostgresClient / createPostgresChannelBackend factories live in @justscale/postgres/advanced.
Type Safety
Repositories are fully typed based on your domain models:
import { User, UserRepository } from './user-model';
class UserService extends defineService({
inject: { users: UserRepository },
factory: ({ users }) => ({
async findActive() {
// Type-safe field references
return users.find({
where: User.fields.status.eq('active'),
orderBy: { createdAt: 'desc' },
});
// Returns: Persistent<User>[]
// Each entity has: id, email, name, status, balance, createdAt, updatedAt, version
},
}),
}) {}Best Practices
- Separate domain from storage - Define models with
defineModel, then wrap withcreatePgModel - Use columnar mode for production - Better performance for queries, indexes, and constraints
- Use JSONB mode for rapid iteration - Schema changes without migrations during development
- Leverage type safety - Use field expressions like
User.fields.email.eq('...')for compile-time validation - Configure indexes - Add indexes for frequently queried fields to improve performance
What's Next
Now that you understand the basics, learn about repository methods, transactions, and advanced features: