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
package/README.md CHANGED
@@ -142,7 +142,8 @@ import { dbConfig } from "../config/db.config"; // defineConfig("db", { default:
142
142
  export class AppInfra {}
143
143
 
144
144
  // src/index.ts
145
- await runApp(AppModule, { infra: AppInfra, http: { port: 3000, health: true } });
145
+ @App({ modules: [AppModule], infra: AppInfra, http: { health: true } })
146
+ export class Application {}
146
147
  ```
147
148
 
148
149
  A feature module attaches its own `DbContext` and entities with
@@ -180,11 +181,13 @@ import { CodexError, RU_CODEX_MESSAGES } from "bazis/core/infra";
180
181
  import { RU_UI_LABELS } from "bazis/core/app";
181
182
 
182
183
  CodexError.useMessages(RU_CODEX_MESSAGES); // Codex connector errors
183
- await runApp(AppModule, {
184
+ @App({
185
+ modules: [AppModule],
184
186
  language: "ru", // response texts: validation, 404/405, 401/403, binding errors
185
187
  messages: { notFound: "Нет такой страницы" }, // optional: own texts for single keys
186
- ui: { labels: RU_UI_LABELS, surfaces: [/* ... */] }, // generated UI texts
187
- });
188
+ ui: { labels: RU_UI_LABELS, surfaces: [/* ... */] }, // generated UI texts,
189
+ })
190
+ export class Application {}
188
191
  ```
189
192
 
190
193
  `language` is per application: it is applied when the response is written, so
@@ -169,39 +169,32 @@ function buildProjectFiles(name: string, dependency: string, frameworkMode: Fram
169
169
  ["bazis.config.json", `${JSON.stringify(codegen, null, 2)}\n`],
170
170
  // Bun reads .env, .env.local and .env.<NODE_ENV>: none of them belongs in git.
171
171
  [".gitignore", "node_modules/\nbin/\nsrc/generated/\n.env\n.env.*\n!.env.example\n"],
172
- [".env.example", "# Copy to .env (Bun loads it automatically).\nBAZIS_ENV=development\nHOST=127.0.0.1\nPORT=3000\n"],
172
+ [".env.example", "# Copy to .env (Bun loads it automatically).\nBAZIS_ENV=development\nBAZIS_HTTP__HOST=127.0.0.1\nBAZIS_HTTP__PORT=3000\n"],
173
173
  ["src/app/test/health.test.ts", HEALTH_TEST],
174
174
  ["AGENTS.md", "# Working on this bazis project\n\nBefore changing the application, read the [module architecture](docs/architecture/MODULE_ARCHITECTURE.md). Create new modules only with `bunx bazis g module` or `bunx bazis g pack`; fill in the generated `MODULE.md` afterwards. Only codegen updates `src/generated/`.\n"],
175
- ["docs/architecture/MODULE_ARCHITECTURE.md", "# Application module architecture\n\n`src/index.ts` calls `runApp`; `src/app/modules/App.module.ts` composes feature modules through `imports`. The application root owns no domain logic.\n\nOne self-contained function is an atomic module. It owns its data, services, HTTP and background handlers. A composite module is only for several independent functions; its root does composition. Layers and file counts alone do not create submodules.\n\nCreate new modules only with `bunx bazis g module <Name> --empty|--minimal|--full` or `bunx bazis g pack <Name> --parts <a,b>`; add a part to an existing composite with `bunx bazis g module <Name> --pack <Pack>`. The generated `MODULE.md` records the creation command. Before implementing, define the responsibility and public entries, then fill in the generated `MODULE.md`: fields, errors, dependencies, exports and checks. Use the public APIs of the `bazis` package and its DI and ORM. Do not edit `src/generated/` by hand; run `bunx bazis codegen`.\n\n`--minimal` creates a sample CRUD with the ORM. To run it the application needs a database provider and a ready schema. For a first function without a database use `--empty`. Check types and the binary build after changes that affect startup.\n"],
175
+ ["docs/architecture/MODULE_ARCHITECTURE.md", "# Application module architecture\n\n`src/index.ts` calls `runApp(Application)`; `src/app/App.ts` declares the application with `@App`: its modules, infrastructure and servers; `src/app/modules/App.module.ts` composes feature modules through `imports`. The application root owns no domain logic.\n\nOne self-contained function is an atomic module. It owns its data, services, HTTP and background handlers. A composite module is only for several independent functions; its root does composition. Layers and file counts alone do not create submodules.\n\nCreate new modules only with `bunx bazis g module <Name> --empty|--minimal|--full` or `bunx bazis g pack <Name> --parts <a,b>`; add a part to an existing composite with `bunx bazis g module <Name> --pack <Pack>`. The generated `MODULE.md` records the creation command. Before implementing, define the responsibility and public entries, then fill in the generated `MODULE.md`: fields, errors, dependencies, exports and checks. Use the public APIs of the `bazis` package and its DI and ORM. Do not edit `src/generated/` by hand; run `bunx bazis codegen`.\n\n`--minimal` creates a sample CRUD with the ORM. To run it the application needs a database provider and a ready schema. For a first function without a database use `--empty`. Check types and the binary build after changes that affect startup.\n"],
176
176
  ["src/app/modules/App.module.ts", 'import { Module } from "bazis/core/di";\n\n@Module({ imports: [], exports: [] })\nexport class AppModule {}\n'],
177
- ["src/index.ts", 'import { runApp } from "bazis/core/app";\nimport { AppModule } from "./app/modules/App.module";\nimport { registerBazisGeneratedRuntime } from "./generated/bazis/runtime";\n\nawait registerBazisGeneratedRuntime();\nawait runApp(AppModule, { http: { hostname: process.env.HOST ?? "127.0.0.1", port: Number(process.env.PORT ?? 3000), health: true } });\n'],
177
+ ["src/app/App.ts", 'import { App } from "bazis/core/app";\nimport { AppModule } from "./modules/App.module";\n\n/** The application: its modules and the servers it starts. `src/index.ts` runs it, tests start the same one. */\n@App({\n modules: [AppModule],\n http: { health: true },\n})\nexport class Application {}\n'],
178
+ ["src/index.ts", 'import { runApp } from "bazis/core/app";\nimport { Application } from "./app/App";\nimport { registerBazisGeneratedRuntime } from "./generated/bazis/runtime";\n\nawait registerBazisGeneratedRuntime();\nawait runApp(Application);\n'],
178
179
  ["README.md", `# ${name}\n\nA [bazis](https://www.npmjs.com/package/bazis) application. ${frameworkMode === "registry"
179
180
  ? "The framework comes from npm as the `bazis` dependency; update it with `bun update bazis`."
180
181
  : frameworkMode === "snapshot"
181
182
  ? "The framework package is copied to `vendor/bazis`; keep it in Git and move it with the project. The framework checkout is no longer needed. Framework updates do not reach this copy automatically."
182
- : "The `bazis` dependency links an external local checkout (`--link-framework`). Moving the project needs the same package and an updated path in `package.json`."}\nRead the [local architecture](docs/architecture/MODULE_ARCHITECTURE.md) before changing modules.\n\nNeeds Bun ≥ 1.4.0. Settings go to \`.env\` (example: \`.env.example\`).\n\n\`\`\`sh\nbun install\nbunx bazis dev\n# GET http://127.0.0.1:3000/health\n# If the port is busy: PORT=3100 bunx bazis dev\n\`\`\`\n\nAdd an atomic module from the project root: \`bunx bazis g module Task --empty\`.\nAfter filling in its passport and implementation, run \`bunx bazis codegen\`.\nTests: \`bunx bazis test\`. Typecheck: \`bunx bazis build\`. Binary: \`bunx bazis build --bin\`, run it with \`bun run start\`.\n\n\`--minimal\` creates a sample CRUD with paging (20 records by default, at most 100 over HTTP); it needs a database provider and a schema to run. The service returns \`PageResult\`; the HTTP controller builds JSON:API. \`DbContext.saveChanges()\` saves every change of its context. In ORM predicates combine conditions with \`.and()\` and \`.or()\`; codegen rejects \`&&\` and \`||\` between predicates.\n`],
183
+ : "The `bazis` dependency links an external local checkout (`--link-framework`). Moving the project needs the same package and an updated path in `package.json`."}\nRead the [local architecture](docs/architecture/MODULE_ARCHITECTURE.md) before changing modules.\n\nNeeds Bun ≥ 1.4.0. Settings go to \`.env\` (example: \`.env.example\`).\n\n\`\`\`sh\nbun install\nbunx bazis dev\n# GET http://127.0.0.1:3000/health\n# If the port is busy: BAZIS_HTTP__PORT=3100 bunx bazis dev\n\`\`\`\n\nAdd an atomic module from the project root: \`bunx bazis g module Task --empty\`.\nAfter filling in its passport and implementation, run \`bunx bazis codegen\`.\nTests: \`bunx bazis test\`. Typecheck: \`bunx bazis build\`. Binary: \`bunx bazis build --bin\`, run it with \`bun run start\`.\n\n\`--minimal\` creates a sample CRUD with paging (20 records by default, at most 100 over HTTP); it needs a database provider and a schema to run. The service returns \`PageResult\`; the HTTP controller builds JSON:API. \`DbContext.saveChanges()\` saves every change of its context. In ORM predicates combine conditions with \`.and()\` and \`.or()\`; codegen rejects \`&&\` and \`||\` between predicates.\n`],
183
184
  ];
