diff --git a/.craft.yml b/.craft.yml index be494ed2d..caf81919d 100644 --- a/.craft.yml +++ b/.craft.yml @@ -7,6 +7,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 6fea7428b..a1abd30ba 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/** @@ -216,6 +217,7 @@ jobs: - run: pnpm --filter @sentry/junior-memory build - run: pnpm --filter @sentry/junior-github build - run: pnpm --filter @sentry/junior-vercel build + - run: pnpm --filter @sentry/junior-migrations test - run: pnpm --filter @sentry/junior-memory test - run: pnpm --filter @sentry/junior-github test - run: pnpm --filter @sentry/junior-vercel test @@ -343,6 +345,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..57da71747 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -54,6 +54,8 @@ pnpm release:check - `policies/` contains durable repo-wide engineering rules. - Package and module `README.md` files explain implemented architecture and non-obvious invariants near the code they describe. +- The [database migration guide](https://junior.sentry.dev/contribute/database-migrations/) + explains when and how to generate schema and data migrations. - Code, schemas, exported types, and tests are the implementation authority. - `openspec/changes/` contains temporary implementation plans. Move durable explanation beside the code and delete a plan when its work is complete. @@ -119,6 +121,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 8ecc578a1..165d50abb 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 506a1fa1b..75079e953 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 --filter @sentry/junior-vercel 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 --filter @sentry/junior-vercel 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-vercel 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-vercel 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-vercel 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-vercel 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-vercel 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-vercel 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/astro.config.mjs b/packages/docs/astro.config.mjs index 5f47981d5..15740f23d 100644 --- a/packages/docs/astro.config.mjs +++ b/packages/docs/astro.config.mjs @@ -178,6 +178,10 @@ export default defineConfig({ label: "Contribute", items: [ { label: "Development", link: "/contribute/development/" }, + { + label: "Database Migrations", + link: "/contribute/database-migrations/", + }, { label: "Testing", link: "/contribute/testing/" }, { label: "Local Agent Validation", @@ -191,10 +195,7 @@ export default defineConfig({ ], }, ], - plugins: [ - sentryStarlightTheme(), - sentryAgentMarkdown(), - ], + plugins: [sentryStarlightTheme(), sentryAgentMarkdown()], }), ], markdown: { diff --git a/packages/docs/src/content/docs/cli/upgrade.md b/packages/docs/src/content/docs/cli/upgrade.md index 44434f54a..29f537608 100644 --- a/packages/docs/src/content/docs/cli/upgrade.md +++ b/packages/docs/src/content/docs/cli/upgrade.md @@ -1,6 +1,6 @@ --- title: "junior upgrade" -description: "Apply Junior and plugin SQL schema migrations." +description: "Apply Junior and plugin schema or data migrations." type: reference summary: Bring the configured Junior SQL database up to the installed schema. prerequisites: @@ -12,7 +12,8 @@ related: --- Use `junior upgrade` after installing a new Junior release. The command applies -pending Drizzle migrations to the database configured by `DATABASE_URL`. +pending SQL schema and TypeScript data entries from the ordered Drizzle journals +owned by Junior and its enabled plugins. ## Usage @@ -22,9 +23,8 @@ Run the command from your Junior app: pnpm exec junior upgrade ``` -The command takes no arguments. It migrates the core Junior schema first, then -the schemas owned by enabled plugins. Already-applied migrations are recognized -as up to date and are not rerun. +The command takes no arguments. It migrates Junior first, then enabled plugins. +Already-applied entries are recognized as up to date and are not rerun. ## Upgrade bridge for older databases diff --git a/packages/docs/src/content/docs/contribute/database-migrations.md b/packages/docs/src/content/docs/contribute/database-migrations.md new file mode 100644 index 000000000..fa8e7e5f4 --- /dev/null +++ b/packages/docs/src/content/docs/contribute/database-migrations.md @@ -0,0 +1,113 @@ +--- +title: Database Migrations +description: Generate and review Junior schema and data migrations. +type: tutorial +summary: Choose the right migration type, generate it in the owning package, and verify the result. +prerequisites: + - /contribute/development/ +related: + - /cli/upgrade/ + - /contribute/testing/ + - /contribute/releasing/ +--- + +Junior keeps schema and data migrations in the same ordered Drizzle journal. +Each journal entry has exactly one source file: + +| Change | Generate | Result | +| ----------------------------------------------------------------------------- | ---------------- | ----------------------------------- | +| Tables, columns, indexes, or a simple SQL backfill | Schema migration | `.sql` plus a Drizzle snapshot | +| Data that needs application code, state storage, Redis, or resumable progress | Data migration | `.ts` with no new snapshot | + +Generate the migration in the package that owns the schema. Core Junior uses +`@sentry/junior`; plugin tables use their plugin package. + +## Generate a schema migration + +First change the owning Drizzle schema, such as +`packages/junior/src/db/schema.ts`. Then run: + +```bash +pnpm --filter @sentry/junior db:generate --name add_delivery_status +``` + +For a plugin, replace the package name: + +```bash +pnpm --filter @sentry/junior-memory db:generate --name add_memory_source +``` + +Drizzle adds a SQL file, updates `meta/_journal.json`, and writes a schema +snapshot. The generated SQL looks like: + +```sql title="migrations/0007_add_delivery_status.sql" +ALTER TABLE "junior_conversations" +ADD COLUMN "delivery_status" text; +``` + +Review the SQL against the schema change. Prefer SQL when the work is entirely +inside the same database and does not need application-level decoding. + +## Generate a data migration + +Use a data migration when SQL is not enough—for example, when moving data from +state storage, preserving a Redis index, decoding an old application record, or +checkpointing a long backfill. + +```bash +pnpm --filter @sentry/junior db:generate:data --name backfill_delivery_status +``` + +The command adds a TypeScript entry to the same journal: + +```ts title="migrations/0008_backfill_delivery_status.ts" +import type { MigrationV1 } from "@sentry/junior-migrations"; + +const migration = { + apiVersion: 1, + async up(context) { + void context; + }, +} satisfies MigrationV1; + +export default migration; +``` + +Implement `up` with the capabilities on `context`: + +- `database` for queries, transactions, and database locks +- `state` for Junior or plugin-scoped state +- `redis` when a migration must preserve raw Redis structures +- `progress` for a checkpoint that survives a failed or interrupted run +- `log` for operator-facing progress messages + +Keep one-off decoding and transformation logic in the migration file. Do not +import current Junior runtime modules from a data migration. + +## Review and verify + +Before committing: + +1. Confirm the new journal entry has exactly one `.sql` or `.ts` file. +2. Commit the updated `meta/_journal.json`. +3. Commit the new snapshot for a schema migration; a data migration should not + add one. +4. Do not edit, rename, reorder, or delete a migration that has shipped. +5. Make long-running data migrations safe to retry and use `progress` when + partial work must resume. + +Run the focused migration checks: + +```bash +pnpm --filter @sentry/junior-migrations test +pnpm typecheck +``` + +`junior upgrade` runs schema and data entries in journal order. Schema-bootstrap +mode is only for constructing empty test databases; it is not an upgrade path +for an existing installation. + +## Next step + +Add focused coverage using the guidance in [Testing](/contribute/testing/), +then review the release path in [Releasing](/contribute/releasing/). diff --git a/packages/docs/src/content/docs/contribute/development.md b/packages/docs/src/content/docs/contribute/development.md index 8d0822711..dacd3bf52 100644 --- a/packages/docs/src/content/docs/contribute/development.md +++ b/packages/docs/src/content/docs/contribute/development.md @@ -5,6 +5,7 @@ type: tutorial summary: Set up Junior locally, run checks, and use isolated worktrees for parallel agent or contributor tasks. prerequisites: [] related: + - /contribute/database-migrations/ - /contribute/testing/ - /contribute/releasing/ - /start-here/quickstart/ 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-evals/postgres-global-setup.ts b/packages/junior-evals/postgres-global-setup.ts index 743c3d1a7..0c29d35c9 100644 --- a/packages/junior-evals/postgres-global-setup.ts +++ b/packages/junior-evals/postgres-global-setup.ts @@ -6,7 +6,7 @@ import { type PostgresHarnessConfig, } from "@sentry/junior-testing/postgres"; import { migrateSchema } from "@/chat/conversations/sql/migrations"; -import { migratePluginSchemas } from "@/chat/plugins/migrations"; +import { bootstrapPluginSchemas } from "@/chat/plugins/migrations"; import { createPostgresJuniorSqlExecutor } from "@/db/postgres"; const workspaceRoot = path.resolve( @@ -43,8 +43,8 @@ export default async function setup( migrateTemplate: async (connectionString) => { const executor = createPostgresJuniorSqlExecutor({ connectionString }); try { - await migrateSchema(executor); - await migratePluginSchemas(executor, [ + await migrateSchema(executor, { mode: "schema-bootstrap" }); + await bootstrapPluginSchemas(executor, [ { dir: path.resolve( workspaceRoot, 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 07b877453..75cf5e45c 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..1495c5c26 --- /dev/null +++ b/packages/junior-migrations/README.md @@ -0,0 +1,54 @@ +# `@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. Migration-specific parsers and transforms belong +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, replaces the +empty SQL file with a `MigrationV1` TypeScript scaffold, and removes the +unchanged custom snapshot. Schema generation continues from the latest real +schema snapshot while preserving the mixed journal order. + +## 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 and, for TypeScript entries, a context and +loader. The +same database adapter drives the journal ledger and is exposed as +`context.database`, so migration files never own connection or driver setup. + +Normal upgrades run with `mode: "all"` and execute every SQL and TypeScript +entry in journal order. `mode: "schema-bootstrap"` is reserved for constructing +an empty database at the latest schema in tests or bootstrap tooling. It skips +TypeScript data migrations while executing SQL entries across the full journal, +so it must not be used to upgrade an existing installation. + +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 stable parsing primitives and +other reusable infrastructure. 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. + +This source validation is an authoring guard for trusted packaged migration +code, not a security sandbox for untrusted scripts. diff --git a/packages/junior-migrations/package.json b/packages/junior-migrations/package.json new file mode 100644 index 000000000..dc8c8aa75 --- /dev/null +++ b/packages/junior-migrations/package.json @@ -0,0 +1,47 @@ +{ + "name": "@sentry/junior-migrations", + "version": "0.109.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..c934f4a6c --- /dev/null +++ b/packages/junior-migrations/src/generate.ts @@ -0,0 +1,119 @@ +import { spawn } from "node:child_process"; +import { mkdtemp, readFile, rename, rm, writeFile } from "node:fs/promises"; +import { relative, resolve, sep } 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`; +} + +function isMissingJournal(error: unknown): boolean { + return ( + error instanceof Error && + (error.cause as NodeJS.ErrnoException | undefined)?.code === "ENOENT" + ); +} + +async function runDrizzleGenerate( + options: GenerateTypeScriptMigrationOptions, + cwd: string, + folder: string, +): Promise { + const configDirectory = await mkdtemp( + resolve(cwd, ".junior-migrations-config-"), + ); + try { + const configPath = resolve(cwd, options.configPath); + const relativeConfigPath = relative(configDirectory, configPath) + .split(sep) + .join("/"); + const configSpecifier = relativeConfigPath.startsWith(".") + ? relativeConfigPath + : `./${relativeConfigPath}`; + const relativeOutputPath = relative(cwd, folder).split(sep).join("/"); + const configOutputPath = relativeOutputPath.startsWith(".") + ? relativeOutputPath + : `./${relativeOutputPath}`; + const overrideConfigPath = resolve(configDirectory, "drizzle.config.ts"); + await writeFile( + overrideConfigPath, + `import config from ${JSON.stringify(configSpecifier)};\nexport default { ...config, out: ${JSON.stringify(configOutputPath)} };\n`, + "utf8", + ); + await run( + "drizzle-kit", + [ + "generate", + "--custom", + "--config", + overrideConfigPath, + "--name", + options.name, + ], + cwd, + ); + } finally { + await rm(configDirectory, { recursive: true, force: true }); + } +} + +/** 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); + let before; + try { + before = await readMigrationJournal(folder); + } catch (error) { + if (!isMissingJournal(error)) throw error; + before = []; + } + await runDrizzleGenerate(options, cwd, folder); + 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`); + const snapshotPath = resolve( + folder, + "meta", + `${String(entry.index).padStart(4, "0")}_snapshot.json`, + ); + await rename(sqlPath, typescriptPath); + await rm(snapshotPath, { force: true }); + 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..1770006de --- /dev/null +++ b/packages/junior-migrations/src/index.ts @@ -0,0 +1,20 @@ +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, + MigrationJournalExecutor, + 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..142ac7573 --- /dev/null +++ b/packages/junior-migrations/src/journal.ts @@ -0,0 +1,186 @@ +import { createHash } from "node:crypto"; +import { lstat, readFile, realpath } from "node:fs/promises"; +import { isAbsolute, join, relative, sep } 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" || + !/^[a-z0-9][a-z0-9_-]*$/.test(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, + migrationsRoot: string, +): Promise { + try { + const stat = await lstat(path); + if (!stat.isFile() || stat.isSymbolicLink()) { + throw new Error(`Migration source must be a regular file: ${path}`); + } + const canonical = await realpath(path); + const fromRoot = relative(migrationsRoot, canonical); + if ( + fromRoot === ".." || + fromRoot.startsWith(`..${sep}`) || + isAbsolute(fromRoot) + ) { + throw new Error(`Migration source escapes its journal root: ${path}`); + } + 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 validateRuntimeSpecifier = (specifier: string | undefined): void => { + 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`, + ); + } + }; + const imports = source.matchAll( + /^\s*import\s+([\s\S]*?)\s+from\s+["']([^"']+)["'];?/gm, + ); + for (const match of imports) { + const clause = match[1]?.trim(); + const specifier = match[2]; + if (clause?.startsWith("type ")) { + continue; + } + validateRuntimeSpecifier(specifier); + } + const exports = source.matchAll( + /^\s*export\s+([\s\S]*?)\s+from\s+["']([^"']+)["'];?/gm, + ); + for (const match of exports) { + const clause = match[1]?.trim(); + if (clause?.startsWith("type ")) { + continue; + } + validateRuntimeSpecifier(match[2]); + } + const executableSource = source.replace( + /\/\*[\s\S]*?\*\/|\/\/[^\r\n]*|"(?:\\[\s\S]|[^"\\])*"|'(?:\\[\s\S]|[^'\\])*'|`(?:\\[\s\S]|[^`\\])*`/g, + (match) => match.replace(/[^\r\n]/g, " "), + ); + if ( + /^\s*import\s*["']/m.test(source) || + /\b(?:import\s*\(|require\s*\()/.test(executableSource) + ) { + 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 migrationsRoot = await realpath(migrationsFolder); + 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, migrationsRoot), + optionalFile(typescriptPath, migrationsRoot), + ]); + 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..b1a66b15b --- /dev/null +++ b/packages/junior-migrations/src/runner.ts @@ -0,0 +1,405 @@ +import { createHash } from "node:crypto"; +import { readFile } from "node:fs/promises"; +import type { + MigrationContextV1, + MigrationDatabaseAdapter, + MigrationJournalExecutor, + MigrationJsonValue, + MigrationRunResult, + MigrationV1, + ResolvedMigration, + TypeScriptMigrationLoader, +} from "./types"; +import { resolveMigrations } from "./journal"; + +interface MigrationRow { + createdAt: string; + hash: string; + progress: unknown; + status: string | null; +} + +function migrationJsonValue( + value: unknown, + label: string, + seen = new WeakSet(), +): MigrationJsonValue { + if ( + value === null || + typeof value === "string" || + typeof value === "boolean" + ) { + return value; + } + if (typeof value === "number") { + if (Number.isFinite(value)) return value; + throw new Error(`${label} must contain only finite JSON numbers`); + } + if (typeof value !== "object") { + throw new Error(`${label} must be JSON-compatible`); + } + if (seen.has(value)) { + throw new Error(`${label} must not contain circular references`); + } + seen.add(value); + if (Array.isArray(value)) { + const result = value.map((item) => migrationJsonValue(item, label, seen)); + seen.delete(value); + return result; + } + const prototype = Object.getPrototypeOf(value); + if (prototype !== Object.prototype && prototype !== null) { + throw new Error(`${label} must contain only plain JSON objects`); + } + const result = Object.fromEntries( + Object.entries(value).map(([key, item]) => [ + key, + migrationJsonValue(item, label, seen), + ]), + ); + seen.delete(value); + return result; +} + +interface RunMigrationJournalBaseOptions { + beforeRun?: () => Promise; + executor: MigrationJournalExecutor; + migrationsFolder: string; + migrationsTable: string; +} + +interface RunAllMigrationJournalOptions extends RunMigrationJournalBaseOptions { + createContext: (args: { + migration: ResolvedMigration; + progress: MigrationContextV1["progress"]; + }) => MigrationContextV1 | Promise; + loadTypeScript: TypeScriptMigrationLoader; + mode?: "all"; +} + +interface RunSchemaBootstrapMigrationJournalOptions extends RunMigrationJournalBaseOptions { + createContext?: never; + loadTypeScript?: never; + mode: "schema-bootstrap"; +} + +/** Host capabilities and execution mode for one mixed migration journal. */ +export type RunMigrationJournalOptions = + | RunAllMigrationJournalOptions + | RunSchemaBootstrapMigrationJournalOptions; + +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: MigrationJournalExecutor, + 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: MigrationJournalExecutor, + 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: MigrationJournalExecutor; + 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: MigrationJournalExecutor; + 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); + const sourceChanged = + args.row !== undefined && args.row.hash !== args.migration.hash; + if ( + args.row && + sourceChanged && + args.row.status !== "failed" && + args.row.status !== "running" + ) { + throw new Error(`Migration ${args.migration.tag} changed after it started`); + } + if (args.row) { + if (sourceChanged) { + await args.executor.execute( + `UPDATE ${qualified} + SET hash = $1, name = $2, kind = 'typescript', status = 'running', + progress = NULL, result = NULL, started_at = NOW(), completed_at = NULL + WHERE created_at = $3`, + [args.migration.hash, args.migration.tag, args.migration.when], + ); + } else { + 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 == null + ? undefined + : migrationJsonValue(current.progress, "Migration progress"); + }, + async save(value) { + const progressValue = migrationJsonValue(value, "Migration progress"); + await args.executor.execute( + `UPDATE ${qualified} SET progress = $1, status = 'running' WHERE created_at = $2`, + [progressValue, args.migration.when], + ); + }, + }; + try { + const context = await args.createContext({ + migration: args.migration, + progress, + }); + const currentSource = await readFile(args.migration.path, "utf8"); + const currentHash = createHash("sha256") + .update(currentSource) + .digest("hex"); + if (currentHash !== args.migration.hash) { + throw new Error(`Migration ${args.migration.tag} changed before loading`); + } + const migration = migrationV1( + await args.loadTypeScript(args.migration.path), + args.migration.tag, + ); + const result = await migration.up(context); + const resultValue = + result === undefined + ? null + : migrationJsonValue(result, "Migration result"); + await args.executor.execute( + `UPDATE ${qualified} + SET status = 'completed', result = $1, completed_at = NOW() + WHERE created_at = $2`, + [resultValue, args.migration.when], + ); + } catch (error) { + try { + await args.executor.execute( + `UPDATE ${qualified} SET status = 'failed' WHERE created_at = $1`, + [args.migration.when], + ); + } catch (statusError) { + throw new AggregateError( + [error, statusError], + `Migration ${args.migration.tag} failed and its failure status could not be persisted`, + ); + } + 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 && + !( + migration.kind === "typescript" && + (row.status === "failed" || row.status === "running") + ) + ) { + throw new Error( + `Migration ${migration.tag} changed after it started`, + ); + } + if (row?.status === "completed" || (row && row.status === null)) { + result.existing += 1; + continue; + } + if (mode === "schema-bootstrap" && 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..e3b3b2528 --- /dev/null +++ b/packages/junior-migrations/src/types.ts @@ -0,0 +1,107 @@ +/** Database capabilities exposed to v1 TypeScript migrations. */ +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; +} + +/** Host executor used to serialize and persist one migration journal. */ +export interface MigrationJournalExecutor extends MigrationDatabaseAdapter { + 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..6536f7192 --- /dev/null +++ b/packages/junior-migrations/tests/generate.test.ts @@ -0,0 +1,152 @@ +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 expect( + access(join(root, "migrations", "meta", "0001_snapshot.json")), + ).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'); +}); + +it("does not invoke Drizzle when an existing journal is invalid", async () => { + const relativeRoot = `.tmp-invalid-migrations-${Date.now()}`; + const root = join(process.cwd(), relativeRoot); + temporaryDirectories.push(root); + await mkdir(join(root, "migrations", "meta"), { recursive: true }); + await writeFile( + join(root, "migrations", "meta", "_journal.json"), + JSON.stringify({ dialect: "sqlite", entries: [] }), + ); + + await expect( + generateTypeScriptMigration({ + configPath: `${relativeRoot}/drizzle.config.ts`, + cwd: process.cwd(), + migrationsFolder: `${relativeRoot}/migrations`, + name: "backfill", + }), + ).rejects.toThrow("Unsupported Drizzle journal"); +}); + +it("forwards the requested output folder to Drizzle Kit", async () => { + const relativeRoot = `.tmp-migration-output-${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}/configured-migrations" });\n`, + ); + + const typescriptPath = await generateTypeScriptMigration({ + configPath: `${relativeRoot}/drizzle.config.ts`, + cwd: process.cwd(), + migrationsFolder: `${relativeRoot}/requested-migrations`, + name: "backfill", + }); + + expect(typescriptPath).toBe( + join(root, "requested-migrations", "0000_backfill.ts"), + ); + await expect(access(typescriptPath)).resolves.toBeUndefined(); + await expect(access(join(root, "configured-migrations"))).rejects.toThrow( + "ENOENT", + ); +}); diff --git a/packages/junior-migrations/tests/journal.test.ts b/packages/junior-migrations/tests/journal.test.ts new file mode 100644 index 000000000..ce2f3e1da --- /dev/null +++ b/packages/junior-migrations/tests/journal.test.ts @@ -0,0 +1,162 @@ +import { mkdtemp, mkdir, rm, symlink, 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("rejects multiline imports of Junior runtime modules", async () => { + const folder = await migrationFolder(["0000_multiline"]); + await writeFile( + join(folder, "0000_multiline.ts"), + 'import {\n getChatConfig,\n getDb,\n} from "@sentry/junior/internal";\nexport default { apiVersion: 1, async up() {} };\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("allows import syntax in comments and strings", async () => { + const folder = await migrationFolder(["0000_documented"]); + await writeFile( + join(folder, "0000_documented.ts"), + `// Never call import("@/db") from a migration. +const guidance = "require(\\"@/db\\") is unsupported"; +export default { apiVersion: 1, async up() { void guidance; } }; +`, + ); + + await expect(resolveMigrations(folder)).resolves.toMatchObject([ + { kind: "typescript", tag: "0000_documented" }, + ]); + }); + + it("still rejects executable dynamic imports", async () => { + const folder = await migrationFolder(["0000_dynamic"]); + await writeFile( + join(folder, "0000_dynamic.ts"), + 'export default { apiVersion: 1, async up() { await import("@/db"); } };\n', + ); + + await expect(resolveMigrations(folder)).rejects.toThrow( + "cannot load runtime modules", + ); + }); + + it("rejects runtime re-exports of Junior internals", async () => { + const folder = await migrationFolder(["0000_reexport"]); + await writeFile( + join(folder, "0000_reexport.ts"), + 'export { getChatConfig } from "@sentry/junior/internal";\nexport default { apiVersion: 1, async up() {} };\n', + ); + + await expect(resolveMigrations(folder)).rejects.toThrow( + "cannot import application runtime code", + ); + }); + + 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", + ); + }); + + it("rejects journal tags that escape the migration directory", async () => { + const folder = await migrationFolder(["../outside"]); + + await expect(resolveMigrations(folder)).rejects.toThrow( + "Invalid Drizzle journal entry", + ); + }); + + it("rejects symlinked migration sources", async () => { + const folder = await migrationFolder(["0000_symlink"]); + const outside = join(folder, "..", "outside-migration.ts"); + await writeFile( + outside, + "export default { apiVersion: 1, async up() {} };\n", + ); + await symlink(outside, join(folder, "0000_symlink.ts")); + + await expect(resolveMigrations(folder)).rejects.toThrow( + "Migration source must be a regular 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..a9429550e --- /dev/null +++ b/packages/junior-migrations/tests/runner.test.ts @@ -0,0 +1,430 @@ +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("hash = $1")) { + row.hash = String(parameters[0]); + } + if (normalized.includes("progress = NULL")) { + row.progress = undefined; + } + 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: async ({ 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("bootstraps the latest schema while leaving TypeScript entries pending", async () => { + const folder = await mixedFolder(); + const executor = new FakeExecutor(); + + await expect( + runMigrationJournal({ + executor, + migrationsFolder: folder, + migrationsTable: "__drizzle_test", + mode: "schema-bootstrap", + }), + ).resolves.toEqual({ existing: 0, migrated: 2, scanned: 3, skipped: 1 }); + expect([...executor.rows.keys()].sort()).toEqual([2_000, 2_002]); + expect(executor.statements).toEqual([ + "SELECT 'schema-zero';", + "SELECT 'schema-two';", + ]); + + const migration: MigrationV1 = { + apiVersion: 1, + async up() { + return { backfilled: true }; + }, + }; + await expect( + runMigrationJournal({ + executor, + migrationsFolder: folder, + migrationsTable: "__drizzle_test", + loadTypeScript: async () => ({ default: migration }), + createContext: ({ progress }) => ({ + database: executor, + log: () => {}, + progress, + state: fakeMigrationState(), + }), + }), + ).resolves.toEqual({ existing: 2, migrated: 1, scanned: 3, skipped: 0 }); + expect(executor.statements).toEqual([ + "SELECT 'schema-zero';", + "SELECT 'schema-two';", + ]); + }); + + 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';", + ]); + }); + + it.each(["failed", "running"] as const)( + "restarts a %s TypeScript migration after its source changes", + async (retryStatus) => { + const folder = await mixedFolder(); + const executor = new FakeExecutor(); + let attempts = 0; + let resumedProgress: unknown; + const migration: MigrationV1 = { + apiVersion: 1, + async up(context) { + attempts += 1; + if (attempts === 1) { + await context.progress.save({ cursor: 1 }); + throw new Error("needs a source fix"); + } + resumedProgress = await context.progress.load(); + }, + }; + const options = { + executor, + migrationsFolder: folder, + migrationsTable: "__drizzle_test", + loadTypeScript: async () => ({ default: migration }), + createContext: ({ + progress, + }: { + progress: MigrationContextV1["progress"]; + }) => ({ + database: executor, + log: () => {}, + progress, + state: fakeMigrationState(), + }), + }; + + await expect(runMigrationJournal(options)).rejects.toThrow( + "needs a source fix", + ); + const interruptedRow = executor.rows.get(2_001); + expect(interruptedRow?.status).toBe("failed"); + if (interruptedRow) { + interruptedRow.status = retryStatus; + } + const failedHash = interruptedRow?.hash; + await writeFile( + join(folder, "0001_data.ts"), + "export default { apiVersion: 1, async up() { return { fixed: true }; } };\n", + ); + + await expect(runMigrationJournal(options)).resolves.toEqual({ + existing: 1, + migrated: 2, + scanned: 3, + skipped: 0, + }); + expect(resumedProgress).toBeUndefined(); + expect(executor.rows.get(2_001)).toMatchObject({ + status: "completed", + }); + expect(executor.rows.get(2_001)?.hash).not.toBe(failedHash); + }, + ); + + it("rejects source changes after a TypeScript migration completes", async () => { + const folder = await mixedFolder(); + const executor = new FakeExecutor(); + const migration: MigrationV1 = { + apiVersion: 1, + async up() {}, + }; + const options = { + executor, + migrationsFolder: folder, + migrationsTable: "__drizzle_test", + loadTypeScript: async () => ({ default: migration }), + createContext: ({ + progress, + }: { + progress: MigrationContextV1["progress"]; + }) => ({ + database: executor, + log: () => {}, + progress, + state: fakeMigrationState(), + }), + }; + await runMigrationJournal(options); + await writeFile( + join(folder, "0001_data.ts"), + "export default { apiVersion: 1, async up() { return { changed: true }; } };\n", + ); + + await expect(runMigrationJournal(options)).rejects.toThrow( + "changed after it started", + ); + }); + + it("rejects non-JSON migration results before completing the ledger row", async () => { + const folder = await mixedFolder(); + const executor = new FakeExecutor(); + const migration = { + apiVersion: 1, + async up() { + return Number.NaN as never; + }, + } satisfies MigrationV1; + + await expect( + runMigrationJournal({ + executor, + migrationsFolder: folder, + migrationsTable: "__drizzle_test", + loadTypeScript: async () => ({ default: migration }), + createContext: ({ progress }) => ({ + database: executor, + log: () => {}, + progress, + state: fakeMigrationState(), + }), + }), + ).rejects.toThrow("Migration result must contain only finite JSON numbers"); + expect(executor.rows.get(2_001)?.status).toBe("failed"); + }); +}); 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 2c1537c1f..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 @@ -58,6 +59,8 @@ reports, 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-scheduler/README.md b/packages/junior-scheduler/README.md index 073710f19..fcb9914a7 100644 --- a/packages/junior-scheduler/README.md +++ b/packages/junior-scheduler/README.md @@ -55,6 +55,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/package.json b/packages/junior-scheduler/package.json index 43af4ebc2..b39dbff63 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-testing/src/pglite.ts b/packages/junior-testing/src/pglite.ts index a76152d2b..933183e5a 100644 --- a/packages/junior-testing/src/pglite.ts +++ b/packages/junior-testing/src/pglite.ts @@ -21,11 +21,16 @@ export interface LocalPgliteFixture { ): Promise; transaction(callback: () => Promise): Promise; withLock(lockName: string, callback: () => Promise): Promise; + withMigrationLock( + lockName: string, + callback: () => Promise, + ): Promise; close(): Promise; } class LocalPgliteExecutor implements LocalPgliteFixture { private activeTransaction: Transaction | undefined; + private readonly migrationLocks = new Map>(); constructor( readonly client: PGlite, @@ -85,6 +90,30 @@ class LocalPgliteExecutor implements LocalPgliteFixture { }); } + async withMigrationLock( + lockName: string, + callback: () => Promise, + ): Promise { + if (!lockName) { + throw new Error("Migration lock name is required"); + } + const previous = this.migrationLocks.get(lockName) ?? Promise.resolve(); + let release!: () => void; + const current = new Promise((resolve) => { + release = resolve; + }); + this.migrationLocks.set(lockName, current); + await previous; + try { + return await callback(); + } finally { + release(); + if (this.migrationLocks.get(lockName) === current) { + this.migrationLocks.delete(lockName); + } + } + } + close(): Promise { return this.client.close(); } diff --git a/packages/junior/migrations/README.md b/packages/junior/migrations/README.md index 39d948d82..c81d5efc5 100644 --- a/packages/junior/migrations/README.md +++ b/packages/junior/migrations/README.md @@ -1,17 +1,17 @@ -# SQL migrations +# Migrations `src/db/schema.ts` is the Drizzle schema entrypoint. This directory is the -append-only history used to bring an existing database to that schema. +append-only history used to bring an existing database to that schema. It +extends Drizzle Kit's journal with self-contained +TypeScript data migrations. Drizzle Kit owns numbering, schema snapshots, and +journal entries; `junior upgrade` executes each entry as either `.sql` or +`.ts` through `@sentry/junior-migrations`. Data entries do not retain an +unchanged snapshot. -`junior upgrade` applies core migrations and enabled plugins' packaged Drizzle -migrations. Core SQL is recorded in `drizzle.__drizzle_junior_core`. Reruns -check that journal before taking the migration lock, so an already-current -schema returns without opening a second SQL connection. - -An existing Junior schema without the core Drizzle journal cannot be upgraded -directly. Upgrade it with `@sentry/junior@0.107.1` first so that bridge release -can establish the journal, then continue to the target version. A database with -no Junior tables remains a normal fresh install. +`junior upgrade` runs the ordered migration list in `src/cli/upgrade.ts`. Core +SQL runs under a migration lock and is recorded in +`drizzle.__drizzle_junior_core`, so reruns do not reapply it. Register backfills +after the schema migration they require. ## Add a migration @@ -20,11 +20,33 @@ no Junior tables remains a normal fresh install. 3. Review the generated SQL and commit it with `meta/_journal.json` and its snapshot. -- Put deterministic row transformations in the SQL migration that requires - them. +- Prefer SQL for schema changes and deterministic transformations of rows in + the same database. +- Use an application data migration only for external data or work that must be + decoded in application code; keep it bounded and rerunnable. - Never edit, rename, reorder, or delete an applied SQL migration or its metadata. Add a new migration to correct it. -Migration loading, locking, and the bridge-version guard live in -`src/chat/conversations/sql/migrations.ts`. Their integration coverage lives in -`tests/integration/conversation-sql.test.ts`. +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. + +Reusable infrastructure belongs in the append-only +`@sentry/junior/migration-helpers/v1` export. It may provide stable parsing and +other reusable infrastructure, 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. Existing databases without core Drizzle +history must first run the documented bridge release; current upgrades do not +infer or adopt legacy state. Fresh databases execute the complete journal +normally. Drizzle ORM's stock `migrate()` function is not compatible with +TypeScript entries in this folder. + +Migration loading, locking, and the bridge-release guard live in +`src/chat/conversations/sql/migrations.ts`. diff --git a/packages/junior/package.json b/packages/junior/package.json index d8132d2b8..159348247 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" @@ -55,6 +59,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", @@ -74,6 +79,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 979029770..ffcc1f1a5 100644 --- a/packages/junior/src/chat/conversations/sql/migrations.ts +++ b/packages/junior/src/chat/conversations/sql/migrations.ts @@ -1,7 +1,14 @@ /** SQL schema migrations for durable Junior records. */ import { basename, dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; -import { readMigrationFiles, type MigrationMeta } from "drizzle-orm/migrator"; +import { + runMigrationJournal, + type MigrationContextV1, + type MigrationRunResult, + 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 { isPostgresErrorCode } from "@/db/postgres-error"; import { juniorSqlSchema as schema } from "@/db/schema"; @@ -26,12 +33,6 @@ interface CoreMigrationState { hasJuniorTables: boolean; } -interface CoreMigrationResult { - existing: number; - migrated: number; - scanned: number; -} - /** Read core journal progress and detect existing pre-journal Junior tables. */ async function loadCoreMigrationState( executor: JuniorSqlMigrationExecutor, @@ -68,30 +69,6 @@ SELECT EXISTS ( }; } -function isCurrentMigrationState( - state: CoreMigrationState, - latestMigrationAt: number, -): boolean { - return state.appliedAt !== undefined && state.appliedAt >= latestMigrationAt; -} - -function migrationResult( - migrations: readonly MigrationMeta[], - state: CoreMigrationState, -): CoreMigrationResult { - const appliedAt = state.appliedAt; - const existing = - appliedAt === undefined - ? 0 - : migrations.filter((migration) => migration.folderMillis <= appliedAt) - .length; - return { - existing, - migrated: migrations.length - existing, - scanned: migrations.length, - }; -} - /** Reject legacy databases that must first run the bridge release upgrade. */ function assertSupportedMigrationState(state: CoreMigrationState): void { if (!state.hasJuniorTables || state.appliedAt !== undefined) { @@ -102,35 +79,62 @@ function assertSupportedMigrationState(state: CoreMigrationState): void { ); } +export type MigrateSchemaOptions = + | { mode: "schema-bootstrap" } + | { + getStateContext: () => Promise<{ + redisStateAdapter?: RedisStateAdapter; + stateAdapter: StateAdapter; + }>; + loadTypeScript: TypeScriptMigrationLoader; + log?: MigrationContextV1["log"]; + mode: "all"; + }; + export { schema }; -/** Apply the packaged Drizzle migrations during `junior upgrade`. */ +/** Apply all migrations, or bootstrap an empty test database to the latest schema. */ export async function migrateSchema( executor: JuniorSqlMigrationExecutor, -): Promise { + options: MigrateSchemaOptions, +): Promise { const migrationsFolder = migrationFolder(); - const migrations = readMigrationFiles({ migrationsFolder }); - const latestMigration = migrations.at(-1); - if (!latestMigration) { - throw new Error("No core Drizzle migrations were packaged"); - } - - const initialState = await loadCoreMigrationState(executor); - if (isCurrentMigrationState(initialState, latestMigration.folderMillis)) { - return migrationResult(migrations, initialState); - } - assertSupportedMigrationState(initialState); - - return await executor.withMigrationLock(MIGRATIONS_TABLE, async () => { - const lockedState = await loadCoreMigrationState(executor); - if (isCurrentMigrationState(lockedState, latestMigration.folderMillis)) { - return migrationResult(migrations, lockedState); - } - assertSupportedMigrationState(lockedState); - await executor.migrate({ - migrationsFolder, - migrationsTable: MIGRATIONS_TABLE, + const runAll = options.mode === "all"; + const baseOptions = { + beforeRun: async () => { + assertSupportedMigrationState(await loadCoreMigrationState(executor)); + }, + executor, + migrationsFolder, + migrationsTable: MIGRATIONS_TABLE, + }; + if (!runAll) { + return await runMigrationJournal({ + ...baseOptions, + mode: "schema-bootstrap", }); - return migrationResult(migrations, lockedState); + } + return await runMigrationJournal({ + ...baseOptions, + createContext: async ({ progress }): Promise => { + const { redisStateAdapter, stateAdapter } = + await options.getStateContext(); + return { + database: executor, + log: options.log ?? (() => {}), + progress, + ...(redisStateAdapter + ? { + redis: { + sendCommand: async (args: readonly string[]) => + await redisStateAdapter.getClient().sendCommand([...args]), + }, + } + : {}), + state: stateAdapter, + }; + }, + loadTypeScript: options.loadTypeScript, + mode: "all", }); } diff --git a/packages/junior/src/chat/plugins/migration-state.ts b/packages/junior/src/chat/plugins/migration-state.ts new file mode 100644 index 000000000..cea06e997 --- /dev/null +++ b/packages/junior/src/chat/plugins/migration-state.ts @@ -0,0 +1,49 @@ +import type { MigrationStateV1 } from "@sentry/junior-migrations"; +import type { StateAdapter } from "chat"; +import { + createPluginState, + pluginStateKey, + validatePluginStateKey, +} from "@/chat/plugins/state"; + +/** Project host state onto the permanent v1 migration API for one plugin. */ +export function createPluginMigrationStateV1( + plugin: string, + stateAdapter: StateAdapter, +): MigrationStateV1 { + const pluginState = createPluginState(plugin, stateAdapter); + const stateKey = (key: string): string => { + validatePluginStateKey(key); + return pluginStateKey(plugin, key); + }; + + return { + acquireLock: async (key, ttlMs) => { + await stateAdapter.connect(); + return await stateAdapter.acquireLock(stateKey(key), ttlMs); + }, + appendToList: async (key, value, options) => { + await stateAdapter.connect(); + await stateAdapter.appendToList(stateKey(key), value, options); + }, + connect: async () => { + await stateAdapter.connect(); + }, + delete: async (key) => { + await pluginState.delete(key); + }, + get: async (key: string) => await pluginState.get(key), + getList: async (key: string) => { + await stateAdapter.connect(); + return await stateAdapter.getList(stateKey(key)); + }, + releaseLock: async (lock) => { + await stateAdapter.releaseLock(lock); + }, + set: async (key, value, ttlMs) => { + await pluginState.set(key, value, ttlMs); + }, + setIfNotExists: async (key, value, ttlMs) => + await pluginState.setIfNotExists(key, value, ttlMs), + }; +} diff --git a/packages/junior/src/chat/plugins/migrations.ts b/packages/junior/src/chat/plugins/migrations.ts index d0073e21f..8d9bbe191 100644 --- a/packages/junior/src/chat/plugins/migrations.ts +++ b/packages/junior/src/chat/plugins/migrations.ts @@ -1,7 +1,15 @@ import { createHash } from "node:crypto"; -import { readMigrationFiles, type MigrationMeta } from "drizzle-orm/migrator"; +import { + resolveMigrations, + runMigrationJournal, + type MigrationContextV1, + type ResolvedMigration, + type TypeScriptMigrationLoader, +} from "@sentry/junior-migrations"; +import type { StateAdapter } from "chat"; +import type { RedisStateAdapter } from "@chat-adapter/state-redis"; +import { createPluginMigrationStateV1 } from "@/chat/plugins/migration-state"; import type { JuniorSqlMigrationExecutor } from "@/db/db"; -import { isPostgresErrorCode } from "@/db/postgres-error"; interface PluginMigrationRoot { /** Absolute path to the plugin's Drizzle migrations directory. */ @@ -9,19 +17,24 @@ interface PluginMigrationRoot { pluginName: string; } -interface PluginMigrationResult { +type PluginMigrationResult = { existing: number; migrated: number; scanned: number; -} - -export interface PluginMigrationSummary extends PluginMigrationResult { - pluginName: string; -} + skipped?: number; +}; -interface PluginMigrationOptions { - onPluginMigration?: (summary: PluginMigrationSummary) => void; -} +type PluginMigrationOptions = + | { mode: "schema-bootstrap" } + | { + getStateContext: () => Promise<{ + redisStateAdapter?: RedisStateAdapter; + stateAdapter: StateAdapter; + }>; + loadTypeScript: TypeScriptMigrationLoader; + log?: MigrationContextV1["log"]; + mode: "all"; + }; const LEGACY_SCHEDULER_BASELINE_HASH = "d1d2f712181dd3a0557808f0fc67fd0722691d25f4c8cfb816b77c71d19e1e42"; @@ -39,28 +52,6 @@ function migrationTable(pluginName: string): string { return `__drizzle_${label}_${hash}`; } -async function appliedMigrationTime( - executor: JuniorSqlMigrationExecutor, - table: string, -): Promise { - try { - 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); - } catch (error) { - if (isPostgresErrorCode(error, "42P01")) { - return undefined; - } - throw error; - } -} - async function legacyMigrationHashes( executor: JuniorSqlMigrationExecutor, pluginName: string, @@ -82,13 +73,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; @@ -105,10 +96,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, @@ -119,7 +117,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"); @@ -132,27 +130,16 @@ 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; } -/** 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 = {}, + options: PluginMigrationOptions, ): Promise { const result: PluginMigrationResult = { existing: 0, @@ -163,52 +150,68 @@ export async function migratePluginSchemas( left.pluginName.localeCompare(right.pluginName), ); for (const root of orderedRoots) { - const migrations = readMigrationFiles({ migrationsFolder: root.dir }); - if (migrations.length === 0) { - continue; - } + const migrations = await resolveMigrations(root.dir); const table = migrationTable(root.pluginName); - const initialTime = await appliedMigrationTime(executor, table); - const initialExisting = appliedCount(migrations, initialTime); - if (initialExisting === migrations.length) { - const summary = { - existing: initialExisting, - migrated: 0, - pluginName: root.pluginName, - scanned: migrations.length, - }; - result.scanned += summary.scanned; - result.existing += summary.existing; - options.onPluginMigration?.(summary); - continue; - } - const summary = 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); - if (existing < migrations.length) { - await executor.migrate({ - migrationsFolder: root.dir, - migrationsTable: table, }); - } - return { - existing, - migrated: migrations.length - existing, - pluginName: root.pluginName, - scanned: migrations.length, - }; - }); - result.scanned += summary.scanned; - result.existing += summary.existing; - result.migrated += summary.migrated; - options.onPluginMigration?.(summary); + }, + executor, + migrationsFolder: root.dir, + migrationsTable: table, + }; + const pluginResult = runAll + ? await runMigrationJournal({ + ...baseOptions, + createContext: async ({ progress }): Promise => { + const { redisStateAdapter, stateAdapter } = + await options.getStateContext(); + return { + database: executor, + log: options.log ?? (() => {}), + progress, + ...(redisStateAdapter + ? { + redis: { + sendCommand: async (args: readonly string[]) => + await redisStateAdapter + .getClient() + .sendCommand([...args]), + }, + } + : {}), + state: createPluginMigrationStateV1( + root.pluginName, + stateAdapter, + ), + }; + }, + loadTypeScript: options.loadTypeScript, + mode: "all", + }) + : await runMigrationJournal({ ...baseOptions, mode: "schema-bootstrap" }); + 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; } + +/** Construct enabled plugins' latest SQL schemas without running data entries. */ +export async function bootstrapPluginSchemas( + executor: JuniorSqlMigrationExecutor, + roots: readonly PluginMigrationRoot[], +): Promise { + return await migratePluginSchemas(executor, roots, { + mode: "schema-bootstrap", + }); +} diff --git a/packages/junior/src/chat/plugins/state.ts b/packages/junior/src/chat/plugins/state.ts index 4812176f3..ccd090b2f 100644 --- a/packages/junior/src/chat/plugins/state.ts +++ b/packages/junior/src/chat/plugins/state.ts @@ -9,7 +9,8 @@ function hashKeyPart(value: string): string { return createHash("sha256").update(value).digest("hex").slice(0, 32); } -function pluginStateKey(plugin: string, key: string): string { +/** Resolve one logical plugin key to its isolated host-state key. */ +export function pluginStateKey(plugin: string, key: string): string { const pluginPrefix = `junior:${plugin}`; if (key === pluginPrefix || key.startsWith(`${pluginPrefix}:`)) { return key; @@ -17,7 +18,8 @@ function pluginStateKey(plugin: string, key: string): string { return `junior:plugin_state:${hashKeyPart(plugin)}:${hashKeyPart(key)}`; } -function validatePluginStateKey(key: string): void { +/** Validate one logical plugin state key before namespacing it. */ +export function validatePluginStateKey(key: string): void { if (!key.trim()) { throw new Error("Plugin state key is required"); } diff --git a/packages/junior/src/cli/upgrade.ts b/packages/junior/src/cli/upgrade.ts index 288a352af..a69ed5348 100644 --- a/packages/junior/src/cli/upgrade.ts +++ b/packages/junior/src/cli/upgrade.ts @@ -1,22 +1,18 @@ -/** - * SQL-only upgrade entry point. - * - * It applies core and enabled-plugin Drizzle migrations through one owned - * executor; legacy state conversion belongs to the required bridge release. - */ -import { getChatConfig } from "@/chat/config"; -import { migrateSchema } from "@/chat/conversations/sql/migrations"; +import { + disconnectStateAdapter, + getConnectedStateContext, +} from "@/chat/state/adapter"; import { createJiti } from "jiti"; import { loadAppPluginSet } from "@/plugin-module"; -import { migratePluginsToSql } from "./upgrade/migrations/plugin-sql"; +import { migrateCoreJournal } from "./upgrade/migrations/core-journal"; +import { migratePluginJournals } from "./upgrade/migrations/plugin-journal"; import type { - MigrationSummary, - UpgradeContext, + MigrationContext, + MigrationResult, + MigrationStateContext, UpgradeIo, } from "./upgrade/types"; import { type JuniorPluginSet } from "@/plugins"; -import type { JuniorSqlExecutor } from "@/db/db"; -import { createJuniorSqlExecutor } from "@/db/executor"; const DEFAULT_IO: UpgradeIo = { info: console.log, @@ -56,71 +52,63 @@ export async function resolveUpgradePluginSet(): Promise< ); } -function migrationCount(count: number): string { - return `${count} ${count === 1 ? "migration" : "migrations"}`; -} - -function formatOwnerSummary(owner: string, summary: MigrationSummary): string { - if (summary.migrated === 0) { - return ` ${owner}: up to date (${migrationCount(summary.scanned)})`; +function formatMigrationResult(result: MigrationResult): string { + const fields = [ + `scanned=${result.scanned}`, + `migrated=${result.migrated}`, + `existing=${result.existing}`, + `missing=${result.missing}`, + ]; + if (result.skipped !== undefined) { + fields.push(`skipped=${result.skipped}`); } - return ` ${owner}: applied ${migrationCount(summary.migrated)} (${summary.scanned} total)`; + return fields.join(" "); } -function pluginMigrationOwner(pluginName: string): string { - const unscopedName = pluginName.split("/").at(-1) ?? pluginName; - return unscopedName.startsWith("junior-") - ? unscopedName - : `junior-${unscopedName}`; -} - -async function runDatabaseMigrations( - context: UpgradeContext, - io: UpgradeIo, -): Promise { - io.info("Checking database migrations..."); - const core = await migrateSchema(context.sqlExecutor); - io.info(formatOwnerSummary("junior", core)); - - const plugins = await migratePluginsToSql(context, { - onPluginMigration: (summary) => { - io.info( - formatOwnerSummary(pluginMigrationOwner(summary.pluginName), summary), - ); - }, - }); - const migrated = core.migrated + plugins.migrated; - const scanned = core.scanned + plugins.scanned; - io.info( - migrated === 0 - ? `Database is up to date (${migrationCount(scanned)}).` - : `Applied ${migrationCount(migrated)} (${scanned} total).`, - ); +/** Run all registered upgrade migrations in order. */ +export async function runUpgradeMigrations( + context: MigrationContext, +): Promise { + const results: MigrationResult[] = []; + const run = async ( + name: string, + migrate: (context: MigrationContext) => Promise, + ): Promise => { + context.io.info(`Running migration ${name}...`); + const result = await migrate(context); + context.io.info( + `Finished migration ${name}: ${formatMigrationResult(result)}`, + ); + results.push(result); + }; + await run("core-migrations", migrateCoreJournal); + await run("plugin-migrations", migratePluginJournals); + return results; } -/** Apply Junior and enabled-plugin database migrations. */ +/** Run one-shot Junior upgrade migrations against the configured state store. */ export async function runUpgrade( io: UpgradeIo = DEFAULT_IO, options: { pluginSet?: JuniorPluginSet | null } = {}, ): Promise { - const { sql } = getChatConfig(); - const sqlExecutor: JuniorSqlExecutor = createJuniorSqlExecutor({ - connectionString: sql.databaseUrl, - driver: sql.driver, - }); + let stateContext: Promise | undefined; + const getStateContext = (): Promise => { + stateContext ??= getConnectedStateContext(); + return stateContext; + }; try { const pluginSet = options.pluginSet === undefined ? await resolveUpgradePluginSet() : (options.pluginSet ?? undefined); - await runDatabaseMigrations( - { - pluginSet, - sqlExecutor, - }, + io.info("Running Junior upgrade migrations..."); + await runUpgradeMigrations({ + getStateContext, io, - ); + pluginSet, + }); + io.info("Junior upgrade complete."); } finally { - await sqlExecutor.close(); + await disconnectStateAdapter(); } } 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..e682a0757 --- /dev/null +++ b/packages/junior/src/cli/upgrade/migrations/core-journal.ts @@ -0,0 +1,36 @@ +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, { + getStateContext: context.getStateContext, + loadTypeScript: async (path) => + await migrationLoader.import>(path), + log: context.io.info, + mode: "all", + }); + 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/plugin-journal.ts b/packages/junior/src/cli/upgrade/migrations/plugin-journal.ts new file mode 100644 index 000000000..4c1b1c9f9 --- /dev/null +++ b/packages/junior/src/cli/upgrade/migrations/plugin-journal.ts @@ -0,0 +1,45 @@ +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 { resolveUpgradePluginCatalog } from "./upgrade-plugins"; +import type { MigrationContext, MigrationResult } from "../types"; + +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(); + const pluginCatalogConfig = await resolveUpgradePluginCatalog(context); + const previousConfig = pluginCatalogRuntime.setConfig(pluginCatalogConfig); + const executor = createJuniorSqlExecutor({ + connectionString: sql.databaseUrl, + driver: sql.driver, + }); + try { + const result = await migratePluginSchemas( + executor, + pluginCatalogRuntime.getMigrationRoots(), + { + getStateContext: context.getStateContext, + loadTypeScript: async (path) => + await migrationLoader.import>(path), + log: context.io.info, + mode: "all", + }, + ); + 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(); + } +} diff --git a/packages/junior/src/cli/upgrade/migrations/plugin-sql.ts b/packages/junior/src/cli/upgrade/migrations/plugin-sql.ts deleted file mode 100644 index 474351ab5..000000000 --- a/packages/junior/src/cli/upgrade/migrations/plugin-sql.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { - migratePluginSchemas, - type PluginMigrationSummary, -} from "@/chat/plugins/migrations"; -import { createPluginCatalogRuntime } from "@/chat/plugins/registry"; -import { resolveUpgradePluginCatalog } from "./upgrade-plugins"; -import type { MigrationSummary, UpgradeContext } from "../types"; - -/** Apply SQL schema migrations owned by explicitly enabled plugins. */ -export async function migratePluginsToSql( - context: UpgradeContext, - options: { - onPluginMigration?: (summary: PluginMigrationSummary) => void; - } = {}, -): Promise { - const pluginCatalog = createPluginCatalogRuntime(); - pluginCatalog.setConfig(await resolveUpgradePluginCatalog(context)); - return await migratePluginSchemas( - context.sqlExecutor, - pluginCatalog.getMigrationRoots(), - options, - ); -} diff --git a/packages/junior/src/cli/upgrade/migrations/upgrade-plugins.ts b/packages/junior/src/cli/upgrade/migrations/upgrade-plugins.ts index 901b16a2b..7d3fc46cd 100644 --- a/packages/junior/src/cli/upgrade/migrations/upgrade-plugins.ts +++ b/packages/junior/src/cli/upgrade/migrations/upgrade-plugins.ts @@ -7,14 +7,14 @@ import { pluginCatalogConfigFromEnv, pluginCatalogConfigFromPluginSet, } from "@/plugins"; -import type { UpgradeContext } from "../types"; +import type { MigrationContext } from "../types"; function unique(values: string[]): string[] { return [...new Set(values)]; } function baseCatalogConfig( - context: UpgradeContext, + context: MigrationContext, ): PluginCatalogConfig | undefined { return ( context.pluginCatalogConfig ?? @@ -69,7 +69,7 @@ function mergeCatalogConfig( } function packageNamesFromContext( - context: UpgradeContext, + context: MigrationContext, catalog: PluginCatalogConfig | undefined, ): string[] { return unique([ @@ -80,7 +80,7 @@ function packageNamesFromContext( /** Resolve the plugin catalog used by SQL upgrade migrations. */ export async function resolveUpgradePluginCatalog( - context: UpgradeContext, + context: MigrationContext, ): Promise { const catalog = baseCatalogConfig(context); const packageNames = packageNamesFromContext(context, catalog); diff --git a/packages/junior/src/cli/upgrade/types.ts b/packages/junior/src/cli/upgrade/types.ts index fe128abdd..078edb4e8 100644 --- a/packages/junior/src/cli/upgrade/types.ts +++ b/packages/junior/src/cli/upgrade/types.ts @@ -1,19 +1,28 @@ +import type { RedisStateAdapter } from "@chat-adapter/state-redis"; +import type { StateAdapter } from "chat"; import type { PluginCatalogConfig } from "@/chat/plugins/types"; -import type { JuniorSqlExecutor } from "@/db/db"; import type { JuniorPluginSet } from "@/plugins"; export interface UpgradeIo { info: (line: string) => void; } -export interface UpgradeContext { +export interface MigrationStateContext { + redisStateAdapter?: RedisStateAdapter; + stateAdapter: StateAdapter; +} + +export interface MigrationContext { + getStateContext: () => Promise; + io: UpgradeIo; pluginCatalogConfig?: PluginCatalogConfig; pluginSet?: JuniorPluginSet; - sqlExecutor: JuniorSqlExecutor; } -export interface MigrationSummary { +export type MigrationResult = { existing: number; migrated: number; + missing: number; scanned: number; -} + skipped?: number; +}; diff --git a/packages/junior/src/db/db.ts b/packages/junior/src/db/db.ts index 87bb719bc..36576d8d1 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..a9bfb9b03 --- /dev/null +++ b/packages/junior/src/migration-helpers/README.md @@ -0,0 +1,14 @@ +# 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 and other reusable infrastructure. +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. + +The behavior and signature of an existing version are permanent compatibility +contracts. Add a new versioned entrypoint when that contract must change. +Migration files may be updated while still unreleased, but shipped migration +sources are hash-verified ledger entries and must remain immutable. diff --git a/packages/junior/src/migration-helpers/v1.ts b/packages/junior/src/migration-helpers/v1.ts new file mode 100644 index 000000000..6f841b8bf --- /dev/null +++ b/packages/junior/src/migration-helpers/v1.ts @@ -0,0 +1,44 @@ +import type { Destination } from "@sentry/junior-plugin-api"; +import { + isRecord as runtimeIsRecord, + toOptionalNumber as runtimeToOptionalNumber, + toOptionalString as runtimeToOptionalString, +} from "@/chat/coerce"; +import { + parseDestination as runtimeParseDestination, + sameDestination as runtimeSameDestination, +} from "@/chat/destination"; +import { unescapeXml } from "@/chat/xml"; + +/** 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); +} + +/** Unescape one persisted XML text fragment. */ +export function migrationUnescapeXml(value: string): string { + return unescapeXml(value); +} diff --git a/packages/junior/tests/component/conversation-search.test.ts b/packages/junior/tests/component/conversation-search.test.ts index 18c04687b..5315a76aa 100644 --- a/packages/junior/tests/component/conversation-search.test.ts +++ b/packages/junior/tests/component/conversation-search.test.ts @@ -12,7 +12,7 @@ describe("conversation search", () => { const fixture = await createLocalJuniorSqlFixture(); try { - await migrateSchema(fixture.sql); + await migrateSchema(fixture.sql, { mode: "schema-bootstrap" }); const conversations = createSqlStore(fixture.sql); const events = createSqlConversationEventStore(fixture.sql); const search = createSqlConversationSearchStore(fixture.sql); diff --git a/packages/junior/tests/component/conversation-sql-store.test.ts b/packages/junior/tests/component/conversation-sql-store.test.ts index f1caaa4b2..f2bc6af2e 100644 --- a/packages/junior/tests/component/conversation-sql-store.test.ts +++ b/packages/junior/tests/component/conversation-sql-store.test.ts @@ -36,7 +36,7 @@ describe("conversation SQL store", () => { try { const store = createSqlStore(fixture.sql); - await migrateSchema(fixture.sql); + await migrateSchema(fixture.sql, { mode: "schema-bootstrap" }); const at = new Date(1); await fixture.sql.db().insert(juniorConversations).values({ conversationId: "parent-without-root", @@ -77,7 +77,7 @@ describe("conversation SQL store", () => { try { const store = createSqlStore(fixture.sql); - await migrateSchema(fixture.sql); + await migrateSchema(fixture.sql, { mode: "schema-bootstrap" }); await store.recordActivity({ conversationId: CONVERSATION_ID, @@ -210,7 +210,7 @@ describe("conversation SQL store", () => { try { const store = createSqlStore(fixture.sql); - await migrateSchema(fixture.sql); + await migrateSchema(fixture.sql, { mode: "schema-bootstrap" }); const identity = await upsertIdentity( fixture.sql, @@ -319,7 +319,7 @@ describe("conversation SQL store", () => { try { const store = createSqlStore(fixture.sql); - await migrateSchema(fixture.sql); + await migrateSchema(fixture.sql, { mode: "schema-bootstrap" }); await store.recordActivity({ conversationId: CONVERSATION_ID, @@ -355,7 +355,7 @@ describe("conversation SQL store", () => { const fixture = await createLocalJuniorSqlFixture(); try { - await migrateSchema(fixture.sql); + await migrateSchema(fixture.sql, { mode: "schema-bootstrap" }); await fixture.sql .db() @@ -428,7 +428,7 @@ describe("conversation SQL store", () => { try { const store = createSqlStore(fixture.sql); - await migrateSchema(fixture.sql); + await migrateSchema(fixture.sql, { mode: "schema-bootstrap" }); await store.recordActivity({ conversationId: CONVERSATION_ID, @@ -475,7 +475,7 @@ describe("conversation SQL store", () => { try { const store = createSqlStore(fixture.sql); - await migrateSchema(fixture.sql); + await migrateSchema(fixture.sql, { mode: "schema-bootstrap" }); const destination = inboundMessage("visibility").destination; // Slack reports this C-prefixed channel private (channel_type: group). @@ -542,7 +542,7 @@ describe("conversation SQL store", () => { try { const store = createSqlStore(fixture.sql); - await migrateSchema(fixture.sql); + await migrateSchema(fixture.sql, { mode: "schema-bootstrap" }); // A write without a live source signal fails closed to private even // though the channel id is C-prefixed. @@ -572,7 +572,7 @@ describe("conversation SQL store", () => { try { const store = createSqlStore(fixture.sql); - await migrateSchema(fixture.sql); + await migrateSchema(fixture.sql, { mode: "schema-bootstrap" }); await fixture.sql.execute( ` INSERT INTO junior_conversations ( @@ -632,7 +632,7 @@ INSERT INTO junior_conversations ( try { const store = createSqlStore(fixture.sql); - await migrateSchema(fixture.sql); + await migrateSchema(fixture.sql, { mode: "schema-bootstrap" }); await store.recordExecution({ conversationId: CONVERSATION_ID, @@ -685,7 +685,7 @@ INSERT INTO junior_conversations ( try { const store = createSqlStore(fixture.sql); - await migrateSchema(fixture.sql); + await migrateSchema(fixture.sql, { mode: "schema-bootstrap" }); await store.recordExecution({ conversationId: CONVERSATION_ID, createdAtMs: 1_000, @@ -762,7 +762,7 @@ WHERE conversation_id = $1 try { const store = createSqlStore(fixture.sql); - await migrateSchema(fixture.sql); + await migrateSchema(fixture.sql, { mode: "schema-bootstrap" }); await store.recordExecution({ conversationId: CONVERSATION_ID, @@ -812,7 +812,7 @@ WHERE conversation_id = $1 try { const store = createSqlStore(fixture.sql); - await migrateSchema(fixture.sql); + await migrateSchema(fixture.sql, { mode: "schema-bootstrap" }); await store.recordActivity({ conversationId: CONVERSATION_ID, @@ -848,7 +848,7 @@ WHERE conversation_id = $1 try { await disconnectStateAdapter(); const store = createSqlStore(fixture.sql); - await migrateSchema(fixture.sql); + await migrateSchema(fixture.sql, { mode: "schema-bootstrap" }); await store.recordExecution({ conversationId: CONVERSATION_ID, createdAtMs: 1_000, @@ -882,7 +882,7 @@ WHERE conversation_id = $1 vi.useFakeTimers({ now: 302_000 }); await disconnectStateAdapter(); const store = createSqlStore(fixture.sql); - await migrateSchema(fixture.sql); + await migrateSchema(fixture.sql, { mode: "schema-bootstrap" }); await store.recordExecution({ conversationId: CONVERSATION_ID, createdAtMs: 1_000, @@ -917,7 +917,7 @@ WHERE conversation_id = $1 vi.useFakeTimers({ now: 2_000 }); await disconnectStateAdapter(); const store = createSqlStore(fixture.sql); - await migrateSchema(fixture.sql); + await migrateSchema(fixture.sql, { mode: "schema-bootstrap" }); await store.recordExecution({ conversationId: CONVERSATION_ID, createdAtMs: 1_000, @@ -955,7 +955,7 @@ WHERE conversation_id = $1 vi.useFakeTimers({ now: 600_000 }); await disconnectStateAdapter(); const store = createSqlStore(fixture.sql); - await migrateSchema(fixture.sql); + await migrateSchema(fixture.sql, { mode: "schema-bootstrap" }); await store.recordExecution({ conversationId: CONVERSATION_ID, createdAtMs: 1_000, @@ -1009,7 +1009,7 @@ WHERE conversation_id = $1 await disconnectStateAdapter(); const state = getStateAdapter(); const store = createSqlStore(fixture.sql); - await migrateSchema(fixture.sql); + await migrateSchema(fixture.sql, { mode: "schema-bootstrap" }); await appendInboundMessage({ message: inboundMessage("check-in"), conversationStore: store, @@ -1062,7 +1062,7 @@ WHERE conversation_id = $1 await disconnectStateAdapter(); const state = getStateAdapter(); const store = createSqlStore(fixture.sql); - await migrateSchema(fixture.sql); + await migrateSchema(fixture.sql, { mode: "schema-bootstrap" }); await appendInboundMessage({ message: inboundMessage("drain-sql"), conversationStore: store, diff --git a/packages/junior/tests/component/conversation-storage-sql.test.ts b/packages/junior/tests/component/conversation-storage-sql.test.ts index a96b683ae..23462edb8 100644 --- a/packages/junior/tests/component/conversation-storage-sql.test.ts +++ b/packages/junior/tests/component/conversation-storage-sql.test.ts @@ -309,8 +309,8 @@ describe("SQL conversation storage", () => { const fixture = await createLocalJuniorSqlFixture(); try { - await migrateSchema(fixture.sql); - await migrateSchema(fixture.sql); + await migrateSchema(fixture.sql, { mode: "schema-bootstrap" }); + await migrateSchema(fixture.sql, { mode: "schema-bootstrap" }); const [applied] = await fixture.sql.query<{ count: number }>( "SELECT count(*)::integer AS count FROM drizzle.__drizzle_junior_core", @@ -325,7 +325,7 @@ describe("SQL conversation storage", () => { const fixture = await createLocalJuniorSqlFixture(); try { - await migrateSchema(fixture.sql); + await migrateSchema(fixture.sql, { mode: "schema-bootstrap" }); await seedConversation(fixture, CONVERSATION_ID); const store = createSqlConversationEventStore(fixture.sql); @@ -379,7 +379,7 @@ describe("SQL conversation storage", () => { const fixture = await createLocalJuniorSqlFixture(); try { - await migrateSchema(fixture.sql); + await migrateSchema(fixture.sql, { mode: "schema-bootstrap" }); const store = createSqlConversationEventStore(fixture.sql); const firstEvent = { data: { type: "agent_step" as const, message: userMessage("first") }, @@ -452,7 +452,7 @@ describe("SQL conversation storage", () => { const store = createSqlConversationEventStore(fixture.sql); try { - await migrateSchema(fixture.sql); + await migrateSchema(fixture.sql, { mode: "schema-bootstrap" }); await store.append(CONVERSATION_ID, [ { idempotencyKey: "event:repeated", @@ -493,7 +493,7 @@ describe("SQL conversation storage", () => { const fixture = await createLocalJuniorSqlFixture(); try { - await migrateSchema(fixture.sql); + await migrateSchema(fixture.sql, { mode: "schema-bootstrap" }); await seedConversation(fixture, CONVERSATION_ID); const store = createSqlConversationEventStore(fixture.sql); const lifecycle = new ConversationTurnLifecycleService(store); @@ -537,7 +537,7 @@ describe("SQL conversation storage", () => { const fixture = await createLocalJuniorSqlFixture(); try { - await migrateSchema(fixture.sql); + await migrateSchema(fixture.sql, { mode: "schema-bootstrap" }); await seedConversation(fixture, CONVERSATION_ID); const store = createSqlConversationEventStore(fixture.sql); @@ -570,7 +570,7 @@ describe("SQL conversation storage", () => { const fixture = await createLocalJuniorSqlFixture(); try { - await migrateSchema(fixture.sql); + await migrateSchema(fixture.sql, { mode: "schema-bootstrap" }); await seedConversation(fixture, CONVERSATION_ID); const store = createSqlConversationEventStore(fixture.sql); @@ -610,7 +610,7 @@ describe("SQL conversation storage", () => { const fixture = await createLocalJuniorSqlFixture(); try { - await migrateSchema(fixture.sql); + await migrateSchema(fixture.sql, { mode: "schema-bootstrap" }); await seedConversation(fixture, CONVERSATION_ID); const store = createSqlConversationEventStore(fixture.sql); @@ -679,7 +679,7 @@ describe("SQL conversation storage", () => { const fixture = await createLocalJuniorSqlFixture(); try { - await migrateSchema(fixture.sql); + await migrateSchema(fixture.sql, { mode: "schema-bootstrap" }); await seedConversation(fixture, CONVERSATION_ID); const store = createSqlConversationEventStore(fixture.sql); await store.append(CONVERSATION_ID, [ @@ -719,7 +719,7 @@ INSERT INTO junior_conversation_events ( const fixture = await createLocalJuniorSqlFixture(); try { - await migrateSchema(fixture.sql); + await migrateSchema(fixture.sql, { mode: "schema-bootstrap" }); await seedConversation(fixture, CONVERSATION_ID); const store = createSqlConversationEventStore(fixture.sql); @@ -770,7 +770,7 @@ INSERT INTO junior_conversation_events ( const fixture = await createLocalJuniorSqlFixture(); try { - await migrateSchema(fixture.sql); + await migrateSchema(fixture.sql, { mode: "schema-bootstrap" }); await seedConversation(fixture, CONVERSATION_ID); const store = createSqlConversationEventStore(fixture.sql); const conversation = coerceThreadConversationState({}); @@ -842,7 +842,7 @@ WHERE conversation_id = $1 AND seq = 0 const fixture = await createLocalJuniorSqlFixture(); try { - await migrateSchema(fixture.sql); + await migrateSchema(fixture.sql, { mode: "schema-bootstrap" }); await seedConversation(fixture, CONVERSATION_ID); const store = createSqlConversationEventStore(fixture.sql); const data = { @@ -864,7 +864,7 @@ WHERE conversation_id = $1 AND seq = 0 const fixture = await createLocalJuniorSqlFixture(); try { - await migrateSchema(fixture.sql); + await migrateSchema(fixture.sql, { mode: "schema-bootstrap" }); await seedConversation(fixture, CONVERSATION_ID); const store = createSqlConversationEventStore(fixture.sql); await store.append(CONVERSATION_ID, [ @@ -921,7 +921,7 @@ WHERE conversation_id = $1 AND seq = 0 const fixture = await createLocalJuniorSqlFixture(); try { - await migrateSchema(fixture.sql); + await migrateSchema(fixture.sql, { mode: "schema-bootstrap" }); await seedConversation(fixture, CONVERSATION_ID); const store = createSqlConversationEventStore(fixture.sql); @@ -965,7 +965,7 @@ INSERT INTO junior_conversation_events ( const fixture = await createLocalJuniorSqlFixture(); try { - await migrateSchema(fixture.sql); + await migrateSchema(fixture.sql, { mode: "schema-bootstrap" }); await seedConversation(fixture, CONVERSATION_ID); const store = createSqlConversationEventStore(fixture.sql); @@ -998,7 +998,7 @@ INSERT INTO junior_conversation_events ( const fixture = await createLocalJuniorSqlFixture(); try { - await migrateSchema(fixture.sql); + await migrateSchema(fixture.sql, { mode: "schema-bootstrap" }); await seedConversation(fixture, CONVERSATION_ID); const store = createSqlConversationEventStore(fixture.sql); @@ -1037,7 +1037,7 @@ INSERT INTO junior_conversation_events ( const fixture = await createLocalJuniorSqlFixture(); try { - await migrateSchema(fixture.sql); + await migrateSchema(fixture.sql, { mode: "schema-bootstrap" }); await seedConversation(fixture, CONVERSATION_ID); const store = createSqlConversationEventStore(fixture.sql); await store.append(CONVERSATION_ID, [ @@ -1119,7 +1119,7 @@ INSERT INTO junior_conversation_events ( } try { - await migrateSchema(fixture.sql); + await migrateSchema(fixture.sql, { mode: "schema-bootstrap" }); // Seed an old activity clock; content writes must refresh the window. await seedConversation(fixture, CONVERSATION_ID); await fixture.sql @@ -1188,7 +1188,7 @@ INSERT INTO junior_conversation_events ( const fixture = await createLocalJuniorSqlFixture(); try { - await migrateSchema(fixture.sql); + await migrateSchema(fixture.sql, { mode: "schema-bootstrap" }); await seedConversation(fixture, CONVERSATION_ID); await seedConversation(fixture, CHILD_CONVERSATION_ID, CONVERSATION_ID); const events = createSqlConversationEventStore(fixture.sql); diff --git a/packages/junior/tests/component/conversations/retention.test.ts b/packages/junior/tests/component/conversations/retention.test.ts index 9802dbeb2..592b7884b 100644 --- a/packages/junior/tests/component/conversations/retention.test.ts +++ b/packages/junior/tests/component/conversations/retention.test.ts @@ -132,7 +132,7 @@ describe("retention purge job", () => { beforeEach(async () => { fixture = await createLocalJuniorSqlFixture(); - await migrateSchema(fixture.sql); + await migrateSchema(fixture.sql, { mode: "schema-bootstrap" }); }); afterEach(async () => { diff --git a/packages/junior/tests/component/memory-plugin-storage.test.ts b/packages/junior/tests/component/memory-plugin-storage.test.ts index 914a113ce..080a4f6cb 100644 --- a/packages/junior/tests/component/memory-plugin-storage.test.ts +++ b/packages/junior/tests/component/memory-plugin-storage.test.ts @@ -13,11 +13,10 @@ import { } from "@sentry/junior-plugin-api"; import { defineJuniorPlugins } from "@/plugins"; import { getPluginTools, setPlugins } from "@/chat/plugins/agent-hooks"; -import { migratePluginSchemas } from "@/chat/plugins/migrations"; +import { bootstrapPluginSchemas } from "@/chat/plugins/migrations"; import { readMigrationFiles } from "drizzle-orm/migrator"; import { closeDb } from "@/chat/db"; -import { migratePluginsToSql } from "@/cli/upgrade/migrations/plugin-sql"; -import { runUpgrade } from "@/cli/upgrade"; +import { runUpgrade, runUpgradeMigrations } from "@/cli/upgrade"; import { createLocalJuniorSqlFixture } from "../fixtures/sql"; const NEON = vi.hoisted(() => ({ @@ -40,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), @@ -88,7 +86,7 @@ function memoryMigrationFiles(): string[] { async function migrateMemorySchema( fixture: Awaited>, ) { - await migratePluginSchemas(fixture.sql, [ + await bootstrapPluginSchemas(fixture.sql, [ { dir: memoryMigrationsDir(), pluginName: "memory", @@ -132,7 +130,7 @@ CREATE TABLE junior_schema_migrations ( } await expect( - migratePluginSchemas(fixture.sql, [ + bootstrapPluginSchemas(fixture.sql, [ { dir: memoryMigrationsDir(), pluginName: "memory", @@ -170,7 +168,7 @@ CREATE TABLE junior_schema_migrations ( ); await expect( - migratePluginSchemas(fixture.sql, [ + bootstrapPluginSchemas(fixture.sql, [ { dir: memoryMigrationsDir(), pluginName: "memory", @@ -186,40 +184,6 @@ CREATE TABLE junior_schema_migrations ( } }); - it("applies packaged migrations through plugin discovery", async () => { - const fixture = await createLocalJuniorSqlFixture(); - NEON.sql = fixture.sql; - - try { - const migrationCount = readMigrationFiles({ - migrationsFolder: memoryMigrationsDir(), - }).length; - await expect( - migratePluginsToSql({ - pluginSet: defineJuniorPlugins([createMemoryPlugin()]), - sqlExecutor: fixture.sql, - }), - ).resolves.toEqual({ - existing: 0, - migrated: migrationCount, - scanned: migrationCount, - }); - - await expect( - fixture.sql.query<{ table_name: string }>( - ` -SELECT table_name -FROM information_schema.tables -WHERE table_name = 'junior_memory_memories' -`, - ), - ).resolves.toEqual([{ table_name: "junior_memory_memories" }]); - } finally { - NEON.sql = undefined; - await fixture.close(); - } - }, 15_000); - it("reports core and nonempty plugin migration journals", async () => { const fixture = await createLocalJuniorSqlFixture(); NEON.sql = fixture.sql; @@ -231,7 +195,6 @@ WHERE table_name = 'junior_memory_memories' const memoryMigrationCount = readMigrationFiles({ migrationsFolder: memoryMigrationsDir(), }).length; - const totalMigrationCount = coreMigrationCount + memoryMigrationCount; const lines: string[] = []; const pluginSet = defineJuniorPlugins([ createMemoryPlugin(), @@ -246,19 +209,23 @@ WHERE table_name = 'junior_memory_memories' await runUpgrade({ info: (line) => lines.push(line) }, { pluginSet }); expect(lines).toEqual([ - "Checking database migrations...", - ` junior: applied ${coreMigrationCount} migrations (${coreMigrationCount} total)`, - ` junior-memory: applied ${memoryMigrationCount} migrations (${memoryMigrationCount} total)`, - `Applied ${totalMigrationCount} migrations (${totalMigrationCount} total).`, + "Running Junior upgrade migrations...", + "Running migration core-migrations...", + `Finished migration core-migrations: scanned=${coreMigrationCount} migrated=${coreMigrationCount} existing=0 missing=0 skipped=0`, + "Running migration plugin-migrations...", + `Finished migration plugin-migrations: scanned=${memoryMigrationCount} migrated=${memoryMigrationCount} existing=0 missing=0`, + "Junior upgrade complete.", ]); lines.length = 0; await runUpgrade({ info: (line) => lines.push(line) }, { pluginSet }); expect(lines).toEqual([ - "Checking database migrations...", - ` junior: up to date (${coreMigrationCount} migrations)`, - ` junior-memory: up to date (${memoryMigrationCount} migrations)`, - `Database is up to date (${totalMigrationCount} migrations).`, + "Running Junior upgrade migrations...", + "Running migration core-migrations...", + `Finished migration core-migrations: scanned=${coreMigrationCount} migrated=0 existing=${coreMigrationCount} missing=0 skipped=0`, + "Running migration plugin-migrations...", + `Finished migration plugin-migrations: scanned=${memoryMigrationCount} migrated=0 existing=${memoryMigrationCount} missing=0`, + "Junior upgrade complete.", ]); } finally { NEON.sql = undefined; @@ -266,6 +233,28 @@ WHERE table_name = 'junior_memory_memories' } }, 15_000); + it("does not connect state for SQL-only migration journals", async () => { + const fixture = await createLocalJuniorSqlFixture(); + NEON.sql = fixture.sql; + + try { + const getStateContext = vi.fn(async () => { + throw new Error("SQL-only migrations must not connect state"); + }); + await expect( + runUpgradeMigrations({ + getStateContext, + io: { info: () => {} }, + pluginSet: defineJuniorPlugins([createMemoryPlugin()]), + }), + ).resolves.toHaveLength(2); + expect(getStateContext).not.toHaveBeenCalled(); + } finally { + NEON.sql = undefined; + await fixture.close(); + } + }, 15_000); + it("registers memory tools with runtime-provided plugin DB access", async () => { const fixture = await createLocalJuniorSqlFixture(); const previousPlugins = setPlugins([createMemoryPlugin()]); diff --git a/packages/junior/tests/component/migrations/mixed-runner-database.test.ts b/packages/junior/tests/component/migrations/mixed-runner-database.test.ts new file mode 100644 index 000000000..cfbfb4df8 --- /dev/null +++ b/packages/junior/tests/component/migrations/mixed-runner-database.test.ts @@ -0,0 +1,358 @@ +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + runMigrationJournal, + type MigrationContextV1, + type MigrationStateV1, +} from "@sentry/junior-migrations"; +import type { RedisStateAdapter } from "@chat-adapter/state-redis"; +import type { StateAdapter } from "chat"; +import { migratePluginSchemas } from "@/chat/plugins/migrations"; +import { createJiti } from "jiti"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + createLocalJuniorSqlFixture, + type LocalJuniorSqlFixture, +} from "../../fixtures/sql"; +import { hasJuniorPostgresTestDatabase } from "../../fixtures/postgres/fixture"; + +const migrationLoader = createJiti(import.meta.url, { moduleCache: false }); +const temporaryDirectories: string[] = []; + +const unusedState: MigrationStateV1 = { + acquireLock: async () => null, + appendToList: async () => {}, + connect: async () => {}, + delete: async () => {}, + get: async () => undefined, + getList: async () => [], + releaseLock: async () => {}, + set: async () => {}, + setIfNotExists: async () => false, +}; + +async function createMigrationFolder(args: { + source: string; + tag: string; + when: number; +}): Promise { + const folder = await mkdtemp(join(tmpdir(), "junior-mixed-runner-")); + temporaryDirectories.push(folder); + await mkdir(join(folder, "meta")); + await writeFile( + join(folder, "meta", "_journal.json"), + JSON.stringify({ + version: "7", + dialect: "postgresql", + entries: [ + { + idx: 0, + version: "7", + when: args.when, + tag: args.tag, + breakpoints: true, + }, + ], + }), + ); + await writeFile(join(folder, `${args.tag}.ts`), args.source); + return folder; +} + +function migrationOptions(args: { + executor?: LocalJuniorSqlFixture["sql"]; + fixture: LocalJuniorSqlFixture; + folder: string; + table: string; +}) { + const executor = args.executor ?? args.fixture.sql; + return { + executor, + migrationsFolder: args.folder, + migrationsTable: args.table, + loadTypeScript: async (migrationPath: string) => + await migrationLoader.import>(migrationPath), + createContext: ({ + progress, + }: { + progress: MigrationContextV1["progress"]; + }): MigrationContextV1 => ({ + database: executor, + log: () => {}, + progress, + state: unusedState, + }), + }; +} + +afterEach(async () => { + await Promise.all( + temporaryDirectories + .splice(0) + .map((directory) => rm(directory, { recursive: true, force: true })), + ); +}); + +describe("mixed migration runner database contract", () => { + it("provides the host Redis capability to plugin data migrations", async () => { + const fixture = await createLocalJuniorSqlFixture(); + const folder = await createMigrationFolder({ + source: ` +export default { + apiVersion: 1, + async up(context) { + return { + response: await context.redis.sendCommand(["PING"]), + }; + }, +}; +`, + tag: "0000_plugin_redis", + when: 2_026_071_600_000, + }); + const sendCommand = vi.fn(async () => "PONG"); + + try { + await expect( + migratePluginSchemas( + fixture.sql, + [{ dir: folder, pluginName: "redis-test" }], + { + getStateContext: async () => ({ + redisStateAdapter: { + getClient: () => ({ sendCommand }), + } as unknown as RedisStateAdapter, + stateAdapter: {} as StateAdapter, + }), + loadTypeScript: async (migrationPath) => + await migrationLoader.import>( + migrationPath, + ), + mode: "all", + }, + ), + ).resolves.toEqual({ + existing: 0, + migrated: 1, + scanned: 1, + }); + expect(sendCommand).toHaveBeenCalledWith(["PING"]); + } finally { + await fixture.close(); + } + }); + + it("persists failed progress and resumes a TypeScript migration", async () => { + const fixture = await createLocalJuniorSqlFixture(); + const folder = await createMigrationFolder({ + source: ` +export default { + apiVersion: 1, + async up(context) { + const progress = await context.progress.load(); + if (progress === undefined) { + await context.database.execute( + "INSERT INTO mixed_runner_events (stage) VALUES ($1)", + ["before-failure"], + ); + await context.progress.save({ cursor: 1 }); + throw new Error("intentional migration interruption"); + } + await context.database.execute( + "INSERT INTO mixed_runner_events (stage) VALUES ($1)", + ["after-resume"], + ); + return { resumedFrom: progress.cursor }; + }, +}; +`, + tag: "0000_resumable", + when: 2_026_071_600_001, + }); + const table = "__junior_mixed_runner_resume"; + const options = migrationOptions({ fixture, folder, table }); + + try { + await fixture.sql.execute(` +CREATE TABLE mixed_runner_events ( + id SERIAL PRIMARY KEY, + stage TEXT NOT NULL +) +`); + + await expect(runMigrationJournal(options)).rejects.toThrow( + "intentional migration interruption", + ); + + await expect( + fixture.sql.query( + `SELECT stage FROM mixed_runner_events ORDER BY id ASC`, + ), + ).resolves.toEqual([{ stage: "before-failure" }]); + await expect( + fixture.sql.query(` +SELECT + name, + kind, + status, + progress, + result, + completed_at IS NOT NULL AS "completed" +FROM drizzle.${table} +`), + ).resolves.toEqual([ + { + completed: false, + kind: "typescript", + name: "0000_resumable", + progress: { cursor: 1 }, + result: null, + status: "failed", + }, + ]); + + await expect(runMigrationJournal(options)).resolves.toEqual({ + existing: 0, + migrated: 1, + scanned: 1, + skipped: 0, + }); + + await expect( + fixture.sql.query( + `SELECT stage FROM mixed_runner_events ORDER BY id ASC`, + ), + ).resolves.toEqual([ + { stage: "before-failure" }, + { stage: "after-resume" }, + ]); + await expect( + fixture.sql.query(` +SELECT + status, + progress, + result, + completed_at IS NOT NULL AS "completed" +FROM drizzle.${table} +`), + ).resolves.toEqual([ + { + completed: true, + progress: { cursor: 1 }, + result: { resumedFrom: 1 }, + status: "completed", + }, + ]); + } finally { + await fixture.close(); + } + }); + + it.skipIf(!hasJuniorPostgresTestDatabase())( + "serializes concurrent runs and executes a TypeScript migration once", + async () => { + const fixture = await createLocalJuniorSqlFixture(); + const folder = await createMigrationFolder({ + source: ` +export default { + apiVersion: 1, + async up(context) { + await context.database.execute( + "INSERT INTO mixed_runner_lock_events (stage) VALUES ($1)", + ["body"], + ); + while (true) { + const [barrier] = await context.database.query( + "SELECT released FROM mixed_runner_lock_barrier WHERE id = 1", + ); + if (barrier?.released === true) break; + await context.database.query("SELECT pg_sleep(0.01)"); + } + return { executed: true }; + }, +}; +`, + tag: "0000_locked", + when: 2_026_071_600_002, + }); + const table = "__junior_mixed_runner_lock"; + let lockAttempts = 0; + const observedExecutor = new Proxy(fixture.sql, { + get(target, key, receiver) { + if (key === "withMigrationLock") { + return async ( + migrationTable: string, + callback: () => Promise, + ) => { + lockAttempts += 1; + return await target.withMigrationLock(migrationTable, callback); + }; + } + const value = Reflect.get(target, key, receiver) as unknown; + return typeof value === "function" ? value.bind(target) : value; + }, + }); + const options = migrationOptions({ + executor: observedExecutor, + fixture, + folder, + table, + }); + + try { + await fixture.sql.execute(` +CREATE TABLE mixed_runner_lock_events ( + id SERIAL PRIMARY KEY, + stage TEXT NOT NULL +); +CREATE TABLE mixed_runner_lock_barrier ( + id INTEGER PRIMARY KEY, + released BOOLEAN NOT NULL +); +INSERT INTO mixed_runner_lock_barrier (id, released) VALUES (1, false) +`); + + const first = runMigrationJournal(options); + await vi.waitFor(async () => { + await expect( + fixture.sql.query( + "SELECT count(*)::integer AS count FROM mixed_runner_lock_events", + ), + ).resolves.toEqual([{ count: 1 }]); + }); + const second = runMigrationJournal(options); + await vi.waitFor(() => expect(lockAttempts).toBe(2)); + await expect( + fixture.sql.query( + "SELECT count(*)::integer AS count FROM mixed_runner_lock_events", + ), + ).resolves.toEqual([{ count: 1 }]); + await fixture.sql.execute( + "UPDATE mixed_runner_lock_barrier SET released = true WHERE id = 1", + ); + const results = await Promise.all([first, second]); + + expect(results).toEqual( + expect.arrayContaining([ + { existing: 0, migrated: 1, scanned: 1, skipped: 0 }, + { existing: 1, migrated: 0, scanned: 1, skipped: 0 }, + ]), + ); + await expect( + fixture.sql.query( + `SELECT stage FROM mixed_runner_lock_events ORDER BY id ASC`, + ), + ).resolves.toEqual([{ stage: "body" }]); + await expect( + fixture.sql.query(`SELECT status, result FROM drizzle.${table}`), + ).resolves.toEqual([ + { result: { executed: true }, status: "completed" }, + ]); + } finally { + await fixture.close(); + } + }, + 15_000, + ); +}); diff --git a/packages/junior/tests/component/scheduler-sql-plugin.test.ts b/packages/junior/tests/component/scheduler-sql-plugin.test.ts index df9a50247..b7c106ed0 100644 --- a/packages/junior/tests/component/scheduler-sql-plugin.test.ts +++ b/packages/junior/tests/component/scheduler-sql-plugin.test.ts @@ -2,16 +2,13 @@ import path from "node:path"; import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { readMigrationFiles } from "drizzle-orm/migrator"; -import { describe, expect, it, vi } from "vitest"; +import { describe, expect, it } from "vitest"; import { createSchedulerSqlStore, - schedulerPlugin, type SchedulerDb, type ScheduledTask, } from "@sentry/junior-scheduler"; -import { defineJuniorPlugins } from "@/plugins"; -import { migratePluginSchemas } from "@/chat/plugins/migrations"; -import { migratePluginsToSql } from "@/cli/upgrade/migrations/plugin-sql"; +import { bootstrapPluginSchemas } from "@/chat/plugins/migrations"; import { createLocalJuniorSqlFixture } from "../fixtures/sql"; const TEST_RUN_AT_MS = Date.parse("2026-05-26T12:00:00.000Z"); @@ -30,7 +27,7 @@ function memoryMigrationsDir(): string { async function migrateSchedulerSchema( fixture: Awaited>, ) { - await migratePluginSchemas(fixture.sql, [ + await bootstrapPluginSchemas(fixture.sql, [ { dir: schedulerMigrationsDir(), pluginName: "scheduler", @@ -125,7 +122,7 @@ INSERT INTO junior_scheduler_tasks ( ); await expect( - migratePluginSchemas(fixture.sql, [ + bootstrapPluginSchemas(fixture.sql, [ { dir: schedulerMigrationsDir(), pluginName: "scheduler", @@ -158,7 +155,7 @@ ORDER BY created_at }); expect(migratedTask?.record).not.toHaveProperty("credentialSubject"); await expect( - migratePluginSchemas(fixture.sql, [ + bootstrapPluginSchemas(fixture.sql, [ { dir: schedulerMigrationsDir(), pluginName: "scheduler", @@ -193,11 +190,13 @@ ORDER BY created_at ); try { - await expect(migratePluginSchemas(fixture.sql, roots)).resolves.toEqual({ - existing: 0, - migrated: migrationCount, - scanned: migrationCount, - }); + await expect(bootstrapPluginSchemas(fixture.sql, roots)).resolves.toEqual( + { + existing: 0, + migrated: migrationCount, + scanned: migrationCount, + }, + ); const migrationTables = await fixture.sql.query<{ tablename: string }>(` SELECT tablename FROM pg_tables @@ -206,19 +205,13 @@ WHERE schemaname = 'drizzle' ORDER BY tablename `); expect(migrationTables).toHaveLength(2); - const migrationLock = vi.spyOn(fixture.sql, "withMigrationLock"); - const summaries: string[] = []; await expect( - migratePluginSchemas(fixture.sql, [...roots].reverse(), { - onPluginMigration: ({ pluginName }) => summaries.push(pluginName), - }), + bootstrapPluginSchemas(fixture.sql, [...roots].reverse()), ).resolves.toEqual({ existing: migrationCount, migrated: 0, scanned: migrationCount, }); - expect(migrationLock).not.toHaveBeenCalled(); - expect(summaries).toEqual(["memory", "scheduler"]); } finally { await fixture.close(); } @@ -237,12 +230,12 @@ ORDER BY tablename try { await expect( - migratePluginSchemas(fixture.sql, [ + bootstrapPluginSchemas(fixture.sql, [ { dir: missingJournal, pluginName: "missing" }, ]), ).rejects.toThrow("Can't find meta/_journal.json file"); await expect( - migratePluginSchemas(fixture.sql, [ + bootstrapPluginSchemas(fixture.sql, [ { dir: invalidJournal, pluginName: "invalid" }, ]), ).rejects.toThrow("Expected property name"); @@ -456,74 +449,6 @@ ORDER BY tablename } }, 15_000); - it("does not apply scheduler SQL migrations from package-only config", async () => { - const fixture = await createLocalJuniorSqlFixture(); - - try { - await expect( - migratePluginsToSql({ - pluginCatalogConfig: { packages: ["@sentry/junior-scheduler"] }, - sqlExecutor: fixture.sql, - }), - ).resolves.toEqual({ - existing: 0, - migrated: 0, - scanned: 0, - }); - } finally { - await fixture.close(); - } - }); - - it("applies scheduler SQL migrations from registration-only config", async () => { - const fixture = await createLocalJuniorSqlFixture(); - - try { - await expect( - migratePluginsToSql({ - pluginSet: defineJuniorPlugins([schedulerPlugin()]), - sqlExecutor: fixture.sql, - }), - ).resolves.toEqual({ - existing: 0, - migrated: 2, - 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, - }); - } finally { - await fixture.close(); - } - }); - - it("does not duplicate scheduler SQL migrations for explicit registrations", async () => { - const fixture = await createLocalJuniorSqlFixture(); - - try { - await expect( - migratePluginsToSql({ - pluginSet: defineJuniorPlugins([ - "@sentry/junior-scheduler", - schedulerPlugin(), - ]), - sqlExecutor: fixture.sql, - }), - ).resolves.toEqual({ - existing: 0, - migrated: 2, - scanned: 2, - }); - } finally { - await fixture.close(); - } - }); - it("skips malformed SQL records while claiming due runs", async () => { const fixture = await createLocalJuniorSqlFixture(); diff --git a/packages/junior/tests/fixtures/postgres/executor.ts b/packages/junior/tests/fixtures/postgres/executor.ts index 2e90d9ad0..420d0f3f4 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}`); @@ -66,10 +60,10 @@ class ClientJuniorSqlExecutor implements JuniorSqlExecutor { } async withMigrationLock( - _migrationTable: string, + migrationTable: string, callback: () => Promise, ): Promise { - return await callback(); + return await this.withLock(`junior:migrate:${migrationTable}`, callback); } async close(): Promise { diff --git a/packages/junior/tests/fixtures/postgres/global-setup.ts b/packages/junior/tests/fixtures/postgres/global-setup.ts index 8fa39271b..59a007636 100644 --- a/packages/junior/tests/fixtures/postgres/global-setup.ts +++ b/packages/junior/tests/fixtures/postgres/global-setup.ts @@ -6,7 +6,7 @@ import { type PostgresHarnessConfig, } from "@sentry/junior-testing/postgres"; import { migrateSchema } from "@/chat/conversations/sql/migrations"; -import { migratePluginSchemas } from "@/chat/plugins/migrations"; +import { bootstrapPluginSchemas } from "@/chat/plugins/migrations"; import type { JuniorSqlMigrationExecutor } from "@/db/db"; import { createPostgresJuniorSqlExecutor } from "@/db/postgres"; @@ -47,8 +47,8 @@ export async function setupJuniorPostgresHarness( migrateTemplate: async (connectionString) => { const executor = createPostgresJuniorSqlExecutor({ connectionString }); try { - await migrateSchema(executor); - await migratePluginSchemas(executor, [ + await migrateSchema(executor, { mode: "schema-bootstrap" }); + await bootstrapPluginSchemas(executor, [ { dir: path.resolve(process.cwd(), "../junior-scheduler/migrations"), pluginName: "scheduler", diff --git a/packages/junior/tests/fixtures/sql.ts b/packages/junior/tests/fixtures/sql.ts index c729dda3c..fd1ee29ba 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,12 +42,12 @@ 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), withLock: (lockName, callback) => fixture.withLock(lockName, callback), - withMigrationLock: (_migrationTable, callback) => callback(), + withMigrationLock: (migrationTable, callback) => + fixture.withMigrationLock(`junior:migrate:${migrationTable}`, callback), }; return { diff --git a/packages/junior/tests/integration/api/conversations/list.test.ts b/packages/junior/tests/integration/api/conversations/list.test.ts index 290a9c5a1..160a7aa51 100644 --- a/packages/junior/tests/integration/api/conversations/list.test.ts +++ b/packages/junior/tests/integration/api/conversations/list.test.ts @@ -18,7 +18,7 @@ describe("conversation list API", () => { const fixture = createConfiguredJuniorSqlFixture(); const store = createSqlStore(fixture.sql); try { - await migrateSchema(fixture.sql); + await migrateSchema(fixture.sql, { mode: "schema-bootstrap" }); await store.recordActivity({ actor: { email: "alice@example.com", @@ -126,7 +126,7 @@ describe("conversation list API", () => { const fixture = createConfiguredJuniorSqlFixture(); const store = createSqlStore(fixture.sql); try { - await migrateSchema(fixture.sql); + await migrateSchema(fixture.sql, { mode: "schema-bootstrap" }); await store.recordActivity({ actor: { email: "other@example.com", @@ -193,7 +193,7 @@ describe("conversation list API", () => { const fixture = createConfiguredJuniorSqlFixture(); const store = createSqlStore(fixture.sql); try { - await migrateSchema(fixture.sql); + await migrateSchema(fixture.sql, { mode: "schema-bootstrap" }); await store.recordActivity({ conversationId: "slack:C1:root", nowMs: 1_000, @@ -237,7 +237,7 @@ describe("conversation list API", () => { const fixture = createConfiguredJuniorSqlFixture(); const store = createSqlStore(fixture.sql); try { - await migrateSchema(fixture.sql); + await migrateSchema(fixture.sql, { mode: "schema-bootstrap" }); await store.recordActivity({ conversationId: "slack:C1:invalid-root", nowMs: 1_000, diff --git a/packages/junior/tests/integration/api/conversations/stats.test.ts b/packages/junior/tests/integration/api/conversations/stats.test.ts index 5c9c9a83c..24cbb1a05 100644 --- a/packages/junior/tests/integration/api/conversations/stats.test.ts +++ b/packages/junior/tests/integration/api/conversations/stats.test.ts @@ -16,7 +16,7 @@ describe("conversation stats API", () => { const fixture = createConfiguredJuniorSqlFixture(); const store = createSqlStore(fixture.sql); try { - await migrateSchema(fixture.sql); + await migrateSchema(fixture.sql, { mode: "schema-bootstrap" }); await store.recordActivity({ conversationId: "slack:C1:recent", channelName: "proj-alpha", @@ -203,7 +203,7 @@ describe("conversation stats API", () => { const fixture = createConfiguredJuniorSqlFixture(); try { - await migrateSchema(fixture.sql); + await migrateSchema(fixture.sql, { mode: "schema-bootstrap" }); const now = new Date("2026-06-15T11:00:00.000Z"); await fixture.sql .db() diff --git a/packages/junior/tests/integration/api/locations.test.ts b/packages/junior/tests/integration/api/locations.test.ts index db7bd6425..bae7ecb26 100644 --- a/packages/junior/tests/integration/api/locations.test.ts +++ b/packages/junior/tests/integration/api/locations.test.ts @@ -19,7 +19,7 @@ describe("locations API", () => { const store = createSqlStore(fixture.sql); try { - await migrateSchema(fixture.sql); + await migrateSchema(fixture.sql, { mode: "schema-bootstrap" }); const nowMs = Date.parse("2026-06-15T11:00:00.000Z"); await store.recordActivity({ conversationId: "slack:C1:seed", diff --git a/packages/junior/tests/integration/api/people/fixture.ts b/packages/junior/tests/integration/api/people/fixture.ts index c35dc0ee1..99356d1ad 100644 --- a/packages/junior/tests/integration/api/people/fixture.ts +++ b/packages/junior/tests/integration/api/people/fixture.ts @@ -6,7 +6,7 @@ import type { LocalJuniorSqlFixture } from "../../../fixtures/sql"; /** Seed representative verified and untrusted people rows for people API tests. */ export async function seedPeople(fixture: LocalJuniorSqlFixture) { const store = createSqlStore(fixture.sql); - await migrateSchema(fixture.sql); + await migrateSchema(fixture.sql, { mode: "schema-bootstrap" }); await store.recordActivity({ conversationId: "slack:C1:123", diff --git a/packages/junior/tests/integration/api/people/profile.test.ts b/packages/junior/tests/integration/api/people/profile.test.ts index a8138560b..148bd8a34 100644 --- a/packages/junior/tests/integration/api/people/profile.test.ts +++ b/packages/junior/tests/integration/api/people/profile.test.ts @@ -21,7 +21,7 @@ describe("people profile API", () => { const ownerChildConversationId = "slack:C-public:owner-child"; try { - await migrateSchema(fixture.sql); + await migrateSchema(fixture.sql, { mode: "schema-bootstrap" }); await store.recordActivity({ conversationId: rootConversationId, actor: { @@ -329,7 +329,7 @@ describe("people profile API", () => { const store = createSqlStore(fixture.sql); try { - await migrateSchema(fixture.sql); + await migrateSchema(fixture.sql, { mode: "schema-bootstrap" }); const nowMs = Date.parse("2026-06-15T11:00:00.000Z"); await store.recordActivity({ conversationId: "slack:C1:seed", diff --git a/packages/junior/tests/integration/conversation-sql.test.ts b/packages/junior/tests/integration/conversation-sql.test.ts index b77f56fb0..b3c7cf33a 100644 --- a/packages/junior/tests/integration/conversation-sql.test.ts +++ b/packages/junior/tests/integration/conversation-sql.test.ts @@ -1,7 +1,7 @@ import { fileURLToPath } from "node:url"; import { getTableColumns, getTableName } from "drizzle-orm"; import { readMigrationFiles } from "drizzle-orm/migrator"; -import { describe, expect, it, vi } from "vitest"; +import { describe, expect, it } from "vitest"; import { migrateSchema } from "@/chat/conversations/sql/migrations"; import type { JuniorSqlMigrationExecutor } from "@/db/db"; import { createPostgresJuniorSqlExecutor } from "@/db/postgres"; @@ -40,7 +40,7 @@ describe("conversation SQL local mode", () => { const fixture = await createLocalJuniorSqlFixture(); try { - await migrateSchema(fixture.sql); + await migrateSchema(fixture.sql, { mode: "schema-bootstrap" }); const rows = await fixture.sql.query<{ column_name: string; @@ -221,7 +221,7 @@ INSERT INTO drizzle.__drizzle_migrations (hash, created_at) VALUES ('host-migration', 9999999999999) `); - await migrateSchema(fixture.sql); + await migrateSchema(fixture.sql, { mode: "schema-bootstrap" }); const [host] = await fixture.sql.query<{ count: number }>( "SELECT count(*)::integer AS count FROM drizzle.__drizzle_migrations", @@ -250,7 +250,10 @@ VALUES ('host-migration', 9999999999999) second.query("SELECT 1"), ]); - await Promise.all([migrateSchema(fixture.sql), migrateSchema(second)]); + await Promise.all([ + migrateSchema(fixture.sql, { mode: "schema-bootstrap" }), + migrateSchema(second, { mode: "schema-bootstrap" }), + ]); const [journal] = await fixture.sql.query<{ count: number }>( "SELECT count(*)::integer AS count FROM drizzle.__drizzle_junior_core", ); @@ -266,21 +269,23 @@ VALUES ('host-migration', 9999999999999) const fixture = await createLocalJuniorSqlFixture(); try { - const migrationLock = vi.spyOn(fixture.sql, "withMigrationLock"); - await expect(migrateSchema(fixture.sql)).resolves.toEqual({ + await expect( + migrateSchema(fixture.sql, { mode: "schema-bootstrap" }), + ).resolves.toEqual({ existing: 0, migrated: coreMigrations.length, scanned: coreMigrations.length, + skipped: 0, }); - expect(migrationLock).toHaveBeenCalledOnce(); - migrationLock.mockClear(); - await expect(migrateSchema(fixture.sql)).resolves.toEqual({ + await expect( + migrateSchema(fixture.sql, { mode: "schema-bootstrap" }), + ).resolves.toEqual({ existing: coreMigrations.length, migrated: 0, scanned: coreMigrations.length, + skipped: 0, }); - expect(migrationLock).not.toHaveBeenCalled(); const conversation = buildJuniorSqlConversation({ conversationId: "slack:C123:1718123456.000000", @@ -371,7 +376,9 @@ CREATE TABLE junior_conversations ( ) `); - await expect(migrateSchema(fixture.sql)).rejects.toThrow( + await expect( + migrateSchema(fixture.sql, { mode: "schema-bootstrap" }), + ).rejects.toThrow( "Stop old Junior workers, install @sentry/junior@0.107.1, run `junior upgrade`, then restore this Junior version", ); await expectNoDrizzleMigrationState(fixture.sql); @@ -385,7 +392,7 @@ CREATE TABLE junior_conversations ( const fixture = await createLocalJuniorSqlFixture(); try { - await migrateSchema(fixture.sql); + await migrateSchema(fixture.sql, { mode: "schema-bootstrap" }); const store = createSqlStore(fixture.sql); await recordAgentTurnSessionSummary({ diff --git a/packages/junior/tests/integration/slack-conversation-search.test.ts b/packages/junior/tests/integration/slack-conversation-search.test.ts index a2e4aa432..7200e9e16 100644 --- a/packages/junior/tests/integration/slack-conversation-search.test.ts +++ b/packages/junior/tests/integration/slack-conversation-search.test.ts @@ -25,7 +25,7 @@ describe("searchConversationHistory", () => { const fixture = await createLocalJuniorSqlFixture(); try { - await migrateSchema(fixture.sql); + await migrateSchema(fixture.sql, { mode: "schema-bootstrap" }); const conversations = createSqlStore(fixture.sql); const events = createSqlConversationEventStore(fixture.sql); const search = createSqlConversationSearchStore(fixture.sql); diff --git a/packages/junior/tests/integration/slack-schedule-tools.test.ts b/packages/junior/tests/integration/slack-schedule-tools.test.ts index 39cdbbcfb..30b39685e 100644 --- a/packages/junior/tests/integration/slack-schedule-tools.test.ts +++ b/packages/junior/tests/integration/slack-schedule-tools.test.ts @@ -18,7 +18,7 @@ import { type SchedulerDb, type SchedulerToolContext, } from "@sentry/junior-scheduler"; -import { migratePluginSchemas } from "@/chat/plugins/migrations"; +import { bootstrapPluginSchemas } from "@/chat/plugins/migrations"; import * as dbModule from "@/chat/db"; import { getPluginTools, setPlugins } from "@/chat/plugins/agent-hooks"; import { disconnectStateAdapter } from "@/chat/state/adapter"; @@ -43,7 +43,7 @@ function schedulerMigrationsDir(): string { async function useSchedulerSqlPlugin() { const fixture = await createLocalJuniorSqlFixture(); - await migratePluginSchemas(fixture.sql, [ + await bootstrapPluginSchemas(fixture.sql, [ { dir: schedulerMigrationsDir(), pluginName: "scheduler", 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/tests/unit/sql/pglite.test.ts b/packages/junior/tests/unit/sql/pglite.test.ts new file mode 100644 index 000000000..f6710c8f3 --- /dev/null +++ b/packages/junior/tests/unit/sql/pglite.test.ts @@ -0,0 +1,37 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { + createLocalPgliteFixture, + type LocalPgliteFixture, +} from "@sentry/junior-testing/pglite"; + +let fixture: LocalPgliteFixture | undefined; + +afterEach(async () => { + await fixture?.close(); + fixture = undefined; +}); + +describe("PGlite migration lock", () => { + it("preserves writes when a migration callback fails", async () => { + fixture = await createLocalPgliteFixture({}); + await fixture.execute(` +CREATE TABLE migration_progress ( + cursor INTEGER NOT NULL +) +`); + + await expect( + fixture.withMigrationLock("test-journal", async () => { + await fixture!.execute( + "INSERT INTO migration_progress (cursor) VALUES ($1)", + [1], + ); + throw new Error("migration interrupted"); + }), + ).rejects.toThrow("migration interrupted"); + + await expect( + fixture.query("SELECT cursor FROM migration_progress"), + ).resolves.toEqual([{ cursor: 1 }]); + }); +}); diff --git a/packages/junior/tsconfig.build.json b/packages/junior/tsconfig.build.json index b94167f70..bbec21a3d 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 56630a8bf..ca9dbf69a 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", version: "src/version.ts", @@ -36,6 +37,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 5c2274f93..59fc59c23 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 @@ -475,6 +478,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 @@ -497,6 +503,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: @@ -530,6 +560,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 @@ -3199,6 +3232,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} @@ -6914,8 +6951,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: @@ -10215,6 +10252,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 @@ -10375,6 +10414,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 @@ -11826,7 +11866,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: {} @@ -15264,7 +15304,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 @@ -16127,7 +16167,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",