@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.
- package/README.md +7 -11
- package/dist/commands/deploy.js +1 -1
- package/dist/commands/generate.js +21 -1
- package/dist/commands/mcp/serve.js +30 -9
- 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/base.d.ts +1 -0
- package/dist/lib/generators/go.d.ts +2 -1
- package/dist/lib/generators/go.js +37 -6
- package/dist/lib/generators/python.d.ts +4 -2
- package/dist/lib/generators/python.js +31 -8
- package/dist/lib/generators/typescript.d.ts +2 -1
- package/dist/lib/generators/typescript.js +43 -6
- package/dist/lib/guards.d.ts +11 -1
- package/dist/lib/guards.js +20 -1
- 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/templates/go/index-module.eta +6 -0
- package/dist/lib/templates/go/middleware/auth.eta +299 -59
- package/dist/lib/templates/guards/api-key-auth.guard.eta +146 -0
- package/dist/lib/templates/guards/auth.guard.eta +6 -0
- package/dist/lib/templates/guards/guards.module.eta +11 -19
- package/dist/lib/templates/guards/index.eta +2 -2
- package/dist/lib/templates/guards/jwt-auth.guard.eta +172 -0
- package/dist/lib/templates/guards/scope.guard.eta +20 -2
- package/dist/lib/templates/python/auth.eta +217 -0
- package/dist/lib/templates/python/index-module.eta +11 -0
- package/dist/lib/templates/python/models/model-col-enum.eta +1 -1
- package/dist/lib/templates/python/schemas/schema.eta +22 -1
- package/dist/lib/templates/rest/controller-rest.eta +16 -1
- package/dist/lib/templates/rest/index-module-rest.eta +6 -0
- package/dist/lib/types/auth.d.ts +41 -7
- package/dist/lib/types/auth.js +61 -2
- package/dist/lib/types/generator.d.ts +6 -0
- package/dist/lib/types/index.d.ts +1 -1
- package/dist/lib/types/index.js +3 -1
- package/dist/lib/utils/crud-deps.d.ts +13 -0
- package/dist/lib/utils/crud-deps.js +37 -1
- 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
package/README.md
CHANGED
|
@@ -185,22 +185,18 @@ Requires Docker and Docker Compose. Looks for `docker-compose.yml` in the curren
|
|
|
185
185
|
|
|
186
186
|
### `apso migrate`
|
|
187
187
|
|
|
188
|
-
|
|
188
|
+
Write the migration for a schema change without losing data. `apso migrate` regenerates the code, compares `.apsorc` with the deployed schema, and asks about every change whose intent the schema can't show: was a missing field renamed or dropped, how does a field split, what replaces a removed enum value, what do existing rows get for a new required field. It then writes the migration from your answers (a TypeORM migration for TypeScript, an Alembic revision for Python, a goose file for Go), saves the answered plan beside it, and tests it on a local PGlite copy of the deployed schema.
|
|
189
189
|
|
|
190
190
|
```bash
|
|
191
|
-
apso migrate
|
|
192
|
-
apso migrate
|
|
193
|
-
apso migrate --
|
|
194
|
-
apso migrate --sql
|
|
191
|
+
apso migrate --baseline deployed.apsorc # first time: the .apsorc that is deployed
|
|
192
|
+
apso migrate # later runs find the baseline from the last migration
|
|
193
|
+
apso migrate --answer field:Contact.phone=rename:mobilePhone
|
|
194
|
+
apso migrate --seed seed.sql --check checks.sql
|
|
195
195
|
```
|
|
196
196
|
|
|
197
|
-
|
|
198
|
-
|--------|-------------|---------|
|
|
199
|
-
| `--apply` | Update local schema snapshot after verified migration | `false` |
|
|
200
|
-
| `--reset` | Reset the sandbox (clear snapshot and PGlite data) | `false` |
|
|
201
|
-
| `--sql` | Output raw SQL statements only | `false` |
|
|
197
|
+
In a terminal it asks each question. Without one (or with `--json`) it prints the open questions as JSON and exits 2, so a coding agent can answer them with `--answer` or in `.apso/migration-plan.json` and run it again. The same answers always produce the same SQL.
|
|
202
198
|
|
|
203
|
-
The
|
|
199
|
+
The steps are also separate commands: `apso migrate plan`, `apso migrate generate` and `apso migrate verify`. `apso migrate sandbox` is the older snapshot preview on an empty database (`--apply`, `--reset`, `--sql`).
|
|
204
200
|
|
|
205
201
|
### `apso deploy`
|
|
206
202
|
|
package/dist/commands/deploy.js
CHANGED
|
@@ -50,7 +50,7 @@ class Deploy extends base_command_1.default {
|
|
|
50
50
|
this.log(`Error: ${result.error}`);
|
|
51
51
|
}
|
|
52
52
|
this.log("Fix the schema issue before deploying.");
|
|
53
|
-
this.log("Run 'apso migrate' for details.");
|
|
53
|
+
this.log("Run 'apso migrate sandbox' for details.");
|
|
54
54
|
this.exit(1);
|
|
55
55
|
}
|
|
56
56
|
migrationCount = result.upSql.length;
|
|
@@ -2,9 +2,11 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
const tslib_1 = require("tslib");
|
|
4
4
|
const core_1 = require("@oclif/core");
|
|
5
|
+
const fs = tslib_1.__importStar(require("fs"));
|
|
5
6
|
const path = tslib_1.__importStar(require("path"));
|
|
6
7
|
const inquirer_1 = tslib_1.__importDefault(require("inquirer"));
|
|
7
8
|
const lib_1 = require("../lib");
|
|
9
|
+
const types_1 = require("../lib/types");
|
|
8
10
|
const base_command_1 = tslib_1.__importDefault(require("../lib/base-command"));
|
|
9
11
|
const interactive_1 = require("../lib/utils/interactive");
|
|
10
12
|
const perf_hooks_1 = require("perf_hooks");
|
|
@@ -173,6 +175,7 @@ class Generate extends base_command_1.default {
|
|
|
173
175
|
allEntities: entities,
|
|
174
176
|
apiType: lowerCaseApiType,
|
|
175
177
|
relationshipMap,
|
|
178
|
+
auth,
|
|
176
179
|
}));
|
|
177
180
|
}
|
|
178
181
|
// eslint-disable-next-line no-await-in-loop
|
|
@@ -202,7 +205,7 @@ class Generate extends base_command_1.default {
|
|
|
202
205
|
await writeGenerated(file.path, file.content);
|
|
203
206
|
}
|
|
204
207
|
// Generate index module
|
|
205
|
-
const indexFiles = await generator.generateIndexModule(entities, lowerCaseApiType, { emitEvents, http });
|
|
208
|
+
const indexFiles = await generator.generateIndexModule(entities, lowerCaseApiType, { emitEvents, http, auth });
|
|
206
209
|
for (const file of indexFiles) {
|
|
207
210
|
// eslint-disable-next-line no-await-in-loop
|
|
208
211
|
await writeGenerated(file.path, file.content);
|
|
@@ -265,6 +268,23 @@ class Generate extends base_command_1.default {
|
|
|
265
268
|
console.log("[apso] Updated src/orm.config.ts so `apso migrate verify` and `apso test smoke` can run on a local PGlite database. Run npm install before building.");
|
|
266
269
|
}
|
|
267
270
|
}
|
|
271
|
+
// The generated auth code imports a JWT library; declare it.
|
|
272
|
+
if (auth && (0, types_1.isJwtAuth)(auth)) {
|
|
273
|
+
if (language === "typescript" &&
|
|
274
|
+
(0, crud_deps_1.addMissingDeps)(process.cwd(), crud_deps_1.JOSE_DEP).length > 0)
|
|
275
|
+
console.log("[apso] Added jose to package.json (the generated auth guard imports it). Run npm install before building.");
|
|
276
|
+
if (language === "python" &&
|
|
277
|
+
(0, crud_deps_1.addMissingPythonDep)(process.cwd(), crud_deps_1.PYJWT_DEP))
|
|
278
|
+
console.log(`[apso] Added ${crud_deps_1.PYJWT_DEP} to pyproject.toml (the generated auth module imports it). Reinstall the project's dependencies.`);
|
|
279
|
+
if (language === "go")
|
|
280
|
+
console.log("[apso] The generated auth middleware imports github.com/golang-jwt/jwt/v5 and github.com/MicahParks/keyfunc/v3. Run go mod tidy before building.");
|
|
281
|
+
}
|
|
282
|
+
if (language === "python") {
|
|
283
|
+
const main = path.join(process.cwd(), "app", "main.py");
|
|
284
|
+
if (fs.existsSync(main) &&
|
|
285
|
+
!fs.readFileSync(main, "utf8").includes("autogen_router"))
|
|
286
|
+
console.warn("[apso] app/main.py does not include autogen_router, so the generated routes are not served. Add: from app.autogen import autogen_router; app.include_router(autogen_router)");
|
|
287
|
+
}
|
|
268
288
|
const totalBuildTime = perf_hooks_1.performance.now() - totalBuildStart;
|
|
269
289
|
console.log(`[apso] Finished building all entities in ${totalBuildTime.toFixed(2)} ms`);
|
|
270
290
|
// Best-effort: install the commit co-author hook so commits that include
|
|
@@ -382,6 +382,7 @@ class McpServe extends base_command_1.default {
|
|
|
382
382
|
allEntities: entities,
|
|
383
383
|
apiType: lowerCaseApiType,
|
|
384
384
|
relationshipMap,
|
|
385
|
+
auth,
|
|
385
386
|
}),
|
|
386
387
|
generator.generateModule({
|
|
387
388
|
entity,
|
|
@@ -408,7 +409,7 @@ class McpServe extends base_command_1.default {
|
|
|
408
409
|
filesGenerated.push(file.path);
|
|
409
410
|
}
|
|
410
411
|
// Generate index module
|
|
411
|
-
const indexFiles = await generator.generateIndexModule(entities, lowerCaseApiType);
|
|
412
|
+
const indexFiles = await generator.generateIndexModule(entities, lowerCaseApiType, { auth });
|
|
412
413
|
for (const file of indexFiles) {
|
|
413
414
|
const fullPath = path.join(autogenPath, file.path);
|
|
414
415
|
// eslint-disable-next-line no-await-in-loop
|
|
@@ -450,6 +451,7 @@ class McpServe extends base_command_1.default {
|
|
|
450
451
|
"auth0",
|
|
451
452
|
"clerk",
|
|
452
453
|
"cognito",
|
|
454
|
+
"jwt",
|
|
453
455
|
"api-key",
|
|
454
456
|
"custom-db-session",
|
|
455
457
|
])
|
|
@@ -518,11 +520,12 @@ class McpServe extends base_command_1.default {
|
|
|
518
520
|
issuer: "https://YOUR_TENANT.auth0.com/",
|
|
519
521
|
audience: "https://api.yourapp.com",
|
|
520
522
|
},
|
|
523
|
+
// Auth0 custom claims must be namespaced URLs, added by a post-login Action.
|
|
521
524
|
claims: {
|
|
522
525
|
userId: "sub",
|
|
523
526
|
email: "email",
|
|
524
|
-
roles: "roles",
|
|
525
|
-
|
|
527
|
+
roles: "https://yourapp.com/roles",
|
|
528
|
+
workspaceId: "https://yourapp.com/org_id",
|
|
526
529
|
},
|
|
527
530
|
},
|
|
528
531
|
entities: [],
|
|
@@ -531,15 +534,14 @@ class McpServe extends base_command_1.default {
|
|
|
531
534
|
clerk: {
|
|
532
535
|
auth: {
|
|
533
536
|
provider: "clerk",
|
|
537
|
+
// Clerk session tokens carry no aud; the active organization is o.id / o.rol.
|
|
534
538
|
jwt: {
|
|
535
539
|
issuer: "https://YOUR_CLERK_FRONTEND_API",
|
|
536
|
-
audience: "",
|
|
537
540
|
},
|
|
538
541
|
claims: {
|
|
539
542
|
userId: "sub",
|
|
540
|
-
|
|
541
|
-
roles: "
|
|
542
|
-
organizationId: "org_id",
|
|
543
|
+
workspaceId: "o.id",
|
|
544
|
+
roles: "o.rol",
|
|
543
545
|
},
|
|
544
546
|
},
|
|
545
547
|
entities: [],
|
|
@@ -556,7 +558,26 @@ class McpServe extends base_command_1.default {
|
|
|
556
558
|
userId: "sub",
|
|
557
559
|
email: "email",
|
|
558
560
|
roles: "cognito:groups",
|
|
559
|
-
|
|
561
|
+
workspaceId: "custom:org_id",
|
|
562
|
+
},
|
|
563
|
+
},
|
|
564
|
+
entities: [],
|
|
565
|
+
relationships: [],
|
|
566
|
+
},
|
|
567
|
+
jwt: {
|
|
568
|
+
// Any issuer that publishes a JWKS (Supabase Auth, Firebase, WorkOS, Okta, Better Auth's JWT plugin).
|
|
569
|
+
auth: {
|
|
570
|
+
provider: "jwt",
|
|
571
|
+
jwt: {
|
|
572
|
+
issuer: "https://YOUR_ISSUER",
|
|
573
|
+
audience: "YOUR_AUDIENCE",
|
|
574
|
+
jwksUri: "https://YOUR_ISSUER/.well-known/jwks.json",
|
|
575
|
+
},
|
|
576
|
+
claims: {
|
|
577
|
+
userId: "sub",
|
|
578
|
+
email: "email",
|
|
579
|
+
workspaceId: "org_id",
|
|
580
|
+
roles: "roles",
|
|
560
581
|
},
|
|
561
582
|
},
|
|
562
583
|
entities: [],
|
|
@@ -601,7 +622,7 @@ class McpServe extends base_command_1.default {
|
|
|
601
622
|
content: [
|
|
602
623
|
{
|
|
603
624
|
type: "text",
|
|
604
|
-
text: `# Auth Setup: ${authProvider}\n\nMerge the following into your .apsorc file:\n\n\`\`\`json\n${JSON.stringify(config, null, 2)}\n\`\`\`\n\nAfter updating .apsorc:\n1. Run \`apso generate\` (or use the scaffold_api tool) to regenerate code\n2. Run \`npm install\` if new dependencies are needed\n3. Run \`apso dev\` to start the server and create auth tables\n\n${authProvider === "better-auth" ? "**Important:** BetterAuth stores passwords in the `account` table (not User). The `providerId` field must be set to \"credential\" for email/password login to work. Use `@apso/better-auth-adapter@latest` in your frontend." : "
|
|
625
|
+
text: `# Auth Setup: ${authProvider}\n\nMerge the following into your .apsorc file:\n\n\`\`\`json\n${JSON.stringify(config, null, 2)}\n\`\`\`\n\nAfter updating .apsorc:\n1. Run \`apso generate\` (or use the scaffold_api tool) to regenerate code\n2. Run \`npm install\` if new dependencies are needed\n3. Run \`apso dev\` to start the server and create auth tables\n\n${authProvider === "better-auth" ? "**Important:** BetterAuth stores passwords in the `account` table (not User). The `providerId` field must be set to \"credential\" for email/password login to work. Use `@apso/better-auth-adapter@latest` in your frontend." : authProvider === "api-key" ? "Store each key's hex SHA-256 in ApiKey.keyHash, never the key itself; callers send the key in the x-api-key header." : "Replace the issuer, audience and claim names with your " + authProvider + " tenant's values. The service checks each token's signature against the issuer's JWKS. See https://docs.apso.ai/develop/auth/byoa."}`,
|
|
605
626
|
},
|
|
606
627
|
],
|
|
607
628
|
};
|
|
@@ -3,8 +3,10 @@ export default class MigrateGenerate extends BaseCommand {
|
|
|
3
3
|
static description: string;
|
|
4
4
|
static examples: string[];
|
|
5
5
|
static flags: {
|
|
6
|
-
baseline: import("@oclif/core/lib/interfaces").OptionFlag<string, import("@oclif/core/lib/interfaces/parser").CustomOptions>;
|
|
6
|
+
baseline: import("@oclif/core/lib/interfaces").OptionFlag<string | undefined, import("@oclif/core/lib/interfaces/parser").CustomOptions>;
|
|
7
7
|
name: import("@oclif/core/lib/interfaces").OptionFlag<string, import("@oclif/core/lib/interfaces/parser").CustomOptions>;
|
|
8
|
+
plan: import("@oclif/core/lib/interfaces").OptionFlag<string, import("@oclif/core/lib/interfaces/parser").CustomOptions>;
|
|
9
|
+
typeorm: import("@oclif/core/lib/interfaces").BooleanFlag<boolean>;
|
|
8
10
|
json: import("@oclif/core/lib/interfaces").BooleanFlag<boolean>;
|
|
9
11
|
};
|
|
10
12
|
run(): Promise<void>;
|
|
@@ -5,14 +5,37 @@ const path = tslib_1.__importStar(require("path"));
|
|
|
5
5
|
const core_1 = require("@oclif/core");
|
|
6
6
|
const base_command_1 = tslib_1.__importDefault(require("../../lib/base-command"));
|
|
7
7
|
const apsorc_parser_1 = require("../../lib/apsorc-parser");
|
|
8
|
+
const migration_file_1 = require("../../lib/ddl/migration-file");
|
|
9
|
+
const baseline_1 = require("../../lib/plan/baseline");
|
|
8
10
|
const verify_1 = require("../../lib/verify");
|
|
9
11
|
class MigrateGenerate extends base_command_1.default {
|
|
10
12
|
async run() {
|
|
13
|
+
var _a, _b;
|
|
11
14
|
const { flags } = await this.parse(MigrateGenerate);
|
|
12
|
-
const
|
|
13
|
-
if (
|
|
14
|
-
this.error(
|
|
15
|
-
|
|
15
|
+
const { apsorc } = (0, apsorc_parser_1.readApsorcFile)();
|
|
16
|
+
if (flags.typeorm && ((_a = apsorc.language) !== null && _a !== void 0 ? _a : "typescript") !== "typescript")
|
|
17
|
+
this.error("--typeorm works on TypeScript services only.");
|
|
18
|
+
let baseline;
|
|
19
|
+
try {
|
|
20
|
+
baseline = (0, baseline_1.resolveBaseline)(process.cwd(), apsorc, flags.baseline, path.resolve(flags.plan));
|
|
21
|
+
}
|
|
22
|
+
catch (error) {
|
|
23
|
+
this.error(error.message, { exit: 1 });
|
|
24
|
+
}
|
|
25
|
+
const result = flags.typeorm
|
|
26
|
+
? await (0, verify_1.generateMigration)(process.cwd(), (0, baseline_1.baselineFile)(baseline), flags.name, (0, verify_1.cliBinPath)(this.config.root))
|
|
27
|
+
: (0, migration_file_1.writePlannedMigration)(process.cwd(), baseline.schema, flags.name, path.resolve(flags.plan));
|
|
28
|
+
const { open } = result;
|
|
29
|
+
if (open && open.length > 0) {
|
|
30
|
+
if (flags.json)
|
|
31
|
+
this.log(JSON.stringify(result));
|
|
32
|
+
else {
|
|
33
|
+
this.log((_b = result.steps[0].output) !== null && _b !== void 0 ? _b : "");
|
|
34
|
+
for (const q of open)
|
|
35
|
+
this.log(`\n${q.id}\n ${q.subject}\n options: ${q.options.join(" | ")}${q.suggested ? ` (suggested: ${q.suggested})` : ""}`);
|
|
36
|
+
}
|
|
37
|
+
this.exit(2);
|
|
38
|
+
}
|
|
16
39
|
const summary = result.needed
|
|
17
40
|
? `Created ${result.file}`
|
|
18
41
|
: "The database already matches; no migration needed.";
|
|
@@ -21,17 +44,28 @@ class MigrateGenerate extends base_command_1.default {
|
|
|
21
44
|
this.exit(1);
|
|
22
45
|
}
|
|
23
46
|
}
|
|
24
|
-
MigrateGenerate.description = "
|
|
25
|
-
MigrateGenerate.examples = [
|
|
47
|
+
MigrateGenerate.description = "Write a migration from the deployed schema to this project's .apsorc. Apso renders the SQL from the answered migration plan (apso migrate plan), including data moves and a down() that reverses them. Exits 2 while plan questions are open. The migration is a TypeORM class, an Alembic revision or a goose SQL file, by the service's language. --typeorm uses TypeORM's generator on a local PGlite database instead (TypeScript services).";
|
|
48
|
+
MigrateGenerate.examples = [
|
|
49
|
+
`$ apso migrate plan --answer entity:Company=rename:Organization`,
|
|
50
|
+
`$ apso migrate generate`,
|
|
51
|
+
`$ apso migrate generate --typeorm`,
|
|
52
|
+
];
|
|
26
53
|
MigrateGenerate.flags = {
|
|
27
54
|
baseline: core_1.Flags.string({
|
|
28
|
-
description: "The deployed .apsorc to migrate from",
|
|
29
|
-
required: true,
|
|
55
|
+
description: "The deployed .apsorc to migrate from. Defaults to the schema after the newest planned migration, or the .apsorc from the last commit that changed the migrations folder",
|
|
30
56
|
}),
|
|
31
57
|
name: core_1.Flags.string({
|
|
32
58
|
description: "Migration name",
|
|
33
59
|
default: "SchemaUpdate",
|
|
34
60
|
}),
|
|
61
|
+
plan: core_1.Flags.string({
|
|
62
|
+
description: "Migration plan with the answers",
|
|
63
|
+
default: ".apso/migration-plan.json",
|
|
64
|
+
}),
|
|
65
|
+
typeorm: core_1.Flags.boolean({
|
|
66
|
+
description: "Use TypeORM's migration:generate instead of Apso's plan (no data moves)",
|
|
67
|
+
default: false,
|
|
68
|
+
}),
|
|
35
69
|
json: core_1.Flags.boolean({
|
|
36
70
|
description: "Print the result as JSON",
|
|
37
71
|
default: false,
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import BaseCommand from "../../lib/base-command";
|
|
2
|
+
export default class MigratePlan extends BaseCommand {
|
|
3
|
+
static description: string;
|
|
4
|
+
static examples: string[];
|
|
5
|
+
static flags: {
|
|
6
|
+
baseline: import("@oclif/core/lib/interfaces").OptionFlag<string | undefined, import("@oclif/core/lib/interfaces/parser").CustomOptions>;
|
|
7
|
+
plan: import("@oclif/core/lib/interfaces").OptionFlag<string, import("@oclif/core/lib/interfaces/parser").CustomOptions>;
|
|
8
|
+
answer: import("@oclif/core/lib/interfaces").OptionFlag<string[], import("@oclif/core/lib/interfaces/parser").CustomOptions>;
|
|
9
|
+
json: import("@oclif/core/lib/interfaces").BooleanFlag<boolean>;
|
|
10
|
+
};
|
|
11
|
+
run(): Promise<void>;
|
|
12
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const tslib_1 = require("tslib");
|
|
4
|
+
const fs = tslib_1.__importStar(require("fs"));
|
|
5
|
+
const path = tslib_1.__importStar(require("path"));
|
|
6
|
+
const core_1 = require("@oclif/core");
|
|
7
|
+
const base_command_1 = tslib_1.__importDefault(require("../../lib/base-command"));
|
|
8
|
+
const apsorc_parser_1 = require("../../lib/apsorc-parser");
|
|
9
|
+
const plan_1 = require("../../lib/plan");
|
|
10
|
+
const baseline_1 = require("../../lib/plan/baseline");
|
|
11
|
+
const prompts_1 = require("../../lib/plan/prompts");
|
|
12
|
+
class MigratePlan extends base_command_1.default {
|
|
13
|
+
async run() {
|
|
14
|
+
const { flags } = await this.parse(MigratePlan);
|
|
15
|
+
const planPath = path.resolve(flags.plan);
|
|
16
|
+
const { apsorc: after } = (0, apsorc_parser_1.readApsorcFile)();
|
|
17
|
+
let baseline;
|
|
18
|
+
try {
|
|
19
|
+
baseline = (0, baseline_1.resolveBaseline)(process.cwd(), after, flags.baseline, planPath);
|
|
20
|
+
}
|
|
21
|
+
catch (error) {
|
|
22
|
+
this.error(error.message, { exit: 1 });
|
|
23
|
+
}
|
|
24
|
+
const before = baseline.schema;
|
|
25
|
+
// Agents read the output from a pipe; people at a terminal get text.
|
|
26
|
+
const json = flags.json || !process.stdout.isTTY;
|
|
27
|
+
// Keep stdout pure JSON for agents: parser notices go to stderr.
|
|
28
|
+
if (json)
|
|
29
|
+
console.info = (...m) => process.stderr.write(m.join(" ") + "\n");
|
|
30
|
+
const previous = fs.existsSync(planPath)
|
|
31
|
+
? JSON.parse(fs.readFileSync(planPath).toString())
|
|
32
|
+
: undefined;
|
|
33
|
+
let plan;
|
|
34
|
+
try {
|
|
35
|
+
plan = (0, plan_1.planWithAnswers)(before, after, previous, flags.answer);
|
|
36
|
+
}
|
|
37
|
+
catch (error) {
|
|
38
|
+
this.error(error.message, { exit: 1 });
|
|
39
|
+
}
|
|
40
|
+
fs.mkdirSync(path.dirname(planPath), { recursive: true });
|
|
41
|
+
fs.writeFileSync(planPath, JSON.stringify({ ...plan, before }, null, 2) + "\n");
|
|
42
|
+
const open = (0, plan_1.openQuestions)(plan);
|
|
43
|
+
if (json) {
|
|
44
|
+
this.log(JSON.stringify({ plan: flags.plan, baseline: baseline.source, open: open.map((q) => (0, prompts_1.describeQuestion)(q)) }, null, 2));
|
|
45
|
+
}
|
|
46
|
+
else if (open.length === 0) {
|
|
47
|
+
this.log(`Compared with ${baseline.source}.`);
|
|
48
|
+
this.log(`${flags.plan}: all ${plan.questions.length} question(s) answered.`);
|
|
49
|
+
}
|
|
50
|
+
else {
|
|
51
|
+
this.log(`Compared with ${baseline.source}.`);
|
|
52
|
+
this.log(`${flags.plan}: ${open.length} of ${plan.questions.length} question(s) open.`);
|
|
53
|
+
for (const q of open) {
|
|
54
|
+
const hint = q.suggested ? ` (suggested: ${q.suggested})` : "";
|
|
55
|
+
this.log(`\n${q.id}\n ${q.subject}\n options: ${q.options.join(" | ")}${hint}`);
|
|
56
|
+
}
|
|
57
|
+
this.log(`\nAnswer with --answer <id>=<option>, or edit ${flags.plan}. ` +
|
|
58
|
+
`"sql" and "split:" answers also need a "sql" value in the file.`);
|
|
59
|
+
}
|
|
60
|
+
if (open.length > 0)
|
|
61
|
+
this.exit(2);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
MigratePlan.description = "Compare the deployed .apsorc with this one and write a migration plan. Changes whose intent is ambiguous (rename or drop, how a field splits, which value replaces a removed enum value) become questions; answer them in the plan file or with --answer. Exits 2 while questions are open.";
|
|
65
|
+
MigratePlan.examples = [
|
|
66
|
+
"$ apso migrate plan",
|
|
67
|
+
"$ apso migrate plan --answer field:Contact.phone=rename:mobilePhone",
|
|
68
|
+
"$ apso migrate plan --baseline deployed.apsorc",
|
|
69
|
+
];
|
|
70
|
+
MigratePlan.flags = {
|
|
71
|
+
baseline: core_1.Flags.string({
|
|
72
|
+
description: "The deployed .apsorc to migrate from. Defaults to the schema after the newest planned migration, or the .apsorc from the last commit that changed the migrations folder",
|
|
73
|
+
}),
|
|
74
|
+
plan: core_1.Flags.string({
|
|
75
|
+
description: "Plan file to read answers from and write",
|
|
76
|
+
default: ".apso/migration-plan.json",
|
|
77
|
+
}),
|
|
78
|
+
answer: core_1.Flags.string({
|
|
79
|
+
description: "Answer a question: <question id>=<option>. Repeatable.",
|
|
80
|
+
multiple: true,
|
|
81
|
+
default: [],
|
|
82
|
+
}),
|
|
83
|
+
json: core_1.Flags.boolean({
|
|
84
|
+
description: "Print the open questions as JSON (the default when output is not a terminal)",
|
|
85
|
+
default: false,
|
|
86
|
+
}),
|
|
87
|
+
};
|
|
88
|
+
exports.default = MigratePlan;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import BaseCommand from "../../lib/base-command";
|
|
2
|
+
export default class MigrateSandbox extends BaseCommand {
|
|
3
|
+
static description: string;
|
|
4
|
+
static examples: string[];
|
|
5
|
+
static flags: {
|
|
6
|
+
apply: import("@oclif/core/lib/interfaces").BooleanFlag<boolean>;
|
|
7
|
+
reset: import("@oclif/core/lib/interfaces").BooleanFlag<boolean>;
|
|
8
|
+
sql: import("@oclif/core/lib/interfaces").BooleanFlag<boolean>;
|
|
9
|
+
};
|
|
10
|
+
run(): Promise<void>;
|
|
11
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const tslib_1 = require("tslib");
|
|
4
|
+
const path = tslib_1.__importStar(require("path"));
|
|
5
|
+
const core_1 = require("@oclif/core");
|
|
6
|
+
const base_command_1 = tslib_1.__importDefault(require("../../lib/base-command"));
|
|
7
|
+
const apsorc_parser_1 = require("../../lib/apsorc-parser");
|
|
8
|
+
const sandbox_1 = require("../../lib/migrate/sandbox");
|
|
9
|
+
const lib_1 = require("../../lib");
|
|
10
|
+
const file_system_1 = require("../../lib/utils/file-system");
|
|
11
|
+
class MigrateSandbox extends base_command_1.default {
|
|
12
|
+
async run() {
|
|
13
|
+
const { flags } = await this.parse(MigrateSandbox);
|
|
14
|
+
// Handle reset
|
|
15
|
+
if (flags.reset) {
|
|
16
|
+
(0, sandbox_1.resetSandbox)();
|
|
17
|
+
this.log("Sandbox reset. Snapshot and PGlite data cleared.");
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
// Parse .apsorc
|
|
21
|
+
let parsed;
|
|
22
|
+
try {
|
|
23
|
+
parsed = (0, apsorc_parser_1.parseApsorc)();
|
|
24
|
+
}
|
|
25
|
+
catch (error) {
|
|
26
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
27
|
+
this.error(`Failed to parse .apsorc: ${msg}`);
|
|
28
|
+
}
|
|
29
|
+
if (!flags.sql) {
|
|
30
|
+
this.log("Schema changes detected. Running migration sandbox...");
|
|
31
|
+
}
|
|
32
|
+
// Run the sandbox
|
|
33
|
+
const result = await (0, sandbox_1.runMigrationSandbox)({ entities: parsed.entities, relationshipMap: parsed.relationshipMap });
|
|
34
|
+
if (!result.needed) {
|
|
35
|
+
if (!flags.sql) {
|
|
36
|
+
this.log("No database-level changes detected.");
|
|
37
|
+
}
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
// Output results
|
|
41
|
+
if (flags.sql) {
|
|
42
|
+
// Raw SQL output mode
|
|
43
|
+
for (const sql of result.upSql) {
|
|
44
|
+
this.log(sql + ";");
|
|
45
|
+
}
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
if (result.success) {
|
|
49
|
+
this.log("");
|
|
50
|
+
this.log("Migration SQL (up):");
|
|
51
|
+
this.log("---");
|
|
52
|
+
for (const sql of result.upSql) {
|
|
53
|
+
this.log(` ${sql};`);
|
|
54
|
+
}
|
|
55
|
+
this.log("---");
|
|
56
|
+
this.log("");
|
|
57
|
+
this.log(`${result.upSql.length} statement(s) generated and tested.`);
|
|
58
|
+
this.log("Migration validated against local PGlite.");
|
|
59
|
+
// Generate language-native migration files for Python and Go
|
|
60
|
+
const language = parsed.language || "typescript";
|
|
61
|
+
if (language !== "typescript") {
|
|
62
|
+
const generatorConfig = {
|
|
63
|
+
language,
|
|
64
|
+
rootFolder: parsed.rootFolder || "src",
|
|
65
|
+
apiType: parsed.apiType || "rest",
|
|
66
|
+
entities: parsed.entities,
|
|
67
|
+
relationshipMap: parsed.relationshipMap,
|
|
68
|
+
};
|
|
69
|
+
const generator = (0, lib_1.createGenerator)(generatorConfig);
|
|
70
|
+
const migrationFiles = await generator.generateMigration({
|
|
71
|
+
entities: parsed.entities,
|
|
72
|
+
relationshipMap: parsed.relationshipMap,
|
|
73
|
+
upSql: result.upSql,
|
|
74
|
+
downSql: result.downSql,
|
|
75
|
+
});
|
|
76
|
+
for (const file of migrationFiles) {
|
|
77
|
+
const fullPath = path.join(process.cwd(), file.path);
|
|
78
|
+
// eslint-disable-next-line no-await-in-loop
|
|
79
|
+
await (0, file_system_1.createFile)(fullPath, file.content);
|
|
80
|
+
this.log(`Generated migration file: ${file.path}`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
if (flags.apply) {
|
|
84
|
+
(0, sandbox_1.applyMigration)({
|
|
85
|
+
entities: parsed.entities,
|
|
86
|
+
relationshipMap: parsed.relationshipMap,
|
|
87
|
+
});
|
|
88
|
+
this.log("Snapshot updated.");
|
|
89
|
+
}
|
|
90
|
+
else {
|
|
91
|
+
this.log("Run with --apply to update the snapshot.");
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
else {
|
|
95
|
+
this.log("");
|
|
96
|
+
this.log("Migration FAILED.");
|
|
97
|
+
if (result.error) {
|
|
98
|
+
this.log(`Error: ${result.error}`);
|
|
99
|
+
}
|
|
100
|
+
if (result.upSql.length > 0) {
|
|
101
|
+
this.log("");
|
|
102
|
+
this.log("Attempted SQL:");
|
|
103
|
+
for (const sql of result.upSql) {
|
|
104
|
+
this.log(` ${sql};`);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
this.exit(1);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
MigrateSandbox.description = "Test schema changes against a local PGlite sandbox that keeps its own snapshot of the last schema (the older flow; apso migrate is the guided one)";
|
|
112
|
+
MigrateSandbox.examples = [
|
|
113
|
+
`$ apso migrate sandbox`,
|
|
114
|
+
`$ apso migrate sandbox --apply`,
|
|
115
|
+
`$ apso migrate sandbox --reset`,
|
|
116
|
+
`$ apso migrate sandbox --sql`,
|
|
117
|
+
];
|
|
118
|
+
MigrateSandbox.flags = {
|
|
119
|
+
apply: core_1.Flags.boolean({
|
|
120
|
+
description: "Apply the migration: update the local snapshot after a successful test",
|
|
121
|
+
default: false,
|
|
122
|
+
}),
|
|
123
|
+
reset: core_1.Flags.boolean({
|
|
124
|
+
description: "Reset the sandbox (clear snapshot and PGlite data)",
|
|
125
|
+
default: false,
|
|
126
|
+
}),
|
|
127
|
+
sql: core_1.Flags.boolean({
|
|
128
|
+
description: "Output raw SQL only (for piping to files)",
|
|
129
|
+
default: false,
|
|
130
|
+
}),
|
|
131
|
+
};
|
|
132
|
+
exports.default = MigrateSandbox;
|
|
@@ -3,8 +3,11 @@ export default class MigrateVerify extends BaseCommand {
|
|
|
3
3
|
static description: string;
|
|
4
4
|
static examples: string[];
|
|
5
5
|
static flags: {
|
|
6
|
-
baseline: import("@oclif/core/lib/interfaces").OptionFlag<string, import("@oclif/core/lib/interfaces/parser").CustomOptions>;
|
|
7
|
-
migration: import("@oclif/core/lib/interfaces").OptionFlag<string, import("@oclif/core/lib/interfaces/parser").CustomOptions>;
|
|
6
|
+
baseline: import("@oclif/core/lib/interfaces").OptionFlag<string | undefined, import("@oclif/core/lib/interfaces/parser").CustomOptions>;
|
|
7
|
+
migration: import("@oclif/core/lib/interfaces").OptionFlag<string | undefined, import("@oclif/core/lib/interfaces/parser").CustomOptions>;
|
|
8
|
+
seed: import("@oclif/core/lib/interfaces").OptionFlag<string | undefined, import("@oclif/core/lib/interfaces/parser").CustomOptions>;
|
|
9
|
+
check: import("@oclif/core/lib/interfaces").OptionFlag<string | undefined, import("@oclif/core/lib/interfaces/parser").CustomOptions>;
|
|
10
|
+
"accept-loss": import("@oclif/core/lib/interfaces").OptionFlag<string[], import("@oclif/core/lib/interfaces/parser").CustomOptions>;
|
|
8
11
|
"skip-build": import("@oclif/core/lib/interfaces").BooleanFlag<boolean>;
|
|
9
12
|
json: import("@oclif/core/lib/interfaces").BooleanFlag<boolean>;
|
|
10
13
|
};
|
|
@@ -5,31 +5,66 @@ const path = tslib_1.__importStar(require("path"));
|
|
|
5
5
|
const core_1 = require("@oclif/core");
|
|
6
6
|
const base_command_1 = tslib_1.__importDefault(require("../../lib/base-command"));
|
|
7
7
|
const apsorc_parser_1 = require("../../lib/apsorc-parser");
|
|
8
|
+
const baseline_1 = require("../../lib/plan/baseline");
|
|
8
9
|
const verify_1 = require("../../lib/verify");
|
|
9
10
|
class MigrateVerify extends base_command_1.default {
|
|
10
11
|
async run() {
|
|
12
|
+
var _a;
|
|
11
13
|
const { flags } = await this.parse(MigrateVerify);
|
|
12
|
-
const
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
14
|
+
const { apsorc } = (0, apsorc_parser_1.readApsorcFile)();
|
|
15
|
+
const latest = (0, baseline_1.latestPlannedMigration)(process.cwd(), apsorc);
|
|
16
|
+
const migration = (_a = flags.migration) !== null && _a !== void 0 ? _a : latest === null || latest === void 0 ? void 0 : latest.file;
|
|
17
|
+
if (!migration)
|
|
18
|
+
this.error("No planned migration found. Run apso migrate generate, or pass --migration.", { exit: 1 });
|
|
19
|
+
// The migration's own plan says which schema it starts from.
|
|
20
|
+
const saved = latest && path.resolve(latest.file) === path.resolve(migration)
|
|
21
|
+
? latest.saved
|
|
22
|
+
: undefined;
|
|
23
|
+
let baseline;
|
|
24
|
+
if (flags.baseline)
|
|
25
|
+
baseline = path.resolve(flags.baseline);
|
|
26
|
+
else if (saved)
|
|
27
|
+
baseline = (0, baseline_1.baselineFile)({
|
|
28
|
+
schema: saved.before,
|
|
29
|
+
source: latest.planFile,
|
|
30
|
+
});
|
|
31
|
+
else
|
|
32
|
+
this.error(`No saved plan for ${migration}; pass the deployed .apsorc with --baseline.`, { exit: 1 });
|
|
33
|
+
const result = await (0, verify_1.verifyMigration)(process.cwd(), baseline, migration, (0, verify_1.cliBinPath)(this.config.root), {
|
|
34
|
+
skipBuild: flags["skip-build"],
|
|
35
|
+
seedFile: flags.seed,
|
|
36
|
+
checkFile: flags.check,
|
|
37
|
+
acceptLoss: flags["accept-loss"],
|
|
38
|
+
});
|
|
16
39
|
(0, verify_1.report)((s) => this.log(s), result, flags.json, result.ok ? "Migration verified." : undefined);
|
|
17
40
|
if (!result.ok)
|
|
18
41
|
this.exit(1);
|
|
19
42
|
}
|
|
20
43
|
}
|
|
21
|
-
MigrateVerify.description = "Run a migration on a local PGlite copy of the deployed schema, then check the database matches this project's entities (TypeScript
|
|
44
|
+
MigrateVerify.description = "Run a migration on a local PGlite copy of the deployed schema, then check the database matches this project's entities (TypeScript), or a fresh build of its .apsorc (Python and Go, which also run down and check it gives back the deployed schema)";
|
|
22
45
|
MigrateVerify.examples = [
|
|
46
|
+
`$ apso migrate verify`,
|
|
47
|
+
`$ apso migrate verify --seed seed.sql --check checks.sql`,
|
|
48
|
+
`$ apso migrate verify --seed seed.sql --check checks.sql --accept-loss contact.fullName`,
|
|
23
49
|
`$ apso migrate verify --baseline deployed.apsorc --migration src/migrations/1700000000000-SchemaUpdate-migration.ts`,
|
|
24
50
|
];
|
|
25
51
|
MigrateVerify.flags = {
|
|
26
52
|
baseline: core_1.Flags.string({
|
|
27
|
-
description: "The deployed .apsorc the migration starts from",
|
|
28
|
-
required: true,
|
|
53
|
+
description: "The deployed .apsorc the migration starts from. Defaults to the schema saved with the migration's plan",
|
|
29
54
|
}),
|
|
30
55
|
migration: core_1.Flags.string({
|
|
31
|
-
description: "The migration file to run",
|
|
32
|
-
|
|
56
|
+
description: "The migration file to run. Defaults to the newest planned migration",
|
|
57
|
+
}),
|
|
58
|
+
seed: core_1.Flags.string({
|
|
59
|
+
description: "SQL file of rows to insert into the deployed schema before the migration runs; the check then fails if the migration loses any of them",
|
|
60
|
+
}),
|
|
61
|
+
check: core_1.Flags.string({
|
|
62
|
+
description: "SQL file of SELECTs run after the migration; each row must have a boolean ok (and a label naming the check), e.g. SELECT bool_and(\"firstName\" <> '') AS ok, 'every contact has a first name' AS label FROM contact",
|
|
63
|
+
}),
|
|
64
|
+
"accept-loss": core_1.Flags.string({
|
|
65
|
+
description: "A table or table.column whose seeded data the migration may drop on purpose (repeatable); pair it with --check to prove where the data went",
|
|
66
|
+
multiple: true,
|
|
67
|
+
default: [],
|
|
33
68
|
}),
|
|
34
69
|
"skip-build": core_1.Flags.boolean({
|
|
35
70
|
description: "Use the existing dist/ build instead of building first",
|