184
185
  }
185
186
 
186
187
  const HEALTH_TEST = `import { expect, test } from "bun:test";
188
+ import { startTestApp } from "bazis/core/testing";
189
+ import { Application } from "../App";
187
190
 
188
- // Starts the app from source exactly as \`bazis dev\` does and checks /health.
191
+ // Starts the application declared in src/app/App.ts on a free port and checks /health.
189
192
  test("app starts and answers /health", async () => {
190
- // A free port from the operating system, not a random guess that may be taken.
191
- const probe = Bun.serve({ hostname: "127.0.0.1", port: 0, fetch: () => new Response() });
192
- const port = String(probe.port);
193
- probe.stop(true);
194
- const app = Bun.spawn([process.execPath, "run", "src/index.ts"], { env: { ...process.env, HOST: "127.0.0.1", PORT: port }, stdout: "ignore", stderr: "inherit" });
193
+ const app = await startTestApp(Application);
195
194
  try {
196
- let status = 0;
197
- for (let attempt = 0; attempt < 100 && status !== 200; attempt++) {
198
- await Bun.sleep(100);
199
- status = await fetch(\`http://127.0.0.1:\${port}/health\`).then((response) => response.status, () => 0);
200
- }
201
- expect(status).toBe(200);
195
+ expect((await app.fetch("/health")).status).toBe(200);
202
196
  } finally {
203
- app.kill();
204
- await app.exited;
197
+ await app.stop();
205
198
  }
206
199
  });
207
200
  `;
