@apso/cli 0.43.3 → 0.44.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/README.md +7 -11
  2. package/dist/commands/deploy.js +1 -1
  3. package/dist/commands/generate.js +22 -12
  4. package/dist/commands/migrate/generate.d.ts +3 -1
  5. package/dist/commands/migrate/generate.js +42 -8
  6. package/dist/commands/migrate/plan.d.ts +12 -0
  7. package/dist/commands/migrate/plan.js +88 -0
  8. package/dist/commands/migrate/sandbox.d.ts +11 -0
  9. package/dist/commands/migrate/sandbox.js +132 -0
  10. package/dist/commands/migrate/verify.d.ts +5 -2
  11. package/dist/commands/migrate/verify.js +44 -9
  12. package/dist/commands/migrate.d.ts +16 -3
  13. package/dist/commands/migrate.js +171 -96
  14. package/dist/lib/ddl/catalog.d.ts +21 -0
  15. package/dist/lib/ddl/catalog.js +58 -0
  16. package/dist/lib/ddl/change.d.ts +19 -0
  17. package/dist/lib/ddl/change.js +393 -0
  18. package/dist/lib/ddl/index.d.ts +80 -0
  19. package/dist/lib/ddl/index.js +309 -0
  20. package/dist/lib/ddl/migration-file.d.ts +42 -0
  21. package/dist/lib/ddl/migration-file.js +222 -0
  22. package/dist/lib/generators/go.js +3 -2
  23. package/dist/lib/generators/python.js +3 -2
  24. package/dist/lib/plan/baseline.d.ts +22 -0
  25. package/dist/lib/plan/baseline.js +100 -0
  26. package/dist/lib/plan/index.d.ts +52 -0
  27. package/dist/lib/plan/index.js +272 -0
  28. package/dist/lib/plan/prompts.d.ts +18 -0
  29. package/dist/lib/plan/prompts.js +77 -0
  30. package/dist/lib/utils/file-system.d.ts +8 -0
  31. package/dist/lib/utils/file-system.js +31 -1
  32. package/dist/lib/verify/index.d.ts +27 -9
  33. package/dist/lib/verify/index.js +249 -21
  34. package/dist/lib/verify/review.d.ts +14 -0
  35. package/dist/lib/verify/review.js +98 -0
  36. package/npm-shrinkwrap.json +2 -2
  37. package/oclif.manifest.json +176 -23
  38. package/package.json +1 -1
package/README.md CHANGED
@@ -185,22 +185,18 @@ Requires Docker and Docker Compose. Looks for `docker-compose.yml` in the curren
185
185
 
186
186
  ### `apso migrate`
187
187
 
188
- Detect schema changes, generate migration SQL, and test against a local PGlite sandbox. No Docker or external database required.
188
+ 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.
189
189
 
190
190
  ```bash
191
- apso migrate # Detect changes, generate and test SQL
192
- apso migrate --apply # Update snapshot after successful test
193
- apso migrate --reset # Clear sandbox and start fresh
194
- apso migrate --sql # Output raw SQL only (for piping)
191
+ apso migrate --baseline deployed.apsorc # first time: the .apsorc that is deployed
192
+ apso migrate # later runs find the baseline from the last migration
193
+ apso migrate --answer field:Contact.phone=rename:mobilePhone
194
+ apso migrate --seed seed.sql --check checks.sql
195
195
  ```
196
196
 
197
- | Option | Description | Default |
198
- |--------|-------------|---------|
199
- | `--apply` | Update local schema snapshot after verified migration | `false` |
200
- | `--reset` | Reset the sandbox (clear snapshot and PGlite data) | `false` |
201
- | `--sql` | Output raw SQL statements only | `false` |
197
+ 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.
202
198
 
