@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
@@ -0,0 +1,100 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.baselineFile = exports.resolveBaseline = exports.latestPlannedMigration = exports.migrationExtension = exports.migrationsDir = void 0;
4
+ const tslib_1 = require("tslib");
5
+ /**
6
+ * Find the deployed schema without being told. Every migration
7
+ * `apso migrate generate` writes is committed with its plan, and the plan
8
+ * records the schema before and after it, so the newest one says what the
9
+ * database looks like once the migrations run. A service whose migrations
10
+ * predate this falls back to the .apsorc from the last commit that touched
11
+ * its migrations.
12
+ */
13
+ const child_process_1 = require("child_process");
14
+ const fs = tslib_1.__importStar(require("fs"));
15
+ const os = tslib_1.__importStar(require("os"));
16
+ const path = tslib_1.__importStar(require("path"));
17
+ /** Where each language's migrations live, and the extension of one. */
18
+ const migrationsDir = (projectDir, apsorc) => (apsorc === null || apsorc === void 0 ? void 0 : apsorc.language) === "python"
19
+ ? path.join(projectDir, "alembic", "versions")
20
+ : (apsorc === null || apsorc === void 0 ? void 0 : apsorc.language) === "go"
21
+ ? path.join(projectDir, "migrations")
22
+ : path.join(projectDir, (apsorc === null || apsorc === void 0 ? void 0 : apsorc.rootFolder) || "src", "migrations");
23
+ exports.migrationsDir = migrationsDir;
24
+ const migrationExtension = (apsorc) => (apsorc === null || apsorc === void 0 ? void 0 : apsorc.language) === "python"
25
+ ? ".py"
26
+ : (apsorc === null || apsorc === void 0 ? void 0 : apsorc.language) === "go"
27
+ ? ".sql"
28
+ : ".ts";
29
+ exports.migrationExtension = migrationExtension;
30
+ /** The newest migration with a saved plan, by its timestamp prefix. */
31
+ function latestPlannedMigration(projectDir, apsorc) {
32
+ const dir = (0, exports.migrationsDir)(projectDir, apsorc);
33
+ if (!fs.existsSync(dir))
34
+ return undefined;
35
+ const plans = fs
36
+ .readdirSync(dir)
37
+ .filter((f) => /^\d+[_-].+\.plan\.json$/.test(f))
38
+ .sort((a, b) => Number(b.split(/[_-]/)[0]) - Number(a.split(/[_-]/)[0]));
39
+ for (const planFile of plans) {
40
+ const saved = JSON.parse(fs.readFileSync(path.join(dir, planFile)).toString());
41
+ if (!saved.before || !saved.after)
42
+ continue;
43
+ const file = planFile.replace(/\.plan\.json$/, (0, exports.migrationExtension)(apsorc));
44
+ return {
45
+ file: path.relative(projectDir, path.join(dir, file)),
46
+ planFile: path.relative(projectDir, path.join(dir, planFile)),
47
+ saved,
48
+ };
49
+ }
50
+ return undefined;
51
+ }
52
+ exports.latestPlannedMigration = latestPlannedMigration;
53
+ const git = (cwd, args) => (0, child_process_1.execFileSync)("git", args, {
54
+ cwd,
55
+ encoding: "utf8",
56
+ stdio: ["ignore", "pipe", "ignore"],
57
+ });
58
+ function resolveBaseline(projectDir, apsorc, explicit, planPath) {
59
+ if (explicit)
60
+ return {
61
+ schema: JSON.parse(fs.readFileSync(path.resolve(explicit)).toString()),
62
+ source: explicit,
63
+ };
64
+ // A plan in progress remembers the baseline it was started from.
65
+ if (planPath && fs.existsSync(planPath)) {
66
+ const working = JSON.parse(fs.readFileSync(planPath).toString());
67
+ if (working.before)
68
+ return {
69
+ schema: working.before,
70
+ source: path.relative(projectDir, planPath),
71
+ };
72
+ }
73
+ const latest = latestPlannedMigration(projectDir, apsorc);
74
+ if (latest)
75
+ return { schema: latest.saved.after, source: latest.planFile };
76
+ try {
77
+ const dir = path.relative(projectDir, (0, exports.migrationsDir)(projectDir, apsorc));
78
+ const sha = git(projectDir, ["log", "-1", "--format=%H", "--", dir]).trim();
79
+ if (sha) {
80
+ // "<sha>:./.apsorc" is relative to cwd, so symlinked paths don't matter.
81
+ const schema = JSON.parse(git(projectDir, ["show", `${sha}:./.apsorc`]));
82
+ return {
83
+ schema,
84
+ source: `.apsorc at ${sha.slice(0, 7)}, the last commit that changed ${dir}`,
85
+ };
86
+ }
87
+ }
88
+ catch {
89
+ // No git, no history for the migrations, or no .apsorc in that commit.
90
+ }
91
+ throw new Error("Can't tell which schema is deployed: no migration from apso migrate generate yet, and no git history for the migrations folder. Pass the deployed .apsorc once with --baseline; later runs find it on their own.");
92
+ }
93
+ exports.resolveBaseline = resolveBaseline;
94
+ /** A baseline as a file, for steps that scaffold it into a project copy. */
95
+ function baselineFile(baseline) {
96
+ const file = path.join(fs.mkdtempSync(path.join(os.tmpdir(), "apso-baseline-")), ".apsorc");
97
+ fs.writeFileSync(file, JSON.stringify(baseline.schema, null, 2));
98
+ return file;
99
+ }
100
+ exports.baselineFile = baselineFile;
@@ -0,0 +1,52 @@
1
+ interface PlanField {
2
+ name: string;
3
+ type: string;
4
+ values?: string[];
5
+ nullable?: boolean;
6
+ default?: string | null;
7
+ }
8
+ interface PlanEntity {
9
+ name: string;
10
+ fields?: PlanField[];
11
+ }
12
+ interface PlanRelationship {
13
+ from: string;
14
+ to: string;
15
+ type: string;
16
+ }
17
+ export interface PlanSchema {
18
+ entities: PlanEntity[];
19
+ relationships?: PlanRelationship[];
20
+ }
21
+ export type QuestionKind = "removedField" | "removedEntity" | "enumValue" | "backfill" | "cast" | "relation";
22
+ export interface Question {
23
+ id: string;
24
+ kind: QuestionKind;
25
+ /** What changed, in words, for the agent and the person reading the plan. */
26
+ subject: string;
27
+ /** Allowed answers. "sql" and "split:..." also need `sql`. */
28
+ options: string[];
29
+ /** Set only when exactly one reading fits; still needs an answer. */
30
+ suggested?: string;
31
+ answer?: string;
32
+ /** A SQL expression, or one per target field for a split. */
33
+ sql?: string | Record<string, string>;
34
+ }
35
+ export interface Plan {
36
+ version: 1;
37
+ from: string;
38
+ to: string;
39
+ questions: Question[];
40
+ }
41
+ export declare const hashSchema: (s: PlanSchema) => string;
42
+ /** Every answer this question will accept is valid; anything else stays open. */
43
+ export declare function isAnswered(q: Question): boolean;
44
+ export declare const openQuestions: (plan: Plan) => Question[];
45
+ export declare function planMigration(before: PlanSchema, after: PlanSchema, previous?: Plan): Plan;
46
+ /**
47
+ * Plan with --answer values (<question id>=<option>) applied on top of the
48
+ * plan in progress. Throws a message for the user when an answer is malformed,
49
+ * names no question, or is not one of its options.
50
+ */
51
+ export declare function planWithAnswers(before: PlanSchema, after: PlanSchema, previous: Plan | undefined, answers: string[]): Plan;
52
+ export {};
@@ -0,0 +1,272 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.planWithAnswers = exports.planMigration = exports.openQuestions = exports.isAnswered = exports.hashSchema = void 0;
4
+ /**
5
+ * The migration plan: compare the deployed .apsorc with the new one and list
6
+ * every change whose intent the schema alone can't settle (a removed field
7
+ * might be renamed, split, moved or dropped). Each becomes a question; the
8
+ * user's own agent answers it, and the answers are kept in the plan file so
9
+ * the same plan always renders the same migration.
10
+ *
11
+ * Question ids come from the change itself, so re-planning after another
12
+ * .apsorc edit keeps every answer whose question still exists.
13
+ */
14
+ const crypto_1 = require("crypto");
15
+ const field_1 = require("../utils/field");
16
+ const hashSchema = (s) => {
17
+ var _a;
18
+ return "sha256:" +
19
+ (0, crypto_1.createHash)("sha256")
20
+ .update(JSON.stringify({
21
+ entities: s.entities,
22
+ relationships: (_a = s.relationships) !== null && _a !== void 0 ? _a : [],
23
+ }))
24
+ .digest("hex");
25
+ };
26
+ exports.hashSchema = hashSchema;
27
+ const STRING_TYPES = new Set(["text", "varchar", "char", "string"]);
28
+ const sameType = (a, b) => {
29
+ const ta = (0, field_1.normalizeFieldType)(a.type);
30
+ const tb = (0, field_1.normalizeFieldType)(b.type);
31
+ return ta === tb || (STRING_TYPES.has(ta) && STRING_TYPES.has(tb));
32
+ };
33
+ const fieldsOf = (e) => { var _a; return (_a = e === null || e === void 0 ? void 0 : e.fields) !== null && _a !== void 0 ? _a : []; };
34
+ const byName = (list, name) => list.find((x) => x.name === name);
35
+ const fieldSignature = (e) => fieldsOf(e)
36
+ .map((f) => `${f.name}:${(0, field_1.normalizeFieldType)(f.type)}`)
37
+ .sort()
38
+ .join(",");
39
+ const relates = (rels, a, b) => rels.some((r) => (r.from === a && r.to === b) || (r.from === b && r.to === a));
40
+ /** Every answer this question will accept is valid; anything else stays open. */
41
+ function isAnswered(q) {
42
+ if (!q.answer || !q.options.includes(q.answer))
43
+ return false;
44
+ if (q.answer === "sql")
45
+ return typeof q.sql === "string" && q.sql.trim() !== "";
46
+ if (q.answer.startsWith("split:")) {
47
+ const targets = q.answer.slice("split:".length).split(",");
48
+ const sql = q.sql;
49
+ return (typeof sql === "object" &&
50
+ sql !== null &&
51
+ targets.every((t) => typeof sql[t] === "string" && sql[t].trim() !== ""));
52
+ }
53
+ return true;
54
+ }
55
+ exports.isAnswered = isAnswered;
56
+ const openQuestions = (plan) => plan.questions.filter((q) => !isAnswered(q));
57
+ exports.openQuestions = openQuestions;
58
+ /** Added fields an answer already accounts for (a rename, split or move target). */
59
+ function claimedTargets(questions) {
60
+ const claimed = new Set();
61
+ for (const q of questions) {
62
+ if (!q.answer)
63
+ continue;
64
+ const entity = q.id.slice(q.id.indexOf(":") + 1).split(".")[0];
65
+ if (q.answer.startsWith("rename:"))
66
+ claimed.add(`${entity}.${q.answer.slice(7)}`);
67
+ if (q.answer.startsWith("split:"))
68
+ for (const t of q.answer.slice(6).split(","))
69
+ claimed.add(`${entity}.${t}`);
70
+ if (q.answer.startsWith("move:"))
71
+ claimed.add(q.answer.slice(5));
72
+ }
73
+ return claimed;
74
+ }
75
+ /** Added fields some still-open question may yet claim. */
76
+ function pendingTargets(questions) {
77
+ const pending = new Set();
78
+ for (const q of questions) {
79
+ if (q.answer || q.kind !== "removedField")
80
+ continue;
81
+ const entity = q.id.slice(q.id.indexOf(":") + 1).split(".")[0];
82
+ for (const o of q.options) {
83
+ if (o.startsWith("rename:"))
84
+ pending.add(`${entity}.${o.slice(7)}`);
85
+ if (o.startsWith("split:"))
86
+ for (const t of o.slice(6).split(","))
87
+ pending.add(`${entity}.${t}`);
88
+ if (o.startsWith("move:"))
89
+ pending.add(o.slice(5));
90
+ }
91
+ }
92
+ return pending;
93
+ }
94
+ function planMigration(before, after, previous) {
95
+ var _a, _b, _c;
96
+ const questions = [];
97
+ const beforeRels = (_a = before.relationships) !== null && _a !== void 0 ? _a : [];
98
+ const afterRels = (_b = after.relationships) !== null && _b !== void 0 ? _b : [];
99
+ const added = after.entities.filter((e) => !byName(before.entities, e.name));
100
+ // Removed entities: rename to an added entity with the same fields, or drop.
101
+ for (const old of before.entities.filter((e) => !byName(after.entities, e.name))) {
102
+ const matches = added.filter((e) => fieldSignature(e) === fieldSignature(old));
103
+ questions.push({
104
+ id: `entity:${old.name}`,
105
+ kind: "removedEntity",
106
+ subject: `Entity ${old.name} is gone`,
107
+ options: [...matches.map((e) => `rename:${e.name}`), "drop"],
108
+ ...(matches.length === 1
109
+ ? { suggested: `rename:${matches[0].name}` }
110
+ : {}),
111
+ });
112
+ }
113
+ for (const now of after.entities) {
114
+ const was = byName(before.entities, now.name);
115
+ if (!was)
116
+ continue;
117
+ const addedFields = fieldsOf(now).filter((f) => !byName(fieldsOf(was), f.name));
118
+ for (const old of fieldsOf(was)) {
119
+ const cur = byName(fieldsOf(now), old.name);
120
+ if (!cur) {
121
+ // Removed field: rename, split, move to a new related entity, or drop.
122
+ const same = addedFields.filter((f) => sameType(f, old));
123
+ const moves = added
124
+ .filter((e) => relates(afterRels, e.name, now.name))
125
+ .flatMap((e) => fieldsOf(e)
126
+ .filter((f) => sameType(f, old))
127
+ .map((f) => `move:${e.name}.${f.name}`));
128
+ const options = [
129
+ ...same.map((f) => `rename:${f.name}`),
130
+ ...(same.length > 1
131
+ ? [`split:${same.map((f) => f.name).join(",")}`]
132
+ : []),
133
+ ...moves,
134
+ "drop",
135
+ ];
136
+ const readings = options.length - 1;
137
+ questions.push({
138
+ id: `field:${now.name}.${old.name}`,
139
+ kind: "removedField",
140
+ subject: `Field ${now.name}.${old.name} (${old.type}) is gone`,
141
+ options,
142
+ ...(readings === 1 && same.length === 1
143
+ ? { suggested: options[0] }
144
+ : {}),
145
+ });
146
+ continue;
147
+ }
148
+ if (!sameType(cur, old)) {
149
+ questions.push({
150
+ id: `cast:${now.name}.${old.name}`,
151
+ kind: "cast",
152
+ subject: `Field ${now.name}.${old.name} changes type from ${old.type} to ${cur.type}`,
153
+ options: ["cast", "sql"],
154
+ });
155
+ }
156
+ else if (old.values && cur.values) {
157
+ for (const v of old.values.filter((v) => !cur.values.includes(v))) {
158
+ questions.push({
159
+ id: `enum:${now.name}.${old.name}:${v}`,
160
+ kind: "enumValue",
161
+ subject: `Enum value '${v}' is removed from ${now.name}.${old.name}`,
162
+ options: [...cur.values],
163
+ });
164
+ }
165
+ }
166
+ if (old.nullable && !cur.nullable && cur.default === undefined) {
167
+ questions.push({
168
+ id: `backfill:${now.name}.${old.name}`,
169
+ kind: "backfill",
170
+ subject: `Field ${now.name}.${old.name} becomes required; rows where it is null need a value`,
171
+ options: cur.values ? [...cur.values, "sql"] : ["sql"],
172
+ });
173
+ }
174
+ }
175
+ }
176
+ // Relationships between the same two entities that change kind or vanish.
177
+ for (const r of beforeRels.filter((r) => r.type === "ManyToOne")) {
178
+ if (!byName(after.entities, r.from) || !byName(after.entities, r.to))
179
+ continue;
180
+ const kept = afterRels.some((x) => x.type === "ManyToOne" && x.from === r.from && x.to === r.to);
181
+ if (kept)
182
+ continue;
183
+ const toMany = afterRels.some((x) => x.type === "ManyToMany" &&
184
+ ((x.from === r.from && x.to === r.to) ||
185
+ (x.from === r.to && x.to === r.from)));
186
+ questions.push({
187
+ id: `relation:${r.from}.${r.to}`,
188
+ kind: "relation",
189
+ subject: toMany
190
+ ? `${r.from} ManyToOne ${r.to} becomes ManyToMany`
191
+ : `${r.from} ManyToOne ${r.to} is removed`,
192
+ options: toMany ? ["copy-links", "drop-links"] : ["drop-links"],
193
+ });
194
+ }
195
+ // Carry answers over from the previous plan before deciding on backfills,
196
+ // which depend on what the removed-field answers claimed.
197
+ const prev = new Map(((_c = previous === null || previous === void 0 ? void 0 : previous.questions) !== null && _c !== void 0 ? _c : []).map((q) => [q.id, q]));
198
+ const carry = (q) => {
199
+ const p = prev.get(q.id);
200
+ if (!(p === null || p === void 0 ? void 0 : p.answer) || !q.options.includes(p.answer))
201
+ return q;
202
+ return {
203
+ ...q,
204
+ answer: p.answer,
205
+ ...(p.sql === undefined ? {} : { sql: p.sql }),
206
+ };
207
+ };
208
+ const answeredSoFar = questions.map((q) => carry(q));
209
+ // New required fields on existing tables need a value for existing rows,
210
+ // unless a rename, split or move fills them, or an open question still might.
211
+ const claimed = claimedTargets(answeredSoFar);
212
+ const pending = pendingTargets(answeredSoFar);
213
+ for (const now of after.entities) {
214
+ const was = byName(before.entities, now.name);
215
+ if (!was)
216
+ continue;
217
+ for (const f of fieldsOf(now)) {
218
+ if (byName(fieldsOf(was), f.name) ||
219
+ f.nullable ||
220
+ f.default !== undefined)
221
+ continue;
222
+ const key = `${now.name}.${f.name}`;
223
+ if (claimed.has(key) || pending.has(key))
224
+ continue;
225
+ answeredSoFar.push(carry({
226
+ id: `backfill:${key}`,
227
+ kind: "backfill",
228
+ subject: `New required field ${key} needs a value for existing rows`,
229
+ options: f.values ? [...f.values, "sql"] : ["sql"],
230
+ }));
231
+ }
232
+ }
233
+ return {
234
+ version: 1,
235
+ from: (0, exports.hashSchema)(before),
236
+ to: (0, exports.hashSchema)(after),
237
+ questions: answeredSoFar,
238
+ };
239
+ }
240
+ exports.planMigration = planMigration;
241
+ /**
242
+ * Plan with --answer values (<question id>=<option>) applied on top of the
243
+ * plan in progress. Throws a message for the user when an answer is malformed,
244
+ * names no question, or is not one of its options.
245
+ */
246
+ function planWithAnswers(before, after, previous, answers) {
247
+ const given = new Map();
248
+ for (const a of answers) {
249
+ const eq = a.indexOf("=");
250
+ if (eq < 1)
251
+ throw new Error(`--answer must be <question id>=<option>, got "${a}"`);
252
+ given.set(a.slice(0, eq), a.slice(eq + 1));
253
+ }
254
+ const seed = previous !== null && previous !== void 0 ? previous : { version: 1, from: "", to: "", questions: [] };
255
+ for (const [id, answer] of given) {
256
+ const q = seed.questions.find((x) => x.id === id);
257
+ if (q)
258
+ q.answer = answer;
259
+ else
260
+ seed.questions.push({ id, kind: "removedField", subject: "", options: [], answer });
261
+ }
262
+ const plan = planMigration(before, after, seed);
263
+ for (const [id, answer] of given) {
264
+ const q = plan.questions.find((x) => x.id === id);
265
+ if (!q)
266
+ throw new Error(`No question "${id}" in this plan.`);
267
+ if (!q.options.includes(answer))
268
+ throw new Error(`"${answer}" is not an option for ${id}. Options: ${q.options.join(", ")}`);
269
+ }
270
+ return plan;
271
+ }
272
+ exports.planWithAnswers = planWithAnswers;
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Words for asking a plan question in a terminal: what each option does to
3
+ * the data, a hint for SQL answers, and a starting rule for a two-way split.
4
+ */
5
+ import { Question } from ".";
6
+ export declare function optionLabel(q: Question, o: string): string;
7
+ export declare function sqlHint(q: Question): string;
8
+ /** A two-way split on the first space: "Ada Lovelace" -> Ada / Lovelace, "Cher" -> Cher / null. */
9
+ export declare function splitDefaults(q: Question, targets: string[]): Record<string, string>;
10
+ /**
11
+ * A question as an app or agent shows it: each option's words, a hint when an
12
+ * option needs SQL, and a starting rule for each two-way split option.
13
+ */
14
+ export declare function describeQuestion(q: Question): Question & {
15
+ labels: Record<string, string>;
16
+ sqlHint?: string;
17
+ splitDefaults?: Record<string, Record<string, string>>;
18
+ };
@@ -0,0 +1,77 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.describeQuestion = exports.splitDefaults = exports.sqlHint = exports.optionLabel = void 0;
4
+ const after = (o, prefix) => o.slice(prefix.length);
5
+ function optionLabel(q, o) {
6
+ let label = o;
7
+ switch (q.kind) {
8
+ case "removedField":
9
+ if (o.startsWith("rename:"))
10
+ label = `Renamed to ${after(o, "rename:")} (keeps the data)`;
11
+ else if (o.startsWith("split:"))
12
+ label = `Split into ${after(o, "split:").split(",").join(" and ")} (you give the rule)`;
13
+ else if (o.startsWith("move:"))
14
+ label = `Moved to ${after(o, "move:")} (copies the existing values)`;
15
+ else if (o === "drop")
16
+ label = "Dropped (deletes its data)";
17
+ break;
18
+ case "removedEntity":
19
+ if (o.startsWith("rename:"))
20
+ label = `Renamed to ${after(o, "rename:")} (keeps its rows)`;
21
+ else if (o === "drop")
22
+ label = "Dropped (deletes its rows)";
23
+ break;
24
+ case "enumValue":
25
+ label = `Change those rows to '${o}'`;
26
+ break;
27
+ case "backfill":
28
+ label = o === "sql" ? "Use a SQL expression" : `Set existing rows to '${o}'`;
29
+ break;
30
+ case "cast":
31
+ label = o === "sql" ? "Use a SQL expression to convert" : "Convert each value as it is (fails on values that don't convert)";
32
+ break;
33
+ case "relation":
34
+ label = o === "copy-links" ? "Copy each existing link into the new join table" : "Start with no links (deletes them)";
35
+ break;
36
+ default:
37
+ break;
38
+ }
39
+ return o === q.suggested ? `${label} (suggested)` : label;
40
+ }
41
+ exports.optionLabel = optionLabel;
42
+ const fieldOf = (q) => { var _a, _b; return (_b = (_a = q.id.slice(q.id.indexOf(":") + 1).split(".")[1]) === null || _a === void 0 ? void 0 : _a.split(":")[0]) !== null && _b !== void 0 ? _b : ""; };
43
+ function sqlHint(q) {
44
+ const f = fieldOf(q);
45
+ return q.kind === "cast"
46
+ ? `SQL that converts "${f}", e.g. NULLIF("${f}", '')::integer`
47
+ : `SQL for "${f}" on existing rows, e.g. 'unknown' or another column`;
48
+ }
49
+ exports.sqlHint = sqlHint;
50
+ /** A two-way split on the first space: "Ada Lovelace" -> Ada / Lovelace, "Cher" -> Cher / null. */
51
+ function splitDefaults(q, targets) {
52
+ if (targets.length !== 2)
53
+ return {};
54
+ const src = `"${fieldOf(q)}"`;
55
+ const first = `split_part(btrim(${src}), ' ', 1)`;
56
+ return {
57
+ [targets[0]]: first,
58
+ [targets[1]]: `NULLIF(btrim(substr(btrim(${src}), length(${first}) + 1)), '')`,
59
+ };
60
+ }
61
+ exports.splitDefaults = splitDefaults;
62
+ /**
63
+ * A question as an app or agent shows it: each option's words, a hint when an
64
+ * option needs SQL, and a starting rule for each two-way split option.
65
+ */
66
+ function describeQuestion(q) {
67
+ const splits = q.options.filter((o) => o.startsWith("split:"));
68
+ return {
69
+ ...q,
70
+ labels: Object.fromEntries(q.options.map((o) => [o, optionLabel(q, o)])),
71
+ ...(q.options.includes("sql") && { sqlHint: sqlHint(q) }),
72
+ ...(splits.length > 0 && {
73
+ splitDefaults: Object.fromEntries(splits.map((o) => [o, splitDefaults(q, after(o, "split:").split(","))])),
74
+ }),
75
+ };
76
+ }
77
+ exports.describeQuestion = describeQuestion;
@@ -1,4 +1,12 @@
1
1
  export declare const createFile: (file: string, content: string) => Promise<void>;
