diff --git a/.github/workflows/db.yml b/.github/workflows/db.yml new file mode 100644 index 0000000..02a530a --- /dev/null +++ b/.github/workflows/db.yml @@ -0,0 +1,68 @@ +# packages/db: the shared aafo database's three migration histories +# (identity, persona, cards). Proves on every change that they apply from an +# empty database and that each history's schema files and migrations agree. Never deploys anything: prod migrations are applied by a +# person (packages/db/README.md). +name: db + +on: + pull_request: + paths: ['packages/db/**', '.github/workflows/db.yml'] + push: + branches: [dev] + paths: ['packages/db/**', '.github/workflows/db.yml'] + +jobs: + migrations: + runs-on: ubuntu-latest + defaults: + run: + working-directory: packages/db + services: + postgres: + # Same major version as Supabase aafo (17), with pgvector for the cards migrations. + image: pgvector/pgvector:pg17 + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: postgres + ports: ['5432:5432'] + options: >- + --health-cmd "pg_isready -U postgres" + --health-interval 5s --health-timeout 5s --health-retries 10 + env: + SCRATCH_URL: postgresql://postgres:postgres@localhost:5432/postgres + CI_DB_URL: postgresql://postgres:postgres@localhost:5432/ci + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + cache-dependency-path: packages/db/package-lock.json + - run: npm ci + - run: npm run typecheck + - name: Snapshot/journal consistency (all histories) + run: npm run db:check + - name: Migration hygiene + run: npm run db:lint + env: + BASE_REF: ${{ github.event_name == 'pull_request' && format('origin/{0}', github.base_ref) || '' }} + - name: Apply every migration to an empty database + run: | + psql "$SCRATCH_URL" -v ON_ERROR_STOP=1 -c 'create database ci' + psql "$CI_DB_URL" -v ON_ERROR_STOP=1 -f test/supabase-stubs.sql + DATABASE_URL="$CI_DB_URL" npm run db:migrate -- --yes + - name: Schema files and migrations are in sync (every history) + run: | + for h in identity persona cards; do + npx drizzle-kit generate --config "$h/drizzle.config.ts" --name ci_should_be_empty + done + if [ -n "$(git status --porcelain -- '*/migrations')" ]; then + echo "A schema file changed with no migration. Run: npm run :generate -- --name _" + git status --porcelain -- '*/migrations' + exit 1 + fi + - name: Drift check against a fresh build (self-consistency) + run: npm run db:drift -- --expected "$CI_DB_URL" --scratch "$SCRATCH_URL" diff --git a/.gitignore b/.gitignore index a9304be..c378f41 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,7 @@ # Root directories and files to exclude +# The root package.json is a script runner, not a workspace: a root lockfile +# would change Next.js's project-root detection for apps/*. Install per app. +/package-lock.json /.zyndai-agent/ /supabase/ /out.txt diff --git a/AGENTS.md b/AGENTS.md index 1209ea0..a9bb75e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -38,7 +38,8 @@ If a task mentions either of those by name, it's the wrong repo. | `services/memory` | Shared context/memory layer: ingest, matching, MCP server, OAuth for AI clients | FastAPI / Python | api.zynd.ai | | `infra/persona-box` | pm2 configs for the box running persona-api + persona-web | — | — | | `infra/api-box` | Caddy + docker-compose for the box running cards-api + memory | — | — | -| `packages/` | Shared code (DB migrations, contracts). **Planned, not built yet.** | — | — | +| `packages/db` | **All migrations** for the shared aafo database, Drizzle: one independent history per Postgres schema (identity / persona=`public` / cards) — read its `README.md` before any schema change | TypeScript / Drizzle | — | +| `packages/contracts` | Shared API contracts. **Planned, not built yet.** | — | — | | `docs/plans` | Architecture and migration plans behind this repo — start with `docs/plans/README.md` | — | — | **History is preserved.** Each service was merged in with `git filter-repo`, @@ -73,10 +74,21 @@ its life as a standalone repo, not just the merge date. It is reached **only** over its HTTP API — never open a direct DB connection to it from persona-api or cards-api, and never assume its tables live in the same database as persona/cards. -- There is no single migrations folder yet (`packages/db` is planned but not - built). Until it exists, **coordinate any schema change with whoever owns - the other services** before merging — don't assume your migration is the - only one in flight. +- **Postgres schemas are the boundary.** persona's tables are in `public`, + cards' in `cards`, and the one shared layer (future Zynd Account) in + `identity`. Each schema has its own independent migration history in + `packages/db//` (Drizzle), so a persona change never touches cards' + history and vice versa. Cross-schema FKs and joins still work. +- **Every schema change is a migration there.** Not the SQL editor, not a + `.sql` file inside a service: the old SQL folders are frozen. Follow + `packages/db/README.md`; `packages/db/OWNERS.md` says whose review a table + needs. +- **There is no staging database.** dev.persona.zynd.ai uses prod aafo, so a + migration applied anywhere is live everywhere. Rehearse locally, keep + migrations expand-only, and apply them only on a human's say-so (§6). +- `services/memory` also *reads* aafo (`persona_agents`, + `search_personas_fts`) with the service key. Changes to those need + memory's owner in the loop. ## 5. Testing — before *and* after every change @@ -122,8 +134,15 @@ Current state: - ❌ **Nothing has been deployed from this checkout yet** — prod and dev servers for cards and memory still run the old standalone repos. Don't trust `infra/` as a description of what's currently live; it's the target. -- ❌ `packages/db`, `packages/contracts`, CI, and a shared Supabase project - for cards are not started. +- 🟡 `packages/db` built (2026-09-27): identity `0000`; persona `0000` + (baseline of prod aafo, verified) and `0001` (security fix); cards + `0000`–`0002`; CI workflow. **Nothing applied to prod yet**: persona's + baseline still has to be recorded there and the rest applied. See + `docs/plans/ZYND_DB_UNIFY_PLAN.md` §8. +- ✅ Root runner: `npm run setup`, `npm run dev`, `npm test` from the repo + root (`docs/LOCAL_DEV.md`). +- ❌ `packages/contracts` is not started. Cards still runs on the dashboard's + Supabase project (xmfj) until the cutover in that plan. If a task depends on any of the above being finished, check with a maintainer rather than assuming the repo layout matches the live servers. diff --git a/README.md b/README.md index 9a185e3..ce32642 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,8 @@ against multiple server versions). | `services/memory` | Shared context layer — ingest, matching, MCP server, OAuth for ChatGPT/Claude/Cursor | FastAPI / Python | https://api.zynd.ai | | `infra/persona-box` | pm2 process configs for the persona server | — | — | | `infra/api-box` | Caddy + Docker Compose for the cards/memory server | — | — | -| `packages/` | Shared DB migrations / API contracts. **Planned, not built yet.** | — | — | +| `packages/db` | Migrations for the shared aafo database (Drizzle), one history per Postgres schema: identity, persona (`public`), cards. See its README | — | — | +| `packages/contracts` | Shared API contracts. **Planned, not built yet.** | — | — | Each service came from its own repo (`agent-persona`, `zynd-cards`, `memory-layer`) and was merged in with full git history — `git log --follow @@ -152,3 +153,7 @@ side effect of an unrelated change: which plans are active vs. superseded. - Per-service `CLAUDE.md`/`AGENTS.md`/`README.md` inside `apps/*` and `services/*` — stack-specific conventions. + +## Running locally + +`npm run setup`, then `npm run dev` from the repo root. Ports, env files and where the data lives: [`docs/LOCAL_DEV.md`](docs/LOCAL_DEV.md). diff --git a/apps/cards-web/.env.local.example b/apps/cards-web/.env.local.example index 9c43035..f129b6f 100644 --- a/apps/cards-web/.env.local.example +++ b/apps/cards-web/.env.local.example @@ -15,6 +15,7 @@ SUPABASE_SERVICE_ROLE_KEY= # Cards API — unchanged, still serving from xmfj until the cards-move P4 # cutover switches it to aafo. +# Local cards-api: http://localhost:8002 (npm run dev:cards-api at the repo root) NEXT_PUBLIC_API_URL=https://api.zynd.ai # Memory API, used by lib/memory.ts (ProfileChatWidget calls the cards API's # own /chat/profile proxy, not this directly). diff --git a/apps/cards-web/AGENTS.md b/apps/cards-web/AGENTS.md new file mode 100644 index 0000000..643577d --- /dev/null +++ b/apps/cards-web/AGENTS.md @@ -0,0 +1,9 @@ + + +# This is NOT the Next.js you know + +This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices. + +This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean. + + diff --git a/apps/cards-web/CLAUDE.md b/apps/cards-web/CLAUDE.md new file mode 100644 index 0000000..43c994c --- /dev/null +++ b/apps/cards-web/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/apps/persona-web/.env.local.example b/apps/persona-web/.env.local.example new file mode 100644 index 0000000..da45c12 --- /dev/null +++ b/apps/persona-web/.env.local.example @@ -0,0 +1,11 @@ +# persona-web local env. Copy to apps/persona-web/.env.local (gitignored). +# The anon key is a PUBLIC key by design (it ships in the browser bundle); +# RLS is what protects data. Never put the service-role key in a NEXT_PUBLIC_ var. +NEXT_PUBLIC_SUPABASE_URL= +NEXT_PUBLIC_SUPABASE_ANON_KEY= + +# Local backends (ports from the root package.json) +NEXT_PUBLIC_API_URL=http://localhost:8000 +NEXT_PUBLIC_MEMORY_API_URL=http://localhost:8001 + +NEXT_PUBLIC_GA_ID= diff --git a/apps/persona-web/.gitignore b/apps/persona-web/.gitignore index 5ef6a52..b721bff 100644 --- a/apps/persona-web/.gitignore +++ b/apps/persona-web/.gitignore @@ -32,6 +32,7 @@ yarn-error.log* # env files (can opt-in for committing if needed) .env* +!.env.local.example # vercel .vercel diff --git a/apps/persona-web/db/README.md b/apps/persona-web/db/README.md new file mode 100644 index 0000000..aaa70e2 --- /dev/null +++ b/apps/persona-web/db/README.md @@ -0,0 +1,9 @@ +# Frozen — do not add or apply anything here + +Schema changes to the shared aafo database now go through the tracked +migration histories in [`packages/db`](../../../packages/db/README.md) (Drizzle). + +The SQL in this folder is history. It was applied to prod by hand at various +times, and nothing records which files ran, so **never re-run it**. The +current, verified state of the schema is `packages/db/persona/migrations/0000_baseline_persona.sql` +plus the migrations after it. diff --git a/apps/persona-web/package.json b/apps/persona-web/package.json index 0d646ee..3dc9d50 100644 --- a/apps/persona-web/package.json +++ b/apps/persona-web/package.json @@ -6,8 +6,7 @@ "dev": "next dev -H 127.0.0.1", "build": "next build", "start": "next start", - "lint": "eslint", - "db:policies": "psql \"$DIRECT_URL\" -f db/sql/policies.sql" + "lint": "eslint" }, "dependencies": { "@dicebear/collection": "^9.4.2", diff --git a/docs/LOCAL_DEV.md b/docs/LOCAL_DEV.md new file mode 100644 index 0000000..7d804e2 --- /dev/null +++ b/docs/LOCAL_DEV.md @@ -0,0 +1,150 @@ +# Local development + +Everything runs from the repo root. + +## One-time setup + +Needs Node 22+, [uv](https://docs.astral.sh/uv/) (Python 3.12 venvs), and +Docker only if you run memory locally. + +```bash +npm run setup # npm ci in apps/persona-web, apps/cards-web, packages/db + # + a .venv in each Python service (scripts/setup-python.sh) +``` + +Then create the env files from their templates (all gitignored, never +committed; no key is hard-coded anywhere in the code): + +| Copy | To | +|---|---| +| `apps/persona-web/.env.local.example` | `apps/persona-web/.env.local` | +| `apps/cards-web/.env.local.example` | `apps/cards-web/.env.local` | +| `services/persona-api/.env.example` | `services/persona-api/.env` | +| `services/cards-api/.env.example` | `services/cards-api/.env` | +| `services/memory/.env.example` | `services/memory/.env` | + +## Env vars + +"Supabase" below means one Supabase project's values (Settings → API): +the project URL, the anon key (public) and the service-role key (secret, +backend only). Today that is aafo (prod); see the warning further down. + +### Minimum to boot everything + +| Where | Variable | Value locally | +|---|---|---| +| `apps/persona-web/.env.local` | `NEXT_PUBLIC_SUPABASE_URL`, `NEXT_PUBLIC_SUPABASE_ANON_KEY` | Supabase URL + anon key | +| | `NEXT_PUBLIC_API_URL` | `http://localhost:8000` | +| | `NEXT_PUBLIC_MEMORY_API_URL` | `http://localhost:8001` | +| `apps/cards-web/.env.local` | `NEXT_PUBLIC_SUPABASE_URL`, `NEXT_PUBLIC_SUPABASE_ANON_KEY` | Supabase URL + anon key | +| | `SUPABASE_SERVICE_ROLE_KEY` | service-role key (server-side only) | +| | `NEXT_PUBLIC_API_URL` | `http://localhost:8002` (cards-api) | +| | `NEXT_PUBLIC_ZYND_API_URL` | `http://localhost:8001` (memory: token exchange, findability) | +| | `NEXT_PUBLIC_SITE_URL` | `http://localhost:3002` | +| `services/persona-api/.env` | `SUPABASE_URL`, `SUPABASE_ANON_KEY`, `SUPABASE_SERVICE_KEY` | Supabase URL + both keys | +| | `FRONTEND_URL`, `PUBLIC_PAGE_BASE_URL` | `http://localhost:3000` | +| | `MEMORY_LAYER_URL` | `http://localhost:8001` | +| | `MEMORY_LAYER_JWT_SECRET` | **same value as memory's `JWT_SECRET`** | +| | one LLM: `LLM_PROVIDER` + its key (`OPENAI_API_KEY`, or `OPENROUTER_API_KEY` + `OPENROUTER_MODEL`, or `GEMINI_API_KEY`, …) | your key | +| `services/cards-api/.env` | `SUPABASE_URL`, `SUPABASE_SERVICE_KEY` | Supabase URL + service-role key | +| | `SUPABASE_DB_SCHEMA` | `cards` on aafo (once the cards migrations are applied); `public` on the old xmfj project | +| | `SUPABASE_JWT_SECRET` | the project's legacy JWT secret (only for old HS256 tokens; can stay empty) | +| | `OPENROUTER_API_KEY` (+ optional `OPENROUTER_MODEL`), `OPENAI_API_KEY` (embeddings for search) | your keys | +| | `CLOUDFLARE_ACCOUNT_ID`, `CLOUDFLARE_AI_KEY` | only for the profile chat widget (Workers AI) | +| | `FRONTEND_URL`, `SITE_BASE_URL`, `API_BASE_URL` | `http://localhost:3002`, `http://localhost:3002`, `http://localhost:8002` | +| | `MEMORY_LAYER_URL`, `MEMORY_SERVICE_TOKEN` | `http://localhost:8001`, **same value as memory's `MEMORY_SERVICE_TOKEN`** | +| `services/memory/.env` | `DATABASE_URL`, `REDIS_URL` | defaults already match `npm run dev:infra` (`localhost:5433`, `localhost:6380`) | +| | `JWT_SECRET`, `MEMORY_SERVICE_TOKEN` | any random strings, shared with persona-api / cards-api as above | +| | `SUPABASE_URL`, `SUPABASE_ANON_KEY`, `SUPABASE_SERVICE_KEY` | Supabase values: memory verifies Supabase logins (`/token/exchange`) and reads `persona_agents` | +| | `PERSONA_ENABLED` | `true` to turn on the persona-network features (link, connect, message); off by default | +| | `OPENAI_API_KEY`, `DEEPSEEK_API_KEY` | embeddings + fact extraction (or `MOCK_LLM=true` to skip both) | +| | `PUBLIC_BASE_URL`, `MCP_PUBLIC_BASE_URL` | `http://localhost:8001`, `http://localhost:8090` | +| | `CORS_ORIGINS` | add `http://localhost:3002`: the default only allows `:3000` | +| | `ENABLE_DEV_BEARER=true`, `DEV_BEARER_TOKEN` | optional: call memory's API with a static token while developing | +| `packages/db` | `DATABASE_URL` | only when running migrations; session-pooler URL, port 5432 | +| `infra/local` | none | Postgres/Redis credentials are fixed (`zynd`/`zynd`) | + +**Values that must match across services:** persona-api `MEMORY_LAYER_JWT_SECRET` = memory `JWT_SECRET`; +cards-api `MEMORY_SERVICE_TOKEN` = memory `MEMORY_SERVICE_TOKEN`. + +### Optional, per feature + +Everything else in the `.env.example` files turns on one integration and +can stay empty until you work on it: Google/LinkedIn/Twitter/GitHub/Notion +OAuth apps (persona-api, memory), Telegram (`TELEGRAM_BOT_TOKEN`, +`TELEGRAM_WEBHOOK_SECRET`), enrichment (`QUICKENRICH_*`, `APIFY_*`), web +search (`EXA_API_KEY`, `TAVILY_API_KEY`, `FIRECRAWL_API_KEY`), the X bot +(`X_*`), SEO pings (`INDEXNOW_KEY`, `BING_*`), analytics +(`NEXT_PUBLIC_GA_ID`, `NEXT_PUBLIC_ANALYTICS_ID`) and the Zynd network +(`ZYND_*`, `NGROK_AUTH_TOKEN`). OAuth logins in the browser also need +`http://localhost:3000/**` and `http://localhost:3002/**` in the Supabase +project's Auth redirect URLs. + +## Run + +```bash +npm run dev # persona-web, cards-web, persona-api, cards-api together +npm run dev:infra # memory's Postgres + Redis in Docker (infra/local/docker-compose.yml) +npm run dev:memory # memory API + MCP server + worker (needs dev:infra) +``` + +Each piece also runs alone: `npm run dev:persona-web`, `dev:cards-web`, +`dev:persona-api`, `dev:cards-api`. + +| Service | Local URL | +|---|---| +| persona-web | http://localhost:3000 | +| cards-web | http://localhost:3002 | +| persona-api | http://localhost:8000 | +| memory API | http://localhost:8001 | +| memory MCP | http://localhost:8090 | +| cards-api | http://localhost:8002 | +| memory Postgres / Redis (Docker) | localhost:5433 / localhost:6380 | + +## Test + +```bash +npm test # all Python suites + lint/typecheck of both web apps +npm run test:persona-api # or test:cards-api, test:memory, check:web +``` + +Known baseline failures are listed in `AGENTS.md` §5. + +## Where the data lives + +- **memory** uses plain Postgres + Redis, so it runs fully locally in Docker. +- **persona and cards** use Supabase: Postgres plus Auth (Google/LinkedIn + OAuth), RLS, Storage and Realtime. Emulating Auth locally is the painful + part, so local dev points `SUPABASE_URL` and the keys at a **hosted** + Supabase project instead of a local one. + +> **Warning: today that hosted project is prod.** There is no separate dev +> Supabase project yet, and dev.persona.zynd.ai already runs on prod aafo. +> Anything you do locally with aafo's keys reads and writes real user data. + +**Recommended next step: a dev Supabase project.** It costs a small +always-on project, and in return local and dev stop touching prod. It can be +built entirely from the migrations: + +1. Create a new Supabase project (e.g. `zynd-dev`, Postgres 17). +2. `cd packages/db && DATABASE_URL= npm run db:migrate -- --yes`. + On an empty project the baseline (0000) runs for real, then every later + migration. This is also a full rehearsal of every migration. +3. In the dev project's Auth settings, enable LinkedIn (OIDC) and add + `http://localhost:3000/**` and `http://localhost:3002/**` as redirect URLs. +4. Put the dev project's URL and keys in the env files above, and in + dev.persona.zynd.ai's env. + +If fully-offline development is ever needed, `supabase start` (Supabase CLI) +runs the whole stack in Docker and the same migrations apply to it. It's +heavier, and OAuth redirects need extra setup. + +## Why the root isn't an npm workspace + +`package.json` at the root only runs scripts. Each app keeps its own lockfile +and installs in its own folder, because that is how they are built in prod: +pm2 on the persona box runs `npm` inside `apps/persona-web`, and Vercel +builds `apps/cards-web` as its root directory. An npm workspace moves every +lockfile to the root, which would change both of those builds. Don't run +`npm install` at the root; `/package-lock.json` is gitignored so a stray one +can't be committed. diff --git a/docs/plans/ZYND_DB_UNIFY_PLAN.md b/docs/plans/ZYND_DB_UNIFY_PLAN.md index d95837e..9c25343 100644 --- a/docs/plans/ZYND_DB_UNIFY_PLAN.md +++ b/docs/plans/ZYND_DB_UNIFY_PLAN.md @@ -1,205 +1,630 @@ -# Shared DB plan: cards data + login → persona DB (aafo), one Drizzle migration history +# Shared DB plan: cards data + login → persona DB (aafo), schema-separated Drizzle migrations | | | |---|---| -| **Status** | Plan · 2026-09-26 · nothing executed | -| **Goal** | Cards stops using the dashboard Supabase project (**xmfj**). Its tables, avatars and login move into the persona project (**aafo**), so cards and persona share one database and one set of users. Every schema change for that DB, from either product, is a migration in **`packages/db`**, managed by **Drizzle**. | -| **Replaces** | `ZYND_CARDS_MOVE_PLAN.md` §3 (Supabase-CLI layout for `packages/db`), P1, P3 and P4. Its P0, P2 (done) and P5 still apply and are referenced below | -| **Out of scope** | memory-layer keeps its own Postgres (decided). The zynd.ai dashboard keeps xmfj and its own Prisma schema; it only loses the cards tables at the end | +| **Status** | 2026-09-27 · `packages/db` built with three independent histories (D14); nothing applied to prod yet. Progress: §8 | +| **Goal** | Cards moves off the dashboard Supabase project (**xmfj**, `xmfjvixclgqcmjmtecwv`) and into the persona project (**aafo**, `aafoguuvmaxymrtnfafn`). That covers its 4 tables, its avatars and its login. Cards and persona then share one database and one set of users (`auth.users`), separated by Postgres schema: persona in `public`, cards in `cards`, the shared identity layer in `identity`. Each schema has its own independent Drizzle migration history in **`packages/db`** (D14) | +| **Replaces** | `ZYND_CARDS_MOVE_PLAN.md` §3, P1, P3 and P4. Its P0 is folded in here (§7). Its P2 (`apps/cards-web`) is done. Its P5 (dashboard cleanup) still applies, at the end (§8 phase I) | +| **Out of scope** | `services/memory` keeps its own Postgres. The zynd.ai dashboard keeps xmfj and its own Prisma schema. `zynd-bridge` is not touched | +| **Evidence** | Full catalog dumps of both projects, from the query in Appendix A, run by the user in the Supabase SQL editor on 2026-09-26, plus a code read of this repo on the same day | --- -## 1. Where things stand (checked 2026-09-26) +## 0. Summary -**Monorepo `zynd/`:** on `dev`, clean, **no git remote yet** (M3 not done), **no `packages/` and no `.github/`** (M2.7–M2.9 not done). `apps/cards-web` is built (`5cd915b`) but not live. P0 from the cards-move plan (`TRUSTED_SUPABASE_URLS`, `MAINTENANCE_READONLY`) isn't in the code yet. +1. **Tool:** Drizzle (`drizzle-kit` 0.31.x + `drizzle-orm` 0.45.x, exact pins) in `packages/db`. It manages schema and migrations only. The runtime stays on supabase-py / supabase-js. +2. **Three histories, one per Postgres schema (D14):** `identity` (shared, empty for now), `persona` (owns `public`), `cards` (owns a new `cards` schema). Each migrates independently. +3. **persona baseline:** persona's `0000` recreates aafo's `public` schema exactly as it is today: 28 tables, 43 extra indexes, 4 functions, 1 trigger and 72 policies. It is recorded as already applied on prod and never runs there. +4. **Persona security fix:** persona's `0001` drops the `using (true)` read policy on `persona_agents`. +5. **Cards:** cards' `0000`–`0002` create the `cards` schema in aafo: `vector` extension, 4 tables, 3 functions, service-role-only access, plus one new column, `cards.agent_profile_cards.owner_user_id → auth.users`. +6. **Logins:** no users are copied. Card owners sign in to aafo, and ownership still matches on `owner_email`. `owner_user_id` is filled at cutover by email match, then set on every signed-in write. That shared user id is what links a card to a persona. +7. **Data move:** `pg_dump --data-only` of the 4 tables (from xmfj's `public` into aafo's `cards`) plus a copy of the `avatars` bucket, rehearsed first, then a final copy during a short read-only window. +8. **After cutover:** 14 days of rollback safety, then xmfj trust is removed and the xmfj cards tables and bucket are dropped. -**aafo (persona, Postgres 17.6):** no migration tracking. Schema history is spread over three folders, all used recently, and nothing records which files ran on prod: -- `services/persona-api/db/` has 30 `patch_*.sql` files plus `schema.sql` and `migrate_v2.sql` -- `services/persona-api/supabase/migrations/` has 3 Supabase CLI files -- `apps/persona-web/db/migrations/0000–0004`, which is Prisma's folder format left over after Prisma was removed, plus `db/sql/policies.sql` - -**Prisma was tried here and removed.** `a9b0c0e` (May 13) made Prisma the canonical schema, used only for migrations while the runtime stayed on supabase-py and supabase-js. `71fcd1a` (Jun 3) removed it. RLS policies, the realtime publication, partial indexes, and the FTS trigger/RPC all had to live in a side-car `policies.sql` that Prisma couldn't express. +--- -**xmfj (cards part):** `agent_profile_cards`, `x_accounts`, `x_mentions`, `x_conversations`, and the functions `skill_names`, `match_cards` and `search_cards_fts`. It also has a `vector(1536)` column with an HNSW index, a generated `search_tsv` column with a GIN index, RLS policies and the `avatars` bucket. The prod schema has columns that no repo SQL creates (`owner_email`), so the prod dump is the source of truth. **The dashboard's `prisma/schema.prisma` doesn't model any cards table** (only `developer_keys`, `subscribers`, `blog_posts`, `entities`, …), so removing them from xmfj later doesn't touch the dashboard's Prisma history. +## 1. Decisions -**xmfj schema export (user, 2026-09-26):** besides the dashboard tables and the 4 cards tables, xmfj also holds **copies of every persona table** (`persona_agents`, `dm_threads`, `api_tokens`, …), with the same columns as aafo, plus a `keyword_posts` table that no local repo references. **Scope confirmed by the user: only the 4 cards tables move. Login moves to aafo, and no xmfj users are copied.** The `agent_profile_cards` columns in prod are `id, status, handle_github, handle_x, card, search_tsv, created_at, updated_at, published_at, handle (unique), embedding, scrape_raw, user_intent, owner_email, suggested_posts, claim_token_hash`. None of the 4 cards table names exist in aafo, so there's no clash. The visualizer export leaves out indexes, policies, functions, triggers, the vector dimension, and whether `search_tsv` is generated or a plain default. The catalog query in the chat (2026-09-26) fills those gaps. +| # | Decision | Why | +|---|---|---| +| D1 | **Drizzle**, not Prisma. Pin `drizzle-kit@0.31.11` and `drizzle-orm@0.45.3`, the current `latest` tags (not the 1.0 betas) | Drizzle expresses RLS policies, Supabase roles, `vector(n)`, HNSW/GIN indexes and generated columns natively. Functions, triggers and publications go in hand-written migrations **inside the same ordered history**. Persona already tried Prisma (`a9b0c0e`) and removed it three weeks later (`71fcd1a`), because RLS, the realtime publication, partial indexes and the FTS trigger/RPC had to live in a side-car SQL file | +| D2 | `packages/db` is the **only** place schema changes to aafo come from, organised as independent histories per schema (D14) | Tracked, reviewable in PRs, reproducible on a fresh database | +| D3 | **Only the 4 cards tables and the `avatars` bucket move**: `agent_profile_cards`, `x_accounts`, `x_mentions`, `x_conversations` | User decision. xmfj's copies of the persona tables, `keyword_posts` and the dashboard tables stay where they are (§2.3) | +| D4 | **Login moves to aafo, and no users are copied** | Nothing in cards stores an xmfj user id, because ownership is `owner_email` (§5) | +| D5 | ~~Cards tables go in the `public` schema~~ **Superseded by D14 (2026-09-27): cards goes in its own `cards` schema.** cards-api switches schema with one env var (`SUPABASE_DB_SCHEMA`, used as the client's default schema), so no query code changes | — | +| D6 | `vector` goes in the **`extensions`** schema on aafo (xmfj has it in `public`) | Supabase default and lint-clean. Cards functions get `set search_path = cards, extensions` | +| D7 | **Cards tables are service-role only**: drop the anon `"public read published cards"` policy, revoke table grants and RPC execute from `anon`/`authenticated` | No code reads cards tables from a browser. cards-web goes through cards-api. On aafo, Supabase's default grants would give `anon` full table privileges, so the old policy would expose `owner_email`, `claim_token_hash` and `scrape_raw`. On xmfj it can't be used today, because xmfj revoked `anon`/`authenticated` grants on every table. This keeps that effective access | +| D8 | Drop the `persona_agents` `"Public read persona agents"` policy (`using (true)`) in its own migration, `0001` | It exposes every persona's `brief_content`, `profile` and `webhook_url` to the anon key. Checked: no code reads `persona_agents` with anon, and every RLS subquery on `persona_agents` filters `user_id = auth.uid()`, which `"Users can read own persona"` still allows (§2.4) | +| D9 | `packages/db` is a **standalone npm package** (own lockfile, no root workspace) | Vercel builds of `apps/*` stay unchanged. Shared TS types can come later | +| D10 | Prod migrations are applied **by a person**. Anyone on the team may run them, after a clean drift check and a local rehearsal | AGENTS.md §6: prod SQL needs a human | +| D11 | **There is no staging database.** `dev.persona.zynd.ai` runs against **prod aafo** (user, 2026-09-26). The rehearsal is a local scratch Postgres (stubs + pgvector), and every migration must be **expand-only**, so that the code on `dev` and on `main` both keep working against the same database. Destructive changes need two steps: expand, deploy, then contract | Any migration applied is live for dev and prod at once | +| D12 | **Cards uses persona's login: LinkedIn (OIDC) only.** Google, GitHub and the email magic link are removed from cards-web. Magic links come back once an email provider (SMTP) is chosen, which is `null` for now | User decision. aafo already has LinkedIn configured, so no new OAuth app is needed | +| D14 | **Postgres schemas are the boundary; one migration history per schema** (user, 2026-09-27). `identity` = the one shared layer (reviewed by both products; empty until Stage 2, since today the shared id is `auth.users`). `persona` owns `public`. `cards` owns `cards`. Each has its own folder, its own tracking table (`drizzle.___migrations`) and its own transaction; one tool (Drizzle) and one set of scripts serve all three | Independent ownership without two tools fighting over one database: no name clashes, no shared PR queue for product-only changes, and cross-schema FKs/joins still work. persona is **not** moved into a `persona` schema now: that would touch every persona query, realtime subscription and policy on a database with no staging copy | +| D15 | **The monorepo root is a script runner, not an npm workspace**, and local dev points persona/cards at hosted Supabase while memory's Postgres + Redis run in Docker (`docs/LOCAL_DEV.md`) | pm2 and Vercel install inside each app folder; a workspace would move lockfiles to the root. Supabase Auth is the hard part to emulate locally | +| D13 | **Remove xmfj's copies of the persona tables** once the activity check (Appendix F) proves nothing uses them. Leave `keyword_posts` alone | User decision. The dashboard code and this repo don't reference either (checked 2026-09-26) | -**aafo catalog (user, 2026-09-26):** -- **Extensions:** `pg_stat_statements`, `pgcrypto`, `plpgsql`, `supabase_vault`, `uuid-ossp`. **`vector` is not enabled**, so D3 has to enable it first. -- **Public functions:** `is_persona_group_member`, `is_persona_group_manager` (both SECURITY DEFINER), `persona_agents_search_vector_update` (a trigger function), `search_personas_fts`. There are no enums; bounded columns are `text` + CHECK. -- **Policies:** 70 in total. Every table has a `to public using (auth.role() = 'service_role')` policy. These are redundant, because service_role bypasses RLS, but the baseline reproduces them verbatim. -- **Still missing:** public indexes, the actual `CREATE TRIGGER`s, views, the realtime publication and grants. -- **Security:** policy `"Public read persona agents"` on `persona_agents` is `using (true)` for role `public`. Anyone with the anon key (it ships in every frontend) can read every persona's `brief_content`, `profile` and `webhook_url` through PostgREST. No app code reads `persona_agents` or `agent_profile_cards` from the browser; every read goes through the backends. xmfj's `"public read published cards"` has the same problem: it exposes `owner_email`, `claim_token_hash` and `scrape_raw` of published cards. agent-persona is a public repo, so keep this out of commit messages until it's fixed in prod. +--- -**Ownership is by email, not by user id.** cards-api's `verify_supabase_jwt` returns the token's `email`, and cards store `owner_email`. No cards table stores an xmfj `auth.users` UUID. **So xmfj auth users don't need to be copied** (§4). +## 2. Current state (verified 2026-09-26) -**Runtime access:** persona-api and cards-api use `supabase-py` with the service key. persona-web and cards-web use `@supabase/ssr` for login. cards-web writes directly only to Storage (`avatars`); everything else goes through cards-api. +### 2.1 Repo -**Local tooling:** Homebrew Postgres 18 **without pgvector**, and no Docker. Applying the cards migrations locally needs `brew install pgvector` (ask first) or a staging Supabase project/branch. +- **Repo:** `zynd-platform` (remote `zyndai/platform`), with `dev` active and `main` protected. `packages/` and `.github/` don't exist yet. +- **cards-web:** `apps/cards-web` is built but not live, and already configured for aafo auth. +- **P0 code:** none of it exists yet (`TRUSTED_SUPABASE_URLS`, `MAINTENANCE_READONLY`). +- **Old SQL folders, none of them tracked** (nothing records what ran on prod): -## 2. Decision: Drizzle, not Prisma (recommended) + | Folder | Contents | + |---|---| + | `services/persona-api/db/` | 30 `patch_*.sql`, plus `schema.sql` and `migrate_v2.sql` | + | `services/persona-api/supabase/migrations/` | 3 files | + | `apps/persona-web/db/migrations/0000–0004` | Prisma-format leftovers, plus `db/sql/policies.sql`. `apps/persona-web/package.json` had a `db:policies` script (removed in phase A) | + | `services/cards-api/db/` | 6 files; they miss prod columns such as `owner_email` | -In both cases the tool only manages the schema and migrations. The Python services keep using supabase-py, and nothing about the runtime changes. +### 2.2 aafo (persona), the target -| Need in this DB | Drizzle (`drizzle-kit`) | Prisma | +| Item | Value | +|---|---| +| Postgres | 17.6 (Supabase) | +| Extensions | `pg_stat_statements`, `pgcrypto`, `uuid-ossp` (schema `extensions`), `plpgsql`, `supabase_vault`. **No `vector`** | +| Tables (28, all in `public`, RLS enabled on all) | `a2a_tasks`, `agent_tasks`, `api_tokens`, `brief_todos`, `callback_results`, `chat_messages`, `dm_messages`, `dm_threads`, `enriched_companies`, `enriched_contacts`, `github_profiles`, `linkedin_profiles`, `oauth_pending_state`, `outbound_callbacks`, `pending_approvals`, `persona_agents`, `persona_group_audit_events`, `persona_group_constraints`, `persona_group_invitations`, `persona_group_members`, `persona_group_messages`, `persona_groups`, `published_pages`, `suggested_contact_runs`, `suggested_contacts`, `telegram_chat_history`, `telegram_links`, `twitter_profiles` | +| Tables with RLS on and **no** policies (service role only) | `oauth_pending_state`, `persona_group_invitations` | +| Constraints | PKs, uniques, CHECKs (limited-value columns are `text` + CHECK, **no enums**), FKs to `auth.users(id)` with `ON DELETE CASCADE` / `SET NULL` | +| Indexes | 43 besides the constraint-backed ones. They include partial indexes (`agent_tasks_one_open_proposal`, `brief_todos_user_title_uniq`, `persona_group_invitations_open_uniq`, …), a GIN index (`persona_agents_search_vector_idx`) and `NULLS FIRST` / `DESC` orderings. Two are duplicates of unique constraints: `published_pages_slug_idx`, `telegram_links_chat_idx` | +| Functions (4) | `is_persona_group_member(uuid)`, `is_persona_group_manager(uuid)` (both `SECURITY DEFINER`, `search_path=public`, **EXECUTE revoked from PUBLIC**, granted to anon/authenticated/service_role), `persona_agents_search_vector_update()` (trigger), `search_personas_fts(text,int)` | +| Trigger (1) | `persona_agents_search_vector_trigger` BEFORE INSERT OR UPDATE on `persona_agents` | +| Policies | **72**. Every table has a redundant `"Service role full access on …"` (`to public using (auth.role()='service_role')`). The baseline copies them verbatim | +| Realtime publication `supabase_realtime` | `a2a_tasks`, `agent_tasks`, `callback_results`, `dm_messages`, `dm_threads`, `outbound_callbacks`, `pending_approvals`, `persona_group_invitations`, `persona_group_messages` | +| Table grants | Supabase defaults (ALL to `anon`, `authenticated`, `postgres`, `service_role`) on every table. These come from default privileges, so the baseline doesn't create them | + +### 2.3 xmfj (dashboard), the source + +**Cards objects, the only ones that move:** + +| Object | Prod definition | +|---|---| +| `agent_profile_cards` | `id text PK`, `status text NOT NULL DEFAULT 'draft'`, `handle_github text`, `handle_x text`, `card jsonb NOT NULL`, `search_tsv tsvector GENERATED ALWAYS AS (…skill_names(card)) STORED`, `created_at`/`updated_at timestamptz NOT NULL DEFAULT now()`, `published_at timestamptz`, `handle text UNIQUE`, `embedding vector(1536)`, `scrape_raw jsonb`, `user_intent jsonb`, `owner_email text`, `suggested_posts jsonb`, `claim_token_hash text` | +| its indexes | `agent_profile_cards_embedding_hnsw_idx` (hnsw, `vector_cosine_ops`), `agent_profile_cards_tsv_idx` (gin), `agent_profile_cards_status_idx`, `idx_cards_owner_email`, `agent_profile_cards_handle_idx` (duplicates the unique constraint, so it's not recreated) | +| `x_accounts` | `x_user_id text PK`, `username text NOT NULL`, `card_id text → agent_profile_cards(id)` (NO ACTION), `created_at`/`updated_at` NOT NULL DEFAULT now() | +| `x_conversations` | `id uuid PK DEFAULT gen_random_uuid()`, `x_user_id text NOT NULL`, `card_id text → agent_profile_cards(id)`, `status text NOT NULL DEFAULT 'initial'`, `current_question text`, `answered jsonb NOT NULL DEFAULT '{}'`, timestamps; index `x_conversations_user_idx(x_user_id)` | +| `x_mentions` | `tweet_id text PK`, `x_user_id text NOT NULL`, `text text`, `status text NOT NULL DEFAULT 'processed'`, `created_at` NOT NULL DEFAULT now(); index `x_mentions_user_idx(x_user_id)` | +| functions | `skill_names(jsonb)` IMMUTABLE; `match_cards(vector, int=200)` STABLE; `search_cards_fts(text, int=200)` STABLE. No `search_path` set; exact bodies are in Appendix C | +| policies | `"public read published cards"` (to public, `status='published'`), `"service role full access on cards"` and the 3 `x_*` equivalents (`to service_role using (true) with check (true)`) | +| grants | only `postgres` and `service_role`: xmfj revoked `anon`/`authenticated` on all tables | +| extension | `vector` 0.8.0 **in schema `public`** | +| storage | bucket `avatars`; card JSON stores public URLs on the xmfj storage host | + +**Staying in xmfj, not migrated:** +- **Dashboard tables:** `developer_keys`, `entities`, `subscribers`, `blog_posts`, `topups`, `_prisma_migrations`. The dashboard's `prisma/schema.prisma` doesn't model the cards tables, so dropping them later doesn't affect its Prisma history. +- **`keyword_posts`:** service-role policy only. **No code in this repo or the dashboard references it.** Owner unknown (§12). +- **Stale copies of all 28 persona tables.** They don't match aafo: RLS is **disabled** on 12 of them, the `persona_group_*` service-role policies are missing, and the realtime publication covers only 5 tables. They must never be used as a source. Row counts tell us whether anything still writes to them (§12). + +### 2.4 Who touches the aafo schema from code + +| Consumer | How | Depends on | |---|---|---| -| RLS policies, Supabase roles | Native: `pgPolicy`, `.enableRLS()`, `authenticatedRole`/`anonRole`/`serviceRole` from `drizzle-orm/supabase` | Not supported, so they end up in a side-car file again | -| `vector(1536)` + HNSW `vector_cosine_ops` | Native `vector({dimensions})`, `index().using('hnsw', col.op('vector_cosine_ops'))` | `Unsupported("vector")`, and the index is hand-edited SQL | -| Generated `tsvector` column + GIN | `customType` + `generatedAlwaysAs(sql…)`, GIN index native | Unsupported | -| Functions, triggers, realtime publication | `drizzle-kit generate --custom`: hand-written SQL **in the same ordered journal** | Hand-edited migrations; `migrate dev` then reports drift | -| FK to Supabase-owned `auth.users` | `authUsers` from `drizzle-orm/supabase`; `schemaFilter: ['public']` leaves `auth`/`storage` alone | multiSchema, plus manually stripping `auth.users` from the baseline (done last time) | -| Adopt a live DB | `drizzle-kit pull` → schema.ts + snapshot | `db pull` + `migrate resolve` | -| Shadow DB | Not needed (diffs against the committed snapshot) | `migrate dev` needs one, and it fights Supabase-managed schemas | +| `services/persona-api` | supabase-py with the **service key** (`config.get_supabase()`). The anon client (`get_supabase_anon()`) is used **only** for realtime broadcasts on `system_pings` (`mcp/tools/zynd_network.py`, `services/meetings.py`) | all persona tables; RPC `search_personas_fts` | +| `services/memory` | supabase-py with the **service key** against **aafo** (`app/tools/zynd_network.py`) | `persona_agents` (`agent_id, name, description, active, updated_at`), RPC `search_personas_fts`. **So memory is a schema consumer too:** changes to these need memory's owner in the loop | +| `apps/persona-web` | supabase-js as the **signed-in user**: reads `dm_threads` and `agent_tasks`, realtime `postgres_changes` on dm/tasks/callbacks/group tables | RLS policies + realtime publication | +| `services/cards-api` | supabase-py with the **service key**: `sb.table("agent_profile_cards"…)`, RPC `match_cards`, `search_cards_fts` (`services/search.py`) | cards tables + functions | +| `apps/cards-web` | supabase-js for **login and Storage (`avatars`) only**. Card data goes through cards-api | Auth config, `avatars` bucket | + +Check for D8: no code reads `persona_agents` or `agent_profile_cards` with the anon key. The RLS subqueries that read `persona_agents` (in the `dm_threads`, `dm_messages` and `a2a_tasks` policies) all filter `p.user_id = auth.uid()`, which `"Users can read own persona"` keeps allowing. + +--- -The one thing Prisma has going for it is that the dashboard team knows it. That doesn't outweigh having to rebuild the side-car the team already abandoned once, now with vectors and RPCs on top. +## 3. Target picture -## 3. `packages/db` layout and rules +``` + ┌──────────────── aafo (Supabase, Postgres 17) ────────────────┐ +cards-web ──auth─┤ auth.users ◄── the one user id for persona AND cards │ +persona-web ─────┤ public (persona history): 28 persona tables │ +cards-api ─svc──┤ cards (cards history): 4 cards tables, owner_user_id ──► auth.users + │ identity (identity history): shared layer, empty until Stage 2 │ +persona-api ─svc─┤ extensions.vector · storage bucket `avatars` │ + │ drizzle.__{identity,persona,cards}_migrations │ +memory ─────svc──┤ (reads persona_agents + search_personas_fts) │ + └───────────────────────────────▲───────────────────────────────┘ + │ npm run db:migrate (a person; rehearsed locally) + zynd-platform/packages/db (Drizzle schema + migrations) + +xmfj: dashboard only (developer_keys, entities, …). Cards tables are dropped 28 days after cutover. +``` + +--- + +## 4. `packages/db`: design + +The package README ([`packages/db/README.md`](../../packages/db/README.md)) is the source of truth for the layout, commands, workflow and rules. This section keeps the reasoning. + +### 4.1 Layout (as built) ``` packages/db/ -├── package.json # "@zynd/db", private; drizzle-kit, drizzle-orm, pg, tsx — EXACT versions pinned -├── drizzle.config.ts -├── src/schema/ -│ ├── _supabase.ts # re-exports authUsers + roles from drizzle-orm/supabase; tsvector customType -│ ├── persona/*.ts # from `drizzle-kit pull`, split by domain (agents, dm, groups, callbacks, …) -│ ├── cards/cards.ts # agent_profile_cards -│ ├── cards/x_bot.ts # x_accounts, x_mentions, x_conversations -│ └── index.ts -├── migrations/ # drizzle-kit `out`: NNNN_name.sql + meta/_journal.json + snapshots -├── scripts/ -│ ├── mark-baseline-applied.ts # inserts 0000's hash into drizzle.__drizzle_migrations (never runs its SQL) -│ ├── preflight.sql # read-only: name clashes, extensions, row counts -│ └── check-drift.sh # prod schema dump vs. scratch DB built from migrations, normalized diff -├── test/supabase-stubs.sql # auth schema, auth.users, auth.uid(), roles anon/authenticated/service_role — for CI only -├── OWNERS.md # table → persona | cards | shared +├── lib/config.ts # historyConfig(name, ownedSchemas): one drizzle-kit config per history +├── lib/shared.ts # FK naming, policy builders, tsvector +├── identity/ # schema/ + migrations/ + drizzle.config.ts → owns `identity` +├── persona/ # schema/*.ts (28 tables) + migrations/ → owns `public` +├── cards/ # schema/*.ts (4 tables) + migrations/ → owns `cards` +├── scripts/ # migrate.ts, check-drift.ts, catalog.sql, baseline-sql.ts, +│ # lint-migrations.sh, preflight-cards.sql, projects.ts (the history registry) +├── test/supabase-stubs.sql +├── introspection/ # dated prod catalog exports +├── OWNERS.md └── README.md ``` -`drizzle.config.ts`: `dialect: 'postgresql'`, `schema: './src/schema/index.ts'`, `out: './migrations'`, `schemaFilter: ['public']`, `entities: { roles: { provider: 'supabase' } }`, `migrations: { schema: 'drizzle', table: '__drizzle_migrations' }`, `strict: true`, `dbCredentials.url = DATABASE_URL`. `DATABASE_URL` must be the **direct or session-pooler (5432)** connection, not the transaction pooler (6543). +Scripts: `npm run :generate` / `:generate:custom`, `db:check`, `db:lint`, `db:migrate` (dry run unless `--yes`; `--project `), `db:drift`, `db:baseline-sql`. There is no push script. + +### 4.2 Why one tool with three histories + +- Each history's config has `schemaFilter` = the schema it owns, so drizzle-kit never diffs, creates or drops anything in another product's schema. +- Each records progress in its own table, so persona can apply `0005` while cards is at `0002`, without either knowing. +- All three share the migrate/drift/lint scripts and one CI job, so there is still one way to change the database, and one place to look. +- Ownership is by folder (`OWNERS.md`; add a `CODEOWNERS` entry per folder once the GitHub team handles exist). -**First migrations** +### 4.3 What lives where -| # | Kind | Contents | +| Kind of object | Where it's defined | How it gets into a migration | |---|---|---| -| `0000_baseline_persona` | custom | `pg_dump --schema-only --schema=public --no-owner --no-privileges` of aafo as it is today: tables, enums, functions, triggers, policies, and grants the app needs. **Marked applied on prod, never run there.** A fresh DB (CI, staging, local) gets the full persona schema from it | -| `0001_cards_prereqs` | custom | `create extension if not exists vector with schema extensions;` plus `skill_names(jsonb)`. It has to come before the table, because the generated `search_tsv` column calls it | -| `0002_cards_tables` | generated | The 4 tables exactly as in the xmfj prod dump (including `owner_email`, `claim_token_hash`, `handle unique`, `embedding vector(1536)`), HNSW + GIN + status/handle indexes, RLS + the existing policies. **New:** `owner_user_id uuid null references auth.users(id) on delete set null` + index (§4) | -| `0003_cards_functions` | custom | `match_cards`, `search_cards_fts`, and their grants | - -**Rules** (these go in the README) -1. Every schema change to aafo is a migration here, whichever product needs it. Tables and columns: edit `src/schema/**`, then run `npm run db:generate -- --name _`. Functions, triggers and publications: use `--custom`. Every file starts with `-- owner: persona|cards|shared`. -2. Read the generated SQL before committing. Drizzle turns a rename into drop + add unless you answer its prompt, so check for that. -3. **Never run `drizzle-kit push` against staging or prod.** Only `db:migrate`, run by a person: staging first, `check-drift.sh` clean, then prod. -4. Each migration runs in a transaction, so `CREATE INDEX CONCURRENTLY` on a big table goes in its own custom migration with a note. -5. The old SQL folders (`services/persona-api/db`, `services/persona-api/supabase/migrations`, `apps/persona-web/db`, `services/cards-api/db`) get a "Frozen — see packages/db" README. `apps/persona-web`'s `db:policies` script is removed. -6. Standalone package with its own lockfile and **no root workspace** for now, so the Vercel builds of the two apps don't change. Shared TS types for the apps can come later. - -**CI (`.github/workflows/db.yml`, `paths: packages/db/**`):** `drizzle-kit check`, then apply every migration to a `pgvector/pgvector:pg17` service container after `supabase-stubs.sql`. Next, run `drizzle-kit generate` and require empty output (schema.ts and migrations in sync). Last, lint the `-- owner:` headers. +| Tables, columns, defaults, PK/FK/unique/CHECK, indexes, RLS, policies, generated columns | `/schema/**` | `npm run :generate` | +| Extensions, schemas' grants, functions, triggers, GRANT/REVOKE, publication, backfills | hand-written SQL | `npm run :generate:custom` | -## 4. Auth: how the logins move +To change a function or trigger, add a new custom migration with `create or replace`; never edit an old file. -**Recommendation: don't copy xmfj users into aafo.** Card owners sign in to cards.zynd.ai with Google, LinkedIn or a magic link, which creates or reuses their **aafo** user. Ownership is still checked against `owner_email`, so a card stays theirs as long as the email is the same, the same rule as today. +### 4.4 Migrations as built -Why not copy `auth.users`/`auth.identities`: -- Nothing in cards references xmfj UUIDs, so there's nothing to preserve. -- xmfj also holds every dashboard developer account. Copying would put non-cards people into the persona user base. -- Card owners who already use persona have an aafo user under a **different UUID**. A copy would mean merging two users per person, which is exactly the "4 identities per person" problem the platform work is removing. +| History | File | Kind | Contents | Runs on prod? | +|---|---|---|---|---| +| identity | `0000_identity_schema` | generated + grants | `create schema identity`; usage + default privileges for `service_role` only | yes | +| persona | `0000_baseline_persona` | generated, then completed by hand (§4.8) | 28 tables + constraints, 43 indexes, 4 functions + ACLs, 1 trigger, RLS on 28 tables, 72 policies, realtime publication × 9 | **No.** Recorded with `db:baseline-sql` | +| persona | `0001_persona_drop_public_read` | generated | `drop policy "Public read persona agents"` | yes (the hotfix) | +| cards | `0000_cards_schema` | generated + custom | `create schema cards`; `service_role`-only grants and default privileges; `create extension vector with schema extensions`; `cards.skill_names()` | yes | +| cards | `0001_cards_tables` | generated | 4 tables in `cards` (+ `owner_user_id → auth.users`), indexes (HNSW, GIN), RLS, service-role policies | yes | +| cards | `0002_cards_search_functions` | custom | `cards.match_cards`, `cards.search_cards_fts` (`set search_path = cards, extensions`) | yes | -**Linking the two products.** This is the step that actually connects them. -- `agent_profile_cards.owner_user_id` → `auth.users(id)` in aafo, the same id persona uses. A card and a persona then join on one user id, which is the `zynd_uid` Stage 2 builds on. -- **Backfill at cutover:** `update agent_profile_cards c set owner_user_id = u.id from auth.users u where lower(u.email) = lower(c.owner_email) and c.owner_user_id is null;` -- **Going forward:** `verify_supabase_jwt` returns `(email, sub)`. Each authenticated publish/claim/edit sets `owner_user_id = sub` when the token's issuer is aafo. -- `owner_email` stays the authority for ownership until Stage 2. `owner_user_id` is additive. +A fresh database (CI, local, a future dev project) applies identity → persona → cards. -**Token trust during the switch (cards-move P0, unchanged):** cards-api `TRUSTED_SUPABASE_URLS` and memory `TRUSTED_SUPABASE_PROJECTS` accept xmfj **and** aafo, with one JWKS client per issuer and `iss` pinned. Drop xmfj 14 days after cutover. +### 4.5 Access to the `cards` and `identity` schemas -**Pre-checks (read-only, the user runs them):** on xmfj, the number of distinct `owner_email` among non-archived cards, and any emails that differ only by case. On aafo, how many of those emails already exist in `auth.users`. That shows how many owners land on an existing persona user and how many create a new one. +Supabase's default grants only cover `public`. The new schemas grant `usage` and default privileges to `service_role` only, and revoke function `execute` from `PUBLIC`. So anon/authenticated can't reach cards data even if a policy is wrong, which replaces the explicit revokes the single-history design needed (D7). For cards-api to query `cards` over PostgREST, a person adds `cards` to the project's API **Exposed schemas** setting (phase D). -## 5. Data move +### 4.6–4.7 Workflow and rules -Scripts live in `services/cards-api/scripts/migrate_to_persona/`, dry-run by default. **The agent writes them and the user runs them.** -- **`copy_tables.sh`:** `pg_dump --data-only` of the 4 tables from xmfj, then `psql --single-transaction` into aafo in FK order: `agent_profile_cards` first, then `x_accounts`, `x_conversations`, `x_mentions`. It truncates the target first so it can be re-run for the rehearsal and the final copy. `search_tsv` is generated, so pg_dump skips it and aafo recomputes it. Embeddings copy as-is, with no re-embedding. -- **`copy_avatars.py`:** list xmfj `avatars` → download → upload to aafo `avatars` at the same path. Then rewrite `…xmfj….supabase.co/storage/v1/object/public/avatars/` to the aafo host inside `card` JSON (text replace on `card::text`, cast back to jsonb, only rows that contain the old host). It reports missing objects and leaves external URLs (GitHub/LinkedIn avatars) alone. -- **`verify.sql`:** row counts per table and per status; `md5(string_agg(id || md5(card::text), ',' order by id))` on both sides; unique handles; embeddings present; one `match_cards` and one `search_cards_fts` call with the same input on both sides, which should return the same top 10. +See `packages/db/README.md` ("Making a schema change", "Rules"). -## 6. Phases +### 4.8 How the baseline is built and proven -Stop after each phase and report. Nothing below touches prod unless the user runs it. +The complete aafo catalog is already in hand (§2.2), so the baseline can be built without a prod connection. It's proven against prod with the same catalog query. -**D0: Monorepo prerequisites (agent + user).** `git pull` doesn't apply yet because there's no remote. Creating `zyndai/zynd` (M3) is needed before CI and Vercel, but not for D1–D3. +1. **Write** `persona/schema/*.ts` for all 28 tables from the catalog: columns, defaults, constraints, 43 indexes, RLS and 72 policies. +2. **Generate:** `npm run persona:generate -- --name baseline_persona` produces `0000_baseline_persona.sql` plus a snapshot. Keep the snapshot, because it represents `schema.ts`. Move the generated SQL aside as `drizzle-generated.sql` (not committed). +3. **Replace** the SQL in `0000_baseline_persona.sql` with the full baseline: + - Drizzle's table and index DDL. + - The 4 functions, verbatim from the catalog, created **before** the tables whose policies call them. + - The function ACLs. + - The trigger. + - The publication lines. +4. **Build scratch DB A:** stubs + `0000`. Run `scripts/catalog.sql` on A and save the output. +5. **Run the same query on prod aafo** (SQL editor, read-only) and save it to `introspection/aafo-.txt`. +6. **Compare with `check-drift.ts`**, which normalizes ordering, the `realtime.messages_*` partitions and grant lines that come from default privileges. **It must match exactly.** Any difference is fixed in `schema.ts` or `0000`, then repeat from step 2. +7. **Check `schema.ts` against the baseline:** build scratch DB B from stubs + `drizzle-generated.sql` + the functions, trigger and publication; its tables, indexes and policies must equal A's. +8. **Confirm no pending changes:** `npm run persona:generate` must now report no changes. +9. **Record the baseline on prod (a person):** run the output of `npm run db:baseline-sql` once in the aafo SQL editor. It creates `drizzle.__persona_migrations` (renaming the earlier `__drizzle_migrations` if the first hotfix SQL was already run) and inserts 0000's hash and timestamp. -**D1: Scaffold `packages/db` (agent, branch off `dev`).** -- Package, config, stubs, README, OWNERS, `db:generate`/`db:migrate`/`db:check` scripts, CI workflow, frozen READMEs. -- Remove `db:policies` from persona-web. -- Nothing runs against any database in this phase. + The hash and timestamp format is checked against the pinned `drizzle-orm` migrator source in phase A before anyone relies on it: rows are applied only if their journal `when` is newer than the last `created_at`. + +Local scratch cluster: Homebrew Postgres 18 on port 5544 (`initdb` + `pg_ctl`). The persona baseline needs nothing extra; the cards migrations need pgvector, installed with `brew install pgvector` (ask first). CI uses `pgvector/pgvector:pg17`. + +### 4.9 CI: `.github/workflows/db.yml` + +Triggers: `paths: packages/db/**`, on PRs to `dev` and pushes to `dev`. +1. `npm ci` in `packages/db`. +2. `npm run db:check`: snapshot/journal consistency for all three histories. +3. `npm run db:lint`: filename format, `-- owner:` header on every migration, journals match files, and no edits to already-merged migration files. +4. Postgres service `pgvector/pgvector:pg17`, then `psql -f test/supabase-stubs.sql`, then `npm run db:migrate -- --yes` **from empty** (identity → persona → cards). +5. `drizzle-kit generate` for each history must produce **no** new file, which proves each history's schema files and migrations are in sync. +6. `db:drift` against a fresh build (self-consistency of the catalog tooling). + +Deploy jobs are deliberately not part of this workflow (D10). + +### 4.10 Freezing the old SQL + +- Add `README.md` "Frozen — schema changes go in `packages/db`" to `services/persona-api/db/`, `services/persona-api/supabase/migrations/`, `apps/persona-web/db/` and `services/cards-api/db/`. +- Remove the `db:policies` script from `apps/persona-web/package.json`. +- Update `AGENTS.md` §4 (where migrations live) and §7 (status) when `packages/db` lands. + +--- + +## 5. Auth migration + +### 5.1 Today + +| Piece | Behaviour | +|---|---| +| Card login | xmfj Auth: Google, and `linkedin_oidc` for claims, via the dashboard | +| `services/cards-api/api/auth.py` | ES256 via the JWKS of **one** `SUPABASE_URL`, with an HS256 legacy fallback. **Returns only `email`**, and email is the principal | +| Ownership | `agent_profile_cards.owner_email` plus claim tokens (`claim_token_hash`) for anonymous publishes | +| `services/memory/app/supabase_auth.py` | Verifies against **one** project (`/token/exchange`, `/me/social-links`, `/oauth/complete`) | +| `apps/cards-web` | Already written against aafo (`@supabase/ssr`, Google, LinkedIn, magic link) | + +### 5.2 Target + +- **One identity provider, aafo, with persona's login: LinkedIn (OIDC) only (D12).** + - **cards-web change:** drop the Google buttons (`app/auth/page.tsx`, `app/create/page.tsx`, `app/agent-card/auth-bar.tsx`), the GitHub option (`app/p/[handle]/profile-auth-actions.tsx`) and the magic-link form (`signInWithOtp`). + - **Magic link:** returns when SMTP exists; `null` for now. + - **Risk this creates:** xmfj card owners who signed in with **Google** must now use a LinkedIn account with the **same email**. Appendix F counts owners by provider before cutover, so we know how many are affected. Anyone whose LinkedIn email differs gets reassigned by support (one `update` on `owner_email`/`owner_user_id`) or re-claims with a claim token. +- **No copy of xmfj users.** Nothing in cards references an xmfj user id. xmfj also holds every dashboard developer account, which doesn't belong in persona. A card owner who already uses persona has an aafo user with a **different** UUID, so copying would create two users per person. +- **Ownership keeps working by email.** An owner signs in to cards.zynd.ai with the same email, which creates or reuses their aafo user, and `owner_email` matches as before. Supabase auto-links identities that share a **verified** email, so Google and LinkedIn with the same email end up as one user. +- **Linking the products:** `owner_user_id uuid → auth.users(id) on delete set null`. + - **Backfill at cutover:** match by `lower(email)`. The SQL is in Appendix C. + - **Going forward:** cards-api sets `owner_user_id = sub` on every aafo-authenticated publish, claim or edit. + - `owner_email` stays the ownership authority until Stage 2 moves everything to one user id (`zynd_uid` = `auth.users.id`). + +### 5.3 Token trust during the switch + +Both backends accept xmfj **and** aafo tokens from D4 until 14 days after cutover. + +- **cards-api:** `TRUSTED_SUPABASE_URLS=https://aafoguuvmaxymrtnfafn.supabase.co,https://xmfjvixclgqcmjmtecwv.supabase.co`. + - **Verification:** one `PyJWKClient` per issuer, chosen by the token's unverified `iss`. The `iss` must be `/auth/v1` exactly, algorithm ES256. HS256 stays only for the configured legacy secret, pinned to the project that secret belongs to. + - **New return type:** `verify_supabase_jwt` returns a small principal `(email, sub, iss)` instead of `str`. Callers are updated, and `sub` is used only when `iss` is aafo. +- **memory:** `TRUSTED_SUPABASE_PROJECTS`, a list of `url|anon_key` pairs, with the same issuer pinning. +- **After day 14:** remove xmfj from both lists. That's config only, no code change. + +### 5.4 Edge cases + +| Case | Result | Handling | +|---|---|---| +| Owner signs in with the same email | owns the card | none | +| Emails differ in letter case | owns it only if the comparison is case-insensitive | `lower()` in the backfill; audit the cards-api comparisons in D4 | +| Owner signs in with a different email | not the owner (same as today) | claim token, or support reassigns (set `owner_email` / `owner_user_id`) | +| Owner already has a persona account | same aafo user, **linked automatically** | that's the goal | +| Magic-link email not verified | Supabase won't issue a session | none | +| Anonymous card with a pending claim token | still claimable (hash copied as-is) | none | + +### 5.5 aafo Auth settings (a person, in dashboards) + +- **OAuth apps:** none new. aafo's existing LinkedIn (OIDC) provider, the one persona uses, serves cards too (D12). +- **aafo → Authentication → URL configuration:** keep the Site URL as it is (persona). Add `https://cards.zynd.ai/**`, `https://*-zyndai.vercel.app/**` (previews) and `http://127.0.0.1:3000/**` to the redirect allow-list. +- **Email magic link / SMTP:** **not now (`null`).** When an email provider is chosen, configure custom SMTP (the built-in mailer is heavily rate-limited), enable the Email provider and bring back the magic-link form in cards-web. +- **aafo → Storage:** create a public bucket `avatars` with an INSERT/UPDATE policy for `authenticated`, limited to paths under the user's own id. Reads are public. + +--- -**D2: Baseline aafo (user runs read-only commands, agent builds).** -- **User:** - 1. `npx drizzle-kit pull` with `DATABASE_URL` = aafo (read-only role, if you have one). - 2. `pg_dump --schema-only --schema=public --no-owner --no-privileges` of aafo, using a pg_dump 17 client. - 3. `select * from pg_publication_tables where pubname = 'supabase_realtime'` -- **Agent:** turns the pulled schema into `src/schema/persona/*`, makes `0000_baseline_persona.sql` from the dump (adding publication lines if the dump left them out), and applies the full chain to a scratch Postgres. It then diffs a dump of that scratch DB against the prod dump; the diff must be empty apart from ordering. -- **User:** `npm run db:mark-baseline` on aafo. That's a single insert into `drizzle.__drizzle_migrations`. *Rollback:* `drop schema drizzle cascade` (it holds only the tracking table). +## 6. Data migration -**D3: Cards schema in aafo (agent writes, user applies).** -- **User:** runs the read-only schema dump of the 4 tables, 3 functions and policies on **xmfj**. -- **Agent:** writes 0001–0003 to match that dump, plus `owner_user_id`. -- **User:** runs `preflight.sql` on aafo (no name clashes, `vector` available), enables `vector` in the dashboard if needed, then runs `npm run db:migrate` on aafo. This creates empty tables and functions, so it's additive. -- *Rollback:* a down script that drops the 4 tables and 3 functions. They're empty at this point. +### 6.1 Scope -**D4: Code, deployed with both issuers trusted (agent, then user deploys).** -- Cards-move P0: multi-issuer auth in cards-api and memory, `MAINTENANCE_READONLY`, publish de-dup. -- Also: `(email, sub)` from `verify_supabase_jwt`, and `owner_user_id` set on aafo-authenticated writes. -- Tests are compared against the A4 baselines (cards 2 failed / 89 passed, memory 9 failed / 203 passed). -- cards-api still points at xmfj. -- *Rollback:* redeploy the previous image. +| Object | Method | Notes | +|---|---|---| +| `agent_profile_cards` | `pg_dump --data-only` → `psql` | `search_tsv` is generated, so pg_dump leaves it out and aafo recomputes it. `embedding` is copied as-is (no re-embedding). `owner_user_id` stays NULL until the backfill | +| `x_accounts`, `x_conversations`, `x_mentions` | same | Load after `agent_profile_cards` (FKs) | +| `avatars` bucket | Storage API copy | same object paths | +| URLs inside card JSON | SQL `replace` | xmfj storage host → aafo storage host, in every jsonb column that contains it | + +Row counts are still to be filled in from the pre-check (Appendix F). + +### 6.2 Scripts: `services/cards-api/scripts/migrate_to_persona/` + +The agent writes these, and **the user runs them**. They default to dry-run. +- **`copy_tables.sh SRC_URL DST_URL [--apply]`** + 1. `pg_dump "$SRC_URL" --data-only --no-owner --no-privileges -t public.agent_profile_cards -t public.x_accounts -t public.x_conversations -t public.x_mentions > cards_data.sql`. pg_dump writes explicit column lists, so the extra `owner_user_id` column doesn't matter. + 2. Retarget the dump from xmfj's `public` to aafo's `cards`: rewrite `COPY public.` → `COPY cards.
` and `SELECT pg_catalog.setval('public.` → `'cards.` (there are no sequences today, but the script handles them), and fail if any other `public.` reference remains. + 3. With `--apply`: `psql "$DST_URL" -v ON_ERROR_STOP=1 --single-transaction` running `truncate cards.x_mentions, cards.x_conversations, cards.x_accounts, cards.agent_profile_cards;` and then `\i cards_data.sql`. + 4. Prints row counts on both sides. +- **`copy_avatars.py`** + - List the xmfj `avatars` objects (service key), download each and upload it to aafo at the same path with the same content-type, skipping anything already there with the same size. + - Then run a single SQL rewrite: + `update cards.agent_profile_cards set card = replace(card::text, '/storage/v1/object/public/avatars/', '/storage/v1/object/public/avatars/')::jsonb where card::text like '%/storage/v1/object/public/avatars/%';` + and the same for `scrape_raw`, `user_intent` and `suggested_posts`. + - Reports objects that are missing or failed, and URLs that still point at xmfj afterwards. +- **`verify.sql`:** run on both sides and diff (Appendix F). +- **`backfill_owner_user_id.sql`:** Appendix C. + +### 6.3 Rehearsal first + +There's no staging (D11), so the rehearsal happens in two places: +1. **Local:** scratch Postgres with stubs + all three histories, then `copy_tables.sh` from xmfj into it. This proves the dump loads, the generated column recomputes and the FKs hold. +2. **Prod aafo, while live (phase G):** the same scripts into the new, still-unused cards tables. Nothing reads aafo's cards tables until cutover, and the final copy re-truncates them, so the rehearsal leaves nothing behind. -**D5: aafo auth setup (user, dashboard clicks).** -- **Google OAuth client and LinkedIn app** (the ones xmfj uses today, or new ones): add aafo's callback `https://aafoguuvmaxymrtnfafn.supabase.co/auth/v1/callback`. -- **aafo → Auth:** enable Google, LinkedIn (OIDC) and email. Add `https://cards.zynd.ai/**` plus the Vercel preview and localhost URLs to the redirect list. Set up **custom SMTP** before relying on magic links, because Supabase's built-in mailer is heavily rate-limited. -- **aafo → Storage:** a public `avatars` bucket with an upload policy for `authenticated` only. -- **Then:** smoke-test cards-web on a Vercel preview. Login works against aafo, and data still comes from xmfj through the API. +--- -**D6: Rehearsal (user runs, while live).** Run `copy_tables.sh`, `copy_avatars.py` and `verify.sql` from xmfj to aafo. Nothing reads the aafo copy yet. Fix anything the verify step flags. The final copy re-truncates, so the rehearsal leaves nothing behind. +## 7. Code changes -**D7: Cutover (quiet hour; a `docs/cards/CUTOVER.md` runbook with a rollback line per step).** -1. `MAINTENANCE_READONLY=true` on cards-api, which also pauses the X poller. -2. Final `copy_tables.sh`, then `copy_avatars.py`, then `verify.sql`, which must match. -3. Run the `owner_user_id` backfill (§4). -4. Switch cards-api `SUPABASE_URL`/`SUPABASE_SERVICE_KEY` to aafo in `infra/api-box`, restart, and check reads, search, chat and the X bot. -5. Point cards.zynd.ai DNS at the Vercel project, then smoke-test login, claim, create, edit and avatar upload. -6. `MAINTENANCE_READONLY=false`. +| File | Change | Tests | +|---|---|---| +| `services/cards-api/config.py` | **Done:** `SUPABASE_DB_SCHEMA` (default `public`; `cards` after cutover) is the Supabase client's default schema, so every `sb.table()`/`sb.rpc()` follows it. To do: `TRUSTED_SUPABASE_URLS` (list, defaults to `[SUPABASE_URL]`), `AAFO_ISSUER`, `MAINTENANCE_READONLY` (bool) | config parsing | +| `services/cards-api/api/auth.py` | Multi-issuer verification (§5.3); returns `Principal(email, sub, iss)` | a token from each issuer, an unknown issuer, a wrong `iss`, expired, HS256 legacy | +| `services/cards-api/api/onboard.py`, `api/cards.py` | Use `principal.email` for ownership as before; set `owner_user_id` on aafo-authenticated publish/claim/edit; case-insensitive owner comparison; publish de-dup (the owner's existing published card is updated; an anonymous publish with a matching `handle_github`/`handle_x` returns `{existing: true, handle}`); `archived` hidden everywhere | extend existing tests | +| `services/cards-api/main.py` | When `MAINTENANCE_READONLY` is on, POST/PATCH/DELETE return 503 `{"detail":"Cards is read-only for maintenance, back shortly"}` | middleware test | +| `services/cards-api/x/*` | The poller skips its loop while read-only | unit test | +| `services/memory/app/supabase_auth.py` | `TRUSTED_SUPABASE_PROJECTS`, issuer pinning | tests per issuer | +| `infra/api-box` env (not in git) | New variables above; at cutover, `SUPABASE_URL`/`SUPABASE_SERVICE_KEY` switch to aafo | none | +| `apps/cards-web` | None. It already targets aafo. Vercel env is set in F | build/lint/tsc | +| persona | None besides migration `0001` | persona-web realtime smoke test after `0001` | + +All code changes follow AGENTS.md §3: branch → `dev`, with tests compared against the §5 baselines (cards 2 failing, memory 9, persona 5). -*Rollback before step 6:* point the env back at xmfj and restart. xmfj was read-only, so nothing is lost. *After step 6:* writes made on aafo would need copying back, so decide within the first hour. +--- -**D8: Cleanup (day +14).** -- Remove xmfj from the trusted issuers. -- Cards-move P5: dashboard feature branch that removes the cards UI and adds the 301s. -- Rename the xmfj cards tables to `*_retired`, then drop them after another 14 days. This is plain SQL, because they aren't in the dashboard's Prisma schema. -- Remove the xmfj `avatars` bucket. +## 8. Phases + +**Progress (2026-09-27):** +- **Phase A: done.** `packages/db` with three histories (D14), CI in `.github/workflows/db.yml`, root runner and local-dev docs (D15). +- **Phase B, agent part: done.** persona's `0000` builds a database whose catalog matches every prod aafo entry that was checked: 103 constraints, 43 indexes, 72 policies, 4 functions with exact ACLs, trigger, publication, 112 grants and RLS flags. A byte-exact `db:drift` against a fresh prod export is still the user's step. +- **Phases C/D, migrations written:** persona `0001` (fix) and cards `0000`–`0002`. The cards history needs pgvector, which isn't installed on the dev Mac; CI runs it on `pgvector/pgvector:pg17`. +- **Hotfix pending (user):** record persona's baseline and apply `0001` on prod aafo. `zyndai/platform` is public and the exposure is described in pushed docs, so nothing more is pushed until it's live. +- **Not done:** everything else that touches prod, the rest of E, the cards-web login change in F, and G–I. + +Stop after each phase, report, and wait for an OK. **A** = agent, **U** = user. Nothing touches prod unless a person runs it. + +| Phase | Who | What | Done when | Rollback | +|---|---|---|---|---| +| **A. Scaffold** | A | `packages/db` package, config, stubs, scripts, README, OWNERS, CI workflow, frozen READMEs; confirm the migrator's hash/`when` semantics in the pinned version | `npm ci && npm run db:check` pass; CI green on a PR | revert the PR | +| **B. Baseline** | A, then U | §4.8 steps 1–8 (A); U runs `catalog.sql` on prod for step 5; U runs `db:baseline-sql` output on prod | drift diff empty; `persona:generate` reports nothing; `drizzle.__persona_migrations` holds the baseline row on prod | `drop schema drizzle cascade` (tracking tables only) | +| **C. Persona fix** | A, then U | `0001`; A rehearses it on the local scratch DB; U applies it to prod aafo (which dev.persona also uses) and smoke-tests persona-web (inbox, tasks, groups, realtime) on dev.persona, then prod | an anon `GET /rest/v1/persona_agents?select=agent_id&limit=1` returns `[]`; persona-web works | `create policy "Public read persona agents" on persona_agents for select using (true);` | +| **D. Cards + identity schemas** | A, then U | identity `0000`, cards `0000`–`0002`; U runs `preflight-cards.sql`, then `db:migrate -- --project identity,cards --yes` on prod aafo; U adds `cards` to aafo → API settings → **Exposed schemas** | catalog shows the `cards` schema with 4 empty tables + 3 functions; an anon request with `Accept-Profile: cards` is denied | `drop schema cards cascade; drop schema identity;` (empty) + `drop table drizzle.__cards_migrations, drizzle.__identity_migrations` | +| **E. Code** | A, then U | §7 on a branch → `dev`; U deploys cards-api + memory with **both** issuers trusted; cards-api **still on xmfj** | tests at baseline; prod behaves as before; an aafo token is accepted by `/cards/*` auth | redeploy the previous image | +| **F. aafo auth + storage** | A, then U | A: cards-web goes LinkedIn-only (D12). U: §5.5 redirect URLs and `avatars` bucket; Vercel env for cards-web (aafo URL/anon key, `NEXT_PUBLIC_API_URL=https://api.zynd.ai`, `NEXT_PUBLIC_SITE_URL=https://cards.zynd.ai`); open a preview deploy | on the preview, LinkedIn login works against aafo; card pages render (data via cards-api from xmfj) | remove the redirect URLs | +| **G. Rehearsal** | U (A reviews output) | §6.2 all scripts into aafo | `verify.sql` matches; avatars report shows 0 missing | re-truncate the aafo cards tables | +| **H. Cutover** | U, A on call | runbook below | all checks pass | per step, below | +| **I. Cleanup** | U + A | day +14: remove xmfj from trusted issuers; dashboard P5 (remove cards UI, add 301s; the dashboard team's branch); day +14: rename xmfj cards tables `*_retired`; day +28: drop them and the xmfj `avatars` bucket. **xmfj persona copies (D13), can run any time:** U runs the activity check (Appendix F); if it's clean, U runs Appendix G part 1 (rename to `*_retired`), then part 2 (drop) 14 days later. `keyword_posts` stays | nothing in the logs uses xmfj for cards; the persona copies show no reads or writes | un-rename the tables (until the drop) | + +### Phase H: cutover runbook (quiet hour, about 30–45 minutes) + +| # | Step | Check | Rollback | +|---|---|---|---| +| H1 | Announce the window. Set `MAINTENANCE_READONLY=true` on cards-api and restart | POST returns 503; GET works; X poller log shows "paused" | set it back to `false` | +| H2 | `copy_tables.sh --apply` (final) | row counts equal | re-run | +| H3 | `copy_avatars.py --apply` + URL rewrite | 0 missing; 0 xmfj avatar URLs left in aafo | re-run | +| H4 | `verify.sql` on both sides | the diff shows only the expected lines (Appendix F) | stop; stay on xmfj; H1 rollback | +| H5 | `backfill_owner_user_id.sql` on aafo | reports matched vs unmatched owners | `update … set owner_user_id = null` | +| H6 | Switch cards-api `SUPABASE_URL` / `SUPABASE_SERVICE_KEY` to aafo and set `SUPABASE_DB_SCHEMA=cards` in `infra/api-box`, restart | `/cards/search` (FTS + vector), `GET /cards/`, chat widget, X-bot dry run | switch the env back to xmfj (and `SUPABASE_DB_SCHEMA=public`), restart; xmfj is unchanged because it was read-only | +| H7 | Point `cards.zynd.ai` DNS at the Vercel project | login, claim (token and LinkedIn), create with avatar upload, edit, publish | remove the DNS record | +| H8 | `MAINTENANCE_READONLY=false` | a new publish lands in aafo | **after this, rolling back loses writes made on aafo**: decide within the first hour, then copy those rows back to xmfj manually | +| H9 | Watch for 24 hours: cards-api errors, auth failures, 401s by issuer | none | none | -## 7. Choices I've made (say so if you want them changed) +--- -1. **Drizzle** over Prisma (§2). -2. **Cards tables stay in the `public` schema.** cards-api's supabase-py queries and RPC calls then don't change at all; only the URL and key do. A separate `cards` Postgres schema is cleaner for ownership, but it has to be exposed in PostgREST and every `.table()`/`.rpc()` call changed. Ownership is tracked with `src/schema/cards/` and `OWNERS.md` instead. -3. **No copy of xmfj auth users** (§4). -4. `packages/db` is a **standalone** npm package, with no root workspace yet. -5. **The cards tables in aafo drop the anon `"public read published cards"` policy.** Only service_role gets access, because cards-api does every read. Nothing in cards-web, the dashboard or memory queries the table directly (checked 2026-09-26). -6. **A migration right after the baseline, `persona_lock_public_reads`,** replaces `persona_agents`' `using (true)` read policy. Owner-only reads stay; the backend keeps using service_role. It's a separate migration so it can be rolled back on its own if something external turns out to depend on it. +## 9. Verification checklist -## 8. Questions only you can answer +- **Baseline:** `catalog(prod aafo) == catalog(stubs + 0000)` after normalization. +- **Schema/migrations in sync:** `:generate` produces nothing for any history after every merge (CI checks this). +- **Persona fix:** anon REST read of `persona_agents` returns `[]`; persona-web inbox, tasks and group realtime still update. +- **Cards schema:** as anon, the `agent_profile_cards` REST request is denied and RPC `match_cards` is denied; as service_role, both work. +- **Data:** equal counts per table and per `status`; equal `md5` over `id || md5(card::text)` (before the URL rewrite); `count(embedding)` equal; top 10 of the same `search_cards_fts('engineer')` and the same `match_cards()` identical on both sides. +- **Auth:** a card owner signs in to aafo and can edit their card; a persona user with the same email ends up as the **same** `auth.users` row as `owner_user_id`. -- **Staging.** Is there a staging Supabase project or branch for aafo? Rehearsing D3/D6 there is safer. The alternative is a local rehearsal, which needs `brew install pgvector` (a scratch cluster on port 5544, as in `local-dev-tooling`). -- **OAuth apps.** Reuse xmfj's Google/LinkedIn OAuth apps (just add the aafo callback), or create new ones for Zynd Account? -- **Open duplicates.** `0xsy3`/`0xsy3-pobf` and `chandan-kumar`/`chandan867` (cards-move §2). Settle them before D6 so they don't get carried over. +--- -## 9. Risks +## 10. Risks | Risk | Mitigation | |---|---| -| The baseline doesn't match prod exactly, and the next generated migration fights the real schema | D2 scratch-apply + dump diff must be empty. `check-drift.sh` before every prod migrate | -| Someone keeps changing aafo in the SQL editor | README rule, `check-drift.sh` in the release checklist, frozen READMEs in the old folders | -| `drizzle-kit` upgrades change the migrations-table format | Exact version pin. `mark-baseline-applied.ts` is tied to that version | -| An owner signs in with a provider whose email differs from `owner_email` | Same as today. Claim tokens and support can reassign the card; `owner_user_id` makes that easier later | -| A name clash between cards and persona tables in `public` | `preflight.sql` in D3 before any migrate | -| The magic link hits the email rate limit on launch day | Custom SMTP in D5 | +| The baseline differs subtly from prod, so the next generated migration fights the real schema | §4.8 exact catalog comparison; `db:drift` before every prod migrate | +| Someone changes aafo in the SQL editor | README rule; drift check in the release steps; frozen old folders | +| A `drizzle-kit` upgrade changes the snapshot or migration-table format | exact pins; `baseline-sql.ts` tied to the pinned version; upgrade only in a dedicated PR | +| Drizzle rename prompts turn into drop + add | answer the prompts deliberately; review generated SQL in PRs | +| Dropping `persona_agents` public read breaks an unknown external reader | own migration `0001`, one-line rollback | +| No staging: a bad migration hits dev.persona **and** prod at once | local rehearsal with stubs (CI does the same from empty on every PR); expand-only migrations (D11); drift check before every apply; rollback SQL written before applying | +| Card owners who used Google on xmfj can't match their LinkedIn email | Appendix F counts them first; support reassigns or they re-claim (§5.2) | +| memory relies on `persona_agents` columns | `OWNERS.md` lists memory as a consumer, and memory's owner reviews those changes | +| Owner email differs between providers | claim tokens and support; `owner_user_id` makes reassignment one update | +| Magic links hit the mail rate limit | not launched until an SMTP provider exists (D12) | +| `vector` moves from `public` (xmfj) to `extensions` (aafo) and breaks function resolution | functions get `set search_path = public, extensions`; checked in D and G | +| xmfj persona copies are still written to by something | row-count pre-check (Appendix F) before I; the dashboard team decides their fate | + +--- + +## 11. Effort (rough) + +| Phase | Agent | User | +|---|---|---| +| A | about half a day | review the PR | +| B | about 1 day (28 tables by hand + diff loop) | 2 SQL-editor runs + Appendix D | +| C–D | about 2 hours | apply to prod after local rehearsal | +| E | about 1 day with tests | deploy | +| F | none | about 1 hour of dashboard settings | +| G–H | on call | about 1 hour + 45-minute window | +| I | about 1 hour | settings + dashboard PR merge | + +--- + +## 12. Questions: answered 2026-09-26 + +| # | Question | Answer | Where it landed | +|---|---|---|---| +| 1 | Staging? | None. dev.persona uses the prod database and is only for testing logic | D11, §6.3, phases C/D | +| 2 | OAuth apps? | Use persona's login | D12, §5.2, §5.5, phase F | +| 3 | `keyword_posts`? | Maybe memory, but if nothing uses it, leave it as is | D13 (left alone; no code references found) | +| 4 | xmfj persona copies? | Remove them if nothing uses them | D13, Appendix F activity check, Appendix G | +| 5 | Who runs prod migrations / SMTP? | Anyone on the team. Magic link / SMTP is `null` for now, added once there's an email provider | D10, D12 | +| — | Drizzle? | Confirmed | D1 | + +**Still open:** the duplicate cards (`0xsy3` / `0xsy3-pobf`, `chandan-kumar` / `chandan867`) need settling before phase G. + +--- + +## Appendix A: catalog query + +The query lives in [`packages/db/scripts/catalog.sql`](../../packages/db/scripts/catalog.sql). It is read-only, returns one text cell (so the SQL editor's 100-row limit doesn't apply), covers every schema the migrations own (`public`, `cards`, `identity`) with schema-qualified names, and its first line identifies the project (`developer_keys=true` means xmfj). The 2026-09-26 exports used an earlier, `public`-only version; re-export with the current file before running `db:drift` against prod. + +## Appendix B: aafo policy inventory (72) + +Per table: *svc* is the redundant `"Service role full access on …"` (`to public using (auth.role()='service_role')`); *own* is `auth.uid() = user_id`. + +| Table | Policies | +|---|---| +| a2a_tasks | svc; participants read (via `dm_threads`/`persona_agents` subquery) | +| agent_tasks | svc; participants read; participants update | +| api_tokens | svc; own read/insert/update/delete | +| brief_todos | svc (with check); own read/update/delete; group members read (`is_persona_group_member`) | +| callback_results | svc; owner read; owner marks delivered (update, with check) | +| chat_messages | svc; own read | +| dm_messages | svc; read in non-blocked threads; send in accepted threads (insert with check) | +| dm_threads | svc; read own; start (insert with check); participants update | +| enriched_companies, enriched_contacts | svc (with check); own CRUD (all, with check) | +| github_profiles, linkedin_profiles, twitter_profiles, telegram_links | svc; own read; own delete | +| outbound_callbacks | svc; owner read | +| pending_approvals | svc; own read; own update | +| persona_agents | svc; **public read `true`** (dropped in 0001); own read; own update | +| persona_group_audit_events | svc (with check); affected user reads; managers read (`is_persona_group_manager`) | +| persona_group_constraints, _members, _messages | svc (with check); members read (`is_persona_group_member(group_id)`) | +| persona_groups | svc (with check); members read (`is_persona_group_member(id)`) | +| published_pages | svc; public read where `visibility='public'`; own CRUD | +| suggested_contact_runs | svc (with check); own read | +| suggested_contacts | svc (with check); own CRUD | +| telegram_chat_history | svc; own read | +| oauth_pending_state, persona_group_invitations | none (RLS on, service role only) | + +The verbatim `using` / `with check` text comes from the catalog output saved in `packages/db/introspection/` during phase B. + +## Appendix C: migration SQL and the owner backfill + +The migration SQL is in the repo and is the source of truth: `packages/db/persona/migrations/0001_persona_drop_public_read.sql`, `packages/db/cards/migrations/0000_cards_schema.sql`, `0001_cards_tables.sql`, `0002_cards_search_functions.sql`, and `packages/db/identity/migrations/0000_identity_schema.sql`. + +```sql +-- backfill_owner_user_id.sql (run once at cutover, H5; idempotent) +with m as ( + update cards.agent_profile_cards c + set owner_user_id = u.id + from auth.users u + where c.owner_user_id is null + and c.owner_email is not null + and lower(u.email) = lower(c.owner_email) + returning c.id +) +select (select count(*) from m) as linked, + (select count(*) from cards.agent_profile_cards where owner_email is not null and owner_user_id is null) as unmatched; +``` + +## Appendix D: recording persona's baseline + +`cd packages/db && npm run db:baseline-sql` prints the SQL to run once in the aafo SQL editor. It records persona's `0000` in `drizzle.__persona_migrations` without running it, and first renames `drizzle.__drizzle_migrations` if the earlier hotfix SQL already created it. + +## Appendix E: `test/supabase-stubs.sql` (CI and scratch DBs only) + +- **Roles:** `anon`, `authenticated` (nologin) and `service_role` (nologin, `bypassrls`), each granted usage on `public`. +- **Schema `auth`:** + - `auth.users(id uuid primary key, email text)`. + - `auth.uid()`, `auth.role()` and `auth.jwt()` as `language sql stable` functions reading `current_setting('request.jwt.claims', true)`, same as Supabase. +- **Schema `extensions`:** `alter database … set search_path = "$user", public, extensions`. +- **Publication:** `create publication supabase_realtime` (empty). +- **Default privileges:** `alter default privileges in schema public grant all on tables to anon, authenticated, service_role`, and the same for functions and sequences, to mirror Supabase's defaults. Without this, grant lines would differ from prod in the drift diff. + +## Appendix F: pre-check and verify queries (read-only) + +**xmfj, before G:** +```sql +select 'cards: ' || status as what, count(*) from agent_profile_cards group by status +union all select 'x_accounts', count(*) from x_accounts +union all select 'x_mentions', count(*) from x_mentions +union all select 'x_conversations', count(*) from x_conversations +union all select 'distinct owners (non-archived)', count(distinct lower(owner_email)) from agent_profile_cards where status <> 'archived' +union all select 'owners differing only by case', count(*) from (select lower(owner_email) from agent_profile_cards where owner_email is not null group by 1 having count(distinct owner_email) > 1) t +union all select 'avatars objects', count(*) from storage.objects where bucket_id = 'avatars' +union all select 'xmfj copy: persona_agents', count(*) from persona_agents +union all select 'xmfj copy: dm_threads', count(*) from dm_threads +union all select 'keyword_posts', count(*) from keyword_posts; +``` + +**Both sides, at G and H4 (`verify.sql`):** +```sql +select 'rows ' || status, count(*)::text from agent_profile_cards group by status +union all select 'x_accounts', count(*)::text from x_accounts +union all select 'x_conversations', count(*)::text from x_conversations +union all select 'x_mentions', count(*)::text from x_mentions +union all select 'embeddings', count(embedding)::text from agent_profile_cards +union all select 'claim tokens', count(claim_token_hash)::text from agent_profile_cards +union all select 'ids+card md5', md5(string_agg(id || md5(card::text), ',' order by id)) from agent_profile_cards +union all select 'fts top10', string_agg(id, ',') from (select id from search_cards_fts('engineer', 10)) t; +-- On aafo the tables and function are in the cards schema: run `set search_path = cards, public, extensions;` first. +``` +Expected difference after H3: the `ids+card md5` line changes, because avatar URLs are rewritten. Run it **before** H3 for the equality check and after H3 only for the URL report. + +**xmfj: card owners by login provider** (sizes the Google → LinkedIn risk in D12): +```sql +select coalesce(i.provider, '(no xmfj user)') as provider, count(distinct lower(c.owner_email)) as owners +from agent_profile_cards c +left join auth.users u on lower(u.email) = lower(c.owner_email) +left join auth.identities i on i.user_id = u.id +where c.owner_email is not null and c.status <> 'archived' +group by 1 order by 2 desc; +``` + +**xmfj: are the persona copies used?** (D13). Run it now and again 7 days later. If `n_tup_ins/upd/del`, `seq_scan` and `idx_scan` haven't moved, and `pg_stat_statements` shows no queries against these tables, nothing uses them. +```sql +select relname, n_live_tup, n_tup_ins, n_tup_upd, n_tup_del, seq_scan, idx_scan, last_autoanalyze +from pg_stat_user_tables +where schemaname = 'public' and relname = any (array[ + 'a2a_tasks','agent_tasks','api_tokens','brief_todos','callback_results','chat_messages','dm_messages','dm_threads', + 'enriched_companies','enriched_contacts','github_profiles','linkedin_profiles','oauth_pending_state','outbound_callbacks', + 'pending_approvals','persona_agents','persona_group_audit_events','persona_group_constraints','persona_group_invitations', + 'persona_group_members','persona_group_messages','persona_groups','published_pages','suggested_contact_runs', + 'suggested_contacts','telegram_chat_history','telegram_links','twitter_profiles']) +order by relname; + +select calls, left(query, 120) as query +from extensions.pg_stat_statements +where query ~* '(persona_agents|dm_threads|dm_messages|api_tokens|brief_todos|persona_group|chat_messages|telegram_|enriched_|suggested_contact|published_pages|outbound_callbacks|callback_results|pending_approvals|a2a_tasks|agent_tasks|oauth_pending_state|linkedin_profiles|twitter_profiles|github_profiles)' +order by calls desc limit 50; +``` + +## Appendix G: removing xmfj's persona copies (D13) + +Run this **on xmfj only**. Double-check the project before running it, because the same table names exist in aafo, where they are live. Only run it after the activity check above comes back clean twice. None of the dashboard tables references these tables, so `cascade` only reaches their own policies, indexes, FKs and trigger. + +**Part 1: rename (reversible):** +```sql +-- guard: abort unless this is xmfj +do $$ begin + if to_regclass('public.developer_keys') is null then + raise exception 'not xmfj: developer_keys missing, refusing to run'; + end if; +end $$; + +do $$ +declare t text; +begin + foreach t in array array[ + 'a2a_tasks','agent_tasks','api_tokens','brief_todos','callback_results','chat_messages','dm_messages','dm_threads', + 'enriched_companies','enriched_contacts','github_profiles','linkedin_profiles','oauth_pending_state','outbound_callbacks', + 'pending_approvals','persona_agents','persona_group_audit_events','persona_group_constraints','persona_group_invitations', + 'persona_group_members','persona_group_messages','persona_groups','published_pages','suggested_contact_runs', + 'suggested_contacts','telegram_chat_history','telegram_links','twitter_profiles'] + loop + execute format('alter table public.%I rename to %I', t, t || '_retired'); + end loop; +end $$; +``` +*Rollback:* the same loop, renaming `t || '_retired'` back to `t`. + +**Part 2: drop (14 days after part 1, if nothing broke):** +```sql +do $$ begin + if to_regclass('public.developer_keys') is null then + raise exception 'not xmfj: developer_keys missing, refusing to run'; + end if; +end $$; + +-- explicit list, so it can't reach the cards tables, which phase I also renames *_retired +do $$ +declare t text; +begin + foreach t in array array[ + 'a2a_tasks','agent_tasks','api_tokens','brief_todos','callback_results','chat_messages','dm_messages','dm_threads', + 'enriched_companies','enriched_contacts','github_profiles','linkedin_profiles','oauth_pending_state','outbound_callbacks', + 'pending_approvals','persona_agents','persona_group_audit_events','persona_group_constraints','persona_group_invitations', + 'persona_group_members','persona_group_messages','persona_groups','published_pages','suggested_contact_runs', + 'suggested_contacts','telegram_chat_history','telegram_links','twitter_profiles'] + loop + execute format('drop table if exists public.%I cascade', t || '_retired'); + end loop; +end $$; + +drop function if exists public.is_persona_group_member(uuid); +drop function if exists public.is_persona_group_manager(uuid); +drop function if exists public.persona_agents_search_vector_update(); +drop function if exists public.search_personas_fts(text, integer); +``` +`keyword_posts` is left as it is (D13). The xmfj cards tables are handled separately, in phase I. diff --git a/infra/local/docker-compose.yml b/infra/local/docker-compose.yml new file mode 100644 index 0000000..03e7662 --- /dev/null +++ b/infra/local/docker-compose.yml @@ -0,0 +1,14 @@ +# Local datastores for development: memory's Postgres (pgvector) + Redis. +# +# persona and cards are NOT here: they use Supabase (Auth + RLS + Storage + +# Realtime), and local dev points them at a hosted Supabase project through +# env vars instead. See docs/LOCAL_DEV.md. +# +# npm run dev:infra # start (waits until healthy) +# npm run dev:infra:down # stop (data stays in the volume) +name: zynd-local + +include: + # Postgres 5433 and Redis 6380 on the host (non-default ports, so they + # don't collide with a Homebrew Postgres/Redis). + - path: ../../services/memory/docker-compose.yml diff --git a/package.json b/package.json new file mode 100644 index 0000000..726b928 --- /dev/null +++ b/package.json @@ -0,0 +1,30 @@ +{ + "name": "zynd-platform", + "private": true, + "description": "Root runner for the monorepo. Not an npm workspace: each app/package keeps its own lockfile and installs in its own folder, exactly as the servers and Vercel build them. See docs/LOCAL_DEV.md.", + "scripts": { + "setup": "npm run setup:web && npm run setup:py", + "setup:web": "npm --prefix apps/persona-web ci && npm --prefix apps/cards-web ci && npm --prefix packages/db ci", + "setup:py": "bash scripts/setup-python.sh", + + "dev": "npx --yes concurrently@10.0.5 -k -n persona-web,cards-web,persona-api,cards-api -c blue,magenta,green,yellow \"npm:dev:persona-web\" \"npm:dev:cards-web\" \"npm:dev:persona-api\" \"npm:dev:cards-api\"", + "dev:persona-web": "npm --prefix apps/persona-web run dev -- -p 3000", + "dev:cards-web": "npm --prefix apps/cards-web run dev -- -p 3002", + "dev:persona-api": "cd services/persona-api && .venv/bin/uvicorn main:app --reload --host 127.0.0.1 --port 8000", + "dev:cards-api": "cd services/cards-api && .venv/bin/uvicorn main:app --reload --host 127.0.0.1 --port 8002", + "dev:memory": "npx --yes concurrently@10.0.5 -k -n memory-api,memory-mcp,memory-worker -c cyan,white,gray \"cd services/memory && .venv/bin/uvicorn app.main:app --reload --host 127.0.0.1 --port 8001\" \"cd services/memory && .venv/bin/uvicorn app.mcp_http:app --reload --host 127.0.0.1 --port 8090\" \"cd services/memory && .venv/bin/arq app.worker.WorkerSettings\"", + "dev:infra": "docker compose -f infra/local/docker-compose.yml up -d --wait", + "dev:infra:down": "docker compose -f infra/local/docker-compose.yml down", + + "test": "npm run test:persona-api; npm run test:cards-api; npm run test:memory; npm run check:web", + "test:persona-api": "cd services/persona-api && .venv/bin/python -m pytest -q", + "test:cards-api": "cd services/cards-api && .venv/bin/python -m pytest -q", + "test:memory": "cd services/memory && .venv/bin/python -m pytest -q -m 'not integration'", + "check:web": "npm --prefix apps/persona-web run lint && (cd apps/persona-web && npx tsc --noEmit) && npm --prefix apps/cards-web run lint && (cd apps/cards-web && npx tsc --noEmit)", + + "db:check": "npm --prefix packages/db run db:check", + "db:migrate": "npm --prefix packages/db run db:migrate --", + "db:drift": "npm --prefix packages/db run db:drift --", + "db:lint": "npm --prefix packages/db run db:lint" + } +} diff --git a/packages/db/.env.example b/packages/db/.env.example new file mode 100644 index 0000000..b7d89f0 --- /dev/null +++ b/packages/db/.env.example @@ -0,0 +1,4 @@ +# Session pooler (port 5432) or direct connection to the aafo database — NOT the +# transaction pooler (6543). Get it from Supabase → project → Connect. +# Only set this when you are about to run db:migrate / db:drift on purpose. +DATABASE_URL=postgresql://postgres.aafoguuvmaxymrtnfafn:@aws-0-.pooler.supabase.com:5432/postgres diff --git a/packages/db/.gitignore b/packages/db/.gitignore new file mode 100644 index 0000000..40f53a2 --- /dev/null +++ b/packages/db/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +.env +.env.* +!.env.example +/tmp/ diff --git a/packages/db/OWNERS.md b/packages/db/OWNERS.md new file mode 100644 index 0000000..a938737 --- /dev/null +++ b/packages/db/OWNERS.md @@ -0,0 +1,34 @@ +# Who owns what in the shared aafo database + +Ownership follows the Postgres schema, and each schema has its own migration +history (see README.md): `public` = persona, `cards` = cards, +`identity` = shared. A change needs a review from the owner, **and** from +every service listed as a reader. + +## Tables + +| Table | Owner | Also read by | +|---|---|---| +| `persona_agents` | persona | **memory** (`services/memory/app/tools/zynd_network.py`: `agent_id, name, description, active, updated_at`), persona-web (via RLS subqueries) | +| `dm_threads`, `dm_messages`, `a2a_tasks`, `agent_tasks`, `pending_approvals` | persona | persona-web (signed-in reads + realtime) | +| `outbound_callbacks`, `callback_results` | persona | persona-web (realtime) | +| `persona_groups`, `persona_group_members`, `persona_group_messages`, `persona_group_invitations`, `persona_group_constraints`, `persona_group_audit_events` | persona | persona-web (realtime on messages/invitations) | +| `api_tokens`, `oauth_pending_state`, `telegram_links`, `telegram_chat_history` | persona | — | +| `linkedin_profiles`, `twitter_profiles`, `github_profiles` | persona | — | +| `enriched_contacts`, `enriched_companies`, `suggested_contacts`, `suggested_contact_runs` | persona | — | +| `chat_messages`, `brief_todos`, `published_pages` | persona | — | +| `cards.agent_profile_cards`, `cards.x_accounts`, `cards.x_mentions`, `cards.x_conversations` | cards | — (service role only; cards-web goes through cards-api) | +| `identity.*` (empty today; Zynd Account tables in Stage 2) | shared | persona, cards | + +## Functions, triggers, publication + +| Object | Owner | Last defined in | Called by | +|---|---|---|---| +| `is_persona_group_member(uuid)`, `is_persona_group_manager(uuid)` | persona | persona 0000 | RLS policies on group tables | +| `persona_agents_search_vector_update()` + trigger `persona_agents_search_vector_trigger` | persona | persona 0000 | keeps `persona_agents.search_vector` current | +| `search_personas_fts(text, int)` | persona | persona 0000 | persona-api, **memory** | +| `cards.skill_names(jsonb)` | cards | cards 0000 | generated column `agent_profile_cards.search_tsv` | +| `cards.match_cards(vector, int)`, `cards.search_cards_fts(text, int)` | cards | cards 0002 | cards-api (`services/search.py`) | +| publication `supabase_realtime` (9 persona tables) | persona | persona 0000 | persona-web realtime | +| extension `vector` (schema `extensions`) | cards | cards 0000 | — | +| schemas `cards`, `identity` + their grants | cards / shared | cards 0000, identity 0000 | — | diff --git a/packages/db/README.md b/packages/db/README.md new file mode 100644 index 0000000..9485bb9 --- /dev/null +++ b/packages/db/README.md @@ -0,0 +1,139 @@ +# @zynd/db — migrations for the shared database + +Persona and cards share one Supabase Postgres project, **aafo** +(`aafoguuvmaxymrtnfafn`). Postgres schemas are the boundary between them, +and each schema has its **own, independent migration history**: + +| History | Owns schema | Folder | Tracking table | Owner / reviews | +|---|---|---|---|---| +| `identity` | `identity` | `identity/` | `drizzle.__identity_migrations` | shared: both products review | +| `persona` | `public` | `persona/` | `drizzle.__persona_migrations` | persona | +| `cards` | `cards` | `cards/` | `drizzle.__cards_migrations` | cards | + +- **Independent.** persona's history never sees the `cards` schema, and + cards' never sees `public`. Adding a column to a cards table is a cards-only + PR. Each history applies in its own transaction. +- **No name clashes.** `cards.x` and `public.x` can both exist. +- **Cross-schema references still work** within the one database: + `cards.agent_profile_cards.owner_user_id → auth.users(id)` is a normal FK. +- **`identity` is the only shared part.** It is what both products point at. + Today that is Supabase's own `auth.users`, so the schema starts empty; the + Zynd Account tables (Stage 2) go there. Keep it small and slow-moving. +- **persona stays in `public`** because its 28 tables have always lived + there. Moving them into a `persona` schema would touch every query, + realtime subscription and policy in persona-api, persona-web and memory, + on a database with no staging copy. It can be a separate project later. +- **Drizzle is only used for schema and migrations.** persona-api, cards-api + and memory keep using supabase-py; the web apps use supabase-js. +- **There is no staging database.** dev.persona.zynd.ai runs on prod aafo, + so any migration you apply is live for dev *and* prod. Rehearse locally, + keep migrations expand-only, and write the rollback SQL before applying. + +The plan and its reasoning: [`docs/plans/ZYND_DB_UNIFY_PLAN.md`](../../docs/plans/ZYND_DB_UNIFY_PLAN.md). + +## Layout + +| Path | What | +|---|---| +| `/schema/*.ts` | Tables, columns, constraints, indexes, RLS policies: everything Drizzle can express | +| `/migrations/` | That history's ordered migrations + Drizzle's `meta/` | +| `/drizzle.config.ts` | Which schema it owns and where it records progress (`lib/config.ts`) | +| `lib/shared.ts` | Helpers used by the schema files (FK naming, policy builders, `tsvector`) | +| `scripts/migrate.ts` | `npm run db:migrate`: dry run by default, `-- --yes` applies, `-- --project cards` for one history | +| `scripts/check-drift.ts` | `npm run db:drift`: does a real database equal what the migrations build? | +| `scripts/catalog.sql` | The read-only catalog query `db:drift` compares (also runs in the SQL editor) | +| `scripts/baseline-sql.ts` | `npm run db:baseline-sql`: records persona's 0000 on a database that already has it | +| `scripts/preflight-cards.sql` | Read-only checks before the cards history first reaches aafo | +| `scripts/lint-migrations.sh` | `npm run db:lint`: naming, owner header, journals, no edits to merged files | +| `test/supabase-stubs.sql` | Enough of Supabase (roles, `auth.*`, publication, default grants) to run migrations on plain Postgres | +| `introspection/` | Dated catalog exports of prod, kept as evidence | +| `OWNERS.md` | Who owns and who reads each table and function | + +## Making a schema change + +| Kind of change | Command (from `packages/db`) | +|---|---| +| Table, column, default, PK/FK/unique/check, index, RLS policy, generated column | Edit `/schema/**`, then `npm run :generate -- --name ` | +| Extension, function, trigger, GRANT/REVOKE, publication, data backfill | `npm run :generate:custom -- --name `, then write the SQL in the new empty file | +| Changing a function or trigger | A **new** custom migration with `CREATE OR REPLACE`; never edit an old file | + +1. `git switch dev && git pull`, then create a branch. +2. Make the change as above. Read the generated SQL. When Drizzle asks + whether a column was renamed, answer carefully, or you get drop + add + (data loss). +3. First line of every migration: `-- owner: persona|cards|shared`. Explain + *why*, and include the rollback SQL in the comment. +4. Keep it **expand-only**. Drops and renames happen in a later migration, + once no deployed code uses the old thing. +5. Rehearse locally (below), run `npm run db:lint`, and open a PR to `dev` + that says "schema change" (AGENTS.md §6). CI builds every history from an + empty database and fails if schema files and migrations disagree. +6. **Apply to prod** (any team member, after review): + ```bash + cd packages/db + export DATABASE_URL='postgresql://postgres.aafoguuvmaxymrtnfafn:@aws-0-.pooler.supabase.com:5432/postgres' + npm run db:drift -- --expected "$DATABASE_URL" --scratch --upto # must say "No drift" + npm run db:migrate -- --project # dry run: check Target and pending + npm run db:migrate -- --project --yes # apply + unset DATABASE_URL + ``` + Use the **session pooler or direct** connection (port 5432), not the + transaction pooler (6543). `--upto` names, per history, the last migration + prod already has, e.g. `--upto persona:0001_persona_drop_public_read,cards:none`. +7. Deploy code that needs the change only **after** step 6. + +## Rules + +- **No schema changes in the Supabase SQL editor.** An emergency hotfix done + there becomes a migration the same day. +- **Never `drizzle-kit push`** against aafo. There is no push script on purpose. +- **Never edit a merged migration.** Add a new one; CI enforces it. +- **A history's pending migrations run in one transaction.** One failure rolls + all of them back. So `CREATE INDEX CONCURRENTLY` can't be used: for a big + table, build the index by hand in a quiet window and record it in a + migration with `CREATE INDEX IF NOT EXISTS`. +- A new Postgres schema is not exposed by default. The cards schema only + grants the service role, and it must be listed in the project's API + **Exposed schemas** setting before cards-api can query it. +- Changes to `persona_agents` or `search_personas_fts` also need memory's + owner: memory reads them (`OWNERS.md`). +- The old SQL folders (`services/persona-api/db`, + `services/persona-api/supabase/migrations`, `apps/persona-web/db`, + `services/cards-api/db`) are frozen history. Don't add to them. + +## Local rehearsal + +Needs a throwaway Postgres (Homebrew is fine; the cards history also needs +`brew install pgvector`). Never point this at Supabase. + +```bash +initdb -D /tmp/zdb -U postgres --auth=trust && pg_ctl -D /tmp/zdb -o "-p 5544" -l /tmp/zdb.log start +psql -h 127.0.0.1 -p 5544 -U postgres -c 'create database rehearsal' +psql -h 127.0.0.1 -p 5544 -U postgres -d rehearsal -f test/supabase-stubs.sql +DATABASE_URL=postgresql://postgres@127.0.0.1:5544/rehearsal npm run db:migrate -- --yes +``` + +A brand-new Supabase project (e.g. a future dev project) is built the same +way: `db:migrate -- --yes` on an empty project runs every history, including +persona's baseline. + +## persona's baseline (0000) + +`persona/migrations/0000_baseline_persona.sql` is aafo's `public` schema +exactly as it was on 2026-09-26: 28 tables, 43 indexes, 72 policies, +4 functions, 1 trigger and the realtime publication. Prod already has all of +it, so on prod it is **recorded, never run**: + +```bash +npm run db:baseline-sql # prints SQL; run it once in the aafo SQL editor +``` + +`db:migrate` refuses to run persona's 0000 on a database that already has the +persona tables. To prove the baseline equals prod, export +`scripts/catalog.sql` from the aafo SQL editor into +`introspection/aafo-.txt` (or pass the prod URL), then: + +```bash +npm run db:drift -- --expected introspection/aafo-.txt --scratch \ + --upto identity:none,persona:0000_baseline_persona,cards:none +``` diff --git a/packages/db/cards/drizzle.config.ts b/packages/db/cards/drizzle.config.ts new file mode 100644 index 0000000..9aa04cc --- /dev/null +++ b/packages/db/cards/drizzle.config.ts @@ -0,0 +1,4 @@ +import { historyConfig } from '../lib/config'; + +// cards owns the `cards` schema. +export default historyConfig('cards', ['cards']); diff --git a/packages/db/cards/migrations/0000_cards_schema.sql b/packages/db/cards/migrations/0000_cards_schema.sql new file mode 100644 index 0000000..59a7624 --- /dev/null +++ b/packages/db/cards/migrations/0000_cards_schema.sql @@ -0,0 +1,29 @@ +-- owner: cards +-- The cards schema, and what the cards tables (0001) need before they exist. +-- Cards moves here from the dashboard's Supabase project (xmfj), where its +-- tables sat in `public`. +CREATE SCHEMA "cards"; +--> statement-breakpoint +-- Service role only. cards-api is the sole reader/writer (cards-web goes +-- through it), so anon/authenticated get nothing in this schema. Supabase's +-- default grants only cover `public`, so grants are explicit here. +GRANT USAGE ON SCHEMA cards TO service_role; +ALTER DEFAULT PRIVILEGES IN SCHEMA cards GRANT ALL ON TABLES TO service_role; +ALTER DEFAULT PRIVILEGES IN SCHEMA cards GRANT ALL ON SEQUENCES TO service_role; +ALTER DEFAULT PRIVILEGES IN SCHEMA cards REVOKE EXECUTE ON FUNCTIONS FROM PUBLIC; +ALTER DEFAULT PRIVILEGES IN SCHEMA cards GRANT EXECUTE ON FUNCTIONS TO service_role; +--> statement-breakpoint +-- pgvector, for agent_profile_cards.embedding. In `extensions`, Supabase's +-- default (xmfj had it in public). +CREATE EXTENSION IF NOT EXISTS vector WITH SCHEMA extensions; +--> statement-breakpoint +-- Verbatim from xmfj (2026-09-26), moved into the cards schema. IMMUTABLE +-- because the generated column agent_profile_cards.search_tsv calls it. +CREATE OR REPLACE FUNCTION cards.skill_names(card jsonb) + RETURNS text + LANGUAGE sql + IMMUTABLE +AS $function$ + select coalesce(string_agg(s->>'name', ' ' order by s->>'name'), '') + from jsonb_array_elements(coalesce(card->'skills', '[]'::jsonb)) s +$function$; diff --git a/packages/db/cards/migrations/0001_cards_tables.sql b/packages/db/cards/migrations/0001_cards_tables.sql new file mode 100644 index 0000000..84322ca --- /dev/null +++ b/packages/db/cards/migrations/0001_cards_tables.sql @@ -0,0 +1,70 @@ +-- owner: cards +-- The cards tables, with the columns xmfj prod has (2026-09-26), plus +-- owner_user_id → auth.users: the link between a card and its persona user. +-- Service role only (see cards/schema/cards.ts and 0000). +CREATE TABLE "cards"."agent_profile_cards" ( + "id" text PRIMARY KEY NOT NULL, + "status" text DEFAULT 'draft' NOT NULL, + "handle_github" text, + "handle_x" text, + "card" jsonb NOT NULL, + "search_tsv" "tsvector" GENERATED ALWAYS AS (to_tsvector('english'::regconfig, ((((((COALESCE(((card -> 'identity'::text) ->> 'name'::text), ''::text) || ' '::text) || COALESCE(((card -> 'identity'::text) ->> 'headline'::text), ''::text)) || ' '::text) || COALESCE((card ->> 'summary'::text), ''::text)) || ' '::text) || cards.skill_names(card)))) STORED, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + "published_at" timestamp with time zone, + "handle" text, + "embedding" vector(1536), + "scrape_raw" jsonb, + "user_intent" jsonb, + "owner_email" text, + "suggested_posts" jsonb, + "claim_token_hash" text, + "owner_user_id" uuid, + CONSTRAINT "agent_profile_cards_handle_key" UNIQUE("handle") +); +--> statement-breakpoint +ALTER TABLE "cards"."agent_profile_cards" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint +CREATE TABLE "cards"."x_accounts" ( + "x_user_id" text PRIMARY KEY NOT NULL, + "username" text NOT NULL, + "card_id" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "cards"."x_accounts" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint +CREATE TABLE "cards"."x_conversations" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "x_user_id" text NOT NULL, + "card_id" text, + "status" text DEFAULT 'initial' NOT NULL, + "current_question" text, + "answered" jsonb DEFAULT '{}'::jsonb NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "cards"."x_conversations" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint +CREATE TABLE "cards"."x_mentions" ( + "tweet_id" text PRIMARY KEY NOT NULL, + "x_user_id" text NOT NULL, + "text" text, + "status" text DEFAULT 'processed' NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "cards"."x_mentions" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint +ALTER TABLE "cards"."agent_profile_cards" ADD CONSTRAINT "agent_profile_cards_owner_user_id_fkey" FOREIGN KEY ("owner_user_id") REFERENCES "auth"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "cards"."x_accounts" ADD CONSTRAINT "x_accounts_card_id_fkey" FOREIGN KEY ("card_id") REFERENCES "cards"."agent_profile_cards"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "cards"."x_conversations" ADD CONSTRAINT "x_conversations_card_id_fkey" FOREIGN KEY ("card_id") REFERENCES "cards"."agent_profile_cards"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "agent_profile_cards_status_idx" ON "cards"."agent_profile_cards" USING btree ("status");--> statement-breakpoint +CREATE INDEX "agent_profile_cards_tsv_idx" ON "cards"."agent_profile_cards" USING gin ("search_tsv");--> statement-breakpoint +CREATE INDEX "agent_profile_cards_embedding_hnsw_idx" ON "cards"."agent_profile_cards" USING hnsw ("embedding" vector_cosine_ops);--> statement-breakpoint +CREATE INDEX "idx_cards_owner_email" ON "cards"."agent_profile_cards" USING btree ("owner_email");--> statement-breakpoint +CREATE INDEX "agent_profile_cards_owner_user_id_idx" ON "cards"."agent_profile_cards" USING btree ("owner_user_id");--> statement-breakpoint +CREATE INDEX "x_conversations_user_idx" ON "cards"."x_conversations" USING btree ("x_user_id");--> statement-breakpoint +CREATE INDEX "x_mentions_user_idx" ON "cards"."x_mentions" USING btree ("x_user_id");--> statement-breakpoint +CREATE POLICY "service role full access on cards" ON "cards"."agent_profile_cards" AS PERMISSIVE FOR ALL TO "service_role" USING (true) WITH CHECK (true);--> statement-breakpoint +CREATE POLICY "service role full access on x_accounts" ON "cards"."x_accounts" AS PERMISSIVE FOR ALL TO "service_role" USING (true) WITH CHECK (true);--> statement-breakpoint +CREATE POLICY "service role full access on x_conversations" ON "cards"."x_conversations" AS PERMISSIVE FOR ALL TO "service_role" USING (true) WITH CHECK (true);--> statement-breakpoint +CREATE POLICY "service role full access on x_mentions" ON "cards"."x_mentions" AS PERMISSIVE FOR ALL TO "service_role" USING (true) WITH CHECK (true); \ No newline at end of file diff --git a/packages/db/cards/migrations/0002_cards_search_functions.sql b/packages/db/cards/migrations/0002_cards_search_functions.sql new file mode 100644 index 0000000..c82b705 --- /dev/null +++ b/packages/db/cards/migrations/0002_cards_search_functions.sql @@ -0,0 +1,31 @@ +-- owner: cards +-- Search functions cards-api calls over PostgREST RPC +-- (services/cards-api/services/search.py). Bodies verbatim from xmfj +-- (2026-09-26), now in the cards schema, with an explicit search_path because +-- `vector` and its <=> operator live in `extensions`. +CREATE OR REPLACE FUNCTION cards.match_cards(query_embedding extensions.vector, match_count integer DEFAULT 200) + RETURNS TABLE(id text, handle text, card jsonb, similarity double precision) + LANGUAGE sql + STABLE + SET search_path TO 'cards', 'extensions' +AS $function$ + select c.id, c.handle, c.card, 1 - (c.embedding <=> query_embedding) + from agent_profile_cards c + where c.status = 'published' and c.embedding is not null + order by c.embedding <=> query_embedding + limit match_count; +$function$; +--> statement-breakpoint +CREATE OR REPLACE FUNCTION cards.search_cards_fts(q text, match_count integer DEFAULT 200) + RETURNS TABLE(id text, handle text, card jsonb, rank real) + LANGUAGE sql + STABLE + SET search_path TO 'cards', 'extensions' +AS $function$ + select c.id, c.handle, c.card, ts_rank_cd(c.search_tsv, websearch_to_tsquery('english', q)) + from agent_profile_cards c + where c.status = 'published' + and c.search_tsv @@ websearch_to_tsquery('english', q) + order by 4 desc + limit match_count; +$function$; diff --git a/packages/db/cards/migrations/meta/0000_snapshot.json b/packages/db/cards/migrations/meta/0000_snapshot.json new file mode 100644 index 0000000..1a66201 --- /dev/null +++ b/packages/db/cards/migrations/meta/0000_snapshot.json @@ -0,0 +1,20 @@ +{ + "id": "0f696566-1312-4bbc-9c8b-80dc73751ee9", + "prevId": "00000000-0000-0000-0000-000000000000", + "version": "7", + "dialect": "postgresql", + "tables": {}, + "enums": {}, + "schemas": { + "cards": "cards" + }, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/db/cards/migrations/meta/0001_snapshot.json b/packages/db/cards/migrations/meta/0001_snapshot.json new file mode 100644 index 0000000..a95f73d --- /dev/null +++ b/packages/db/cards/migrations/meta/0001_snapshot.json @@ -0,0 +1,505 @@ +{ + "id": "d7626411-df29-4541-b0ce-5f1359dcee72", + "prevId": "0f696566-1312-4bbc-9c8b-80dc73751ee9", + "version": "7", + "dialect": "postgresql", + "tables": { + "cards.agent_profile_cards": { + "name": "agent_profile_cards", + "schema": "cards", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "handle_github": { + "name": "handle_github", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "handle_x": { + "name": "handle_x", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "card": { + "name": "card", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "search_tsv": { + "name": "search_tsv", + "type": "tsvector", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "to_tsvector('english'::regconfig, ((((((COALESCE(((card -> 'identity'::text) ->> 'name'::text), ''::text) || ' '::text) || COALESCE(((card -> 'identity'::text) ->> 'headline'::text), ''::text)) || ' '::text) || COALESCE((card ->> 'summary'::text), ''::text)) || ' '::text) || cards.skill_names(card)))", + "type": "stored" + } + }, + "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()" + }, + "published_at": { + "name": "published_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "handle": { + "name": "handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": false + }, + "scrape_raw": { + "name": "scrape_raw", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "user_intent": { + "name": "user_intent", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "owner_email": { + "name": "owner_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "suggested_posts": { + "name": "suggested_posts", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "claim_token_hash": { + "name": "claim_token_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "agent_profile_cards_status_idx": { + "name": "agent_profile_cards_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agent_profile_cards_tsv_idx": { + "name": "agent_profile_cards_tsv_idx", + "columns": [ + { + "expression": "search_tsv", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "agent_profile_cards_embedding_hnsw_idx": { + "name": "agent_profile_cards_embedding_hnsw_idx", + "columns": [ + { + "expression": "embedding", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "vector_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": {} + }, + "idx_cards_owner_email": { + "name": "idx_cards_owner_email", + "columns": [ + { + "expression": "owner_email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agent_profile_cards_owner_user_id_idx": { + "name": "agent_profile_cards_owner_user_id_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_profile_cards_owner_user_id_fkey": { + "name": "agent_profile_cards_owner_user_id_fkey", + "tableFrom": "agent_profile_cards", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "agent_profile_cards_handle_key": { + "name": "agent_profile_cards_handle_key", + "nullsNotDistinct": false, + "columns": [ + "handle" + ] + } + }, + "policies": { + "service role full access on cards": { + "name": "service role full access on cards", + "as": "PERMISSIVE", + "for": "ALL", + "to": [ + "service_role" + ], + "using": "true", + "withCheck": "true" + } + }, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "cards.x_accounts": { + "name": "x_accounts", + "schema": "cards", + "columns": { + "x_user_id": { + "name": "x_user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "card_id": { + "name": "card_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": {}, + "foreignKeys": { + "x_accounts_card_id_fkey": { + "name": "x_accounts_card_id_fkey", + "tableFrom": "x_accounts", + "tableTo": "agent_profile_cards", + "schemaTo": "cards", + "columnsFrom": [ + "card_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": { + "service role full access on x_accounts": { + "name": "service role full access on x_accounts", + "as": "PERMISSIVE", + "for": "ALL", + "to": [ + "service_role" + ], + "using": "true", + "withCheck": "true" + } + }, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "cards.x_conversations": { + "name": "x_conversations", + "schema": "cards", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "x_user_id": { + "name": "x_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "card_id": { + "name": "card_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'initial'" + }, + "current_question": { + "name": "current_question", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "answered": { + "name": "answered", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "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": { + "x_conversations_user_idx": { + "name": "x_conversations_user_idx", + "columns": [ + { + "expression": "x_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "x_conversations_card_id_fkey": { + "name": "x_conversations_card_id_fkey", + "tableFrom": "x_conversations", + "tableTo": "agent_profile_cards", + "schemaTo": "cards", + "columnsFrom": [ + "card_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": { + "service role full access on x_conversations": { + "name": "service role full access on x_conversations", + "as": "PERMISSIVE", + "for": "ALL", + "to": [ + "service_role" + ], + "using": "true", + "withCheck": "true" + } + }, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "cards.x_mentions": { + "name": "x_mentions", + "schema": "cards", + "columns": { + "tweet_id": { + "name": "tweet_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "x_user_id": { + "name": "x_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'processed'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "x_mentions_user_idx": { + "name": "x_mentions_user_idx", + "columns": [ + { + "expression": "x_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": { + "service role full access on x_mentions": { + "name": "service role full access on x_mentions", + "as": "PERMISSIVE", + "for": "ALL", + "to": [ + "service_role" + ], + "using": "true", + "withCheck": "true" + } + }, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": { + "cards": "cards" + }, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/db/cards/migrations/meta/0002_snapshot.json b/packages/db/cards/migrations/meta/0002_snapshot.json new file mode 100644 index 0000000..e284016 --- /dev/null +++ b/packages/db/cards/migrations/meta/0002_snapshot.json @@ -0,0 +1,505 @@ +{ + "id": "7c7baa8d-88e6-42c2-834f-56ee9f9cf5a2", + "prevId": "d7626411-df29-4541-b0ce-5f1359dcee72", + "version": "7", + "dialect": "postgresql", + "tables": { + "cards.agent_profile_cards": { + "name": "agent_profile_cards", + "schema": "cards", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "handle_github": { + "name": "handle_github", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "handle_x": { + "name": "handle_x", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "card": { + "name": "card", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "search_tsv": { + "name": "search_tsv", + "type": "tsvector", + "primaryKey": false, + "notNull": false, + "generated": { + "type": "stored", + "as": "to_tsvector('english'::regconfig, ((((((COALESCE(((card -> 'identity'::text) ->> 'name'::text), ''::text) || ' '::text) || COALESCE(((card -> 'identity'::text) ->> 'headline'::text), ''::text)) || ' '::text) || COALESCE((card ->> 'summary'::text), ''::text)) || ' '::text) || cards.skill_names(card)))" + } + }, + "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()" + }, + "published_at": { + "name": "published_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "handle": { + "name": "handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": false + }, + "scrape_raw": { + "name": "scrape_raw", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "user_intent": { + "name": "user_intent", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "owner_email": { + "name": "owner_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "suggested_posts": { + "name": "suggested_posts", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "claim_token_hash": { + "name": "claim_token_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "agent_profile_cards_status_idx": { + "name": "agent_profile_cards_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "agent_profile_cards_tsv_idx": { + "name": "agent_profile_cards_tsv_idx", + "columns": [ + { + "expression": "search_tsv", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "gin", + "concurrently": false + }, + "agent_profile_cards_embedding_hnsw_idx": { + "name": "agent_profile_cards_embedding_hnsw_idx", + "columns": [ + { + "expression": "embedding", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "vector_cosine_ops" + } + ], + "isUnique": false, + "with": {}, + "method": "hnsw", + "concurrently": false + }, + "idx_cards_owner_email": { + "name": "idx_cards_owner_email", + "columns": [ + { + "expression": "owner_email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "agent_profile_cards_owner_user_id_idx": { + "name": "agent_profile_cards_owner_user_id_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "agent_profile_cards_owner_user_id_fkey": { + "name": "agent_profile_cards_owner_user_id_fkey", + "tableFrom": "agent_profile_cards", + "columnsFrom": [ + "owner_user_id" + ], + "tableTo": "users", + "schemaTo": "auth", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "set null" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "agent_profile_cards_handle_key": { + "name": "agent_profile_cards_handle_key", + "columns": [ + "handle" + ], + "nullsNotDistinct": false + } + }, + "policies": { + "service role full access on cards": { + "name": "service role full access on cards", + "as": "PERMISSIVE", + "for": "ALL", + "to": [ + "service_role" + ], + "using": "true", + "withCheck": "true" + } + }, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "cards.x_accounts": { + "name": "x_accounts", + "schema": "cards", + "columns": { + "x_user_id": { + "name": "x_user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "card_id": { + "name": "card_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": {}, + "foreignKeys": { + "x_accounts_card_id_fkey": { + "name": "x_accounts_card_id_fkey", + "tableFrom": "x_accounts", + "columnsFrom": [ + "card_id" + ], + "tableTo": "agent_profile_cards", + "schemaTo": "cards", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": { + "service role full access on x_accounts": { + "name": "service role full access on x_accounts", + "as": "PERMISSIVE", + "for": "ALL", + "to": [ + "service_role" + ], + "using": "true", + "withCheck": "true" + } + }, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "cards.x_conversations": { + "name": "x_conversations", + "schema": "cards", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "x_user_id": { + "name": "x_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "card_id": { + "name": "card_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'initial'" + }, + "current_question": { + "name": "current_question", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "answered": { + "name": "answered", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "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": { + "x_conversations_user_idx": { + "name": "x_conversations_user_idx", + "columns": [ + { + "expression": "x_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "x_conversations_card_id_fkey": { + "name": "x_conversations_card_id_fkey", + "tableFrom": "x_conversations", + "columnsFrom": [ + "card_id" + ], + "tableTo": "agent_profile_cards", + "schemaTo": "cards", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": { + "service role full access on x_conversations": { + "name": "service role full access on x_conversations", + "as": "PERMISSIVE", + "for": "ALL", + "to": [ + "service_role" + ], + "using": "true", + "withCheck": "true" + } + }, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "cards.x_mentions": { + "name": "x_mentions", + "schema": "cards", + "columns": { + "tweet_id": { + "name": "tweet_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "x_user_id": { + "name": "x_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'processed'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "x_mentions_user_idx": { + "name": "x_mentions_user_idx", + "columns": [ + { + "expression": "x_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": { + "service role full access on x_mentions": { + "name": "service role full access on x_mentions", + "as": "PERMISSIVE", + "for": "ALL", + "to": [ + "service_role" + ], + "using": "true", + "withCheck": "true" + } + }, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": { + "cards": "cards" + }, + "views": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/db/cards/migrations/meta/_journal.json b/packages/db/cards/migrations/meta/_journal.json new file mode 100644 index 0000000..daddd17 --- /dev/null +++ b/packages/db/cards/migrations/meta/_journal.json @@ -0,0 +1,27 @@ +{ + "version": "7", + "dialect": "postgresql", + "entries": [ + { + "idx": 0, + "version": "7", + "when": 1790431248410, + "tag": "0000_cards_schema", + "breakpoints": true + }, + { + "idx": 1, + "version": "7", + "when": 1790431277839, + "tag": "0001_cards_tables", + "breakpoints": true + }, + { + "idx": 2, + "version": "7", + "when": 1790431288763, + "tag": "0002_cards_search_functions", + "breakpoints": true + } + ] +} \ No newline at end of file diff --git a/packages/db/cards/schema/_schema.ts b/packages/db/cards/schema/_schema.ts new file mode 100644 index 0000000..0715a2a --- /dev/null +++ b/packages/db/cards/schema/_schema.ts @@ -0,0 +1,5 @@ +import { pgSchema } from 'drizzle-orm/pg-core'; + +// cards' tables live in their own `cards` schema, so they can never collide +// with persona's names, and this history never touches anything else. +export const cards = pgSchema('cards'); diff --git a/packages/db/cards/schema/cards.ts b/packages/db/cards/schema/cards.ts new file mode 100644 index 0000000..693e54b --- /dev/null +++ b/packages/db/cards/schema/cards.ts @@ -0,0 +1,57 @@ +import { sql } from 'drizzle-orm'; +import { index, jsonb, pgPolicy, text, uuid, vector } from 'drizzle-orm/pg-core'; +import { authUsers, fk, serviceRole, tsvector, tstz } from '../../lib/shared'; +import { cards } from './_schema'; + +// owner: cards. Moved from the dashboard's Supabase project (xmfj) — columns +// exactly as in xmfj prod on 2026-09-26, plus owner_user_id. +// +// Access: service role only. cards-api is the only reader/writer; there is +// deliberately no anon "public read published cards" policy (xmfj has one, +// inert there because xmfj revoked anon's table grants). The cards schema +// grants nothing to anon/authenticated at all (migration 0000). +export const agentProfileCards = cards.table( + 'agent_profile_cards', + { + id: text('id').primaryKey(), + status: text('status').notNull().default('draft'), + handleGithub: text('handle_github'), + handleX: text('handle_x'), + card: jsonb('card').notNull(), + // cards.skill_names() is created in migration 0000. + searchTsv: tsvector('search_tsv').generatedAlwaysAs( + sql`to_tsvector('english'::regconfig, ((((((COALESCE(((card -> 'identity'::text) ->> 'name'::text), ''::text) || ' '::text) || COALESCE(((card -> 'identity'::text) ->> 'headline'::text), ''::text)) || ' '::text) || COALESCE((card ->> 'summary'::text), ''::text)) || ' '::text) || cards.skill_names(card)))`, + ), + createdAt: tstz('created_at').notNull().defaultNow(), + updatedAt: tstz('updated_at').notNull().defaultNow(), + publishedAt: tstz('published_at'), + handle: text('handle').unique('agent_profile_cards_handle_key'), + embedding: vector('embedding', { dimensions: 1536 }), + scrapeRaw: jsonb('scrape_raw'), + userIntent: jsonb('user_intent'), + ownerEmail: text('owner_email'), + suggestedPosts: jsonb('suggested_posts'), + claimTokenHash: text('claim_token_hash'), + // NEW (not in xmfj): the owner's aafo user — the same id persona uses. + // Backfilled by email at cutover, then set by cards-api on signed-in writes. + // owner_email stays the ownership authority until Stage 2. + ownerUserId: uuid('owner_user_id'), + }, + (t) => [ + fk('agent_profile_cards_owner_user_id_fkey', t.ownerUserId, authUsers.id, 'set null'), + index('agent_profile_cards_status_idx').on(t.status), + index('agent_profile_cards_tsv_idx').using('gin', t.searchTsv), + index('agent_profile_cards_embedding_hnsw_idx').using('hnsw', t.embedding.op('vector_cosine_ops')), + index('idx_cards_owner_email').on(t.ownerEmail), + index('agent_profile_cards_owner_user_id_idx').on(t.ownerUserId), + // xmfj's agent_profile_cards_handle_idx is not recreated: it duplicates + // the unique constraint's index. + pgPolicy('service role full access on cards', { + as: 'permissive', + for: 'all', + to: serviceRole, + using: sql`true`, + withCheck: sql`true`, + }), + ], +); diff --git a/packages/db/cards/schema/index.ts b/packages/db/cards/schema/index.ts new file mode 100644 index 0000000..00aa651 --- /dev/null +++ b/packages/db/cards/schema/index.ts @@ -0,0 +1,3 @@ +export * from './_schema'; +export * from './cards'; +export * from './x_bot'; diff --git a/packages/db/cards/schema/x_bot.ts b/packages/db/cards/schema/x_bot.ts new file mode 100644 index 0000000..27468dd --- /dev/null +++ b/packages/db/cards/schema/x_bot.ts @@ -0,0 +1,60 @@ +import { sql } from 'drizzle-orm'; +import { index, jsonb, pgPolicy, text, uuid } from 'drizzle-orm/pg-core'; +import { fk, jsonbDefault, serviceRole, tstz } from '../../lib/shared'; +import { agentProfileCards } from './cards'; +import { cards } from './_schema'; + +// owner: cards — the @zynd X (Twitter) bot's state. Moved from xmfj as-is. +// Service role only, like xmfj. + +const serviceOnly = (table: string) => + pgPolicy(`service role full access on ${table}`, { + as: 'permissive', + for: 'all', + to: serviceRole, + using: sql`true`, + withCheck: sql`true`, + }); + +export const xAccounts = cards.table( + 'x_accounts', + { + xUserId: text('x_user_id').primaryKey(), + username: text('username').notNull(), + cardId: text('card_id'), + createdAt: tstz('created_at').notNull().defaultNow(), + updatedAt: tstz('updated_at').notNull().defaultNow(), + }, + (t) => [fk('x_accounts_card_id_fkey', t.cardId, agentProfileCards.id), serviceOnly('x_accounts')], +); + +export const xConversations = cards.table( + 'x_conversations', + { + id: uuid('id').primaryKey().defaultRandom(), + xUserId: text('x_user_id').notNull(), + cardId: text('card_id'), + status: text('status').notNull().default('initial'), + currentQuestion: text('current_question'), + answered: jsonb('answered').notNull().default(jsonbDefault('{}')), + createdAt: tstz('created_at').notNull().defaultNow(), + updatedAt: tstz('updated_at').notNull().defaultNow(), + }, + (t) => [ + fk('x_conversations_card_id_fkey', t.cardId, agentProfileCards.id), + index('x_conversations_user_idx').on(t.xUserId), + serviceOnly('x_conversations'), + ], +); + +export const xMentions = cards.table( + 'x_mentions', + { + tweetId: text('tweet_id').primaryKey(), + xUserId: text('x_user_id').notNull(), + text: text('text'), + status: text('status').notNull().default('processed'), + createdAt: tstz('created_at').notNull().defaultNow(), + }, + (t) => [index('x_mentions_user_idx').on(t.xUserId), serviceOnly('x_mentions')], +); diff --git a/packages/db/identity/drizzle.config.ts b/packages/db/identity/drizzle.config.ts new file mode 100644 index 0000000..600804d --- /dev/null +++ b/packages/db/identity/drizzle.config.ts @@ -0,0 +1,4 @@ +import { historyConfig } from '../lib/config'; + +// identity owns the `identity` schema: the one thing both products share. +export default historyConfig('identity', ['identity']); diff --git a/packages/db/identity/migrations/0000_identity_schema.sql b/packages/db/identity/migrations/0000_identity_schema.sql new file mode 100644 index 0000000..edb84ab --- /dev/null +++ b/packages/db/identity/migrations/0000_identity_schema.sql @@ -0,0 +1,11 @@ +-- owner: shared +-- The identity schema: the only thing persona and cards genuinely share. +-- Empty for now (the shared user id is Supabase's auth.users). Stage 2 adds +-- the Zynd Account tables here. Only the backends (service role) may use it. +CREATE SCHEMA "identity"; +--> statement-breakpoint +GRANT USAGE ON SCHEMA identity TO service_role; +ALTER DEFAULT PRIVILEGES IN SCHEMA identity GRANT ALL ON TABLES TO service_role; +ALTER DEFAULT PRIVILEGES IN SCHEMA identity GRANT ALL ON SEQUENCES TO service_role; +ALTER DEFAULT PRIVILEGES IN SCHEMA identity REVOKE EXECUTE ON FUNCTIONS FROM PUBLIC; +ALTER DEFAULT PRIVILEGES IN SCHEMA identity GRANT EXECUTE ON FUNCTIONS TO service_role; diff --git a/packages/db/identity/migrations/meta/0000_snapshot.json b/packages/db/identity/migrations/meta/0000_snapshot.json new file mode 100644 index 0000000..6d12c98 --- /dev/null +++ b/packages/db/identity/migrations/meta/0000_snapshot.json @@ -0,0 +1,20 @@ +{ + "id": "fe6a4eea-fa0a-4ced-8a7b-19bbe6b1ea1e", + "prevId": "00000000-0000-0000-0000-000000000000", + "version": "7", + "dialect": "postgresql", + "tables": {}, + "enums": {}, + "schemas": { + "identity": "identity" + }, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/db/identity/migrations/meta/_journal.json b/packages/db/identity/migrations/meta/_journal.json new file mode 100644 index 0000000..17053a7 --- /dev/null +++ b/packages/db/identity/migrations/meta/_journal.json @@ -0,0 +1,13 @@ +{ + "version": "7", + "dialect": "postgresql", + "entries": [ + { + "idx": 0, + "version": "7", + "when": 1790431239730, + "tag": "0000_identity_schema", + "breakpoints": true + } + ] +} \ No newline at end of file diff --git a/packages/db/identity/schema/index.ts b/packages/db/identity/schema/index.ts new file mode 100644 index 0000000..b4d56dd --- /dev/null +++ b/packages/db/identity/schema/index.ts @@ -0,0 +1,9 @@ +import { pgSchema } from 'drizzle-orm/pg-core'; + +// owner: shared (reviews: platform owner). The `identity` schema holds what +// persona AND cards both reference, and nothing else. Today the shared +// identity is Supabase's own auth.users (both products' user ids point at +// it), so this schema starts empty. It is where the Zynd Account layer goes +// (Stage 2: profiles / zynd_uid). Changes here are rare and reviewed by both +// products. +export const identity = pgSchema('identity'); diff --git a/packages/db/introspection/README.md b/packages/db/introspection/README.md new file mode 100644 index 0000000..8b06465 --- /dev/null +++ b/packages/db/introspection/README.md @@ -0,0 +1,9 @@ +# Catalog exports of prod + +Evidence that the migrations match the real database. Each file is the +single-cell output of `../scripts/catalog.sql`, run in the Supabase SQL +editor (read-only) and saved as-is: raw text, or the editor's JSON/CSV +export. `npm run db:drift` accepts all three. + +Name files `-.txt`, e.g. `aafo-2026-09-26.txt`. +Schema only: no row data, no secrets. diff --git a/packages/db/lib/config.ts b/packages/db/lib/config.ts new file mode 100644 index 0000000..4379193 --- /dev/null +++ b/packages/db/lib/config.ts @@ -0,0 +1,23 @@ +import 'dotenv/config'; +import { defineConfig } from 'drizzle-kit'; + +/** + * drizzle-kit config for one migration history. Each history owns exactly the + * Postgres schemas it lists and records its own progress in its own table + * (drizzle.___migrations), so persona, cards and identity migrate + * independently. Run drizzle-kit from packages/db with + * `--config /drizzle.config.ts` (the npm scripts do this). + */ +export const historyConfig = (name: 'identity' | 'persona' | 'cards', ownedSchemas: string[]) => + defineConfig({ + dialect: 'postgresql', + schema: `./${name}/schema/index.ts`, + out: `./${name}/migrations`, + schemaFilter: ownedSchemas, + // anon / authenticated / service_role exist on Supabase already; never create them. + entities: { roles: { provider: 'supabase' } }, + migrations: { schema: 'drizzle', table: `__${name}_migrations` }, + dbCredentials: { url: process.env.DATABASE_URL ?? '' }, + strict: true, + verbose: true, + }); diff --git a/packages/db/lib/shared.ts b/packages/db/lib/shared.ts new file mode 100644 index 0000000..da65ceb --- /dev/null +++ b/packages/db/lib/shared.ts @@ -0,0 +1,68 @@ +import { sql, type SQL } from 'drizzle-orm'; +import { customType, foreignKey, pgPolicy, timestamp, type PgColumn } from 'drizzle-orm/pg-core'; + +export { authUsers, serviceRole } from 'drizzle-orm/supabase'; + +/** Postgres `tsvector`. Drizzle has no built-in type for it. */ +export const tsvector = customType<{ data: string }>({ + dataType() { + return 'tsvector'; + }, +}); + +/** `timestamp with time zone`, the only timestamp type this database uses. */ +export const tstz = (name: string) => timestamp(name, { withTimezone: true }); + +/** jsonb literal default, e.g. jsonbDefault('[]') → DEFAULT '[]'::jsonb */ +export const jsonbDefault = (literal: '[]' | '{}') => sql.raw(`'${literal}'::jsonb`); + +/** + * A named FK. Constraint names must match prod (`
__fkey`, + * the Postgres default), not Drizzle's own naming, or future diffs would try + * to rename every FK. + */ +export const fk = ( + name: string, + column: PgColumn, + references: PgColumn, + onDelete?: 'cascade' | 'set null', +) => { + const constraint = foreignKey({ name, columns: [column], foreignColumns: [references] }); + return onDelete ? constraint.onDelete(onDelete) : constraint; +}; + +const isServiceRole = sql`(auth.role() = 'service_role'::text)`; + +/** + * The "service role full access" policy every persona table carries. It is + * redundant (service_role bypasses RLS) but exists in prod, so the schema + * mirrors it exactly: capitalised name on the older tables, lower-case on + * the persona_group_* ones, and WITH CHECK only where prod has it. + */ +export const serviceRolePolicy = ( + table: string, + opts: { withCheck?: boolean; lowerCase?: boolean } = {}, +) => + pgPolicy(`${opts.lowerCase ? 'service' : 'Service'} role full access on ${table}`, { + as: 'permissive', + for: 'all', + to: 'public', + using: isServiceRole, + ...(opts.withCheck ? { withCheck: isServiceRole } : {}), + }); + +/** A policy for role `public`, the way every persona policy is declared. */ +export const publicPolicy = ( + name: string, + opts: { for: 'select' | 'insert' | 'update' | 'delete' | 'all'; using?: SQL; withCheck?: SQL }, +) => + pgPolicy(name, { + as: 'permissive', + for: opts.for, + to: 'public', + ...(opts.using ? { using: opts.using } : {}), + ...(opts.withCheck ? { withCheck: opts.withCheck } : {}), + }); + +/** `auth.uid() = ` — the owner check most persona policies use. */ +export const isOwner = (column: string) => sql.raw(`(auth.uid() = ${column})`); diff --git a/packages/db/package-lock.json b/packages/db/package-lock.json new file mode 100644 index 0000000..30c8616 --- /dev/null +++ b/packages/db/package-lock.json @@ -0,0 +1,1883 @@ +{ + "name": "@zynd/db", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@zynd/db", + "version": "0.0.0", + "devDependencies": { + "@types/node": "22.20.4", + "@types/pg": "8.23.1", + "dotenv": "18.0.4", + "drizzle-kit": "0.31.11", + "drizzle-orm": "0.45.3", + "pg": "8.23.0", + "tsx": "4.23.15", + "typescript": "5.9.3" + } + }, + "node_modules/@drizzle-team/brocli": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@drizzle-team/brocli/-/brocli-0.10.2.tgz", + "integrity": "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/@esbuild-kit/core-utils": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/@esbuild-kit/core-utils/-/core-utils-3.3.2.tgz", + "integrity": "sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==", + "deprecated": "Merged into tsx: https://tsx.hirok.io", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.18.20", + "source-map-support": "^0.5.21" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/android-arm": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.18.20.tgz", + "integrity": "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/android-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.18.20.tgz", + "integrity": "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/android-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.18.20.tgz", + "integrity": "sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/darwin-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.18.20.tgz", + "integrity": "sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/darwin-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.18.20.tgz", + "integrity": "sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/freebsd-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.18.20.tgz", + "integrity": "sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/freebsd-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.18.20.tgz", + "integrity": "sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-arm": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.18.20.tgz", + "integrity": "sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.18.20.tgz", + "integrity": "sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-ia32": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.18.20.tgz", + "integrity": "sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-loong64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.18.20.tgz", + "integrity": "sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-mips64el": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.18.20.tgz", + "integrity": "sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-ppc64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.18.20.tgz", + "integrity": "sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-riscv64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.18.20.tgz", + "integrity": "sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-s390x": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.18.20.tgz", + "integrity": "sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.18.20.tgz", + "integrity": "sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/netbsd-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.18.20.tgz", + "integrity": "sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/openbsd-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.18.20.tgz", + "integrity": "sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/sunos-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.18.20.tgz", + "integrity": "sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/win32-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.18.20.tgz", + "integrity": "sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/win32-ia32": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.18.20.tgz", + "integrity": "sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/win32-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.18.20.tgz", + "integrity": "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/esbuild": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.18.20.tgz", + "integrity": "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/android-arm": "0.18.20", + "@esbuild/android-arm64": "0.18.20", + "@esbuild/android-x64": "0.18.20", + "@esbuild/darwin-arm64": "0.18.20", + "@esbuild/darwin-x64": "0.18.20", + "@esbuild/freebsd-arm64": "0.18.20", + "@esbuild/freebsd-x64": "0.18.20", + "@esbuild/linux-arm": "0.18.20", + "@esbuild/linux-arm64": "0.18.20", + "@esbuild/linux-ia32": "0.18.20", + "@esbuild/linux-loong64": "0.18.20", + "@esbuild/linux-mips64el": "0.18.20", + "@esbuild/linux-ppc64": "0.18.20", + "@esbuild/linux-riscv64": "0.18.20", + "@esbuild/linux-s390x": "0.18.20", + "@esbuild/linux-x64": "0.18.20", + "@esbuild/netbsd-x64": "0.18.20", + "@esbuild/openbsd-x64": "0.18.20", + "@esbuild/sunos-x64": "0.18.20", + "@esbuild/win32-arm64": "0.18.20", + "@esbuild/win32-ia32": "0.18.20", + "@esbuild/win32-x64": "0.18.20" + } + }, + "node_modules/@esbuild-kit/esm-loader": { + "version": "2.6.5", + "resolved": "https://registry.npmjs.org/@esbuild-kit/esm-loader/-/esm-loader-2.6.5.tgz", + "integrity": "sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA==", + "deprecated": "Merged into tsx: https://tsx.hirok.io", + "dev": true, + "license": "MIT", + "dependencies": { + "@esbuild-kit/core-utils": "^3.3.2", + "get-tsconfig": "^4.7.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@types/node": { + "version": "22.20.4", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.4.tgz", + "integrity": "sha512-zJRE40jpHtKqE/C4fgHrAKQLJuSpzEnP9ff9Y7YtoR3Wd2pwqzlekDeEuUQXjRd+QCYnVnNwuJYmhdk9XV8gvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/pg": { + "version": "8.23.1", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.23.1.tgz", + "integrity": "sha512-fKVHpikPdg4GKks3JuLEhvwSyvwzF23hnabPy6DD8ljVbC7+6J5dQzdv4arV6jqq57djnMgs1HKBxX4P8aBI3A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "pg-protocol": "*", + "pg-types": "^2.2.0" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/dotenv": { + "version": "18.0.4", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-18.0.4.tgz", + "integrity": "sha512-jGt9uI5BIxpVO15kvy+iEFqNAwxYClKWnkptLjSNPnFLC003Imd0ofr3aEVsbpHdSBdSsbcApNIlz4nbyGMwdA==", + "dev": true, + "license": "BSD-2-Clause", + "bin": { + "dotenv": "dist/index.cjs" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/drizzle-kit": { + "version": "0.31.11", + "resolved": "https://registry.npmjs.org/drizzle-kit/-/drizzle-kit-0.31.11.tgz", + "integrity": "sha512-YCYqxTLIB2OCqf6w9/Vef13baBVbwQIPcbT4Y56AfO6B9ajZhDhD8Jkveg/hJvT/iuMloKD/IYYkyMMNhr7Kqg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@drizzle-team/brocli": "^0.10.2", + "@esbuild-kit/esm-loader": "^2.5.5", + "esbuild": "^0.25.4", + "tsx": "^4.21.0" + }, + "bin": { + "drizzle-kit": "bin.cjs" + } + }, + "node_modules/drizzle-orm": { + "version": "0.45.3", + "resolved": "https://registry.npmjs.org/drizzle-orm/-/drizzle-orm-0.45.3.tgz", + "integrity": "sha512-CAloER21cDdcgZ1OmjrZX82EeRNsdP+y0onX/eyoYDaYzki/adrAa91lzM5jWh7zZPK4qUbw/6LGv9J+A+uizA==", + "dev": true, + "license": "Apache-2.0", + "peerDependencies": { + "@aws-sdk/client-rds-data": ">=3", + "@cloudflare/workers-types": ">=4", + "@electric-sql/pglite": ">=0.2.0", + "@libsql/client": ">=0.10.0", + "@libsql/client-wasm": ">=0.10.0", + "@neondatabase/serverless": ">=0.10.0", + "@netlify/db": ">=0.4.0", + "@op-engineering/op-sqlite": ">=2", + "@opentelemetry/api": "^1.4.1", + "@planetscale/database": ">=1.13", + "@prisma/client": "*", + "@tidbcloud/serverless": "*", + "@types/better-sqlite3": "*", + "@types/pg": "*", + "@types/sql.js": "*", + "@upstash/redis": ">=1.34.7", + "@vercel/postgres": ">=0.8.0", + "@xata.io/client": "*", + "better-sqlite3": ">=7", + "bun-types": "*", + "expo-sqlite": ">=14.0.0", + "gel": ">=2", + "knex": "*", + "kysely": "*", + "mysql2": ">=2", + "pg": ">=8", + "postgres": ">=3", + "sql.js": ">=1", + "sqlite3": ">=5" + }, + "peerDependenciesMeta": { + "@aws-sdk/client-rds-data": { + "optional": true + }, + "@cloudflare/workers-types": { + "optional": true + }, + "@electric-sql/pglite": { + "optional": true + }, + "@libsql/client": { + "optional": true + }, + "@libsql/client-wasm": { + "optional": true + }, + "@neondatabase/serverless": { + "optional": true + }, + "@netlify/db": { + "optional": true + }, + "@op-engineering/op-sqlite": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@planetscale/database": { + "optional": true + }, + "@prisma/client": { + "optional": true + }, + "@tidbcloud/serverless": { + "optional": true + }, + "@types/better-sqlite3": { + "optional": true + }, + "@types/pg": { + "optional": true + }, + "@types/sql.js": { + "optional": true + }, + "@upstash/redis": { + "optional": true + }, + "@vercel/postgres": { + "optional": true + }, + "@xata.io/client": { + "optional": true + }, + "better-sqlite3": { + "optional": true + }, + "bun-types": { + "optional": true + }, + "expo-sqlite": { + "optional": true + }, + "gel": { + "optional": true + }, + "knex": { + "optional": true + }, + "kysely": { + "optional": true + }, + "mysql2": { + "optional": true + }, + "pg": { + "optional": true + }, + "postgres": { + "optional": true + }, + "prisma": { + "optional": true + }, + "sql.js": { + "optional": true + }, + "sqlite3": { + "optional": true + } + } + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/get-tsconfig": { + "version": "4.14.3", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.3.tgz", + "integrity": "sha512-++QEw4DIY7WGoukz+/+A/8dGYPT9l9yIadnmSgZ8Rjr3YVSVDipQSO9CdnJo9ePqFqUUqh+wk9uIaoiAwsiPkA==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/pg": { + "version": "8.23.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.23.0.tgz", + "integrity": "sha512-Ip2EQCngowJLGOfCwkFhPXU7/ljlhn6Rxlmy4XYfL2Y+vyRM59+8uR2xqRWKdYmbXmxCFOAmKxBuSUCdF34qLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.14.0", + "pg-pool": "^3.14.0", + "pg-protocol": "^1.16.0", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.4.0" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz", + "integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.14.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz", + "integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz", + "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.16.0.tgz", + "integrity": "sha512-sILXutLVjCLjcDuOmvhX5e2Z4cS5qG/6Bu3VkpFwdf/633ElGLpEh9bgmuI5I4sqKqkifQiGyiCcx1HdtrK7tg==", + "dev": true, + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "dev": true, + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/tsx": { + "version": "4.23.15", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.15.tgz", + "integrity": "sha512-Yiex1Ovn8z2xPpOWckIiysV1SSyRMY9BkLF++q0yKiDxCqRhosKfMg3janKkiLBwZ5c/YryloKwGZcrEmtwxKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/tsx/node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4" + } + } + } +} diff --git a/packages/db/package.json b/packages/db/package.json new file mode 100644 index 0000000..f9378f1 --- /dev/null +++ b/packages/db/package.json @@ -0,0 +1,31 @@ +{ + "name": "@zynd/db", + "version": "0.0.0", + "private": true, + "description": "Migrations for the shared aafo database: three independent histories (identity, persona, cards), one per Postgres schema. See README.md.", + "type": "module", + "scripts": { + "persona:generate": "drizzle-kit generate --config persona/drizzle.config.ts", + "persona:generate:custom": "drizzle-kit generate --config persona/drizzle.config.ts --custom", + "cards:generate": "drizzle-kit generate --config cards/drizzle.config.ts", + "cards:generate:custom": "drizzle-kit generate --config cards/drizzle.config.ts --custom", + "identity:generate": "drizzle-kit generate --config identity/drizzle.config.ts", + "identity:generate:custom": "drizzle-kit generate --config identity/drizzle.config.ts --custom", + "db:check": "drizzle-kit check --config identity/drizzle.config.ts && drizzle-kit check --config persona/drizzle.config.ts && drizzle-kit check --config cards/drizzle.config.ts", + "db:migrate": "tsx scripts/migrate.ts", + "db:drift": "tsx scripts/check-drift.ts", + "db:baseline-sql": "tsx scripts/baseline-sql.ts", + "db:lint": "bash scripts/lint-migrations.sh", + "typecheck": "tsc --noEmit" + }, + "devDependencies": { + "@types/node": "22.20.4", + "@types/pg": "8.23.1", + "dotenv": "18.0.4", + "drizzle-kit": "0.31.11", + "drizzle-orm": "0.45.3", + "pg": "8.23.0", + "tsx": "4.23.15", + "typescript": "5.9.3" + } +} diff --git a/packages/db/persona/drizzle.config.ts b/packages/db/persona/drizzle.config.ts new file mode 100644 index 0000000..32e6a68 --- /dev/null +++ b/packages/db/persona/drizzle.config.ts @@ -0,0 +1,4 @@ +import { historyConfig } from '../lib/config'; + +// persona owns `public` (its tables have always lived there). +export default historyConfig('persona', ['public']); diff --git a/packages/db/persona/migrations/0000_baseline_persona.sql b/packages/db/persona/migrations/0000_baseline_persona.sql new file mode 100644 index 0000000..ac95ca0 --- /dev/null +++ b/packages/db/persona/migrations/0000_baseline_persona.sql @@ -0,0 +1,730 @@ +-- owner: persona +-- 0000 — BASELINE of the aafo public schema as it was on 2026-09-26. +-- +-- !! NEVER RUN THIS ON PROD. Prod already has all of it; it is recorded as +-- !! applied with `npm run db:baseline-sql` (see README.md). It runs only on +-- !! fresh databases (CI, local scratch) to rebuild the same schema. +-- +-- Tables, constraints, indexes, RLS and policies below are drizzle-kit's +-- output for persona/schema/*.ts. Functions, grants, the trigger and the +-- realtime publication are hand-written (Drizzle doesn't model them), copied +-- from prod's catalog. Proof that this matches prod: `npm run db:drift`. + +CREATE TABLE "persona_agents" ( + "user_id" uuid PRIMARY KEY NOT NULL, + "agent_id" text NOT NULL, + "derivation_index" integer NOT NULL, + "public_key" text NOT NULL, + "name" text NOT NULL, + "agent_handle" text, + "description" text DEFAULT '' NOT NULL, + "capabilities" jsonb DEFAULT '[]'::jsonb, + "profile" jsonb DEFAULT '{}'::jsonb, + "webhook_url" text, + "active" boolean DEFAULT true, + "created_at" timestamp with time zone DEFAULT now(), + "updated_at" timestamp with time zone DEFAULT now(), + "brief_doc_id" text, + "brief_doc_url" text, + "brief_doc_revision_id" text, + "brief_content" text, + "search_vector" "tsvector", + "auto_extract_todos" boolean DEFAULT true NOT NULL, + CONSTRAINT "persona_agents_agent_id_key" UNIQUE("agent_id"), + CONSTRAINT "persona_agents_derivation_index_key" UNIQUE("derivation_index") +); +--> statement-breakpoint +ALTER TABLE "persona_agents" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint +CREATE TABLE "callback_results" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "callback_id" uuid NOT NULL, + "user_id" uuid NOT NULL, + "thread_id" uuid NOT NULL, + "peer_agent_id" text NOT NULL, + "task_state" text NOT NULL, + "reply_text" text, + "raw_event" jsonb NOT NULL, + "delivered_to_ui" boolean DEFAULT false NOT NULL, + "created_at" timestamp with time zone DEFAULT now(), + CONSTRAINT "callback_results_callback_id_key" UNIQUE("callback_id") +); +--> statement-breakpoint +ALTER TABLE "callback_results" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint +CREATE TABLE "outbound_callbacks" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "user_id" uuid NOT NULL, + "thread_id" uuid NOT NULL, + "peer_agent_id" text NOT NULL, + "peer_task_id" text, + "our_message_id" text NOT NULL, + "origin_kind" text NOT NULL, + "origin_ref" jsonb DEFAULT '{}'::jsonb NOT NULL, + "push_token" text NOT NULL, + "status" text DEFAULT 'pending' NOT NULL, + "expires_at" timestamp with time zone DEFAULT (now() + '24:00:00'::interval) NOT NULL, + "created_at" timestamp with time zone DEFAULT now(), + "updated_at" timestamp with time zone DEFAULT now(), + "peer_a2a_url" text, + "last_state" text, + "last_event" jsonb, + "last_event_at" timestamp with time zone, + "answer_text" text, + "terminal_state" text, + CONSTRAINT "outbound_callbacks_push_token_key" UNIQUE("push_token"), + CONSTRAINT "outbound_callbacks_status_check" CHECK (status IN ('pending', 'received', 'expired', 'failed')) +); +--> statement-breakpoint +ALTER TABLE "outbound_callbacks" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint +CREATE TABLE "enriched_companies" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "user_id" uuid NOT NULL, + "cache_key" text NOT NULL, + "company_name" text, + "company_url" text, + "linkedin_url" text, + "industry" text, + "employee_count" text, + "revenue" text, + "city" text, + "region_code" text, + "country_code" text, + "data" jsonb DEFAULT '{}'::jsonb, + "source" text, + "created_at" timestamp with time zone DEFAULT now(), + "updated_at" timestamp with time zone DEFAULT now(), + CONSTRAINT "enriched_companies_user_id_cache_key_key" UNIQUE("user_id","cache_key") +); +--> statement-breakpoint +ALTER TABLE "enriched_companies" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint +CREATE TABLE "enriched_contacts" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "user_id" uuid NOT NULL, + "cache_key" text NOT NULL, + "employee_linkedin" text, + "email" text, + "first_name" text, + "last_name" text, + "title" text, + "company_name" text, + "company_url" text, + "phone" text, + "phone_type" text, + "has_email" boolean DEFAULT false, + "has_phone" boolean DEFAULT false, + "data" jsonb DEFAULT '{}'::jsonb, + "source" text, + "enriched_at" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT now(), + "updated_at" timestamp with time zone DEFAULT now(), + CONSTRAINT "enriched_contacts_user_id_cache_key_key" UNIQUE("user_id","cache_key") +); +--> statement-breakpoint +ALTER TABLE "enriched_contacts" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint +CREATE TABLE "suggested_contact_runs" ( + "user_id" uuid PRIMARY KEY NOT NULL, + "last_run_at" timestamp with time zone, + "last_manual_at" timestamp with time zone, + "status" text, + "detail" text, + "updated_at" timestamp with time zone DEFAULT now() +); +--> statement-breakpoint +ALTER TABLE "suggested_contact_runs" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint +CREATE TABLE "suggested_contacts" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "user_id" uuid NOT NULL, + "cache_key" text NOT NULL, + "recipe" text NOT NULL, + "reason" text, + "score" real DEFAULT 0, + "rank" integer DEFAULT 0, + "generated_at" timestamp with time zone DEFAULT now(), + "created_at" timestamp with time zone DEFAULT now(), + "updated_at" timestamp with time zone DEFAULT now(), + CONSTRAINT "suggested_contacts_user_id_cache_key_recipe_key" UNIQUE("user_id","cache_key","recipe") +); +--> statement-breakpoint +ALTER TABLE "suggested_contacts" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint +CREATE TABLE "brief_todos" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "user_id" uuid NOT NULL, + "title" text NOT NULL, + "source_text" text, + "done" boolean DEFAULT false NOT NULL, + "done_at" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "group_id" uuid, + "assigned_by_user_id" uuid +); +--> statement-breakpoint +ALTER TABLE "brief_todos" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint +CREATE TABLE "chat_messages" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "user_id" uuid NOT NULL, + "conversation_id" text NOT NULL, + "role" text NOT NULL, + "content" text NOT NULL, + "actions" jsonb DEFAULT '[]'::jsonb, + "created_at" timestamp with time zone DEFAULT now(), + "action_summary" jsonb DEFAULT '[]'::jsonb +); +--> statement-breakpoint +ALTER TABLE "chat_messages" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint +CREATE TABLE "published_pages" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "user_id" uuid NOT NULL, + "slug" text NOT NULL, + "title" text NOT NULL, + "format" text NOT NULL, + "content" text NOT NULL, + "visibility" text DEFAULT 'unlisted' NOT NULL, + "created_at" timestamp with time zone DEFAULT now(), + "updated_at" timestamp with time zone DEFAULT now(), + "expires_at" timestamp with time zone, + CONSTRAINT "published_pages_slug_key" UNIQUE("slug"), + CONSTRAINT "published_pages_format_check" CHECK (format IN ('html', 'markdown')), + CONSTRAINT "published_pages_visibility_check" CHECK (visibility IN ('public', 'unlisted', 'private')) +); +--> statement-breakpoint +ALTER TABLE "published_pages" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint +CREATE TABLE "a2a_tasks" ( + "task_id" uuid PRIMARY KEY NOT NULL, + "context_id" uuid NOT NULL, + "state" text DEFAULT 'submitted' NOT NULL, + "permission_snapshot" jsonb DEFAULT '{}'::jsonb NOT NULL, + "history" jsonb DEFAULT '[]'::jsonb NOT NULL, + "artifacts" jsonb DEFAULT '[]'::jsonb NOT NULL, + "push_url" text, + "push_token" text, + "last_message_id" text, + "idle_ttl_ms" bigint DEFAULT 3600000 NOT NULL, + "idle_until" timestamp with time zone, + "terminal_at" timestamp with time zone, + "failure_reason" text, + "created_at" timestamp with time zone DEFAULT now(), + "updated_at" timestamp with time zone DEFAULT now(), + CONSTRAINT "a2a_tasks_state_check" CHECK (state IN ('submitted', 'working', 'input-required', 'auth-required', 'completed', 'canceled', 'failed', 'rejected')) +); +--> statement-breakpoint +ALTER TABLE "a2a_tasks" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint +CREATE TABLE "agent_tasks" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "thread_id" uuid NOT NULL, + "type" text DEFAULT 'meeting' NOT NULL, + "status" text DEFAULT 'proposed' NOT NULL, + "initiator_user_id" uuid NOT NULL, + "recipient_user_id" uuid NOT NULL, + "initiator_agent_id" text NOT NULL, + "recipient_agent_id" text NOT NULL, + "payload" jsonb DEFAULT '{}'::jsonb NOT NULL, + "history" jsonb DEFAULT '[]'::jsonb NOT NULL, + "calendar_event_ids" jsonb DEFAULT '{}'::jsonb NOT NULL, + "created_at" timestamp with time zone DEFAULT now(), + "updated_at" timestamp with time zone DEFAULT now(), + CONSTRAINT "agent_tasks_type_check" CHECK (type = 'meeting'), + CONSTRAINT "agent_tasks_status_check" CHECK (status IN ('proposed', 'countered', 'accepted', 'scheduled', 'declined', 'cancelled', 'book_failed')) +); +--> statement-breakpoint +ALTER TABLE "agent_tasks" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint +CREATE TABLE "dm_messages" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "thread_id" uuid NOT NULL, + "sender_id" text NOT NULL, + "sender_type" text DEFAULT 'human' NOT NULL, + "channel" text DEFAULT 'human' NOT NULL, + "content" text NOT NULL, + "created_at" timestamp with time zone DEFAULT now(), + CONSTRAINT "dm_messages_sender_type_check" CHECK (sender_type IN ('human', 'agent', 'system')), + CONSTRAINT "dm_messages_channel_check" CHECK (channel IN ('human', 'agent')) +); +--> statement-breakpoint +ALTER TABLE "dm_messages" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint +CREATE TABLE "dm_threads" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "initiator_id" text NOT NULL, + "receiver_id" text NOT NULL, + "initiator_name" text DEFAULT '', + "receiver_name" text DEFAULT '', + "status" text DEFAULT 'pending' NOT NULL, + "lifecycle" text DEFAULT 'pending' NOT NULL, + "initiator_mode" text DEFAULT 'agent' NOT NULL, + "receiver_mode" text DEFAULT 'agent' NOT NULL, + "permissions" jsonb DEFAULT jsonb_build_object('can_request_meetings', true, 'can_query_availability', false, 'can_view_full_profile', false, 'can_post_on_my_behalf', false) NOT NULL, + "created_at" timestamp with time zone DEFAULT now(), + "updated_at" timestamp with time zone DEFAULT now(), + CONSTRAINT "dm_threads_initiator_id_receiver_id_key" UNIQUE("initiator_id","receiver_id"), + CONSTRAINT "dm_threads_status_check" CHECK (status IN ('pending', 'accepted', 'declined', 'blocked', 'revoked')), + CONSTRAINT "dm_threads_initiator_mode_check" CHECK (initiator_mode IN ('human', 'agent')), + CONSTRAINT "dm_threads_receiver_mode_check" CHECK (receiver_mode IN ('human', 'agent')) +); +--> statement-breakpoint +ALTER TABLE "dm_threads" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint +CREATE TABLE "pending_approvals" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "user_id" uuid NOT NULL, + "thread_id" uuid, + "tool_name" text NOT NULL, + "tool_args" jsonb DEFAULT '{}'::jsonb NOT NULL, + "summary" text, + "status" text DEFAULT 'pending' NOT NULL, + "result" jsonb, + "created_at" timestamp with time zone DEFAULT now(), + "decided_at" timestamp with time zone, + "expires_at" timestamp with time zone DEFAULT (now() + '24:00:00'::interval), + CONSTRAINT "pending_approvals_status_check" CHECK (status IN ('pending', 'approved', 'declined', 'expired')) +); +--> statement-breakpoint +ALTER TABLE "pending_approvals" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint +CREATE TABLE "persona_group_audit_events" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "group_id" uuid NOT NULL, + "affected_user_id" uuid NOT NULL, + "actor_user_id" uuid, + "kind" text NOT NULL, + "metadata" jsonb, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "persona_group_audit_events_kind_check" CHECK (kind IN ('brief_shared', 'calendar_queried')) +); +--> statement-breakpoint +ALTER TABLE "persona_group_audit_events" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint +CREATE TABLE "persona_group_constraints" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "group_id" uuid NOT NULL, + "kind" text NOT NULL, + "text" text NOT NULL, + "created_by_user_id" uuid, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "archived_at" timestamp with time zone, + CONSTRAINT "persona_group_constraints_kind_check" CHECK (kind IN ('fact', 'rule', 'voice')), + CONSTRAINT "persona_group_constraints_text_check" CHECK ((length(text) >= 1) AND (length(text) <= 400)) +); +--> statement-breakpoint +ALTER TABLE "persona_group_constraints" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint +CREATE TABLE "persona_group_invitations" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "group_id" uuid NOT NULL, + "invitee_user_id" uuid NOT NULL, + "inviter_user_id" uuid, + "invitee_role" text DEFAULT 'member' NOT NULL, + "status" text DEFAULT 'pending' NOT NULL, + "message" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "decided_at" timestamp with time zone, + "expires_at" timestamp with time zone DEFAULT (now() + '7 days'::interval) NOT NULL, + CONSTRAINT "persona_group_invitations_invitee_role_check" CHECK (invitee_role IN ('admin', 'member')), + CONSTRAINT "persona_group_invitations_status_check" CHECK (status IN ('pending', 'accepted', 'declined', 'revoked', 'expired')) +); +--> statement-breakpoint +ALTER TABLE "persona_group_invitations" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint +CREATE TABLE "persona_group_members" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "group_id" uuid NOT NULL, + "user_id" uuid NOT NULL, + "agent_id" text, + "role" text DEFAULT 'member' NOT NULL, + "permissions" jsonb DEFAULT jsonb_build_object('can_see_brief', false, 'can_see_member_briefs', false, 'can_see_group_brief', true, 'can_query_calendar', false, 'can_post', true, 'can_invite', false, 'can_speak_for_group', false) NOT NULL, + "invited_by" uuid, + "joined_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "persona_group_members_group_id_user_id_key" UNIQUE("group_id","user_id"), + CONSTRAINT "persona_group_members_role_check" CHECK (role IN ('owner', 'admin', 'member')) +); +--> statement-breakpoint +ALTER TABLE "persona_group_members" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint +CREATE TABLE "persona_group_messages" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "group_id" uuid NOT NULL, + "sender_user_id" uuid, + "sender_agent_id" text, + "sender_name" text, + "channel" text DEFAULT 'human' NOT NULL, + "content" text NOT NULL, + "reply_to" uuid, + "metadata" jsonb, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "persona_group_messages_channel_check" CHECK (channel IN ('human', 'agent', 'system', 'broadcast')) +); +--> statement-breakpoint +ALTER TABLE "persona_group_messages" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint +CREATE TABLE "persona_groups" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "slug" text NOT NULL, + "name" text NOT NULL, + "description" text, + "avatar_url" text, + "owner_user_id" uuid NOT NULL, + "visibility" text DEFAULT 'private' NOT NULL, + "invite_token" text, + "group_seed_index" integer DEFAULT 0 NOT NULL, + "archived_at" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + "brief_doc_id" text, + "brief_doc_url" text, + "join_domain" text, + CONSTRAINT "persona_groups_slug_key" UNIQUE("slug"), + CONSTRAINT "persona_groups_invite_token_key" UNIQUE("invite_token"), + CONSTRAINT "persona_groups_visibility_check" CHECK (visibility IN ('private', 'open')) +); +--> statement-breakpoint +ALTER TABLE "persona_groups" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint +CREATE TABLE "api_tokens" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "user_id" uuid NOT NULL, + "provider" text NOT NULL, + "access_token" text NOT NULL, + "refresh_token" text, + "expires_at" timestamp with time zone, + "scopes" text, + "raw_data" jsonb DEFAULT '{}'::jsonb, + "created_at" timestamp with time zone DEFAULT now(), + "updated_at" timestamp with time zone DEFAULT now(), + CONSTRAINT "api_tokens_user_id_provider_key" UNIQUE("user_id","provider") +); +--> statement-breakpoint +ALTER TABLE "api_tokens" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint +CREATE TABLE "github_profiles" ( + "user_id" uuid PRIMARY KEY NOT NULL, + "username" text, + "raw_repos" jsonb DEFAULT '[]'::jsonb NOT NULL, + "skills" jsonb DEFAULT '[]'::jsonb NOT NULL, + "projects" jsonb DEFAULT '[]'::jsonb NOT NULL, + "synced_at" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT now(), + "updated_at" timestamp with time zone DEFAULT now() +); +--> statement-breakpoint +ALTER TABLE "github_profiles" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint +CREATE TABLE "linkedin_profiles" ( + "user_id" uuid PRIMARY KEY NOT NULL, + "profile_url" text, + "scraped_at" timestamp with time zone, + "raw_profile" jsonb DEFAULT '{}'::jsonb NOT NULL, + "raw_posts" jsonb DEFAULT '[]'::jsonb NOT NULL, + "created_at" timestamp with time zone DEFAULT now(), + "updated_at" timestamp with time zone DEFAULT now() +); +--> statement-breakpoint +ALTER TABLE "linkedin_profiles" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint +CREATE TABLE "oauth_pending_state" ( + "state" text PRIMARY KEY NOT NULL, + "user_id" uuid NOT NULL, + "provider" text NOT NULL, + "code_verifier" text, + "created_at" timestamp with time zone DEFAULT now(), + "expires_at" timestamp with time zone DEFAULT (now() + '00:15:00'::interval) +); +--> statement-breakpoint +ALTER TABLE "oauth_pending_state" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint +CREATE TABLE "telegram_chat_history" ( + "conversation_id" text PRIMARY KEY NOT NULL, + "user_id" uuid NOT NULL, + "messages" jsonb DEFAULT '[]'::jsonb NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() +); +--> statement-breakpoint +ALTER TABLE "telegram_chat_history" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint +CREATE TABLE "telegram_links" ( + "user_id" uuid PRIMARY KEY NOT NULL, + "chat_id" text NOT NULL, + "linked_at" timestamp with time zone DEFAULT now(), + CONSTRAINT "telegram_links_chat_id_key" UNIQUE("chat_id") +); +--> statement-breakpoint +ALTER TABLE "telegram_links" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint +CREATE TABLE "twitter_profiles" ( + "user_id" uuid PRIMARY KEY NOT NULL, + "handle" text, + "scraped_at" timestamp with time zone, + "raw_tweets" jsonb DEFAULT '[]'::jsonb NOT NULL, + "facts" jsonb DEFAULT '[]'::jsonb NOT NULL, + "created_at" timestamp with time zone DEFAULT now(), + "updated_at" timestamp with time zone DEFAULT now() +); +--> statement-breakpoint +ALTER TABLE "twitter_profiles" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint +ALTER TABLE "persona_agents" ADD CONSTRAINT "persona_agents_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "auth"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "callback_results" ADD CONSTRAINT "callback_results_callback_id_fkey" FOREIGN KEY ("callback_id") REFERENCES "public"."outbound_callbacks"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "callback_results" ADD CONSTRAINT "callback_results_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "auth"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "outbound_callbacks" ADD CONSTRAINT "outbound_callbacks_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "auth"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "enriched_companies" ADD CONSTRAINT "enriched_companies_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "auth"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "enriched_contacts" ADD CONSTRAINT "enriched_contacts_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "auth"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "suggested_contact_runs" ADD CONSTRAINT "suggested_contact_runs_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "auth"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "suggested_contacts" ADD CONSTRAINT "suggested_contacts_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "auth"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "brief_todos" ADD CONSTRAINT "brief_todos_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "auth"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "brief_todos" ADD CONSTRAINT "brief_todos_group_id_fkey" FOREIGN KEY ("group_id") REFERENCES "public"."persona_groups"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "brief_todos" ADD CONSTRAINT "brief_todos_assigned_by_user_id_fkey" FOREIGN KEY ("assigned_by_user_id") REFERENCES "auth"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "chat_messages" ADD CONSTRAINT "chat_messages_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "auth"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "a2a_tasks" ADD CONSTRAINT "a2a_tasks_context_id_fkey" FOREIGN KEY ("context_id") REFERENCES "public"."dm_threads"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "agent_tasks" ADD CONSTRAINT "agent_tasks_thread_id_fkey" FOREIGN KEY ("thread_id") REFERENCES "public"."dm_threads"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "agent_tasks" ADD CONSTRAINT "agent_tasks_initiator_user_id_fkey" FOREIGN KEY ("initiator_user_id") REFERENCES "auth"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "agent_tasks" ADD CONSTRAINT "agent_tasks_recipient_user_id_fkey" FOREIGN KEY ("recipient_user_id") REFERENCES "auth"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "dm_messages" ADD CONSTRAINT "dm_messages_thread_id_fkey" FOREIGN KEY ("thread_id") REFERENCES "public"."dm_threads"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "pending_approvals" ADD CONSTRAINT "pending_approvals_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "auth"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "pending_approvals" ADD CONSTRAINT "pending_approvals_thread_id_fkey" FOREIGN KEY ("thread_id") REFERENCES "public"."dm_threads"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "persona_group_audit_events" ADD CONSTRAINT "persona_group_audit_events_group_id_fkey" FOREIGN KEY ("group_id") REFERENCES "public"."persona_groups"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "persona_group_audit_events" ADD CONSTRAINT "persona_group_audit_events_affected_user_id_fkey" FOREIGN KEY ("affected_user_id") REFERENCES "auth"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "persona_group_audit_events" ADD CONSTRAINT "persona_group_audit_events_actor_user_id_fkey" FOREIGN KEY ("actor_user_id") REFERENCES "auth"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "persona_group_constraints" ADD CONSTRAINT "persona_group_constraints_group_id_fkey" FOREIGN KEY ("group_id") REFERENCES "public"."persona_groups"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "persona_group_constraints" ADD CONSTRAINT "persona_group_constraints_created_by_user_id_fkey" FOREIGN KEY ("created_by_user_id") REFERENCES "auth"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "persona_group_invitations" ADD CONSTRAINT "persona_group_invitations_group_id_fkey" FOREIGN KEY ("group_id") REFERENCES "public"."persona_groups"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "persona_group_invitations" ADD CONSTRAINT "persona_group_invitations_invitee_user_id_fkey" FOREIGN KEY ("invitee_user_id") REFERENCES "auth"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "persona_group_invitations" ADD CONSTRAINT "persona_group_invitations_inviter_user_id_fkey" FOREIGN KEY ("inviter_user_id") REFERENCES "auth"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "persona_group_members" ADD CONSTRAINT "persona_group_members_group_id_fkey" FOREIGN KEY ("group_id") REFERENCES "public"."persona_groups"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "persona_group_members" ADD CONSTRAINT "persona_group_members_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "auth"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "persona_group_members" ADD CONSTRAINT "persona_group_members_invited_by_fkey" FOREIGN KEY ("invited_by") REFERENCES "auth"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "persona_group_messages" ADD CONSTRAINT "persona_group_messages_group_id_fkey" FOREIGN KEY ("group_id") REFERENCES "public"."persona_groups"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "persona_group_messages" ADD CONSTRAINT "persona_group_messages_sender_user_id_fkey" FOREIGN KEY ("sender_user_id") REFERENCES "auth"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "persona_group_messages" ADD CONSTRAINT "persona_group_messages_reply_to_fkey" FOREIGN KEY ("reply_to") REFERENCES "public"."persona_group_messages"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "persona_groups" ADD CONSTRAINT "persona_groups_owner_user_id_fkey" FOREIGN KEY ("owner_user_id") REFERENCES "auth"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "api_tokens" ADD CONSTRAINT "api_tokens_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "auth"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "github_profiles" ADD CONSTRAINT "github_profiles_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "auth"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "linkedin_profiles" ADD CONSTRAINT "linkedin_profiles_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "auth"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "oauth_pending_state" ADD CONSTRAINT "oauth_pending_state_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "auth"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "telegram_chat_history" ADD CONSTRAINT "telegram_chat_history_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "auth"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "telegram_links" ADD CONSTRAINT "telegram_links_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "auth"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "twitter_profiles" ADD CONSTRAINT "twitter_profiles_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "auth"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "persona_agents_search_vector_idx" ON "persona_agents" USING gin ("search_vector");--> statement-breakpoint +CREATE INDEX "callback_results_thread_idx" ON "callback_results" USING btree ("thread_id","created_at" DESC NULLS FIRST);--> statement-breakpoint +CREATE INDEX "callback_results_user_idx" ON "callback_results" USING btree ("user_id","delivered_to_ui","created_at" DESC NULLS FIRST);--> statement-breakpoint +CREATE INDEX "outbound_callbacks_expires_idx" ON "outbound_callbacks" USING btree ("expires_at") WHERE status = 'pending';--> statement-breakpoint +CREATE INDEX "outbound_callbacks_peer_task_idx" ON "outbound_callbacks" USING btree ("peer_agent_id","peer_task_id") WHERE peer_task_id IS NOT NULL;--> statement-breakpoint +CREATE INDEX "outbound_callbacks_user_idx" ON "outbound_callbacks" USING btree ("user_id","status","created_at" DESC NULLS FIRST);--> statement-breakpoint +CREATE INDEX "enriched_companies_user_created_idx" ON "enriched_companies" USING btree ("user_id","created_at" DESC NULLS FIRST);--> statement-breakpoint +CREATE INDEX "enriched_contacts_user_created_idx" ON "enriched_contacts" USING btree ("user_id","created_at" DESC NULLS FIRST);--> statement-breakpoint +CREATE INDEX "enriched_contacts_user_email_idx" ON "enriched_contacts" USING btree ("user_id","email");--> statement-breakpoint +CREATE INDEX "enriched_contacts_user_linkedin_idx" ON "enriched_contacts" USING btree ("user_id","employee_linkedin");--> statement-breakpoint +CREATE INDEX "suggested_contacts_user_rank_idx" ON "suggested_contacts" USING btree ("user_id","recipe","rank");--> statement-breakpoint +CREATE INDEX "brief_todos_group_idx" ON "brief_todos" USING btree ("group_id");--> statement-breakpoint +CREATE INDEX "brief_todos_user_idx" ON "brief_todos" USING btree ("user_id","done","created_at" DESC NULLS FIRST);--> statement-breakpoint +CREATE UNIQUE INDEX "brief_todos_user_title_uniq" ON "brief_todos" USING btree ("user_id","title") WHERE done = false;--> statement-breakpoint +CREATE INDEX "published_pages_expires_idx" ON "published_pages" USING btree ("expires_at") WHERE expires_at IS NOT NULL;--> statement-breakpoint +CREATE INDEX "published_pages_slug_idx" ON "published_pages" USING btree ("slug");--> statement-breakpoint +CREATE INDEX "published_pages_user_idx" ON "published_pages" USING btree ("user_id","created_at" DESC NULLS FIRST);--> statement-breakpoint +CREATE INDEX "a2a_tasks_context_idx" ON "a2a_tasks" USING btree ("context_id","updated_at" DESC NULLS FIRST);--> statement-breakpoint +CREATE INDEX "a2a_tasks_state_idle_idx" ON "a2a_tasks" USING btree ("state","idle_until") WHERE state IN ('input-required', 'auth-required');--> statement-breakpoint +CREATE INDEX "agent_tasks_initiator_idx" ON "agent_tasks" USING btree ("initiator_user_id");--> statement-breakpoint +CREATE INDEX "agent_tasks_recipient_idx" ON "agent_tasks" USING btree ("recipient_user_id");--> statement-breakpoint +CREATE INDEX "agent_tasks_status_idx" ON "agent_tasks" USING btree ("status");--> statement-breakpoint +CREATE INDEX "agent_tasks_thread_idx" ON "agent_tasks" USING btree ("thread_id");--> statement-breakpoint +CREATE UNIQUE INDEX "agent_tasks_one_open_proposal" ON "agent_tasks" USING btree ("thread_id") WHERE status IN ('proposed', 'countered', 'accepted');--> statement-breakpoint +CREATE INDEX "dm_threads_lifecycle_idx" ON "dm_threads" USING btree ("lifecycle");--> statement-breakpoint +CREATE INDEX "pending_approvals_thread_idx" ON "pending_approvals" USING btree ("thread_id");--> statement-breakpoint +CREATE INDEX "pending_approvals_user_status_idx" ON "pending_approvals" USING btree ("user_id","status");--> statement-breakpoint +CREATE INDEX "persona_group_audit_events_affected_idx" ON "persona_group_audit_events" USING btree ("affected_user_id","created_at" DESC NULLS FIRST);--> statement-breakpoint +CREATE INDEX "persona_group_audit_events_group_idx" ON "persona_group_audit_events" USING btree ("group_id","created_at" DESC NULLS FIRST);--> statement-breakpoint +CREATE INDEX "persona_group_constraints_group_idx" ON "persona_group_constraints" USING btree ("group_id","archived_at" NULLS FIRST,"created_at" DESC NULLS FIRST);--> statement-breakpoint +CREATE INDEX "persona_group_invitations_group_status_idx" ON "persona_group_invitations" USING btree ("group_id","status");--> statement-breakpoint +CREATE INDEX "persona_group_invitations_invitee_status_idx" ON "persona_group_invitations" USING btree ("invitee_user_id","status");--> statement-breakpoint +CREATE UNIQUE INDEX "persona_group_invitations_open_uniq" ON "persona_group_invitations" USING btree ("group_id","invitee_user_id") WHERE status = 'pending';--> statement-breakpoint +CREATE INDEX "persona_group_members_agent_idx" ON "persona_group_members" USING btree ("agent_id");--> statement-breakpoint +CREATE INDEX "persona_group_members_user_idx" ON "persona_group_members" USING btree ("user_id");--> statement-breakpoint +CREATE INDEX "persona_group_messages_group_idx" ON "persona_group_messages" USING btree ("group_id","created_at" DESC NULLS FIRST);--> statement-breakpoint +CREATE INDEX "persona_groups_join_domain_idx" ON "persona_groups" USING btree ("join_domain") WHERE (join_domain IS NOT NULL) AND (archived_at IS NULL);--> statement-breakpoint +CREATE INDEX "persona_groups_owner_idx" ON "persona_groups" USING btree ("owner_user_id");--> statement-breakpoint +CREATE INDEX "github_profiles_synced_at_idx" ON "github_profiles" USING btree ("synced_at" DESC NULLS FIRST);--> statement-breakpoint +CREATE INDEX "linkedin_profiles_scraped_at_idx" ON "linkedin_profiles" USING btree ("scraped_at" DESC NULLS FIRST);--> statement-breakpoint +CREATE INDEX "telegram_chat_history_user_idx" ON "telegram_chat_history" USING btree ("user_id");--> statement-breakpoint +CREATE INDEX "telegram_links_chat_idx" ON "telegram_links" USING btree ("chat_id");--> statement-breakpoint +CREATE INDEX "twitter_profiles_scraped_at_idx" ON "twitter_profiles" USING btree ("scraped_at" DESC NULLS FIRST);--> statement-breakpoint +-- ── Functions (verbatim from prod aafo, 2026-09-26). They must exist before +-- the policies below that call is_persona_group_member/_manager. +CREATE OR REPLACE FUNCTION public.is_persona_group_manager(check_group_id uuid) + RETURNS boolean + LANGUAGE sql + STABLE SECURITY DEFINER + SET search_path TO 'public' +AS $function$ + SELECT auth.uid() IS NOT NULL + AND EXISTS ( + SELECT 1 + FROM public.persona_group_members m + WHERE m.group_id = check_group_id + AND m.user_id = auth.uid() + AND m.role IN ('owner', 'admin') + ); +$function$; +--> statement-breakpoint +CREATE OR REPLACE FUNCTION public.is_persona_group_member(check_group_id uuid) + RETURNS boolean + LANGUAGE sql + STABLE SECURITY DEFINER + SET search_path TO 'public' +AS $function$ + SELECT auth.uid() IS NOT NULL + AND EXISTS ( + SELECT 1 + FROM public.persona_group_members m + WHERE m.group_id = check_group_id + AND m.user_id = auth.uid() + ); +$function$; +--> statement-breakpoint +-- SECURITY DEFINER helpers: callable by the API roles, not by PUBLIC (as in prod). +REVOKE EXECUTE ON FUNCTION public.is_persona_group_manager(uuid) FROM PUBLIC; +REVOKE EXECUTE ON FUNCTION public.is_persona_group_member(uuid) FROM PUBLIC; +--> statement-breakpoint +CREATE OR REPLACE FUNCTION public.persona_agents_search_vector_update() + RETURNS trigger + LANGUAGE plpgsql +AS $function$ +DECLARE + caps_text TEXT; + interests_text TEXT; +BEGIN + -- capabilities is a JSONB array of strings e.g. ["content writing", "fundraising"] + SELECT COALESCE(string_agg(val, ' '), '') + INTO caps_text + FROM jsonb_array_elements_text( + CASE WHEN jsonb_typeof(NEW.capabilities) = 'array' + THEN NEW.capabilities + ELSE '[]'::jsonb + END + ) AS val; + + -- profile->interests can be a JSON array or a comma-separated string + IF jsonb_typeof(NEW.profile->'interests') = 'array' THEN + SELECT COALESCE(string_agg(val, ' '), '') + INTO interests_text + FROM jsonb_array_elements_text(NEW.profile->'interests') AS val; + ELSE + interests_text := COALESCE(NEW.profile->>'interests', ''); + END IF; + + NEW.search_vector := + setweight(to_tsvector('english', COALESCE(NEW.name, '')), 'A') || + setweight(to_tsvector('english', COALESCE(NEW.description, '')), 'B') || + setweight(to_tsvector('english', COALESCE(NEW.profile->>'title', '')), 'B') || + setweight(to_tsvector('english', COALESCE(NEW.profile->>'organization', '')), 'C') || + setweight(to_tsvector('english', caps_text), 'C') || + setweight(to_tsvector('english', interests_text), 'C') || + setweight(to_tsvector('english', COALESCE(NEW.brief_content, '')), 'C'); + + RETURN NEW; +END; +$function$; +--> statement-breakpoint +CREATE OR REPLACE FUNCTION public.search_personas_fts(query_text text, result_limit integer DEFAULT 24) + RETURNS TABLE(agent_id text, name text, description text) + LANGUAGE plpgsql + STABLE +AS $function$ +BEGIN + RETURN QUERY + SELECT + pa.agent_id, + pa.name, + pa.description + FROM persona_agents pa + WHERE pa.active = TRUE + AND pa.search_vector @@ plainto_tsquery('english', query_text) + ORDER BY ts_rank(pa.search_vector, plainto_tsquery('english', query_text)) DESC + LIMIT result_limit; +END; +$function$; +--> statement-breakpoint +CREATE POLICY "Service role full access on persona_agents" ON "persona_agents" AS PERMISSIVE FOR ALL TO public USING ((auth.role() = 'service_role'::text));--> statement-breakpoint +CREATE POLICY "Users can read own persona" ON "persona_agents" AS PERMISSIVE FOR SELECT TO public USING ((auth.uid() = user_id));--> statement-breakpoint +CREATE POLICY "Users can update own persona" ON "persona_agents" AS PERMISSIVE FOR UPDATE TO public USING ((auth.uid() = user_id));--> statement-breakpoint +CREATE POLICY "Public read persona agents" ON "persona_agents" AS PERMISSIVE FOR SELECT TO public USING (true);--> statement-breakpoint +CREATE POLICY "Service role full access on callback_results" ON "callback_results" AS PERMISSIVE FOR ALL TO public USING ((auth.role() = 'service_role'::text));--> statement-breakpoint +CREATE POLICY "Owner reads callback_results" ON "callback_results" AS PERMISSIVE FOR SELECT TO public USING ((user_id = auth.uid()));--> statement-breakpoint +CREATE POLICY "Owner marks delivered" ON "callback_results" AS PERMISSIVE FOR UPDATE TO public USING ((user_id = auth.uid())) WITH CHECK ((user_id = auth.uid()));--> statement-breakpoint +CREATE POLICY "Service role full access on outbound_callbacks" ON "outbound_callbacks" AS PERMISSIVE FOR ALL TO public USING ((auth.role() = 'service_role'::text));--> statement-breakpoint +CREATE POLICY "Owner reads outbound_callbacks" ON "outbound_callbacks" AS PERMISSIVE FOR SELECT TO public USING ((user_id = auth.uid()));--> statement-breakpoint +CREATE POLICY "Service role full access on enriched_companies" ON "enriched_companies" AS PERMISSIVE FOR ALL TO public USING ((auth.role() = 'service_role'::text)) WITH CHECK ((auth.role() = 'service_role'::text));--> statement-breakpoint +CREATE POLICY "Users can CRUD own enriched companies" ON "enriched_companies" AS PERMISSIVE FOR ALL TO public USING ((auth.uid() = user_id)) WITH CHECK ((auth.uid() = user_id));--> statement-breakpoint +CREATE POLICY "Service role full access on enriched_contacts" ON "enriched_contacts" AS PERMISSIVE FOR ALL TO public USING ((auth.role() = 'service_role'::text)) WITH CHECK ((auth.role() = 'service_role'::text));--> statement-breakpoint +CREATE POLICY "Users can CRUD own enriched contacts" ON "enriched_contacts" AS PERMISSIVE FOR ALL TO public USING ((auth.uid() = user_id)) WITH CHECK ((auth.uid() = user_id));--> statement-breakpoint +CREATE POLICY "Service role full access on suggested_contact_runs" ON "suggested_contact_runs" AS PERMISSIVE FOR ALL TO public USING ((auth.role() = 'service_role'::text)) WITH CHECK ((auth.role() = 'service_role'::text));--> statement-breakpoint +CREATE POLICY "Users can read own suggestion run state" ON "suggested_contact_runs" AS PERMISSIVE FOR SELECT TO public USING ((auth.uid() = user_id));--> statement-breakpoint +CREATE POLICY "Service role full access on suggested_contacts" ON "suggested_contacts" AS PERMISSIVE FOR ALL TO public USING ((auth.role() = 'service_role'::text)) WITH CHECK ((auth.role() = 'service_role'::text));--> statement-breakpoint +CREATE POLICY "Users can CRUD own suggested contacts" ON "suggested_contacts" AS PERMISSIVE FOR ALL TO public USING ((auth.uid() = user_id)) WITH CHECK ((auth.uid() = user_id));--> statement-breakpoint +CREATE POLICY "Service role full access on brief_todos" ON "brief_todos" AS PERMISSIVE FOR ALL TO public USING ((auth.role() = 'service_role'::text)) WITH CHECK ((auth.role() = 'service_role'::text));--> statement-breakpoint +CREATE POLICY "Users can read own brief todos" ON "brief_todos" AS PERMISSIVE FOR SELECT TO public USING ((auth.uid() = user_id));--> statement-breakpoint +CREATE POLICY "Users can update own brief todos" ON "brief_todos" AS PERMISSIVE FOR UPDATE TO public USING ((auth.uid() = user_id));--> statement-breakpoint +CREATE POLICY "Users can delete own brief todos" ON "brief_todos" AS PERMISSIVE FOR DELETE TO public USING ((auth.uid() = user_id));--> statement-breakpoint +CREATE POLICY "Group members can read group todos" ON "brief_todos" AS PERMISSIVE FOR SELECT TO public USING ((group_id IS NOT NULL) AND is_persona_group_member(group_id));--> statement-breakpoint +CREATE POLICY "Service role full access on chat_messages" ON "chat_messages" AS PERMISSIVE FOR ALL TO public USING ((auth.role() = 'service_role'::text));--> statement-breakpoint +CREATE POLICY "Users can read own messages" ON "chat_messages" AS PERMISSIVE FOR SELECT TO public USING ((auth.uid() = user_id));--> statement-breakpoint +CREATE POLICY "Service role full access on published_pages" ON "published_pages" AS PERMISSIVE FOR ALL TO public USING ((auth.role() = 'service_role'::text));--> statement-breakpoint +CREATE POLICY "Public read for public pages" ON "published_pages" AS PERMISSIVE FOR SELECT TO public USING ((visibility = 'public'::text));--> statement-breakpoint +CREATE POLICY "Users can CRUD own pages" ON "published_pages" AS PERMISSIVE FOR ALL TO public USING ((auth.uid() = user_id)) WITH CHECK ((auth.uid() = user_id));--> statement-breakpoint +CREATE POLICY "Service role full access on a2a_tasks" ON "a2a_tasks" AS PERMISSIVE FOR ALL TO public USING ((auth.role() = 'service_role'::text));--> statement-breakpoint +CREATE POLICY "Participants can read a2a_tasks" ON "a2a_tasks" AS PERMISSIVE FOR SELECT TO public USING (EXISTS ( SELECT 1 + FROM dm_threads t + WHERE ((t.id = a2a_tasks.context_id) AND ((t.initiator_id = (auth.uid())::text) OR (t.receiver_id = (auth.uid())::text) OR (EXISTS ( SELECT 1 + FROM persona_agents p + WHERE ((p.user_id = auth.uid()) AND ((p.agent_id = t.initiator_id) OR (p.agent_id = t.receiver_id)))))))));--> statement-breakpoint +CREATE POLICY "Service role full access on agent_tasks" ON "agent_tasks" AS PERMISSIVE FOR ALL TO public USING ((auth.role() = 'service_role'::text));--> statement-breakpoint +CREATE POLICY "Participants can read agent_tasks" ON "agent_tasks" AS PERMISSIVE FOR SELECT TO public USING ((auth.uid() = initiator_user_id) OR (auth.uid() = recipient_user_id));--> statement-breakpoint +CREATE POLICY "Participants can update agent_tasks" ON "agent_tasks" AS PERMISSIVE FOR UPDATE TO public USING ((auth.uid() = initiator_user_id) OR (auth.uid() = recipient_user_id));--> statement-breakpoint +CREATE POLICY "Service role full access on dm_messages" ON "dm_messages" AS PERMISSIVE FOR ALL TO public USING ((auth.role() = 'service_role'::text));--> statement-breakpoint +CREATE POLICY "Users can read messages in non-blocked threads" ON "dm_messages" AS PERMISSIVE FOR SELECT TO public USING (EXISTS ( SELECT 1 + FROM dm_threads t + WHERE ((t.id = dm_messages.thread_id) AND ((t.initiator_id = (auth.uid())::text) OR (t.receiver_id = (auth.uid())::text) OR (EXISTS ( SELECT 1 + FROM persona_agents p + WHERE ((p.user_id = auth.uid()) AND ((p.agent_id = t.initiator_id) OR (p.agent_id = t.receiver_id)))))) AND (t.status <> ALL (ARRAY['blocked'::text, 'revoked'::text])))));--> statement-breakpoint +CREATE POLICY "Users can send messages in accepted threads" ON "dm_messages" AS PERMISSIVE FOR INSERT TO public WITH CHECK ((((auth.uid())::text = sender_id) OR (EXISTS ( SELECT 1 + FROM persona_agents + WHERE ((persona_agents.user_id = auth.uid()) AND (persona_agents.agent_id = dm_messages.sender_id))))) AND (EXISTS ( SELECT 1 + FROM dm_threads t + WHERE ((t.id = dm_messages.thread_id) AND ((t.initiator_id = (auth.uid())::text) OR (t.receiver_id = (auth.uid())::text) OR (EXISTS ( SELECT 1 + FROM persona_agents p + WHERE ((p.user_id = auth.uid()) AND ((p.agent_id = t.initiator_id) OR (p.agent_id = t.receiver_id)))))) AND (t.status <> ALL (ARRAY['blocked'::text, 'declined'::text, 'revoked'::text]))))));--> statement-breakpoint +CREATE POLICY "Service role full access on dm_threads" ON "dm_threads" AS PERMISSIVE FOR ALL TO public USING ((auth.role() = 'service_role'::text));--> statement-breakpoint +CREATE POLICY "Users can read own threads" ON "dm_threads" AS PERMISSIVE FOR SELECT TO public USING (((auth.uid())::text = dm_threads.initiator_id) OR ((auth.uid())::text = dm_threads.receiver_id) OR (EXISTS ( SELECT 1 + FROM persona_agents + WHERE ((persona_agents.user_id = auth.uid()) AND ((persona_agents.agent_id = dm_threads.initiator_id) OR (persona_agents.agent_id = dm_threads.receiver_id))))));--> statement-breakpoint +CREATE POLICY "Participants can update threads" ON "dm_threads" AS PERMISSIVE FOR UPDATE TO public USING (((auth.uid())::text = dm_threads.initiator_id) OR ((auth.uid())::text = dm_threads.receiver_id) OR (EXISTS ( SELECT 1 + FROM persona_agents + WHERE ((persona_agents.user_id = auth.uid()) AND ((persona_agents.agent_id = dm_threads.initiator_id) OR (persona_agents.agent_id = dm_threads.receiver_id))))));--> statement-breakpoint +CREATE POLICY "Users can start threads" ON "dm_threads" AS PERMISSIVE FOR INSERT TO public WITH CHECK (((auth.uid())::text = initiator_id) OR (EXISTS ( SELECT 1 + FROM persona_agents + WHERE ((persona_agents.user_id = auth.uid()) AND (persona_agents.agent_id = dm_threads.initiator_id)))));--> statement-breakpoint +CREATE POLICY "Service role full access on pending_approvals" ON "pending_approvals" AS PERMISSIVE FOR ALL TO public USING ((auth.role() = 'service_role'::text));--> statement-breakpoint +CREATE POLICY "Users read own approvals" ON "pending_approvals" AS PERMISSIVE FOR SELECT TO public USING ((auth.uid() = user_id));--> statement-breakpoint +CREATE POLICY "Users update own approvals" ON "pending_approvals" AS PERMISSIVE FOR UPDATE TO public USING ((auth.uid() = user_id));--> statement-breakpoint +CREATE POLICY "service role full access on persona_group_audit_events" ON "persona_group_audit_events" AS PERMISSIVE FOR ALL TO public USING ((auth.role() = 'service_role'::text)) WITH CHECK ((auth.role() = 'service_role'::text));--> statement-breakpoint +CREATE POLICY "affected user reads own audit events" ON "persona_group_audit_events" AS PERMISSIVE FOR SELECT TO public USING ((auth.uid() = affected_user_id));--> statement-breakpoint +CREATE POLICY "owner reads group audit events" ON "persona_group_audit_events" AS PERMISSIVE FOR SELECT TO public USING (is_persona_group_manager(group_id));--> statement-breakpoint +CREATE POLICY "service role full access on persona_group_constraints" ON "persona_group_constraints" AS PERMISSIVE FOR ALL TO public USING ((auth.role() = 'service_role'::text)) WITH CHECK ((auth.role() = 'service_role'::text));--> statement-breakpoint +CREATE POLICY "members read group constraints" ON "persona_group_constraints" AS PERMISSIVE FOR SELECT TO public USING (is_persona_group_member(group_id));--> statement-breakpoint +CREATE POLICY "service role full access on persona_group_members" ON "persona_group_members" AS PERMISSIVE FOR ALL TO public USING ((auth.role() = 'service_role'::text)) WITH CHECK ((auth.role() = 'service_role'::text));--> statement-breakpoint +CREATE POLICY "members read roster" ON "persona_group_members" AS PERMISSIVE FOR SELECT TO public USING (is_persona_group_member(group_id));--> statement-breakpoint +CREATE POLICY "service role full access on persona_group_messages" ON "persona_group_messages" AS PERMISSIVE FOR ALL TO public USING ((auth.role() = 'service_role'::text)) WITH CHECK ((auth.role() = 'service_role'::text));--> statement-breakpoint +CREATE POLICY "members read messages" ON "persona_group_messages" AS PERMISSIVE FOR SELECT TO public USING (is_persona_group_member(group_id));--> statement-breakpoint +CREATE POLICY "service role full access on persona_groups" ON "persona_groups" AS PERMISSIVE FOR ALL TO public USING ((auth.role() = 'service_role'::text)) WITH CHECK ((auth.role() = 'service_role'::text));--> statement-breakpoint +CREATE POLICY "members read group" ON "persona_groups" AS PERMISSIVE FOR SELECT TO public USING (is_persona_group_member(id));--> statement-breakpoint +CREATE POLICY "Service role full access on api_tokens" ON "api_tokens" AS PERMISSIVE FOR ALL TO public USING ((auth.role() = 'service_role'::text));--> statement-breakpoint +CREATE POLICY "Users can read own tokens" ON "api_tokens" AS PERMISSIVE FOR SELECT TO public USING ((auth.uid() = user_id));--> statement-breakpoint +CREATE POLICY "Users can insert own tokens" ON "api_tokens" AS PERMISSIVE FOR INSERT TO public WITH CHECK ((auth.uid() = user_id));--> statement-breakpoint +CREATE POLICY "Users can update own tokens" ON "api_tokens" AS PERMISSIVE FOR UPDATE TO public USING ((auth.uid() = user_id));--> statement-breakpoint +CREATE POLICY "Users can delete own tokens" ON "api_tokens" AS PERMISSIVE FOR DELETE TO public USING ((auth.uid() = user_id));--> statement-breakpoint +CREATE POLICY "Service role full access on github_profiles" ON "github_profiles" AS PERMISSIVE FOR ALL TO public USING ((auth.role() = 'service_role'::text));--> statement-breakpoint +CREATE POLICY "Users read own github profile" ON "github_profiles" AS PERMISSIVE FOR SELECT TO public USING ((auth.uid() = user_id));--> statement-breakpoint +CREATE POLICY "Users delete own github profile" ON "github_profiles" AS PERMISSIVE FOR DELETE TO public USING ((auth.uid() = user_id));--> statement-breakpoint +CREATE POLICY "Service role full access on linkedin_profiles" ON "linkedin_profiles" AS PERMISSIVE FOR ALL TO public USING ((auth.role() = 'service_role'::text));--> statement-breakpoint +CREATE POLICY "Users read own linkedin profile" ON "linkedin_profiles" AS PERMISSIVE FOR SELECT TO public USING ((auth.uid() = user_id));--> statement-breakpoint +CREATE POLICY "Users delete own linkedin profile" ON "linkedin_profiles" AS PERMISSIVE FOR DELETE TO public USING ((auth.uid() = user_id));--> statement-breakpoint +CREATE POLICY "Service role full access on telegram_chat_history" ON "telegram_chat_history" AS PERMISSIVE FOR ALL TO public USING ((auth.role() = 'service_role'::text));--> statement-breakpoint +CREATE POLICY "Users read own telegram history" ON "telegram_chat_history" AS PERMISSIVE FOR SELECT TO public USING ((auth.uid() = user_id));--> statement-breakpoint +CREATE POLICY "Service role full access on telegram_links" ON "telegram_links" AS PERMISSIVE FOR ALL TO public USING ((auth.role() = 'service_role'::text));--> statement-breakpoint +CREATE POLICY "Users read own telegram link" ON "telegram_links" AS PERMISSIVE FOR SELECT TO public USING ((auth.uid() = user_id));--> statement-breakpoint +CREATE POLICY "Users delete own telegram link" ON "telegram_links" AS PERMISSIVE FOR DELETE TO public USING ((auth.uid() = user_id));--> statement-breakpoint +CREATE POLICY "Service role full access on twitter_profiles" ON "twitter_profiles" AS PERMISSIVE FOR ALL TO public USING ((auth.role() = 'service_role'::text));--> statement-breakpoint +CREATE POLICY "Users read own twitter profile" ON "twitter_profiles" AS PERMISSIVE FOR SELECT TO public USING ((auth.uid() = user_id));--> statement-breakpoint +CREATE POLICY "Users delete own twitter profile" ON "twitter_profiles" AS PERMISSIVE FOR DELETE TO public USING ((auth.uid() = user_id)); +--> statement-breakpoint +-- ── Trigger, realtime publication (verbatim from prod aafo, 2026-09-26). +CREATE TRIGGER persona_agents_search_vector_trigger BEFORE INSERT OR UPDATE ON public.persona_agents FOR EACH ROW EXECUTE FUNCTION persona_agents_search_vector_update(); +--> statement-breakpoint +ALTER PUBLICATION supabase_realtime ADD TABLE + public.a2a_tasks, + public.agent_tasks, + public.callback_results, + public.dm_messages, + public.dm_threads, + public.outbound_callbacks, + public.pending_approvals, + public.persona_group_invitations, + public.persona_group_messages; diff --git a/packages/db/persona/migrations/0001_persona_drop_public_read.sql b/packages/db/persona/migrations/0001_persona_drop_public_read.sql new file mode 100644 index 0000000..cc014dc --- /dev/null +++ b/packages/db/persona/migrations/0001_persona_drop_public_read.sql @@ -0,0 +1,8 @@ +-- owner: persona +-- Stop exposing every persona row to the anon key. The dropped policy was +-- `for select using (true)`; the owner still reads their own row through +-- "Users can read own persona", and the backends use the service role. +-- Every RLS subquery on persona_agents (dm_threads, dm_messages, a2a_tasks +-- policies) filters user_id = auth.uid(), which the owner policy allows. +-- Rollback: create policy "Public read persona agents" on persona_agents for select using (true); +DROP POLICY "Public read persona agents" ON "persona_agents" CASCADE; diff --git a/packages/db/persona/migrations/meta/0000_snapshot.json b/packages/db/persona/migrations/meta/0000_snapshot.json new file mode 100644 index 0000000..6803bc1 --- /dev/null +++ b/packages/db/persona/migrations/meta/0000_snapshot.json @@ -0,0 +1,4545 @@ +{ + "id": "90b55989-e810-44ec-8aca-311ce22f3745", + "prevId": "00000000-0000-0000-0000-000000000000", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.persona_agents": { + "name": "persona_agents", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "derivation_index": { + "name": "derivation_index", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_handle": { + "name": "agent_handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "capabilities": { + "name": "capabilities", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "profile": { + "name": "profile", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "webhook_url": { + "name": "webhook_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "brief_doc_id": { + "name": "brief_doc_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "brief_doc_url": { + "name": "brief_doc_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "brief_doc_revision_id": { + "name": "brief_doc_revision_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "brief_content": { + "name": "brief_content", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "search_vector": { + "name": "search_vector", + "type": "tsvector", + "primaryKey": false, + "notNull": false + }, + "auto_extract_todos": { + "name": "auto_extract_todos", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + } + }, + "indexes": { + "persona_agents_search_vector_idx": { + "name": "persona_agents_search_vector_idx", + "columns": [ + { + "expression": "search_vector", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": { + "persona_agents_user_id_fkey": { + "name": "persona_agents_user_id_fkey", + "tableFrom": "persona_agents", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "persona_agents_agent_id_key": { + "name": "persona_agents_agent_id_key", + "nullsNotDistinct": false, + "columns": [ + "agent_id" + ] + }, + "persona_agents_derivation_index_key": { + "name": "persona_agents_derivation_index_key", + "nullsNotDistinct": false, + "columns": [ + "derivation_index" + ] + } + }, + "policies": { + "Service role full access on persona_agents": { + "name": "Service role full access on persona_agents", + "as": "PERMISSIVE", + "for": "ALL", + "to": [ + "public" + ], + "using": "(auth.role() = 'service_role'::text)" + }, + "Users can read own persona": { + "name": "Users can read own persona", + "as": "PERMISSIVE", + "for": "SELECT", + "to": [ + "public" + ], + "using": "(auth.uid() = user_id)" + }, + "Users can update own persona": { + "name": "Users can update own persona", + "as": "PERMISSIVE", + "for": "UPDATE", + "to": [ + "public" + ], + "using": "(auth.uid() = user_id)" + }, + "Public read persona agents": { + "name": "Public read persona agents", + "as": "PERMISSIVE", + "for": "SELECT", + "to": [ + "public" + ], + "using": "true" + } + }, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.callback_results": { + "name": "callback_results", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "callback_id": { + "name": "callback_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "peer_agent_id": { + "name": "peer_agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "task_state": { + "name": "task_state", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reply_text": { + "name": "reply_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "raw_event": { + "name": "raw_event", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "delivered_to_ui": { + "name": "delivered_to_ui", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "callback_results_thread_idx": { + "name": "callback_results_thread_idx", + "columns": [ + { + "expression": "thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "first" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "callback_results_user_idx": { + "name": "callback_results_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "delivered_to_ui", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "first" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "callback_results_callback_id_fkey": { + "name": "callback_results_callback_id_fkey", + "tableFrom": "callback_results", + "tableTo": "outbound_callbacks", + "columnsFrom": [ + "callback_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "callback_results_user_id_fkey": { + "name": "callback_results_user_id_fkey", + "tableFrom": "callback_results", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "callback_results_callback_id_key": { + "name": "callback_results_callback_id_key", + "nullsNotDistinct": false, + "columns": [ + "callback_id" + ] + } + }, + "policies": { + "Service role full access on callback_results": { + "name": "Service role full access on callback_results", + "as": "PERMISSIVE", + "for": "ALL", + "to": [ + "public" + ], + "using": "(auth.role() = 'service_role'::text)" + }, + "Owner reads callback_results": { + "name": "Owner reads callback_results", + "as": "PERMISSIVE", + "for": "SELECT", + "to": [ + "public" + ], + "using": "(user_id = auth.uid())" + }, + "Owner marks delivered": { + "name": "Owner marks delivered", + "as": "PERMISSIVE", + "for": "UPDATE", + "to": [ + "public" + ], + "using": "(user_id = auth.uid())", + "withCheck": "(user_id = auth.uid())" + } + }, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.outbound_callbacks": { + "name": "outbound_callbacks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "peer_agent_id": { + "name": "peer_agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "peer_task_id": { + "name": "peer_task_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "our_message_id": { + "name": "our_message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "origin_kind": { + "name": "origin_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "origin_ref": { + "name": "origin_ref", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "push_token": { + "name": "push_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "(now() + '24:00:00'::interval)" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "peer_a2a_url": { + "name": "peer_a2a_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_state": { + "name": "last_state", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_event": { + "name": "last_event", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "last_event_at": { + "name": "last_event_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "answer_text": { + "name": "answer_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "terminal_state": { + "name": "terminal_state", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "outbound_callbacks_expires_idx": { + "name": "outbound_callbacks_expires_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "status = 'pending'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "outbound_callbacks_peer_task_idx": { + "name": "outbound_callbacks_peer_task_idx", + "columns": [ + { + "expression": "peer_agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "peer_task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "peer_task_id IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "outbound_callbacks_user_idx": { + "name": "outbound_callbacks_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "first" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "outbound_callbacks_user_id_fkey": { + "name": "outbound_callbacks_user_id_fkey", + "tableFrom": "outbound_callbacks", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "outbound_callbacks_push_token_key": { + "name": "outbound_callbacks_push_token_key", + "nullsNotDistinct": false, + "columns": [ + "push_token" + ] + } + }, + "policies": { + "Service role full access on outbound_callbacks": { + "name": "Service role full access on outbound_callbacks", + "as": "PERMISSIVE", + "for": "ALL", + "to": [ + "public" + ], + "using": "(auth.role() = 'service_role'::text)" + }, + "Owner reads outbound_callbacks": { + "name": "Owner reads outbound_callbacks", + "as": "PERMISSIVE", + "for": "SELECT", + "to": [ + "public" + ], + "using": "(user_id = auth.uid())" + } + }, + "checkConstraints": { + "outbound_callbacks_status_check": { + "name": "outbound_callbacks_status_check", + "value": "status IN ('pending', 'received', 'expired', 'failed')" + } + }, + "isRLSEnabled": false + }, + "public.enriched_companies": { + "name": "enriched_companies", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "cache_key": { + "name": "cache_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "company_name": { + "name": "company_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "company_url": { + "name": "company_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "linkedin_url": { + "name": "linkedin_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "industry": { + "name": "industry", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "employee_count": { + "name": "employee_count", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "revenue": { + "name": "revenue", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "city": { + "name": "city", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "region_code": { + "name": "region_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "country_code": { + "name": "country_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "enriched_companies_user_created_idx": { + "name": "enriched_companies_user_created_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "first" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "enriched_companies_user_id_fkey": { + "name": "enriched_companies_user_id_fkey", + "tableFrom": "enriched_companies", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "enriched_companies_user_id_cache_key_key": { + "name": "enriched_companies_user_id_cache_key_key", + "nullsNotDistinct": false, + "columns": [ + "user_id", + "cache_key" + ] + } + }, + "policies": { + "Service role full access on enriched_companies": { + "name": "Service role full access on enriched_companies", + "as": "PERMISSIVE", + "for": "ALL", + "to": [ + "public" + ], + "using": "(auth.role() = 'service_role'::text)", + "withCheck": "(auth.role() = 'service_role'::text)" + }, + "Users can CRUD own enriched companies": { + "name": "Users can CRUD own enriched companies", + "as": "PERMISSIVE", + "for": "ALL", + "to": [ + "public" + ], + "using": "(auth.uid() = user_id)", + "withCheck": "(auth.uid() = user_id)" + } + }, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.enriched_contacts": { + "name": "enriched_contacts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "cache_key": { + "name": "cache_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "employee_linkedin": { + "name": "employee_linkedin", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "first_name": { + "name": "first_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_name": { + "name": "last_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "company_name": { + "name": "company_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "company_url": { + "name": "company_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "phone": { + "name": "phone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "phone_type": { + "name": "phone_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "has_email": { + "name": "has_email", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "has_phone": { + "name": "has_phone", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enriched_at": { + "name": "enriched_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "enriched_contacts_user_created_idx": { + "name": "enriched_contacts_user_created_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "first" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "enriched_contacts_user_email_idx": { + "name": "enriched_contacts_user_email_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "enriched_contacts_user_linkedin_idx": { + "name": "enriched_contacts_user_linkedin_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "employee_linkedin", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "enriched_contacts_user_id_fkey": { + "name": "enriched_contacts_user_id_fkey", + "tableFrom": "enriched_contacts", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "enriched_contacts_user_id_cache_key_key": { + "name": "enriched_contacts_user_id_cache_key_key", + "nullsNotDistinct": false, + "columns": [ + "user_id", + "cache_key" + ] + } + }, + "policies": { + "Service role full access on enriched_contacts": { + "name": "Service role full access on enriched_contacts", + "as": "PERMISSIVE", + "for": "ALL", + "to": [ + "public" + ], + "using": "(auth.role() = 'service_role'::text)", + "withCheck": "(auth.role() = 'service_role'::text)" + }, + "Users can CRUD own enriched contacts": { + "name": "Users can CRUD own enriched contacts", + "as": "PERMISSIVE", + "for": "ALL", + "to": [ + "public" + ], + "using": "(auth.uid() = user_id)", + "withCheck": "(auth.uid() = user_id)" + } + }, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.suggested_contact_runs": { + "name": "suggested_contact_runs", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "last_run_at": { + "name": "last_run_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_manual_at": { + "name": "last_manual_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "detail": { + "name": "detail", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "suggested_contact_runs_user_id_fkey": { + "name": "suggested_contact_runs_user_id_fkey", + "tableFrom": "suggested_contact_runs", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": { + "Service role full access on suggested_contact_runs": { + "name": "Service role full access on suggested_contact_runs", + "as": "PERMISSIVE", + "for": "ALL", + "to": [ + "public" + ], + "using": "(auth.role() = 'service_role'::text)", + "withCheck": "(auth.role() = 'service_role'::text)" + }, + "Users can read own suggestion run state": { + "name": "Users can read own suggestion run state", + "as": "PERMISSIVE", + "for": "SELECT", + "to": [ + "public" + ], + "using": "(auth.uid() = user_id)" + } + }, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.suggested_contacts": { + "name": "suggested_contacts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "cache_key": { + "name": "cache_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "recipe": { + "name": "recipe", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "score": { + "name": "score", + "type": "real", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "rank": { + "name": "rank", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "generated_at": { + "name": "generated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "suggested_contacts_user_rank_idx": { + "name": "suggested_contacts_user_rank_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "recipe", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "rank", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "suggested_contacts_user_id_fkey": { + "name": "suggested_contacts_user_id_fkey", + "tableFrom": "suggested_contacts", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "suggested_contacts_user_id_cache_key_recipe_key": { + "name": "suggested_contacts_user_id_cache_key_recipe_key", + "nullsNotDistinct": false, + "columns": [ + "user_id", + "cache_key", + "recipe" + ] + } + }, + "policies": { + "Service role full access on suggested_contacts": { + "name": "Service role full access on suggested_contacts", + "as": "PERMISSIVE", + "for": "ALL", + "to": [ + "public" + ], + "using": "(auth.role() = 'service_role'::text)", + "withCheck": "(auth.role() = 'service_role'::text)" + }, + "Users can CRUD own suggested contacts": { + "name": "Users can CRUD own suggested contacts", + "as": "PERMISSIVE", + "for": "ALL", + "to": [ + "public" + ], + "using": "(auth.uid() = user_id)", + "withCheck": "(auth.uid() = user_id)" + } + }, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.brief_todos": { + "name": "brief_todos", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_text": { + "name": "source_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "done": { + "name": "done", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "done_at": { + "name": "done_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()" + }, + "group_id": { + "name": "group_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "assigned_by_user_id": { + "name": "assigned_by_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "brief_todos_group_idx": { + "name": "brief_todos_group_idx", + "columns": [ + { + "expression": "group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "brief_todos_user_idx": { + "name": "brief_todos_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "done", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "first" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "brief_todos_user_title_uniq": { + "name": "brief_todos_user_title_uniq", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "title", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "done = false", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "brief_todos_user_id_fkey": { + "name": "brief_todos_user_id_fkey", + "tableFrom": "brief_todos", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "brief_todos_group_id_fkey": { + "name": "brief_todos_group_id_fkey", + "tableFrom": "brief_todos", + "tableTo": "persona_groups", + "columnsFrom": [ + "group_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "brief_todos_assigned_by_user_id_fkey": { + "name": "brief_todos_assigned_by_user_id_fkey", + "tableFrom": "brief_todos", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "assigned_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": { + "Service role full access on brief_todos": { + "name": "Service role full access on brief_todos", + "as": "PERMISSIVE", + "for": "ALL", + "to": [ + "public" + ], + "using": "(auth.role() = 'service_role'::text)", + "withCheck": "(auth.role() = 'service_role'::text)" + }, + "Users can read own brief todos": { + "name": "Users can read own brief todos", + "as": "PERMISSIVE", + "for": "SELECT", + "to": [ + "public" + ], + "using": "(auth.uid() = user_id)" + }, + "Users can update own brief todos": { + "name": "Users can update own brief todos", + "as": "PERMISSIVE", + "for": "UPDATE", + "to": [ + "public" + ], + "using": "(auth.uid() = user_id)" + }, + "Users can delete own brief todos": { + "name": "Users can delete own brief todos", + "as": "PERMISSIVE", + "for": "DELETE", + "to": [ + "public" + ], + "using": "(auth.uid() = user_id)" + }, + "Group members can read group todos": { + "name": "Group members can read group todos", + "as": "PERMISSIVE", + "for": "SELECT", + "to": [ + "public" + ], + "using": "(group_id IS NOT NULL) AND is_persona_group_member(group_id)" + } + }, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat_messages": { + "name": "chat_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actions": { + "name": "actions", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "action_summary": { + "name": "action_summary", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + } + }, + "indexes": {}, + "foreignKeys": { + "chat_messages_user_id_fkey": { + "name": "chat_messages_user_id_fkey", + "tableFrom": "chat_messages", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": { + "Service role full access on chat_messages": { + "name": "Service role full access on chat_messages", + "as": "PERMISSIVE", + "for": "ALL", + "to": [ + "public" + ], + "using": "(auth.role() = 'service_role'::text)" + }, + "Users can read own messages": { + "name": "Users can read own messages", + "as": "PERMISSIVE", + "for": "SELECT", + "to": [ + "public" + ], + "using": "(auth.uid() = user_id)" + } + }, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.published_pages": { + "name": "published_pages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "format": { + "name": "format", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "visibility": { + "name": "visibility", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'unlisted'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "published_pages_expires_idx": { + "name": "published_pages_expires_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "expires_at IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "published_pages_slug_idx": { + "name": "published_pages_slug_idx", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "published_pages_user_idx": { + "name": "published_pages_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "first" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "published_pages_slug_key": { + "name": "published_pages_slug_key", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": { + "Service role full access on published_pages": { + "name": "Service role full access on published_pages", + "as": "PERMISSIVE", + "for": "ALL", + "to": [ + "public" + ], + "using": "(auth.role() = 'service_role'::text)" + }, + "Public read for public pages": { + "name": "Public read for public pages", + "as": "PERMISSIVE", + "for": "SELECT", + "to": [ + "public" + ], + "using": "(visibility = 'public'::text)" + }, + "Users can CRUD own pages": { + "name": "Users can CRUD own pages", + "as": "PERMISSIVE", + "for": "ALL", + "to": [ + "public" + ], + "using": "(auth.uid() = user_id)", + "withCheck": "(auth.uid() = user_id)" + } + }, + "checkConstraints": { + "published_pages_format_check": { + "name": "published_pages_format_check", + "value": "format IN ('html', 'markdown')" + }, + "published_pages_visibility_check": { + "name": "published_pages_visibility_check", + "value": "visibility IN ('public', 'unlisted', 'private')" + } + }, + "isRLSEnabled": false + }, + "public.a2a_tasks": { + "name": "a2a_tasks", + "schema": "", + "columns": { + "task_id": { + "name": "task_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "context_id": { + "name": "context_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'submitted'" + }, + "permission_snapshot": { + "name": "permission_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "history": { + "name": "history", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "artifacts": { + "name": "artifacts", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "push_url": { + "name": "push_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "push_token": { + "name": "push_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_message_id": { + "name": "last_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "idle_ttl_ms": { + "name": "idle_ttl_ms", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 3600000 + }, + "idle_until": { + "name": "idle_until", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "terminal_at": { + "name": "terminal_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "failure_reason": { + "name": "failure_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "a2a_tasks_context_idx": { + "name": "a2a_tasks_context_idx", + "columns": [ + { + "expression": "context_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": false, + "nulls": "first" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "a2a_tasks_state_idle_idx": { + "name": "a2a_tasks_state_idle_idx", + "columns": [ + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idle_until", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "state IN ('input-required', 'auth-required')", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "a2a_tasks_context_id_fkey": { + "name": "a2a_tasks_context_id_fkey", + "tableFrom": "a2a_tasks", + "tableTo": "dm_threads", + "columnsFrom": [ + "context_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": { + "Service role full access on a2a_tasks": { + "name": "Service role full access on a2a_tasks", + "as": "PERMISSIVE", + "for": "ALL", + "to": [ + "public" + ], + "using": "(auth.role() = 'service_role'::text)" + }, + "Participants can read a2a_tasks": { + "name": "Participants can read a2a_tasks", + "as": "PERMISSIVE", + "for": "SELECT", + "to": [ + "public" + ], + "using": "EXISTS ( SELECT 1\n FROM dm_threads t\n WHERE ((t.id = a2a_tasks.context_id) AND ((t.initiator_id = (auth.uid())::text) OR (t.receiver_id = (auth.uid())::text) OR (EXISTS ( SELECT 1\n FROM persona_agents p\n WHERE ((p.user_id = auth.uid()) AND ((p.agent_id = t.initiator_id) OR (p.agent_id = t.receiver_id))))))))" + } + }, + "checkConstraints": { + "a2a_tasks_state_check": { + "name": "a2a_tasks_state_check", + "value": "state IN ('submitted', 'working', 'input-required', 'auth-required', 'completed', 'canceled', 'failed', 'rejected')" + } + }, + "isRLSEnabled": false + }, + "public.agent_tasks": { + "name": "agent_tasks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "thread_id": { + "name": "thread_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'meeting'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'proposed'" + }, + "initiator_user_id": { + "name": "initiator_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "recipient_user_id": { + "name": "recipient_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "initiator_agent_id": { + "name": "initiator_agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "recipient_agent_id": { + "name": "recipient_agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "history": { + "name": "history", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "calendar_event_ids": { + "name": "calendar_event_ids", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "agent_tasks_initiator_idx": { + "name": "agent_tasks_initiator_idx", + "columns": [ + { + "expression": "initiator_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agent_tasks_recipient_idx": { + "name": "agent_tasks_recipient_idx", + "columns": [ + { + "expression": "recipient_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agent_tasks_status_idx": { + "name": "agent_tasks_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agent_tasks_thread_idx": { + "name": "agent_tasks_thread_idx", + "columns": [ + { + "expression": "thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agent_tasks_one_open_proposal": { + "name": "agent_tasks_one_open_proposal", + "columns": [ + { + "expression": "thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "status IN ('proposed', 'countered', 'accepted')", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_tasks_thread_id_fkey": { + "name": "agent_tasks_thread_id_fkey", + "tableFrom": "agent_tasks", + "tableTo": "dm_threads", + "columnsFrom": [ + "thread_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "agent_tasks_initiator_user_id_fkey": { + "name": "agent_tasks_initiator_user_id_fkey", + "tableFrom": "agent_tasks", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "initiator_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "agent_tasks_recipient_user_id_fkey": { + "name": "agent_tasks_recipient_user_id_fkey", + "tableFrom": "agent_tasks", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "recipient_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": { + "Service role full access on agent_tasks": { + "name": "Service role full access on agent_tasks", + "as": "PERMISSIVE", + "for": "ALL", + "to": [ + "public" + ], + "using": "(auth.role() = 'service_role'::text)" + }, + "Participants can read agent_tasks": { + "name": "Participants can read agent_tasks", + "as": "PERMISSIVE", + "for": "SELECT", + "to": [ + "public" + ], + "using": "(auth.uid() = initiator_user_id) OR (auth.uid() = recipient_user_id)" + }, + "Participants can update agent_tasks": { + "name": "Participants can update agent_tasks", + "as": "PERMISSIVE", + "for": "UPDATE", + "to": [ + "public" + ], + "using": "(auth.uid() = initiator_user_id) OR (auth.uid() = recipient_user_id)" + } + }, + "checkConstraints": { + "agent_tasks_type_check": { + "name": "agent_tasks_type_check", + "value": "type = 'meeting'" + }, + "agent_tasks_status_check": { + "name": "agent_tasks_status_check", + "value": "status IN ('proposed', 'countered', 'accepted', 'scheduled', 'declined', 'cancelled', 'book_failed')" + } + }, + "isRLSEnabled": false + }, + "public.dm_messages": { + "name": "dm_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "thread_id": { + "name": "thread_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "sender_id": { + "name": "sender_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sender_type": { + "name": "sender_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'human'" + }, + "channel": { + "name": "channel", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'human'" + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "dm_messages_thread_id_fkey": { + "name": "dm_messages_thread_id_fkey", + "tableFrom": "dm_messages", + "tableTo": "dm_threads", + "columnsFrom": [ + "thread_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": { + "Service role full access on dm_messages": { + "name": "Service role full access on dm_messages", + "as": "PERMISSIVE", + "for": "ALL", + "to": [ + "public" + ], + "using": "(auth.role() = 'service_role'::text)" + }, + "Users can read messages in non-blocked threads": { + "name": "Users can read messages in non-blocked threads", + "as": "PERMISSIVE", + "for": "SELECT", + "to": [ + "public" + ], + "using": "EXISTS ( SELECT 1\n FROM dm_threads t\n WHERE ((t.id = dm_messages.thread_id) AND ((t.initiator_id = (auth.uid())::text) OR (t.receiver_id = (auth.uid())::text) OR (EXISTS ( SELECT 1\n FROM persona_agents p\n WHERE ((p.user_id = auth.uid()) AND ((p.agent_id = t.initiator_id) OR (p.agent_id = t.receiver_id)))))) AND (t.status <> ALL (ARRAY['blocked'::text, 'revoked'::text]))))" + }, + "Users can send messages in accepted threads": { + "name": "Users can send messages in accepted threads", + "as": "PERMISSIVE", + "for": "INSERT", + "to": [ + "public" + ], + "withCheck": "(((auth.uid())::text = sender_id) OR (EXISTS ( SELECT 1\n FROM persona_agents\n WHERE ((persona_agents.user_id = auth.uid()) AND (persona_agents.agent_id = dm_messages.sender_id))))) AND (EXISTS ( SELECT 1\n FROM dm_threads t\n WHERE ((t.id = dm_messages.thread_id) AND ((t.initiator_id = (auth.uid())::text) OR (t.receiver_id = (auth.uid())::text) OR (EXISTS ( SELECT 1\n FROM persona_agents p\n WHERE ((p.user_id = auth.uid()) AND ((p.agent_id = t.initiator_id) OR (p.agent_id = t.receiver_id)))))) AND (t.status <> ALL (ARRAY['blocked'::text, 'declined'::text, 'revoked'::text])))))" + } + }, + "checkConstraints": { + "dm_messages_sender_type_check": { + "name": "dm_messages_sender_type_check", + "value": "sender_type IN ('human', 'agent', 'system')" + }, + "dm_messages_channel_check": { + "name": "dm_messages_channel_check", + "value": "channel IN ('human', 'agent')" + } + }, + "isRLSEnabled": false + }, + "public.dm_threads": { + "name": "dm_threads", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "initiator_id": { + "name": "initiator_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "receiver_id": { + "name": "receiver_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "initiator_name": { + "name": "initiator_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "''" + }, + "receiver_name": { + "name": "receiver_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "''" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "lifecycle": { + "name": "lifecycle", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "initiator_mode": { + "name": "initiator_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'agent'" + }, + "receiver_mode": { + "name": "receiver_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'agent'" + }, + "permissions": { + "name": "permissions", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "jsonb_build_object('can_request_meetings', true, 'can_query_availability', false, 'can_view_full_profile', false, 'can_post_on_my_behalf', false)" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "dm_threads_lifecycle_idx": { + "name": "dm_threads_lifecycle_idx", + "columns": [ + { + "expression": "lifecycle", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "dm_threads_initiator_id_receiver_id_key": { + "name": "dm_threads_initiator_id_receiver_id_key", + "nullsNotDistinct": false, + "columns": [ + "initiator_id", + "receiver_id" + ] + } + }, + "policies": { + "Service role full access on dm_threads": { + "name": "Service role full access on dm_threads", + "as": "PERMISSIVE", + "for": "ALL", + "to": [ + "public" + ], + "using": "(auth.role() = 'service_role'::text)" + }, + "Users can read own threads": { + "name": "Users can read own threads", + "as": "PERMISSIVE", + "for": "SELECT", + "to": [ + "public" + ], + "using": "((auth.uid())::text = dm_threads.initiator_id) OR ((auth.uid())::text = dm_threads.receiver_id) OR (EXISTS ( SELECT 1\n FROM persona_agents\n WHERE ((persona_agents.user_id = auth.uid()) AND ((persona_agents.agent_id = dm_threads.initiator_id) OR (persona_agents.agent_id = dm_threads.receiver_id)))))" + }, + "Participants can update threads": { + "name": "Participants can update threads", + "as": "PERMISSIVE", + "for": "UPDATE", + "to": [ + "public" + ], + "using": "((auth.uid())::text = dm_threads.initiator_id) OR ((auth.uid())::text = dm_threads.receiver_id) OR (EXISTS ( SELECT 1\n FROM persona_agents\n WHERE ((persona_agents.user_id = auth.uid()) AND ((persona_agents.agent_id = dm_threads.initiator_id) OR (persona_agents.agent_id = dm_threads.receiver_id)))))" + }, + "Users can start threads": { + "name": "Users can start threads", + "as": "PERMISSIVE", + "for": "INSERT", + "to": [ + "public" + ], + "withCheck": "((auth.uid())::text = initiator_id) OR (EXISTS ( SELECT 1\n FROM persona_agents\n WHERE ((persona_agents.user_id = auth.uid()) AND (persona_agents.agent_id = dm_threads.initiator_id))))" + } + }, + "checkConstraints": { + "dm_threads_status_check": { + "name": "dm_threads_status_check", + "value": "status IN ('pending', 'accepted', 'declined', 'blocked', 'revoked')" + }, + "dm_threads_initiator_mode_check": { + "name": "dm_threads_initiator_mode_check", + "value": "initiator_mode IN ('human', 'agent')" + }, + "dm_threads_receiver_mode_check": { + "name": "dm_threads_receiver_mode_check", + "value": "receiver_mode IN ('human', 'agent')" + } + }, + "isRLSEnabled": false + }, + "public.pending_approvals": { + "name": "pending_approvals", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "tool_name": { + "name": "tool_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_args": { + "name": "tool_args", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "result": { + "name": "result", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "decided_at": { + "name": "decided_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "(now() + '24:00:00'::interval)" + } + }, + "indexes": { + "pending_approvals_thread_idx": { + "name": "pending_approvals_thread_idx", + "columns": [ + { + "expression": "thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pending_approvals_user_status_idx": { + "name": "pending_approvals_user_status_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pending_approvals_user_id_fkey": { + "name": "pending_approvals_user_id_fkey", + "tableFrom": "pending_approvals", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pending_approvals_thread_id_fkey": { + "name": "pending_approvals_thread_id_fkey", + "tableFrom": "pending_approvals", + "tableTo": "dm_threads", + "columnsFrom": [ + "thread_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": { + "Service role full access on pending_approvals": { + "name": "Service role full access on pending_approvals", + "as": "PERMISSIVE", + "for": "ALL", + "to": [ + "public" + ], + "using": "(auth.role() = 'service_role'::text)" + }, + "Users read own approvals": { + "name": "Users read own approvals", + "as": "PERMISSIVE", + "for": "SELECT", + "to": [ + "public" + ], + "using": "(auth.uid() = user_id)" + }, + "Users update own approvals": { + "name": "Users update own approvals", + "as": "PERMISSIVE", + "for": "UPDATE", + "to": [ + "public" + ], + "using": "(auth.uid() = user_id)" + } + }, + "checkConstraints": { + "pending_approvals_status_check": { + "name": "pending_approvals_status_check", + "value": "status IN ('pending', 'approved', 'declined', 'expired')" + } + }, + "isRLSEnabled": false + }, + "public.persona_group_audit_events": { + "name": "persona_group_audit_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "group_id": { + "name": "group_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "affected_user_id": { + "name": "affected_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "actor_user_id": { + "name": "actor_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "persona_group_audit_events_affected_idx": { + "name": "persona_group_audit_events_affected_idx", + "columns": [ + { + "expression": "affected_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "first" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "persona_group_audit_events_group_idx": { + "name": "persona_group_audit_events_group_idx", + "columns": [ + { + "expression": "group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "first" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "persona_group_audit_events_group_id_fkey": { + "name": "persona_group_audit_events_group_id_fkey", + "tableFrom": "persona_group_audit_events", + "tableTo": "persona_groups", + "columnsFrom": [ + "group_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "persona_group_audit_events_affected_user_id_fkey": { + "name": "persona_group_audit_events_affected_user_id_fkey", + "tableFrom": "persona_group_audit_events", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "affected_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "persona_group_audit_events_actor_user_id_fkey": { + "name": "persona_group_audit_events_actor_user_id_fkey", + "tableFrom": "persona_group_audit_events", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "actor_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": { + "service role full access on persona_group_audit_events": { + "name": "service role full access on persona_group_audit_events", + "as": "PERMISSIVE", + "for": "ALL", + "to": [ + "public" + ], + "using": "(auth.role() = 'service_role'::text)", + "withCheck": "(auth.role() = 'service_role'::text)" + }, + "affected user reads own audit events": { + "name": "affected user reads own audit events", + "as": "PERMISSIVE", + "for": "SELECT", + "to": [ + "public" + ], + "using": "(auth.uid() = affected_user_id)" + }, + "owner reads group audit events": { + "name": "owner reads group audit events", + "as": "PERMISSIVE", + "for": "SELECT", + "to": [ + "public" + ], + "using": "is_persona_group_manager(group_id)" + } + }, + "checkConstraints": { + "persona_group_audit_events_kind_check": { + "name": "persona_group_audit_events_kind_check", + "value": "kind IN ('brief_shared', 'calendar_queried')" + } + }, + "isRLSEnabled": false + }, + "public.persona_group_constraints": { + "name": "persona_group_constraints", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "group_id": { + "name": "group_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_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": { + "persona_group_constraints_group_idx": { + "name": "persona_group_constraints_group_idx", + "columns": [ + { + "expression": "group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "first" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "first" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "persona_group_constraints_group_id_fkey": { + "name": "persona_group_constraints_group_id_fkey", + "tableFrom": "persona_group_constraints", + "tableTo": "persona_groups", + "columnsFrom": [ + "group_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "persona_group_constraints_created_by_user_id_fkey": { + "name": "persona_group_constraints_created_by_user_id_fkey", + "tableFrom": "persona_group_constraints", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "created_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": { + "service role full access on persona_group_constraints": { + "name": "service role full access on persona_group_constraints", + "as": "PERMISSIVE", + "for": "ALL", + "to": [ + "public" + ], + "using": "(auth.role() = 'service_role'::text)", + "withCheck": "(auth.role() = 'service_role'::text)" + }, + "members read group constraints": { + "name": "members read group constraints", + "as": "PERMISSIVE", + "for": "SELECT", + "to": [ + "public" + ], + "using": "is_persona_group_member(group_id)" + } + }, + "checkConstraints": { + "persona_group_constraints_kind_check": { + "name": "persona_group_constraints_kind_check", + "value": "kind IN ('fact', 'rule', 'voice')" + }, + "persona_group_constraints_text_check": { + "name": "persona_group_constraints_text_check", + "value": "(length(text) >= 1) AND (length(text) <= 400)" + } + }, + "isRLSEnabled": false + }, + "public.persona_group_invitations": { + "name": "persona_group_invitations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "group_id": { + "name": "group_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "invitee_user_id": { + "name": "invitee_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "inviter_user_id": { + "name": "inviter_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "invitee_role": { + "name": "invitee_role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "decided_at": { + "name": "decided_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "(now() + '7 days'::interval)" + } + }, + "indexes": { + "persona_group_invitations_group_status_idx": { + "name": "persona_group_invitations_group_status_idx", + "columns": [ + { + "expression": "group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "persona_group_invitations_invitee_status_idx": { + "name": "persona_group_invitations_invitee_status_idx", + "columns": [ + { + "expression": "invitee_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "persona_group_invitations_open_uniq": { + "name": "persona_group_invitations_open_uniq", + "columns": [ + { + "expression": "group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "invitee_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "status = 'pending'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "persona_group_invitations_group_id_fkey": { + "name": "persona_group_invitations_group_id_fkey", + "tableFrom": "persona_group_invitations", + "tableTo": "persona_groups", + "columnsFrom": [ + "group_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "persona_group_invitations_invitee_user_id_fkey": { + "name": "persona_group_invitations_invitee_user_id_fkey", + "tableFrom": "persona_group_invitations", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "invitee_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "persona_group_invitations_inviter_user_id_fkey": { + "name": "persona_group_invitations_inviter_user_id_fkey", + "tableFrom": "persona_group_invitations", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "inviter_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "persona_group_invitations_invitee_role_check": { + "name": "persona_group_invitations_invitee_role_check", + "value": "invitee_role IN ('admin', 'member')" + }, + "persona_group_invitations_status_check": { + "name": "persona_group_invitations_status_check", + "value": "status IN ('pending', 'accepted', 'declined', 'revoked', 'expired')" + } + }, + "isRLSEnabled": true + }, + "public.persona_group_members": { + "name": "persona_group_members", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "group_id": { + "name": "group_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "permissions": { + "name": "permissions", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "jsonb_build_object('can_see_brief', false, 'can_see_member_briefs', false, 'can_see_group_brief', true, 'can_query_calendar', false, 'can_post', true, 'can_invite', false, 'can_speak_for_group', false)" + }, + "invited_by": { + "name": "invited_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "joined_at": { + "name": "joined_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "persona_group_members_agent_idx": { + "name": "persona_group_members_agent_idx", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "persona_group_members_user_idx": { + "name": "persona_group_members_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "persona_group_members_group_id_fkey": { + "name": "persona_group_members_group_id_fkey", + "tableFrom": "persona_group_members", + "tableTo": "persona_groups", + "columnsFrom": [ + "group_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "persona_group_members_user_id_fkey": { + "name": "persona_group_members_user_id_fkey", + "tableFrom": "persona_group_members", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "persona_group_members_invited_by_fkey": { + "name": "persona_group_members_invited_by_fkey", + "tableFrom": "persona_group_members", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "invited_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "persona_group_members_group_id_user_id_key": { + "name": "persona_group_members_group_id_user_id_key", + "nullsNotDistinct": false, + "columns": [ + "group_id", + "user_id" + ] + } + }, + "policies": { + "service role full access on persona_group_members": { + "name": "service role full access on persona_group_members", + "as": "PERMISSIVE", + "for": "ALL", + "to": [ + "public" + ], + "using": "(auth.role() = 'service_role'::text)", + "withCheck": "(auth.role() = 'service_role'::text)" + }, + "members read roster": { + "name": "members read roster", + "as": "PERMISSIVE", + "for": "SELECT", + "to": [ + "public" + ], + "using": "is_persona_group_member(group_id)" + } + }, + "checkConstraints": { + "persona_group_members_role_check": { + "name": "persona_group_members_role_check", + "value": "role IN ('owner', 'admin', 'member')" + } + }, + "isRLSEnabled": false + }, + "public.persona_group_messages": { + "name": "persona_group_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "group_id": { + "name": "group_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "sender_user_id": { + "name": "sender_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "sender_agent_id": { + "name": "sender_agent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sender_name": { + "name": "sender_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "channel": { + "name": "channel", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'human'" + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reply_to": { + "name": "reply_to", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "persona_group_messages_group_idx": { + "name": "persona_group_messages_group_idx", + "columns": [ + { + "expression": "group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "first" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "persona_group_messages_group_id_fkey": { + "name": "persona_group_messages_group_id_fkey", + "tableFrom": "persona_group_messages", + "tableTo": "persona_groups", + "columnsFrom": [ + "group_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "persona_group_messages_sender_user_id_fkey": { + "name": "persona_group_messages_sender_user_id_fkey", + "tableFrom": "persona_group_messages", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "sender_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "persona_group_messages_reply_to_fkey": { + "name": "persona_group_messages_reply_to_fkey", + "tableFrom": "persona_group_messages", + "tableTo": "persona_group_messages", + "columnsFrom": [ + "reply_to" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": { + "service role full access on persona_group_messages": { + "name": "service role full access on persona_group_messages", + "as": "PERMISSIVE", + "for": "ALL", + "to": [ + "public" + ], + "using": "(auth.role() = 'service_role'::text)", + "withCheck": "(auth.role() = 'service_role'::text)" + }, + "members read messages": { + "name": "members read messages", + "as": "PERMISSIVE", + "for": "SELECT", + "to": [ + "public" + ], + "using": "is_persona_group_member(group_id)" + } + }, + "checkConstraints": { + "persona_group_messages_channel_check": { + "name": "persona_group_messages_channel_check", + "value": "channel IN ('human', 'agent', 'system', 'broadcast')" + } + }, + "isRLSEnabled": false + }, + "public.persona_groups": { + "name": "persona_groups", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "visibility": { + "name": "visibility", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'private'" + }, + "invite_token": { + "name": "invite_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "group_seed_index": { + "name": "group_seed_index", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "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()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "brief_doc_id": { + "name": "brief_doc_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "brief_doc_url": { + "name": "brief_doc_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "join_domain": { + "name": "join_domain", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "persona_groups_join_domain_idx": { + "name": "persona_groups_join_domain_idx", + "columns": [ + { + "expression": "join_domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "(join_domain IS NOT NULL) AND (archived_at IS NULL)", + "concurrently": false, + "method": "btree", + "with": {} + }, + "persona_groups_owner_idx": { + "name": "persona_groups_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "persona_groups_owner_user_id_fkey": { + "name": "persona_groups_owner_user_id_fkey", + "tableFrom": "persona_groups", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "persona_groups_slug_key": { + "name": "persona_groups_slug_key", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + }, + "persona_groups_invite_token_key": { + "name": "persona_groups_invite_token_key", + "nullsNotDistinct": false, + "columns": [ + "invite_token" + ] + } + }, + "policies": { + "service role full access on persona_groups": { + "name": "service role full access on persona_groups", + "as": "PERMISSIVE", + "for": "ALL", + "to": [ + "public" + ], + "using": "(auth.role() = 'service_role'::text)", + "withCheck": "(auth.role() = 'service_role'::text)" + }, + "members read group": { + "name": "members read group", + "as": "PERMISSIVE", + "for": "SELECT", + "to": [ + "public" + ], + "using": "is_persona_group_member(id)" + } + }, + "checkConstraints": { + "persona_groups_visibility_check": { + "name": "persona_groups_visibility_check", + "value": "visibility IN ('private', 'open')" + } + }, + "isRLSEnabled": false + }, + "public.api_tokens": { + "name": "api_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "raw_data": { + "name": "raw_data", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "api_tokens_user_id_fkey": { + "name": "api_tokens_user_id_fkey", + "tableFrom": "api_tokens", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "api_tokens_user_id_provider_key": { + "name": "api_tokens_user_id_provider_key", + "nullsNotDistinct": false, + "columns": [ + "user_id", + "provider" + ] + } + }, + "policies": { + "Service role full access on api_tokens": { + "name": "Service role full access on api_tokens", + "as": "PERMISSIVE", + "for": "ALL", + "to": [ + "public" + ], + "using": "(auth.role() = 'service_role'::text)" + }, + "Users can read own tokens": { + "name": "Users can read own tokens", + "as": "PERMISSIVE", + "for": "SELECT", + "to": [ + "public" + ], + "using": "(auth.uid() = user_id)" + }, + "Users can insert own tokens": { + "name": "Users can insert own tokens", + "as": "PERMISSIVE", + "for": "INSERT", + "to": [ + "public" + ], + "withCheck": "(auth.uid() = user_id)" + }, + "Users can update own tokens": { + "name": "Users can update own tokens", + "as": "PERMISSIVE", + "for": "UPDATE", + "to": [ + "public" + ], + "using": "(auth.uid() = user_id)" + }, + "Users can delete own tokens": { + "name": "Users can delete own tokens", + "as": "PERMISSIVE", + "for": "DELETE", + "to": [ + "public" + ], + "using": "(auth.uid() = user_id)" + } + }, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.github_profiles": { + "name": "github_profiles", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "raw_repos": { + "name": "raw_repos", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "skills": { + "name": "skills", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "projects": { + "name": "projects", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "synced_at": { + "name": "synced_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "github_profiles_synced_at_idx": { + "name": "github_profiles_synced_at_idx", + "columns": [ + { + "expression": "synced_at", + "isExpression": false, + "asc": false, + "nulls": "first" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "github_profiles_user_id_fkey": { + "name": "github_profiles_user_id_fkey", + "tableFrom": "github_profiles", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": { + "Service role full access on github_profiles": { + "name": "Service role full access on github_profiles", + "as": "PERMISSIVE", + "for": "ALL", + "to": [ + "public" + ], + "using": "(auth.role() = 'service_role'::text)" + }, + "Users read own github profile": { + "name": "Users read own github profile", + "as": "PERMISSIVE", + "for": "SELECT", + "to": [ + "public" + ], + "using": "(auth.uid() = user_id)" + }, + "Users delete own github profile": { + "name": "Users delete own github profile", + "as": "PERMISSIVE", + "for": "DELETE", + "to": [ + "public" + ], + "using": "(auth.uid() = user_id)" + } + }, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.linkedin_profiles": { + "name": "linkedin_profiles", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "profile_url": { + "name": "profile_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scraped_at": { + "name": "scraped_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "raw_profile": { + "name": "raw_profile", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "raw_posts": { + "name": "raw_posts", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "linkedin_profiles_scraped_at_idx": { + "name": "linkedin_profiles_scraped_at_idx", + "columns": [ + { + "expression": "scraped_at", + "isExpression": false, + "asc": false, + "nulls": "first" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "linkedin_profiles_user_id_fkey": { + "name": "linkedin_profiles_user_id_fkey", + "tableFrom": "linkedin_profiles", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": { + "Service role full access on linkedin_profiles": { + "name": "Service role full access on linkedin_profiles", + "as": "PERMISSIVE", + "for": "ALL", + "to": [ + "public" + ], + "using": "(auth.role() = 'service_role'::text)" + }, + "Users read own linkedin profile": { + "name": "Users read own linkedin profile", + "as": "PERMISSIVE", + "for": "SELECT", + "to": [ + "public" + ], + "using": "(auth.uid() = user_id)" + }, + "Users delete own linkedin profile": { + "name": "Users delete own linkedin profile", + "as": "PERMISSIVE", + "for": "DELETE", + "to": [ + "public" + ], + "using": "(auth.uid() = user_id)" + } + }, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_pending_state": { + "name": "oauth_pending_state", + "schema": "", + "columns": { + "state": { + "name": "state", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "code_verifier": { + "name": "code_verifier", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "(now() + '00:15:00'::interval)" + } + }, + "indexes": {}, + "foreignKeys": { + "oauth_pending_state_user_id_fkey": { + "name": "oauth_pending_state_user_id_fkey", + "tableFrom": "oauth_pending_state", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.telegram_chat_history": { + "name": "telegram_chat_history", + "schema": "", + "columns": { + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "messages": { + "name": "messages", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "telegram_chat_history_user_idx": { + "name": "telegram_chat_history_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "telegram_chat_history_user_id_fkey": { + "name": "telegram_chat_history_user_id_fkey", + "tableFrom": "telegram_chat_history", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": { + "Service role full access on telegram_chat_history": { + "name": "Service role full access on telegram_chat_history", + "as": "PERMISSIVE", + "for": "ALL", + "to": [ + "public" + ], + "using": "(auth.role() = 'service_role'::text)" + }, + "Users read own telegram history": { + "name": "Users read own telegram history", + "as": "PERMISSIVE", + "for": "SELECT", + "to": [ + "public" + ], + "using": "(auth.uid() = user_id)" + } + }, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.telegram_links": { + "name": "telegram_links", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "linked_at": { + "name": "linked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "telegram_links_chat_idx": { + "name": "telegram_links_chat_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "telegram_links_user_id_fkey": { + "name": "telegram_links_user_id_fkey", + "tableFrom": "telegram_links", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "telegram_links_chat_id_key": { + "name": "telegram_links_chat_id_key", + "nullsNotDistinct": false, + "columns": [ + "chat_id" + ] + } + }, + "policies": { + "Service role full access on telegram_links": { + "name": "Service role full access on telegram_links", + "as": "PERMISSIVE", + "for": "ALL", + "to": [ + "public" + ], + "using": "(auth.role() = 'service_role'::text)" + }, + "Users read own telegram link": { + "name": "Users read own telegram link", + "as": "PERMISSIVE", + "for": "SELECT", + "to": [ + "public" + ], + "using": "(auth.uid() = user_id)" + }, + "Users delete own telegram link": { + "name": "Users delete own telegram link", + "as": "PERMISSIVE", + "for": "DELETE", + "to": [ + "public" + ], + "using": "(auth.uid() = user_id)" + } + }, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.twitter_profiles": { + "name": "twitter_profiles", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "handle": { + "name": "handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scraped_at": { + "name": "scraped_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "raw_tweets": { + "name": "raw_tweets", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "facts": { + "name": "facts", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "twitter_profiles_scraped_at_idx": { + "name": "twitter_profiles_scraped_at_idx", + "columns": [ + { + "expression": "scraped_at", + "isExpression": false, + "asc": false, + "nulls": "first" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "twitter_profiles_user_id_fkey": { + "name": "twitter_profiles_user_id_fkey", + "tableFrom": "twitter_profiles", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": { + "Service role full access on twitter_profiles": { + "name": "Service role full access on twitter_profiles", + "as": "PERMISSIVE", + "for": "ALL", + "to": [ + "public" + ], + "using": "(auth.role() = 'service_role'::text)" + }, + "Users read own twitter profile": { + "name": "Users read own twitter profile", + "as": "PERMISSIVE", + "for": "SELECT", + "to": [ + "public" + ], + "using": "(auth.uid() = user_id)" + }, + "Users delete own twitter profile": { + "name": "Users delete own twitter profile", + "as": "PERMISSIVE", + "for": "DELETE", + "to": [ + "public" + ], + "using": "(auth.uid() = user_id)" + } + }, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/db/persona/migrations/meta/0001_snapshot.json b/packages/db/persona/migrations/meta/0001_snapshot.json new file mode 100644 index 0000000..b2cf9ed --- /dev/null +++ b/packages/db/persona/migrations/meta/0001_snapshot.json @@ -0,0 +1,4536 @@ +{ + "id": "3011212a-2f8c-4d00-ae69-498aff29cb3b", + "prevId": "90b55989-e810-44ec-8aca-311ce22f3745", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.persona_agents": { + "name": "persona_agents", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "derivation_index": { + "name": "derivation_index", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_handle": { + "name": "agent_handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "capabilities": { + "name": "capabilities", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "profile": { + "name": "profile", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "webhook_url": { + "name": "webhook_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "brief_doc_id": { + "name": "brief_doc_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "brief_doc_url": { + "name": "brief_doc_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "brief_doc_revision_id": { + "name": "brief_doc_revision_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "brief_content": { + "name": "brief_content", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "search_vector": { + "name": "search_vector", + "type": "tsvector", + "primaryKey": false, + "notNull": false + }, + "auto_extract_todos": { + "name": "auto_extract_todos", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + } + }, + "indexes": { + "persona_agents_search_vector_idx": { + "name": "persona_agents_search_vector_idx", + "columns": [ + { + "expression": "search_vector", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": { + "persona_agents_user_id_fkey": { + "name": "persona_agents_user_id_fkey", + "tableFrom": "persona_agents", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "persona_agents_agent_id_key": { + "name": "persona_agents_agent_id_key", + "nullsNotDistinct": false, + "columns": [ + "agent_id" + ] + }, + "persona_agents_derivation_index_key": { + "name": "persona_agents_derivation_index_key", + "nullsNotDistinct": false, + "columns": [ + "derivation_index" + ] + } + }, + "policies": { + "Service role full access on persona_agents": { + "name": "Service role full access on persona_agents", + "as": "PERMISSIVE", + "for": "ALL", + "to": [ + "public" + ], + "using": "(auth.role() = 'service_role'::text)" + }, + "Users can read own persona": { + "name": "Users can read own persona", + "as": "PERMISSIVE", + "for": "SELECT", + "to": [ + "public" + ], + "using": "(auth.uid() = user_id)" + }, + "Users can update own persona": { + "name": "Users can update own persona", + "as": "PERMISSIVE", + "for": "UPDATE", + "to": [ + "public" + ], + "using": "(auth.uid() = user_id)" + } + }, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.callback_results": { + "name": "callback_results", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "callback_id": { + "name": "callback_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "peer_agent_id": { + "name": "peer_agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "task_state": { + "name": "task_state", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reply_text": { + "name": "reply_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "raw_event": { + "name": "raw_event", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "delivered_to_ui": { + "name": "delivered_to_ui", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "callback_results_thread_idx": { + "name": "callback_results_thread_idx", + "columns": [ + { + "expression": "thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "first" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "callback_results_user_idx": { + "name": "callback_results_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "delivered_to_ui", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "first" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "callback_results_callback_id_fkey": { + "name": "callback_results_callback_id_fkey", + "tableFrom": "callback_results", + "tableTo": "outbound_callbacks", + "columnsFrom": [ + "callback_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "callback_results_user_id_fkey": { + "name": "callback_results_user_id_fkey", + "tableFrom": "callback_results", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "callback_results_callback_id_key": { + "name": "callback_results_callback_id_key", + "nullsNotDistinct": false, + "columns": [ + "callback_id" + ] + } + }, + "policies": { + "Service role full access on callback_results": { + "name": "Service role full access on callback_results", + "as": "PERMISSIVE", + "for": "ALL", + "to": [ + "public" + ], + "using": "(auth.role() = 'service_role'::text)" + }, + "Owner reads callback_results": { + "name": "Owner reads callback_results", + "as": "PERMISSIVE", + "for": "SELECT", + "to": [ + "public" + ], + "using": "(user_id = auth.uid())" + }, + "Owner marks delivered": { + "name": "Owner marks delivered", + "as": "PERMISSIVE", + "for": "UPDATE", + "to": [ + "public" + ], + "using": "(user_id = auth.uid())", + "withCheck": "(user_id = auth.uid())" + } + }, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.outbound_callbacks": { + "name": "outbound_callbacks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "peer_agent_id": { + "name": "peer_agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "peer_task_id": { + "name": "peer_task_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "our_message_id": { + "name": "our_message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "origin_kind": { + "name": "origin_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "origin_ref": { + "name": "origin_ref", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "push_token": { + "name": "push_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "(now() + '24:00:00'::interval)" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "peer_a2a_url": { + "name": "peer_a2a_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_state": { + "name": "last_state", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_event": { + "name": "last_event", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "last_event_at": { + "name": "last_event_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "answer_text": { + "name": "answer_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "terminal_state": { + "name": "terminal_state", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "outbound_callbacks_expires_idx": { + "name": "outbound_callbacks_expires_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "status = 'pending'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "outbound_callbacks_peer_task_idx": { + "name": "outbound_callbacks_peer_task_idx", + "columns": [ + { + "expression": "peer_agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "peer_task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "peer_task_id IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "outbound_callbacks_user_idx": { + "name": "outbound_callbacks_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "first" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "outbound_callbacks_user_id_fkey": { + "name": "outbound_callbacks_user_id_fkey", + "tableFrom": "outbound_callbacks", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "outbound_callbacks_push_token_key": { + "name": "outbound_callbacks_push_token_key", + "nullsNotDistinct": false, + "columns": [ + "push_token" + ] + } + }, + "policies": { + "Service role full access on outbound_callbacks": { + "name": "Service role full access on outbound_callbacks", + "as": "PERMISSIVE", + "for": "ALL", + "to": [ + "public" + ], + "using": "(auth.role() = 'service_role'::text)" + }, + "Owner reads outbound_callbacks": { + "name": "Owner reads outbound_callbacks", + "as": "PERMISSIVE", + "for": "SELECT", + "to": [ + "public" + ], + "using": "(user_id = auth.uid())" + } + }, + "checkConstraints": { + "outbound_callbacks_status_check": { + "name": "outbound_callbacks_status_check", + "value": "status IN ('pending', 'received', 'expired', 'failed')" + } + }, + "isRLSEnabled": false + }, + "public.enriched_companies": { + "name": "enriched_companies", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "cache_key": { + "name": "cache_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "company_name": { + "name": "company_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "company_url": { + "name": "company_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "linkedin_url": { + "name": "linkedin_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "industry": { + "name": "industry", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "employee_count": { + "name": "employee_count", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "revenue": { + "name": "revenue", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "city": { + "name": "city", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "region_code": { + "name": "region_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "country_code": { + "name": "country_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "enriched_companies_user_created_idx": { + "name": "enriched_companies_user_created_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "first" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "enriched_companies_user_id_fkey": { + "name": "enriched_companies_user_id_fkey", + "tableFrom": "enriched_companies", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "enriched_companies_user_id_cache_key_key": { + "name": "enriched_companies_user_id_cache_key_key", + "nullsNotDistinct": false, + "columns": [ + "user_id", + "cache_key" + ] + } + }, + "policies": { + "Service role full access on enriched_companies": { + "name": "Service role full access on enriched_companies", + "as": "PERMISSIVE", + "for": "ALL", + "to": [ + "public" + ], + "using": "(auth.role() = 'service_role'::text)", + "withCheck": "(auth.role() = 'service_role'::text)" + }, + "Users can CRUD own enriched companies": { + "name": "Users can CRUD own enriched companies", + "as": "PERMISSIVE", + "for": "ALL", + "to": [ + "public" + ], + "using": "(auth.uid() = user_id)", + "withCheck": "(auth.uid() = user_id)" + } + }, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.enriched_contacts": { + "name": "enriched_contacts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "cache_key": { + "name": "cache_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "employee_linkedin": { + "name": "employee_linkedin", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "first_name": { + "name": "first_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_name": { + "name": "last_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "company_name": { + "name": "company_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "company_url": { + "name": "company_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "phone": { + "name": "phone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "phone_type": { + "name": "phone_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "has_email": { + "name": "has_email", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "has_phone": { + "name": "has_phone", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enriched_at": { + "name": "enriched_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "enriched_contacts_user_created_idx": { + "name": "enriched_contacts_user_created_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "first" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "enriched_contacts_user_email_idx": { + "name": "enriched_contacts_user_email_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "enriched_contacts_user_linkedin_idx": { + "name": "enriched_contacts_user_linkedin_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "employee_linkedin", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "enriched_contacts_user_id_fkey": { + "name": "enriched_contacts_user_id_fkey", + "tableFrom": "enriched_contacts", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "enriched_contacts_user_id_cache_key_key": { + "name": "enriched_contacts_user_id_cache_key_key", + "nullsNotDistinct": false, + "columns": [ + "user_id", + "cache_key" + ] + } + }, + "policies": { + "Service role full access on enriched_contacts": { + "name": "Service role full access on enriched_contacts", + "as": "PERMISSIVE", + "for": "ALL", + "to": [ + "public" + ], + "using": "(auth.role() = 'service_role'::text)", + "withCheck": "(auth.role() = 'service_role'::text)" + }, + "Users can CRUD own enriched contacts": { + "name": "Users can CRUD own enriched contacts", + "as": "PERMISSIVE", + "for": "ALL", + "to": [ + "public" + ], + "using": "(auth.uid() = user_id)", + "withCheck": "(auth.uid() = user_id)" + } + }, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.suggested_contact_runs": { + "name": "suggested_contact_runs", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "last_run_at": { + "name": "last_run_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_manual_at": { + "name": "last_manual_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "detail": { + "name": "detail", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "suggested_contact_runs_user_id_fkey": { + "name": "suggested_contact_runs_user_id_fkey", + "tableFrom": "suggested_contact_runs", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": { + "Service role full access on suggested_contact_runs": { + "name": "Service role full access on suggested_contact_runs", + "as": "PERMISSIVE", + "for": "ALL", + "to": [ + "public" + ], + "using": "(auth.role() = 'service_role'::text)", + "withCheck": "(auth.role() = 'service_role'::text)" + }, + "Users can read own suggestion run state": { + "name": "Users can read own suggestion run state", + "as": "PERMISSIVE", + "for": "SELECT", + "to": [ + "public" + ], + "using": "(auth.uid() = user_id)" + } + }, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.suggested_contacts": { + "name": "suggested_contacts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "cache_key": { + "name": "cache_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "recipe": { + "name": "recipe", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "score": { + "name": "score", + "type": "real", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "rank": { + "name": "rank", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "generated_at": { + "name": "generated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "suggested_contacts_user_rank_idx": { + "name": "suggested_contacts_user_rank_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "recipe", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "rank", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "suggested_contacts_user_id_fkey": { + "name": "suggested_contacts_user_id_fkey", + "tableFrom": "suggested_contacts", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "suggested_contacts_user_id_cache_key_recipe_key": { + "name": "suggested_contacts_user_id_cache_key_recipe_key", + "nullsNotDistinct": false, + "columns": [ + "user_id", + "cache_key", + "recipe" + ] + } + }, + "policies": { + "Service role full access on suggested_contacts": { + "name": "Service role full access on suggested_contacts", + "as": "PERMISSIVE", + "for": "ALL", + "to": [ + "public" + ], + "using": "(auth.role() = 'service_role'::text)", + "withCheck": "(auth.role() = 'service_role'::text)" + }, + "Users can CRUD own suggested contacts": { + "name": "Users can CRUD own suggested contacts", + "as": "PERMISSIVE", + "for": "ALL", + "to": [ + "public" + ], + "using": "(auth.uid() = user_id)", + "withCheck": "(auth.uid() = user_id)" + } + }, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.brief_todos": { + "name": "brief_todos", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_text": { + "name": "source_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "done": { + "name": "done", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "done_at": { + "name": "done_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()" + }, + "group_id": { + "name": "group_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "assigned_by_user_id": { + "name": "assigned_by_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "brief_todos_group_idx": { + "name": "brief_todos_group_idx", + "columns": [ + { + "expression": "group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "brief_todos_user_idx": { + "name": "brief_todos_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "done", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "first" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "brief_todos_user_title_uniq": { + "name": "brief_todos_user_title_uniq", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "title", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "done = false", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "brief_todos_user_id_fkey": { + "name": "brief_todos_user_id_fkey", + "tableFrom": "brief_todos", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "brief_todos_group_id_fkey": { + "name": "brief_todos_group_id_fkey", + "tableFrom": "brief_todos", + "tableTo": "persona_groups", + "columnsFrom": [ + "group_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "brief_todos_assigned_by_user_id_fkey": { + "name": "brief_todos_assigned_by_user_id_fkey", + "tableFrom": "brief_todos", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "assigned_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": { + "Service role full access on brief_todos": { + "name": "Service role full access on brief_todos", + "as": "PERMISSIVE", + "for": "ALL", + "to": [ + "public" + ], + "using": "(auth.role() = 'service_role'::text)", + "withCheck": "(auth.role() = 'service_role'::text)" + }, + "Users can read own brief todos": { + "name": "Users can read own brief todos", + "as": "PERMISSIVE", + "for": "SELECT", + "to": [ + "public" + ], + "using": "(auth.uid() = user_id)" + }, + "Users can update own brief todos": { + "name": "Users can update own brief todos", + "as": "PERMISSIVE", + "for": "UPDATE", + "to": [ + "public" + ], + "using": "(auth.uid() = user_id)" + }, + "Users can delete own brief todos": { + "name": "Users can delete own brief todos", + "as": "PERMISSIVE", + "for": "DELETE", + "to": [ + "public" + ], + "using": "(auth.uid() = user_id)" + }, + "Group members can read group todos": { + "name": "Group members can read group todos", + "as": "PERMISSIVE", + "for": "SELECT", + "to": [ + "public" + ], + "using": "(group_id IS NOT NULL) AND is_persona_group_member(group_id)" + } + }, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat_messages": { + "name": "chat_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actions": { + "name": "actions", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "action_summary": { + "name": "action_summary", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + } + }, + "indexes": {}, + "foreignKeys": { + "chat_messages_user_id_fkey": { + "name": "chat_messages_user_id_fkey", + "tableFrom": "chat_messages", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": { + "Service role full access on chat_messages": { + "name": "Service role full access on chat_messages", + "as": "PERMISSIVE", + "for": "ALL", + "to": [ + "public" + ], + "using": "(auth.role() = 'service_role'::text)" + }, + "Users can read own messages": { + "name": "Users can read own messages", + "as": "PERMISSIVE", + "for": "SELECT", + "to": [ + "public" + ], + "using": "(auth.uid() = user_id)" + } + }, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.published_pages": { + "name": "published_pages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "format": { + "name": "format", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "visibility": { + "name": "visibility", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'unlisted'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "published_pages_expires_idx": { + "name": "published_pages_expires_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "expires_at IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "published_pages_slug_idx": { + "name": "published_pages_slug_idx", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "published_pages_user_idx": { + "name": "published_pages_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "first" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "published_pages_slug_key": { + "name": "published_pages_slug_key", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": { + "Service role full access on published_pages": { + "name": "Service role full access on published_pages", + "as": "PERMISSIVE", + "for": "ALL", + "to": [ + "public" + ], + "using": "(auth.role() = 'service_role'::text)" + }, + "Public read for public pages": { + "name": "Public read for public pages", + "as": "PERMISSIVE", + "for": "SELECT", + "to": [ + "public" + ], + "using": "(visibility = 'public'::text)" + }, + "Users can CRUD own pages": { + "name": "Users can CRUD own pages", + "as": "PERMISSIVE", + "for": "ALL", + "to": [ + "public" + ], + "using": "(auth.uid() = user_id)", + "withCheck": "(auth.uid() = user_id)" + } + }, + "checkConstraints": { + "published_pages_format_check": { + "name": "published_pages_format_check", + "value": "format IN ('html', 'markdown')" + }, + "published_pages_visibility_check": { + "name": "published_pages_visibility_check", + "value": "visibility IN ('public', 'unlisted', 'private')" + } + }, + "isRLSEnabled": false + }, + "public.a2a_tasks": { + "name": "a2a_tasks", + "schema": "", + "columns": { + "task_id": { + "name": "task_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "context_id": { + "name": "context_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'submitted'" + }, + "permission_snapshot": { + "name": "permission_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "history": { + "name": "history", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "artifacts": { + "name": "artifacts", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "push_url": { + "name": "push_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "push_token": { + "name": "push_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_message_id": { + "name": "last_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "idle_ttl_ms": { + "name": "idle_ttl_ms", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 3600000 + }, + "idle_until": { + "name": "idle_until", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "terminal_at": { + "name": "terminal_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "failure_reason": { + "name": "failure_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "a2a_tasks_context_idx": { + "name": "a2a_tasks_context_idx", + "columns": [ + { + "expression": "context_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": false, + "nulls": "first" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "a2a_tasks_state_idle_idx": { + "name": "a2a_tasks_state_idle_idx", + "columns": [ + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idle_until", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "state IN ('input-required', 'auth-required')", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "a2a_tasks_context_id_fkey": { + "name": "a2a_tasks_context_id_fkey", + "tableFrom": "a2a_tasks", + "tableTo": "dm_threads", + "columnsFrom": [ + "context_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": { + "Service role full access on a2a_tasks": { + "name": "Service role full access on a2a_tasks", + "as": "PERMISSIVE", + "for": "ALL", + "to": [ + "public" + ], + "using": "(auth.role() = 'service_role'::text)" + }, + "Participants can read a2a_tasks": { + "name": "Participants can read a2a_tasks", + "as": "PERMISSIVE", + "for": "SELECT", + "to": [ + "public" + ], + "using": "EXISTS ( SELECT 1\n FROM dm_threads t\n WHERE ((t.id = a2a_tasks.context_id) AND ((t.initiator_id = (auth.uid())::text) OR (t.receiver_id = (auth.uid())::text) OR (EXISTS ( SELECT 1\n FROM persona_agents p\n WHERE ((p.user_id = auth.uid()) AND ((p.agent_id = t.initiator_id) OR (p.agent_id = t.receiver_id))))))))" + } + }, + "checkConstraints": { + "a2a_tasks_state_check": { + "name": "a2a_tasks_state_check", + "value": "state IN ('submitted', 'working', 'input-required', 'auth-required', 'completed', 'canceled', 'failed', 'rejected')" + } + }, + "isRLSEnabled": false + }, + "public.agent_tasks": { + "name": "agent_tasks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "thread_id": { + "name": "thread_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'meeting'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'proposed'" + }, + "initiator_user_id": { + "name": "initiator_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "recipient_user_id": { + "name": "recipient_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "initiator_agent_id": { + "name": "initiator_agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "recipient_agent_id": { + "name": "recipient_agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "history": { + "name": "history", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "calendar_event_ids": { + "name": "calendar_event_ids", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "agent_tasks_initiator_idx": { + "name": "agent_tasks_initiator_idx", + "columns": [ + { + "expression": "initiator_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agent_tasks_recipient_idx": { + "name": "agent_tasks_recipient_idx", + "columns": [ + { + "expression": "recipient_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agent_tasks_status_idx": { + "name": "agent_tasks_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agent_tasks_thread_idx": { + "name": "agent_tasks_thread_idx", + "columns": [ + { + "expression": "thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agent_tasks_one_open_proposal": { + "name": "agent_tasks_one_open_proposal", + "columns": [ + { + "expression": "thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "status IN ('proposed', 'countered', 'accepted')", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_tasks_thread_id_fkey": { + "name": "agent_tasks_thread_id_fkey", + "tableFrom": "agent_tasks", + "tableTo": "dm_threads", + "columnsFrom": [ + "thread_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "agent_tasks_initiator_user_id_fkey": { + "name": "agent_tasks_initiator_user_id_fkey", + "tableFrom": "agent_tasks", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "initiator_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "agent_tasks_recipient_user_id_fkey": { + "name": "agent_tasks_recipient_user_id_fkey", + "tableFrom": "agent_tasks", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "recipient_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": { + "Service role full access on agent_tasks": { + "name": "Service role full access on agent_tasks", + "as": "PERMISSIVE", + "for": "ALL", + "to": [ + "public" + ], + "using": "(auth.role() = 'service_role'::text)" + }, + "Participants can read agent_tasks": { + "name": "Participants can read agent_tasks", + "as": "PERMISSIVE", + "for": "SELECT", + "to": [ + "public" + ], + "using": "(auth.uid() = initiator_user_id) OR (auth.uid() = recipient_user_id)" + }, + "Participants can update agent_tasks": { + "name": "Participants can update agent_tasks", + "as": "PERMISSIVE", + "for": "UPDATE", + "to": [ + "public" + ], + "using": "(auth.uid() = initiator_user_id) OR (auth.uid() = recipient_user_id)" + } + }, + "checkConstraints": { + "agent_tasks_type_check": { + "name": "agent_tasks_type_check", + "value": "type = 'meeting'" + }, + "agent_tasks_status_check": { + "name": "agent_tasks_status_check", + "value": "status IN ('proposed', 'countered', 'accepted', 'scheduled', 'declined', 'cancelled', 'book_failed')" + } + }, + "isRLSEnabled": false + }, + "public.dm_messages": { + "name": "dm_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "thread_id": { + "name": "thread_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "sender_id": { + "name": "sender_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sender_type": { + "name": "sender_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'human'" + }, + "channel": { + "name": "channel", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'human'" + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "dm_messages_thread_id_fkey": { + "name": "dm_messages_thread_id_fkey", + "tableFrom": "dm_messages", + "tableTo": "dm_threads", + "columnsFrom": [ + "thread_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": { + "Service role full access on dm_messages": { + "name": "Service role full access on dm_messages", + "as": "PERMISSIVE", + "for": "ALL", + "to": [ + "public" + ], + "using": "(auth.role() = 'service_role'::text)" + }, + "Users can read messages in non-blocked threads": { + "name": "Users can read messages in non-blocked threads", + "as": "PERMISSIVE", + "for": "SELECT", + "to": [ + "public" + ], + "using": "EXISTS ( SELECT 1\n FROM dm_threads t\n WHERE ((t.id = dm_messages.thread_id) AND ((t.initiator_id = (auth.uid())::text) OR (t.receiver_id = (auth.uid())::text) OR (EXISTS ( SELECT 1\n FROM persona_agents p\n WHERE ((p.user_id = auth.uid()) AND ((p.agent_id = t.initiator_id) OR (p.agent_id = t.receiver_id)))))) AND (t.status <> ALL (ARRAY['blocked'::text, 'revoked'::text]))))" + }, + "Users can send messages in accepted threads": { + "name": "Users can send messages in accepted threads", + "as": "PERMISSIVE", + "for": "INSERT", + "to": [ + "public" + ], + "withCheck": "(((auth.uid())::text = sender_id) OR (EXISTS ( SELECT 1\n FROM persona_agents\n WHERE ((persona_agents.user_id = auth.uid()) AND (persona_agents.agent_id = dm_messages.sender_id))))) AND (EXISTS ( SELECT 1\n FROM dm_threads t\n WHERE ((t.id = dm_messages.thread_id) AND ((t.initiator_id = (auth.uid())::text) OR (t.receiver_id = (auth.uid())::text) OR (EXISTS ( SELECT 1\n FROM persona_agents p\n WHERE ((p.user_id = auth.uid()) AND ((p.agent_id = t.initiator_id) OR (p.agent_id = t.receiver_id)))))) AND (t.status <> ALL (ARRAY['blocked'::text, 'declined'::text, 'revoked'::text])))))" + } + }, + "checkConstraints": { + "dm_messages_sender_type_check": { + "name": "dm_messages_sender_type_check", + "value": "sender_type IN ('human', 'agent', 'system')" + }, + "dm_messages_channel_check": { + "name": "dm_messages_channel_check", + "value": "channel IN ('human', 'agent')" + } + }, + "isRLSEnabled": false + }, + "public.dm_threads": { + "name": "dm_threads", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "initiator_id": { + "name": "initiator_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "receiver_id": { + "name": "receiver_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "initiator_name": { + "name": "initiator_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "''" + }, + "receiver_name": { + "name": "receiver_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "''" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "lifecycle": { + "name": "lifecycle", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "initiator_mode": { + "name": "initiator_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'agent'" + }, + "receiver_mode": { + "name": "receiver_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'agent'" + }, + "permissions": { + "name": "permissions", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "jsonb_build_object('can_request_meetings', true, 'can_query_availability', false, 'can_view_full_profile', false, 'can_post_on_my_behalf', false)" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "dm_threads_lifecycle_idx": { + "name": "dm_threads_lifecycle_idx", + "columns": [ + { + "expression": "lifecycle", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "dm_threads_initiator_id_receiver_id_key": { + "name": "dm_threads_initiator_id_receiver_id_key", + "nullsNotDistinct": false, + "columns": [ + "initiator_id", + "receiver_id" + ] + } + }, + "policies": { + "Service role full access on dm_threads": { + "name": "Service role full access on dm_threads", + "as": "PERMISSIVE", + "for": "ALL", + "to": [ + "public" + ], + "using": "(auth.role() = 'service_role'::text)" + }, + "Users can read own threads": { + "name": "Users can read own threads", + "as": "PERMISSIVE", + "for": "SELECT", + "to": [ + "public" + ], + "using": "((auth.uid())::text = dm_threads.initiator_id) OR ((auth.uid())::text = dm_threads.receiver_id) OR (EXISTS ( SELECT 1\n FROM persona_agents\n WHERE ((persona_agents.user_id = auth.uid()) AND ((persona_agents.agent_id = dm_threads.initiator_id) OR (persona_agents.agent_id = dm_threads.receiver_id)))))" + }, + "Participants can update threads": { + "name": "Participants can update threads", + "as": "PERMISSIVE", + "for": "UPDATE", + "to": [ + "public" + ], + "using": "((auth.uid())::text = dm_threads.initiator_id) OR ((auth.uid())::text = dm_threads.receiver_id) OR (EXISTS ( SELECT 1\n FROM persona_agents\n WHERE ((persona_agents.user_id = auth.uid()) AND ((persona_agents.agent_id = dm_threads.initiator_id) OR (persona_agents.agent_id = dm_threads.receiver_id)))))" + }, + "Users can start threads": { + "name": "Users can start threads", + "as": "PERMISSIVE", + "for": "INSERT", + "to": [ + "public" + ], + "withCheck": "((auth.uid())::text = initiator_id) OR (EXISTS ( SELECT 1\n FROM persona_agents\n WHERE ((persona_agents.user_id = auth.uid()) AND (persona_agents.agent_id = dm_threads.initiator_id))))" + } + }, + "checkConstraints": { + "dm_threads_status_check": { + "name": "dm_threads_status_check", + "value": "status IN ('pending', 'accepted', 'declined', 'blocked', 'revoked')" + }, + "dm_threads_initiator_mode_check": { + "name": "dm_threads_initiator_mode_check", + "value": "initiator_mode IN ('human', 'agent')" + }, + "dm_threads_receiver_mode_check": { + "name": "dm_threads_receiver_mode_check", + "value": "receiver_mode IN ('human', 'agent')" + } + }, + "isRLSEnabled": false + }, + "public.pending_approvals": { + "name": "pending_approvals", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "tool_name": { + "name": "tool_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_args": { + "name": "tool_args", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "result": { + "name": "result", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "decided_at": { + "name": "decided_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "(now() + '24:00:00'::interval)" + } + }, + "indexes": { + "pending_approvals_thread_idx": { + "name": "pending_approvals_thread_idx", + "columns": [ + { + "expression": "thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pending_approvals_user_status_idx": { + "name": "pending_approvals_user_status_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pending_approvals_user_id_fkey": { + "name": "pending_approvals_user_id_fkey", + "tableFrom": "pending_approvals", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pending_approvals_thread_id_fkey": { + "name": "pending_approvals_thread_id_fkey", + "tableFrom": "pending_approvals", + "tableTo": "dm_threads", + "columnsFrom": [ + "thread_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": { + "Service role full access on pending_approvals": { + "name": "Service role full access on pending_approvals", + "as": "PERMISSIVE", + "for": "ALL", + "to": [ + "public" + ], + "using": "(auth.role() = 'service_role'::text)" + }, + "Users read own approvals": { + "name": "Users read own approvals", + "as": "PERMISSIVE", + "for": "SELECT", + "to": [ + "public" + ], + "using": "(auth.uid() = user_id)" + }, + "Users update own approvals": { + "name": "Users update own approvals", + "as": "PERMISSIVE", + "for": "UPDATE", + "to": [ + "public" + ], + "using": "(auth.uid() = user_id)" + } + }, + "checkConstraints": { + "pending_approvals_status_check": { + "name": "pending_approvals_status_check", + "value": "status IN ('pending', 'approved', 'declined', 'expired')" + } + }, + "isRLSEnabled": false + }, + "public.persona_group_audit_events": { + "name": "persona_group_audit_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "group_id": { + "name": "group_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "affected_user_id": { + "name": "affected_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "actor_user_id": { + "name": "actor_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "persona_group_audit_events_affected_idx": { + "name": "persona_group_audit_events_affected_idx", + "columns": [ + { + "expression": "affected_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "first" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "persona_group_audit_events_group_idx": { + "name": "persona_group_audit_events_group_idx", + "columns": [ + { + "expression": "group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "first" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "persona_group_audit_events_group_id_fkey": { + "name": "persona_group_audit_events_group_id_fkey", + "tableFrom": "persona_group_audit_events", + "tableTo": "persona_groups", + "columnsFrom": [ + "group_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "persona_group_audit_events_affected_user_id_fkey": { + "name": "persona_group_audit_events_affected_user_id_fkey", + "tableFrom": "persona_group_audit_events", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "affected_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "persona_group_audit_events_actor_user_id_fkey": { + "name": "persona_group_audit_events_actor_user_id_fkey", + "tableFrom": "persona_group_audit_events", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "actor_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": { + "service role full access on persona_group_audit_events": { + "name": "service role full access on persona_group_audit_events", + "as": "PERMISSIVE", + "for": "ALL", + "to": [ + "public" + ], + "using": "(auth.role() = 'service_role'::text)", + "withCheck": "(auth.role() = 'service_role'::text)" + }, + "affected user reads own audit events": { + "name": "affected user reads own audit events", + "as": "PERMISSIVE", + "for": "SELECT", + "to": [ + "public" + ], + "using": "(auth.uid() = affected_user_id)" + }, + "owner reads group audit events": { + "name": "owner reads group audit events", + "as": "PERMISSIVE", + "for": "SELECT", + "to": [ + "public" + ], + "using": "is_persona_group_manager(group_id)" + } + }, + "checkConstraints": { + "persona_group_audit_events_kind_check": { + "name": "persona_group_audit_events_kind_check", + "value": "kind IN ('brief_shared', 'calendar_queried')" + } + }, + "isRLSEnabled": false + }, + "public.persona_group_constraints": { + "name": "persona_group_constraints", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "group_id": { + "name": "group_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_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": { + "persona_group_constraints_group_idx": { + "name": "persona_group_constraints_group_idx", + "columns": [ + { + "expression": "group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "first" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "first" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "persona_group_constraints_group_id_fkey": { + "name": "persona_group_constraints_group_id_fkey", + "tableFrom": "persona_group_constraints", + "tableTo": "persona_groups", + "columnsFrom": [ + "group_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "persona_group_constraints_created_by_user_id_fkey": { + "name": "persona_group_constraints_created_by_user_id_fkey", + "tableFrom": "persona_group_constraints", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "created_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": { + "service role full access on persona_group_constraints": { + "name": "service role full access on persona_group_constraints", + "as": "PERMISSIVE", + "for": "ALL", + "to": [ + "public" + ], + "using": "(auth.role() = 'service_role'::text)", + "withCheck": "(auth.role() = 'service_role'::text)" + }, + "members read group constraints": { + "name": "members read group constraints", + "as": "PERMISSIVE", + "for": "SELECT", + "to": [ + "public" + ], + "using": "is_persona_group_member(group_id)" + } + }, + "checkConstraints": { + "persona_group_constraints_kind_check": { + "name": "persona_group_constraints_kind_check", + "value": "kind IN ('fact', 'rule', 'voice')" + }, + "persona_group_constraints_text_check": { + "name": "persona_group_constraints_text_check", + "value": "(length(text) >= 1) AND (length(text) <= 400)" + } + }, + "isRLSEnabled": false + }, + "public.persona_group_invitations": { + "name": "persona_group_invitations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "group_id": { + "name": "group_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "invitee_user_id": { + "name": "invitee_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "inviter_user_id": { + "name": "inviter_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "invitee_role": { + "name": "invitee_role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "decided_at": { + "name": "decided_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "(now() + '7 days'::interval)" + } + }, + "indexes": { + "persona_group_invitations_group_status_idx": { + "name": "persona_group_invitations_group_status_idx", + "columns": [ + { + "expression": "group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "persona_group_invitations_invitee_status_idx": { + "name": "persona_group_invitations_invitee_status_idx", + "columns": [ + { + "expression": "invitee_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "persona_group_invitations_open_uniq": { + "name": "persona_group_invitations_open_uniq", + "columns": [ + { + "expression": "group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "invitee_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "status = 'pending'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "persona_group_invitations_group_id_fkey": { + "name": "persona_group_invitations_group_id_fkey", + "tableFrom": "persona_group_invitations", + "tableTo": "persona_groups", + "columnsFrom": [ + "group_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "persona_group_invitations_invitee_user_id_fkey": { + "name": "persona_group_invitations_invitee_user_id_fkey", + "tableFrom": "persona_group_invitations", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "invitee_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "persona_group_invitations_inviter_user_id_fkey": { + "name": "persona_group_invitations_inviter_user_id_fkey", + "tableFrom": "persona_group_invitations", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "inviter_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "persona_group_invitations_invitee_role_check": { + "name": "persona_group_invitations_invitee_role_check", + "value": "invitee_role IN ('admin', 'member')" + }, + "persona_group_invitations_status_check": { + "name": "persona_group_invitations_status_check", + "value": "status IN ('pending', 'accepted', 'declined', 'revoked', 'expired')" + } + }, + "isRLSEnabled": true + }, + "public.persona_group_members": { + "name": "persona_group_members", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "group_id": { + "name": "group_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "permissions": { + "name": "permissions", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "jsonb_build_object('can_see_brief', false, 'can_see_member_briefs', false, 'can_see_group_brief', true, 'can_query_calendar', false, 'can_post', true, 'can_invite', false, 'can_speak_for_group', false)" + }, + "invited_by": { + "name": "invited_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "joined_at": { + "name": "joined_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "persona_group_members_agent_idx": { + "name": "persona_group_members_agent_idx", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "persona_group_members_user_idx": { + "name": "persona_group_members_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "persona_group_members_group_id_fkey": { + "name": "persona_group_members_group_id_fkey", + "tableFrom": "persona_group_members", + "tableTo": "persona_groups", + "columnsFrom": [ + "group_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "persona_group_members_user_id_fkey": { + "name": "persona_group_members_user_id_fkey", + "tableFrom": "persona_group_members", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "persona_group_members_invited_by_fkey": { + "name": "persona_group_members_invited_by_fkey", + "tableFrom": "persona_group_members", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "invited_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "persona_group_members_group_id_user_id_key": { + "name": "persona_group_members_group_id_user_id_key", + "nullsNotDistinct": false, + "columns": [ + "group_id", + "user_id" + ] + } + }, + "policies": { + "service role full access on persona_group_members": { + "name": "service role full access on persona_group_members", + "as": "PERMISSIVE", + "for": "ALL", + "to": [ + "public" + ], + "using": "(auth.role() = 'service_role'::text)", + "withCheck": "(auth.role() = 'service_role'::text)" + }, + "members read roster": { + "name": "members read roster", + "as": "PERMISSIVE", + "for": "SELECT", + "to": [ + "public" + ], + "using": "is_persona_group_member(group_id)" + } + }, + "checkConstraints": { + "persona_group_members_role_check": { + "name": "persona_group_members_role_check", + "value": "role IN ('owner', 'admin', 'member')" + } + }, + "isRLSEnabled": false + }, + "public.persona_group_messages": { + "name": "persona_group_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "group_id": { + "name": "group_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "sender_user_id": { + "name": "sender_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "sender_agent_id": { + "name": "sender_agent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sender_name": { + "name": "sender_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "channel": { + "name": "channel", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'human'" + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reply_to": { + "name": "reply_to", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "persona_group_messages_group_idx": { + "name": "persona_group_messages_group_idx", + "columns": [ + { + "expression": "group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "first" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "persona_group_messages_group_id_fkey": { + "name": "persona_group_messages_group_id_fkey", + "tableFrom": "persona_group_messages", + "tableTo": "persona_groups", + "columnsFrom": [ + "group_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "persona_group_messages_sender_user_id_fkey": { + "name": "persona_group_messages_sender_user_id_fkey", + "tableFrom": "persona_group_messages", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "sender_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "persona_group_messages_reply_to_fkey": { + "name": "persona_group_messages_reply_to_fkey", + "tableFrom": "persona_group_messages", + "tableTo": "persona_group_messages", + "columnsFrom": [ + "reply_to" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": { + "service role full access on persona_group_messages": { + "name": "service role full access on persona_group_messages", + "as": "PERMISSIVE", + "for": "ALL", + "to": [ + "public" + ], + "using": "(auth.role() = 'service_role'::text)", + "withCheck": "(auth.role() = 'service_role'::text)" + }, + "members read messages": { + "name": "members read messages", + "as": "PERMISSIVE", + "for": "SELECT", + "to": [ + "public" + ], + "using": "is_persona_group_member(group_id)" + } + }, + "checkConstraints": { + "persona_group_messages_channel_check": { + "name": "persona_group_messages_channel_check", + "value": "channel IN ('human', 'agent', 'system', 'broadcast')" + } + }, + "isRLSEnabled": false + }, + "public.persona_groups": { + "name": "persona_groups", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "visibility": { + "name": "visibility", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'private'" + }, + "invite_token": { + "name": "invite_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "group_seed_index": { + "name": "group_seed_index", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "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()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "brief_doc_id": { + "name": "brief_doc_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "brief_doc_url": { + "name": "brief_doc_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "join_domain": { + "name": "join_domain", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "persona_groups_join_domain_idx": { + "name": "persona_groups_join_domain_idx", + "columns": [ + { + "expression": "join_domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "(join_domain IS NOT NULL) AND (archived_at IS NULL)", + "concurrently": false, + "method": "btree", + "with": {} + }, + "persona_groups_owner_idx": { + "name": "persona_groups_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "persona_groups_owner_user_id_fkey": { + "name": "persona_groups_owner_user_id_fkey", + "tableFrom": "persona_groups", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "persona_groups_slug_key": { + "name": "persona_groups_slug_key", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + }, + "persona_groups_invite_token_key": { + "name": "persona_groups_invite_token_key", + "nullsNotDistinct": false, + "columns": [ + "invite_token" + ] + } + }, + "policies": { + "service role full access on persona_groups": { + "name": "service role full access on persona_groups", + "as": "PERMISSIVE", + "for": "ALL", + "to": [ + "public" + ], + "using": "(auth.role() = 'service_role'::text)", + "withCheck": "(auth.role() = 'service_role'::text)" + }, + "members read group": { + "name": "members read group", + "as": "PERMISSIVE", + "for": "SELECT", + "to": [ + "public" + ], + "using": "is_persona_group_member(id)" + } + }, + "checkConstraints": { + "persona_groups_visibility_check": { + "name": "persona_groups_visibility_check", + "value": "visibility IN ('private', 'open')" + } + }, + "isRLSEnabled": false + }, + "public.api_tokens": { + "name": "api_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "raw_data": { + "name": "raw_data", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "api_tokens_user_id_fkey": { + "name": "api_tokens_user_id_fkey", + "tableFrom": "api_tokens", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "api_tokens_user_id_provider_key": { + "name": "api_tokens_user_id_provider_key", + "nullsNotDistinct": false, + "columns": [ + "user_id", + "provider" + ] + } + }, + "policies": { + "Service role full access on api_tokens": { + "name": "Service role full access on api_tokens", + "as": "PERMISSIVE", + "for": "ALL", + "to": [ + "public" + ], + "using": "(auth.role() = 'service_role'::text)" + }, + "Users can read own tokens": { + "name": "Users can read own tokens", + "as": "PERMISSIVE", + "for": "SELECT", + "to": [ + "public" + ], + "using": "(auth.uid() = user_id)" + }, + "Users can insert own tokens": { + "name": "Users can insert own tokens", + "as": "PERMISSIVE", + "for": "INSERT", + "to": [ + "public" + ], + "withCheck": "(auth.uid() = user_id)" + }, + "Users can update own tokens": { + "name": "Users can update own tokens", + "as": "PERMISSIVE", + "for": "UPDATE", + "to": [ + "public" + ], + "using": "(auth.uid() = user_id)" + }, + "Users can delete own tokens": { + "name": "Users can delete own tokens", + "as": "PERMISSIVE", + "for": "DELETE", + "to": [ + "public" + ], + "using": "(auth.uid() = user_id)" + } + }, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.github_profiles": { + "name": "github_profiles", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "raw_repos": { + "name": "raw_repos", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "skills": { + "name": "skills", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "projects": { + "name": "projects", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "synced_at": { + "name": "synced_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "github_profiles_synced_at_idx": { + "name": "github_profiles_synced_at_idx", + "columns": [ + { + "expression": "synced_at", + "isExpression": false, + "asc": false, + "nulls": "first" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "github_profiles_user_id_fkey": { + "name": "github_profiles_user_id_fkey", + "tableFrom": "github_profiles", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": { + "Service role full access on github_profiles": { + "name": "Service role full access on github_profiles", + "as": "PERMISSIVE", + "for": "ALL", + "to": [ + "public" + ], + "using": "(auth.role() = 'service_role'::text)" + }, + "Users read own github profile": { + "name": "Users read own github profile", + "as": "PERMISSIVE", + "for": "SELECT", + "to": [ + "public" + ], + "using": "(auth.uid() = user_id)" + }, + "Users delete own github profile": { + "name": "Users delete own github profile", + "as": "PERMISSIVE", + "for": "DELETE", + "to": [ + "public" + ], + "using": "(auth.uid() = user_id)" + } + }, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.linkedin_profiles": { + "name": "linkedin_profiles", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "profile_url": { + "name": "profile_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scraped_at": { + "name": "scraped_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "raw_profile": { + "name": "raw_profile", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "raw_posts": { + "name": "raw_posts", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "linkedin_profiles_scraped_at_idx": { + "name": "linkedin_profiles_scraped_at_idx", + "columns": [ + { + "expression": "scraped_at", + "isExpression": false, + "asc": false, + "nulls": "first" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "linkedin_profiles_user_id_fkey": { + "name": "linkedin_profiles_user_id_fkey", + "tableFrom": "linkedin_profiles", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": { + "Service role full access on linkedin_profiles": { + "name": "Service role full access on linkedin_profiles", + "as": "PERMISSIVE", + "for": "ALL", + "to": [ + "public" + ], + "using": "(auth.role() = 'service_role'::text)" + }, + "Users read own linkedin profile": { + "name": "Users read own linkedin profile", + "as": "PERMISSIVE", + "for": "SELECT", + "to": [ + "public" + ], + "using": "(auth.uid() = user_id)" + }, + "Users delete own linkedin profile": { + "name": "Users delete own linkedin profile", + "as": "PERMISSIVE", + "for": "DELETE", + "to": [ + "public" + ], + "using": "(auth.uid() = user_id)" + } + }, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_pending_state": { + "name": "oauth_pending_state", + "schema": "", + "columns": { + "state": { + "name": "state", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "code_verifier": { + "name": "code_verifier", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "(now() + '00:15:00'::interval)" + } + }, + "indexes": {}, + "foreignKeys": { + "oauth_pending_state_user_id_fkey": { + "name": "oauth_pending_state_user_id_fkey", + "tableFrom": "oauth_pending_state", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.telegram_chat_history": { + "name": "telegram_chat_history", + "schema": "", + "columns": { + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "messages": { + "name": "messages", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "telegram_chat_history_user_idx": { + "name": "telegram_chat_history_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "telegram_chat_history_user_id_fkey": { + "name": "telegram_chat_history_user_id_fkey", + "tableFrom": "telegram_chat_history", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": { + "Service role full access on telegram_chat_history": { + "name": "Service role full access on telegram_chat_history", + "as": "PERMISSIVE", + "for": "ALL", + "to": [ + "public" + ], + "using": "(auth.role() = 'service_role'::text)" + }, + "Users read own telegram history": { + "name": "Users read own telegram history", + "as": "PERMISSIVE", + "for": "SELECT", + "to": [ + "public" + ], + "using": "(auth.uid() = user_id)" + } + }, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.telegram_links": { + "name": "telegram_links", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "linked_at": { + "name": "linked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "telegram_links_chat_idx": { + "name": "telegram_links_chat_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "telegram_links_user_id_fkey": { + "name": "telegram_links_user_id_fkey", + "tableFrom": "telegram_links", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "telegram_links_chat_id_key": { + "name": "telegram_links_chat_id_key", + "nullsNotDistinct": false, + "columns": [ + "chat_id" + ] + } + }, + "policies": { + "Service role full access on telegram_links": { + "name": "Service role full access on telegram_links", + "as": "PERMISSIVE", + "for": "ALL", + "to": [ + "public" + ], + "using": "(auth.role() = 'service_role'::text)" + }, + "Users read own telegram link": { + "name": "Users read own telegram link", + "as": "PERMISSIVE", + "for": "SELECT", + "to": [ + "public" + ], + "using": "(auth.uid() = user_id)" + }, + "Users delete own telegram link": { + "name": "Users delete own telegram link", + "as": "PERMISSIVE", + "for": "DELETE", + "to": [ + "public" + ], + "using": "(auth.uid() = user_id)" + } + }, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.twitter_profiles": { + "name": "twitter_profiles", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "handle": { + "name": "handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scraped_at": { + "name": "scraped_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "raw_tweets": { + "name": "raw_tweets", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "facts": { + "name": "facts", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "twitter_profiles_scraped_at_idx": { + "name": "twitter_profiles_scraped_at_idx", + "columns": [ + { + "expression": "scraped_at", + "isExpression": false, + "asc": false, + "nulls": "first" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "twitter_profiles_user_id_fkey": { + "name": "twitter_profiles_user_id_fkey", + "tableFrom": "twitter_profiles", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": { + "Service role full access on twitter_profiles": { + "name": "Service role full access on twitter_profiles", + "as": "PERMISSIVE", + "for": "ALL", + "to": [ + "public" + ], + "using": "(auth.role() = 'service_role'::text)" + }, + "Users read own twitter profile": { + "name": "Users read own twitter profile", + "as": "PERMISSIVE", + "for": "SELECT", + "to": [ + "public" + ], + "using": "(auth.uid() = user_id)" + }, + "Users delete own twitter profile": { + "name": "Users delete own twitter profile", + "as": "PERMISSIVE", + "for": "DELETE", + "to": [ + "public" + ], + "using": "(auth.uid() = user_id)" + } + }, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/db/persona/migrations/meta/_journal.json b/packages/db/persona/migrations/meta/_journal.json new file mode 100644 index 0000000..87d55ae --- /dev/null +++ b/packages/db/persona/migrations/meta/_journal.json @@ -0,0 +1,20 @@ +{ + "version": "7", + "dialect": "postgresql", + "entries": [ + { + "idx": 0, + "version": "7", + "when": 1790429838838, + "tag": "0000_baseline_persona", + "breakpoints": true + }, + { + "idx": 1, + "version": "7", + "when": 1790430024353, + "tag": "0001_persona_drop_public_read", + "breakpoints": true + } + ] +} diff --git a/packages/db/persona/schema/agents.ts b/packages/db/persona/schema/agents.ts new file mode 100644 index 0000000..21ca37f --- /dev/null +++ b/packages/db/persona/schema/agents.ts @@ -0,0 +1,39 @@ +import { boolean, index, integer, jsonb, pgTable, text, uuid } from 'drizzle-orm/pg-core'; +import { authUsers, fk, isOwner, jsonbDefault, publicPolicy, serviceRolePolicy, tsvector, tstz } from '../../lib/shared'; + +// owner: persona. Also read by services/memory (agent_id, name, description, +// active, updated_at + search_personas_fts) — see OWNERS.md. +export const personaAgents = pgTable( + 'persona_agents', + { + userId: uuid('user_id').primaryKey(), + agentId: text('agent_id').notNull().unique('persona_agents_agent_id_key'), + derivationIndex: integer('derivation_index').notNull().unique('persona_agents_derivation_index_key'), + publicKey: text('public_key').notNull(), + name: text('name').notNull(), + agentHandle: text('agent_handle'), + description: text('description').notNull().default(''), + capabilities: jsonb('capabilities').default(jsonbDefault('[]')), + profile: jsonb('profile').default(jsonbDefault('{}')), + webhookUrl: text('webhook_url'), + active: boolean('active').default(true), + createdAt: tstz('created_at').defaultNow(), + updatedAt: tstz('updated_at').defaultNow(), + briefDocId: text('brief_doc_id'), + briefDocUrl: text('brief_doc_url'), + briefDocRevisionId: text('brief_doc_revision_id'), + briefContent: text('brief_content'), + // Maintained by trigger persona_agents_search_vector_trigger (migration 0000). + searchVector: tsvector('search_vector'), + autoExtractTodos: boolean('auto_extract_todos').notNull().default(true), + }, + (t) => [ + fk('persona_agents_user_id_fkey', t.userId, authUsers.id, 'cascade'), + index('persona_agents_search_vector_idx').using('gin', t.searchVector), + serviceRolePolicy('persona_agents'), + publicPolicy('Users can read own persona', { for: 'select', using: isOwner('user_id') }), + publicPolicy('Users can update own persona', { for: 'update', using: isOwner('user_id') }), + // "Public read persona agents" (using true) was dropped in 0001: it let the + // anon key read every persona's brief_content, profile and webhook_url. + ], +); diff --git a/packages/db/persona/schema/callbacks.ts b/packages/db/persona/schema/callbacks.ts new file mode 100644 index 0000000..59b30cf --- /dev/null +++ b/packages/db/persona/schema/callbacks.ts @@ -0,0 +1,69 @@ +import { sql } from 'drizzle-orm'; +import { boolean, check, index, jsonb, pgTable, text, uuid } from 'drizzle-orm/pg-core'; +import { authUsers, fk, jsonbDefault, publicPolicy, serviceRolePolicy, tstz } from '../../lib/shared'; + +// owner: persona — outbound A2A calls waiting on a peer's push callback. + +const ownerIsCaller = sql`(user_id = auth.uid())`; + +export const outboundCallbacks = pgTable( + 'outbound_callbacks', + { + id: uuid('id').primaryKey().defaultRandom(), + userId: uuid('user_id').notNull(), + // No FK: a thread can be deleted while its callback is still in flight. + threadId: uuid('thread_id').notNull(), + peerAgentId: text('peer_agent_id').notNull(), + peerTaskId: text('peer_task_id'), + ourMessageId: text('our_message_id').notNull(), + originKind: text('origin_kind').notNull(), + originRef: jsonb('origin_ref').notNull().default(jsonbDefault('{}')), + pushToken: text('push_token').notNull().unique('outbound_callbacks_push_token_key'), + status: text('status').notNull().default('pending'), + expiresAt: tstz('expires_at').notNull().default(sql`(now() + '24:00:00'::interval)`), + createdAt: tstz('created_at').defaultNow(), + updatedAt: tstz('updated_at').defaultNow(), + peerA2aUrl: text('peer_a2a_url'), + lastState: text('last_state'), + lastEvent: jsonb('last_event'), + lastEventAt: tstz('last_event_at'), + answerText: text('answer_text'), + terminalState: text('terminal_state'), + }, + (t) => [ + fk('outbound_callbacks_user_id_fkey', t.userId, authUsers.id, 'cascade'), + check('outbound_callbacks_status_check', sql`status IN ('pending', 'received', 'expired', 'failed')`), + index('outbound_callbacks_expires_idx').on(t.expiresAt).where(sql`status = 'pending'`), + index('outbound_callbacks_peer_task_idx') + .on(t.peerAgentId, t.peerTaskId) + .where(sql`peer_task_id IS NOT NULL`), + index('outbound_callbacks_user_idx').on(t.userId, t.status, t.createdAt.desc().nullsFirst()), + serviceRolePolicy('outbound_callbacks'), + publicPolicy('Owner reads outbound_callbacks', { for: 'select', using: ownerIsCaller }), + ], +); + +export const callbackResults = pgTable( + 'callback_results', + { + id: uuid('id').primaryKey().defaultRandom(), + callbackId: uuid('callback_id').notNull().unique('callback_results_callback_id_key'), + userId: uuid('user_id').notNull(), + threadId: uuid('thread_id').notNull(), + peerAgentId: text('peer_agent_id').notNull(), + taskState: text('task_state').notNull(), + replyText: text('reply_text'), + rawEvent: jsonb('raw_event').notNull(), + deliveredToUi: boolean('delivered_to_ui').notNull().default(false), + createdAt: tstz('created_at').defaultNow(), + }, + (t) => [ + fk('callback_results_callback_id_fkey', t.callbackId, outboundCallbacks.id, 'cascade'), + fk('callback_results_user_id_fkey', t.userId, authUsers.id, 'cascade'), + index('callback_results_thread_idx').on(t.threadId, t.createdAt.desc().nullsFirst()), + index('callback_results_user_idx').on(t.userId, t.deliveredToUi, t.createdAt.desc().nullsFirst()), + serviceRolePolicy('callback_results'), + publicPolicy('Owner reads callback_results', { for: 'select', using: ownerIsCaller }), + publicPolicy('Owner marks delivered', { for: 'update', using: ownerIsCaller, withCheck: ownerIsCaller }), + ], +); diff --git a/packages/db/persona/schema/contacts.ts b/packages/db/persona/schema/contacts.ts new file mode 100644 index 0000000..da754db --- /dev/null +++ b/packages/db/persona/schema/contacts.ts @@ -0,0 +1,111 @@ +import { boolean, index, integer, jsonb, pgTable, real, text, unique, uuid } from 'drizzle-orm/pg-core'; +import { authUsers, fk, isOwner, jsonbDefault, publicPolicy, serviceRolePolicy, tstz } from '../../lib/shared'; + +// owner: persona — contact enrichment cache and suggested-contact recipes. + +const ownAll = (name: string) => + publicPolicy(name, { for: 'all', using: isOwner('user_id'), withCheck: isOwner('user_id') }); +const svc = (table: string) => serviceRolePolicy(table, { withCheck: true }); + +export const enrichedContacts = pgTable( + 'enriched_contacts', + { + id: uuid('id').primaryKey().defaultRandom(), + userId: uuid('user_id').notNull(), + cacheKey: text('cache_key').notNull(), + employeeLinkedin: text('employee_linkedin'), + email: text('email'), + firstName: text('first_name'), + lastName: text('last_name'), + title: text('title'), + companyName: text('company_name'), + companyUrl: text('company_url'), + phone: text('phone'), + phoneType: text('phone_type'), + hasEmail: boolean('has_email').default(false), + hasPhone: boolean('has_phone').default(false), + data: jsonb('data').default(jsonbDefault('{}')), + source: text('source'), + enrichedAt: tstz('enriched_at'), + createdAt: tstz('created_at').defaultNow(), + updatedAt: tstz('updated_at').defaultNow(), + }, + (t) => [ + fk('enriched_contacts_user_id_fkey', t.userId, authUsers.id, 'cascade'), + unique('enriched_contacts_user_id_cache_key_key').on(t.userId, t.cacheKey), + index('enriched_contacts_user_created_idx').on(t.userId, t.createdAt.desc().nullsFirst()), + index('enriched_contacts_user_email_idx').on(t.userId, t.email), + index('enriched_contacts_user_linkedin_idx').on(t.userId, t.employeeLinkedin), + svc('enriched_contacts'), + ownAll('Users can CRUD own enriched contacts'), + ], +); + +export const enrichedCompanies = pgTable( + 'enriched_companies', + { + id: uuid('id').primaryKey().defaultRandom(), + userId: uuid('user_id').notNull(), + cacheKey: text('cache_key').notNull(), + companyName: text('company_name'), + companyUrl: text('company_url'), + linkedinUrl: text('linkedin_url'), + industry: text('industry'), + employeeCount: text('employee_count'), + revenue: text('revenue'), + city: text('city'), + regionCode: text('region_code'), + countryCode: text('country_code'), + data: jsonb('data').default(jsonbDefault('{}')), + source: text('source'), + createdAt: tstz('created_at').defaultNow(), + updatedAt: tstz('updated_at').defaultNow(), + }, + (t) => [ + fk('enriched_companies_user_id_fkey', t.userId, authUsers.id, 'cascade'), + unique('enriched_companies_user_id_cache_key_key').on(t.userId, t.cacheKey), + index('enriched_companies_user_created_idx').on(t.userId, t.createdAt.desc().nullsFirst()), + svc('enriched_companies'), + ownAll('Users can CRUD own enriched companies'), + ], +); + +export const suggestedContacts = pgTable( + 'suggested_contacts', + { + id: uuid('id').primaryKey().defaultRandom(), + userId: uuid('user_id').notNull(), + cacheKey: text('cache_key').notNull(), + recipe: text('recipe').notNull(), + reason: text('reason'), + score: real('score').default(0), + rank: integer('rank').default(0), + generatedAt: tstz('generated_at').defaultNow(), + createdAt: tstz('created_at').defaultNow(), + updatedAt: tstz('updated_at').defaultNow(), + }, + (t) => [ + fk('suggested_contacts_user_id_fkey', t.userId, authUsers.id, 'cascade'), + unique('suggested_contacts_user_id_cache_key_recipe_key').on(t.userId, t.cacheKey, t.recipe), + index('suggested_contacts_user_rank_idx').on(t.userId, t.recipe, t.rank), + svc('suggested_contacts'), + ownAll('Users can CRUD own suggested contacts'), + ], +); + +export const suggestedContactRuns = pgTable( + 'suggested_contact_runs', + { + userId: uuid('user_id').primaryKey(), + lastRunAt: tstz('last_run_at'), + lastManualAt: tstz('last_manual_at'), + status: text('status'), + detail: text('detail'), + updatedAt: tstz('updated_at').defaultNow(), + }, + (t) => [ + fk('suggested_contact_runs_user_id_fkey', t.userId, authUsers.id, 'cascade'), + svc('suggested_contact_runs'), + publicPolicy('Users can read own suggestion run state', { for: 'select', using: isOwner('user_id') }), + ], +); diff --git a/packages/db/persona/schema/content.ts b/packages/db/persona/schema/content.ts new file mode 100644 index 0000000..fa2b2aa --- /dev/null +++ b/packages/db/persona/schema/content.ts @@ -0,0 +1,84 @@ +import { sql } from 'drizzle-orm'; +import { boolean, check, index, jsonb, pgTable, text, uniqueIndex, uuid } from 'drizzle-orm/pg-core'; +import { authUsers, fk, isOwner, jsonbDefault, publicPolicy, serviceRolePolicy, tstz } from '../../lib/shared'; +import { personaGroups } from './groups'; + +// owner: persona — chat history, brief todos, published pages. + +export const chatMessages = pgTable( + 'chat_messages', + { + id: uuid('id').primaryKey().defaultRandom(), + userId: uuid('user_id').notNull(), + conversationId: text('conversation_id').notNull(), + role: text('role').notNull(), + content: text('content').notNull(), + actions: jsonb('actions').default(jsonbDefault('[]')), + createdAt: tstz('created_at').defaultNow(), + actionSummary: jsonb('action_summary').default(jsonbDefault('[]')), + }, + (t) => [ + fk('chat_messages_user_id_fkey', t.userId, authUsers.id, 'cascade'), + serviceRolePolicy('chat_messages'), + publicPolicy('Users can read own messages', { for: 'select', using: isOwner('user_id') }), + ], +); + +export const briefTodos = pgTable( + 'brief_todos', + { + id: uuid('id').primaryKey().defaultRandom(), + userId: uuid('user_id').notNull(), + title: text('title').notNull(), + sourceText: text('source_text'), + done: boolean('done').notNull().default(false), + doneAt: tstz('done_at'), + createdAt: tstz('created_at').notNull().defaultNow(), + groupId: uuid('group_id'), + assignedByUserId: uuid('assigned_by_user_id'), + }, + (t) => [ + fk('brief_todos_user_id_fkey', t.userId, authUsers.id, 'cascade'), + fk('brief_todos_group_id_fkey', t.groupId, personaGroups.id, 'cascade'), + fk('brief_todos_assigned_by_user_id_fkey', t.assignedByUserId, authUsers.id, 'set null'), + index('brief_todos_group_idx').on(t.groupId), + index('brief_todos_user_idx').on(t.userId, t.done, t.createdAt.desc().nullsFirst()), + uniqueIndex('brief_todos_user_title_uniq').on(t.userId, t.title).where(sql`done = false`), + serviceRolePolicy('brief_todos', { withCheck: true }), + publicPolicy('Users can read own brief todos', { for: 'select', using: isOwner('user_id') }), + publicPolicy('Users can update own brief todos', { for: 'update', using: isOwner('user_id') }), + publicPolicy('Users can delete own brief todos', { for: 'delete', using: isOwner('user_id') }), + publicPolicy('Group members can read group todos', { + for: 'select', + using: sql`(group_id IS NOT NULL) AND is_persona_group_member(group_id)`, + }), + ], +); + +export const publishedPages = pgTable( + 'published_pages', + { + id: uuid('id').primaryKey().defaultRandom(), + // No FK to auth.users in prod; kept that way. + userId: uuid('user_id').notNull(), + slug: text('slug').notNull().unique('published_pages_slug_key'), + title: text('title').notNull(), + format: text('format').notNull(), + content: text('content').notNull(), + visibility: text('visibility').notNull().default('unlisted'), + createdAt: tstz('created_at').defaultNow(), + updatedAt: tstz('updated_at').defaultNow(), + expiresAt: tstz('expires_at'), + }, + (t) => [ + check('published_pages_format_check', sql`format IN ('html', 'markdown')`), + check('published_pages_visibility_check', sql`visibility IN ('public', 'unlisted', 'private')`), + index('published_pages_expires_idx').on(t.expiresAt).where(sql`expires_at IS NOT NULL`), + // Duplicates the unique constraint's index; kept because prod has it. + index('published_pages_slug_idx').on(t.slug), + index('published_pages_user_idx').on(t.userId, t.createdAt.desc().nullsFirst()), + serviceRolePolicy('published_pages'), + publicPolicy('Public read for public pages', { for: 'select', using: sql`(visibility = 'public'::text)` }), + publicPolicy('Users can CRUD own pages', { for: 'all', using: isOwner('user_id'), withCheck: isOwner('user_id') }), + ], +); diff --git a/packages/db/persona/schema/dm.ts b/packages/db/persona/schema/dm.ts new file mode 100644 index 0000000..4807281 --- /dev/null +++ b/packages/db/persona/schema/dm.ts @@ -0,0 +1,202 @@ +import { sql } from 'drizzle-orm'; +import { bigint, check, index, jsonb, pgTable, text, unique, uniqueIndex, uuid } from 'drizzle-orm/pg-core'; +import { authUsers, fk, isOwner, jsonbDefault, publicPolicy, serviceRolePolicy, tstz } from '../../lib/shared'; + +// owner: persona — DMs between personas, A2A tasks, meeting tasks, approvals. + +/** The caller is a party to the thread, as a human (auth uid) or through their persona agent. */ +const isThreadParty = (thread: string) => + sql.raw( + `((auth.uid())::text = ${thread}.initiator_id) OR ((auth.uid())::text = ${thread}.receiver_id) OR (EXISTS ( SELECT 1 + FROM persona_agents + WHERE ((persona_agents.user_id = auth.uid()) AND ((persona_agents.agent_id = ${thread}.initiator_id) OR (persona_agents.agent_id = ${thread}.receiver_id)))))`, + ); + +export const dmThreads = pgTable( + 'dm_threads', + { + id: uuid('id').primaryKey().defaultRandom(), + initiatorId: text('initiator_id').notNull(), + receiverId: text('receiver_id').notNull(), + initiatorName: text('initiator_name').default(''), + receiverName: text('receiver_name').default(''), + status: text('status').notNull().default('pending'), + lifecycle: text('lifecycle').notNull().default('pending'), + initiatorMode: text('initiator_mode').notNull().default('agent'), + receiverMode: text('receiver_mode').notNull().default('agent'), + permissions: jsonb('permissions') + .notNull() + .default( + sql`jsonb_build_object('can_request_meetings', true, 'can_query_availability', false, 'can_view_full_profile', false, 'can_post_on_my_behalf', false)`, + ), + createdAt: tstz('created_at').defaultNow(), + updatedAt: tstz('updated_at').defaultNow(), + }, + (t) => [ + unique('dm_threads_initiator_id_receiver_id_key').on(t.initiatorId, t.receiverId), + check('dm_threads_status_check', sql`status IN ('pending', 'accepted', 'declined', 'blocked', 'revoked')`), + check('dm_threads_initiator_mode_check', sql`initiator_mode IN ('human', 'agent')`), + check('dm_threads_receiver_mode_check', sql`receiver_mode IN ('human', 'agent')`), + index('dm_threads_lifecycle_idx').on(t.lifecycle), + serviceRolePolicy('dm_threads'), + publicPolicy('Users can read own threads', { for: 'select', using: isThreadParty('dm_threads') }), + publicPolicy('Participants can update threads', { for: 'update', using: isThreadParty('dm_threads') }), + publicPolicy('Users can start threads', { + for: 'insert', + withCheck: sql`((auth.uid())::text = initiator_id) OR (EXISTS ( SELECT 1 + FROM persona_agents + WHERE ((persona_agents.user_id = auth.uid()) AND (persona_agents.agent_id = dm_threads.initiator_id))))`, + }), + ], +); + +/** EXISTS over the parent thread: caller is a party and the status is not in `blockedStatuses`. */ +const inOpenThread = (fkExpr: string, blockedStatuses: string[]) => + sql.raw(`EXISTS ( SELECT 1 + FROM dm_threads t + WHERE ((t.id = ${fkExpr}) AND ((t.initiator_id = (auth.uid())::text) OR (t.receiver_id = (auth.uid())::text) OR (EXISTS ( SELECT 1 + FROM persona_agents p + WHERE ((p.user_id = auth.uid()) AND ((p.agent_id = t.initiator_id) OR (p.agent_id = t.receiver_id)))))) AND (t.status <> ALL (ARRAY[${blockedStatuses + .map((s) => `'${s}'::text`) + .join(', ')}]))))`); + +export const dmMessages = pgTable( + 'dm_messages', + { + id: uuid('id').primaryKey().defaultRandom(), + threadId: uuid('thread_id').notNull(), + senderId: text('sender_id').notNull(), + senderType: text('sender_type').notNull().default('human'), + channel: text('channel').notNull().default('human'), + content: text('content').notNull(), + createdAt: tstz('created_at').defaultNow(), + }, + (t) => [ + fk('dm_messages_thread_id_fkey', t.threadId, dmThreads.id, 'cascade'), + check('dm_messages_sender_type_check', sql`sender_type IN ('human', 'agent', 'system')`), + check('dm_messages_channel_check', sql`channel IN ('human', 'agent')`), + serviceRolePolicy('dm_messages'), + publicPolicy('Users can read messages in non-blocked threads', { + for: 'select', + using: inOpenThread('dm_messages.thread_id', ['blocked', 'revoked']), + }), + publicPolicy('Users can send messages in accepted threads', { + for: 'insert', + withCheck: sql`(((auth.uid())::text = sender_id) OR (EXISTS ( SELECT 1 + FROM persona_agents + WHERE ((persona_agents.user_id = auth.uid()) AND (persona_agents.agent_id = dm_messages.sender_id))))) AND (${inOpenThread( + 'dm_messages.thread_id', + ['blocked', 'declined', 'revoked'], + )})`, + }), + ], +); + +export const a2aTasks = pgTable( + 'a2a_tasks', + { + taskId: uuid('task_id').primaryKey(), + contextId: uuid('context_id').notNull(), + state: text('state').notNull().default('submitted'), + permissionSnapshot: jsonb('permission_snapshot').notNull().default(jsonbDefault('{}')), + history: jsonb('history').notNull().default(jsonbDefault('[]')), + artifacts: jsonb('artifacts').notNull().default(jsonbDefault('[]')), + pushUrl: text('push_url'), + pushToken: text('push_token'), + lastMessageId: text('last_message_id'), + idleTtlMs: bigint('idle_ttl_ms', { mode: 'number' }).notNull().default(3600000), + idleUntil: tstz('idle_until'), + terminalAt: tstz('terminal_at'), + failureReason: text('failure_reason'), + createdAt: tstz('created_at').defaultNow(), + updatedAt: tstz('updated_at').defaultNow(), + }, + (t) => [ + fk('a2a_tasks_context_id_fkey', t.contextId, dmThreads.id, 'cascade'), + check( + 'a2a_tasks_state_check', + sql`state IN ('submitted', 'working', 'input-required', 'auth-required', 'completed', 'canceled', 'failed', 'rejected')`, + ), + index('a2a_tasks_context_idx').on(t.contextId, t.updatedAt.desc().nullsFirst()), + index('a2a_tasks_state_idle_idx') + .on(t.state, t.idleUntil) + .where(sql`state IN ('input-required', 'auth-required')`), + serviceRolePolicy('a2a_tasks'), + publicPolicy('Participants can read a2a_tasks', { + for: 'select', + using: sql`EXISTS ( SELECT 1 + FROM dm_threads t + WHERE ((t.id = a2a_tasks.context_id) AND ((t.initiator_id = (auth.uid())::text) OR (t.receiver_id = (auth.uid())::text) OR (EXISTS ( SELECT 1 + FROM persona_agents p + WHERE ((p.user_id = auth.uid()) AND ((p.agent_id = t.initiator_id) OR (p.agent_id = t.receiver_id))))))))`, + }), + ], +); + +const isTaskParty = sql`(auth.uid() = initiator_user_id) OR (auth.uid() = recipient_user_id)`; + +export const agentTasks = pgTable( + 'agent_tasks', + { + id: uuid('id').primaryKey().defaultRandom(), + threadId: uuid('thread_id').notNull(), + type: text('type').notNull().default('meeting'), + status: text('status').notNull().default('proposed'), + initiatorUserId: uuid('initiator_user_id').notNull(), + recipientUserId: uuid('recipient_user_id').notNull(), + initiatorAgentId: text('initiator_agent_id').notNull(), + recipientAgentId: text('recipient_agent_id').notNull(), + payload: jsonb('payload').notNull().default(jsonbDefault('{}')), + history: jsonb('history').notNull().default(jsonbDefault('[]')), + calendarEventIds: jsonb('calendar_event_ids').notNull().default(jsonbDefault('{}')), + createdAt: tstz('created_at').defaultNow(), + updatedAt: tstz('updated_at').defaultNow(), + }, + (t) => [ + fk('agent_tasks_thread_id_fkey', t.threadId, dmThreads.id, 'cascade'), + fk('agent_tasks_initiator_user_id_fkey', t.initiatorUserId, authUsers.id, 'cascade'), + fk('agent_tasks_recipient_user_id_fkey', t.recipientUserId, authUsers.id, 'cascade'), + check('agent_tasks_type_check', sql`type = 'meeting'`), + check( + 'agent_tasks_status_check', + sql`status IN ('proposed', 'countered', 'accepted', 'scheduled', 'declined', 'cancelled', 'book_failed')`, + ), + index('agent_tasks_initiator_idx').on(t.initiatorUserId), + index('agent_tasks_recipient_idx').on(t.recipientUserId), + index('agent_tasks_status_idx').on(t.status), + index('agent_tasks_thread_idx').on(t.threadId), + uniqueIndex('agent_tasks_one_open_proposal') + .on(t.threadId) + .where(sql`status IN ('proposed', 'countered', 'accepted')`), + serviceRolePolicy('agent_tasks'), + publicPolicy('Participants can read agent_tasks', { for: 'select', using: isTaskParty }), + publicPolicy('Participants can update agent_tasks', { for: 'update', using: isTaskParty }), + ], +); + +export const pendingApprovals = pgTable( + 'pending_approvals', + { + id: uuid('id').primaryKey().defaultRandom(), + userId: uuid('user_id').notNull(), + threadId: uuid('thread_id'), + toolName: text('tool_name').notNull(), + toolArgs: jsonb('tool_args').notNull().default(jsonbDefault('{}')), + summary: text('summary'), + status: text('status').notNull().default('pending'), + result: jsonb('result'), + createdAt: tstz('created_at').defaultNow(), + decidedAt: tstz('decided_at'), + expiresAt: tstz('expires_at').default(sql`(now() + '24:00:00'::interval)`), + }, + (t) => [ + fk('pending_approvals_user_id_fkey', t.userId, authUsers.id, 'cascade'), + fk('pending_approvals_thread_id_fkey', t.threadId, dmThreads.id, 'cascade'), + check('pending_approvals_status_check', sql`status IN ('pending', 'approved', 'declined', 'expired')`), + index('pending_approvals_thread_idx').on(t.threadId), + index('pending_approvals_user_status_idx').on(t.userId, t.status), + serviceRolePolicy('pending_approvals'), + publicPolicy('Users read own approvals', { for: 'select', using: isOwner('user_id') }), + publicPolicy('Users update own approvals', { for: 'update', using: isOwner('user_id') }), + ], +); diff --git a/packages/db/persona/schema/groups.ts b/packages/db/persona/schema/groups.ts new file mode 100644 index 0000000..6974408 --- /dev/null +++ b/packages/db/persona/schema/groups.ts @@ -0,0 +1,178 @@ +import { sql } from 'drizzle-orm'; +import { check, index, integer, jsonb, pgTable, text, unique, uniqueIndex, uuid } from 'drizzle-orm/pg-core'; +import { authUsers, fk, publicPolicy, serviceRolePolicy, tstz } from '../../lib/shared'; + +// owner: persona — persona groups. is_persona_group_member / _manager are +// SECURITY DEFINER functions created in migration 0000; the policies here call them. + +const isMember = (column: string) => sql.raw(`is_persona_group_member(${column})`); +const svc = (table: string) => serviceRolePolicy(table, { withCheck: true, lowerCase: true }); + +export const personaGroups = pgTable( + 'persona_groups', + { + id: uuid('id').primaryKey().defaultRandom(), + slug: text('slug').notNull().unique('persona_groups_slug_key'), + name: text('name').notNull(), + description: text('description'), + avatarUrl: text('avatar_url'), + ownerUserId: uuid('owner_user_id').notNull(), + visibility: text('visibility').notNull().default('private'), + inviteToken: text('invite_token').unique('persona_groups_invite_token_key'), + groupSeedIndex: integer('group_seed_index').notNull().default(0), + archivedAt: tstz('archived_at'), + createdAt: tstz('created_at').notNull().defaultNow(), + updatedAt: tstz('updated_at').notNull().defaultNow(), + briefDocId: text('brief_doc_id'), + briefDocUrl: text('brief_doc_url'), + joinDomain: text('join_domain'), + }, + (t) => [ + fk('persona_groups_owner_user_id_fkey', t.ownerUserId, authUsers.id, 'cascade'), + check('persona_groups_visibility_check', sql`visibility IN ('private', 'open')`), + index('persona_groups_join_domain_idx') + .on(t.joinDomain) + .where(sql`(join_domain IS NOT NULL) AND (archived_at IS NULL)`), + index('persona_groups_owner_idx').on(t.ownerUserId), + svc('persona_groups'), + publicPolicy('members read group', { for: 'select', using: isMember('id') }), + ], +); + +export const personaGroupMembers = pgTable( + 'persona_group_members', + { + id: uuid('id').primaryKey().defaultRandom(), + groupId: uuid('group_id').notNull(), + userId: uuid('user_id').notNull(), + agentId: text('agent_id'), + role: text('role').notNull().default('member'), + permissions: jsonb('permissions') + .notNull() + .default( + sql`jsonb_build_object('can_see_brief', false, 'can_see_member_briefs', false, 'can_see_group_brief', true, 'can_query_calendar', false, 'can_post', true, 'can_invite', false, 'can_speak_for_group', false)`, + ), + invitedBy: uuid('invited_by'), + joinedAt: tstz('joined_at').notNull().defaultNow(), + }, + (t) => [ + fk('persona_group_members_group_id_fkey', t.groupId, personaGroups.id, 'cascade'), + fk('persona_group_members_user_id_fkey', t.userId, authUsers.id, 'cascade'), + fk('persona_group_members_invited_by_fkey', t.invitedBy, authUsers.id, 'set null'), + unique('persona_group_members_group_id_user_id_key').on(t.groupId, t.userId), + check('persona_group_members_role_check', sql`role IN ('owner', 'admin', 'member')`), + index('persona_group_members_agent_idx').on(t.agentId), + index('persona_group_members_user_idx').on(t.userId), + svc('persona_group_members'), + publicPolicy('members read roster', { for: 'select', using: isMember('group_id') }), + ], +); + +export const personaGroupMessages = pgTable( + 'persona_group_messages', + { + id: uuid('id').primaryKey().defaultRandom(), + groupId: uuid('group_id').notNull(), + senderUserId: uuid('sender_user_id'), + senderAgentId: text('sender_agent_id'), + senderName: text('sender_name'), + channel: text('channel').notNull().default('human'), + content: text('content').notNull(), + replyTo: uuid('reply_to'), + metadata: jsonb('metadata'), + createdAt: tstz('created_at').notNull().defaultNow(), + }, + (t) => [ + fk('persona_group_messages_group_id_fkey', t.groupId, personaGroups.id, 'cascade'), + fk('persona_group_messages_sender_user_id_fkey', t.senderUserId, authUsers.id, 'set null'), + fk('persona_group_messages_reply_to_fkey', t.replyTo, t.id, 'set null'), + check('persona_group_messages_channel_check', sql`channel IN ('human', 'agent', 'system', 'broadcast')`), + index('persona_group_messages_group_idx').on(t.groupId, t.createdAt.desc().nullsFirst()), + svc('persona_group_messages'), + publicPolicy('members read messages', { for: 'select', using: isMember('group_id') }), + ], +); + +// RLS on, no policies: only the service role reads or writes invitations. +export const personaGroupInvitations = pgTable( + 'persona_group_invitations', + { + id: uuid('id').primaryKey().defaultRandom(), + groupId: uuid('group_id').notNull(), + inviteeUserId: uuid('invitee_user_id').notNull(), + inviterUserId: uuid('inviter_user_id'), + inviteeRole: text('invitee_role').notNull().default('member'), + status: text('status').notNull().default('pending'), + message: text('message'), + createdAt: tstz('created_at').notNull().defaultNow(), + decidedAt: tstz('decided_at'), + expiresAt: tstz('expires_at').notNull().default(sql`(now() + '7 days'::interval)`), + }, + (t) => [ + fk('persona_group_invitations_group_id_fkey', t.groupId, personaGroups.id, 'cascade'), + fk('persona_group_invitations_invitee_user_id_fkey', t.inviteeUserId, authUsers.id, 'cascade'), + fk('persona_group_invitations_inviter_user_id_fkey', t.inviterUserId, authUsers.id, 'set null'), + check('persona_group_invitations_invitee_role_check', sql`invitee_role IN ('admin', 'member')`), + check( + 'persona_group_invitations_status_check', + sql`status IN ('pending', 'accepted', 'declined', 'revoked', 'expired')`, + ), + index('persona_group_invitations_group_status_idx').on(t.groupId, t.status), + index('persona_group_invitations_invitee_status_idx').on(t.inviteeUserId, t.status), + uniqueIndex('persona_group_invitations_open_uniq') + .on(t.groupId, t.inviteeUserId) + .where(sql`status = 'pending'`), + ], +).enableRLS(); + +export const personaGroupConstraints = pgTable( + 'persona_group_constraints', + { + id: uuid('id').primaryKey().defaultRandom(), + groupId: uuid('group_id').notNull(), + kind: text('kind').notNull(), + text: text('text').notNull(), + createdByUserId: uuid('created_by_user_id'), + createdAt: tstz('created_at').notNull().defaultNow(), + archivedAt: tstz('archived_at'), + }, + (t) => [ + fk('persona_group_constraints_group_id_fkey', t.groupId, personaGroups.id, 'cascade'), + fk('persona_group_constraints_created_by_user_id_fkey', t.createdByUserId, authUsers.id, 'set null'), + check('persona_group_constraints_kind_check', sql`kind IN ('fact', 'rule', 'voice')`), + check('persona_group_constraints_text_check', sql`(length(text) >= 1) AND (length(text) <= 400)`), + index('persona_group_constraints_group_idx').on(t.groupId, t.archivedAt.asc().nullsFirst(), t.createdAt.desc().nullsFirst()), + svc('persona_group_constraints'), + publicPolicy('members read group constraints', { for: 'select', using: isMember('group_id') }), + ], +); + +export const personaGroupAuditEvents = pgTable( + 'persona_group_audit_events', + { + id: uuid('id').primaryKey().defaultRandom(), + groupId: uuid('group_id').notNull(), + affectedUserId: uuid('affected_user_id').notNull(), + actorUserId: uuid('actor_user_id'), + kind: text('kind').notNull(), + metadata: jsonb('metadata'), + createdAt: tstz('created_at').notNull().defaultNow(), + }, + (t) => [ + fk('persona_group_audit_events_group_id_fkey', t.groupId, personaGroups.id, 'cascade'), + fk('persona_group_audit_events_affected_user_id_fkey', t.affectedUserId, authUsers.id, 'cascade'), + fk('persona_group_audit_events_actor_user_id_fkey', t.actorUserId, authUsers.id, 'set null'), + check('persona_group_audit_events_kind_check', sql`kind IN ('brief_shared', 'calendar_queried')`), + index('persona_group_audit_events_affected_idx').on(t.affectedUserId, t.createdAt.desc().nullsFirst()), + index('persona_group_audit_events_group_idx').on(t.groupId, t.createdAt.desc().nullsFirst()), + svc('persona_group_audit_events'), + publicPolicy('affected user reads own audit events', { + for: 'select', + using: sql`(auth.uid() = affected_user_id)`, + }), + publicPolicy('owner reads group audit events', { + for: 'select', + using: sql`is_persona_group_manager(group_id)`, + }), + ], +); diff --git a/packages/db/persona/schema/index.ts b/packages/db/persona/schema/index.ts new file mode 100644 index 0000000..16a941e --- /dev/null +++ b/packages/db/persona/schema/index.ts @@ -0,0 +1,9 @@ +// persona's tables. They live in the `public` schema (where they have always +// been); this history owns `public` and nothing else. +export * from './agents'; +export * from './callbacks'; +export * from './contacts'; +export * from './content'; +export * from './dm'; +export * from './groups'; +export * from './integrations'; diff --git a/packages/db/persona/schema/integrations.ts b/packages/db/persona/schema/integrations.ts new file mode 100644 index 0000000..dde0f3a --- /dev/null +++ b/packages/db/persona/schema/integrations.ts @@ -0,0 +1,142 @@ +import { sql } from 'drizzle-orm'; +import { index, jsonb, pgTable, text, unique, uuid } from 'drizzle-orm/pg-core'; +import { authUsers, fk, isOwner, jsonbDefault, publicPolicy, serviceRolePolicy, tstz } from '../../lib/shared'; + +// owner: persona — third-party connections (OAuth tokens, Telegram) and the +// profile data scraped from them. + +const ownRead = (name: string) => publicPolicy(name, { for: 'select', using: isOwner('user_id') }); +const ownDelete = (name: string) => publicPolicy(name, { for: 'delete', using: isOwner('user_id') }); + +export const apiTokens = pgTable( + 'api_tokens', + { + id: uuid('id').primaryKey().defaultRandom(), + userId: uuid('user_id').notNull(), + provider: text('provider').notNull(), + accessToken: text('access_token').notNull(), + refreshToken: text('refresh_token'), + expiresAt: tstz('expires_at'), + scopes: text('scopes'), + rawData: jsonb('raw_data').default(jsonbDefault('{}')), + createdAt: tstz('created_at').defaultNow(), + updatedAt: tstz('updated_at').defaultNow(), + }, + (t) => [ + fk('api_tokens_user_id_fkey', t.userId, authUsers.id, 'cascade'), + unique('api_tokens_user_id_provider_key').on(t.userId, t.provider), + serviceRolePolicy('api_tokens'), + ownRead('Users can read own tokens'), + publicPolicy('Users can insert own tokens', { for: 'insert', withCheck: isOwner('user_id') }), + publicPolicy('Users can update own tokens', { for: 'update', using: isOwner('user_id') }), + ownDelete('Users can delete own tokens'), + ], +); + +// RLS on, no policies: only the service role touches OAuth state. +export const oauthPendingState = pgTable( + 'oauth_pending_state', + { + state: text('state').primaryKey(), + userId: uuid('user_id').notNull(), + provider: text('provider').notNull(), + codeVerifier: text('code_verifier'), + createdAt: tstz('created_at').defaultNow(), + expiresAt: tstz('expires_at').default(sql`(now() + '00:15:00'::interval)`), + }, + (t) => [fk('oauth_pending_state_user_id_fkey', t.userId, authUsers.id, 'cascade')], +).enableRLS(); + +export const telegramLinks = pgTable( + 'telegram_links', + { + userId: uuid('user_id').primaryKey(), + chatId: text('chat_id').notNull().unique('telegram_links_chat_id_key'), + linkedAt: tstz('linked_at').defaultNow(), + }, + (t) => [ + fk('telegram_links_user_id_fkey', t.userId, authUsers.id, 'cascade'), + // Duplicates the unique constraint's index; kept because prod has it. + index('telegram_links_chat_idx').on(t.chatId), + serviceRolePolicy('telegram_links'), + ownRead('Users read own telegram link'), + ownDelete('Users delete own telegram link'), + ], +); + +export const telegramChatHistory = pgTable( + 'telegram_chat_history', + { + conversationId: text('conversation_id').primaryKey(), + userId: uuid('user_id').notNull(), + messages: jsonb('messages').notNull().default(jsonbDefault('[]')), + updatedAt: tstz('updated_at').defaultNow(), + }, + (t) => [ + fk('telegram_chat_history_user_id_fkey', t.userId, authUsers.id, 'cascade'), + index('telegram_chat_history_user_idx').on(t.userId), + serviceRolePolicy('telegram_chat_history'), + ownRead('Users read own telegram history'), + ], +); + +export const linkedinProfiles = pgTable( + 'linkedin_profiles', + { + userId: uuid('user_id').primaryKey(), + profileUrl: text('profile_url'), + scrapedAt: tstz('scraped_at'), + rawProfile: jsonb('raw_profile').notNull().default(jsonbDefault('{}')), + rawPosts: jsonb('raw_posts').notNull().default(jsonbDefault('[]')), + createdAt: tstz('created_at').defaultNow(), + updatedAt: tstz('updated_at').defaultNow(), + }, + (t) => [ + fk('linkedin_profiles_user_id_fkey', t.userId, authUsers.id, 'cascade'), + index('linkedin_profiles_scraped_at_idx').on(t.scrapedAt.desc().nullsFirst()), + serviceRolePolicy('linkedin_profiles'), + ownRead('Users read own linkedin profile'), + ownDelete('Users delete own linkedin profile'), + ], +); + +export const twitterProfiles = pgTable( + 'twitter_profiles', + { + userId: uuid('user_id').primaryKey(), + handle: text('handle'), + scrapedAt: tstz('scraped_at'), + rawTweets: jsonb('raw_tweets').notNull().default(jsonbDefault('[]')), + facts: jsonb('facts').notNull().default(jsonbDefault('[]')), + createdAt: tstz('created_at').defaultNow(), + updatedAt: tstz('updated_at').defaultNow(), + }, + (t) => [ + fk('twitter_profiles_user_id_fkey', t.userId, authUsers.id, 'cascade'), + index('twitter_profiles_scraped_at_idx').on(t.scrapedAt.desc().nullsFirst()), + serviceRolePolicy('twitter_profiles'), + ownRead('Users read own twitter profile'), + ownDelete('Users delete own twitter profile'), + ], +); + +export const githubProfiles = pgTable( + 'github_profiles', + { + userId: uuid('user_id').primaryKey(), + username: text('username'), + rawRepos: jsonb('raw_repos').notNull().default(jsonbDefault('[]')), + skills: jsonb('skills').notNull().default(jsonbDefault('[]')), + projects: jsonb('projects').notNull().default(jsonbDefault('[]')), + syncedAt: tstz('synced_at'), + createdAt: tstz('created_at').defaultNow(), + updatedAt: tstz('updated_at').defaultNow(), + }, + (t) => [ + fk('github_profiles_user_id_fkey', t.userId, authUsers.id, 'cascade'), + index('github_profiles_synced_at_idx').on(t.syncedAt.desc().nullsFirst()), + serviceRolePolicy('github_profiles'), + ownRead('Users read own github profile'), + ownDelete('Users delete own github profile'), + ], +); diff --git a/packages/db/scripts/baseline-sql.ts b/packages/db/scripts/baseline-sql.ts new file mode 100644 index 0000000..6edf3b7 --- /dev/null +++ b/packages/db/scripts/baseline-sql.ts @@ -0,0 +1,41 @@ +/** + * Print the SQL that records persona's migration 0000 (the baseline) as + * already applied, WITHOUT running it. Use it once on a database that already + * has persona's tables (prod aafo); afterwards `npm run db:migrate` applies + * only what follows 0000. + * + * npm run db:baseline-sql # paste the output into the Supabase SQL editor + * + * The row is exactly what drizzle-orm's migrator would have written: the + * sha256 of the migration file and the journal's `when` for it. + */ +import { createHash } from 'node:crypto'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { migrationsFolder, migrationsTable, readJournal } from './projects'; + +const folder = migrationsFolder('persona'); +const table = migrationsTable('persona'); +const baseline = readJournal(folder).entries[0]; +if (!baseline?.tag.startsWith('0000_')) throw new Error('first persona journal entry is not a 0000_ baseline'); + +const hash = createHash('sha256').update(readFileSync(join(folder, `${baseline.tag}.sql`), 'utf8')).digest('hex'); + +console.log(`-- Records persona's ${baseline.tag} as applied on a database that ALREADY has +-- persona's tables. Run once, in the aafo SQL editor. Safe to re-run. +create schema if not exists drizzle; +-- Earlier instructions used drizzle.__drizzle_migrations; carry it over if present. +do $$ begin + if to_regclass('drizzle.__drizzle_migrations') is not null and to_regclass('drizzle.${table}') is null then + alter table drizzle.__drizzle_migrations rename to ${table}; + end if; +end $$; +create table if not exists drizzle.${table} ( + id serial primary key, + hash text not null, + created_at bigint +); +insert into drizzle.${table} (hash, created_at) +select '${hash}', ${baseline.when} +where not exists (select 1 from drizzle.${table}); +select id, hash, created_at from drizzle.${table} order by id;`); diff --git a/packages/db/scripts/catalog.sql b/packages/db/scripts/catalog.sql new file mode 100644 index 0000000..edc1871 --- /dev/null +++ b/packages/db/scripts/catalog.sql @@ -0,0 +1,64 @@ +-- Read-only catalog of every schema our migrations own (public = persona, +-- cards, identity), as ONE text cell, so the Supabase SQL editor's row limit +-- never truncates it. Run it on prod in the SQL editor and save the cell to +-- introspection/aafo-YYYY-MM-DD.txt; `npm run db:drift` runs it on a scratch +-- DB built from the migrations and diffs the two. Names are schema-qualified. +-- First line identifies the project: developer_keys=true means xmfj. +with owned as ( + select oid, nspname from pg_namespace where nspname in ('public', 'cards', 'identity') +) +select string_agg(line, E'\n' order by line) from ( + select '0 project | developer_keys=' || (to_regclass('public.developer_keys') is not null)::text + || ' agent_profile_cards=' || (to_regclass('cards.agent_profile_cards') is not null or to_regclass('public.agent_profile_cards') is not null)::text as line + union all + select 'schema | ' || nspname from owned + union all + select 'extension | ' || extname || ' | ' || extversion || ' | schema=' || extnamespace::regnamespace::text from pg_extension + union all + select 'column | ' || o.nspname || '.' || c.relname || '.' || a.attname || ' | ' || format_type(a.atttypid, a.atttypmod) + || case when a.attgenerated = 's' then ' GENERATED ALWAYS AS (' || pg_get_expr(d.adbin, d.adrelid) || ') STORED' + when d.adbin is not null then ' DEFAULT ' || pg_get_expr(d.adbin, d.adrelid) else '' end + || case when a.attnotnull then ' NOT NULL' else '' end + from pg_attribute a join pg_class c on c.oid = a.attrelid join owned o on o.oid = c.relnamespace + left join pg_attrdef d on d.adrelid = a.attrelid and d.adnum = a.attnum + where c.relkind = 'r' and a.attnum > 0 and not a.attisdropped + union all + select 'constraint | ' || o.nspname || '.' || c.relname || '.' || k.conname || ' | ' || pg_get_constraintdef(k.oid) + from pg_constraint k join pg_class c on c.oid = k.conrelid join owned o on o.oid = c.relnamespace + union all + select 'index | ' || i.schemaname || '.' || i.indexname || ' | ' || i.indexdef + from pg_indexes i join owned o on o.nspname = i.schemaname + union all + select 'trigger | ' || o.nspname || '.' || c.relname || '.' || t.tgname || ' | ' || pg_get_triggerdef(t.oid) + from pg_trigger t join pg_class c on c.oid = t.tgrelid join owned o on o.oid = c.relnamespace + where not t.tgisinternal + union all + select 'function | ' || o.nspname || '.' || p.proname || '(' || pg_get_function_identity_arguments(p.oid) || ')' + || ' | acl=' || coalesce(p.proacl::text, 'default') || E'\n' || pg_get_functiondef(p.oid) + from pg_proc p join owned o on o.oid = p.pronamespace + where p.prokind = 'f' + and not exists (select 1 from pg_depend d where d.objid = p.oid and d.deptype = 'e') + union all + select 'rls | ' || o.nspname || '.' || c.relname || ' | enabled=' || c.relrowsecurity::text || ' forced=' || c.relforcerowsecurity::text + from pg_class c join owned o on o.oid = c.relnamespace where c.relkind = 'r' + union all + select 'policy | ' || p.schemaname || '.' || p.tablename || '.' || p.policyname || ' | ' + || format('%s for %s to %s using (%s) with check (%s)', p.permissive, p.cmd, p.roles, p.qual, p.with_check) + from pg_policies p join owned o on o.nspname = p.schemaname + union all + select 'view | ' || v.schemaname || '.' || v.viewname || ' | ' || v.definition + from pg_views v join owned o on o.nspname = v.schemaname + union all + select 'publication | ' || pubname || ' | ' || schemaname || '.' || tablename from pg_publication_tables + union all + select 'grant | ' || g.table_schema || '.' || g.table_name || ' -> ' || g.grantee || ' | ' || string_agg(g.privilege_type, ',' order by g.privilege_type) + from information_schema.role_table_grants g join owned o on o.nspname = g.table_schema + group by g.table_schema, g.table_name, g.grantee + union all + select 'schema-acl | ' || nspname || ' | ' || coalesce(n.nspacl::text, 'default') + from pg_namespace n where nspname in ('cards', 'identity') + union all + select 'default-acl | ' || n.nspname || ' | ' || d.defaclobjtype::text || ' | ' || d.defaclacl::text + from pg_default_acl d join pg_namespace n on n.oid = d.defaclnamespace + where n.nspname in ('cards', 'identity') +) x; diff --git a/packages/db/scripts/check-drift.ts b/packages/db/scripts/check-drift.ts new file mode 100644 index 0000000..3300cf9 --- /dev/null +++ b/packages/db/scripts/check-drift.ts @@ -0,0 +1,173 @@ +/** + * Drift check: do the schemas our migrations own (public, cards, identity) + * in a real database equal what the migrations build? + * + * npm run db:drift -- --expected --scratch [--upto :,...] + * + * --expected Prod's schema. Either a file holding the output of + * scripts/catalog.sql (raw text, or the Supabase SQL editor's + * JSON/CSV export), or a postgres URL to query read-only. + * --scratch A throwaway Postgres server (NOT Supabase, NOT prod). A temp + * database is created there, loaded with test/supabase-stubs.sql + * and every history's migrations (identity, persona, cards), + * catalogued, then dropped. + * --upto Per history, apply only up to and including a tag, e.g. to + * check prod before new migrations reach it: + * --upto persona:0001_persona_drop_public_read,cards:none + * (`none` = apply nothing from that history). + * + * Exit code 0 = no drift. 1 = drift (the differing entries are printed). + */ +import { cpSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { drizzle } from 'drizzle-orm/node-postgres'; +import { migrate } from 'drizzle-orm/node-postgres/migrator'; +import pg from 'pg'; +import { HISTORIES, migrationsFolder, migrationsTable, root } from './projects'; + +const catalogSql = readFileSync(join(root, 'scripts/catalog.sql'), 'utf8'); + +function arg(name: string): string | undefined { + const i = process.argv.indexOf(`--${name}`); + return i === -1 ? undefined : process.argv[i + 1]; +} + +async function catalogOf(url: string): Promise { + const client = new pg.Client({ connectionString: url }); + await client.connect(); + try { + await client.query('begin transaction read only'); + const res = await client.query(catalogSql); + await client.query('commit'); + return String(Object.values(res.rows[0])[0] ?? ''); + } finally { + await client.end(); + } +} + +/** Accept raw text, the SQL editor's JSON export, or its CSV export. */ +function decodeExport(raw: string): string { + const t = raw.trim(); + if (t.startsWith('[')) return String(Object.values(JSON.parse(t)[0])[0]); + if (t.startsWith('string_agg')) { + const body = t.slice(t.indexOf('\n') + 1).trim(); + return body.startsWith('"') ? body.slice(1, -1).replace(/""/g, '"') : body; + } + return t; +} + +const ENTRY = /^(0 project|schema|schema-acl|default-acl|extension|column|constraint|index|trigger|function|rls|policy|view|publication|grant) \| /; + +/** Split the catalog into entries (a function body spans several lines) and drop the ones that differ for reasons that aren't drift. */ +function entries(catalog: string): Set { + const out: string[] = []; + for (const line of catalog.split('\n')) { + if (ENTRY.test(line) || out.length === 0) out.push(line); + else out[out.length - 1] += '\n' + line; + } + return new Set( + out + .map((e) => e.trimEnd()) + .filter( + (e) => + !e.startsWith('0 project |') && // which project, not schema + !e.startsWith('extension |') && // Supabase-managed + !e.startsWith('publication | supabase_realtime_messages_publication') && // daily partitions + !/^constraint \| [^|]+ \| NOT NULL /.test(e), // Postgres 18+ lists NOT NULL as constraints; 17 doesn't + ), + ); +} + +/** "persona:0001_x,cards:none" -> { persona: "0001_x", cards: "none" } */ +function parseUpto(value?: string): Record { + const out: Record = {}; + for (const part of value?.split(',').filter(Boolean) ?? []) { + const [history, tag] = part.split(':'); + if (!history || !tag || !HISTORIES.some((h) => h.name === history)) { + throw new Error(`--upto ${part}: expected :, history one of ${HISTORIES.map((h) => h.name).join(', ')}`); + } + out[history] = tag; + } + return out; +} + +async function buildScratch(serverUrl: string, upto: Record): Promise { + const dbName = `zynd_drift_${process.pid}_${Date.now()}`; + const admin = new pg.Client({ connectionString: serverUrl }); + await admin.connect(); + await admin.query(`create database ${dbName}`); + const dbUrl = new URL(serverUrl); + dbUrl.pathname = `/${dbName}`; + + const work = mkdtempSync(join(tmpdir(), 'zynd-drift-')); + try { + // The stubs set the database's search_path (adding `extensions`, where + // `vector` lives). That only applies to NEW sessions, so load them on one + // connection and migrate on a fresh one, as Supabase and CI do. + const stubs = new pg.Client({ connectionString: dbUrl.toString() }); + await stubs.connect(); + await stubs.query(readFileSync(join(root, 'test/supabase-stubs.sql'), 'utf8')); + await stubs.end(); + + const client = new pg.Client({ connectionString: dbUrl.toString() }); + await client.connect(); + try { + for (const { name } of HISTORIES) { + // Copy the history so --upto can trim its journal without touching the repo. + const folder = join(work, name); + cpSync(migrationsFolder(name), folder, { recursive: true }); + const cutAt = upto[name]; + if (cutAt) { + const journalPath = join(folder, 'meta/_journal.json'); + const journal = JSON.parse(readFileSync(journalPath, 'utf8')); + const cut = cutAt === 'none' ? -1 : journal.entries.findIndex((e: { tag: string }) => e.tag === cutAt); + if (cutAt !== 'none' && cut === -1) throw new Error(`--upto ${name}:${cutAt}: no such migration`); + journal.entries = journal.entries.slice(0, cut + 1); + writeFileSync(journalPath, JSON.stringify(journal, null, 2)); + } + await migrate(drizzle(client), { + migrationsFolder: folder, + migrationsSchema: 'drizzle', + migrationsTable: migrationsTable(name), + }); + } + } finally { + await client.end(); + } + return await catalogOf(dbUrl.toString()); + } finally { + rmSync(work, { recursive: true, force: true }); + await admin.query(`drop database if exists ${dbName} with (force)`); + await admin.end(); + } +} + +async function main() { + const expected = arg('expected'); + const scratch = arg('scratch'); + if (!expected || !scratch) { + console.error('usage: npm run db:drift -- --expected --scratch [--upto :,...]'); + process.exit(2); + } + const prod = entries( + /^postgres(ql)?:\/\//.test(expected) ? await catalogOf(expected) : decodeExport(readFileSync(expected, 'utf8')), + ); + const built = entries(await buildScratch(scratch, parseUpto(arg('upto')))); + + const onlyProd = [...prod].filter((e) => !built.has(e)).sort(); + const onlyBuilt = [...built].filter((e) => !prod.has(e)).sort(); + if (onlyProd.length === 0 && onlyBuilt.length === 0) { + console.log(`No drift: ${prod.size} catalog entries match.`); + return; + } + for (const e of onlyProd) console.log(`- only in the real database:\n ${e.replace(/\n/g, '\n ')}`); + for (const e of onlyBuilt) console.log(`+ only in the migrations:\n ${e.replace(/\n/g, '\n ')}`); + console.log(`\nDRIFT: ${onlyProd.length} entries only in the database, ${onlyBuilt.length} only in the migrations.`); + process.exit(1); +} + +main().catch((err) => { + console.error(err); + process.exit(2); +}); diff --git a/packages/db/scripts/lint-migrations.sh b/packages/db/scripts/lint-migrations.sh new file mode 100755 index 0000000..07a3421 --- /dev/null +++ b/packages/db/scripts/lint-migrations.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +# Migration hygiene for every history (identity, persona, cards), run in CI +# and before opening a PR: +# - every migration file is listed in its journal and vice versa +# - file names are NNNN_lower_snake.sql +# - every migration starts with "-- owner: persona|cards|shared" +# (persona's 0000 baseline included) +# - with BASE_REF set (CI), no already-merged migration file was modified +set -euo pipefail +cd "$(dirname "$0")/.." + +fail=0 +err() { echo "lint-migrations: $*" >&2; fail=1; } +total=0 + +for history in identity persona cards; do + dir="$history/migrations" + journal_tags=$(node -e 'for (const e of JSON.parse(require("fs").readFileSync(process.argv[1])).entries) console.log(e.tag)' "$dir/meta/_journal.json") + file_tags=$(cd "$dir" && ls *.sql | sed 's/\.sql$//') + [ "$journal_tags" = "$file_tags" ] || err "$history: journal and $dir/*.sql differ: + journal: $(echo $journal_tags) + files: $(echo $file_tags)" + for tag in $file_tags; do + total=$((total + 1)) + [[ "$tag" =~ ^[0-9]{4}_[a-z0-9_]+$ ]] || err "$history/$tag.sql: name must be NNNN_lower_snake" + head -1 "$dir/$tag.sql" | grep -Eq '^-- owner: (persona|cards|shared)$' \ + || err "$history/$tag.sql: first line must be '-- owner: persona|cards|shared'" + done +done + +if [ -n "${BASE_REF:-}" ]; then + changed=$(git diff --name-only --diff-filter=MD "$BASE_REF"...HEAD -- '*/migrations/*.sql' | grep '\.sql$' || true) + [ -z "$changed" ] || err "already-merged migrations must never change; add a new one instead: +$changed" +fi + +[ $fail -eq 0 ] && echo "lint-migrations: ok ($total migrations in 3 histories)" +exit $fail diff --git a/packages/db/scripts/migrate.ts b/packages/db/scripts/migrate.ts new file mode 100644 index 0000000..a2bb082 --- /dev/null +++ b/packages/db/scripts/migrate.ts @@ -0,0 +1,94 @@ +/** + * Apply pending migrations to DATABASE_URL. + * + * npm run db:migrate # dry run, every history + * npm run db:migrate -- --project cards # dry run, one history + * npm run db:migrate -- --project cards --yes # apply + * + * Histories (identity, persona, cards) are independent: each has its own + * folder and its own tracking table (drizzle.___migrations), and each + * applies in its own transaction. Within a history, all pending migrations run + * in ONE transaction: if one fails, none of that history's pending ones apply. + * + * Uses drizzle-orm's own migrator (same bookkeeping as `drizzle-kit migrate`) + * but prints the real Postgres error, shows what is pending, and refuses to + * run against the dashboard project or to re-run persona's baseline on a + * database that already has it. + */ +import 'dotenv/config'; +import { drizzle } from 'drizzle-orm/node-postgres'; +import { migrate } from 'drizzle-orm/node-postgres/migrator'; +import pg from 'pg'; +import { migrationsFolder, migrationsTable, readJournal, selectedHistories, type HistoryName } from './projects'; + +// The dashboard's Supabase project. These migrations must never touch it. +const FORBIDDEN_REFS = ['xmfjvixclgqcmjmtecwv']; + +async function lastApplied(client: pg.Client, name: HistoryName): Promise { + const exists = await client.query(`select to_regclass($1) is not null as has`, [`drizzle.${migrationsTable(name)}`]); + if (!exists.rows[0].has) return undefined; + const res = await client.query( + `select created_at from drizzle.${migrationsTable(name)} order by created_at desc limit 1`, + ); + return res.rows[0] ? Number(res.rows[0].created_at) : undefined; +} + +async function main() { + const url = process.env.DATABASE_URL; + if (!url) throw new Error('DATABASE_URL is not set (see .env.example)'); + const target = new URL(url); + const shown = `${target.username}@${target.hostname}:${target.port || 5432}${target.pathname}`; + if (FORBIDDEN_REFS.some((ref) => url.includes(ref))) { + throw new Error(`Refusing to migrate ${shown}: that is the dashboard (xmfj) project, not aafo.`); + } + const apply = process.argv.includes('--yes'); + const histories = selectedHistories(process.argv); + + const client = new pg.Client({ connectionString: url }); + await client.connect(); + try { + console.log(`Target: ${shown}${apply ? '' : ' (dry run: add `-- --yes` to apply)'}\n`); + for (const { name } of histories) { + const folder = migrationsFolder(name); + const journal = readJournal(folder); + const last = await lastApplied(client, name); + const pending = journal.entries.filter((e) => last === undefined || e.when > last); + const appliedUpTo = last === undefined ? 'nothing' : journal.entries.filter((e) => e.when <= last).at(-1)?.tag; + console.log(`[${name}] applied: ${appliedUpTo}; pending: ${pending.length ? pending.map((e) => e.tag).join(', ') : 'none'}`); + if (pending.length === 0) continue; + + if (name === 'persona' && last === undefined) { + const legacy = await client.query(`select to_regclass('drizzle.__drizzle_migrations') is not null as has`); + if (legacy.rows[0].has) { + throw new Error( + 'Found drizzle.__drizzle_migrations (the pre-split tracking table). Rename it first:\n' + + ' alter table drizzle.__drizzle_migrations rename to __persona_migrations;', + ); + } + const existing = await client.query(`select to_regclass('public.persona_agents') is not null as has`); + if (existing.rows[0].has) { + throw new Error( + "This database already has persona's tables but no persona migration history. persona's\n" + + '0000 is the BASELINE of exactly those tables: record it with `npm run db:baseline-sql`\n' + + '(see README.md), never run it here. Refusing.', + ); + } + } + if (!apply) continue; + await migrate(drizzle(client), { + migrationsFolder: folder, + migrationsSchema: 'drizzle', + migrationsTable: migrationsTable(name), + }); + console.log(`[${name}] applied ${pending.length} migration(s).`); + } + } finally { + await client.end(); + } +} + +main().catch((err) => { + console.error(`\nMigration failed. The history being applied rolled back (one transaction).\n${err?.message ?? err}`); + if (err?.cause) console.error(`Cause: ${err.cause.message ?? err.cause}`); + process.exit(1); +}); diff --git a/packages/db/scripts/preflight-cards.sql b/packages/db/scripts/preflight-cards.sql new file mode 100644 index 0000000..956d484 --- /dev/null +++ b/packages/db/scripts/preflight-cards.sql @@ -0,0 +1,11 @@ +-- Read-only checks on aafo BEFORE applying the cards history +-- (cards/migrations). Every row should say ok = true. Paste into the aafo +-- SQL editor. +select 'is aafo (persona tables present)' as check, to_regclass('public.persona_agents') is not null as ok +union all select 'is NOT xmfj (no developer_keys)', to_regclass('public.developer_keys') is null +union all select 'persona baseline recorded (drizzle.__persona_migrations)', to_regclass('drizzle.__persona_migrations') is not null +union all select 'no cards schema yet', to_regnamespace('cards') is null +union all select 'no cards tables in public either', to_regclass('public.agent_profile_cards') is null +union all select 'vector extension available to install', exists (select 1 from pg_available_extensions where name = 'vector') +union all select 'extensions schema exists', to_regnamespace('extensions') is not null +union all select 'service_role exists', exists (select 1 from pg_roles where rolname = 'service_role'); diff --git a/packages/db/scripts/projects.ts b/packages/db/scripts/projects.ts new file mode 100644 index 0000000..37beb4a --- /dev/null +++ b/packages/db/scripts/projects.ts @@ -0,0 +1,36 @@ +import { readFileSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +export const root = resolve(dirname(fileURLToPath(import.meta.url)), '..'); + +/** + * The independent migration histories, in the order they apply to a fresh + * database (cards references auth.users only, but identity comes first so + * later histories can reference it). Each owns its schemas and records its + * progress in drizzle.___migrations. + */ +export const HISTORIES = [ + { name: 'identity', owns: ['identity'] }, + { name: 'persona', owns: ['public'] }, + { name: 'cards', owns: ['cards'] }, +] as const; + +export type HistoryName = (typeof HISTORIES)[number]['name']; + +export const migrationsFolder = (name: HistoryName) => join(root, name, 'migrations'); +export const migrationsTable = (name: HistoryName) => `__${name}_migrations`; + +export function readJournal(folder: string): { entries: { idx: number; tag: string; when: number }[] } { + return JSON.parse(readFileSync(join(folder, 'meta/_journal.json'), 'utf8')); +} + +/** Parse `--project a,b` (default: all, in order). */ +export function selectedHistories(argv: string[]): (typeof HISTORIES)[number][] { + const i = argv.indexOf('--project'); + if (i === -1) return [...HISTORIES]; + const wanted = argv[i + 1]?.split(',') ?? []; + const unknown = wanted.filter((w) => !HISTORIES.some((h) => h.name === w)); + if (unknown.length) throw new Error(`unknown --project ${unknown.join(',')}; expected ${HISTORIES.map((h) => h.name).join(', ')}`); + return HISTORIES.filter((h) => wanted.includes(h.name)); +} diff --git a/packages/db/test/supabase-stubs.sql b/packages/db/test/supabase-stubs.sql new file mode 100644 index 0000000..7bdfde2 --- /dev/null +++ b/packages/db/test/supabase-stubs.sql @@ -0,0 +1,79 @@ +-- Just enough of Supabase for these migrations to apply to plain Postgres +-- (CI and local scratch databases). NEVER run this against a Supabase project. +-- +-- Mirrors what Supabase provides out of the box: the three API roles, the +-- auth schema with auth.users and the JWT helper functions, the extensions +-- schema, an empty supabase_realtime publication, and the default +-- privileges that make every new public table/function reachable by the +-- API roles (RLS is what actually restricts access). + +do $$ +begin + if not exists (select from pg_roles where rolname = 'anon') then + create role anon nologin noinherit; + end if; + if not exists (select from pg_roles where rolname = 'authenticated') then + create role authenticated nologin noinherit; + end if; + if not exists (select from pg_roles where rolname = 'service_role') then + create role service_role nologin noinherit bypassrls; + end if; +end $$; + +grant usage on schema public to anon, authenticated, service_role; + +create schema if not exists extensions; +grant usage on schema extensions to anon, authenticated, service_role; + +create schema if not exists auth; +grant usage on schema auth to anon, authenticated, service_role; + +create table if not exists auth.users ( + id uuid primary key, + email varchar(255) +); + +create or replace function auth.uid() returns uuid language sql stable as $$ + select coalesce( + nullif(current_setting('request.jwt.claim.sub', true), ''), + (nullif(current_setting('request.jwt.claims', true), '')::jsonb ->> 'sub') + )::uuid +$$; + +create or replace function auth.role() returns text language sql stable as $$ + select coalesce( + nullif(current_setting('request.jwt.claim.role', true), ''), + (nullif(current_setting('request.jwt.claims', true), '')::jsonb ->> 'role') + )::text +$$; + +create or replace function auth.email() returns text language sql stable as $$ + select coalesce( + nullif(current_setting('request.jwt.claim.email', true), ''), + (nullif(current_setting('request.jwt.claims', true), '')::jsonb ->> 'email') + )::text +$$; + +create or replace function auth.jwt() returns jsonb language sql stable as $$ + select coalesce( + nullif(current_setting('request.jwt.claim', true), ''), + nullif(current_setting('request.jwt.claims', true), '') + )::jsonb +$$; + +do $$ +begin + if not exists (select from pg_publication where pubname = 'supabase_realtime') then + create publication supabase_realtime; + end if; +end $$; + +alter default privileges in schema public grant all on tables to anon, authenticated, service_role; +alter default privileges in schema public grant all on functions to anon, authenticated, service_role; +alter default privileges in schema public grant all on sequences to anon, authenticated, service_role; + +-- Supabase's search_path, so unqualified extension types (vector) resolve. +do $$ +begin + execute format('alter database %I set search_path = "$user", public, extensions', current_database()); +end $$; diff --git a/packages/db/tsconfig.json b/packages/db/tsconfig.json new file mode 100644 index 0000000..3a61c67 --- /dev/null +++ b/packages/db/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "esModuleInterop": true, + "types": [ + "node" + ] + }, + "include": [ + "lib/**/*.ts", + "identity/**/*.ts", + "persona/**/*.ts", + "cards/**/*.ts", + "scripts/**/*.ts" + ] +} diff --git a/scripts/setup-python.sh b/scripts/setup-python.sh new file mode 100755 index 0000000..2e5b160 --- /dev/null +++ b/scripts/setup-python.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +# Create a Python 3.12 venv in each service (services//.venv, gitignored) +# with its runtime deps plus pytest. Same layout the servers use, so +# `npm run dev` and `npm test` at the root can call .venv/bin/* directly. +set -euo pipefail +cd "$(dirname "$0")/.." + +command -v uv >/dev/null || { echo "uv is required: https://docs.astral.sh/uv/getting-started/installation/" >&2; exit 1; } + +for svc in persona-api cards-api; do + echo "== services/$svc" + [ -x "services/$svc/.venv/bin/python" ] || uv venv --python 3.12 "services/$svc/.venv" + uv pip install --python "services/$svc/.venv" -r "services/$svc/requirements.txt" pytest pytest-asyncio +done + +# memory has a pyproject (no lockfile committed): install its deps + the dev group +# without `uv sync`, which would write a new uv.lock into the repo. +echo "== services/memory" +[ -x services/memory/.venv/bin/python ] || uv venv --python 3.12 services/memory/.venv +uv pip install --python services/memory/.venv -r services/memory/pyproject.toml --group services/memory/pyproject.toml:dev + +echo "Python services ready." diff --git a/services/cards-api/.env.example b/services/cards-api/.env.example index aba2441..e570087 100644 --- a/services/cards-api/.env.example +++ b/services/cards-api/.env.example @@ -1,6 +1,8 @@ # ── Supabase / Postgres ── SUPABASE_URL=http://127.0.0.1:54321 SUPABASE_SERVICE_KEY= +# "public" on xmfj (today); "cards" on the shared aafo database after the cutover +SUPABASE_DB_SCHEMA=public # ── LLM (OpenRouter) ── OPENROUTER_API_KEY= @@ -15,7 +17,7 @@ GITHUB_TOKEN= # ── Site / indexing ── SITE_BASE_URL=https://zynd.ai API_BASE_URL=https://api.zynd.ai -FRONTEND_URL=http://localhost:3000 +FRONTEND_URL=http://localhost:3002 INDEXNOW_KEY= BING_API_KEY= BING_SITE_URL=https://zynd.ai diff --git a/services/cards-api/.env.prod.example b/services/cards-api/.env.prod.example index bf1bb42..b0ccdb7 100644 --- a/services/cards-api/.env.prod.example +++ b/services/cards-api/.env.prod.example @@ -1,6 +1,8 @@ # ── Supabase / Postgres ── SUPABASE_URL=https://your-project.supabase.co SUPABASE_SERVICE_KEY=your_supabase_service_role_key +# "public" on xmfj (today); "cards" on the shared aafo database after the cutover +SUPABASE_DB_SCHEMA=public # ── LLM (OpenRouter) ── OPENROUTER_API_KEY=your_openrouter_key diff --git a/services/cards-api/config.py b/services/cards-api/config.py index 06af5b1..3fa4c11 100644 --- a/services/cards-api/config.py +++ b/services/cards-api/config.py @@ -10,6 +10,11 @@ SUPABASE_URL: str = os.getenv("SUPABASE_URL", "http://127.0.0.1:54321") SUPABASE_SERVICE_KEY: str = os.getenv("SUPABASE_SERVICE_KEY", "") SUPABASE_JWT_SECRET: str = os.getenv("SUPABASE_JWT_SECRET", "") +# Postgres schema holding the cards tables and RPC functions. "public" on the +# dashboard project (xmfj) today; "cards" once cards runs on the shared aafo +# database (packages/db/cards). The schema must be listed in the project's +# API "Exposed schemas" setting. +SUPABASE_DB_SCHEMA: str = os.getenv("SUPABASE_DB_SCHEMA") or "public" # Unowned cards published after claim tokens shipped can only be claimed with # the one-time token returned at publish. Cards published before that have no @@ -64,9 +69,13 @@ def _get_supabase(): global _sb_service if _sb_service is None: - from supabase import create_client + from supabase import ClientOptions, create_client - _sb_service = create_client(SUPABASE_URL, SUPABASE_SERVICE_KEY) + _sb_service = create_client( + SUPABASE_URL, + SUPABASE_SERVICE_KEY, + options=ClientOptions(schema=SUPABASE_DB_SCHEMA), + ) return _sb_service diff --git a/services/cards-api/db/README.md b/services/cards-api/db/README.md new file mode 100644 index 0000000..8cfeb72 --- /dev/null +++ b/services/cards-api/db/README.md @@ -0,0 +1,10 @@ +# Frozen — do not add or apply anything here + +Schema changes to the shared aafo database now go through the tracked +migration histories in [`packages/db`](../../../packages/db/README.md) (Drizzle). + +The SQL in this folder is history. It built cards' tables on the dashboard's +Supabase project (xmfj), and prod has drifted from it (e.g. `owner_email` is +created by no file here), so **never re-run it**. Cards' schema now lives in +the `cards` Postgres schema on the shared database, defined by +`packages/db/cards/` (its own migration history). diff --git a/services/memory/.env.example b/services/memory/.env.example index 828d118..a78e888 100644 --- a/services/memory/.env.example +++ b/services/memory/.env.example @@ -24,10 +24,10 @@ JWT_SECRET=dev-jwt-secret-change-me-in-production-0123456789 OAUTH_CLIENT_ID=zynd-chatgpt OAUTH_CLIENT_SECRET=zynd-oauth-secret # Public HTTPS URL of this API once deployed (used in the Action schema + OAuth URLs). -PUBLIC_BASE_URL=http://localhost:8000 +PUBLIC_BASE_URL=http://localhost:8001 # Public base URL of the MCP server (where MCP clients connect). May differ from # PUBLIC_BASE_URL if the MCP server runs on a different host/port (e.g. port 8090). -MCP_PUBLIC_BASE_URL=http://localhost:8000 +MCP_PUBLIC_BASE_URL=http://localhost:8090 # --- OAuth provider credentials (for MCP tools: Twitter, LinkedIn, Google, Notion) --- TWITTER_CLIENT_ID= diff --git a/services/persona-api/.env.example b/services/persona-api/.env.example new file mode 100644 index 0000000..25a5ced --- /dev/null +++ b/services/persona-api/.env.example @@ -0,0 +1,65 @@ +# persona-api local env. Copy to services/persona-api/.env (gitignored). +# Every secret comes from env; nothing is hard-coded in the code. +# +# !! There is no separate dev Supabase project yet. If you paste aafo's +# !! values here, local runs read and WRITE the real prod database (the same +# !! one dev.persona.zynd.ai uses). See docs/LOCAL_DEV.md. + +# ── Supabase (aafo, or a dev project once one exists) ───────────────────── +SUPABASE_URL= +SUPABASE_ANON_KEY= +SUPABASE_SERVICE_KEY= + +# ── URLs (local defaults: ports from the root package.json) ─────────────── +FRONTEND_URL=http://localhost:3000 +PUBLIC_PAGE_BASE_URL=http://localhost:3000 +MEMORY_LAYER_URL=http://localhost:8001 +ZYND_WEBHOOK_BASE_URL= +ZYND_REGISTRY_URL=https://zns01.zynd.ai +ZYND_DEPLOYER_URL=https://deployer.zynd.ai + +# ── App secrets ────────────────────────────────────────────────────────── +INTERNAL_SERVICE_KEYS= +MEMORY_LAYER_JWT_SECRET= +MEMORY_LAYER_MAX_CONTEXT_ASSERTIONS= +MEMORY_LAYER_MIN_CONFIDENCE= + +# ── LLMs ───────────────────────────────────────────────────────────────── +LLM_PROVIDER= +OPENROUTER_API_KEY= +OPENROUTER_MODEL= +OPENROUTER_FALLBACK_API_KEY= +OPENROUTER_FALLBACK_MODELS= +OPENAI_API_KEY= +OPENAI_MODEL= +GEMINI_API_KEY= +GEMINI_MODEL= +GROQ_API_KEY= +GROQ_WHISPER_MODEL= +CUSTOM_LLM_API_KEY= +CUSTOM_LLM_BASE_URL= +CUSTOM_LLM_MODEL= +ASK_ENDPOINT_MODEL= + +# ── OAuth apps (connect Google/LinkedIn/Twitter/GitHub/Notion to a persona) ─ +GOOGLE_CLIENT_ID= +GOOGLE_CLIENT_SECRET= +LINKEDIN_CLIENT_ID= +LINKEDIN_CLIENT_SECRET= +TWITTER_CLIENT_ID= +TWITTER_CLIENT_SECRET= +GITHUB_CLIENT_ID= +GITHUB_CLIENT_SECRET= +NOTION_CLIENT_ID= +NOTION_CLIENT_SECRET= + +# ── Other integrations ─────────────────────────────────────────────────── +TELEGRAM_BOT_TOKEN= +TELEGRAM_WEBHOOK_SECRET= +APIFY_API_TOKEN= +QUICKENRICH_API_KEY= +QUICKENRICH_AUTH_HEADER= +QUICKENRICH_BASE_URL= +QUICKENRICH_CACHE_TTL_DAYS= +QUICKENRICH_TIMEOUT= +NGROK_AUTH_TOKEN= diff --git a/services/persona-api/CLAUDE.md b/services/persona-api/CLAUDE.md index 44199e0..6f615a1 100644 --- a/services/persona-api/CLAUDE.md +++ b/services/persona-api/CLAUDE.md @@ -202,13 +202,12 @@ permissions into the same `external_permissions` shape used for A2A. See ### Persistence -Supabase/Postgres. `backend/db/schema.sql` is the full v2 schema for a fresh -install; numerous `backend/db/patch_*.sql` files are incremental migrations -that have already been applied in order — check filenames against the -target database before assuming a patch still needs running. `db/migrations/` -and `db/sql/policies.sql` at the repo root are the newer, webapp-facing -migration path (`npm run db:policies` applies RLS policies via `psql -$DIRECT_URL`). RLS policies on `dm_threads`/`dm_messages` accept both +Supabase/Postgres (project aafo, shared with cards). **All schema changes go +through `packages/db`** (Drizzle migrations; see its README). The old SQL in +`services/persona-api/db/`, `services/persona-api/supabase/migrations/` and +`apps/persona-web/db/` is frozen history: never re-run it. The verified +current schema is `packages/db/persona/migrations/0000_baseline_persona.sql` +plus the migrations after it. RLS policies on `dm_threads`/`dm_messages` accept both Supabase user UUIDs and `agdns:` agent IDs in the same TEXT columns since a thread can be between two humans, a human and an agent, or two agents. diff --git a/services/persona-api/db/README.md b/services/persona-api/db/README.md new file mode 100644 index 0000000..aaa70e2 --- /dev/null +++ b/services/persona-api/db/README.md @@ -0,0 +1,9 @@ +# Frozen — do not add or apply anything here + +Schema changes to the shared aafo database now go through the tracked +migration histories in [`packages/db`](../../../packages/db/README.md) (Drizzle). + +The SQL in this folder is history. It was applied to prod by hand at various +times, and nothing records which files ran, so **never re-run it**. The +current, verified state of the schema is `packages/db/persona/migrations/0000_baseline_persona.sql` +plus the migrations after it. diff --git a/services/persona-api/supabase/migrations/README.md b/services/persona-api/supabase/migrations/README.md new file mode 100644 index 0000000..d57db27 --- /dev/null +++ b/services/persona-api/supabase/migrations/README.md @@ -0,0 +1,9 @@ +# Frozen — do not add or apply anything here + +Schema changes to the shared aafo database now go through the tracked +migration histories in [`packages/db`](../../../../packages/db/README.md) (Drizzle). + +The SQL in this folder is history. It was applied to prod by hand at various +times, and nothing records which files ran, so **never re-run it**. The +current, verified state of the schema is `packages/db/persona/migrations/0000_baseline_persona.sql` +plus the migrations after it.