package/cli/main.ts CHANGED
@@ -18,6 +18,8 @@ export const USAGE = `Usage:
18
18
  for a part of a composite module)
19
19
  bazis workflow check [--app-module <path>] Check the processes of the modules against their Tools
20
20
  bazis workflow diagram <key> Print a process as a Mermaid flowchart
21
+ bazis upgrade columns [--dry-run] For 0.137.0: mark every column @Required() or @Nullable() by what
22
+ it was, so the schema stays as it is (--path <dir>, default src)
21
23
  bazis codegen [--target <name|all>] Generate DI wiring into src/generated/bazis
22
24
  bazis dev [--watch] Codegen, then run the app from source with
23
25
  BAZIS_ENV=development (unless set);
@@ -46,7 +48,7 @@ Generation options:
46
48
  --minimal Compact example CRUD and MODULE.md (default)
47
49
  --full Example CRUD/list/cache/auth/background/AI and MODULE.md;
48
50
  uses src/app/modules/auth helpers when the project has them.
49
- To run, the host needs a cache (runApp({ cache: memory() }))
51
+ To run, the host needs a cache (@App({ cache: memory() }))
50
52
  and a database provider. Legacy alias: --enterprise
51
53
  --parts <a,b,...> Independent empty atomic parts; pack only, at least two
52
54
  --dry-run Validate and list planned changes without writing
@@ -143,6 +145,12 @@ export async function runCli(argv: readonly string[], runtime: CliRuntime = defa
143
145
  if (parsed.kind === "test") return await runTest(cwd, parsed.args, codegen, ui);
144
146
  if (parsed.kind === "build") return await runBuild(cwd, { bin: parsed.bin, outfile: parsed.outfile, dotenv: parsed.dotenv, platform: parsed.platform }, codegen, ui);
145
147
  if (parsed.kind === "new") return await runNew(parsed, ui);
148
+ if (parsed.kind === "upgrade") {
149
+ // The codemod parses sources with the TypeScript API: load it for this project first.
150
+ useTypeScriptCompilerApi(cwd);
151
+ const { runUpgradeColumns } = await import("./upgrade/runUpgrade");
152
+ return await runUpgradeColumns(cwd, parsed.path, parsed.dryRun, runtime.log);
153
+ }
146
154
  if (parsed.kind === "workflow") {
147
155
  const { runWorkflowCheck, runWorkflowDiagram } = await import("./workflow");
148
156
  return parsed.action === "check" ? await runWorkflowCheck(cwd, parsed.appModule, ui) : await runWorkflowDiagram(cwd, parsed.key, parsed.appModule, runtime.log);
package/cli/parseCli.ts CHANGED
@@ -37,6 +37,7 @@ export type ParseCliResult =
37
37
  | { readonly kind: "workflow"; readonly action: "check"; readonly appModule?: string }
38
38
  | { readonly kind: "workflow"; readonly action: "diagram"; readonly key: string; readonly appModule?: string }
39
39
  | { readonly kind: "codegen"; readonly target?: string }
40
+ | { readonly kind: "upgrade"; readonly action: "columns"; readonly path?: string; readonly dryRun: boolean }
40
41
  | { readonly kind: "dev"; readonly watch: boolean }
41
42
  | { readonly kind: "test"; readonly args: readonly string[] }
42
43
  | { readonly kind: "build"; readonly bin: boolean; readonly outfile?: string; readonly dotenv: boolean; readonly platform?: string }
@@ -127,6 +128,14 @@ export function parseCliArgs(argv: readonly string[]): ParseCliResult {
127
128
  }
128
129
  return { kind: "codegen", target };
129
130
  }
131
+ if (command === "upgrade") {
132
+ for (const option of options.keys()) {
133
+ if (option !== "--path" && option !== "--dry-run") return error(`Option ${option} is not supported by upgrade.`);
134
+ }
135
+ if (generatorToken !== "columns" || positional.length !== 2) return error("Use: bazis upgrade columns [--path <directory>] [--dry-run]");
136
+ const directory = options.get("--path");
137
+ return { kind: "upgrade", action: "columns", ...(directory === undefined ? {} : { path: directory }), dryRun: options.has("--dry-run") };
138
+ }
130
139
  if (command === "workflow") {
131
140
  for (const option of options.keys()) {
132
141
  if (option !== "--app-module") return error(`Option ${option} is not supported by workflow.`);
@@ -68,7 +68,6 @@ function moduleFile(n: ModuleNaming, profile: ModuleTemplateProfile): string {
68
68
 
69
69
  return `${moduleImport}
70
70
  ${aiImports}${backgroundImport}import { ${n.module}Controller } from "./http/${n.module}.controller";
71
- import { ${n.entity} } from "./model/${n.module}.model";
72
71
  import { ${n.module}DbContext } from "./model/${n.module}.dbContext";
73
72
  import { I${n.module}Service } from "./services/I${n.module}.service";
74
73
  import { ${n.module}Service } from "./services/${n.module}.service";
