bazis 0.136.0 → 0.138.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 -4
- package/cli/generateProject.ts +11 -18
- package/cli/main.ts +9 -1
- package/cli/parseCli.ts +9 -0
- package/cli/templates/module.ts +16 -19
- package/cli/templates/passport.ts +2 -2
- 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 +41 -4
- 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/access/testing/index.ts +26 -4
- package/core/agent/ToolRunner.ts +7 -1
- package/core/agent/defineTool.ts +109 -0
- package/core/agent/index.ts +8 -3
- package/core/agent/moduleContributions-v1.ts +3 -10
- package/core/agent/session/entities.ts +45 -45
- package/core/app/index.ts +1 -1
- package/core/app/messages.ts +3 -3
- package/core/app/runApp.ts +90 -26
- package/core/cache/ICache.ts +1 -1
- package/core/cache/README.md +4 -2
- package/core/cache/SPEC.md +16 -9
- package/core/cache/http/missingCacheModuleCheck.ts +5 -4
- package/core/cache/internal/buildCacheProviders.ts +4 -5
- package/core/cache/providers/cachedProviders.ts +1 -1
- package/core/di/index.ts +0 -4
- package/core/di/lowLevel.ts +12 -0
- package/core/di/module/index.ts +0 -3
- package/core/di/module/shortcuts.ts +31 -86
- package/core/generatedRuntime.ts +11 -0
- package/core/grpc/Grpc.module.ts +2 -1
- package/core/grpc/grpcClients.ts +2 -1
- package/core/http/Authorization/metadata.ts +1 -1
- package/core/http/Decorators/metadata.ts +3 -0
- package/core/http/Decorators/routes.ts +12 -0
- package/core/http/Errors/HttpError.ts +16 -2
- package/core/http/Http.config.ts +22 -0
- package/core/http/HttpContext/HttpContext.ts +1 -1
- package/core/http/HttpServer.ts +29 -9
- package/core/http/Middleware/errorHandler.ts +16 -1
- package/core/http/OpenApi/openApiDocument.ts +44 -5
- 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/httpModule.ts +10 -8
- package/core/http/index.ts +4 -0
- package/core/http/middlewareTokens.ts +12 -0
- package/core/http/options.ts +5 -1
- package/core/http-client/httpClients.ts +2 -1
- package/core/infra/Infra.ts +3 -3
- package/core/infra/connectors/codex/CodexAppServerClient.ts +1 -1
- package/core/infra/connectors/codex/contracts.ts +3 -3
- package/core/infra/connectors/llm.ts +2 -1
- package/core/infra/connectors/postgres.ts +4 -2
- package/core/infra/connectors/redis.ts +2 -1
- package/core/infra/index.ts +1 -1
- package/core/kernel/KernelBuilder.ts +2 -1
- package/core/kernel/config/ConfigRegistry.ts +2 -1
- package/core/kernel/config/addConfigOptions.ts +2 -1
- package/core/kernel/config/defineConfig.ts +56 -20
- package/core/kernel/events/EventBus.ts +1 -0
- package/core/kernel/events/eventSubscribers.ts +1 -1
- package/core/kernel/events/onEvent.ts +2 -1
- package/core/kernel/index.ts +3 -0
- package/core/orm/buildOrmProviders.ts +4 -2
- package/core/orm/contextEntities.ts +36 -0
- package/core/orm/databaseConnector.ts +9 -3
- package/core/orm/databaseHttpError.ts +21 -0
- package/core/orm/encryptedWith.ts +1 -1
- package/core/orm/index.ts +12 -1
- package/core/orm/ormModule.ts +19 -8
- package/core/orm/ownedStoreContributions.ts +2 -1
- package/core/orm/registerRepositories.ts +2 -1
- package/core/scripts/di-generate.ts +113 -12
- package/core/testing/index.ts +51 -11
- package/core/tools/StandardTools.module.ts +1 -1
- package/core/tools/contracts/Tools.config.ts +13 -7
- package/core/tools/generated/deps.ts +3 -3
- package/core/tools/http/HttpRequest.tool.ts +1 -2
- package/core/tools/http/publicAddress.ts +2 -2
- package/core/tools/index.ts +1 -1
- package/core/tools/orm/OrmEntity.references.ts +7 -8
- package/core/tools/orm/ormEntityTools.ts +54 -28
- package/core/websocket/DESIGN.md +5 -3
- package/core/websocket/middleware.ts +1 -1
- package/core/websocket/websocketModule.ts +3 -3
- package/core/workflow/contracts/Workflow.config.ts +5 -5
- package/core/workflow/definitions_module/WorkflowDefinitions.module.ts +3 -4
- package/core/workflow/definitions_module/code/defineProcess.ts +213 -11
- package/core/workflow/definitions_module/code/inspectProcesses.ts +12 -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 +2 -1
- package/core/workflow/index.ts +23 -1
- 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/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/runtime_module/services/IWorkflowRuntime.service.ts +10 -6
- package/core/workflow/runtime_module/services/WorkflowRuntime.service.ts +5 -2
- package/core/workflow/tasks_module/model/WfTask.model.ts +25 -9
- package/core/workflow/testing/index.ts +46 -4
- package/index.ts +4 -3
- 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/terminal/messages.ts +6 -6
- package/library/validation/README.md +1 -1
- package/library/validation/SPEC.md +1 -1
- package/library/workflow/index.ts +1 -0
- package/library/workflow/nodeConfigs.ts +130 -0
- package/package.json +2 -1
|
@@ -189,6 +189,8 @@ interface ConstructorDependencies {
|
|
|
189
189
|
readonly unsupportedParameter?: { readonly index: number; readonly name: string; readonly type: string };
|
|
190
190
|
}
|
|
191
191
|
let classDeps = new Map<ts.ClassDeclaration, readonly CollectedDependency[]>();
|
|
192
|
+
/** `permissions` of the target's modules: the exported constant and its file. */
|
|
193
|
+
let permissionDeclarations = new Map<string, { readonly name: string; readonly file: string }>();
|
|
192
194
|
let inferredDiClasses = new Set<ts.ClassDeclaration>();
|
|
193
195
|
let explicitlyBoundDiClasses = new Set<ts.ClassDeclaration>();
|
|
194
196
|
// Known runtime token names: createToken descriptions + class names usable as class-tokens.
|
|
@@ -505,7 +507,7 @@ function collectDynamicImportDiagnostics(source: ts.SourceFile, target: string,
|
|
|
505
507
|
|
|
506
508
|
function resetTargetAnalysis(files: Set<string>, generatedDir: string): void {
|
|
507
509
|
activeGeneratedDir = generatedDir;
|
|
508
|
-
classDeps = new Map(); inferredDiClasses = new Set(); explicitlyBoundDiClasses = new Set();
|
|
510
|
+
classDeps = new Map(); inferredDiClasses = new Set(); explicitlyBoundDiClasses = new Set(); permissionDeclarations = new Map();
|
|
509
511
|
tokenDescriptions = new Set(); configPrefixesByType = new Map(); knownClassNames = new Set(); warnings = []; fatalErrors = [];
|
|
510
512
|
httpBindings = {}; httpControllerFiles = {}; openApiSchemas = {}; openApiOperations = {};
|
|
511
513
|
grpcBindings = new Map();
|
|
@@ -555,6 +557,8 @@ async function generateTarget(name: string, reachable: Set<string>, production:
|
|
|
555
557
|
stageWrite(`${generatedDir}/httpRequestModels.ts`, requestModels);
|
|
556
558
|
stageWrite(`${generatedDir}/httpListModels.ts`, listModels);
|
|
557
559
|
stageWrite(`${generatedDir}/openapi.ts`, openApi);
|
|
560
|
+
if (permissionDeclarations.size > 0) stageWrite(`${generatedDir}/permissions.ts`, renderPermissions());
|
|
561
|
+
else stageRemove(`${generatedDir}/permissions.ts`);
|
|
558
562
|
// Generated before 0.99.0, when agents were classes; nothing imports it now.
|
|
559
563
|
stageRemove(`${generatedDir}/agentCatalog.ts`);
|
|
560
564
|
stageWrite(`${generatedDir}/runtime.ts`, renderGeneratedRuntime(name));
|
|
@@ -708,6 +712,15 @@ function renderGeneratedRuntime(targetId: string): string {
|
|
|
708
712
|
}
|
|
709
713
|
|
|
710
714
|
/** Sources of this target as codegen saw them; read back by the generated runtime. */
|
|
715
|
+
/** Types only: the keys of the target's permissions for `can`, `access.can`, `access.filter` and `defineRoles`. */
|
|
716
|
+
function renderPermissions(): string {
|
|
717
|
+
const entries = [...permissionDeclarations.values()].sort((left, right) => left.file.localeCompare(right.file) || left.name.localeCompare(right.name));
|
|
718
|
+
const lines = ["// This file is auto-generated by `bazis codegen`.", "// Do not edit manually.", 'import type { PermissionsOf } from "bazis/core/access";'];
|
|
719
|
+
entries.forEach((entry, index) => lines.push(`import type { ${entry.name} as Permissions_${index} } from ${JSON.stringify(toModuleSpecifierFrom(activeGeneratedDir, entry.file))};`));
|
|
720
|
+
lines.push("", 'declare module "bazis/core/access" {', ` interface BazisPermissions extends ${entries.map((_, index) => `PermissionsOf<typeof Permissions_${index}>`).join(", ")} {}`, "}", "");
|
|
721
|
+
return lines.join("\n");
|
|
722
|
+
}
|
|
723
|
+
|
|
711
724
|
function renderSourceFingerprint(): string {
|
|
712
725
|
const files = sourceEntries.map((entry) => entry.filePath).sort();
|
|
713
726
|
const framework = JSON.parse(readFileSync(path.join(FRAMEWORK_DIR, "package.json"), "utf8")).version as string;
|
|
@@ -832,6 +845,8 @@ function collectTokenDescriptions(source: ts.SourceFile): void {
|
|
|
832
845
|
const visit = (node: ts.Node): void => {
|
|
833
846
|
if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && node.arguments.length > 0) {
|
|
834
847
|
const firstArg = node.arguments[0];
|
|
848
|
+
// defineConfig({ default: … }) without a section: its token is Config:default.
|
|
849
|
+
if (node.expression.text === "defineConfig" && firstArg && !ts.isStringLiteralLike(firstArg)) tokenDescriptions.add("Config:default");
|
|
835
850
|
if (firstArg && ts.isStringLiteralLike(firstArg)) {
|
|
836
851
|
if (node.expression.text === "createToken" || node.expression.text === "createOpenGenericTokenFamily") {
|
|
837
852
|
tokenDescriptions.add(firstArg.text);
|
|
@@ -873,7 +888,9 @@ function resolveConfigViewDeps(): void {
|
|
|
873
888
|
if (prefixes.length !== 1) {
|
|
874
889
|
classDeps.delete(declaration);
|
|
875
890
|
if (needsInferredDiDeps(declaration)) {
|
|
876
|
-
fatalErrors.push(
|
|
891
|
+
fatalErrors.push(typeName.startsWith("typeof ")
|
|
892
|
+
? `BAZIS_DI_CONFIG_UNKNOWN: ${sourceLocation(declaration)}: constructor parameter ${index + 1} of "${declaration.name?.text}" has type "ConfigView<${typeName}>", but ${typeName.slice(7)} is not a declaration whose section codegen can read. Declare it as defineConfig("name", { default: {...} }) with a literal name and without a type argument.`
|
|
893
|
+
: prefixes.length === 0
|
|
877
894
|
? `BAZIS_DI_CONFIG_UNKNOWN: ${sourceLocation(declaration)}: constructor parameter ${index + 1} of "${declaration.name?.text}" has type "ConfigView<${typeName}>", but no defineConfig<${typeName}>(...) declaration was found. Declare the configuration with an explicit type argument, for example defineConfig<${typeName}>("name", { default: {...} }).`
|
|
878
895
|
: `BAZIS_DI_CONFIG_AMBIGUOUS: ${sourceLocation(declaration)}: constructor parameter ${index + 1} of "${declaration.name?.text}" has type "ConfigView<${typeName}>", which matches several declarations: ${prefixes.map((prefix) => `"${prefix}"`).join(", ")}. Give each declaration its own type.`);
|
|
879
896
|
}
|
|
@@ -977,6 +994,35 @@ function collectDiClassRegistrations(source: ts.SourceFile): void {
|
|
|
977
994
|
if (properties.has("provide") && properties.has("useClass")
|
|
978
995
|
&& (!properties.has("deps") || properties.get("deps")!.getText() === "undefined")) record(properties.get("useClass"));
|
|
979
996
|
};
|
|
997
|
+
/** The `lookup` classes of a `definePermissions({...})` result: of `scope` and of the attributes in `on`. */
|
|
998
|
+
const permissionLookups = (expression: ts.Expression | undefined): ts.Expression[] => {
|
|
999
|
+
const declared = (node: ts.Expression | undefined, depth = 0): ts.ObjectLiteralExpression | undefined => {
|
|
1000
|
+
if (!node || depth > 5) return undefined;
|
|
1001
|
+
if (ts.isCallExpression(node)) return objectFor(node.arguments[0]);
|
|
1002
|
+
if (ts.isParenthesizedExpression(node) || ts.isAsExpression(node) || ts.isSatisfiesExpression(node)) return declared(node.expression, depth + 1);
|
|
1003
|
+
let symbol = checker.getSymbolAtLocation(node);
|
|
1004
|
+
if (symbol && (symbol.flags & ts.SymbolFlags.Alias)) symbol = checker.getAliasedSymbol(symbol);
|
|
1005
|
+
const declaration = symbol?.valueDeclaration;
|
|
1006
|
+
return declaration && ts.isVariableDeclaration(declaration) ? declared(declaration.initializer, depth + 1) : undefined;
|
|
1007
|
+
};
|
|
1008
|
+
const permissions = declared(expression);
|
|
1009
|
+
const found: ts.Expression[] = [];
|
|
1010
|
+
if (!permissions) return found;
|
|
1011
|
+
const lookupOf = (holder: ts.Expression | undefined): void => {
|
|
1012
|
+
const object = objectFor(holder);
|
|
1013
|
+
const lookup = object && propertiesOf(object).get("lookup");
|
|
1014
|
+
if (lookup) found.push(lookup);
|
|
1015
|
+
};
|
|
1016
|
+
for (const spec of propertiesOf(permissions).values()) {
|
|
1017
|
+
const permission = objectFor(spec);
|
|
1018
|
+
if (!permission) continue;
|
|
1019
|
+
const fields = propertiesOf(permission);
|
|
1020
|
+
lookupOf(fields.get("scope"));
|
|
1021
|
+
const attributes = objectFor(fields.get("on"));
|
|
1022
|
+
if (attributes) for (const attribute of propertiesOf(attributes).values()) lookupOf(attribute);
|
|
1023
|
+
}
|
|
1024
|
+
return found;
|
|
1025
|
+
};
|
|
980
1026
|
const visitedModules = new Set<ts.ObjectLiteralExpression>();
|
|
981
1027
|
const recordModule = (expression: ts.Expression | undefined): void => {
|
|
982
1028
|
const object = objectFor(expression);
|
|
@@ -987,6 +1033,11 @@ function collectDiClassRegistrations(source: ts.SourceFile): void {
|
|
|
987
1033
|
const classes = properties.get(name);
|
|
988
1034
|
if (classes && ts.isArrayLiteralExpression(classes)) for (const entry of classes.elements) record(entry);
|
|
989
1035
|
}
|
|
1036
|
+
// `references: { "orders.order": OrderReferences }`: the field registers the classes.
|
|
1037
|
+
const references = objectFor(properties.get("references"));
|
|
1038
|
+
if (references) for (const entry of propertiesOf(references).values()) record(entry);
|
|
1039
|
+
// `permissions: orderPermissions`: the field registers the `lookup` classes its permissions name.
|
|
1040
|
+
for (const lookup of permissionLookups(properties.get("permissions"))) record(lookup);
|
|
990
1041
|
const imports = properties.get("imports");
|
|
991
1042
|
if (imports && ts.isArrayLiteralExpression(imports)) for (const entry of imports.elements) recordModule(entry);
|
|
992
1043
|
};
|
|
@@ -1086,12 +1137,45 @@ function collectProviderFormErrors(source: ts.SourceFile): void {
|
|
|
1086
1137
|
}
|
|
1087
1138
|
fatalErrors.push(`BAZIS_DI_PROVIDER_FORM: ${sourceLocation(element)}: providers of ${module}: "${text(element)}" is not an agreed registration. A computed service is a class with a constructor: scoped(IService, Service). Agreed forms: ${AGREED_PROVIDERS}.`);
|
|
1088
1139
|
};
|
|
1089
|
-
/**
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1140
|
+
/**
|
|
1141
|
+
* Tools made by a function: a Tool of `defineTool`, a set of them, or a call (or a
|
|
1142
|
+
* spread) that gives them. They carry their own wiring, so codegen has nothing to add.
|
|
1143
|
+
*/
|
|
1144
|
+
const madeToolType = (type: ts.Type, depth = 0): boolean => {
|
|
1145
|
+
if (depth > 3) return false;
|
|
1146
|
+
const alias = type.aliasSymbol;
|
|
1147
|
+
if (alias && (alias.name === "DefinedTool" || alias.name === "ToolSet")
|
|
1148
|
+
&& alias.declarations?.some((entry) => entry.getSourceFile().fileName.replace(/\\/g, "/").endsWith("/core/agent/defineTool.ts"))) return true;
|
|
1149
|
+
if (type.isUnion()) return type.types.every((part) => madeToolType(part, depth + 1));
|
|
1150
|
+
if (checker.isArrayType(type) || checker.isTupleType(type)) {
|
|
1151
|
+
const items = checker.getTypeArguments(type as ts.TypeReference);
|
|
1152
|
+
return items.length > 0 && items.every((item) => madeToolType(item, depth + 1));
|
|
1153
|
+
}
|
|
1154
|
+
if ((type.flags & ts.TypeFlags.Object) === 0 || type.getCallSignatures().length > 0 || type.getConstructSignatures().length > 0) return false;
|
|
1155
|
+
const members = type.getProperties();
|
|
1156
|
+
return members.length > 0 && members.every((member) => madeToolType(checker.getTypeOfSymbol(member), depth + 1));
|
|
1157
|
+
};
|
|
1158
|
+
const isMadeTools = (element: ts.Expression): boolean =>
|
|
1159
|
+
madeToolType(checker.getTypeAtLocation(ts.isSpreadElement(element) ? element.expression : element));
|
|
1160
|
+
/**
|
|
1161
|
+
* `permissions: orderPermissions`: the exported constant goes to the generated
|
|
1162
|
+
* `permissions.ts`, which makes its keys the type of `can` and `defineRoles`.
|
|
1163
|
+
* A framework pack names its own in its sources.
|
|
1164
|
+
*/
|
|
1165
|
+
const collectPermissions = (module: string, initializer: ts.Expression): void => {
|
|
1166
|
+
let symbol = ts.isIdentifier(initializer) ? checker.getSymbolAtLocation(initializer) : undefined;
|
|
1167
|
+
if (symbol && (symbol.flags & ts.SymbolFlags.Alias)) symbol = checker.getAliasedSymbol(symbol);
|
|
1168
|
+
const declaration = symbol?.valueDeclaration;
|
|
1169
|
+
const file = normalizeProjectPath((declaration ?? initializer).getSourceFile().fileName);
|
|
1170
|
+
// A framework pack names its permissions in its own sources.
|
|
1171
|
+
if (file.includes("/node_modules/") || isFrameworkPackPath(file)) return;
|
|
1172
|
+
const fileSymbol = declaration && checker.getSymbolAtLocation(declaration.getSourceFile());
|
|
1173
|
+
const exported = fileSymbol && symbol && checker.getExportsOfModule(fileSymbol).find((entry) => (entry.flags & ts.SymbolFlags.Alias ? checker.getAliasedSymbol(entry) : entry) === symbol);
|
|
1174
|
+
if (!declaration || !ts.isVariableDeclaration(declaration) || exported === undefined) {
|
|
1175
|
+
fatalErrors.push(`BAZIS_ACCESS_PERMISSIONS_FORM: ${sourceLocation(initializer)}: permissions of ${module}: "${text(initializer)}" — export the result of definePermissions(...) as a constant and name it here, so that its keys become the type of can(...) and defineRoles(...).`);
|
|
1176
|
+
return;
|
|
1177
|
+
}
|
|
1178
|
+
permissionDeclarations.set(`${file}\0${exported.name}`, { name: exported.name, file });
|
|
1095
1179
|
};
|
|
1096
1180
|
const checkModule = (module: string, argument: ts.Expression | undefined): void => {
|
|
1097
1181
|
const object = argument && resolved(argument, ts.isObjectLiteralExpression);
|
|
@@ -1099,6 +1183,7 @@ function collectProviderFormErrors(source: ts.SourceFile): void {
|
|
|
1099
1183
|
for (const property of object.properties) {
|
|
1100
1184
|
if (!ts.isPropertyAssignment(property)) continue;
|
|
1101
1185
|
const field = property.name.getText().replace(/["']/g, "");
|
|
1186
|
+
if (field === "permissions") collectPermissions(module, property.initializer);
|
|
1102
1187
|
if (field !== "providers" && !CLASS_LISTS.includes(field)) continue;
|
|
1103
1188
|
const list = resolved(property.initializer, ts.isArrayLiteralExpression);
|
|
1104
1189
|
if (!list) {
|
|
@@ -1107,9 +1192,9 @@ function collectProviderFormErrors(source: ts.SourceFile): void {
|
|
|
1107
1192
|
}
|
|
1108
1193
|
for (const element of list.elements) {
|
|
1109
1194
|
if (field === "providers") checkProvider(module, element);
|
|
1110
|
-
else if (field === "tools" &&
|
|
1195
|
+
else if (field === "tools" && isMadeTools(element)) continue;
|
|
1111
1196
|
else if (!ts.isIdentifier(element) && !ts.isPropertyAccessExpression(element)) {
|
|
1112
|
-
fatalErrors.push(`BAZIS_DI_PROVIDER_FORM: ${sourceLocation(element)}: ${field} of ${module}: "${text(element)}" — list the class itself; its constructor dependencies come from codegen
|
|
1197
|
+
fatalErrors.push(`BAZIS_DI_PROVIDER_FORM: ${sourceLocation(element)}: ${field} of ${module}: "${text(element)}" — list the class itself; its constructor dependencies come from codegen.${field === "tools" ? " A Tool made by a function comes from defineTool(...)." : ""}`);
|
|
1113
1198
|
}
|
|
1114
1199
|
}
|
|
1115
1200
|
}
|
|
@@ -1182,6 +1267,13 @@ function readInheritedConstructorDeps(node: ts.ClassDeclaration): ConstructorDep
|
|
|
1182
1267
|
return { deps };
|
|
1183
1268
|
}
|
|
1184
1269
|
|
|
1270
|
+
/** The section of a `defineConfig("mail", …)` declaration by its type: the literal of its `prefix`. */
|
|
1271
|
+
function configPrefixOf(type: ts.Type): string | undefined {
|
|
1272
|
+
const prefix = type.getProperty("prefix");
|
|
1273
|
+
const literal = prefix && checker.getTypeOfSymbol(prefix);
|
|
1274
|
+
return literal?.isStringLiteral() ? literal.value : undefined;
|
|
1275
|
+
}
|
|
1276
|
+
|
|
1185
1277
|
function dependencyFromType(type: ts.Type): CollectedDependency | undefined {
|
|
1186
1278
|
if (type.flags & ts.TypeFlags.TypeParameter) return undefined;
|
|
1187
1279
|
const symbol = type.getSymbol();
|
|
@@ -1191,8 +1283,11 @@ function dependencyFromType(type: ts.Type): CollectedDependency | undefined {
|
|
|
1191
1283
|
const name = declaration?.name?.text ?? type.aliasSymbol?.name ?? symbol?.name;
|
|
1192
1284
|
if (!name || BUILTIN_DEPENDENCY_NAMES.has(name)) return undefined;
|
|
1193
1285
|
const arguments_ = type.flags & ts.TypeFlags.Object ? checker.getTypeArguments(type as ts.TypeReference) : [];
|
|
1194
|
-
if (name === "ConfigView") {
|
|
1195
|
-
|
|
1286
|
+
if (name === "ConfigView" || name === "ResolvedConfigView") {
|
|
1287
|
+
// ConfigView<typeof mailConfig>: the declaration carries the name of its section in its type.
|
|
1288
|
+
const declared = type.aliasTypeArguments?.[0] && configPrefixOf(type.aliasTypeArguments[0]);
|
|
1289
|
+
if (declared !== undefined) return { name: `Config:${declared}` };
|
|
1290
|
+
const configType = type.aliasTypeArguments?.[0] ?? arguments_[0];
|
|
1196
1291
|
const configTypeName = configType && (configType.aliasSymbol?.name ?? configType.getSymbol()?.name);
|
|
1197
1292
|
return configTypeName ? { name: `ConfigView<${configTypeName}>` } : undefined;
|
|
1198
1293
|
}
|
|
@@ -1244,6 +1339,12 @@ function readConstructorTypeNames(node: ts.ClassDeclaration): ConstructorDepende
|
|
|
1244
1339
|
|
|
1245
1340
|
if (refName === "ConfigView" && paramType && ts.isTypeReferenceNode(paramType)) {
|
|
1246
1341
|
const configType = paramType.typeArguments?.[0];
|
|
1342
|
+
if (configType && ts.isTypeQueryNode(configType)) {
|
|
1343
|
+
// ConfigView<typeof mailConfig>: the declaration carries the name of its section in its type.
|
|
1344
|
+
const declared = configPrefixOf(checker.getTypeFromTypeNode(configType));
|
|
1345
|
+
deps.push({ name: declared === undefined ? `ConfigView<${configType.getText()}>` : `Config:${declared}`, ...optionalFlag });
|
|
1346
|
+
continue;
|
|
1347
|
+
}
|
|
1247
1348
|
const configTypeName = configType ? getDependencyTypeName(configType) : undefined;
|
|
1248
1349
|
if (configTypeName) {
|
|
1249
1350
|
deps.push({ name: `ConfigView<${configTypeName}>`, ...optionalFlag });
|
package/core/testing/index.ts
CHANGED
|
@@ -1,9 +1,8 @@
|
|
|
1
|
-
import { HOSTED_SERVICE, type BazisModuleRef, type DiContainer, type ProviderDefinition } from "../di";
|
|
1
|
+
import { HOSTED_SERVICE, type BazisModuleRef, type DiContainer, type ProviderDefinition, type Token } from "../di";
|
|
2
2
|
import { loadBazisGeneratedRuntime } from "../generatedRuntime";
|
|
3
3
|
import { HttpServer, type HttpModuleOptions } from "../http";
|
|
4
4
|
import { KernelBuilder, type Kernel } from "../kernel";
|
|
5
|
-
import { composeApp } from "../app/runApp";
|
|
6
|
-
import type { RunAppOptions } from "../app";
|
|
5
|
+
import { appOptionsOf, composeApp, type AppSettings } from "../app/runApp";
|
|
7
6
|
|
|
8
7
|
export interface TestContainerOptions {
|
|
9
8
|
/** Replacements for registered providers: `singleton(ITaskStore, FakeStore)`. */
|
|
@@ -23,18 +22,30 @@ export interface TestContainerOptions {
|
|
|
23
22
|
* const container = await createTestContainer(TaskModule, { overrides: [singleton(ITaskStore, FakeStore)] });
|
|
24
23
|
* const tasks = container.createScope().resolve(TaskService);
|
|
25
24
|
* ```
|
|
25
|
+
*
|
|
26
|
+
* Given an `@App` class, the graph also holds its infrastructure, cache and configs.
|
|
26
27
|
*/
|
|
27
28
|
export async function createTestContainer(root: BazisModuleRef, options: TestContainerOptions = {}): Promise<DiContainer> {
|
|
28
29
|
await loadBazisGeneratedRuntime();
|
|
29
|
-
const
|
|
30
|
+
const declared = appOptionsOf(root);
|
|
31
|
+
const graph = declared === undefined ? root : composeApp(root, { infra: declared.infra, cache: declared.cache, config: declared.config, language: declared.language, messages: declared.messages });
|
|
32
|
+
const builder = new KernelBuilder(graph).useOptions({ startupReport: false, environment: "test", signals: [] });
|
|
30
33
|
options.configure?.(builder);
|
|
31
34
|
if (options.overrides !== undefined) builder.useOverrides(options.overrides);
|
|
32
35
|
const kernel: Kernel = await builder.build();
|
|
33
36
|
return kernel.container;
|
|
34
37
|
}
|
|
35
38
|
|
|
36
|
-
|
|
37
|
-
|
|
39
|
+
/**
|
|
40
|
+
* What a test changes in the application it starts. For an `@App` class every
|
|
41
|
+
* field replaces the declared one; for a module it is the whole description.
|
|
42
|
+
*/
|
|
43
|
+
export interface TestAppOptions extends Omit<AppSettings, "http"> {
|
|
44
|
+
/**
|
|
45
|
+
* HTTP options; the test app always listens on 127.0.0.1 and a free port.
|
|
46
|
+
* `false`: no HTTP server. Left out: the `http` of the `@App` class; a module
|
|
47
|
+
* (or an `@App` without `http`, given other HTTP options here) gets a server with the defaults.
|
|
48
|
+
*/
|
|
38
49
|
readonly http?: Omit<HttpModuleOptions, "port" | "hostname"> | false;
|
|
39
50
|
/** Replacements for registered providers, see `createContainer` `overrides`. */
|
|
40
51
|
readonly overrides?: readonly ProviderDefinition[];
|
|
@@ -45,7 +56,16 @@ export interface TestApp {
|
|
|
45
56
|
readonly url: string;
|
|
46
57
|
/** `fetch` relative to {@link url}: `app.fetch("/tasks/1")`. */
|
|
47
58
|
fetch(path: string, init?: RequestInit): Promise<Response>;
|
|
48
|
-
/**
|
|
59
|
+
/**
|
|
60
|
+
* Calls a service the way a request does: in a scope of its own, closed when
|
|
61
|
+
* the work is done.
|
|
62
|
+
*
|
|
63
|
+
* ```ts
|
|
64
|
+
* const task = await app.use(ITaskService, (tasks) => tasks.create("Write the report"));
|
|
65
|
+
* ```
|
|
66
|
+
*/
|
|
67
|
+
use<T, R>(service: Token<T>, work: (service: T) => R | Promise<R>): Promise<R>;
|
|
68
|
+
/** The application container, when {@link use} is not enough: singletons, several services of one scope. */
|
|
49
69
|
readonly container: DiContainer;
|
|
50
70
|
/** Graceful stop, like SIGTERM in a real run. */
|
|
51
71
|
stop(): Promise<void>;
|
|
@@ -57,20 +77,32 @@ export interface TestApp {
|
|
|
57
77
|
* Configuration errors and failing startup throw instead of exiting the process.
|
|
58
78
|
*
|
|
59
79
|
* ```ts
|
|
60
|
-
* const app = await startTestApp(
|
|
80
|
+
* const app = await startTestApp(TodoApp, { overrides: [singleton(ITaskStore, FakeStore)] });
|
|
61
81
|
* try {
|
|
62
82
|
* expect((await app.fetch("/tasks/1")).status).toBe(200);
|
|
63
83
|
* } finally {
|
|
64
84
|
* await app.stop();
|
|
65
85
|
* }
|
|
66
86
|
* ```
|
|
87
|
+
*
|
|
88
|
+
* `root` is the `@App` class: the test starts what production starts, and the
|
|
89
|
+
* options only say what differs. A single module works too, to test a part of
|
|
90
|
+
* the application: `startTestApp(TaskModule, { infra: TestInfra })`.
|
|
67
91
|
*/
|
|
68
92
|
export async function startTestApp(root: BazisModuleRef, options: TestAppOptions = {}): Promise<TestApp> {
|
|
69
93
|
await loadBazisGeneratedRuntime();
|
|
70
|
-
const
|
|
71
|
-
const
|
|
94
|
+
const declared = appOptionsOf(root);
|
|
95
|
+
const { modules: _modules, ...base } = declared ?? { modules: [] };
|
|
96
|
+
const merged: TestAppOptions & { readonly http?: HttpModuleOptions | Omit<HttpModuleOptions, "port" | "hostname"> | false } = { ...base, ...options };
|
|
97
|
+
const { http: _http, overrides, configure, kernel: kernelOptions, ...rest } = merged;
|
|
98
|
+
// A worker application (an `@App` without `http`) stays without a server unless the test asks for one.
|
|
99
|
+
const serve = options.http !== false && (declared === undefined || declared.http !== undefined || options.http !== undefined);
|
|
100
|
+
const http = options.http === false ? undefined : { ...declared?.http, ...options.http };
|
|
101
|
+
const appRoot = composeApp(root, { ...rest, http: serve ? { ...http, hostname: "127.0.0.1", port: 0 } : undefined });
|
|
72
102
|
const builder = new KernelBuilder(appRoot).useOptions({ startupReport: false, ...kernelOptions, environment: kernelOptions?.environment ?? "test", signals: [] });
|
|
73
|
-
|
|
103
|
+
if (declared !== undefined) builder.useApplicationRoot(root);
|
|
104
|
+
declared?.configure?.(builder);
|
|
105
|
+
if (configure !== declared?.configure) configure?.(builder);
|
|
74
106
|
if (overrides !== undefined) builder.useOverrides(overrides);
|
|
75
107
|
const kernel: Kernel = await builder.build();
|
|
76
108
|
await kernel.start();
|
|
@@ -79,6 +111,14 @@ export async function startTestApp(root: BazisModuleRef, options: TestAppOptions
|
|
|
79
111
|
return {
|
|
80
112
|
url,
|
|
81
113
|
container: kernel.container,
|
|
114
|
+
use: async (service, work) => {
|
|
115
|
+
const scope = kernel.container.createScope();
|
|
116
|
+
try {
|
|
117
|
+
return await work(scope.resolve(service));
|
|
118
|
+
} finally {
|
|
119
|
+
await scope.dispose();
|
|
120
|
+
}
|
|
121
|
+
},
|
|
82
122
|
fetch: (path, init) => {
|
|
83
123
|
if (!url) throw new Error("startTestApp was started with http: false.");
|
|
84
124
|
return fetch(`${url}${path}`, init);
|
|
@@ -16,7 +16,7 @@ import { ConfigSecretStore, SecretStore } from "./secrets/SecretStore";
|
|
|
16
16
|
config: [toolsConfig],
|
|
17
17
|
references: { "orm.entity": OrmEntityReferences, "orm.field": OrmFieldReferences },
|
|
18
18
|
tools: [HttpRequestTool],
|
|
19
|
-
providers: [singleton(
|
|
19
|
+
providers: [singleton(SecretStore, ConfigSecretStore)],
|
|
20
20
|
exports: [SecretStore],
|
|
21
21
|
})
|
|
22
22
|
export class StandardToolsModule {}
|
|
@@ -1,29 +1,35 @@
|
|
|
1
|
-
import { defineConfig } from "../../kernel";
|
|
1
|
+
import { configList, defineConfig } from "../../kernel";
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* Settings of the standard Tools. `http.allow` is the
|
|
5
|
-
*
|
|
4
|
+
* Settings of the standard Tools. `http.allow` is the list of hosts `http.request`
|
|
5
|
+
* may reach, comma-separated in the environment: `api.example.com`, `*.example.com`,
|
|
6
|
+
* `localhost:8080`.
|
|
6
7
|
* Empty by default: requests go nowhere. `http.allowPrivateNetworks` lets it reach
|
|
7
8
|
* private and loopback addresses of a listed host: intranet services, tests.
|
|
8
9
|
* `http.callers` says who may call it: `workflow`, `agent`.
|
|
9
10
|
*/
|
|
10
11
|
export interface ToolsSettings {
|
|
11
|
-
readonly "http.allow": string;
|
|
12
|
-
readonly "http.callers": string;
|
|
12
|
+
readonly "http.allow": readonly string[];
|
|
13
|
+
readonly "http.callers": readonly string[];
|
|
13
14
|
readonly "http.allowPrivateNetworks": boolean;
|
|
14
15
|
readonly "http.maxResponseBytes": number;
|
|
15
16
|
readonly "http.timeoutMs": number;
|
|
16
17
|
}
|
|
17
18
|
|
|
19
|
+
/** A host, a subdomain pattern or an IPv6 address in brackets, with an optional port. */
|
|
20
|
+
const HOST = /^(\*\.)?[A-Za-z0-9]([A-Za-z0-9.-]*[A-Za-z0-9])?(:\d{1,5})?$|^\[[0-9A-Fa-f:.]+\](:\d{1,5})?$/;
|
|
21
|
+
|
|
18
22
|
export const toolsConfig = defineConfig<ToolsSettings>("tools", {
|
|
19
23
|
default: {
|
|
20
|
-
"http.allow":
|
|
21
|
-
"http.callers": "workflow",
|
|
24
|
+
"http.allow": configList(),
|
|
25
|
+
"http.callers": configList(["workflow"]),
|
|
22
26
|
"http.allowPrivateNetworks": false,
|
|
23
27
|
"http.maxResponseBytes": 1_048_576,
|
|
24
28
|
"http.timeoutMs": 30_000,
|
|
25
29
|
},
|
|
26
30
|
validate: {
|
|
31
|
+
"http.allow": (value) => value.find((item) => !HOST.test(item)) === undefined ? undefined : `must list hosts like api.example.com, *.example.com or localhost:8080; ${JSON.stringify(value.find((item) => !HOST.test(item)))} is not one`,
|
|
32
|
+
"http.callers": (value) => (value.every((item) => /^[a-z][a-z0-9_.-]{0,63}$/.test(item)) ? undefined : "must list caller ids like workflow, agent"),
|
|
27
33
|
"http.maxResponseBytes": (value) => (Number.isSafeInteger(value) && value >= 1 && value <= 104_857_600 ? undefined : "must be an integer 1–104857600"),
|
|
28
34
|
"http.timeoutMs": (value) => (Number.isSafeInteger(value) && value >= 1 && value <= 600_000 ? undefined : "must be an integer 1–600000"),
|
|
29
35
|
},
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// This file is auto-generated by `bun run di:generate` (framework.packs of bazis.config.json).
|
|
2
2
|
// Do not edit manually.
|
|
3
3
|
import { registerFrameworkClassDeps } from "../../di/module/autoDeps";
|
|
4
|
-
import {
|
|
4
|
+
import { ToolRunner } from "../../agent/ToolRunner";
|
|
5
5
|
import { Configuration } from "../../kernel/config/Configuration";
|
|
6
6
|
import { HttpRequestTool } from "../http/HttpRequest.tool";
|
|
7
7
|
import { OrmEntityReferences, OrmFieldReferences } from "../orm/OrmEntity.references";
|
|
@@ -10,6 +10,6 @@ import { ConfigSecretStore } from "../secrets/SecretStore";
|
|
|
10
10
|
registerFrameworkClassDeps([
|
|
11
11
|
[ConfigSecretStore, [Configuration]],
|
|
12
12
|
[HttpRequestTool, ["Config:tools"]],
|
|
13
|
-
[OrmEntityReferences, [
|
|
14
|
-
[OrmFieldReferences, [
|
|
13
|
+
[OrmEntityReferences, [ToolRunner]],
|
|
14
|
+
[OrmFieldReferences, [ToolRunner]],
|
|
15
15
|
]);
|
|
@@ -82,8 +82,7 @@ export class HttpRequestTool {
|
|
|
82
82
|
constructor(private readonly config: ConfigView<ToolsSettings>) {}
|
|
83
83
|
|
|
84
84
|
async execute(raw: unknown, context: AgentToolExecutionContext): Promise<unknown> {
|
|
85
|
-
|
|
86
|
-
if (context.caller !== undefined && !callers.includes(context.caller)) throw new ToolError(`tools.http.callers does not list ${context.caller}`, { reason: "caller_not_allowed" });
|
|
85
|
+
if (context.caller !== undefined && !this.config.get("http.callers").includes(context.caller)) throw new ToolError(`tools.http.callers does not list ${context.caller}`, { reason: "caller_not_allowed" });
|
|
87
86
|
const request = this.requestOf(raw);
|
|
88
87
|
if (context.dryRun === true && !SAFE_METHODS.has(request.method)) return { dryRun: true };
|
|
89
88
|
const signal = AbortSignal.any([context.signal, AbortSignal.timeout(request.timeoutMs)]);
|
|
@@ -82,9 +82,9 @@ export function isPublicAddress(address: string): boolean {
|
|
|
82
82
|
}
|
|
83
83
|
|
|
84
84
|
/** Whether `tools.http.allow` lets this host and port through: `api.example.com`, `*.example.com`, `localhost:8080`. */
|
|
85
|
-
export function hostAllowed(allow: string, hostname: string, port: string): boolean {
|
|
85
|
+
export function hostAllowed(allow: readonly string[], hostname: string, port: string): boolean {
|
|
86
86
|
const host = hostname.toLowerCase().replace(/\.$/, "");
|
|
87
|
-
return allow.
|
|
87
|
+
return allow.map((item) => item.toLowerCase()).some((item) => {
|
|
88
88
|
// An IPv6 address goes in brackets, as in a URL: [::1]:8080.
|
|
89
89
|
const match = /^\[(.+)\](?::(\d+))?$/.exec(item) ?? /^(.*?)(?::(\d+))?$/.exec(item)!;
|
|
90
90
|
const [name, wanted] = [match[1]!, match[2]];
|
package/core/tools/index.ts
CHANGED
|
@@ -8,4 +8,4 @@ export { StandardToolsModule } from "./StandardTools.module";
|
|
|
8
8
|
export { toolsConfig, type ToolsSettings } from "./contracts/Tools.config";
|
|
9
9
|
export { HttpRequestTool } from "./http/HttpRequest.tool";
|
|
10
10
|
export { ConfigSecretStore, SecretStore, type SecretContext } from "./secrets/SecretStore";
|
|
11
|
-
export { ormEntityTools, type OrmEntityOperation, type OrmEntityToolsOptions } from "./orm/ormEntityTools";
|
|
11
|
+
export { ormEntityTools, type OrmEntityOperation, type OrmEntityToolInputs, type OrmEntityToolOutputs, type OrmEntityToolSet, type OrmEntityToolsOptions, type OrmEntityWhere } from "./orm/ormEntityTools";
|
|
@@ -1,13 +1,12 @@
|
|
|
1
1
|
import type { ReferenceContext, ReferenceItem, ReferencePage } from "../../access";
|
|
2
2
|
import { References } from "../../access";
|
|
3
|
-
import {
|
|
4
|
-
import type { ServiceProvider } from "../../di";
|
|
3
|
+
import type { ToolRunner } from "../../agent";
|
|
5
4
|
import { ormEntityToolInfo, type OrmEntityToolInfo } from "./ormEntityTools";
|
|
6
5
|
|
|
7
6
|
/** What `ormEntityTools` opened in this application: only that is referenced. */
|
|
8
|
-
function opened(
|
|
7
|
+
function opened(tools: ToolRunner): readonly OrmEntityToolInfo[] {
|
|
9
8
|
const entities = new Map<string, Set<string>>();
|
|
10
|
-
for (const tool of
|
|
9
|
+
for (const tool of tools.listTools()) {
|
|
11
10
|
const info = ormEntityToolInfo(tool.target);
|
|
12
11
|
if (info === undefined) continue;
|
|
13
12
|
const fields = entities.get(info.entity) ?? new Set<string>();
|
|
@@ -24,12 +23,12 @@ function page(ids: readonly string[], text: string, { limit, offset }: Reference
|
|
|
24
23
|
|
|
25
24
|
/** Reference kind `orm.entity`: an entity open to processes, `Order`. */
|
|
26
25
|
export class OrmEntityReferences extends References {
|
|
27
|
-
constructor(private readonly
|
|
26
|
+
constructor(private readonly tools: ToolRunner) {
|
|
28
27
|
super();
|
|
29
28
|
}
|
|
30
29
|
|
|
31
30
|
private ids(): readonly string[] {
|
|
32
|
-
return opened(this.
|
|
31
|
+
return opened(this.tools).map((item) => item.entity);
|
|
33
32
|
}
|
|
34
33
|
|
|
35
34
|
async exists(ids: readonly string[]): Promise<ReadonlySet<string>> {
|
|
@@ -48,12 +47,12 @@ export class OrmEntityReferences extends References {
|
|
|
48
47
|
|
|
49
48
|
/** Reference kind `orm.field`: a field open to processes, `Order.status`, or `status` within `Order`. */
|
|
50
49
|
export class OrmFieldReferences extends References {
|
|
51
|
-
constructor(private readonly
|
|
50
|
+
constructor(private readonly tools: ToolRunner) {
|
|
52
51
|
super();
|
|
53
52
|
}
|
|
54
53
|
|
|
55
54
|
private ids(): readonly string[] {
|
|
56
|
-
return opened(this.
|
|
55
|
+
return opened(this.tools).flatMap((item) => item.fields.map((field) => `${item.entity}.${field}`));
|
|
57
56
|
}
|
|
58
57
|
|
|
59
58
|
async exists(ids: readonly string[], context: ReferenceContext): Promise<ReadonlySet<string>> {
|
|
@@ -1,15 +1,14 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import { registerFrameworkClassDeps } from "../../di/module/autoDeps";
|
|
1
|
+
import { defineTool, ToolError, type AgentToolExecutionContext, type DefinedTool } from "../../agent";
|
|
2
|
+
import type { Class } from "../../di";
|
|
4
3
|
import { ModelBuilder, type DbContext, type DbSet, type EntityModel, type FieldSelector, type Operand, type Predicate, type PropertyModel } from "../../orm";
|
|
5
4
|
|
|
6
5
|
/** What a process may do with an entity. */
|
|
7
6
|
export type OrmEntityOperation = "find" | "create" | "update" | "delete";
|
|
8
7
|
|
|
9
|
-
export interface OrmEntityToolsOptions {
|
|
8
|
+
export interface OrmEntityToolsOptions<Op extends OrmEntityOperation = OrmEntityOperation> {
|
|
10
9
|
/** The context of the entity, from `@Module({ ormBazis })`. */
|
|
11
10
|
readonly context: Class<DbContext>;
|
|
12
|
-
readonly ops: readonly
|
|
11
|
+
readonly ops: readonly Op[];
|
|
13
12
|
/** The only fields a Tool reads, filters by, sorts by and writes. The key is always read and filtered by, never written. */
|
|
14
13
|
readonly fields: readonly string[];
|
|
15
14
|
/**
|
|
@@ -28,6 +27,33 @@ export interface OrmEntityToolsOptions {
|
|
|
28
27
|
readonly maxChanges?: number;
|
|
29
28
|
}
|
|
30
29
|
|
|
30
|
+
/** A filter of records: field → a value to equal, or `{ eq, ne, gt, gte, lt, lte, in, nin, contains, startsWith, isNull }`. */
|
|
31
|
+
export type OrmEntityWhere = Readonly<Record<string, unknown>>;
|
|
32
|
+
|
|
33
|
+
/** What each Tool takes, as a step of a process maps it. */
|
|
34
|
+
export interface OrmEntityToolInputs {
|
|
35
|
+
readonly find: {
|
|
36
|
+
readonly where?: OrmEntityWhere;
|
|
37
|
+
readonly sort?: readonly { readonly field: string; readonly dir?: "asc" | "desc" }[];
|
|
38
|
+
readonly limit?: number;
|
|
39
|
+
readonly offset?: number;
|
|
40
|
+
};
|
|
41
|
+
readonly create: { readonly values: Readonly<Record<string, unknown>> };
|
|
42
|
+
readonly update: { readonly where: OrmEntityWhere; readonly values: Readonly<Record<string, unknown>> };
|
|
43
|
+
readonly delete: { readonly where: OrmEntityWhere };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** What each Tool gives back; a trial run adds `dryRun: true` to what would have changed. */
|
|
47
|
+
export interface OrmEntityToolOutputs {
|
|
48
|
+
readonly find: { readonly items: readonly Readonly<Record<string, unknown>>[]; readonly total: number };
|
|
49
|
+
readonly create: { readonly item: Readonly<Record<string, unknown>>; readonly dryRun?: boolean };
|
|
50
|
+
readonly update: { readonly updated: number; readonly dryRun?: boolean };
|
|
51
|
+
readonly delete: { readonly deleted: number; readonly dryRun?: boolean };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The Tools of the listed operations, by operation: `orderTools.find`. */
|
|
55
|
+
export type OrmEntityToolSet<Op extends OrmEntityOperation = OrmEntityOperation> = { readonly [P in Op]: DefinedTool<OrmEntityToolInputs[P], OrmEntityToolOutputs[P]> };
|
|
56
|
+
|
|
31
57
|
/** What the reference kinds `orm.entity` and `orm.field` read about a made Tool. */
|
|
32
58
|
export interface OrmEntityToolInfo {
|
|
33
59
|
readonly entity: string;
|
|
@@ -66,15 +92,20 @@ type Run = (set: DbSet<object>, input: Json, context: AgentToolExecutionContext,
|
|
|
66
92
|
/**
|
|
67
93
|
* Tools `orm.<entity>.find|create|update|delete` over an entity of the application
|
|
68
94
|
* (WORKFLOW-002 §7.4). Nothing is open by itself: the application names the
|
|
69
|
-
* operations and the fields. The result
|
|
70
|
-
*
|
|
71
|
-
*
|
|
95
|
+
* operations and the fields. The result is a set of Tools named by operation: it
|
|
96
|
+
* goes to `@Module({ tools })`, and a process names a Tool of it by reference.
|
|
97
|
+
* The module imports `StandardToolsModule` for the reference kinds `orm.entity`
|
|
98
|
+
* and `orm.field`. All of them run with `context.dryRun`: nothing is written then.
|
|
72
99
|
*
|
|
73
100
|
* ```ts
|
|
74
|
-
*
|
|
101
|
+
* export const orderTools = ormEntityTools(Order, { context: OrdersDbContext, ops: ["find", "update"], fields: ["status", "amount"], partitionField: "shopId" });
|
|
102
|
+
*
|
|
103
|
+
* @Module({ imports: [StandardToolsModule], tools: [orderTools] })
|
|
104
|
+
*
|
|
105
|
+
* serviceTask({ tool: orderTools.find, input: { where: { status: "new" } } })
|
|
75
106
|
* ```
|
|
76
107
|
*/
|
|
77
|
-
export function ormEntityTools(entity: EntityClass<object>, options: OrmEntityToolsOptions):
|
|
108
|
+
export function ormEntityTools<const Op extends OrmEntityOperation>(entity: EntityClass<object>, options: OrmEntityToolsOptions<Op>): OrmEntityToolSet<Op> {
|
|
78
109
|
const model = ModelBuilder.build(entity);
|
|
79
110
|
const name = options.name ?? entity.name.replace(/(?<=[a-z0-9])[A-Z]/g, (letter) => `-${letter}`).toLowerCase();
|
|
80
111
|
const fail = (problem: string) => new TypeError(`ormEntityTools(${entity.name}): ${problem}`);
|
|
@@ -101,10 +132,12 @@ export function ormEntityTools(entity: EntityClass<object>, options: OrmEntityTo
|
|
|
101
132
|
maxChanges,
|
|
102
133
|
};
|
|
103
134
|
const callers = options.callers ?? ["workflow"];
|
|
104
|
-
|
|
135
|
+
const tools: Partial<Record<OrmEntityOperation, DefinedTool>> = {};
|
|
136
|
+
for (const op of new Set<OrmEntityOperation>(options.ops)) {
|
|
105
137
|
if (!Object.hasOwn(OPERATIONS, op)) throw fail(`unknown operation ${JSON.stringify(op)}`);
|
|
106
|
-
|
|
107
|
-
}
|
|
138
|
+
tools[op] = madeTool(spec, op, callers);
|
|
139
|
+
}
|
|
140
|
+
return Object.freeze(tools) as OrmEntityToolSet<Op>;
|
|
108
141
|
}
|
|
109
142
|
|
|
110
143
|
function limit(value: number | undefined, fallback: number, max: number, fail: (problem: string) => Error, name: string): number {
|
|
@@ -113,10 +146,9 @@ function limit(value: number | undefined, fallback: number, max: number, fail: (
|
|
|
113
146
|
return value;
|
|
114
147
|
}
|
|
115
148
|
|
|
116
|
-
function
|
|
149
|
+
function madeTool(spec: Spec, op: OrmEntityOperation, callers: readonly string[]): DefinedTool {
|
|
117
150
|
const { describe, run, sideEffect, input, output } = OPERATIONS[op];
|
|
118
|
-
|
|
119
|
-
@Tool({
|
|
151
|
+
const tool = defineTool({
|
|
120
152
|
name: `orm.${spec.name}.${op}`,
|
|
121
153
|
description: describe(spec.entity.name),
|
|
122
154
|
sideEffect,
|
|
@@ -124,19 +156,13 @@ function toolClass(spec: Spec, op: OrmEntityOperation, callers: readonly string[
|
|
|
124
156
|
dryRun: "supported",
|
|
125
157
|
inputSchema: input(spec),
|
|
126
158
|
outputSchema: output(spec),
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
constructor(private readonly services: ServiceProvider) {}
|
|
130
|
-
|
|
131
|
-
async execute(raw: unknown, context: AgentToolExecutionContext): Promise<unknown> {
|
|
132
|
-
const db = this.services.resolve(spec.context) as DbContext;
|
|
159
|
+
execute: async (raw, context, services) => {
|
|
160
|
+
const db = services.resolve(spec.context) as DbContext;
|
|
133
161
|
return await run(spec)(db.setOf(spec.entity), inputOf(raw), context, db);
|
|
134
|
-
}
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
INFO.set(OrmEntityTool, { entity: spec.entity.name, fields: spec.readable });
|
|
139
|
-
return OrmEntityTool;
|
|
162
|
+
},
|
|
163
|
+
});
|
|
164
|
+
INFO.set(tool, { entity: spec.entity.name, fields: spec.readable });
|
|
165
|
+
return tool;
|
|
140
166
|
}
|
|
141
167
|
|
|
142
168
|
interface Operation {
|