@apso/cli 0.42.0 → 0.43.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/dist/commands/migrate/generate.js +4 -0
- package/dist/commands/migrate/verify.js +4 -0
- package/dist/commands/test/smoke.js +9 -8
- package/dist/lib/apsorc-parser.d.ts +11 -0
- package/dist/lib/apsorc-parser.js +41 -3
- package/dist/lib/generators/go.js +3 -0
- package/dist/lib/generators/python.js +5 -0
- package/dist/lib/templates/go/models/model.eta +2 -2
- package/dist/lib/templates/python/models/model.eta +8 -8
- package/dist/lib/verify/crud-plan.d.ts +27 -0
- package/dist/lib/verify/crud-plan.js +105 -0
- package/dist/lib/verify/index.d.ts +25 -10
- package/dist/lib/verify/index.js +256 -55
- package/npm-shrinkwrap.json +2 -2
- package/oclif.manifest.json +3 -3
- package/package.json +1 -1
|
@@ -4,10 +4,14 @@ const tslib_1 = require("tslib");
|
|
|
4
4
|
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
|
+
const apsorc_parser_1 = require("../../lib/apsorc-parser");
|
|
7
8
|
const verify_1 = require("../../lib/verify");
|
|
8
9
|
class MigrateGenerate extends base_command_1.default {
|
|
9
10
|
async run() {
|
|
10
11
|
const { flags } = await this.parse(MigrateGenerate);
|
|
12
|
+
const unsupported = (0, verify_1.migrateUnsupported)((0, apsorc_parser_1.parseApsorc)().language);
|
|
13
|
+
if (unsupported)
|
|
14
|
+
this.error(unsupported);
|
|
11
15
|
const result = await (0, verify_1.generateMigration)(process.cwd(), path.resolve(flags.baseline), flags.name, (0, verify_1.cliBinPath)(this.config.root));
|
|
12
16
|
const summary = result.needed
|
|
13
17
|
? `Created ${result.file}`
|
|
@@ -4,10 +4,14 @@ const tslib_1 = require("tslib");
|
|
|
4
4
|
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
|
+
const apsorc_parser_1 = require("../../lib/apsorc-parser");
|
|
7
8
|
const verify_1 = require("../../lib/verify");
|
|
8
9
|
class MigrateVerify extends base_command_1.default {
|
|
9
10
|
async run() {
|
|
10
11
|
const { flags } = await this.parse(MigrateVerify);
|
|
12
|
+
const unsupported = (0, verify_1.migrateUnsupported)((0, apsorc_parser_1.parseApsorc)().language);
|
|
13
|
+
if (unsupported)
|
|
14
|
+
this.error(unsupported);
|
|
11
15
|
const result = await (0, verify_1.verifyMigration)(process.cwd(), path.resolve(flags.baseline), flags.migration, (0, verify_1.cliBinPath)(this.config.root), flags["skip-build"]);
|
|
12
16
|
(0, verify_1.report)((s) => this.log(s), result, flags.json, result.ok ? "Migration verified." : undefined);
|
|
13
17
|
if (!result.ok)
|
|
@@ -7,23 +7,24 @@ const apsorc_parser_1 = require("../../lib/apsorc-parser");
|
|
|
7
7
|
const verify_1 = require("../../lib/verify");
|
|
8
8
|
class TestSmoke extends base_command_1.default {
|
|
9
9
|
async run() {
|
|
10
|
+
var _a;
|
|
10
11
|
const { flags } = await this.parse(TestSmoke);
|
|
11
|
-
const { entities, auth } = (0, apsorc_parser_1.parseApsorc)();
|
|
12
|
+
const { entities, relationshipMap, auth, language } = (0, apsorc_parser_1.parseApsorc)();
|
|
12
13
|
// With auth configured every entity route needs a session, so only /health is checked.
|
|
13
|
-
const
|
|
14
|
-
const
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
(0, verify_1.report)((s) => this.log(s), result, flags.json, result.ok ?
|
|
14
|
+
const result = await (0, verify_1.smokeTest)(process.cwd(), auth ? [] : entities, relationshipMap, { skipBuild: flags["skip-build"], language });
|
|
15
|
+
const summary = ((_a = result.steps[0]) === null || _a === void 0 ? void 0 : _a.name) === "skipped"
|
|
16
|
+
? "Smoke test skipped."
|
|
17
|
+
: "Smoke test passed.";
|
|
18
|
+
(0, verify_1.report)((s) => this.log(s), result, flags.json, result.ok ? summary : undefined);
|
|
18
19
|
if (!result.ok)
|
|
19
20
|
this.exit(1);
|
|
20
21
|
}
|
|
21
22
|
}
|
|
22
|
-
TestSmoke.description = "Start the service on a local PGlite database and
|
|
23
|
+
TestSmoke.description = "Start the service and call /health, every entity's list endpoint, and a create/read/delete of one row per entity. TypeScript services run on a local PGlite database; Python and Go services run on the database in DATABASE_URL (rows are created and deleted there, tables are not created) and the check is skipped when none answers";
|
|
23
24
|
TestSmoke.examples = [`$ apso test smoke`];
|
|
24
25
|
TestSmoke.flags = {
|
|
25
26
|
"skip-build": core_1.Flags.boolean({
|
|
26
|
-
description: "Use the existing dist/ build instead of building first",
|
|
27
|
+
description: "Use the existing dist/ build instead of building first (TypeScript)",
|
|
27
28
|
default: false,
|
|
28
29
|
}),
|
|
29
30
|
json: core_1.Flags.boolean({
|
|
@@ -71,6 +71,17 @@ export declare class ApsorcNamingError extends Error {
|
|
|
71
71
|
* schema builder runs the same rules, so a schema that passes there passes here.
|
|
72
72
|
*/
|
|
73
73
|
export declare const assertCodegenSafeNames: (apsorc: Pick<ApsorcType, "entities" | "relationships">) => void;
|
|
74
|
+
/**
|
|
75
|
+
* Drop indexes the database cannot create alongside another. TypeORM names an
|
|
76
|
+
* index from its table and columns only, so a plain and a unique index on the
|
|
77
|
+
* same columns get one name and the second CREATE fails, rolling back the
|
|
78
|
+
* schema (prod svc 52: Warehouse.manager_email). The unique index also serves
|
|
79
|
+
* lookups, so it is kept; exact duplicates are dropped too.
|
|
80
|
+
*
|
|
81
|
+
* @param {Entity} entity - An entity from .apsorc.
|
|
82
|
+
* @returns {Entity} The entity, with a filtered copy of its indexes when needed.
|
|
83
|
+
*/
|
|
84
|
+
export declare const dedupeIndexes: (entity: Entity) => Entity;
|
|
74
85
|
export declare const parseApsorcV2: (apsorc: ApsorcType) => ParsedApsorcData;
|
|
75
86
|
export declare const parseApsorc: () => ParsedApsorc;
|
|
76
87
|
/**
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.findConfigPath = exports.readApsorcFile = exports.parseApsorc = exports.parseApsorcV2 = exports.assertCodegenSafeNames = exports.ApsorcNamingError = exports.parseApsorcV1 = exports.ApiType = void 0;
|
|
3
|
+
exports.findConfigPath = exports.readApsorcFile = exports.parseApsorc = exports.parseApsorcV2 = exports.dedupeIndexes = exports.assertCodegenSafeNames = exports.ApsorcNamingError = exports.parseApsorcV1 = exports.ApiType = 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"));
|
|
@@ -55,11 +55,46 @@ const assertCodegenSafeNames = (apsorc) => {
|
|
|
55
55
|
throw new ApsorcNamingError(`${error.message}${more > 0 ? ` (${more} more error${more === 1 ? "" : "s"})` : ""} Run "apso schema lint" to see every issue, or "apso schema lint --fix" to apply the safe fixes.`, error.rule, error.entity || "", error.field);
|
|
56
56
|
};
|
|
57
57
|
exports.assertCodegenSafeNames = assertCodegenSafeNames;
|
|
58
|
+
const columns = (fields) => fields.join(",");
|
|
59
|
+
/**
|
|
60
|
+
* Drop indexes the database cannot create alongside another. TypeORM names an
|
|
61
|
+
* index from its table and columns only, so a plain and a unique index on the
|
|
62
|
+
* same columns get one name and the second CREATE fails, rolling back the
|
|
63
|
+
* schema (prod svc 52: Warehouse.manager_email). The unique index also serves
|
|
64
|
+
* lookups, so it is kept; exact duplicates are dropped too.
|
|
65
|
+
*
|
|
66
|
+
* @param {Entity} entity - An entity from .apsorc.
|
|
67
|
+
* @returns {Entity} The entity, with a filtered copy of its indexes when needed.
|
|
68
|
+
*/
|
|
69
|
+
const dedupeIndexes = (entity) => {
|
|
70
|
+
var _a;
|
|
71
|
+
if (!((_a = entity.indexes) === null || _a === void 0 ? void 0 : _a.length))
|
|
72
|
+
return entity;
|
|
73
|
+
const uniqueColumns = new Set(entity.indexes
|
|
74
|
+
.filter((index) => index.unique)
|
|
75
|
+
.map((index) => columns(index.fields)));
|
|
76
|
+
const seen = new Set();
|
|
77
|
+
const indexes = entity.indexes.filter((index) => {
|
|
78
|
+
const key = `${columns(index.fields)}|${index.unique ? "unique" : ""}`;
|
|
79
|
+
if (seen.has(key) ||
|
|
80
|
+
(!index.unique && uniqueColumns.has(columns(index.fields))))
|
|
81
|
+
return false;
|
|
82
|
+
seen.add(key);
|
|
83
|
+
return true;
|
|
84
|
+
});
|
|
85
|
+
return indexes.length === entity.indexes.length
|
|
86
|
+
? entity
|
|
87
|
+
: { ...entity, indexes };
|
|
88
|
+
};
|
|
89
|
+
exports.dedupeIndexes = dedupeIndexes;
|
|
58
90
|
const parseApsorcV2 = (apsorc) => {
|
|
59
91
|
const { entities, relationships: apsoRelationships } = apsorc;
|
|
60
92
|
(0, exports.assertCodegenSafeNames)(apsorc);
|
|
61
93
|
const relationshipMap = (0, relationships_1.parseRelationships)(apsoRelationships);
|
|
62
|
-
return {
|
|
94
|
+
return {
|
|
95
|
+
entities: entities.map((entity) => (0, exports.dedupeIndexes)(entity)),
|
|
96
|
+
relationshipMap,
|
|
97
|
+
};
|
|
63
98
|
};
|
|
64
99
|
exports.parseApsorcV2 = parseApsorcV2;
|
|
65
100
|
const parseRc = () => {
|
|
@@ -156,7 +191,10 @@ const readApsorcFile = () => {
|
|
|
156
191
|
if (!configPath) {
|
|
157
192
|
throw new Error("No .apsorc found in this directory or any parent.");
|
|
158
193
|
}
|
|
159
|
-
return {
|
|
194
|
+
return {
|
|
195
|
+
configPath,
|
|
196
|
+
apsorc: JSON.parse(fs.readFileSync(configPath).toString()),
|
|
197
|
+
};
|
|
160
198
|
};
|
|
161
199
|
exports.readApsorcFile = readApsorcFile;
|
|
162
200
|
const findConfigPath = () => {
|
|
@@ -156,6 +156,9 @@ class GoGenerator extends base_1.BaseGenerator {
|
|
|
156
156
|
createPrimaryKey,
|
|
157
157
|
primaryKeyType,
|
|
158
158
|
snakeCasedName: (0, casing_1.snakeCase)(name),
|
|
159
|
+
// Honor an explicit per-entity `table` override; otherwise derive from
|
|
160
|
+
// the entity name. See apsoai/cli#107 (Python/Go parity with #98).
|
|
161
|
+
tableName: entity.table || (0, casing_1.snakeCase)(name),
|
|
159
162
|
createdAt,
|
|
160
163
|
updatedAt,
|
|
161
164
|
pluralizedName: (0, casing_1.camelCase)((0, pluralize_1.default)(name)),
|
|
@@ -99,6 +99,11 @@ class PythonGenerator extends base_1.BaseGenerator {
|
|
|
99
99
|
createPrimaryKey,
|
|
100
100
|
primaryKeyType,
|
|
101
101
|
snakeCasedName: (0, casing_1.snakeCase)(name),
|
|
102
|
+
// Honor an explicit per-entity `table` override; otherwise derive from
|
|
103
|
+
// the entity name. See apsoai/cli#107 (Python/Go parity with #98).
|
|
104
|
+
tableName: entity.table || (0, casing_1.snakeCase)(name),
|
|
105
|
+
// Foreign keys name the referenced entity's real table, override included.
|
|
106
|
+
tableNames: Object.fromEntries(allEntities.map((e) => [e.name, e.table || (0, casing_1.snakeCase)(e.name)])),
|
|
102
107
|
createdAt,
|
|
103
108
|
updatedAt,
|
|
104
109
|
pluralizedName: (0, casing_1.camelCase)((0, pluralize_1.default)(name)),
|
|
@@ -9,7 +9,7 @@ import (
|
|
|
9
9
|
<% } %>
|
|
10
10
|
)
|
|
11
11
|
|
|
12
|
-
// <%= it.name %> represents the <%= it.
|
|
12
|
+
// <%= it.name %> represents the <%= it.tableName %> table
|
|
13
13
|
type <%= it.name %> struct {
|
|
14
14
|
<% if (it.createPrimaryKey) { %>
|
|
15
15
|
<% if (it.primaryKeyType === 'uuid') { %>
|
|
@@ -48,5 +48,5 @@ type <%= it.name %> struct {
|
|
|
48
48
|
|
|
49
49
|
// TableName overrides the table name
|
|
50
50
|
func (<%= it.name %>) TableName() string {
|
|
51
|
-
return "<%= it.
|
|
51
|
+
return "<%= it.tableName %>"
|
|
52
52
|
}
|
|
@@ -25,11 +25,11 @@ if TYPE_CHECKING:
|
|
|
25
25
|
|
|
26
26
|
<% /* Generate ManyToMany junction tables */ %>
|
|
27
27
|
<% it.associations.filter(a => a.type === 'ManyToMany' && a.joinTable).forEach((assoc) => { %>
|
|
28
|
-
<%= assoc.joinTableName || `${it.
|
|
29
|
-
"<%= assoc.joinTableName || `${it.
|
|
28
|
+
<%= assoc.joinTableName || `${it.tableName}_${assoc.name.toLowerCase()}` %> = Table(
|
|
29
|
+
"<%= assoc.joinTableName || `${it.tableName}_${assoc.name.toLowerCase()}` %>",
|
|
30
30
|
Base.metadata,
|
|
31
|
-
Column("<%= assoc.joinColumnName || `${it.
|
|
32
|
-
Column("<%= assoc.inverseJoinColumnName || `${assoc.name.toLowerCase()}_id` %>", <% if (assoc.referencedEntityPrimaryKeyType === 'uuid') { %>PGUUID(as_uuid=True)<% } else { %>Integer<% } %>, ForeignKey("<%= assoc.name.toLowerCase() %>.id"), primary_key=True),
|
|
31
|
+
Column("<%= assoc.joinColumnName || `${it.tableName}_id` %>", <% if (it.primaryKeyType === 'uuid') { %>PGUUID(as_uuid=True)<% } else { %>Integer<% } %>, ForeignKey("<%= it.tableName %>.id"), primary_key=True),
|
|
32
|
+
Column("<%= assoc.inverseJoinColumnName || `${assoc.name.toLowerCase()}_id` %>", <% if (assoc.referencedEntityPrimaryKeyType === 'uuid') { %>PGUUID(as_uuid=True)<% } else { %>Integer<% } %>, ForeignKey("<%= it.tableNames[assoc.name] || assoc.name.toLowerCase() %>.id"), primary_key=True),
|
|
33
33
|
)
|
|
34
34
|
<% }) %>
|
|
35
35
|
|
|
@@ -37,13 +37,13 @@ class <%= it.name %>(Base):
|
|
|
37
37
|
"""
|
|
38
38
|
SQLAlchemy model for <%= it.name %>
|
|
39
39
|
"""
|
|
40
|
-
__tablename__ = "<%= it.
|
|
40
|
+
__tablename__ = "<%= it.tableName %>"
|
|
41
41
|
|
|
42
42
|
<% /* Generate table arguments for indexes and unique constraints */ %>
|
|
43
43
|
<% if ((it.indexes && it.indexes.length > 0) || (it.uniques && it.uniques.length > 0)) { %>
|
|
44
44
|
__table_args__ = (
|
|
45
45
|
<% it.indexes.forEach((index) => { %>
|
|
46
|
-
Index("<%= index.name || `idx_${it.
|
|
46
|
+
Index("<%= index.name || `idx_${it.tableName}_${index.fields.join('_')}` %>", <%~ index.fields.map(f => `"${f}"`).join(', ') %><% if (index.unique) { %>, unique=True<% } %>),
|
|
47
47
|
<% }) %>
|
|
48
48
|
<% it.uniques.forEach((unique) => { %>
|
|
49
49
|
UniqueConstraint(<%~ unique.fields.map(f => `"${f}"`).join(', ') %><% if (unique.name) { %>, name="<%= unique.name %>"<% } %>),
|
|
@@ -93,7 +93,7 @@ columnsToProcess.forEach((field) => { -%>
|
|
|
93
93
|
# Foreign key for <%= assoc.name %>
|
|
94
94
|
<%= assoc.camelCasedId %>: Mapped[<% if (assoc.referencedEntityPrimaryKeyType === 'uuid') { %>Optional[UUID]<% } else { %>Optional[int]<% } %>] = mapped_column(
|
|
95
95
|
<% if (assoc.referencedEntityPrimaryKeyType === 'uuid') { %>PGUUID(as_uuid=True)<% } else { %>Integer<% } %>,
|
|
96
|
-
ForeignKey("<%= assoc.name.toLowerCase() %>.id"<% if (assoc.cascadeDelete) { %>, ondelete="CASCADE"<% } %>),
|
|
96
|
+
ForeignKey("<%= it.tableNames[assoc.name] || assoc.name.toLowerCase() %>.id"<% if (assoc.cascadeDelete) { %>, ondelete="CASCADE"<% } %>),
|
|
97
97
|
nullable=<%= assoc.nullable ? 'True' : 'False' %><% if (assoc.index) { %>,
|
|
98
98
|
index=True<% } %>
|
|
99
99
|
)
|
|
@@ -105,7 +105,7 @@ columnsToProcess.forEach((field) => { -%>
|
|
|
105
105
|
<% } else if (assoc.type === 'ManyToMany') { %>
|
|
106
106
|
<%= assoc.pluralizedRelationshipName %>: Mapped[List["<%= assoc.name %>"]] = relationship(
|
|
107
107
|
"<%= assoc.name %>",
|
|
108
|
-
secondary=<%= assoc.joinTableName || `${it.
|
|
108
|
+
secondary=<%= assoc.joinTableName || `${it.tableName}_${assoc.name.toLowerCase()}` %>,
|
|
109
109
|
<% if (assoc.biDirectional) { %>back_populates="<%= assoc.inverseSidePropertyName %>",<% } %>
|
|
110
110
|
lazy="selectin"
|
|
111
111
|
)
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the smoke test creates, reads and deletes: one row per entity, built
|
|
3
|
+
* from the .apsorc, in an order where every required parent exists first.
|
|
4
|
+
*/
|
|
5
|
+
import { Entity } from "../types/entity";
|
|
6
|
+
import { Field } from "../types/field";
|
|
7
|
+
import { RelationshipMap } from "../types/relationship";
|
|
8
|
+
/** A value for a required field, or undefined when its type can't be filled. */
|
|
9
|
+
export declare function sampleValue(field: Field): unknown;
|
|
10
|
+
export interface PlannedEntity {
|
|
11
|
+
name: string;
|
|
12
|
+
payload: Record<string, unknown>;
|
|
13
|
+
/** Foreign keys to fill from parents created earlier: property -> parent entity. */
|
|
14
|
+
parents: {
|
|
15
|
+
property: string;
|
|
16
|
+
entity: string;
|
|
17
|
+
}[];
|
|
18
|
+
}
|
|
19
|
+
export interface CrudPlan {
|
|
20
|
+
/** Parents before children. */
|
|
21
|
+
order: PlannedEntity[];
|
|
22
|
+
skipped: {
|
|
23
|
+
name: string;
|
|
24
|
+
reason: string;
|
|
25
|
+
}[];
|
|
26
|
+
}
|
|
27
|
+
export declare function planCrud(entities: Entity[], relationships: RelationshipMap): CrudPlan;
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* What the smoke test creates, reads and deletes: one row per entity, built
|
|
4
|
+
* from the .apsorc, in an order where every required parent exists first.
|
|
5
|
+
*/
|
|
6
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
7
|
+
exports.planCrud = exports.sampleValue = void 0;
|
|
8
|
+
const crypto_1 = require("crypto");
|
|
9
|
+
const relationships_1 = require("../utils/relationships");
|
|
10
|
+
const DATE = "2024-01-01T00:00:00.000Z";
|
|
11
|
+
/** A value for a required field, or undefined when its type can't be filled. */
|
|
12
|
+
function sampleValue(field) {
|
|
13
|
+
var _a, _b;
|
|
14
|
+
if (field.is_email)
|
|
15
|
+
return "smoke@example.com";
|
|
16
|
+
switch (field.type) {
|
|
17
|
+
case "text":
|
|
18
|
+
case "string":
|
|
19
|
+
case "varchar":
|
|
20
|
+
return "smoke".slice(0, Math.min(5, (_a = field.length) !== null && _a !== void 0 ? _a : 5));
|
|
21
|
+
case "integer":
|
|
22
|
+
case "int":
|
|
23
|
+
case "bigint":
|
|
24
|
+
return 1;
|
|
25
|
+
case "decimal":
|
|
26
|
+
case "numeric":
|
|
27
|
+
case "float":
|
|
28
|
+
return 1.5;
|
|
29
|
+
case "boolean":
|
|
30
|
+
return true;
|
|
31
|
+
case "date":
|
|
32
|
+
case "timestamp":
|
|
33
|
+
case "timestamptz":
|
|
34
|
+
case "datetime":
|
|
35
|
+
return DATE;
|
|
36
|
+
case "enum":
|
|
37
|
+
return (_b = field.values) === null || _b === void 0 ? void 0 : _b[0];
|
|
38
|
+
case "uuid":
|
|
39
|
+
return (0, crypto_1.randomUUID)();
|
|
40
|
+
case "json":
|
|
41
|
+
case "jsonb":
|
|
42
|
+
case "json-plain":
|
|
43
|
+
return {};
|
|
44
|
+
default:
|
|
45
|
+
return undefined;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
exports.sampleValue = sampleValue;
|
|
49
|
+
/** Required = not nullable, no default, and not a user-declared primary key. */
|
|
50
|
+
const required = (field) => !field.nullable &&
|
|
51
|
+
(field.default === undefined || field.default === null) &&
|
|
52
|
+
!field.primary;
|
|
53
|
+
function planCrud(entities, relationships) {
|
|
54
|
+
const pending = [];
|
|
55
|
+
const skipped = [];
|
|
56
|
+
for (const entity of entities) {
|
|
57
|
+
const payload = {};
|
|
58
|
+
const bad = (entity.fields || [])
|
|
59
|
+
.filter((field) => required(field))
|
|
60
|
+
.find((field) => {
|
|
61
|
+
payload[field.name] = sampleValue(field);
|
|
62
|
+
return payload[field.name] === undefined;
|
|
63
|
+
});
|
|
64
|
+
if (bad) {
|
|
65
|
+
skipped.push({
|
|
66
|
+
name: entity.name,
|
|
67
|
+
reason: `required field "${bad.name}" has type "${bad.type}", which the smoke test can't fill`,
|
|
68
|
+
});
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
const parents = (relationships[entity.name] || [])
|
|
72
|
+
.filter((r) => r.type === "ManyToOne" && !r.nullable)
|
|
73
|
+
.map((r) => ({ property: (0, relationships_1.getRelationshipIdField)(r), entity: r.name }));
|
|
74
|
+
pending.push({ name: entity.name, payload, parents });
|
|
75
|
+
}
|
|
76
|
+
// Topological order: take entities whose required parents are all planned.
|
|
77
|
+
const order = [];
|
|
78
|
+
const planned = new Set();
|
|
79
|
+
let progress = true;
|
|
80
|
+
while (progress) {
|
|
81
|
+
progress = false;
|
|
82
|
+
for (const p of pending) {
|
|
83
|
+
if (planned.has(p.name))
|
|
84
|
+
continue;
|
|
85
|
+
if (p.parents.every((parent) => planned.has(parent.entity))) {
|
|
86
|
+
order.push(p);
|
|
87
|
+
planned.add(p.name);
|
|
88
|
+
progress = true;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
for (const p of pending) {
|
|
93
|
+
if (planned.has(p.name))
|
|
94
|
+
continue;
|
|
95
|
+
const missing = p.parents
|
|
96
|
+
.map((parent) => parent.entity)
|
|
97
|
+
.filter((name) => !planned.has(name));
|
|
98
|
+
skipped.push({
|
|
99
|
+
name: p.name,
|
|
100
|
+
reason: `needs a ${missing.join(", ")} row first, which can't be created (a cycle of required relationships or a skipped entity)`,
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
return { order, skipped };
|
|
104
|
+
}
|
|
105
|
+
exports.planCrud = planCrud;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Pre-deploy checks for a generated
|
|
3
|
-
*
|
|
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
4
|
* and smoke test the endpoints. The CLI and the in-browser editor (which runs
|
|
5
5
|
* the CLI in a WebContainer) share these steps.
|
|
6
6
|
*/
|
|
@@ -13,6 +13,9 @@
|
|
|
13
13
|
/// <reference types="node" />
|
|
14
14
|
/// <reference types="node" />
|
|
15
15
|
/// <reference types="node" />
|
|
16
|
+
import { Entity } from "../types/entity";
|
|
17
|
+
import { TargetLanguage } from "../types/generator";
|
|
18
|
+
import { RelationshipMap } from "../types/relationship";
|
|
16
19
|
export interface Step {
|
|
17
20
|
name: string;
|
|
18
21
|
ok: boolean;
|
|
@@ -30,6 +33,12 @@ export declare const pgliteEnv: (dataDir: string) => NodeJS.ProcessEnv;
|
|
|
30
33
|
* it, and schema:sync.
|
|
31
34
|
*/
|
|
32
35
|
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;
|
|
33
42
|
export interface GenerateResult {
|
|
34
43
|
ok: boolean;
|
|
35
44
|
needed: boolean;
|
|
@@ -48,16 +57,22 @@ export declare function supportsPglite(projectDir: string): boolean;
|
|
|
48
57
|
* database matches the project's entities (a second generate finds nothing).
|
|
49
58
|
*/
|
|
50
59
|
export declare function verifyMigration(projectDir: string, baselineApsorc: string, migrationFile: string, cliBin: string, skipBuild?: boolean): Promise<CheckResult>;
|
|
60
|
+
/** The route each language's generated controller serves an entity on. */
|
|
61
|
+
export declare const entityRoute: (language: TargetLanguage, name: string) => string;
|
|
62
|
+
export interface SmokeOptions {
|
|
63
|
+
skipBuild?: boolean;
|
|
64
|
+
timeoutMs?: number;
|
|
65
|
+
language?: TargetLanguage;
|
|
66
|
+
}
|
|
51
67
|
/**
|
|
52
|
-
* Start the
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
68
|
+
* Start the service and call GET /health, every entity's list endpoint, and a
|
|
69
|
+
* create/read/delete of one row per entity. TypeScript services run on a
|
|
70
|
+
* PGlite database synced to their entities; Python and Go services run on the
|
|
71
|
+
* database in DATABASE_URL.
|
|
56
72
|
*/
|
|
57
|
-
export declare function smokeTest(projectDir: string,
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
}): Promise<CheckResult>;
|
|
73
|
+
export declare function smokeTest(projectDir: string, entities: Entity[], relationships?: RelationshipMap, { skipBuild, timeoutMs, language, }?: SmokeOptions): Promise<CheckResult>;
|
|
74
|
+
/** KEY=value lines of the project's .env; no interpolation. */
|
|
75
|
+
export declare function readDotEnv(projectDir: string): Record<string, string>;
|
|
61
76
|
/** The CLI's own entry point, for running `generate` in the baseline copy. */
|
|
62
77
|
export declare const cliBinPath: (root: string) => string;
|
|
63
78
|
/** Print a check's steps (or JSON) through the command's logger. */
|
package/dist/lib/verify/index.js
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
/**
|
|
3
|
-
* Pre-deploy checks for a generated
|
|
4
|
-
*
|
|
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
5
|
* and smoke test the 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.smokeTest = exports.verifyMigration = exports.supportsPglite = exports.generateMigration = exports.buildBaseline = exports.pgliteEnv = exports.run = void 0;
|
|
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;
|
|
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,6 +15,8 @@ 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 casing_1 = require("../utils/casing");
|
|
19
|
+
const crud_plan_1 = require("./crud-plan");
|
|
18
20
|
// eslint-disable-next-line no-control-regex
|
|
19
21
|
const plain = (s) => s.replace(/\u001B\[[\d;]*m/g, "");
|
|
20
22
|
const tail = (s, n = 4000) => {
|
|
@@ -83,6 +85,19 @@ async function buildBaseline(projectDir, baselineApsorc, dataDir, cliBin) {
|
|
|
83
85
|
}
|
|
84
86
|
}
|
|
85
87
|
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;
|
|
86
101
|
/** Generate a migration from the deployed schema (baselineApsorc) to the project's entities. */
|
|
87
102
|
async function generateMigration(projectDir, baselineApsorc, name, cliBin) {
|
|
88
103
|
const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "apso-pglite-"));
|
|
@@ -199,93 +214,279 @@ const freePort = () => new Promise((resolve, reject) => {
|
|
|
199
214
|
});
|
|
200
215
|
s.on("error", reject);
|
|
201
216
|
});
|
|
202
|
-
const
|
|
217
|
+
const request = (method, url, body) => new Promise((resolve) => {
|
|
218
|
+
const data = body === undefined ? undefined : JSON.stringify(body);
|
|
219
|
+
const headers = data
|
|
220
|
+
? {
|
|
221
|
+
"content-type": "application/json",
|
|
222
|
+
"content-length": Buffer.byteLength(data),
|
|
223
|
+
}
|
|
224
|
+
: {};
|
|
203
225
|
http
|
|
204
|
-
.
|
|
205
|
-
let
|
|
226
|
+
.request(url, { method, headers, timeout: 15000 }, (res) => {
|
|
227
|
+
let text = "";
|
|
206
228
|
res.on("data", (d) => {
|
|
207
|
-
|
|
229
|
+
text += d;
|
|
208
230
|
});
|
|
209
|
-
res.on("end", () => { var _a; return resolve({ status: (_a = res.statusCode) !== null && _a !== void 0 ? _a : 0, body }); });
|
|
231
|
+
res.on("end", () => { var _a; return resolve({ status: (_a = res.statusCode) !== null && _a !== void 0 ? _a : 0, body: text }); });
|
|
210
232
|
})
|
|
211
233
|
.on("timeout", function () {
|
|
212
234
|
this.destroy(new Error("No response within 15s"));
|
|
213
235
|
})
|
|
214
|
-
.on("error", (e) => resolve({ status: 0, body: e.message }))
|
|
236
|
+
.on("error", (e) => resolve({ status: 0, body: e.message }))
|
|
237
|
+
.end(data);
|
|
215
238
|
});
|
|
239
|
+
const get = (url) => request("GET", url);
|
|
240
|
+
const expectStatus = (res, ...ok) => ({
|
|
241
|
+
ok: ok.includes(res.status),
|
|
242
|
+
output: ok.includes(res.status)
|
|
243
|
+
? undefined
|
|
244
|
+
: `${res.status} ${tail(res.body, 500)}`,
|
|
245
|
+
});
|
|
246
|
+
const idOf = (body) => {
|
|
247
|
+
var _a;
|
|
248
|
+
try {
|
|
249
|
+
return (_a = JSON.parse(body).id) !== null && _a !== void 0 ? _a : undefined;
|
|
250
|
+
}
|
|
251
|
+
catch {
|
|
252
|
+
return undefined;
|
|
253
|
+
}
|
|
254
|
+
};
|
|
255
|
+
/** The route each language's generated controller serves an entity on. */
|
|
256
|
+
const entityRoute = (language, name) => {
|
|
257
|
+
const plural = (0, pluralize_1.default)(name);
|
|
258
|
+
if (language === "python")
|
|
259
|
+
return `/api/${plural.toLowerCase()}`;
|
|
260
|
+
if (language === "go")
|
|
261
|
+
return `/api/${(0, casing_1.kebabCase)(plural)}`;
|
|
262
|
+
return `/${plural}`;
|
|
263
|
+
};
|
|
264
|
+
exports.entityRoute = entityRoute;
|
|
265
|
+
/* eslint-disable no-await-in-loop */
|
|
216
266
|
/**
|
|
217
|
-
*
|
|
218
|
-
*
|
|
219
|
-
* ponytail: list only, no create/read/delete; add when payloads can be built
|
|
220
|
-
* from required fields and relationships.
|
|
267
|
+
* List every entity, then create, read and delete one row of each: parents
|
|
268
|
+
* before children on the way in, children before parents on the way out.
|
|
221
269
|
*/
|
|
222
|
-
async function
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
const done = (s) => {
|
|
229
|
-
steps.push(s);
|
|
230
|
-
return s.ok;
|
|
231
|
-
};
|
|
232
|
-
let server;
|
|
233
|
-
try {
|
|
234
|
-
if (!skipBuild && !done(step("build", await build(projectDir))))
|
|
235
|
-
return { ok: false, steps };
|
|
236
|
-
if (!done(step("schema", await typeorm(projectDir, ["schema:sync"], (0, exports.pgliteEnv)(dataDir)))))
|
|
237
|
-
return { ok: false, steps };
|
|
238
|
-
const port = await freePort();
|
|
239
|
-
let log = "";
|
|
240
|
-
server = (0, child_process_1.spawn)(process.execPath, ["dist/main"], {
|
|
241
|
-
cwd: projectDir,
|
|
242
|
-
env: { ...process.env, ...(0, exports.pgliteEnv)(dataDir), APP_PORT: String(port) },
|
|
270
|
+
async function checkEndpoints(base, language, entities, relationships, done) {
|
|
271
|
+
const route = (name) => (0, exports.entityRoute)(language, name);
|
|
272
|
+
for (const { name } of entities) {
|
|
273
|
+
done({
|
|
274
|
+
name: `GET ${route(name)}`,
|
|
275
|
+
...expectStatus(await get(base + route(name)), 200),
|
|
243
276
|
});
|
|
244
|
-
|
|
245
|
-
|
|
277
|
+
}
|
|
278
|
+
const plan = (0, crud_plan_1.planCrud)(entities, relationships);
|
|
279
|
+
for (const s of plan.skipped)
|
|
280
|
+
done({ name: `skip ${s.name}`, ok: true, output: s.reason });
|
|
281
|
+
const ids = new Map();
|
|
282
|
+
for (const p of plan.order) {
|
|
283
|
+
const r = route(p.name);
|
|
284
|
+
const failed = p.parents.find((parent) => !ids.has(parent.entity));
|
|
285
|
+
if (failed) {
|
|
286
|
+
done({
|
|
287
|
+
name: `skip ${p.name}`,
|
|
288
|
+
ok: true,
|
|
289
|
+
output: `needs a ${failed.entity} row, which failed to create`,
|
|
290
|
+
});
|
|
291
|
+
continue;
|
|
292
|
+
}
|
|
293
|
+
const body = { ...p.payload };
|
|
294
|
+
for (const parent of p.parents)
|
|
295
|
+
body[parent.property] = ids.get(parent.entity);
|
|
296
|
+
const created = await request("POST", base + r, body);
|
|
297
|
+
const id = [200, 201].includes(created.status)
|
|
298
|
+
? idOf(created.body)
|
|
299
|
+
: undefined;
|
|
300
|
+
if (!done({
|
|
301
|
+
name: `POST ${r}`,
|
|
302
|
+
ok: id !== undefined,
|
|
303
|
+
output: id === undefined
|
|
304
|
+
? `${created.status} ${tail(created.body, 500)}`
|
|
305
|
+
: undefined,
|
|
306
|
+
}))
|
|
307
|
+
continue;
|
|
308
|
+
ids.set(p.name, id);
|
|
309
|
+
done({
|
|
310
|
+
name: `GET ${r}/${id}`,
|
|
311
|
+
...expectStatus(await get(`${base}${r}/${id}`), 200),
|
|
246
312
|
});
|
|
247
|
-
|
|
248
|
-
|
|
313
|
+
}
|
|
314
|
+
for (const p of [...plan.order].reverse()) {
|
|
315
|
+
if (!ids.has(p.name))
|
|
316
|
+
continue;
|
|
317
|
+
const url = `${route(p.name)}/${ids.get(p.name)}`;
|
|
318
|
+
done({
|
|
319
|
+
name: `DELETE ${url}`,
|
|
320
|
+
...expectStatus(await request("DELETE", base + url), 200, 204),
|
|
249
321
|
});
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
/** Wait for the server's /health, run check against it, and stop the server. */
|
|
325
|
+
async function serve(server, port, timeoutMs, done, check) {
|
|
326
|
+
var _a, _b;
|
|
327
|
+
let log = "";
|
|
328
|
+
(_a = server.stdout) === null || _a === void 0 ? void 0 : _a.on("data", (d) => {
|
|
329
|
+
log += d;
|
|
330
|
+
});
|
|
331
|
+
(_b = server.stderr) === null || _b === void 0 ? void 0 : _b.on("data", (d) => {
|
|
332
|
+
log += d;
|
|
333
|
+
});
|
|
334
|
+
// A command that can't start (e.g. no python3) has no pid; keep its error.
|
|
335
|
+
server.on("error", (e) => {
|
|
336
|
+
log += e.message;
|
|
337
|
+
});
|
|
338
|
+
try {
|
|
250
339
|
const base = `http://127.0.0.1:${port}`;
|
|
251
340
|
const deadline = Date.now() + timeoutMs;
|
|
252
341
|
let health = await get(`${base}/health`);
|
|
253
342
|
while (health.status !== 200 &&
|
|
254
343
|
Date.now() < deadline &&
|
|
255
|
-
server.exitCode === null
|
|
256
|
-
|
|
344
|
+
server.exitCode === null &&
|
|
345
|
+
server.pid !== undefined) {
|
|
257
346
|
await new Promise((r) => {
|
|
258
347
|
setTimeout(r, 500);
|
|
259
348
|
});
|
|
260
|
-
// eslint-disable-next-line no-await-in-loop
|
|
261
349
|
health = await get(`${base}/health`);
|
|
262
350
|
}
|
|
263
|
-
if (
|
|
351
|
+
if (done({
|
|
264
352
|
name: "start",
|
|
265
353
|
ok: health.status === 200,
|
|
266
354
|
output: health.status === 200 ? undefined : tail(log),
|
|
267
355
|
}))
|
|
356
|
+
await check(base);
|
|
357
|
+
}
|
|
358
|
+
finally {
|
|
359
|
+
server.kill();
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
/**
|
|
363
|
+
* Start the service and call GET /health, every entity's list endpoint, and a
|
|
364
|
+
* create/read/delete of one row per entity. TypeScript services run on a
|
|
365
|
+
* PGlite database synced to their entities; Python and Go services run on the
|
|
366
|
+
* database in DATABASE_URL.
|
|
367
|
+
*/
|
|
368
|
+
async function smokeTest(projectDir, entities, relationships = {}, { skipBuild = false, timeoutMs = 60000, language = "typescript", } = {}) {
|
|
369
|
+
if (language !== "typescript")
|
|
370
|
+
return smokeTestOnDatabaseUrl(projectDir, entities, relationships, language, timeoutMs);
|
|
371
|
+
if (!supportsPglite(projectDir))
|
|
372
|
+
return NO_PGLITE;
|
|
373
|
+
const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "apso-pglite-"));
|
|
374
|
+
const steps = [];
|
|
375
|
+
const done = (s) => {
|
|
376
|
+
steps.push(s);
|
|
377
|
+
return s.ok;
|
|
378
|
+
};
|
|
379
|
+
try {
|
|
380
|
+
if (!skipBuild && !done(step("build", await build(projectDir))))
|
|
268
381
|
return { ok: false, steps };
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
? undefined
|
|
278
|
-
: `${res.status} ${tail(res.body, 500)}`,
|
|
279
|
-
});
|
|
280
|
-
}
|
|
382
|
+
if (!done(step("schema", await typeorm(projectDir, ["schema:sync"], (0, exports.pgliteEnv)(dataDir)))))
|
|
383
|
+
return { ok: false, steps };
|
|
384
|
+
const port = await freePort();
|
|
385
|
+
const server = (0, child_process_1.spawn)(process.execPath, ["dist/main"], {
|
|
386
|
+
cwd: projectDir,
|
|
387
|
+
env: { ...process.env, ...(0, exports.pgliteEnv)(dataDir), APP_PORT: String(port) },
|
|
388
|
+
});
|
|
389
|
+
await serve(server, port, timeoutMs, done, (base) => checkEndpoints(base, language, entities, relationships, done));
|
|
281
390
|
return { ok: steps.every((s) => s.ok), steps };
|
|
282
391
|
}
|
|
283
392
|
finally {
|
|
284
|
-
server === null || server === void 0 ? void 0 : server.kill();
|
|
285
393
|
fs.rmSync(dataDir, { recursive: true, force: true });
|
|
286
394
|
}
|
|
287
395
|
}
|
|
288
396
|
exports.smokeTest = smokeTest;
|
|
397
|
+
/** KEY=value lines of the project's .env; no interpolation. */
|
|
398
|
+
function readDotEnv(projectDir) {
|
|
399
|
+
let text = "";
|
|
400
|
+
try {
|
|
401
|
+
text = fs.readFileSync(path.join(projectDir, ".env"), "utf8");
|
|
402
|
+
}
|
|
403
|
+
catch {
|
|
404
|
+
return {};
|
|
405
|
+
}
|
|
406
|
+
const env = {};
|
|
407
|
+
for (const line of text.split(/\r?\n/)) {
|
|
408
|
+
const m = line.match(/^\s*(?:export\s+)?([\w.]+)\s*=\s*(.*?)\s*$/);
|
|
409
|
+
if (m)
|
|
410
|
+
env[m[1]] = m[2].replace(/^(["'])(.*)\1$/, "$2");
|
|
411
|
+
}
|
|
412
|
+
return env;
|
|
413
|
+
}
|
|
414
|
+
exports.readDotEnv = readDotEnv;
|
|
415
|
+
const canConnect = (host, port) => new Promise((resolve) => {
|
|
416
|
+
const s = net.connect({ host, port, timeout: 3000 });
|
|
417
|
+
const end = (ok) => {
|
|
418
|
+
s.destroy();
|
|
419
|
+
resolve(ok);
|
|
420
|
+
};
|
|
421
|
+
s.on("connect", () => end(true));
|
|
422
|
+
s.on("timeout", () => end(false));
|
|
423
|
+
s.on("error", () => end(false));
|
|
424
|
+
});
|
|
425
|
+
const skipped = (output) => ({
|
|
426
|
+
ok: true,
|
|
427
|
+
steps: [{ name: "skipped", ok: true, output }],
|
|
428
|
+
});
|
|
429
|
+
/**
|
|
430
|
+
* Python and Go services have no embedded database, so they run against the
|
|
431
|
+
* one in DATABASE_URL (environment first, then .env), as configured: the
|
|
432
|
+
* smoke test never creates tables, and it skips when no database answers.
|
|
433
|
+
*/
|
|
434
|
+
async function smokeTestOnDatabaseUrl(projectDir, entities, relationships, language, timeoutMs) {
|
|
435
|
+
const env = { ...readDotEnv(projectDir), ...process.env };
|
|
436
|
+
if (!env.DATABASE_URL)
|
|
437
|
+
return skipped("No DATABASE_URL in the environment or .env, so there is no database to run the service against. Set it and rerun.");
|
|
438
|
+
let db;
|
|
439
|
+
try {
|
|
440
|
+
db = new URL(env.DATABASE_URL);
|
|
441
|
+
}
|
|
442
|
+
catch {
|
|
443
|
+
return {
|
|
444
|
+
ok: false,
|
|
445
|
+
steps: [
|
|
446
|
+
{ name: "database", ok: false, output: "DATABASE_URL is not a URL." },
|
|
447
|
+
],
|
|
448
|
+
};
|
|
449
|
+
}
|
|
450
|
+
const dbPort = Number(db.port) || 5432;
|
|
451
|
+
if (!(await canConnect(db.hostname, dbPort)))
|
|
452
|
+
return skipped(`No database is listening at ${db.hostname}:${dbPort} (DATABASE_URL), so the service can't start. Start the database and rerun.`);
|
|
453
|
+
const steps = [];
|
|
454
|
+
const done = (s) => {
|
|
455
|
+
steps.push(s);
|
|
456
|
+
return s.ok;
|
|
457
|
+
};
|
|
458
|
+
const port = await freePort();
|
|
459
|
+
const serverEnv = { ...env, PORT: String(port) };
|
|
460
|
+
const binDir = fs.mkdtempSync(path.join(os.tmpdir(), "apso-smoke-"));
|
|
461
|
+
try {
|
|
462
|
+
let server;
|
|
463
|
+
if (language === "go") {
|
|
464
|
+
// A built binary, not `go run`: killing `go run` can leave the server running.
|
|
465
|
+
const bin = path.join(binDir, "server");
|
|
466
|
+
if (!done(step("build", await run("go", ["build", "-o", bin, "./cmd"], projectDir))))
|
|
467
|
+
return { ok: false, steps };
|
|
468
|
+
server = (0, child_process_1.spawn)(bin, [], { cwd: projectDir, env: serverEnv });
|
|
469
|
+
}
|
|
470
|
+
else {
|
|
471
|
+
const venv = path.join(projectDir, ".venv", "bin", "python");
|
|
472
|
+
const python = fs.existsSync(venv) ? venv : "python3";
|
|
473
|
+
server = (0, child_process_1.spawn)(python, [
|
|
474
|
+
"-m",
|
|
475
|
+
"uvicorn",
|
|
476
|
+
"app.main:app",
|
|
477
|
+
"--host",
|
|
478
|
+
"127.0.0.1",
|
|
479
|
+
"--port",
|
|
480
|
+
String(port),
|
|
481
|
+
], { cwd: projectDir, env: serverEnv });
|
|
482
|
+
}
|
|
483
|
+
await serve(server, port, timeoutMs, done, (base) => checkEndpoints(base, language, entities, relationships, done));
|
|
484
|
+
return { ok: steps.every((s) => s.ok), steps };
|
|
485
|
+
}
|
|
486
|
+
finally {
|
|
487
|
+
fs.rmSync(binDir, { recursive: true, force: true });
|
|
488
|
+
}
|
|
489
|
+
}
|
|
289
490
|
/** The CLI's own entry point, for running `generate` in the baseline copy. */
|
|
290
491
|
const cliBinPath = (root) => path.join(root, "bin", "run");
|
|
291
492
|
exports.cliBinPath = cliBinPath;
|
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@apso/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.43.0",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@apso/cli",
|
|
9
|
-
"version": "0.
|
|
9
|
+
"version": "0.43.0",
|
|
10
10
|
"license": "Apache-2.0",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@apso/schema-tools": "^0.2.0",
|
package/oclif.manifest.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "0.
|
|
2
|
+
"version": "0.43.0",
|
|
3
3
|
"commands": {
|
|
4
4
|
"config": {
|
|
5
5
|
"id": "config",
|
|
@@ -997,7 +997,7 @@
|
|
|
997
997
|
},
|
|
998
998
|
"test:smoke": {
|
|
999
999
|
"id": "test:smoke",
|
|
1000
|
-
"description": "Start the service on a local PGlite database and
|
|
1000
|
+
"description": "Start the service and call /health, every entity's list endpoint, and a create/read/delete of one row per entity. TypeScript services run on a local PGlite database; Python and Go services run on the database in DATABASE_URL (rows are created and deleted there, tables are not created) and the check is skipped when none answers",
|
|
1001
1001
|
"strict": true,
|
|
1002
1002
|
"pluginName": "@apso/cli",
|
|
1003
1003
|
"pluginAlias": "@apso/cli",
|
|
@@ -1011,7 +1011,7 @@
|
|
|
1011
1011
|
"skip-build": {
|
|
1012
1012
|
"name": "skip-build",
|
|
1013
1013
|
"type": "boolean",
|
|
1014
|
-
"description": "Use the existing dist/ build instead of building first",
|
|
1014
|
+
"description": "Use the existing dist/ build instead of building first (TypeScript)",
|
|
1015
1015
|
"allowNo": false
|
|
1016
1016
|
},
|
|
1017
1017
|
"json": {
|