@spfn/core/db/schema
Reusable column-definition helpers and package-scoped PostgreSQL schema utilities for
defining Drizzle ORM entities (pgTable) with consistent, type-safe boilerplate.
Import paths
There is no @spfn/core/db/schema subpath export. Everything in this module is
re-exported from @spfn/core/db. Import from there.
import {
id, uuid, timestamps,
foreignKey, optionalForeignKey,
auditFields, publishingFields, softDelete,
verificationTimestamp, utcTimestamp, enumText, typedJsonb,
createSchema, packageNameToSchema, getSchemaInfo,
} from '@spfn/core/db';
// Column primitives (text, integer, index, etc.) come from drizzle directly:
import { pgTable, text } from 'drizzle-orm/pg-core';
There is no root
.export for@spfn/core.import { id } from '@spfn/core'does not resolve — always use@spfn/core/db. (Some JSDoc examples in the source still showfrom '@spfn/core'; that is stale.)
Public API (complete)
Column helpers (entity-helper.ts):
id()— bigserial primary keyuuid()— uuid primary key (gen_random_uuid())timestamps()—createdAt+updatedAt(timestamptz, default now, not null)foreignKey(name, reference, options?)— required FK (bigint, cascade by default)optionalForeignKey(name, reference, options?)— nullable FK (set null by default)auditFields()—createdBy+updatedBy(text, nullable)publishingFields()—publishedAt(timestamptz, nullable) +publishedBy(text)softDelete()—deletedAt(timestamptz, nullable) +deletedBy(text)verificationTimestamp(fieldName)— single nullable timestamptz,{fieldName}AtutcTimestamp(fieldName, mode?)— single timestamptz column (chainable)enumText(fieldName, values)— text column with enum constraint (chainable)typedJsonb<T>(fieldName)— jsonb column typed asT(chainable)
Schema namespacing (schema-helper.ts):
createSchema(packageName)→PgSchema(drizzlepgSchema) forschema.table(...)packageNameToSchema(packageName)→stringschema namegetSchemaInfo(packageName)→{ schemaName, isScoped, scope }
The following do not exist in the current code — do not use them:
autoUpdateTimestamp(),statusEnum(), thetimestamps({ autoUpdate })option, and the__autoUpdatemarker. Earlier docs referenced these; they were removed. For auto-updatingupdatedAt, set it manually (.set({ updatedAt: new Date() })) — the helpers do not auto-update it.
Quick Start
// src/server/entities/users.ts
import { pgTable, text, boolean } from 'drizzle-orm/pg-core';
import { id, timestamps, enumText } from '@spfn/core/db';
export const USER_ROLES = ['admin', 'user', 'guest'] as const;
export const users = pgTable('users', {
id: id(),
email: text('email').notNull().unique(),
name: text('name').notNull(),
role: enumText('role', USER_ROLES).notNull().default('user'),
isActive: boolean('is_active').notNull().default(true),
...timestamps(),
});
// Type inference straight from the table
export type User = typeof users.$inferSelect;
export type NewUser = typeof users.$inferInsert;
Spread helpers that return multiple columns (timestamps(), auditFields(),
publishingFields(), softDelete(), verificationTimestamp()); call directly the ones
that return a single column (id(), uuid(), foreignKey(), enumText(),
utcTimestamp(), typedJsonb()).
Column helpers
Primary keys — id(), uuid()
id: id(), // bigserial('id', { mode: 'number' }).primaryKey() → bigserial PK
id: uuid(), // uuid('id').defaultRandom().primaryKey() → uuid PK
id() is the default. Use uuid() for distributed/public-facing IDs. Both produce a
column named id.
timestamps()
...timestamps(),
// createdAt: timestamptz, defaultNow(), notNull()
// updatedAt: timestamptz, defaultNow(), notNull()
DB column names are created_at / updated_at. updatedAt is not auto-updated —
set it explicitly on writes:
await db.update(users)
.set({ name: 'new', updatedAt: new Date() })
.where(eq(users.id, userId));
foreignKey(name, reference, options?) / optionalForeignKey(...)
authorId: foreignKey('author', () => users.id), // author_id BIGINT NOT NULL, ON DELETE CASCADE
categoryId: optionalForeignKey('category', () => cats.id), // category_id BIGINT (nullable), ON DELETE SET NULL
- Column name is
{name}_id. Both produce a bigint column (mode: 'number') — pair withid()(bigserial), notuuid(). options.onDelete:'cascade' | 'set null' | 'restrict' | 'no action'. Defaults:foreignKey→'cascade',optionalForeignKey→'set null'.- There is no
onUpdateoption on these helpers. ForonUpdate(or FK to a uuid PK), use drizzle's table-levelforeignKey({ columns, foreignColumns, onUpdate })instead.
auditFields(), publishingFields(), softDelete()
...auditFields(), // created_by TEXT, updated_by TEXT (nullable)
...publishingFields(), // published_at TIMESTAMPTZ (nullable), published_by TEXT
...softDelete(), // deleted_at TIMESTAMPTZ (nullable), deleted_by TEXT
Soft-delete query pattern — filter manually, there is no automatic scoping:
await db.select().from(posts).where(isNull(posts.deletedAt));
verificationTimestamp(fieldName)
Single nullable timestamptz. The JS property is {fieldName}At, the DB column is
snake_case(fieldName) + '_at'.
...verificationTimestamp('emailVerified'), // prop emailVerifiedAt → col email_verified_at
...verificationTimestamp('phoneVerified'), // prop phoneVerifiedAt → col phone_verified_at
utcTimestamp(fieldName, mode?)
Single timestamptz column. fieldName is the DB column name (snake_case, unlike
verificationTimestamp). mode is 'date' (default, Date) or 'string' (ISO string).
Chainable.
scheduledAt: utcTimestamp('scheduled_at').notNull(),
lastLoginAt: utcTimestamp('last_login_at').defaultNow().notNull(),
processedAt: utcTimestamp('processed_at', 'string'), // ISO string
enumText(fieldName, values)
Text column constrained to a const tuple; the value type is inferred. Chainable.
export const USER_STATUSES = ['active', 'inactive', 'suspended'] as const;
export type UserStatus = typeof USER_STATUSES[number];
status: enumText('status', USER_STATUSES).notNull().default('active'),
Backing type is plain text (no PG enum type), so adding values needs no migration.
typedJsonb<T>(fieldName)
jsonb column typed as T — avoids unknown / as any on reads. Chainable.
type Metadata = { theme: 'light' | 'dark'; settings: Record<string, unknown> };
metadata: typedJsonb<Metadata>('metadata').notNull(),
tags: typedJsonb<string[]>('tags'),
Schema namespacing
Isolate a package's tables under a dedicated PostgreSQL schema so multiple SPFN packages share one database without table-name collisions.
import { createSchema, id, timestamps } from '@spfn/core/db';
import { text } from 'drizzle-orm/pg-core';
const schema = createSchema('@spfn/cms'); // PG schema: spfn_cms
export const labels = schema.table('labels', {
id: id(),
name: text('name').notNull(),
...timestamps(),
});
// → table spfn_cms.labels
Naming rules (packageNameToSchema): strip @, replace / and - with _.
packageNameToSchema('@spfn/cms'); // 'spfn_cms'
packageNameToSchema('@company/auth'); // 'company_auth'
packageNameToSchema('spfn-storage'); // 'spfn_storage'
getSchemaInfo('@spfn/cms');
// { schemaName: 'spfn_cms', isScoped: true, scope: 'spfn' }
Use schema.table(...) instead of pgTable(...) for every table in that package. The
column helpers above work identically inside schema.table.
Pitfalls & anti-patterns
@spfn/core/db/schemais not an export path;@spfn/core(bare) has no root export either. Import everything from@spfn/core/db.idis bigint, not uuid.id()andforeignKey()/optionalForeignKey()are all bigint-based. Don't point aforeignKey()(bigint) at auuid()PK — types won't match. For a uuid FK, use drizzle's table-levelforeignKey({ columns, foreignColumns }).timestamps()takes no arguments and does not auto-updateupdatedAt. Calls liketimestamps({ autoUpdate: true })are from an old API and will fail/no-op. SetupdatedAt: new Date()yourself on updates.autoUpdateTimestamp(),statusEnum(), and the__autoUpdatemarker do not exist. ReplacestatusEnum([...])withenumText('status', [...] as const).notNull().default(...).verificationTimestamp(name)takes a camelCase logical name;utcTimestamp(col)takes a snake_case DB column name. They differ —verificationTimestamp('emailVerified')yields columnemail_verified_at, bututcTimestamp('emailVerified')would create a column literally namedemailVerified.enumTextis plain text + CHECK, not a PG enum type. Adding values requires no migration; renaming/removing the column still does.- Exported tables must be reachable by drizzle-kit to land in migrations. Re-export
every entity (e.g.
src/server/entities/index.ts → export * from './users') and point drizzle config at it; an unexported table generates no migration. - When using
createSchema, all tables in that package must useschema.table(...). MixingpgTable(...)puts that table in the defaultpublicschema.
Complete example
// src/server/entities/posts.ts
import { pgTable, text, index } from 'drizzle-orm/pg-core';
import { sql } from 'drizzle-orm';
import {
id, foreignKey, optionalForeignKey,
enumText, typedJsonb,
timestamps, auditFields, publishingFields, softDelete,
} from '@spfn/core/db';
import { users } from './users';
export const POST_STATUSES = ['draft', 'published', 'archived'] as const;
export type PostStatus = typeof POST_STATUSES[number];
type PostMeta = { seoTitle?: string; tags: string[] };
export const posts = pgTable('posts', {
id: id(),
title: text('title').notNull(),
slug: text('slug').notNull().unique(),
authorId: foreignKey('author', () => users.id), // NOT NULL, cascade
reviewerId: optionalForeignKey('reviewer', () => users.id), // nullable, set null
status: enumText('status', POST_STATUSES).notNull().default('draft'),
meta: typedJsonb<PostMeta>('meta'),
...timestamps(),
...auditFields(),
...publishingFields(),
...softDelete(),
}, (table) => [
index('posts_status_idx').on(table.status),
index('posts_active_idx').on(table.slug).where(sql`${table.deletedAt} is null`),
]);
export type Post = typeof posts.$inferSelect;
export type NewPost = typeof posts.$inferInsert;
// src/server/entities/index.ts — must re-export so drizzle-kit sees every table
export * from './users';
export * from './posts';
export * from './relations';
Indexes, composite primary keys, unique/check constraints, and relations() are standard
drizzle features (table callback / drizzle-orm) — this module does not wrap them; use
drizzle directly.
Types reference
// foreignKey / optionalForeignKey options
type FkOptions = { onDelete?: 'cascade' | 'set null' | 'restrict' | 'no action' };
// utcTimestamp mode
type UtcMode = 'date' | 'string'; // default 'date'
// enumText values
type EnumValues = readonly [string, ...string[]];
// getSchemaInfo return
type SchemaInfo = { schemaName: string; isScoped: boolean; scope: string | null };
Column return types are drizzle column builders (PgColumn-based); chain
.notNull(), .default(...), .unique(), .references(...) as usual.
Related
- @spfn/core/db — DB manager, transactions, repository, postgres errors
- Drizzle ORM —
pgTable,pgSchema, indexes, relations