@@ -76,7 +75,6 @@ import { ${n.module}Service } from "./services/${n.module}.service";
76
75
  @Module({
77
76
  ormBazis: {
78
77
  context: ${n.module}DbContext,
79
- entities: [${n.entity}],
80
78
  },
81
79
  controllers: [${n.module}Controller],
82
80
  providers: [
@@ -91,15 +89,15 @@ export class ${n.moduleClass} {}
91
89
  function controllerFile(n: ModuleNaming, profile: ModuleTemplateProfile, authImportPath: string | null): string {
92
90
  const auth = profile === "full" && authImportPath !== null;
93
91
  const fullImports = profile !== "full"
94
- ? "import { Controller, Created, Delete, Get, HttpContext, NoContent, NotFound, Ok, Post, Put, type FromBody, type FromQuery, type FromRoute } from \"bazis/core/http\";\n"
92
+ ? "import { Controller, Created, Delete, Get, HttpContext, NoContent, NotFound, Post, Put, type FromBody, type FromQuery, type FromRoute } from \"bazis/core/http\";\n"
95
93
  : auth
96
94
  ? `import { OutputCache } from "bazis/core/cache";
97
- import { Authorize, Controller, Created, Delete, Get, HttpContext, NoContent, NotFound, Ok, Post, Put, type FromBody, type FromQuery, type FromRoute } from "bazis/core/http";
95
+ import { Authorize, Controller, Created, Delete, Get, HttpContext, NoContent, NotFound, Post, Put, type FromBody, type FromQuery, type FromRoute } from "bazis/core/http";
98
96
  import { TokenKind } from "${authImportPath}/tokenKinds";
99
97
  import { requireTokenKind } from "${authImportPath}/jwtAuth";
100
98
  `
101
99
  : `import { OutputCache } from "bazis/core/cache";
102
- import { Controller, Created, Delete, Get, HttpContext, NoContent, NotFound, Ok, Post, Put, type FromBody, type FromQuery, type FromRoute } from "bazis/core/http";
100
+ import { Controller, Created, Delete, Get, HttpContext, NoContent, NotFound, Post, Put, type FromBody, type FromQuery, type FromRoute } from "bazis/core/http";
103
101
  // No host auth helpers (src/app/modules/auth) were found: these routes are public.
104
102
  // Add @Authorize(...) from "bazis/core/http" with your policy before exposing them.
105
103
  `;
@@ -125,28 +123,25 @@ ${authList} @Get()
125
123
  return buildListDocument(items, query, total, { basePath: ctx.path });
126
124
  }
127
125
 
128
- ${authRead} @Get(":id(uuid)")
129
- async getById(id: FromRoute<string>) {
130
- const item = await this.${n.collection}.getById(id);
131
- return item ? Ok(item) : NotFound({ error: \`${n.entity.toLowerCase()} \${id} not found\` });
126
+ ${authRead} @Get(":id(uuid)", { nullAs: NotFound })
127
+ getById(id: FromRoute<string>) {
128
+ return this.${n.collection}.getById(id);
132
129
  }
133
130
 
134
131
  ${authWrite} @Post()
135
- async create(body: FromBody<Create${n.entity}Request>, ctx: HttpContext) {
136
- const item = await this.${n.collection}.create(body);
137
- return Created(\`\${ctx.path.replace(/\\/$/, "")}/\${item.id}\`, item);
132
+ async create(body: FromBody<Create${n.entity}Request>) {
133
+ const result = await this.${n.collection}.create(body);
134
+ return Created(result);
138
135
  }
139
136
 
140
- ${authWrite} @Put(":id(uuid)")
141
- async update(id: FromRoute<string>, body: FromBody<Update${n.entity}Request>) {
142
- const item = await this.${n.collection}.update(id, body);
143
- return item ? Ok(item) : NotFound({ error: \`${n.entity.toLowerCase()} \${id} not found\` });
137
+ ${authWrite} @Put(":id(uuid)", { nullAs: NotFound })
138
+ update(id: FromRoute<string>, body: FromBody<Update${n.entity}Request>) {
139
+ return this.${n.collection}.update(id, body);
144
140
  }
145
141
 
146
142
  ${authWrite} @Delete(":id(uuid)")
147
143
  async delete(id: FromRoute<string>) {
148
- const removed = await this.${n.collection}.delete(id);
149
- return removed ? NoContent() : NotFound({ error: \`${n.entity.toLowerCase()} \${id} not found\` });
144
+ return await this.${n.collection}.delete(id) ? NoContent() : NotFound();
150
145
  }
151
146
  }
152
147
  `;
@@ -155,17 +150,19 @@ ${authWrite} @Delete(":id(uuid)")
155
150
  function ormModelFile(n: ModuleNaming, profile: ModuleTemplateProfile): string {
156
151
  const schemaImport = profile === "full" ? ", Schema" : "";
157
152
  const schemaDecorator = profile === "full" ? `@Schema("${n.dbSchema}")\n` : "";
158
- return `import { Column, Entity, Index, UUID${schemaImport} } from "bazis/core/orm";
153
+ return `import { Column, Entity, Index, Required, UUID${schemaImport} } from "bazis/core/orm";
159
154
 
160
155
  ${schemaDecorator}@Entity({ table: "${n.route}" })
161
156
  export class ${n.entity} {
162
157
  @UUID()
163
158
  id = "";
164
159
 
160
+ @Required()
165
161
  @Column({ type: "text" })
166
162
  name = "";
167
163
 
168
164
  @Index({ unique: true })
165
+ @Required()
169
166
  @Column({ type: "text" })
170
167
  email = "";
171
168
 
@@ -76,14 +76,14 @@ exports: [I${m}Service]. TypeScript contract: services/I${e}.service.ts;
76
76
  HTTP access is defined by the controller and the host policy, separately from DI exports.
77
77
  I${m}Service → ${m}Service, scoped lifetime. The context and DbSet belong to the ORM.
78
78
  ${profile === "full" ? `${m}SummaryTool is listed in tools: [${m}SummaryTool]; the framework registers it as scoped, the tool is not exported.
79
- The service is scoped; its keyed @Cacheable works when the host installs the cache (runApp cache: memory()).
79
+ The service is scoped; its keyed @Cacheable works when the host installs the cache (@App cache: memory()).
80
80
  The background service gets its own scope per tick and disposes it in finally.
81
81
  The host also provides auth from src/app/modules/auth (TokenKind.Admin/Client)
82
82
  and an LLM connector, which registers AgentRuntime. The CLI does not create them.
83
83
  ` : ""}Constructor dependencies, including ${m}DbContext, are wired by codegen.
84
84
  No manual deps arrays. UI, events and own configuration are not used.
85
85
 
86
- ORM: context ${m}DbContext, entities [${e}], shared host provider.
86
+ ORM: context ${m}DbContext with the set of ${e}, shared host provider.
87
87
  Table ${n.route}${profile === "full" ? `, schema ${n.dbSchema}` : ""}.
88
88
  Only the module decides how tables are created: \`ensureCreated: true\` or
89
89
  \`migrateOnStart: true\` in \`ormBazis\`. The scaffold sets neither, so the host
@@ -0,0 +1,151 @@
1
+ import ts from "typescript";
2
+
3
+ /** What {@link markColumns} did to one file. */
4
+ export interface MarkColumnsResult {
5
+ readonly text: string;
6
+ /** Columns that got `@Nullable()`. */
7
+ readonly nullable: number;
8
+ /** Columns that got `@Required()`. */
9
+ readonly required: number;
10
+ /** What needs a look: options not written in place, a mark that could not be imported. */
11
+ readonly skipped: readonly string[];
12
+ }
13
+
14
+ /** Decorators of a field that make it a column. */
15
+ const COLUMN_DECORATORS = new Set(["Column", "Index", "ForeignKey", "HasConversion"]);
16
+ /** Decorators that make a column NOT NULL whatever else is said. */
17
+ const ALWAYS_REQUIRED = new Set(["Key", "UUID", "CreatedAt", "UpdatedAt"]);
18
+ /** A `uuid` column is NOT NULL only as a key; as a plain column it was nullable. */
19
+ const REQUIRED_TYPES = new Set(["createdAt", "updatedAt"]);
20
+ /** Names an ORM import is recognised by. */
21
+ const ORM_NAMES = new Set(["Column", "Entity", "Index", "Key", "UUID", "ForeignKey"]);
22
+
23
+ interface Edit { readonly start: number; readonly end: number; readonly text: string }
24
+
25
+ function decoratorCall(decorator: ts.Decorator): { readonly name: string; readonly call: ts.CallExpression } | undefined {
26
+ const expression = decorator.expression;
27
+ if (!ts.isCallExpression(expression) || !ts.isIdentifier(expression.expression)) return undefined;
28
+ return { name: expression.expression.text, call: expression };
29
+ }
30
+
31
+ function propertyName(node: ts.ObjectLiteralElementLike): string | undefined {
32
+ const name = node.name;
33
+ return name && (ts.isIdentifier(name) || ts.isStringLiteralLike(name)) ? name.text : undefined;
34
+ }
35
+
36
+ /**
37
+ * Brings the entities of a file to 0.137.0, where every column says whether it
38
+ * may be empty: `@Required()` or `@Nullable()`. A column gets the mark of what it
39
+ * was, so the schema of the application stays as it is: `@Nullable()` for one
40
+ * that allowed NULL by silence or by `nullable: true`, `@Required()` for
41
+ * `nullable: false`. The `nullable` option, which no longer exists, is removed.
42
+ * Keys and timestamps are left alone: they were always NOT NULL. Works on the
43
+ * text of one file and changes nothing else in it.
44
+ */
45
+ export function markColumns(source: string, fileName = "file.ts"): MarkColumnsResult {
46
+ const file = ts.createSourceFile(fileName, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
47
+ const edits: Edit[] = [];
48
+ const skipped: string[] = [];
49
+ const marks = { Nullable: 0, Required: 0 };
50
+
51
+ /** `, nullable: true` out of the options; an object left empty goes away whole. */
52
+ const dropOption = (options: ts.ObjectLiteralExpression, index: number): void => {
53
+ const property = options.properties[index]!;
54
+ if (options.properties.length === 1) {
55
+ edits.push({ start: options.getStart(file), end: options.end, text: "" });
56
+ } else if (index > 0) {
57
+ edits.push({ start: options.properties[index - 1]!.end, end: property.end, text: "" });
58
+ } else {
59
+ edits.push({ start: property.getStart(file), end: options.properties[1]!.getStart(file), text: "" });
60
+ }
61
+ };
62
+
63
+ /** The mark before a decorator: on its own line when the decorator has one. */
64
+ const addMark = (mark: "Nullable" | "Required", before: ts.Decorator): void => {
65
+ const start = before.getStart(file);
66
+ const lineStart = source.lastIndexOf("\n", start - 1) + 1;
67
+ const indent = source.slice(lineStart, start);
68
+ const ownLine = /^[ \t]*$/.test(indent) && /^[ \t]*\r?\n/.test(source.slice(before.end));
69
+ edits.push({ start, end: start, text: ownLine ? `@${mark}()\n${indent}` : `@${mark}() ` });
70
+ marks[mark] += 1;
71
+ };
72
+
73
+ const visitClass = (node: ts.ClassLikeDeclaration): void => {
74
+ // `@Key(["shopId", "code"])` on the class: these properties are the key.
75
+ const keyed = new Set<string>();
76
+ for (const decorator of ts.getDecorators(node) ?? []) {
77
+ const found = decoratorCall(decorator);
78
+ const list = found?.name === "Key" ? found.call.arguments[0] : undefined;
79
+ if (list && ts.isArrayLiteralExpression(list)) for (const item of list.elements) if (ts.isStringLiteralLike(item)) keyed.add(item.text);
80
+ }
81
+ for (const member of node.members) {
82
+ if (!ts.isPropertyDeclaration(member)) continue;
83
+ const decorators = (ts.getDecorators(member) ?? []).map((decorator) => ({ decorator, found: decoratorCall(decorator) }));
84
+ const has = (name: string) => decorators.some(({ found }) => found?.name === name);
85
+ const anchor = decorators.find(({ found }) => found?.name === "Column") ?? decorators.find(({ found }) => found !== undefined && COLUMN_DECORATORS.has(found.name));
86
+ if (!anchor?.found) continue;
87
+ const name = member.name.getText(file);
88
+ const where = `${fileName}: ${node.name?.text ?? "class"}.${name}`;
89
+
90
+ const column = decorators.find(({ found }) => found?.name === "Column")?.found?.call;
91
+ const options = column?.arguments[0];
92
+ let declared: boolean | undefined;
93
+ let type: string | undefined;
94
+ if (options !== undefined) {
95
+ if (!ts.isObjectLiteralExpression(options) || options.properties.some(ts.isSpreadAssignment)) {
96
+ skipped.push(`${where}: the options of @Column are not written in place; mark the column @Required() or @Nullable() by hand`);
97
+ continue;
98
+ }
99
+ const typeProperty = options.properties.find((property) => propertyName(property) === "type");
100
+ if (typeProperty && ts.isPropertyAssignment(typeProperty) && ts.isStringLiteralLike(typeProperty.initializer)) type = typeProperty.initializer.text;
101
+ const index = options.properties.findIndex((property) => propertyName(property) === "nullable");
102
+ if (index >= 0) {
103
+ const property = options.properties[index]!;
104
+ const value = ts.isPropertyAssignment(property) ? property.initializer.kind : undefined;
105
+ if (value !== ts.SyntaxKind.TrueKeyword && value !== ts.SyntaxKind.FalseKeyword) {
106
+ skipped.push(`${where}: nullable is not true or false here; mark the column @Required() or @Nullable() by hand`);
107
+ continue;
108
+ }
109
+ declared = value === ts.SyntaxKind.TrueKeyword;
110
+ dropOption(options, index);
111
+ }
112
+ }
113
+
114
+ // A soft-delete column is nullable by its own decorator; keys and timestamps are always NOT NULL.
115
+ if (has("SoftDelete") || keyed.has(name) || decorators.some(({ found }) => found !== undefined && ALWAYS_REQUIRED.has(found.name)) || (type !== undefined && REQUIRED_TYPES.has(type))) continue;
116
+ if (has("Required") || has("Nullable")) continue;
117
+ addMark(declared === false ? "Required" : "Nullable", anchor.decorator);
118
+ }
119
+ };
120
+
121
+ const visit = (node: ts.Node): void => {
122
+ if (ts.isClassLike(node)) visitClass(node);
123
+ ts.forEachChild(node, visit);
124
+ };
125
+ visit(file);
126
+
127
+ const wanted = (["Nullable", "Required"] as const).filter((mark) => marks[mark] > 0);
128
+ if (wanted.length > 0) {
129
+ const problem = importEdits(file, wanted, edits);
130
+ if (problem !== undefined) skipped.push(`${fileName}: ${problem}`);
131
+ }
132
+ let text = source;
133
+ for (const edit of edits.sort((left, right) => right.start - left.start || right.end - left.end)) text = text.slice(0, edit.start) + edit.text + text.slice(edit.end);
134
+ return { text, nullable: marks.Nullable, required: marks.Required, skipped };
135
+ }
136
+
137
+ /** The marks join the import the other ORM decorators of the file come from. */
138
+ function importEdits(file: ts.SourceFile, marks: readonly string[], edits: Edit[]): string | undefined {
139
+ for (const statement of file.statements) {
140
+ if (!ts.isImportDeclaration(statement) || statement.importClause?.isTypeOnly) continue;
141
+ const bindings = statement.importClause?.namedBindings;
142
+ if (!bindings || !ts.isNamedImports(bindings)) continue;
143
+ const names = new Set(bindings.elements.map((element) => (element.propertyName ?? element.name).text));
144
+ if (![...names].some((name) => ORM_NAMES.has(name))) continue;
145
+ const missing = marks.filter((mark) => !names.has(mark));
146
+ const last = bindings.elements.at(-1);
147
+ if (missing.length > 0 && last) edits.push({ start: last.end, end: last.end, text: `, ${missing.join(", ")}` });
148
+ return undefined;
149
+ }
150
+ return `import ${marks.join(" and ")} from the ORM by hand: the file has no import of its decorators to add them to`;
151
+ }
@@ -0,0 +1,57 @@
1
+ import { readdir } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { markColumns } from "./markColumns";
4
+
5
+ const SKIPPED_DIRECTORIES = new Set(["node_modules", "generated", "dist", "bin", ".git"]);
6
+
7
+ async function sourceFiles(directory: string): Promise<string[]> {
8
+ const out: string[] = [];
9
+ for (const entry of await readdir(directory, { withFileTypes: true })) {
10
+ const full = path.join(directory, entry.name);
11
+ if (entry.isDirectory()) {
12
+ if (!SKIPPED_DIRECTORIES.has(entry.name)) out.push(...await sourceFiles(full));
13
+ } else if (/\.tsx?$/.test(entry.name) && !entry.name.endsWith(".d.ts")) {
14
+ out.push(full);
15
+ }
16
+ }
17
+ return out.sort();
18
+ }
19
+
20
+ /**
21
+ * `bazis upgrade columns`: from 0.137.0 every column says whether it may be
22
+ * empty, `@Required()` or `@Nullable()`. This gives each column the mark of what
23
+ * it was, so the schema of the application stays as it is, and removes the
24
+ * `nullable` option. With `--dry-run` it only tells what it would change.
25
+ */
26
+ export async function runUpgradeColumns(cwd: string, directory: string | undefined, dryRun: boolean, log: (text: string) => void): Promise<number> {
27
+ const root = path.resolve(cwd, directory ?? "src");
28
+ let files: string[];
29
+ try {
30
+ files = await sourceFiles(root);
31
+ } catch {
32
+ log(`No directory ${path.relative(cwd, root) || "."}. Pass the sources with --path <directory>.`);
33
+ return 1;
34
+ }
35
+ let nullable = 0;
36
+ let required = 0;
37
+ let changed = 0;
38
+ const skipped: string[] = [];
39
+ for (const file of files) {
40
+ const source = await Bun.file(file).text();
41
+ if (!/@(Column|Index|ForeignKey|HasConversion)\(/.test(source)) continue;
42
+ const name = path.relative(cwd, file);
43
+ const result = markColumns(source, name);
44
+ skipped.push(...result.skipped);
45
+ if (result.text === source) continue;
46
+ changed += 1;
47
+ nullable += result.nullable;
48
+ required += result.required;
49
+ log(`${dryRun ? "would change" : "changed"} ${name}: ${[result.nullable > 0 ? `${result.nullable} @Nullable()` : "", result.required > 0 ? `${result.required} @Required()` : "", result.nullable + result.required === 0 ? "the nullable option removed" : ""].filter(Boolean).join(", ")}`);
50
+ if (!dryRun) await Bun.write(file, result.text);
51
+ }
52
+ for (const line of skipped) log(`look at ${line}`);
53
+ log(changed === 0
54
+ ? "Nothing to change: every column already says whether it may be empty."
55
+ : `${dryRun ? "Would change" : "Changed"} ${changed} ${changed === 1 ? "file" : "files"}: ${nullable} columns marked @Nullable(), ${required} marked @Required(). The schema stays as it was. Review the diff: where a column must not be empty, write @Required() instead and migrate the data.`);
56
+ return 0;
57
+ }
@@ -9,7 +9,7 @@ import { AccessPolicy } from "./contracts/AccessPolicy";
9
9
  import { AccessCatalogController } from "./http/AccessCatalog.controller";
10
10
  import { RolesAccessPolicy } from "./roles/RolesAccessPolicy";
11
11
 
12
- const accessPermissions = definePermissions({
12
+ export const accessPermissions = definePermissions({
13
13
  "access.catalog.read": {
14
14
  title: { en: "See the permissions catalog", ru: "Видеть каталог прав" },
15
15
  },
@@ -1,5 +1,7 @@
1
1
  import {
2
2
  createModuleOwnedMetadataChannel,
3
+ createModuleOwnedProviderChannel,
4
+ scoped,
3
5
  registerModuleOwnedContributionValidator,
4
6
  registerModuleOwnedProviderContributor,
5
7
  DiContainer,
@@ -22,6 +24,10 @@ interface RolePayload { readonly name: string; readonly permissions: "all" | rea
22
24
  const PERMISSION_CHANNEL = createModuleOwnedMetadataChannel<PermissionPayload>("access.permission/v1");
23
25
  const REFERENCE_CHANNEL = createModuleOwnedMetadataChannel<ReferencePayload>("access.reference/v1");
24
26
  const ROLE_CHANNEL = createModuleOwnedMetadataChannel<RolePayload>("access.role/v1");
27
+ /** The classes of `references`: the field registers them, like `tools` and `controllers`. */
28
+ const REFERENCE_CLASS_CHANNEL = createModuleOwnedProviderChannel<{ readonly kind: string }, object>("access.reference-class/v1");
29
+ /** The `lookup` classes of `permissions`: the field registers them too. */
30
+ const LOOKUP_CLASS_CHANNEL = createModuleOwnedProviderChannel<{ readonly permission: string }, object>("access.lookup-class/v1");
25
31
 
26
32
  /** The values of `in`/`notIn` a role may list; the run-time limit is `access.maxInList`. */
27
33
  const STARTUP_MAX_IN_LIST = 1000;
@@ -42,7 +48,20 @@ registerModuleOwnedProviderContributor((metadata: BazisModuleMetadata, context)
42
48
  if (!isDeclaredPermissions(fields.permissions)) throw new AccessSetupError(`${module}: permissions must be a definePermissions(...) result.`);
43
49
  const problems = Object.entries(fields.permissions).flatMap(([key, spec]) => permissionProblems(key, spec));
44
50
  if (problems.length > 0) throw setupError(module, "permissions", problems);
45
- for (const [key, spec] of Object.entries(fields.permissions)) context.addMetadata(PERMISSION_CHANNEL, { key, spec });
51
+ const lookups = new Set<unknown>();
52
+ for (const [key, spec] of Object.entries(fields.permissions)) {
53
+ context.addMetadata(PERMISSION_CHANNEL, { key, spec });
54
+ for (const lookup of lookupsOf(spec)) {
55
+ // A lookup another module owns and exports is taken from it.
56
+ if (lookups.has(lookup) || importsExport(metadata, lookup)) continue;
57
+ lookups.add(lookup);
58
+ try {
59
+ context.registerScoped(LOOKUP_CLASS_CHANNEL, scoped(lookup as Class<object>), { permission: key });
60
+ } catch {
61
+ // The module registered the class its own way: that registration stands.
62
+ }
63
+ }
64
+ }
46
65
  }
47
66
  if (fields.references !== undefined) {
48
67
  const references = fields.references;
@@ -53,7 +72,17 @@ registerModuleOwnedProviderContributor((metadata: BazisModuleMetadata, context)
53
72
  if (typeof type !== "function") problems.push(`reference kind "${kind}" must name a References class`);
54
73
  }
55
74
  if (problems.length > 0) throw setupError(module, "references", problems);
56
- for (const [kind, type] of Object.entries(references)) context.addMetadata(REFERENCE_CHANNEL, { kind, type: type as Class<References> });
75
+ const registered = new Set<unknown>();
76
+ for (const [kind, type] of Object.entries(references)) {
77
+ context.addMetadata(REFERENCE_CHANNEL, { kind, type: type as Class<References> });
78
+ if (registered.has(type)) continue;
79
+ registered.add(type);
80
+ try {
81
+ context.registerScoped(REFERENCE_CLASS_CHANNEL, scoped(type as Class<object>), { kind });
82
+ } catch {
83
+ // The module registered the class its own way (a singleton): that registration stands.
84
+ }
85
+ }
57
86
  }
58
87
  if (fields.roles !== undefined) {
59
88
  if (!isDeclaredRoles(fields.roles)) throw new AccessSetupError(`${module}: roles must be a defineRoles(...) result.`);
@@ -93,7 +122,7 @@ registerModuleOwnedContributionValidator((snapshot) => {
93
122
  const other = kinds.get(payload.kind);
94
123
  if (other !== undefined) throw new AccessSetupError(`Reference kind "${payload.kind}" is declared twice: in ${other} and in ${ownerName}.`);
95
124
  kinds.set(payload.kind, ownerName);
96
- if (snapshot.countProviders(payload.type) === 0) throw new AccessSetupError(`${ownerName}: ${payload.type.name} of reference kind "${payload.kind}" is not registered: add it to the module's providers.`);
125
+ if (snapshot.countProviders(payload.type) === 0) throw new AccessSetupError(`${ownerName}: ${payload.type.name} of reference kind "${payload.kind}" is not registered.`);
97
126
  }
98
127
 
99
128
  for (const { payload, ownerName } of permissions) {
@@ -103,7 +132,7 @@ registerModuleOwnedContributionValidator((snapshot) => {
103
132
  }
104
133
  }
105
134
  for (const lookup of lookupsOf(payload.spec)) {
106
- if (snapshot.countProviders(lookup) === 0) throw new AccessSetupError(`${ownerName}: ${lookup.name} of permission "${payload.key}" is not registered: add it to the module's providers.`);
135
+ if (snapshot.countProviders(lookup) === 0) throw new AccessSetupError(`${ownerName}: ${lookup.name} of permission "${payload.key}" is not registered.`);
107
136
  }
108
137
  }
109
138
 
@@ -122,6 +151,14 @@ registerModuleOwnedContributionValidator((snapshot) => {
122
151
  }
123
152
  });
124
153
 
154
+ /** Whether a module the given one imports exports this class. */
155
+ function importsExport(metadata: BazisModuleMetadata, type: unknown): boolean {
156
+ return (metadata.imports ?? []).some((imported) => {
157
+ const exported = (imported as { readonly exports?: unknown }).exports;
158
+ return Array.isArray(exported) && exported.includes(type);
159
+ });
160
+ }
161
+
125
162
  /** What the modules of the application's container declared. */
126
163
  export function declarationsOf(services: ServiceProvider): { permissions: OwnedPermission[]; kinds: OwnedReferenceKind[]; roles: DeclaredRole[] } {
127
164
  if (!(services instanceof DiContainer)) throw new AccessSetupError("Access declarations require the framework DiContainer.");
@@ -1,6 +1,7 @@
1
1
  import type { Class } from "../../di";
2
2
  import type { AttributeLookup } from "../contracts/AttributeLookup";
3
3
  import type { LocalizedText } from "../contracts/types";
4
+ import type { BazisPermissions } from "../index";
4
5
 
5
6
  /** An attribute of the object of a permission: what a condition may test (ACCESS-001 §3). */
6
7
  export interface AttributeSpec {
@@ -39,6 +40,15 @@ export interface PermissionSpec {
39
40
 
40
41
  export type PermissionDeclarations = Readonly<Record<string, PermissionSpec>>;
41
42
 
43
+ /** The declarations of `definePermissions(...)` as a part of {@link BazisPermissions}. */
44
+ export type PermissionsOf<T extends PermissionDeclarations> = { readonly [K in keyof T]: T[K] };
45
+
46
+ /** A declared permission. */
47
+ export type PermissionKey = Extract<keyof BazisPermissions, string>;
48
+
49
+ /** A permission with an `owner`: the only kind `"<key>:own"` makes sense for. */
50
+ export type OwnPermissionKey = { [K in keyof BazisPermissions]: BazisPermissions[K] extends { readonly owner: string } ? Extract<K, string> : never }[keyof BazisPermissions];
51
+
42
52
  const DECLARED = new WeakSet<object>();
43
53
 
44
54
  /**
@@ -3,7 +3,7 @@ import { UnauthorizedError, type AuthorizeContext } from "../../http";
3
3
  import { LOGGER, type ConfigView } from "../../kernel";
4
4
  import type { AccessConfig } from "./accessConfig";
5
5
  import type { IAccessCatalog } from "../catalog/IAccessCatalog.service";
6
- import type { PermissionSpec } from "../catalog/permissions";
6
+ import type { PermissionKey, PermissionSpec } from "../catalog/permissions";
7
7
  import { AccessPolicy, type ScopePrincipals } from "../contracts/AccessPolicy";
8
8
  import type { AttributeLookup } from "../contracts/AttributeLookup";
9
9
  import type { AccessObject, AccessSubject, Condition, Grant, Grants, Scope } from "../contracts/types";
@@ -70,7 +70,7 @@ export class SubjectAccess {
70
70
  ) {}
71
71
 
72
72
  /** The grants of the permission; a grant the permission cannot use is dropped with a warning. */
73
- grants(permission: string): Promise<Grants> {
73
+ grants(permission: PermissionKey): Promise<Grants> {
74
74
  let grants = this.grantCache.get(permission);
75
75
  if (grants === undefined) {
76
76
  grants = this.load(permission);
@@ -83,7 +83,7 @@ export class SubjectAccess {
83
83
  * Whether the subject may do `permission` on `object`. Without an object —
84
84
  * whether it has the permission anywhere (a route before the object is known).
85
85
  */
86
- async can(permission: string, object?: AccessObject): Promise<boolean> {
86
+ async can(permission: PermissionKey, object?: AccessObject): Promise<boolean> {
87
87
  const spec = this.catalog.require(permission).spec;
88
88
  const grants = await this.grants(permission);
89
89
  if (grants === "all") return true;
@@ -92,7 +92,7 @@ export class SubjectAccess {
92
92
  }
93
93
 
94
94
  /** Which rows of a list the subject may see; `scope` — the partition of the request for `scope: "request"`. */
95
- async filter(permission: string, options: { readonly scope?: Scope } = {}): Promise<AccessFilter> {
95
+ async filter(permission: PermissionKey, options: { readonly scope?: Scope } = {}): Promise<AccessFilter> {
96
96
  const spec = this.catalog.require(permission).spec;
97
97
  const grants = await this.grants(permission);
98
98
  return await buildFilter({
@@ -107,7 +107,7 @@ export class SubjectAccess {
107
107
  }
108
108
 
109
109
  /** The fields of `object` the subject may read and write (ACCESS-001 §4.4). */
110
- async fields(permission: string, object: AccessObject): Promise<FieldAccess> {
110
+ async fields(permission: PermissionKey, object: AccessObject): Promise<FieldAccess> {
111
111
  const spec = this.catalog.require(permission).spec;
112
112
  const grants = await this.grants(permission);
113
113
  if (grants === "all") return { read: "all", write: "all" };