chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,46 @@
|
||||
# @internal/dashboard-agent-db
|
||||
|
||||
The conversation datastore for the in-dashboard agent, isolated from the main
|
||||
Prisma database. Drizzle (postgres-js) over a dedicated `trigger_dashboard_agent`
|
||||
Postgres schema.
|
||||
|
||||
- **Cloud:** a separate PlanetScale Postgres database. The app connects over a
|
||||
pooled connection (`DASHBOARD_AGENT_DATABASE_URL`); migrations run over a direct
|
||||
(non-pooler) connection (`DASHBOARD_AGENT_DIRECT_URL`), since a transaction-mode
|
||||
pooler can't run the migrator.
|
||||
- **OSS / self-host:** falls back to the main `DATABASE_URL` (and `DIRECT_URL` for
|
||||
migrations); the tables live in the dedicated `trigger_dashboard_agent` schema,
|
||||
isolated from Prisma's `public`.
|
||||
|
||||
The schema is **foreign-key-free** — it references main entities (`organizationId`,
|
||||
`userId`) by id only, because in cloud it lives in a different database.
|
||||
|
||||
## Why a separate store
|
||||
|
||||
The agent runs as an ephemeral Trigger task and must have **no access to the main
|
||||
database or ClickHouse** (those go through the API). This is its own low-blast-radius
|
||||
store: the agent connects directly here to persist conversations, and the webapp
|
||||
connects here for the History tab. Conversation history *correctness* is owned by
|
||||
`chat.agent`'s built-in object-store snapshot — this DB is a display read-model
|
||||
(list chats, render a past chat, resume the transport), never the model's source
|
||||
of truth.
|
||||
|
||||
## Tables
|
||||
|
||||
- `chats` — one row per conversation: org/user scope, title, a `messages` JSONB
|
||||
display copy of the transcript, and `metadata` (the project/env context the chat
|
||||
ran in). Soft-deleted via `deleted_at`, pinned via `pinned_at`.
|
||||
- `chat_sessions` — live transport state keyed by `chat_id`: the session-scoped
|
||||
`public_access_token` and `last_event_id` for resume. Separate table so the
|
||||
secret token is isolated from list queries and the hot per-turn write stays off
|
||||
the conversation row's indexes.
|
||||
|
||||
## Migrations
|
||||
|
||||
```bash
|
||||
pnpm run db:generate # generate SQL migration from src/schema.ts (offline)
|
||||
pnpm run db:migrate # apply migrations (direct url: DASHBOARD_AGENT_DIRECT_URL, falling back to DASHBOARD_AGENT_DATABASE_URL / DIRECT_URL / DATABASE_URL)
|
||||
```
|
||||
|
||||
drizzle-kit is scoped to the `trigger_dashboard_agent` schema (`schemaFilter`), so
|
||||
pointing it at the main OSS database never touches Prisma's tables.
|
||||
@@ -0,0 +1,25 @@
|
||||
import { defineConfig } from "drizzle-kit";
|
||||
|
||||
// Migrations need a direct (non-pooler) connection; a transaction-mode pooler
|
||||
// can't run the migrator. Prefer the agent's direct url, then its pooled url,
|
||||
// then the main DIRECT_URL/DATABASE_URL (OSS single-database fallback; tables
|
||||
// still land in the trigger_dashboard_agent schema).
|
||||
const url =
|
||||
process.env.DASHBOARD_AGENT_DIRECT_URL ??
|
||||
process.env.DASHBOARD_AGENT_DATABASE_URL ??
|
||||
process.env.DIRECT_URL ??
|
||||
process.env.DATABASE_URL ??
|
||||
"postgres://placeholder"; // generate is offline; a real url is only needed for migrate/studio
|
||||
|
||||
export default defineConfig({
|
||||
schema: "./src/schema.ts",
|
||||
out: "./drizzle",
|
||||
dialect: "postgresql",
|
||||
// Only manage our schema — never introspect or diff Prisma's `public` schema.
|
||||
schemaFilter: ["trigger_dashboard_agent"],
|
||||
// Own journal table so dev (`drizzle-kit migrate`) and deploy (migrate.mjs)
|
||||
// share one history and we don't cross-poison the default
|
||||
// drizzle.__drizzle_migrations when the DB is shared. See migrate.mjs.
|
||||
migrations: { table: "__dashboard_agent_migrations", schema: "drizzle" },
|
||||
dbCredentials: { url },
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
CREATE SCHEMA IF NOT EXISTS "trigger_dashboard_agent";
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS "trigger_dashboard_agent"."chat_sessions" (
|
||||
"chat_id" text PRIMARY KEY NOT NULL,
|
||||
"public_access_token" text NOT NULL,
|
||||
"last_event_id" text,
|
||||
"run_id" text,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS "trigger_dashboard_agent"."chats" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"organization_id" text NOT NULL,
|
||||
"user_id" text NOT NULL,
|
||||
"title" text DEFAULT 'New chat' NOT NULL,
|
||||
"messages" jsonb DEFAULT '[]'::jsonb NOT NULL,
|
||||
"metadata" jsonb DEFAULT '{}'::jsonb NOT NULL,
|
||||
"pinned_at" timestamp with time zone,
|
||||
"deleted_at" timestamp with time zone,
|
||||
"last_message_at" timestamp with time zone,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "chats_org_user_last_msg_idx" ON "trigger_dashboard_agent"."chats" USING btree ("organization_id","user_id","last_message_at" DESC NULLS LAST) WHERE "trigger_dashboard_agent"."chats"."deleted_at" is null;
|
||||
@@ -0,0 +1,38 @@
|
||||
CREATE TABLE IF NOT EXISTS "trigger_dashboard_agent"."chat_turn_evals" (
|
||||
"chat_id" text NOT NULL,
|
||||
"turn" integer NOT NULL,
|
||||
"organization_id" text NOT NULL,
|
||||
"user_id" text NOT NULL,
|
||||
"agent_run_id" text,
|
||||
"eval_run_id" text,
|
||||
"project_ref" text,
|
||||
"environment" text,
|
||||
"current_page" text,
|
||||
"model" text,
|
||||
"prompt_slug" text,
|
||||
"prompt_version" integer,
|
||||
"tools_used" jsonb DEFAULT '[]'::jsonb NOT NULL,
|
||||
"tool_error" boolean DEFAULT false NOT NULL,
|
||||
"judge_model" text,
|
||||
"score_grounded" smallint,
|
||||
"score_answered" smallint,
|
||||
"score_concise" smallint,
|
||||
"passed" boolean,
|
||||
"intent_category" text,
|
||||
"outcome" text,
|
||||
"sentiment" text,
|
||||
"capability_gap" boolean DEFAULT false NOT NULL,
|
||||
"docs_gap" boolean DEFAULT false NOT NULL,
|
||||
"support_opportunity" boolean DEFAULT false NOT NULL,
|
||||
"feature_request" boolean DEFAULT false NOT NULL,
|
||||
"topics" jsonb DEFAULT '[]'::jsonb NOT NULL,
|
||||
"signals" jsonb DEFAULT '[]'::jsonb NOT NULL,
|
||||
"summary" text,
|
||||
"user_text" text,
|
||||
"judge" jsonb,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "chat_turn_evals_chat_id_turn_pk" PRIMARY KEY("chat_id","turn")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "chat_turn_evals_org_created_idx" ON "trigger_dashboard_agent"."chat_turn_evals" USING btree ("organization_id","created_at" DESC NULLS LAST);--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "chat_turn_evals_org_opps_idx" ON "trigger_dashboard_agent"."chat_turn_evals" USING btree ("organization_id","created_at" DESC NULLS LAST) WHERE "trigger_dashboard_agent"."chat_turn_evals"."capability_gap" or "trigger_dashboard_agent"."chat_turn_evals"."docs_gap" or "trigger_dashboard_agent"."chat_turn_evals"."support_opportunity" or "trigger_dashboard_agent"."chat_turn_evals"."feature_request";
|
||||
@@ -0,0 +1,178 @@
|
||||
{
|
||||
"id": "512ce1a7-b31d-4644-9639-3e124b22e52e",
|
||||
"prevId": "00000000-0000-0000-0000-000000000000",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"tables": {
|
||||
"trigger_dashboard_agent.chat_sessions": {
|
||||
"name": "chat_sessions",
|
||||
"schema": "trigger_dashboard_agent",
|
||||
"columns": {
|
||||
"chat_id": {
|
||||
"name": "chat_id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"public_access_token": {
|
||||
"name": "public_access_token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"last_event_id": {
|
||||
"name": "last_event_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"run_id": {
|
||||
"name": "run_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"trigger_dashboard_agent.chats": {
|
||||
"name": "chats",
|
||||
"schema": "trigger_dashboard_agent",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"organization_id": {
|
||||
"name": "organization_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"title": {
|
||||
"name": "title",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'New chat'"
|
||||
},
|
||||
"messages": {
|
||||
"name": "messages",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'[]'::jsonb"
|
||||
},
|
||||
"metadata": {
|
||||
"name": "metadata",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'{}'::jsonb"
|
||||
},
|
||||
"pinned_at": {
|
||||
"name": "pinned_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"deleted_at": {
|
||||
"name": "deleted_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"last_message_at": {
|
||||
"name": "last_message_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"chats_org_user_last_msg_idx": {
|
||||
"name": "chats_org_user_last_msg_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "organization_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "user_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "last_message_at",
|
||||
"isExpression": false,
|
||||
"asc": false,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"where": "\"trigger_dashboard_agent\".\"chats\".\"deleted_at\" is null",
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
}
|
||||
},
|
||||
"enums": {},
|
||||
"schemas": {
|
||||
"trigger_dashboard_agent": "trigger_dashboard_agent"
|
||||
},
|
||||
"sequences": {},
|
||||
"roles": {},
|
||||
"policies": {},
|
||||
"views": {},
|
||||
"_meta": {
|
||||
"columns": {},
|
||||
"schemas": {},
|
||||
"tables": {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,441 @@
|
||||
{
|
||||
"id": "7a42a0cf-9933-4381-adad-98c25105465b",
|
||||
"prevId": "512ce1a7-b31d-4644-9639-3e124b22e52e",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"tables": {
|
||||
"trigger_dashboard_agent.chat_sessions": {
|
||||
"name": "chat_sessions",
|
||||
"schema": "trigger_dashboard_agent",
|
||||
"columns": {
|
||||
"chat_id": {
|
||||
"name": "chat_id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"public_access_token": {
|
||||
"name": "public_access_token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"last_event_id": {
|
||||
"name": "last_event_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"run_id": {
|
||||
"name": "run_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"trigger_dashboard_agent.chat_turn_evals": {
|
||||
"name": "chat_turn_evals",
|
||||
"schema": "trigger_dashboard_agent",
|
||||
"columns": {
|
||||
"chat_id": {
|
||||
"name": "chat_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"turn": {
|
||||
"name": "turn",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"organization_id": {
|
||||
"name": "organization_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"agent_run_id": {
|
||||
"name": "agent_run_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"eval_run_id": {
|
||||
"name": "eval_run_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"project_ref": {
|
||||
"name": "project_ref",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"environment": {
|
||||
"name": "environment",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"current_page": {
|
||||
"name": "current_page",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"model": {
|
||||
"name": "model",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"prompt_slug": {
|
||||
"name": "prompt_slug",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"prompt_version": {
|
||||
"name": "prompt_version",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"tools_used": {
|
||||
"name": "tools_used",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'[]'::jsonb"
|
||||
},
|
||||
"tool_error": {
|
||||
"name": "tool_error",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": false
|
||||
},
|
||||
"judge_model": {
|
||||
"name": "judge_model",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"score_grounded": {
|
||||
"name": "score_grounded",
|
||||
"type": "smallint",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"score_answered": {
|
||||
"name": "score_answered",
|
||||
"type": "smallint",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"score_concise": {
|
||||
"name": "score_concise",
|
||||
"type": "smallint",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"passed": {
|
||||
"name": "passed",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"intent_category": {
|
||||
"name": "intent_category",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"outcome": {
|
||||
"name": "outcome",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"sentiment": {
|
||||
"name": "sentiment",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"capability_gap": {
|
||||
"name": "capability_gap",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": false
|
||||
},
|
||||
"docs_gap": {
|
||||
"name": "docs_gap",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": false
|
||||
},
|
||||
"support_opportunity": {
|
||||
"name": "support_opportunity",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": false
|
||||
},
|
||||
"feature_request": {
|
||||
"name": "feature_request",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": false
|
||||
},
|
||||
"topics": {
|
||||
"name": "topics",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'[]'::jsonb"
|
||||
},
|
||||
"signals": {
|
||||
"name": "signals",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'[]'::jsonb"
|
||||
},
|
||||
"summary": {
|
||||
"name": "summary",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"user_text": {
|
||||
"name": "user_text",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"judge": {
|
||||
"name": "judge",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"chat_turn_evals_org_created_idx": {
|
||||
"name": "chat_turn_evals_org_created_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "organization_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "created_at",
|
||||
"isExpression": false,
|
||||
"asc": false,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
},
|
||||
"chat_turn_evals_org_opps_idx": {
|
||||
"name": "chat_turn_evals_org_opps_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "organization_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "created_at",
|
||||
"isExpression": false,
|
||||
"asc": false,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"where": "\"trigger_dashboard_agent\".\"chat_turn_evals\".\"capability_gap\" or \"trigger_dashboard_agent\".\"chat_turn_evals\".\"docs_gap\" or \"trigger_dashboard_agent\".\"chat_turn_evals\".\"support_opportunity\" or \"trigger_dashboard_agent\".\"chat_turn_evals\".\"feature_request\"",
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {
|
||||
"chat_turn_evals_chat_id_turn_pk": {
|
||||
"name": "chat_turn_evals_chat_id_turn_pk",
|
||||
"columns": ["chat_id", "turn"]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"trigger_dashboard_agent.chats": {
|
||||
"name": "chats",
|
||||
"schema": "trigger_dashboard_agent",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"organization_id": {
|
||||
"name": "organization_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"title": {
|
||||
"name": "title",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'New chat'"
|
||||
},
|
||||
"messages": {
|
||||
"name": "messages",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'[]'::jsonb"
|
||||
},
|
||||
"metadata": {
|
||||
"name": "metadata",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'{}'::jsonb"
|
||||
},
|
||||
"pinned_at": {
|
||||
"name": "pinned_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"deleted_at": {
|
||||
"name": "deleted_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"last_message_at": {
|
||||
"name": "last_message_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"chats_org_user_last_msg_idx": {
|
||||
"name": "chats_org_user_last_msg_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "organization_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "user_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "last_message_at",
|
||||
"isExpression": false,
|
||||
"asc": false,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"where": "\"trigger_dashboard_agent\".\"chats\".\"deleted_at\" is null",
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
}
|
||||
},
|
||||
"enums": {},
|
||||
"schemas": {
|
||||
"trigger_dashboard_agent": "trigger_dashboard_agent"
|
||||
},
|
||||
"sequences": {},
|
||||
"roles": {},
|
||||
"policies": {},
|
||||
"views": {},
|
||||
"_meta": {
|
||||
"columns": {},
|
||||
"schemas": {},
|
||||
"tables": {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"entries": [
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "7",
|
||||
"when": 1781711036274,
|
||||
"tag": "0000_magenta_lilandra",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 1,
|
||||
"version": "7",
|
||||
"when": 1781990914081,
|
||||
"tag": "0001_slimy_living_tribunal",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
// Pending-status check for the `trigger_dashboard_agent` schema (sibling of
|
||||
// migrate.mjs). Exit 0 = up to date, 1 = pending, 2 = error.
|
||||
import { readFileSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import postgres from "postgres";
|
||||
|
||||
const MIGRATIONS_SCHEMA = "drizzle";
|
||||
const MIGRATIONS_TABLE = "__dashboard_agent_migrations";
|
||||
|
||||
// Match migrate.mjs: same precedence, and expand `${VAR}` refs (see migrate.mjs).
|
||||
const connectionString = (
|
||||
process.env.DASHBOARD_AGENT_DIRECT_URL ??
|
||||
process.env.DASHBOARD_AGENT_DATABASE_URL ??
|
||||
process.env.DIRECT_URL ??
|
||||
process.env.DATABASE_URL
|
||||
)?.replace(/\$\{(\w+)\}/g, (_, k) => process.env[k] ?? "");
|
||||
|
||||
if (!connectionString) {
|
||||
console.error(
|
||||
"[dashboard-agent-db] No database url set (DASHBOARD_AGENT_DIRECT_URL / DASHBOARD_AGENT_DATABASE_URL / DIRECT_URL / DATABASE_URL); cannot check status."
|
||||
);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
// Match migrate.mjs: drop the Prisma-style `?schema=` param postgres.js forwards.
|
||||
function normalizeConnectionString(value) {
|
||||
try {
|
||||
const url = new URL(value);
|
||||
url.searchParams.delete("schema");
|
||||
return url.toString();
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
const journalPath = join(dirname(fileURLToPath(import.meta.url)), "drizzle/meta/_journal.json");
|
||||
const sql = postgres(normalizeConnectionString(connectionString), {
|
||||
max: 1,
|
||||
prepare: false,
|
||||
onnotice: () => {},
|
||||
});
|
||||
|
||||
async function main() {
|
||||
const journal = JSON.parse(readFileSync(journalPath, "utf-8"));
|
||||
const entries = [...journal.entries].sort((a, b) => a.when - b.when);
|
||||
|
||||
let lastAppliedAt = -1;
|
||||
try {
|
||||
const rows = await sql`SELECT MAX(created_at)::bigint AS last FROM ${sql(
|
||||
MIGRATIONS_SCHEMA
|
||||
)}.${sql(MIGRATIONS_TABLE)}`;
|
||||
lastAppliedAt = rows[0].last === null ? -1 : Number(rows[0].last);
|
||||
} catch (err) {
|
||||
// 42P01: journal table absent (fresh database), so nothing is applied.
|
||||
if (err.code !== "42P01") throw err;
|
||||
}
|
||||
|
||||
const pending = entries.filter((e) => e.when > lastAppliedAt);
|
||||
console.log(`${entries.length} migration(s) found, ${entries.length - pending.length} applied`);
|
||||
|
||||
if (pending.length > 0) {
|
||||
console.log(`${pending.length} pending migration(s):`);
|
||||
for (const e of pending) console.log(` - ${e.tag}`);
|
||||
return 1;
|
||||
}
|
||||
|
||||
console.log("Dashboard agent schema is up to date");
|
||||
return 0;
|
||||
}
|
||||
|
||||
main()
|
||||
.then((code) => sql.end({ timeout: 5 }).then(() => process.exit(code)))
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
sql.end({ timeout: 5 }).finally(() => process.exit(2));
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
// Production migration runner for the `trigger_dashboard_agent` schema.
|
||||
//
|
||||
// Runs under plain `node migrate.mjs` in the built image: `drizzle-orm` and
|
||||
// `postgres` are runtime dependencies, so this needs no `drizzle-kit`, `tsx`,
|
||||
// or build step (keeps the image lean). The OSS container runs this from its
|
||||
// entrypoint; cloud runs it out-of-band against its own database.
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { drizzle } from "drizzle-orm/postgres-js";
|
||||
import { migrate } from "drizzle-orm/postgres-js/migrator";
|
||||
import postgres from "postgres";
|
||||
|
||||
// Migrations need a direct (non-pooler) connection; a transaction-mode pooler
|
||||
// can't run the migrator. Prefer the agent's direct url, then its pooled url,
|
||||
// then the main DIRECT_URL/DATABASE_URL (OSS single-database fallback; tables
|
||||
// still land in the `trigger_dashboard_agent` schema).
|
||||
// `.replace` expands `${VAR}` refs (e.g. the repo .env's DIRECT_URL=${DATABASE_URL});
|
||||
// node's --env-file loads them literally, unlike Prisma's dotenv-expand.
|
||||
const connectionString = (
|
||||
process.env.DASHBOARD_AGENT_DIRECT_URL ??
|
||||
process.env.DASHBOARD_AGENT_DATABASE_URL ??
|
||||
process.env.DIRECT_URL ??
|
||||
process.env.DATABASE_URL
|
||||
)?.replace(/\$\{(\w+)\}/g, (_, k) => process.env[k] ?? "");
|
||||
|
||||
if (!connectionString) {
|
||||
console.error(
|
||||
"[dashboard-agent-db] No database url set (DASHBOARD_AGENT_DIRECT_URL / DASHBOARD_AGENT_DATABASE_URL / DIRECT_URL / DATABASE_URL); cannot migrate."
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Prisma-style URLs carry `?schema=...`; postgres.js forwards unknown query
|
||||
// params as server startup config and Postgres rejects `schema`. Our tables are
|
||||
// schema-qualified, so the param is unnecessary — drop it.
|
||||
function normalizeConnectionString(value) {
|
||||
try {
|
||||
const url = new URL(value);
|
||||
url.searchParams.delete("schema");
|
||||
return url.toString();
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
const migrationsFolder = join(dirname(fileURLToPath(import.meta.url)), "drizzle");
|
||||
const sql = postgres(normalizeConnectionString(connectionString), {
|
||||
max: 1,
|
||||
prepare: false,
|
||||
// Silence the "schema/relation already exists, skipping" notices the journal's
|
||||
// idempotent CREATE IF NOT EXISTS emits on every re-run, so restart logs stay clean.
|
||||
onnotice: () => {},
|
||||
});
|
||||
|
||||
try {
|
||||
// Track our history in a DEDICATED journal table. Drizzle's migrator reads the
|
||||
// latest row from <schema>.<table> by created_at and skips any journal entry
|
||||
// dated at or before it. The default `drizzle.__drizzle_migrations` is shared
|
||||
// by every Drizzle app, so when this DB is shared (OSS single-database fallback
|
||||
// and the enterprise-image E2E gate) billing's journal rows poison ours: a
|
||||
// billing row dated between our 0000 and 0001 makes the migrator skip 0000 (the
|
||||
// CREATE SCHEMA) and run 0001 against a schema that never got created. An own
|
||||
// table keeps our history independent (enterprise/db does the same with
|
||||
// __enterprise_migrations; billing keeps the default).
|
||||
//
|
||||
// The table stays in the `drizzle` schema, not our data schema, so 0000's
|
||||
// `CREATE SCHEMA "trigger_dashboard_agent"` doesn't collide with the schema the
|
||||
// migrator pre-creates for its journal.
|
||||
await migrate(drizzle(sql), {
|
||||
migrationsFolder,
|
||||
migrationsTable: "__dashboard_agent_migrations",
|
||||
});
|
||||
console.log("[dashboard-agent-db] migrations complete");
|
||||
} finally {
|
||||
await sql.end();
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "@internal/dashboard-agent-db",
|
||||
"private": true,
|
||||
"version": "0.0.1",
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"drizzle-orm": "^0.45.0",
|
||||
"postgres": "^3.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"drizzle-kit": "^0.31.0"
|
||||
},
|
||||
"scripts": {
|
||||
"typecheck": "tsc --noEmit",
|
||||
"db:generate": "drizzle-kit generate",
|
||||
"db:migrate": "drizzle-kit migrate",
|
||||
"db:migrate:deploy": "node --env-file-if-exists=../../.env migrate.mjs",
|
||||
"db:migrate:status": "node --env-file-if-exists=../../.env migrate-status.mjs",
|
||||
"db:studio": "drizzle-kit studio"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { drizzle, type PostgresJsDatabase } from "drizzle-orm/postgres-js";
|
||||
import postgres, { type Sql } from "postgres";
|
||||
import * as schema from "./schema.js";
|
||||
|
||||
export type DashboardAgentSchema = typeof schema;
|
||||
export type DashboardAgentDb = PostgresJsDatabase<DashboardAgentSchema>;
|
||||
|
||||
export interface DashboardAgentDbClient {
|
||||
db: DashboardAgentDb;
|
||||
sql: Sql;
|
||||
/** Close the underlying connection pool. Call on agent run shutdown. */
|
||||
close: () => Promise<void>;
|
||||
}
|
||||
|
||||
export interface CreateDashboardAgentDbOptions {
|
||||
/**
|
||||
* Max client-side pool size. Keep small — the agent runs in many short-lived
|
||||
* task containers and PlanetScale's pooler does the real connection pooling.
|
||||
*/
|
||||
max?: number;
|
||||
/** Idle timeout (seconds) so suspended agent runs release connections. */
|
||||
idleTimeoutSeconds?: number;
|
||||
/** Connection timeout (seconds). */
|
||||
connectTimeoutSeconds?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a Drizzle client for the dashboard-agent datastore. Shared by the agent
|
||||
* task (its own persistence) and the webapp (History tab + frontend actions).
|
||||
*
|
||||
* Connections go through a transaction-mode pooler (PlanetScale / PgBouncer-style),
|
||||
* so prepared statements are disabled — they don't survive a connection being
|
||||
* handed to a different client between checkouts.
|
||||
*/
|
||||
// Prisma-style URLs carry `?schema=...`; postgres.js forwards unknown query
|
||||
// params as server startup config and Postgres rejects `schema`. Our tables are
|
||||
// schema-qualified, so the param is unnecessary — drop it. Matters for the OSS
|
||||
// fallback to the main DATABASE_URL.
|
||||
function normalizeConnectionString(connectionString: string): string {
|
||||
try {
|
||||
const url = new URL(connectionString);
|
||||
url.searchParams.delete("schema");
|
||||
return url.toString();
|
||||
} catch {
|
||||
return connectionString;
|
||||
}
|
||||
}
|
||||
|
||||
export function createDashboardAgentDb(
|
||||
connectionString: string,
|
||||
options: CreateDashboardAgentDbOptions = {}
|
||||
): DashboardAgentDbClient {
|
||||
const sql = postgres(normalizeConnectionString(connectionString), {
|
||||
max: options.max ?? 5,
|
||||
idle_timeout: options.idleTimeoutSeconds ?? 20,
|
||||
connect_timeout: options.connectTimeoutSeconds ?? 10,
|
||||
prepare: false,
|
||||
});
|
||||
|
||||
const db = drizzle(sql, { schema });
|
||||
|
||||
return {
|
||||
db,
|
||||
sql,
|
||||
close: () => sql.end(),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from "./schema.js";
|
||||
export * from "./client.js";
|
||||
export * from "./queries.js";
|
||||
@@ -0,0 +1,271 @@
|
||||
import { and, desc, eq, isNull, sql } from "drizzle-orm";
|
||||
import type { DashboardAgentDb } from "./client.js";
|
||||
import {
|
||||
chats,
|
||||
chatSessions,
|
||||
chatTurnEvals,
|
||||
type ChatSession,
|
||||
type NewChatTurnEval,
|
||||
} from "./schema.js";
|
||||
|
||||
/**
|
||||
* The access-pattern layer. Every query that touches user data is scoped by
|
||||
* `organizationId` and/or `userId` so tenant isolation lives in one place —
|
||||
* callers can't forget the `where`. Shared by the agent task and the webapp.
|
||||
*/
|
||||
|
||||
/** Placeholder title for a chat with no generated or user-set title yet. */
|
||||
export const DEFAULT_CHAT_TITLE = "New chat";
|
||||
|
||||
export interface ChatListItem {
|
||||
id: string;
|
||||
title: string;
|
||||
pinnedAt: Date | null;
|
||||
lastMessageAt: Date | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
metadata: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* #1 History tab: a user's chats within an org, recent first, pinned on top.
|
||||
* Deliberately selects metadata columns only — never `messages` (large blob) or
|
||||
* the session token. Covered by `chats_org_user_last_msg_idx`.
|
||||
*/
|
||||
export async function listChats(
|
||||
db: DashboardAgentDb,
|
||||
params: { organizationId: string; userId: string; limit?: number }
|
||||
): Promise<ChatListItem[]> {
|
||||
return db
|
||||
.select({
|
||||
id: chats.id,
|
||||
title: chats.title,
|
||||
pinnedAt: chats.pinnedAt,
|
||||
lastMessageAt: chats.lastMessageAt,
|
||||
createdAt: chats.createdAt,
|
||||
updatedAt: chats.updatedAt,
|
||||
metadata: chats.metadata,
|
||||
})
|
||||
.from(chats)
|
||||
.where(
|
||||
and(
|
||||
eq(chats.organizationId, params.organizationId),
|
||||
eq(chats.userId, params.userId),
|
||||
isNull(chats.deletedAt)
|
||||
)
|
||||
)
|
||||
.orderBy(sql`${chats.pinnedAt} desc nulls last`, desc(chats.lastMessageAt))
|
||||
.limit(params.limit ?? 50);
|
||||
}
|
||||
|
||||
/**
|
||||
* #2 Open a chat: the stored transcript for `useChat`'s initialMessages.
|
||||
* Scoped to the owner; returns null if missing/deleted/not theirs.
|
||||
*/
|
||||
export async function getChatMessages(
|
||||
db: DashboardAgentDb,
|
||||
params: { chatId: string; userId: string }
|
||||
): Promise<unknown[] | null> {
|
||||
const rows = await db
|
||||
.select({ messages: chats.messages })
|
||||
.from(chats)
|
||||
.where(
|
||||
and(eq(chats.id, params.chatId), eq(chats.userId, params.userId), isNull(chats.deletedAt))
|
||||
)
|
||||
.limit(1);
|
||||
return rows[0]?.messages ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* #3 Resume the transport on first paint: the session-scoped token + stream
|
||||
* cursor. Joins `chats` to scope by owner (chat_sessions has no userId).
|
||||
*/
|
||||
export async function getSession(
|
||||
db: DashboardAgentDb,
|
||||
params: { chatId: string; userId: string }
|
||||
): Promise<ChatSession | null> {
|
||||
const rows = await db
|
||||
.select({
|
||||
chatId: chatSessions.chatId,
|
||||
publicAccessToken: chatSessions.publicAccessToken,
|
||||
lastEventId: chatSessions.lastEventId,
|
||||
runId: chatSessions.runId,
|
||||
updatedAt: chatSessions.updatedAt,
|
||||
})
|
||||
.from(chatSessions)
|
||||
.innerJoin(chats, eq(chats.id, chatSessions.chatId))
|
||||
.where(and(eq(chatSessions.chatId, params.chatId), eq(chats.userId, params.userId)))
|
||||
.limit(1);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Owner check: true when a non-deleted chat with this id belongs to the user.
|
||||
* Used to authorize chat-scoped actions (e.g. minting a session token) before
|
||||
* a session row necessarily exists.
|
||||
*/
|
||||
export async function chatExists(
|
||||
db: DashboardAgentDb,
|
||||
params: { chatId: string; userId: string; organizationId: string }
|
||||
): Promise<boolean> {
|
||||
const rows = await db
|
||||
.select({ id: chats.id })
|
||||
.from(chats)
|
||||
.where(
|
||||
and(
|
||||
eq(chats.id, params.chatId),
|
||||
eq(chats.organizationId, params.organizationId),
|
||||
eq(chats.userId, params.userId),
|
||||
isNull(chats.deletedAt)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
return rows.length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* #4 Create a chat. Idempotent (`onConflictDoNothing`) so the webapp's "new
|
||||
* chat" insert and the agent's defensive `onChatStart` ensure can't race into a
|
||||
* duplicate-key error.
|
||||
*/
|
||||
export async function createChat(
|
||||
db: DashboardAgentDb,
|
||||
params: {
|
||||
id: string;
|
||||
organizationId: string;
|
||||
userId: string;
|
||||
title?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
): Promise<void> {
|
||||
await db
|
||||
.insert(chats)
|
||||
.values({
|
||||
id: params.id,
|
||||
organizationId: params.organizationId,
|
||||
userId: params.userId,
|
||||
title: params.title ?? DEFAULT_CHAT_TITLE,
|
||||
metadata: params.metadata ?? {},
|
||||
})
|
||||
.onConflictDoNothing();
|
||||
}
|
||||
|
||||
/** The agent's defensive ensure-exists in `onChatStart` / `onPreload`. */
|
||||
export const ensureChat = createChat;
|
||||
|
||||
/** #5 Rename. */
|
||||
export async function renameChat(
|
||||
db: DashboardAgentDb,
|
||||
params: { chatId: string; userId: string; title: string }
|
||||
): Promise<void> {
|
||||
await db
|
||||
.update(chats)
|
||||
.set({ title: params.title, updatedAt: sql`now()` })
|
||||
.where(and(eq(chats.id, params.chatId), eq(chats.userId, params.userId)));
|
||||
}
|
||||
|
||||
/**
|
||||
* #5 Set an auto-generated title, but only while the chat still has the default
|
||||
* title. Conditional on `DEFAULT_CHAT_TITLE` so the background title write can't
|
||||
* clobber a user rename, and so it's a safe no-op if it runs more than once.
|
||||
*/
|
||||
export async function setChatTitleIfDefault(
|
||||
db: DashboardAgentDb,
|
||||
params: { chatId: string; title: string }
|
||||
): Promise<void> {
|
||||
await db
|
||||
.update(chats)
|
||||
.set({ title: params.title, updatedAt: sql`now()` })
|
||||
.where(
|
||||
and(eq(chats.id, params.chatId), eq(chats.title, DEFAULT_CHAT_TITLE), isNull(chats.deletedAt))
|
||||
);
|
||||
}
|
||||
|
||||
/** #5 Pin / unpin. */
|
||||
export async function setChatPinned(
|
||||
db: DashboardAgentDb,
|
||||
params: { chatId: string; userId: string; pinned: boolean }
|
||||
): Promise<void> {
|
||||
await db
|
||||
.update(chats)
|
||||
.set({ pinnedAt: params.pinned ? sql`now()` : null, updatedAt: sql`now()` })
|
||||
.where(and(eq(chats.id, params.chatId), eq(chats.userId, params.userId)));
|
||||
}
|
||||
|
||||
/** #5 Soft-delete. */
|
||||
export async function softDeleteChat(
|
||||
db: DashboardAgentDb,
|
||||
params: { chatId: string; userId: string }
|
||||
): Promise<void> {
|
||||
await db
|
||||
.update(chats)
|
||||
.set({ deletedAt: sql`now()`, updatedAt: sql`now()` })
|
||||
.where(and(eq(chats.id, params.chatId), eq(chats.userId, params.userId)));
|
||||
}
|
||||
|
||||
/**
|
||||
* #6a Persist messages only (agent `onTurnStart` — make the user's message
|
||||
* durable in the display copy before the model starts streaming).
|
||||
*/
|
||||
export async function persistMessages(
|
||||
db: DashboardAgentDb,
|
||||
params: { chatId: string; messages: unknown[] }
|
||||
): Promise<void> {
|
||||
await db
|
||||
.update(chats)
|
||||
.set({ messages: params.messages, lastMessageAt: sql`now()`, updatedAt: sql`now()` })
|
||||
.where(eq(chats.id, params.chatId));
|
||||
}
|
||||
|
||||
/**
|
||||
* #6b Persist a completed turn (agent `onTurnComplete`): the finalized transcript
|
||||
* and the refreshed session state, in one transaction. Atomicity matters — on
|
||||
* the next page load the frontend reads `messages` and `lastEventId` in parallel;
|
||||
* a torn write can resume from a stale cursor and double-render the last turn.
|
||||
*/
|
||||
export async function persistTurn(
|
||||
db: DashboardAgentDb,
|
||||
params: {
|
||||
chatId: string;
|
||||
messages: unknown[];
|
||||
session: {
|
||||
publicAccessToken: string;
|
||||
lastEventId?: string | null;
|
||||
runId?: string | null;
|
||||
};
|
||||
}
|
||||
): Promise<void> {
|
||||
await db.transaction(async (tx) => {
|
||||
await tx
|
||||
.update(chats)
|
||||
.set({ messages: params.messages, lastMessageAt: sql`now()`, updatedAt: sql`now()` })
|
||||
.where(eq(chats.id, params.chatId));
|
||||
|
||||
await tx
|
||||
.insert(chatSessions)
|
||||
.values({
|
||||
chatId: params.chatId,
|
||||
publicAccessToken: params.session.publicAccessToken,
|
||||
lastEventId: params.session.lastEventId ?? null,
|
||||
runId: params.session.runId ?? null,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: chatSessions.chatId,
|
||||
set: {
|
||||
publicAccessToken: params.session.publicAccessToken,
|
||||
lastEventId: params.session.lastEventId ?? null,
|
||||
runId: params.session.runId ?? null,
|
||||
updatedAt: sql`now()`,
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* #11 Record a turn eval. Idempotent on `(chatId, turn)` so a re-delivered turn
|
||||
* (the eval task is triggered with an idempotency key, and may still retry) can
|
||||
* never write a second row.
|
||||
*/
|
||||
export async function insertTurnEval(db: DashboardAgentDb, row: NewChatTurnEval): Promise<void> {
|
||||
await db.insert(chatTurnEvals).values(row).onConflictDoNothing();
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import { sql } from "drizzle-orm";
|
||||
import {
|
||||
boolean,
|
||||
index,
|
||||
integer,
|
||||
jsonb,
|
||||
pgSchema,
|
||||
primaryKey,
|
||||
smallint,
|
||||
text,
|
||||
timestamp,
|
||||
} from "drizzle-orm/pg-core";
|
||||
|
||||
/**
|
||||
* All dashboard-agent tables live in a dedicated Postgres schema. In cloud this
|
||||
* is a separate PlanetScale database; in OSS it isolates the agent's tables from
|
||||
* Prisma's `public` schema inside the main database. Tables are schema-qualified
|
||||
* explicitly, so no `search_path` configuration is required on the connection.
|
||||
*/
|
||||
export const dashboardAgentSchema = pgSchema("trigger_dashboard_agent");
|
||||
|
||||
/**
|
||||
* One row per conversation. Scope is **org + user** — a chat is not bound to a
|
||||
* single project/env; the project/env it ran in (and any extra ones the user
|
||||
* adds to context) live in `metadata`, because one conversation can range over
|
||||
* several projects/envs.
|
||||
*
|
||||
* `messages` is a display copy of the `UIMessage[]` transcript. The model's
|
||||
* source of truth for history is chat.agent's built-in object-store snapshot,
|
||||
* not this column — a stale write here can make the History view lag a turn but
|
||||
* can never corrupt what the model sees.
|
||||
*
|
||||
* Foreign-key-free: `organizationId` / `userId` are main-DB ids with no FK,
|
||||
* because in cloud this table lives in a different database.
|
||||
*/
|
||||
export const chats = dashboardAgentSchema.table(
|
||||
"chats",
|
||||
{
|
||||
// = chatId = the Session externalId. Stable for the life of the thread.
|
||||
id: text("id").primaryKey(),
|
||||
organizationId: text("organization_id").notNull(),
|
||||
userId: text("user_id").notNull(),
|
||||
title: text("title").notNull().default("New chat"),
|
||||
// UIMessage[] display copy — never read to rebuild model context.
|
||||
messages: jsonb("messages").$type<unknown[]>().notNull().default([]),
|
||||
// Project/env context + model choice + page snapshot. Flexible by design.
|
||||
metadata: jsonb("metadata").$type<Record<string, unknown>>().notNull().default({}),
|
||||
pinnedAt: timestamp("pinned_at", { withTimezone: true }),
|
||||
deletedAt: timestamp("deleted_at", { withTimezone: true }),
|
||||
lastMessageAt: timestamp("last_message_at", { withTimezone: true }),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(t) => [
|
||||
// History tab: "my chats in this org, recent first". Partial index keeps
|
||||
// soft-deleted rows out of the hot path.
|
||||
index("chats_org_user_last_msg_idx")
|
||||
.on(t.organizationId, t.userId, t.lastMessageAt.desc())
|
||||
.where(sql`${t.deletedAt} is null`),
|
||||
]
|
||||
);
|
||||
|
||||
/**
|
||||
* Live transport state the frontend needs to resume a chat on first paint,
|
||||
* keyed by chatId. Separate from `chats` so the secret token is isolated from
|
||||
* list queries and the hot per-turn write stays off the conversation row.
|
||||
*
|
||||
* No `userId` here on purpose: the agent's `onTurnComplete` event doesn't carry
|
||||
* `clientData`, and ownership is already enforced via the `chats` row — the
|
||||
* resume query joins `chats` to scope by owner (see `getSession`).
|
||||
*/
|
||||
export const chatSessions = dashboardAgentSchema.table("chat_sessions", {
|
||||
chatId: text("chat_id").primaryKey(), // = chats.id (FK-free, cross-db)
|
||||
publicAccessToken: text("public_access_token").notNull(),
|
||||
lastEventId: text("last_event_id"),
|
||||
runId: text("run_id"), // telemetry / "view this run"
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
});
|
||||
|
||||
/**
|
||||
* One row per evaluated turn, written by the `dashboard-agent-eval-turn` task
|
||||
* that the agent triggers from `onTurnComplete`. Two kinds of data: quality
|
||||
* scores (did the agent answer well, grounded in its tool results) and insight
|
||||
* classification (what the user wanted, whether we have a product/docs/support
|
||||
* gap). Append-only analytics; the higher-level views ("top capability gaps",
|
||||
* "what users struggle with") are aggregations over these rows, not stored here.
|
||||
*
|
||||
* Structured columns are the things we filter, alert, and chart on; the evolving
|
||||
* taxonomy (typed `signals`) and the raw judge output live in JSONB so adding a
|
||||
* signal type is never a migration. Org + user scoped, FK-free (cross-db), with
|
||||
* a composite `(chatId, turn)` key so a re-delivered turn can't double-insert.
|
||||
*/
|
||||
export const chatTurnEvals = dashboardAgentSchema.table(
|
||||
"chat_turn_evals",
|
||||
{
|
||||
chatId: text("chat_id").notNull(), // = chats.id
|
||||
turn: integer("turn").notNull(), // 0-indexed turn within the chat
|
||||
organizationId: text("organization_id").notNull(),
|
||||
userId: text("user_id").notNull(),
|
||||
agentRunId: text("agent_run_id"), // the chat.agent run that produced the turn
|
||||
evalRunId: text("eval_run_id"), // the eval task's own run, for tracing
|
||||
// Per-turn context (the project/env/page the user was looking at).
|
||||
projectRef: text("project_ref"),
|
||||
environment: text("environment"),
|
||||
currentPage: text("current_page"),
|
||||
// Operational + model. `promptVersion` lets a quality drop be attributed to a
|
||||
// dashboard-managed prompt edit that never went through CI.
|
||||
model: text("model"),
|
||||
promptSlug: text("prompt_slug"),
|
||||
promptVersion: integer("prompt_version"),
|
||||
toolsUsed: jsonb("tools_used").$type<string[]>().notNull().default([]),
|
||||
toolError: boolean("tool_error").notNull().default(false),
|
||||
// Quality (LLM judge), scored 1-5.
|
||||
judgeModel: text("judge_model"),
|
||||
scoreGrounded: smallint("score_grounded"),
|
||||
scoreAnswered: smallint("score_answered"),
|
||||
scoreConcise: smallint("score_concise"),
|
||||
passed: boolean("passed"),
|
||||
// Insight classification — the filterable summary of `signals`.
|
||||
intentCategory: text("intent_category"),
|
||||
outcome: text("outcome"), // resolved | partial | unresolved | deflected
|
||||
sentiment: text("sentiment"),
|
||||
capabilityGap: boolean("capability_gap").notNull().default(false),
|
||||
docsGap: boolean("docs_gap").notNull().default(false),
|
||||
supportOpportunity: boolean("support_opportunity").notNull().default(false),
|
||||
featureRequest: boolean("feature_request").notNull().default(false),
|
||||
// Rich / evolving.
|
||||
topics: jsonb("topics").$type<string[]>().notNull().default([]),
|
||||
signals: jsonb("signals").$type<unknown[]>().notNull().default([]),
|
||||
summary: text("summary"),
|
||||
userText: text("user_text"), // the user's question (clustering input)
|
||||
judge: jsonb("judge").$type<Record<string, unknown>>(), // full raw verdict
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(t) => [
|
||||
primaryKey({ columns: [t.chatId, t.turn] }),
|
||||
// "what happened in this org lately", recent first.
|
||||
index("chat_turn_evals_org_created_idx").on(t.organizationId, t.createdAt.desc()),
|
||||
// The opportunities feed: gaps, struggles, support, feature asks.
|
||||
index("chat_turn_evals_org_opps_idx")
|
||||
.on(t.organizationId, t.createdAt.desc())
|
||||
.where(
|
||||
sql`${t.capabilityGap} or ${t.docsGap} or ${t.supportOpportunity} or ${t.featureRequest}`
|
||||
),
|
||||
]
|
||||
);
|
||||
|
||||
export type Chat = typeof chats.$inferSelect;
|
||||
export type NewChat = typeof chats.$inferInsert;
|
||||
export type ChatSession = typeof chatSessions.$inferSelect;
|
||||
export type NewChatSession = typeof chatSessions.$inferInsert;
|
||||
export type ChatTurnEval = typeof chatTurnEvals.$inferSelect;
|
||||
export type NewChatTurnEval = typeof chatTurnEvals.$inferInsert;
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"lib": ["ES2020"],
|
||||
"module": "Node16",
|
||||
"moduleResolution": "Node16",
|
||||
"moduleDetection": "force",
|
||||
"verbatimModuleSyntax": false,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"isolatedModules": true,
|
||||
"preserveWatchOutput": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true,
|
||||
"strict": true
|
||||
},
|
||||
"exclude": ["node_modules", "drizzle"]
|
||||
}
|
||||
Reference in New Issue
Block a user