bazis 0.135.0 → 0.137.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/cli/main.ts +8 -0
- package/cli/parseCli.ts +9 -0
- package/cli/templates/module.ts +16 -19
- package/cli/templates/passport.ts +1 -1
- package/cli/upgrade/markColumns.ts +151 -0
- package/cli/upgrade/runUpgrade.ts +57 -0
- package/core/access/Access.module.ts +1 -1
- package/core/access/catalog/moduleFields.ts +16 -2
- package/core/access/catalog/permissions.ts +10 -0
- package/core/access/check/Access.ts +5 -5
- package/core/access/check/can.ts +2 -1
- package/core/access/index.ts +11 -1
- package/core/access/roles/roles.ts +4 -2
- package/core/agent/AgentToolExecutor.ts +3 -0
- package/core/agent/ToolRunner.ts +5 -0
- package/core/agent/session/entities.ts +45 -45
- package/core/http/Decorators/metadata.ts +3 -0
- package/core/http/Decorators/routes.ts +12 -0
- package/core/http/Errors/HttpError.ts +15 -1
- package/core/http/HttpServer.ts +19 -2
- package/core/http/Middleware/errorHandler.ts +16 -1
- package/core/http/OpenApi/openApiDocument.ts +43 -4
- package/core/http/Results/normalize.ts +38 -1
- package/core/http/Results/results.ts +5 -2
- package/core/http/Routing/RouterBuilder.ts +27 -2
- package/core/http/index.ts +3 -0
- package/core/http/middlewareTokens.ts +12 -0
- package/core/infra/connectors/codex/CodexAppServerClient.ts +1 -1
- package/core/infra/connectors/postgres.ts +4 -2
- package/core/infra/index.ts +1 -1
- package/core/kernel/config/defineConfig.ts +55 -19
- package/core/kernel/index.ts +3 -0
- package/core/orm/buildOrmProviders.ts +2 -1
- package/core/orm/contextEntities.ts +36 -0
- package/core/orm/databaseConnector.ts +9 -3
- package/core/orm/databaseHttpError.ts +21 -0
- package/core/orm/index.ts +12 -1
- package/core/orm/ormModule.ts +17 -7
- package/core/orm/ownedStoreContributions.ts +2 -1
- package/core/scripts/di-generate.ts +61 -4
- package/core/tools/StandardTools.module.ts +10 -2
- package/core/tools/contracts/Tools.config.ts +36 -0
- package/core/tools/generated/deps.ts +8 -3
- package/core/tools/http/HttpRequest.tool.ts +245 -0
- package/core/tools/http/publicAddress.ts +94 -0
- package/core/tools/index.ts +5 -2
- package/core/tools/orm/OrmEntity.references.ts +7 -8
- package/core/tools/secrets/SecretStore.ts +28 -0
- package/core/workflow/contracts/Workflow.config.ts +5 -5
- package/core/workflow/definitions_module/WorkflowDefinitions.module.ts +2 -2
- package/core/workflow/definitions_module/http/Processes.controller.ts +2 -2
- package/core/workflow/definitions_module/internal/PublishedVersions.ts +39 -0
- package/core/workflow/definitions_module/model/WfProcess.model.ts +24 -11
- package/core/workflow/definitions_module/model/WfVersion.model.ts +7 -1
- package/core/workflow/definitions_module/services/IWorkflowDefinitions.service.ts +4 -1
- package/core/workflow/definitions_module/services/WorkflowDefinitions.service.ts +17 -7
- package/core/workflow/generated/deps.ts +3 -2
- package/core/workflow/index.ts +9 -0
- package/core/workflow/internal/WorkflowLanguages.ts +1 -1
- package/core/workflow/internal/http.ts +2 -2
- package/core/workflow/runtime_module/background/WorkflowWorker.ts +6 -2
- package/core/workflow/runtime_module/http/Instances.controller.ts +1 -1
- package/core/workflow/runtime_module/internal/ToolStep.ts +30 -4
- package/core/workflow/runtime_module/internal/WorkflowJobs.ts +43 -1
- package/core/workflow/runtime_module/model/WfHistory.model.ts +13 -6
- package/core/workflow/runtime_module/model/WfInstance.model.ts +30 -10
- package/core/workflow/runtime_module/model/WfJob.model.ts +22 -10
- package/core/workflow/runtime_module/model/WfSubscription.model.ts +8 -3
- package/core/workflow/runtime_module/model/WfToken.model.ts +16 -7
- package/core/workflow/tasks_module/model/WfTask.model.ts +25 -9
- package/library/openapi/codegen.ts +2 -2
- package/library/orm/DbContext.ts +37 -1
- package/library/orm/Metadata/ModelBuilder.ts +23 -7
- package/library/orm/Metadata/decorators.ts +23 -10
- package/library/orm/Metadata/types.ts +2 -0
- package/library/orm/Transactions/TransactionScopeCoordinator.ts +3 -3
- package/library/orm/errors.ts +39 -1
- package/library/orm/index.ts +1 -0
- package/library/redaction/index.ts +28 -0
- package/library/workflow/expressions.ts +8 -1
- package/library/workflow/index.ts +1 -1
- package/library/workflow/paths.ts +21 -1
- package/library/workflow/validateProcess.ts +5 -5
- package/package.json +1 -1
package/cli/main.ts
CHANGED
|
@@ -18,6 +18,8 @@ export const USAGE = `Usage:
|
|
|
18
18
|
for a part of a composite module)
|
|
19
19
|
bazis workflow check [--app-module <path>] Check the processes of the modules against their Tools
|
|
20
20
|
bazis workflow diagram <key> Print a process as a Mermaid flowchart
|
|
21
|
+
bazis upgrade columns [--dry-run] For 0.137.0: mark every column @Required() or @Nullable() by what
|
|
22
|
+
it was, so the schema stays as it is (--path <dir>, default src)
|
|
21
23
|
bazis codegen [--target <name|all>] Generate DI wiring into src/generated/bazis
|
|
22
24
|
bazis dev [--watch] Codegen, then run the app from source with
|
|
23
25
|
BAZIS_ENV=development (unless set);
|
|
@@ -143,6 +145,12 @@ export async function runCli(argv: readonly string[], runtime: CliRuntime = defa
|
|
|
143
145
|
if (parsed.kind === "test") return await runTest(cwd, parsed.args, codegen, ui);
|
|
144
146
|
if (parsed.kind === "build") return await runBuild(cwd, { bin: parsed.bin, outfile: parsed.outfile, dotenv: parsed.dotenv, platform: parsed.platform }, codegen, ui);
|
|
145
147
|
if (parsed.kind === "new") return await runNew(parsed, ui);
|
|
148
|
+
if (parsed.kind === "upgrade") {
|
|
149
|
+
// The codemod parses sources with the TypeScript API: load it for this project first.
|
|
150
|
+
useTypeScriptCompilerApi(cwd);
|
|
151
|
+
const { runUpgradeColumns } = await import("./upgrade/runUpgrade");
|
|
152
|
+
return await runUpgradeColumns(cwd, parsed.path, parsed.dryRun, runtime.log);
|
|
153
|
+
}
|
|
146
154
|
if (parsed.kind === "workflow") {
|
|
147
155
|
const { runWorkflowCheck, runWorkflowDiagram } = await import("./workflow");
|
|
148
156
|
return parsed.action === "check" ? await runWorkflowCheck(cwd, parsed.appModule, ui) : await runWorkflowDiagram(cwd, parsed.key, parsed.appModule, runtime.log);
|
package/cli/parseCli.ts
CHANGED
|
@@ -37,6 +37,7 @@ export type ParseCliResult =
|
|
|
37
37
|
| { readonly kind: "workflow"; readonly action: "check"; readonly appModule?: string }
|
|
38
38
|
| { readonly kind: "workflow"; readonly action: "diagram"; readonly key: string; readonly appModule?: string }
|
|
39
39
|
| { readonly kind: "codegen"; readonly target?: string }
|
|
40
|
+
| { readonly kind: "upgrade"; readonly action: "columns"; readonly path?: string; readonly dryRun: boolean }
|
|
40
41
|
| { readonly kind: "dev"; readonly watch: boolean }
|
|
41
42
|
| { readonly kind: "test"; readonly args: readonly string[] }
|
|
42
43
|
| { readonly kind: "build"; readonly bin: boolean; readonly outfile?: string; readonly dotenv: boolean; readonly platform?: string }
|
|
@@ -127,6 +128,14 @@ export function parseCliArgs(argv: readonly string[]): ParseCliResult {
|
|
|
127
128
|
}
|
|
128
129
|
return { kind: "codegen", target };
|
|
129
130
|
}
|
|
131
|
+
if (command === "upgrade") {
|
|
132
|
+
for (const option of options.keys()) {
|
|
133
|
+
if (option !== "--path" && option !== "--dry-run") return error(`Option ${option} is not supported by upgrade.`);
|
|
134
|
+
}
|
|
135
|
+
if (generatorToken !== "columns" || positional.length !== 2) return error("Use: bazis upgrade columns [--path <directory>] [--dry-run]");
|
|
136
|
+
const directory = options.get("--path");
|
|
137
|
+
return { kind: "upgrade", action: "columns", ...(directory === undefined ? {} : { path: directory }), dryRun: options.has("--dry-run") };
|
|
138
|
+
}
|
|
130
139
|
if (command === "workflow") {
|
|
131
140
|
for (const option of options.keys()) {
|
|
132
141
|
if (option !== "--app-module") return error(`Option ${option} is not supported by workflow.`);
|
package/cli/templates/module.ts
CHANGED
|
@@ -68,7 +68,6 @@ function moduleFile(n: ModuleNaming, profile: ModuleTemplateProfile): string {
|
|
|
68
68
|
|
|
69
69
|
return `${moduleImport}
|
|
70
70
|
${aiImports}${backgroundImport}import { ${n.module}Controller } from "./http/${n.module}.controller";
|
|
71
|
-
import { ${n.entity} } from "./model/${n.module}.model";
|
|
72
71
|
import { ${n.module}DbContext } from "./model/${n.module}.dbContext";
|
|
73
72
|
import { I${n.module}Service } from "./services/I${n.module}.service";
|
|
74
73
|
import { ${n.module}Service } from "./services/${n.module}.service";
|
|
@@ -76,7 +75,6 @@ import { ${n.module}Service } from "./services/${n.module}.service";
|
|
|
76
75
|
@Module({
|
|
77
76
|
ormBazis: {
|
|
78
77
|
context: ${n.module}DbContext,
|
|
79
|
-
entities: [${n.entity}],
|
|
80
78
|
},
|
|
81
79
|
controllers: [${n.module}Controller],
|
|
82
80
|
providers: [
|
|
@@ -91,15 +89,15 @@ export class ${n.moduleClass} {}
|
|
|
91
89
|
function controllerFile(n: ModuleNaming, profile: ModuleTemplateProfile, authImportPath: string | null): string {
|
|
92
90
|
const auth = profile === "full" && authImportPath !== null;
|
|
93
91
|
const fullImports = profile !== "full"
|
|
94
|
-
? "import { Controller, Created, Delete, Get, HttpContext, NoContent, NotFound,
|
|
92
|
+
? "import { Controller, Created, Delete, Get, HttpContext, NoContent, NotFound, Post, Put, type FromBody, type FromQuery, type FromRoute } from \"bazis/core/http\";\n"
|
|
95
93
|
: auth
|
|
96
94
|
? `import { OutputCache } from "bazis/core/cache";
|
|
97
|
-
import { Authorize, Controller, Created, Delete, Get, HttpContext, NoContent, NotFound,
|
|
95
|
+
import { Authorize, Controller, Created, Delete, Get, HttpContext, NoContent, NotFound, Post, Put, type FromBody, type FromQuery, type FromRoute } from "bazis/core/http";
|
|
98
96
|
import { TokenKind } from "${authImportPath}/tokenKinds";
|
|
99
97
|
import { requireTokenKind } from "${authImportPath}/jwtAuth";
|
|
100
98
|
`
|
|
101
99
|
: `import { OutputCache } from "bazis/core/cache";
|
|
102
|
-
import { Controller, Created, Delete, Get, HttpContext, NoContent, NotFound,
|
|
100
|
+
import { Controller, Created, Delete, Get, HttpContext, NoContent, NotFound, Post, Put, type FromBody, type FromQuery, type FromRoute } from "bazis/core/http";
|
|
103
101
|
// No host auth helpers (src/app/modules/auth) were found: these routes are public.
|
|
104
102
|
// Add @Authorize(...) from "bazis/core/http" with your policy before exposing them.
|
|
105
103
|
`;
|
|
@@ -125,28 +123,25 @@ ${authList} @Get()
|
|
|
125
123
|
return buildListDocument(items, query, total, { basePath: ctx.path });
|
|
126
124
|
}
|
|
127
125
|
|
|
128
|
-
${authRead} @Get(":id(uuid)")
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
return item ? Ok(item) : NotFound({ error: \`${n.entity.toLowerCase()} \${id} not found\` });
|
|
126
|
+
${authRead} @Get(":id(uuid)", { nullAs: NotFound })
|
|
127
|
+
getById(id: FromRoute<string>) {
|
|
128
|
+
return this.${n.collection}.getById(id);
|
|
132
129
|
}
|
|
133
130
|
|
|
134
131
|
${authWrite} @Post()
|
|
135
|
-
async create(body: FromBody<Create${n.entity}Request
|
|
136
|
-
const
|
|
137
|
-
return Created(
|
|
132
|
+
async create(body: FromBody<Create${n.entity}Request>) {
|
|
133
|
+
const result = await this.${n.collection}.create(body);
|
|
134
|
+
return Created(result);
|
|
138
135
|
}
|
|
139
136
|
|
|
140
|
-
${authWrite} @Put(":id(uuid)")
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
return item ? Ok(item) : NotFound({ error: \`${n.entity.toLowerCase()} \${id} not found\` });
|
|
137
|
+
${authWrite} @Put(":id(uuid)", { nullAs: NotFound })
|
|
138
|
+
update(id: FromRoute<string>, body: FromBody<Update${n.entity}Request>) {
|
|
139
|
+
return this.${n.collection}.update(id, body);
|
|
144
140
|
}
|
|
145
141
|
|
|
146
142
|
${authWrite} @Delete(":id(uuid)")
|
|
147
143
|
async delete(id: FromRoute<string>) {
|
|
148
|
-
|
|
149
|
-
return removed ? NoContent() : NotFound({ error: \`${n.entity.toLowerCase()} \${id} not found\` });
|
|
144
|
+
return await this.${n.collection}.delete(id) ? NoContent() : NotFound();
|
|
150
145
|
}
|
|
151
146
|
}
|
|
152
147
|
`;
|
|
@@ -155,17 +150,19 @@ ${authWrite} @Delete(":id(uuid)")
|
|
|
155
150
|
function ormModelFile(n: ModuleNaming, profile: ModuleTemplateProfile): string {
|
|
156
151
|
const schemaImport = profile === "full" ? ", Schema" : "";
|
|
157
152
|
const schemaDecorator = profile === "full" ? `@Schema("${n.dbSchema}")\n` : "";
|
|
158
|
-
return `import { Column, Entity, Index, UUID${schemaImport} } from "bazis/core/orm";
|
|
153
|
+
return `import { Column, Entity, Index, Required, UUID${schemaImport} } from "bazis/core/orm";
|
|
159
154
|
|
|
160
155
|
${schemaDecorator}@Entity({ table: "${n.route}" })
|
|
161
156
|
export class ${n.entity} {
|
|
162
157
|
@UUID()
|
|
163
158
|
id = "";
|
|
164
159
|
|
|
160
|
+
@Required()
|
|
165
161
|
@Column({ type: "text" })
|
|
166
162
|
name = "";
|
|
167
163
|
|
|
168
164
|
@Index({ unique: true })
|
|
165
|
+
@Required()
|
|
169
166
|
@Column({ type: "text" })
|
|
170
167
|
email = "";
|
|
171
168
|
|
|
@@ -83,7 +83,7 @@ and an LLM connector, which registers AgentRuntime. The CLI does not create them
|
|
|
83
83
|
` : ""}Constructor dependencies, including ${m}DbContext, are wired by codegen.
|
|
84
84
|
No manual deps arrays. UI, events and own configuration are not used.
|
|
85
85
|
|
|
86
|
-
ORM: context ${m}DbContext
|
|
86
|
+
ORM: context ${m}DbContext with the set of ${e}, shared host provider.
|
|
87
87
|
Table ${n.route}${profile === "full" ? `, schema ${n.dbSchema}` : ""}.
|
|
88
88
|
Only the module decides how tables are created: \`ensureCreated: true\` or
|
|
89
89
|
\`migrateOnStart: true\` in \`ormBazis\`. The scaffold sets neither, so the host
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import ts from "typescript";
|
|
2
|
+
|
|
3
|
+
/** What {@link markColumns} did to one file. */
|
|
4
|
+
export interface MarkColumnsResult {
|
|
5
|
+
readonly text: string;
|
|
6
|
+
/** Columns that got `@Nullable()`. */
|
|
7
|
+
readonly nullable: number;
|
|
8
|
+
/** Columns that got `@Required()`. */
|
|
9
|
+
readonly required: number;
|
|
10
|
+
/** What needs a look: options not written in place, a mark that could not be imported. */
|
|
11
|
+
readonly skipped: readonly string[];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Decorators of a field that make it a column. */
|
|
15
|
+
const COLUMN_DECORATORS = new Set(["Column", "Index", "ForeignKey", "HasConversion"]);
|
|
16
|
+
/** Decorators that make a column NOT NULL whatever else is said. */
|
|
17
|
+
const ALWAYS_REQUIRED = new Set(["Key", "UUID", "CreatedAt", "UpdatedAt"]);
|
|
18
|
+
/** A `uuid` column is NOT NULL only as a key; as a plain column it was nullable. */
|
|
19
|
+
const REQUIRED_TYPES = new Set(["createdAt", "updatedAt"]);
|
|
20
|
+
/** Names an ORM import is recognised by. */
|
|
21
|
+
const ORM_NAMES = new Set(["Column", "Entity", "Index", "Key", "UUID", "ForeignKey"]);
|
|
22
|
+
|
|
23
|
+
interface Edit { readonly start: number; readonly end: number; readonly text: string }
|
|
24
|
+
|
|
25
|
+
function decoratorCall(decorator: ts.Decorator): { readonly name: string; readonly call: ts.CallExpression } | undefined {
|
|
26
|
+
const expression = decorator.expression;
|
|
27
|
+
if (!ts.isCallExpression(expression) || !ts.isIdentifier(expression.expression)) return undefined;
|
|
28
|
+
return { name: expression.expression.text, call: expression };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function propertyName(node: ts.ObjectLiteralElementLike): string | undefined {
|
|
32
|
+
const name = node.name;
|
|
33
|
+
return name && (ts.isIdentifier(name) || ts.isStringLiteralLike(name)) ? name.text : undefined;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Brings the entities of a file to 0.137.0, where every column says whether it
|
|
38
|
+
* may be empty: `@Required()` or `@Nullable()`. A column gets the mark of what it
|
|
39
|
+
* was, so the schema of the application stays as it is: `@Nullable()` for one
|
|
40
|
+
* that allowed NULL by silence or by `nullable: true`, `@Required()` for
|
|
41
|
+
* `nullable: false`. The `nullable` option, which no longer exists, is removed.
|
|
42
|
+
* Keys and timestamps are left alone: they were always NOT NULL. Works on the
|
|
43
|
+
* text of one file and changes nothing else in it.
|
|
44
|
+
*/
|
|
45
|
+
export function markColumns(source: string, fileName = "file.ts"): MarkColumnsResult {
|
|
46
|
+
const file = ts.createSourceFile(fileName, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
|
|
47
|
+
const edits: Edit[] = [];
|
|
48
|
+
const skipped: string[] = [];
|
|
49
|
+
const marks = { Nullable: 0, Required: 0 };
|
|
50
|
+
|
|
51
|
+
/** `, nullable: true` out of the options; an object left empty goes away whole. */
|
|
52
|
+
const dropOption = (options: ts.ObjectLiteralExpression, index: number): void => {
|
|
53
|
+
const property = options.properties[index]!;
|
|
54
|
+
if (options.properties.length === 1) {
|
|
55
|
+
edits.push({ start: options.getStart(file), end: options.end, text: "" });
|
|
56
|
+
} else if (index > 0) {
|
|
57
|
+
edits.push({ start: options.properties[index - 1]!.end, end: property.end, text: "" });
|
|
58
|
+
} else {
|
|
59
|
+
edits.push({ start: property.getStart(file), end: options.properties[1]!.getStart(file), text: "" });
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
/** The mark before a decorator: on its own line when the decorator has one. */
|
|
64
|
+
const addMark = (mark: "Nullable" | "Required", before: ts.Decorator): void => {
|
|
65
|
+
const start = before.getStart(file);
|
|
66
|
+
const lineStart = source.lastIndexOf("\n", start - 1) + 1;
|
|
67
|
+
const indent = source.slice(lineStart, start);
|
|
68
|
+
const ownLine = /^[ \t]*$/.test(indent) && /^[ \t]*\r?\n/.test(source.slice(before.end));
|
|
69
|
+
edits.push({ start, end: start, text: ownLine ? `@${mark}()\n${indent}` : `@${mark}() ` });
|
|
70
|
+
marks[mark] += 1;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const visitClass = (node: ts.ClassLikeDeclaration): void => {
|
|
74
|
+
// `@Key(["shopId", "code"])` on the class: these properties are the key.
|
|
75
|
+
const keyed = new Set<string>();
|
|
76
|
+
for (const decorator of ts.getDecorators(node) ?? []) {
|
|
77
|
+
const found = decoratorCall(decorator);
|
|
78
|
+
const list = found?.name === "Key" ? found.call.arguments[0] : undefined;
|
|
79
|
+
if (list && ts.isArrayLiteralExpression(list)) for (const item of list.elements) if (ts.isStringLiteralLike(item)) keyed.add(item.text);
|
|
80
|
+
}
|
|
81
|
+
for (const member of node.members) {
|
|
82
|
+
if (!ts.isPropertyDeclaration(member)) continue;
|
|
83
|
+
const decorators = (ts.getDecorators(member) ?? []).map((decorator) => ({ decorator, found: decoratorCall(decorator) }));
|
|
84
|
+
const has = (name: string) => decorators.some(({ found }) => found?.name === name);
|
|
85
|
+
const anchor = decorators.find(({ found }) => found?.name === "Column") ?? decorators.find(({ found }) => found !== undefined && COLUMN_DECORATORS.has(found.name));
|
|
86
|
+
if (!anchor?.found) continue;
|
|
87
|
+
const name = member.name.getText(file);
|
|
88
|
+
const where = `${fileName}: ${node.name?.text ?? "class"}.${name}`;
|
|
89
|
+
|
|
90
|
+
const column = decorators.find(({ found }) => found?.name === "Column")?.found?.call;
|
|
91
|
+
const options = column?.arguments[0];
|
|
92
|
+
let declared: boolean | undefined;
|
|
93
|
+
let type: string | undefined;
|
|
94
|
+
if (options !== undefined) {
|
|
95
|
+
if (!ts.isObjectLiteralExpression(options) || options.properties.some(ts.isSpreadAssignment)) {
|
|
96
|
+
skipped.push(`${where}: the options of @Column are not written in place; mark the column @Required() or @Nullable() by hand`);
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
const typeProperty = options.properties.find((property) => propertyName(property) === "type");
|
|
100
|
+
if (typeProperty && ts.isPropertyAssignment(typeProperty) && ts.isStringLiteralLike(typeProperty.initializer)) type = typeProperty.initializer.text;
|
|
101
|
+
const index = options.properties.findIndex((property) => propertyName(property) === "nullable");
|
|
102
|
+
if (index >= 0) {
|
|
103
|
+
const property = options.properties[index]!;
|
|
104
|
+
const value = ts.isPropertyAssignment(property) ? property.initializer.kind : undefined;
|
|
105
|
+
if (value !== ts.SyntaxKind.TrueKeyword && value !== ts.SyntaxKind.FalseKeyword) {
|
|
106
|
+
skipped.push(`${where}: nullable is not true or false here; mark the column @Required() or @Nullable() by hand`);
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
declared = value === ts.SyntaxKind.TrueKeyword;
|
|
110
|
+
dropOption(options, index);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// A soft-delete column is nullable by its own decorator; keys and timestamps are always NOT NULL.
|
|
115
|
+
if (has("SoftDelete") || keyed.has(name) || decorators.some(({ found }) => found !== undefined && ALWAYS_REQUIRED.has(found.name)) || (type !== undefined && REQUIRED_TYPES.has(type))) continue;
|
|
116
|
+
if (has("Required") || has("Nullable")) continue;
|
|
117
|
+
addMark(declared === false ? "Required" : "Nullable", anchor.decorator);
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
const visit = (node: ts.Node): void => {
|
|
122
|
+
if (ts.isClassLike(node)) visitClass(node);
|
|
123
|
+
ts.forEachChild(node, visit);
|
|
124
|
+
};
|
|
125
|
+
visit(file);
|
|
126
|
+
|
|
127
|
+
const wanted = (["Nullable", "Required"] as const).filter((mark) => marks[mark] > 0);
|
|
128
|
+
if (wanted.length > 0) {
|
|
129
|
+
const problem = importEdits(file, wanted, edits);
|
|
130
|
+
if (problem !== undefined) skipped.push(`${fileName}: ${problem}`);
|
|
131
|
+
}
|
|
132
|
+
let text = source;
|
|
133
|
+
for (const edit of edits.sort((left, right) => right.start - left.start || right.end - left.end)) text = text.slice(0, edit.start) + edit.text + text.slice(edit.end);
|
|
134
|
+
return { text, nullable: marks.Nullable, required: marks.Required, skipped };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** The marks join the import the other ORM decorators of the file come from. */
|
|
138
|
+
function importEdits(file: ts.SourceFile, marks: readonly string[], edits: Edit[]): string | undefined {
|
|
139
|
+
for (const statement of file.statements) {
|
|
140
|
+
if (!ts.isImportDeclaration(statement) || statement.importClause?.isTypeOnly) continue;
|
|
141
|
+
const bindings = statement.importClause?.namedBindings;
|
|
142
|
+
if (!bindings || !ts.isNamedImports(bindings)) continue;
|
|
143
|
+
const names = new Set(bindings.elements.map((element) => (element.propertyName ?? element.name).text));
|
|
144
|
+
if (![...names].some((name) => ORM_NAMES.has(name))) continue;
|
|
145
|
+
const missing = marks.filter((mark) => !names.has(mark));
|
|
146
|
+
const last = bindings.elements.at(-1);
|
|
147
|
+
if (missing.length > 0 && last) edits.push({ start: last.end, end: last.end, text: `, ${missing.join(", ")}` });
|
|
148
|
+
return undefined;
|
|
149
|
+
}
|
|
150
|
+
return `import ${marks.join(" and ")} from the ORM by hand: the file has no import of its decorators to add them to`;
|
|
151
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { readdir } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { markColumns } from "./markColumns";
|
|
4
|
+
|
|
5
|
+
const SKIPPED_DIRECTORIES = new Set(["node_modules", "generated", "dist", "bin", ".git"]);
|
|
6
|
+
|
|
7
|
+
async function sourceFiles(directory: string): Promise<string[]> {
|
|
8
|
+
const out: string[] = [];
|
|
9
|
+
for (const entry of await readdir(directory, { withFileTypes: true })) {
|
|
10
|
+
const full = path.join(directory, entry.name);
|
|
11
|
+
if (entry.isDirectory()) {
|
|
12
|
+
if (!SKIPPED_DIRECTORIES.has(entry.name)) out.push(...await sourceFiles(full));
|
|
13
|
+
} else if (/\.tsx?$/.test(entry.name) && !entry.name.endsWith(".d.ts")) {
|
|
14
|
+
out.push(full);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
return out.sort();
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* `bazis upgrade columns`: from 0.137.0 every column says whether it may be
|
|
22
|
+
* empty, `@Required()` or `@Nullable()`. This gives each column the mark of what
|
|
23
|
+
* it was, so the schema of the application stays as it is, and removes the
|
|
24
|
+
* `nullable` option. With `--dry-run` it only tells what it would change.
|
|
25
|
+
*/
|
|
26
|
+
export async function runUpgradeColumns(cwd: string, directory: string | undefined, dryRun: boolean, log: (text: string) => void): Promise<number> {
|
|
27
|
+
const root = path.resolve(cwd, directory ?? "src");
|
|
28
|
+
let files: string[];
|
|
29
|
+
try {
|
|
30
|
+
files = await sourceFiles(root);
|
|
31
|
+
} catch {
|
|
32
|
+
log(`No directory ${path.relative(cwd, root) || "."}. Pass the sources with --path <directory>.`);
|
|
33
|
+
return 1;
|
|
34
|
+
}
|
|
35
|
+
let nullable = 0;
|
|
36
|
+
let required = 0;
|
|
37
|
+
let changed = 0;
|
|
38
|
+
const skipped: string[] = [];
|
|
39
|
+
for (const file of files) {
|
|
40
|
+
const source = await Bun.file(file).text();
|
|
41
|
+
if (!/@(Column|Index|ForeignKey|HasConversion)\(/.test(source)) continue;
|
|
42
|
+
const name = path.relative(cwd, file);
|
|
43
|
+
const result = markColumns(source, name);
|
|
44
|
+
skipped.push(...result.skipped);
|
|
45
|
+
if (result.text === source) continue;
|
|
46
|
+
changed += 1;
|
|
47
|
+
nullable += result.nullable;
|
|
48
|
+
required += result.required;
|
|
49
|
+
log(`${dryRun ? "would change" : "changed"} ${name}: ${[result.nullable > 0 ? `${result.nullable} @Nullable()` : "", result.required > 0 ? `${result.required} @Required()` : "", result.nullable + result.required === 0 ? "the nullable option removed" : ""].filter(Boolean).join(", ")}`);
|
|
50
|
+
if (!dryRun) await Bun.write(file, result.text);
|
|
51
|
+
}
|
|
52
|
+
for (const line of skipped) log(`look at ${line}`);
|
|
53
|
+
log(changed === 0
|
|
54
|
+
? "Nothing to change: every column already says whether it may be empty."
|
|
55
|
+
: `${dryRun ? "Would change" : "Changed"} ${changed} ${changed === 1 ? "file" : "files"}: ${nullable} columns marked @Nullable(), ${required} marked @Required(). The schema stays as it was. Review the diff: where a column must not be empty, write @Required() instead and migrate the data.`);
|
|
56
|
+
return 0;
|
|
57
|
+
}
|
|
@@ -9,7 +9,7 @@ import { AccessPolicy } from "./contracts/AccessPolicy";
|
|
|
9
9
|
import { AccessCatalogController } from "./http/AccessCatalog.controller";
|
|
10
10
|
import { RolesAccessPolicy } from "./roles/RolesAccessPolicy";
|
|
11
11
|
|
|
12
|
-
const accessPermissions = definePermissions({
|
|
12
|
+
export const accessPermissions = definePermissions({
|
|
13
13
|
"access.catalog.read": {
|
|
14
14
|
title: { en: "See the permissions catalog", ru: "Видеть каталог прав" },
|
|
15
15
|
},
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import {
|
|
2
2
|
createModuleOwnedMetadataChannel,
|
|
3
|
+
createModuleOwnedProviderChannel,
|
|
4
|
+
scoped,
|
|
3
5
|
registerModuleOwnedContributionValidator,
|
|
4
6
|
registerModuleOwnedProviderContributor,
|
|
5
7
|
DiContainer,
|
|
@@ -22,6 +24,8 @@ interface RolePayload { readonly name: string; readonly permissions: "all" | rea
|
|
|
22
24
|
const PERMISSION_CHANNEL = createModuleOwnedMetadataChannel<PermissionPayload>("access.permission/v1");
|
|
23
25
|
const REFERENCE_CHANNEL = createModuleOwnedMetadataChannel<ReferencePayload>("access.reference/v1");
|
|
24
26
|
const ROLE_CHANNEL = createModuleOwnedMetadataChannel<RolePayload>("access.role/v1");
|
|
27
|
+
/** The classes of `references`: the field registers them, like `tools` and `controllers`. */
|
|
28
|
+
const REFERENCE_CLASS_CHANNEL = createModuleOwnedProviderChannel<{ readonly kind: string }, object>("access.reference-class/v1");
|
|
25
29
|
|
|
26
30
|
/** The values of `in`/`notIn` a role may list; the run-time limit is `access.maxInList`. */
|
|
27
31
|
const STARTUP_MAX_IN_LIST = 1000;
|
|
@@ -53,7 +57,17 @@ registerModuleOwnedProviderContributor((metadata: BazisModuleMetadata, context)
|
|
|
53
57
|
if (typeof type !== "function") problems.push(`reference kind "${kind}" must name a References class`);
|
|
54
58
|
}
|
|
55
59
|
if (problems.length > 0) throw setupError(module, "references", problems);
|
|
56
|
-
|
|
60
|
+
const registered = new Set<unknown>();
|
|
61
|
+
for (const [kind, type] of Object.entries(references)) {
|
|
62
|
+
context.addMetadata(REFERENCE_CHANNEL, { kind, type: type as Class<References> });
|
|
63
|
+
if (registered.has(type)) continue;
|
|
64
|
+
registered.add(type);
|
|
65
|
+
try {
|
|
66
|
+
context.registerScoped(REFERENCE_CLASS_CHANNEL, scoped(type as Class<object>), { kind });
|
|
67
|
+
} catch {
|
|
68
|
+
// The module registered the class its own way (a singleton): that registration stands.
|
|
69
|
+
}
|
|
70
|
+
}
|
|
57
71
|
}
|
|
58
72
|
if (fields.roles !== undefined) {
|
|
59
73
|
if (!isDeclaredRoles(fields.roles)) throw new AccessSetupError(`${module}: roles must be a defineRoles(...) result.`);
|
|
@@ -93,7 +107,7 @@ registerModuleOwnedContributionValidator((snapshot) => {
|
|
|
93
107
|
const other = kinds.get(payload.kind);
|
|
94
108
|
if (other !== undefined) throw new AccessSetupError(`Reference kind "${payload.kind}" is declared twice: in ${other} and in ${ownerName}.`);
|
|
95
109
|
kinds.set(payload.kind, ownerName);
|
|
96
|
-
if (snapshot.countProviders(payload.type) === 0) throw new AccessSetupError(`${ownerName}: ${payload.type.name} of reference kind "${payload.kind}" is not registered
|
|
110
|
+
if (snapshot.countProviders(payload.type) === 0) throw new AccessSetupError(`${ownerName}: ${payload.type.name} of reference kind "${payload.kind}" is not registered.`);
|
|
97
111
|
}
|
|
98
112
|
|
|
99
113
|
for (const { payload, ownerName } of permissions) {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { Class } from "../../di";
|
|
2
2
|
import type { AttributeLookup } from "../contracts/AttributeLookup";
|
|
3
3
|
import type { LocalizedText } from "../contracts/types";
|
|
4
|
+
import type { BazisPermissions } from "../index";
|
|
4
5
|
|
|
5
6
|
/** An attribute of the object of a permission: what a condition may test (ACCESS-001 §3). */
|
|
6
7
|
export interface AttributeSpec {
|
|
@@ -39,6 +40,15 @@ export interface PermissionSpec {
|
|
|
39
40
|
|
|
40
41
|
export type PermissionDeclarations = Readonly<Record<string, PermissionSpec>>;
|
|
41
42
|
|
|
43
|
+
/** The declarations of `definePermissions(...)` as a part of {@link BazisPermissions}. */
|
|
44
|
+
export type PermissionsOf<T extends PermissionDeclarations> = { readonly [K in keyof T]: T[K] };
|
|
45
|
+
|
|
46
|
+
/** A declared permission. */
|
|
47
|
+
export type PermissionKey = Extract<keyof BazisPermissions, string>;
|
|
48
|
+
|
|
49
|
+
/** A permission with an `owner`: the only kind `"<key>:own"` makes sense for. */
|
|
50
|
+
export type OwnPermissionKey = { [K in keyof BazisPermissions]: BazisPermissions[K] extends { readonly owner: string } ? Extract<K, string> : never }[keyof BazisPermissions];
|
|
51
|
+
|
|
42
52
|
const DECLARED = new WeakSet<object>();
|
|
43
53
|
|
|
44
54
|
/**
|
|
@@ -3,7 +3,7 @@ import { UnauthorizedError, type AuthorizeContext } from "../../http";
|
|
|
3
3
|
import { LOGGER, type ConfigView } from "../../kernel";
|
|
4
4
|
import type { AccessConfig } from "./accessConfig";
|
|
5
5
|
import type { IAccessCatalog } from "../catalog/IAccessCatalog.service";
|
|
6
|
-
import type { PermissionSpec } from "../catalog/permissions";
|
|
6
|
+
import type { PermissionKey, PermissionSpec } from "../catalog/permissions";
|
|
7
7
|
import { AccessPolicy, type ScopePrincipals } from "../contracts/AccessPolicy";
|
|
8
8
|
import type { AttributeLookup } from "../contracts/AttributeLookup";
|
|
9
9
|
import type { AccessObject, AccessSubject, Condition, Grant, Grants, Scope } from "../contracts/types";
|
|
@@ -70,7 +70,7 @@ export class SubjectAccess {
|
|
|
70
70
|
) {}
|
|
71
71
|
|
|
72
72
|
/** The grants of the permission; a grant the permission cannot use is dropped with a warning. */
|
|
73
|
-
grants(permission:
|
|
73
|
+
grants(permission: PermissionKey): Promise<Grants> {
|
|
74
74
|
let grants = this.grantCache.get(permission);
|
|
75
75
|
if (grants === undefined) {
|
|
76
76
|
grants = this.load(permission);
|
|
@@ -83,7 +83,7 @@ export class SubjectAccess {
|
|
|
83
83
|
* Whether the subject may do `permission` on `object`. Without an object —
|
|
84
84
|
* whether it has the permission anywhere (a route before the object is known).
|
|
85
85
|
*/
|
|
86
|
-
async can(permission:
|
|
86
|
+
async can(permission: PermissionKey, object?: AccessObject): Promise<boolean> {
|
|
87
87
|
const spec = this.catalog.require(permission).spec;
|
|
88
88
|
const grants = await this.grants(permission);
|
|
89
89
|
if (grants === "all") return true;
|
|
@@ -92,7 +92,7 @@ export class SubjectAccess {
|
|
|
92
92
|
}
|
|
93
93
|
|
|
94
94
|
/** Which rows of a list the subject may see; `scope` — the partition of the request for `scope: "request"`. */
|
|
95
|
-
async filter(permission:
|
|
95
|
+
async filter(permission: PermissionKey, options: { readonly scope?: Scope } = {}): Promise<AccessFilter> {
|
|
96
96
|
const spec = this.catalog.require(permission).spec;
|
|
97
97
|
const grants = await this.grants(permission);
|
|
98
98
|
return await buildFilter({
|
|
@@ -107,7 +107,7 @@ export class SubjectAccess {
|
|
|
107
107
|
}
|
|
108
108
|
|
|
109
109
|
/** The fields of `object` the subject may read and write (ACCESS-001 §4.4). */
|
|
110
|
-
async fields(permission:
|
|
110
|
+
async fields(permission: PermissionKey, object: AccessObject): Promise<FieldAccess> {
|
|
111
111
|
const spec = this.catalog.require(permission).spec;
|
|
112
112
|
const grants = await this.grants(permission);
|
|
113
113
|
if (grants === "all") return { read: "all", write: "all" };
|
package/core/access/check/can.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { AuthorizeCheck, AuthorizeContext } from "../../http";
|
|
2
|
+
import type { PermissionKey } from "../catalog/permissions";
|
|
2
3
|
import type { AccessObject } from "../contracts/types";
|
|
3
4
|
import { IAccess } from "./IAccess.service";
|
|
4
5
|
|
|
@@ -13,7 +14,7 @@ import { IAccess } from "./IAccess.service";
|
|
|
13
14
|
* @Post() create(ctx: HttpContext) { … }
|
|
14
15
|
* ```
|
|
15
16
|
*/
|
|
16
|
-
export function can(permission:
|
|
17
|
+
export function can(permission: PermissionKey, objectOf?: (ctx: AuthorizeContext) => AccessObject | Promise<AccessObject>): AuthorizeCheck {
|
|
17
18
|
return async (ctx) => {
|
|
18
19
|
const access = await ctx.services.resolve(IAccess).for(ctx);
|
|
19
20
|
return await access.can(permission, objectOf === undefined ? undefined : await objectOf(ctx));
|
package/core/access/index.ts
CHANGED
|
@@ -5,9 +5,19 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import "./catalog/moduleFields";
|
|
7
7
|
import "./generated/deps";
|
|
8
|
+
import type { accessPermissions } from "./Access.module";
|
|
9
|
+
import type { PermissionsOf } from "./catalog/permissions";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The permissions the application declares, by key: what `can`, `access.can`,
|
|
13
|
+
* `access.filter` and `defineRoles` accept. Nobody writes it by hand: `bazis codegen`
|
|
14
|
+
* fills it from `permissions` of the application's modules
|
|
15
|
+
* (`src/generated/bazis/permissions.ts`), a framework pack from its own.
|
|
16
|
+
*/
|
|
17
|
+
export interface BazisPermissions extends PermissionsOf<typeof accessPermissions> {}
|
|
8
18
|
|
|
9
19
|
export { AccessModule } from "./Access.module";
|
|
10
|
-
export { definePermissions, type AttributeSpec, type PermissionDeclarations, type PermissionSpec, type ScopeSource } from "./catalog/permissions";
|
|
20
|
+
export { definePermissions, type AttributeSpec, type OwnPermissionKey, type PermissionDeclarations, type PermissionKey, type PermissionsOf, type PermissionSpec, type ScopeSource } from "./catalog/permissions";
|
|
11
21
|
export { IAccessCatalog } from "./catalog/IAccessCatalog.service";
|
|
12
22
|
export type { DeclaredRole, OwnedPermission, OwnedReferenceKind } from "./catalog/AccessCatalog";
|
|
13
23
|
export { IAccess } from "./check/IAccess.service";
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { OwnPermissionKey, PermissionKey } from "../catalog/permissions";
|
|
1
2
|
import type { Condition, Grant, GrantFields, Scope } from "../contracts/types";
|
|
2
3
|
|
|
3
4
|
/**
|
|
@@ -5,9 +6,10 @@ import type { Condition, Grant, GrantFields, Scope } from "../contracts/types";
|
|
|
5
6
|
* grant with conditions.
|
|
6
7
|
*/
|
|
7
8
|
export type RoleGrant =
|
|
8
|
-
|
|
|
9
|
+
| PermissionKey
|
|
10
|
+
| `${OwnPermissionKey}:own`
|
|
9
11
|
| {
|
|
10
|
-
readonly permission:
|
|
12
|
+
readonly permission: PermissionKey;
|
|
11
13
|
readonly scopes?: "all" | readonly Scope[];
|
|
12
14
|
readonly where?: Condition;
|
|
13
15
|
readonly fields?: GrantFields;
|
|
@@ -62,6 +62,8 @@ export interface AgentToolExecutionContext {
|
|
|
62
62
|
readonly actor?: ToolActor;
|
|
63
63
|
/** A trial: the Tool, one with `dryRun: "supported"`, changes nothing (WORKFLOW-002 §5.4). */
|
|
64
64
|
readonly dryRun?: boolean;
|
|
65
|
+
/** Who calls: `"agent"` or the caller id of `ToolRunner.as`. */
|
|
66
|
+
readonly caller?: string;
|
|
65
67
|
readonly metadata: JsonObject;
|
|
66
68
|
readonly signal: AbortSignal;
|
|
67
69
|
}
|
|
@@ -1672,6 +1674,7 @@ export class AgentToolExecutor {
|
|
|
1672
1674
|
approval,
|
|
1673
1675
|
...(options.actor !== undefined ? { actor: options.actor } : {}),
|
|
1674
1676
|
...(options.dryRun === true ? { dryRun: true } : {}),
|
|
1677
|
+
...(callerOf(options) === undefined ? {} : { caller: callerOf(options)! }),
|
|
1675
1678
|
metadata,
|
|
1676
1679
|
signal,
|
|
1677
1680
|
});
|
package/core/agent/ToolRunner.ts
CHANGED
|
@@ -42,6 +42,11 @@ export class ToolRunner {
|
|
|
42
42
|
this.executor = new AgentToolExecutor(services, this.catalog, { schemaValidator: modelValidatorAdapter, ...executorOptions }, logger);
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
+
/** Every Tool of the application, whoever may call it. */
|
|
46
|
+
listTools(): readonly ToolDefinition[] {
|
|
47
|
+
return this.catalog.listTools();
|
|
48
|
+
}
|
|
49
|
+
|
|
45
50
|
/** A view of the Tools this caller may run. `"agent"` is refused: agents run through `AgentRuntime`. */
|
|
46
51
|
as(caller: string): BoundToolRunner {
|
|
47
52
|
let id: string;
|