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.
Files changed (132) hide show
  1. package/README.md +7 -4
  2. package/cli/generateProject.ts +11 -18
  3. package/cli/main.ts +9 -1
  4. package/cli/parseCli.ts +9 -0
  5. package/cli/templates/module.ts +16 -19
  6. package/cli/templates/passport.ts +2 -2
  7. package/cli/upgrade/markColumns.ts +151 -0
  8. package/cli/upgrade/runUpgrade.ts +57 -0
  9. package/core/access/Access.module.ts +1 -1
  10. package/core/access/catalog/moduleFields.ts +41 -4
  11. package/core/access/catalog/permissions.ts +10 -0
  12. package/core/access/check/Access.ts +5 -5
  13. package/core/access/check/can.ts +2 -1
  14. package/core/access/index.ts +11 -1
  15. package/core/access/roles/roles.ts +4 -2
  16. package/core/access/testing/index.ts +26 -4
  17. package/core/agent/ToolRunner.ts +7 -1
  18. package/core/agent/defineTool.ts +109 -0
  19. package/core/agent/index.ts +8 -3
  20. package/core/agent/moduleContributions-v1.ts +3 -10
  21. package/core/agent/session/entities.ts +45 -45
  22. package/core/app/index.ts +1 -1
  23. package/core/app/messages.ts +3 -3
  24. package/core/app/runApp.ts +90 -26
  25. package/core/cache/ICache.ts +1 -1
  26. package/core/cache/README.md +4 -2
  27. package/core/cache/SPEC.md +16 -9
  28. package/core/cache/http/missingCacheModuleCheck.ts +5 -4
  29. package/core/cache/internal/buildCacheProviders.ts +4 -5
  30. package/core/cache/providers/cachedProviders.ts +1 -1
  31. package/core/di/index.ts +0 -4
  32. package/core/di/lowLevel.ts +12 -0
  33. package/core/di/module/index.ts +0 -3
  34. package/core/di/module/shortcuts.ts +31 -86
  35. package/core/generatedRuntime.ts +11 -0
  36. package/core/grpc/Grpc.module.ts +2 -1
  37. package/core/grpc/grpcClients.ts +2 -1
  38. package/core/http/Authorization/metadata.ts +1 -1
  39. package/core/http/Decorators/metadata.ts +3 -0
  40. package/core/http/Decorators/routes.ts +12 -0
  41. package/core/http/Errors/HttpError.ts +16 -2
  42. package/core/http/Http.config.ts +22 -0
  43. package/core/http/HttpContext/HttpContext.ts +1 -1
  44. package/core/http/HttpServer.ts +29 -9
  45. package/core/http/Middleware/errorHandler.ts +16 -1
  46. package/core/http/OpenApi/openApiDocument.ts +44 -5
  47. package/core/http/Results/normalize.ts +38 -1
  48. package/core/http/Results/results.ts +5 -2
  49. package/core/http/Routing/RouterBuilder.ts +27 -2
  50. package/core/http/httpModule.ts +10 -8
  51. package/core/http/index.ts +4 -0
  52. package/core/http/middlewareTokens.ts +12 -0
  53. package/core/http/options.ts +5 -1
  54. package/core/http-client/httpClients.ts +2 -1
  55. package/core/infra/Infra.ts +3 -3
  56. package/core/infra/connectors/codex/CodexAppServerClient.ts +1 -1
  57. package/core/infra/connectors/codex/contracts.ts +3 -3
  58. package/core/infra/connectors/llm.ts +2 -1
  59. package/core/infra/connectors/postgres.ts +4 -2
  60. package/core/infra/connectors/redis.ts +2 -1
  61. package/core/infra/index.ts +1 -1
  62. package/core/kernel/KernelBuilder.ts +2 -1
  63. package/core/kernel/config/ConfigRegistry.ts +2 -1
  64. package/core/kernel/config/addConfigOptions.ts +2 -1
  65. package/core/kernel/config/defineConfig.ts +56 -20
  66. package/core/kernel/events/EventBus.ts +1 -0
  67. package/core/kernel/events/eventSubscribers.ts +1 -1
  68. package/core/kernel/events/onEvent.ts +2 -1
  69. package/core/kernel/index.ts +3 -0
  70. package/core/orm/buildOrmProviders.ts +4 -2
  71. package/core/orm/contextEntities.ts +36 -0
  72. package/core/orm/databaseConnector.ts +9 -3
  73. package/core/orm/databaseHttpError.ts +21 -0
  74. package/core/orm/encryptedWith.ts +1 -1
  75. package/core/orm/index.ts +12 -1
  76. package/core/orm/ormModule.ts +19 -8
  77. package/core/orm/ownedStoreContributions.ts +2 -1
  78. package/core/orm/registerRepositories.ts +2 -1
  79. package/core/scripts/di-generate.ts +113 -12
  80. package/core/testing/index.ts +51 -11
  81. package/core/tools/StandardTools.module.ts +1 -1
  82. package/core/tools/contracts/Tools.config.ts +13 -7
  83. package/core/tools/generated/deps.ts +3 -3
  84. package/core/tools/http/HttpRequest.tool.ts +1 -2
  85. package/core/tools/http/publicAddress.ts +2 -2
  86. package/core/tools/index.ts +1 -1
  87. package/core/tools/orm/OrmEntity.references.ts +7 -8
  88. package/core/tools/orm/ormEntityTools.ts +54 -28
  89. package/core/websocket/DESIGN.md +5 -3
  90. package/core/websocket/middleware.ts +1 -1
  91. package/core/websocket/websocketModule.ts +3 -3
  92. package/core/workflow/contracts/Workflow.config.ts +5 -5
  93. package/core/workflow/definitions_module/WorkflowDefinitions.module.ts +3 -4
  94. package/core/workflow/definitions_module/code/defineProcess.ts +213 -11
  95. package/core/workflow/definitions_module/code/inspectProcesses.ts +12 -2
  96. package/core/workflow/definitions_module/http/Processes.controller.ts +2 -2
  97. package/core/workflow/definitions_module/internal/PublishedVersions.ts +39 -0
  98. package/core/workflow/definitions_module/model/WfProcess.model.ts +24 -11
  99. package/core/workflow/definitions_module/model/WfVersion.model.ts +7 -1
  100. package/core/workflow/definitions_module/services/IWorkflowDefinitions.service.ts +4 -1
  101. package/core/workflow/definitions_module/services/WorkflowDefinitions.service.ts +17 -7
  102. package/core/workflow/generated/deps.ts +2 -1
  103. package/core/workflow/index.ts +23 -1
  104. package/core/workflow/internal/WorkflowLanguages.ts +1 -1
  105. package/core/workflow/internal/http.ts +2 -2
  106. package/core/workflow/runtime_module/background/WorkflowWorker.ts +6 -2
  107. package/core/workflow/runtime_module/http/Instances.controller.ts +1 -1
  108. package/core/workflow/runtime_module/internal/WorkflowJobs.ts +43 -1
  109. package/core/workflow/runtime_module/model/WfHistory.model.ts +13 -6
  110. package/core/workflow/runtime_module/model/WfInstance.model.ts +30 -10
  111. package/core/workflow/runtime_module/model/WfJob.model.ts +22 -10
  112. package/core/workflow/runtime_module/model/WfSubscription.model.ts +8 -3
  113. package/core/workflow/runtime_module/model/WfToken.model.ts +16 -7
  114. package/core/workflow/runtime_module/services/IWorkflowRuntime.service.ts +10 -6
  115. package/core/workflow/runtime_module/services/WorkflowRuntime.service.ts +5 -2
  116. package/core/workflow/tasks_module/model/WfTask.model.ts +25 -9
  117. package/core/workflow/testing/index.ts +46 -4
  118. package/index.ts +4 -3
  119. package/library/openapi/codegen.ts +2 -2
  120. package/library/orm/DbContext.ts +37 -1
  121. package/library/orm/Metadata/ModelBuilder.ts +23 -7
  122. package/library/orm/Metadata/decorators.ts +23 -10
  123. package/library/orm/Metadata/types.ts +2 -0
  124. package/library/orm/Transactions/TransactionScopeCoordinator.ts +3 -3
  125. package/library/orm/errors.ts +39 -1
  126. package/library/orm/index.ts +1 -0
  127. package/library/terminal/messages.ts +6 -6
  128. package/library/validation/README.md +1 -1
  129. package/library/validation/SPEC.md +1 -1
  130. package/library/workflow/index.ts +1 -0
  131. package/library/workflow/nodeConfigs.ts +130 -0
  132. 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(prefixes.length === 0
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
- /** Tools made from an entity: they are classes too, and carry their own wiring. */
1090
- const isOrmEntityTools = (element: ts.Expression): boolean => {
1091
- if (!ts.isSpreadElement(element)) return false;
1092
- const call = resolved(element.expression, ts.isCallExpression);
1093
- const target = call && callee(call);
1094
- return target?.name === "ormEntityTools" && target.file?.endsWith("/core/tools/orm/ormEntityTools.ts") === true;
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" && isOrmEntityTools(element)) continue;
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
- const configType = arguments_[0];
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 });
@@ -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 builder = new KernelBuilder(root).useOptions({ startupReport: false, environment: "test", signals: [] });
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
- export interface TestAppOptions extends Omit<RunAppOptions, "http"> {
37
- /** HTTP options; the test app always listens on 127.0.0.1 and a free port. `false`: no HTTP server. */
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
- /** The application container: `app.container.createScope().resolve(TaskService)`. */
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(AppModule, { overrides: [singleton(ITaskStore, FakeStore)] });
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 { http, overrides, configure, kernel: kernelOptions, ...rest } = options;
71
- const appRoot = composeApp(root, { ...rest, http: http === false ? undefined : { ...http, hostname: "127.0.0.1", port: 0 } });
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
- configure?.(builder);
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(OrmEntityReferences), singleton(OrmFieldReferences), singleton(SecretStore, ConfigSecretStore)],
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 comma-separated list of hosts
5
- * `http.request` may reach: `api.example.com`, `*.example.com`, `localhost:8080`.
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 { ServiceProvider } from "../../di/ServiceProvider";
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, [ServiceProvider]],
14
- [OrmFieldReferences, [ServiceProvider]],
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
- const callers = this.config.get("http.callers").split(",").map((item) => item.trim());
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.split(",").map((item) => item.trim().toLowerCase()).filter((item) => item !== "").some((item) => {
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]];
@@ -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 { AgentRegistry } from "../../agent/AgentRegistry";
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(services: ServiceProvider): readonly OrmEntityToolInfo[] {
7
+ function opened(tools: ToolRunner): readonly OrmEntityToolInfo[] {
9
8
  const entities = new Map<string, Set<string>>();
10
- for (const tool of AgentRegistry.fromContainer(services).listTools()) {
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 services: ServiceProvider) {
26
+ constructor(private readonly tools: ToolRunner) {
28
27
  super();
29
28
  }
30
29
 
31
30
  private ids(): readonly string[] {
32
- return opened(this.services).map((item) => item.entity);
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 services: ServiceProvider) {
50
+ constructor(private readonly tools: ToolRunner) {
52
51
  super();
53
52
  }
54
53
 
55
54
  private ids(): readonly string[] {
56
- return opened(this.services).flatMap((item) => item.fields.map((field) => `${item.entity}.${field}`));
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 { Tool, ToolError, type AgentToolExecutionContext } from "../../agent";
2
- import { type Class, ServiceProvider } from "../../di";
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 OrmEntityOperation[];
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 goes to `@Module({ tools })`, and the
70
- * module imports `StandardToolsModule` for the reference kinds `orm.entity` and
71
- * `orm.field`. All of them run with `context.dryRun`: nothing is written then.
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
- * @Module({ imports: [StandardToolsModule], tools: [...ormEntityTools(Order, { context: OrdersDbContext, ops: ["find", "update"], fields: ["status", "amount"], partitionField: "shopId" })] })
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): Class<object>[] {
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
- return [...new Set(options.ops)].map((op) => {
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
- return toolClass(spec, op, callers);
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 toolClass(spec: Spec, op: OrmEntityOperation, callers: readonly string[]): Class<object> {
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
- class OrmEntityTool {
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
- registerFrameworkClassDeps([[OrmEntityTool, [ServiceProvider]]]);
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 {