@apso/cli 0.43.4 → 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.
@@ -1,131 +1,206 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  const tslib_1 = require("tslib");
4
+ const fs = tslib_1.__importStar(require("fs"));
4
5
  const path = tslib_1.__importStar(require("path"));
5
6
  const core_1 = require("@oclif/core");
7
+ const inquirer = tslib_1.__importStar(require("inquirer"));
6
8
  const base_command_1 = tslib_1.__importDefault(require("../lib/base-command"));
7
9
  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");
10
+ const migration_file_1 = require("../lib/ddl/migration-file");
11
+ const plan_1 = require("../lib/plan");
12
+ const baseline_1 = require("../lib/plan/baseline");
13
+ const prompts_1 = require("../lib/plan/prompts");
14
+ const verify_1 = require("../lib/verify");
15
+ const required = (v) => v.trim() !== "" || "Enter an expression";
11
16
  class Migrate extends base_command_1.default {
17
+ constructor() {
18
+ super(...arguments);
19
+ this.json = false;
20
+ }
21
+ /** Progress for people; agents get only the final JSON. */
22
+ say(s) {
23
+ if (!this.json)
24
+ this.log(s);
25
+ }
26
+ /** Print the outcome: one JSON object for agents, nothing extra for people. */
27
+ out(status, extra) {
28
+ if (this.json)
29
+ this.log(JSON.stringify({ status, ...extra }, null, 2));
30
+ }
31
+ fail(message, extra = {}) {
32
+ if (this.json) {
33
+ this.out("failed", { error: message, ...extra });
34
+ this.exit(1);
35
+ }
36
+ this.error(message, { exit: 1 });
37
+ }
12
38
  async run() {
39
+ var _a, _b, _c;
13
40
  const { flags } = await this.parse(Migrate);
14
- // Handle reset
15
- if (flags.reset) {
16
- (0, sandbox_1.resetSandbox)();
17
- this.log("Sandbox reset. Snapshot and PGlite data cleared.");
18
- return;
41
+ const cwd = process.cwd();
42
+ this.json = flags.json || !process.stdout.isTTY;
43
+ const interactive = !this.json && Boolean(process.stdin.isTTY);
44
+ // Keep stdout pure JSON for agents: parser notices go to stderr.
45
+ if (this.json)
46
+ console.info = (...m) => process.stderr.write(m.join(" ") + "\n");
47
+ const { apsorc } = (0, apsorc_parser_1.readApsorcFile)();
48
+ const cliBin = (0, verify_1.cliBinPath)(this.config.root);
49
+ if (!flags["skip-generate"]) {
50
+ this.say("Generating code from .apsorc...");
51
+ // Agents run without a terminal, where generate needs the language spelled out.
52
+ const language = (_a = apsorc.language) !== null && _a !== void 0 ? _a : "typescript";
53
+ const gen = await (0, verify_1.run)(process.execPath, [cliBin, "generate", "--language", language], cwd);
54
+ if (!gen.ok)
55
+ this.fail(`apso generate failed:\n${gen.output.trim().split("\n").slice(-15).join("\n")}`);
19
56
  }
20
- // Parse .apsorc
21
- let parsed;
57
+ const planPath = path.resolve(".apso/migration-plan.json");
58
+ let baseline;
22
59
  try {
23
- parsed = (0, apsorc_parser_1.parseApsorc)();
60
+ baseline = (0, baseline_1.resolveBaseline)(cwd, apsorc, flags.baseline, planPath);
24
61
  }
25
62
  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...");
63
+ this.fail(error.message);
31
64
  }
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;
65
+ this.say(`Comparing with ${baseline.source}.`);
66
+ const previous = fs.existsSync(planPath)
67
+ ? JSON.parse(fs.readFileSync(planPath).toString())
68
+ : undefined;
69
+ let plan;
70
+ try {
71
+ plan = (0, plan_1.planWithAnswers)(baseline.schema, apsorc, previous, flags.answer);
39
72
  }
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;
73
+ catch (error) {
74
+ this.fail(error.message);
47
75
  }
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}`);
76
+ const save = () => {
77
+ fs.mkdirSync(path.dirname(planPath), { recursive: true });
78
+ fs.writeFileSync(planPath, JSON.stringify({ ...plan, before: baseline.schema }, null, 2) + "\n");
79
+ };
80
+ // Ask until nothing is open. Answers can raise new questions (dropping a
81
+ // field leaves a required one to backfill), so re-plan after each round.
82
+ for (let open = (0, plan_1.openQuestions)(plan); open.length > 0; open = (0, plan_1.openQuestions)(plan)) {
83
+ save();
84
+ if (!interactive) {
85
+ const next = 'Rerun apso migrate --answer <id>=<option> for each. For "sql" and "split:" answers, also set the question\'s "sql" in the plan file: a string, or one expression per split field.';
86
+ if (this.json) {
87
+ this.out("questions", { plan: path.relative(cwd, planPath), baseline: baseline.source, open: open.map((q) => (0, prompts_1.describeQuestion)(q)), next });
81
88
  }
89
+ else {
90
+ for (const q of open)
91
+ this.log(`\n${q.id}\n ${q.subject}\n options: ${q.options.join(" | ")}`);
92
+ this.log(`\n${next}`);
93
+ }
94
+ this.exit(2);
82
95
  }
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.");
96
+ for (const q of open) {
97
+ // eslint-disable-next-line no-await-in-loop
98
+ await this.ask(q);
92
99
  }
100
+ plan = (0, plan_1.planWithAnswers)(baseline.schema, apsorc, plan, []);
93
101
  }
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
- }
102
+ save();
103
+ const written = (0, migration_file_1.writePlannedMigration)(cwd, baseline.schema, flags.name, planPath);
104
+ if (!written.ok)
105
+ this.fail(written.steps.map((s) => s.output).filter(Boolean).join("\n"));
106
+ if (!written.needed) {
107
+ this.say("The database already matches .apsorc. Nothing to migrate.");
108
+ this.out("up-to-date", {});
109
+ return;
110
+ }
111
+ this.say(`Wrote ${written.file}`);
112
+ for (const f of (_b = written.findings) !== null && _b !== void 0 ? _b : [])
113
+ this.say(` warning ${f.code}: ${f.message}`);
114
+ let verified = "skipped";
115
+ // Python and Go verify without the project's own database driver.
116
+ const canVerify = ((_c = apsorc.language) !== null && _c !== void 0 ? _c : "typescript") !== "typescript" || (0, verify_1.supportsPglite)(cwd);
117
+ if (!flags["skip-verify"] && canVerify) {
118
+ this.say("Testing the migration on a local copy of the deployed schema...");
119
+ const result = await (0, verify_1.verifyMigration)(cwd, (0, baseline_1.baselineFile)(baseline), written.file, cliBin, {
120
+ seedFile: flags.seed,
121
+ checkFile: flags.check,
122
+ acceptLoss: flags["accept-loss"],
123
+ });
124
+ verified = result.ok;
125
+ if (!this.json)
126
+ (0, verify_1.report)((s) => this.log(s), { ...result, findings: [] }, false);
127
+ if (!result.ok)
128
+ this.fail(`The migration in ${written.file} failed its test. Fix the answers in ${written.planFile} or the .apsorc, then run apso migrate again.`, { file: written.file, planFile: written.planFile, steps: result.steps });
129
+ }
130
+ this.say(`\nReview and commit ${written.file} and ${written.planFile}, then deploy.`);
131
+ if (verified === true && !flags.seed)
132
+ this.say("Tip: --seed <rows.sql> also checks that existing rows survive the migration.");
133
+ this.out("written", { file: written.file, planFile: written.planFile, verified });
134
+ }
135
+ async ask(q) {
136
+ const { choice } = await inquirer.prompt([
137
+ {
138
+ type: "list",
139
+ name: "choice",
140
+ message: q.subject,
141
+ choices: q.options.map((o) => ({ name: (0, prompts_1.optionLabel)(q, o), value: o })),
142
+ default: q.suggested,
143
+ },
144
+ ]);
145
+ q.answer = choice;
146
+ if (choice === "sql") {
147
+ const { sql } = await inquirer.prompt([
148
+ { type: "input", name: "sql", message: (0, prompts_1.sqlHint)(q), validate: required },
149
+ ]);
150
+ q.sql = sql;
151
+ }
152
+ if (choice.startsWith("split:")) {
153
+ const targets = choice.slice("split:".length).split(",");
154
+ const defaults = (0, prompts_1.splitDefaults)(q, targets);
155
+ const sql = {};
156
+ for (const t of targets) {
157
+ // eslint-disable-next-line no-await-in-loop
158
+ const { expr } = await inquirer.prompt([
159
+ { type: "input", name: "expr", message: `SQL for ${t}`, default: defaults[t], validate: required },
160
+ ]);
161
+ sql[t] = expr;
106
162
  }
107
- this.exit(1);
163
+ q.sql = sql;
108
164
  }
109
165
  }
110
166
  }
111
- Migrate.description = "Detect schema changes, generate migration SQL, and test against a local PGlite sandbox";
167
+ Migrate.description = "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.";
112
168
  Migrate.examples = [
113
- `$ apso migrate`,
114
- `$ apso migrate --apply`,
115
- `$ apso migrate --reset`,
116
- `$ apso migrate --sql`,
169
+ "$ apso migrate",
170
+ "$ apso migrate --answer field:Contact.phone=rename:mobilePhone",
171
+ "$ apso migrate --seed seed.sql --check checks.sql",
117
172
  ];
118
173
  Migrate.flags = {
119
- apply: core_1.Flags.boolean({
120
- description: "Apply the migration: update the local snapshot after a successful test",
174
+ baseline: core_1.Flags.string({
175
+ description: "The deployed .apsorc, the first time only. Later runs find it from the last migration",
176
+ }),
177
+ answer: core_1.Flags.string({
178
+ description: "Answer a question: <question id>=<option>. Repeatable.",
179
+ multiple: true,
180
+ default: [],
181
+ }),
182
+ seed: core_1.Flags.string({
183
+ description: "SQL file of rows to load first; the test then fails if the migration loses any",
184
+ }),
185
+ check: core_1.Flags.string({
186
+ description: "SQL file of SELECTs with a boolean ok column, run after the migration",
187
+ }),
188
+ "accept-loss": core_1.Flags.string({
189
+ description: "A table or table.column whose seeded data may be dropped on purpose (repeatable)",
190
+ multiple: true,
191
+ default: [],
192
+ }),
193
+ name: core_1.Flags.string({ description: "Migration name", default: "SchemaUpdate" }),
194
+ "skip-generate": core_1.Flags.boolean({
195
+ description: "Don't regenerate code from .apsorc first",
121
196
  default: false,
122
197
  }),
123
- reset: core_1.Flags.boolean({
124
- description: "Reset the sandbox (clear snapshot and PGlite data)",
198
+ "skip-verify": core_1.Flags.boolean({
199
+ description: "Write the migration without testing it",
125
200
  default: false,
126
201
  }),
127
- sql: core_1.Flags.boolean({
128
- description: "Output raw SQL only (for piping to files)",
202
+ json: core_1.Flags.boolean({
203
+ description: "Print the result as JSON (the default when output is not a terminal)",
129
204
  default: false,
130
205
  }),
131
206
  };
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Read a database's schema back out of Postgres's own catalog, in a form two
3
+ * databases can be compared by. Postgres normalizes what it stores (types,
4
+ * defaults, constraint definitions), so two databases built by different SQL
5
+ * compare equal when they ended up the same.
6
+ */
7
+ export interface Catalog {
8
+ columns: string[];
9
+ constraints: string[];
10
+ indexes: string[];
11
+ enums: string[];
12
+ }
13
+ interface Queryable {
14
+ query<T>(sql: string): Promise<{
15
+ rows: T[];
16
+ }>;
17
+ }
18
+ export declare function readCatalog(db: Queryable): Promise<Catalog>;
19
+ /** Lines present in one catalog and not the other, prefixed - or +. */
20
+ export declare function catalogDiff(a: Catalog, b: Catalog): string[];
21
+ export {};
@@ -0,0 +1,58 @@
1
+ "use strict";
2
+ /**
3
+ * Read a database's schema back out of Postgres's own catalog, in a form two
4
+ * databases can be compared by. Postgres normalizes what it stores (types,
5
+ * defaults, constraint definitions), so two databases built by different SQL
6
+ * compare equal when they ended up the same.
7
+ */
8
+ Object.defineProperty(exports, "__esModule", { value: true });
9
+ exports.catalogDiff = exports.readCatalog = void 0;
10
+ const COLUMNS = `
11
+ SELECT table_name || '.' || column_name || ' ' || format_type(a.atttypid, a.atttypmod)
12
+ || CASE WHEN c.is_nullable = 'NO' THEN ' NOT NULL' ELSE '' END
13
+ || COALESCE(' DEFAULT ' || c.column_default, '') AS line
14
+ FROM information_schema.columns c
15
+ JOIN pg_attribute a ON a.attrelid = ('"' || c.table_name || '"')::regclass AND a.attname = c.column_name
16
+ WHERE c.table_schema = 'public'`;
17
+ const CONSTRAINTS = `
18
+ SELECT conrelid::regclass::text || ' ' || conname || ' ' || pg_get_constraintdef(oid) AS line
19
+ FROM pg_constraint
20
+ WHERE connamespace = 'public'::regnamespace AND contype IN ('p', 'u', 'f', 'c')`;
21
+ const INDEXES = `
22
+ SELECT indexdef AS line FROM pg_indexes
23
+ WHERE schemaname = 'public'
24
+ AND indexname NOT IN (SELECT conname FROM pg_constraint WHERE connamespace = 'public'::regnamespace)`;
25
+ const ENUMS = `
26
+ SELECT t.typname || ' ' || string_agg(e.enumlabel, ',' ORDER BY e.enumsortorder) AS line
27
+ FROM pg_type t JOIN pg_enum e ON e.enumtypid = t.oid
28
+ WHERE t.typnamespace = 'public'::regnamespace
29
+ GROUP BY t.typname`;
30
+ const lines = async (db, sql) => {
31
+ const result = await db.query(sql);
32
+ return result.rows.map((r) => r.line).sort();
33
+ };
34
+ async function readCatalog(db) {
35
+ return {
36
+ columns: await lines(db, COLUMNS),
37
+ constraints: await lines(db, CONSTRAINTS),
38
+ indexes: await lines(db, INDEXES),
39
+ enums: await lines(db, ENUMS),
40
+ };
41
+ }
42
+ exports.readCatalog = readCatalog;
43
+ /** Lines present in one catalog and not the other, prefixed - or +. */
44
+ function catalogDiff(a, b) {
45
+ const out = [];
46
+ for (const key of Object.keys(a)) {
47
+ const left = new Set(a[key]);
48
+ const right = new Set(b[key]);
49
+ for (const l of a[key])
50
+ if (!right.has(l))
51
+ out.push(`- ${key}: ${l}`);
52
+ for (const l of b[key])
53
+ if (!left.has(l))
54
+ out.push(`+ ${key}: ${l}`);
55
+ }
56
+ return out;
57
+ }
58
+ exports.catalogDiff = catalogDiff;
@@ -0,0 +1,19 @@
1
+ import { ApsorcType } from "../apsorc-parser";
2
+ import { Plan } from "../plan";
3
+ export interface ChangeDdl {
4
+ up: string[];
5
+ }
6
+ export declare function changeDdl(before: ApsorcType, after: ApsorcType, plan: Plan): ChangeDdl;
7
+ export type DownDdl = {
8
+ down: string[];
9
+ } | {
10
+ irreversible: string[];
11
+ };
12
+ /**
13
+ * The reverse migration: the plan from the new schema back to the deployed
14
+ * one, answered from the forward answers. Renames invert, added fields drop,
15
+ * removed enum values map back, a split rejoins, and data that moved (to a
16
+ * new table, or into many-to-many links) is copied back. Anything left
17
+ * unanswered is reported instead of guessed.
18
+ */
19
+ export declare function downDdl(before: ApsorcType, after: ApsorcType, plan: Plan): DownDdl;