diff --git a/CLAUDE.md b/CLAUDE.md index 03cfd36..b2739d6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -25,9 +25,11 @@ apso generate # Generate code from .apsorc schema apso dev # Start local dev server via Docker Compose # Schema -apso migrate # Test schema migrations locally (PGlite sandbox) -apso migrate generate # Migration from a deployed .apsorc to this project's entities (TypeScript, PGlite) -apso migrate verify # Run a migration on the deployed schema, then check for drift +apso migrate # The guided flow: generate code, plan against the deployed schema, ask about anything ambiguous (or print questions as JSON and exit 2), write the migration, verify it +apso migrate sandbox # The older snapshot preview on an empty PGlite database (--sql prints DDL for an .apsorc) +apso migrate plan # Compare the deployed schema (found automatically) with .apsorc; ambiguous changes become questions in .apso/migration-plan.json; JSON when piped; exits 2 while any are open +apso migrate generate # Write the TypeORM migration from the answered plan: up() with renames, data moves and backfills, plus a reversing down(); --typeorm uses TypeORM's generator +apso migrate verify # Run the newest migration on a local PGlite copy of the schema it starts from (optionally --seed rows, --check assertions), then check data loss and drift apso test smoke # Start the service; call /health, list, and create/read/delete each entity (TS on PGlite, Python/Go on DATABASE_URL) apso schema validate # Validate local .apsorc (includes lint) apso schema lint # Lint .apsorc with @apso/schema-tools (--fix, --json) diff --git a/README.md b/README.md index 7762c2d..d0df2f3 100644 --- a/README.md +++ b/README.md @@ -185,22 +185,18 @@ Requires Docker and Docker Compose. Looks for `docker-compose.yml` in the curren ### `apso migrate` -Detect schema changes, generate migration SQL, and test against a local PGlite sandbox. No Docker or external database required. +Write the migration for a schema change without losing data. `apso migrate` regenerates the code, compares `.apsorc` with the deployed schema, and asks about every change whose intent the schema can't show: was a missing field renamed or dropped, how does a field split, what replaces a removed enum value, what do existing rows get for a new required field. It then writes the migration from your answers (a TypeORM migration for TypeScript, an Alembic revision for Python, a goose file for Go), saves the answered plan beside it, and tests it on a local PGlite copy of the deployed schema. ```bash -apso migrate # Detect changes, generate and test SQL -apso migrate --apply # Update snapshot after successful test -apso migrate --reset # Clear sandbox and start fresh -apso migrate --sql # Output raw SQL only (for piping) +apso migrate --baseline deployed.apsorc # first time: the .apsorc that is deployed +apso migrate # later runs find the baseline from the last migration +apso migrate --answer field:Contact.phone=rename:mobilePhone +apso migrate --seed seed.sql --check checks.sql ``` -| Option | Description | Default | -|--------|-------------|---------| -| `--apply` | Update local schema snapshot after verified migration | `false` | -| `--reset` | Reset the sandbox (clear snapshot and PGlite data) | `false` | -| `--sql` | Output raw SQL statements only | `false` | +In a terminal it asks each question. Without one (or with `--json`) it prints the open questions as JSON and exits 2, so a coding agent can answer them with `--answer` or in `.apso/migration-plan.json` and run it again. The same answers always produce the same SQL. -The sandbox works by comparing your current `.apsorc` against the last-known snapshot, generating the migration SQL, and executing it against an in-process Postgres instance (PGlite). If the migration fails locally, you know before it reaches any real database. +The steps are also separate commands: `apso migrate plan`, `apso migrate generate` and `apso migrate verify`. `apso migrate sandbox` is the older snapshot preview on an empty database (`--apply`, `--reset`, `--sql`). ### `apso deploy` diff --git a/jest.config.js b/jest.config.js index 017ad80..0383bb3 100644 --- a/jest.config.js +++ b/jest.config.js @@ -2,6 +2,8 @@ module.exports = { preset: 'ts-jest', testEnvironment: 'node', + // PGlite-backed suites (migration render, verify, generate) take seconds per test on CI runners. + testTimeout: 30000, testMatch: [ "**/src/**/*.spec.ts", "**/src/**/*.test.ts", diff --git a/src/commands/deploy.ts b/src/commands/deploy.ts index 6510a2f..97fb7b1 100644 --- a/src/commands/deploy.ts +++ b/src/commands/deploy.ts @@ -95,7 +95,7 @@ export default class Deploy extends BaseCommand { this.log(`Error: ${result.error}`); } this.log("Fix the schema issue before deploying."); - this.log("Run 'apso migrate' for details."); + this.log("Run 'apso migrate sandbox' for details."); this.exit(1); } diff --git a/src/commands/migrate.ts b/src/commands/migrate.ts index 74f4bc7..5e2a01d 100644 --- a/src/commands/migrate.ts +++ b/src/commands/migrate.ts @@ -1,149 +1,218 @@ +import * as fs from "fs"; import * as path from "path"; import { Flags } from "@oclif/core"; +import * as inquirer from "inquirer"; import BaseCommand from "../lib/base-command"; -import { parseApsorc } from "../lib/apsorc-parser"; +import { readApsorcFile } from "../lib/apsorc-parser"; +import { writePlannedMigration } from "../lib/ddl/migration-file"; +import { openQuestions, Plan, planWithAnswers, Question } from "../lib/plan"; +import { Baseline, baselineFile, resolveBaseline } from "../lib/plan/baseline"; +import { describeQuestion, optionLabel, splitDefaults, sqlHint } from "../lib/plan/prompts"; import { - runMigrationSandbox, - applyMigration, - resetSandbox, -} from "../lib/migrate/sandbox"; -import { createGenerator } from "../lib"; -import { TargetLanguage, GeneratorConfig } from "../lib/types"; -import { createFile } from "../lib/utils/file-system"; + cliBinPath, + report, + run, + supportsPglite, + verifyMigration, +} from "../lib/verify"; + +const required = (v: string) => v.trim() !== "" || "Enter an expression"; export default class Migrate extends BaseCommand { static description = - "Detect schema changes, generate migration SQL, and test against a local PGlite sandbox"; + "Migrate the database to this .apsorc in one step: regenerate the code, compare with the deployed schema, ask about anything ambiguous (a rename or a drop, how a field splits, what replaces a removed value), write the migration, and test it on a local copy. In a terminal it asks; for an agent it prints the open questions as JSON and exits 2, and the agent reruns it with --answer. The steps are also separate commands: apso migrate plan, generate and verify."; static examples = [ - `$ apso migrate`, - `$ apso migrate --apply`, - `$ apso migrate --reset`, - `$ apso migrate --sql`, + "$ apso migrate", + "$ apso migrate --answer field:Contact.phone=rename:mobilePhone", + "$ apso migrate --seed seed.sql --check checks.sql", ]; static flags = { - apply: Flags.boolean({ + baseline: Flags.string({ description: - "Apply the migration: update the local snapshot after a successful test", + "The deployed .apsorc, the first time only. Later runs find it from the last migration", + }), + answer: Flags.string({ + description: "Answer a question: =