203
- 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.
199
+ 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`).
204
200
 
205
201
  ### `apso deploy`
206
202
 
@@ -50,7 +50,7 @@ class Deploy extends base_command_1.default {
50
50
  this.log(`Error: ${result.error}`);
51
51
  }
52
52
  this.log("Fix the schema issue before deploying.");
53
- this.log("Run 'apso migrate' for details.");
53
+ this.log("Run 'apso migrate sandbox' for details.");
54
54
  this.exit(1);
55
55
  }
56
56
  migrationCount = result.upSql.length;
@@ -66,6 +66,15 @@ class Generate extends base_command_1.default {
66
66
  console.log("[apso] No entities found in .apsorc — nothing to generate. Check that the file exists and defines an `entities` array.");
67
67
  }
68
68
  let generatedFileCount = 0;
69
+ // Every path this run writes. After generation, anything else under
70
+ // autogen/ that carries the Apso header belongs to an entity that was
71
+ // removed or renamed in .apsorc and gets deleted (apsoai/cli#157).
72
+ const generatedPaths = new Set();
73
+ const writeGenerated = (filePath, content) => {
74
+ const fullPath = path.join(autogenPath, filePath);
75
+ generatedPaths.add(fullPath);
76
+ return (0, file_system_1.createFile)(fullPath, content);
77
+ };
69
78
  const generatorConfig = {
70
79
  language,
71
80
  rootFolder,
@@ -87,17 +96,15 @@ class Generate extends base_command_1.default {
87
96
  // Generate enums
88
97
  const enumFiles = await generator.generateEnums(entities, lowerCaseApiType);
89
98
  for (const file of enumFiles) {
90
- const fullPath = path.join(autogenPath, file.path);
91
99
  // eslint-disable-next-line no-await-in-loop
92
- await (0, file_system_1.createFile)(fullPath, file.content);
100
+ await writeGenerated(file.path, file.content);
93
101
  }
94
102
  generatedFileCount += enumFiles.length;
95
103
  // Generate shared query utilities
96
104
  const queryUtilFiles = await generator.generateQueryUtils(entities, lowerCaseApiType);
97
105
  for (const file of queryUtilFiles) {
98
- const fullPath = path.join(autogenPath, file.path);
99
106
  // eslint-disable-next-line no-await-in-loop
100
- await (0, file_system_1.createFile)(fullPath, file.content);
107
+ await writeGenerated(file.path, file.content);
101
108
  }
102
109
  generatedFileCount += queryUtilFiles.length;
103
110
  // Generate per-entity files
@@ -173,9 +180,8 @@ class Generate extends base_command_1.default {
173
180
  // eslint-disable-next-line no-await-in-loop
174
181
  for (const files of allFiles) {
175
182
  for (const file of files) {
176
- const fullPath = path.join(autogenPath, file.path);
177
183
  // eslint-disable-next-line no-await-in-loop
178
- await (0, file_system_1.createFile)(fullPath, file.content);
184
+ await writeGenerated(file.path, file.content);
179
185
  generatedFileCount += 1;
180
186
  }
181
187
  }
@@ -185,26 +191,30 @@ class Generate extends base_command_1.default {
185
191
  // Generate guards
186
192
  const guardFiles = await generator.generateGuards(entities, auth);
187
193
  for (const file of guardFiles) {
188
- const fullPath = path.join(autogenPath, file.path);
189
194
  // eslint-disable-next-line no-await-in-loop
190
- await (0, file_system_1.createFile)(fullPath, file.content);
195
+ await writeGenerated(file.path, file.content);
191
196
  }
192
197
  generatedFileCount += guardFiles.length;
193
198
  // Generate domain-event spine (only when at least one entity opts in)
194
199
  const domainEventFiles = await generator.generateDomainEvents(entities, lowerCaseApiType, { emitEvents });
195
200
  for (const file of domainEventFiles) {
196
- const fullPath = path.join(autogenPath, file.path);
197
201
  // eslint-disable-next-line no-await-in-loop
198
- await (0, file_system_1.createFile)(fullPath, file.content);
202
+ await writeGenerated(file.path, file.content);
199
203
  }
200
204
  // Generate index module
201
205
  const indexFiles = await generator.generateIndexModule(entities, lowerCaseApiType, { emitEvents, http });
202
206
  for (const file of indexFiles) {
203
- const fullPath = path.join(autogenPath, file.path);
204
207
  // eslint-disable-next-line no-await-in-loop
205
- await (0, file_system_1.createFile)(fullPath, file.content);
208
+ await writeGenerated(file.path, file.content);
206
209
  }
207
210
  generatedFileCount += indexFiles.length;
211
+ // Skip cleanup when nothing was generated: an empty or missing .apsorc
212
+ // would otherwise wipe every generated file.
213
+ if (entities.length > 0) {
214
+ for (const removed of (0, file_system_1.removeStaleGeneratedFiles)(autogenPath, generatedPaths)) {
215
+ console.log(`[apso] Removed stale ${path.relative(process.cwd(), removed)}`);
216
+ }
217
+ }
208
218
  // Format generated files (TypeScript only) with Biome — bundled with the
209
219
  // CLI and invoked directly, so it's a fast native pass (~ms) with no npx
210
220
  // download and no dependence on the project having a formatter installed.
@@ -3,8 +3,10 @@ export default class MigrateGenerate extends BaseCommand {
3
3
  static description: string;
4
4
  static examples: string[];
5
5
  static flags: {
6
- baseline: import("@oclif/core/lib/interfaces").OptionFlag<string, import("@oclif/core/lib/interfaces/parser").CustomOptions>;
6
+ baseline: import("@oclif/core/lib/interfaces").OptionFlag<string | undefined, import("@oclif/core/lib/interfaces/parser").CustomOptions>;
7
7
  name: import("@oclif/core/lib/interfaces").OptionFlag<string, import("@oclif/core/lib/interfaces/parser").CustomOptions>;
8
+ plan: import("@oclif/core/lib/interfaces").OptionFlag<string, import("@oclif/core/lib/interfaces/parser").CustomOptions>;
9
+ typeorm: import("@oclif/core/lib/interfaces").BooleanFlag<boolean>;
8
10
  json: import("@oclif/core/lib/interfaces").BooleanFlag<boolean>;
9
11
  };
10
12
  run(): Promise<void>;
@@ -5,14 +5,37 @@ const path = tslib_1.__importStar(require("path"));
5
5
  const core_1 = require("@oclif/core");
6
6
  const base_command_1 = tslib_1.__importDefault(require("../../lib/base-command"));
7
7
  const apsorc_parser_1 = require("../../lib/apsorc-parser");
8
+ const migration_file_1 = require("../../lib/ddl/migration-file");
9
+ const baseline_1 = require("../../lib/plan/baseline");
8
10
  const verify_1 = require("../../lib/verify");
9
11
  class MigrateGenerate extends base_command_1.default {
10
12
  async run() {
13
+ var _a, _b;
11
14
  const { flags } = await this.parse(MigrateGenerate);
12
- const unsupported = (0, verify_1.migrateUnsupported)((0, apsorc_parser_1.parseApsorc)().language);
13
- if (unsupported)
14
- this.error(unsupported);
15
- const result = await (0, verify_1.generateMigration)(process.cwd(), path.resolve(flags.baseline), flags.name, (0, verify_1.cliBinPath)(this.config.root));
15
+ const { apsorc } = (0, apsorc_parser_1.readApsorcFile)();
16
+ if (flags.typeorm && ((_a = apsorc.language) !== null && _a !== void 0 ? _a : "typescript") !== "typescript")
17
+ this.error("--typeorm works on TypeScript services only.");
18
+ let baseline;
19
+ try {
20
+ baseline = (0, baseline_1.resolveBaseline)(process.cwd(), apsorc, flags.baseline, path.resolve(flags.plan));
21
+ }
22
+ catch (error) {
23
+ this.error(error.message, { exit: 1 });
24
+ }
25
+ const result = flags.typeorm
26
+ ? await (0, verify_1.generateMigration)(process.cwd(), (0, baseline_1.baselineFile)(baseline), flags.name, (0, verify_1.cliBinPath)(this.config.root))
27
+ : (0, migration_file_1.writePlannedMigration)(process.cwd(), baseline.schema, flags.name, path.resolve(flags.plan));
28
+ const { open } = result;
29
+ if (open && open.length > 0) {
30
+ if (flags.json)
31
+ this.log(JSON.stringify(result));
32
+ else {
33
+ this.log((_b = result.steps[0].output) !== null && _b !== void 0 ? _b : "");
34
+ for (const q of open)
35
+ this.log(`\n${q.id}\n ${q.subject}\n options: ${q.options.join(" | ")}${q.suggested ? ` (suggested: ${q.suggested})` : ""}`);
36
+ }
37
+ this.exit(2);
38
+ }
16
39
  const summary = result.needed
17
40
  ? `Created ${result.file}`
18
41
  : "The database already matches; no migration needed.";
@@ -21,17 +44,28 @@ class MigrateGenerate extends base_command_1.default {
21
44
  this.exit(1);
22
45
  }
23
46
  }
24
- MigrateGenerate.description = "Generate a TypeORM migration from the deployed schema to this project's entities, using a local PGlite database (TypeScript services)";
25
- MigrateGenerate.examples = [`$ apso migrate generate --baseline deployed.apsorc`];
47
+ MigrateGenerate.description = "Write a migration from the deployed schema to this project's .apsorc. Apso renders the SQL from the answered migration plan (apso migrate plan), including data moves and a down() that reverses them. Exits 2 while plan questions are open. The migration is a TypeORM class, an Alembic revision or a goose SQL file, by the service's language. --typeorm uses TypeORM's generator on a local PGlite database instead (TypeScript services).";
48
+ MigrateGenerate.examples = [
49
+ `$ apso migrate plan --answer entity:Company=rename:Organization`,
50
+ `$ apso migrate generate`,
51
+ `$ apso migrate generate --typeorm`,
52
+ ];
26
53
  MigrateGenerate.flags = {
27
54
  baseline: core_1.Flags.string({
28
- description: "The deployed .apsorc to migrate from",
29
- required: true,
55
+ description: "The deployed .apsorc to migrate from. Defaults to the schema after the newest planned migration, or the .apsorc from the last commit that changed the migrations folder",
30
56
  }),
31
57
  name: core_1.Flags.string({
32
58
  description: "Migration name",
33
59
  default: "SchemaUpdate",
34
60
  }),
61
+ plan: core_1.Flags.string({
62
+ description: "Migration plan with the answers",
63
+ default: ".apso/migration-plan.json",
64
+ }),
65
+ typeorm: core_1.Flags.boolean({
66
+ description: "Use TypeORM's migration:generate instead of Apso's plan (no data moves)",
67
+ default: false,
68
+ }),
35
69
  json: core_1.Flags.boolean({
36
70
  description: "Print the result as JSON",
37
71
  default: false,
@@ -0,0 +1,12 @@
1
+ import BaseCommand from "../../lib/base-command";
2
+ export default class MigratePlan extends BaseCommand {
3
+ static description: string;
4
+ static examples: string[];
5
+ static flags: {
6
+ baseline: import("@oclif/core/lib/interfaces").OptionFlag<string | undefined, import("@oclif/core/lib/interfaces/parser").CustomOptions>;
7
+ plan: import("@oclif/core/lib/interfaces").OptionFlag<string, import("@oclif/core/lib/interfaces/parser").CustomOptions>;
8
+ answer: import("@oclif/core/lib/interfaces").OptionFlag<string[], import("@oclif/core/lib/interfaces/parser").CustomOptions>;
9
+ json: import("@oclif/core/lib/interfaces").BooleanFlag<boolean>;
10
+ };
11
+ run(): Promise<void>;
12
+ }
@@ -0,0 +1,88 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const tslib_1 = require("tslib");
4
+ const fs = tslib_1.__importStar(require("fs"));
5
+ const path = tslib_1.__importStar(require("path"));
6
+ const core_1 = require("@oclif/core");
7
+ const base_command_1 = tslib_1.__importDefault(require("../../lib/base-command"));
8
+ const apsorc_parser_1 = require("../../lib/apsorc-parser");
9
+ const plan_1 = require("../../lib/plan");
10
+ const baseline_1 = require("../../lib/plan/baseline");
11
+ const prompts_1 = require("../../lib/plan/prompts");
12
+ class MigratePlan extends base_command_1.default {
13
+ async run() {
14
+ const { flags } = await this.parse(MigratePlan);
15
+ const planPath = path.resolve(flags.plan);
16
+ const { apsorc: after } = (0, apsorc_parser_1.readApsorcFile)();
17
+ let baseline;
18
+ try {
19
+ baseline = (0, baseline_1.resolveBaseline)(process.cwd(), after, flags.baseline, planPath);
20
+ }
21
+ catch (error) {
22
+ this.error(error.message, { exit: 1 });
23
+ }
24
+ const before = baseline.schema;
25
+ // Agents read the output from a pipe; people at a terminal get text.
26
+ const json = flags.json || !process.stdout.isTTY;
27
+ // Keep stdout pure JSON for agents: parser notices go to stderr.
28
+ if (json)
29
+ console.info = (...m) => process.stderr.write(m.join(" ") + "\n");
30
+ const previous = fs.existsSync(planPath)
31
+ ? JSON.parse(fs.readFileSync(planPath).toString())
32
+ : undefined;
33
+ let plan;
34
+ try {
35
+ plan = (0, plan_1.planWithAnswers)(before, after, previous, flags.answer);
36
+ }
37
+ catch (error) {
38
+ this.error(error.message, { exit: 1 });
39
+ }
40
+ fs.mkdirSync(path.dirname(planPath), { recursive: true });
41
+ fs.writeFileSync(planPath, JSON.stringify({ ...plan, before }, null, 2) + "\n");
42
+ const open = (0, plan_1.openQuestions)(plan);
43
+ if (json) {
44
+ this.log(JSON.stringify({ plan: flags.plan, baseline: baseline.source, open: open.map((q) => (0, prompts_1.describeQuestion)(q)) }, null, 2));
45
+ }
46
+ else if (open.length === 0) {
47
+ this.log(`Compared with ${baseline.source}.`);
48
+ this.log(`${flags.plan}: all ${plan.questions.length} question(s) answered.`);
49
+ }
50
+ else {
51
+ this.log(`Compared with ${baseline.source}.`);
52
+ this.log(`${flags.plan}: ${open.length} of ${plan.questions.length} question(s) open.`);
53
+ for (const q of open) {
54
+ const hint = q.suggested ? ` (suggested: ${q.suggested})` : "";
55
+ this.log(`\n${q.id}\n ${q.subject}\n options: ${q.options.join(" | ")}${hint}`);
56
+ }
57
+ this.log(`\nAnswer with --answer <id>=<option>, or edit ${flags.plan}. ` +
58
+ `"sql" and "split:" answers also need a "sql" value in the file.`);
59
+ }
60
+ if (open.length > 0)
61
+ this.exit(2);
62
+ }
63
+ }
64
+ MigratePlan.description = "Compare the deployed .apsorc with this one and write a migration plan. Changes whose intent is ambiguous (rename or drop, how a field splits, which value replaces a removed enum value) become questions; answer them in the plan file or with --answer. Exits 2 while questions are open.";
65
+ MigratePlan.examples = [
66
+ "$ apso migrate plan",
67
+ "$ apso migrate plan --answer field:Contact.phone=rename:mobilePhone",
68
+ "$ apso migrate plan --baseline deployed.apsorc",
69
+ ];
70
+ MigratePlan.flags = {
71
+ baseline: core_1.Flags.string({
72
+ description: "The deployed .apsorc to migrate from. Defaults to the schema after the newest planned migration, or the .apsorc from the last commit that changed the migrations folder",
73
+ }),
74
+ plan: core_1.Flags.string({
75
+ description: "Plan file to read answers from and write",
76
+ default: ".apso/migration-plan.json",
77
+ }),
78
+ answer: core_1.Flags.string({
79
+ description: "Answer a question: <question id>=<option>. Repeatable.",
80
+ multiple: true,
81
+ default: [],
82
+ }),
83
+ json: core_1.Flags.boolean({
84
+ description: "Print the open questions as JSON (the default when output is not a terminal)",
85
+ default: false,
86
+ }),
87
+ };
88
+ exports.default = MigratePlan;
@@ -0,0 +1,11 @@
1
+ import BaseCommand from "../../lib/base-command";
2
+ export default class MigrateSandbox extends BaseCommand {
3
+ static description: string;
4
+ static examples: string[];
5
+ static flags: {
6
+ apply: import("@oclif/core/lib/interfaces").BooleanFlag<boolean>;
7
+ reset: import("@oclif/core/lib/interfaces").BooleanFlag<boolean>;
8
+ sql: import("@oclif/core/lib/interfaces").BooleanFlag<boolean>;
9
+ };
10
+ run(): Promise<void>;
11
+ }
@@ -0,0 +1,132 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const tslib_1 = require("tslib");
4
+ const path = tslib_1.__importStar(require("path"));
5
+ const core_1 = require("@oclif/core");
6
+ const base_command_1 = tslib_1.__importDefault(require("../../lib/base-command"));
7
+ const apsorc_parser_1 = require("../../lib/apsorc-parser");
8
+ const sandbox_1 = require("../../lib/migrate/sandbox");
9
+ const lib_1 = require("../../lib");
10
+ const file_system_1 = require("../../lib/utils/file-system");
11
+ class MigrateSandbox extends base_command_1.default {
12
+ async run() {
13
+ const { flags } = await this.parse(MigrateSandbox);
14
+ // Handle reset
15
+ if (flags.reset) {
16
+ (0, sandbox_1.resetSandbox)();
17
+ this.log("Sandbox reset. Snapshot and PGlite data cleared.");
18
+ return;
19
+ }
20
+ // Parse .apsorc
21
+ let parsed;
22
+ try {
23
+ parsed = (0, apsorc_parser_1.parseApsorc)();
24
+ }
25
+ catch (error) {
26
+ const msg = error instanceof Error ? error.message : String(error);
27
+ this.error(`Failed to parse .apsorc: ${msg}`);
28
+ }
29
+ if (!flags.sql) {
30
+ this.log("Schema changes detected. Running migration sandbox...");
31
+ }
32
+ // Run the sandbox
33
+ const result = await (0, sandbox_1.runMigrationSandbox)({ entities: parsed.entities, relationshipMap: parsed.relationshipMap });
34
+ if (!result.needed) {
35
+ if (!flags.sql) {
36
+ this.log("No database-level changes detected.");
37
+ }
38
+ return;
39
+ }
40
+ // Output results
41
+ if (flags.sql) {
42
+ // Raw SQL output mode
43
+ for (const sql of result.upSql) {
44
+ this.log(sql + ";");
45
+ }
46
+ return;
47
+ }
48
+ if (result.success) {
49
+ this.log("");
50
+ this.log("Migration SQL (up):");
51
+ this.log("---");
52
+ for (const sql of result.upSql) {
53
+ this.log(` ${sql};`);
54
+ }
55
+ this.log("---");
56
+ this.log("");
57
+ this.log(`${result.upSql.length} statement(s) generated and tested.`);
58
+ this.log("Migration validated against local PGlite.");
59
+ // Generate language-native migration files for Python and Go
60
+ const language = parsed.language || "typescript";
61
+ if (language !== "typescript") {
62
+ const generatorConfig = {
63
+ language,
64
+ rootFolder: parsed.rootFolder || "src",
65
+ apiType: parsed.apiType || "rest",
66
+ entities: parsed.entities,
67
+ relationshipMap: parsed.relationshipMap,
68
+ };
69
+ const generator = (0, lib_1.createGenerator)(generatorConfig);
70
+ const migrationFiles = await generator.generateMigration({
71
+ entities: parsed.entities,
72
+ relationshipMap: parsed.relationshipMap,
73
+ upSql: result.upSql,
74
+ downSql: result.downSql,
75
+ });
76
+ for (const file of migrationFiles) {
77
+ const fullPath = path.join(process.cwd(), file.path);
78
+ // eslint-disable-next-line no-await-in-loop
79
+ await (0, file_system_1.createFile)(fullPath, file.content);
80
+ this.log(`Generated migration file: ${file.path}`);
81
+ }
82
+ }
83
+ if (flags.apply) {
84
+ (0, sandbox_1.applyMigration)({
85
+ entities: parsed.entities,
86
+ relationshipMap: parsed.relationshipMap,
87
+ });
88
+ this.log("Snapshot updated.");
89
+ }
90
+ else {
91
+ this.log("Run with --apply to update the snapshot.");
92
+ }
93
+ }
94
+ else {
95
+ this.log("");
96
+ this.log("Migration FAILED.");
97
+ if (result.error) {
98
+ this.log(`Error: ${result.error}`);
99
+ }
100
+ if (result.upSql.length > 0) {
101
+ this.log("");
102
+ this.log("Attempted SQL:");
103
+ for (const sql of result.upSql) {
104
+ this.log(` ${sql};`);
105
+ }
106
+ }
107
+ this.exit(1);
108
+ }
109
+ }
110
+ }
111
+ MigrateSandbox.description = "Test schema changes against a local PGlite sandbox that keeps its own snapshot of the last schema (the older flow; apso migrate is the guided one)";
112
+ MigrateSandbox.examples = [
113
+ `$ apso migrate sandbox`,
114
+ `$ apso migrate sandbox --apply`,
115
+ `$ apso migrate sandbox --reset`,
116
+ `$ apso migrate sandbox --sql`,
117
+ ];
118
+ MigrateSandbox.flags = {
119
+ apply: core_1.Flags.boolean({
120
+ description: "Apply the migration: update the local snapshot after a successful test",
121
+ default: false,
122
+ }),
123
+ reset: core_1.Flags.boolean({
124
+ description: "Reset the sandbox (clear snapshot and PGlite data)",
125
+ default: false,
126
+ }),
127
+ sql: core_1.Flags.boolean({
128
+ description: "Output raw SQL only (for piping to files)",
129
+ default: false,
130
+ }),
131
+ };
132
+ exports.default = MigrateSandbox;
@@ -3,8 +3,11 @@ export default class MigrateVerify extends BaseCommand {
3
3
  static description: string;
4
4
  static examples: string[];
5
5
  static flags: {
6
- baseline: import("@oclif/core/lib/interfaces").OptionFlag<string, import("@oclif/core/lib/interfaces/parser").CustomOptions>;
7
- migration: import("@oclif/core/lib/interfaces").OptionFlag<string, import("@oclif/core/lib/interfaces/parser").CustomOptions>;
6
+ baseline: import("@oclif/core/lib/interfaces").OptionFlag<string | undefined, import("@oclif/core/lib/interfaces/parser").CustomOptions>;
7
+ migration: import("@oclif/core/lib/interfaces").OptionFlag<string | undefined, import("@oclif/core/lib/interfaces/parser").CustomOptions>;
8
+ seed: import("@oclif/core/lib/interfaces").OptionFlag<string | undefined, import("@oclif/core/lib/interfaces/parser").CustomOptions>;
9
+ check: import("@oclif/core/lib/interfaces").OptionFlag<string | undefined, import("@oclif/core/lib/interfaces/parser").CustomOptions>;
10
+ "accept-loss": import("@oclif/core/lib/interfaces").OptionFlag<string[], import("@oclif/core/lib/interfaces/parser").CustomOptions>;
8
11
  "skip-build": import("@oclif/core/lib/interfaces").BooleanFlag<boolean>;
9
12
  json: import("@oclif/core/lib/interfaces").BooleanFlag<boolean>;
10
13
  };
@@ -5,31 +5,66 @@ const path = tslib_1.__importStar(require("path"));
5
5
  const core_1 = require("@oclif/core");
6
6
  const base_command_1 = tslib_1.__importDefault(require("../../lib/base-command"));
7
7
  const apsorc_parser_1 = require("../../lib/apsorc-parser");
8
+ const baseline_1 = require("../../lib/plan/baseline");
8
9
  const verify_1 = require("../../lib/verify");
9
10
  class MigrateVerify extends base_command_1.default {
10
11
  async run() {
12
+ var _a;
11
13
  const { flags } = await this.parse(MigrateVerify);
12
- const unsupported = (0, verify_1.migrateUnsupported)((0, apsorc_parser_1.parseApsorc)().language);
13
- if (unsupported)
14
- this.error(unsupported);
15
- const result = await (0, verify_1.verifyMigration)(process.cwd(), path.resolve(flags.baseline), flags.migration, (0, verify_1.cliBinPath)(this.config.root), flags["skip-build"]);
14
+ const { apsorc } = (0, apsorc_parser_1.readApsorcFile)();
15
+ const latest = (0, baseline_1.latestPlannedMigration)(process.cwd(), apsorc);
16
+ const migration = (_a = flags.migration) !== null && _a !== void 0 ? _a : latest === null || latest === void 0 ? void 0 : latest.file;
17
+ if (!migration)
18
+ this.error("No planned migration found. Run apso migrate generate, or pass --migration.", { exit: 1 });
19
+ // The migration's own plan says which schema it starts from.
20
+ const saved = latest && path.resolve(latest.file) === path.resolve(migration)
21
+ ? latest.saved
22
+ : undefined;
23
+ let baseline;
24
+ if (flags.baseline)
25
+ baseline = path.resolve(flags.baseline);
26
+ else if (saved)
27
+ baseline = (0, baseline_1.baselineFile)({
28
+ schema: saved.before,
29
+ source: latest.planFile,
30
+ });
31
+ else
32
+ this.error(`No saved plan for ${migration}; pass the deployed .apsorc with --baseline.`, { exit: 1 });
33
+ const result = await (0, verify_1.verifyMigration)(process.cwd(), baseline, migration, (0, verify_1.cliBinPath)(this.config.root), {
34
+ skipBuild: flags["skip-build"],
35
+ seedFile: flags.seed,
36
+ checkFile: flags.check,
37
+ acceptLoss: flags["accept-loss"],
38
+ });
16
39
  (0, verify_1.report)((s) => this.log(s), result, flags.json, result.ok ? "Migration verified." : undefined);
17
40
  if (!result.ok)
18
41
  this.exit(1);
19
42
  }
20
43
  }
21
- MigrateVerify.description = "Run a migration on a local PGlite copy of the deployed schema, then check the database matches this project's entities (TypeScript services)";
44
+ MigrateVerify.description = "Run a migration on a local PGlite copy of the deployed schema, then check the database matches this project's entities (TypeScript), or a fresh build of its .apsorc (Python and Go, which also run down and check it gives back the deployed schema)";
22
45
  MigrateVerify.examples = [
46
+ `$ apso migrate verify`,
47
+ `$ apso migrate verify --seed seed.sql --check checks.sql`,
48
+ `$ apso migrate verify --seed seed.sql --check checks.sql --accept-loss contact.fullName`,
23
49
  `$ apso migrate verify --baseline deployed.apsorc --migration src/migrations/1700000000000-SchemaUpdate-migration.ts`,
24
50
  ];
25
51
  MigrateVerify.flags = {
26
52
  baseline: core_1.Flags.string({
27
- description: "The deployed .apsorc the migration starts from",
28
- required: true,
53
+ description: "The deployed .apsorc the migration starts from. Defaults to the schema saved with the migration's plan",
29
54
  }),
30
55
  migration: core_1.Flags.string({
31
- description: "The migration file to run",
32
- required: true,
56
+ description: "The migration file to run. Defaults to the newest planned migration",
57
+ }),
58
+ seed: core_1.Flags.string({
59
+ description: "SQL file of rows to insert into the deployed schema before the migration runs; the check then fails if the migration loses any of them",
60
+ }),
61
+ check: core_1.Flags.string({
62
+ description: "SQL file of SELECTs run after the migration; each row must have a boolean ok (and a label naming the check), e.g. SELECT bool_and(\"firstName\" <> '') AS ok, 'every contact has a first name' AS label FROM contact",
63
+ }),
64
+ "accept-loss": core_1.Flags.string({
65
+ description: "A table or table.column whose seeded data the migration may drop on purpose (repeatable); pair it with --check to prove where the data went",
66
+ multiple: true,
67
+ default: [],
33
68
  }),
34
69
  "skip-build": core_1.Flags.boolean({
35
70
  description: "Use the existing dist/ build instead of building first",
@@ -3,9 +3,22 @@ export default class Migrate extends BaseCommand {
3
3
  static description: string;
4
4
  static examples: string[];
5
5
  static flags: {
6
- apply: import("@oclif/core/lib/interfaces").BooleanFlag<boolean>;
7
- reset: import("@oclif/core/lib/interfaces").BooleanFlag<boolean>;
8
- sql: import("@oclif/core/lib/interfaces").BooleanFlag<boolean>;
6
+ baseline: import("@oclif/core/lib/interfaces").OptionFlag<string | undefined, import("@oclif/core/lib/interfaces/parser").CustomOptions>;
7
+ answer: import("@oclif/core/lib/interfaces").OptionFlag<string[], import("@oclif/core/lib/interfaces/parser").CustomOptions>;
8
+ seed: import("@oclif/core/lib/interfaces").OptionFlag<string | undefined, import("@oclif/core/lib/interfaces/parser").CustomOptions>;
9
+ check: import("@oclif/core/lib/interfaces").OptionFlag<string | undefined, import("@oclif/core/lib/interfaces/parser").CustomOptions>;
10
+ "accept-loss": import("@oclif/core/lib/interfaces").OptionFlag<string[], import("@oclif/core/lib/interfaces/parser").CustomOptions>;
11
+ name: import("@oclif/core/lib/interfaces").OptionFlag<string, import("@oclif/core/lib/interfaces/parser").CustomOptions>;
12
+ "skip-generate": import("@oclif/core/lib/interfaces").BooleanFlag<boolean>;
13
+ "skip-verify": import("@oclif/core/lib/interfaces").BooleanFlag<boolean>;
14
+ json: import("@oclif/core/lib/interfaces").BooleanFlag<boolean>;
9
15
  };
16
+ private json;
17
+ /** Progress for people; agents get only the final JSON. */
18
+ private say;
19
+ /** Print the outcome: one JSON object for agents, nothing extra for people. */
20
+ private out;
21
+ private fail;
10
22
  run(): Promise<void>;
23
+ private ask;
11
24
  }