@apso/cli 0.43.4 → 0.45.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 (65) hide show
  1. package/README.md +7 -11
  2. package/dist/commands/deploy.js +1 -1
  3. package/dist/commands/generate.js +21 -1
  4. package/dist/commands/mcp/serve.js +30 -9
  5. package/dist/commands/migrate/generate.d.ts +3 -1
  6. package/dist/commands/migrate/generate.js +42 -8
  7. package/dist/commands/migrate/plan.d.ts +12 -0
  8. package/dist/commands/migrate/plan.js +88 -0
  9. package/dist/commands/migrate/sandbox.d.ts +11 -0
  10. package/dist/commands/migrate/sandbox.js +132 -0
  11. package/dist/commands/migrate/verify.d.ts +5 -2
  12. package/dist/commands/migrate/verify.js +44 -9
  13. package/dist/commands/migrate.d.ts +16 -3
  14. package/dist/commands/migrate.js +171 -96
  15. package/dist/lib/ddl/catalog.d.ts +21 -0
  16. package/dist/lib/ddl/catalog.js +58 -0
  17. package/dist/lib/ddl/change.d.ts +19 -0
  18. package/dist/lib/ddl/change.js +393 -0
  19. package/dist/lib/ddl/index.d.ts +80 -0
  20. package/dist/lib/ddl/index.js +309 -0
  21. package/dist/lib/ddl/migration-file.d.ts +42 -0
  22. package/dist/lib/ddl/migration-file.js +222 -0
  23. package/dist/lib/generators/base.d.ts +1 -0
  24. package/dist/lib/generators/go.d.ts +2 -1
  25. package/dist/lib/generators/go.js +37 -6
  26. package/dist/lib/generators/python.d.ts +4 -2
  27. package/dist/lib/generators/python.js +31 -8
  28. package/dist/lib/generators/typescript.d.ts +2 -1
  29. package/dist/lib/generators/typescript.js +43 -6
  30. package/dist/lib/guards.d.ts +11 -1
  31. package/dist/lib/guards.js +20 -1
  32. package/dist/lib/plan/baseline.d.ts +22 -0
  33. package/dist/lib/plan/baseline.js +100 -0
  34. package/dist/lib/plan/index.d.ts +52 -0
  35. package/dist/lib/plan/index.js +272 -0
  36. package/dist/lib/plan/prompts.d.ts +18 -0
  37. package/dist/lib/plan/prompts.js +77 -0
  38. package/dist/lib/templates/go/index-module.eta +6 -0
  39. package/dist/lib/templates/go/middleware/auth.eta +299 -59
  40. package/dist/lib/templates/guards/api-key-auth.guard.eta +146 -0
  41. package/dist/lib/templates/guards/auth.guard.eta +6 -0
  42. package/dist/lib/templates/guards/guards.module.eta +11 -19
  43. package/dist/lib/templates/guards/index.eta +2 -2
  44. package/dist/lib/templates/guards/jwt-auth.guard.eta +172 -0
  45. package/dist/lib/templates/guards/scope.guard.eta +20 -2
  46. package/dist/lib/templates/python/auth.eta +217 -0
  47. package/dist/lib/templates/python/index-module.eta +11 -0
  48. package/dist/lib/templates/python/models/model-col-enum.eta +1 -1
  49. package/dist/lib/templates/python/schemas/schema.eta +22 -1
  50. package/dist/lib/templates/rest/controller-rest.eta +16 -1
  51. package/dist/lib/templates/rest/index-module-rest.eta +6 -0
  52. package/dist/lib/types/auth.d.ts +41 -7
  53. package/dist/lib/types/auth.js +61 -2
  54. package/dist/lib/types/generator.d.ts +6 -0
  55. package/dist/lib/types/index.d.ts +1 -1
  56. package/dist/lib/types/index.js +3 -1
  57. package/dist/lib/utils/crud-deps.d.ts +13 -0
  58. package/dist/lib/utils/crud-deps.js +37 -1
  59. package/dist/lib/verify/index.d.ts +27 -9
  60. package/dist/lib/verify/index.js +249 -21
  61. package/dist/lib/verify/review.d.ts +14 -0
  62. package/dist/lib/verify/review.js +98 -0
  63. package/npm-shrinkwrap.json +2 -2
  64. package/oclif.manifest.json +176 -23
  65. package/package.json +1 -1
@@ -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;
@@ -4,6 +4,7 @@ package routes
4
4
 
5
5
  import (
6
6
  <% if (httpEntities.length > 0) { %> "app/autogen/handlers"
7
+ <% } %><% if (it.hasAuth) { %> "app/autogen/middleware"
7
8
  <% } %> "app/autogen/services"
8
9
  "github.com/gin-gonic/gin"
9
10
  "gorm.io/gorm"
@@ -11,6 +12,11 @@ import (
11
12
 
12
13
  // RegisterAllRoutes registers all entity routes
13
14
  func RegisterAllRoutes(r *gin.RouterGroup, db *gorm.DB) {
15
+ <% if (it.hasAuth) { %>
16
+ // Every route in this group requires authentication (.apsorc auth).
17
+ r.Use(middleware.AuthMiddleware(db))
18
+
19
+ <% } %>
14
20
  <% httpEntities.forEach((entity) => { %>
15
21
  // <%= entity.name %> routes
16
22
  <%= entity.camelName %>Service := services.New<%= entity.name %>Service(db)