diff --git a/.craft.yml b/.craft.yml index 2b05578d7..e4c71a09a 100644 --- a/.craft.yml +++ b/.craft.yml @@ -5,6 +5,9 @@ targets: - name: npm id: "@sentry/junior" includeNames: /^sentry-junior-\d.*\.tgz$/ + - name: npm + id: "@sentry/junior-migrations" + includeNames: /^sentry-junior-migrations-\d.*\.tgz$/ - name: npm id: "@sentry/junior-plugin-api" includeNames: /^sentry-junior-plugin-api-\d.*\.tgz$/ diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2a639ce61..c05d85642 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -49,6 +49,7 @@ jobs: - .github/actions/** core: - packages/junior/** + - packages/junior-migrations/** - packages/junior-plugin-api/** - packages/junior-scheduler/** - packages/junior-testing/** @@ -215,6 +216,7 @@ jobs: - uses: ./.github/actions/setup-node-pnpm - run: pnpm --filter @sentry/junior-memory build - run: pnpm --filter @sentry/junior-github build + - run: pnpm --filter @sentry/junior-migrations test - run: pnpm --filter @sentry/junior-memory test - run: pnpm --filter @sentry/junior-github test @@ -341,6 +343,7 @@ jobs: run: | mkdir -p artifacts pnpm --filter @sentry/junior pack --pack-destination artifacts + pnpm --filter @sentry/junior-migrations pack --pack-destination artifacts pnpm --filter @sentry/junior-plugin-api pack --pack-destination artifacts pnpm --filter @sentry/junior-agent-browser pack --pack-destination artifacts pnpm --filter @sentry/junior-amplitude pack --pack-destination artifacts diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0aed6be2b..25619e00f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -119,6 +119,7 @@ pnpm build:pkg This repo uses Craft for manual lockstep npm releases of: - `@sentry/junior` +- `@sentry/junior-migrations` - `@sentry/junior-plugin-api` - `@sentry/junior-agent-browser` - `@sentry/junior-amplitude` diff --git a/README.md b/README.md index a24241acd..7dfe164c3 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ Start here: | Package | Purpose | | ------------------------------ | ---------------------------------------------------------------------------- | | `@sentry/junior` | Core Slack bot runtime | +| `@sentry/junior-migrations` | Mixed Drizzle SQL and TypeScript migration format and runner | | `@sentry/junior-plugin-api` | Lightweight plugin API types and helpers | | `@sentry/junior-agent-browser` | Agent Browser plugin package for browser automation | | `@sentry/junior-amplitude` | Read-only Amplitude product analytics through Amplitude's hosted MCP server | diff --git a/ast-grep/rules/no-core-plugin-dynamic-imports.yml b/ast-grep/rules/no-core-plugin-dynamic-imports.yml index 9ae0f5e4d..a36ee7071 100644 --- a/ast-grep/rules/no-core-plugin-dynamic-imports.yml +++ b/ast-grep/rules/no-core-plugin-dynamic-imports.yml @@ -12,4 +12,4 @@ constraints: all: - regex: '^["@'']@sentry/junior-.+["@'']$' - not: - regex: '^["@'']@sentry/junior-plugin-api["@'']$' + regex: '^["@'']@sentry/junior-(?:plugin-api|migrations)["@'']$' diff --git a/ast-grep/rules/no-core-plugin-reexports.yml b/ast-grep/rules/no-core-plugin-reexports.yml index adb19e779..c4504cd33 100644 --- a/ast-grep/rules/no-core-plugin-reexports.yml +++ b/ast-grep/rules/no-core-plugin-reexports.yml @@ -14,4 +14,4 @@ rule: - not: has: kind: string - regex: '^["@'']@sentry/junior-plugin-api["@'']$' + regex: '^["@'']@sentry/junior-(?:plugin-api|migrations)["@'']$' diff --git a/ast-grep/rules/no-core-plugin-static-imports.yml b/ast-grep/rules/no-core-plugin-static-imports.yml index c2af1d8e9..c26903f92 100644 --- a/ast-grep/rules/no-core-plugin-static-imports.yml +++ b/ast-grep/rules/no-core-plugin-static-imports.yml @@ -14,4 +14,4 @@ rule: - not: has: kind: string - regex: '^["@'']@sentry/junior-plugin-api["@'']$' + regex: '^["@'']@sentry/junior-(?:plugin-api|migrations)["@'']$' diff --git a/package.json b/package.json index bd59a8af1..99e5fd539 100644 --- a/package.json +++ b/package.json @@ -12,19 +12,19 @@ "worktree:setup": "node scripts/worktree.mjs setup", "cloudflare:token": "node scripts/refresh-cloudflare-tunnel-token.mjs", "prepare": "simple-git-hooks", - "lint": "pnpm --filter @sentry/junior lint && pnpm --filter @sentry/junior-memory lint && pnpm --filter @sentry/junior-github lint && pnpm ast-grep:lint && pnpm package:lint", + "lint": "pnpm --filter @sentry/junior-migrations lint && pnpm --filter @sentry/junior lint && pnpm --filter @sentry/junior-memory lint && pnpm --filter @sentry/junior-github lint && pnpm ast-grep:lint && pnpm package:lint", "lint:fix": "pnpm --filter @sentry/junior lint:fix", "ast-grep:lint": "ast-grep scan", "lint-staged": "lint-staged", - "build": "pnpm --filter @sentry/junior build && pnpm --filter @sentry/junior-memory build && pnpm --filter @sentry/junior-github build && pnpm --filter @sentry/junior-dashboard build", + "build": "pnpm --filter @sentry/junior-migrations build && pnpm --filter @sentry/junior build && pnpm --filter @sentry/junior-memory build && pnpm --filter @sentry/junior-github build && pnpm --filter @sentry/junior-dashboard build", "build:example": "pnpm --filter @sentry/junior-example build", "docs:dev": "pnpm --filter @sentry/junior-docs dev", "docs:build": "pnpm --filter @sentry/junior-docs build", "docs:check": "pnpm --filter @sentry/junior-docs check", - "package:lint": "for pkg in packages/junior packages/junior-plugin-api packages/junior-scheduler packages/junior-memory packages/junior-dashboard packages/junior-github packages/junior-agent-browser packages/junior-amplitude packages/junior-cloudflare packages/junior-datadog packages/junior-hex packages/junior-linear packages/junior-maintenance packages/junior-notion packages/junior-sentry packages/junior-vercel; do pnpm exec publint \"$pkg\" || exit $?; done", + "package:lint": "for pkg in packages/junior packages/junior-migrations packages/junior-plugin-api packages/junior-scheduler packages/junior-memory packages/junior-dashboard packages/junior-github packages/junior-agent-browser packages/junior-amplitude packages/junior-cloudflare packages/junior-datadog packages/junior-hex packages/junior-linear packages/junior-maintenance packages/junior-notion packages/junior-sentry packages/junior-vercel; do pnpm exec publint \"$pkg\" || exit $?; done", "release:check": "node scripts/check-release-config.mjs", "start": "pnpm --filter @sentry/junior-example dev", - "test": "pnpm --filter @sentry/junior build && pnpm --filter @sentry/junior-memory build && pnpm --filter @sentry/junior-github build && pnpm --filter @sentry/junior-dashboard build && pnpm --filter @sentry/junior test && pnpm --filter @sentry/junior-memory test && pnpm --filter @sentry/junior-github test && pnpm --filter @sentry/junior-dashboard test", + "test": "pnpm --filter @sentry/junior-migrations test && pnpm --filter @sentry/junior build && pnpm --filter @sentry/junior-memory build && pnpm --filter @sentry/junior-github build && pnpm --filter @sentry/junior-dashboard build && pnpm --filter @sentry/junior test && pnpm --filter @sentry/junior-memory test && pnpm --filter @sentry/junior-github test && pnpm --filter @sentry/junior-dashboard test", "test:e2e:dashboard": "pnpm --filter @sentry/junior-dashboard build && playwright test -c packages/junior-dashboard/playwright.config.ts", "test:watch": "pnpm --filter @sentry/junior test:watch", "evals": "pnpm --filter @sentry/junior-evals evals", diff --git a/packages/docs/src/content/docs/cli/upgrade.md b/packages/docs/src/content/docs/cli/upgrade.md index e491c6b51..4673d0b1a 100644 --- a/packages/docs/src/content/docs/cli/upgrade.md +++ b/packages/docs/src/content/docs/cli/upgrade.md @@ -1,8 +1,8 @@ --- title: "junior upgrade" -description: "Run one-shot Junior state upgrade migrations." +description: "Apply Junior schema and data migrations." type: reference -summary: Move persisted Junior state forward after upgrading packages. +summary: Move configured SQL schemas and persisted state forward after upgrades. prerequisites: - /start-here/quickstart/ related: @@ -11,7 +11,10 @@ related: - /cli/snapshot-create/ --- -Use `junior upgrade` after installing a Junior release that includes a one-shot state migration. The command mutates the configured state stores, so run it from the same app environment that has the production state and SQL environment variables configured for the deployment you are upgrading. +Use `junior upgrade` after installing a Junior release that includes schema or +data migrations. The command mutates the configured SQL database and state +stores, so run it from the same app environment that has the production state +and SQL environment variables configured for the deployment you are upgrading. ## Usage @@ -25,12 +28,25 @@ The command takes no extra arguments. ## What it does -`junior upgrade` runs registered migrations sequentially. Current migrations: +`junior upgrade` runs migrations sequentially. Core and plugin migration +directories use Drizzle Kit's ordered journal and may contain either generated +SQL schema migrations or TypeScript data migrations targeting a versioned +host capability API. Current +upgrade work includes: +- Apply core and enabled-plugin SQL schema migrations. +- Rewrite retained turn-session records from legacy storage shapes before the + new runtime reads them. - Move legacy `junior:conversation-work:*` Redis state into the newer conversation record and index state used by the durable worker and dashboard feed. - Backfill retained conversation records into the shared Junior SQL database. The upgrade requires `DATABASE_URL`. -The migrations are idempotent: rerunning them skips records that were already moved, removes stale legacy index entries that no longer have a record, and upserts SQL conversation rows. The SQL conversation backfill copies a bounded legacy slice of Redis conversation metadata; after cutover, durable conversation metadata is written to SQL while Redis remains the transcript and execution/cache store. +Completed journal entries are tracked individually, and TypeScript migrations +can checkpoint progress for safe retries. Legacy backfills remain idempotent: +rerunning them skips records that were already moved, removes stale legacy +index entries that no longer have a record, and upserts SQL conversation rows. +The SQL conversation backfill copies a bounded legacy slice of Redis +conversation metadata; after cutover, durable conversation metadata is written +to SQL while Redis remains the transcript and execution/cache store. ## Vercel deploys @@ -56,10 +72,10 @@ Typical logs look like this: ```text Running Junior upgrade migrations... -Running migration migrate-redis-conversation-state... -Finished migration migrate-redis-conversation-state: scanned=2 migrated=1 existing=0 missing=1 -Running migration backfill-conversations-sql... -Finished migration backfill-conversations-sql: scanned=2 migrated=2 existing=0 missing=0 +Running migration core-migrations... +Finished migration core-migrations: scanned=8 migrated=4 existing=4 missing=0 skipped=0 +Running migration plugin-migrations... +Finished migration plugin-migrations: scanned=8 migrated=8 existing=0 missing=0 skipped=0 Junior upgrade complete. ``` diff --git a/packages/docs/src/content/docs/contribute/releasing.md b/packages/docs/src/content/docs/contribute/releasing.md index 5556fcb5c..9950005a3 100644 --- a/packages/docs/src/content/docs/contribute/releasing.md +++ b/packages/docs/src/content/docs/contribute/releasing.md @@ -12,6 +12,7 @@ related: Junior uses lockstep package releases for: - `@sentry/junior` +- `@sentry/junior-migrations` - `@sentry/junior-plugin-api` - `@sentry/junior-agent-browser` - `@sentry/junior-amplitude` diff --git a/packages/junior-memory/README.md b/packages/junior-memory/README.md index f6c634a13..1eb814815 100644 --- a/packages/junior-memory/README.md +++ b/packages/junior-memory/README.md @@ -55,6 +55,8 @@ exported types, tools, and tests are authoritative. - `MEMORY_RECALL_MAX_VECTOR_DISTANCE` or `recallMaxVectorDistance` configures the vector candidate threshold. - Generate schema changes with `pnpm --filter @sentry/junior-memory db:generate`. +- Generate self-contained data migrations with + `pnpm --filter @sentry/junior-memory db:generate:data --name `. Follow `../../policies/data-redaction.md`, `../../policies/security.md`, and the plugin contract in `../junior-plugin-api/README.md`. diff --git a/packages/junior-memory/package.json b/packages/junior-memory/package.json index 28e3c84ca..8dbd204c6 100644 --- a/packages/junior-memory/package.json +++ b/packages/junior-memory/package.json @@ -25,6 +25,7 @@ "scripts": { "build": "tsup && tsc -p tsconfig.build.json --emitDeclarationOnly", "db:generate": "pnpm exec drizzle-kit generate --config drizzle.config.ts", + "db:generate:data": "pnpm exec junior-migrations generate --config drizzle.config.ts --out migrations", "lint": "oxlint --config ../junior/.oxlintrc.json --deny-warnings src tests tsup.config.ts", "prepare": "pnpm run build", "prepack": "pnpm run build", @@ -39,6 +40,7 @@ "zod": "catalog:" }, "devDependencies": { + "@sentry/junior-migrations": "workspace:*", "@sentry/junior-testing": "workspace:*", "@types/node": "^25.9.1", "drizzle-kit": "catalog:", diff --git a/packages/junior-migrations/README.md b/packages/junior-migrations/README.md new file mode 100644 index 000000000..1abc59712 --- /dev/null +++ b/packages/junior-migrations/README.md @@ -0,0 +1,42 @@ +# `@sentry/junior-migrations` + +Junior's migration format extends a Drizzle Kit journal with TypeScript data +migrations. Every journal entry resolves to exactly one `.sql` or +`.ts` file. Drizzle Kit continues to own numbering and schema snapshots; +the Junior migration runner owns execution and exact per-entry tracking. + +TypeScript migrations target a versioned capability API and must not import +Junior runtime internals. Every parser, transform, and migration-specific +helper belongs permanently in the migration file so application refactors or +deletions cannot break pending upgrades. + +## Authoring + +Generate schema migrations with the owning package's normal Drizzle command. +Generate data migrations through this package's wrapper: + +```bash +junior-migrations generate \ + --config drizzle.config.ts \ + --out migrations \ + --name backfill_actor +``` + +The wrapper asks Drizzle Kit to create a custom journal entry and snapshot, +then replaces the empty SQL file with a `MigrationV1` TypeScript scaffold. + +## Compatibility + +Drizzle Kit remains the supported authoring tool. Drizzle ORM's stock +`migrate()` function is not a supported executor for mixed journals because it +requires every entry to have a SQL file. Call `runMigrationJournal` instead and +provide the host database adapter, state adapter, and TypeScript loader. The +same database adapter drives the journal ledger and is exposed as +`context.database`, so migration files never own connection or driver setup. + +The runner rejects runtime imports of application source, relative modules, +and unversioned `@sentry/junior` modules. Migrations may import the append-only +`@sentry/junior/migration-helpers/v1` surface for parsing primitives, adapters, +stores, and key resolution. One-off migration decisions and data transforms +must still remain in the journal entry. Add a new helper or capability version +rather than changing an existing contract. diff --git a/packages/junior-migrations/package.json b/packages/junior-migrations/package.json new file mode 100644 index 000000000..391e14d0f --- /dev/null +++ b/packages/junior-migrations/package.json @@ -0,0 +1,47 @@ +{ + "name": "@sentry/junior-migrations", + "version": "0.101.0", + "private": false, + "publishConfig": { + "access": "public" + }, + "type": "module", + "repository": { + "type": "git", + "url": "git+https://github.com/getsentry/junior.git", + "directory": "packages/junior-migrations" + }, + "bin": { + "junior-migrations": "dist/cli.js" + }, + "exports": { + ".": { + "types": "./src/index.ts", + "default": "./dist/index.js" + } + }, + "files": [ + "dist", + "src" + ], + "scripts": { + "build": "tsup && tsc -p tsconfig.build.json --emitDeclarationOnly", + "prepare": "pnpm run build", + "prepack": "pnpm run build", + "lint": "oxlint --config ../junior/.oxlintrc.json --deny-warnings src tests tsup.config.ts", + "test": "vitest run", + "typecheck": "tsc --noEmit" + }, + "peerDependencies": { + "drizzle-kit": "catalog:" + }, + "devDependencies": { + "@types/node": "^25.9.1", + "drizzle-kit": "catalog:", + "drizzle-orm": "catalog:", + "oxlint": "^1.66.0", + "tsup": "^8.5.1", + "typescript": "^6.0.3", + "vitest": "^4.1.7" + } +} diff --git a/packages/junior-migrations/src/cli.ts b/packages/junior-migrations/src/cli.ts new file mode 100644 index 000000000..22714b788 --- /dev/null +++ b/packages/junior-migrations/src/cli.ts @@ -0,0 +1,24 @@ +import { parseArgs } from "node:util"; +import { generateTypeScriptMigration } from "./generate"; + +const { positionals, values } = parseArgs({ + allowPositionals: true, + options: { + config: { type: "string" }, + name: { type: "string" }, + out: { type: "string", default: "./migrations" }, + }, +}); + +if (positionals[0] !== "generate" || !values.config || !values.name) { + throw new Error( + "Usage: junior-migrations generate --config --name [--out ]", + ); +} + +const path = await generateTypeScriptMigration({ + configPath: values.config, + migrationsFolder: values.out, + name: values.name, +}); +console.log(`Created TypeScript migration ${path}`); diff --git a/packages/junior-migrations/src/generate.ts b/packages/junior-migrations/src/generate.ts new file mode 100644 index 000000000..3c78c79a3 --- /dev/null +++ b/packages/junior-migrations/src/generate.ts @@ -0,0 +1,68 @@ +import { spawn } from "node:child_process"; +import { readFile, rename, writeFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import { readMigrationJournal } from "./journal"; + +/** Drizzle Kit inputs used to create one TypeScript migration entry. */ +export interface GenerateTypeScriptMigrationOptions { + configPath: string; + cwd?: string; + migrationsFolder: string; + name: string; +} + +function run(command: string, args: string[], cwd: string): Promise { + return new Promise((resolvePromise, reject) => { + const child = spawn(command, args, { cwd, stdio: "inherit" }); + child.on("error", reject); + child.on("exit", (code) => { + if (code === 0) { + resolvePromise(); + } else { + reject(new Error(`${command} exited with code ${code ?? "unknown"}`)); + } + }); + }); +} + +function scaffold(): string { + return `import type { MigrationV1 } from "@sentry/junior-migrations";\n\nconst migration = {\n apiVersion: 1,\n async up(context) {\n void context;\n },\n} satisfies MigrationV1;\n\nexport default migration;\n`; +} + +/** Create a journaled TypeScript migration through Drizzle Kit's custom generator. */ +export async function generateTypeScriptMigration( + options: GenerateTypeScriptMigrationOptions, +): Promise { + const cwd = resolve(options.cwd ?? process.cwd()); + const folder = resolve(cwd, options.migrationsFolder); + const before = await readMigrationJournal(folder).catch(() => []); + await run( + "drizzle-kit", + [ + "generate", + "--custom", + "--config", + options.configPath, + "--name", + options.name, + ], + cwd, + ); + const after = await readMigrationJournal(folder); + if (after.length !== before.length + 1) { + throw new Error("Drizzle Kit did not create exactly one migration entry"); + } + const entry = after.at(-1); + if (!entry) { + throw new Error("Drizzle Kit did not create a migration entry"); + } + const sqlPath = resolve(folder, `${entry.tag}.sql`); + const source = await readFile(sqlPath, "utf8"); + if (!source.includes("Custom SQL migration file")) { + throw new Error(`Refusing to replace non-custom migration ${entry.tag}`); + } + const typescriptPath = resolve(folder, `${entry.tag}.ts`); + await rename(sqlPath, typescriptPath); + await writeFile(typescriptPath, scaffold(), "utf8"); + return typescriptPath; +} diff --git a/packages/junior-migrations/src/index.ts b/packages/junior-migrations/src/index.ts new file mode 100644 index 000000000..6c07a55ad --- /dev/null +++ b/packages/junior-migrations/src/index.ts @@ -0,0 +1,19 @@ +export { generateTypeScriptMigration } from "./generate"; +export { readMigrationJournal, resolveMigrations } from "./journal"; +export { runMigrationJournal } from "./runner"; +export type { GenerateTypeScriptMigrationOptions } from "./generate"; +export type { RunMigrationJournalOptions } from "./runner"; +export type { + MigrationContextV1, + MigrationDatabaseAdapter, + MigrationJsonValue, + MigrationJournalEntry, + MigrationLockV1, + MigrationProgressV1, + MigrationRedisV1, + MigrationRunResult, + MigrationStateV1, + MigrationV1, + ResolvedMigration, + TypeScriptMigrationLoader, +} from "./types"; diff --git a/packages/junior-migrations/src/journal.ts b/packages/junior-migrations/src/journal.ts new file mode 100644 index 000000000..826ffef26 --- /dev/null +++ b/packages/junior-migrations/src/journal.ts @@ -0,0 +1,153 @@ +import { createHash } from "node:crypto"; +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; +import type { MigrationJournalEntry, ResolvedMigration } from "./types"; + +interface DrizzleJournalEntry { + breakpoints?: unknown; + idx?: unknown; + tag?: unknown; + when?: unknown; +} + +interface DrizzleJournal { + dialect?: unknown; + entries?: unknown; +} + +function parseJournalEntry( + value: DrizzleJournalEntry, + position: number, +): MigrationJournalEntry { + if ( + typeof value.idx !== "number" || + !Number.isInteger(value.idx) || + value.idx !== position || + typeof value.when !== "number" || + !Number.isFinite(value.when) || + typeof value.tag !== "string" || + !value.tag || + typeof value.breakpoints !== "boolean" + ) { + throw new Error(`Invalid Drizzle journal entry at index ${position}`); + } + return { + breakpoints: value.breakpoints, + index: value.idx, + tag: value.tag, + when: value.when, + }; +} + +async function optionalFile(path: string): Promise { + try { + return await readFile(path, "utf8"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + return undefined; + } + throw error; + } +} + +function validateTypeScriptSource(tag: string, source: string): void { + const allowedRuntimeImports = new Set([ + "@sentry/junior/migration-helpers/v1", + ]); + const imports = source.matchAll( + /^\s*import\s+([^;]+?)\s+from\s+["']([^"']+)["'];?/gm, + ); + for (const match of imports) { + const clause = match[1]?.trim(); + const specifier = match[2]; + if (clause?.startsWith("type ")) { + continue; + } + if ( + !specifier || + specifier.startsWith(".") || + specifier.startsWith("/") || + specifier.startsWith("@/") || + ((specifier === "@sentry/junior" || + specifier.startsWith("@sentry/junior/")) && + !allowedRuntimeImports.has(specifier)) + ) { + throw new Error( + `TypeScript migration ${tag} cannot import application runtime code`, + ); + } + } + if ( + /^\s*import\s*["']/m.test(source) || + /^\s*export\s+.+\s+from\s+["'][./]/m.test(source) || + /\b(?:import\s*\(|require\s*\()/.test(source) + ) { + throw new Error(`TypeScript migration ${tag} cannot load runtime modules`); + } +} + +/** Read and validate the ordered Drizzle journal entries in one migration directory. */ +export async function readMigrationJournal( + migrationsFolder: string, +): Promise { + const journalPath = join(migrationsFolder, "meta", "_journal.json"); + let source: string; + try { + source = await readFile(journalPath, "utf8"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + throw new Error("Can't find meta/_journal.json file", { cause: error }); + } + throw error; + } + const parsed = JSON.parse(source) as DrizzleJournal; + if (parsed.dialect !== "postgresql" || !Array.isArray(parsed.entries)) { + throw new Error(`Unsupported Drizzle journal in ${journalPath}`); + } + const entries = parsed.entries.map((entry, index) => + parseJournalEntry(entry as DrizzleJournalEntry, index), + ); + const timestamps = new Set(); + for (const entry of entries) { + if (timestamps.has(entry.when)) { + throw new Error(`Duplicate migration timestamp ${entry.when}`); + } + timestamps.add(entry.when); + } + return entries; +} + +/** Resolve every journal entry to exactly one immutable SQL or TypeScript source file. */ +export async function resolveMigrations( + migrationsFolder: string, +): Promise { + const entries = await readMigrationJournal(migrationsFolder); + return await Promise.all( + entries.map(async (entry) => { + const sqlPath = join(migrationsFolder, `${entry.tag}.sql`); + const typescriptPath = join(migrationsFolder, `${entry.tag}.ts`); + const [sqlSource, typescriptSource] = await Promise.all([ + optionalFile(sqlPath), + optionalFile(typescriptPath), + ]); + if ((sqlSource === undefined) === (typescriptSource === undefined)) { + throw new Error( + `Migration ${entry.tag} must have exactly one .sql or .ts file`, + ); + } + const kind = sqlSource === undefined ? "typescript" : "sql"; + const source = sqlSource ?? typescriptSource ?? ""; + const path = kind === "sql" ? sqlPath : typescriptPath; + if (kind === "typescript") { + validateTypeScriptSource(entry.tag, source); + } + return { + ...entry, + hash: createHash("sha256").update(source).digest("hex"), + kind, + path, + source, + }; + }), + ); +} diff --git a/packages/junior-migrations/src/runner.ts b/packages/junior-migrations/src/runner.ts new file mode 100644 index 000000000..d0d043e93 --- /dev/null +++ b/packages/junior-migrations/src/runner.ts @@ -0,0 +1,311 @@ +import type { + MigrationContextV1, + MigrationDatabaseAdapter, + MigrationRunResult, + MigrationV1, + ResolvedMigration, + TypeScriptMigrationLoader, +} from "./types"; +import { resolveMigrations } from "./journal"; + +interface MigrationRow { + createdAt: string; + hash: string; + progress: unknown; + status: string | null; +} + +interface RunMigrationJournalBaseOptions { + beforeRun?: () => Promise; + executor: MigrationDatabaseAdapter; + migrationsFolder: string; + migrationsTable: string; +} + +interface RunAllMigrationJournalOptions extends RunMigrationJournalBaseOptions { + createContext: (args: { + migration: ResolvedMigration; + progress: MigrationContextV1["progress"]; + }) => MigrationContextV1; + loadTypeScript: TypeScriptMigrationLoader; + mode?: "all"; +} + +interface RunSqlMigrationJournalOptions extends RunMigrationJournalBaseOptions { + createContext?: never; + loadTypeScript?: never; + mode: "sql"; +} + +/** Host capabilities and execution mode for one mixed migration journal. */ +export type RunMigrationJournalOptions = + | RunAllMigrationJournalOptions + | RunSqlMigrationJournalOptions; + +function identifier(value: string): string { + if (!/^[a-z_][a-z0-9_]*$/.test(value)) { + throw new Error(`Invalid migration table identifier: ${value}`); + } + return value; +} + +function qualifiedTable(table: string): string { + return `drizzle.${identifier(table)}`; +} + +async function ensureMigrationTable( + executor: MigrationDatabaseAdapter, + table: string, +): Promise { + const qualified = qualifiedTable(table); + await executor.execute("CREATE SCHEMA IF NOT EXISTS drizzle"); + await executor.execute(` +CREATE TABLE IF NOT EXISTS ${qualified} ( + id SERIAL PRIMARY KEY, + hash TEXT NOT NULL, + created_at BIGINT +) +`); + await executor.execute( + `ALTER TABLE ${qualified} ADD COLUMN IF NOT EXISTS name TEXT`, + ); + await executor.execute( + `ALTER TABLE ${qualified} ADD COLUMN IF NOT EXISTS kind TEXT`, + ); + await executor.execute( + `ALTER TABLE ${qualified} ADD COLUMN IF NOT EXISTS status TEXT`, + ); + await executor.execute( + `ALTER TABLE ${qualified} ADD COLUMN IF NOT EXISTS progress JSONB`, + ); + await executor.execute( + `ALTER TABLE ${qualified} ADD COLUMN IF NOT EXISTS result JSONB`, + ); + await executor.execute( + `ALTER TABLE ${qualified} ADD COLUMN IF NOT EXISTS started_at TIMESTAMPTZ`, + ); + await executor.execute( + `ALTER TABLE ${qualified} ADD COLUMN IF NOT EXISTS completed_at TIMESTAMPTZ`, + ); +} + +async function migrationRows( + executor: MigrationDatabaseAdapter, + table: string, +): Promise> { + const rows = await executor.query(` +SELECT + created_at::text AS "createdAt", + hash, + progress, + status +FROM ${qualifiedTable(table)} +WHERE created_at IS NOT NULL +ORDER BY created_at ASC, id ASC +`); + return new Map(rows.map((row) => [Number(row.createdAt), row])); +} + +async function adoptLegacySqlPrefix(args: { + executor: MigrationDatabaseAdapter; + migrations: readonly ResolvedMigration[]; + rows: Map; + table: string; +}): Promise { + const latestAppliedAt = Math.max( + ...args.rows.keys(), + Number.NEGATIVE_INFINITY, + ); + if (!Number.isFinite(latestAppliedAt)) { + return; + } + for (const migration of args.migrations) { + if ( + migration.when >= latestAppliedAt || + migration.kind !== "sql" || + args.rows.has(migration.when) + ) { + continue; + } + await args.executor.execute( + `INSERT INTO ${qualifiedTable(args.table)} + (hash, created_at, name, kind, status, completed_at) + VALUES ($1, $2, $3, 'sql', 'completed', NOW())`, + [migration.hash, migration.when, migration.tag], + ); + } +} + +function sqlStatements(migration: ResolvedMigration): string[] { + return migration.source + .split("--> statement-breakpoint") + .map((statement) => statement.trim()) + .filter(Boolean); +} + +function migrationV1(value: unknown, tag: string): MigrationV1 { + const candidate = + typeof value === "object" && value !== null && "default" in value + ? (value as { default?: unknown }).default + : value; + if ( + typeof candidate !== "object" || + candidate === null || + (candidate as { apiVersion?: unknown }).apiVersion !== 1 || + typeof (candidate as { up?: unknown }).up !== "function" + ) { + throw new Error(`TypeScript migration ${tag} does not export MigrationV1`); + } + return candidate as MigrationV1; +} + +async function runSqlMigration(args: { + executor: MigrationDatabaseAdapter; + migration: ResolvedMigration; + table: string; +}): Promise { + await args.executor.transaction(async () => { + for (const statement of sqlStatements(args.migration)) { + await args.executor.execute(statement); + } + await args.executor.execute( + `INSERT INTO ${qualifiedTable(args.table)} + (hash, created_at, name, kind, status, started_at, completed_at) + VALUES ($1, $2, $3, 'sql', 'completed', NOW(), NOW())`, + [args.migration.hash, args.migration.when, args.migration.tag], + ); + }); +} + +async function runTypeScriptMigration(args: { + createContext: RunAllMigrationJournalOptions["createContext"]; + executor: MigrationDatabaseAdapter; + loadTypeScript: TypeScriptMigrationLoader; + migration: ResolvedMigration; + row: MigrationRow | undefined; + table: string; +}): Promise { + const qualified = qualifiedTable(args.table); + if (args.row && args.row.hash !== args.migration.hash) { + throw new Error(`Migration ${args.migration.tag} changed after it started`); + } + if (args.row) { + await args.executor.execute( + `UPDATE ${qualified} + SET name = $1, kind = 'typescript', status = 'running', started_at = NOW() + WHERE created_at = $2`, + [args.migration.tag, args.migration.when], + ); + } else { + await args.executor.execute( + `INSERT INTO ${qualified} + (hash, created_at, name, kind, status, started_at) + VALUES ($1, $2, $3, 'typescript', 'running', NOW())`, + [args.migration.hash, args.migration.when, args.migration.tag], + ); + } + const progress: MigrationContextV1["progress"] = { + async load() { + const [current] = await args.executor.query<{ + progress: Awaited>; + }>(`SELECT progress FROM ${qualified} WHERE created_at = $1 LIMIT 1`, [ + args.migration.when, + ]); + return current?.progress ?? undefined; + }, + async save(value) { + await args.executor.execute( + `UPDATE ${qualified} SET progress = $1, status = 'running' WHERE created_at = $2`, + [value, args.migration.when], + ); + }, + }; + try { + const context = args.createContext({ migration: args.migration, progress }); + const migration = migrationV1( + await args.loadTypeScript(args.migration.path), + args.migration.tag, + ); + const result = await migration.up(context); + await args.executor.execute( + `UPDATE ${qualified} + SET status = 'completed', result = $1, completed_at = NOW() + WHERE created_at = $2`, + [result ?? null, args.migration.when], + ); + } catch (error) { + await args.executor.execute( + `UPDATE ${qualified} SET status = 'failed' WHERE created_at = $1`, + [args.migration.when], + ); + throw error; + } +} + +/** Execute one mixed Drizzle journal with exact SQL and TypeScript entry tracking. */ +export async function runMigrationJournal( + options: RunMigrationJournalOptions, +): Promise { + const migrations = await resolveMigrations(options.migrationsFolder); + const mode = options.mode ?? "all"; + return await options.executor.withMigrationLock( + options.migrationsTable, + async () => { + await options.beforeRun?.(); + await ensureMigrationTable(options.executor, options.migrationsTable); + let rows = await migrationRows(options.executor, options.migrationsTable); + await adoptLegacySqlPrefix({ + executor: options.executor, + migrations, + rows, + table: options.migrationsTable, + }); + rows = await migrationRows(options.executor, options.migrationsTable); + const result: MigrationRunResult = { + existing: 0, + migrated: 0, + scanned: migrations.length, + skipped: 0, + }; + for (const migration of migrations) { + const row = rows.get(migration.when); + if (row && row.hash !== migration.hash) { + throw new Error( + `Migration ${migration.tag} changed after it started`, + ); + } + if (row?.status === "completed" || (row && row.status === null)) { + result.existing += 1; + continue; + } + if (mode === "sql" && migration.kind === "typescript") { + result.skipped += 1; + continue; + } + if (migration.kind === "sql") { + await runSqlMigration({ + executor: options.executor, + migration, + table: options.migrationsTable, + }); + } else { + if (!options.createContext || !options.loadTypeScript) { + throw new Error( + `TypeScript migration ${migration.tag} requires a loader and context`, + ); + } + await runTypeScriptMigration({ + createContext: options.createContext, + executor: options.executor, + loadTypeScript: options.loadTypeScript, + migration, + row, + table: options.migrationsTable, + }); + } + result.migrated += 1; + } + return result; + }, + ); +} diff --git a/packages/junior-migrations/src/types.ts b/packages/junior-migrations/src/types.ts new file mode 100644 index 000000000..dd2aa6813 --- /dev/null +++ b/packages/junior-migrations/src/types.ts @@ -0,0 +1,103 @@ +/** Database adapter used by the mixed migration runner and TypeScript entries. */ +export interface MigrationDatabaseAdapter { + db(): unknown; + execute(statement: string, parameters?: readonly unknown[]): Promise; + query( + statement: string, + parameters?: readonly unknown[], + ): Promise; + transaction(callback: () => Promise): Promise; + withLock(lockName: string, callback: () => Promise): Promise; + withMigrationLock( + migrationTable: string, + callback: () => Promise, + ): Promise; +} + +/** Stable state-store capabilities exposed to v1 TypeScript migrations. */ +export interface MigrationLockV1 { + expiresAt: number; + threadId: string; + token: string; +} + +/** Stable state-store capabilities exposed to v1 TypeScript migrations. */ +export interface MigrationStateV1 { + acquireLock(threadId: string, ttlMs: number): Promise; + appendToList( + key: string, + value: unknown, + options?: { maxLength?: number; ttlMs?: number }, + ): Promise; + connect(): Promise; + delete(key: string): Promise; + get(key: string): Promise; + getList(key: string): Promise; + releaseLock(lock: MigrationLockV1): Promise; + set(key: string, value: unknown, ttlMs?: number): Promise; + setIfNotExists(key: string, value: unknown, ttlMs?: number): Promise; +} + +/** Optional raw Redis capability for migrations preserving Redis indexes. */ +export interface MigrationRedisV1 { + sendCommand(args: readonly string[]): Promise; +} + +/** Resumable progress storage scoped to one TypeScript migration. */ +export interface MigrationProgressV1 { + load(): Promise; + save(value: MigrationJsonValue): Promise; +} + +/** JSON-compatible value persisted in migration progress and result columns. */ +export type MigrationJsonValue = + | boolean + | number + | string + | null + | MigrationJsonValue[] + | { [key: string]: MigrationJsonValue }; + +/** Permanent capability contract for apiVersion 1 migrations. */ +export interface MigrationContextV1 { + database: MigrationDatabaseAdapter; + log(message: string): void; + progress: MigrationProgressV1; + redis?: MigrationRedisV1; + state: MigrationStateV1; +} + +/** Isolated TypeScript data migration targeting the v1 ABI. */ +export interface MigrationV1 { + apiVersion: 1; + up(context: MigrationContextV1): Promise; +} + +/** One ordered entry from Drizzle Kit's journal metadata. */ +export interface MigrationJournalEntry { + breakpoints: boolean; + index: number; + tag: string; + when: number; +} + +/** Journal entry paired with its unique SQL or TypeScript source file. */ +export interface ResolvedMigration extends MigrationJournalEntry { + hash: string; + kind: "sql" | "typescript"; + path: string; + source: string; +} + +/** Aggregate counts returned after one journal execution. */ +export interface MigrationRunResult { + existing: number; + migrated: number; + scanned: number; + skipped: number; +} + +/** Host-provided loader for an isolated TypeScript migration module. */ +export interface TypeScriptMigrationLoader { + (path: string): Promise; +} diff --git a/packages/junior-migrations/tests/generate.test.ts b/packages/junior-migrations/tests/generate.test.ts new file mode 100644 index 000000000..399d368a8 --- /dev/null +++ b/packages/junior-migrations/tests/generate.test.ts @@ -0,0 +1,99 @@ +import { spawn } from "node:child_process"; +import { access, mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { afterEach, expect, it } from "vitest"; +import { generateTypeScriptMigration } from "../src/generate"; +import { readMigrationJournal } from "../src/journal"; + +const temporaryDirectories: string[] = []; + +function runDrizzle(args: string[], cwd: string): Promise { + return new Promise((resolve, reject) => { + const child = spawn("drizzle-kit", args, { cwd, stdio: "pipe" }); + let output = ""; + child.stdout.on("data", (chunk) => { + output += String(chunk); + }); + child.stderr.on("data", (chunk) => { + output += String(chunk); + }); + child.on("error", reject); + child.on("exit", (code) => { + if (code === 0) { + resolve(); + } else { + reject(new Error(output)); + } + }); + }); +} + +afterEach(async () => { + await Promise.all( + temporaryDirectories + .splice(0) + .map((directory) => rm(directory, { recursive: true, force: true })), + ); +}); + +it("keeps Drizzle schema generation working across a TypeScript entry", async () => { + const relativeRoot = `.tmp-mixed-migrations-${Date.now()}`; + const root = join(process.cwd(), relativeRoot); + temporaryDirectories.push(root); + await mkdir(root); + await writeFile( + join(root, "schema.ts"), + 'import { pgTable, text } from "drizzle-orm/pg-core";\nexport const users = pgTable("users", { id: text("id").primaryKey() });\n', + ); + await writeFile( + join(root, "drizzle.config.ts"), + `import { defineConfig } from "drizzle-kit";\nexport default defineConfig({ dialect: "postgresql", schema: "./${relativeRoot}/schema.ts", out: "./${relativeRoot}/migrations" });\n`, + ); + + await runDrizzle( + [ + "generate", + "--config", + `${relativeRoot}/drizzle.config.ts`, + "--name", + "initial", + ], + process.cwd(), + ); + const typescriptPath = await generateTypeScriptMigration({ + configPath: `${relativeRoot}/drizzle.config.ts`, + cwd: process.cwd(), + migrationsFolder: `${relativeRoot}/migrations`, + name: "backfill", + }); + await expect(access(typescriptPath)).resolves.toBeUndefined(); + await expect( + access(join(root, "migrations", "0001_backfill.sql")), + ).rejects.toThrow("ENOENT"); + + await writeFile( + join(root, "schema.ts"), + 'import { pgTable, text } from "drizzle-orm/pg-core";\nexport const users = pgTable("users", { id: text("id").primaryKey(), name: text("name") });\n', + ); + await runDrizzle( + [ + "generate", + "--config", + `${relativeRoot}/drizzle.config.ts`, + "--name", + "add_name", + ], + process.cwd(), + ); + + await expect( + readMigrationJournal(join(root, "migrations")), + ).resolves.toMatchObject([ + { index: 0, tag: "0000_initial" }, + { index: 1, tag: "0001_backfill" }, + { index: 2, tag: "0002_add_name" }, + ]); + await expect( + readFile(join(root, "migrations", "0002_add_name.sql"), "utf8"), + ).resolves.toContain('ADD COLUMN "name" text'); +}); diff --git a/packages/junior-migrations/tests/journal.test.ts b/packages/junior-migrations/tests/journal.test.ts new file mode 100644 index 000000000..0acef14cf --- /dev/null +++ b/packages/junior-migrations/tests/journal.test.ts @@ -0,0 +1,89 @@ +import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { resolveMigrations } from "../src/journal"; + +const temporaryDirectories: string[] = []; + +async function migrationFolder(entries: string[]): Promise { + const root = await mkdtemp(join(tmpdir(), "junior-migrations-")); + temporaryDirectories.push(root); + await mkdir(join(root, "meta")); + await writeFile( + join(root, "meta", "_journal.json"), + JSON.stringify({ + version: "7", + dialect: "postgresql", + entries: entries.map((tag, index) => ({ + idx: index, + version: "7", + when: 1_000 + index, + tag, + breakpoints: true, + })), + }), + ); + return root; +} + +afterEach(async () => { + await Promise.all( + temporaryDirectories + .splice(0) + .map((directory) => rm(directory, { recursive: true, force: true })), + ); +}); + +describe("resolveMigrations", () => { + it("resolves one ordered SQL or TypeScript file per journal entry", async () => { + const folder = await migrationFolder(["0000_initial", "0001_backfill"]); + await writeFile(join(folder, "0000_initial.sql"), "SELECT 1;"); + await writeFile( + join(folder, "0001_backfill.ts"), + 'import type { MigrationV1 } from "@sentry/junior-migrations";\nexport default { apiVersion: 1, async up() {} } satisfies MigrationV1;\n', + ); + + await expect(resolveMigrations(folder)).resolves.toMatchObject([ + { index: 0, kind: "sql", tag: "0000_initial", when: 1_000 }, + { index: 1, kind: "typescript", tag: "0001_backfill", when: 1_001 }, + ]); + }); + + it("rejects migrations that import runtime modules", async () => { + const folder = await migrationFolder(["0000_unsafe"]); + await writeFile( + join(folder, "0000_unsafe.ts"), + 'import { getDb } from "@/db";\nexport default { apiVersion: 1, async up() { getDb(); } };\n', + ); + + await expect(resolveMigrations(folder)).rejects.toThrow( + "cannot import application runtime code", + ); + }); + + it("allows versioned Junior migration helpers", async () => { + const folder = await migrationFolder(["0000_helpers"]); + await writeFile( + join(folder, "0000_helpers.ts"), + 'import { isRecord } from "@sentry/junior/migration-helpers/v1";\nexport default { apiVersion: 1, async up() { isRecord({}); } };\n', + ); + + await expect(resolveMigrations(folder)).resolves.toMatchObject([ + { kind: "typescript", tag: "0000_helpers" }, + ]); + }); + + it("rejects ambiguous migration files", async () => { + const folder = await migrationFolder(["0000_ambiguous"]); + await writeFile(join(folder, "0000_ambiguous.sql"), "SELECT 1;"); + await writeFile( + join(folder, "0000_ambiguous.ts"), + "export default { apiVersion: 1, async up() {} };\n", + ); + + await expect(resolveMigrations(folder)).rejects.toThrow( + "exactly one .sql or .ts file", + ); + }); +}); diff --git a/packages/junior-migrations/tests/runner.test.ts b/packages/junior-migrations/tests/runner.test.ts new file mode 100644 index 000000000..c072487b5 --- /dev/null +++ b/packages/junior-migrations/tests/runner.test.ts @@ -0,0 +1,271 @@ +import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { runMigrationJournal } from "../src/runner"; +import type { + MigrationContextV1, + MigrationDatabaseAdapter, + MigrationV1, +} from "../src/types"; + +interface StoredRow { + createdAt: number; + hash: string; + progress?: unknown; + status: string | null; +} + +class FakeExecutor implements MigrationDatabaseAdapter { + readonly rows = new Map(); + readonly statements: string[] = []; + + db(): undefined { + return undefined; + } + + async execute(statement: string, parameters: readonly unknown[] = []) { + const normalized = statement.trim(); + if ( + normalized.startsWith("CREATE SCHEMA") || + normalized.startsWith("CREATE TABLE IF NOT EXISTS drizzle.") || + normalized.startsWith("ALTER TABLE drizzle.") + ) { + return; + } + if (normalized.startsWith("INSERT INTO drizzle.")) { + const hash = String(parameters[0]); + const createdAt = Number(parameters[1]); + const kind = normalized.includes("'typescript'") + ? "running" + : "completed"; + this.rows.set(createdAt, { createdAt, hash, status: kind }); + return; + } + if (normalized.startsWith("UPDATE drizzle.")) { + const createdAt = Number(parameters.at(-1)); + const row = this.rows.get(createdAt); + if (!row) { + throw new Error(`Missing row ${createdAt}`); + } + if (normalized.includes("progress = $1")) { + row.progress = parameters[0]; + } + if (normalized.includes("status = 'running'")) { + row.status = "running"; + } else if (normalized.includes("status = 'completed'")) { + row.status = "completed"; + } else if (normalized.includes("status = 'failed'")) { + row.status = "failed"; + } + return; + } + this.statements.push(normalized); + } + + async query(statement: string, parameters: readonly unknown[] = []) { + if (statement.includes('created_at::text AS "createdAt"')) { + return [...this.rows.values()].map((row) => ({ + createdAt: String(row.createdAt), + hash: row.hash, + progress: row.progress, + status: row.status, + })) as T[]; + } + if (statement.includes("SELECT progress")) { + const row = this.rows.get(Number(parameters[0])); + return (row ? [{ progress: row.progress ?? null }] : []) as T[]; + } + return []; + } + + async transaction(callback: () => Promise): Promise { + return await callback(); + } + + async withMigrationLock( + _migrationTable: string, + callback: () => Promise, + ): Promise { + return await callback(); + } + + async withLock(_lockName: string, callback: () => Promise): Promise { + return await callback(); + } +} + +function fakeMigrationState(): MigrationContextV1["state"] { + return { + acquireLock: async () => null, + appendToList: async () => {}, + connect: async () => {}, + delete: async () => {}, + get: async () => undefined, + getList: async () => [], + releaseLock: async () => {}, + set: async () => {}, + setIfNotExists: async () => true, + }; +} + +const temporaryDirectories: string[] = []; + +async function mixedFolder(): Promise { + const root = await mkdtemp(join(tmpdir(), "junior-migrations-runner-")); + temporaryDirectories.push(root); + await mkdir(join(root, "meta")); + await writeFile( + join(root, "meta", "_journal.json"), + JSON.stringify({ + version: "7", + dialect: "postgresql", + entries: ["0000_schema", "0001_data", "0002_schema"].map( + (tag, index) => ({ + idx: index, + version: "7", + when: 2_000 + index, + tag, + breakpoints: true, + }), + ), + }), + ); + await writeFile(join(root, "0000_schema.sql"), "SELECT 'schema-zero';"); + await writeFile( + join(root, "0001_data.ts"), + 'import type { MigrationV1 } from "@sentry/junior-migrations";\nexport default {} satisfies MigrationV1;\n', + ); + await writeFile(join(root, "0002_schema.sql"), "SELECT 'schema-two';"); + return root; +} + +afterEach(async () => { + await Promise.all( + temporaryDirectories + .splice(0) + .map((directory) => rm(directory, { recursive: true, force: true })), + ); +}); + +describe("runMigrationJournal", () => { + it("runs mixed entries in journal order and is a no-op on rerun", async () => { + const folder = await mixedFolder(); + const executor = new FakeExecutor(); + const order: string[] = []; + const migration: MigrationV1 = { + apiVersion: 1, + async up(context) { + order.push("typescript"); + await context.progress.save({ cursor: 1 }); + }, + }; + + await expect( + runMigrationJournal({ + executor, + migrationsFolder: folder, + migrationsTable: "__drizzle_test", + loadTypeScript: async () => ({ default: migration }), + createContext: ({ progress }) => ({ + log: () => {}, + progress, + database: executor, + state: fakeMigrationState(), + }), + }), + ).resolves.toEqual({ existing: 0, migrated: 3, scanned: 3, skipped: 0 }); + expect(executor.statements).toEqual([ + "SELECT 'schema-zero';", + "SELECT 'schema-two';", + ]); + expect(order).toEqual(["typescript"]); + + await expect( + runMigrationJournal({ + executor, + migrationsFolder: folder, + migrationsTable: "__drizzle_test", + loadTypeScript: async () => ({ default: migration }), + createContext: ({ progress }) => ({ + log: () => {}, + progress, + database: executor, + state: fakeMigrationState(), + }), + }), + ).resolves.toEqual({ existing: 3, migrated: 0, scanned: 3, skipped: 0 }); + }); + + it("allows schema-only execution to leave a TypeScript gap pending", async () => { + const folder = await mixedFolder(); + const executor = new FakeExecutor(); + + await expect( + runMigrationJournal({ + executor, + migrationsFolder: folder, + migrationsTable: "__drizzle_test", + mode: "sql", + }), + ).resolves.toEqual({ existing: 0, migrated: 2, scanned: 3, skipped: 1 }); + expect([...executor.rows.keys()].sort()).toEqual([2_000, 2_002]); + }); + + it("resumes a failed TypeScript migration from saved progress", async () => { + const folder = await mixedFolder(); + const executor = new FakeExecutor(); + let attempts = 0; + const migration: MigrationV1 = { + apiVersion: 1, + async up(context) { + attempts += 1; + const progress = await context.progress.load(); + if (!progress) { + await context.progress.save({ cursor: 1 }); + throw new Error("interrupted"); + } + return { resumed: true }; + }, + }; + const options = { + executor, + migrationsFolder: folder, + migrationsTable: "__drizzle_test", + loadTypeScript: async () => ({ default: migration }), + createContext: ({ + progress, + }: { + progress: MigrationContextV1["progress"]; + }) => ({ + log: () => {}, + progress, + database: executor, + state: fakeMigrationState(), + }), + }; + + await expect(runMigrationJournal(options)).rejects.toThrow("interrupted"); + expect(executor.rows.get(2_001)).toMatchObject({ + progress: { cursor: 1 }, + status: "failed", + }); + expect(executor.statements).toEqual(["SELECT 'schema-zero';"]); + + await expect(runMigrationJournal(options)).resolves.toEqual({ + existing: 1, + migrated: 2, + scanned: 3, + skipped: 0, + }); + expect(attempts).toBe(2); + expect(executor.rows.get(2_001)).toMatchObject({ + progress: { cursor: 1 }, + status: "completed", + }); + expect(executor.statements).toEqual([ + "SELECT 'schema-zero';", + "SELECT 'schema-two';", + ]); + }); +}); diff --git a/packages/junior-migrations/tsconfig.build.json b/packages/junior-migrations/tsconfig.build.json new file mode 100644 index 000000000..7d583629c --- /dev/null +++ b/packages/junior-migrations/tsconfig.build.json @@ -0,0 +1,11 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "declaration": true, + "emitDeclarationOnly": true, + "noEmit": false, + "rootDir": "src", + "outDir": "dist" + }, + "include": ["src/**/*.ts"] +} diff --git a/packages/junior-migrations/tsconfig.json b/packages/junior-migrations/tsconfig.json new file mode 100644 index 000000000..165b4315c --- /dev/null +++ b/packages/junior-migrations/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "DOM"], + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "skipLibCheck": true, + "isolatedModules": true, + "noEmit": true, + "types": ["node", "vitest/globals"] + }, + "include": ["src/**/*.ts", "tests/**/*.ts"] +} diff --git a/packages/junior-migrations/tsup.config.ts b/packages/junior-migrations/tsup.config.ts new file mode 100644 index 000000000..254c4d2c6 --- /dev/null +++ b/packages/junior-migrations/tsup.config.ts @@ -0,0 +1,22 @@ +import { defineConfig } from "tsup"; + +const shared = { + format: "esm" as const, + tsconfig: "tsconfig.build.json", + dts: false, + outDir: "dist", +}; + +export default defineConfig([ + { + ...shared, + entry: { index: "src/index.ts" }, + clean: true, + }, + { + ...shared, + entry: { cli: "src/cli.ts" }, + clean: false, + banner: { js: "#!/usr/bin/env node" }, + }, +]); diff --git a/packages/junior-plugin-api/README.md b/packages/junior-plugin-api/README.md index 7074f6222..407d7925b 100644 --- a/packages/junior-plugin-api/README.md +++ b/packages/junior-plugin-api/README.md @@ -5,12 +5,13 @@ exported TypeScript types and runtime validators are authoritative. ## Registration -Use `defineJuniorPlugin({ manifest, hooks, tasks, cli, model })`. A plugin name -is a lowercase identifier and is unique within the enabled app plugin set. +Use `defineJuniorPlugin({ manifest, hooks, tasks, cli, model })`. +A plugin name is a lowercase identifier and is unique within the enabled app +plugin set. A plugin may instead be a declarative `plugin.yaml` package when it has no -host-executed hooks. Do not combine an inline manifest with a second YAML -definition for the same plugin. +host-executed hooks, tasks, CLI, or model implementation. Do not combine an +inline manifest with a second YAML definition for the same plugin. ## Manifest @@ -30,7 +31,7 @@ safe. ## Hooks Plugins may contribute tools, prompt messages, lifecycle work, operational -reports, migrations, and other typed hook surfaces exported by this package. +reports, and other typed hook surfaces exported by this package. - Hook context carries the active source, actor, conversation, plugin metadata, database, logging, and only the host capabilities required by that hook. @@ -58,6 +59,8 @@ reports, migrations, and other typed hook surfaces exported by this package. - Packaged migrations create plugin-owned tables through the host migration runner. +- TypeScript journal entries contain their complete durable implementation and + execute through the versioned migration capability API. - Generate migration artifacts from the package schema; do not hand-maintain a second schema contract. - Runtime hooks and CLI actions use host-provided `ctx.db`. diff --git a/packages/junior-plugin-api/src/hooks.ts b/packages/junior-plugin-api/src/hooks.ts index b4d05ad16..a787a3f56 100644 --- a/packages/junior-plugin-api/src/hooks.ts +++ b/packages/junior-plugin-api/src/hooks.ts @@ -18,8 +18,6 @@ import type { RouteRegistrationHookContext, SlackConversationLink, SlackConversationLinkHookContext, - StorageMigrationContext, - StorageMigrationResult, } from "./operations"; import type { BeforeToolExecuteHookContext, @@ -73,10 +71,4 @@ export interface PluginHooks { tools?( ctx: ToolRegistrationHookContext, ): Record; - migrateStorage?( - ctx: StorageMigrationContext, - ): - | Promise - | StorageMigrationResult - | undefined; } diff --git a/packages/junior-plugin-api/src/operations.ts b/packages/junior-plugin-api/src/operations.ts index 51a179c67..429529afd 100644 --- a/packages/junior-plugin-api/src/operations.ts +++ b/packages/junior-plugin-api/src/operations.ts @@ -47,18 +47,6 @@ export interface HeartbeatResult { dispatchCount?: number; } -export interface StorageMigrationResult { - existing: number; - migrated: number; - missing: number; - scanned: number; - skipped?: number; -} - -export interface StorageMigrationContext extends PluginContext { - state: PluginState; -} - export type PluginOperationalTone = "danger" | "good" | "neutral" | "warning"; export interface PluginOperationalMetric { diff --git a/packages/junior-scheduler/README.md b/packages/junior-scheduler/README.md index a952c07bd..4681e43f4 100644 --- a/packages/junior-scheduler/README.md +++ b/packages/junior-scheduler/README.md @@ -46,6 +46,10 @@ Junior's durable agent runtime. The plugin exposes create, update, delete, list, and run-now tools plus bounded operational reporting. Generate schema changes with `pnpm --filter @sentry/junior-scheduler db:generate`. +Use `pnpm --filter @sentry/junior-scheduler db:generate:data --name ` +for a TypeScript data migration in the same journal. New migrations should use +the stable migration capabilities directly and keep their complete +implementation in the migration file. Follow `../../policies/serverless-background-work.md`, `../../policies/context-bound-systems.md`, and diff --git a/packages/junior-scheduler/migrations/0002_scheduler_state_to_sql.ts b/packages/junior-scheduler/migrations/0002_scheduler_state_to_sql.ts new file mode 100644 index 000000000..5ac29f8fc --- /dev/null +++ b/packages/junior-scheduler/migrations/0002_scheduler_state_to_sql.ts @@ -0,0 +1,211 @@ +import type { + MigrationJsonValue, + MigrationV1, +} from "@sentry/junior-migrations"; + +const TASK_INDEX_KEY = "junior:scheduler:tasks"; +const TASK_KEY_PREFIX = "junior:scheduler:task:"; +const ACTIVE_RUN_KEY_PREFIX = "junior:scheduler:active:"; +const RUN_KEY_PREFIX = "junior:scheduler:run:"; +const TASK_STATUSES = new Set(["active", "paused", "blocked", "deleted"]); +const RUN_STATUSES = new Set([ + "pending", + "running", + "completed", + "failed", + "blocked", + "skipped", +]); + +type JsonRecord = Record; + +function record(value: unknown): JsonRecord | undefined { + if (typeof value === "string") { + try { + return record(JSON.parse(value)); + } catch { + return undefined; + } + } + return value && typeof value === "object" && !Array.isArray(value) + ? (value as JsonRecord) + : undefined; +} + +function strings(value: unknown): string[] { + return Array.isArray(value) + ? [ + ...new Set( + value.filter((item): item is string => typeof item === "string"), + ), + ] + : []; +} + +function finiteNumber( + value: MigrationJsonValue | undefined, +): number | undefined { + return typeof value === "number" && Number.isFinite(value) + ? value + : undefined; +} + +function taskRecord(value: unknown): JsonRecord | undefined { + const task = record(value); + const destination = record(task?.destination); + const spec = record(task?.task); + const id = task?.id; + const status = task?.status; + const createdAtMs = finiteNumber(task?.createdAtMs); + if ( + !task || + typeof id !== "string" || + typeof destination?.teamId !== "string" || + typeof spec?.text !== "string" || + typeof status !== "string" || + !TASK_STATUSES.has(status) || + createdAtMs === undefined + ) { + return undefined; + } + const { + credentialSubject: _credentialSubject, + version: _version, + ...current + } = task; + return { + ...current, + credentialMode: current.credentialMode === "creator" ? "creator" : "system", + }; +} + +function runRecord(value: unknown): JsonRecord | undefined { + const run = record(value); + const id = run?.id; + const taskId = run?.taskId; + const status = run?.status; + const scheduledForMs = finiteNumber(run?.scheduledForMs); + if ( + !run || + typeof id !== "string" || + typeof taskId !== "string" || + typeof status !== "string" || + !RUN_STATUSES.has(status) || + scheduledForMs === undefined + ) { + return undefined; + } + const { + idempotencyKey: _idempotencyKey, + taskVersion: _taskVersion, + ...current + } = run; + return current; +} + +const migration = { + apiVersion: 1, + async up(context) { + const ids = strings(await context.state.get(TASK_INDEX_KEY)); + let existing = 0; + let migrated = 0; + let missing = 0; + const tasks: JsonRecord[] = []; + + for (const id of ids) { + const task = taskRecord( + await context.state.get(`${TASK_KEY_PREFIX}${id}`), + ); + if (!task) { + missing += 1; + continue; + } + tasks.push(task); + const [stored] = await context.database.query<{ id: string }>( + "SELECT id FROM junior_scheduler_tasks WHERE id = $1 LIMIT 1", + [id], + ); + if (stored) { + existing += 1; + continue; + } + const destination = record(task.destination)!; + await context.database.execute( + `INSERT INTO junior_scheduler_tasks + (id, team_id, status, next_run_at_ms, run_now_at_ms, created_at_ms, record) + VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb) + ON CONFLICT (id) DO UPDATE SET + team_id = excluded.team_id, + status = excluded.status, + next_run_at_ms = excluded.next_run_at_ms, + run_now_at_ms = excluded.run_now_at_ms, + created_at_ms = excluded.created_at_ms, + record = excluded.record`, + [ + id, + destination.teamId, + task.status, + finiteNumber(task.nextRunAtMs) ?? null, + finiteNumber(task.runNowAtMs) ?? null, + finiteNumber(task.createdAtMs), + JSON.stringify(task), + ], + ); + migrated += 1; + } + + const runs: JsonRecord[] = []; + for (const task of tasks) { + const active = record( + await context.state.get(`${ACTIVE_RUN_KEY_PREFIX}${task.id}`), + ); + if (typeof active?.runId !== "string") { + continue; + } + const run = runRecord( + await context.state.get(`${RUN_KEY_PREFIX}${active.runId}`), + ); + if (run && (run.status === "pending" || run.status === "running")) { + runs.push(run); + } + } + + for (const run of runs) { + const [stored] = await context.database.query<{ id: string }>( + "SELECT id FROM junior_scheduler_runs WHERE id = $1 LIMIT 1", + [run.id], + ); + if (stored) { + existing += 1; + continue; + } + await context.database.execute( + `INSERT INTO junior_scheduler_runs + (id, task_id, status, scheduled_for_ms, record) + VALUES ($1, $2, $3, $4, $5::jsonb) + ON CONFLICT (id) DO UPDATE SET + task_id = excluded.task_id, + status = excluded.status, + scheduled_for_ms = excluded.scheduled_for_ms, + record = excluded.record`, + [ + run.id, + run.taskId, + run.status, + finiteNumber(run.scheduledForMs), + JSON.stringify(run), + ], + ); + migrated += 1; + } + + return { + existing, + migrated, + missing, + scanned: ids.length + runs.length, + }; + }, +} satisfies MigrationV1; + +export default migration; diff --git a/packages/junior-scheduler/migrations/meta/0002_snapshot.json b/packages/junior-scheduler/migrations/meta/0002_snapshot.json new file mode 100644 index 000000000..3452e36a6 --- /dev/null +++ b/packages/junior-scheduler/migrations/meta/0002_snapshot.json @@ -0,0 +1,251 @@ +{ + "id": "1c41ba67-8755-4577-ac1e-16538249993e", + "prevId": "ccd0c14d-8a21-41aa-82b9-69a3d80cdc88", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.junior_scheduler_runs": { + "name": "junior_scheduler_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scheduled_for_ms": { + "name": "scheduled_for_ms", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "record": { + "name": "record", + "type": "jsonb", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "junior_scheduler_runs_task_status_idx": { + "name": "junior_scheduler_runs_task_status_idx", + "columns": [ + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scheduled_for_ms", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "junior_scheduler_runs_status_idx": { + "name": "junior_scheduler_runs_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scheduled_for_ms", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_scheduler_tasks": { + "name": "junior_scheduler_tasks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "team_id": { + "name": "team_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "next_run_at_ms": { + "name": "next_run_at_ms", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "run_now_at_ms": { + "name": "run_now_at_ms", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "created_at_ms": { + "name": "created_at_ms", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "record": { + "name": "record", + "type": "jsonb", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "junior_scheduler_tasks_team_status_idx": { + "name": "junior_scheduler_tasks_team_status_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at_ms", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "where": "\"junior_scheduler_tasks\".\"status\" <> 'deleted'", + "concurrently": false + }, + "junior_scheduler_tasks_run_now_due_idx": { + "name": "junior_scheduler_tasks_run_now_due_idx", + "columns": [ + { + "expression": "run_now_at_ms", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at_ms", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "where": "\"junior_scheduler_tasks\".\"status\" = 'active' AND \"junior_scheduler_tasks\".\"run_now_at_ms\" IS NOT NULL", + "concurrently": false + }, + "junior_scheduler_tasks_next_run_due_idx": { + "name": "junior_scheduler_tasks_next_run_due_idx", + "columns": [ + { + "expression": "next_run_at_ms", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at_ms", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "where": "\"junior_scheduler_tasks\".\"status\" = 'active' AND \"junior_scheduler_tasks\".\"next_run_at_ms\" IS NOT NULL", + "concurrently": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "views": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/packages/junior-scheduler/migrations/meta/_journal.json b/packages/junior-scheduler/migrations/meta/_journal.json index bf8784579..5552ed530 100644 --- a/packages/junior-scheduler/migrations/meta/_journal.json +++ b/packages/junior-scheduler/migrations/meta/_journal.json @@ -15,6 +15,13 @@ "when": 1783964295816, "tag": "0001_explicit_credential_mode", "breakpoints": true + }, + { + "idx": 2, + "version": "7", + "when": 1783977988234, + "tag": "0002_scheduler_state_to_sql", + "breakpoints": true } ] } diff --git a/packages/junior-scheduler/package.json b/packages/junior-scheduler/package.json index 97c127110..c432ad89b 100644 --- a/packages/junior-scheduler/package.json +++ b/packages/junior-scheduler/package.json @@ -25,6 +25,7 @@ "scripts": { "build": "tsup && tsc -p tsconfig.build.json --emitDeclarationOnly", "db:generate": "pnpm exec drizzle-kit generate --config drizzle.config.ts", + "db:generate:data": "pnpm exec junior-migrations generate --config drizzle.config.ts --out migrations", "prepare": "pnpm run build", "prepack": "pnpm run build", "typecheck": "tsc --noEmit" @@ -35,6 +36,7 @@ "zod": "catalog:" }, "devDependencies": { + "@sentry/junior-migrations": "workspace:*", "@types/node": "^25.9.1", "drizzle-kit": "catalog:", "tsup": "^8.5.1", diff --git a/packages/junior-scheduler/src/index.ts b/packages/junior-scheduler/src/index.ts index eec29d6ac..a145e9d74 100644 --- a/packages/junior-scheduler/src/index.ts +++ b/packages/junior-scheduler/src/index.ts @@ -10,7 +10,6 @@ export { export { createSchedulerOperationalSqlStore, createSchedulerSqlStore, - migrateSchedulerStateToSql, type SchedulerDb, } from "./store"; export type { diff --git a/packages/junior-scheduler/src/plugin.ts b/packages/junior-scheduler/src/plugin.ts index 108b7ce97..6f8d9ab95 100644 --- a/packages/junior-scheduler/src/plugin.ts +++ b/packages/junior-scheduler/src/plugin.ts @@ -13,7 +13,6 @@ import { import { createSchedulerOperationalSqlStore, createSchedulerSqlStore, - migrateSchedulerStateToSql, type SchedulerDb, type SchedulerOperationalStore, type SchedulerStore, @@ -578,12 +577,6 @@ export function createSchedulerPlugin() { store: schedulerOperationalStore(ctx), }); }, - async migrateStorage(ctx) { - return await migrateSchedulerStateToSql({ - db: ctx.db as SchedulerDb, - state: ctx.state, - }); - }, }, }); } diff --git a/packages/junior-scheduler/src/store.ts b/packages/junior-scheduler/src/store.ts index 7c0978be2..da310cf49 100644 --- a/packages/junior-scheduler/src/store.ts +++ b/packages/junior-scheduler/src/store.ts @@ -1692,58 +1692,3 @@ export function createSchedulerOperationalSqlStore( ): SchedulerOperationalStore { return new SqlSchedulerStore(db); } - -/** Copy retained scheduler plugin-state records into the scheduler SQL tables. */ -export async function migrateSchedulerStateToSql(args: { - db: SchedulerDb; - state: PluginState; -}): Promise<{ - existing: number; - migrated: number; - missing: number; - scanned: number; -}> { - const store = createSchedulerSqlStore(args.db); - const ids = await getIndex(args.state, globalTaskIndexKey()); - let existing = 0; - let migrated = 0; - let missing = 0; - const migratedTasks: ScheduledTask[] = []; - - for (const id of ids) { - const rawTask = await args.state.get(taskKey(id)); - const task = - parseStoredTask(rawTask) ?? parseLegacyStoredTaskForMigration(rawTask); - if (!task) { - missing += 1; - continue; - } - migratedTasks.push(task); - if (await store.getTask(task.id)) { - existing += 1; - continue; - } - await store.saveTask(task); - migrated += 1; - } - - const runs = await listIncompleteRunsForTasksFromState( - args.state, - migratedTasks, - ); - for (const run of runs) { - if (await store.getRun(run.id)) { - existing += 1; - continue; - } - await upsertSqlRun(args.db, run); - migrated += 1; - } - - return { - existing, - migrated, - missing, - scanned: ids.length + runs.length, - }; -} diff --git a/packages/junior/src/cli/upgrade/migrations/agent-turn-session-actor.ts b/packages/junior/migrations/0004_agent_turn_session_actor.ts similarity index 84% rename from packages/junior/src/cli/upgrade/migrations/agent-turn-session-actor.ts rename to packages/junior/migrations/0004_agent_turn_session_actor.ts index 116598fd4..7018c1653 100644 --- a/packages/junior/src/cli/upgrade/migrations/agent-turn-session-actor.ts +++ b/packages/junior/migrations/0004_agent_turn_session_actor.ts @@ -1,12 +1,28 @@ -import type { RedisStateAdapter } from "@chat-adapter/state-redis"; -import { THREAD_STATE_TTL_MS, type StateAdapter } from "chat"; -import { isRecord, toOptionalString } from "@/chat/coerce"; -import { getChatConfig } from "@/chat/config"; +import { THREAD_STATE_TTL_MS } from "chat"; import type { - MigrationContext, - MigrationResult, - UpgradeMigration, -} from "../types"; + MigrationRedisV1, + MigrationStateV1, + MigrationV1, +} from "@sentry/junior-migrations"; +import { + isRecord, + migrationRedisKey, + toOptionalString, +} from "@sentry/junior/migration-helpers/v1"; + +type StateAdapter = MigrationStateV1; +type RedisStateAdapter = { getClient(): MigrationRedisV1 }; +type MigrationContext = { + io?: { info(message: string): void }; + redisStateAdapter?: RedisStateAdapter; + stateAdapter: StateAdapter; +}; +type MigrationResult = { + existing: number; + migrated: number; + missing: number; + scanned: number; +}; const AGENT_TURN_SESSION_PREFIX = "junior:agent_turn_session"; const AGENT_TURN_SESSION_INDEX_KEY = `${AGENT_TURN_SESSION_PREFIX}:index`; @@ -31,11 +47,7 @@ function sessionRecordKey(conversationId: string, sessionId: string): string { } function logicalConversationIndexPrefix(): string { - const prefix = getChatConfig().state.keyPrefix; - return [ - ...(prefix ? [prefix] : []), - `${AGENT_TURN_SESSION_PREFIX}:conversation:`, - ].join(":"); + return migrationRedisKey(`${AGENT_TURN_SESSION_PREFIX}:conversation:`); } function conversationIdFromRedisListKey(key: string): string | undefined { @@ -151,7 +163,8 @@ async function migrateSessionRecord(args: { return "migrated"; } -async function migrateAgentTurnSessionActor( +/** Rewrite retained turn-session requester fields to actor fields. */ +export async function migrateAgentTurnSessionActor( context: MigrationContext, ): Promise { const result: MigrationResult = { @@ -230,7 +243,16 @@ async function migrateAgentTurnSessionActor( return result; } -export const agentTurnSessionActorMigration: UpgradeMigration = { - name: "migrate-agent-turn-session-requester-to-actor", - run: migrateAgentTurnSessionActor, -}; +const migration = { + apiVersion: 1, + async up(context) { + return await migrateAgentTurnSessionActor({ + stateAdapter: context.state, + ...(context.redis + ? { redisStateAdapter: { getClient: () => context.redis! } } + : {}), + }); + }, +} satisfies MigrationV1; + +export default migration; diff --git a/packages/junior/src/cli/upgrade/migrations/redis-conversation-state.ts b/packages/junior/migrations/0005_redis_conversation_state.ts similarity index 93% rename from packages/junior/src/cli/upgrade/migrations/redis-conversation-state.ts rename to packages/junior/migrations/0005_redis_conversation_state.ts index d2b903865..0463efb51 100644 --- a/packages/junior/src/cli/upgrade/migrations/redis-conversation-state.ts +++ b/packages/junior/migrations/0005_redis_conversation_state.ts @@ -1,25 +1,40 @@ -import type { RedisStateAdapter } from "@chat-adapter/state-redis"; -import type { StateAdapter } from "chat"; import type { Destination } from "@sentry/junior-plugin-api"; -import { isRecord, toOptionalNumber, toOptionalString } from "@/chat/coerce"; -import { getChatConfig } from "@/chat/config"; -import { parseDestination, sameDestination } from "@/chat/destination"; -import { coerceThreadConversationState } from "@/chat/state/conversation"; -import { JUNIOR_THREAD_STATE_TTL_MS } from "@/chat/state/ttl"; -import { - getConversation, - requestConversationWork, - type Conversation, - type ExecutionStatus, - type InboundMessage, - type Lease, - type Source, -} from "@/chat/task-execution/state"; import type { - MigrationContext, - MigrationResult, - UpgradeMigration, -} from "../types"; + MigrationRedisV1, + MigrationStateV1, + MigrationV1, +} from "@sentry/junior-migrations"; +import { + coerceThreadConversationState, + getMigrationConversation as getConversation, + isRecord, + JUNIOR_THREAD_STATE_TTL_MS, + migrationRedisKey, + parseDestination, + requestMigrationConversationWork as requestConversationWork, + sameDestination, + toOptionalNumber, + toOptionalString, + type MigrationRetainedConversationV1 as Conversation, + type MigrationExecutionStatusV1 as ExecutionStatus, + type MigrationInboundMessageV1 as InboundMessage, + type MigrationLeaseV1 as Lease, + type MigrationSourceV1 as Source, +} from "@sentry/junior/migration-helpers/v1"; + +type StateAdapter = MigrationStateV1; +type RedisStateAdapter = { getClient(): MigrationRedisV1 }; +type MigrationContext = { + io?: { info(message: string): void }; + redisStateAdapter?: RedisStateAdapter; + stateAdapter: StateAdapter; +}; +type MigrationResult = { + existing: number; + migrated: number; + missing: number; + scanned: number; +}; const CONVERSATION_PREFIX = "junior:conversation"; const CONVERSATION_SCHEMA_VERSION = 1; @@ -451,8 +466,7 @@ function uniqueAwaitingContinuationSummaries( } function redisIndexKey(indexKey: string): string { - const prefix = getChatConfig().state.keyPrefix; - return [...(prefix ? [prefix] : []), indexKey].join(":"); + return migrationRedisKey(indexKey); } async function upsertEmulatedIndexEntry(args: { @@ -771,7 +785,8 @@ async function seedAwaitingContinuationConversationWork( } } -async function migrateRedisConversationState( +/** Move legacy Redis conversation work into the retained conversation model. */ +export async function migrateRedisConversationState( context: MigrationContext, ): Promise { const result = await migrateLegacyConversationWorkRedisState(context); @@ -779,9 +794,16 @@ async function migrateRedisConversationState( return result; } -export const redisConversationStateMigration: UpgradeMigration = { - // TODO(after 2026-07-01): remove after deployed installs have had a release - // window to move legacy conversation-work Redis state forward. - name: "migrate-redis-conversation-state", - run: migrateRedisConversationState, -}; +const migration = { + apiVersion: 1, + async up(context) { + return await migrateRedisConversationState({ + stateAdapter: context.state, + ...(context.redis + ? { redisStateAdapter: { getClient: () => context.redis! } } + : {}), + }); + }, +} satisfies MigrationV1; + +export default migration; diff --git a/packages/junior/migrations/0006_conversations_to_sql.ts b/packages/junior/migrations/0006_conversations_to_sql.ts new file mode 100644 index 000000000..51c62687b --- /dev/null +++ b/packages/junior/migrations/0006_conversations_to_sql.ts @@ -0,0 +1,110 @@ +import type { MigrationStateV1, MigrationV1 } from "@sentry/junior-migrations"; +import { + addAgentTurnUsage, + createMigrationSqlStore, + createMigrationStateConversationStore, + listMigrationTurnSessionSummaries, +} from "@sentry/junior/migration-helpers/v1"; + +const CONVERSATION_BACKFILL_LIMIT = 10_000; + +type MigrationResult = { + existing: number; + migrated: number; + missing: number; + scanned: number; +}; + +type ConversationTarget = Pick< + ReturnType, + "backfillConversation" | "listByActivity" +>; + +export async function migrateConversationsToSql( + context: { + io?: { info(message: string): void }; + stateAdapter: MigrationStateV1; + }, + options: { batchSize?: number; target: ConversationTarget }, +): Promise { + const source = createMigrationStateConversationStore(context.stateAdapter); + const target = options.target; + const limit = Math.max(1, options.batchSize ?? CONVERSATION_BACKFILL_LIMIT); + const [stateConversations, sqlConversations] = await Promise.all([ + source.listByActivity({ limit }), + target.listByActivity({ limit }), + ]); + const byId = new Map( + sqlConversations.map((conversation) => [ + conversation.conversationId, + conversation, + ]), + ); + for (const conversation of stateConversations) { + const existing = byId.get(conversation.conversationId); + const existingExecutionAt = + existing?.execution.updatedAtMs ?? existing?.updatedAtMs ?? 0; + const stateExecutionAt = + conversation.execution.updatedAtMs ?? conversation.updatedAtMs; + if (!existing || stateExecutionAt >= existingExecutionAt) { + byId.set(conversation.conversationId, conversation); + } + } + const conversations = [...byId.values()] + .sort( + (left, right) => + right.lastActivityAtMs - left.lastActivityAtMs || + left.conversationId.localeCompare(right.conversationId), + ) + .slice(0, limit); + const summaries = await listMigrationTurnSessionSummaries( + context.stateAdapter, + conversations.map((conversation) => conversation.conversationId), + ); + for (const conversation of conversations) { + const conversationSummaries = + summaries.get(conversation.conversationId) ?? []; + const executionSummary = conversation.execution.runId + ? conversationSummaries.find( + (summary) => summary.sessionId === conversation.execution.runId, + ) + : undefined; + await target.backfillConversation( + conversation, + conversationSummaries.length > 0 + ? { + durationMs: conversationSummaries.reduce( + (total, summary) => total + summary.cumulativeDurationMs, + 0, + ), + usage: addAgentTurnUsage( + ...conversationSummaries.map( + (summary) => summary.cumulativeUsage, + ), + ), + executionDurationMs: executionSummary?.cumulativeDurationMs ?? 0, + executionUsage: executionSummary?.cumulativeUsage, + } + : undefined, + ); + } + + return { + existing: 0, + migrated: conversations.length, + missing: 0, + scanned: conversations.length, + }; +} + +const migration = { + apiVersion: 1, + async up(context) { + return await migrateConversationsToSql( + { stateAdapter: context.state }, + { target: createMigrationSqlStore(context.database) }, + ); + }, +} satisfies MigrationV1; + +export default migration; diff --git a/packages/junior/migrations/0007_conversation_history_to_sql.ts b/packages/junior/migrations/0007_conversation_history_to_sql.ts new file mode 100644 index 000000000..22fd052c9 --- /dev/null +++ b/packages/junior/migrations/0007_conversation_history_to_sql.ts @@ -0,0 +1,3575 @@ +// @ts-nocheck -- frozen migration capsule; do not refactor against current Junior internals. +/* eslint-disable no-unused-vars */ +var __defProp = Object.defineProperty; +var __getOwnPropDesc = Object.getOwnPropertyDescriptor; +var __getOwnPropNames = Object.getOwnPropertyNames; +var __hasOwnProp = Object.prototype.hasOwnProperty; +var __esm = (fn, res) => + function __init() { + return (fn && (res = (0, fn[__getOwnPropNames(fn)[0]])((fn = 0))), res); + }; +var __export = (target, all) => { + for (var name in all) + __defProp(target, name, { get: all[name], enumerable: true }); +}; +var __copyProps = (to, from, except, desc3) => { + if ((from && typeof from === "object") || typeof from === "function") { + for (let key3 of __getOwnPropNames(from)) + if (!__hasOwnProp.call(to, key3) && key3 !== except) + __defProp(to, key3, { + get: () => from[key3], + enumerable: + !(desc3 = __getOwnPropDesc(from, key3)) || desc3.enumerable, + }); + } + return to; +}; +var __reExport = (target, mod, secondTarget) => ( + __copyProps(target, mod, "default"), + secondTarget && __copyProps(secondTarget, mod, "default") +); + +// migration:config +function getChatConfig() { + return { + bot: botConfig, + state: { keyPrefix: process.env.JUNIOR_STATE_KEY_PREFIX?.trim() || void 0 }, + }; +} +var botConfig; +var init_config = __esm({ + "migration:config"() { + "use strict"; + botConfig = { modelContextWindowTokens: void 0 }; + }, +}); + +// packages/junior/src/chat/slack/timestamp.ts +import { z } from "zod"; +var slackMessageTsSchema; +var init_timestamp = __esm({ + "packages/junior/src/chat/slack/timestamp.ts"() { + "use strict"; + slackMessageTsSchema = z + .string() + .trim() + .regex(/^\d+(?:\.\d+)?$/) + .brand(); + }, +}); + +// packages/junior/src/chat/slack/ids.ts +import { z as z2 } from "zod"; +function parseSlackTeamId(value) { + if (typeof value !== "string") return void 0; + const parsed = slackTeamIdSchema.safeParse(value.trim()); + return parsed.success ? parsed.data : void 0; +} +var slackChannelIdSchema, slackTeamIdSchema, slackUserIdSchema; +var init_ids = __esm({ + "packages/junior/src/chat/slack/ids.ts"() { + "use strict"; + init_timestamp(); + slackChannelIdSchema = z2 + .string() + .regex(/^[CDG][A-Z0-9]+$/) + .brand(); + slackTeamIdSchema = z2 + .string() + .regex(/^T[A-Z0-9]+$/) + .brand(); + slackUserIdSchema = z2 + .string() + .regex(/^[UW][A-Z0-9]+$/) + .brand(); + }, +}); + +// packages/junior/src/chat/destination.ts +import { destinationSchema } from "@sentry/junior-plugin-api"; +function parseDestination(value) { + const parsed = destinationSchema.safeParse(value); + return parsed.success ? parsed.data : void 0; +} +function sameDestination(left, right) { + if (left.platform !== right.platform) { + return false; + } + if (left.platform === "local" && right.platform === "local") { + return left.conversationId === right.conversationId; + } + if (left.platform === "slack" && right.platform === "slack") { + return left.teamId === right.teamId && left.channelId === right.channelId; + } + return false; +} +var init_destination = __esm({ + "packages/junior/src/chat/destination.ts"() { + "use strict"; + init_ids(); + }, +}); + +// packages/junior/src/db/schema/timestamps.ts +import { timestamp } from "drizzle-orm/pg-core"; +var timestamptz; +var init_timestamps = __esm({ + "packages/junior/src/db/schema/timestamps.ts"() { + "use strict"; + timestamptz = (name) => timestamp(name, { withTimezone: true }); + }, +}); + +// packages/junior/src/db/schema/destinations.ts +import { index, jsonb, pgTable, text, uniqueIndex } from "drizzle-orm/pg-core"; +import { z as z3 } from "zod"; +var juniorDestinationKindSchema, juniorDestinations; +var init_destinations = __esm({ + "packages/junior/src/db/schema/destinations.ts"() { + "use strict"; + init_timestamps(); + juniorDestinationKindSchema = z3.enum([ + "channel", + "dm", + "group", + "local_conversation", + "thread", + "web_session", + ]); + juniorDestinations = pgTable( + "junior_destinations", + { + id: text("id").primaryKey(), + provider: text("provider").notNull(), + providerTenantId: text("provider_tenant_id").notNull().default(""), + providerDestinationId: text("provider_destination_id").notNull(), + kind: text("kind").$type().notNull(), + parentDestinationId: text("parent_destination_id"), + displayName: text("display_name"), + visibility: text("visibility").$type().notNull().default("unknown"), + metadata: jsonb("metadata_json"), + createdAt: timestamptz("created_at").notNull(), + updatedAt: timestamptz("updated_at").notNull(), + }, + (table) => [ + uniqueIndex("junior_destinations_provider_destination_uidx").on( + table.provider, + table.providerTenantId, + table.providerDestinationId, + ), + index("junior_destinations_provider_kind_idx").on( + table.provider, + table.kind, + ), + ], + ); + }, +}); + +// packages/junior/src/db/schema/users.ts +import { + text as text2, + pgTable as pgTable2, + uniqueIndex as uniqueIndex2, +} from "drizzle-orm/pg-core"; +var juniorUsers; +var init_users = __esm({ + "packages/junior/src/db/schema/users.ts"() { + "use strict"; + init_timestamps(); + juniorUsers = pgTable2( + "junior_users", + { + id: text2("id").primaryKey(), + primaryEmail: text2("primary_email").notNull(), + primaryEmailNormalized: text2("primary_email_normalized").notNull(), + displayName: text2("display_name"), + createdAt: timestamptz("created_at").notNull(), + updatedAt: timestamptz("updated_at").notNull(), + }, + (table) => [ + uniqueIndex2("junior_users_primary_email_normalized_uidx").on( + table.primaryEmailNormalized, + ), + ], + ); + }, +}); + +// packages/junior/src/db/schema/identities.ts +import { sql } from "drizzle-orm"; +import { + boolean, + index as index2, + jsonb as jsonb2, + pgTable as pgTable3, + text as text3, + uniqueIndex as uniqueIndex3, +} from "drizzle-orm/pg-core"; +var juniorIdentities; +var init_identities = __esm({ + "packages/junior/src/db/schema/identities.ts"() { + "use strict"; + init_timestamps(); + init_users(); + juniorIdentities = pgTable3( + "junior_identities", + { + id: text3("id").primaryKey(), + kind: text3("kind").$type().notNull(), + provider: text3("provider").notNull(), + providerTenantId: text3("provider_tenant_id").notNull().default(""), + providerSubjectId: text3("provider_subject_id").notNull(), + displayName: text3("display_name"), + handle: text3("handle"), + email: text3("email"), + avatarUrl: text3("avatar_url"), + metadata: jsonb2("metadata_json"), + createdAt: timestamptz("created_at").notNull(), + updatedAt: timestamptz("updated_at").notNull(), + userId: text3("user_id").references(() => juniorUsers.id), + emailNormalized: text3("email_normalized"), + emailVerified: boolean("email_verified").notNull().default(false), + }, + (table) => [ + uniqueIndex3("junior_identities_provider_subject_uidx").on( + table.provider, + table.providerTenantId, + table.providerSubjectId, + ), + index2("junior_identities_user_idx").on(table.userId), + index2("junior_identities_verified_email_idx") + .on(table.emailNormalized) + .where( + sql`${table.emailVerified} = true AND ${table.emailNormalized} IS NOT NULL`, + ), + index2("junior_identities_kind_provider_idx").on( + table.kind, + table.provider, + ), + ], + ); + }, +}); + +// packages/junior/src/db/schema/conversations.ts +import { sql as sql2 } from "drizzle-orm"; +import { + index as index3, + integer, + jsonb as jsonb3, + pgTable as pgTable4, + text as text4, +} from "drizzle-orm/pg-core"; +var juniorConversations; +var init_conversations = __esm({ + "packages/junior/src/db/schema/conversations.ts"() { + "use strict"; + init_destinations(); + init_identities(); + init_timestamps(); + juniorConversations = pgTable4( + "junior_conversations", + { + conversationId: text4("conversation_id").primaryKey(), + schemaVersion: integer("schema_version").notNull().default(1), + source: text4("source").$type(), + originType: text4("origin_type"), + originId: text4("origin_id"), + originRunId: text4("origin_run_id"), + destinationId: text4("destination_id").references( + () => juniorDestinations.id, + ), + destination: jsonb3("destination_json").$type(), + actorIdentityId: text4("actor_identity_id").references( + () => juniorIdentities.id, + ), + creatorIdentityId: text4("creator_identity_id").references( + () => juniorIdentities.id, + ), + credentialSubjectIdentityId: text4( + "credential_subject_identity_id", + ).references(() => juniorIdentities.id), + actor: jsonb3("actor_json").$type(), + channelName: text4("channel_name"), + title: text4("title"), + createdAt: timestamptz("created_at").notNull(), + lastActivityAt: timestamptz("last_activity_at").notNull(), + updatedAt: timestamptz("updated_at").notNull(), + executionUpdatedAt: timestamptz("execution_updated_at"), + executionStatus: text4("execution_status").$type().notNull(), + runId: text4("run_id"), + lastCheckpointAt: timestamptz("last_checkpoint_at"), + lastEnqueuedAt: timestamptz("last_enqueued_at"), + // Subagent runs are child conversations; top-level listings filter + // parent_conversation_id IS NULL. Historical advisor children use this too. + parentConversationId: text4("parent_conversation_id").references( + () => juniorConversations.conversationId, + ), + transcriptPurgedAt: timestamptz("transcript_purged_at"), + durationMs: integer("duration_ms").notNull().default(0), + usage: jsonb3("usage_json").$type(), + executionDurationMs: integer("execution_duration_ms") + .notNull() + .default(0), + executionUsage: jsonb3("execution_usage_json").$type(), + metricRunId: text4("metric_run_id"), + }, + (table) => [ + index3("junior_conversations_last_activity_idx").on( + table.lastActivityAt.desc(), + table.conversationId, + ), + index3("junior_conversations_active_idx") + .using( + "btree", + sql2`coalesce(${table.executionUpdatedAt}, ${table.updatedAt})`, + table.conversationId, + ) + .where(sql2`${table.executionStatus} <> 'idle'`), + index3("junior_conversations_destination_activity_idx").on( + table.destinationId, + table.lastActivityAt.desc(), + ), + index3("junior_conversations_actor_activity_idx").on( + table.actorIdentityId, + table.lastActivityAt.desc(), + ), + index3("junior_conversations_origin_idx").on( + table.originType, + table.originId, + table.lastActivityAt.desc(), + ), + index3("junior_conversations_parent_idx").on( + table.parentConversationId, + ), + ], + ); + }, +}); + +// packages/junior/src/db/schema/agent-steps.ts +import { + index as index4, + integer as integer2, + jsonb as jsonb4, + pgTable as pgTable5, + primaryKey, + text as text5, +} from "drizzle-orm/pg-core"; +var juniorAgentSteps; +var init_agent_steps = __esm({ + "packages/junior/src/db/schema/agent-steps.ts"() { + "use strict"; + init_conversations(); + init_timestamps(); + juniorAgentSteps = pgTable5( + "junior_agent_steps", + { + conversationId: text5("conversation_id") + .notNull() + .references(() => juniorConversations.conversationId), + seq: integer2("seq").notNull(), + contextEpoch: integer2("context_epoch").notNull(), + type: text5("type").notNull(), + role: text5("role"), + payload: jsonb4("payload").$type().notNull(), + createdAt: timestamptz("created_at").notNull(), + }, + (table) => [ + primaryKey({ columns: [table.conversationId, table.seq] }), + index4("junior_agent_steps_epoch_idx").on( + table.conversationId, + table.contextEpoch, + table.seq, + ), + ], + ); + }, +}); + +// packages/junior/src/db/schema/conversation-messages.ts +import { sql as sql3 } from "drizzle-orm"; +import { + index as index5, + jsonb as jsonb5, + pgTable as pgTable6, + primaryKey as primaryKey2, + text as text6, +} from "drizzle-orm/pg-core"; +var juniorConversationMessages; +var init_conversation_messages = __esm({ + "packages/junior/src/db/schema/conversation-messages.ts"() { + "use strict"; + init_conversations(); + init_identities(); + init_timestamps(); + juniorConversationMessages = pgTable6( + "junior_conversation_messages", + { + conversationId: text6("conversation_id") + .notNull() + .references(() => juniorConversations.conversationId), + messageId: text6("message_id").notNull(), + role: text6("role").$type().notNull(), + authorIdentityId: text6("author_identity_id").references( + () => juniorIdentities.id, + ), + text: text6("text").notNull(), + meta: jsonb5("meta").$type(), + repliedAt: timestamptz("replied_at"), + createdAt: timestamptz("created_at").notNull(), + }, + (table) => [ + primaryKey2({ columns: [table.conversationId, table.messageId] }), + index5("junior_conversation_messages_activity_idx").on( + table.conversationId, + table.createdAt, + ), + index5("junior_conversation_messages_search_idx").using( + "gin", + sql3`to_tsvector('english', ${table.text})`, + ), + ], + ); + }, +}); + +// packages/junior/src/db/schema.ts +var init_schema = __esm({ + "packages/junior/src/db/schema.ts"() { + "use strict"; + init_agent_steps(); + init_conversation_messages(); + init_conversations(); + init_destinations(); + init_identities(); + init_users(); + }, +}); + +// packages/junior/src/chat/identities/identity.ts +var init_identity = __esm({ + "packages/junior/src/chat/identities/identity.ts"() { + "use strict"; + }, +}); + +// packages/junior/src/chat/identities/sql.ts +import { and, eq, sql as sql4 } from "drizzle-orm"; +var init_sql = __esm({ + "packages/junior/src/chat/identities/sql.ts"() { + "use strict"; + init_schema(); + init_identity(); + }, +}); + +// packages/junior/src/chat/conversations/sql/store.ts +import { + and as and2, + asc, + desc, + eq as eq2, + isNull, + sql as sql5, +} from "drizzle-orm"; +var init_store = __esm({ + "packages/junior/src/chat/conversations/sql/store.ts"() { + "use strict"; + init_destination(); + init_sql(); + init_schema(); + }, +}); + +// packages/junior/src/chat/pi/messages.ts +import { z as z4 } from "zod"; +var piMessageSchema, piContentMessageSchema; +var init_messages = __esm({ + "packages/junior/src/chat/pi/messages.ts"() { + "use strict"; + piMessageSchema = z4 + .object({ + role: z4.string(), + }) + .passthrough() + .transform((value) => value); + piContentMessageSchema = z4 + .object({ + content: z4.array(z4.unknown()), + role: z4.string().min(1), + }) + .passthrough() + .transform((value) => value); + }, +}); + +// packages/junior/src/chat/actor.ts +import { z as z5 } from "zod"; +import { actorSchema } from "@sentry/junior-plugin-api"; +function isSyntheticActorUserId(value) { + return value.toLowerCase() === "unknown"; +} +function parseActorUserId(value) { + if (typeof value !== "string" || value.length === 0) { + return void 0; + } + if (value !== value.trim() || isSyntheticActorUserId(value)) { + return void 0; + } + return value; +} +function parseStoredSlackActor(value) { + const parsed = storedSlackActorSchema.safeParse(value); + if (!parsed.success) { + return void 0; + } + if ( + parsed.data.slackUserId !== void 0 && + !parseActorUserId(parsed.data.slackUserId) + ) { + return void 0; + } + if (parsed.data.teamId !== void 0 && !parseSlackTeamId(parsed.data.teamId)) { + return void 0; + } + if ( + (parsed.data.platform !== void 0 || parsed.data.teamId !== void 0) && + (!parsed.data.platform || !parsed.data.teamId) + ) { + return void 0; + } + return parsed.data; +} +var exactStoredStringSchema, storedSlackActorSchema; +var init_actor = __esm({ + "packages/junior/src/chat/actor.ts"() { + "use strict"; + init_ids(); + exactStoredStringSchema = z5 + .string() + .min(1) + .refine((value) => value === value.trim()); + storedSlackActorSchema = z5 + .object({ + email: exactStoredStringSchema.optional(), + fullName: exactStoredStringSchema.optional(), + platform: z5.literal("slack").optional(), + slackUserId: exactStoredStringSchema.optional(), + slackUserName: exactStoredStringSchema.optional(), + teamId: exactStoredStringSchema.optional(), + }) + .strict(); + }, +}); + +// packages/junior/src/chat/state/locks.ts +var ACTIVE_LOCK_TTL_MS; +var init_locks = __esm({ + "packages/junior/src/chat/state/locks.ts"() { + "use strict"; + ACTIVE_LOCK_TTL_MS = 9e4; + }, +}); + +// packages/junior/src/chat/state/adapter.ts +import { createMemoryState } from "@chat-adapter/state-memory"; +import { createRedisState } from "@chat-adapter/state-redis"; +function createPrefixedStateAdapter(base, prefix) { + const prefixed = (value) => `${prefix}:${value}`; + const unprefixed = (value) => + value.startsWith(`${prefix}:`) ? value.slice(prefix.length + 1) : value; + const prefixLock = (lock) => ({ + ...lock, + threadId: prefixed(lock.threadId), + }); + const unprefixLock = (lock) => ({ + ...lock, + threadId: unprefixed(lock.threadId), + }); + return { + appendToList: (key3, value, options) => + base.appendToList(prefixed(key3), value, options), + connect: () => base.connect(), + disconnect: () => base.disconnect(), + subscribe: (threadId) => base.subscribe(prefixed(threadId)), + unsubscribe: (threadId) => base.unsubscribe(prefixed(threadId)), + isSubscribed: (threadId) => base.isSubscribed(prefixed(threadId)), + acquireLock: async (threadId, ttlMs) => { + const lock = await base.acquireLock(prefixed(threadId), ttlMs); + return lock ? unprefixLock(lock) : null; + }, + releaseLock: (lock) => base.releaseLock(prefixLock(lock)), + extendLock: async (lock, ttlMs) => { + const prefixedLock = prefixLock(lock); + const extended = await base.extendLock(prefixedLock, ttlMs); + if (extended) { + lock.expiresAt = prefixedLock.expiresAt; + } + return extended; + }, + forceReleaseLock: (threadId) => base.forceReleaseLock(prefixed(threadId)), + enqueue: (threadId, entry, maxSize) => + base.enqueue(prefixed(threadId), entry, maxSize), + dequeue: (threadId) => base.dequeue(prefixed(threadId)), + queueDepth: (threadId) => base.queueDepth(prefixed(threadId)), + get: (key3) => base.get(prefixed(key3)), + getList: (key3) => base.getList(prefixed(key3)), + set: (key3, value, ttlMs) => base.set(prefixed(key3), value, ttlMs), + setIfNotExists: (key3, value, ttlMs) => + base.setIfNotExists(prefixed(key3), value, ttlMs), + delete: (key3) => base.delete(prefixed(key3)), + }; +} +function createQueuedStateAdapter(base, options) { + const heartbeats = /* @__PURE__ */ new Map(); + const shouldHeartbeatLock = (ttlMs) => ttlMs === ACTIVE_LOCK_TTL_MS; + const heartbeatKey = (lock) => `${lock.threadId}:${lock.token}`; + const stopHeartbeatByKey = (key3) => { + const heartbeat = heartbeats.get(key3); + if (!heartbeat) { + return; + } + clearInterval(heartbeat.timer); + heartbeats.delete(key3); + }; + const stopHeartbeat = (lock) => { + stopHeartbeatByKey(heartbeatKey(lock)); + }; + const stopHeartbeatsForThread = (threadId) => { + for (const [key3, heartbeat] of heartbeats) { + if (heartbeat.lock.threadId === threadId) { + stopHeartbeatByKey(key3); + } + } + }; + const stopAllHeartbeats = () => { + for (const key3 of heartbeats.keys()) { + stopHeartbeatByKey(key3); + } + }; + const runHeartbeat = async (key3) => { + const heartbeat = heartbeats.get(key3); + if (!heartbeat || heartbeat.inFlight) { + return; + } + heartbeat.inFlight = true; + try { + if (Date.now() - heartbeat.startedAtMs >= options.activeLockMaxAgeMs) { + stopHeartbeatByKey(key3); + return; + } + const extended = await base.extendLock(heartbeat.lock, heartbeat.ttlMs); + if (!extended) { + stopHeartbeatByKey(key3); + return; + } + heartbeat.lock.expiresAt = Date.now() + heartbeat.ttlMs; + } catch { + } finally { + const current = heartbeats.get(key3); + if (current === heartbeat) { + current.inFlight = false; + } + } + }; + const startOrUpdateHeartbeat = (lock, ttlMs) => { + const key3 = heartbeatKey(lock); + const existing = heartbeats.get(key3); + if (existing) { + existing.ttlMs = ttlMs; + return; + } + const timer = setInterval(() => { + void runHeartbeat(key3); + }, ACTIVE_LOCK_HEARTBEAT_MS); + timer.unref?.(); + heartbeats.set(key3, { + inFlight: false, + lock, + startedAtMs: Date.now(), + timer, + ttlMs, + }); + }; + const acquireLock = async (threadId, ttlMs) => { + const lock = await base.acquireLock(threadId, ttlMs); + if (lock && shouldHeartbeatLock(ttlMs)) { + startOrUpdateHeartbeat(lock, ttlMs); + } + return lock; + }; + return { + appendToList: (key3, value, options2) => + base.appendToList(key3, value, options2), + connect: () => base.connect(), + disconnect: async () => { + stopAllHeartbeats(); + await base.disconnect(); + }, + subscribe: (threadId) => base.subscribe(threadId), + unsubscribe: (threadId) => base.unsubscribe(threadId), + isSubscribed: (threadId) => base.isSubscribed(threadId), + acquireLock, + releaseLock: async (lock) => { + stopHeartbeat(lock); + await base.releaseLock(lock); + }, + extendLock: async (lock, ttlMs) => { + const extended = await base.extendLock(lock, ttlMs); + if (extended) { + lock.expiresAt = Date.now() + ttlMs; + if (shouldHeartbeatLock(ttlMs)) { + startOrUpdateHeartbeat(lock, ttlMs); + } else { + stopHeartbeat(lock); + } + } else { + stopHeartbeat(lock); + } + return extended; + }, + forceReleaseLock: async (threadId) => { + stopHeartbeatsForThread(threadId); + await base.forceReleaseLock(threadId); + }, + enqueue: (threadId, entry, maxSize) => + base.enqueue(threadId, entry, maxSize), + dequeue: (threadId) => base.dequeue(threadId), + queueDepth: (threadId) => base.queueDepth(threadId), + get: (key3) => base.get(key3), + getList: (key3) => base.getList(key3), + set: (key3, value, ttlMs) => base.set(key3, value, ttlMs), + setIfNotExists: (key3, value, ttlMs) => + base.setIfNotExists(key3, value, ttlMs), + delete: (key3) => base.delete(key3), + }; +} +function withOptionalPrefix(base, prefix) { + return prefix ? createPrefixedStateAdapter(base, prefix) : base; +} +function createStateAdapter() { + const config = getChatConfig(); + const activeLockMaxAgeMs = config.bot.turnTimeoutMs + ACTIVE_LOCK_TTL_MS; + if (config.state.adapter === "memory") { + redisStateAdapter = void 0; + return createQueuedStateAdapter( + withOptionalPrefix(createMemoryState(), config.state.keyPrefix), + { activeLockMaxAgeMs }, + ); + } + if (!config.state.redisUrl) { + throw new Error("REDIS_URL is required for durable Slack thread state"); + } + const redisState = createRedisState({ + url: config.state.redisUrl, + }); + redisStateAdapter = redisState; + return createQueuedStateAdapter( + withOptionalPrefix(redisState, config.state.keyPrefix), + { activeLockMaxAgeMs }, + ); +} +function getOptionalRedisStateAdapter() { + getStateAdapter(); + return redisStateAdapter; +} +async function getConnectedStateContext() { + const adapter = getStateAdapter(); + await adapter.connect(); + return { + redisStateAdapter: getOptionalRedisStateAdapter(), + stateAdapter: adapter, + }; +} +async function getDefaultRedisStateAdapterFor(adapter) { + if (adapter !== stateAdapter) { + return void 0; + } + const context = await getConnectedStateContext(); + return context.stateAdapter === adapter ? context.redisStateAdapter : void 0; +} +function getStateAdapter() { + if (!stateAdapter) { + stateAdapter = createStateAdapter(); + } + return stateAdapter; +} +var ACTIVE_LOCK_HEARTBEAT_MS, stateAdapter, redisStateAdapter; +var init_adapter = __esm({ + "packages/junior/src/chat/state/adapter.ts"() { + "use strict"; + init_config(); + init_locks(); + ACTIVE_LOCK_HEARTBEAT_MS = 3e4; + }, +}); + +// packages/junior/src/chat/state/session-log.ts +import { z as z6 } from "zod"; +import { actorSchema as actorSchema2 } from "@sentry/junior-plugin-api"; +function instructionProvenance(actor) { + return actor + ? { authority: "instruction", actor } + : { authority: "instruction" }; +} +function isDefaultContextProvenance(provenance) { + return provenance.authority === "context" && !provenance.actor; +} +function legacyActorProvenance(actor) { + if (actor.teamId && actor.slackUserId && actor.platform) { + return instructionProvenance({ + platform: "slack", + teamId: actor.teamId, + userId: actor.slackUserId, + ...(actor.slackUserName ? { userName: actor.slackUserName } : {}), + ...(actor.fullName ? { fullName: actor.fullName } : {}), + ...(actor.email ? { email: actor.email } : {}), + }); + } + return unattributedContextProvenance; +} +function key(scope) { + const prefix = getChatConfig().state.keyPrefix; + return [ + ...(prefix ? [prefix] : []), + AGENT_SESSION_LOG_PREFIX, + scope.conversationId, + ].join(":"); +} +function rawKey(scope) { + return [AGENT_SESSION_LOG_PREFIX, scope.conversationId].join(":"); +} +function storedRecord(value) { + return typeof value === "object" && value !== null && !Array.isArray(value) + ? value + : void 0; +} +function migrateStoredEntry(value) { + const record = storedRecord(value); + if (!record) { + return value; + } + const migrated = { ...record }; + if ("requester" in migrated && !("actor" in migrated)) { + migrated.actor = migrated.requester; + } + delete migrated.requester; + if (migrated.type === "requester_recorded") { + migrated.type = "actor_recorded"; + } + if ("requesterId" in migrated && !("actorId" in migrated)) { + migrated.actorId = migrated.requesterId; + } + delete migrated.requesterId; + return migrated; +} +function piEntry(message, sessionId, provenance) { + return { + schemaVersion: AGENT_SESSION_LOG_SCHEMA_VERSION, + type: "pi_message", + sessionId, + message, + // Ambient context is the decode default, so only attributed/instruction + // provenance needs to be persisted on the entry. + ...(provenance && !isDefaultContextProvenance(provenance) + ? { provenance } + : {}), + }; +} +function decode(value) { + if (typeof value === "string") { + return decode(JSON.parse(value)); + } + const parsed = sessionLogEntrySchema.safeParse(migrateStoredEntry(value)); + if (parsed.success) { + return parsed.data; + } + return piEntry(piMessageSchema.parse(value), INITIAL_SESSION_ID); +} +function redisStore(redisStateAdapter2) { + const client = redisStateAdapter2.getClient(); + return { + async append({ entries, scope, ttlMs }) { + const listKey = key(scope); + if (entries.length > 0) { + await client.rPush( + listKey, + entries.map((entry) => JSON.stringify(entry)), + ); + } + await client.pExpire(listKey, Math.max(1, ttlMs)); + }, + async read(scope) { + const values = await client.lRange(key(scope), 0, -1); + return values.map(decode); + }, + }; +} +function stateStore() { + const stateAdapter2 = getStateAdapter(); + return { + async append({ entries, scope, ttlMs }) { + const listKey = rawKey(scope); + const lock = await stateAdapter2.acquireLock( + `${listKey}:commit`, + STATE_STORE_LOCK_TTL_MS, + ); + if (!lock) { + throw new Error("Could not acquire session log commit lock"); + } + try { + const existingValue = await stateAdapter2.get(listKey); + const existingEntries = Array.isArray(existingValue) + ? existingValue.map(decode) + : (await stateAdapter2.getList(listKey)).map(decode); + await stateAdapter2.set( + listKey, + [...existingEntries, ...entries], + Math.max(1, ttlMs), + ); + } finally { + await stateAdapter2.releaseLock(lock); + } + }, + async read(scope) { + const listKey = rawKey(scope); + const value = await stateAdapter2.get(listKey); + if (Array.isArray(value)) { + return value.map(decode); + } + const values = await stateAdapter2.getList(listKey); + return values.map(decode); + }, + }; +} +async function defaultStore() { + const { redisStateAdapter: redisStateAdapter2, stateAdapter: stateAdapter2 } = + await getConnectedStateContext(); + if (redisStateAdapter2) { + return redisStore(redisStateAdapter2); + } + await stateAdapter2.connect(); + return stateStore(); +} +async function loadEntries(args) { + const store = args.store ?? (await defaultStore()); + return (await store.read(args)).map(decode); +} +async function readSessionLogEntries(args) { + return loadEntries(args); +} +var AGENT_SESSION_LOG_PREFIX, + AGENT_SESSION_LOG_SCHEMA_VERSION, + INITIAL_SESSION_ID, + STATE_STORE_LOCK_TTL_MS, + schemaVersionSchema, + piMessageAuthoritySchema, + piMessageProvenanceSchema, + unattributedContextProvenance, + piMessageEntrySchema, + projectionResetEntrySchema, + actorRecordedEntrySchema, + mcpProviderConnectedEntrySchema, + authorizationKindSchema, + authorizationRequestedEntrySchema, + authorizationCompletedEntrySchema, + transcriptRefSchema, + toolExecutionStartedEntrySchema, + subagentStartedEntrySchema, + subagentEndedEntrySchema, + sessionLogEntrySchema, + contextProvenance; +var init_session_log = __esm({ + "packages/junior/src/chat/state/session-log.ts"() { + "use strict"; + init_config(); + init_messages(); + init_actor(); + init_adapter(); + AGENT_SESSION_LOG_PREFIX = "junior:agent-session-log"; + AGENT_SESSION_LOG_SCHEMA_VERSION = 2; + INITIAL_SESSION_ID = "session_0"; + STATE_STORE_LOCK_TTL_MS = 5e3; + schemaVersionSchema = z6.union([z6.literal(1), z6.literal(2)]); + piMessageAuthoritySchema = z6.union([ + z6.literal("instruction"), + z6.literal("context"), + ]); + piMessageProvenanceSchema = z6 + .object({ + authority: piMessageAuthoritySchema, + actor: actorSchema2.optional(), + }) + .strict(); + unattributedContextProvenance = { + authority: "context", + }; + piMessageEntrySchema = z6.object({ + schemaVersion: schemaVersionSchema, + type: z6.literal("pi_message"), + sessionId: z6.string().min(1).default(INITIAL_SESSION_ID), + message: piMessageSchema, + provenance: piMessageProvenanceSchema.optional(), + // Legacy v1 latest-wins actor, decoded into provenance on read. + actor: storedSlackActorSchema.optional(), + }); + projectionResetEntrySchema = z6.object({ + schemaVersion: schemaVersionSchema, + type: z6.literal("projection_reset"), + sessionId: z6.string().min(1).default(INITIAL_SESSION_ID), + messages: z6.array(piMessageSchema), + provenance: z6.array(piMessageProvenanceSchema).optional(), + // Legacy v1 latest-wins actor; v1 resets carry no per-message provenance. + actor: storedSlackActorSchema.optional(), + }); + actorRecordedEntrySchema = z6.object({ + schemaVersion: schemaVersionSchema, + type: z6.literal("actor_recorded"), + sessionId: z6.string().min(1).default(INITIAL_SESSION_ID), + actor: storedSlackActorSchema, + }); + mcpProviderConnectedEntrySchema = z6.object({ + schemaVersion: schemaVersionSchema, + type: z6.literal("mcp_provider_connected"), + sessionId: z6.string().min(1).default(INITIAL_SESSION_ID), + provider: z6.string().min(1), + }); + authorizationKindSchema = z6.union([ + z6.literal("plugin"), + z6.literal("mcp"), + ]); + authorizationRequestedEntrySchema = z6.object({ + schemaVersion: schemaVersionSchema, + type: z6.literal("authorization_requested"), + sessionId: z6.string().min(1).default(INITIAL_SESSION_ID), + createdAtMs: z6.number().int().nonnegative(), + kind: authorizationKindSchema, + provider: z6.string().min(1), + actorId: z6.string().min(1), + authorizationId: z6.string().min(1), + delivery: z6.union([ + z6.literal("private_link_sent"), + z6.literal("private_link_reused"), + ]), + }); + authorizationCompletedEntrySchema = z6.object({ + schemaVersion: schemaVersionSchema, + type: z6.literal("authorization_completed"), + sessionId: z6.string().min(1).default(INITIAL_SESSION_ID), + createdAtMs: z6.number().int().nonnegative(), + kind: authorizationKindSchema, + provider: z6.string().min(1), + actorId: z6.string().min(1), + authorizationId: z6.string().min(1), + }); + transcriptRefSchema = z6.object({ + type: z6.literal("advisor_session"), + parentConversationId: z6.string().min(1), + key: z6.string().min(1), + }); + toolExecutionStartedEntrySchema = z6.object({ + schemaVersion: schemaVersionSchema, + type: z6.literal("tool_execution_started"), + sessionId: z6.string().min(1).default(INITIAL_SESSION_ID), + createdAtMs: z6.number().int().nonnegative(), + toolCallId: z6.string().min(1), + toolName: z6.string().min(1), + args: z6.unknown().optional(), + }); + subagentStartedEntrySchema = z6.object({ + schemaVersion: schemaVersionSchema, + type: z6.literal("subagent_started"), + sessionId: z6.string().min(1).default(INITIAL_SESSION_ID), + subagentInvocationId: z6.string().min(1), + subagentKind: z6.string().min(1), + parentToolCallId: z6.string().min(1).optional(), + parentConversationId: z6.string().min(1), + parentSessionId: z6.string().min(1).optional(), + transcriptRef: transcriptRefSchema, + historyMode: z6.literal("shared"), + modelId: z6.string().min(1).optional(), + reasoningLevel: z6.string().min(1).optional(), + createdAtMs: z6.number().int().nonnegative(), + }); + subagentEndedEntrySchema = z6.object({ + schemaVersion: schemaVersionSchema, + type: z6.literal("subagent_ended"), + sessionId: z6.string().min(1).default(INITIAL_SESSION_ID), + subagentInvocationId: z6.string().min(1), + outcome: z6.union([ + z6.literal("success"), + z6.literal("error"), + z6.literal("aborted"), + ]), + errorCode: z6.string().min(1).optional(), + transcriptEndMessageIndex: z6.number().int().nonnegative().optional(), + transcriptStartMessageIndex: z6.number().int().nonnegative().optional(), + createdAtMs: z6.number().int().nonnegative(), + }); + sessionLogEntrySchema = z6.discriminatedUnion("type", [ + piMessageEntrySchema, + projectionResetEntrySchema, + actorRecordedEntrySchema, + mcpProviderConnectedEntrySchema, + authorizationRequestedEntrySchema, + authorizationCompletedEntrySchema, + toolExecutionStartedEntrySchema, + subagentStartedEntrySchema, + subagentEndedEntrySchema, + ]); + contextProvenance = unattributedContextProvenance; + }, +}); + +// packages/junior/src/chat/model-profile.ts +import { z as z7 } from "zod"; +var modelProfileSchema; +var init_model_profile = __esm({ + "packages/junior/src/chat/model-profile.ts"() { + "use strict"; + modelProfileSchema = z7.string().regex(/^[a-z][a-z0-9_-]*$/); + }, +}); + +// packages/junior/src/chat/conversations/history.ts +import { z as z8 } from "zod"; +var handoffModelProfileSchema, + piMessageStepEntrySchema, + contextEpochStartedEntrySchema, + piMessageStepSchema, + contextEpochStartSchema, + mcpProviderConnectedEntrySchema2, + authorizationKindSchema2, + authorizationRequestedEntrySchema2, + authorizationCompletedEntrySchema2, + toolExecutionStartedEntrySchema2, + visibleContextCompactedEntrySchema, + subagentStartedEntrySchema2, + subagentEndedEntrySchema2, + appendableAgentStepEntrySchema, + agentStepEntrySchema, + newAgentStepSchema; +var init_history = __esm({ + "packages/junior/src/chat/conversations/history.ts"() { + "use strict"; + init_messages(); + init_session_log(); + init_model_profile(); + handoffModelProfileSchema = modelProfileSchema.refine( + (profile) => profile !== "standard", + "handoff profile must not be standard", + ); + piMessageStepEntrySchema = z8.object({ + type: z8.literal("pi_message"), + schemaVersion: z8.number().int().optional(), + message: piMessageSchema, + provenance: piMessageProvenanceSchema.optional(), + }); + contextEpochStartedEntrySchema = z8.union([ + z8 + .object({ + type: z8.literal("context_epoch_started"), + reason: z8.literal("initial"), + modelProfile: z8.literal("standard"), + modelId: z8.string().min(1), + }) + .strict(), + z8 + .object({ + type: z8.literal("context_epoch_started"), + reason: z8.literal("handoff"), + modelProfile: handoffModelProfileSchema, + modelId: z8.string().min(1), + }) + .strict(), + z8 + .object({ + type: z8.literal("context_epoch_started"), + reason: z8.union([z8.literal("compaction"), z8.literal("rollback")]), + // TODO(v0.97.0): Remove support for deployed compaction/rollback markers + // without model bindings after those rows pass the retention horizon. + modelProfile: z8.undefined().optional(), + modelId: z8.undefined().optional(), + }) + .strict(), + z8 + .object({ + type: z8.literal("context_epoch_started"), + reason: z8.union([z8.literal("compaction"), z8.literal("rollback")]), + modelProfile: modelProfileSchema, + modelId: z8.string().min(1), + }) + .strict(), + ]); + piMessageStepSchema = z8 + .object({ + message: piMessageSchema, + createdAtMs: z8.number().finite(), + provenance: piMessageProvenanceSchema.optional(), + }) + .strict(); + contextEpochStartSchema = z8.discriminatedUnion("reason", [ + z8 + .object({ + reason: z8.literal("initial"), + modelProfile: z8.literal("standard"), + modelId: z8.string().min(1), + messages: z8.array(piMessageStepSchema), + }) + .strict(), + z8 + .object({ + reason: z8.literal("handoff"), + modelProfile: handoffModelProfileSchema, + modelId: z8.string().min(1), + messages: z8.array(piMessageStepSchema), + }) + .strict(), + z8 + .object({ + reason: z8.union([z8.literal("compaction"), z8.literal("rollback")]), + modelProfile: modelProfileSchema, + modelId: z8.string().min(1), + messages: z8.array(piMessageStepSchema), + }) + .strict(), + ]); + mcpProviderConnectedEntrySchema2 = z8.object({ + type: z8.literal("mcp_provider_connected"), + provider: z8.string().min(1), + }); + authorizationKindSchema2 = z8.union([ + z8.literal("plugin"), + z8.literal("mcp"), + ]); + authorizationRequestedEntrySchema2 = z8.object({ + type: z8.literal("authorization_requested"), + kind: authorizationKindSchema2, + provider: z8.string().min(1), + actorId: z8.string().min(1), + authorizationId: z8.string().min(1), + delivery: z8.union([ + z8.literal("private_link_sent"), + z8.literal("private_link_reused"), + ]), + }); + authorizationCompletedEntrySchema2 = z8.object({ + type: z8.literal("authorization_completed"), + kind: authorizationKindSchema2, + provider: z8.string().min(1), + actorId: z8.string().min(1), + authorizationId: z8.string().min(1), + }); + toolExecutionStartedEntrySchema2 = z8.object({ + type: z8.literal("tool_execution_started"), + toolCallId: z8.string().min(1), + toolName: z8.string().min(1), + args: z8.unknown().optional(), + }); + visibleContextCompactedEntrySchema = z8.object({ + type: z8.literal("visible_context_compacted"), + compactions: z8.array( + z8.object({ + coveredMessageIds: z8.array(z8.string()), + createdAtMs: z8.number(), + id: z8.string().min(1), + summary: z8.string(), + }), + ), + }); + subagentStartedEntrySchema2 = z8 + .object({ + type: z8.literal("subagent_started"), + subagentInvocationId: z8.string().min(1), + subagentKind: z8.string().min(1), + modelId: z8.string().min(1).optional(), + parentToolCallId: z8.string().min(1).optional(), + reasoningLevel: z8.string().min(1).optional(), + childConversationId: z8.string().min(1), + historyMode: z8.union([z8.literal("isolated"), z8.literal("shared")]), + }) + .strict(); + subagentEndedEntrySchema2 = z8.object({ + type: z8.literal("subagent_ended"), + subagentInvocationId: z8.string().min(1), + outcome: z8.union([ + z8.literal("success"), + z8.literal("error"), + z8.literal("aborted"), + ]), + errorCode: z8.string().min(1).optional(), + }); + appendableAgentStepEntrySchema = z8.union([ + piMessageStepEntrySchema, + mcpProviderConnectedEntrySchema2, + authorizationRequestedEntrySchema2, + authorizationCompletedEntrySchema2, + toolExecutionStartedEntrySchema2, + visibleContextCompactedEntrySchema, + subagentStartedEntrySchema2, + subagentEndedEntrySchema2, + ]); + agentStepEntrySchema = z8.union([ + appendableAgentStepEntrySchema, + contextEpochStartedEntrySchema, + ]); + newAgentStepSchema = z8 + .object({ + entry: appendableAgentStepEntrySchema, + createdAtMs: z8.number().finite(), + }) + .strict(); + }, +}); + +// packages/junior/src/chat/conversations/sql/conversation-row.ts +import { sql as sql6 } from "drizzle-orm"; +async function ensureConversationRow(executor, conversationId, atMs) { + const at = new Date(atMs); + await executor + .db() + .insert(juniorConversations) + .values({ + conversationId, + createdAt: at, + lastActivityAt: at, + updatedAt: at, + executionStatus: "idle", + }) + .onConflictDoUpdate({ + target: juniorConversations.conversationId, + set: { + lastActivityAt: sql6`greatest(${juniorConversations.lastActivityAt}, excluded.last_activity_at)`, + updatedAt: sql6`greatest(${juniorConversations.updatedAt}, excluded.updated_at)`, + transcriptPurgedAt: null, + }, + }); +} +var init_conversation_row = __esm({ + "packages/junior/src/chat/conversations/sql/conversation-row.ts"() { + "use strict"; + init_schema(); + }, +}); + +// packages/junior/src/chat/conversations/sql/history.ts +import { and as and3, asc as asc2, eq as eq3, sql as sql7 } from "drizzle-orm"; +function messageRole(entry) { + if (entry.type !== "pi_message") { + return null; + } + const role = entry.message.role; + return typeof role === "string" ? role : null; +} +function insertFromStep(conversationId, seq, contextEpoch, step) { + const { type, ...payload } = agentStepEntrySchema.parse(step.entry); + return { + conversationId, + seq, + contextEpoch, + type, + role: messageRole(step.entry), + payload, + createdAt: new Date(step.createdAtMs), + }; +} +function stepFromRow(row) { + const entry = agentStepEntrySchema.parse({ type: row.type, ...row.payload }); + return { + seq: row.seq, + contextEpoch: row.contextEpoch, + createdAtMs: row.createdAt.getTime(), + entry, + }; +} +function piMessageStep(step) { + return { + entry: { + type: "pi_message", + message: step.message, + ...(step.provenance ? { provenance: step.provenance } : {}), + }, + createdAtMs: step.createdAtMs, + }; +} +function createSqlAgentStepStore(executor) { + return new SqlAgentStepStore(executor); +} +var SqlAgentStepStore; +var init_history2 = __esm({ + "packages/junior/src/chat/conversations/sql/history.ts"() { + "use strict"; + init_history(); + init_conversation_row(); + init_schema(); + SqlAgentStepStore = class { + constructor(executor) { + this.executor = executor; + } + executor; + async append(conversationId, steps) { + const parsed = steps.map((step) => newAgentStepSchema.parse(step)); + if (parsed.length === 0) { + return; + } + const newestCreatedAtMs = Math.max( + ...parsed.map((step) => step.createdAtMs), + ); + await this.executor.transaction(async () => { + await ensureConversationRow( + this.executor, + conversationId, + newestCreatedAtMs, + ); + const cursor = await this.readCursor(conversationId); + const contextEpoch = cursor.maxEpoch ?? 0; + let seq = cursor.nextSeq; + const rows = parsed.map((step) => + insertFromStep(conversationId, seq++, contextEpoch, step), + ); + await this.executor.db().insert(juniorAgentSteps).values(rows); + }); + } + async startEpoch(conversationId, opts) { + const parsed = contextEpochStartSchema.parse(opts); + await this.executor.transaction(async () => { + await ensureConversationRow( + this.executor, + conversationId, + Date.now(), + ); + const cursor = await this.readCursor(conversationId); + const contextEpoch = + parsed.reason === "initial" + ? (cursor.maxEpoch ?? 0) + : (cursor.maxEpoch ?? -1) + 1; + let seq = cursor.nextSeq; + const { messages, ...binding } = parsed; + const marker = { + entry: { type: "context_epoch_started", ...binding }, + createdAtMs: Date.now(), + }; + const rows = [marker, ...messages.map(piMessageStep)].map((step) => + insertFromStep(conversationId, seq++, contextEpoch, step), + ); + await this.executor.db().insert(juniorAgentSteps).values(rows); + }); + } + async loadCurrentEpoch(conversationId) { + const cursor = await this.readCursor(conversationId); + if (cursor.maxEpoch === null) { + return []; + } + const rows = await this.executor + .db() + .select() + .from(juniorAgentSteps) + .where( + and3( + eq3(juniorAgentSteps.conversationId, conversationId), + eq3(juniorAgentSteps.contextEpoch, cursor.maxEpoch), + ), + ) + .orderBy(asc2(juniorAgentSteps.seq)); + return rows.map(stepFromRow); + } + async loadHistory(conversationId) { + const rows = await this.executor + .db() + .select() + .from(juniorAgentSteps) + .where(eq3(juniorAgentSteps.conversationId, conversationId)) + .orderBy(asc2(juniorAgentSteps.seq)); + return rows.map(stepFromRow); + } + /** Read the next `seq` and current highest epoch for one conversation. */ + async readCursor(conversationId) { + const rows = await this.executor + .db() + .select({ + maxSeq: sql7`max(${juniorAgentSteps.seq})`, + maxEpoch: sql7`max(${juniorAgentSteps.contextEpoch})`, + }) + .from(juniorAgentSteps) + .where(eq3(juniorAgentSteps.conversationId, conversationId)); + const maxSeq = rows[0]?.maxSeq; + const maxEpoch = rows[0]?.maxEpoch; + return { + maxEpoch: + maxEpoch === null || maxEpoch === void 0 ? null : Number(maxEpoch), + nextSeq: + maxSeq === null || maxSeq === void 0 ? 0 : Number(maxSeq) + 1, + }; + } + }; + }, +}); + +// packages/junior/src/chat/conversations/sql/messages.ts +import { + and as and4, + asc as asc3, + eq as eq4, + isNull as isNull2, + sql as sql8, +} from "drizzle-orm"; +function messageFromRow(row) { + return { + conversationId: row.conversationId, + messageId: row.messageId, + role: row.role, + text: row.text, + createdAtMs: row.createdAt.getTime(), + ...(row.authorIdentityId ? { authorIdentityId: row.authorIdentityId } : {}), + ...(row.meta ? { meta: row.meta } : {}), + ...(row.repliedAt ? { repliedAtMs: row.repliedAt.getTime() } : {}), + }; +} +function createSqlConversationMessageStore(executor) { + return new SqlConversationMessageStore(executor); +} +var SqlConversationMessageStore; +var init_messages2 = __esm({ + "packages/junior/src/chat/conversations/sql/messages.ts"() { + "use strict"; + init_conversation_row(); + init_schema(); + SqlConversationMessageStore = class { + constructor(executor) { + this.executor = executor; + } + executor; + async record(conversationId, messages) { + if (messages.length === 0) { + return; + } + const newestCreatedAtMs = Math.max( + ...messages.map((message) => message.createdAtMs), + ); + await this.executor.transaction(async () => { + await ensureConversationRow( + this.executor, + conversationId, + newestCreatedAtMs, + ); + await this.executor + .db() + .insert(juniorConversationMessages) + .values( + messages.map((message) => ({ + conversationId, + messageId: message.messageId, + role: message.role, + authorIdentityId: message.authorIdentityId ?? null, + text: message.text, + meta: message.meta ?? null, + repliedAt: null, + createdAt: new Date(message.createdAtMs), + })), + ) + .onConflictDoUpdate({ + target: [ + juniorConversationMessages.conversationId, + juniorConversationMessages.messageId, + ], + set: { + meta: sql8`nullif(coalesce(${juniorConversationMessages.meta}, '{}'::jsonb) || coalesce(excluded.meta, '{}'::jsonb), '{}'::jsonb)`, + }, + }); + }); + } + async markReplied(conversationId, messageId, repliedAtMs) { + await this.executor + .db() + .update(juniorConversationMessages) + .set({ + repliedAt: sql8`coalesce(${juniorConversationMessages.repliedAt}, ${new Date(repliedAtMs)})`, + }) + .where( + and4( + eq4(juniorConversationMessages.conversationId, conversationId), + eq4(juniorConversationMessages.messageId, messageId), + isNull2(juniorConversationMessages.repliedAt), + ), + ); + } + async list(conversationId, opts = {}) { + const query = this.executor + .db() + .select() + .from(juniorConversationMessages) + .where(eq4(juniorConversationMessages.conversationId, conversationId)) + .orderBy( + asc3(juniorConversationMessages.createdAt), + asc3(juniorConversationMessages.messageId), + ); + const rows = + opts.limit === void 0 + ? await query + : await query.limit(Math.max(0, opts.limit)); + return rows.map(messageFromRow); + } + }; + }, +}); + +// packages/junior/src/chat/conversations/sql/search.ts +import { + and as and5, + desc as desc2, + eq as eq5, + inArray, + isNull as isNull3, + ne, + sql as sql9, +} from "drizzle-orm"; +var init_search = __esm({ + "packages/junior/src/chat/conversations/sql/search.ts"() { + "use strict"; + init_schema(); + }, +}); + +// migration:executor +function createJuniorSqlExecutor() { + throw new Error("Migration database adapter is required"); +} +var init_executor = __esm({ + "migration:executor"() { + "use strict"; + }, +}); + +// packages/junior/src/chat/db.ts +var init_db = __esm({ + "packages/junior/src/chat/db.ts"() { + "use strict"; + init_config(); + init_store(); + init_history2(); + init_messages2(); + init_search(); + init_executor(); + }, +}); + +// packages/junior/src/chat/conversations/visible-compactions.ts +var init_visible_compactions = __esm({ + "packages/junior/src/chat/conversations/visible-compactions.ts"() { + "use strict"; + init_db(); + }, +}); + +// packages/junior/src/chat/coerce.ts +function toOptionalString(value) { + return typeof value === "string" && value.trim() ? value : void 0; +} +function toOptionalNumber(value) { + return typeof value === "number" && Number.isFinite(value) ? value : void 0; +} +function isRecord(value) { + return typeof value === "object" && value !== null; +} +var init_coerce = __esm({ + "packages/junior/src/chat/coerce.ts"() { + "use strict"; + }, +}); + +// packages/junior/src/chat/sentry.ts +var sentry_exports = {}; +__export(sentry_exports, { + captureException: () => captureException, + captureMessage: () => captureMessage, + continueTrace: () => continueTrace, + flush: () => flush, + getActiveSpan: () => getActiveSpan, + getClient: () => getClient, + getGlobalScope: () => getGlobalScope, + getTraceData: () => getTraceData, + init: () => init, + logger: () => logger, + setTag: () => setTag, + setUser: () => setUser, + spanToJSON: () => spanToJSON, + startInactiveSpan: () => startInactiveSpan, + startSpan: () => startSpan, + vercelAIIntegration: () => vercelAIIntegration, + withActiveSpan: () => withActiveSpan, + withScope: () => withScope, + withStreamedSpan: () => withStreamedSpan, +}); +import { + captureException, + captureMessage, + continueTrace, + flush, + getActiveSpan, + getClient, + getGlobalScope, + getTraceData, + init, + logger, + setTag, + setUser, + spanToJSON, + startInactiveSpan, + startSpan, + vercelAIIntegration, + withActiveSpan, + withScope, + withStreamedSpan, +} from "@sentry/node"; +import * as node_star from "@sentry/node"; +var init_sentry = __esm({ + "packages/junior/src/chat/sentry.ts"() { + "use strict"; + __reExport(sentry_exports, node_star); + }, +}); + +// packages/junior/src/deployment.ts +function toOptionalTrimmed(value) { + const trimmed = value?.trim(); + return trimmed ? trimmed : void 0; +} +function getDeploymentServiceVersion() { + return ( + toOptionalTrimmed(process.env.SENTRY_RELEASE) ?? + toOptionalTrimmed(process.env.VERCEL_GIT_COMMIT_SHA) + ); +} +function getDeploymentTelemetryAttributes() { + const attributes = {}; + const serviceVersion = getDeploymentServiceVersion(); + const deploymentId = toOptionalTrimmed(process.env.VERCEL_DEPLOYMENT_ID); + if (serviceVersion) { + attributes["service.version"] = serviceVersion; + } + if (deploymentId) { + attributes["deployment.id"] = deploymentId; + } + return attributes; +} +var init_deployment = __esm({ + "packages/junior/src/deployment.ts"() { + "use strict"; + }, +}); + +// packages/junior/src/chat/logging.ts +import { AsyncLocalStorage } from "node:async_hooks"; +import { + ConfigError, + configureSync, + getConfig, + getLogger, +} from "@logtape/logtape"; +var contextStorage, + deploymentLogAttributes, + CONSOLE_PRIORITY_KEYS, + CONSOLE_PRIORITY_INDEX; +var init_logging = __esm({ + "packages/junior/src/chat/logging.ts"() { + "use strict"; + init_coerce(); + init_identity(); + init_sentry(); + init_sentry(); + init_deployment(); + contextStorage = new AsyncLocalStorage(); + deploymentLogAttributes = getDeploymentTelemetryAttributes(); + CONSOLE_PRIORITY_KEYS = [ + "gen_ai.conversation.id", + "event.name", + "app.log.source", + "exception.message", + "messaging.message.id", + "trace_id", + "span_id", + "messaging.message.conversation_id", + "messaging.destination.name", + "app.run.id", + "app.message.kind", + ]; + CONSOLE_PRIORITY_INDEX = new Map( + CONSOLE_PRIORITY_KEYS.map((key3, index6) => [key3, index6]), + ); + }, +}); + +// packages/junior/src/chat/pi/sdk.ts +import { + completeSimple, + getEnvApiKey, + getModels, + registerApiProvider, +} from "@earendil-works/pi-ai/compat"; +import { + stream, + streamSimple, +} from "@earendil-works/pi-ai/api/anthropic-messages"; +var init_sdk = __esm({ + "packages/junior/src/chat/pi/sdk.ts"() { + "use strict"; + }, +}); + +// packages/junior/src/chat/optional-string.ts +var init_optional_string = __esm({ + "packages/junior/src/chat/optional-string.ts"() { + "use strict"; + }, +}); + +// packages/junior/src/chat/slack/context.ts +import { z as z9 } from "zod"; +var slackMessageEnvelopeSchema, + rawSlackMessageSchema, + nestedRawSlackMessageSchema; +var init_context = __esm({ + "packages/junior/src/chat/slack/context.ts"() { + "use strict"; + init_coerce(); + init_ids(); + init_timestamp(); + slackMessageEnvelopeSchema = z9.object({ + raw: z9.unknown().optional(), + }); + rawSlackMessageSchema = z9.object({ + channel: z9.unknown().optional(), + message: z9.unknown().optional(), + team: z9.unknown().optional(), + team_id: z9.unknown().optional(), + thread_ts: z9.unknown().optional(), + ts: z9.unknown().optional(), + user_team: z9.unknown().optional(), + }); + nestedRawSlackMessageSchema = z9.object({ + ts: z9.unknown().optional(), + }); + }, +}); + +// packages/junior/src/chat/conversation-privacy.ts +import { AsyncLocalStorage as AsyncLocalStorage2 } from "node:async_hooks"; +var conversationPrivacyStorage; +var init_conversation_privacy = __esm({ + "packages/junior/src/chat/conversation-privacy.ts"() { + "use strict"; + init_context(); + conversationPrivacyStorage = new AsyncLocalStorage2(); + }, +}); + +// packages/junior/src/chat/xml.ts +function unescapeXml(value) { + return value + .replaceAll(""", '"') + .replaceAll("'", "'") + .replaceAll(">", ">") + .replaceAll("<", "<") + .replaceAll("&", "&"); +} +var init_xml = __esm({ + "packages/junior/src/chat/xml.ts"() { + "use strict"; + }, +}); + +// packages/junior/src/chat/current-instruction.ts +var CURRENT_INSTRUCTION_TAG, + CURRENT_INSTRUCTION_OPEN_PREFIX, + CURRENT_INSTRUCTION_OPEN_BARE, + CURRENT_INSTRUCTION_OPEN_ATTR_PREFIX, + CURRENT_INSTRUCTION_CLOSE; +var init_current_instruction = __esm({ + "packages/junior/src/chat/current-instruction.ts"() { + "use strict"; + init_xml(); + CURRENT_INSTRUCTION_TAG = "current-instruction"; + CURRENT_INSTRUCTION_OPEN_PREFIX = `<${CURRENT_INSTRUCTION_TAG}`; + CURRENT_INSTRUCTION_OPEN_BARE = `<${CURRENT_INSTRUCTION_TAG}>`; + CURRENT_INSTRUCTION_OPEN_ATTR_PREFIX = `<${CURRENT_INSTRUCTION_TAG} `; + CURRENT_INSTRUCTION_CLOSE = ` +`; + }, +}); + +// packages/junior/src/chat/turn-context-tag.ts +var TURN_CONTEXT_TAG; +var init_turn_context_tag = __esm({ + "packages/junior/src/chat/turn-context-tag.ts"() { + "use strict"; + TURN_CONTEXT_TAG = "runtime-turn-context"; + }, +}); + +// packages/junior/src/chat/pi/transcript.ts +var RUNTIME_TURN_CONTEXT_START, + EMBEDDED_THREAD_CONTEXT_TAGS, + EMBEDDED_THREAD_CONTEXT_PATTERN; +var init_transcript = __esm({ + "packages/junior/src/chat/pi/transcript.ts"() { + "use strict"; + init_current_instruction(); + init_turn_context_tag(); + RUNTIME_TURN_CONTEXT_START = `<${TURN_CONTEXT_TAG}>`; + EMBEDDED_THREAD_CONTEXT_TAGS = [ + "recent-thread-messages", + "thread-compactions", + "thread-transcript", + "thread-background", + ]; + EMBEDDED_THREAD_CONTEXT_PATTERN = new RegExp( + `<(${EMBEDDED_THREAD_CONTEXT_TAGS.join("|")})(?:\\s[^>]*)?>[\\s\\S]*?`, + "g", + ); + }, +}); + +// packages/junior/src/chat/services/provider-retry.ts +var init_provider_retry = __esm({ + "packages/junior/src/chat/services/provider-retry.ts"() { + "use strict"; + init_transcript(); + }, +}); + +// packages/junior/src/chat/pi/client.ts +import { createGatewayProvider } from "@ai-sdk/gateway"; +import { embedMany, generateObject } from "ai"; +var init_client = __esm({ + "packages/junior/src/chat/pi/client.ts"() { + "use strict"; + init_sdk(); + init_logging(); + init_logging(); + init_optional_string(); + init_conversation_privacy(); + init_provider_retry(); + registerApiProvider({ + api: "anthropic-messages", + stream, + streamSimple, + }); + }, +}); + +// packages/junior/src/chat/services/context-budget.ts +var init_context_budget = __esm({ + "packages/junior/src/chat/services/context-budget.ts"() { + "use strict"; + init_config(); + init_client(); + }, +}); + +// packages/junior/src/chat/services/conversation-memory.ts +var init_conversation_memory = __esm({ + "packages/junior/src/chat/services/conversation-memory.ts"() { + "use strict"; + init_config(); + init_logging(); + init_context_budget(); + init_timestamp(); + init_xml(); + }, +}); + +// packages/junior/src/chat/conversations/visible-messages.ts +function toStoredConversationMessage(message) { + const meta = {}; + if (message.author) { + meta.author = message.author; + } + const { replied, ...restMeta } = message.meta ?? {}; + Object.assign(meta, restMeta); + if (replied === false) { + meta.replied = false; + } + return { + messageId: message.id, + role: message.role, + text: message.text, + ...(Object.keys(meta).length > 0 ? { meta } : {}), + createdAtMs: message.createdAtMs, + }; +} +var init_visible_messages = __esm({ + "packages/junior/src/chat/conversations/visible-messages.ts"() { + "use strict"; + init_db(); + init_visible_compactions(); + init_conversation_memory(); + }, +}); + +// packages/junior/src/chat/conversations/legacy-advisor-session.ts +import { z as z10 } from "zod"; +function key2(conversationId) { + return `junior:${conversationId}:advisor_session`; +} +function createLegacyAdvisorSessionReader() { + return { + load: async (conversationId) => { + const stateAdapter2 = getStateAdapter(); + await stateAdapter2.connect(); + return legacyAdvisorMessagesSchema.parse( + structuredClone((await stateAdapter2.get(key2(conversationId))) ?? []), + ); + }, + }; +} +var legacyAdvisorMessagesSchema; +var init_legacy_advisor_session = __esm({ + "packages/junior/src/chat/conversations/legacy-advisor-session.ts"() { + "use strict"; + init_messages(); + init_adapter(); + legacyAdvisorMessagesSchema = z10.array(piMessageSchema); + }, +}); + +// packages/junior/src/chat/conversations/sql/legacy-history-import.ts +import { eq as eq6, sql as sql10 } from "drizzle-orm"; +function importedAdvisorChildConversationId(parentConversationId) { + return `advisor:${parentConversationId}`; +} +function epochFromSessionId(sessionId) { + const match = /^session_(\d+)$/.exec(sessionId); + return match ? Number(match[1]) : 0; +} +function messageTimestampMs(message) { + const timestamp2 = message.timestamp; + return typeof timestamp2 === "number" ? timestamp2 : void 0; +} +function readAdvisorRequest(text7) { + if ( + !text7.startsWith(ADVISOR_TASK_OPEN) || + !text7.endsWith(ADVISOR_CONTEXT_CLOSE) + ) { + return void 0; + } + const taskEnd = text7.indexOf(ADVISOR_TASK_CLOSE, ADVISOR_TASK_OPEN.length); + if (taskEnd < 0) { + return void 0; + } + const contextStart = taskEnd + ADVISOR_TASK_CLOSE.length + 2; + if (!text7.startsWith(ADVISOR_CONTEXT_OPEN, contextStart)) { + return void 0; + } + const task = text7.slice(ADVISOR_TASK_OPEN.length, taskEnd); + const context = text7.slice( + contextStart + ADVISOR_CONTEXT_OPEN.length, + -ADVISOR_CONTEXT_CLOSE.length, + ); + return `${unescapeXml(task)} + +Executor context: +${unescapeXml(context)}`; +} +function normalizeAdvisorMessage(message) { + const record = message; + if (record.role !== "user" || !Array.isArray(record.content)) { + return message; + } + let changed = false; + const content = record.content.map((part) => { + if ( + !part || + typeof part !== "object" || + part.type !== "text" || + typeof part.text !== "string" + ) { + return part; + } + const text7 = readAdvisorRequest(part.text); + if (text7 === void 0) { + return part; + } + changed = true; + return { ...part, text: text7 }; + }); + return changed ? { ...record, content } : message; +} +function piEntryProvenance(entry) { + if (entry.provenance) { + return entry.provenance; + } + if (entry.actor) { + return legacyActorProvenance(entry.actor); + } + return contextProvenance; +} +function convertLegacySessionLog(args) { + const steps = []; + const fallback = args.fallbackCreatedAtMs; + let advisorChildConversationId; + let seq = 0; + const push = (contextEpoch, entry, createdAtMs) => { + steps.push({ seq, contextEpoch, entry, createdAtMs }); + seq += 1; + }; + for (const entry of args.entries) { + const epoch = epochFromSessionId(entry.sessionId ?? INITIAL_SESSION_ID2); + switch (entry.type) { + case "pi_message": { + push( + epoch, + { + type: "pi_message", + message: entry.message, + provenance: piEntryProvenance(entry), + }, + messageTimestampMs(entry.message) ?? fallback, + ); + break; + } + case "projection_reset": { + const provenance = + entry.provenance ?? entry.messages.map(() => contextProvenance); + if (provenance.length !== entry.messages.length) { + throw new Error( + "projection_reset provenance must align one-to-one with messages", + ); + } + push( + epoch, + { type: "context_epoch_started", reason: "compaction" }, + fallback, + ); + entry.messages.forEach((message, index6) => { + push( + epoch, + { + type: "pi_message", + message, + provenance: provenance[index6], + }, + messageTimestampMs(message) ?? fallback, + ); + }); + break; + } + case "mcp_provider_connected": { + push( + epoch, + { type: "mcp_provider_connected", provider: entry.provider }, + fallback, + ); + break; + } + case "authorization_requested": { + push( + epoch, + { + type: "authorization_requested", + kind: entry.kind, + provider: entry.provider, + actorId: entry.actorId, + authorizationId: entry.authorizationId, + delivery: entry.delivery, + }, + entry.createdAtMs, + ); + break; + } + case "authorization_completed": { + push( + epoch, + { + type: "authorization_completed", + kind: entry.kind, + provider: entry.provider, + actorId: entry.actorId, + authorizationId: entry.authorizationId, + }, + entry.createdAtMs, + ); + break; + } + case "tool_execution_started": { + push( + epoch, + { + type: "tool_execution_started", + toolCallId: entry.toolCallId, + toolName: entry.toolName, + ...(entry.args !== void 0 ? { args: entry.args } : {}), + }, + entry.createdAtMs, + ); + break; + } + case "subagent_started": { + const childConversationId = importedAdvisorChildConversationId( + args.conversationId, + ); + advisorChildConversationId = childConversationId; + push( + epoch, + { + type: "subagent_started", + subagentInvocationId: entry.subagentInvocationId, + subagentKind: entry.subagentKind, + ...(entry.parentToolCallId + ? { parentToolCallId: entry.parentToolCallId } + : {}), + childConversationId, + historyMode: "shared", + }, + entry.createdAtMs, + ); + break; + } + case "subagent_ended": { + push( + epoch, + { + type: "subagent_ended", + subagentInvocationId: entry.subagentInvocationId, + outcome: entry.outcome, + ...(entry.errorCode ? { errorCode: entry.errorCode } : {}), + }, + entry.createdAtMs, + ); + break; + } + case "actor_recorded": { + break; + } + } + } + return { + steps, + ...(advisorChildConversationId ? { advisorChildConversationId } : {}), + }; +} +function convertAdvisorMessages(messages, fallbackCreatedAtMs) { + return messages.map((sourceMessage, seq) => { + const message = normalizeAdvisorMessage(sourceMessage); + return { + seq, + contextEpoch: 0, + entry: { type: "pi_message", message, provenance: contextProvenance }, + createdAtMs: messageTimestampMs(message) ?? fallbackCreatedAtMs, + }; + }); +} +function messageRole2(entry) { + if (entry.type !== "pi_message") { + return null; + } + const role = entry.message.role; + return typeof role === "string" ? role : null; +} +function insertRow(conversationId, step) { + const { type, ...payload } = agentStepEntrySchema.parse(step.entry); + return { + conversationId, + seq: step.seq, + contextEpoch: step.contextEpoch, + type, + role: messageRole2(step.entry), + payload, + createdAt: new Date(step.createdAtMs), + }; +} +async function writeLegacyImport(executor, args) { + return executor.withLock( + `junior_conversation:legacy-import:${args.conversationId}`, + () => + executor.transaction(async () => { + const db = executor.db(); + const conversations = await db + .select({ + transcriptPurgedAt: juniorConversations.transcriptPurgedAt, + }) + .from(juniorConversations) + .where(eq6(juniorConversations.conversationId, args.conversationId)) + .for("update"); + if (conversations[0]?.transcriptPurgedAt) { + return false; + } + const existing = await db + .select({ seq: juniorAgentSteps.seq }) + .from(juniorAgentSteps) + .where(eq6(juniorAgentSteps.conversationId, args.conversationId)) + .limit(1); + if (existing.length > 0) { + return false; + } + const createdAt = new Date(args.fallbackCreatedAtMs); + await ensureConversationRow2( + executor, + args.conversationId, + createdAt, + new Date(args.lastActivityAtMs), + ); + if (args.messages && args.messages.length > 0) { + await db + .insert(juniorConversationMessages) + .values( + args.messages.map((message) => ({ + conversationId: args.conversationId, + messageId: message.messageId, + role: message.role, + authorIdentityId: message.authorIdentityId ?? null, + text: message.text, + meta: message.meta ?? null, + repliedAt: + message.repliedAtMs === void 0 + ? null + : new Date(message.repliedAtMs), + createdAt: new Date(message.createdAtMs), + })), + ) + .onConflictDoUpdate({ + target: [ + juniorConversationMessages.conversationId, + juniorConversationMessages.messageId, + ], + set: { + meta: sql10`nullif(coalesce(${juniorConversationMessages.meta}, '{}'::jsonb) || coalesce(excluded.meta, '{}'::jsonb), '{}'::jsonb)`, + repliedAt: sql10`coalesce(${juniorConversationMessages.repliedAt}, excluded.replied_at)`, + }, + }); + } + if (args.steps.length > 0) { + await db + .insert(juniorAgentSteps) + .values( + args.steps.map((step) => insertRow(args.conversationId, step)), + ); + } + if (args.child) { + const childCreatedAtMs = + args.child.steps.length > 0 + ? Math.min(...args.child.steps.map((step) => step.createdAtMs)) + : args.fallbackCreatedAtMs; + const childLastActivityAtMs = + args.child.steps.length > 0 + ? Math.max(...args.child.steps.map((step) => step.createdAtMs)) + : childCreatedAtMs; + await ensureChildConversationRow( + executor, + args.child.conversationId, + args.conversationId, + new Date(childCreatedAtMs), + new Date(childLastActivityAtMs), + ); + if (args.child.steps.length > 0) { + await db + .insert(juniorAgentSteps) + .values( + args.child.steps.map((step) => + insertRow(args.child.conversationId, step), + ), + ); + } + } + return true; + }), + ); +} +async function ensureConversationRow2( + executor, + conversationId, + createdAt, + lastActivityAt, +) { + await executor + .db() + .insert(juniorConversations) + .values({ + conversationId, + schemaVersion: 1, + createdAt, + lastActivityAt, + updatedAt: lastActivityAt, + executionStatus: "idle", + }) + .onConflictDoUpdate({ + target: juniorConversations.conversationId, + set: { + createdAt: sql10`least(${juniorConversations.createdAt}, excluded.created_at)`, + lastActivityAt: sql10`greatest(${juniorConversations.lastActivityAt}, excluded.last_activity_at)`, + updatedAt: sql10`greatest(${juniorConversations.updatedAt}, excluded.updated_at)`, + }, + }); +} +async function ensureChildConversationRow( + executor, + childConversationId, + parentConversationId, + createdAt, + lastActivityAt, +) { + await ensureConversationRow2( + executor, + parentConversationId, + createdAt, + lastActivityAt, + ); + await executor + .db() + .insert(juniorConversations) + .values({ + conversationId: childConversationId, + schemaVersion: 1, + parentConversationId, + createdAt, + lastActivityAt, + updatedAt: lastActivityAt, + executionStatus: "idle", + }) + .onConflictDoUpdate({ + target: juniorConversations.conversationId, + set: { + parentConversationId: sql10`coalesce(${juniorConversations.parentConversationId}, excluded.parent_conversation_id)`, + createdAt: sql10`least(${juniorConversations.createdAt}, excluded.created_at)`, + lastActivityAt: sql10`greatest(${juniorConversations.lastActivityAt}, excluded.last_activity_at)`, + updatedAt: sql10`greatest(${juniorConversations.updatedAt}, excluded.updated_at)`, + }, + }); +} +var INITIAL_SESSION_ID2, + ADVISOR_TASK_OPEN, + ADVISOR_TASK_CLOSE, + ADVISOR_CONTEXT_OPEN, + ADVISOR_CONTEXT_CLOSE; +var init_legacy_history_import = __esm({ + "packages/junior/src/chat/conversations/sql/legacy-history-import.ts"() { + "use strict"; + init_xml(); + init_session_log(); + init_history(); + init_schema(); + INITIAL_SESSION_ID2 = "session_0"; + ADVISOR_TASK_OPEN = "\n"; + ADVISOR_TASK_CLOSE = "\n"; + ADVISOR_CONTEXT_OPEN = "\n"; + ADVISOR_CONTEXT_CLOSE = "\n"; + }, +}); + +// packages/junior/src/chat/conversations/legacy-import.ts +import { isDeepStrictEqual } from "node:util"; +import { eq as eq7 } from "drizzle-orm"; +import { z as z11 } from "zod"; +async function loadThreadStateSnapshot(conversationId) { + const stateAdapter2 = getStateAdapter(); + await stateAdapter2.connect(); + const raw = await stateAdapter2.get(`thread-state:${conversationId}`); + if (!raw) { + return { compactions: [], messages: [] }; + } + const conversation = legacyThreadStateSnapshotSchema.parse(raw).conversation; + return { + compactions: conversation?.compactions ?? [], + messages: conversation?.messages ?? [], + ...(conversation?.stats?.updatedAtMs !== void 0 + ? { lastActivityAtMs: conversation.stats.updatedAtMs } + : {}), + }; +} +function intrinsicTimestamps(entries, visible, compactions) { + const candidates = []; + const pushMessageTs = (message) => { + const timestamp2 = message.timestamp; + if (typeof timestamp2 === "number") { + candidates.push(timestamp2); + } + }; + for (const entry of entries) { + if (entry.type === "pi_message") { + pushMessageTs(entry.message); + } else if (entry.type === "projection_reset") { + entry.messages.forEach(pushMessageTs); + } else if ("createdAtMs" in entry) { + candidates.push(entry.createdAtMs); + } + } + for (const message of visible) { + candidates.push(message.createdAtMs); + } + for (const compaction of compactions) { + candidates.push(compaction.createdAtMs); + } + return candidates; +} +async function importConversationFromLegacy(conversationId, deps) { + const stepStore = createSqlAgentStepStore(deps.executor); + const existing = await stepStore.loadCurrentEpoch(conversationId); + if (existing.length > 0) { + return { imported: false }; + } + const entries = deps.sessionLogStore + ? await deps.sessionLogStore.read({ conversationId }) + : await readSessionLogEntries({ conversationId }); + const snapshot = deps.loadVisibleMessages + ? { + compactions: deps.legacyCompactions ?? [], + messages: await deps.loadVisibleMessages(conversationId), + } + : await loadThreadStateSnapshot(conversationId); + const { compactions, messages: visible } = snapshot; + if ( + entries.length === 0 && + visible.length === 0 && + compactions.length === 0 + ) { + return { imported: false }; + } + const hasAdvisor = entries.some((entry) => entry.type === "subagent_started"); + const advisorMessages = hasAdvisor + ? await ( + deps.advisorSessionStore ?? createLegacyAdvisorSessionReader() + ).load(conversationId) + : []; + const intrinsic = intrinsicTimestamps(entries, visible, compactions); + for (const message of advisorMessages) { + const timestamp2 = message.timestamp; + if (typeof timestamp2 === "number") { + intrinsic.push(timestamp2); + } + } + const fallbackCreatedAtMs = + deps.conversationRecord?.createdAtMs ?? + (intrinsic.length > 0 ? Math.min(...intrinsic) : void 0) ?? + 0; + const lastActivityAtMs = Math.max( + fallbackCreatedAtMs, + deps.conversationRecord?.lastActivityAtMs ?? 0, + deps.legacyLastActivityAtMs ?? 0, + intrinsic.length > 0 ? Math.max(...intrinsic) : 0, + ); + const converted = convertLegacySessionLog({ + conversationId, + entries, + fallbackCreatedAtMs, + }); + if (compactions.length > 0) { + converted.steps.push({ + seq: converted.steps.length, + contextEpoch: converted.steps.at(-1)?.contextEpoch ?? 0, + entry: { type: "visible_context_compacted", compactions }, + createdAtMs: compactions.at(-1)?.createdAtMs ?? fallbackCreatedAtMs, + }); + } + if (converted.steps.length === 0 && visible.length > 0) { + const existingMessages = new Map( + (await deps.messageStore.list(conversationId)).map((message) => [ + message.messageId, + message, + ]), + ); + const fullyImported = visible.every((message) => { + const existingMessage = existingMessages.get(message.id); + const projected = toStoredConversationMessage(message); + return ( + existingMessage !== void 0 && + Object.entries(projected.meta ?? {}).every(([key3, value]) => + isDeepStrictEqual(existingMessage.meta?.[key3], value), + ) && + (message.meta?.replied !== true || + existingMessage.repliedAtMs !== void 0) + ); + }); + if (fullyImported) { + await writeLegacyImport(deps.executor, { + conversationId, + fallbackCreatedAtMs, + lastActivityAtMs, + steps: [], + }); + return { imported: false }; + } + } + let child; + if (converted.advisorChildConversationId) { + child = { + conversationId: converted.advisorChildConversationId, + steps: convertAdvisorMessages(advisorMessages, fallbackCreatedAtMs), + }; + } + const messages = visible.map((message) => ({ + ...toStoredConversationMessage(message), + ...(message.meta?.replied ? { repliedAtMs: message.createdAtMs } : {}), + })); + const imported = await writeLegacyImport(deps.executor, { + conversationId, + fallbackCreatedAtMs, + lastActivityAtMs, + ...(messages.length > 0 ? { messages } : {}), + steps: converted.steps, + ...(child ? { child } : {}), + }); + return { imported }; +} +var legacyVisibleMessageSchema, + legacyCompactionSchema, + legacyThreadStateSnapshotSchema; +var init_legacy_import = __esm({ + "packages/junior/src/chat/conversations/legacy-import.ts"() { + "use strict"; + init_visible_messages(); + init_adapter(); + init_session_log(); + init_legacy_advisor_session(); + init_schema(); + init_db(); + init_history2(); + init_legacy_history_import(); + legacyVisibleMessageSchema = z11.object({ + id: z11.string(), + role: z11.enum(["user", "assistant", "system"]), + text: z11.string(), + createdAtMs: z11.number().finite(), + author: z11.object({}).passthrough().optional(), + meta: z11.object({}).passthrough().optional(), + }); + legacyCompactionSchema = z11.object({ + coveredMessageIds: z11.array(z11.string()), + createdAtMs: z11.number().finite(), + id: z11.string(), + summary: z11.string(), + }); + legacyThreadStateSnapshotSchema = z11.object({ + conversation: z11 + .object({ + compactions: z11.array(legacyCompactionSchema).optional(), + messages: z11.array(legacyVisibleMessageSchema).optional(), + stats: z11 + .object({ updatedAtMs: z11.number().finite().optional() }) + .passthrough() + .optional(), + }) + .passthrough() + .optional(), + }); + }, +}); + +// ../../../../../../private/tmp/conversations-history-sql.ts +init_config(); +init_legacy_import(); +init_messages2(); + +// packages/junior/src/chat/task-execution/state.ts +init_coerce(); +init_config(); +init_destination(); +init_actor(); +init_adapter(); + +// packages/junior/src/chat/state/ttl.ts +var JUNIOR_THREAD_STATE_TTL_MS = 7 * 24 * 60 * 60 * 1e3; + +// packages/junior/src/chat/task-execution/state.ts +var CONVERSATION_PREFIX = "junior:conversation"; +var CONVERSATION_SCHEMA_VERSION = 1; +var CONVERSATION_ACTIVITY_INDEX_MAX_LENGTH = 1e4; +var CONVERSATION_INDEX_LOCK_TTL_MS = 1e4; +var CONVERSATION_INDEX_LOCK_WAIT_MS = 2e3; +var CONVERSATION_INDEX_LOCK_RETRY_MS = 25; +var CONVERSATION_MUTATION_LOCK_TTL_MS = 1e4; +var CONVERSATION_MUTATION_WAIT_MS = 1e4; +var CONVERSATION_MUTATION_RETRY_MS = 25; +var InvalidConversationRecordError = class extends Error { + constructor(conversationId) { + super(`Conversation record is invalid for ${conversationId}`); + this.name = "InvalidConversationRecordError"; + } +}; +var ConversationMutationFencedError = class extends Error { + constructor(conversationId) { + super( + `Conversation mutation lock was lost before write for ${conversationId}`, + ); + this.name = "ConversationMutationFencedError"; + } +}; +var CONVERSATION_BY_ACTIVITY_INDEX_KEY = `${CONVERSATION_PREFIX}:by-activity`; +var CONVERSATION_ACTIVE_INDEX_KEY = `${CONVERSATION_PREFIX}:active`; +function conversationKey(conversationId) { + return `${CONVERSATION_PREFIX}:${conversationId}`; +} +function indexLockKey(indexKey) { + return `${indexKey}:lock`; +} +function mutationLockKey(conversationId) { + return `${CONVERSATION_PREFIX}:mutation:${conversationId}`; +} +function now() { + return Date.now(); +} +function compareMessages(left, right) { + return ( + left.createdAtMs - right.createdAtMs || + left.receivedAtMs - right.receivedAtMs || + left.inboundMessageId.localeCompare(right.inboundMessageId) + ); +} +function compareIndexDescending(left, right) { + return ( + right.score - left.score || + right.conversationId.localeCompare(left.conversationId) + ); +} +function compareIndexAscending(left, right) { + return ( + left.score - right.score || + left.conversationId.localeCompare(right.conversationId) + ); +} +function uniqueStrings(values) { + return [...new Set(values)]; +} +function normalizeSource(value) { + if ( + value === "api" || + value === "internal" || + value === "local" || + value === "plugin" || + value === "resource_event" || + value === "scheduler" || + value === "slack" + ) { + return value; + } + return void 0; +} +function normalizeExecutionStatus(value) { + if ( + value === "awaiting_resume" || + value === "failed" || + value === "idle" || + value === "pending" || + value === "running" + ) { + return value; + } + return void 0; +} +function normalizeMetadata(value) { + if (!isRecord(value)) { + return void 0; + } + return value; +} +function normalizeInput(value) { + if (!isRecord(value)) { + return void 0; + } + const text7 = toOptionalString(value.text); + if (!text7) { + return void 0; + } + return { + text: text7, + authorId: toOptionalString(value.authorId), + attachments: Array.isArray(value.attachments) + ? [...value.attachments] + : void 0, + metadata: normalizeMetadata(value.metadata), + }; +} +function normalizeMessage(value) { + if (!isRecord(value)) { + return void 0; + } + const conversationId = toOptionalString(value.conversationId); + const inboundMessageId = toOptionalString(value.inboundMessageId); + const source = normalizeSource(value.source); + const destination = parseDestination(value.destination); + const createdAtMs = toOptionalNumber(value.createdAtMs); + const receivedAtMs = toOptionalNumber(value.receivedAtMs); + const input = normalizeInput(value.input); + if ( + !conversationId || + !destination || + !inboundMessageId || + !source || + typeof createdAtMs !== "number" || + typeof receivedAtMs !== "number" || + !input + ) { + return void 0; + } + return { + conversationId, + destination, + inboundMessageId, + source, + createdAtMs, + receivedAtMs, + input, + injectedAtMs: toOptionalNumber(value.injectedAtMs), + attemptCount: toOptionalNumber(value.attemptCount), + }; +} +function normalizeActor(value) { + return parseStoredSlackActor(value); +} +function normalizeLease(value) { + if (!isRecord(value)) { + return void 0; + } + const token = toOptionalString(value.token); + const acquiredAtMs = toOptionalNumber(value.acquiredAtMs); + const lastCheckInAtMs = toOptionalNumber(value.lastCheckInAtMs); + const expiresAtMs = toOptionalNumber(value.expiresAtMs); + if ( + !token || + typeof acquiredAtMs !== "number" || + typeof lastCheckInAtMs !== "number" || + typeof expiresAtMs !== "number" + ) { + return void 0; + } + return { + token, + acquiredAtMs, + lastCheckInAtMs, + expiresAtMs, + }; +} +function normalizeExecution(conversationId, value) { + if (!isRecord(value)) { + return void 0; + } + const status = normalizeExecutionStatus(value.status); + if (!status) { + return void 0; + } + const pendingMessages2 = Array.isArray(value.pendingMessages) + ? value.pendingMessages + .map(normalizeMessage) + .filter((message) => Boolean(message)) + .filter((message) => message.conversationId === conversationId) + .filter((message) => message.injectedAtMs === void 0) + .sort(compareMessages) + : []; + const inboundMessageIds = Array.isArray(value.inboundMessageIds) + ? uniqueStrings( + value.inboundMessageIds + .map((id) => (typeof id === "string" ? id : void 0)) + .filter((id) => Boolean(id)), + ) + : []; + const lease = normalizeLease(value.lease); + const normalizedStatus = + status === "idle" && lease + ? "running" + : status === "idle" && pendingMessages2.length > 0 + ? "pending" + : status; + return { + status: normalizedStatus, + inboundMessageIds: uniqueStrings([ + ...inboundMessageIds, + ...pendingMessages2.map((message) => message.inboundMessageId), + ]), + pendingCount: pendingMessages2.length, + pendingMessages: pendingMessages2, + lease, + lastCheckpointAtMs: toOptionalNumber(value.lastCheckpointAtMs), + lastEnqueuedAtMs: toOptionalNumber(value.lastEnqueuedAtMs), + runId: toOptionalString(value.runId), + updatedAtMs: toOptionalNumber(value.updatedAtMs), + }; +} +function normalizeConversation(conversationId, value) { + if (!isRecord(value) || value.schemaVersion !== CONVERSATION_SCHEMA_VERSION) { + return void 0; + } + const storedConversationId = toOptionalString(value.conversationId); + const createdAtMs = toOptionalNumber(value.createdAtMs); + const lastActivityAtMs = toOptionalNumber(value.lastActivityAtMs); + const updatedAtMs = toOptionalNumber(value.updatedAtMs); + const execution = normalizeExecution(conversationId, value.execution); + const destination = + value.destination === void 0 ? void 0 : parseDestination(value.destination); + if ( + storedConversationId !== conversationId || + typeof createdAtMs !== "number" || + typeof lastActivityAtMs !== "number" || + typeof updatedAtMs !== "number" || + !execution || + (value.destination !== void 0 && !destination) + ) { + return void 0; + } + if ( + execution.pendingMessages.length > 0 && + (!destination || + execution.pendingMessages.some( + (message) => !sameDestination(message.destination, destination), + )) + ) { + return void 0; + } + return { + schemaVersion: CONVERSATION_SCHEMA_VERSION, + conversationId, + createdAtMs, + lastActivityAtMs, + updatedAtMs, + execution, + ...(destination ? { destination } : {}), + ...(toOptionalString(value.title) + ? { title: toOptionalString(value.title) } + : {}), + ...(toOptionalString(value.channelName) + ? { channelName: toOptionalString(value.channelName) } + : {}), + ...(normalizeActor(value.actor) + ? { actor: normalizeActor(value.actor) } + : {}), + ...(normalizeSource(value.source) + ? { source: normalizeSource(value.source) } + : {}), + }; +} +function emptyConversation(args) { + return { + schemaVersion: CONVERSATION_SCHEMA_VERSION, + conversationId: args.conversationId, + createdAtMs: args.nowMs, + lastActivityAtMs: args.nowMs, + updatedAtMs: args.nowMs, + ...(args.destination ? { destination: args.destination } : {}), + ...(args.source ? { source: args.source } : {}), + execution: { + status: "idle", + inboundMessageIds: [], + pendingCount: 0, + pendingMessages: [], + updatedAtMs: args.nowMs, + }, + }; +} +function pendingMessages(conversation) { + return [...conversation.execution.pendingMessages].sort(compareMessages); +} +function isRunnableStatus(status) { + return status !== "failed" && status !== "idle"; +} +function hasRunnableWork(conversation) { + return ( + isRunnableStatus(conversation.execution.status) || + pendingMessages(conversation).length > 0 + ); +} +function executionWithPendingMessages(execution, pending) { + const pendingMessages2 = [...pending].sort(compareMessages); + const status = + execution.status === "idle" && execution.lease + ? "running" + : execution.status === "idle" && pendingMessages2.length > 0 + ? "pending" + : execution.status; + return { + ...execution, + status, + inboundMessageIds: uniqueStrings([ + ...execution.inboundMessageIds, + ...pendingMessages2.map((message) => message.inboundMessageId), + ]), + pendingMessages: pendingMessages2, + pendingCount: pendingMessages2.length, + }; +} +function withExecutionUpdate(conversation, execution, nowMs) { + return { + ...conversation, + updatedAtMs: nowMs, + execution: { + ...executionWithPendingMessages(execution, execution.pendingMessages), + updatedAtMs: nowMs, + }, + }; +} +async function getConnectedState(stateAdapter2) { + const state = stateAdapter2 ?? getStateAdapter(); + await state.connect(); + return state; +} +async function sleep(ms) { + await new Promise((resolve) => { + const timer = setTimeout(resolve, ms); + timer.unref?.(); + }); +} +async function withIndexLock(state, indexKey, callback) { + const startedAtMs = now(); + let lock; + while (true) { + lock = await state.acquireLock( + indexLockKey(indexKey), + CONVERSATION_INDEX_LOCK_TTL_MS, + ); + if (lock) { + break; + } + if (now() - startedAtMs >= CONVERSATION_INDEX_LOCK_WAIT_MS) { + throw new Error( + `Could not acquire conversation index lock for ${indexKey}`, + ); + } + await sleep(CONVERSATION_INDEX_LOCK_RETRY_MS); + } + try { + return await callback(); + } finally { + await state.releaseLock(lock); + } +} +function normalizeIndexEntry(value) { + if (!isRecord(value)) { + return void 0; + } + const conversationId = toOptionalString(value.conversationId); + const score = toOptionalNumber(value.score); + if (!conversationId || typeof score !== "number") { + return void 0; + } + return { conversationId, score }; +} +function uniqueIndexEntries(value) { + if (!Array.isArray(value)) { + return []; + } + const entries = /* @__PURE__ */ new Map(); + for (const item of value) { + const entry = normalizeIndexEntry(item); + if (!entry) { + continue; + } + const existing = entries.get(entry.conversationId); + if (!existing || entry.score > existing.score) { + entries.set(entry.conversationId, entry); + } + } + return [...entries.values()]; +} +function retainedIndexEntries(indexKey, entries) { + if (indexKey === CONVERSATION_BY_ACTIVITY_INDEX_KEY) { + return entries + .sort(compareIndexDescending) + .slice(0, CONVERSATION_ACTIVITY_INDEX_MAX_LENGTH); + } + if (indexKey === CONVERSATION_ACTIVE_INDEX_KEY) { + return entries.sort(compareIndexAscending); + } + throw new Error(`Unknown conversation index ${indexKey}`); +} +function redisIndexKey(indexKey) { + const prefix = getChatConfig().state.keyPrefix; + return [...(prefix ? [prefix] : []), indexKey].join(":"); +} +function parseRedisIndexEntries(values) { + if (!Array.isArray(values)) { + return []; + } + const entries = []; + for (let index6 = 0; index6 < values.length; index6 += 2) { + const conversationId = toOptionalString(values[index6]); + const score = + typeof values[index6 + 1] === "number" + ? values[index6 + 1] + : Number(values[index6 + 1]); + if (!conversationId || !Number.isFinite(score)) { + continue; + } + entries.push({ conversationId, score }); + } + return entries; +} +function redisConversationIndexStore(client) { + const upsertBoundedActivityScript = ` + redis.call("ZADD", KEYS[1], ARGV[1], ARGV[2]) + redis.call("PEXPIRE", KEYS[1], ARGV[3]) + local extra = redis.call("ZCARD", KEYS[1]) - tonumber(ARGV[4]) + if extra > 0 then + redis.call("ZREMRANGEBYRANK", KEYS[1], 0, extra - 1) + end + return 1 + `; + return { + async list(args) { + const key3 = redisIndexKey(args.indexKey); + const limit = args.limit; + const offset = Math.max(0, args.offset ?? 0); + if (limit === 0) { + return []; + } + const values = + args.scoreMax !== void 0 + ? await client.sendCommand([ + "ZRANGEBYSCORE", + key3, + "-inf", + String(args.scoreMax), + "WITHSCORES", + ...(limit !== void 0 || offset > 0 + ? ["LIMIT", String(offset), String(limit ?? 1e9)] + : []), + ]) + : await client.sendCommand([ + args.order === "asc" ? "ZRANGE" : "ZREVRANGE", + key3, + String(offset), + String(limit === void 0 ? -1 : offset + Math.max(0, limit - 1)), + "WITHSCORES", + ]); + return parseRedisIndexEntries(values); + }, + async remove(args) { + await client.sendCommand([ + "ZREM", + redisIndexKey(args.indexKey), + args.conversationId, + ]); + }, + async upsert(args) { + const key3 = redisIndexKey(args.indexKey); + if (args.indexKey === CONVERSATION_BY_ACTIVITY_INDEX_KEY) { + await client.sendCommand([ + "EVAL", + upsertBoundedActivityScript, + "1", + key3, + String(args.score), + args.conversationId, + String(JUNIOR_THREAD_STATE_TTL_MS), + String(CONVERSATION_ACTIVITY_INDEX_MAX_LENGTH), + ]); + return; + } + if (args.indexKey === CONVERSATION_ACTIVE_INDEX_KEY) { + await client.sendCommand([ + "ZADD", + key3, + String(args.score), + args.conversationId, + ]); + await client.sendCommand([ + "PEXPIRE", + key3, + String(JUNIOR_THREAD_STATE_TTL_MS), + ]); + return; + } + throw new Error(`Unknown conversation index ${args.indexKey}`); + }, + }; +} +function emulatedConversationIndexStore(state) { + const readIndex = async (indexKey) => + uniqueIndexEntries(await state.get(indexKey)); + const writeIndex = async (indexKey, entries) => { + await state.set(indexKey, entries, JUNIOR_THREAD_STATE_TTL_MS); + }; + return { + async list(args) { + const entries = (await readIndex(args.indexKey)) + .filter((entry) => + args.scoreMax === void 0 ? true : entry.score <= args.scoreMax, + ) + .sort( + args.order === "asc" ? compareIndexAscending : compareIndexDescending, + ); + const offset = Math.max(0, args.offset ?? 0); + return entries.slice( + offset, + args.limit === void 0 ? entries.length : offset + args.limit, + ); + }, + async remove(args) { + await withIndexLock(state, args.indexKey, async () => { + const entries = await readIndex(args.indexKey); + const next = entries.filter( + (entry) => entry.conversationId !== args.conversationId, + ); + if (next.length === entries.length) { + return; + } + await writeIndex(args.indexKey, next); + }); + }, + async upsert(args) { + await withIndexLock(state, args.indexKey, async () => { + const entries = await readIndex(args.indexKey); + const withoutCurrent = entries.filter( + (entry) => entry.conversationId !== args.conversationId, + ); + const next = retainedIndexEntries(args.indexKey, [ + ...withoutCurrent, + { conversationId: args.conversationId, score: args.score }, + ]); + await writeIndex(args.indexKey, next); + }); + }, + }; +} +async function getConversationIndexStore(state) { + const redisStateAdapter2 = await getDefaultRedisStateAdapterFor(state); + if (redisStateAdapter2) { + return redisConversationIndexStore(redisStateAdapter2.getClient()); + } + return emulatedConversationIndexStore(state); +} +async function upsertIndexEntry(args) { + const index6 = await getConversationIndexStore(args.state); + await index6.upsert({ + conversationId: args.conversationId, + indexKey: args.indexKey, + score: args.score, + }); +} +async function removeIndexEntry(args) { + const index6 = await getConversationIndexStore(args.state); + await index6.remove({ + conversationId: args.conversationId, + indexKey: args.indexKey, + }); +} +async function acquireMutationLock(state, conversationId) { + const startedAtMs = now(); + while (true) { + const lock = await state.acquireLock( + mutationLockKey(conversationId), + CONVERSATION_MUTATION_LOCK_TTL_MS, + ); + if (lock) { + return lock; + } + if (now() - startedAtMs >= CONVERSATION_MUTATION_WAIT_MS) { + throw new Error( + `Could not acquire conversation mutation lock for ${conversationId}`, + ); + } + await sleep(CONVERSATION_MUTATION_RETRY_MS); + } +} +async function withConversationMutation(args, callback) { + const state = await getConnectedState(args.state); + const lock = await acquireMutationLock(state, args.conversationId); + try { + return await callback(state, lock); + } finally { + await state.releaseLock(lock); + } +} +async function readConversation(state, conversationId) { + const raw = await state.get(conversationKey(conversationId)); + if (raw == null) { + return void 0; + } + const conversation = normalizeConversation(conversationId, raw); + if (!conversation) { + throw new InvalidConversationRecordError(conversationId); + } + return conversation; +} +async function writeConversation(state, lock, conversation) { + const execution = executionWithPendingMessages( + conversation.execution, + conversation.execution.pendingMessages, + ); + const next = { + ...conversation, + execution, + }; + const fenced = await state.extendLock( + lock, + CONVERSATION_MUTATION_LOCK_TTL_MS, + ); + if (!fenced) { + throw new ConversationMutationFencedError(next.conversationId); + } + await state.set( + conversationKey(next.conversationId), + next, + JUNIOR_THREAD_STATE_TTL_MS, + ); + await upsertIndexEntry({ + state, + indexKey: CONVERSATION_BY_ACTIVITY_INDEX_KEY, + conversationId: next.conversationId, + score: next.lastActivityAtMs, + }); + if (!hasRunnableWork(next)) { + await removeIndexEntry({ + state, + indexKey: CONVERSATION_ACTIVE_INDEX_KEY, + conversationId: next.conversationId, + }); + return; + } + await upsertIndexEntry({ + state, + indexKey: CONVERSATION_ACTIVE_INDEX_KEY, + conversationId: next.conversationId, + score: next.execution.updatedAtMs ?? next.updatedAtMs, + }); +} +function assertSameConversationDestination(args) { + if (!args.current || sameDestination(args.current, args.next)) { + return; + } + throw new Error( + `Conversation destination changed for ${args.conversationId}`, + ); +} +async function getConversation(args) { + const state = await getConnectedState(args.state); + return await readConversation(state, args.conversationId); +} +async function recordConversationActivity(args) { + const nowMs = args.nowMs ?? now(); + const activityAtMs = args.activityAtMs ?? nowMs; + await withConversationMutation(args, async (state, lock) => { + const existing = await readConversation(state, args.conversationId); + if (existing && args.destination) { + assertSameConversationDestination({ + conversationId: args.conversationId, + current: existing.destination, + next: args.destination, + }); + } + const current = + existing ?? + emptyConversation({ + conversationId: args.conversationId, + destination: args.destination, + nowMs, + source: args.source, + }); + await writeConversation(state, lock, { + ...current, + ...((current.destination ?? args.destination) + ? { destination: current.destination ?? args.destination } + : {}), + ...((current.source ?? args.source) + ? { source: current.source ?? args.source } + : {}), + ...((current.channelName ?? args.channelName) + ? { channelName: current.channelName ?? args.channelName } + : {}), + ...((current.actor ?? args.actor) + ? { actor: current.actor ?? args.actor } + : {}), + ...((current.title ?? args.title) + ? { title: current.title ?? args.title } + : {}), + lastActivityAtMs: Math.max(current.lastActivityAtMs, activityAtMs), + updatedAtMs: nowMs, + execution: executionWithPendingMessages( + current.execution, + current.execution.pendingMessages, + ), + }); + }); +} +async function recordConversationExecution(args) { + const nowMs = args.updatedAtMs; + await withConversationMutation(args, async (state, lock) => { + const existing = await readConversation(state, args.conversationId); + if (existing && args.destination) { + assertSameConversationDestination({ + conversationId: args.conversationId, + current: existing.destination, + next: args.destination, + }); + } + const current = + existing ?? + emptyConversation({ + conversationId: args.conversationId, + destination: args.destination, + nowMs, + source: args.source, + }); + await writeConversation( + state, + lock, + withExecutionUpdate( + { + ...current, + ...((current.destination ?? args.destination) + ? { destination: current.destination ?? args.destination } + : {}), + ...((current.source ?? args.source) + ? { source: current.source ?? args.source } + : {}), + ...((current.channelName ?? args.channelName) + ? { channelName: current.channelName ?? args.channelName } + : {}), + ...((current.actor ?? args.actor) + ? { actor: current.actor ?? args.actor } + : {}), + ...((current.title ?? args.title) + ? { title: current.title ?? args.title } + : {}), + createdAtMs: Math.min(current.createdAtMs, args.createdAtMs), + lastActivityAtMs: Math.max( + current.lastActivityAtMs, + args.lastActivityAtMs, + ), + }, + { + ...current.execution, + ...args.execution, + }, + nowMs, + ), + ); + }); +} +async function listConversationsByActivity(args = {}) { + const state = await getConnectedState(args.state); + const index6 = await getConversationIndexStore(state); + const entries = await index6.list({ + indexKey: CONVERSATION_BY_ACTIVITY_INDEX_KEY, + limit: args.limit ?? CONVERSATION_ACTIVITY_INDEX_MAX_LENGTH, + offset: args.offset, + order: "desc", + }); + const conversations = []; + for (const entry of entries) { + try { + const conversation = await readConversation(state, entry.conversationId); + if (conversation) { + conversations.push(conversation); + } + } catch (error) { + if (!(error instanceof InvalidConversationRecordError)) { + throw error; + } + await removeIndexEntry({ + state, + indexKey: CONVERSATION_BY_ACTIVITY_INDEX_KEY, + conversationId: entry.conversationId, + }); + } + } + return conversations; +} + +// packages/junior/src/chat/conversations/state.ts +function createStateConversationStore(state) { + return { + get: (args) => getConversation({ ...args, state }), + // Task-execution state has no destination records, so visibility is never + // source-confirmed here and cross-context reads fail closed to private. + getDestinationVisibility: async () => void 0, + // Child conversations are a SQL-only concept and never use this legacy + // metadata path. + ensureChildConversation: async () => void 0, + recordActivity: (args) => recordConversationActivity({ ...args, state }), + recordExecution: (args) => recordConversationExecution({ ...args, state }), + listByActivity: (args) => listConversationsByActivity({ ...args, state }), + }; +} + +// ../../../../../../private/tmp/conversations-history-sql.ts +init_executor(); +var HISTORY_BACKFILL_LIMIT = 1e4; +async function migrateConversationHistoryToSql(context, options = {}) { + const source = createStateConversationStore(context.stateAdapter); + const sessionLogStore = options.sessionLogStore ?? { + read: async ({ conversationId }) => { + const entries = await context.stateAdapter.get( + `${AGENT_SESSION_LOG_PREFIX}:${conversationId}`, + ); + return Array.isArray(entries) ? entries.map(decode) : []; + }, + append: async () => {}, + }; + let executor = options.executor; + let closeExecutor; + if (!executor) { + const { sql: sql11 } = getChatConfig(); + executor = createJuniorSqlExecutor({ + connectionString: sql11.databaseUrl, + driver: sql11.driver, + }); + closeExecutor = () => executor.close(); + } + const limit = Math.max(1, options.batchSize ?? HISTORY_BACKFILL_LIMIT); + try { + const messageStore = createSqlConversationMessageStore(executor); + const conversations = await source.listByActivity({ limit }); + let migrated = 0; + let existing = 0; + for (const conversation of conversations) { + const snapshot = options.loadVisibleMessages + ? void 0 + : await (async () => { + const raw = await context.stateAdapter.get( + `thread-state:${conversation.conversationId}`, + ); + if (!raw) { + return { compactions: [], messages: [] }; + } + const stored = + legacyThreadStateSnapshotSchema.parse(raw).conversation; + return { + compactions: stored?.compactions ?? [], + messages: stored?.messages ?? [], + }; + })(); + const result = await importConversationFromLegacy( + conversation.conversationId, + { + executor, + messageStore, + conversationRecord: conversation, + sessionLogStore, + ...(options.advisorSessionStore + ? { advisorSessionStore: options.advisorSessionStore } + : {}), + loadVisibleMessages: + options.loadVisibleMessages ?? (async () => snapshot.messages), + legacyCompactions: options.legacyCompactions ?? snapshot.compactions, + }, + ); + if (result.imported) { + migrated += 1; + } else { + existing += 1; + } + } + return { + existing, + migrated, + missing: 0, + scanned: conversations.length, + }; + } finally { + await closeExecutor?.(); + } +} + +// ../../../../../../private/tmp/0007_conversation_history_to_sql-entry.ts +var migration = { + apiVersion: 1, + async up(context) { + return await migrateConversationHistoryToSql( + { stateAdapter: context.state, io: { info: context.log } }, + { executor: context.database }, + ); + }, +}; +var conversation_history_to_sql_entry_default = migration; +export { + conversation_history_to_sql_entry_default as default, + migrateConversationHistoryToSql, +}; diff --git a/packages/junior/migrations/README.md b/packages/junior/migrations/README.md index a0b5c2aea..b80f7c67a 100644 --- a/packages/junior/migrations/README.md +++ b/packages/junior/migrations/README.md @@ -1,14 +1,31 @@ # SQL migrations for `@sentry/junior` -This is a standard Drizzle migration folder. `drizzle-kit generate` owns each -SQL file, snapshot, and journal entry; `junior upgrade` applies the folder with -Drizzle ORM's migrator before any data backfills run. +This migration folder extends Drizzle Kit's journal with self-contained +TypeScript data migrations. Drizzle Kit owns numbering, snapshots, and journal +entries; `junior upgrade` executes each entry as either `.sql` or +`.ts` through `@sentry/junior-migrations`. 1. Edit the schema under `src/db/schema/`. 2. Run `pnpm --filter @sentry/junior db:generate --name `. 3. Commit the generated SQL file and `meta/` changes together. +For a data migration, run +`pnpm --filter @sentry/junior db:generate:data --name `. +TypeScript migrations target the versioned migration capability API and must +not import Junior runtime internals or current feature schemas. Their complete +implementation remains in the migration file and uses only the stable +database, state, Redis, and progress capabilities supplied by the runner. The +host database adapter owns connections, drivers, transactions, and locks so +those infrastructure modules are not frozen into every migration. + +Reusable infrastructure belongs in the append-only +`@sentry/junior/migration-helpers/v1` export. It may provide stable parsers, +stores, adapter projections, and key resolution, but must not implement a +specific data migration. The journal entry remains the only owner of one-off +record transformations. + The `0000_initial.sql` baseline represents the schema already deployed by the pre-Drizzle Junior migration runner. During upgrade, existing installations adopt that baseline once; new installations execute it normally. All later -migrations are applied by Drizzle in journal order. +migrations are applied by Junior in journal order. Drizzle ORM's stock +`migrate()` function is not compatible with TypeScript entries in this folder. diff --git a/packages/junior/migrations/meta/0004_snapshot.json b/packages/junior/migrations/meta/0004_snapshot.json new file mode 100644 index 000000000..ef06e06c4 --- /dev/null +++ b/packages/junior/migrations/meta/0004_snapshot.json @@ -0,0 +1,993 @@ +{ + "id": "8d217382-e047-4c68-95c4-b8568a5298e5", + "prevId": "3668b705-7991-4e7e-8fb2-6e4ef8147aac", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.junior_agent_steps": { + "name": "junior_agent_steps", + "schema": "", + "columns": { + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "context_epoch": { + "name": "context_epoch", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "junior_agent_steps_epoch_idx": { + "name": "junior_agent_steps_epoch_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "context_epoch", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "seq", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "junior_agent_steps_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_agent_steps_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_agent_steps", + "columnsFrom": ["conversation_id"], + "tableTo": "junior_conversations", + "columnsTo": ["conversation_id"], + "onUpdate": "no action", + "onDelete": "no action" + } + }, + "compositePrimaryKeys": { + "junior_agent_steps_conversation_id_seq_pk": { + "name": "junior_agent_steps_conversation_id_seq_pk", + "columns": ["conversation_id", "seq"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_conversation_messages": { + "name": "junior_conversation_messages", + "schema": "", + "columns": { + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "author_identity_id": { + "name": "author_identity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "meta": { + "name": "meta", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "replied_at": { + "name": "replied_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "junior_conversation_messages_activity_idx": { + "name": "junior_conversation_messages_activity_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "junior_conversation_messages_search_idx": { + "name": "junior_conversation_messages_search_idx", + "columns": [ + { + "expression": "to_tsvector('english', \"text\")", + "isExpression": true, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "gin", + "concurrently": false + } + }, + "foreignKeys": { + "junior_conversation_messages_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_conversation_messages_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_conversation_messages", + "columnsFrom": ["conversation_id"], + "tableTo": "junior_conversations", + "columnsTo": ["conversation_id"], + "onUpdate": "no action", + "onDelete": "no action" + }, + "junior_conversation_messages_author_identity_id_junior_identities_id_fk": { + "name": "junior_conversation_messages_author_identity_id_junior_identities_id_fk", + "tableFrom": "junior_conversation_messages", + "columnsFrom": ["author_identity_id"], + "tableTo": "junior_identities", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "no action" + } + }, + "compositePrimaryKeys": { + "junior_conversation_messages_conversation_id_message_id_pk": { + "name": "junior_conversation_messages_conversation_id_message_id_pk", + "columns": ["conversation_id", "message_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_conversations": { + "name": "junior_conversations", + "schema": "", + "columns": { + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "schema_version": { + "name": "schema_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "origin_type": { + "name": "origin_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "origin_id": { + "name": "origin_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "origin_run_id": { + "name": "origin_run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "destination_id": { + "name": "destination_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "destination_json": { + "name": "destination_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "actor_identity_id": { + "name": "actor_identity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "creator_identity_id": { + "name": "creator_identity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credential_subject_identity_id": { + "name": "credential_subject_identity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_json": { + "name": "actor_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "channel_name": { + "name": "channel_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "last_activity_at": { + "name": "last_activity_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "execution_updated_at": { + "name": "execution_updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "execution_status": { + "name": "execution_status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_checkpoint_at": { + "name": "last_checkpoint_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_enqueued_at": { + "name": "last_enqueued_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "parent_conversation_id": { + "name": "parent_conversation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "transcript_purged_at": { + "name": "transcript_purged_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "usage_json": { + "name": "usage_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "metric_run_id": { + "name": "metric_run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "execution_duration_ms": { + "name": "execution_duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "execution_usage_json": { + "name": "execution_usage_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "junior_conversations_last_activity_idx": { + "name": "junior_conversations_last_activity_idx", + "columns": [ + { + "expression": "last_activity_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "junior_conversations_active_idx": { + "name": "junior_conversations_active_idx", + "columns": [ + { + "expression": "coalesce(\"execution_updated_at\", \"updated_at\")", + "isExpression": true, + "asc": true, + "nulls": "last" + }, + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "where": "\"junior_conversations\".\"execution_status\" <> 'idle'", + "concurrently": false + }, + "junior_conversations_destination_activity_idx": { + "name": "junior_conversations_destination_activity_idx", + "columns": [ + { + "expression": "destination_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_activity_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "junior_conversations_actor_activity_idx": { + "name": "junior_conversations_actor_activity_idx", + "columns": [ + { + "expression": "actor_identity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_activity_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "junior_conversations_origin_idx": { + "name": "junior_conversations_origin_idx", + "columns": [ + { + "expression": "origin_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "origin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_activity_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "junior_conversations_parent_idx": { + "name": "junior_conversations_parent_idx", + "columns": [ + { + "expression": "parent_conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "junior_conversations_destination_id_junior_destinations_id_fk": { + "name": "junior_conversations_destination_id_junior_destinations_id_fk", + "tableFrom": "junior_conversations", + "columnsFrom": ["destination_id"], + "tableTo": "junior_destinations", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "no action" + }, + "junior_conversations_actor_identity_id_junior_identities_id_fk": { + "name": "junior_conversations_actor_identity_id_junior_identities_id_fk", + "tableFrom": "junior_conversations", + "columnsFrom": ["actor_identity_id"], + "tableTo": "junior_identities", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "no action" + }, + "junior_conversations_creator_identity_id_junior_identities_id_fk": { + "name": "junior_conversations_creator_identity_id_junior_identities_id_fk", + "tableFrom": "junior_conversations", + "columnsFrom": ["creator_identity_id"], + "tableTo": "junior_identities", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "no action" + }, + "junior_conversations_credential_subject_identity_id_junior_identities_id_fk": { + "name": "junior_conversations_credential_subject_identity_id_junior_identities_id_fk", + "tableFrom": "junior_conversations", + "columnsFrom": ["credential_subject_identity_id"], + "tableTo": "junior_identities", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "no action" + }, + "junior_conversations_parent_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_conversations_parent_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_conversations", + "columnsFrom": ["parent_conversation_id"], + "tableTo": "junior_conversations", + "columnsTo": ["conversation_id"], + "onUpdate": "no action", + "onDelete": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_destinations": { + "name": "junior_destinations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_tenant_id": { + "name": "provider_tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "provider_destination_id": { + "name": "provider_destination_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_destination_id": { + "name": "parent_destination_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "visibility": { + "name": "visibility", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'unknown'" + }, + "metadata_json": { + "name": "metadata_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "junior_destinations_provider_destination_uidx": { + "name": "junior_destinations_provider_destination_uidx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_destination_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + }, + "junior_destinations_provider_kind_idx": { + "name": "junior_destinations_provider_kind_idx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_identities": { + "name": "junior_identities", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_tenant_id": { + "name": "provider_tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "provider_subject_id": { + "name": "provider_subject_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "handle": { + "name": "handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata_json": { + "name": "metadata_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_normalized": { + "name": "email_normalized", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "junior_identities_provider_subject_uidx": { + "name": "junior_identities_provider_subject_uidx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_subject_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + }, + "junior_identities_user_idx": { + "name": "junior_identities_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "junior_identities_verified_email_idx": { + "name": "junior_identities_verified_email_idx", + "columns": [ + { + "expression": "email_normalized", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "where": "\"junior_identities\".\"email_verified\" = true AND \"junior_identities\".\"email_normalized\" IS NOT NULL", + "concurrently": false + }, + "junior_identities_kind_provider_idx": { + "name": "junior_identities_kind_provider_idx", + "columns": [ + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "junior_identities_user_id_junior_users_id_fk": { + "name": "junior_identities_user_id_junior_users_id_fk", + "tableFrom": "junior_identities", + "columnsFrom": ["user_id"], + "tableTo": "junior_users", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_users": { + "name": "junior_users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "primary_email": { + "name": "primary_email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "primary_email_normalized": { + "name": "primary_email_normalized", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "junior_users_primary_email_normalized_uidx": { + "name": "junior_users_primary_email_normalized_uidx", + "columns": [ + { + "expression": "primary_email_normalized", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "views": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/packages/junior/migrations/meta/0005_snapshot.json b/packages/junior/migrations/meta/0005_snapshot.json new file mode 100644 index 000000000..af4d384b2 --- /dev/null +++ b/packages/junior/migrations/meta/0005_snapshot.json @@ -0,0 +1,993 @@ +{ + "id": "15906b35-8fea-417f-a9f1-a008a884418d", + "prevId": "8d217382-e047-4c68-95c4-b8568a5298e5", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.junior_agent_steps": { + "name": "junior_agent_steps", + "schema": "", + "columns": { + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "context_epoch": { + "name": "context_epoch", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "junior_agent_steps_epoch_idx": { + "name": "junior_agent_steps_epoch_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "context_epoch", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "seq", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "junior_agent_steps_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_agent_steps_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_agent_steps", + "columnsFrom": ["conversation_id"], + "tableTo": "junior_conversations", + "columnsTo": ["conversation_id"], + "onUpdate": "no action", + "onDelete": "no action" + } + }, + "compositePrimaryKeys": { + "junior_agent_steps_conversation_id_seq_pk": { + "name": "junior_agent_steps_conversation_id_seq_pk", + "columns": ["conversation_id", "seq"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_conversation_messages": { + "name": "junior_conversation_messages", + "schema": "", + "columns": { + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "author_identity_id": { + "name": "author_identity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "meta": { + "name": "meta", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "replied_at": { + "name": "replied_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "junior_conversation_messages_activity_idx": { + "name": "junior_conversation_messages_activity_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "junior_conversation_messages_search_idx": { + "name": "junior_conversation_messages_search_idx", + "columns": [ + { + "expression": "to_tsvector('english', \"text\")", + "isExpression": true, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "gin", + "concurrently": false + } + }, + "foreignKeys": { + "junior_conversation_messages_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_conversation_messages_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_conversation_messages", + "columnsFrom": ["conversation_id"], + "tableTo": "junior_conversations", + "columnsTo": ["conversation_id"], + "onUpdate": "no action", + "onDelete": "no action" + }, + "junior_conversation_messages_author_identity_id_junior_identities_id_fk": { + "name": "junior_conversation_messages_author_identity_id_junior_identities_id_fk", + "tableFrom": "junior_conversation_messages", + "columnsFrom": ["author_identity_id"], + "tableTo": "junior_identities", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "no action" + } + }, + "compositePrimaryKeys": { + "junior_conversation_messages_conversation_id_message_id_pk": { + "name": "junior_conversation_messages_conversation_id_message_id_pk", + "columns": ["conversation_id", "message_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_conversations": { + "name": "junior_conversations", + "schema": "", + "columns": { + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "schema_version": { + "name": "schema_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "origin_type": { + "name": "origin_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "origin_id": { + "name": "origin_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "origin_run_id": { + "name": "origin_run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "destination_id": { + "name": "destination_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "destination_json": { + "name": "destination_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "actor_identity_id": { + "name": "actor_identity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "creator_identity_id": { + "name": "creator_identity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credential_subject_identity_id": { + "name": "credential_subject_identity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_json": { + "name": "actor_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "channel_name": { + "name": "channel_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "last_activity_at": { + "name": "last_activity_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "execution_updated_at": { + "name": "execution_updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "execution_status": { + "name": "execution_status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_checkpoint_at": { + "name": "last_checkpoint_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_enqueued_at": { + "name": "last_enqueued_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "parent_conversation_id": { + "name": "parent_conversation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "transcript_purged_at": { + "name": "transcript_purged_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "usage_json": { + "name": "usage_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "metric_run_id": { + "name": "metric_run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "execution_duration_ms": { + "name": "execution_duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "execution_usage_json": { + "name": "execution_usage_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "junior_conversations_last_activity_idx": { + "name": "junior_conversations_last_activity_idx", + "columns": [ + { + "expression": "last_activity_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "junior_conversations_active_idx": { + "name": "junior_conversations_active_idx", + "columns": [ + { + "expression": "coalesce(\"execution_updated_at\", \"updated_at\")", + "isExpression": true, + "asc": true, + "nulls": "last" + }, + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "where": "\"junior_conversations\".\"execution_status\" <> 'idle'", + "concurrently": false + }, + "junior_conversations_destination_activity_idx": { + "name": "junior_conversations_destination_activity_idx", + "columns": [ + { + "expression": "destination_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_activity_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "junior_conversations_actor_activity_idx": { + "name": "junior_conversations_actor_activity_idx", + "columns": [ + { + "expression": "actor_identity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_activity_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "junior_conversations_origin_idx": { + "name": "junior_conversations_origin_idx", + "columns": [ + { + "expression": "origin_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "origin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_activity_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "junior_conversations_parent_idx": { + "name": "junior_conversations_parent_idx", + "columns": [ + { + "expression": "parent_conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "junior_conversations_destination_id_junior_destinations_id_fk": { + "name": "junior_conversations_destination_id_junior_destinations_id_fk", + "tableFrom": "junior_conversations", + "columnsFrom": ["destination_id"], + "tableTo": "junior_destinations", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "no action" + }, + "junior_conversations_actor_identity_id_junior_identities_id_fk": { + "name": "junior_conversations_actor_identity_id_junior_identities_id_fk", + "tableFrom": "junior_conversations", + "columnsFrom": ["actor_identity_id"], + "tableTo": "junior_identities", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "no action" + }, + "junior_conversations_creator_identity_id_junior_identities_id_fk": { + "name": "junior_conversations_creator_identity_id_junior_identities_id_fk", + "tableFrom": "junior_conversations", + "columnsFrom": ["creator_identity_id"], + "tableTo": "junior_identities", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "no action" + }, + "junior_conversations_credential_subject_identity_id_junior_identities_id_fk": { + "name": "junior_conversations_credential_subject_identity_id_junior_identities_id_fk", + "tableFrom": "junior_conversations", + "columnsFrom": ["credential_subject_identity_id"], + "tableTo": "junior_identities", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "no action" + }, + "junior_conversations_parent_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_conversations_parent_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_conversations", + "columnsFrom": ["parent_conversation_id"], + "tableTo": "junior_conversations", + "columnsTo": ["conversation_id"], + "onUpdate": "no action", + "onDelete": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_destinations": { + "name": "junior_destinations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_tenant_id": { + "name": "provider_tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "provider_destination_id": { + "name": "provider_destination_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_destination_id": { + "name": "parent_destination_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "visibility": { + "name": "visibility", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'unknown'" + }, + "metadata_json": { + "name": "metadata_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "junior_destinations_provider_destination_uidx": { + "name": "junior_destinations_provider_destination_uidx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_destination_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + }, + "junior_destinations_provider_kind_idx": { + "name": "junior_destinations_provider_kind_idx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_identities": { + "name": "junior_identities", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_tenant_id": { + "name": "provider_tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "provider_subject_id": { + "name": "provider_subject_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "handle": { + "name": "handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata_json": { + "name": "metadata_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_normalized": { + "name": "email_normalized", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "junior_identities_provider_subject_uidx": { + "name": "junior_identities_provider_subject_uidx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_subject_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + }, + "junior_identities_user_idx": { + "name": "junior_identities_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "junior_identities_verified_email_idx": { + "name": "junior_identities_verified_email_idx", + "columns": [ + { + "expression": "email_normalized", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "where": "\"junior_identities\".\"email_verified\" = true AND \"junior_identities\".\"email_normalized\" IS NOT NULL", + "concurrently": false + }, + "junior_identities_kind_provider_idx": { + "name": "junior_identities_kind_provider_idx", + "columns": [ + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "junior_identities_user_id_junior_users_id_fk": { + "name": "junior_identities_user_id_junior_users_id_fk", + "tableFrom": "junior_identities", + "columnsFrom": ["user_id"], + "tableTo": "junior_users", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_users": { + "name": "junior_users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "primary_email": { + "name": "primary_email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "primary_email_normalized": { + "name": "primary_email_normalized", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "junior_users_primary_email_normalized_uidx": { + "name": "junior_users_primary_email_normalized_uidx", + "columns": [ + { + "expression": "primary_email_normalized", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "views": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/packages/junior/migrations/meta/0006_snapshot.json b/packages/junior/migrations/meta/0006_snapshot.json new file mode 100644 index 000000000..8bba60b00 --- /dev/null +++ b/packages/junior/migrations/meta/0006_snapshot.json @@ -0,0 +1,993 @@ +{ + "id": "703cec39-e95b-4e04-98fc-6af9d70cb157", + "prevId": "15906b35-8fea-417f-a9f1-a008a884418d", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.junior_agent_steps": { + "name": "junior_agent_steps", + "schema": "", + "columns": { + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "context_epoch": { + "name": "context_epoch", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "junior_agent_steps_epoch_idx": { + "name": "junior_agent_steps_epoch_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "context_epoch", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "seq", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "junior_agent_steps_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_agent_steps_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_agent_steps", + "columnsFrom": ["conversation_id"], + "tableTo": "junior_conversations", + "columnsTo": ["conversation_id"], + "onUpdate": "no action", + "onDelete": "no action" + } + }, + "compositePrimaryKeys": { + "junior_agent_steps_conversation_id_seq_pk": { + "name": "junior_agent_steps_conversation_id_seq_pk", + "columns": ["conversation_id", "seq"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_conversation_messages": { + "name": "junior_conversation_messages", + "schema": "", + "columns": { + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "author_identity_id": { + "name": "author_identity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "meta": { + "name": "meta", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "replied_at": { + "name": "replied_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "junior_conversation_messages_activity_idx": { + "name": "junior_conversation_messages_activity_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "junior_conversation_messages_search_idx": { + "name": "junior_conversation_messages_search_idx", + "columns": [ + { + "expression": "to_tsvector('english', \"text\")", + "isExpression": true, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "gin", + "concurrently": false + } + }, + "foreignKeys": { + "junior_conversation_messages_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_conversation_messages_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_conversation_messages", + "columnsFrom": ["conversation_id"], + "tableTo": "junior_conversations", + "columnsTo": ["conversation_id"], + "onUpdate": "no action", + "onDelete": "no action" + }, + "junior_conversation_messages_author_identity_id_junior_identities_id_fk": { + "name": "junior_conversation_messages_author_identity_id_junior_identities_id_fk", + "tableFrom": "junior_conversation_messages", + "columnsFrom": ["author_identity_id"], + "tableTo": "junior_identities", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "no action" + } + }, + "compositePrimaryKeys": { + "junior_conversation_messages_conversation_id_message_id_pk": { + "name": "junior_conversation_messages_conversation_id_message_id_pk", + "columns": ["conversation_id", "message_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_conversations": { + "name": "junior_conversations", + "schema": "", + "columns": { + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "schema_version": { + "name": "schema_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "origin_type": { + "name": "origin_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "origin_id": { + "name": "origin_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "origin_run_id": { + "name": "origin_run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "destination_id": { + "name": "destination_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "destination_json": { + "name": "destination_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "actor_identity_id": { + "name": "actor_identity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "creator_identity_id": { + "name": "creator_identity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credential_subject_identity_id": { + "name": "credential_subject_identity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_json": { + "name": "actor_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "channel_name": { + "name": "channel_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "last_activity_at": { + "name": "last_activity_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "execution_updated_at": { + "name": "execution_updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "execution_status": { + "name": "execution_status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_checkpoint_at": { + "name": "last_checkpoint_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_enqueued_at": { + "name": "last_enqueued_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "parent_conversation_id": { + "name": "parent_conversation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "transcript_purged_at": { + "name": "transcript_purged_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "usage_json": { + "name": "usage_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "metric_run_id": { + "name": "metric_run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "execution_duration_ms": { + "name": "execution_duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "execution_usage_json": { + "name": "execution_usage_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "junior_conversations_last_activity_idx": { + "name": "junior_conversations_last_activity_idx", + "columns": [ + { + "expression": "last_activity_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "junior_conversations_active_idx": { + "name": "junior_conversations_active_idx", + "columns": [ + { + "expression": "coalesce(\"execution_updated_at\", \"updated_at\")", + "isExpression": true, + "asc": true, + "nulls": "last" + }, + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "where": "\"junior_conversations\".\"execution_status\" <> 'idle'", + "concurrently": false + }, + "junior_conversations_destination_activity_idx": { + "name": "junior_conversations_destination_activity_idx", + "columns": [ + { + "expression": "destination_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_activity_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "junior_conversations_actor_activity_idx": { + "name": "junior_conversations_actor_activity_idx", + "columns": [ + { + "expression": "actor_identity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_activity_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "junior_conversations_origin_idx": { + "name": "junior_conversations_origin_idx", + "columns": [ + { + "expression": "origin_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "origin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_activity_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "junior_conversations_parent_idx": { + "name": "junior_conversations_parent_idx", + "columns": [ + { + "expression": "parent_conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "junior_conversations_destination_id_junior_destinations_id_fk": { + "name": "junior_conversations_destination_id_junior_destinations_id_fk", + "tableFrom": "junior_conversations", + "columnsFrom": ["destination_id"], + "tableTo": "junior_destinations", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "no action" + }, + "junior_conversations_actor_identity_id_junior_identities_id_fk": { + "name": "junior_conversations_actor_identity_id_junior_identities_id_fk", + "tableFrom": "junior_conversations", + "columnsFrom": ["actor_identity_id"], + "tableTo": "junior_identities", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "no action" + }, + "junior_conversations_creator_identity_id_junior_identities_id_fk": { + "name": "junior_conversations_creator_identity_id_junior_identities_id_fk", + "tableFrom": "junior_conversations", + "columnsFrom": ["creator_identity_id"], + "tableTo": "junior_identities", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "no action" + }, + "junior_conversations_credential_subject_identity_id_junior_identities_id_fk": { + "name": "junior_conversations_credential_subject_identity_id_junior_identities_id_fk", + "tableFrom": "junior_conversations", + "columnsFrom": ["credential_subject_identity_id"], + "tableTo": "junior_identities", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "no action" + }, + "junior_conversations_parent_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_conversations_parent_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_conversations", + "columnsFrom": ["parent_conversation_id"], + "tableTo": "junior_conversations", + "columnsTo": ["conversation_id"], + "onUpdate": "no action", + "onDelete": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_destinations": { + "name": "junior_destinations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_tenant_id": { + "name": "provider_tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "provider_destination_id": { + "name": "provider_destination_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_destination_id": { + "name": "parent_destination_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "visibility": { + "name": "visibility", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'unknown'" + }, + "metadata_json": { + "name": "metadata_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "junior_destinations_provider_destination_uidx": { + "name": "junior_destinations_provider_destination_uidx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_destination_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + }, + "junior_destinations_provider_kind_idx": { + "name": "junior_destinations_provider_kind_idx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_identities": { + "name": "junior_identities", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_tenant_id": { + "name": "provider_tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "provider_subject_id": { + "name": "provider_subject_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "handle": { + "name": "handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata_json": { + "name": "metadata_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_normalized": { + "name": "email_normalized", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "junior_identities_provider_subject_uidx": { + "name": "junior_identities_provider_subject_uidx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_subject_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + }, + "junior_identities_user_idx": { + "name": "junior_identities_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "junior_identities_verified_email_idx": { + "name": "junior_identities_verified_email_idx", + "columns": [ + { + "expression": "email_normalized", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "where": "\"junior_identities\".\"email_verified\" = true AND \"junior_identities\".\"email_normalized\" IS NOT NULL", + "concurrently": false + }, + "junior_identities_kind_provider_idx": { + "name": "junior_identities_kind_provider_idx", + "columns": [ + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "junior_identities_user_id_junior_users_id_fk": { + "name": "junior_identities_user_id_junior_users_id_fk", + "tableFrom": "junior_identities", + "columnsFrom": ["user_id"], + "tableTo": "junior_users", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_users": { + "name": "junior_users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "primary_email": { + "name": "primary_email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "primary_email_normalized": { + "name": "primary_email_normalized", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "junior_users_primary_email_normalized_uidx": { + "name": "junior_users_primary_email_normalized_uidx", + "columns": [ + { + "expression": "primary_email_normalized", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "views": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/packages/junior/migrations/meta/0007_snapshot.json b/packages/junior/migrations/meta/0007_snapshot.json new file mode 100644 index 000000000..9f8fb4170 --- /dev/null +++ b/packages/junior/migrations/meta/0007_snapshot.json @@ -0,0 +1,993 @@ +{ + "id": "ba68decd-aae8-46ce-a1e5-db2b7dc111db", + "prevId": "703cec39-e95b-4e04-98fc-6af9d70cb157", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.junior_agent_steps": { + "name": "junior_agent_steps", + "schema": "", + "columns": { + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "context_epoch": { + "name": "context_epoch", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "junior_agent_steps_epoch_idx": { + "name": "junior_agent_steps_epoch_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "context_epoch", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "seq", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "junior_agent_steps_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_agent_steps_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_agent_steps", + "columnsFrom": ["conversation_id"], + "tableTo": "junior_conversations", + "columnsTo": ["conversation_id"], + "onUpdate": "no action", + "onDelete": "no action" + } + }, + "compositePrimaryKeys": { + "junior_agent_steps_conversation_id_seq_pk": { + "name": "junior_agent_steps_conversation_id_seq_pk", + "columns": ["conversation_id", "seq"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_conversation_messages": { + "name": "junior_conversation_messages", + "schema": "", + "columns": { + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "author_identity_id": { + "name": "author_identity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "meta": { + "name": "meta", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "replied_at": { + "name": "replied_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "junior_conversation_messages_activity_idx": { + "name": "junior_conversation_messages_activity_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "junior_conversation_messages_search_idx": { + "name": "junior_conversation_messages_search_idx", + "columns": [ + { + "expression": "to_tsvector('english', \"text\")", + "isExpression": true, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "gin", + "concurrently": false + } + }, + "foreignKeys": { + "junior_conversation_messages_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_conversation_messages_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_conversation_messages", + "columnsFrom": ["conversation_id"], + "tableTo": "junior_conversations", + "columnsTo": ["conversation_id"], + "onUpdate": "no action", + "onDelete": "no action" + }, + "junior_conversation_messages_author_identity_id_junior_identities_id_fk": { + "name": "junior_conversation_messages_author_identity_id_junior_identities_id_fk", + "tableFrom": "junior_conversation_messages", + "columnsFrom": ["author_identity_id"], + "tableTo": "junior_identities", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "no action" + } + }, + "compositePrimaryKeys": { + "junior_conversation_messages_conversation_id_message_id_pk": { + "name": "junior_conversation_messages_conversation_id_message_id_pk", + "columns": ["conversation_id", "message_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_conversations": { + "name": "junior_conversations", + "schema": "", + "columns": { + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "schema_version": { + "name": "schema_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "origin_type": { + "name": "origin_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "origin_id": { + "name": "origin_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "origin_run_id": { + "name": "origin_run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "destination_id": { + "name": "destination_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "destination_json": { + "name": "destination_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "actor_identity_id": { + "name": "actor_identity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "creator_identity_id": { + "name": "creator_identity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credential_subject_identity_id": { + "name": "credential_subject_identity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_json": { + "name": "actor_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "channel_name": { + "name": "channel_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "last_activity_at": { + "name": "last_activity_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "execution_updated_at": { + "name": "execution_updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "execution_status": { + "name": "execution_status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_checkpoint_at": { + "name": "last_checkpoint_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_enqueued_at": { + "name": "last_enqueued_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "parent_conversation_id": { + "name": "parent_conversation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "transcript_purged_at": { + "name": "transcript_purged_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "usage_json": { + "name": "usage_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "metric_run_id": { + "name": "metric_run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "execution_duration_ms": { + "name": "execution_duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "execution_usage_json": { + "name": "execution_usage_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "junior_conversations_last_activity_idx": { + "name": "junior_conversations_last_activity_idx", + "columns": [ + { + "expression": "last_activity_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "junior_conversations_active_idx": { + "name": "junior_conversations_active_idx", + "columns": [ + { + "expression": "coalesce(\"execution_updated_at\", \"updated_at\")", + "isExpression": true, + "asc": true, + "nulls": "last" + }, + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "where": "\"junior_conversations\".\"execution_status\" <> 'idle'", + "concurrently": false + }, + "junior_conversations_destination_activity_idx": { + "name": "junior_conversations_destination_activity_idx", + "columns": [ + { + "expression": "destination_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_activity_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "junior_conversations_actor_activity_idx": { + "name": "junior_conversations_actor_activity_idx", + "columns": [ + { + "expression": "actor_identity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_activity_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "junior_conversations_origin_idx": { + "name": "junior_conversations_origin_idx", + "columns": [ + { + "expression": "origin_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "origin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_activity_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "junior_conversations_parent_idx": { + "name": "junior_conversations_parent_idx", + "columns": [ + { + "expression": "parent_conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "junior_conversations_destination_id_junior_destinations_id_fk": { + "name": "junior_conversations_destination_id_junior_destinations_id_fk", + "tableFrom": "junior_conversations", + "columnsFrom": ["destination_id"], + "tableTo": "junior_destinations", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "no action" + }, + "junior_conversations_actor_identity_id_junior_identities_id_fk": { + "name": "junior_conversations_actor_identity_id_junior_identities_id_fk", + "tableFrom": "junior_conversations", + "columnsFrom": ["actor_identity_id"], + "tableTo": "junior_identities", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "no action" + }, + "junior_conversations_creator_identity_id_junior_identities_id_fk": { + "name": "junior_conversations_creator_identity_id_junior_identities_id_fk", + "tableFrom": "junior_conversations", + "columnsFrom": ["creator_identity_id"], + "tableTo": "junior_identities", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "no action" + }, + "junior_conversations_credential_subject_identity_id_junior_identities_id_fk": { + "name": "junior_conversations_credential_subject_identity_id_junior_identities_id_fk", + "tableFrom": "junior_conversations", + "columnsFrom": ["credential_subject_identity_id"], + "tableTo": "junior_identities", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "no action" + }, + "junior_conversations_parent_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_conversations_parent_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_conversations", + "columnsFrom": ["parent_conversation_id"], + "tableTo": "junior_conversations", + "columnsTo": ["conversation_id"], + "onUpdate": "no action", + "onDelete": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_destinations": { + "name": "junior_destinations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_tenant_id": { + "name": "provider_tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "provider_destination_id": { + "name": "provider_destination_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_destination_id": { + "name": "parent_destination_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "visibility": { + "name": "visibility", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'unknown'" + }, + "metadata_json": { + "name": "metadata_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "junior_destinations_provider_destination_uidx": { + "name": "junior_destinations_provider_destination_uidx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_destination_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + }, + "junior_destinations_provider_kind_idx": { + "name": "junior_destinations_provider_kind_idx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_identities": { + "name": "junior_identities", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_tenant_id": { + "name": "provider_tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "provider_subject_id": { + "name": "provider_subject_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "handle": { + "name": "handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata_json": { + "name": "metadata_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_normalized": { + "name": "email_normalized", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "junior_identities_provider_subject_uidx": { + "name": "junior_identities_provider_subject_uidx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_subject_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + }, + "junior_identities_user_idx": { + "name": "junior_identities_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "junior_identities_verified_email_idx": { + "name": "junior_identities_verified_email_idx", + "columns": [ + { + "expression": "email_normalized", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "where": "\"junior_identities\".\"email_verified\" = true AND \"junior_identities\".\"email_normalized\" IS NOT NULL", + "concurrently": false + }, + "junior_identities_kind_provider_idx": { + "name": "junior_identities_kind_provider_idx", + "columns": [ + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "junior_identities_user_id_junior_users_id_fk": { + "name": "junior_identities_user_id_junior_users_id_fk", + "tableFrom": "junior_identities", + "columnsFrom": ["user_id"], + "tableTo": "junior_users", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_users": { + "name": "junior_users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "primary_email": { + "name": "primary_email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "primary_email_normalized": { + "name": "primary_email_normalized", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "junior_users_primary_email_normalized_uidx": { + "name": "junior_users_primary_email_normalized_uidx", + "columns": [ + { + "expression": "primary_email_normalized", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "views": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/packages/junior/migrations/meta/_journal.json b/packages/junior/migrations/meta/_journal.json index 1da614f71..6b57916cb 100644 --- a/packages/junior/migrations/meta/_journal.json +++ b/packages/junior/migrations/meta/_journal.json @@ -29,6 +29,34 @@ "when": 1783915376595, "tag": "0003_peaceful_scalphunter", "breakpoints": true + }, + { + "idx": 4, + "version": "7", + "when": 1783976225853, + "tag": "0004_agent_turn_session_actor", + "breakpoints": true + }, + { + "idx": 5, + "version": "7", + "when": 1783977955563, + "tag": "0005_redis_conversation_state", + "breakpoints": true + }, + { + "idx": 6, + "version": "7", + "when": 1783977956391, + "tag": "0006_conversations_to_sql", + "breakpoints": true + }, + { + "idx": 7, + "version": "7", + "when": 1783977957210, + "tag": "0007_conversation_history_to_sql", + "breakpoints": true } ] -} \ No newline at end of file +} diff --git a/packages/junior/package.json b/packages/junior/package.json index 31c231cff..a83e88448 100644 --- a/packages/junior/package.json +++ b/packages/junior/package.json @@ -24,6 +24,10 @@ "types": "./dist/instrumentation.d.ts", "default": "./dist/instrumentation.js" }, + "./migration-helpers/v1": { + "types": "./dist/migration-helpers/v1.d.ts", + "default": "./dist/migration-helpers/v1.js" + }, "./nitro": { "types": "./dist/nitro.d.ts", "default": "./dist/nitro.js" @@ -51,6 +55,7 @@ "prepack": "pnpm run build", "build": "tsup && tsc -p tsconfig.build.json --emitDeclarationOnly", "db:generate": "pnpm exec drizzle-kit generate --config drizzle.config.ts", + "db:generate:data": "pnpm exec junior-migrations generate --config drizzle.config.ts --out migrations", "lint": "oxlint --config .oxlintrc.json --deny-warnings src tests scripts bin tsup.config.ts && depcruise --config .dependency-cruiser.mjs src/chat", "lint:fix": "oxlint --config .oxlintrc.json --deny-warnings --fix src tests scripts bin tsup.config.ts", "test": "vitest run --maxWorkers=4", @@ -70,6 +75,7 @@ "@modelcontextprotocol/sdk": "1.29.0", "@neondatabase/serverless": "^1.1.0", "@sentry/junior-plugin-api": "workspace:*", + "@sentry/junior-migrations": "workspace:*", "@sentry/node": "catalog:", "@sinclair/typebox": "^0.34.49", "@slack/web-api": "^7.16.0", diff --git a/packages/junior/src/chat/conversations/sql/migrations.ts b/packages/junior/src/chat/conversations/sql/migrations.ts index 29c897b46..3e53ba9da 100644 --- a/packages/junior/src/chat/conversations/sql/migrations.ts +++ b/packages/junior/src/chat/conversations/sql/migrations.ts @@ -1,7 +1,16 @@ /** SQL schema migrations for durable Junior records. */ import { basename, dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; -import { readMigrationFiles } from "drizzle-orm/migrator"; +import { + resolveMigrations, + runMigrationJournal, + type MigrationContextV1, + type MigrationRunResult, + type MigrationStateV1, + type TypeScriptMigrationLoader, +} from "@sentry/junior-migrations"; +import type { StateAdapter } from "chat"; +import type { RedisStateAdapter } from "@chat-adapter/state-redis"; import type { JuniorSqlMigrationExecutor } from "@/db/db"; import { juniorSqlSchema as schema } from "@/db/schema"; @@ -43,7 +52,7 @@ SELECT return; } - const migrations = readMigrationFiles({ migrationsFolder }); + const migrations = await resolveMigrations(migrationsFolder); const [metrics] = await executor.query<{ complete: boolean }>(` SELECT count(*) = 4 AS complete FROM information_schema.columns @@ -92,23 +101,87 @@ CREATE TABLE IF NOT EXISTS drizzle.__drizzle_junior_core ( await executor.execute( `INSERT INTO drizzle.__drizzle_junior_core (hash, created_at) VALUES ($1, $2)`, - [migration.hash, migration.folderMillis], + [migration.hash, migration.when], ); }); } +function migrationState(stateAdapter: StateAdapter): MigrationStateV1 { + return { + acquireLock: async (threadId, ttlMs) => + await stateAdapter.acquireLock(threadId, ttlMs), + appendToList: async (key, value, options) => { + await stateAdapter.appendToList(key, value, options); + }, + connect: async () => { + await stateAdapter.connect(); + }, + delete: async (key) => { + await stateAdapter.delete(key); + }, + get: async (key: string) => + (await stateAdapter.get(key)) ?? undefined, + getList: async (key: string) => await stateAdapter.getList(key), + releaseLock: async (lock) => { + await stateAdapter.releaseLock(lock); + }, + set: async (key, value, ttlMs) => { + await stateAdapter.set(key, value, ttlMs); + }, + setIfNotExists: async (key, value, ttlMs) => + await stateAdapter.setIfNotExists(key, value, ttlMs), + }; +} + +export type MigrateSchemaOptions = + | { mode?: "sql" } + | { + loadTypeScript: TypeScriptMigrationLoader; + log?: MigrationContextV1["log"]; + mode: "all"; + redisStateAdapter?: RedisStateAdapter; + stateAdapter: StateAdapter; + }; + export { schema }; -/** Apply the packaged Drizzle migrations during `junior upgrade`. */ +/** Apply the packaged mixed migration journal, or SQL entries alone in tests. */ export async function migrateSchema( executor: JuniorSqlMigrationExecutor, -): Promise { + options: MigrateSchemaOptions = { mode: "sql" }, +): Promise { const migrationsFolder = migrationFolder(); - await executor.withMigrationLock(MIGRATIONS_TABLE, async () => { - await adoptLegacyMigrationState(executor, migrationsFolder); - await executor.migrate({ - migrationsFolder, - migrationsTable: MIGRATIONS_TABLE, - }); + const runAll = options.mode === "all"; + const baseOptions = { + beforeRun: async () => { + await adoptLegacyMigrationState(executor, migrationsFolder); + }, + executor, + migrationsFolder, + migrationsTable: MIGRATIONS_TABLE, + }; + if (!runAll) { + return await runMigrationJournal({ ...baseOptions, mode: "sql" }); + } + return await runMigrationJournal({ + ...baseOptions, + createContext: ({ progress }): MigrationContextV1 => ({ + database: executor, + log: options.log ?? (() => {}), + progress, + ...(options.redisStateAdapter + ? { + redis: { + sendCommand: async (args: readonly string[]) => + await options + .redisStateAdapter!.getClient() + .sendCommand([...args]), + }, + } + : {}), + state: migrationState(options.stateAdapter), + }), + loadTypeScript: options.loadTypeScript, + mode: "all", }); } diff --git a/packages/junior/src/chat/plugins/migrations.ts b/packages/junior/src/chat/plugins/migrations.ts index d054f6e11..a0321beea 100644 --- a/packages/junior/src/chat/plugins/migrations.ts +++ b/packages/junior/src/chat/plugins/migrations.ts @@ -1,5 +1,13 @@ import { createHash } from "node:crypto"; -import { readMigrationFiles, type MigrationMeta } from "drizzle-orm/migrator"; +import { + resolveMigrations, + runMigrationJournal, + type MigrationContextV1, + type MigrationStateV1, + type ResolvedMigration, + type TypeScriptMigrationLoader, +} from "@sentry/junior-migrations"; +import type { StateAdapter } from "chat"; import type { JuniorSqlMigrationExecutor } from "@/db/db"; interface PluginMigrationRoot { @@ -8,11 +16,21 @@ interface PluginMigrationRoot { pluginName: string; } -interface PluginMigrationResult { +type PluginMigrationResult = { existing: number; migrated: number; scanned: number; -} + skipped?: number; +}; + +type PluginMigrationOptions = + | { mode?: "sql" } + | { + loadTypeScript: TypeScriptMigrationLoader; + log?: MigrationContextV1["log"]; + mode: "all"; + stateAdapter: StateAdapter; + }; const LEGACY_SCHEDULER_BASELINE_HASH = "d1d2f712181dd3a0557808f0fc67fd0722691d25f4c8cfb816b77c71d19e1e42"; @@ -30,28 +48,6 @@ function migrationTable(pluginName: string): string { return `__drizzle_${label}_${hash}`; } -async function appliedMigrationTime( - executor: JuniorSqlMigrationExecutor, - table: string, -): Promise { - const [exists] = await executor.query<{ tableName: string | null }>( - `SELECT to_regclass($1)::text AS "tableName"`, - [`drizzle.${table}`], - ); - if (!exists?.tableName) { - return undefined; - } - const [row] = await executor.query<{ createdAt: string | null }>( - `SELECT created_at::text AS "createdAt" - FROM drizzle.${table} - ORDER BY created_at DESC - LIMIT 1`, - ); - return row?.createdAt === null || row?.createdAt === undefined - ? undefined - : Number(row.createdAt); -} - async function legacyMigrationHashes( executor: JuniorSqlMigrationExecutor, pluginName: string, @@ -73,13 +69,13 @@ async function legacyMigrationHashes( } function adoptedMigration( - migrations: readonly MigrationMeta[], + migrations: readonly ResolvedMigration[], legacyHashes: ReadonlySet, pluginName: string, -): MigrationMeta | undefined { - let adopted: MigrationMeta | undefined; +): ResolvedMigration | undefined { + let adopted: ResolvedMigration | undefined; for (const migration of migrations) { - if (!legacyHashes.has(migration.hash)) { + if (migration.kind !== "sql" || !legacyHashes.has(migration.hash)) { break; } adopted = migration; @@ -96,10 +92,17 @@ function adoptedMigration( async function adoptLegacyMigrationState(args: { executor: JuniorSqlMigrationExecutor; - migrations: readonly MigrationMeta[]; + migrations: readonly ResolvedMigration[]; pluginName: string; table: string; -}): Promise { +}): Promise { + const [exists] = await args.executor.query<{ tableName: string | null }>( + `SELECT to_regclass($1)::text AS "tableName"`, + [`drizzle.${args.table}`], + ); + if (exists?.tableName) { + return; + } const legacyHashes = await legacyMigrationHashes( args.executor, args.pluginName, @@ -110,7 +113,7 @@ async function adoptLegacyMigrationState(args: { args.pluginName, ); if (!migration) { - return undefined; + return; } await args.executor.transaction(async () => { await args.executor.execute("CREATE SCHEMA IF NOT EXISTS drizzle"); @@ -123,26 +126,43 @@ CREATE TABLE IF NOT EXISTS drizzle.${args.table} ( `); await args.executor.execute( `INSERT INTO drizzle.${args.table} (hash, created_at) VALUES ($1, $2)`, - [migration.hash, migration.folderMillis], + [migration.hash, migration.when], ); }); - return migration.folderMillis; } -function appliedCount( - migrations: readonly MigrationMeta[], - createdAt: number | undefined, -): number { - return createdAt === undefined - ? 0 - : migrations.filter((migration) => migration.folderMillis <= createdAt) - .length; +function migrationState(stateAdapter: StateAdapter): MigrationStateV1 { + return { + acquireLock: async (threadId, ttlMs) => + await stateAdapter.acquireLock(threadId, ttlMs), + appendToList: async (key, value, options) => { + await stateAdapter.appendToList(key, value, options); + }, + connect: async () => { + await stateAdapter.connect(); + }, + delete: async (key) => { + await stateAdapter.delete(key); + }, + get: async (key: string) => + (await stateAdapter.get(key)) ?? undefined, + getList: async (key: string) => await stateAdapter.getList(key), + releaseLock: async (lock) => { + await stateAdapter.releaseLock(lock); + }, + set: async (key, value, ttlMs) => { + await stateAdapter.set(key, value, ttlMs); + }, + setIfNotExists: async (key, value, ttlMs) => + await stateAdapter.setIfNotExists(key, value, ttlMs), + }; } -/** Apply enabled plugins' packaged Drizzle migrations in plugin-name order. */ +/** Apply enabled plugins' mixed migrations in plugin-name and journal order. */ export async function migratePluginSchemas( executor: JuniorSqlMigrationExecutor, roots: readonly PluginMigrationRoot[], + options: PluginMigrationOptions = { mode: "sql" }, ): Promise { const result: PluginMigrationResult = { existing: 0, @@ -153,26 +173,41 @@ export async function migratePluginSchemas( left.pluginName.localeCompare(right.pluginName), ); for (const root of orderedRoots) { - const migrations = readMigrationFiles({ migrationsFolder: root.dir }); + const migrations = await resolveMigrations(root.dir); const table = migrationTable(root.pluginName); - await executor.withMigrationLock(table, async () => { - const currentTime = - (await appliedMigrationTime(executor, table)) ?? - (await adoptLegacyMigrationState({ + const runAll = options.mode === "all"; + const baseOptions = { + beforeRun: async () => { + await adoptLegacyMigrationState({ executor, migrations, pluginName: root.pluginName, table, - })); - const existing = appliedCount(migrations, currentTime); - await executor.migrate({ - migrationsFolder: root.dir, - migrationsTable: table, - }); - result.scanned += migrations.length; - result.existing += existing; - result.migrated += migrations.length - existing; - }); + }); + }, + executor, + migrationsFolder: root.dir, + migrationsTable: table, + }; + const pluginResult = runAll + ? await runMigrationJournal({ + ...baseOptions, + createContext: ({ progress }): MigrationContextV1 => ({ + database: executor, + log: options.log ?? (() => {}), + progress, + state: migrationState(options.stateAdapter), + }), + loadTypeScript: options.loadTypeScript, + mode: "all", + }) + : await runMigrationJournal({ ...baseOptions, mode: "sql" }); + result.scanned += pluginResult.scanned; + result.existing += pluginResult.existing; + result.migrated += pluginResult.migrated; + if (pluginResult.skipped > 0) { + result.skipped = (result.skipped ?? 0) + pluginResult.skipped; + } } return result; } diff --git a/packages/junior/src/cli/upgrade.ts b/packages/junior/src/cli/upgrade.ts index f566c1afe..b5ff82c17 100644 --- a/packages/junior/src/cli/upgrade.ts +++ b/packages/junior/src/cli/upgrade.ts @@ -4,19 +4,13 @@ import { } from "@/chat/state/adapter"; import { createJiti } from "jiti"; import { loadAppPluginSet } from "@/plugin-module"; -import { sqlConversationMigration } from "./upgrade/migrations/conversations-sql"; -import { coreSqlSchemaMigration } from "./upgrade/migrations/core-sql"; -import { sqlConversationHistoryMigration } from "./upgrade/migrations/conversations-history-sql"; -import { pluginStorageMigration } from "./upgrade/migrations/plugin-storage"; -import { sqlPluginMigration } from "./upgrade/migrations/plugin-sql"; +import { migrateCoreJournal } from "./upgrade/migrations/core-journal"; +import { migratePluginJournals } from "./upgrade/migrations/plugin-journal"; import { resolveUpgradePlugins } from "./upgrade/migrations/upgrade-plugins"; -import { redisConversationStateMigration } from "./upgrade/migrations/redis-conversation-state"; -import { agentTurnSessionActorMigration } from "./upgrade/migrations/agent-turn-session-actor"; import type { MigrationContext, MigrationResult, UpgradeIo, - UpgradeMigration, } from "./upgrade/types"; import { type JuniorPluginSet } from "@/plugins"; @@ -25,16 +19,6 @@ const DEFAULT_IO: UpgradeIo = { }; const localPluginLoader = createJiti(import.meta.url, { moduleCache: false }); -const MIGRATIONS: UpgradeMigration[] = [ - agentTurnSessionActorMigration, - redisConversationStateMigration, - coreSqlSchemaMigration, - sqlConversationMigration, - sqlConversationHistoryMigration, - sqlPluginMigration, - pluginStorageMigration, -]; - function isMissingVirtualConfig(error: unknown): boolean { if (!(error instanceof Error)) { return false; @@ -88,14 +72,19 @@ export async function runUpgradeMigrations( const plugins = await resolveUpgradePlugins(context); const migrationContext = { ...context, ...plugins }; const results: MigrationResult[] = []; - for (const migration of MIGRATIONS) { - migrationContext.io.info(`Running migration ${migration.name}...`); - const result = await migration.run(migrationContext); + const run = async ( + name: string, + migrate: (context: MigrationContext) => Promise, + ): Promise => { + migrationContext.io.info(`Running migration ${name}...`); + const result = await migrate(migrationContext); migrationContext.io.info( - `Finished migration ${migration.name}: ${formatMigrationResult(result)}`, + `Finished migration ${name}: ${formatMigrationResult(result)}`, ); results.push(result); - } + }; + await run("core-migrations", migrateCoreJournal); + await run("plugin-migrations", migratePluginJournals); return results; } diff --git a/packages/junior/src/cli/upgrade/migrations/conversations-history-sql.ts b/packages/junior/src/cli/upgrade/migrations/conversations-history-sql.ts deleted file mode 100644 index 66e3e39a0..000000000 --- a/packages/junior/src/cli/upgrade/migrations/conversations-history-sql.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { getChatConfig } from "@/chat/config"; -import { importConversationFromLegacy } from "@/chat/conversations/legacy-import"; -import { createSqlConversationMessageStore } from "@/chat/conversations/sql/messages"; -import { createStateConversationStore } from "@/chat/conversations/state"; -import type { LegacyAdvisorSessionReader } from "@/chat/conversations/legacy-advisor-session"; -import type { ConversationMessage as ThreadConversationMessage } from "@/chat/state/conversation"; -import type { SessionLogStore } from "@/chat/state/session-log"; -import { createJuniorSqlExecutor } from "@/db/executor"; -import type { JuniorSqlExecutor } from "@/db/db"; -import type { MigrationContext, MigrationResult } from "../types"; - -const HISTORY_BACKFILL_LIMIT = 10_000; - -/** - * Bulk-import legacy Redis conversation history (session logs, advisor blobs, - * and visible messages) into SQL, bounded newest-first over the same activity - * scan as the metadata backfill. Idempotent per conversation: it skips any - * conversation that already has step rows. - */ -export async function migrateConversationHistoryToSql( - context: MigrationContext, - options: { - batchSize?: number; - executor?: JuniorSqlExecutor; - sessionLogStore?: SessionLogStore; - advisorSessionStore?: LegacyAdvisorSessionReader; - loadVisibleMessages?: ( - conversationId: string, - ) => Promise; - } = {}, -): Promise { - const source = createStateConversationStore(context.stateAdapter); - let executor = options.executor; - let closeExecutor: (() => Promise) | undefined; - if (!executor) { - const { sql } = getChatConfig(); - executor = createJuniorSqlExecutor({ - connectionString: sql.databaseUrl, - driver: sql.driver, - }); - closeExecutor = () => executor!.close(); - } - const limit = Math.max(1, options.batchSize ?? HISTORY_BACKFILL_LIMIT); - try { - const messageStore = createSqlConversationMessageStore(executor); - const conversations = await source.listByActivity({ limit }); - let migrated = 0; - let existing = 0; - for (const conversation of conversations) { - const result = await importConversationFromLegacy( - conversation.conversationId, - { - executor, - messageStore, - conversationRecord: conversation, - ...(options.sessionLogStore - ? { sessionLogStore: options.sessionLogStore } - : {}), - ...(options.advisorSessionStore - ? { advisorSessionStore: options.advisorSessionStore } - : {}), - ...(options.loadVisibleMessages - ? { loadVisibleMessages: options.loadVisibleMessages } - : {}), - }, - ); - if (result.imported) { - migrated += 1; - } else { - existing += 1; - } - } - return { - existing, - migrated, - missing: 0, - scanned: conversations.length, - }; - } finally { - await closeExecutor?.(); - } -} - -export const sqlConversationHistoryMigration = { - name: "backfill-agent-steps-sql", - run: migrateConversationHistoryToSql, -}; diff --git a/packages/junior/src/cli/upgrade/migrations/conversations-sql.ts b/packages/junior/src/cli/upgrade/migrations/conversations-sql.ts deleted file mode 100644 index bb24c4283..000000000 --- a/packages/junior/src/cli/upgrade/migrations/conversations-sql.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { getChatConfig } from "@/chat/config"; -import { createSqlStore, type SqlStore } from "@/chat/conversations/sql/store"; -import { createStateConversationStore } from "@/chat/conversations/state"; -import { addAgentTurnUsage } from "@/chat/usage"; -import { listAgentTurnSessionSummariesForConversations } from "@/chat/state/turn-session"; -import { createJuniorSqlExecutor } from "@/db/executor"; -import type { MigrationContext, MigrationResult } from "../types"; - -const CONVERSATION_BACKFILL_LIMIT = 10_000; - -/** Copy retained conversation records into the configured SQL store. */ -export async function migrateConversationsToSql( - context: MigrationContext, - options: { - batchSize?: number; - target?: Pick; - } = {}, -): Promise { - const source = createStateConversationStore(context.stateAdapter); - let target = options.target; - let closeTarget: (() => Promise) | undefined; - if (!target) { - const { sql } = getChatConfig(); - const executor = createJuniorSqlExecutor({ - connectionString: sql.databaseUrl, - driver: sql.driver, - }); - target = createSqlStore(executor); - closeTarget = () => executor.close(); - } - const limit = Math.max(1, options.batchSize ?? CONVERSATION_BACKFILL_LIMIT); - try { - const [stateConversations, sqlConversations] = await Promise.all([ - source.listByActivity({ limit }), - target.listByActivity({ limit }), - ]); - const byId = new Map( - sqlConversations.map((conversation) => [ - conversation.conversationId, - conversation, - ]), - ); - for (const conversation of stateConversations) { - const existing = byId.get(conversation.conversationId); - const existingExecutionAt = - existing?.execution.updatedAtMs ?? existing?.updatedAtMs ?? 0; - const stateExecutionAt = - conversation.execution.updatedAtMs ?? conversation.updatedAtMs; - if (!existing || stateExecutionAt >= existingExecutionAt) { - byId.set(conversation.conversationId, conversation); - } - } - const conversations = [...byId.values()] - .sort( - (left, right) => - right.lastActivityAtMs - left.lastActivityAtMs || - left.conversationId.localeCompare(right.conversationId), - ) - .slice(0, limit); - const summaries = await listAgentTurnSessionSummariesForConversations( - context.stateAdapter, - conversations.map((conversation) => conversation.conversationId), - ); - for (const conversation of conversations) { - const conversationSummaries = - summaries.get(conversation.conversationId) ?? []; - const executionSummary = conversation.execution.runId - ? conversationSummaries.find( - (summary) => summary.sessionId === conversation.execution.runId, - ) - : undefined; - await target.backfillConversation( - conversation, - conversationSummaries.length > 0 - ? { - durationMs: conversationSummaries.reduce( - (total, summary) => total + summary.cumulativeDurationMs, - 0, - ), - usage: addAgentTurnUsage( - ...conversationSummaries.map( - (summary) => summary.cumulativeUsage, - ), - ), - executionDurationMs: executionSummary?.cumulativeDurationMs ?? 0, - executionUsage: executionSummary?.cumulativeUsage, - } - : undefined, - ); - } - - return { - existing: 0, - migrated: conversations.length, - missing: 0, - scanned: conversations.length, - }; - } finally { - await closeTarget?.(); - } -} - -export const sqlConversationMigration = { - name: "backfill-conversations-sql", - run: migrateConversationsToSql, -}; diff --git a/packages/junior/src/cli/upgrade/migrations/core-journal.ts b/packages/junior/src/cli/upgrade/migrations/core-journal.ts new file mode 100644 index 000000000..7951c77d8 --- /dev/null +++ b/packages/junior/src/cli/upgrade/migrations/core-journal.ts @@ -0,0 +1,37 @@ +import { getChatConfig } from "@/chat/config"; +import { migrateSchema } from "@/chat/conversations/sql/migrations"; +import { createJuniorSqlExecutor } from "@/db/executor"; +import { createJiti } from "jiti"; +import type { MigrationContext, MigrationResult } from "../types"; + +const migrationLoader = createJiti(import.meta.url, { moduleCache: false }); + +/** Apply core schema and self-contained data migrations in journal order. */ +export async function migrateCoreJournal( + context: MigrationContext, +): Promise { + const { sql } = getChatConfig(); + const executor = createJuniorSqlExecutor({ + connectionString: sql.databaseUrl, + driver: sql.driver, + }); + try { + const result = await migrateSchema(executor, { + loadTypeScript: async (path) => + await migrationLoader.import>(path), + log: context.io.info, + mode: "all", + redisStateAdapter: context.redisStateAdapter, + stateAdapter: context.stateAdapter, + }); + return { + existing: result.existing, + migrated: result.migrated, + missing: 0, + scanned: result.scanned, + skipped: result.skipped, + }; + } finally { + await executor.close(); + } +} diff --git a/packages/junior/src/cli/upgrade/migrations/core-sql.ts b/packages/junior/src/cli/upgrade/migrations/core-sql.ts deleted file mode 100644 index a8b07ef98..000000000 --- a/packages/junior/src/cli/upgrade/migrations/core-sql.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { getChatConfig } from "@/chat/config"; -import { migrateSchema } from "@/chat/conversations/sql/migrations"; -import { createJuniorSqlExecutor } from "@/db/executor"; -import type { MigrationContext, MigrationResult } from "../types"; - -/** Apply core SQL schema migrations before upgrade backfills run. */ -export async function migrateCoreSqlSchema( - _context: MigrationContext, -): Promise { - const { sql } = getChatConfig(); - const executor = createJuniorSqlExecutor({ - connectionString: sql.databaseUrl, - driver: sql.driver, - }); - try { - await migrateSchema(executor); - return { existing: 0, migrated: 0, missing: 0, scanned: 0 }; - } finally { - await executor.close(); - } -} - -export const coreSqlSchemaMigration = { - name: "core-sql-schema", - run: migrateCoreSqlSchema, -}; diff --git a/packages/junior/src/cli/upgrade/migrations/plugin-sql.ts b/packages/junior/src/cli/upgrade/migrations/plugin-journal.ts similarity index 67% rename from packages/junior/src/cli/upgrade/migrations/plugin-sql.ts rename to packages/junior/src/cli/upgrade/migrations/plugin-journal.ts index c41ef3dc7..d03c89249 100644 --- a/packages/junior/src/cli/upgrade/migrations/plugin-sql.ts +++ b/packages/junior/src/cli/upgrade/migrations/plugin-journal.ts @@ -2,11 +2,14 @@ import { getChatConfig } from "@/chat/config"; import { migratePluginSchemas } from "@/chat/plugins/migrations"; import { pluginCatalogRuntime } from "@/chat/plugins/catalog-runtime"; import { createJuniorSqlExecutor } from "@/db/executor"; +import { createJiti } from "jiti"; import { resolveUpgradePlugins } from "./upgrade-plugins"; import type { MigrationContext, MigrationResult } from "../types"; -/** Apply SQL schema migrations owned by explicitly enabled plugins. */ -export async function migratePluginsToSql( +const migrationLoader = createJiti(import.meta.url, { moduleCache: false }); + +/** Apply mixed migration journals owned by explicitly enabled plugins. */ +export async function migratePluginJournals( context: MigrationContext, ): Promise { const { sql } = getChatConfig(); @@ -20,20 +23,23 @@ export async function migratePluginsToSql( const result = await migratePluginSchemas( executor, pluginCatalogRuntime.getMigrationRoots(), + { + loadTypeScript: async (path) => + await migrationLoader.import>(path), + log: context.io.info, + mode: "all", + stateAdapter: context.stateAdapter, + }, ); return { existing: result.existing, migrated: result.migrated, missing: 0, scanned: result.scanned, + ...(result.skipped === undefined ? {} : { skipped: result.skipped }), }; } finally { pluginCatalogRuntime.setConfig(previousConfig); await executor.close(); } } - -export const sqlPluginMigration = { - name: "migrate-plugin-sql", - run: migratePluginsToSql, -}; diff --git a/packages/junior/src/cli/upgrade/migrations/plugin-storage.ts b/packages/junior/src/cli/upgrade/migrations/plugin-storage.ts deleted file mode 100644 index bc340d266..000000000 --- a/packages/junior/src/cli/upgrade/migrations/plugin-storage.ts +++ /dev/null @@ -1,93 +0,0 @@ -import type { StorageMigrationResult } from "@sentry/junior-plugin-api"; -import { pluginRuntimeRegistrationsFromPluginSet } from "@/plugins"; -import { getDb } from "@/chat/db"; -import { createPluginLogger } from "@/chat/plugins/logging"; -import { createPluginState } from "@/chat/plugins/state"; -import { pluginCatalogRuntime } from "@/chat/plugins/catalog-runtime"; -import { getChatConfig } from "@/chat/config"; -import { createJuniorSqlExecutor } from "@/db/executor"; -import { resolveUpgradePlugins } from "./upgrade-plugins"; -import type { MigrationContext, MigrationResult } from "../types"; - -function emptyResult(): MigrationResult { - return { - existing: 0, - migrated: 0, - missing: 0, - scanned: 0, - }; -} - -function addResult( - left: MigrationResult, - right: StorageMigrationResult, -): MigrationResult { - return { - existing: left.existing + right.existing, - migrated: left.migrated + right.migrated, - missing: left.missing + right.missing, - scanned: left.scanned + right.scanned, - ...(left.skipped !== undefined || right.skipped !== undefined - ? { skipped: (left.skipped ?? 0) + (right.skipped ?? 0) } - : {}), - }; -} - -function dbForPlugin(context: MigrationContext, sqlUrlDb: unknown): unknown { - return context.db ?? sqlUrlDb ?? getDb(); -} - -/** Run plugin-owned storage migrations after plugin SQL schemas are available. */ -export async function runPluginStorageMigrations( - context: MigrationContext, -): Promise { - const { pluginCatalogConfig, pluginSet } = - await resolveUpgradePlugins(context); - if (!pluginSet) { - return emptyResult(); - } - - const previousConfig = pluginCatalogRuntime.setConfig(pluginCatalogConfig); - const sql = getChatConfig().sql; - const ownedExecutor = context.db - ? undefined - : createJuniorSqlExecutor({ - connectionString: sql.databaseUrl, - driver: sql.driver, - }); - const sqlUrlDb = ownedExecutor ? ownedExecutor.db() : undefined; - try { - let result = emptyResult(); - const plugins = pluginRuntimeRegistrationsFromPluginSet(pluginSet) - .filter((plugin) => plugin.hooks?.migrateStorage) - .sort((left, right) => - left.manifest.name.localeCompare(right.manifest.name), - ); - for (const plugin of plugins) { - const pluginName = plugin.manifest.name; - const hook = plugin.hooks?.migrateStorage; - if (!hook) { - continue; - } - const db = dbForPlugin(context, sqlUrlDb); - const pluginResult = await hook({ - db, - log: createPluginLogger(pluginName), - plugin: { name: pluginName }, - state: createPluginState(pluginName, context.stateAdapter), - }); - if (pluginResult) { - result = addResult(result, pluginResult); - } - } - return result; - } finally { - pluginCatalogRuntime.setConfig(previousConfig); - await ownedExecutor?.close(); - } -} - -export const pluginStorageMigration = { - name: "run-plugin-storage-migrations", - run: runPluginStorageMigrations, -}; diff --git a/packages/junior/src/cli/upgrade/types.ts b/packages/junior/src/cli/upgrade/types.ts index 0168d025d..913af8b3b 100644 --- a/packages/junior/src/cli/upgrade/types.ts +++ b/packages/junior/src/cli/upgrade/types.ts @@ -8,7 +8,6 @@ export interface UpgradeIo { } export interface MigrationContext { - db?: unknown; io: UpgradeIo; pluginCatalogConfig?: PluginCatalogConfig; pluginSet?: JuniorPluginSet; @@ -16,15 +15,10 @@ export interface MigrationContext { stateAdapter: StateAdapter; } -export interface MigrationResult { +export type MigrationResult = { existing: number; migrated: number; missing: number; scanned: number; skipped?: number; -} - -export interface UpgradeMigration { - name: string; - run(context: MigrationContext): Promise; -} +}; diff --git a/packages/junior/src/db/db.ts b/packages/junior/src/db/db.ts index 35c037e43..321184858 100644 --- a/packages/junior/src/db/db.ts +++ b/packages/junior/src/db/db.ts @@ -7,7 +7,6 @@ */ import type { PgDatabase } from "drizzle-orm/pg-core"; import type { PgQueryResultHKT } from "drizzle-orm/pg-core/session"; -import type { MigrationConfig } from "drizzle-orm/migrator"; import type { juniorSqlSchema } from "./schema"; export type JuniorDatabase = PgDatabase< @@ -23,7 +22,6 @@ export interface JuniorSqlDatabase { export interface JuniorSqlMigrationExecutor extends JuniorSqlDatabase { execute(statement: string, params?: readonly unknown[]): Promise; - migrate(config: MigrationConfig): Promise; query( statement: string, params?: readonly unknown[], diff --git a/packages/junior/src/db/neon.ts b/packages/junior/src/db/neon.ts index 62e7aa041..0ce11d06a 100644 --- a/packages/junior/src/db/neon.ts +++ b/packages/junior/src/db/neon.ts @@ -6,8 +6,6 @@ import { type QueryResultRow, } from "@neondatabase/serverless"; import { drizzle } from "drizzle-orm/neon-serverless"; -import { migrate } from "drizzle-orm/neon-serverless/migrator"; -import type { MigrationConfig } from "drizzle-orm/migrator"; import type { JuniorDatabase, JuniorSqlExecutor } from "./db"; import { juniorSqlSchema } from "./schema"; @@ -45,13 +43,6 @@ class NeonExecutor implements NeonJuniorSqlExecutor { return result.rows as T[]; } - async migrate(config: MigrationConfig): Promise { - await migrate( - drizzle(this.queryClient(), { schema: juniorSqlSchema }), - config, - ); - } - async transaction(callback: () => Promise): Promise { const existingClient = this.transactionClient.getStore(); if (existingClient) { diff --git a/packages/junior/src/db/postgres.ts b/packages/junior/src/db/postgres.ts index 4124bd9ff..53020ce44 100644 --- a/packages/junior/src/db/postgres.ts +++ b/packages/junior/src/db/postgres.ts @@ -5,8 +5,6 @@ import pg, { type QueryResultRow, } from "pg"; import { drizzle } from "drizzle-orm/node-postgres"; -import { migrate } from "drizzle-orm/node-postgres/migrator"; -import type { MigrationConfig } from "drizzle-orm/migrator"; import type { JuniorDatabase, JuniorSqlExecutor } from "./db"; import { juniorSqlSchema } from "./schema"; @@ -43,13 +41,6 @@ class PostgresExecutor implements JuniorSqlExecutor { return result.rows as T[]; } - async migrate(config: MigrationConfig): Promise { - await migrate( - drizzle(this.queryClient(), { schema: juniorSqlSchema }), - config, - ); - } - async transaction(callback: () => Promise): Promise { const existingClient = this.transactionClient.getStore(); if (existingClient) { diff --git a/packages/junior/src/migration-helpers/README.md b/packages/junior/src/migration-helpers/README.md new file mode 100644 index 000000000..9f2f3637f --- /dev/null +++ b/packages/junior/src/migration-helpers/README.md @@ -0,0 +1,13 @@ +# Migration Helpers + +This directory owns versioned, append-only infrastructure helpers available to +packaged TypeScript migrations through `@sentry/junior/migration-helpers/v1`. + +Helpers may expose stable parsing primitives, state/database adapters, stores, +and key resolution. They must not contain one-off migration decisions or data +transforms. Logic such as mapping one retired record shape into another belongs +only in the journal entry that performs that migration. + +Never change the behavior or signature of an existing version in a way that can +break a pending migration. Add a new versioned entrypoint when the helper +contract must change. diff --git a/packages/junior/src/migration-helpers/v1.ts b/packages/junior/src/migration-helpers/v1.ts new file mode 100644 index 000000000..f6c922991 --- /dev/null +++ b/packages/junior/src/migration-helpers/v1.ts @@ -0,0 +1,234 @@ +import type { Destination } from "@sentry/junior-plugin-api"; +import type { + MigrationDatabaseAdapter, + MigrationStateV1, +} from "@sentry/junior-migrations"; +import type { StateAdapter } from "chat"; +import { + isRecord as runtimeIsRecord, + toOptionalNumber as runtimeToOptionalNumber, + toOptionalString as runtimeToOptionalString, +} from "@/chat/coerce"; +import { getChatConfig } from "@/chat/config"; +import { createStateConversationStore } from "@/chat/conversations/state"; +import { createSqlStore } from "@/chat/conversations/sql/store"; +import { + parseDestination as runtimeParseDestination, + sameDestination as runtimeSameDestination, +} from "@/chat/destination"; +import { coerceThreadConversationState as runtimeCoerceThreadConversationState } from "@/chat/state/conversation"; +import { listAgentTurnSessionSummariesForConversations } from "@/chat/state/turn-session"; +import { + getConversation, + requestConversationWork, +} from "@/chat/task-execution/state"; +import { addAgentTurnUsage as runtimeAddAgentTurnUsage } from "@/chat/usage"; +import type { JuniorSqlDatabase } from "@/db/db"; + +export const JUNIOR_THREAD_STATE_TTL_MS = 7 * 24 * 60 * 60 * 1_000; + +export type MigrationSourceV1 = + | "api" + | "internal" + | "local" + | "plugin" + | "resource_event" + | "scheduler" + | "slack"; + +export type MigrationExecutionStatusV1 = + | "awaiting_resume" + | "failed" + | "idle" + | "pending" + | "running"; + +export interface MigrationInboundMessageV1 { + attemptCount?: number; + conversationId: string; + createdAtMs: number; + destination: Destination; + inboundMessageId: string; + injectedAtMs?: number; + input: { + attachments?: unknown[]; + authorId?: string; + metadata?: Record; + text: string; + }; + receivedAtMs: number; + source: MigrationSourceV1; +} + +export interface MigrationLeaseV1 { + acquiredAtMs: number; + expiresAtMs: number; + lastCheckInAtMs: number; + token: string; +} + +export interface MigrationConversationV1 { + actor?: unknown; + channelName?: string; + conversationId: string; + createdAtMs: number; + destination?: Destination; + execution: { + inboundMessageIds?: string[]; + lastCheckpointAtMs?: number; + lastEnqueuedAtMs?: number; + lease?: MigrationLeaseV1; + pendingCount?: number; + pendingMessages?: MigrationInboundMessageV1[]; + runId?: string; + status: MigrationExecutionStatusV1; + updatedAtMs?: number; + }; + lastActivityAtMs: number; + schemaVersion: 1; + source?: MigrationSourceV1; + title?: string; + updatedAtMs: number; +} + +export interface MigrationRetainedConversationV1 extends MigrationConversationV1 { + execution: MigrationConversationV1["execution"] & { + inboundMessageIds: string[]; + pendingCount: number; + pendingMessages: MigrationInboundMessageV1[]; + }; +} + +export interface MigrationThreadConversationStateV1 { + processing: { activeTurnId?: string }; +} + +export interface MigrationTurnSessionSummaryV1 { + cumulativeDurationMs: number; + cumulativeUsage?: Record; + sessionId: string; +} + +export interface MigrationStateConversationStoreV1 { + listByActivity(args: { limit: number }): Promise; +} + +export interface MigrationSqlConversationStoreV1 { + backfillConversation( + conversation: MigrationConversationV1, + metrics?: { + durationMs: number; + executionDurationMs: number; + executionUsage?: Record; + usage?: Record; + }, + ): Promise; + listByActivity(args: { limit: number }): Promise; +} + +/** Return whether a value is a non-null object record. */ +export function isRecord(value: unknown): value is Record { + return runtimeIsRecord(value); +} + +/** Return a finite number or undefined. */ +export function toOptionalNumber(value: unknown): number | undefined { + return runtimeToOptionalNumber(value); +} + +/** Return a non-empty string or undefined. */ +export function toOptionalString(value: unknown): string | undefined { + return runtimeToOptionalString(value); +} + +/** Parse one persisted destination through the stable destination schema. */ +export function parseDestination(value: unknown): Destination | undefined { + return runtimeParseDestination(value); +} + +/** Compare two persisted destinations by routing identity. */ +export function sameDestination( + left: Destination, + right: Destination, +): boolean { + return runtimeSameDestination(left, right); +} + +/** Coerce one legacy thread-state value into the retained conversation shape. */ +export function coerceThreadConversationState( + value: unknown, +): MigrationThreadConversationStateV1 { + return runtimeCoerceThreadConversationState( + value, + ) as unknown as MigrationThreadConversationStateV1; +} + +/** Merge retained turn usage records. */ +export function addAgentTurnUsage( + ...values: Array | undefined> +): Record | undefined { + return runtimeAddAgentTurnUsage(...values) as + | Record + | undefined; +} + +/** Resolve a logical state key to the configured physical Redis key. */ +export function migrationRedisKey(key: string): string { + const prefix = getChatConfig().state.keyPrefix; + return [...(prefix ? [prefix] : []), key].join(":"); +} + +/** Create the retained state-backed conversation store for a v1 migration. */ +export function createMigrationStateConversationStore( + state: MigrationStateV1, +): MigrationStateConversationStoreV1 { + return createStateConversationStore( + state as StateAdapter, + ) as unknown as MigrationStateConversationStoreV1; +} + +/** Create the SQL conversation store for a v1 migration database adapter. */ +export function createMigrationSqlStore( + database: MigrationDatabaseAdapter, +): MigrationSqlConversationStoreV1 { + return createSqlStore( + database as unknown as JuniorSqlDatabase, + ) as unknown as MigrationSqlConversationStoreV1; +} + +/** Read one retained conversation through the v1 migration state adapter. */ +export async function getMigrationConversation(args: { + conversationId: string; + state: MigrationStateV1; +}): Promise { + return (await getConversation({ + conversationId: args.conversationId, + state: args.state as StateAdapter, + })) as unknown as MigrationRetainedConversationV1 | undefined; +} + +/** Mark one retained conversation runnable through the v1 migration state adapter. */ +export async function requestMigrationConversationWork(args: { + conversationId: string; + destination: Destination; + nowMs?: number; + state: MigrationStateV1; +}): Promise { + await requestConversationWork({ + conversationId: args.conversationId, + destination: args.destination, + ...(args.nowMs === undefined ? {} : { nowMs: args.nowMs }), + state: args.state as StateAdapter, + }); +} + +/** Read retained turn summaries through the v1 migration state adapter. */ +export async function listMigrationTurnSessionSummaries( + state: MigrationStateV1, + conversationIds: string[], +): Promise> { + return (await listAgentTurnSessionSummariesForConversations( + state as StateAdapter, + conversationIds, + )) as Map; +} diff --git a/packages/junior/tests/component/cli/upgrade-cli.test.ts b/packages/junior/tests/component/cli/upgrade-cli.test.ts index 0afa4f023..9c5f6e66e 100644 --- a/packages/junior/tests/component/cli/upgrade-cli.test.ts +++ b/packages/junior/tests/component/cli/upgrade-cli.test.ts @@ -2,6 +2,7 @@ import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import type { RedisStateAdapter } from "@chat-adapter/state-redis"; +import { createJiti } from "jiti"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { getChatConfig } from "@/chat/config"; import { disconnectStateAdapter, getStateAdapter } from "@/chat/state/adapter"; @@ -17,9 +18,9 @@ import type { ConversationStore } from "@/chat/conversations/store"; import { persistThreadStateById } from "@/chat/runtime/thread-state"; import { recordAgentTurnSessionSummary } from "@/chat/state/turn-session"; import { resolveUpgradePluginSet } from "@/cli/upgrade"; -import { agentTurnSessionActorMigration } from "@/cli/upgrade/migrations/agent-turn-session-actor"; -import { migrateConversationsToSql } from "@/cli/upgrade/migrations/conversations-sql"; -import { redisConversationStateMigration } from "@/cli/upgrade/migrations/redis-conversation-state"; +import { migrateAgentTurnSessionActor } from "../../../migrations/0004_agent_turn_session_actor"; +import { migrateRedisConversationState } from "../../../migrations/0005_redis_conversation_state"; +import { migrateConversationsToSql } from "../../../migrations/0006_conversations_to_sql"; import { CONVERSATION_ID, SLACK_DESTINATION, @@ -41,6 +42,7 @@ const OTHER_SLACK_DESTINATION = { ...SLACK_DESTINATION, channelId: "C999", } as const; +const migrationLoader = createJiti(import.meta.url, { moduleCache: false }); const stateOnlyConversationStore: ConversationStore = { get: async () => undefined, @@ -166,7 +168,7 @@ export const plugins = { ); await expect( - agentTurnSessionActorMigration.run({ + migrateAgentTurnSessionActor({ io: { info: () => {} }, stateAdapter, }), @@ -193,7 +195,7 @@ export const plugins = { expect(migratedRecord).not.toHaveProperty("requester"); await expect( - agentTurnSessionActorMigration.run({ + migrateAgentTurnSessionActor({ io: { info: () => {} }, stateAdapter, }), @@ -262,7 +264,7 @@ export const plugins = { } as unknown as RedisStateAdapter; await expect( - agentTurnSessionActorMigration.run({ + migrateAgentTurnSessionActor({ io: { info: () => {} }, redisStateAdapter, stateAdapter, @@ -287,6 +289,92 @@ export const plugins = { ), ).resolves.toEqual(expect.objectContaining({ actor: requester })); }); + it("runs journaled TypeScript state migrations exactly once", async () => { + const stateAdapter = getStateAdapter(); + await stateAdapter.connect(); + const fixture = await createLocalJuniorSqlFixture(); + const actor = { + platform: "slack", + teamId: "T123", + userId: "migration-user", + } as const; + const summary = { + version: 1, + conversationId: CONVERSATION_ID, + cumulativeDurationMs: 0, + lastProgressAtMs: 2, + sessionId: "session-one", + sliceId: 1, + startedAtMs: 1, + state: "completed", + updatedAtMs: 3, + requester: actor, + }; + await stateAdapter.appendToList("junior:agent_turn_session:index", summary); + await stateAdapter.appendToList( + `junior:agent_turn_session:conversation:${CONVERSATION_ID}:index`, + summary, + ); + await stateAdapter.set( + `junior:agent_turn_session:${CONVERSATION_ID}:session-one`, + summary, + ); + + try { + await expect( + migrateSchema(fixture.sql, { + loadTypeScript: async (migrationPath) => + await migrationLoader.import>( + migrationPath, + ), + mode: "all", + stateAdapter, + }), + ).resolves.toEqual({ + existing: 0, + migrated: 8, + scanned: 8, + skipped: 0, + }); + await expect( + stateAdapter.getList("junior:agent_turn_session:index"), + ).resolves.toEqual([ + expect.objectContaining({ + actor, + conversationId: CONVERSATION_ID, + sessionId: "session-one", + }), + ]); + await expect( + stateAdapter.get( + `junior:agent_turn_session:${CONVERSATION_ID}:session-one`, + ), + ).resolves.toEqual( + expect.objectContaining({ + actor, + conversationId: CONVERSATION_ID, + sessionId: "session-one", + }), + ); + await expect( + migrateSchema(fixture.sql, { + loadTypeScript: async (migrationPath) => + await migrationLoader.import>( + migrationPath, + ), + mode: "all", + stateAdapter, + }), + ).resolves.toEqual({ + existing: 8, + migrated: 0, + scanned: 8, + skipped: 0, + }); + } finally { + await fixture.close(); + } + }, 15_000); it("migrates legacy conversation work before SQL conversation backfill", async () => { const stateAdapter = getStateAdapter(); @@ -314,7 +402,7 @@ export const plugins = { stateAdapter, }; const results = [ - await redisConversationStateMigration.run(context), + await migrateRedisConversationState(context), await migrateConversationsToSql(context, { target: sqlStore }), ]; @@ -558,7 +646,7 @@ WHERE conversation_id = $1 await persistActiveTurn(CONVERSATION_ID, "turn-timeout"); await expect( - redisConversationStateMigration.run({ + migrateRedisConversationState({ io: { info: () => {} }, stateAdapter, }), @@ -615,7 +703,7 @@ WHERE conversation_id = $1 await stateAdapter.set("junior:conversation-work:index", [CONVERSATION_ID]); await expect( - redisConversationStateMigration.run({ + migrateRedisConversationState({ io: { info: () => {} }, stateAdapter, }), @@ -692,7 +780,7 @@ WHERE conversation_id = $1 await stateAdapter.set("junior:conversation-work:index", [CONVERSATION_ID]); await expect( - redisConversationStateMigration.run({ + migrateRedisConversationState({ io: { info: () => {} }, stateAdapter, }), @@ -726,7 +814,7 @@ WHERE conversation_id = $1 await stateAdapter.set("junior:conversation-work:index", [CONVERSATION_ID]); await expect( - redisConversationStateMigration.run({ + migrateRedisConversationState({ io: { info: () => {} }, stateAdapter, }), @@ -746,7 +834,7 @@ WHERE conversation_id = $1 }); await expect( - redisConversationStateMigration.run({ + migrateRedisConversationState({ io: { info: () => {} }, stateAdapter, }), @@ -778,7 +866,7 @@ WHERE conversation_id = $1 stateAdapter, }; const results = [ - await redisConversationStateMigration.run(context), + await migrateRedisConversationState(context), await migrateConversationsToSql(context, { target: sqlStore }), ]; diff --git a/packages/junior/tests/component/conversations/legacy-import.test.ts b/packages/junior/tests/component/conversations/legacy-import.test.ts index c14924843..806561b2c 100644 --- a/packages/junior/tests/component/conversations/legacy-import.test.ts +++ b/packages/junior/tests/component/conversations/legacy-import.test.ts @@ -18,7 +18,7 @@ import { createSqlConversationMessageStore } from "@/chat/conversations/sql/mess import { migrateSchema } from "@/chat/conversations/sql/migrations"; import { hydrateConversationMessages } from "@/chat/conversations/visible-messages"; import { coerceThreadConversationState } from "@/chat/state/conversation"; -import { migrateConversationHistoryToSql } from "@/cli/upgrade/migrations/conversations-history-sql"; +import { migrateConversationHistoryToSql } from "../../../migrations/0007_conversation_history_to_sql"; import type { LegacyAdvisorSessionReader } from "@/chat/conversations/legacy-advisor-session"; import type { Conversation } from "@/chat/conversations/store"; import type { PiMessage } from "@/chat/pi/messages"; diff --git a/packages/junior/tests/component/memory-plugin-storage.test.ts b/packages/junior/tests/component/memory-plugin-storage.test.ts index c6cbdbe29..d9600d282 100644 --- a/packages/junior/tests/component/memory-plugin-storage.test.ts +++ b/packages/junior/tests/component/memory-plugin-storage.test.ts @@ -1,6 +1,7 @@ import path from "node:path"; import { readdirSync } from "node:fs"; import { createMemoryState } from "@chat-adapter/state-memory"; +import { resolveMigrations } from "@sentry/junior-migrations"; import { afterAll, describe, expect, it, vi } from "vitest"; import { createMemoryPlugin, @@ -14,9 +15,8 @@ import { import { defineJuniorPlugins } from "@/plugins"; import { getPluginTools, setPlugins } from "@/chat/plugins/agent-hooks"; import { migratePluginSchemas } from "@/chat/plugins/migrations"; -import { readMigrationFiles } from "drizzle-orm/migrator"; import { closeDb } from "@/chat/db"; -import { migratePluginsToSql } from "@/cli/upgrade/migrations/plugin-sql"; +import { migratePluginJournals } from "@/cli/upgrade/migrations/plugin-journal"; import { createLocalJuniorSqlFixture } from "../fixtures/sql"; const NEON = vi.hoisted(() => ({ @@ -39,7 +39,6 @@ vi.mock("@/db/executor", () => ({ db: NEON.sql.db.bind(NEON.sql), execute: NEON.sql.execute.bind(NEON.sql), query: NEON.sql.query.bind(NEON.sql), - migrate: NEON.sql.migrate.bind(NEON.sql), transaction: NEON.sql.transaction.bind(NEON.sql), withLock: NEON.sql.withLock.bind(NEON.sql), withMigrationLock: NEON.sql.withMigrationLock.bind(NEON.sql), @@ -98,9 +97,7 @@ async function migrateMemorySchema( describe("memory plugin host wiring", () => { it("adopts exact legacy migration hashes without replaying them", async () => { const fixture = await createLocalJuniorSqlFixture(); - const migrations = readMigrationFiles({ - migrationsFolder: memoryMigrationsDir(), - }); + const migrations = await resolveMigrations(memoryMigrationsDir()); const migrationFiles = memoryMigrationFiles(); expect(migrationFiles).toHaveLength(migrations.length); @@ -149,9 +146,8 @@ CREATE TABLE junior_schema_migrations ( it("does not adopt an unknown memory legacy checksum", async () => { const fixture = await createLocalJuniorSqlFixture(); - const migrationCount = readMigrationFiles({ - migrationsFolder: memoryMigrationsDir(), - }).length; + const migrationCount = (await resolveMigrations(memoryMigrationsDir())) + .length; const [baselineFile] = memoryMigrationFiles(); expect(baselineFile).toBeDefined(); @@ -192,11 +188,10 @@ CREATE TABLE junior_schema_migrations ( NEON.sql = fixture.sql; try { - const migrationCount = readMigrationFiles({ - migrationsFolder: memoryMigrationsDir(), - }).length; + const migrationCount = (await resolveMigrations(memoryMigrationsDir())) + .length; await expect( - migratePluginsToSql({ + migratePluginJournals({ io: { info: () => {} }, pluginSet: defineJuniorPlugins([createMemoryPlugin()]), stateAdapter, diff --git a/packages/junior/tests/component/scheduler-sql-plugin.test.ts b/packages/junior/tests/component/scheduler-sql-plugin.test.ts index 5304eb74a..507cadf64 100644 --- a/packages/junior/tests/component/scheduler-sql-plugin.test.ts +++ b/packages/junior/tests/component/scheduler-sql-plugin.test.ts @@ -2,22 +2,25 @@ import path from "node:path"; import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { createMemoryState } from "@chat-adapter/state-memory"; -import { readMigrationFiles } from "drizzle-orm/migrator"; -import { afterAll, afterEach, describe, expect, it, vi } from "vitest"; +import { + resolveMigrations, + type MigrationContextV1, +} from "@sentry/junior-migrations"; import { defineJuniorPlugin } from "@sentry/junior-plugin-api"; +import { afterAll, afterEach, describe, expect, it, vi } from "vitest"; import { createSchedulerSqlStore, schedulerPlugin, type SchedulerDb, type ScheduledTask, } from "@sentry/junior-scheduler"; +import schedulerStateToSqlMigration from "../../../junior-scheduler/migrations/0002_scheduler_state_to_sql"; import { createSchedulerStore } from "../../../junior-scheduler/src/store"; import { defineJuniorPlugins } from "@/plugins"; import { migratePluginSchemas } from "@/chat/plugins/migrations"; import { createPluginState } from "@/chat/plugins/state"; -import { disconnectStateAdapter, getStateAdapter } from "@/chat/state/adapter"; -import { runPluginStorageMigrations } from "@/cli/upgrade/migrations/plugin-storage"; -import { migratePluginsToSql } from "@/cli/upgrade/migrations/plugin-sql"; +import { disconnectStateAdapter } from "@/chat/state/adapter"; +import { migratePluginJournals } from "@/cli/upgrade/migrations/plugin-journal"; import { createLocalJuniorSqlFixture } from "../fixtures/sql"; const NEON = vi.hoisted(() => ({ @@ -41,7 +44,6 @@ vi.mock("@/db/executor", () => ({ db: NEON.sql.db.bind(NEON.sql), execute: NEON.sql.execute.bind(NEON.sql), query: NEON.sql.query.bind(NEON.sql), - migrate: NEON.sql.migrate.bind(NEON.sql), transaction: NEON.sql.transaction.bind(NEON.sql), withLock: NEON.sql.withLock.bind(NEON.sql), withMigrationLock: NEON.sql.withMigrationLock.bind(NEON.sql), @@ -74,6 +76,21 @@ async function migrateSchedulerSchema( ]); } +async function runSchedulerStateMigration(args: { + fixture: Awaited>; + stateAdapter: ReturnType; +}) { + return await schedulerStateToSqlMigration.up({ + database: args.fixture.sql, + log: () => {}, + progress: { + load: async () => undefined, + save: async () => {}, + }, + state: args.stateAdapter as unknown as MigrationContextV1["state"], + }); +} + function createTask(overrides: Partial = {}): ScheduledTask { return { id: "sched_sql_1", @@ -180,10 +197,13 @@ INSERT INTO junior_scheduler_tasks ( pluginName: "scheduler", }, ]), - ).resolves.toEqual({ existing: 1, migrated: 1, scanned: 2 }); - const migrations = readMigrationFiles({ - migrationsFolder: schedulerMigrationsDir(), + ).resolves.toEqual({ + existing: 1, + migrated: 1, + scanned: 3, + skipped: 1, }); + const migrations = await resolveMigrations(schedulerMigrationsDir()); const migrationRows = await fixture.sql.query<{ createdAt: string; hash: string; @@ -193,10 +213,12 @@ FROM drizzle.${migrationTable!.tablename} ORDER BY created_at `); expect(migrationRows).toEqual( - migrations.map((migration) => ({ - createdAt: String(migration.folderMillis), - hash: migration.hash, - })), + migrations + .filter((migration) => migration.kind === "sql") + .map((migration) => ({ + createdAt: String(migration.when), + hash: migration.hash, + })), ); const [migratedTask] = await fixture.sql.query<{ record: unknown }>( `SELECT record FROM junior_scheduler_tasks WHERE id = $1`, @@ -213,7 +235,12 @@ ORDER BY created_at pluginName: "scheduler", }, ]), - ).resolves.toEqual({ existing: 2, migrated: 0, scanned: 2 }); + ).resolves.toEqual({ + existing: 2, + migrated: 0, + scanned: 3, + skipped: 1, + }); await expect( fixture.sql.query<{ createdAt: string; @@ -235,17 +262,21 @@ ORDER BY created_at { dir: schedulerMigrationsDir(), pluginName: "scheduler" }, { dir: memoryMigrationsDir(), pluginName: "memory" }, ]; - const migrationCount = roots.reduce( - (count, root) => - count + readMigrationFiles({ migrationsFolder: root.dir }).length, - 0, - ); - + const migrations = ( + await Promise.all( + roots.map(async (root) => await resolveMigrations(root.dir)), + ) + ).flat(); + const migrationCount = migrations.length; + const sqlMigrationCount = migrations.filter( + (migration) => migration.kind === "sql", + ).length; try { await expect(migratePluginSchemas(fixture.sql, roots)).resolves.toEqual({ existing: 0, - migrated: migrationCount, + migrated: sqlMigrationCount, scanned: migrationCount, + skipped: 1, }); const migrationTables = await fixture.sql.query<{ tablename: string }>(` SELECT tablename @@ -258,9 +289,10 @@ ORDER BY tablename await expect( migratePluginSchemas(fixture.sql, [...roots].reverse()), ).resolves.toEqual({ - existing: migrationCount, + existing: sqlMigrationCount, migrated: 0, scanned: migrationCount, + skipped: 1, }); } finally { await fixture.close(); @@ -515,20 +547,17 @@ ORDER BY tablename const run = await stateStore.claimDueRun({ nowMs: TEST_NOW_MS }); expect(run).toBeDefined(); - const context = { - db, - io: { info: () => {} }, - pluginSet: defineJuniorPlugins([schedulerPlugin()]), - stateAdapter, - }; - - await expect(runPluginStorageMigrations(context)).resolves.toEqual({ + await expect( + runSchedulerStateMigration({ fixture, stateAdapter }), + ).resolves.toEqual({ existing: 0, migrated: 2, missing: 0, scanned: 2, }); - await expect(runPluginStorageMigrations(context)).resolves.toEqual({ + await expect( + runSchedulerStateMigration({ fixture, stateAdapter }), + ).resolves.toEqual({ existing: 2, migrated: 0, missing: 0, @@ -603,12 +632,7 @@ ORDER BY tablename ); await expect( - runPluginStorageMigrations({ - db, - io: { info: () => {} }, - pluginSet: defineJuniorPlugins([schedulerPlugin()]), - stateAdapter, - }), + runSchedulerStateMigration({ fixture, stateAdapter }), ).resolves.toEqual({ existing: 0, migrated: 1, @@ -631,27 +655,17 @@ ORDER BY tablename } }, 15_000); - it("does not load scheduler storage migration from package-only plugin set", async () => { + it("does not apply scheduler SQL migrations from package-only config", async () => { const stateAdapter = createMemoryState(); await stateAdapter.connect(); const fixture = await createLocalJuniorSqlFixture(); + NEON.sql = fixture.sql; try { - await migrateSchedulerSchema(fixture); - const db = fixture.sql.db() as unknown as SchedulerDb; - const stateStore = createSchedulerStore( - createPluginState("scheduler", stateAdapter), - ); - const task = createTask({ id: "sched_package_config" }); - await stateStore.saveTask(task); - const run = await stateStore.claimDueRun({ nowMs: TEST_NOW_MS }); - expect(run).toBeDefined(); - await expect( - runPluginStorageMigrations({ - db, + migratePluginJournals({ io: { info: () => {} }, - pluginSet: defineJuniorPlugins(["@sentry/junior-scheduler"]), + pluginCatalogConfig: { packages: ["@sentry/junior-scheduler"] }, stateAdapter, }), ).resolves.toEqual({ @@ -660,17 +674,13 @@ ORDER BY tablename missing: 0, scanned: 0, }); - - const sqlStore = createSchedulerSqlStore(db); - await expect(sqlStore.getTask(task.id)).resolves.toBe(undefined); - await expect(sqlStore.getRun(run!.id)).resolves.toBe(undefined); } finally { await stateAdapter.disconnect(); await fixture.close(); } - }, 15_000); + }); - it("does not apply scheduler SQL migrations from package-only config", async () => { + it("applies scheduler SQL migrations from registration-only config", async () => { const stateAdapter = createMemoryState(); await stateAdapter.connect(); const fixture = await createLocalJuniorSqlFixture(); @@ -678,16 +688,24 @@ ORDER BY tablename try { await expect( - migratePluginsToSql({ + migratePluginJournals({ io: { info: () => {} }, - pluginCatalogConfig: { packages: ["@sentry/junior-scheduler"] }, + pluginSet: defineJuniorPlugins([schedulerPlugin()]), stateAdapter, }), ).resolves.toEqual({ existing: 0, - migrated: 0, + migrated: 3, missing: 0, - scanned: 0, + scanned: 3, + }); + + const db = fixture.sql.db() as unknown as SchedulerDb; + const store = createSchedulerSqlStore(db); + const task = createTask({ id: "sched_schema_registration_config" }); + await store.saveTask(task); + await expect(store.getTask(task.id)).resolves.toMatchObject({ + id: task.id, }); } finally { await stateAdapter.disconnect(); @@ -695,32 +713,29 @@ ORDER BY tablename } }); - it("applies scheduler SQL migrations from registration-only config", async () => { + it("runs a migration-only plugin registration", async () => { const stateAdapter = createMemoryState(); await stateAdapter.connect(); const fixture = await createLocalJuniorSqlFixture(); NEON.sql = fixture.sql; + const scheduler = schedulerPlugin(); + const migrationOnly = defineJuniorPlugin({ + manifest: scheduler.manifest, + packageName: scheduler.packageName, + }); try { await expect( - migratePluginsToSql({ + migratePluginJournals({ io: { info: () => {} }, - pluginSet: defineJuniorPlugins([schedulerPlugin()]), + pluginSet: defineJuniorPlugins([migrationOnly]), stateAdapter, }), ).resolves.toEqual({ existing: 0, - migrated: 2, + migrated: 3, missing: 0, - scanned: 2, - }); - - const db = fixture.sql.db() as unknown as SchedulerDb; - const store = createSchedulerSqlStore(db); - const task = createTask({ id: "sched_schema_registration_config" }); - await store.saveTask(task); - await expect(store.getTask(task.id)).resolves.toMatchObject({ - id: task.id, + scanned: 3, }); } finally { await stateAdapter.disconnect(); @@ -736,7 +751,7 @@ ORDER BY tablename try { await expect( - migratePluginsToSql({ + migratePluginJournals({ io: { info: () => {} }, pluginSet: defineJuniorPlugins([ "@sentry/junior-scheduler", @@ -746,9 +761,9 @@ ORDER BY tablename }), ).resolves.toEqual({ existing: 0, - migrated: 2, + migrated: 3, missing: 0, - scanned: 2, + scanned: 3, }); } finally { await stateAdapter.disconnect(); @@ -861,50 +876,4 @@ INSERT INTO junior_scheduler_runs ( await fixture.close(); } }, 15_000); - - it("passes database access to plugin storage migrations", async () => { - const stateAdapter = getStateAdapter(); - await stateAdapter.connect(); - const fixture = await createLocalJuniorSqlFixture(); - - try { - const db = fixture.sql.db() as unknown as SchedulerDb; - let receivedDb: unknown; - const plugin = defineJuniorPlugin({ - manifest: { - name: "stateless", - displayName: "Stateless", - description: "Storage migration with database access", - }, - hooks: { - migrateStorage(ctx) { - receivedDb = ctx.db; - return { - existing: 0, - migrated: 0, - missing: 0, - scanned: 1, - }; - }, - }, - }); - - await expect( - runPluginStorageMigrations({ - db, - io: { info: () => {} }, - pluginSet: defineJuniorPlugins([plugin]), - stateAdapter, - }), - ).resolves.toEqual({ - existing: 0, - migrated: 0, - missing: 0, - scanned: 1, - }); - expect(receivedDb).toBe(db); - } finally { - await fixture.close(); - } - }, 15_000); }); diff --git a/packages/junior/tests/fixtures/postgres/executor.ts b/packages/junior/tests/fixtures/postgres/executor.ts index 2e90d9ad0..3757405c3 100644 --- a/packages/junior/tests/fixtures/postgres/executor.ts +++ b/packages/junior/tests/fixtures/postgres/executor.ts @@ -1,7 +1,5 @@ import { type PoolClient, type QueryResultRow } from "pg"; import { drizzle } from "drizzle-orm/node-postgres"; -import { migrate } from "drizzle-orm/node-postgres/migrator"; -import type { MigrationConfig } from "drizzle-orm/migrator"; import type { JuniorDatabase, JuniorSqlExecutor } from "@/db/db"; import { createPostgresJuniorSqlExecutor } from "@/db/postgres"; import { juniorSqlSchema } from "@/db/schema"; @@ -37,10 +35,6 @@ class ClientJuniorSqlExecutor implements JuniorSqlExecutor { return result.rows as T[]; } - async migrate(config: MigrationConfig): Promise { - await migrate(drizzle(this.client, { schema: juniorSqlSchema }), config); - } - async transaction(callback: () => Promise): Promise { const savepoint = `junior_test_savepoint_${++this.savepointId}`; await this.client.query(`SAVEPOINT ${savepoint}`); diff --git a/packages/junior/tests/fixtures/sql.ts b/packages/junior/tests/fixtures/sql.ts index 2d88accac..61f562daa 100644 --- a/packages/junior/tests/fixtures/sql.ts +++ b/packages/junior/tests/fixtures/sql.ts @@ -5,7 +5,6 @@ import { createLocalPgliteFixture, type LocalPgliteFixture, } from "@sentry/junior-testing/pglite"; -import { migrate } from "drizzle-orm/pglite/migrator"; import type { PgliteDatabase } from "drizzle-orm/pglite"; import { createEmptyJuniorSqlFixture, @@ -43,7 +42,6 @@ export async function createLocalJuniorSqlFixture(): Promise fixture.close(), db: () => fixture.db() as unknown as JuniorDatabase, execute: (statement, params) => fixture.execute(statement, params), - migrate: (config) => migrate(fixture.db(), config), query: (statement: string, params?: readonly unknown[]) => fixture.query(statement, params), transaction: (callback) => fixture.transaction(callback), diff --git a/packages/junior/tests/integration/conversation-sql.test.ts b/packages/junior/tests/integration/conversation-sql.test.ts index 482ed0251..1882582ca 100644 --- a/packages/junior/tests/integration/conversation-sql.test.ts +++ b/packages/junior/tests/integration/conversation-sql.test.ts @@ -399,7 +399,7 @@ WHERE table_schema = 'public' ) ORDER BY column_name `); - expect(migrationRows?.count).toBe(3); + expect(migrationRows?.count).toBe(4); expect(searchIndex?.exists).toBe(true); expect(metricColumns.map((row) => row.column_name)).toEqual([ "duration_ms", diff --git a/packages/junior/tests/unit/sql/executor.test.ts b/packages/junior/tests/unit/sql/executor.test.ts index 4950889e4..329ca8e0f 100644 --- a/packages/junior/tests/unit/sql/executor.test.ts +++ b/packages/junior/tests/unit/sql/executor.test.ts @@ -13,7 +13,6 @@ function executor(name: string): JuniorSqlExecutor { throw new Error(`${name} test executor does not expose Drizzle`); }), execute: vi.fn(), - migrate: vi.fn(), query: vi.fn(), transaction: vi.fn(async (callback) => await callback()), withLock: vi.fn(async (_lockName, callback) => await callback()), diff --git a/packages/junior/tsconfig.build.json b/packages/junior/tsconfig.build.json index ca568009e..3edb702bb 100644 --- a/packages/junior/tsconfig.build.json +++ b/packages/junior/tsconfig.build.json @@ -13,6 +13,7 @@ "src/app.ts", "src/handlers/**/*.ts", "src/instrumentation.ts", + "src/migration-helpers/**/*.ts", "src/nitro.ts", "src/reporting.ts", "src/vercel.ts", diff --git a/packages/junior/tsup.config.ts b/packages/junior/tsup.config.ts index a1791cd3b..38fe661b6 100644 --- a/packages/junior/tsup.config.ts +++ b/packages/junior/tsup.config.ts @@ -15,6 +15,7 @@ export default defineConfig({ api: "src/api.ts", "api/schema": "src/api/schema.ts", instrumentation: "src/instrumentation.ts", + "migration-helpers/v1": "src/migration-helpers/v1.ts", nitro: "src/nitro.ts", vercel: "src/vercel.ts", }, @@ -35,6 +36,7 @@ export default defineConfig({ "@chat-adapter/state-redis", "@earendil-works/pi-agent-core", "@earendil-works/pi-ai", + "@sentry/junior-migrations", "@sinclair/typebox", "@slack/web-api", "@vercel/functions", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5628a4e19..2621bc436 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -169,6 +169,9 @@ importers: '@neondatabase/serverless': specifier: ^1.1.0 version: 1.1.0 + '@sentry/junior-migrations': + specifier: workspace:* + version: link:../junior-migrations '@sentry/junior-plugin-api': specifier: workspace:* version: link:../junior-plugin-api @@ -466,6 +469,9 @@ importers: specifier: 'catalog:' version: 4.4.3 devDependencies: + '@sentry/junior-migrations': + specifier: workspace:* + version: link:../junior-migrations '@sentry/junior-testing': specifier: workspace:* version: file:packages/junior-testing @@ -488,6 +494,30 @@ importers: specifier: ^4.1.7 version: 4.1.7(@types/node@25.9.1)(tsx@4.22.3) + packages/junior-migrations: + devDependencies: + '@types/node': + specifier: ^25.9.1 + version: 25.9.1 + drizzle-kit: + specifier: 'catalog:' + version: 0.31.10 + drizzle-orm: + specifier: 'catalog:' + version: 0.45.2 + oxlint: + specifier: ^1.66.0 + version: 1.66.0 + tsup: + specifier: ^8.5.1 + version: 8.5.1(tsx@4.22.3)(typescript@6.0.3) + typescript: + specifier: ^6.0.3 + version: 6.0.3 + vitest: + specifier: ^4.1.7 + version: 4.1.7(@types/node@25.9.1)(tsx@4.22.3) + packages/junior-notion: {} packages/junior-plugin-api: @@ -521,6 +551,9 @@ importers: specifier: 'catalog:' version: 4.4.3 devDependencies: + '@sentry/junior-migrations': + specifier: workspace:* + version: link:../junior-migrations '@types/node': specifier: ^25.9.1 version: 25.9.1 @@ -3164,6 +3197,10 @@ packages: '@sentry/junior-memory@file:packages/junior-memory': resolution: {directory: packages/junior-memory, type: directory} + '@sentry/junior-migrations@file:packages/junior-migrations': + resolution: {directory: packages/junior-migrations, type: directory} + hasBin: true + '@sentry/junior-plugin-api@file:packages/junior-plugin-api': resolution: {directory: packages/junior-plugin-api, type: directory} @@ -6879,8 +6916,8 @@ packages: resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==} engines: {node: '>=18'} - string-width@8.2.1: - resolution: {integrity: sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA==} + string-width@8.2.2: + resolution: {integrity: sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg==} engines: {node: '>=20'} string_decoder@1.3.0: @@ -10102,6 +10139,8 @@ snapshots: - sql.js - sqlite3 + '@sentry/junior-migrations@file:packages/junior-migrations': {} + '@sentry/junior-plugin-api@file:packages/junior-plugin-api': dependencies: commander: 14.0.3 @@ -10262,6 +10301,7 @@ snapshots: '@logtape/logtape': 2.1.1 '@modelcontextprotocol/sdk': 1.29.0(zod@4.4.3) '@neondatabase/serverless': 1.1.0 + '@sentry/junior-migrations': file:packages/junior-migrations '@sentry/junior-plugin-api': file:packages/junior-plugin-api '@sentry/node': 10.53.1 '@sinclair/typebox': 0.34.49 @@ -11713,7 +11753,7 @@ snapshots: cli-truncate@5.2.0: dependencies: slice-ansi: 8.0.0 - string-width: 8.2.1 + string-width: 8.2.2 cli-width@4.1.0: {} @@ -14362,6 +14402,12 @@ snapshots: postcss: 8.5.15 tsx: 4.22.3 + postcss-load-config@6.0.1(tsx@4.22.3): + dependencies: + lilconfig: 3.1.3 + optionalDependencies: + tsx: 4.22.3 + postcss-nested@6.2.0(postcss@8.5.15): dependencies: postcss: 8.5.15 @@ -15145,7 +15191,7 @@ snapshots: get-east-asian-width: 1.6.0 strip-ansi: 7.2.0 - string-width@8.2.1: + string-width@8.2.2: dependencies: get-east-asian-width: 1.6.0 strip-ansi: 7.2.0 @@ -15412,6 +15458,33 @@ snapshots: - tsx - yaml + tsup@8.5.1(tsx@4.22.3)(typescript@6.0.3): + dependencies: + bundle-require: 5.1.0(esbuild@0.27.7) + cac: 6.7.14 + chokidar: 4.0.3 + consola: 3.4.2 + debug: 4.4.3 + esbuild: 0.27.7 + fix-dts-default-cjs-exports: 1.0.1 + joycon: 3.1.1 + picocolors: 1.1.1 + postcss-load-config: 6.0.1(tsx@4.22.3) + resolve-from: 5.0.0 + rollup: 4.60.4 + source-map: 0.7.6 + sucrase: 3.35.1 + tinyexec: 1.1.2 + tinyglobby: 0.2.16 + tree-kill: 1.2.2 + optionalDependencies: + typescript: 6.0.3 + transitivePeerDependencies: + - jiti + - supports-color + - tsx + - yaml + tsx@4.21.0: dependencies: esbuild: 0.27.7 @@ -15979,7 +16052,7 @@ snapshots: wrap-ansi@10.0.0: dependencies: ansi-styles: 6.2.3 - string-width: 8.2.1 + string-width: 8.2.2 strip-ansi: 7.2.0 wrap-ansi@7.0.0: diff --git a/scripts/bump-release-versions.mjs b/scripts/bump-release-versions.mjs index 07646a8d5..d37091931 100644 --- a/scripts/bump-release-versions.mjs +++ b/scripts/bump-release-versions.mjs @@ -10,6 +10,7 @@ if (!newVersion) { const files = [ "packages/junior/package.json", + "packages/junior-migrations/package.json", "packages/junior-plugin-api/package.json", "packages/junior-agent-browser/package.json", "packages/junior-amplitude/package.json",