2
+ /**
3
+ * Delete files under `dir` that carry the Apso header but are not in `keep`,
4
+ * i.e. files left over from entities removed or renamed in .apsorc. Files
5
+ * without the header (template seeds like .gitkeep, hand-written code) are
6
+ * never touched. Directories emptied this way are removed too. Returns the
7
+ * deleted paths.
8
+ */
9
+ export declare const removeStaleGeneratedFiles: (dir: string, keep: Set<string>) => string[];
2
10
  export declare const createDirectoryContents: (templatePath: string, newProjectPath: string, apiType: string) => void;
3
11
  export declare function withGeneratedMeta<T extends object>(data: T): T & {
4
12
  generatedAt: string;
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.withGeneratedMeta = exports.createDirectoryContents = exports.createFile = void 0;
3
+ exports.withGeneratedMeta = exports.createDirectoryContents = exports.removeStaleGeneratedFiles = exports.createFile = void 0;
4
4
  const tslib_1 = require("tslib");
5
5
  const path = tslib_1.__importStar(require("path"));
6
6
  const fs = tslib_1.__importStar(require("fs"));
@@ -15,6 +15,36 @@ const createFile = async (file, content) => {
15
15
  await fs.promises.writeFile(file, content);
16
16
  };
17
17
  exports.createFile = createFile;
18
+ // Every file the CLI generates starts with this line (see templates/header.eta).
19
+ const APSO_HEADER = "Generated with Apso";
20
+ /**
21
+ * Delete files under `dir` that carry the Apso header but are not in `keep`,
22
+ * i.e. files left over from entities removed or renamed in .apsorc. Files
23
+ * without the header (template seeds like .gitkeep, hand-written code) are
24
+ * never touched. Directories emptied this way are removed too. Returns the
25
+ * deleted paths.
26
+ */
27
+ const removeStaleGeneratedFiles = (dir, keep) => {
28
+ if (!fs.existsSync(dir))
29
+ return [];
30
+ const removed = [];
31
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
32
+ const fullPath = path.join(dir, entry.name);
33
+ if (entry.isDirectory()) {
34
+ removed.push(...(0, exports.removeStaleGeneratedFiles)(fullPath, keep));
35
+ if (fs.readdirSync(fullPath).length === 0)
36
+ fs.rmdirSync(fullPath);
37
+ }
38
+ else if (entry.isFile() &&
39
+ !keep.has(fullPath) &&
40
+ fs.readFileSync(fullPath, "utf8").includes(APSO_HEADER)) {
41
+ fs.unlinkSync(fullPath);
42
+ removed.push(fullPath);
43
+ }
44
+ }
45
+ return removed;
46
+ };
47
+ exports.removeStaleGeneratedFiles = removeStaleGeneratedFiles;
18
48
  const createDirectoryContents = (templatePath, newProjectPath, apiType) => {
19
49
  const filesToCreate = fs.readdirSync(templatePath);
20
50
  const CURR_DIR = process.cwd();
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Pre-deploy checks for a generated service: generate the migration from the
3
- * deployed schema, run it and check for drift (TypeScript only, on PGlite),
4
- * and smoke test the endpoints. The CLI and the in-browser editor (which runs
3
+ * deployed schema, run it and check for drift (on PGlite), and smoke test the
4
+ * endpoints. The CLI and the in-browser editor (which runs
5
5
  * the CLI in a WebContainer) share these steps.
6
6
  */
7
7
  /// <reference types="node" />
@@ -16,6 +16,7 @@
16
16
  import { Entity } from "../types/entity";
17
17
  import { TargetLanguage } from "../types/generator";
18
18
  import { RelationshipMap } from "../types/relationship";
19
+ import { Finding } from "./review";
19
20
  export interface Step {
20
21
  name: string;
21
22
  ok: boolean;
@@ -33,30 +34,46 @@ export declare const pgliteEnv: (dataDir: string) => NodeJS.ProcessEnv;
33
34
  * it, and schema:sync.
34
35
  */
35
36
  export declare function buildBaseline(projectDir: string, baselineApsorc: string, dataDir: string, cliBin: string): Promise<Step>;
36
- /**
37
- * Why `apso migrate` can't run for this project's language, or undefined.
38
- * ponytail: Python (Alembic) and Go (goose) have no local scratch database to
39
- * build the baseline on; add when they get one like PGlite for TypeScript.
40
- */
41
- export declare const migrateUnsupported: (language?: TargetLanguage) => string | undefined;
42
37
  export interface GenerateResult {
43
38
  ok: boolean;
44
39
  needed: boolean;
45
40
  file?: string;
46
41
  steps: Step[];
42
+ /** Statements in the generated migration that lose data or fail on rows. */
43
+ findings?: Finding[];
47
44
  }
48
45
  /** Generate a migration from the deployed schema (baselineApsorc) to the project's entities. */
49
46
  export declare function generateMigration(projectDir: string, baselineApsorc: string, name: string, cliBin: string): Promise<GenerateResult>;
50
47
  export interface CheckResult {
51
48
  ok: boolean;
52
49
  steps: Step[];
50
+ findings?: Finding[];
53
51
  }
52
+ type Counts = Record<string, {
53
+ rows: number;
54
+ values: Record<string, number>;
55
+ distinct?: Record<string, string[]>;
56
+ }>;
57
+ /**
58
+ * Rows and values present before the migration and missing after it,
59
+ * following the tables and columns the migration renames. A column or table
60
+ * whose values all turn up in another column after the migration was moved,
61
+ * not lost (a field moved to a new table, a foreign key moved to a join table).
62
+ */
63
+ export declare function dataLoss(before: Counts, after: Counts, source: string): string[];
64
+ /** Losses the user accepted by name: a table, or table.column. */
65
+ export declare const unaccepted: (lost: string[], accept: string[]) => string[];
54
66
  export declare function supportsPglite(projectDir: string): boolean;
55
67
  /**
56
68
  * Run a migration on a fresh copy of the deployed schema, then check that the
57
69
  * database matches the project's entities (a second generate finds nothing).
58
70
  */
59
- export declare function verifyMigration(projectDir: string, baselineApsorc: string, migrationFile: string, cliBin: string, skipBuild?: boolean): Promise<CheckResult>;
71
+ export declare function verifyMigration(projectDir: string, baselineApsorc: string, migrationFile: string, cliBin: string, { skipBuild, seedFile, checkFile, acceptLoss, }?: {
72
+ skipBuild?: boolean;
73
+ seedFile?: string;
74
+ checkFile?: string;
75
+ acceptLoss?: string[];
76
+ }): Promise<CheckResult>;
60
77
  /** The route each language's generated controller serves an entity on. */
61
78
  export declare const entityRoute: (language: TargetLanguage, name: string) => string;
62
79
  export interface SmokeOptions {
@@ -77,3 +94,4 @@ export declare function readDotEnv(projectDir: string): Record<string, string>;
77
94
  export declare const cliBinPath: (root: string) => string;
78
95
  /** Print a check's steps (or JSON) through the command's logger. */
79
96
  export declare function report(log: (s: string) => void, result: CheckResult, json: boolean, summary?: string): void;
97
+ export {};