chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,191 @@
|
||||
---
|
||||
name: drizzle
|
||||
description: Use this skill when writing or modifying Drizzle ORM schemas, queries, or migrations in this repo — specifically the `@internal/dashboard-agent-db` package (the dashboard agent's conversation datastore). Covers pg-core schema definition, the postgres-js driver, drizzle-kit migrations, and this repo's conventions: a dedicated Postgres schema, foreign-key-free cross-database design, pooler-safe connections, and the access-pattern query layer. Drizzle is NOT the main database — that's Prisma.
|
||||
allowed-tools: Read, Write, Edit, Glob, Grep, Bash
|
||||
---
|
||||
|
||||
# Drizzle ORM (this repo)
|
||||
|
||||
Drizzle is used in exactly one place: **`internal-packages/dashboard-agent-db`** (`@internal/dashboard-agent-db`), the in-dashboard agent's conversation store. Everything else in the monorepo is **Prisma** (`@trigger.dev/database`). Keep them separate.
|
||||
|
||||
Pinned versions: **`drizzle-orm` ^0.45**, **`drizzle-kit` ^0.31** (dev), **`postgres` ^3.4** (postgres.js driver). drizzle-orm and drizzle-kit are intentionally on different version lines — 0.31.x is the correct companion for 0.45.x, there is no peer dependency between them.
|
||||
|
||||
## Critical rules
|
||||
|
||||
1. **Drizzle is only the agent's own datastore.** The agent (and its task bundle) must have **no access to the main Prisma database or ClickHouse**. Never import the Prisma client into the agent task or into `@internal/dashboard-agent-db`. Main data is reached via the API, not Drizzle.
|
||||
2. **Foreign-key-free.** In cloud this DB is a *separate* PlanetScale database, so it can't FK into the main DB. Reference main entities (`organizationId`, `userId`, …) **by id only — never `.references()`**. Joins happen in app code; tenant scoping is enforced in the query layer.
|
||||
3. **One dedicated Postgres schema.** All tables live under `pgSchema("trigger_dashboard_agent")` so they're schema-qualified and isolated from Prisma's `public` schema (this is what makes the OSS single-database fallback safe).
|
||||
4. **Pooler-safe connections.** Connections go through a transaction-mode pooler (PlanetScale / PgBouncer-style), so postgres.js must run with **`prepare: false`** — prepared statements don't survive a connection being handed to another client between checkouts.
|
||||
5. **Node16 module resolution.** Relative imports need explicit **`.js`** extensions (`import { chats } from "./schema.js"`), even though the source is `.ts`.
|
||||
6. **Scope every user query.** All queries that touch user data go through `src/queries.ts` and are scoped by `organizationId` / `userId`, so callers can't forget the `where`. Don't write ad-hoc cross-tenant queries elsewhere.
|
||||
|
||||
## Package layout
|
||||
|
||||
```text
|
||||
internal-packages/dashboard-agent-db/
|
||||
drizzle.config.ts # drizzle-kit config (schema path, out dir, schemaFilter)
|
||||
drizzle/ # generated migrations (committed)
|
||||
src/
|
||||
schema.ts # pgSchema + table definitions
|
||||
client.ts # createDashboardAgentDb() — postgres.js + drizzle
|
||||
queries.ts # the access-pattern layer (org/user-scoped)
|
||||
index.ts # barrel: re-exports schema, client, queries
|
||||
```
|
||||
|
||||
`package.json` points `main`/`types` at `./src/index.ts` (consumed as source, no build step) — same as other simple internal packages.
|
||||
|
||||
## Schema (pg-core)
|
||||
|
||||
Use `pgSchema(...).table(...)`, not the bare `pgTable`, so tables land in the dedicated schema. ([schemas](https://orm.drizzle.team/docs/schemas), [pg column types](https://orm.drizzle.team/docs/column-types/pg), [indexes](https://orm.drizzle.team/docs/indexes-constraints))
|
||||
|
||||
```ts
|
||||
import { sql } from "drizzle-orm";
|
||||
import { index, jsonb, pgSchema, text, timestamp } from "drizzle-orm/pg-core";
|
||||
|
||||
export const dashboardAgentSchema = pgSchema("trigger_dashboard_agent");
|
||||
|
||||
export const chats = dashboardAgentSchema.table(
|
||||
"chats",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
organizationId: text("organization_id").notNull(), // FK-free: id only, no .references()
|
||||
userId: text("user_id").notNull(),
|
||||
title: text("title").notNull().default("New chat"),
|
||||
// JSONB with a typed view; .default([]) / .default({}) emit '[]'::jsonb / '{}'::jsonb
|
||||
messages: jsonb("messages").$type<unknown[]>().notNull().default([]),
|
||||
metadata: jsonb("metadata").$type<Record<string, unknown>>().notNull().default({}),
|
||||
deletedAt: timestamp("deleted_at", { withTimezone: true }), // soft delete
|
||||
lastMessageAt: timestamp("last_message_at", { withTimezone: true }),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
// Extra config returns an ARRAY in drizzle-orm 0.36+ (not an object).
|
||||
(t) => [
|
||||
// Partial + ordered composite index. `.desc()` on the column, `.where(sql`...`)` for partial.
|
||||
index("chats_org_user_last_msg_idx")
|
||||
.on(t.organizationId, t.userId, t.lastMessageAt.desc())
|
||||
.where(sql`${t.deletedAt} is null`),
|
||||
]
|
||||
);
|
||||
|
||||
// Inferred row types for the query layer + consumers.
|
||||
export type Chat = typeof chats.$inferSelect;
|
||||
export type NewChat = typeof chats.$inferInsert;
|
||||
```
|
||||
|
||||
Notes:
|
||||
- `timestamp(..., { withTimezone: true })` → `timestamp with time zone`. Use `.defaultNow()` for `DEFAULT now()`.
|
||||
- For a "newest first, nulls last" sort the partial index uses `.desc()`; the *query* uses raw `sql` for `NULLS LAST` (see below).
|
||||
- Don't add `.references()` — see critical rule 2.
|
||||
|
||||
## Client (postgres.js + drizzle)
|
||||
|
||||
([connect overview](https://orm.drizzle.team/docs/connect-overview)) One small pool, `prepare: false`. In the agent task create it once in `onBoot` (per-process); in the webapp wrap it in the `singleton(...)` helper.
|
||||
|
||||
```ts
|
||||
import { drizzle, type PostgresJsDatabase } from "drizzle-orm/postgres-js";
|
||||
import postgres, { type Sql } from "postgres";
|
||||
import * as schema from "./schema.js";
|
||||
|
||||
export type DashboardAgentDb = PostgresJsDatabase<typeof schema>;
|
||||
|
||||
export function createDashboardAgentDb(connectionString: string, opts: { max?: number } = {}) {
|
||||
const sql: Sql = postgres(connectionString, {
|
||||
max: opts.max ?? 5, // small — the pooler does the real pooling
|
||||
idle_timeout: 20, // release conns when an agent run suspends
|
||||
prepare: false, // REQUIRED for transaction-mode poolers
|
||||
});
|
||||
return { db: drizzle(sql, { schema }), sql, close: () => sql.end() };
|
||||
}
|
||||
```
|
||||
|
||||
## Queries (the access-pattern layer)
|
||||
|
||||
([select](https://orm.drizzle.team/docs/select), [insert](https://orm.drizzle.team/docs/insert), [operators](https://orm.drizzle.team/docs/operators), [transactions](https://orm.drizzle.team/docs/transactions), [joins](https://orm.drizzle.team/docs/joins))
|
||||
|
||||
```ts
|
||||
import { and, desc, eq, isNull, sql } from "drizzle-orm";
|
||||
|
||||
// Select EXPLICIT columns for list views — never select a large blob (messages)
|
||||
// or a secret (tokens) you don't need. `NULLS LAST` needs raw sql in orderBy.
|
||||
await db
|
||||
.select({ id: chats.id, title: chats.title, lastMessageAt: chats.lastMessageAt })
|
||||
.from(chats)
|
||||
.where(and(eq(chats.organizationId, orgId), eq(chats.userId, userId), isNull(chats.deletedAt)))
|
||||
.orderBy(sql`${chats.pinnedAt} desc nulls last`, desc(chats.lastMessageAt))
|
||||
.limit(50);
|
||||
|
||||
// Idempotent create (avoids a duplicate-key race between two writers).
|
||||
await db.insert(chats).values({ id, organizationId: orgId, userId }).onConflictDoNothing();
|
||||
|
||||
// Upsert.
|
||||
await db
|
||||
.insert(chatSessions)
|
||||
.values({ chatId, publicAccessToken })
|
||||
.onConflictDoUpdate({ target: chatSessions.chatId, set: { publicAccessToken, updatedAt: sql`now()` } });
|
||||
|
||||
// Owner-scope a join (this DB is FK-free, so enforce ownership in the query).
|
||||
await db
|
||||
.select({ /* session cols */ })
|
||||
.from(chatSessions)
|
||||
.innerJoin(chats, eq(chats.id, chatSessions.chatId))
|
||||
.where(and(eq(chatSessions.chatId, chatId), eq(chats.userId, userId)));
|
||||
|
||||
// Multi-write that must be consistent on the next read → one transaction.
|
||||
await db.transaction(async (tx) => {
|
||||
await tx.update(chats).set({ messages, updatedAt: sql`now()` }).where(eq(chats.id, chatId));
|
||||
await tx.insert(chatSessions).values({ /* ... */ }).onConflictDoUpdate({ /* ... */ });
|
||||
});
|
||||
```
|
||||
|
||||
Use `sql\`now()\`` for DB-side timestamps in updates.
|
||||
|
||||
## Migrations (drizzle-kit)
|
||||
|
||||
([kit overview](https://orm.drizzle.team/docs/kit-overview), [generate](https://orm.drizzle.team/docs/drizzle-kit-generate), [migrate](https://orm.drizzle.team/docs/drizzle-kit-migrate))
|
||||
|
||||
`drizzle.config.ts` must set **`schemaFilter`** so drizzle-kit only ever manages our schema — never Prisma's `public` (critical in the OSS single-DB fallback):
|
||||
|
||||
```ts
|
||||
import { defineConfig } from "drizzle-kit";
|
||||
export default defineConfig({
|
||||
schema: "./src/schema.ts",
|
||||
out: "./drizzle",
|
||||
dialect: "postgresql",
|
||||
schemaFilter: ["trigger_dashboard_agent"],
|
||||
dbCredentials: { url: process.env.DASHBOARD_AGENT_DATABASE_URL ?? process.env.DATABASE_URL ?? "postgres://placeholder" },
|
||||
});
|
||||
```
|
||||
|
||||
Workflow:
|
||||
|
||||
```bash
|
||||
cd internal-packages/dashboard-agent-db
|
||||
pnpm run db:generate # diff schema.ts → emit SQL into drizzle/. OFFLINE (no DB needed).
|
||||
# review the generated drizzle/000N_*.sql before committing
|
||||
pnpm run db:migrate # apply pending migrations. Needs a real DATABASE URL.
|
||||
```
|
||||
|
||||
- `db:generate` is **offline** — it only reads `schema.ts`, so you can verify a schema change compiles to valid DDL with no database. Use it as a fast check.
|
||||
- drizzle-kit names migration files with a **random suffix** (`0000_magenta_lilandra.sql`). Don't regenerate a committed migration just to "refresh" it — that churns the filename. After the first migration is committed, schema changes produce a **new** `000N_*.sql`; commit that.
|
||||
- Generated DDL for a new schema is one `CREATE SCHEMA` + schema-qualified `CREATE TABLE`s + indexes, **no foreign keys** (by design here).
|
||||
|
||||
## Common gotchas
|
||||
|
||||
- **`prepare: false`** is not optional with a pooler — without it you'll get prepared-statement errors under load.
|
||||
- **Missing `.js` extension** on a relative import → TS2835 under Node16 resolution.
|
||||
- **Extra-config callback returns an array** `(t) => [ ... ]` in drizzle-orm 0.36+. The old object form `(t) => ({ ... })` is deprecated.
|
||||
- **`NULLS LAST` / `NULLS FIRST`** aren't on the `desc()` helper — use raw `sql\`col desc nulls last\`` in `orderBy`.
|
||||
- **Don't `SELECT *` into list views** — explicitly pick columns so you never ship a megabyte `messages` blob or a session token to a list query.
|
||||
- **Adding a dependency**: edit `package.json`, then `pnpm i` from the repo root (never `pnpm add`). Mind the repo's `minimumReleaseAge` (3 days) — pin with a caret range and let pnpm resolve an old-enough version.
|
||||
|
||||
## Reference (official docs)
|
||||
|
||||
- Schema declaration — https://orm.drizzle.team/docs/sql-schema-declaration
|
||||
- PostgreSQL column types — https://orm.drizzle.team/docs/column-types/pg
|
||||
- Schemas (`pgSchema`) — https://orm.drizzle.team/docs/schemas
|
||||
- Indexes & constraints — https://orm.drizzle.team/docs/indexes-constraints
|
||||
- Connect (postgres-js) — https://orm.drizzle.team/docs/connect-overview
|
||||
- Select / Insert / Update / Delete — https://orm.drizzle.team/docs/select · /insert · /update · /delete
|
||||
- Joins / Operators — https://orm.drizzle.team/docs/joins · /operators
|
||||
- Transactions — https://orm.drizzle.team/docs/transactions
|
||||
- drizzle-kit (generate / migrate / push) — https://orm.drizzle.team/docs/kit-overview
|
||||
Reference in New Issue
Block a user