@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.
- package/README.md +7 -11
- package/dist/commands/deploy.js +1 -1
- package/dist/commands/migrate/generate.d.ts +3 -1
- package/dist/commands/migrate/generate.js +42 -8
- package/dist/commands/migrate/plan.d.ts +12 -0
- package/dist/commands/migrate/plan.js +88 -0
- package/dist/commands/migrate/sandbox.d.ts +11 -0
- package/dist/commands/migrate/sandbox.js +132 -0
- package/dist/commands/migrate/verify.d.ts +5 -2
- package/dist/commands/migrate/verify.js +44 -9
- package/dist/commands/migrate.d.ts +16 -3
- package/dist/commands/migrate.js +171 -96
- package/dist/lib/ddl/catalog.d.ts +21 -0
- package/dist/lib/ddl/catalog.js +58 -0
- package/dist/lib/ddl/change.d.ts +19 -0
- package/dist/lib/ddl/change.js +393 -0
- package/dist/lib/ddl/index.d.ts +80 -0
- package/dist/lib/ddl/index.js +309 -0
- package/dist/lib/ddl/migration-file.d.ts +42 -0
- package/dist/lib/ddl/migration-file.js +222 -0
- package/dist/lib/generators/go.js +3 -2
- package/dist/lib/generators/python.js +3 -2
- package/dist/lib/plan/baseline.d.ts +22 -0
- package/dist/lib/plan/baseline.js +100 -0
- package/dist/lib/plan/index.d.ts +52 -0
- package/dist/lib/plan/index.js +272 -0
- package/dist/lib/plan/prompts.d.ts +18 -0
- package/dist/lib/plan/prompts.js +77 -0
- package/dist/lib/verify/index.d.ts +27 -9
- package/dist/lib/verify/index.js +249 -21
- package/dist/lib/verify/review.d.ts +14 -0
- package/dist/lib/verify/review.js +98 -0
- package/npm-shrinkwrap.json +2 -2
- package/oclif.manifest.json +176 -23
- 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,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 (
|
|
4
|
-
*
|
|
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?:
|
|
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 {};
|