diff --git a/.github/scripts/detect-rebake-lanes.py b/.github/scripts/detect-rebake-lanes.py index a90a1817c..d8de0dc20 100755 --- a/.github/scripts/detect-rebake-lanes.py +++ b/.github/scripts/detect-rebake-lanes.py @@ -272,7 +272,8 @@ ".github/actions/", ".github/scripts/detect-rebake-lanes.py"] PROTO_PATHS = ["crates/engram-protocol/proto/", "buf.gen.yaml"] -WEB_PATHS = ["web/", "orchestrator/packages/spec-document/"] +WEB_PATHS = ["web/", "orchestrator/packages/spec-document/", + "orchestrator/packages/user-preferences/"] # The public docs + landing site (site/, Astro + Starlight). Its content is # hand-written under site/; the API reference is generated at build time # from the public app protos, so those gate the lane too. site-deploy.yml @@ -639,6 +640,7 @@ def image_flag(name): return bake_all_l or proto_l or any_path(cs, [ "web/", "orchestrator/packages/spec-document/", + "orchestrator/packages/user-preferences/", "docker/web.Dockerfile", ]) if name == "orchestrator": diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 906acf955..8bfe0c66d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -194,6 +194,13 @@ jobs: .github/scripts/detect-rebake-lanes.py --stdin <<< 'site/package.json' grep -qxF 'test_site=true' /tmp/lanes-site.env grep -qxF 'test_web=false' /tmp/lanes-site.env + # A shared preference contract must test and rebuild both consumers. + env GITHUB_OUTPUT=/tmp/lanes-preferences.env python3 \ + .github/scripts/detect-rebake-lanes.py --stdin \ + <<< 'orchestrator/packages/user-preferences/src/index.ts' + grep -qxF 'test_web=true' /tmp/lanes-preferences.env + grep -qxF 'test_orchestrator=true' /tmp/lanes-preferences.env + grep -qxF 'images_matrix=["web", "orchestrator"]' /tmp/lanes-preferences.env # ADR 0122: the deploy templates are user-facing product surface once # the repo is open source — a broken chart or module is a broken diff --git a/AGENTS.md b/AGENTS.md index b6623db93..cbd1274da 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -141,9 +141,10 @@ change can affect run (orchestrator-only → only orchestrator; web-only → onl a coordinator-crate change → Rust lanes incl. macOS/VZ + firecracker + e2e). `bake-images.yml` bakes only the changed images. A CI-workflow or detector change re-runs everything. -**The only required status check is the aggregator `CI Gate`** — it always runs, `needs:` -EVERY lane (Linux, macOS, firecracker, AND the e2e stack), and passes iff each lane -succeeded-or-skipped. When you add a new lane, add it to the gate's `needs:` (and give it a +**Required status checks are `CI Gate` and `cla`.** The CLA workflow checks the +contributor signature. The aggregator `CI Gate` always runs and lists every lane +(Linux, macOS, firecracker, and the e2e stack) in `needs:`. It passes only when +each lane succeeds or is skipped. When you add a new lane, add it to the gate's `needs:` (and give it a detector flag) — **never add an individual lane as a required check**, or a path-skipped lane will wedge the merge queue. **Admin merges skip combined-state validation** — each PR was green in isolation, not together. Land batches through the merge queue, diff --git a/docker/orchestrator.Dockerfile b/docker/orchestrator.Dockerfile index 12d48a214..1a5c1a620 100644 --- a/docker/orchestrator.Dockerfile +++ b/docker/orchestrator.Dockerfile @@ -30,6 +30,7 @@ WORKDIR /app/orchestrator # runtime runs the .ts entry directly via Bun, no build/transpile step. COPY orchestrator/package.json orchestrator/bun.lock ./ COPY orchestrator/packages/spec-document ./packages/spec-document +COPY orchestrator/packages/user-preferences ./packages/user-preferences RUN --mount=type=cache,target=/root/.bun/install/cache \ bun install --frozen-lockfile --production diff --git a/docker/web.Dockerfile b/docker/web.Dockerfile index 331df2c06..b109ef4dd 100644 --- a/docker/web.Dockerfile +++ b/docker/web.Dockerfile @@ -23,6 +23,7 @@ RUN npm install -g pnpm@9 # Lockfile first so the install layer stays warm across source edits. COPY web/package.json web/pnpm-lock.yaml /src/web/ COPY orchestrator/packages/spec-document /src/orchestrator/packages/spec-document +COPY orchestrator/packages/user-preferences /src/orchestrator/packages/user-preferences RUN --mount=type=cache,target=/root/.local/share/pnpm/store \ pnpm install --frozen-lockfile diff --git a/docs/adr/0124-user-appearance-preferences.md b/docs/adr/0124-user-appearance-preferences.md new file mode 100644 index 000000000..cb13fe883 --- /dev/null +++ b/docs/adr/0124-user-appearance-preferences.md @@ -0,0 +1,85 @@ +# ADR 0124: User appearance preferences + +Status: Accepted (2026-10-06) + +## Context + +Appearance settings currently use one local-storage key per browser origin. The +key has no user identity. Two accounts in one browser share colors and fonts, +and a second device cannot read the saved settings. The orchestrator already +owns account data through Better Auth and Postgres (ADR 0051). + +## Decision + +Store one versioned preference document per authenticated user in the +orchestrator database. The first document contains appearance settings only: +theme mode, selected scheme, custom schemes, and font IDs. Store the system +theme mode itself; each device resolves its current light or dark theme. + +Use a separate `user_preferences` table with a cascading foreign key to the +auth user, a JSONB document, a revision, and a save timestamp. Add a new Drizzle +migration. The deployment migration job applies it through `drizzle-orm`. + +Expose authenticated GET and PATCH `/api/v1/me/preferences` routes. The session +supplies the user ID. GET returns defaults at revision zero for a missing row. +PATCH replaces the appearance group and requires the expected revision. An +atomic insert or conditional update advances the revision. A stale write +returns HTTP 409 with the current document. GET does not create a row. + +Put types, defaults, and validation in `@engrams/user-preferences`, a shared +TypeScript package. Limit request bodies to 64 KiB and custom schemes to 50. +Keep DOM, color-token generation, and font loading in the web app. + +Resolve identity before reading a user cache. Cache confirmed preferences and +pending edits under a user-specific key. Apply edits immediately and use one +save queue per mounted account. Retain failed edits and show a retry action. +Refresh on startup, window focus, and reconnect. Never replace pending edits +with a remote update without an explicit user choice. On account change, reset +appearance, cancel requests, and ignore responses from the previous account. + +The existing global browser value has no owner. Offer an explicit import only +when the server has no saved document. Remove that value after a successful +import or an explicit dismissal. Do not assign it to an account automatically. + +## Consequences + +Settings survive browser-data removal and follow the user to other devices. +Local storage remains optional: failure to access it does not prevent server +saves. Revision checks prevent silent lost updates between tabs and devices. +Unsaved edits can require a conflict choice after another device changes the +server document. Anonymous pages use default appearance without account data. + +Both web and orchestrator images must include the shared package. Changes to +it must select both test lanes and both image builds. Tests must cover real +Postgres concurrency, account isolation, retry, migration, and browser reloads. + +## Implementation record + +The commit chain starts with `bed755c7` (Proposed decision), followed by +`b55d235f95f9` (shared contract, migration, API, web controls, and tests). This +record accepts the decision after those checks. + +Migration `0095_user_preferences` follows the current migration journal. It +adds the account document and revision constraint without changing applied +migrations. Both Docker images include the shared package, and path detection +selects both consumers. + +The initial implementation needed two corrections during review. A clean tab +could clear another tab's cached draft. Cache writes now preserve foreign +pending edits and their base revision. Terminal selection text now has an +explicit foreground color with at least 4.5:1 contrast; reference ANSI colors +remain unchanged. + +The appearance controls include custom color schemes, independent fonts, and +Dracula, Catppuccin, Nord, Solarized, and Gruvbox presets. Optional Inter and +Fira Code fonts use local Fontsource assets with their complete license notices. + +Validation passed: 1,046 web tests in 151 files; 20 shared-contract, API, +real-Postgres, and migration-journal tests; both browser suites under the +production CSP; web formatting, lint, and build; and orchestrator type checking. +A fresh Postgres 18 database accepted all migrations. A repeat migration made +no changes. Workflow YAML parses, the shared-package path selects both test +lanes and images, and actionlint has no findings beyond those on the base branch. + +Rust checks were not needed for this web and orchestrator change. The +current-head CI Gate supplies the final repository result before merge. diff --git a/orchestrator/bun.lock b/orchestrator/bun.lock index ff21b5838..8f382072e 100644 --- a/orchestrator/bun.lock +++ b/orchestrator/bun.lock @@ -12,6 +12,7 @@ "@connectrpc/connect-node": "^2.1.1", "@dbos-inc/dbos-sdk": "^4.21.6", "@engrams/spec-document": "file:./packages/spec-document", + "@engrams/user-preferences": "file:./packages/user-preferences", "@hono/node-server": "^2.0.4", "@jitl/quickjs-singlefile-mjs-release-sync": "^0.32.0", "@openrouter/ai-sdk-provider": "^3.0.0", @@ -88,6 +89,8 @@ "@engrams/spec-document": ["@engrams/spec-document@file:packages/spec-document", { "dependencies": { "prosemirror-model": "^1.25.4", "prosemirror-transform": "^1.10.5", "y-prosemirror": "^1.3.7", "yjs": "^13.6.27" } }], + "@engrams/user-preferences": ["@engrams/user-preferences@file:packages/user-preferences", { "dependencies": { "zod": "^4.4.3" } }], + "@esbuild-kit/core-utils": ["@esbuild-kit/core-utils@3.3.2", "", { "dependencies": { "esbuild": "~0.18.20", "source-map-support": "^0.5.21" } }, "sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ=="], "@esbuild-kit/esm-loader": ["@esbuild-kit/esm-loader@2.6.5", "", { "dependencies": { "@esbuild-kit/core-utils": "^3.3.2", "get-tsconfig": "^4.7.0" } }, "sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA=="], diff --git a/orchestrator/drizzle/0095_user_preferences.sql b/orchestrator/drizzle/0095_user_preferences.sql new file mode 100644 index 000000000..b14f50371 --- /dev/null +++ b/orchestrator/drizzle/0095_user_preferences.sql @@ -0,0 +1,9 @@ +CREATE TABLE "user_preferences" ( + "user_id" text PRIMARY KEY NOT NULL, + "document" jsonb NOT NULL, + "revision" integer NOT NULL, + "updated_at" timestamp with time zone NOT NULL, + CONSTRAINT "user_preferences_revision_positive" CHECK ("user_preferences"."revision" > 0) +); +--> statement-breakpoint +ALTER TABLE "user_preferences" ADD CONSTRAINT "user_preferences_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action; \ No newline at end of file diff --git a/orchestrator/drizzle/meta/0095_snapshot.json b/orchestrator/drizzle/meta/0095_snapshot.json new file mode 100644 index 000000000..cfb323c5d --- /dev/null +++ b/orchestrator/drizzle/meta/0095_snapshot.json @@ -0,0 +1,7455 @@ +{ + "id": "a05b385c-3d50-47b5-8d33-680913acc9cc", + "prevId": "22cf52e4-5468-470e-aae6-227b114c6a1c", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "account_userId_idx": { + "name": "account_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.apikey": { + "name": "apikey", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "config_id": { + "name": "config_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'default'" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "start": { + "name": "start", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refill_interval": { + "name": "refill_interval", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "refill_amount": { + "name": "refill_amount", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_refill_at": { + "name": "last_refill_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "rate_limit_enabled": { + "name": "rate_limit_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "rate_limit_time_window": { + "name": "rate_limit_time_window", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "rate_limit_max": { + "name": "rate_limit_max", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "request_count": { + "name": "request_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "remaining": { + "name": "remaining", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_request": { + "name": "last_request", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "permissions": { + "name": "permissions", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "apikey_reference_id_idx": { + "name": "apikey_reference_id_idx", + "columns": [ + { + "expression": "reference_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "apikey_key_idx": { + "name": "apikey_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "apikey_reference_id_user_id_fk": { + "name": "apikey_reference_id_user_id_fk", + "tableFrom": "apikey", + "tableTo": "user", + "columnsFrom": [ + "reference_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.artifact": { + "name": "artifact", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_name": { + "name": "file_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "visibility": { + "name": "visibility", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'private'" + }, + "current_version": { + "name": "current_version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "artifact_owner_idx": { + "name": "artifact_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "artifact_visibility_idx": { + "name": "artifact_visibility_idx", + "columns": [ + { + "expression": "visibility", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "artifact_owner_user_id_user_id_fk": { + "name": "artifact_owner_user_id_user_id_fk", + "tableFrom": "artifact", + "tableTo": "user", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.artifact_version": { + "name": "artifact_version", + "schema": "", + "columns": { + "artifact_id": { + "name": "artifact_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "coord_artifact_id": { + "name": "coord_artifact_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "media_type": { + "name": "media_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size_bytes": { + "name": "size_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "artifact_version_session_idx": { + "name": "artifact_version_session_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "artifact_version_artifact_id_artifact_id_fk": { + "name": "artifact_version_artifact_id_artifact_id_fk", + "tableFrom": "artifact_version", + "tableTo": "artifact", + "columnsFrom": [ + "artifact_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "artifact_version_artifact_id_version_pk": { + "name": "artifact_version_artifact_id_version_pk", + "columns": [ + "artifact_id", + "version" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.automation": { + "name": "automation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'user'" + }, + "builtin_key": { + "name": "builtin_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "current_version": { + "name": "current_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "inputs": { + "name": "inputs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "block_overrides": { + "name": "block_overrides", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "end_sessions_on_finish": { + "name": "end_sessions_on_finish", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "next_fire_at": { + "name": "next_fire_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_fired_at": { + "name": "last_fired_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "draft_session_id": { + "name": "draft_session_id", + "type": "text", + "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()" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "automation_due_idx": { + "name": "automation_due_idx", + "columns": [ + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_fire_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "automation_draft_session_idx": { + "name": "automation_draft_session_idx", + "columns": [ + { + "expression": "draft_session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "automation_builtin_key_unique": { + "name": "automation_builtin_key_unique", + "columns": [ + { + "expression": "builtin_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "builtin_key is not null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.automation_concurrency_claim": { + "name": "automation_concurrency_claim", + "schema": "", + "columns": { + "automation_id": { + "name": "automation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "concurrency_key": { + "name": "concurrency_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "automation_concurrency_claim_automation_id_automation_id_fk": { + "name": "automation_concurrency_claim_automation_id_automation_id_fk", + "tableFrom": "automation_concurrency_claim", + "tableTo": "automation", + "columnsFrom": [ + "automation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "automation_concurrency_claim_automation_id_concurrency_key_pk": { + "name": "automation_concurrency_claim_automation_id_concurrency_key_pk", + "columns": [ + "automation_id", + "concurrency_key" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.automation_drop": { + "name": "automation_drop", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "automation_id": { + "name": "automation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entrypoint_id": { + "name": "entrypoint_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'main'" + }, + "event_key": { + "name": "event_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "detail": { + "name": "detail", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "dropped_at": { + "name": "dropped_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "automation_drop_automation_idx": { + "name": "automation_drop_automation_idx", + "columns": [ + { + "expression": "automation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "automation_drop_automation_id_automation_id_fk": { + "name": "automation_drop_automation_id_automation_id_fk", + "tableFrom": "automation_drop", + "tableTo": "automation", + "columnsFrom": [ + "automation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.automation_instance": { + "name": "automation_instance", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "automation_id": { + "name": "automation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "inputs": { + "name": "inputs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "opened_by": { + "name": "opened_by", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "opened_at": { + "name": "opened_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "closed_at": { + "name": "closed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "close_reason": { + "name": "close_reason", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "automation_instance_open_key_unique": { + "name": "automation_instance_open_key_unique", + "columns": [ + { + "expression": "automation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "status = 'open'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "automation_instance_automation_idx": { + "name": "automation_instance_automation_idx", + "columns": [ + { + "expression": "automation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "opened_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "automation_instance_automation_id_automation_id_fk": { + "name": "automation_instance_automation_id_automation_id_fk", + "tableFrom": "automation_instance", + "tableTo": "automation", + "columnsFrom": [ + "automation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.automation_instance_handle": { + "name": "automation_instance_handle", + "schema": "", + "columns": { + "automation_id": { + "name": "automation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "handle": { + "name": "handle", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "instance_id": { + "name": "instance_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "written_by": { + "name": "written_by", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "automation_instance_handle_instance_idx": { + "name": "automation_instance_handle_instance_idx", + "columns": [ + { + "expression": "instance_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "automation_instance_handle_automation_id_automation_id_fk": { + "name": "automation_instance_handle_automation_id_automation_id_fk", + "tableFrom": "automation_instance_handle", + "tableTo": "automation", + "columnsFrom": [ + "automation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "automation_instance_handle_instance_id_automation_instance_id_fk": { + "name": "automation_instance_handle_instance_id_automation_instance_id_fk", + "tableFrom": "automation_instance_handle", + "tableTo": "automation_instance", + "columnsFrom": [ + "instance_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "automation_instance_handle_automation_id_handle_pk": { + "name": "automation_instance_handle_automation_id_handle_pk", + "columns": [ + "automation_id", + "handle" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.automation_run": { + "name": "automation_run", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "automation_id": { + "name": "automation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "trigger": { + "name": "trigger", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "delivery_key": { + "name": "delivery_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "concurrency_key": { + "name": "concurrency_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "context": { + "name": "context", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "rendered_prompt": { + "name": "rendered_prompt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rendered_title": { + "name": "rendered_title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "entrypoint_id": { + "name": "entrypoint_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'main'" + }, + "instance_id": { + "name": "instance_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scheduled_for": { + "name": "scheduled_for", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "lease_owner": { + "name": "lease_owner", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lease_expires_at": { + "name": "lease_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "dry_run": { + "name": "dry_run", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "details_pruned_at": { + "name": "details_pruned_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()" + } + }, + "indexes": { + "automation_run_retention_idx": { + "name": "automation_run_retention_idx", + "columns": [ + { + "expression": "ended_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "ended_at is not null and details_pruned_at is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "automation_run_occurrence_unique": { + "name": "automation_run_occurrence_unique", + "columns": [ + { + "expression": "automation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entrypoint_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "instance_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scheduled_for", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "scheduled_for is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "automation_run_delivery_unique": { + "name": "automation_run_delivery_unique", + "columns": [ + { + "expression": "automation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entrypoint_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "delivery_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "delivery_key is not null and scheduled_for is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "automation_run_automation_created_idx": { + "name": "automation_run_automation_created_idx", + "columns": [ + { + "expression": "automation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "automation_run_lease_idx": { + "name": "automation_run_lease_idx", + "columns": [ + { + "expression": "lease_expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "automation_run_concurrency_idx": { + "name": "automation_run_concurrency_idx", + "columns": [ + { + "expression": "automation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "concurrency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "automation_run_automation_id_automation_id_fk": { + "name": "automation_run_automation_id_automation_id_fk", + "tableFrom": "automation_run", + "tableTo": "automation", + "columnsFrom": [ + "automation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "automation_run_task_id_task_id_fk": { + "name": "automation_run_task_id_task_id_fk", + "tableFrom": "automation_run", + "tableTo": "task", + "columnsFrom": [ + "task_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.automation_session": { + "name": "automation_session", + "schema": "", + "columns": { + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "block_id": { + "name": "block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'primary'" + }, + "keep": { + "name": "keep", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "relay": { + "name": "relay", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "automation_session_run_idx": { + "name": "automation_session_run_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "automation_session_run_id_automation_run_id_fk": { + "name": "automation_session_run_id_automation_run_id_fk", + "tableFrom": "automation_session", + "tableTo": "automation_run", + "columnsFrom": [ + "run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.automation_state": { + "name": "automation_state", + "schema": "", + "columns": { + "automation_id": { + "name": "automation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "writer": { + "name": "writer", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "automation_state_automation_id_automation_id_fk": { + "name": "automation_state_automation_id_automation_id_fk", + "tableFrom": "automation_state", + "tableTo": "automation", + "columnsFrom": [ + "automation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "automation_state_automation_id_key_pk": { + "name": "automation_state_automation_id_key_pk", + "columns": [ + "automation_id", + "key" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.automation_step_run": { + "name": "automation_step_run", + "schema": "", + "columns": { + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "block_id": { + "name": "block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "attempt": { + "name": "attempt", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "inputs": { + "name": "inputs", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "outputs": { + "name": "outputs", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "automation_step_run_run_id_automation_run_id_fk": { + "name": "automation_step_run_run_id_automation_run_id_fk", + "tableFrom": "automation_step_run", + "tableTo": "automation_run", + "columnsFrom": [ + "run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "automation_step_run_run_id_block_id_attempt_pk": { + "name": "automation_step_run_run_id_block_id_attempt_pk", + "columns": [ + "run_id", + "block_id", + "attempt" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.automation_version": { + "name": "automation_version", + "schema": "", + "columns": { + "automation_id": { + "name": "automation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "trigger": { + "name": "trigger", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "blocks": { + "name": "blocks", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "entrypoints": { + "name": "entrypoints", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "inputs_schema": { + "name": "inputs_schema", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "automation_version_automation_id_automation_id_fk": { + "name": "automation_version_automation_id_automation_id_fk", + "tableFrom": "automation_version", + "tableTo": "automation", + "columnsFrom": [ + "automation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "automation_version_automation_id_version_pk": { + "name": "automation_version_automation_id_version_pk", + "columns": [ + "automation_id", + "version" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.connector": { + "name": "connector", + "schema": "", + "columns": { + "provider": { + "name": "provider", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.connector_logo": { + "name": "connector_logo", + "schema": "", + "columns": { + "provider": { + "name": "provider", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "media_type": { + "name": "media_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.consumer_cursors": { + "name": "consumer_cursors", + "schema": "", + "columns": { + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "consumer": { + "name": "consumer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_idx": { + "name": "last_idx", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "consumer_cursors_session_id_consumer_pk": { + "name": "consumer_cursors_session_id_consumer_pk", + "columns": [ + "session_id", + "consumer" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.coordination_operation": { + "name": "coordination_operation", + "schema": "", + "columns": { + "caller_session_id": { + "name": "caller_session_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "operation": { + "name": "operation", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "request_hash": { + "name": "request_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'reserved'" + }, + "reserved_task_id": { + "name": "reserved_task_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reserved_session_id": { + "name": "reserved_session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prompt_id": { + "name": "prompt_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "result": { + "name": "result", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "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": { + "coordination_operation_reserved_session_idx": { + "name": "coordination_operation_reserved_session_idx", + "columns": [ + { + "expression": "reserved_session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "coordination_operation_caller_session_id_operation_idempotency_key_pk": { + "name": "coordination_operation_caller_session_id_operation_idempotency_key_pk", + "columns": [ + "caller_session_id", + "operation", + "idempotency_key" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.dbos_sweep_lease": { + "name": "dbos_sweep_lease", + "schema": "", + "columns": { + "name": { + "name": "name", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.dbos_sweep_ledger": { + "name": "dbos_sweep_ledger", + "schema": "", + "columns": { + "workflow_uuid": { + "name": "workflow_uuid", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_name": { + "name": "workflow_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sweep_count": { + "name": "sweep_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "first_swept_at": { + "name": "first_swept_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_swept_at": { + "name": "last_swept_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "suppressed": { + "name": "suppressed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "alerted_at": { + "name": "alerted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "terminal_alerted_at": { + "name": "terminal_alerted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.dbos_version_heartbeats": { + "name": "dbos_version_heartbeats", + "schema": "", + "columns": { + "application_version": { + "name": "application_version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pod_name": { + "name": "pod_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_seen": { + "name": "last_seen", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "dbos_version_heartbeats_application_version_pod_name_pk": { + "name": "dbos_version_heartbeats_application_version_pod_name_pk", + "columns": [ + "application_version", + "pod_name" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.device_code": { + "name": "device_code", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "device_code": { + "name": "device_code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_code": { + "name": "user_code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_polled_at": { + "name": "last_polled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "polling_interval": { + "name": "polling_interval", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "device_code_device_code_idx": { + "name": "device_code_device_code_idx", + "columns": [ + { + "expression": "device_code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "device_code_user_code_idx": { + "name": "device_code_user_code_idx", + "columns": [ + { + "expression": "user_code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.integration_connection": { + "name": "integration_connection", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "alias": { + "name": "alias", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "tested_at": { + "name": "tested_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": { + "integration_connection_provider_idx": { + "name": "integration_connection_provider_idx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "integration_connection_provider_default_unique": { + "name": "integration_connection_provider_default_unique", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "is_default", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "integration_connection_alias_unique": { + "name": "integration_connection_alias_unique", + "nullsNotDistinct": false, + "columns": [ + "alias" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.integration_event": { + "name": "integration_event", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_key": { + "name": "event_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "delivery_id": { + "name": "delivery_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "scope_value": { + "name": "scope_value", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "received_at": { + "name": "received_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "integration_event_delivery_unique": { + "name": "integration_event_delivery_unique", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "delivery_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "integration_event_conn_key_idx": { + "name": "integration_event_conn_key_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "event_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "received_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "integration_event_connection_id_integration_connection_id_fk": { + "name": "integration_event_connection_id_integration_connection_id_fk", + "tableFrom": "integration_event", + "tableTo": "integration_connection", + "columnsFrom": [ + "connection_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.integration_oidc_key": { + "name": "integration_oidc_key", + "schema": "", + "columns": { + "kid": { + "name": "kid", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "public_jwk": { + "name": "public_jwk", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "wrapped_dek": { + "name": "wrapped_dek", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "nonce": { + "name": "nonce", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "ciphertext": { + "name": "ciphertext", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "key_id": { + "name": "key_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "publish_until": { + "name": "publish_until", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "integration_oidc_key_active_unique": { + "name": "integration_oidc_key_active_unique", + "columns": [ + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "state = 'active'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.org_setting": { + "name": "org_setting", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "value": { + "name": "value", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_by_user_id": { + "name": "updated_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.papercuts": { + "name": "papercuts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "severity": { + "name": "severity", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tags": { + "name": "tags", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_call_id": { + "name": "tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "profile_id": { + "name": "profile_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_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()" + } + }, + "indexes": { + "papercuts_session_tool_call_unique": { + "name": "papercuts_session_tool_call_unique", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "papercuts_created_at_idx": { + "name": "papercuts_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "papercuts_archived_at_idx": { + "name": "papercuts_archived_at_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pending_tool_calls": { + "name": "pending_tool_calls", + "schema": "", + "columns": { + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_call_id": { + "name": "tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_name": { + "name": "tool_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "handling": { + "name": "handling", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "requested_at": { + "name": "requested_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "submitted_at": { + "name": "submitted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "pending_tool_calls_session_tool_call_unique": { + "name": "pending_tool_calls_session_tool_call_unique", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pending_tool_calls_session_idx": { + "name": "pending_tool_calls_session_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pending_tool_calls_awaiting_idx": { + "name": "pending_tool_calls_awaiting_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"pending_tool_calls\".\"handling\" = 'session' AND \"pending_tool_calls\".\"submitted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pr_ref": { + "name": "pr_ref", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "repo": { + "name": "repo", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pr_number": { + "name": "pr_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "authoring_task_id": { + "name": "authoring_task_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "head_branch": { + "name": "head_branch", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "base_branch": { + "name": "base_branch", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "observed_at": { + "name": "observed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "pr_ref_repo_pr_number_unique": { + "name": "pr_ref_repo_pr_number_unique", + "columns": [ + { + "expression": "repo", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "pr_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pr_ref_authoring_task_idx": { + "name": "pr_ref_authoring_task_idx", + "columns": [ + { + "expression": "authoring_task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pr_ref_session_idx": { + "name": "pr_ref_session_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pr_ref_authoring_task_id_task_id_fk": { + "name": "pr_ref_authoring_task_id_task_id_fk", + "tableFrom": "pr_ref", + "tableTo": "task", + "columnsFrom": [ + "authoring_task_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.profile": { + "name": "profile", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'Bot'" + }, + "image_id": { + "name": "image_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "harness": { + "name": "harness", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "model_router": { + "name": "model_router", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "effort": { + "name": "effort", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "include_user_tokens": { + "name": "include_user_tokens", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "env_vars": { + "name": "env_vars", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "skills": { + "name": "skills", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "integration_grants": { + "name": "integration_grants", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "network": { + "name": "network", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{\"default\":\"deny\",\"allowHosts\":[],\"allowHostPatterns\":[]}'::jsonb" + }, + "secrets": { + "name": "secrets", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "repos": { + "name": "repos", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "apps": { + "name": "apps", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.review": { + "name": "review", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "target_id": { + "name": "target_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "head_sha": { + "name": "head_sha", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "base_sha": { + "name": "base_sha", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "trigger": { + "name": "trigger", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "github_review_id": { + "name": "github_review_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "finder_session_id": { + "name": "finder_session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "verifier_session_id": { + "name": "verifier_session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "automation_run_id": { + "name": "automation_run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "summary_md": { + "name": "summary_md", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "head_branch": { + "name": "head_branch", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "base_branch": { + "name": "base_branch", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "additions": { + "name": "additions", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "deletions": { + "name": "deletions", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "changed_files": { + "name": "changed_files", + "type": "integer", + "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": { + "review_target_idx": { + "name": "review_target_idx", + "columns": [ + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "review_task_idx": { + "name": "review_task_idx", + "columns": [ + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "review_one_active_per_target_idx": { + "name": "review_one_active_per_target_idx", + "columns": [ + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"review\".\"status\" IN ('queued', 'finding', 'verifying')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "review_finder_session_idx": { + "name": "review_finder_session_idx", + "columns": [ + { + "expression": "finder_session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "review_verifier_session_idx": { + "name": "review_verifier_session_idx", + "columns": [ + { + "expression": "verifier_session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "review_target_id_review_target_id_fk": { + "name": "review_target_id_review_target_id_fk", + "tableFrom": "review", + "tableTo": "review_target", + "columnsFrom": [ + "target_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "review_task_id_task_id_fk": { + "name": "review_task_id_task_id_fk", + "tableFrom": "review", + "tableTo": "task", + "columnsFrom": [ + "task_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.review_event": { + "name": "review_event", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "review_id": { + "name": "review_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "detail": { + "name": "detail", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "review_event_review_idx": { + "name": "review_event_review_idx", + "columns": [ + { + "expression": "review_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "review_event_review_id_review_id_fk": { + "name": "review_event_review_id_review_id_fk", + "tableFrom": "review_event", + "tableTo": "review", + "columnsFrom": [ + "review_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.review_finding": { + "name": "review_finding", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "review_id": { + "name": "review_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "start_line": { + "name": "start_line", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "end_line": { + "name": "end_line", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "side": { + "name": "side", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "severity": { + "name": "severity", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "confidence": { + "name": "confidence", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "body_md": { + "name": "body_md", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "suggested_fix": { + "name": "suggested_fix", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "evidence": { + "name": "evidence", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'candidate'" + }, + "verdict_reason": { + "name": "verdict_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "github_thread_id": { + "name": "github_thread_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resolution": { + "name": "resolution", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_call_id": { + "name": "tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "review_finding_session_tool_call_unique": { + "name": "review_finding_session_tool_call_unique", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "review_finding_review_idx": { + "name": "review_finding_review_idx", + "columns": [ + { + "expression": "review_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "review_finding_review_id_review_id_fk": { + "name": "review_finding_review_id_review_id_fk", + "tableFrom": "review_finding", + "tableTo": "review", + "columnsFrom": [ + "review_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.review_target": { + "name": "review_target", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'github'" + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "repo": { + "name": "repo", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "number": { + "name": "number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "author": { + "name": "author", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_updated_at": { + "name": "provider_updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "hydration_failed_at": { + "name": "hydration_failed_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": { + "review_target_provider_id_unique": { + "name": "review_target_provider_id_unique", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "review_target_coordinate_idx": { + "name": "review_target_coordinate_idx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "repo", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.review_verdict": { + "name": "review_verdict", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "finding_id": { + "name": "finding_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "verdict": { + "name": "verdict", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "confidence": { + "name": "confidence", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reasoning": { + "name": "reasoning", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_call_id": { + "name": "tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "review_verdict_session_tool_call_unique": { + "name": "review_verdict_session_tool_call_unique", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "review_verdict_finding_unique": { + "name": "review_verdict_finding_unique", + "columns": [ + { + "expression": "finding_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "review_verdict_finding_id_review_finding_id_fk": { + "name": "review_verdict_finding_id_review_finding_id_fk", + "tableFrom": "review_verdict", + "tableTo": "review_finding", + "columnsFrom": [ + "finding_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.router_model": { + "name": "router_model", + "schema": "", + "columns": { + "router_id": { + "name": "router_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "model_id": { + "name": "model_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "canonical_slug": { + "name": "canonical_slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "author": { + "name": "author", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "context_length": { + "name": "context_length", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "prompt_price": { + "name": "prompt_price", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "completion_price": { + "name": "completion_price", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "input_modalities": { + "name": "input_modalities", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "output_modalities": { + "name": "output_modalities", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "supported_parameters": { + "name": "supported_parameters", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "hugging_face_id": { + "name": "hugging_face_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "upstream": { + "name": "upstream", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "available": { + "name": "available", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "router_model_available_idx": { + "name": "router_model_available_idx", + "columns": [ + { + "expression": "router_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "available", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "router_model_router_id_model_id_pk": { + "name": "router_model_router_id_model_id_pk", + "columns": [ + "router_id", + "model_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.router_model_policy": { + "name": "router_model_policy", + "schema": "", + "columns": { + "router_id": { + "name": "router_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "model_id": { + "name": "model_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "user_enabled": { + "name": "user_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "router_model_policy_router_id_model_id_router_model_router_id_model_id_fk": { + "name": "router_model_policy_router_id_model_id_router_model_router_id_model_id_fk", + "tableFrom": "router_model_policy", + "tableTo": "router_model", + "columnsFrom": [ + "router_id", + "model_id" + ], + "columnsTo": [ + "router_id", + "model_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "router_model_policy_router_id_model_id_pk": { + "name": "router_model_policy_router_id_model_id_pk", + "columns": [ + "router_id", + "model_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "router_model_policy_user_implies_enabled": { + "name": "router_model_policy_user_implies_enabled", + "value": "NOT \"router_model_policy\".\"user_enabled\" OR \"router_model_policy\".\"enabled\"" + } + }, + "isRLSEnabled": false + }, + "public.router_sync_state": { + "name": "router_sync_state", + "schema": "", + "columns": { + "router_id": { + "name": "router_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "last_successful_sync_at": { + "name": "last_successful_sync_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_attempt_at": { + "name": "last_attempt_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "impersonated_by": { + "name": "impersonated_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "session_userId_idx": { + "name": "session_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session_app": { + "name": "session_app", + "schema": "", + "columns": { + "host_label": { + "name": "host_label", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "port": { + "name": "port", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "visibility": { + "name": "visibility", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'org'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "session_app_session_name_idx": { + "name": "session_app_session_name_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "session_app_session_port_idx": { + "name": "session_app_session_port_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "port", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "session_app_session_idx": { + "name": "session_app_session_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "session_app_owner_idx": { + "name": "session_app_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_app_owner_user_id_user_id_fk": { + "name": "session_app_owner_user_id_user_id_fk", + "tableFrom": "session_app", + "tableTo": "user", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session_listeners": { + "name": "session_listeners", + "schema": "", + "columns": { + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lease_expires_at": { + "name": "lease_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "terminal_at": { + "name": "terminal_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "dormant_at": { + "name": "dormant_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "woken_at": { + "name": "woken_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()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.spec": { + "name": "spec", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "session_id": { + "name": "session_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "template_id": { + "name": "template_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "phase": { + "name": "phase", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "current_doc_seq": { + "name": "current_doc_seq", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": "0" + }, + "current_semantic_doc_seq": { + "name": "current_semantic_doc_seq", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": "0" + }, + "published_checkpoint_id": { + "name": "published_checkpoint_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "published_by": { + "name": "published_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published_at": { + "name": "published_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": { + "spec_org_updated_idx": { + "name": "spec_org_updated_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "spec_owner_idx": { + "name": "spec_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "spec_session_idx": { + "name": "spec_session_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "spec_owner_user_id_user_id_fk": { + "name": "spec_owner_user_id_user_id_fk", + "tableFrom": "spec", + "tableTo": "user", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "spec_template_id_spec_template_id_fk": { + "name": "spec_template_id_spec_template_id_fk", + "tableFrom": "spec", + "tableTo": "spec_template", + "columnsFrom": [ + "template_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "spec_published_by_user_id_fk": { + "name": "spec_published_by_user_id_fk", + "tableFrom": "spec", + "tableTo": "user", + "columnsFrom": [ + "published_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.spec_chat_message": { + "name": "spec_chat_message", + "schema": "", + "columns": { + "prompt_id": { + "name": "prompt_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "spec_id": { + "name": "spec_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "author_user_id": { + "name": "author_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "author_name": { + "name": "author_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "spec_chat_message_spec_created_idx": { + "name": "spec_chat_message_spec_created_idx", + "columns": [ + { + "expression": "spec_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "spec_chat_message_spec_id_spec_id_fk": { + "name": "spec_chat_message_spec_id_spec_id_fk", + "tableFrom": "spec_chat_message", + "tableTo": "spec", + "columnsFrom": [ + "spec_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "spec_chat_message_author_user_id_user_id_fk": { + "name": "spec_chat_message_author_user_id_user_id_fk", + "tableFrom": "spec_chat_message", + "tableTo": "user", + "columnsFrom": [ + "author_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.spec_checkpoint": { + "name": "spec_checkpoint", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "spec_id": { + "name": "spec_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "state_vector": { + "name": "state_vector", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "rendered_markdown": { + "name": "rendered_markdown", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "doc_seq": { + "name": "doc_seq", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "author_user_id": { + "name": "author_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "spec_checkpoint_spec_created_idx": { + "name": "spec_checkpoint_spec_created_idx", + "columns": [ + { + "expression": "spec_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "spec_checkpoint_spec_id_spec_id_fk": { + "name": "spec_checkpoint_spec_id_spec_id_fk", + "tableFrom": "spec_checkpoint", + "tableTo": "spec", + "columnsFrom": [ + "spec_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "spec_checkpoint_author_user_id_user_id_fk": { + "name": "spec_checkpoint_author_user_id_user_id_fk", + "tableFrom": "spec_checkpoint", + "tableTo": "user", + "columnsFrom": [ + "author_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.spec_drafting_seed": { + "name": "spec_drafting_seed", + "schema": "", + "columns": { + "spec_id": { + "name": "spec_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "prompt_id": { + "name": "prompt_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "next_attempt_at": { + "name": "next_attempt_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "requested_at": { + "name": "requested_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "delivered_at": { + "name": "delivered_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "spec_drafting_seed_due_idx": { + "name": "spec_drafting_seed_due_idx", + "columns": [ + { + "expression": "next_attempt_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"spec_drafting_seed\".\"state\" = 'pending'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "spec_drafting_seed_spec_id_spec_id_fk": { + "name": "spec_drafting_seed_spec_id_spec_id_fk", + "tableFrom": "spec_drafting_seed", + "tableTo": "spec", + "columnsFrom": [ + "spec_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "spec_drafting_seed_prompt_id_unique": { + "name": "spec_drafting_seed_prompt_id_unique", + "nullsNotDistinct": false, + "columns": [ + "prompt_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.spec_gap_check_finding": { + "name": "spec_gap_check_finding", + "schema": "", + "columns": { + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "finding_id": { + "name": "finding_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ordinal": { + "name": "ordinal", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "severity": { + "name": "severity", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "layer_key": { + "name": "layer_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "section_id": { + "name": "section_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "section_title": { + "name": "section_title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "requirement_id": { + "name": "requirement_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "detail": { + "name": "detail", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "proposed_diff": { + "name": "proposed_diff", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "disposition": { + "name": "disposition", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "open_question_id": { + "name": "open_question_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "disposed_by": { + "name": "disposed_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "disposed_at": { + "name": "disposed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "spec_gap_check_finding_run_id_spec_gap_check_run_id_fk": { + "name": "spec_gap_check_finding_run_id_spec_gap_check_run_id_fk", + "tableFrom": "spec_gap_check_finding", + "tableTo": "spec_gap_check_run", + "columnsFrom": [ + "run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "spec_gap_check_finding_disposed_by_user_id_fk": { + "name": "spec_gap_check_finding_disposed_by_user_id_fk", + "tableFrom": "spec_gap_check_finding", + "tableTo": "user", + "columnsFrom": [ + "disposed_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "spec_gap_check_finding_run_id_finding_id_pk": { + "name": "spec_gap_check_finding_run_id_finding_id_pk", + "columns": [ + "run_id", + "finding_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.spec_gap_check_run": { + "name": "spec_gap_check_run", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "spec_id": { + "name": "spec_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "request_fingerprint": { + "name": "request_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "semantic_doc_seq": { + "name": "semantic_doc_seq", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "stopped_at_layer_key": { + "name": "stopped_at_layer_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "suppressed_count": { + "name": "suppressed_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "matrix": { + "name": "matrix", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "started_by": { + "name": "started_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "spec_gap_check_run_fingerprint_idx": { + "name": "spec_gap_check_run_fingerprint_idx", + "columns": [ + { + "expression": "spec_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "request_fingerprint", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "spec_gap_check_run_spec_created_idx": { + "name": "spec_gap_check_run_spec_created_idx", + "columns": [ + { + "expression": "spec_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "spec_gap_check_run_spec_id_spec_id_fk": { + "name": "spec_gap_check_run_spec_id_spec_id_fk", + "tableFrom": "spec_gap_check_run", + "tableTo": "spec", + "columnsFrom": [ + "spec_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "spec_gap_check_run_started_by_user_id_fk": { + "name": "spec_gap_check_run_started_by_user_id_fk", + "tableFrom": "spec_gap_check_run", + "tableTo": "user", + "columnsFrom": [ + "started_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.spec_open_question": { + "name": "spec_open_question", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "spec_id": { + "name": "spec_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "section_id": { + "name": "section_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "opened_by": { + "name": "opened_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "request_fingerprint": { + "name": "request_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resolution_note": { + "name": "resolution_note", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resolved_by": { + "name": "resolved_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resolved_at": { + "name": "resolved_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()" + } + }, + "indexes": { + "spec_open_question_spec_section_idx": { + "name": "spec_open_question_spec_section_idx", + "columns": [ + { + "expression": "spec_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "section_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "spec_open_question_spec_id_spec_id_fk": { + "name": "spec_open_question_spec_id_spec_id_fk", + "tableFrom": "spec_open_question", + "tableTo": "spec", + "columnsFrom": [ + "spec_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "spec_open_question_opened_by_user_id_fk": { + "name": "spec_open_question_opened_by_user_id_fk", + "tableFrom": "spec_open_question", + "tableTo": "user", + "columnsFrom": [ + "opened_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "spec_open_question_resolved_by_user_id_fk": { + "name": "spec_open_question_resolved_by_user_id_fk", + "tableFrom": "spec_open_question", + "tableTo": "user", + "columnsFrom": [ + "resolved_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.spec_participant": { + "name": "spec_participant", + "schema": "", + "columns": { + "spec_id": { + "name": "spec_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "connection_epoch": { + "name": "connection_epoch", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": "0" + }, + "connected_at": { + "name": "connected_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "disconnected_at": { + "name": "disconnected_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "lease_expires_at": { + "name": "lease_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "spec_participant_live_idx": { + "name": "spec_participant_live_idx", + "columns": [ + { + "expression": "spec_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lease_expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "spec_participant_spec_id_spec_id_fk": { + "name": "spec_participant_spec_id_spec_id_fk", + "tableFrom": "spec_participant", + "tableTo": "spec", + "columnsFrom": [ + "spec_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "spec_participant_user_id_user_id_fk": { + "name": "spec_participant_user_id_user_id_fk", + "tableFrom": "spec_participant", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "spec_participant_spec_id_client_id_pk": { + "name": "spec_participant_spec_id_client_id_pk", + "columns": [ + "spec_id", + "client_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.spec_projection": { + "name": "spec_projection", + "schema": "", + "columns": { + "spec_id": { + "name": "spec_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "rev": { + "name": "rev", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "doc_seq": { + "name": "doc_seq", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "semantic_doc_seq": { + "name": "semantic_doc_seq", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "sha256": { + "name": "sha256", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "rendered": { + "name": "rendered", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "document_state": { + "name": "document_state", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "digest": { + "name": "digest", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "digest_sha256": { + "name": "digest_sha256", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "staging_path": { + "name": "staging_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "requested_source": { + "name": "requested_source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "discard_notice": { + "name": "discard_notice", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "pushed_at": { + "name": "pushed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "spec_projection_session_state_idx": { + "name": "spec_projection_session_state_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "rev", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "spec_projection_spec_id_spec_id_fk": { + "name": "spec_projection_spec_id_spec_id_fk", + "tableFrom": "spec_projection", + "tableTo": "spec", + "columnsFrom": [ + "spec_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "spec_projection_spec_id_rev_pk": { + "name": "spec_projection_spec_id_rev_pk", + "columns": [ + "spec_id", + "rev" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.spec_publish": { + "name": "spec_publish", + "schema": "", + "columns": { + "spec_id": { + "name": "spec_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "checkpoint_id": { + "name": "checkpoint_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "artifact_id": { + "name": "artifact_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "artifact_version": { + "name": "artifact_version", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "requested_by": { + "name": "requested_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "requested_at": { + "name": "requested_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "acknowledged_question_count": { + "name": "acknowledged_question_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "acknowledged_question_ids": { + "name": "acknowledged_question_ids", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "gap_check_run_id": { + "name": "gap_check_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "next_attempt_at": { + "name": "next_attempt_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pinned_at": { + "name": "pinned_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "spec_publish_due_idx": { + "name": "spec_publish_due_idx", + "columns": [ + { + "expression": "next_attempt_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"spec_publish\".\"state\" <> 'complete'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "spec_publish_spec_id_spec_id_fk": { + "name": "spec_publish_spec_id_spec_id_fk", + "tableFrom": "spec_publish", + "tableTo": "spec", + "columnsFrom": [ + "spec_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "spec_publish_requested_by_user_id_fk": { + "name": "spec_publish_requested_by_user_id_fk", + "tableFrom": "spec_publish", + "tableTo": "user", + "columnsFrom": [ + "requested_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.spec_section_state": { + "name": "spec_section_state", + "schema": "", + "columns": { + "spec_id": { + "name": "spec_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "section_id": { + "name": "section_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "na_reason": { + "name": "na_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "settled_by": { + "name": "settled_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "spec_section_state_spec_id_spec_id_fk": { + "name": "spec_section_state_spec_id_spec_id_fk", + "tableFrom": "spec_section_state", + "tableTo": "spec", + "columnsFrom": [ + "spec_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "spec_section_state_settled_by_user_id_fk": { + "name": "spec_section_state_settled_by_user_id_fk", + "tableFrom": "spec_section_state", + "tableTo": "user", + "columnsFrom": [ + "settled_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "spec_section_state_spec_id_section_id_pk": { + "name": "spec_section_state_spec_id_section_id_pk", + "columns": [ + "spec_id", + "section_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.spec_snapshot": { + "name": "spec_snapshot", + "schema": "", + "columns": { + "spec_id": { + "name": "spec_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "state": { + "name": "state", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "state_vector": { + "name": "state_vector", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "covered_seq": { + "name": "covered_seq", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "covered_semantic_doc_seq": { + "name": "covered_semantic_doc_seq", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "spec_snapshot_spec_id_spec_id_fk": { + "name": "spec_snapshot_spec_id_spec_id_fk", + "tableFrom": "spec_snapshot", + "tableTo": "spec", + "columnsFrom": [ + "spec_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.spec_template": { + "name": "spec_template", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "layers": { + "name": "layers", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "sections": { + "name": "sections", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "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": { + "spec_template_org_idx": { + "name": "spec_template_org_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.spec_ticket_draft": { + "name": "spec_ticket_draft", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "spec_id": { + "name": "spec_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "parent_id": { + "name": "parent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "ordinal": { + "name": "ordinal", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "section_id": { + "name": "section_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "depends_on": { + "name": "depends_on", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "sync_state": { + "name": "sync_state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "linear_id": { + "name": "linear_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sync_error": { + "name": "sync_error", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "spec_ticket_draft_tree_idx": { + "name": "spec_ticket_draft_tree_idx", + "columns": [ + { + "expression": "spec_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "ordinal", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "spec_ticket_draft_spec_id_spec_id_fk": { + "name": "spec_ticket_draft_spec_id_spec_id_fk", + "tableFrom": "spec_ticket_draft", + "tableTo": "spec", + "columnsFrom": [ + "spec_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "spec_ticket_draft_parent_id_fk": { + "name": "spec_ticket_draft_parent_id_fk", + "tableFrom": "spec_ticket_draft", + "tableTo": "spec_ticket_draft", + "columnsFrom": [ + "parent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.spec_ticket_sync_config": { + "name": "spec_ticket_sync_config", + "schema": "", + "columns": { + "spec_id": { + "name": "spec_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "team_id": { + "name": "team_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "team_name": { + "name": "team_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "project_name": { + "name": "project_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "label_ids": { + "name": "label_ids", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "label_names": { + "name": "label_names", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "spec_ticket_sync_config_spec_id_spec_id_fk": { + "name": "spec_ticket_sync_config_spec_id_spec_id_fk", + "tableFrom": "spec_ticket_sync_config", + "tableTo": "spec", + "columnsFrom": [ + "spec_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.spec_ticket_sync_operation": { + "name": "spec_ticket_sync_operation", + "schema": "", + "columns": { + "caller_spec_id": { + "name": "caller_spec_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "operation": { + "name": "operation", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "request_hash": { + "name": "request_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'reserved'" + }, + "reserved_ticket_id": { + "name": "reserved_ticket_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "reserved_external_id": { + "name": "reserved_external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "result": { + "name": "result", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "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": { + "spec_ticket_sync_operation_ticket_idx": { + "name": "spec_ticket_sync_operation_ticket_idx", + "columns": [ + { + "expression": "reserved_ticket_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "spec_ticket_sync_operation_caller_spec_id_spec_id_fk": { + "name": "spec_ticket_sync_operation_caller_spec_id_spec_id_fk", + "tableFrom": "spec_ticket_sync_operation", + "tableTo": "spec", + "columnsFrom": [ + "caller_spec_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "spec_ticket_sync_operation_caller_spec_id_operation_idempotency_key_pk": { + "name": "spec_ticket_sync_operation_caller_spec_id_operation_idempotency_key_pk", + "columns": [ + "caller_spec_id", + "operation", + "idempotency_key" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.spec_transcript_action": { + "name": "spec_transcript_action", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "spec_id": { + "name": "spec_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "section_id": { + "name": "section_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "request_fingerprint": { + "name": "request_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chip": { + "name": "chip", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "result": { + "name": "result", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "actor_user_id": { + "name": "actor_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "delivered_at": { + "name": "delivered_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "spec_transcript_action_pending_idx": { + "name": "spec_transcript_action_pending_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"spec_transcript_action\".\"delivered_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "spec_transcript_action_spec_id_spec_id_fk": { + "name": "spec_transcript_action_spec_id_spec_id_fk", + "tableFrom": "spec_transcript_action", + "tableTo": "spec", + "columnsFrom": [ + "spec_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "spec_transcript_action_actor_user_id_user_id_fk": { + "name": "spec_transcript_action_actor_user_id_user_id_fk", + "tableFrom": "spec_transcript_action", + "tableTo": "user", + "columnsFrom": [ + "actor_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.spec_update_log": { + "name": "spec_update_log", + "schema": "", + "columns": { + "seq": { + "name": "seq", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "spec_id": { + "name": "spec_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "update": { + "name": "update", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "semantic_doc_seq": { + "name": "semantic_doc_seq", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "changed_section_ids": { + "name": "changed_section_ids", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'::text[]" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "spec_update_log_spec_id_spec_id_fk": { + "name": "spec_update_log_spec_id_spec_id_fk", + "tableFrom": "spec_update_log", + "tableTo": "spec", + "columnsFrom": [ + "spec_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "spec_update_log_spec_id_seq_pk": { + "name": "spec_update_log_spec_id_seq_pk", + "columns": [ + "spec_id", + "seq" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.task": { + "name": "task", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "suggested_title": { + "name": "suggested_title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "custom_title": { + "name": "custom_title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "workflow_run_id": { + "name": "workflow_run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "harness": { + "name": "harness", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model_router": { + "name": "model_router", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "effort": { + "name": "effort", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parent_task_id": { + "name": "parent_task_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "root_task_id": { + "name": "root_task_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "local_task_name": { + "name": "local_task_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "canonical_task_name": { + "name": "canonical_task_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "spawning_session_id": { + "name": "spawning_session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "launch_policy": { + "name": "launch_policy", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "task_slack_channel_created_idx": { + "name": "task_slack_channel_created_idx", + "columns": [ + { + "expression": "(source->>'team')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "(source->>'channel')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "where": "source->>'provider' = 'slack'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_parent_idx": { + "name": "task_parent_idx", + "columns": [ + { + "expression": "parent_task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_root_idx": { + "name": "task_root_idx", + "columns": [ + { + "expression": "root_task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_root_canonical_name_unique": { + "name": "task_root_canonical_name_unique", + "columns": [ + { + "expression": "root_task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "canonical_task_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "canonical_task_name IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.task_session": { + "name": "task_session", + "schema": "", + "columns": { + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "profile_id": { + "name": "profile_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "capabilities": { + "name": "capabilities", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "integration_grants": { + "name": "integration_grants", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "integration_connections": { + "name": "integration_connections", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "integration_principal_id": { + "name": "integration_principal_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "integration_snapshot_hash": { + "name": "integration_snapshot_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "task_session_session_idx": { + "name": "task_session_session_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "task_session_task_id_task_id_fk": { + "name": "task_session_task_id_task_id_fk", + "tableFrom": "task_session", + "tableTo": "task", + "columnsFrom": [ + "task_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "task_session_profile_id_profile_id_fk": { + "name": "task_session_profile_id_profile_id_fk", + "tableFrom": "task_session", + "tableTo": "profile", + "columnsFrom": [ + "profile_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "task_session_task_id_session_id_pk": { + "name": "task_session_task_id_session_id_pk", + "columns": [ + "task_id", + "session_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "banned": { + "name": "banned", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "ban_reason": { + "name": "ban_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ban_expires": { + "name": "ban_expires", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_preferences": { + "name": "user_preferences", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "document": { + "name": "document", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "user_preferences_user_id_user_id_fk": { + "name": "user_preferences_user_id_user_id_fk", + "tableFrom": "user_preferences", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "user_preferences_revision_positive": { + "name": "user_preferences_revision_positive", + "value": "\"user_preferences\".\"revision\" > 0" + } + }, + "isRLSEnabled": false + }, + "public.user_session_secrets": { + "name": "user_session_secrets", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "env_var_name": { + "name": "env_var_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "wrapped_dek": { + "name": "wrapped_dek", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "nonce": { + "name": "nonce", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ciphertext": { + "name": "ciphertext", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_id": { + "name": "key_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_session_secrets_user_id_user_id_fk": { + "name": "user_session_secrets_user_id_user_id_fk", + "tableFrom": "user_session_secrets", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_session_secrets_user_id_env_var_name_pk": { + "name": "user_session_secrets_user_id_env_var_name_pk", + "columns": [ + "user_id", + "env_var_name" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.webhook_registration": { + "name": "webhook_registration", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "verification": { + "name": "verification", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "provider_hint": { + "name": "provider_hint", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "disabled_reason": { + "name": "disabled_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "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": { + "webhook_registration_provider_idx": { + "name": "webhook_registration_provider_idx", + "columns": [ + { + "expression": "provider_hint", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.webhook_sample": { + "name": "webhook_sample", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "registration_id": { + "name": "registration_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_key": { + "name": "event_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "received_at": { + "name": "received_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "webhook_sample_registration_event_idx": { + "name": "webhook_sample_registration_event_idx", + "columns": [ + { + "expression": "registration_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "event_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "received_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "webhook_sample_registration_id_webhook_registration_id_fk": { + "name": "webhook_sample_registration_id_webhook_registration_id_fk", + "tableFrom": "webhook_sample", + "tableTo": "webhook_registration", + "columnsFrom": [ + "registration_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/orchestrator/drizzle/meta/_journal.json b/orchestrator/drizzle/meta/_journal.json index 94963db80..27536dbd5 100644 --- a/orchestrator/drizzle/meta/_journal.json +++ b/orchestrator/drizzle/meta/_journal.json @@ -666,6 +666,13 @@ "when": 1791218834318, "tag": "0094_listener_dormancy", "breakpoints": true + }, + { + "idx": 95, + "version": "7", + "when": 1791305583943, + "tag": "0095_user_preferences", + "breakpoints": true } ] } \ No newline at end of file diff --git a/orchestrator/package.json b/orchestrator/package.json index 181f1270c..26da10d82 100644 --- a/orchestrator/package.json +++ b/orchestrator/package.json @@ -19,6 +19,7 @@ "@connectrpc/connect-node": "^2.1.1", "@dbos-inc/dbos-sdk": "^4.21.6", "@engrams/spec-document": "file:./packages/spec-document", + "@engrams/user-preferences": "file:./packages/user-preferences", "@hono/node-server": "^2.0.4", "@jitl/quickjs-singlefile-mjs-release-sync": "^0.32.0", "@openrouter/ai-sdk-provider": "^3.0.0", diff --git a/orchestrator/packages/user-preferences/package.json b/orchestrator/packages/user-preferences/package.json new file mode 100644 index 000000000..385e7ffe0 --- /dev/null +++ b/orchestrator/packages/user-preferences/package.json @@ -0,0 +1,12 @@ +{ + "name": "@engrams/user-preferences", + "version": "0.0.0", + "private": true, + "type": "module", + "exports": { + ".": "./src/index.ts" + }, + "dependencies": { + "zod": "^4.4.3" + } +} diff --git a/orchestrator/packages/user-preferences/src/index.test.ts b/orchestrator/packages/user-preferences/src/index.test.ts new file mode 100644 index 000000000..77bfd3c8e --- /dev/null +++ b/orchestrator/packages/user-preferences/src/index.test.ts @@ -0,0 +1,202 @@ +import { describe, expect, test } from "bun:test"; +import { + appearanceSchema, + colorSchemeSchema, + preferencesDocumentSchema, + preferencesSnapshotSchema, + preferencesUpdateSchema, + DEFAULT_APPEARANCE, + BUILTIN_SCHEMES, + FONT_IDS, +} from "./index.ts"; + +/** Return an independent custom palette for normalization and validation tests. */ +const custom = () => ({ + ...structuredClone(BUILTIN_SCHEMES[0]!), + id: "custom", + name: " My scheme ", +}); +describe("user preferences contract", () => { + test("validates defaults and normalizes imported colors and names", () => { + expect(appearanceSchema.parse(DEFAULT_APPEARANCE)).toEqual(DEFAULT_APPEARANCE); + const scheme = custom(); + scheme.light.accent = "#ABCDEF"; + expect(colorSchemeSchema.parse(scheme)).toMatchObject({ + name: "My scheme", + light: { accent: "#abcdef" }, + }); + expect( + preferencesDocumentSchema.parse({ version: 1, appearance: DEFAULT_APPEARANCE }).version, + ).toBe(1); + expect( + preferencesSnapshotSchema.safeParse({ + revision: 0, + document: { version: 1, appearance: DEFAULT_APPEARANCE }, + updatedAt: null, + }).success, + ).toBe(true); + }); + test("Dracula selection survives update, stored document, and snapshot round trips", () => { + for (const mode of ["light", "dark", "system"] as const) { + const appearance = { ...DEFAULT_APPEARANCE, mode, schemeId: "dracula" }; + const update = preferencesUpdateSchema.parse({ expectedRevision: 3, appearance }); + const document = preferencesDocumentSchema.parse( + JSON.parse(JSON.stringify({ version: 1, appearance: update.appearance })), + ); + const snapshot = preferencesSnapshotSchema.parse({ + revision: update.expectedRevision + 1, + document, + updatedAt: "2026-10-02T12:00:00.000Z", + }); + expect(snapshot.document.appearance).toEqual(appearance); + } + }); + test("Dracula is reserved for the built-in scheme; a distinct copy is valid", () => { + const dracula = BUILTIN_SCHEMES.find((scheme) => scheme.id === "dracula"); + expect(dracula).toBeDefined(); + const replacement = { + ...dracula!, + name: "My replacement", + dark: { ...dracula!.dark, accent: "#ffffff" }, + }; + expect( + preferencesUpdateSchema.safeParse({ + expectedRevision: 0, + appearance: { ...DEFAULT_APPEARANCE, schemeId: "dracula", customSchemes: [replacement] }, + }).success, + ).toBe(false); + const copy = { ...replacement, id: "my-dracula" }; + expect( + appearanceSchema.parse({ + ...DEFAULT_APPEARANCE, + schemeId: copy.id, + customSchemes: [copy], + }).customSchemes, + ).toEqual([copy]); + }); + test("new presets and fonts survive all mode and persisted document round trips", () => { + for (const schemeId of ["catppuccin", "nord", "solarized", "gruvbox"]) { + for (const mode of ["light", "dark", "system"] as const) { + const appearance = { + ...DEFAULT_APPEARANCE, + schemeId, + mode, + fonts: { sans: "inter", display: "inter", mono: "fira-code" }, + }; + const update = preferencesUpdateSchema.parse({ expectedRevision: 3, appearance }); + const document = preferencesDocumentSchema.parse( + JSON.parse(JSON.stringify({ version: 1, appearance: update.appearance })), + ); + expect( + preferencesSnapshotSchema.parse({ + revision: 4, + document, + updatedAt: "2026-10-02T12:00:00.000Z", + }).document.appearance, + ).toEqual(appearance); + } + } + }); + test("new built-in IDs cannot be replaced by custom schemes", () => { + for (const id of ["catppuccin", "nord", "solarized", "gruvbox"]) { + const builtin = BUILTIN_SCHEMES.find((scheme) => scheme.id === id); + expect(builtin).toBeDefined(); + const replacement = { ...builtin!, name: "My replacement" }; + const appearance = { ...DEFAULT_APPEARANCE, schemeId: id, customSchemes: [replacement] }; + expect(preferencesUpdateSchema.safeParse({ expectedRevision: 0, appearance }).success).toBe( + false, + ); + const copy = { ...replacement, id: `my-${id}` }; + expect( + appearanceSchema.parse({ ...appearance, schemeId: copy.id, customSchemes: [copy] }) + .customSchemes, + ).toEqual([copy]); + } + }); + test("the font registry preserves display order and accepts every ID for its role", () => { + expect(FONT_IDS).toEqual({ + sans: ["system", "saira", "inter"], + display: ["saira", "inter", "system", "georgia"], + mono: ["jetbrains", "fira-code", "system"], + }); + expect(DEFAULT_APPEARANCE.fonts).toEqual({ + sans: "system", + display: "saira", + mono: "jetbrains", + }); + for (const role of ["sans", "display", "mono"] as const) { + for (const id of FONT_IDS[role]) { + const appearance = { + ...DEFAULT_APPEARANCE, + fonts: { ...DEFAULT_APPEARANCE.fonts, [role]: id }, + }; + expect( + preferencesUpdateSchema.parse({ expectedRevision: 0, appearance }).appearance.fonts[role], + ).toBe(id); + } + } + }); + test("new font IDs remain limited to their supported roles", () => { + for (const fonts of [ + { sans: "fira-code", display: "inter", mono: "fira-code" }, + { sans: "inter", display: "fira-code", mono: "fira-code" }, + { sans: "inter", display: "inter", mono: "inter" }, + ]) + expect(appearanceSchema.safeParse({ ...DEFAULT_APPEARANCE, fonts }).success).toBe(false); + }); + test("rejects unknown fields at each nested level", () => { + for (const appearance of [ + { ...DEFAULT_APPEARANCE, userId: "other" }, + { ...DEFAULT_APPEARANCE, fonts: { ...DEFAULT_APPEARANCE.fonts, other: "system" } }, + { ...DEFAULT_APPEARANCE, customSchemes: [{ ...custom(), extra: true }] }, + { + ...DEFAULT_APPEARANCE, + customSchemes: [{ ...custom(), light: { ...custom().light, extra: "#ffffff" } }], + }, + ]) + expect(appearanceSchema.safeParse(appearance).success).toBe(false); + expect( + preferencesUpdateSchema.safeParse({ + expectedRevision: 0, + appearance: DEFAULT_APPEARANCE, + userId: "other", + }).success, + ).toBe(false); + }); + test("requires existing selected scheme, unique custom IDs, and reserved built-in IDs", () => { + for (const appearance of [ + { ...DEFAULT_APPEARANCE, schemeId: "missing" }, + { ...DEFAULT_APPEARANCE, customSchemes: [custom(), custom()] }, + { ...DEFAULT_APPEARANCE, customSchemes: [BUILTIN_SCHEMES[0]] }, + ]) + expect(appearanceSchema.safeParse(appearance).success).toBe(false); + expect( + appearanceSchema.safeParse({ + ...DEFAULT_APPEARANCE, + schemeId: "custom", + customSchemes: [custom()], + }).success, + ).toBe(true); + }); + test("enforces palette, font, version, count, and revision bounds", () => { + for (const appearance of [ + { ...DEFAULT_APPEARANCE, fonts: { ...DEFAULT_APPEARANCE.fonts, mono: "saira" } }, + { ...DEFAULT_APPEARANCE, version: 2 }, + { ...DEFAULT_APPEARANCE, mode: "automatic" }, + { + ...DEFAULT_APPEARANCE, + customSchemes: [{ ...custom(), light: { ...custom().light, accent: "red" } }], + }, + { + ...DEFAULT_APPEARANCE, + customSchemes: Array.from({ length: 51 }, (_, i) => ({ ...custom(), id: `custom-${i}` })), + }, + ]) + expect(appearanceSchema.safeParse(appearance).success).toBe(false); + for (const expectedRevision of [-1, 0.5, 2_147_483_647, Number.MAX_SAFE_INTEGER + 1, "0"]) + expect( + preferencesUpdateSchema.safeParse({ expectedRevision, appearance: DEFAULT_APPEARANCE }) + .success, + ).toBe(false); + }); +}); diff --git a/orchestrator/packages/user-preferences/src/index.ts b/orchestrator/packages/user-preferences/src/index.ts new file mode 100644 index 000000000..cf03e6207 --- /dev/null +++ b/orchestrator/packages/user-preferences/src/index.ts @@ -0,0 +1,185 @@ +import { z } from "zod"; + +/** The resolved color mode for one device. */ +export type Theme = "light" | "dark"; +/** The saved mode; each device resolves system mode locally. */ +export type AppearanceMode = Theme | "system"; +/** The text roles that support separate font choices. */ +export type FontRole = "sans" | "display" | "mono"; +/** Source colors used to derive UI, syntax, diff, and terminal colors. */ +export interface ColorSeeds { + background: string; + foreground: string; + accent: string; + chrome: string; +} +/** A named pair of light and dark source palettes. */ +export interface ColorScheme { + id: string; + name: string; + light: ColorSeeds; + dark: ColorSeeds; +} +/** The complete appearance group saved for one account. */ +export interface AppearancePreferences { + version: 1; + mode: AppearanceMode; + schemeId: string; + customSchemes: ColorScheme[]; + fonts: Record; +} +/** Shared presets. Their IDs are reserved and cannot name custom schemes. */ +export const BUILTIN_SCHEMES: ColorScheme[] = [ + { + id: "engrams", + name: "Engrams", + light: { background: "#f1f5ed", foreground: "#223133", accent: "#c6e63a", chrome: "#133738" }, + dark: { background: "#0b1d1e", foreground: "#dce3c9", accent: "#bddc31", chrome: "#061617" }, + }, + { + id: "slate", + name: "Slate", + light: { background: "#f5f7fa", foreground: "#253044", accent: "#2563eb", chrome: "#253044" }, + dark: { background: "#121826", foreground: "#e5eaf3", accent: "#93b4ff", chrome: "#0a1020" }, + }, + { + id: "sand", + name: "Sand", + light: { background: "#faf6ee", foreground: "#3e342c", accent: "#a34b28", chrome: "#44362c" }, + dark: { background: "#211c18", foreground: "#efe3d1", accent: "#efad79", chrome: "#17120e" }, + }, + { + id: "dracula", + name: "Dracula", + // Dracula Classic dark and Alucard Classic light: https://draculatheme.com/spec + // UI, syntax, and diff text follow the app's contrast rules. + light: { background: "#fffbeb", foreground: "#1f1f1f", accent: "#644ac9", chrome: "#ceccc0" }, + dark: { background: "#282a36", foreground: "#f8f8f2", accent: "#bd93f9", chrome: "#21222c" }, + }, + { + id: "catppuccin", + name: "Catppuccin", + // Latte light and Mocha dark: https://catppuccin.com/palette/ + light: { background: "#eff1f5", foreground: "#4c4f69", accent: "#8839ef", chrome: "#e6e9ef" }, + dark: { background: "#1e1e2e", foreground: "#cdd6f4", accent: "#cba6f7", chrome: "#181825" }, + }, + { + id: "nord", + name: "Nord", + // Snow Storm light and Polar Night dark: https://www.nordtheme.com/docs/colors-and-palettes/ + light: { background: "#eceff4", foreground: "#2e3440", accent: "#5e81ac", chrome: "#e5e9f0" }, + dark: { background: "#2e3440", foreground: "#eceff4", accent: "#88c0d0", chrome: "#3b4252" }, + }, + { + id: "solarized", + name: "Solarized", + // Official light and dark palettes: https://ethanschoonover.com/solarized/ + light: { background: "#fdf6e3", foreground: "#586e75", accent: "#268bd2", chrome: "#eee8d5" }, + dark: { background: "#002b36", foreground: "#839496", accent: "#268bd2", chrome: "#073642" }, + }, + { + id: "gruvbox", + name: "Gruvbox", + // Medium-contrast light and dark palettes: https://github.com/morhetz/gruvbox + light: { background: "#fbf1c7", foreground: "#3c3836", accent: "#076678", chrome: "#ebdbb2" }, + dark: { background: "#282828", foreground: "#ebdbb2", accent: "#83a598", chrome: "#3c3836" }, + }, +]; +/** Defaults for anonymous users and accounts with no saved document. */ +export const DEFAULT_APPEARANCE: AppearancePreferences = { + version: 1, + mode: "light", + schemeId: "engrams", + customSchemes: [], + fonts: { sans: "system", display: "saira", mono: "jetbrains" }, +}; + +/** Accepted font IDs in display order for each role; font loading remains in the web app. */ +export const FONT_IDS = { + sans: ["system", "saira", "inter"], + display: ["saira", "inter", "system", "georgia"], + mono: ["jetbrains", "fira-code", "system"], +} as const; +const idSchema = z.string().regex(/^[a-zA-Z0-9_-]{1,100}$/); +const hexSchema = z + .string() + .regex(/^#[\da-f]{6}$/i) + .transform((value) => value.toLowerCase()); +const colorSeedsSchema = z.strictObject({ + background: hexSchema, + foreground: hexSchema, + accent: hexSchema, + chrome: hexSchema, +}); +/** Validate a portable scheme and normalize color case and name spacing. */ +export const colorSchemeSchema = z.strictObject({ + id: idSchema, + name: z.string().trim().min(1).max(80), + light: colorSeedsSchema, + dark: colorSeedsSchema, +}); +/** Reject unknown fields, invalid font roles, duplicate IDs, and missing selections. */ +export const appearanceSchema = z + .strictObject({ + version: z.literal(1), + mode: z.enum(["light", "dark", "system"]), + schemeId: idSchema, + customSchemes: z.array(colorSchemeSchema).max(50), + fonts: z.strictObject({ + sans: z.enum(FONT_IDS.sans), + display: z.enum(FONT_IDS.display), + mono: z.enum(FONT_IDS.mono), + }), + }) + .superRefine((value, context) => { + const ids = new Set(BUILTIN_SCHEMES.map((scheme) => scheme.id)); + value.customSchemes.forEach((scheme, index) => { + if (ids.has(scheme.id)) + context.addIssue({ + code: "custom", + path: ["customSchemes", index, "id"], + message: "Scheme IDs must be unique; built-in IDs are reserved.", + }); + ids.add(scheme.id); + }); + if (!ids.has(value.schemeId)) + context.addIssue({ + code: "custom", + path: ["schemeId"], + message: "Selected scheme must exist.", + }); + }); +/** Validate the stored document without changing its version. */ +export const preferencesDocumentSchema = z.strictObject({ + version: z.literal(1), + appearance: appearanceSchema, +}); +// Keep revisions within the Postgres integer range. +const revisionSchema = z.number().int().min(0).max(2_147_483_647); +/** Validate an API response, including defaults at revision zero. */ +export const preferencesSnapshotSchema = z.strictObject({ + revision: revisionSchema, + document: preferencesDocumentSchema, + updatedAt: z.iso.datetime().nullable(), +}); +/** Require the revision the caller read and leave room for the next revision. */ +export const preferencesUpdateSchema = z.strictObject({ + expectedRevision: revisionSchema.max(2_147_483_646), + appearance: appearanceSchema, +}); +/** The versioned account document stored in Postgres. */ +export interface UserPreferencesDocument { + version: 1; + appearance: AppearancePreferences; +} +/** A confirmed document and its revision; unsaved defaults have no timestamp. */ +export interface UserPreferencesSnapshot { + revision: number; + document: UserPreferencesDocument; + updatedAt: string | null; +} +/** A complete appearance replacement guarded by the last confirmed revision. */ +export interface UserPreferencesUpdate { + expectedRevision: number; + appearance: AppearancePreferences; +} diff --git a/orchestrator/src/db/__tests__/user-preferences-postgres.test.ts b/orchestrator/src/db/__tests__/user-preferences-postgres.test.ts new file mode 100644 index 000000000..3b80697a6 --- /dev/null +++ b/orchestrator/src/db/__tests__/user-preferences-postgres.test.ts @@ -0,0 +1,169 @@ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { randomUUID } from "node:crypto"; +import { Pool } from "pg"; +import { drizzle } from "drizzle-orm/node-postgres"; +import { eq } from "drizzle-orm"; +import { DEFAULT_APPEARANCE } from "@engrams/user-preferences"; +import * as schema from "../schema.ts"; +import { makeUserPreferencesRoute } from "../../routes/user-preferences.ts"; +import { defaultPreferencesSnapshot, makeUserPreferencesStore } from "../user-preferences.ts"; + +const url = process.env["ORCHESTRATOR_DATABASE_URL"]; +const pool = url ? new Pool({ connectionString: url, max: 8 }) : null; +// CI supplies a migrated database; local runs skip this suite when no URL is set. +const liveTest = pool ? test : test.skip; +const ids = Array.from({ length: 4 }, () => `preferences-${randomUUID()}`); +const now = new Date("2026-10-02T12:00:00.000Z"); +/** Create a store against the test database with a fixed save timestamp. */ +function store() { + if (!pool) throw new Error("Postgres tests require ORCHESTRATOR_DATABASE_URL"); + return makeUserPreferencesStore(drizzle(pool, { schema }), () => now); +} +describe("user preferences with live Postgres", () => { + beforeAll(async () => { + if (!pool) return; + const db = drizzle(pool, { schema }); + await db.insert(schema.user).values( + ids.map((id) => ({ + id, + name: id, + email: `${id}@example.test`, + createdAt: now, + updatedAt: now, + })), + ); + }); + afterAll(async () => { + if (!pool) return; + try { + await pool.query('DELETE FROM "user" WHERE id = ANY($1::text[])', [ids]); + } finally { + await pool.end(); + } + }); + liveTest("GET defaults does not insert and preferences stay isolated", async () => { + expect(await store().get(ids[0]!)).toEqual(defaultPreferencesSnapshot()); + expect( + ( + await pool!.query( + "SELECT count(*)::int AS count FROM user_preferences WHERE user_id = $1", + [ids[0]], + ) + ).rows[0].count, + ).toBe(0); + await store().update(ids[1]!, { + expectedRevision: 0, + appearance: { ...DEFAULT_APPEARANCE, mode: "system" }, + }); + expect(await store().get(ids[0]!)).toEqual(defaultPreferencesSnapshot()); + expect((await store().get(ids[1]!)).document.appearance.mode).toBe("system"); + }); + liveTest("concurrent first saves and later updates each have one winner", async () => { + const initial = await Promise.all( + ["dark", "system"].map((mode) => + store().update(ids[0]!, { + expectedRevision: 0, + appearance: { ...DEFAULT_APPEARANCE, mode: mode === "dark" ? "dark" : "system" }, + }), + ), + ); + expect(initial.filter((result) => result.saved)).toHaveLength(1); + expect(initial.every((result) => result.snapshot.revision === 1)).toBe(true); + const updates = await Promise.all( + ["slate", "sand"].map((schemeId) => + store().update(ids[0]!, { + expectedRevision: 1, + appearance: { ...DEFAULT_APPEARANCE, schemeId }, + }), + ), + ); + expect(updates.filter((result) => result.saved)).toHaveLength(1); + expect(updates.every((result) => result.snapshot.revision === 2)).toBe(true); + const latest = await store().get(ids[0]!); + expect(latest).toEqual(updates.find((result) => result.saved)!.snapshot); + expect(latest.updatedAt).toBe(now.toISOString()); + // A new pool models a separate process reading the same persisted document. + const secondPool = new Pool({ connectionString: url, max: 1 }); + try { + expect(await makeUserPreferencesStore(drizzle(secondPool, { schema })).get(ids[0]!)).toEqual( + latest, + ); + } finally { + await secondPool.end(); + } + }); + liveTest("stale writes do not create rows; deleting a user cascades", async () => { + const absent = await store().update(ids[2]!, { + expectedRevision: 8, + appearance: DEFAULT_APPEARANCE, + }); + expect(absent).toEqual({ saved: false, snapshot: defaultPreferencesSnapshot() }); + await store().update(ids[2]!, { expectedRevision: 0, appearance: DEFAULT_APPEARANCE }); + await drizzle(pool!, { schema }).delete(schema.user).where(eq(schema.user.id, ids[2]!)); + expect( + ( + await pool!.query( + "SELECT count(*)::int AS count FROM user_preferences WHERE user_id = $1", + [ids[2]], + ) + ).rows[0].count, + ).toBe(0); + }); + liveTest("authenticated routes persist the complete appearance group", async () => { + const app = makeUserPreferencesRoute({ + store: store(), + getSession: async (headers) => { + const id = headers.get("x-test-user"); + return id ? { user: { id } } : null; + }, + }); + const path = "/api/v1/me/preferences"; + const headers = { "x-test-user": ids[3]!, "content-type": "application/json" }; + const defaults = await app.request(path, { headers }); + expect(await defaults.json()).toEqual(defaultPreferencesSnapshot()); + const appearance = { + ...DEFAULT_APPEARANCE, + mode: "system" as const, + fonts: { sans: "saira", display: "georgia", mono: "system" }, + schemeId: "custom", + customSchemes: [ + { + id: "custom", + name: "Stored custom palette", + light: { + background: "#ffffff", + foreground: "#111111", + accent: "#223344", + chrome: "#334455", + }, + dark: { + background: "#111111", + foreground: "#ffffff", + accent: "#aabbcc", + chrome: "#223344", + }, + }, + ], + }; + const saved = await app.request(path, { + method: "PATCH", + headers, + body: JSON.stringify({ expectedRevision: 0, appearance }), + }); + expect(saved.status).toBe(200); + expect(saved.headers.get("cache-control")).toBe("no-store"); + const snapshot = await saved.json(); + expect(snapshot.document.appearance).toEqual(appearance); + const reopened = await app.request(path, { headers }); + expect(await reopened.json()).toEqual(snapshot); + const conflict = await app.request(path, { + method: "PATCH", + headers, + body: JSON.stringify({ expectedRevision: 0, appearance: DEFAULT_APPEARANCE }), + }); + expect(conflict.status).toBe(409); + expect(await conflict.json()).toEqual(snapshot); + const other = await app.request(path, { headers: { "x-test-user": ids[1]! } }); + expect((await other.json()).document.appearance.customSchemes).toEqual([]); + }); +}); diff --git a/orchestrator/src/db/schema.ts b/orchestrator/src/db/schema.ts index f4b685d83..8cb43aefe 100644 --- a/orchestrator/src/db/schema.ts +++ b/orchestrator/src/db/schema.ts @@ -16,6 +16,7 @@ */ import { relations, sql } from "drizzle-orm"; +import type { UserPreferencesDocument } from "@engrams/user-preferences"; import type { GapFindingKind, GapFindingSeverity, @@ -1925,6 +1926,20 @@ export const user = pgTable("user", { banExpires: timestamp("ban_expires"), }); +/** Per-user preference document, separate from Better Auth generated fields. */ +export const userPreferences = pgTable( + "user_preferences", + { + userId: text("user_id") + .primaryKey() + .references(() => user.id, { onDelete: "cascade" }), + document: jsonb("document").$type().notNull(), + revision: integer("revision").notNull(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull(), + }, + (table) => [check("user_preferences_revision_positive", sql`${table.revision} > 0`)], +); + export const session = pgTable( "session", { diff --git a/orchestrator/src/db/user-preferences.ts b/orchestrator/src/db/user-preferences.ts new file mode 100644 index 000000000..4306f4079 --- /dev/null +++ b/orchestrator/src/db/user-preferences.ts @@ -0,0 +1,87 @@ +import { and, eq } from "drizzle-orm"; +import { + DEFAULT_APPEARANCE, + preferencesDocumentSchema, + preferencesUpdateSchema, + type UserPreferencesSnapshot, + type UserPreferencesUpdate, +} from "@engrams/user-preferences"; +import { getDb } from "./client.ts"; +import { userPreferences } from "./schema.ts"; + +/** Account-scoped reads and atomic writes used by the preferences API. */ +export interface UserPreferencesStore { + /** Return the saved document or defaults without inserting a row. */ + get(userId: string): Promise; + /** Save only at the expected revision; return the current snapshot on conflict. */ + update( + userId: string, + update: UserPreferencesUpdate, + ): Promise<{ saved: boolean; snapshot: UserPreferencesSnapshot }>; +} +/** Return independent default objects at revision zero with no save timestamp. */ +export function defaultPreferencesSnapshot(): UserPreferencesSnapshot { + return { + revision: 0, + document: { version: 1, appearance: structuredClone(DEFAULT_APPEARANCE) }, + updatedAt: null, + }; +} +/** Validate a stored document and convert its timestamp to the API format. */ +function snapshot(row: typeof userPreferences.$inferSelect): UserPreferencesSnapshot { + return { + revision: row.revision, + document: preferencesDocumentSchema.parse(row.document), + updatedAt: row.updatedAt.toISOString(), + }; +} +/** + * Create a Postgres store with an injectable database and clock. + * Revision zero permits one insert. Later writes require both the account ID + * and current revision to match. A failed write reads the latest snapshot. + */ +export function makeUserPreferencesStore( + db: ReturnType = getDb(), + clock: () => Date = () => new Date(), +): UserPreferencesStore { + const store: UserPreferencesStore = { + /** Read only this account; a missing row does not create persistent state. */ + async get(userId) { + const [row] = await db + .select() + .from(userPreferences) + .where(eq(userPreferences.userId, userId)); + return row ? snapshot(row) : defaultPreferencesSnapshot(); + }, + /** Validate before writing and advance the revision in the same SQL statement. */ + async update(userId, input) { + const update = preferencesUpdateSchema.parse(input); + const document = { version: 1 as const, appearance: update.appearance }; + const updatedAt = clock(); + // The unique user key permits one first save. The revision condition + // permits one later save when concurrent callers hold the same revision. + const rows = + update.expectedRevision === 0 + ? await db + .insert(userPreferences) + .values({ userId, document, revision: 1, updatedAt }) + .onConflictDoNothing({ target: userPreferences.userId }) + .returning() + : await db + .update(userPreferences) + .set({ document, revision: update.expectedRevision + 1, updatedAt }) + .where( + and( + eq(userPreferences.userId, userId), + eq(userPreferences.revision, update.expectedRevision), + ), + ) + .returning(); + const row = rows[0]; + return row + ? { saved: true, snapshot: snapshot(row) } + : { saved: false, snapshot: await store.get(userId) }; + }, + }; + return store; +} diff --git a/orchestrator/src/index.ts b/orchestrator/src/index.ts index 13bb39905..d4e635e56 100644 --- a/orchestrator/src/index.ts +++ b/orchestrator/src/index.ts @@ -12,6 +12,7 @@ import artifactsRoute from "./routes/artifacts.ts"; import appsRoute from "./routes/apps.ts"; import connectorLogoRoute from "./routes/connector-logo.ts"; import meRoute from "./routes/me.ts"; +import userPreferencesRoute from "./routes/user-preferences.ts"; import adminRoute from "./routes/admin.ts"; import integrationOpRoute from "./routes/integration-op.ts"; import integrationOauthRoute from "./routes/integration-oauth.ts"; @@ -434,6 +435,7 @@ app.route("/", appsRoute); // Connector logos (redesign): orchestrator-owned brand marks, served for . app.route("/", connectorLogoRoute); app.route("/", meRoute); +app.route("/", userPreferencesRoute); // ADR 0051 Task 28: admin REST proxy (pause/resume session — no gRPC equiv yet). app.route("/", adminRoute); // Admin trigger for the IntegrationOp seam (sessionless integration calls). diff --git a/orchestrator/src/routes/__tests__/user-preferences.test.ts b/orchestrator/src/routes/__tests__/user-preferences.test.ts new file mode 100644 index 000000000..e01220342 --- /dev/null +++ b/orchestrator/src/routes/__tests__/user-preferences.test.ts @@ -0,0 +1,172 @@ +import { describe, expect, test } from "bun:test"; +import { + BUILTIN_SCHEMES, + DEFAULT_APPEARANCE, + type UserPreferencesSnapshot, +} from "@engrams/user-preferences"; +import { + defaultPreferencesSnapshot, + type UserPreferencesStore, +} from "../../db/user-preferences.ts"; +import { makeUserPreferencesRoute, MAX_PREFERENCES_BODY_BYTES } from "../user-preferences.ts"; +const path = "/api/v1/me/preferences"; +/** Create a route with controlled session identity and a revision-aware fake store. */ +function setup() { + const users: string[] = []; + let latest = defaultPreferencesSnapshot(); + const store: UserPreferencesStore = { + /** Record the account ID supplied by the route. */ + async get(id) { + users.push(id); + return latest; + }, + /** Model a revision conflict without a database dependency. */ + async update(id, update) { + users.push(id); + if (update.expectedRevision !== latest.revision) return { saved: false, snapshot: latest }; + latest = { + revision: latest.revision + 1, + document: { version: 1, appearance: update.appearance }, + updatedAt: "2026-10-02T12:00:00.000Z", + }; + return { saved: true, snapshot: latest }; + }, + }; + const app = makeUserPreferencesRoute({ + store, + getSession: async (headers) => + headers.get("x-user") ? { user: { id: headers.get("x-user")! } } : null, + }); + return { app, users }; +} +/** Build an authenticated JSON request for the first fixture account. */ +function patch(body: unknown) { + return { + method: "PATCH", + headers: { "x-user": "U1", "content-type": "application/json" }, + body: JSON.stringify(body), + }; +} +/** Check the status and cache policy before reading response JSON. */ +async function check(response: Response, status: number) { + expect(response.status).toBe(status); + expect(response.headers.get("cache-control")).toBe("no-store"); + return response.json(); +} +describe("user preferences routes", () => { + test("requires authentication before accessing store or reading body", async () => { + const { app, users } = setup(); + await check(await app.request(path), 401); + await check(await app.request(path, { method: "PATCH", body: "invalid" }), 401); + expect(users).toEqual([]); + }); + test("returns defaults, updates authenticated user, and reports latest on conflict", async () => { + const { app, users } = setup(); + expect(await check(await app.request(path, { headers: { "x-user": "U1" } }), 200)).toEqual( + defaultPreferencesSnapshot(), + ); + const appearance = { ...DEFAULT_APPEARANCE, mode: "dark" }; + const saved = (await check( + await app.request(path, patch({ expectedRevision: 0, appearance })), + 200, + )) as UserPreferencesSnapshot; + expect(saved.revision).toBe(1); + expect(saved.document.appearance.mode).toBe("dark"); + expect( + await check( + await app.request(path, patch({ expectedRevision: 0, appearance: DEFAULT_APPEARANCE })), + 409, + ), + ).toEqual(saved); + expect(users).toEqual(["U1", "U1", "U1"]); + }); + test("PATCH and GET preserve new presets, font IDs, and selected mode", async () => { + const { app } = setup(); + let revision = 0; + for (const schemeId of ["catppuccin", "nord", "solarized", "gruvbox"]) { + for (const mode of ["light", "dark", "system"] as const) { + const appearance = { + ...DEFAULT_APPEARANCE, + schemeId, + mode, + fonts: { sans: "inter", display: "inter", mono: "fira-code" }, + }; + const saved = await check( + await app.request(path, patch({ expectedRevision: revision, appearance })), + 200, + ); + revision++; + expect(saved.revision).toBe(revision); + expect(saved.document.appearance).toEqual(appearance); + const readback = await check(await app.request(path, { headers: { "x-user": "U1" } }), 200); + expect(readback).toEqual(saved); + } + } + }); + test("PATCH rejects replacement of each new built-in ID before writing", async () => { + const { app, users } = setup(); + for (const id of ["catppuccin", "nord", "solarized", "gruvbox"]) { + const builtin = BUILTIN_SCHEMES.find((scheme) => scheme.id === id); + await check( + await app.request( + path, + patch({ + expectedRevision: 0, + appearance: { + ...DEFAULT_APPEARANCE, + schemeId: id, + customSchemes: [{ ...builtin, name: "Replacement" }], + }, + }), + ), + 400, + ); + } + expect(users).toEqual([]); + }); + test("rejects invalid JSON, extra user IDs, invalid appearance, and oversized stream", async () => { + const { app, users } = setup(); + await check(await app.request(path, { ...patch({}), body: "{" }), 400); + await check( + await app.request( + path, + patch({ expectedRevision: 0, appearance: DEFAULT_APPEARANCE, userId: "U2" }), + ), + 400, + ); + await check( + await app.request( + path, + patch({ expectedRevision: 0, appearance: { ...DEFAULT_APPEARANCE, schemeId: "absent" } }), + ), + 400, + ); + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array(MAX_PREFERENCES_BODY_BYTES + 1)); + controller.close(); + }, + }); + await check( + await app.request( + new Request(`http://localhost${path}`, { + method: "PATCH", + headers: { "x-user": "U1" }, + body: stream, + }), + ), + 413, + ); + expect(users).toEqual([]); + }); + test("sets no-store on unexpected failures", async () => { + const app = makeUserPreferencesRoute({ + getSession: async () => { + throw new Error("session failed"); + }, + }); + const response = await app.request(path); + expect(response.status).toBe(500); + expect(response.headers.get("cache-control")).toBe("no-store"); + }); +}); diff --git a/orchestrator/src/routes/user-preferences.ts b/orchestrator/src/routes/user-preferences.ts new file mode 100644 index 000000000..c56592d5e --- /dev/null +++ b/orchestrator/src/routes/user-preferences.ts @@ -0,0 +1,55 @@ +import { Hono } from "hono"; +import { preferencesUpdateSchema } from "@engrams/user-preferences"; +import { getSessionFromHeaders } from "../auth/session.ts"; +import { makeUserPreferencesStore, type UserPreferencesStore } from "../db/user-preferences.ts"; +import { BodyTooLargeError, readBoundedBody } from "../http/bounded-body.ts"; +import type { GetSession } from "./guard.ts"; + +/** Bound the actual request stream before JSON parsing. */ +export const MAX_PREFERENCES_BODY_BYTES = 64 * 1024; +/** + * Create authenticated GET and PATCH routes for the current account. + * The session supplies the account ID. PATCH replaces the appearance group + * and returns the latest snapshot with HTTP 409 when its revision is stale. + * Session and store dependencies are injectable for tests. + */ +export function makeUserPreferencesRoute( + deps: { getSession?: GetSession; store?: UserPreferencesStore } = {}, +) { + const app = new Hono(); + const resolveSession = deps.getSession ?? getSessionFromHeaders; + // Resolve the database lazily so imports and unauthenticated calls need no pool. + const resolveStore = () => deps.store ?? makeUserPreferencesStore(); + // Account data and error responses must not enter a browser or shared cache. + app.use("/api/v1/me/preferences", async (c, next) => { + c.header("Cache-Control", "no-store"); + await next(); + }); + // GET returns defaults without inserting an account document. + app.get("/api/v1/me/preferences", async (c) => { + const session = await resolveSession(c.req.raw.headers); + if (!session) return c.json({ error: "unauthenticated" }, 401); + return c.json(await resolveStore().get(session.user.id)); + }); + // Authenticate before reading the body; never accept a caller-supplied user ID. + app.patch("/api/v1/me/preferences", async (c) => { + const session = await resolveSession(c.req.raw.headers); + if (!session) return c.json({ error: "unauthenticated" }, 401); + let body: unknown; + try { + body = JSON.parse( + new TextDecoder().decode(await readBoundedBody(c.req.raw, MAX_PREFERENCES_BODY_BYTES)), + ); + } catch (error) { + if (error instanceof BodyTooLargeError) return c.json({ error: error.message }, 413); + return c.json({ error: "invalid JSON body" }, 400); + } + const parsed = preferencesUpdateSchema.safeParse(body); + if (!parsed.success) + return c.json({ error: "invalid preferences", issues: parsed.error.issues }, 400); + const result = await resolveStore().update(session.user.id, parsed.data); + return c.json(result.snapshot, result.saved ? 200 : 409); + }); + return app; +} +export default makeUserPreferencesRoute(); diff --git a/web/DESIGN.md b/web/DESIGN.md index 36510c0fb..37f7ba44d 100644 --- a/web/DESIGN.md +++ b/web/DESIGN.md @@ -246,6 +246,72 @@ not merely dim. **Body / Label Font:** system UI stack **Machine Font:** JetBrains Mono Variable +These are the default fonts. Settings > Appearance lets each user choose body, +heading, and code fonts independently. The choices use bundled fonts or local +system fonts. Saira headings keep the 108% width; other heading fonts use their +normal width. Code blocks, diffs, the code editor, and the terminal use the code +font choice. + +Inter is an additional body and heading font. Fira Code is an additional code +font. Both are variable fonts served by the app through Fontsource. Inter has +normal and italic faces; Fira Code has a normal face. The CSS uses +`font-display: swap` and system fallbacks. Optional fonts are not preloaded; +the browser downloads their WOFF2 files when displayed text uses them. +Both fonts use the SIL Open Font License 1.1. The app includes the full notices +at `/licenses/inter.txt` and `/licenses/fira-code.txt`. + +### Personal appearance + +Settings > Appearance provides light, dark, and system modes. Engrams is the +default color scheme; Slate, Sand, Dracula, Catppuccin, Nord, Solarized, and +Gruvbox are additional presets. Dracula +uses Dracula Classic in dark mode and Alucard Classic in light mode, from the +[official Dracula specification](https://draculatheme.com/spec). The preset uses +the specification's background, foreground, purple accent, and dark UI frame +colors. Syntax, diff, and terminal roles use the official palettes. UI, syntax, +and diff text follow the app's contrast rules; these rules can adjust a source +color for readability. Terminal ANSI colors retain the specified values. +Catppuccin pairs [Latte and Mocha](https://github.com/catppuccin/catppuccin). +Nord uses [Snow Storm and Polar Night](https://www.nordtheme.com/docs/colors-and-palettes/). +[Solarized](https://ethanschoonover.com/solarized/) uses its light and dark +palettes; light body text uses `base01` to meet the 4.5:1 contrast target. +[Gruvbox](https://github.com/morhetz/gruvbox) uses medium-contrast light and dark +palettes. A renderer palette registry supplies each preset's syntax, diff, and +terminal roles. A color scheme change retains the user's font choices. +Users can copy a scheme, edit its page, text, accent, and frame colors for both modes, then save +the copy. The preview shows both modes and text contrast. JSON export and import +share colors. Reset restores the default mode, colors, and fonts, and retains +saved custom schemes. + +Preferences belong to the signed-in account. The orchestrator stores a versioned +appearance document and a revision in Postgres. The web app applies edits +immediately, then saves them through the authenticated preferences API. Settings +follow the account to another device and survive removal of browser data. + +A cache uses the account ID and contains confirmed settings and pending edits. +The app resolves identity before reading the cache. Account changes cancel old +requests and reset appearance. Anonymous pages use defaults. System mode follows +the operating system on each device; it saves the mode, not its resolved color. + +Startup, window focus, and reconnect refresh server settings. Revision checks +prevent silent replacement of another device's changes. Failed saves retain the +local edit and show a retry action. A conflict requires an explicit choice to +load server settings or save the local settings. Storage events trigger refresh +for the same account. Local storage is optional and does not control server saves. + +The previous browser-wide value has no known owner. When the server has no saved +settings, Appearance offers an explicit import or dismissal. The app never +assigns the old value to an account automatically. Successful import or dismissal +removes the old value. + +Custom colors define the page, cards, rails, and work pane through semantic CSS +tokens. Scoped rail and pane tokens retain their surface hierarchy. Syntax and +diff colors use shared roles; status labels and glyphs retain their meaning. +The terminal updates its background, cursor, selection, and font without a shell +reconnect. Ghostty 0.4.0 cannot change existing text-cell colors through its public +API; existing text cells retain foreground, background, and ANSI colors from +the terminal's initial palette. + **Character:** One workhorse family carries chrome, headings and prose; the display face appears exactly once per page, on the title (`Text variant="display"`). The borrowed, generated feel came from tracked caps on diff --git a/web/browser-tests/appearance.html b/web/browser-tests/appearance.html new file mode 100644 index 000000000..1a365120e --- /dev/null +++ b/web/browser-tests/appearance.html @@ -0,0 +1,13 @@ + + + + + + Appearance browser test + + +
+ + + + diff --git a/web/browser-tests/appearance.mjs b/web/browser-tests/appearance.mjs new file mode 100644 index 000000000..ab45d597e --- /dev/null +++ b/web/browser-tests/appearance.mjs @@ -0,0 +1,532 @@ +import assert from "node:assert/strict"; +import { existsSync, readFileSync, realpathSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { chromium } from "playwright"; +import { createServer } from "vite"; + +const webRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const helmConfig = readFileSync( + path.resolve(webRoot, "../deploy/helm/engram/templates/web-configmap.yaml"), + "utf8", +); +const productionCsp = [ + ...helmConfig.matchAll(/add_header Content-Security-Policy "([^"]+)" always;/g), +] + .map((match) => match[1]) + .find((value) => value.includes("worker-src")); +assert(productionCsp); +const server = await createServer({ + root: webRoot, + server: { + host: "127.0.0.1", + port: 0, + hmr: false, + fs: { allow: [webRoot, realpathSync(path.join(webRoot, "node_modules"))] }, + headers: { "Content-Security-Policy": productionCsp }, + }, +}); +let browser; +try { + await server.listen(); + const address = server.httpServer.address(); + assert(address && typeof address === "object"); + const origin = `http://127.0.0.1:${address.port}`; + const localBrowser = "/usr/local/bin/engram-chromium"; + browser = await chromium.launch({ + headless: true, + executablePath: + process.env.ENGRAM_BROWSER_EXECUTABLE ?? + (existsSync(localBrowser) ? localBrowser : undefined), + }); + const context = await browser.newContext(); + const resourceRequests = []; + context.on("request", (request) => resourceRequests.push(request.url())); + /** Identify optional font assets in recorded network requests. */ + const isOptionalFont = (url) => /(?:inter|fira-code)-[^/]*\.woff2(?:\?|$)/.test(url); + const records = new Map(); + const requests = []; + let failNextSave = false; + const defaults = { + version: 1, + mode: "light", + schemeId: "engrams", + customSchemes: [], + fonts: { sans: "system", display: "saira", mono: "jetbrains" }, + }; + /** Read account state without creating a server record for defaults. */ + const snapshot = (account) => + records.get(account) ?? { + revision: 0, + document: { version: 1, appearance: structuredClone(defaults) }, + updatedAt: null, + }; + // This HTTP fixture survives page reloads and browser storage removal. Cookies + // select the session identity; neither the URL nor the PATCH body supplies it. + /** Model revision checks and save failures at the HTTP boundary. */ + const preferencesRoute = async (route) => { + const request = route.request(); + const account = + request.headers().cookie?.match(/(?:^|; )appearance-test-account=([^;]+)/)?.[1] ?? "U1"; + const current = snapshot(account); + requests.push({ method: request.method(), account }); + /** Return the API fixture status and JSON snapshot. */ + const respond = (status, body) => route.fulfill({ status, json: body }); + if (request.method() === "GET") return respond(200, current); + assert.equal(request.method(), "PATCH"); + const body = request.postDataJSON(); + assert.deepEqual(Object.keys(body).sort(), ["appearance", "expectedRevision"]); + if (failNextSave) { + failNextSave = false; + return respond(503, { error: "Temporary save failure" }); + } + if (body.expectedRevision !== current.revision) return respond(409, current); + /** Wait for the current account queue to confirm its latest save. */ + const saved = { + revision: current.revision + 1, + document: { version: 1, appearance: body.appearance }, + updatedAt: new Date().toISOString(), + }; + records.set(account, saved); + return respond(200, saved); + }; + await context.route("**/api/v1/me/preferences", preferencesRoute); + const page = await context.newPage(); + const saved = () => + page + .getByTestId("appearance-sync-status") + .filter({ hasText: /^saved$/ }) + .waitFor(); + const errors = []; + page.on("pageerror", (error) => errors.push(error.message)); + await page.emulateMedia({ colorScheme: "dark" }); + await page.goto(`${origin}/browser-tests/appearance.html`); + await page.getByRole("heading", { name: "Appearance", exact: true }).waitFor(); + await saved(); + assert.equal(records.size, 0, "A GET must not create server preferences."); + await page.evaluate(() => document.fonts.ready); + assert.equal( + resourceRequests.filter(isOptionalFont).length, + 0, + "Optional fonts must not load before selection.", + ); + /** Select a scheme or font through the browser controls. */ + const choose = async (target, label, option) => { + await target.getByRole("combobox", { name: label, exact: true }).click(); + await target.getByRole("option", { name: option, exact: true }).click(); + }; + await choose(page, "Body font", "Inter"); + await choose(page, "Heading font", "Inter"); + await choose(page, "Code font", "Fira Code"); + for (const [family, style] of [ + ["Inter Variable", "400"], + ["Inter Variable", "italic 400"], + ["Fira Code Variable", "400"], + ]) { + const faces = await page.evaluate( + async ({ family, style }) => { + const loaded = await document.fonts.load(`${style} 16px "${family}"`, "Engrams 123"); + return loaded.map((face) => ({ + family: face.family.replaceAll('"', ""), + status: face.status, + })); + }, + { family, style }, + ); + assert(faces.length > 0, `${family} ${style} must load an actual FontFace.`); + assert(faces.every((face) => face.family === family && face.status === "loaded")); + } + assert(resourceRequests.some((url) => /inter-[^/]*normal\.woff2/.test(url))); + assert(resourceRequests.some((url) => /inter-[^/]*italic\.woff2/.test(url))); + assert(resourceRequests.some((url) => /fira-code-[^/]*\.woff2/.test(url))); + const optionalFamilies = await page.evaluate(() => ({ + body: getComputedStyle(document.querySelector('[data-testid="body-font-sample"]')).fontFamily, + heading: getComputedStyle(document.querySelector("h1")).fontFamily, + code: getComputedStyle(document.querySelector("pre")).fontFamily, + })); + assert.match(optionalFamilies.body, /Inter Variable/); + assert.match(optionalFamilies.heading, /Inter Variable/); + assert.match(optionalFamilies.code, /Fira Code Variable/); + for (const [id, label, light, dark] of [ + ["catppuccin", "Catppuccin", "#eff1f5", "#1e1e2e"], + ["nord", "Nord", "#eceff4", "#2e3440"], + ["solarized", "Solarized", "#fdf6e3", "#002b36"], + ["gruvbox", "Gruvbox", "#fbf1c7", "#282828"], + ]) { + await choose(page, "Color scheme", label); + for (const [mode, background] of [ + ["Light", light], + ["Dark", dark], + ]) { + await page.getByRole("button", { name: mode, exact: true }).click(); + await page.waitForFunction( + (color) => document.documentElement.style.getPropertyValue("--background") === color, + background, + ); + assert.match( + await page.getByRole("combobox", { name: "Body font", exact: true }).innerText(), + /Inter/, + ); + assert.match( + await page.getByRole("combobox", { name: "Heading font", exact: true }).innerText(), + /Inter/, + ); + assert.match( + await page.getByRole("combobox", { name: "Code font", exact: true }).innerText(), + /Fira Code/, + ); + } + await saved(); + assert.equal(snapshot("U1").document.appearance.schemeId, id); + assert.deepEqual(snapshot("U1").document.appearance.customSchemes, []); + } + assert.deepEqual(snapshot("U1").document.appearance.fonts, { + sans: "inter", + display: "inter", + mono: "fira-code", + }); + await page.evaluate(() => localStorage.clear()); + await page.reload(); + await saved(); + assert.match( + await page.getByRole("combobox", { name: "Color scheme", exact: true }).innerText(), + /Gruvbox/, + ); + assert.match( + await page.getByRole("combobox", { name: "Body font", exact: true }).innerText(), + /Inter/, + ); + assert.match( + await page.getByRole("combobox", { name: "Heading font", exact: true }).innerText(), + /Inter/, + ); + assert.match( + await page.getByRole("combobox", { name: "Code font", exact: true }).innerText(), + /Fira Code/, + ); + await page.getByRole("button", { name: "Reset appearance", exact: true }).click(); + await saved(); + + await page.getByRole("button", { name: "Use system setting", exact: true }).click(); + await page.waitForFunction(() => document.documentElement.classList.contains("dark")); + await page.emulateMedia({ colorScheme: "light" }); + await page.waitForFunction(() => !document.documentElement.classList.contains("dark")); + await page.getByRole("button", { name: "Create custom scheme", exact: true }).click(); + await page.getByRole("textbox", { name: "Scheme name", exact: true }).fill("Browser test colors"); + await page.getByLabel("Light page color", { exact: true }).evaluate((input) => { + Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value").set.call(input, "#fff4e8"); + input.dispatchEvent(new Event("input", { bubbles: true })); + input.dispatchEvent(new Event("change", { bubbles: true })); + }); + await page.getByRole("button", { name: "Save scheme", exact: true }).click(); + await page.waitForFunction( + () => document.documentElement.style.getPropertyValue("--background") === "#fff4e8", + ); + await page.getByRole("combobox", { name: "Heading font", exact: true }).click(); + await page.getByRole("option", { name: "Georgia", exact: true }).click(); + await page.waitForFunction(() => + getComputedStyle(document.querySelector("h1")).fontFamily.includes("Georgia"), + ); + await page.getByRole("combobox", { name: "Code font", exact: true }).click(); + await page.getByRole("option", { name: "System monospace", exact: true }).click(); + await page.waitForFunction( + () => !getComputedStyle(document.querySelector("pre")).fontFamily.includes("JetBrains"), + ); + await page.getByRole("button", { name: "Export scheme", exact: true }).click(); + const exported = await page + .getByRole("textbox", { name: "Color scheme JSON", exact: true }) + .inputValue(); + assert.equal(JSON.parse(exported).name, "Browser test colors"); + await saved(); + assert.equal(snapshot("U1").document.appearance.customSchemes[0].name, "Browser test colors"); + assert.equal(snapshot("U1").document.appearance.fonts.display, "georgia"); + // Prove the server restores preferences without any browser cache. + await page.evaluate(() => localStorage.clear()); + await page.reload(); + await page.getByRole("heading", { name: "Appearance", exact: true }).waitFor(); + await page.waitForFunction( + () => document.documentElement.style.getPropertyValue("--background") === "#fff4e8", + ); + assert.match( + await page.getByRole("combobox", { name: "Heading font", exact: true }).innerText(), + /Georgia/, + ); + const surfaces = await page.evaluate(() => ({ + page: getComputedStyle(document.body).backgroundColor, + pane: getComputedStyle(document.querySelector('[data-testid="pane-sample"]')).getPropertyValue( + "--background", + ), + rail: getComputedStyle(document.querySelector('[data-testid="rail-sample"]')).getPropertyValue( + "--sidebar", + ), + })); + assert.equal(surfaces.page, "rgb(255, 244, 232)"); + assert.notEqual(surfaces.pane.trim(), "#fff4e8"); + assert(surfaces.rail.trim()); + await page.waitForSelector('code[data-highlighted="true"]'); + const keyword = page + .locator('code[data-highlighted="true"] span') + .filter({ hasText: /^const$/ }) + .first(); + assert.match(await keyword.evaluate((element) => getComputedStyle(element).color), /^rgb/); + await page.waitForFunction(() => + document.querySelector("diffs-container")?.shadowRoot?.textContent.includes("After"), + ); + const diffKeyword = await page.evaluate(() => { + const shadow = document.querySelector("diffs-container").shadowRoot; + const token = [...shadow.querySelectorAll("span")].find( + (element) => element.textContent === "const", + ); + return token + ? { color: getComputedStyle(token).color, font: getComputedStyle(token).fontFamily } + : null; + }); + assert(diffKeyword, "The diff keyword did not render."); + assert.equal( + diffKeyword.color, + await keyword.evaluate((element) => getComputedStyle(element).color), + ); + assert.doesNotMatch(diffKeyword.font, /JetBrains/); + if (process.env.ENGRAM_APPEARANCE_SCREENSHOT) { + await page.screenshot({ path: process.env.ENGRAM_APPEARANCE_SCREENSHOT, fullPage: true }); + } + await page.getByRole("button", { name: "Open sample dialog", exact: true }).click(); + const dialog = page.getByRole("dialog"); + await dialog.waitFor(); + assert.equal( + await dialog.evaluate((element) => getComputedStyle(element).backgroundColor), + "rgb(255, 244, 232)", + ); + await page.keyboard.press("Escape"); + await page + .getByRole("textbox", { name: "Color scheme JSON", exact: true }) + .fill('{"id":"broken"}'); + await page.getByRole("button", { name: "Import scheme", exact: true }).click(); + await page.getByRole("alert").waitFor(); + await page.getByRole("button", { name: "Reset appearance", exact: true }).click(); + await page.waitForFunction( + () => document.documentElement.style.getPropertyValue("--background") === "", + ); + assert.equal( + await page.getByRole("button", { name: "Light", exact: true }).getAttribute("aria-pressed"), + "true", + ); + await saved(); + const accountRevision = snapshot("U1").revision; + await page.getByRole("button", { name: "Dark", exact: true }).click(); + await saved(); + assert(snapshot("U1").revision > accountRevision); + await page.getByRole("button", { name: "Switch to U2", exact: true }).click(); + await saved(); + assert.equal(await page.getByTestId("account").innerText(), "U2"); + assert.equal( + await page.getByRole("button", { name: "Light", exact: true }).getAttribute("aria-pressed"), + "true", + ); + assert.equal(snapshot("U2").revision, 0); + await page.getByRole("combobox", { name: "Heading font", exact: true }).click(); + await page.getByRole("option", { name: "Georgia", exact: true }).click(); + await saved(); + assert.equal(snapshot("U2").document.appearance.fonts.display, "georgia"); + assert.equal(snapshot("U1").document.appearance.fonts.display, "saira"); + await page.getByRole("button", { name: "Switch to U1", exact: true }).click(); + await saved(); + assert.equal( + await page.getByRole("button", { name: "Dark", exact: true }).getAttribute("aria-pressed"), + "true", + ); + assert.match( + await page.getByRole("combobox", { name: "Heading font", exact: true }).innerText(), + /Saira/, + ); + + // A failed save retains the visible edit and exposes a working retry. + failNextSave = true; + await page.getByRole("button", { name: "Light", exact: true }).click(); + await page + .getByTestId("appearance-sync-status") + .filter({ hasText: /^error$/ }) + .waitFor(); + assert.equal( + await page.getByRole("button", { name: "Light", exact: true }).getAttribute("aria-pressed"), + "true", + ); + assert.equal(snapshot("U1").document.appearance.mode, "dark"); + await page.getByRole("button", { name: "Retry", exact: true }).click(); + await saved(); + assert.equal(snapshot("U1").document.appearance.mode, "light"); + + // A second device changes the server revision. This tab must keep its local + // edit until the user chooses the current server settings. + const otherDevice = await browser.newContext(); + await otherDevice.route("**/api/v1/me/preferences", preferencesRoute); + const remotePage = await otherDevice.newPage(); + remotePage.on("pageerror", (error) => errors.push(error.message)); + await remotePage.goto(`${origin}/browser-tests/appearance.html`); + /** Wait for the second device to finish saving its account changes. */ + const remoteSaved = () => + remotePage + .getByTestId("appearance-sync-status") + .filter({ hasText: /^saved$/ }) + .waitFor(); + await remoteSaved(); + await remotePage.getByRole("button", { name: "Use system setting", exact: true }).click(); + await remotePage.getByRole("combobox", { name: "Heading font", exact: true }).click(); + await remotePage.getByRole("option", { name: "Georgia", exact: true }).click(); + await remoteSaved(); + await page.getByRole("button", { name: "Dark", exact: true }).click(); + await page + .getByTestId("appearance-sync-status") + .filter({ hasText: /^conflict$/ }) + .waitFor(); + assert.equal( + await page.getByRole("button", { name: "Dark", exact: true }).getAttribute("aria-pressed"), + "true", + ); + assert.equal(snapshot("U1").document.appearance.mode, "system"); + await page.getByRole("button", { name: "Load account settings", exact: true }).click(); + await saved(); + assert.equal( + await page + .getByRole("button", { name: "Use system setting", exact: true }) + .getAttribute("aria-pressed"), + "true", + ); + assert.match( + await page.getByRole("combobox", { name: "Heading font", exact: true }).innerText(), + /Georgia/, + ); + await remotePage.getByRole("button", { name: "Light", exact: true }).click(); + await remoteSaved(); + await page.getByRole("button", { name: "Dark", exact: true }).click(); + await page + .getByTestId("appearance-sync-status") + .filter({ hasText: /^conflict$/ }) + .waitFor(); + assert.equal(snapshot("U1").document.appearance.mode, "light"); + await page.getByRole("button", { name: "Save my current settings", exact: true }).click(); + await saved(); + assert.equal(snapshot("U1").document.appearance.mode, "dark"); + await otherDevice.close(); + + // The ownerless legacy value is offered, never assigned without consent. + await page.evaluate((appearance) => { + localStorage.setItem("engrams-appearance-v1", JSON.stringify({ ...appearance, mode: "dark" })); + }, defaults); + await page.getByRole("button", { name: "Switch to U3", exact: true }).click(); + await saved(); + assert.equal( + await page.getByRole("button", { name: "Light", exact: true }).getAttribute("aria-pressed"), + "true", + ); + assert.equal(snapshot("U3").revision, 0); + assert(await page.evaluate(() => localStorage.getItem("engrams-appearance-v1"))); + await page.getByRole("button", { name: "Import this browser appearance", exact: true }).click(); + await saved(); + assert.equal(snapshot("U3").document.appearance.mode, "dark"); + assert.equal(await page.evaluate(() => localStorage.getItem("engrams-appearance-v1")), null); + await page.getByRole("combobox", { name: "Color scheme", exact: true }).click(); + await page.getByRole("option", { name: "Dracula", exact: true }).click(); + await saved(); + assert.equal(snapshot("U3").document.appearance.schemeId, "dracula"); + await page.waitForFunction( + () => document.documentElement.style.getPropertyValue("--background") === "#282a36", + ); + assert.equal( + await keyword.evaluate((element) => getComputedStyle(element).color), + "rgb(255, 121, 198)", + ); + await page.getByRole("button", { name: "Light", exact: true }).click(); + await saved(); + assert.equal(snapshot("U3").document.appearance.mode, "light"); + await page.waitForFunction( + () => document.documentElement.style.getPropertyValue("--background") === "#fffbeb", + ); + assert.equal( + await keyword.evaluate((element) => getComputedStyle(element).color), + "rgb(163, 20, 77)", + ); + + // An unavailable optional face must use its local fallback without blocking + // controls, code rendering, or server saves. + const fallbackContext = await browser.newContext(); + fallbackContext.on("request", (request) => resourceRequests.push(request.url())); + await fallbackContext.route("**/api/v1/me/preferences", preferencesRoute); + const blockedFonts = []; + await fallbackContext.route(/(?:inter|fira-code)-[^/]*\.woff2(?:\?|$)/, async (route) => { + blockedFonts.push(route.request().url()); + await route.abort("failed"); + }); + const fallbackPage = await fallbackContext.newPage(); + fallbackPage.on("pageerror", (error) => errors.push(error.message)); + await fallbackPage.goto(`${origin}/browser-tests/appearance.html`); + await fallbackPage + .getByTestId("appearance-sync-status") + .filter({ hasText: /^saved$/ }) + .waitFor(); + await choose(fallbackPage, "Body font", "Inter"); + await choose(fallbackPage, "Code font", "Fira Code"); + await fallbackPage.evaluate(() => document.fonts.ready); + for (const family of ["Inter Variable", "Fira Code Variable"]) { + const fallback = await fallbackPage.evaluate(async (family) => { + let rejected = false; + try { + await document.fonts.load(`400 16px "${family}"`, "Engrams 123"); + } catch { + rejected = true; + } + const selector = family === "Inter Variable" ? '[data-testid="body-font-sample"]' : "pre"; + const element = document.querySelector(selector); + const stack = getComputedStyle(element).fontFamily; + const canvas = document.createElement("canvas"); + const painter = canvas.getContext("2d"); + painter.font = `16px ${stack}`; + const actualWidth = painter.measureText("Engrams MMMiii 0123456789").width; + painter.font = `16px ${stack.split(",").slice(1).join(",")}`; + const fallbackWidth = painter.measureText("Engrams MMMiii 0123456789").width; + return { + rejected, + actualWidth, + fallbackWidth, + stack, + height: element.getBoundingClientRect().height, + }; + }, family); + assert(fallback.rejected, `${family} should report the blocked face.`); + assert(fallback.actualWidth > 0 && fallback.height > 0); + assert( + Math.abs(fallback.actualWidth - fallback.fallbackWidth) < 0.1, + `${family} must render with fallback metrics.`, + ); + assert.match(fallback.stack, family === "Inter Variable" ? /system-ui/ : /ui-monospace/); + } + assert(blockedFonts.some((url) => url.includes("inter-"))); + assert(blockedFonts.some((url) => url.includes("fira-code-"))); + await fallbackPage + .getByTestId("appearance-sync-status") + .filter({ hasText: /^saved$/ }) + .waitFor(); + assert.equal(snapshot("U1").document.appearance.fonts.sans, "inter"); + assert.equal(snapshot("U1").document.appearance.fonts.mono, "fira-code"); + await fallbackContext.close(); + assert( + resourceRequests + .filter((url) => /\.woff2(?:\?|$)/.test(url)) + .every((url) => new URL(url).origin === origin), + "All bundled font requests must stay on the application origin.", + ); + assert( + !resourceRequests.some((url) => + /fonts\.(?:googleapis|gstatic)\.com/.test(new URL(url).hostname), + ), + "Fonts must not contact Google.", + ); + assert(requests.some((request) => request.method === "PATCH" && request.account === "U2")); + assert.deepEqual(errors, []); + console.log( + "Appearance browser test passed: modes, Dracula light/dark palettes and syntax, custom colors, fonts, code, diffs, dialogs, new presets in both modes, loaded local optional fonts, lazy font requests and readable font fallback, server persistence after cache removal, account isolation, retry, revision conflicts, import validation, and reset.", + ); +} finally { + await browser?.close(); + await server.close(); +} diff --git a/web/browser-tests/appearance.tsx b/web/browser-tests/appearance.tsx new file mode 100644 index 000000000..bb6f0bcab --- /dev/null +++ b/web/browser-tests/appearance.tsx @@ -0,0 +1,106 @@ +import React, { useState } from "react"; +import { createRoot } from "react-dom/client"; +import "@fontsource-variable/jetbrains-mono/wght.css"; +import "@fontsource-variable/jetbrains-mono/wght-italic.css"; +import "@fontsource-variable/saira/wdth.css"; +import "@fontsource-variable/inter/wght.css"; +import "@fontsource-variable/inter/wght-italic.css"; +import "@fontsource-variable/fira-code/wght.css"; +import "../src/index.css"; +import { ThemeProvider, useTheme } from "../src/components/theme-provider"; +import { AppearancePanel } from "../src/components/settings/AppearancePanel"; +import { CodeBlock } from "../src/components/CodeBlock"; +import PierreDiff from "../src/components/session-thread/PierreDiff"; +import { initializeAppearance } from "../src/lib/appearance"; +import { AuthContextProvider } from "../src/auth/AuthProvider"; +import { abilityFor } from "../src/lib/ability"; +import { Button } from "../src/components/ui/button"; +import { Dialog, DialogContent, DialogTitle, DialogTrigger } from "../src/components/ui/dialog"; + +initializeAppearance(); + +/** Expose surfaces, fonts, and renderers for browser assertions. */ +function Samples() { + const { theme, syncStatus } = useTheme(); + return ( + <> + {theme} +

+ Local font sample. Italic sample. +

+ {syncStatus} +
+ Navigation sample +
+
+ Work pane sample +
+
+        
+      
+ + + + + + + Theme sample dialog +

Custom colors also apply to dialogs.

+
+
+ + ); +} + +/** Switch fixture accounts while retaining server records across page reloads. */ +function Harness() { + const [userId, setUserId] = useState( + () => document.cookie.match(/(?:^|; )appearance-test-account=([^;]+)/)?.[1] ?? "U1", + ); + const value = { + userId, + principal: { + email: `${userId}@example.test`, + display_name: userId, + role: "member" as const, + is_admin: false, + can_sign_out: true, + }, + isAdmin: false, + ability: abilityFor({ id: userId, role: "user" }), + }; + return ( + + +
+ + {userId} + + +
+
+
+ ); +} + +createRoot(document.getElementById("root")!).render(); diff --git a/web/package.json b/web/package.json index a6b696e41..9111c2aa1 100644 --- a/web/package.json +++ b/web/package.json @@ -9,7 +9,7 @@ "build": "tsc -b && vite build", "preview": "vite preview --port 5173", "test": "vitest run", - "test:browser": "node browser-tests/spec-block-renderers.mjs", + "test:browser": "node browser-tests/spec-block-renderers.mjs && node browser-tests/appearance.mjs", "lint": "oxlint src", "format": "oxfmt src", "format:check": "oxfmt --check src", @@ -33,6 +33,9 @@ "@connectrpc/connect-query": "^2.3.1", "@connectrpc/connect-web": "^2.2.0", "@engrams/spec-document": "file:../orchestrator/packages/spec-document", + "@engrams/user-preferences": "file:../orchestrator/packages/user-preferences", + "@fontsource-variable/fira-code": "^5.3.0", + "@fontsource-variable/inter": "^5.3.0", "@fontsource-variable/jetbrains-mono": "^5.3.0", "@fontsource-variable/saira": "5.3.0", "@hookform/resolvers": "^5.9.1", diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index 67fd0eb56..27d715e48 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -50,6 +50,15 @@ importers: '@engrams/spec-document': specifier: file:../orchestrator/packages/spec-document version: file:../orchestrator/packages/spec-document(prosemirror-state@1.4.4)(prosemirror-view@1.42.3)(y-protocols@1.0.7(yjs@13.6.32)) + '@engrams/user-preferences': + specifier: file:../orchestrator/packages/user-preferences + version: file:../orchestrator/packages/user-preferences + '@fontsource-variable/fira-code': + specifier: ^5.3.0 + version: 5.3.0 + '@fontsource-variable/inter': + specifier: ^5.3.0 + version: 5.3.0 '@fontsource-variable/jetbrains-mono': specifier: ^5.3.0 version: 5.3.0 @@ -537,6 +546,9 @@ packages: '@engrams/spec-document@file:../orchestrator/packages/spec-document': resolution: {directory: ../orchestrator/packages/spec-document, type: directory} + '@engrams/user-preferences@file:../orchestrator/packages/user-preferences': + resolution: {directory: ../orchestrator/packages/user-preferences, type: directory} + '@exodus/bytes@1.15.1': resolution: {integrity: sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} @@ -561,6 +573,12 @@ packages: '@floating-ui/utils@0.2.12': resolution: {integrity: sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==} + '@fontsource-variable/fira-code@5.3.0': + resolution: {integrity: sha512-ZhvHeknlicraKVSJBa1NrJnXDVByUkC407zwgELYfmnx3p9g8vwIPfQCxx8YD3K7dAeCdLuHJOHlpMfRsIcApQ==} + + '@fontsource-variable/inter@5.3.0': + resolution: {integrity: sha512-OupL48va4JNofb97w6NYeF9S7W/kHNKM0Er8Dem5nqi4jeOLrVJDoE8tZEpnMJmtkvNbB1EIPPwHcdkF6b1oUA==} + '@fontsource-variable/jetbrains-mono@5.3.0': resolution: {integrity: sha512-F32xpS2NsGYoQi2ADSkKTgpJj7ozajsGgDJ8woTnqjmIB+dxDIqImjl4pXZVEExu8UFZ2ndhmX18EBS/hdz3Lw==} @@ -4727,6 +4745,10 @@ snapshots: - prosemirror-view - y-protocols + '@engrams/user-preferences@file:../orchestrator/packages/user-preferences': + dependencies: + zod: 4.6.5 + '@exodus/bytes@1.15.1(@noble/hashes@2.3.0)': optionalDependencies: '@noble/hashes': 2.3.0 @@ -4748,6 +4770,10 @@ snapshots: '@floating-ui/utils@0.2.12': {} + '@fontsource-variable/fira-code@5.3.0': {} + + '@fontsource-variable/inter@5.3.0': {} + '@fontsource-variable/jetbrains-mono@5.3.0': {} '@fontsource-variable/saira@5.3.0': {} diff --git a/web/public/licenses/fira-code.txt b/web/public/licenses/fira-code.txt new file mode 100644 index 000000000..5db0b0c30 --- /dev/null +++ b/web/public/licenses/fira-code.txt @@ -0,0 +1,93 @@ +Copyright 2014-2020 The Fira Code Project Authors (https://github.com/tonsky/FiraCode) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/web/public/licenses/inter.txt b/web/public/licenses/inter.txt new file mode 100644 index 000000000..40589daa9 --- /dev/null +++ b/web/public/licenses/inter.txt @@ -0,0 +1,93 @@ +Copyright 2016 The Inter Project Authors (https://github.com/rsms/inter) Inter-Italic[opsz,wght].ttf: Copyright 2016 The Inter Project Authors (https://github.com/rsms/inter) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/web/src/App.tsx b/web/src/App.tsx index d23291219..33cf172e8 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -4,6 +4,7 @@ import { TransportProvider } from "@connectrpc/connect-query"; import { createConnectTransport } from "@connectrpc/connect-web"; import { AuthProvider, useOptionalAuth } from "./auth/AuthProvider"; import { RPC_BASE, API_CREDENTIALS } from "./lib/base"; +import { ThemeProvider } from "./components/theme-provider"; import { router } from "./router"; const queryClient = new QueryClient({ @@ -34,7 +35,9 @@ export function App() { - + + + diff --git a/web/src/auth/AuthProvider.tsx b/web/src/auth/AuthProvider.tsx index 56f346e68..0fe917a7b 100644 --- a/web/src/auth/AuthProvider.tsx +++ b/web/src/auth/AuthProvider.tsx @@ -35,6 +35,7 @@ import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; export interface AuthState { + userId: string; /** Always present for children — the provider only renders them once the * principal has resolved. */ principal: Principal; @@ -122,6 +123,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { }; const value: AuthState = { + userId: session.user.id, principal, isAdmin, ability, diff --git a/web/src/components/CodeBlock.test.tsx b/web/src/components/CodeBlock.test.tsx index 526db5088..3b6a17f69 100644 --- a/web/src/components/CodeBlock.test.tsx +++ b/web/src/components/CodeBlock.test.tsx @@ -11,7 +11,7 @@ function code(container: HTMLElement): HTMLElement { return el; } -test("colours a fence in both themes at once", async () => { +test("uses shared syntax roles for both theme variants", async () => { const { container } = render(); // Plain until the grammar loads — the text is right from the first paint. @@ -27,11 +27,11 @@ test("colours a fence in both themes at once", async () => { expect(code(container).textContent).toBe(PYTHON); const tokens = container.querySelectorAll("span[style*='--syntax-light']"); expect(tokens.length).toBeGreaterThan(1); - // Every token carries a light AND a dark colour, so a theme flip is a - // variable swap rather than a second tokenization. + // Both theme variants reference shared syntax roles, so changing their + // inherited values does not require another tokenization. for (const token of tokens) { - expect(token.style.getPropertyValue("--syntax-light")).toMatch(/^#/); - expect(token.style.getPropertyValue("--syntax-dark")).toMatch(/^#/); + expect(token.style.getPropertyValue("--syntax-light")).toMatch(/^var\(--syntax-/); + expect(token.style.getPropertyValue("--syntax-dark")).toMatch(/^var\(--syntax-/); } }); @@ -83,3 +83,15 @@ test("keeps the tokens it has while a fence is still streaming", async () => { ); expect(code(container).textContent).toBe(grown); }); + +test("keeps highlighted tokens when custom scheme colors change", async () => { + const { container } = render(); + await waitFor(() => expect(code(container).dataset["highlighted"]).toBe("true")); + const keyword = container.querySelector("span[style*='--syntax-keyword']"); + expect(keyword).not.toBeNull(); + const original = keyword; + container.style.setProperty("--syntax-keyword", "#123456"); + expect(keyword?.style.getPropertyValue("--syntax-light")).toContain("var(--syntax-keyword,"); + expect(keyword?.style.getPropertyValue("--syntax-dark")).toContain("var(--syntax-keyword,"); + expect(container.querySelector("span[style*='--syntax-keyword']")).toBe(original); +}); diff --git a/web/src/components/CodeBlock.tsx b/web/src/components/CodeBlock.tsx index ba4ba42f5..faa80960a 100644 --- a/web/src/components/CodeBlock.tsx +++ b/web/src/components/CodeBlock.tsx @@ -4,9 +4,9 @@ import { highlight, type SyntaxLines } from "@/lib/shiki"; // Syntax highlighting for a fenced code block, in the app's own palette. // -// Each token carries BOTH theme colours as CSS variables, so switching light -// and dark is a variable swap rather than a re-highlight — no flash, no second -// tokenization, and the transcript keeps its scroll position. +// Each token references a shared syntax role, with defaults for both themes. +// A scheme change replaces the inherited role variables without re-highlighting +// or replacing token elements, so the transcript keeps its scroll position. // // Unknown languages, oversized fences and load failures all fall back to plain // text. The block's chrome (the ruled box, the header, the ground) belongs to @@ -20,6 +20,7 @@ const SETTLE_MS = 90; type Highlighted = { code: string; lines: SyntaxLines }; +/** Keep the highlighted prefix visible while a streamed fence receives more text. */ export function useSyntaxTokens( code: string, language: string, @@ -68,6 +69,7 @@ export function useSyntaxTokens( return { lines: null, tail: code, complete: false }; } +/** Preserve token elements while inherited syntax colors change. */ export function SyntaxTokens({ lines }: { lines: SyntaxLines }) { return ( <> @@ -83,6 +85,7 @@ export function SyntaxTokens({ lines }: { lines: SyntaxLines }) { ); } +/** Render shared color roles with the token weight, slant, and decoration. */ function SyntaxToken({ token }: { token: SyntaxLines[number][number] }) { const light = token.variants["light"]; const dark = token.variants["dark"]; @@ -102,6 +105,7 @@ function SyntaxToken({ token }: { token: SyntaxLines[number][number] }) { ); } +/** Render a fenced block with readable plain text until highlighting is ready. */ export function CodeBlock({ code, language, diff --git a/web/src/components/TerminalPane.test.tsx b/web/src/components/TerminalPane.test.tsx index 22e3b90c6..bf3a55f7d 100644 --- a/web/src/components/TerminalPane.test.tsx +++ b/web/src/components/TerminalPane.test.tsx @@ -22,9 +22,20 @@ // the cleanup contract. These tests assert it via mock invocation // counters so a future refactor fails loudly. +import { useState } from "react"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; -import { act, cleanup, render } from "@testing-library/react"; +import { act, cleanup, render, renderHook } from "@testing-library/react"; import { TerminalPane } from "./TerminalPane"; +import { ThemeProvider, useTheme } from "./theme-provider"; +import * as themeProvider from "./theme-provider"; +import type { ITerminalOptions } from "ghostty-web"; +import { + BUILTIN_SCHEMES, + DEFAULT_APPEARANCE, + contrastRatio, + getAppearanceTokens, + getFontOption, +} from "@/lib/appearance"; // vi.hoisted runs before the vi.mock factory, so the classes are // defined when the mock module is constructed AND accessible inside @@ -41,7 +52,10 @@ const { MockTerminal, MockFitAddon } = vi.hoisted(() => { onData = vi.fn(); onResize = vi.fn(); wasmTerm = { free: vi.fn() }; - constructor() { + renderer = { setTheme: vi.fn(), render: vi.fn(), remeasureFont: vi.fn() }; + options: ITerminalOptions; + constructor(options: ITerminalOptions) { + this.options = options; MockTerminal.instances.push(this); } } @@ -64,6 +78,7 @@ vi.mock("ghostty-web", () => ({ })); class MockWebSocket { + static OPEN = 1; static instances: MockWebSocket[] = []; readyState = 0; onopen: ((ev: Event) => void) | null = null; @@ -82,10 +97,12 @@ class MockWebSocket { } beforeEach(() => { + localStorage.clear(); + document.documentElement.removeAttribute("style"); MockTerminal.instances = []; MockFitAddon.instances = []; MockWebSocket.instances = []; - vi.stubGlobal("WebSocket", MockWebSocket as unknown as typeof WebSocket); + vi.stubGlobal("WebSocket", MockWebSocket); }); afterEach(() => { @@ -105,7 +122,11 @@ async function flush() { describe("TerminalPane mount sequence", () => { test("clear escape runs AFTER fitAddon.fit() so post-resize cells are zeroed", async () => { - render(); + render( + + + , + ); await flush(); const term = MockTerminal.instances.at(-1)!; @@ -130,7 +151,11 @@ describe("TerminalPane mount sequence", () => { }); test("opens a WebSocket to /sessions/:id/shell with the tty subprotocol", async () => { - render(); + render( + + + , + ); await flush(); expect(MockWebSocket.instances).toHaveLength(1); @@ -142,7 +167,11 @@ describe("TerminalPane mount sequence", () => { describe("TerminalPane unmount sequence", () => { test("disposes the Terminal but does NOT call wasmTerm.free() — that path double-frees", async () => { - const { unmount } = render(); + const { unmount } = render( + + + , + ); await flush(); const term = MockTerminal.instances.at(-1)!; @@ -165,7 +194,11 @@ describe("TerminalPane unmount sequence", () => { describe("TerminalPane remount cycle", () => { test("a fresh mount allocates a new Terminal and re-runs the fit-then-clear sequence", async () => { - const { unmount } = render(); + const { unmount } = render( + + + , + ); await flush(); const firstTerm = MockTerminal.instances.at(-1)!; @@ -175,7 +208,11 @@ describe("TerminalPane remount cycle", () => { // Second mount — same sessionId, same as a SPA route nav out and // back. Must produce a fresh Terminal instance, not reuse the old. - render(); + render( + + + , + ); await flush(); const secondTerm = MockTerminal.instances.at(-1)!; @@ -202,14 +239,22 @@ describe("TerminalPane remount cycle", () => { // the second mount's first multi-line write OOB'd inside the // ghostty-web WASM. Asserting the cleanup never touches // wasmTerm.free() pins the contract that prevents it. - const { unmount: unmount1 } = render(); + const { unmount: unmount1 } = render( + + + , + ); await flush(); const firstTerm = MockTerminal.instances.at(-1)!; unmount1(); expect(firstTerm.dispose).toHaveBeenCalledTimes(1); expect(firstTerm.wasmTerm.free).not.toHaveBeenCalled(); - const { unmount: unmount2 } = render(); + const { unmount: unmount2 } = render( + + + , + ); await flush(); const secondTerm = MockTerminal.instances.at(-1)!; unmount2(); @@ -217,3 +262,455 @@ describe("TerminalPane remount cycle", () => { expect(secondTerm.wasmTerm.free).not.toHaveBeenCalled(); }); }); + +/** Change colors and the code font together to exercise live renderer updates. */ +function AppearanceControls() { + const { setTheme, setFont, setScheme } = useTheme(); + return ( + + ); +} + +test("theme and loaded font changes retain the terminal and socket and send fitted dimensions", async () => { + let finishFontLoad: (() => void) | undefined; + const load = vi.fn( + () => + new Promise((resolve) => { + finishFontLoad = () => resolve([]); + }), + ); + const { getByRole } = render( + + + + , + ); + await flush(); + const term = MockTerminal.instances[0]!; + const socket = MockWebSocket.instances[0]!; + const addon = MockFitAddon.instances[0]!; + socket.readyState = MockWebSocket.OPEN; + const initialFont = term.options.fontFamily; + const initialFitCount = addon.fit.mock.calls.length; + Object.defineProperty(document, "fonts", { configurable: true, value: { load } }); + try { + await act(async () => { + getByRole("button", { name: "Change appearance" }).click(); + }); + expect(load).toHaveBeenCalledWith(expect.stringContaining("ui-monospace")); + expect(term.options.fontFamily).toBe(initialFont); + expect(addon.fit).toHaveBeenCalledTimes(initialFitCount); + await act(async () => { + finishFontLoad?.(); + }); + expect(term.options.fontFamily).toContain("ui-monospace"); + expect(term.options.fontFamily).not.toContain("JetBrains"); + expect(term.renderer.setTheme).toHaveBeenCalledWith( + expect.objectContaining({ background: "#121826", foreground: "#e5eaf3" }), + ); + expect(addon.fit).toHaveBeenCalledTimes(initialFitCount + 1); + expect(socket.send).toHaveBeenCalledWith('1{"columns":80,"rows":24}'); + expect(MockTerminal.instances).toHaveLength(1); + expect(MockWebSocket.instances).toHaveLength(1); + expect(term.dispose).not.toHaveBeenCalled(); + expect(socket.close).not.toHaveBeenCalled(); + } finally { + Reflect.deleteProperty(document, "fonts"); + } +}); + +test("reads literal colors from the terminal's CSS scope on appearance changes", async () => { + const { container, getByRole } = render( + + + + , + ); + await flush(); + const pane = container.querySelector(".absolute.inset-0"); + expect(pane).not.toBeNull(); + pane?.style.setProperty("--terminal-background", "#123456"); + pane?.style.setProperty("--terminal-cursor", "#abcdef"); + pane?.style.setProperty("--terminal-selection", "#112233"); + pane?.style.setProperty("--terminal-selection-foreground", "#ffffff"); + await act(async () => { + getByRole("button", { name: "Change appearance" }).click(); + }); + expect(MockTerminal.instances[0]?.renderer.setTheme).toHaveBeenCalledWith( + expect.objectContaining({ + background: "#123456", + cursor: "#abcdef", + selectionBackground: "#112233", + selectionForeground: "#ffffff", + }), + ); +}); + +/** Change only the palette while terminal font loading is pending. */ +function PaletteControls() { + const { setTheme, setScheme } = useTheme(); + return ( + + ); +} + +test("uses the latest palette when the mode changes during initial font loading", async () => { + let finishFontLoad: (() => void) | undefined; + const load = vi.fn( + () => + new Promise((resolve) => { + finishFontLoad = () => resolve([]); + }), + ); + Object.defineProperty(document, "fonts", { configurable: true, value: { load } }); + try { + const { getByRole } = render( + + + + , + ); + await flush(); + expect(MockTerminal.instances).toHaveLength(0); + await act(async () => { + getByRole("button", { name: "Change palette" }).click(); + }); + await act(async () => { + finishFontLoad?.(); + }); + expect(MockTerminal.instances).toHaveLength(1); + expect(MockTerminal.instances[0]?.options.theme).toEqual( + expect.objectContaining({ background: "#121826", foreground: "#e5eaf3" }), + ); + expect(MockWebSocket.instances).toHaveLength(1); + } finally { + Reflect.deleteProperty(document, "fonts"); + } +}); + +/** Select fonts independently to test pending terminal metric updates. */ +function FontControls() { + const { setFont } = useTheme(); + return ( + <> + + + + + ); +} +/** Select Fira Code before mounting a new terminal instance. */ +function FiraCodeMount() { + const { setFont } = useTheme(); + const [mounted, setMounted] = useState(false); + return ( + <> + + {mounted && } + + ); +} +/** Expose font-load completion and failure for deterministic ordering checks. */ +function deferredFont() { + let resolve!: (faces: FontFace[]) => void; + let reject!: (reason: Error) => void; + const promise = new Promise((finish, fail) => { + resolve = finish; + reject = fail; + }); + return { + promise, + resolve: () => resolve([]), + reject: () => reject(new Error("Font unavailable")), + }; +} +/** Replace the browser font loader while retaining each test's promise ordering. */ +function mockFontLoading(load: (font: string) => Promise) { + Object.defineProperty(document, "fonts", { configurable: true, value: { load } }); +} + +describe("TerminalPane Fira Code loading", () => { + afterEach(() => Reflect.deleteProperty(document, "fonts")); + + test("loads and measures the default code font when its selected ID is unknown", async () => { + const { result, unmount } = renderHook(useTheme, { wrapper: ThemeProvider }); + const current = result.current; + unmount(); + // Remove provider variables so the terminal must resolve its own font stack. + document.documentElement.removeAttribute("style"); + const theme = vi.spyOn(themeProvider, "useTheme").mockReturnValue({ + ...current, + appearance: { + ...current.appearance, + fonts: { ...current.appearance.fonts, mono: "unknown-font" }, + }, + }); + const font = deferredFont(); + const load = vi.fn(() => font.promise); + mockFontLoading(load); + try { + render(); + await flush(); + const fallback = getFontOption("mono", DEFAULT_APPEARANCE.fonts.mono).family; + expect(load).toHaveBeenCalledWith(`13px ${fallback}`); + expect(MockTerminal.instances).toHaveLength(0); + expect(MockFitAddon.instances).toHaveLength(0); + await act(async () => font.resolve()); + expect(MockTerminal.instances).toHaveLength(1); + expect(MockTerminal.instances[0]?.options.fontFamily).toBe(fallback); + expect(MockFitAddon.instances[0]?.fit).toHaveBeenCalledTimes(1); + expect(MockWebSocket.instances).toHaveLength(1); + } finally { + theme.mockRestore(); + } + }); + + test("loads Fira Code before creating and fitting the terminal", async () => { + const font = deferredFont(); + const load = vi.fn(() => font.promise); + mockFontLoading(load); + const { getByRole } = render( + + + , + ); + await act(async () => { + getByRole("button", { name: "Open Fira Code terminal" }).click(); + }); + await flush(); + expect(load).toHaveBeenCalledWith(expect.stringContaining("Fira Code Variable")); + expect(MockTerminal.instances).toHaveLength(0); + expect(MockFitAddon.instances).toHaveLength(0); + expect(MockWebSocket.instances).toHaveLength(0); + await act(async () => { + font.resolve(); + }); + expect(MockTerminal.instances).toHaveLength(1); + expect(MockTerminal.instances[0]?.options.fontFamily).toContain("Fira Code Variable"); + expect(MockFitAddon.instances[0]?.fit).toHaveBeenCalledTimes(1); + expect(MockWebSocket.instances).toHaveLength(1); + }); + + test("waits for Fira Code before changing live metrics and retains the terminal and socket", async () => { + const { getByRole } = render( + + + + , + ); + await flush(); + const term = MockTerminal.instances[0]!; + const socket = MockWebSocket.instances[0]!; + const addon = MockFitAddon.instances[0]!; + socket.readyState = MockWebSocket.OPEN; + const initialFamily = term.options.fontFamily; + const initialFits = addon.fit.mock.calls.length; + const font = deferredFont(); + const load = vi.fn(() => font.promise); + mockFontLoading(load); + await act(async () => { + getByRole("button", { name: "Use Fira Code" }).click(); + }); + expect(load).toHaveBeenCalledWith(expect.stringContaining("Fira Code Variable")); + expect(term.options.fontFamily).toBe(initialFamily); + expect(term.renderer.remeasureFont).not.toHaveBeenCalled(); + expect(addon.fit).toHaveBeenCalledTimes(initialFits); + await act(async () => { + font.resolve(); + }); + expect(term.options.fontFamily).toContain("Fira Code Variable"); + expect(term.renderer.remeasureFont).toHaveBeenCalledTimes(1); + expect(addon.fit).toHaveBeenCalledTimes(initialFits + 1); + expect(socket.send).toHaveBeenCalledWith('1{"columns":80,"rows":24}'); + expect(MockTerminal.instances).toHaveLength(1); + expect(MockWebSocket.instances).toHaveLength(1); + expect(term.dispose).not.toHaveBeenCalled(); + expect(socket.close).not.toHaveBeenCalled(); + }); + + test("uses the readable monospace fallback when a live Fira Code load fails", async () => { + const { getByRole } = render( + + + + , + ); + await flush(); + const term = MockTerminal.instances[0]!; + const socket = MockWebSocket.instances[0]!; + const font = deferredFont(); + mockFontLoading(vi.fn(() => font.promise)); + await act(async () => { + getByRole("button", { name: "Use Fira Code" }).click(); + }); + await act(async () => { + font.reject(); + }); + expect(term.options.fontFamily).toContain("Fira Code Variable"); + expect(term.options.fontFamily).toContain("ui-monospace"); + expect(term.renderer.remeasureFont).toHaveBeenCalledTimes(1); + expect(MockTerminal.instances).toHaveLength(1); + expect(MockWebSocket.instances).toHaveLength(1); + expect(term.dispose).not.toHaveBeenCalled(); + expect(socket.close).not.toHaveBeenCalled(); + }); + + test("ignores an older Fira Code completion after a faster system font change", async () => { + const { getByRole } = render( + + + + , + ); + await flush(); + const term = MockTerminal.instances[0]!; + const socket = MockWebSocket.instances[0]!; + const addon = MockFitAddon.instances[0]!; + const fira = deferredFont(); + const system = deferredFont(); + mockFontLoading( + vi.fn((font: string) => + font.includes("Fira Code Variable") ? fira.promise : system.promise, + ), + ); + await act(async () => { + getByRole("button", { name: "Use Fira Code" }).click(); + }); + await act(async () => { + getByRole("button", { name: "Use system code font" }).click(); + }); + await act(async () => { + system.resolve(); + }); + expect(term.options.fontFamily).toContain("ui-monospace"); + expect(term.options.fontFamily).not.toContain("Fira Code Variable"); + const systemFamily = term.options.fontFamily; + const fitCount = addon.fit.mock.calls.length; + const measurements = term.renderer.remeasureFont.mock.calls.length; + await act(async () => { + fira.resolve(); + }); + expect(term.options.fontFamily).toBe(systemFamily); + expect(addon.fit).toHaveBeenCalledTimes(fitCount); + expect(term.renderer.remeasureFont).toHaveBeenCalledTimes(measurements); + expect(MockTerminal.instances).toHaveLength(1); + expect(MockWebSocket.instances).toHaveLength(1); + expect(term.dispose).not.toHaveBeenCalled(); + expect(socket.close).not.toHaveBeenCalled(); + }); + + test("cancels a pending Fira Code switch when the user keeps the current font", async () => { + const { getByRole } = render( + + + + , + ); + await flush(); + const term = MockTerminal.instances[0]!; + const initialFamily = term.options.fontFamily; + const font = deferredFont(); + mockFontLoading(vi.fn(() => font.promise)); + await act(async () => { + getByRole("button", { name: "Use Fira Code" }).click(); + }); + await act(async () => { + getByRole("button", { name: "Use JetBrains Mono" }).click(); + }); + await act(async () => { + font.resolve(); + }); + expect(term.options.fontFamily).toBe(initialFamily); + expect(term.renderer.remeasureFont).not.toHaveBeenCalled(); + expect(MockTerminal.instances).toHaveLength(1); + expect(MockWebSocket.instances).toHaveLength(1); + }); +}); + +/** Change the selected preset and mode without replacing the terminal pane. */ +function SelectionPaletteControls({ + schemeId, + mode, +}: { + schemeId: string; + mode: "light" | "dark"; +}) { + const { setScheme, setTheme } = useTheme(); + return ( + + ); +} + +test.each( + BUILTIN_SCHEMES.flatMap((scheme) => [ + { scheme, mode: "light" as const }, + { scheme, mode: "dark" as const }, + ]), +)( + "passes readable $scheme.id $mode selection colors on mount and live changes", + async ({ scheme, mode }) => { + const { getByRole } = render( + + + + , + ); + await flush(); + const term = MockTerminal.instances[0]!; + const socket = MockWebSocket.instances[0]!; + expect(term.options.theme?.selectionForeground).toBe("#223133"); + expect( + contrastRatio( + term.options.theme!.selectionForeground!, + term.options.theme!.selectionBackground!, + ), + ).toBeGreaterThanOrEqual(4.5); + await act(async () => { + getByRole("button", { name: "Change selection palette" }).click(); + }); + const tokens = getAppearanceTokens(scheme, mode); + expect(term.renderer.setTheme).toHaveBeenLastCalledWith( + expect.objectContaining({ + selectionBackground: tokens["terminal-selection"], + selectionForeground: tokens["terminal-selection-foreground"], + }), + ); + const applied = term.renderer.setTheme.mock.lastCall![0]; + expect( + contrastRatio(applied.selectionForeground, applied.selectionBackground), + ).toBeGreaterThanOrEqual(4.5); + expect(MockTerminal.instances).toHaveLength(1); + expect(MockWebSocket.instances).toHaveLength(1); + expect(term.dispose).not.toHaveBeenCalled(); + expect(socket.close).not.toHaveBeenCalled(); + }, +); diff --git a/web/src/components/TerminalPane.tsx b/web/src/components/TerminalPane.tsx index 2de95e410..9f52d9638 100644 --- a/web/src/components/TerminalPane.tsx +++ b/web/src/components/TerminalPane.tsx @@ -1,4 +1,14 @@ import { useEffect, useRef, useState } from "react"; +import type { ITheme } from "ghostty-web"; + +import { useTheme } from "./theme-provider"; +import { + getFontOption, + getAppearanceTokens, + resolveScheme, + type AppearancePreferences, + type Theme, +} from "@/lib/appearance"; import { apiWsUrl } from "../lib/base"; import { PaneStatus } from "./PaneStatus"; @@ -43,57 +53,52 @@ const SERVER_OUTPUT = 0x30; const SERVER_TITLE = 0x31; const SERVER_PREFERENCES = 0x32; -// The terminal base (surface, ink, cursor, selection) tracks the -// Aston-racing theme so the shell reads as part of the page, not a -// pasted-in cream box. Cursor is the theme's "live" accent: racing green -// on the celadon paper (lime would be invisible on paper — same reason -// --ring is green in light mode), lime on the green-black ground. -// -// The ANSI 16 are program colors (`ls --color`, `grep`, prompts), not -// brand tokens — kept legible against both grounds. xterm needs literal -// hex, so these are resolved here rather than from CSS vars; a theme -// toggle while the shell is open re-colors on the next mount. -const ANSI = { - black: "#1c2b29", - red: "#d9544d", - green: "#7faa3f", - yellow: "#c7a83c", - blue: "#4f93c9", - magenta: "#c56aa6", - cyan: "#3fa79b", - white: "#cdd9c4", - brightBlack: "#5a6b63", - brightRed: "#e8736b", - brightGreen: "#9bc457", - brightYellow: "#dcc24f", - brightBlue: "#6fb0e0", - brightMagenta: "#d98cc0", - brightCyan: "#5fc4b6", - brightWhite: "#eef3e8", -}; - -const LIGHT_THEME = { - background: "#eef3e7", // lab-paper sheet (--background, light) - foreground: "#223133", // petrol ink (--foreground, light) - cursor: "#5f8f3a", // racing green — visible on paper - cursorAccent: "#eef3e7", - selectionBackground: "#dae7c8", - ...ANSI, -}; +const TERMINAL_COLORS = { + background: "background", + foreground: "foreground", + cursor: "cursor", + cursorAccent: "cursor-accent", + selectionBackground: "selection", + selectionForeground: "selection-foreground", + black: "black", + red: "red", + green: "green", + yellow: "yellow", + blue: "blue", + magenta: "magenta", + cyan: "cyan", + white: "white", + brightBlack: "bright-black", + brightRed: "bright-red", + brightGreen: "bright-green", + brightYellow: "bright-yellow", + brightBlue: "bright-blue", + brightMagenta: "bright-magenta", + brightCyan: "bright-cyan", + brightWhite: "bright-white", +} as const satisfies Partial>; -const DARK_THEME = { - background: "#11201d", // racing green-black (--background, dark) - foreground: "#d7e4cf", // pale sage ink - cursor: "#b6e84a", // Aston-F1 lime — pops on the dark ground - cursorAccent: "#11201d", - selectionBackground: "#21332f", - ...ANSI, -}; - -function pickTheme() { - const dark = - typeof document !== "undefined" && document.documentElement.classList.contains("dark"); - return dark ? DARK_THEME : LIGHT_THEME; +/** Read literal pane colors and the selected font stack for the terminal renderer. */ +function terminalAppearance( + container: HTMLElement, + appearance: AppearancePreferences, + theme: Theme, +) { + const computed = getComputedStyle(container); + const fallback = getAppearanceTokens(resolveScheme(appearance), theme); + const palette: ITheme = {}; + for (const key of Object.keys(TERMINAL_COLORS) as Array) { + const name = `terminal-${TERMINAL_COLORS[key]}`; + const color = computed.getPropertyValue(`--${name}`).trim(); + // The appearance model accepts and emits six-digit hex colors. Canvas and + // WASM require literal colors. Read this pane's scope, then use model values + // when no appearance stylesheet is attached (for example in a unit test). + palette[key] = /^#[\da-f]{6}$/i.test(color) ? color : fallback[name]; + } + const fontFamily = + computed.getPropertyValue("--appearance-font-mono").trim() || + getFontOption("mono", appearance.fonts.mono).family; + return { palette, fontFamily }; } // Module-level memoization of ghostty-web's WASM init. React's @@ -113,8 +118,25 @@ function loadGhostty(): Promise { return ghosttyModulePromise; } +/** Keep a shell terminal open while applying account colors and loaded fonts. */ export function TerminalPane({ sessionId }: TerminalPaneProps) { const containerRef = useRef(null); + const { appearance, theme } = useTheme(); + const currentAppearance = useRef({ appearance, theme }); + currentAppearance.current = { appearance, theme }; + const applyAppearanceRef = useRef<(() => void) | null>(null); + + useEffect(() => { + // Child effects run before the provider applies its CSS variables. + // Read the pane's computed colors after all effects for this render finish. + let active = true; + queueMicrotask(() => { + if (active) applyAppearanceRef.current?.(); + }); + return () => { + active = false; + }; + }, [appearance, theme]); const [status, setStatus] = useState<"loading" | "connecting" | "connected" | "closed" | "error">( "loading", ); @@ -153,11 +175,33 @@ export function TerminalPane({ sessionId }: TerminalPaneProps) { container.removeChild(container.firstChild); } + let initial = terminalAppearance( + container, + currentAppearance.current.appearance, + currentAppearance.current.theme, + ); + // Resolve metrics only after the selected font is available. Re-read + // preferences after each await, so a change during loading also configures + // the WASM cells with the latest palette before they are created. + while (document.fonts) { + const loadingFamily = initial.fontFamily; + try { + await document.fonts.load(`13px ${loadingFamily}`); + } catch { + /* Use the font's fallback. */ + } + if (disposed) return; + initial = terminalAppearance( + container, + currentAppearance.current.appearance, + currentAppearance.current.theme, + ); + if (initial.fontFamily === loadingFamily) break; + } term = new mod.Terminal({ fontSize: 13, - fontFamily: - '"JetBrains Mono Variable", "Berkeley Mono", "SF Mono", ui-monospace, monospace', - theme: pickTheme(), + fontFamily: initial.fontFamily, + theme: initial.palette, cursorBlink: true, scrollback: 5000, }); @@ -165,6 +209,42 @@ export function TerminalPane({ sessionId }: TerminalPaneProps) { fitAddon = new mod.FitAddon(); term.loadAddon(fitAddon); term.open(container); + let fontUpdate = 0; + applyAppearanceRef.current = () => { + if (!term || disposed) return; + const next = terminalAppearance( + container, + currentAppearance.current.appearance, + currentAppearance.current.theme, + ); + // Ghostty 0.4 supports canvas, cursor, and selection updates through + // this public setter. Its WASM cells retain their original default and + // ANSI RGB colors: runtime options.theme and OSC color updates are not + // supported. Do not remap RGB values; that would corrupt truecolor output. + term.renderer?.setTheme(next.palette); + if (term.renderer && term.wasmTerm) { + term.renderer.render(term.wasmTerm, true, term.viewportY, term); + } + const revision = ++fontUpdate; + if (next.fontFamily === term.options.fontFamily) return; + const updateFont = () => { + if (disposed || !term || revision !== fontUpdate) return; + term.options.fontFamily = next.fontFamily; + term.renderer?.remeasureFont(); + fitAddon?.fit(); + if (term.renderer && term.wasmTerm) { + term.renderer.render(term.wasmTerm, true, term.viewportY, term); + } + if (ws?.readyState === WebSocket.OPEN) { + ws.send(ttyClient.RESIZE + JSON.stringify({ columns: term.cols, rows: term.rows })); + } + }; + if (document.fonts) { + void document.fonts.load(`13px ${next.fontFamily}`).then(updateFont, updateFont); + } else { + updateFont(); + } + }; // ghostty-web creates a hidden