Agent Skillslatitude-dev/latitude-llm › database-postgres

database-postgres

GitHub

Postgres数据库开发技能,涵盖Drizzle ORM schema定义、SqlClient集成、RLS安全策略、迁移脚本及数据映射器实现。

.agents/skills/database-postgres/SKILL.md latitude-dev/latitude-llm

Trigger Scenarios

需要创建或修改Postgres数据库Schema 配置SqlClient与行级安全(RLS) 执行Postgres数据迁移或重置

Install

npx skills add latitude-dev/latitude-llm --skill database-postgres -g -y
More Options

Non-standard path

npx skills add https://github.com/latitude-dev/latitude-llm/tree/development/.agents/skills/database-postgres -g -y

Use without installing

npx skills use latitude-dev/latitude-llm@database-postgres

指定 Agent (Claude Code)

npx skills add latitude-dev/latitude-llm --skill database-postgres -a claude-code -g -y

安装 repo 全部 skill

npx skills add latitude-dev/latitude-llm --all -g -y

预览 repo 内 skill

npx skills add latitude-dev/latitude-llm --list

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 SqlClientLive with 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): SqlClient interface with transaction() and query() methods
  • Platform Layer (@platform/db-postgres): SqlClientLive implementation with automatic RLS context setting
  • App Layer (apps/*): Boundaries provide SqlClientLive with the request's organization context

Key behaviors:

  • Every transaction automatically sets app.current_organization_id session 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, SqlClientLive still 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/observability while 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/save methods, writing the RLS-supplied org id (instead of trusting entity.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. listPendingByOrganizationIdlistPending), 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 than withPostgres(...).

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.

  1. Use latitudeSchema — never create a local pgSchema("latitude"). Import latitudeSchema from ../schemaHelpers.ts.
  2. Use cuid("id").primaryKey() — every table's primary key must use the cuid() helper (varchar(24) with auto-generated CUID2).
  3. Use tzTimestamp(name) — never use raw timestamp(name, { withTimezone: true }). Import tzTimestamp from the helpers.
  4. Use ...timestamps() — every table that has createdAt/updatedAt must spread the timestamps() helper (includes $onUpdateFn on updatedAt).
  5. Use organizationRLSPolicy(tableName) — every table with an organization_id column must include this helper in its third argument to enable row-level security.
  6. No foreign keys — new Postgres tables must not add foreign key constraints. Do not use .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")],
)

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 TABLE migrations over bespoke backfill choreography unless the change truly requires data rewriting.
  • Never manually create SQL files in the drizzle folder
  • Use IF NOT EXISTS in custom SQL for idempotency
  • Migrations are tracked in drizzle.__drizzle_migrations table

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 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.
  • Never coerce nullable columns with ?? 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.

Version History

  • 2479822 Current 2026-08-20 10:36

Same Skill Collection

.agents/skills/agentation-watch-mode/SKILL.md
.agents/skills/analyze-problem/SKILL.md
.agents/skills/api-endpoints/SKILL.md
.agents/skills/architecture-boundaries/SKILL.md
.agents/skills/artifact-designer/SKILL.md
.agents/skills/async-jobs-and-events/SKILL.md
.agents/skills/authentication/SKILL.md
.agents/skills/backoffice/SKILL.md
.agents/skills/better-auth-best-practices/SKILL.md
.agents/skills/code-style/SKILL.md
.agents/skills/database-clickhouse/SKILL.md
.agents/skills/docs/SKILL.md
.agents/skills/effect-and-errors/SKILL.md
.agents/skills/env-configuration/SKILL.md
.agents/skills/explain-diff-html/SKILL.md
.agents/skills/fix-datadog-issues/SKILL.md
.agents/skills/gh-issue/SKILL.md
.agents/skills/humanizer/SKILL.md
.agents/skills/managing-maintenance-windows/SKILL.md
.agents/skills/mintlify-preview/SKILL.md
.agents/skills/notifications/SKILL.md
.agents/skills/production-release/SKILL.md
.agents/skills/review-pr-comments/SKILL.md
.agents/skills/testing/SKILL.md
.agents/skills/toolchain-commands/SKILL.md
.agents/skills/web-frontend/SKILL.md
.agents/skills/ci-watchdog/SKILL.md
.agents/skills/create-pr/SKILL.md
.agents/skills/temporal-developer/SKILL.md

Metadata

Files
0
Version
2479822
Hash
3fc6b32e
Indexed
2026-08-20 10:36

- 위키
Copyright © 2011-2026 iteam. Current version is 2.155.2. UTC+08:00, 2026-08-25 13:15
浙ICP备14020137号-1 $방문자$