@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
|
@@ -16,7 +16,9 @@ export declare class PythonGenerator extends BaseGenerator {
|
|
|
16
16
|
generateModule(_options: EntityGenerationOptions): Promise<GeneratedFile[]>;
|
|
17
17
|
generateMigration(options: MigrationGenerationOptions): Promise<GeneratedFile[]>;
|
|
18
18
|
generateEnums(entities: Entity[], _apiType: string): Promise<GeneratedFile[]>;
|
|
19
|
-
generateIndexModule(entities: Entity[], _apiType: string
|
|
19
|
+
generateIndexModule(entities: Entity[], _apiType: string, opts?: {
|
|
20
|
+
auth?: AuthConfig;
|
|
21
|
+
}): Promise<GeneratedFile[]>;
|
|
20
22
|
/**
|
|
21
23
|
* Emits a schema-derived manifest of the entities that opted in to
|
|
22
24
|
* domain-event emission (`.apsorc` `emitEvents`), mirroring the TypeScript
|
|
@@ -35,6 +37,6 @@ export declare class PythonGenerator extends BaseGenerator {
|
|
|
35
37
|
generateDomainEvents(entities: Entity[], _apiType?: string, opts?: {
|
|
36
38
|
emitEvents?: boolean;
|
|
37
39
|
}): Promise<GeneratedFile[]>;
|
|
38
|
-
generateGuards(
|
|
40
|
+
generateGuards(entities: Entity[], auth?: AuthConfig): Promise<GeneratedFile[]>;
|
|
39
41
|
generateQueryUtils(_entities: Entity[], _apiType: string): Promise<GeneratedFile[]>;
|
|
40
42
|
}
|
|
@@ -5,10 +5,13 @@ const tslib_1 = require("tslib");
|
|
|
5
5
|
const path = tslib_1.__importStar(require("path"));
|
|
6
6
|
const Eta = tslib_1.__importStar(require("eta"));
|
|
7
7
|
const base_1 = require("./base");
|
|
8
|
+
const types_1 = require("../types");
|
|
8
9
|
const field_1 = require("../utils/field");
|
|
9
10
|
const relationships_1 = require("../utils/relationships");
|
|
10
11
|
const casing_1 = require("../utils/casing");
|
|
11
12
|
const events_1 = require("../events");
|
|
13
|
+
const guards_1 = require("../guards");
|
|
14
|
+
const migration_file_1 = require("../ddl/migration-file");
|
|
12
15
|
const pluralize_1 = tslib_1.__importDefault(require("pluralize"));
|
|
13
16
|
/**
|
|
14
17
|
* Python/FastAPI generator that produces SQLAlchemy models, Pydantic schemas,
|
|
@@ -225,10 +228,10 @@ class PythonGenerator extends base_1.BaseGenerator {
|
|
|
225
228
|
const name = migrationName || "auto";
|
|
226
229
|
const filename = `${timestamp}_${name}.py`;
|
|
227
230
|
const upStatements = upSql
|
|
228
|
-
.map((sql) => ` op.execute(
|
|
231
|
+
.map((sql) => ` op.execute(${(0, migration_file_1.pyString)(sql)})`)
|
|
229
232
|
.join("\n");
|
|
230
233
|
const downStatements = (downSql || [])
|
|
231
|
-
.map((sql) => ` op.execute(
|
|
234
|
+
.map((sql) => ` op.execute(${(0, migration_file_1.pyString)(sql)})`)
|
|
232
235
|
.join("\n");
|
|
233
236
|
const content = `"""${name}"""
|
|
234
237
|
from alembic import op
|
|
@@ -278,8 +281,11 @@ ${downStatements || " pass"}
|
|
|
278
281
|
},
|
|
279
282
|
];
|
|
280
283
|
}
|
|
281
|
-
async generateIndexModule(entities, _apiType) {
|
|
282
|
-
const content = await this.renderTemplate("./index-module", {
|
|
284
|
+
async generateIndexModule(entities, _apiType, opts) {
|
|
285
|
+
const content = await this.renderTemplate("./index-module", {
|
|
286
|
+
entities,
|
|
287
|
+
hasAuth: Boolean(opts === null || opts === void 0 ? void 0 : opts.auth),
|
|
288
|
+
});
|
|
283
289
|
return [
|
|
284
290
|
{
|
|
285
291
|
path: "__init__.py",
|
|
@@ -319,10 +325,27 @@ ${downStatements || " pass"}
|
|
|
319
325
|
},
|
|
320
326
|
];
|
|
321
327
|
}
|
|
322
|
-
async generateGuards(
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
328
|
+
async generateGuards(entities, auth) {
|
|
329
|
+
if (!auth)
|
|
330
|
+
return [];
|
|
331
|
+
if ((0, guards_1.hasScopedEntities)(entities))
|
|
332
|
+
console.warn("[apso] scopeBy is enforced in TypeScript services only; this Python service authenticates requests but does not filter rows by tenant");
|
|
333
|
+
let data;
|
|
334
|
+
if ((0, types_1.isJwtAuth)(auth)) {
|
|
335
|
+
data = { jwt: (0, types_1.resolveJwtAuth)(auth) };
|
|
336
|
+
}
|
|
337
|
+
else if ((0, types_1.isApiKeyAuth)(auth)) {
|
|
338
|
+
const apiKey = (0, guards_1.apiKeyTemplateData)((0, types_1.resolveApiKeyAuth)(auth), entities);
|
|
339
|
+
data = {
|
|
340
|
+
apiKey: { ...apiKey, pyModule: apiKey.apiKeyEntity.toLowerCase() },
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
else {
|
|
344
|
+
throw new Error(`auth.provider "${auth.provider}" is generated for TypeScript services only. For Python, use "jwt" (Better Auth issues JWTs with its JWT plugin) or "api-key".`);
|
|
345
|
+
}
|
|
346
|
+
return [
|
|
347
|
+
{ path: "auth.py", content: await this.renderTemplate("./auth", data) },
|
|
348
|
+
];
|
|
326
349
|
}
|
|
327
350
|
async generateQueryUtils(_entities, _apiType) {
|
|
328
351
|
const queryContent = await this.renderTemplate("./utils/query", {});
|
|
@@ -16,8 +16,9 @@ export declare class TypeScriptGenerator extends BaseGenerator {
|
|
|
16
16
|
generateModule(options: EntityGenerationOptions): Promise<GeneratedFile[]>;
|
|
17
17
|
generateMigration(_options: MigrationGenerationOptions): Promise<GeneratedFile[]>;
|
|
18
18
|
generateEnums(entities: Entity[], apiType: string): Promise<GeneratedFile[]>;
|
|
19
|
-
generateIndexModule(entities: Entity[], apiType: string,
|
|
19
|
+
generateIndexModule(entities: Entity[], apiType: string, opts?: {
|
|
20
20
|
emitEvents?: boolean;
|
|
21
|
+
auth?: AuthConfig;
|
|
21
22
|
}): Promise<GeneratedFile[]>;
|
|
22
23
|
generateGuards(entities: Entity[], auth?: AuthConfig): Promise<GeneratedFile[]>;
|
|
23
24
|
/**
|
|
@@ -109,7 +109,8 @@ class TypeScriptGenerator extends base_1.BaseGenerator {
|
|
|
109
109
|
];
|
|
110
110
|
}
|
|
111
111
|
async generateController(options) {
|
|
112
|
-
|
|
112
|
+
var _a, _b;
|
|
113
|
+
const { entity, relationships, relationshipMap, auth } = options;
|
|
113
114
|
const { name: entityName } = entity;
|
|
114
115
|
const relationshipsForTemplate = (0, relationships_1.getRelationshipForTemplate)(entityName, relationships);
|
|
115
116
|
// Handle nested relationships for joins
|
|
@@ -135,6 +136,12 @@ class TypeScriptGenerator extends base_1.BaseGenerator {
|
|
|
135
136
|
primaryKeyType,
|
|
136
137
|
associations: [...new Set(relationshipsForTemplate)],
|
|
137
138
|
nestedJoinsFile,
|
|
139
|
+
// Direct scopeBy fields @CrudAuth adds to every query once a caller is
|
|
140
|
+
// authenticated. Nested scopes (task.workspaceId) are checked per row by
|
|
141
|
+
// ScopeGuard only.
|
|
142
|
+
crudAuth: auth
|
|
143
|
+
? ((_b = (_a = (0, guards_1.getScopedEntities)([entity])[0]) === null || _a === void 0 ? void 0 : _a.scopes) !== null && _b !== void 0 ? _b : []).filter((s) => s.direct)
|
|
144
|
+
: [],
|
|
138
145
|
};
|
|
139
146
|
const controllerContent = await this.renderTemplate("./rest/controller-rest", data);
|
|
140
147
|
const specContent = await this.renderTemplate("./rest/controller-rest-spec", data);
|
|
@@ -307,8 +314,13 @@ class TypeScriptGenerator extends base_1.BaseGenerator {
|
|
|
307
314
|
},
|
|
308
315
|
];
|
|
309
316
|
}
|
|
310
|
-
async generateIndexModule(entities, apiType,
|
|
311
|
-
|
|
317
|
+
async generateIndexModule(entities, apiType, opts) {
|
|
318
|
+
// generateGuards emits GuardsModule whenever auth or scopeBy is configured;
|
|
319
|
+
// listing it here is what turns its global guards on.
|
|
320
|
+
const content = await this.renderTemplate(`./${apiType}/index-module-${apiType}`, {
|
|
321
|
+
entities,
|
|
322
|
+
hasGuards: Boolean(opts === null || opts === void 0 ? void 0 : opts.auth) || (0, guards_1.hasScopedEntities)(entities),
|
|
323
|
+
});
|
|
312
324
|
return [
|
|
313
325
|
{
|
|
314
326
|
path: "index.ts",
|
|
@@ -319,20 +331,45 @@ class TypeScriptGenerator extends base_1.BaseGenerator {
|
|
|
319
331
|
async generateGuards(entities, auth) {
|
|
320
332
|
const hasScopes = (0, guards_1.hasScopedEntities)(entities);
|
|
321
333
|
const normalizedAuth = this.normalizeAuthConfig(auth);
|
|
322
|
-
|
|
334
|
+
const jwt = auth && (0, auth_1.isJwtAuth)(auth) ? (0, auth_1.resolveJwtAuth)(auth) : undefined;
|
|
335
|
+
const apiKey = auth && (0, auth_1.isApiKeyAuth)(auth)
|
|
336
|
+
? (0, guards_1.apiKeyTemplateData)((0, auth_1.resolveApiKeyAuth)(auth), entities)
|
|
337
|
+
: undefined;
|
|
338
|
+
const hasAuth = Boolean(normalizedAuth || jwt || apiKey);
|
|
339
|
+
if (!hasScopes && !hasAuth) {
|
|
323
340
|
return [];
|
|
324
341
|
}
|
|
325
342
|
const scopedEntities = (0, guards_1.getScopedEntities)(entities);
|
|
326
343
|
const files = [];
|
|
344
|
+
const authEntities = normalizedAuth
|
|
345
|
+
? [
|
|
346
|
+
normalizedAuth.sessionEntity,
|
|
347
|
+
normalizedAuth.userEntity,
|
|
348
|
+
...(normalizedAuth.accountUserEntity
|
|
349
|
+
? [normalizedAuth.accountUserEntity]
|
|
350
|
+
: []),
|
|
351
|
+
]
|
|
352
|
+
: apiKey
|
|
353
|
+
? [apiKey.apiKeyEntity]
|
|
354
|
+
: [];
|
|
327
355
|
const templateData = {
|
|
328
356
|
scopedEntities,
|
|
329
357
|
authConfig: normalizedAuth,
|
|
358
|
+
hasAuth,
|
|
359
|
+
authProvider: auth === null || auth === void 0 ? void 0 : auth.provider,
|
|
360
|
+
authEntities,
|
|
361
|
+
jwt,
|
|
362
|
+
apiKey,
|
|
330
363
|
generatedAt: new Date().toISOString(),
|
|
331
364
|
generatedBy: "Apso CLI",
|
|
332
365
|
};
|
|
333
366
|
// Generate auth.guard.ts if auth is configured
|
|
334
|
-
if (
|
|
335
|
-
const authGuardContent = await this.renderTemplate(
|
|
367
|
+
if (hasAuth) {
|
|
368
|
+
const authGuardContent = await this.renderTemplate(jwt
|
|
369
|
+
? "./guards/jwt-auth.guard.eta"
|
|
370
|
+
: apiKey
|
|
371
|
+
? "./guards/api-key-auth.guard.eta"
|
|
372
|
+
: "./guards/auth.guard.eta", templateData);
|
|
336
373
|
files.push({
|
|
337
374
|
path: "guards/auth.guard.ts",
|
|
338
375
|
content: authGuardContent,
|
package/dist/lib/guards.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Entity } from "./types";
|
|
1
|
+
import { Entity, ResolvedApiKeyAuth } from "./types";
|
|
2
2
|
/**
|
|
3
3
|
* Represents a scope field configuration for template rendering
|
|
4
4
|
*/
|
|
@@ -28,3 +28,13 @@ export declare function getScopedEntities(entities: Entity[]): ScopedEntityConfi
|
|
|
28
28
|
* Checks if any entities have scopeBy configured.
|
|
29
29
|
*/
|
|
30
30
|
export declare function hasScopedEntities(entities: Entity[]): boolean;
|
|
31
|
+
/** API key settings plus what the key entity offers, as the guard templates use them. */
|
|
32
|
+
export interface ApiKeyTemplateData extends ResolvedApiKeyAuth {
|
|
33
|
+
hasStatus: boolean;
|
|
34
|
+
hasExpiresAt: boolean;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Check the API key entity exists and has the hash field, and note whether it
|
|
38
|
+
* has `status` and `expiresAt`, which the guard then enforces.
|
|
39
|
+
*/
|
|
40
|
+
export declare function apiKeyTemplateData(apiKey: ResolvedApiKeyAuth, entities: Entity[]): ApiKeyTemplateData;
|
package/dist/lib/guards.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.hasScopedEntities = exports.getScopedEntities = void 0;
|
|
3
|
+
exports.apiKeyTemplateData = exports.hasScopedEntities = exports.getScopedEntities = void 0;
|
|
4
4
|
const tslib_1 = require("tslib");
|
|
5
5
|
const pluralize_1 = tslib_1.__importDefault(require("pluralize"));
|
|
6
6
|
/**
|
|
@@ -62,3 +62,22 @@ function hasScopedEntities(entities) {
|
|
|
62
62
|
return entities.some((entity) => entity.scopeBy);
|
|
63
63
|
}
|
|
64
64
|
exports.hasScopedEntities = hasScopedEntities;
|
|
65
|
+
/**
|
|
66
|
+
* Check the API key entity exists and has the hash field, and note whether it
|
|
67
|
+
* has `status` and `expiresAt`, which the guard then enforces.
|
|
68
|
+
*/
|
|
69
|
+
function apiKeyTemplateData(apiKey, entities) {
|
|
70
|
+
var _a;
|
|
71
|
+
const entity = entities.find((e) => e.name === apiKey.apiKeyEntity);
|
|
72
|
+
if (!entity)
|
|
73
|
+
throw new Error(`auth.apiKeyEntity "${apiKey.apiKeyEntity}" is not an entity in .apsorc; add it with a "${apiKey.keyField}" text field`);
|
|
74
|
+
const fields = new Set(((_a = entity.fields) !== null && _a !== void 0 ? _a : []).map((f) => f.name));
|
|
75
|
+
if (!fields.has(apiKey.keyField))
|
|
76
|
+
throw new Error(`Entity ${apiKey.apiKeyEntity} needs a "${apiKey.keyField}" text field holding each key's hex SHA-256 (auth.keyField)`);
|
|
77
|
+
return {
|
|
78
|
+
...apiKey,
|
|
79
|
+
hasStatus: fields.has("status"),
|
|
80
|
+
hasExpiresAt: fields.has("expiresAt"),
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
exports.apiKeyTemplateData = apiKeyTemplateData;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { ApsorcType } from "../apsorc-parser";
|
|
2
|
+
export interface SavedPlan {
|
|
3
|
+
before: ApsorcType;
|
|
4
|
+
after: ApsorcType;
|
|
5
|
+
}
|
|
6
|
+
export interface Baseline {
|
|
7
|
+
schema: ApsorcType;
|
|
8
|
+
/** Where the schema came from, for messages. */
|
|
9
|
+
source: string;
|
|
10
|
+
}
|
|
11
|
+
/** Where each language's migrations live, and the extension of one. */
|
|
12
|
+
export declare const migrationsDir: (projectDir: string, apsorc?: ApsorcType) => string;
|
|
13
|
+
export declare const migrationExtension: (apsorc?: ApsorcType) => ".ts" | ".py" | ".sql";
|
|
14
|
+
/** The newest migration with a saved plan, by its timestamp prefix. */
|
|
15
|
+
export declare function latestPlannedMigration(projectDir: string, apsorc?: ApsorcType): {
|
|
16
|
+
file: string;
|
|
17
|
+
planFile: string;
|
|
18
|
+
saved: SavedPlan;
|
|
19
|
+
} | undefined;
|
|
20
|
+
export declare function resolveBaseline(projectDir: string, apsorc: ApsorcType, explicit?: string, planPath?: string): Baseline;
|
|
21
|
+
/** A baseline as a file, for steps that scaffold it into a project copy. */
|
|
22
|
+
export declare function baselineFile(baseline: Baseline): string;
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.baselineFile = exports.resolveBaseline = exports.latestPlannedMigration = exports.migrationExtension = exports.migrationsDir = void 0;
|
|
4
|
+
const tslib_1 = require("tslib");
|
|
5
|
+
/**
|
|
6
|
+
* Find the deployed schema without being told. Every migration
|
|
7
|
+
* `apso migrate generate` writes is committed with its plan, and the plan
|
|
8
|
+
* records the schema before and after it, so the newest one says what the
|
|
9
|
+
* database looks like once the migrations run. A service whose migrations
|
|
10
|
+
* predate this falls back to the .apsorc from the last commit that touched
|
|
11
|
+
* its migrations.
|
|
12
|
+
*/
|
|
13
|
+
const child_process_1 = require("child_process");
|
|
14
|
+
const fs = tslib_1.__importStar(require("fs"));
|
|
15
|
+
const os = tslib_1.__importStar(require("os"));
|
|
16
|
+
const path = tslib_1.__importStar(require("path"));
|
|
17
|
+
/** Where each language's migrations live, and the extension of one. */
|
|
18
|
+
const migrationsDir = (projectDir, apsorc) => (apsorc === null || apsorc === void 0 ? void 0 : apsorc.language) === "python"
|
|
19
|
+
? path.join(projectDir, "alembic", "versions")
|
|
20
|
+
: (apsorc === null || apsorc === void 0 ? void 0 : apsorc.language) === "go"
|
|
21
|
+
? path.join(projectDir, "migrations")
|
|
22
|
+
: path.join(projectDir, (apsorc === null || apsorc === void 0 ? void 0 : apsorc.rootFolder) || "src", "migrations");
|
|
23
|
+
exports.migrationsDir = migrationsDir;
|
|
24
|
+
const migrationExtension = (apsorc) => (apsorc === null || apsorc === void 0 ? void 0 : apsorc.language) === "python"
|
|
25
|
+
? ".py"
|
|
26
|
+
: (apsorc === null || apsorc === void 0 ? void 0 : apsorc.language) === "go"
|
|
27
|
+
? ".sql"
|
|
28
|
+
: ".ts";
|
|
29
|
+
exports.migrationExtension = migrationExtension;
|
|
30
|
+
/** The newest migration with a saved plan, by its timestamp prefix. */
|
|
31
|
+
function latestPlannedMigration(projectDir, apsorc) {
|
|
32
|
+
const dir = (0, exports.migrationsDir)(projectDir, apsorc);
|
|
33
|
+
if (!fs.existsSync(dir))
|
|
34
|
+
return undefined;
|
|
35
|
+
const plans = fs
|
|
36
|
+
.readdirSync(dir)
|
|
37
|
+
.filter((f) => /^\d+[_-].+\.plan\.json$/.test(f))
|
|
38
|
+
.sort((a, b) => Number(b.split(/[_-]/)[0]) - Number(a.split(/[_-]/)[0]));
|
|
39
|
+
for (const planFile of plans) {
|
|
40
|
+
const saved = JSON.parse(fs.readFileSync(path.join(dir, planFile)).toString());
|
|
41
|
+
if (!saved.before || !saved.after)
|
|
42
|
+
continue;
|
|
43
|
+
const file = planFile.replace(/\.plan\.json$/, (0, exports.migrationExtension)(apsorc));
|
|
44
|
+
return {
|
|
45
|
+
file: path.relative(projectDir, path.join(dir, file)),
|
|
46
|
+
planFile: path.relative(projectDir, path.join(dir, planFile)),
|
|
47
|
+
saved,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
return undefined;
|
|
51
|
+
}
|
|
52
|
+
exports.latestPlannedMigration = latestPlannedMigration;
|
|
53
|
+
const git = (cwd, args) => (0, child_process_1.execFileSync)("git", args, {
|
|
54
|
+
cwd,
|
|
55
|
+
encoding: "utf8",
|
|
56
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
57
|
+
});
|
|
58
|
+
function resolveBaseline(projectDir, apsorc, explicit, planPath) {
|
|
59
|
+
if (explicit)
|
|
60
|
+
return {
|
|
61
|
+
schema: JSON.parse(fs.readFileSync(path.resolve(explicit)).toString()),
|
|
62
|
+
source: explicit,
|
|
63
|
+
};
|
|
64
|
+
// A plan in progress remembers the baseline it was started from.
|
|
65
|
+
if (planPath && fs.existsSync(planPath)) {
|
|
66
|
+
const working = JSON.parse(fs.readFileSync(planPath).toString());
|
|
67
|
+
if (working.before)
|
|
68
|
+
return {
|
|
69
|
+
schema: working.before,
|
|
70
|
+
source: path.relative(projectDir, planPath),
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
const latest = latestPlannedMigration(projectDir, apsorc);
|
|
74
|
+
if (latest)
|
|
75
|
+
return { schema: latest.saved.after, source: latest.planFile };
|
|
76
|
+
try {
|
|
77
|
+
const dir = path.relative(projectDir, (0, exports.migrationsDir)(projectDir, apsorc));
|
|
78
|
+
const sha = git(projectDir, ["log", "-1", "--format=%H", "--", dir]).trim();
|
|
79
|
+
if (sha) {
|
|
80
|
+
// "<sha>:./.apsorc" is relative to cwd, so symlinked paths don't matter.
|
|
81
|
+
const schema = JSON.parse(git(projectDir, ["show", `${sha}:./.apsorc`]));
|
|
82
|
+
return {
|
|
83
|
+
schema,
|
|
84
|
+
source: `.apsorc at ${sha.slice(0, 7)}, the last commit that changed ${dir}`,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
// No git, no history for the migrations, or no .apsorc in that commit.
|
|
90
|
+
}
|
|
91
|
+
throw new Error("Can't tell which schema is deployed: no migration from apso migrate generate yet, and no git history for the migrations folder. Pass the deployed .apsorc once with --baseline; later runs find it on their own.");
|
|
92
|
+
}
|
|
93
|
+
exports.resolveBaseline = resolveBaseline;
|
|
94
|
+
/** A baseline as a file, for steps that scaffold it into a project copy. */
|
|
95
|
+
function baselineFile(baseline) {
|
|
96
|
+
const file = path.join(fs.mkdtempSync(path.join(os.tmpdir(), "apso-baseline-")), ".apsorc");
|
|
97
|
+
fs.writeFileSync(file, JSON.stringify(baseline.schema, null, 2));
|
|
98
|
+
return file;
|
|
99
|
+
}
|
|
100
|
+
exports.baselineFile = baselineFile;
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
interface PlanField {
|
|
2
|
+
name: string;
|
|
3
|
+
type: string;
|
|
4
|
+
values?: string[];
|
|
5
|
+
nullable?: boolean;
|
|
6
|
+
default?: string | null;
|
|
7
|
+
}
|
|
8
|
+
interface PlanEntity {
|
|
9
|
+
name: string;
|
|
10
|
+
fields?: PlanField[];
|
|
11
|
+
}
|
|
12
|
+
interface PlanRelationship {
|
|
13
|
+
from: string;
|
|
14
|
+
to: string;
|
|
15
|
+
type: string;
|
|
16
|
+
}
|
|
17
|
+
export interface PlanSchema {
|
|
18
|
+
entities: PlanEntity[];
|
|
19
|
+
relationships?: PlanRelationship[];
|
|
20
|
+
}
|
|
21
|
+
export type QuestionKind = "removedField" | "removedEntity" | "enumValue" | "backfill" | "cast" | "relation";
|
|
22
|
+
export interface Question {
|
|
23
|
+
id: string;
|
|
24
|
+
kind: QuestionKind;
|
|
25
|
+
/** What changed, in words, for the agent and the person reading the plan. */
|
|
26
|
+
subject: string;
|
|
27
|
+
/** Allowed answers. "sql" and "split:..." also need `sql`. */
|
|
28
|
+
options: string[];
|
|
29
|
+
/** Set only when exactly one reading fits; still needs an answer. */
|
|
30
|
+
suggested?: string;
|
|
31
|
+
answer?: string;
|
|
32
|
+
/** A SQL expression, or one per target field for a split. */
|
|
33
|
+
sql?: string | Record<string, string>;
|
|
34
|
+
}
|
|
35
|
+
export interface Plan {
|
|
36
|
+
version: 1;
|
|
37
|
+
from: string;
|
|
38
|
+
to: string;
|
|
39
|
+
questions: Question[];
|
|
40
|
+
}
|
|
41
|
+
export declare const hashSchema: (s: PlanSchema) => string;
|
|
42
|
+
/** Every answer this question will accept is valid; anything else stays open. */
|
|
43
|
+
export declare function isAnswered(q: Question): boolean;
|
|
44
|
+
export declare const openQuestions: (plan: Plan) => Question[];
|
|
45
|
+
export declare function planMigration(before: PlanSchema, after: PlanSchema, previous?: Plan): Plan;
|
|
46
|
+
/**
|
|
47
|
+
* Plan with --answer values (<question id>=<option>) applied on top of the
|
|
48
|
+
* plan in progress. Throws a message for the user when an answer is malformed,
|
|
49
|
+
* names no question, or is not one of its options.
|
|
50
|
+
*/
|
|
51
|
+
export declare function planWithAnswers(before: PlanSchema, after: PlanSchema, previous: Plan | undefined, answers: string[]): Plan;
|
|
52
|
+
export {};
|