@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
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.isApiKeyAuth = exports.isJwtAuth = exports.isDbSessionAuth = exports.API_KEY_AUTH_DEFAULTS = exports.JWT_AUTH_DEFAULTS = exports.DB_SESSION_AUTH_DEFAULTS = void 0;
3
+ exports.resolveApiKeyAuth = exports.resolveJwtAuth = exports.isApiKeyAuth = exports.isJwtAuth = exports.isDbSessionAuth = exports.API_KEY_AUTH_DEFAULTS = exports.JWT_AUTH_DEFAULTS = exports.DB_SESSION_AUTH_DEFAULTS = void 0;
4
4
  /**
5
5
  * Default values for DB session auth configuration
6
6
  */
@@ -28,6 +28,7 @@ exports.JWT_AUTH_DEFAULTS = {
28
28
  exports.API_KEY_AUTH_DEFAULTS = {
29
29
  apiKeyHeader: "x-api-key",
30
30
  apiKeyEntity: "ApiKey",
31
+ keyField: "keyHash",
31
32
  };
32
33
  /**
33
34
  * Type guard: Check if auth config is DB session based
@@ -46,7 +47,8 @@ exports.isDbSessionAuth = isDbSessionAuth;
46
47
  function isJwtAuth(auth) {
47
48
  return (auth.provider === "auth0" ||
48
49
  auth.provider === "clerk" ||
49
- auth.provider === "cognito");
50
+ auth.provider === "cognito" ||
51
+ auth.provider === "jwt");
50
52
  }
51
53
  exports.isJwtAuth = isJwtAuth;
52
54
  /**
@@ -58,3 +60,60 @@ function isApiKeyAuth(auth) {
58
60
  return auth.provider === "api-key";
59
61
  }
60
62
  exports.isApiKeyAuth = isApiKeyAuth;
63
+ /**
64
+ * Claim defaults per provider. Clerk's v2 session token nests the active
65
+ * organization under `o`; Cognito puts group membership in `cognito:groups`.
66
+ */
67
+ const PROVIDER_CLAIMS = {
68
+ auth0: { userId: "sub", email: "email" },
69
+ clerk: { userId: "sub", email: "email", workspaceId: "o.id", roles: "o.rol" },
70
+ cognito: { userId: "sub", email: "email", roles: "cognito:groups" },
71
+ jwt: { userId: "sub", email: "email", roles: "roles" },
72
+ };
73
+ const ALLOWED_ALGORITHMS = new Set([
74
+ "RS256", "RS384", "RS512", "PS256", "PS384", "PS512", "ES256", "ES384",
75
+ "EdDSA",
76
+ ]);
77
+ /**
78
+ * Fill JWT defaults and reject configs that would generate an unsafe check.
79
+ * Only asymmetric algorithms are accepted: the key comes from the issuer's
80
+ * JWKS, so there is no shared secret to configure or leak.
81
+ */
82
+ function resolveJwtAuth(auth) {
83
+ var _a, _b, _c, _d, _e, _f;
84
+ const issuer = (_b = (_a = auth.jwt) === null || _a === void 0 ? void 0 : _a.issuer) === null || _b === void 0 ? void 0 : _b.trim();
85
+ if (!issuer)
86
+ throw new Error(`auth.jwt.issuer is required for the "${auth.provider}" provider`);
87
+ const algorithms = ((_c = auth.jwt.algorithms) === null || _c === void 0 ? void 0 : _c.length)
88
+ ? auth.jwt.algorithms
89
+ : ["RS256"];
90
+ const bad = algorithms.filter((a) => !ALLOWED_ALGORITHMS.has(a));
91
+ if (bad.length > 0)
92
+ throw new Error(`auth.jwt.algorithms: ${bad.join(", ")} not supported; use ${[
93
+ ...ALLOWED_ALGORITHMS,
94
+ ].join(", ")}`);
95
+ const claims = { ...PROVIDER_CLAIMS[auth.provider], ...auth.claims };
96
+ return {
97
+ provider: auth.provider,
98
+ issuer,
99
+ audience: ((_d = auth.jwt.audience) === null || _d === void 0 ? void 0 : _d.trim()) || undefined,
100
+ jwksUri: ((_e = auth.jwt.jwksUri) === null || _e === void 0 ? void 0 : _e.trim()) ||
101
+ `${issuer.replace(/\/+$/, "")}/.well-known/jwks.json`,
102
+ algorithms,
103
+ authorizedParties: (_f = auth.jwt.authorizedParties) !== null && _f !== void 0 ? _f : [],
104
+ claims: { ...claims, userId: claims.userId || "sub" },
105
+ };
106
+ }
107
+ exports.resolveJwtAuth = resolveJwtAuth;
108
+ /** Fill API key defaults. */
109
+ function resolveApiKeyAuth(auth) {
110
+ return {
111
+ provider: "api-key",
112
+ apiKeyHeader: (auth.apiKeyHeader || exports.API_KEY_AUTH_DEFAULTS.apiKeyHeader).toLowerCase(),
113
+ apiKeyEntity: auth.apiKeyEntity || exports.API_KEY_AUTH_DEFAULTS.apiKeyEntity,
114
+ keyField: auth.keyField || exports.API_KEY_AUTH_DEFAULTS.keyField,
115
+ workspaceField: auth.workspaceField || "organizationId",
116
+ roleField: auth.roleField,
117
+ };
118
+ }
119
+ exports.resolveApiKeyAuth = resolveApiKeyAuth;
@@ -153,6 +153,11 @@ export interface ControllerGenerationOptions extends EntityGenerationOptions {
153
153
  * Relationship map for join queries
154
154
  */
155
155
  relationshipMap: RelationshipMap;
156
+ /**
157
+ * The .apsorc auth config; with scopeBy it limits every query to the
158
+ * caller's tenant
159
+ */
160
+ auth?: AuthConfig;
156
161
  }
157
162
  /**
158
163
  * Options for generating services
@@ -247,6 +252,7 @@ export interface LanguageGenerator {
247
252
  generateIndexModule(entities: Entity[], apiType: string, opts?: {
248
253
  emitEvents?: boolean;
249
254
  http?: boolean;
255
+ auth?: AuthConfig;
250
256
  }): Promise<GeneratedFile[]>;
251
257
  /**
252
258
  * Generate guard files (auth, scope, etc.)
@@ -3,5 +3,5 @@ export { FieldType, Field, ComputedField } from "./field";
3
3
  export { Index } from "./indices";
4
4
  export { JSONValue } from "./json";
5
5
  export { RelationshipType, Association, ApsorcRelationship, Relationship, RelationshipForTemplate, RelationshipMap, } from "./relationship";
6
- export { AuthProviderType, BaseAuthConfig, DbSessionAuthConfig, JwtConfig, JwtClaimsMapping, JwtAuthConfig, ApiKeyAuthConfig, AuthConfig, AuthContext, DB_SESSION_AUTH_DEFAULTS, JWT_AUTH_DEFAULTS, API_KEY_AUTH_DEFAULTS, isDbSessionAuth, isJwtAuth, isApiKeyAuth, } from "./auth";
6
+ export { AuthProviderType, BaseAuthConfig, DbSessionAuthConfig, JwtConfig, JwtClaimsMapping, JwtAuthConfig, ApiKeyAuthConfig, AuthConfig, AuthContext, DB_SESSION_AUTH_DEFAULTS, JWT_AUTH_DEFAULTS, API_KEY_AUTH_DEFAULTS, isDbSessionAuth, isJwtAuth, isApiKeyAuth, ResolvedJwtAuth, ResolvedApiKeyAuth, resolveJwtAuth, resolveApiKeyAuth, } from "./auth";
7
7
  export { TargetLanguage, GeneratedFile, ValidationResult, PythonLanguageConfig, GoLanguageConfig, TypeScriptLanguageConfig, LanguageConfig, GeneratorConfig, EntityGenerationOptions, ControllerGenerationOptions, ServiceGenerationOptions, DtoGenerationOptions, MigrationGenerationOptions, LanguageGenerator, GeneratorFactory, GeneratorRegistry, } from "./generator";
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.isApiKeyAuth = exports.isJwtAuth = exports.isDbSessionAuth = exports.API_KEY_AUTH_DEFAULTS = exports.JWT_AUTH_DEFAULTS = exports.DB_SESSION_AUTH_DEFAULTS = void 0;
3
+ exports.resolveApiKeyAuth = exports.resolveJwtAuth = exports.isApiKeyAuth = exports.isJwtAuth = exports.isDbSessionAuth = exports.API_KEY_AUTH_DEFAULTS = exports.JWT_AUTH_DEFAULTS = exports.DB_SESSION_AUTH_DEFAULTS = void 0;
4
4
  var auth_1 = require("./auth");
5
5
  Object.defineProperty(exports, "DB_SESSION_AUTH_DEFAULTS", { enumerable: true, get: function () { return auth_1.DB_SESSION_AUTH_DEFAULTS; } });
6
6
  Object.defineProperty(exports, "JWT_AUTH_DEFAULTS", { enumerable: true, get: function () { return auth_1.JWT_AUTH_DEFAULTS; } });
@@ -8,3 +8,5 @@ Object.defineProperty(exports, "API_KEY_AUTH_DEFAULTS", { enumerable: true, get:
8
8
  Object.defineProperty(exports, "isDbSessionAuth", { enumerable: true, get: function () { return auth_1.isDbSessionAuth; } });
9
9
  Object.defineProperty(exports, "isJwtAuth", { enumerable: true, get: function () { return auth_1.isJwtAuth; } });
10
10
  Object.defineProperty(exports, "isApiKeyAuth", { enumerable: true, get: function () { return auth_1.isApiKeyAuth; } });
11
+ Object.defineProperty(exports, "resolveJwtAuth", { enumerable: true, get: function () { return auth_1.resolveJwtAuth; } });
12
+ Object.defineProperty(exports, "resolveApiKeyAuth", { enumerable: true, get: function () { return auth_1.resolveApiKeyAuth; } });
@@ -24,3 +24,16 @@ export declare function ensureCrudDeps(projectDir: string): string[];
24
24
  * @returns {string[]} Names added (empty when nothing changed or no package.json).
25
25
  */
26
26
  export declare function addMissingDeps(projectDir: string, deps: Record<string, string>): string[];
27
+ /** JWT verification library the generated TypeScript JWT guard imports. */
28
+ export declare const JOSE_DEP: Record<string, string>;
29
+ /** JWT verification library the generated Python auth module imports. */
30
+ export declare const PYJWT_DEP = "PyJWT[crypto]>=2.8.0";
31
+ /**
32
+ * Add a requirement to pyproject.toml's [project] dependencies list unless a
33
+ * requirement for the same package is already there.
34
+ *
35
+ * @param {string} projectDir - Directory holding pyproject.toml.
36
+ * @param {string} requirement - A PEP 508 requirement, e.g. "PyJWT[crypto]>=2.8.0".
37
+ * @returns {boolean} True when pyproject.toml was changed.
38
+ */
39
+ export declare function addMissingPythonDep(projectDir: string, requirement: string): boolean;
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.addMissingDeps = exports.ensureCrudDeps = exports.APSO_CRUD_DEPS = void 0;
3
+ exports.addMissingPythonDep = exports.PYJWT_DEP = exports.JOSE_DEP = exports.addMissingDeps = exports.ensureCrudDeps = exports.APSO_CRUD_DEPS = void 0;
4
4
  const tslib_1 = require("tslib");
5
5
  const fs = tslib_1.__importStar(require("fs"));
6
6
  const path = tslib_1.__importStar(require("path"));
@@ -56,3 +56,39 @@ function addMissingDeps(projectDir, deps) {
56
56
  return missing;
57
57
  }
58
58
  exports.addMissingDeps = addMissingDeps;
59
+ /** JWT verification library the generated TypeScript JWT guard imports. */
60
+ exports.JOSE_DEP = { jose: "^5.10.0" };
61
+ /** JWT verification library the generated Python auth module imports. */
62
+ exports.PYJWT_DEP = "PyJWT[crypto]>=2.8.0";
63
+ /**
64
+ * Add a requirement to pyproject.toml's [project] dependencies list unless a
65
+ * requirement for the same package is already there.
66
+ *
67
+ * @param {string} projectDir - Directory holding pyproject.toml.
68
+ * @param {string} requirement - A PEP 508 requirement, e.g. "PyJWT[crypto]>=2.8.0".
69
+ * @returns {boolean} True when pyproject.toml was changed.
70
+ */
71
+ function addMissingPythonDep(projectDir, requirement) {
72
+ var _a;
73
+ const file = path.join(projectDir, "pyproject.toml");
74
+ if (!fs.existsSync(file))
75
+ return false;
76
+ const text = fs.readFileSync(file, "utf8");
77
+ const name = requirement.split(/[ !<=>[~]/)[0].toLowerCase();
78
+ // The list's closing bracket sits on its own line, or ends a one-line list;
79
+ // brackets inside requirements ("uvicorn[standard]") are not the end.
80
+ const list = (_a = /^dependencies\s*=\s*\[(.*)][^\S\n]*$/m.exec(text)) !== null && _a !== void 0 ? _a : /^dependencies\s*=\s*\[([\S\s]*?)^]/m.exec(text);
81
+ if (!list)
82
+ return false;
83
+ const declared = [...list[1].matchAll(/["']([^"']+)["']/g)].map((m) => m[1].split(/[ !<=>[~]/)[0].toLowerCase());
84
+ if (declared.includes(name))
85
+ return false;
86
+ const body = list[1].replace(/\s*$/, "");
87
+ const sep = body.trim() === "" || body.trim().endsWith(",") ? "" : ",";
88
+ const updated = text.slice(0, list.index) +
89
+ `dependencies = [${body}${sep}\n "${requirement}",\n]` +
90
+ text.slice(list.index + list[0].length);
91
+ fs.writeFileSync(file, updated);
92
+ return true;
93
+ }
94
+ exports.addMissingPythonDep = addMissingPythonDep;
@@ -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 {};
@@ -1,12 +1,12 @@
1
1
  "use strict";
2
2
  /**
3
3
  * Pre-deploy checks for a generated service: generate the migration from the
4
- * deployed schema, run it and check for drift (TypeScript only, on PGlite),
5
- * and smoke test the endpoints. The CLI and the in-browser editor (which runs
4
+ * deployed schema, run it and check for drift (on PGlite), and smoke test the
5
+ * endpoints. The CLI and the in-browser editor (which runs
6
6
  * the CLI in a WebContainer) share these steps.
7
7
  */
8
8
  Object.defineProperty(exports, "__esModule", { value: true });
9
- exports.report = exports.cliBinPath = exports.readDotEnv = exports.smokeTest = exports.entityRoute = exports.verifyMigration = exports.supportsPglite = exports.generateMigration = exports.migrateUnsupported = exports.buildBaseline = exports.pgliteEnv = exports.run = void 0;
9
+ exports.report = exports.cliBinPath = exports.readDotEnv = exports.smokeTest = exports.entityRoute = exports.verifyMigration = exports.supportsPglite = exports.unaccepted = exports.dataLoss = exports.generateMigration = exports.buildBaseline = exports.pgliteEnv = exports.run = void 0;
10
10
  const tslib_1 = require("tslib");
11
11
  const child_process_1 = require("child_process");
12
12
  const fs = tslib_1.__importStar(require("fs"));
@@ -15,8 +15,13 @@ const net = tslib_1.__importStar(require("net"));
15
15
  const os = tslib_1.__importStar(require("os"));
16
16
  const path = tslib_1.__importStar(require("path"));
17
17
  const pluralize_1 = tslib_1.__importDefault(require("pluralize"));
18
+ const apsorc_parser_1 = require("../apsorc-parser");
19
+ const ddl_1 = require("../ddl");
20
+ const catalog_1 = require("../ddl/catalog");
21
+ const migration_file_1 = require("../ddl/migration-file");
18
22
  const casing_1 = require("../utils/casing");
19
23
  const crud_plan_1 = require("./crud-plan");
24
+ const review_1 = require("./review");
20
25
  // eslint-disable-next-line no-control-regex
21
26
  const plain = (s) => s.replace(/\u001B\[[\d;]*m/g, "");
22
27
  const tail = (s, n = 4000) => {
@@ -85,19 +90,6 @@ async function buildBaseline(projectDir, baselineApsorc, dataDir, cliBin) {
85
90
  }
86
91
  }
87
92
  exports.buildBaseline = buildBaseline;
88
- /**
89
- * Why `apso migrate` can't run for this project's language, or undefined.
90
- * ponytail: Python (Alembic) and Go (goose) have no local scratch database to
91
- * build the baseline on; add when they get one like PGlite for TypeScript.
92
- */
93
- const migrateUnsupported = (language = "typescript") => {
94
- if (language === "python")
95
- return "apso migrate supports TypeScript services only for now. This is a Python service: use Alembic directly (alembic revision --autogenerate, alembic upgrade head, alembic check).";
96
- if (language === "go")
97
- return "apso migrate supports TypeScript services only for now. This is a Go service: write goose migrations in migrations/ and run them with `go run cmd/migrate/main.go up`.";
98
- return undefined;
99
- };
100
- exports.migrateUnsupported = migrateUnsupported;
101
93
  /** Generate a migration from the deployed schema (baselineApsorc) to the project's entities. */
102
94
  async function generateMigration(projectDir, baselineApsorc, name, cliBin) {
103
95
  const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "apso-pglite-"));
@@ -129,6 +121,9 @@ async function generateMigration(projectDir, baselineApsorc, name, cliBin) {
129
121
  needed: true,
130
122
  file: file && path.join("src", "migrations", file),
131
123
  steps,
124
+ findings: file
125
+ ? (0, review_1.reviewMigration)(fs.readFileSync(path.join(migrationsDir, file), "utf8"))
126
+ : undefined,
132
127
  };
133
128
  }
134
129
  finally {
@@ -148,6 +143,96 @@ ds.initialize()
148
143
  .then((ran) => { console.log("ran " + ran.map((m) => m.name).join(", ")); return ds.destroy(); })
149
144
  .catch((e) => { console.error(e && e.message ? e.message : e); process.exit(1); });
150
145
  `;
146
+ // Row and non-null value counts per table and column of the PGlite database
147
+ // at PGLITE_DATA_DIR, after running the SQL file in argv[1] if given, plus each
148
+ // column's distinct values (as text) so data moved to another table is found.
149
+ const COUNT_ROWS = `
150
+ const fs = require("fs");
151
+ const { PGlite } = require("@electric-sql/pglite");
152
+ (async () => {
153
+ const db = new PGlite(process.env.PGLITE_DATA_DIR);
154
+ if (process.argv[1]) await db.exec(fs.readFileSync(process.argv[1], "utf8"));
155
+ const cols = (await db.query("SELECT table_name, column_name FROM information_schema.columns WHERE table_schema = 'public' AND table_name <> 'migrations'")).rows;
156
+ const out = {};
157
+ for (const { table_name: t, column_name: c } of cols) {
158
+ const q = '"' + c + '"';
159
+ const r = (await db.query("SELECT count(*)::int AS rows, count(" + q + ")::int AS vals, (SELECT array_agg(v) FROM (SELECT DISTINCT " + q + "::text AS v FROM \\"" + t + "\\" WHERE " + q + " IS NOT NULL LIMIT 1000) d) AS dv FROM \\"" + t + "\\"")).rows[0];
160
+ out[t] = out[t] || { rows: r.rows, values: {}, distinct: {} };
161
+ out[t].values[c] = r.vals;
162
+ out[t].distinct[c] = r.dv || [];
163
+ }
164
+ console.log(JSON.stringify(out));
165
+ await db.close();
166
+ })().catch((e) => { console.error(e.message); process.exit(1); });
167
+ `;
168
+ // Assertions the user wrote for this migration: each SELECT returns rows with a
169
+ // boolean "ok" and a "label", the same format as the eval checks.
170
+ const RUN_CHECKS = `
171
+ const fs = require("fs");
172
+ const { PGlite } = require("@electric-sql/pglite");
173
+ (async () => {
174
+ const db = new PGlite(process.env.PGLITE_DATA_DIR);
175
+ const failed = [];
176
+ for (const r of await db.exec(fs.readFileSync(process.argv[1], "utf8")))
177
+ for (const row of r.rows) if ("ok" in row && row.ok !== true) failed.push(row.label || JSON.stringify(row));
178
+ await db.close();
179
+ if (failed.length) { console.error("Failed checks:\\n" + failed.join("\\n")); process.exit(1); }
180
+ })().catch((e) => { console.error(e.message); process.exit(1); });
181
+ `;
182
+ // Filled in by the database on insert, so a moved row gets new ones.
183
+ const GENERATED = new Set(["created_at", "updated_at"]);
184
+ /**
185
+ * Rows and values present before the migration and missing after it,
186
+ * following the tables and columns the migration renames. A column or table
187
+ * whose values all turn up in another column after the migration was moved,
188
+ * not lost (a field moved to a new table, a foreign key moved to a join table).
189
+ */
190
+ function dataLoss(before, after, source) {
191
+ var _a, _b, _c, _d;
192
+ const tables = new Map([...source.matchAll(/ALTER TABLE "([^"]+)" RENAME TO "([^"]+)"/g)].map((m) => [m[1], m[2]]));
193
+ const columns = new Map([...source.matchAll(/ALTER TABLE "([^"]+)" RENAME COLUMN "([^"]+)" TO "([^"]+)"/g)].map((m) => [`${m[1]}.${m[2]}`, m[3]]));
194
+ // Only tables the migration creates count, and not their ids: small integer
195
+ // values (foreign keys) would otherwise match any existing id column.
196
+ const afterValues = Object.entries(after)
197
+ .filter(([t]) => !before[t] && ![...tables.values()].includes(t))
198
+ .flatMap(([, a]) => {
199
+ var _a;
200
+ return Object.entries((_a = a.distinct) !== null && _a !== void 0 ? _a : {})
201
+ .filter(([c]) => c !== "id")
202
+ .map(([, v]) => new Set(v));
203
+ });
204
+ const moved = (t, c) => {
205
+ var _a, _b;
206
+ const vals = (_b = (_a = before[t].distinct) === null || _a === void 0 ? void 0 : _a[c]) !== null && _b !== void 0 ? _b : [];
207
+ return vals.length > 0 && afterValues.some((s) => vals.every((v) => s.has(v)));
208
+ };
209
+ const lost = [];
210
+ for (const [t, { rows, values }] of Object.entries(before)) {
211
+ if (!rows)
212
+ continue;
213
+ const t2 = (_a = tables.get(t)) !== null && _a !== void 0 ? _a : t;
214
+ const now = after[t2];
215
+ if (!now) {
216
+ const gone = Object.keys(values).filter((c) => values[c] > 0 && !GENERATED.has(c) && !moved(t, c));
217
+ if (gone.length > 0)
218
+ lost.push(`table ${t}: all ${rows} rows`);
219
+ continue;
220
+ }
221
+ if (now.rows < rows)
222
+ lost.push(`table ${t}: ${rows - now.rows} of ${rows} rows`);
223
+ for (const [c, n] of Object.entries(values)) {
224
+ const c2 = (_c = (_b = columns.get(`${t}.${c}`)) !== null && _b !== void 0 ? _b : columns.get(`${t2}.${c}`)) !== null && _c !== void 0 ? _c : c;
225
+ const kept = (_d = now.values[c2]) !== null && _d !== void 0 ? _d : 0;
226
+ if (n > kept && !(kept === 0 && moved(t, c)))
227
+ lost.push(`${t}.${c}: ${n - kept} of ${n} values`);
228
+ }
229
+ }
230
+ return lost;
231
+ }
232
+ exports.dataLoss = dataLoss;
233
+ /** Losses the user accepted by name: a table, or table.column. */
234
+ const unaccepted = (lost, accept) => lost.filter((l) => !accept.some((a) => l.startsWith(`${a}:`) || l.startsWith(`table ${a}:`)));
235
+ exports.unaccepted = unaccepted;
151
236
  /**
152
237
  * Projects from the v1 template have no PGlite support in src/orm.config.ts,
153
238
  * so the checks cannot run locally. They pass as skipped rather than block a
@@ -176,9 +261,17 @@ exports.supportsPglite = supportsPglite;
176
261
  * Run a migration on a fresh copy of the deployed schema, then check that the
177
262
  * database matches the project's entities (a second generate finds nothing).
178
263
  */
179
- async function verifyMigration(projectDir, baselineApsorc, migrationFile, cliBin, skipBuild = false) {
264
+ async function verifyMigration(projectDir, baselineApsorc, migrationFile, cliBin, { skipBuild = false, seedFile, checkFile, acceptLoss = [], } = {}) {
265
+ if (/\.(py|sql)$/.test(migrationFile))
266
+ return verifySqlMigration(projectDir, baselineApsorc, migrationFile, cliBin, {
267
+ seedFile,
268
+ checkFile,
269
+ acceptLoss,
270
+ });
180
271
  if (!supportsPglite(projectDir))
181
272
  return NO_PGLITE;
273
+ const source = fs.readFileSync(path.resolve(projectDir, migrationFile), "utf8");
274
+ const findings = (0, review_1.reviewMigration)(source);
182
275
  const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "apso-pglite-"));
183
276
  const steps = [];
184
277
  const done = (s) => {
@@ -187,25 +280,157 @@ async function verifyMigration(projectDir, baselineApsorc, migrationFile, cliBin
187
280
  };
188
281
  try {
189
282
  if (!done(await buildBaseline(projectDir, baselineApsorc, dataDir, cliBin)))
190
- return { ok: false, steps };
283
+ return { ok: false, steps, findings };
284
+ const count = async (name, sql) => {
285
+ const r = await run(process.execPath, ["-e", COUNT_ROWS, ...(sql ? [sql] : [])], projectDir, (0, exports.pgliteEnv)(dataDir));
286
+ done(step(name, r));
287
+ return r.ok ? JSON.parse(r.output.trim().split("\n").pop()) : undefined;
288
+ };
289
+ // Seed rows make the migration face the data it will meet in production.
290
+ const before = seedFile ? await count("seed", path.resolve(seedFile)) : undefined;
291
+ if (seedFile && !before)
292
+ return { ok: false, steps, findings };
191
293
  const compiled = path
192
294
  .join("dist", path.relative("src", migrationFile))
193
295
  .replace(/\.ts$/, ".js");
194
296
  // --skip-build still builds when the migration isn't compiled yet.
195
297
  const needsBuild = !skipBuild || !fs.existsSync(path.join(projectDir, compiled));
196
298
  if (needsBuild && !done(step("build", await build(projectDir))))
197
- return { ok: false, steps };
299
+ return { ok: false, steps, findings };
198
300
  const migrate = await run(process.execPath, ["-e", RUN_ONE_MIGRATION, compiled], projectDir, (0, exports.pgliteEnv)(dataDir));
199
301
  if (!done({ ...step("migrate", migrate), output: tail(migrate.output) }))
200
- return { ok: false, steps };
302
+ return { ok: false, steps, findings };
303
+ if (before) {
304
+ const after = await count("count");
305
+ if (!after)
306
+ return { ok: false, steps, findings };
307
+ const lost = dataLoss(before, after, source);
308
+ const ok = (0, exports.unaccepted)(lost, acceptLoss).length === 0;
309
+ done({
310
+ name: "data",
311
+ ok,
312
+ output: lost.length > 0
313
+ ? `The migration lost seeded data:\n${lost.join("\n")}${ok ? "" : "\nFix the migration. If data moved somewhere the check can't follow (e.g. a field split in two), prove it with --check and rerun with --accept-loss <table.column>."}`
314
+ : undefined,
315
+ });
316
+ if (!ok)
317
+ return { ok: false, steps, findings };
318
+ }
319
+ if (checkFile &&
320
+ !done(step("check", await run(process.execPath, ["-e", RUN_CHECKS, path.resolve(checkFile)], projectDir, (0, exports.pgliteEnv)(dataDir)))))
321
+ return { ok: false, steps, findings };
201
322
  const drift = await typeorm(projectDir, ["migration:generate", "./src/migrations/drift", "--check"], (0, exports.pgliteEnv)(dataDir));
202
- return { ok: done(step("drift", drift)), steps };
323
+ return { ok: done(step("drift", drift)), steps, findings };
203
324
  }
204
325
  finally {
205
326
  fs.rmSync(dataDir, { recursive: true, force: true });
206
327
  }
207
328
  }
208
329
  exports.verifyMigration = verifyMigration;
330
+ const ddlOf = (apsorcFile) => (0, ddl_1.schemaDdl)((0, apsorc_parser_1.parseApsorcV2)(JSON.parse(fs.readFileSync(apsorcFile).toString()))).join(";\n");
331
+ /**
332
+ * verifyMigration for an Alembic or goose migration. Python and Go services
333
+ * have no TypeORM, so Apso's DDL writer builds the deployed schema, the
334
+ * migration's SQL runs as written, and drift is a catalog diff against a
335
+ * fresh build of the new .apsorc. down then runs and must give back the
336
+ * deployed schema.
337
+ */
338
+ async function verifySqlMigration(projectDir, baselineApsorc, migrationFile, cliBin, { seedFile, checkFile, acceptLoss = [] }) {
339
+ const { PGlite } = await Promise.resolve().then(() => tslib_1.__importStar(require("@electric-sql/pglite")));
340
+ const steps = [];
341
+ const done = (s) => {
342
+ steps.push(s);
343
+ return s.ok;
344
+ };
345
+ let sql;
346
+ try {
347
+ sql = (0, migration_file_1.migrationSql)(migrationFile, fs.readFileSync(path.resolve(projectDir, migrationFile), "utf8"));
348
+ }
349
+ catch (error) {
350
+ done({ name: "read", ok: false, output: error.message });
351
+ return { ok: false, steps };
352
+ }
353
+ const findings = (0, review_1.reviewMigration)((0, migration_file_1.typeormMigration)("Migration", [sql.up], { down: [] }));
354
+ const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "apso-pglite-"));
355
+ // The CLI's own PGlite: the project has no node_modules to load one from.
356
+ const cliRoot = path.dirname(path.dirname(cliBin));
357
+ const script = (name, code, arg) => run(process.execPath, ["-e", code, ...(arg ? [path.resolve(arg)] : [])], cliRoot, (0, exports.pgliteEnv)(dataDir)).then((r) => {
358
+ done(step(name, r));
359
+ return r;
360
+ });
361
+ const exec = async (name, statements) => {
362
+ const db = new PGlite(dataDir);
363
+ try {
364
+ await db.exec(statements);
365
+ return done({ name, ok: true });
366
+ }
367
+ catch (error) {
368
+ return done({ name, ok: false, output: error.message });
369
+ }
370
+ finally {
371
+ await db.close();
372
+ }
373
+ };
374
+ const drift = async (name, apsorcFile) => {
375
+ const db = new PGlite(dataDir);
376
+ const fresh = new PGlite();
377
+ try {
378
+ await fresh.exec(ddlOf(apsorcFile));
379
+ const diff = (0, catalog_1.catalogDiff)(await (0, catalog_1.readCatalog)(fresh), await (0, catalog_1.readCatalog)(db));
380
+ return done({
381
+ name,
382
+ ok: diff.length === 0,
383
+ output: diff.length > 0 ? `The database differs from ${path.basename(apsorcFile)} (- missing, + extra):\n${diff.join("\n")}` : undefined,
384
+ });
385
+ }
386
+ finally {
387
+ await db.close();
388
+ await fresh.close();
389
+ }
390
+ };
391
+ try {
392
+ if (!(await exec("baseline", ddlOf(baselineApsorc))))
393
+ return { ok: false, steps, findings };
394
+ const count = async (name, file) => {
395
+ const r = await script(name, COUNT_ROWS, file);
396
+ return r.ok ? JSON.parse(r.output.trim().split("\n").pop()) : undefined;
397
+ };
398
+ const before = seedFile ? await count("seed", seedFile) : undefined;
399
+ if (seedFile && !before)
400
+ return { ok: false, steps, findings };
401
+ if (!(await exec("migrate", sql.up)))
402
+ return { ok: false, steps, findings };
403
+ if (before) {
404
+ const after = await count("count");
405
+ if (!after)
406
+ return { ok: false, steps, findings };
407
+ const lost = dataLoss(before, after, sql.up);
408
+ const ok = (0, exports.unaccepted)(lost, acceptLoss).length === 0;
409
+ done({
410
+ name: "data",
411
+ ok,
412
+ output: lost.length > 0
413
+ ? `The migration lost seeded data:\n${lost.join("\n")}${ok ? "" : "\nFix the migration. If data moved somewhere the check can't follow (e.g. a field split in two), prove it with --check and rerun with --accept-loss <table.column>."}`
414
+ : undefined,
415
+ });
416
+ if (!ok)
417
+ return { ok: false, steps, findings };
418
+ }
419
+ const checked = checkFile ? await script("check", RUN_CHECKS, checkFile) : undefined;
420
+ if (checked && !checked.ok)
421
+ return { ok: false, steps, findings };
422
+ if (!(await drift("drift", path.join(projectDir, ".apsorc"))))
423
+ return { ok: false, steps, findings };
424
+ if (sql.down === undefined)
425
+ done({ name: "down", ok: true, output: "down refuses to run (irreversible), so it was not tested." });
426
+ else if (!(await exec("down", sql.down)) || !(await drift("down drift", baselineApsorc)))
427
+ return { ok: false, steps, findings };
428
+ return { ok: true, steps, findings };
429
+ }
430
+ finally {
431
+ fs.rmSync(dataDir, { recursive: true, force: true });
432
+ }
433
+ }
209
434
  const freePort = () => new Promise((resolve, reject) => {
210
435
  const s = net.createServer();
211
436
  s.listen(0, () => {
@@ -492,6 +717,7 @@ const cliBinPath = (root) => path.join(root, "bin", "run");
492
717
  exports.cliBinPath = cliBinPath;
493
718
  /** Print a check's steps (or JSON) through the command's logger. */
494
719
  function report(log, result, json, summary) {
720
+ var _a;
495
721
  if (json) {
496
722
  log(JSON.stringify(result));
497
723
  return;
@@ -501,6 +727,8 @@ function report(log, result, json, summary) {
501
727
  if (s.output)
502
728
  log(s.output.replace(/^/gm, " "));
503
729
  }
730
+ for (const f of (_a = result.findings) !== null && _a !== void 0 ? _a : [])
731
+ log(`WARN ${f.code}: ${f.message}`);
504
732
  if (summary)
505
733
  log(summary);
506
734
  }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Review a TypeORM-generated migration for statements that lose data or fail
3
+ * on a populated database. TypeORM changes a column type by DROP + ADD, casts
4
+ * enums without remapping removed values, and adds NOT NULL columns as if the
5
+ * table were empty. The generated file is kept; each finding names a
6
+ * statement to rewrite.
7
+ */
8
+ export interface Finding {
9
+ code: string;
10
+ table: string;
11
+ column?: string;
12
+ message: string;
13
+ }
14
+ export declare function reviewMigration(source: string): Finding[];