database-postgres
GitHubPostgres数据库开发技能,涵盖Drizzle ORM schema定义、SqlClient集成、RLS安全策略、迁移脚本及数据映射器实现。
Trigger Scenarios
Install
npx skills add latitude-dev/latitude-llm --skill database-postgres -g -y
SKILL.md
Frontmatter
{
"name": "database-postgres",
"description": "Drizzle schema, repositories, RLS, SqlClient wiring, Postgres migrations, psql \/ reset, or platform mappers (toDomain* \/ toInsertRow)."
}
Postgres, SqlClient, schema, migrations, mappers
When to use: Drizzle schema, repositories, RLS, SqlClient wiring, Postgres migrations, psql / reset, or platform mappers (toDomain* / toInsertRow).
Database patterns (Postgres)
- Postgres adapter stack uses Drizzle ORM in
packages/platform/db-postgres - Domain models are independent from table/row shapes
- Mapping from DB rows to domain objects belongs in platform adapters
- Apps use SqlClient for all DB access: Boundaries provide
SqlClientLivewith organization context for RLS enforcement
SqlClient and row-level security (RLS)
All Postgres access flows through SqlClient—a domain-level service that abstracts database operations and enforces organization scoping via RLS.
Architecture:
- Domain Layer (
@domain/shared):SqlClientinterface withtransaction()andquery()methods - Platform Layer (
@platform/db-postgres):SqlClientLiveimplementation with automatic RLS context setting - App Layer (
apps/*): Boundaries provideSqlClientLivewith the request's organization context
Key behaviors:
- Every transaction automatically sets
app.current_organization_idsession variable - RLS policies filter all queries by this organization ID at the database level
- Nested transactions share the same connection (pass-through proxy—no nested transaction overhead)
- Domain errors propagate through Effect error channel; database errors become
RepositoryError - On effect failure,
SqlClientLivestill awaits the Drizzle transaction promise so the connection returns to the pool; if the driver surfaces a different error than the Effect failure (for example rollback/commit), that secondary error is logged via@repo/observabilitywhile the original failure remains the propagated error
Usage in boundaries (apps):
// packages/operations/src/operations/projects.ts
import { SqlClientLive } from "@platform/db-postgres"
import { ProjectRepositoryLive } from "@platform/db-postgres"
app.openapi(createProjectRoute, async (c) => {
const project = await Effect.runPromise(
createProjectUseCase(input).pipe(
Effect.provide(ProjectRepositoryLive),
Effect.provide(SqlClientLive(c.var.postgresClient, c.var.organization.id)),
),
)
return c.json(toProjectResponse(project), 201)
})
// apps/web/src/domains/projects/projects.functions.ts
import { getPostgresClient } from "../../server/clients.ts"
export const createProject = createServerFn({ method: "POST" })
.handler(async ({ data }) => {
const { organizationId } = await requireSession()
const client = getPostgresClient()
const project = await Effect.runPromise(
createProjectUseCase({...}).pipe(
Effect.provide(ProjectRepositoryLive),
Effect.provide(SqlClientLive(client, organizationId)),
)
)
return toRecord(project)
})
Usage in use-cases (multi-operation transactions):
// packages/domain/auth/src/use-cases/complete-auth-intent.ts
export const completeAuthIntentUseCase = (input) =>
Effect.gen(function* () {
const sqlClient = yield* SqlClient
yield* sqlClient.transaction(handleIntentByType(intent, input.session))
})
const handleSignup = (intent, session) =>
Effect.gen(function* () {
const users = yield* UserRepository
const memberships = yield* MembershipRepository
const organization = yield* createOrganizationUseCase({...})
yield* memberships.save(createMembership({...}))
yield* users.setNameIfMissing({...})
})
Usage in repositories (single operations):
Repository methods must resolve SqlClient inside each call — never capture it at layer build. See the "Never capture scope-bound services at layer build" rule in the Effect and errors skill.
// packages/platform/db-postgres/src/repositories/project-repository.ts
export const ProjectRepositoryLive = Layer.effect(
ProjectRepository,
Effect.gen(function* () {
return {
findById: (id) =>
Effect.gen(function* () {
const sqlClient = (yield* SqlClient) as SqlClientShape<Operator>
return yield* sqlClient
.query((db, organizationId) =>
db
.select()
.from(projects)
.where(and(eq(projects.organizationId, organizationId), eq(projects.id, id)))
.limit(1),
)
.pipe(Effect.flatMap(...))
}),
save: (project) =>
Effect.gen(function* () {
const sqlClient = (yield* SqlClient) as SqlClientShape<Operator>
yield* sqlClient.query((db, organizationId) =>
db.insert(projects).values({ ...row, organizationId }).onConflictDoUpdate({...})
)
}),
}
})
)
The layer-build effect doesn't yield* SqlClient at all — the dependency is declared via each method's R channel, and resolved per call. A build-time yield is redundant and (if captured) re-introduces the very bug this pattern avoids.
Pull organizationId from the RLS context, not from method params
The query((db, organizationId) => …) callback receives the active organization id from the SqlClient's RLS context. Use that value in WHERE predicates and INSERT … VALUES rows. Don't accept organizationId as a parameter on the repository method just to re-thread it into the SQL.
Why:
- Consistency — every repo call is scoped the same way regardless of which caller invokes it. Use-cases don't get to pick a different org from the one their request authenticated against.
- Defense in depth alongside RLS — RLS already filters rows by
app.current_organization_id, but the explicit predicate makes intent obvious in the query plan and catches accidental "I forgot RLS is on" mistakes during code review. - Insert safety — for
create/savemethods, writing the RLS-supplied org id (instead of trustingentity.organizationId) prevents a caller from fabricating an entity for a different org and inserting it through the right org's transaction.
// Good — orgId comes from RLS, name reflects the actual action.
findMemberByEmail: (email: string) =>
Effect.gen(function* () {
const sqlClient = (yield* SqlClient) as SqlClientShape<Operator>
return yield* sqlClient.query((db, organizationId) =>
db
.select({ id: members.id })
.from(members)
.where(and(eq(members.organizationId, organizationId), eq(members.email, email.toLowerCase())))
.limit(1),
)
}),
create: (invitation: Invitation) =>
Effect.gen(function* () {
const sqlClient = (yield* SqlClient) as SqlClientShape<Operator>
yield* sqlClient.query((db, organizationId) =>
db.insert(invitations).values({ ...row, organizationId }),
)
}),
// Bad — orgId is a redundant input the caller could mis-pass.
findMemberByEmail: ({ email, organizationId }: { email: string; organizationId: OrganizationId }) =>
/* … query((db) => …where(eq(members.organizationId, organizationId))) */,
// Bad — trusts the entity for the inserted org id; nothing stops a caller
// from passing an entity for a different org through this org's transaction.
create: (invitation: Invitation) =>
/* … query((db) => db.insert(invitations).values({ ...invitation })) */,
Naming: if dropping the explicit param makes the method's name redundant (e.g. listPendingByOrganizationId → listPending), rename it. The repository contract should describe what the method does, not which scope it's bound to — the scope is the RLS context by construction.
Exceptions — methods that legitimately operate outside the current RLS org are rare and should be obvious from the name and a comment:
findPublicPendingPreviewById(invitationId)— invite landing pages query before the invitee has authenticated, so there is no RLS context to lean on. Document the cross-org scope explicitly.- Admin/maintenance scripts that go through
withAdmin(...)rather thanwithPostgres(...).
The repository port's method signatures must list SqlClient in their R channel:
// packages/domain/projects/src/ports/project-repository.ts
export interface ProjectRepositoryShape {
findById(id: ProjectId): Effect.Effect<Project, NotFoundError | RepositoryError, SqlClient>
save(project: Project): Effect.Effect<void, RepositoryError, SqlClient>
}
SqlClient is marked @effect-leakable-service in @domain/shared, so the Effect linter accepts this intentional leak. Callers already have SqlClient in their R (via withPostgres(...) at the boundary), so the leak is invisible to them.
Postgres management
Connect to the development database:
docker compose exec postgres psql -U latitude -d latitude_development
Reset only the Postgres volume (without affecting other services):
pnpm --filter @platform/db-postgres pg:reset
This runs docker/reset-postgres.sh which stops postgres, removes the data-llm_postgres_data volume, restarts postgres, waits for it to be ready, runs migrations, and seeds the database.
Postgres schema conventions
All Drizzle table definitions in packages/platform/db-postgres/src/schema/ must follow these rules. Shared helpers live in schemaHelpers.ts.
Organization-scoped Postgres tables must use the repository RLS conventions.
- Use
latitudeSchema— never create a localpgSchema("latitude"). ImportlatitudeSchemafrom../schemaHelpers.ts. - Use
cuid("id").primaryKey()— every table's primary key must use thecuid()helper (varchar(24)with auto-generated CUID2). - Use
tzTimestamp(name)— never use rawtimestamp(name, { withTimezone: true }). ImporttzTimestampfrom the helpers. - Use
...timestamps()— every table that hascreatedAt/updatedAtmust spread thetimestamps()helper (includes$onUpdateFnonupdatedAt). - Use
organizationRLSPolicy(tableName)— every table with anorganization_idcolumn must include this helper in its third argument to enable row-level security. - No foreign keys — new Postgres tables must not add foreign key constraints. Do not use
.references()or manually createFOREIGN KEYconstraints. Referential integrity is enforced at the application/domain layer. Use indexes on relationship columns instead (e.g.index().on(t.datasetId)rather than.references(() => datasets.id)).
// ✅ Good - follows all conventions
export const projects = latitudeSchema.table(
"projects",
{
id: cuid("id").primaryKey(),
organizationId: text("organization_id").notNull(),
name: varchar("name", { length: 256 }).notNull(),
deletedAt: tzTimestamp("deleted_at"),
...timestamps(),
},
() => [organizationRLSPolicy("projects")],
)
Database migrations (Drizzle Kit)
Migration execution safety (agents)
Do not run Postgres migration commands (pg:generate, pg:generate:custom, pg:migrate, etc.) unless the user explicitly asked in this conversation. If migrations are needed but not requested, explain and wait for confirmation. ClickHouse / Weaviate follow the same policy in their respective skills.
Always use drizzle-kit for migrations. Never create manual SQL files in the drizzle folder.
Schema changes:
# Generate migration from schema changes
pnpm --filter @platform/db-postgres pg:generate "<name>"
# Create empty migration for custom SQL (RLS policies, seed data, etc.)
pnpm --filter @platform/db-postgres pg:generate:custom "<name>"
# Apply migrations
pnpm --filter @platform/db-postgres pg:migrate
Key points:
- Name is slugified automatically; always quote multi-word names (e.g.
"add users table"→add-users-table) - Postgres migration history is append-only in this repository. Do not edit existing Drizzle migration files; change the schema and generate a new migration instead.
- For additive changes to existing tables, prefer ordinary generated
ALTER TABLEmigrations over bespoke backfill choreography unless the change truly requires data rewriting. - Never manually create SQL files in the drizzle folder
- Use
IF NOT EXISTSin custom SQL for idempotency - Migrations are tracked in
drizzle.__drizzle_migrationstable
Repository port naming
Domain repository ports and method naming conventions (including Effect result shapes and when to use listBy* vs findBy*) live in dev-docs/repositories.md. Prefer that vocabulary for new Postgres-backed ports and when renaming existing methods.
Mapper conventions
When writing toDomain* and toInsertRow functions in platform repositories:
- Never hardcode field values. Every field on the domain entity must be read from the DB row (
row.fieldName), not assigned a literal (null,"",new Date()). If a field has no backing column, that is a schema gap — add the column or remove the field from the domain type. - Never use
as EntityTypecasts on mapper return values. These bypass TypeScript's structural check and hide type mismatches. Let the return type be inferred or explicitly annotated — the compiler will catch missing or incompatible fields. - Never coerce nullable columns with
?? fallbackto satisfy a non-nullable domain type. Surface the mismatch: either make the columnnotNull()or make the domain field nullable. **toInsertRowmust round-trip.** Every field written bytoInsertRowshould be readable bytoDomain*, and vice versa. A field present in the domain type but absent fromtoInsertRowmeans data is silently discarded on write.
Version History
- 2479822 Current 2026-08-20 10:36


