.agents/skills/database-postgres/SKILL.md
Drizzle schema, repositories, RLS, SqlClient wiring, Postgres migrations, psql / reset, or platform mappers (toDomain* / toInsertRow).
npx skillsauth add latitude-dev/latitude-llm database-postgresInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
When to use: Drizzle schema, repositories, RLS, SqlClient wiring, Postgres migrations, psql / reset, or platform mappers (toDomain* / toInsertRow).
packages/platform/db-postgresSqlClientLive with organization context for RLS enforcementAll Postgres access flows through SqlClient—a domain-level service that abstracts database operations and enforces organization scoping via RLS.
Architecture:
@domain/shared): SqlClient interface with transaction() and query() methods@platform/db-postgres): SqlClientLive implementation with automatic RLS context settingapps/*): Boundaries provide SqlClientLive with the request's organization contextKey behaviors:
app.current_organization_id session variableRepositoryErrorUsage in boundaries (apps):
// apps/api/src/routes/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):
// packages/platform/db-postgres/src/repositories/project-repository.ts
export const ProjectRepositoryLive = Layer.effect(
ProjectRepository,
Effect.gen(function* () {
const sqlClient = (yield* SqlClient) as SqlClientShape<Operator>
return {
findById: (id) =>
sqlClient
.query((db) => db.select().from(projects).where(eq(projects.id, id)))
.pipe(Effect.flatMap(...)),
save: (project) =>
Effect.gen(function* () {
yield* sqlClient.query((db) =>
db.insert(projects).values(row).onConflictDoUpdate({...})
)
}),
}
})
)
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.
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.
latitudeSchema — never create a local pgSchema("latitude"). Import latitudeSchema from ../schemaHelpers.ts.cuid("id").primaryKey() — every table's primary key must use the cuid() helper (varchar(24) with auto-generated CUID2).tzTimestamp(name) — never use raw timestamp(name, { withTimezone: true }). Import tzTimestamp from the helpers....timestamps() — every table that has createdAt/updatedAt must spread the timestamps() helper (includes $onUpdateFn on updatedAt).organizationRLSPolicy(tableName) — every table with an organization_id column must include this helper in its third argument to enable row-level security..references() or manually create FOREIGN KEY constraints. 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")],
)
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:
"add users table" → add-users-table)ALTER TABLE migrations over bespoke backfill choreography unless the change truly requires data rewriting.IF NOT EXISTS in custom SQL for idempotencydrizzle.__drizzle_migrations tableWhen writing toDomain* and toInsertRow functions in platform repositories:
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.as EntityType casts 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.?? fallback to satisfy a non-nullable domain type. Surface the mismatch: either make the column notNull() or make the domain field nullable.toInsertRow must round-trip. Every field written by toInsertRow should be readable by toDomain*, and vice versa. A field present in the domain type but absent from toInsertRow means data is silently discarded on write.data-ai
Continuous Agentation annotation handling. Use when the user says "watch mode", asks you to watch for Agentation annotations, process feedback as it arrives, or keep fixing annotation-driven changes until told to stop or a timeout is reached.
development
apps/web UI — routes, @repo/ui, TanStack Start server functions and collections, forms, Tailwind layout rules, design-system updates, and useEffect / useMountEffect policy.
tools
Installing dependencies, running dev/build/test/lint, filtering packages, single-test runs, git hooks, preparing a clone (.env.development / .env.test), or Docker-backed local services and dev servers.
tools
Writing or debugging tests, choosing unit vs integration style, Postgres/ClickHouse tests, regenerating ClickHouse test schema, or exporting test helpers from packages without pulling test code into production bundles.