bazis 0.136.0 → 0.137.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/cli/main.ts +8 -0
  2. package/cli/parseCli.ts +9 -0
  3. package/cli/templates/module.ts +16 -19
  4. package/cli/templates/passport.ts +1 -1
  5. package/cli/upgrade/markColumns.ts +151 -0
  6. package/cli/upgrade/runUpgrade.ts +57 -0
  7. package/core/access/Access.module.ts +1 -1
  8. package/core/access/catalog/moduleFields.ts +16 -2
  9. package/core/access/catalog/permissions.ts +10 -0
  10. package/core/access/check/Access.ts +5 -5
  11. package/core/access/check/can.ts +2 -1
  12. package/core/access/index.ts +11 -1
  13. package/core/access/roles/roles.ts +4 -2
  14. package/core/agent/ToolRunner.ts +5 -0
  15. package/core/agent/session/entities.ts +45 -45
  16. package/core/http/Decorators/metadata.ts +3 -0
  17. package/core/http/Decorators/routes.ts +12 -0
  18. package/core/http/Errors/HttpError.ts +15 -1
  19. package/core/http/HttpServer.ts +19 -2
  20. package/core/http/Middleware/errorHandler.ts +16 -1
  21. package/core/http/OpenApi/openApiDocument.ts +43 -4
  22. package/core/http/Results/normalize.ts +38 -1
  23. package/core/http/Results/results.ts +5 -2
  24. package/core/http/Routing/RouterBuilder.ts +27 -2
  25. package/core/http/index.ts +3 -0
  26. package/core/http/middlewareTokens.ts +12 -0
  27. package/core/infra/connectors/codex/CodexAppServerClient.ts +1 -1
  28. package/core/infra/connectors/postgres.ts +4 -2
  29. package/core/infra/index.ts +1 -1
  30. package/core/kernel/config/defineConfig.ts +55 -19
  31. package/core/kernel/index.ts +3 -0
  32. package/core/orm/buildOrmProviders.ts +2 -1
  33. package/core/orm/contextEntities.ts +36 -0
  34. package/core/orm/databaseConnector.ts +9 -3
  35. package/core/orm/databaseHttpError.ts +21 -0
  36. package/core/orm/index.ts +12 -1
  37. package/core/orm/ormModule.ts +17 -7
  38. package/core/orm/ownedStoreContributions.ts +2 -1
  39. package/core/scripts/di-generate.ts +61 -4
  40. package/core/tools/StandardTools.module.ts +1 -1
  41. package/core/tools/contracts/Tools.config.ts +13 -7
  42. package/core/tools/generated/deps.ts +3 -3
  43. package/core/tools/http/HttpRequest.tool.ts +1 -2
  44. package/core/tools/http/publicAddress.ts +2 -2
  45. package/core/tools/orm/OrmEntity.references.ts +7 -8
  46. package/core/workflow/contracts/Workflow.config.ts +5 -5
  47. package/core/workflow/definitions_module/WorkflowDefinitions.module.ts +2 -2
  48. package/core/workflow/definitions_module/http/Processes.controller.ts +2 -2
  49. package/core/workflow/definitions_module/internal/PublishedVersions.ts +39 -0
  50. package/core/workflow/definitions_module/model/WfProcess.model.ts +24 -11
  51. package/core/workflow/definitions_module/model/WfVersion.model.ts +7 -1
  52. package/core/workflow/definitions_module/services/IWorkflowDefinitions.service.ts +4 -1
  53. package/core/workflow/definitions_module/services/WorkflowDefinitions.service.ts +17 -7
  54. package/core/workflow/generated/deps.ts +2 -1
  55. package/core/workflow/index.ts +9 -0
  56. package/core/workflow/internal/WorkflowLanguages.ts +1 -1
  57. package/core/workflow/internal/http.ts +2 -2
  58. package/core/workflow/runtime_module/background/WorkflowWorker.ts +6 -2
  59. package/core/workflow/runtime_module/http/Instances.controller.ts +1 -1
  60. package/core/workflow/runtime_module/internal/WorkflowJobs.ts +43 -1
  61. package/core/workflow/runtime_module/model/WfHistory.model.ts +13 -6
  62. package/core/workflow/runtime_module/model/WfInstance.model.ts +30 -10
  63. package/core/workflow/runtime_module/model/WfJob.model.ts +22 -10
  64. package/core/workflow/runtime_module/model/WfSubscription.model.ts +8 -3
  65. package/core/workflow/runtime_module/model/WfToken.model.ts +16 -7
  66. package/core/workflow/tasks_module/model/WfTask.model.ts +25 -9
  67. package/library/openapi/codegen.ts +2 -2
  68. package/library/orm/DbContext.ts +37 -1
  69. package/library/orm/Metadata/ModelBuilder.ts +23 -7
  70. package/library/orm/Metadata/decorators.ts +23 -10
  71. package/library/orm/Metadata/types.ts +2 -0
  72. package/library/orm/Transactions/TransactionScopeCoordinator.ts +3 -3
  73. package/library/orm/errors.ts +39 -1
  74. package/library/orm/index.ts +1 -0
  75. package/package.json +1 -1
@@ -1,4 +1,4 @@
1
- import { Column, DbContext, Entity, Index, Key } from "../../../library/orm";
1
+ import { Column, DbContext, Entity, Index, Key, Nullable } from "../../../library/orm";
2
2
 
3
3
  /** Immutable v1 schema. These tables are owned exclusively by AgentSessionDbContext. */
4
4
  @Entity({ table: "bazis_agent_sessions_v1" })
@@ -6,42 +6,42 @@ import { Column, DbContext, Entity, Index, Key } from "../../../library/orm";
6
6
  @Index(["sessionId"], { unique: true })
7
7
  export class AgentSessionRowV1 {
8
8
  @Key({ generated: false }) @Column({ type: "uuid" }) id = "";
9
- @Column({ type: "uuid" }) sessionId = "";
10
- @Column({ type: "uuid" }) invocationId = "";
11
- @Column({ type: "text" }) createKey = "";
12
- @Column({ type: "text" }) requestBinding = "";
13
- @Column({ type: "text" }) ownerSubject = "";
14
- @Column({ type: "text", nullable: true }) tenant?: string;
15
- @Column({ type: "text" }) agentName = "";
16
- @Column({ type: "text" }) implementationVersion = "";
17
- @Column({ type: "text" }) bindingHash = "";
18
- @Column({ type: "text" }) state = "created";
19
- @Column({ type: "integer" }) revision = 0;
20
- @Column({ type: "integer" }) steps = 0;
21
- @Column({ type: "boolean" }) stopRequested = false;
22
- @Column({ type: "integer" }) checkpointRevision = 0;
23
- @Column({ type: "text", nullable: true }) ownerId?: string;
24
- @Column({ type: "integer" }) ownerEpoch = 0;
25
- @Column({ type: "datetime", nullable: true }) ownerLeaseUntil?: Date;
26
- @Column({ type: "text", nullable: true }) runCommandId?: string;
27
- @Column({ type: "datetime", nullable: true }) runDeadline?: Date;
28
- @Column({ type: "integer" }) nextSequence = 1;
29
- @Column({ type: "integer", nullable: true }) pendingIntentSequence?: number;
30
- @Column({ type: "boolean" }) admissionReserved = false;
31
- @Column({ type: "datetime" }) createdAt = new Date();
32
- @Column({ type: "datetime" }) updatedAt = new Date();
9
+ @Nullable() @Column({ type: "uuid" }) sessionId = "";
10
+ @Nullable() @Column({ type: "uuid" }) invocationId = "";
11
+ @Nullable() @Column({ type: "text" }) createKey = "";
12
+ @Nullable() @Column({ type: "text" }) requestBinding = "";
13
+ @Nullable() @Column({ type: "text" }) ownerSubject = "";
14
+ @Nullable() @Column({ type: "text" }) tenant?: string;
15
+ @Nullable() @Column({ type: "text" }) agentName = "";
16
+ @Nullable() @Column({ type: "text" }) implementationVersion = "";
17
+ @Nullable() @Column({ type: "text" }) bindingHash = "";
18
+ @Nullable() @Column({ type: "text" }) state = "created";
19
+ @Nullable() @Column({ type: "integer" }) revision = 0;
20
+ @Nullable() @Column({ type: "integer" }) steps = 0;
21
+ @Nullable() @Column({ type: "boolean" }) stopRequested = false;
22
+ @Nullable() @Column({ type: "integer" }) checkpointRevision = 0;
23
+ @Nullable() @Column({ type: "text" }) ownerId?: string;
24
+ @Nullable() @Column({ type: "integer" }) ownerEpoch = 0;
25
+ @Nullable() @Column({ type: "datetime" }) ownerLeaseUntil?: Date;
26
+ @Nullable() @Column({ type: "text" }) runCommandId?: string;
27
+ @Nullable() @Column({ type: "datetime" }) runDeadline?: Date;
28
+ @Nullable() @Column({ type: "integer" }) nextSequence = 1;
29
+ @Nullable() @Column({ type: "integer" }) pendingIntentSequence?: number;
30
+ @Nullable() @Column({ type: "boolean" }) admissionReserved = false;
31
+ @Nullable() @Column({ type: "datetime" }) createdAt = new Date();
32
+ @Nullable() @Column({ type: "datetime" }) updatedAt = new Date();
33
33
  }
34
34
 
35
35
  @Entity({ table: "bazis_agent_session_checkpoints_v1" })
36
36
  @Index(["sessionId", "checkpointRevision"], { unique: true })
37
37
  export class AgentSessionCheckpointRowV1 {
38
38
  @Key({ generated: false }) @Column({ type: "uuid" }) id = "";
39
- @Column({ type: "uuid" }) sessionId = "";
40
- @Column({ type: "integer" }) checkpointRevision = 0;
41
- @Column({ type: "text" }) bindingHash = "";
42
- @Column({ type: "text" }) keyId = "";
43
- @Column({ type: "text" }) ciphertext = "";
44
- @Column({ type: "datetime" }) createdAt = new Date();
39
+ @Nullable() @Column({ type: "uuid" }) sessionId = "";
40
+ @Nullable() @Column({ type: "integer" }) checkpointRevision = 0;
41
+ @Nullable() @Column({ type: "text" }) bindingHash = "";
42
+ @Nullable() @Column({ type: "text" }) keyId = "";
43
+ @Nullable() @Column({ type: "text" }) ciphertext = "";
44
+ @Nullable() @Column({ type: "datetime" }) createdAt = new Date();
45
45
  }
46
46
 
47
47
  @Entity({ table: "bazis_agent_session_journal_v1" })
@@ -49,26 +49,26 @@ export class AgentSessionCheckpointRowV1 {
49
49
  @Index(["sessionId", "commandId"], { unique: false })
50
50
  export class AgentSessionJournalRowV1 {
51
51
  @Key({ generated: false }) @Column({ type: "uuid" }) id = "";
52
- @Column({ type: "uuid" }) eventId = "";
53
- @Column({ type: "uuid" }) sessionId = "";
54
- @Column({ type: "integer" }) sequence = 0;
55
- @Column({ type: "integer" }) aggregateRevision = 0;
56
- @Column({ type: "text" }) kind = "";
57
- @Column({ type: "text", nullable: true }) commandId?: string;
58
- @Column({ type: "text" }) projectionJson = "{}";
59
- @Column({ type: "text", nullable: true }) binding?: string;
60
- @Column({ type: "datetime" }) occurredAt = new Date();
52
+ @Nullable() @Column({ type: "uuid" }) eventId = "";
53
+ @Nullable() @Column({ type: "uuid" }) sessionId = "";
54
+ @Nullable() @Column({ type: "integer" }) sequence = 0;
55
+ @Nullable() @Column({ type: "integer" }) aggregateRevision = 0;
56
+ @Nullable() @Column({ type: "text" }) kind = "";
57
+ @Nullable() @Column({ type: "text" }) commandId?: string;
58
+ @Nullable() @Column({ type: "text" }) projectionJson = "{}";
59
+ @Nullable() @Column({ type: "text" }) binding?: string;
60
+ @Nullable() @Column({ type: "datetime" }) occurredAt = new Date();
61
61
  }
62
62
 
63
63
  @Entity({ table: "bazis_agent_session_admission_v1" })
64
64
  @Index(["bucket"], { unique: true })
65
65
  export class AgentSessionAdmissionRowV1 {
66
66
  @Key({ generated: false }) @Column({ type: "uuid" }) id = "";
67
- @Column({ type: "text" }) bucket = "";
68
- @Column({ type: "text" }) configHash = "";
69
- @Column({ type: "integer" }) maxActiveRuns = 8;
70
- @Column({ type: "integer" }) activeReservations = 0;
71
- @Column({ type: "datetime" }) updatedAt = new Date();
67
+ @Nullable() @Column({ type: "text" }) bucket = "";
68
+ @Nullable() @Column({ type: "text" }) configHash = "";
69
+ @Nullable() @Column({ type: "integer" }) maxActiveRuns = 8;
70
+ @Nullable() @Column({ type: "integer" }) activeReservations = 0;
71
+ @Nullable() @Column({ type: "datetime" }) updatedAt = new Date();
72
72
  }
73
73
 
74
74
  export class AgentSessionDbContext extends DbContext {
@@ -1,5 +1,6 @@
1
1
  import type { Class } from "../../di";
2
2
  import type { ActionFilterHooks, HttpMiddleware } from "../Middleware/types";
3
+ import type { HttpResult } from "../Results/HttpResult";
3
4
 
4
5
  // Same one-line polyfill as the validation module: Bun executes TC39
5
6
  // decorators natively, Symbol.metadata may be missing in the runtime.
@@ -26,6 +27,8 @@ export interface CatchDeclaration {
26
27
  export interface ActionMeta {
27
28
  routes: RouteDeclaration[];
28
29
  httpCode?: number;
30
+ /** `nullAs` of the route options: the result that stands for `null`. */
31
+ nullAs?: () => HttpResult;
29
32
  produces?: string;
30
33
  consumes?: string;
31
34
  version?: string;
@@ -1,5 +1,6 @@
1
1
  import type { HttpMiddleware } from "../Middleware/types";
2
2
  import { HttpSetupError } from "../Errors/HttpError";
3
+ import type { HttpResult } from "../Results/HttpResult";
3
4
  import { ownActionMeta, ownRoutes } from "./metadata";
4
5
 
5
6
  type MethodDecorator = (value: (...args: never[]) => unknown, context: ClassMethodDecoratorContext) => void;
@@ -27,6 +28,11 @@ export interface RouteOptions {
27
28
  readonly version?: string;
28
29
  /** Route middleware (= `@Middleware` on the method). */
29
30
  readonly middleware?: readonly HttpMiddleware[];
31
+ /**
32
+ * What `null` or `undefined` from the action means, as a result helper:
33
+ * `{ nullAs: NotFound }` answers 404 with the standard body instead of 204.
34
+ */
35
+ readonly nullAs?: () => HttpResult;
30
36
  /** Maximum body size: an integer and a binary unit together, for example `4mb`. */
31
37
  readonly maxBodySize?: `${bigint}${"b" | "kb" | "mb" | "gb"}`;
32
38
  }
@@ -59,6 +65,12 @@ function route(httpMethod: string, templateOrOptions?: string | RouteOptions, ma
59
65
  if (options.middleware !== undefined) {
60
66
  action.middleware.push(...options.middleware);
61
67
  }
68
+ if (options.nullAs !== undefined) {
69
+ if (typeof options.nullAs !== "function") {
70
+ throw new HttpSetupError("nullAs must be a result helper, for example NotFound.");
71
+ }
72
+ action.nullAs = options.nullAs;
73
+ }
62
74
  if (options.maxBodySize !== undefined) {
63
75
  const maxBodyBytes = parseRouteMaxBodySize(options.maxBodySize);
64
76
  if (action.maxBodyBytes !== undefined && action.maxBodyBytes !== maxBodyBytes) {
@@ -1,6 +1,6 @@
1
1
  import { KernelError } from "../../kernel";
2
2
  import type { ModelValidationIssue } from "../Binding/modelValidator";
3
- import { englishHttpMessage, type HttpMessageKey, type LocalizedMessage, type MessageParams } from "./messages";
3
+ import { englishHttpMessage, STATUS_MESSAGE_KEYS, type HttpMessageKey, type LocalizedMessage, type MessageParams } from "./messages";
4
4
 
5
5
  /**
6
6
  * Base class for errors that map directly to an HTTP response.
@@ -59,6 +59,14 @@ export class NotFoundError extends HttpError {
59
59
  }
60
60
  }
61
61
 
62
+ /** The request conflicts with what is stored: the name is taken, the record changed meanwhile. */
63
+ export class ConflictError extends HttpError {
64
+ constructor(message?: string, details?: unknown) {
65
+ const [text, localized] = textOf(message, "conflict");
66
+ super(409, text, details, localized);
67
+ }
68
+ }
69
+
62
70
  export class MethodNotAllowedError extends HttpError {
63
71
  constructor(readonly allow: readonly string[]) {
64
72
  super(405, englishHttpMessage("methodNotAllowed"), undefined, { key: "methodNotAllowed" });
@@ -116,3 +124,9 @@ export function frameworkBadRequest(key: HttpMessageKey, params?: MessageParams,
116
124
  * KernelError: the start reports it as a configuration error of the code.
117
125
  */
118
126
  export class HttpSetupError extends KernelError {}
127
+
128
+ /** An error of a status with the framework text for it, translated at the response: what an empty result helper stands for. */
129
+ export function httpErrorOfStatus(status: number): HttpError {
130
+ const key = STATUS_MESSAGE_KEYS[status] ?? (status >= 500 ? "serverError" : "clientError");
131
+ return new HttpError(status, englishHttpMessage(key), undefined, { key });
132
+ }
@@ -19,10 +19,10 @@ import { defaultAuthorizeChecks } from "./Authorization/metadata";
19
19
  import type { Router } from "./Routing/Router";
20
20
  import { parseRequestPath } from "./Routing/template";
21
21
  import { assertVersioningOptions, createVersionReader, type VersionReader } from "./Versioning/VersionReader";
22
- import { HTTP_ERROR_HOOK, ROUTE_MIDDLEWARE_COMPOSER, SERVER_MIDDLEWARE, sortByOrder } from "./middlewareTokens";
22
+ import { HTTP_ERROR_HOOK, HTTP_ERROR_MAPPER, ROUTE_MIDDLEWARE_COMPOSER, SERVER_MIDDLEWARE, sortByOrder } from "./middlewareTokens";
23
23
  import type { HealthEndpointOptions, RouteMiddlewareComposer, HttpModuleOptions, HttpServerOptions } from "./options";
24
24
  import { WEBSOCKET_UPGRADE, type WebSocketUpgrade } from "./WebSocketUpgrade";
25
- import { HttpSetupError } from "./Errors/HttpError";
25
+ import { HttpSetupError, type HttpError } from "./Errors/HttpError";
26
26
  import { clientAddress, trustedProxies, type TrustedProxies } from "./HttpContext/clientAddress";
27
27
  import { EN_HTTP_MESSAGES, formatMessage, languageHeaders, type HttpMessageKey, type MessageParams, type SelectedLanguage } from "./Errors/messages";
28
28
  import { getModelValidator, type ModelValidator } from "./Binding/modelValidator";
@@ -180,6 +180,7 @@ export class HttpServer implements HostedService {
180
180
  ...(this.options.languages ? { languages: this.options.languages } : {}),
181
181
  ...this.options.errorHandler,
182
182
  onUnexpectedError: this.collectErrorHook(),
183
+ map: this.collectErrorMapper(),
183
184
  }),
184
185
  );
185
186
  this.shortCircuitChain = [...serverChain];
@@ -332,6 +333,22 @@ export class HttpServer implements HostedService {
332
333
  };
333
334
  }
334
335
 
336
+ /** Explicit `options.errorHandler.map` first, then DI {@link HTTP_ERROR_MAPPER} mappers; the first answer wins. */
337
+ private collectErrorMapper(): ((error: unknown) => HttpError | undefined) | undefined {
338
+ const explicit = this.options.errorHandler?.map;
339
+ const mappers = [...(explicit === undefined ? [] : [explicit]), ...this.resolver.resolveAll(HTTP_ERROR_MAPPER)];
340
+ if (mappers.length === 0) {
341
+ return undefined;
342
+ }
343
+ return (error) => {
344
+ for (const mapper of mappers) {
345
+ const mapped = mapper(error);
346
+ if (mapped !== undefined) return mapped;
347
+ }
348
+ return undefined;
349
+ };
350
+ }
351
+
335
352
  /** Explicit `options.routeMiddlewareComposer` (order 0) merged with DI {@link ROUTE_MIDDLEWARE_COMPOSER}. */
336
353
  private collectRouteComposer(): RouteMiddlewareComposer | undefined {
337
354
  const explicit = this.options.routeMiddlewareComposer
@@ -28,6 +28,12 @@ export interface ErrorHandlerOptions {
28
28
  * mistake and is not logged or sent to `onUnexpectedError`.
29
29
  */
30
30
  status?: (error: unknown) => number | undefined;
31
+ /**
32
+ * Turns a non-{@link HttpError} failure into an HttpError, with its own text
33
+ * and details; `HTTP_ERROR_MAPPER` registrations are merged in. Asked before
34
+ * {@link status}. A mapped 5xx is still logged as unexpected.
35
+ */
36
+ map?: (error: unknown) => HttpError | undefined;
31
37
  /**
32
38
  * Logger for unexpected errors when {@link logError} is absent: one `error`
33
39
  * line with the method, path, request id and the redacted error. The server
@@ -114,7 +120,16 @@ export function errorHandler(options: ErrorHandlerOptions = {}): HttpMiddleware
114
120
  return async (ctx, next) => {
115
121
  try {
116
122
  await next();
117
- } catch (error) {
123
+ } catch (thrown) {
124
+ let error = thrown;
125
+ if (!(thrown instanceof HttpError) && options.map !== undefined) {
126
+ let mapped: HttpError | undefined;
127
+ try { mapped = options.map(thrown); } catch { mapped = undefined; }
128
+ if (mapped instanceof HttpError) {
129
+ if (mapped.status >= 500) safelyReportUnexpected(options, log, ctx, thrown);
130
+ error = mapped;
131
+ }
132
+ }
118
133
  const selected = options.languages?.select(ctx.header("accept-language"));
119
134
  const text = textRenderer(selected?.localize);
120
135
  const language = languageHeaders(options.languages, selected);
@@ -87,7 +87,42 @@ function collectOpenApiOperations(
87
87
  }
88
88
  }
89
89
  }
90
- return operations;
90
+ return withCreatedLocation(operations);
91
+ }
92
+
93
+ /**
94
+ * Documents the automatic `Location` of a 201: the server sets it when the
95
+ * body has `id` and a GET route answers `<path>/<id>`.
96
+ */
97
+ function withCreatedLocation(operations: OpenApiCatalogOperation[]): OpenApiCatalogOperation[] {
98
+ const itemPaths = new Set<string>();
99
+ for (const operation of operations) {
100
+ const parent = /^(.*)\/\{[^{}/]+\}$/u.exec(operation.path)?.[1];
101
+ if (operation.httpMethod === "GET" && parent !== undefined) {
102
+ itemPaths.add(parent);
103
+ }
104
+ }
105
+ return operations.map((operation) => {
106
+ const created = operation.responses["201"];
107
+ if (!created || !itemPaths.has(operation.path.replace(/\/+$/u, ""))) {
108
+ return operation;
109
+ }
110
+ return {
111
+ ...operation,
112
+ responses: {
113
+ ...operation.responses,
114
+ "201": {
115
+ ...created,
116
+ headers: {
117
+ Location: {
118
+ description: "Address of the created item, when the response has `id`.",
119
+ schema: { type: "string" },
120
+ },
121
+ },
122
+ },
123
+ },
124
+ };
125
+ });
91
126
  }
92
127
 
93
128
  interface CreateOperationInput {
@@ -140,7 +175,7 @@ function createOperation(input: CreateOperationInput): OpenApiCatalogOperation {
140
175
  },
141
176
  }
142
177
  : { description: "Success" },
143
- ...errorResponses(errorStatuses(input.bindings, authorized, generatedOperation?.errors)),
178
+ ...errorResponses(errorStatuses(input.bindings, authorized, generatedOperation?.errors, input.action)),
144
179
  },
145
180
  authorized,
146
181
  ...(version !== undefined ? { versions: [version] } : {}),
@@ -380,15 +415,19 @@ function successStatus(method: string, action: ActionMeta, generated: number | u
380
415
  /**
381
416
  * Errors the operation can answer with: 400 when the request carries values
382
417
  * that are converted or validated (body, list query, query and header values
383
- * and models, typed route parameters), 401/403 under @Authorize, and the
384
- * errors the method body returns or throws itself.
418
+ * and models, typed route parameters), 401/403 under @Authorize, the status of
419
+ * `nullAs`, and the errors the method body returns or throws itself.
385
420
  */
386
421
  function errorStatuses(
387
422
  bindings: readonly ParameterBinding[],
388
423
  authorized: boolean,
389
424
  generated: readonly number[] | undefined,
425
+ action: ActionMeta,
390
426
  ): number[] {
391
427
  const statuses = new Set(generated ?? []);
428
+ // `nullAs: NotFound` is an answer of the route the method body does not show.
429
+ const instead = action.nullAs?.().status;
430
+ if (instead !== undefined && instead >= 400) statuses.add(instead);
392
431
  const validated = bindings.some((binding) =>
393
432
  binding.source === "body" || binding.source === "list" || binding.source === "query" || binding.source === "header"
394
433
  || binding.source === "queryModel" || binding.source === "headerModel"
@@ -1,4 +1,5 @@
1
1
  import type { HttpContext } from "../HttpContext/HttpContext";
2
+ import { httpErrorOfStatus } from "../Errors/HttpError";
2
3
  import { HttpResult } from "./HttpResult";
3
4
 
4
5
  /** Per-action response defaults coming from decorators. */
@@ -7,6 +8,26 @@ export interface ResponseDefaults {
7
8
  httpCode?: number;
8
9
  /** `@Produces(...)` value. */
9
10
  produces?: string;
11
+ /** `nullAs` of the route: the result that stands for `null` and `undefined`. */
12
+ nullAs?: () => HttpResult;
13
+ /**
14
+ * Address of a created item for an automatic `Location` header: given the
15
+ * `id` of a 201 body, returns the address or `undefined` when the
16
+ * application has no GET route for it.
17
+ */
18
+ locate?: (ctx: HttpContext, id: string | number) => string | undefined;
19
+ }
20
+
21
+ /** `id` of a 201 body that can name the created item. */
22
+ function createdItemId(body: unknown): string | number | undefined {
23
+ if (body === null || typeof body !== "object" || Array.isArray(body) || isBinaryBody(body)) {
24
+ return undefined;
25
+ }
26
+ const id = (body as { id?: unknown }).id;
27
+ if (typeof id === "number" && Number.isFinite(id)) {
28
+ return id;
29
+ }
30
+ return typeof id === "string" && id !== "" ? id : undefined;
10
31
  }
11
32
 
12
33
  type ResponseBody = ConstructorParameters<typeof Response>[0];
@@ -26,15 +47,24 @@ function isBinaryBody(body: unknown): body is Blob | Uint8Array | ArrayBuffer |
26
47
  * - `string` — text/plain (unless `@Produces` says otherwise);
27
48
  * - `Blob`/bytes/stream — binary passthrough;
28
49
  * - any other object/array/number/boolean — JSON;
29
- * - `undefined`/`null` — empty body, 204 (or `@HttpCode`).
50
+ * - `undefined`/`null` — empty body, 204 (or `@HttpCode`), unless the route says `nullAs`.
30
51
  *
31
52
  * Status priority: result helper > `@HttpCode` > `ctx.res.status(...)` > default.
32
53
  * Headers from `ctx.res.header(...)` are merged into every non-raw response.
54
+ *
55
+ * A 201 without `Location` whose body is an object with `id` gets
56
+ * `Location: <request path>/<id>` when a GET route answers that address.
33
57
  */
34
58
  export function normalizeResult(result: unknown, ctx: HttpContext, defaults: ResponseDefaults): Response {
35
59
  if (result instanceof Response) {
36
60
  return result;
37
61
  }
62
+ if ((result === null || result === undefined) && defaults.nullAs !== undefined) {
63
+ const instead = defaults.nullAs();
64
+ // An error goes through the error handler: the standard body, in the language of the request.
65
+ if (instead.status >= 400) throw httpErrorOfStatus(instead.status);
66
+ result = instead;
67
+ }
38
68
 
39
69
  const builderStatus = ctx.res.statusOverride;
40
70
  const builderHeaders = ctx.res.headers;
@@ -63,6 +93,13 @@ export function normalizeResult(result: unknown, ctx: HttpContext, defaults: Res
63
93
  for (const [name, value] of builderHeaders) {
64
94
  headers.append(name, value);
65
95
  }
96
+ if (status === 201 && defaults.locate && !headers.has("location")) {
97
+ const id = createdItemId(body);
98
+ const location = id === undefined ? undefined : defaults.locate(ctx, id);
99
+ if (location !== undefined) {
100
+ headers.set("location", location);
101
+ }
102
+ }
66
103
 
67
104
  let payload: ResponseBody = null;
68
105
  if (body === undefined || body === null) {
@@ -5,8 +5,11 @@ export function Ok(body?: unknown): HttpResult {
5
5
  return new HttpResult(200, body);
6
6
  }
7
7
 
8
- /** 201 Created with optional Location header. */
9
- export function Created(location?: string, body?: unknown): HttpResult {
8
+ /**
9
+ * 201 Created. Without `location` the server derives it: a body with `id`
10
+ * gets `Location: <request path>/<id>` when a GET route answers that address.
11
+ */
12
+ export function Created(body?: unknown, location?: string): HttpResult {
10
13
  return new HttpResult(201, body, location ? [["location", location]] : undefined);
11
14
  }
12
15
 
@@ -23,7 +23,7 @@ export interface RegisteredRoute {
23
23
  readonly anonymous: boolean;
24
24
  }
25
25
  import { Router, type RouteAction } from "./Router";
26
- import { joinPaths, parseTemplate } from "./template";
26
+ import { joinPaths, parseRequestPath, parseTemplate } from "./template";
27
27
 
28
28
  type ControllerInstance = Record<string | symbol, (...args: unknown[]) => unknown>;
29
29
 
@@ -43,6 +43,8 @@ export class RouterBuilder {
43
43
  private readonly authorizeComposer: RouteMiddlewareComposer;
44
44
  /** Every route of the last {@link build}, in registration order. */
45
45
  readonly routes: RegisteredRoute[] = [];
46
+ /** Router of the last {@link build}: automatic `Location` looks up GET routes in it. */
47
+ private router?: Router;
46
48
 
47
49
  constructor(
48
50
  /** Middleware prepended to every route: [logger?, cors?, errorHandler, ...global]. */
@@ -58,6 +60,7 @@ export class RouterBuilder {
58
60
 
59
61
  build(controllers: readonly Class<object>[]): Router {
60
62
  const router = new Router();
63
+ this.router = router;
61
64
  for (const controllerClass of controllers) {
62
65
  const meta = controllerMetaOf(controllerClass);
63
66
  if (!meta?.isController) {
@@ -122,6 +125,23 @@ export class RouterBuilder {
122
125
  }
123
126
  }
124
127
 
128
+ /**
129
+ * Address of an item created by a request: the request path plus `/<id>`,
130
+ * but only when a GET route of this application answers it.
131
+ */
132
+ private locateCreated(ctx: HttpContext, id: string | number): string | undefined {
133
+ const segments = parseRequestPath(ctx.url.pathname);
134
+ if (!segments || !this.router) {
135
+ return undefined;
136
+ }
137
+ const value = String(id);
138
+ const match = this.router.match("GET", [...segments, value], ctx.apiVersion);
139
+ if (match.kind !== "matched") {
140
+ return undefined;
141
+ }
142
+ return `${ctx.url.pathname.replace(/\/+$/u, "")}/${encodeURIComponent(value)}`;
143
+ }
144
+
125
145
  /**
126
146
  * Precompiles binding + ActionFilter.before as a boundary middleware and
127
147
  * controller execution + ActionFilter.after as the terminal. This lets
@@ -138,7 +158,12 @@ export class RouterBuilder {
138
158
  const catches = meta.catches;
139
159
  const bindings = resolveGeneratedBindings(controllerClass, methodName);
140
160
  assertBindingsFresh(controllerClass, methodName, bindings);
141
- const responseDefaults = { httpCode: action.httpCode, produces: action.produces };
161
+ const responseDefaults = {
162
+ httpCode: action.httpCode,
163
+ nullAs: action.nullAs,
164
+ produces: action.produces,
165
+ locate: (ctx: HttpContext, id: string | number) => this.locateCreated(ctx, id),
166
+ };
142
167
  const bindingDefaults = { consumes: action.consumes };
143
168
  const token = controllerClass as unknown as Token<object>;
144
169
  const boundArguments = new WeakMap<HttpContext, readonly unknown[]>();
@@ -18,10 +18,12 @@ export type { OpenApiDocsOptions } from "../../library/openapi";
18
18
  export { composeRouteMiddlewareComposers } from "./composeRouteMiddlewareComposers";
19
19
  export {
20
20
  HTTP_ERROR_HOOK,
21
+ HTTP_ERROR_MAPPER,
21
22
  ROUTE_MIDDLEWARE_COMPOSER,
22
23
  SERVER_MIDDLEWARE,
23
24
  sortByOrder,
24
25
  type HttpErrorHook,
26
+ type HttpErrorMapper,
25
27
  type RouteMiddlewareComposerRegistration,
26
28
  type ServerMiddlewareRegistration,
27
29
  } from "./middlewareTokens";
@@ -131,6 +133,7 @@ export type { ActionFilterHooks, HttpMiddleware } from "./Middleware/types";
131
133
  // Errors
132
134
  export {
133
135
  BadRequestError,
136
+ ConflictError,
134
137
  ForbiddenError,
135
138
  HttpError,
136
139
  HttpSetupError,
@@ -1,4 +1,5 @@
1
1
  import { createToken } from "../di";
2
+ import type { HttpError } from "./Errors/HttpError";
2
3
  import type { HttpContext } from "./HttpContext/HttpContext";
3
4
  import type { HttpMiddleware } from "./Middleware/types";
4
5
  import type { RouteMiddlewareComposer } from "./options";
@@ -39,6 +40,17 @@ export type HttpErrorHook = (ctx: HttpContext, error: unknown) => void;
39
40
  /** Enumerable token: register any number of unexpected-error hooks. */
40
41
  export const HTTP_ERROR_HOOK = createToken<HttpErrorHook>("BazisHttpErrorHook");
41
42
 
43
+ /**
44
+ * Turns a failure that is not an {@link HttpError} into one: a module that owns
45
+ * a kind of errors says how they answer. `undefined` leaves the error to the
46
+ * next mapper and then to 500. The ORM registers one for database errors.
47
+ * Collected via `resolveAll`.
48
+ */
49
+ export type HttpErrorMapper = (error: unknown) => HttpError | undefined;
50
+
51
+ /** Enumerable token: register any number of error mappers. */
52
+ export const HTTP_ERROR_MAPPER = createToken<HttpErrorMapper>("BazisHttpErrorMapper");
53
+
42
54
  /** Stable order by `order ?? 0` (registration order preserved within a tier). */
43
55
  export function sortByOrder<T extends { readonly order?: number }>(items: readonly T[]): T[] {
44
56
  return [...items]
@@ -232,7 +232,7 @@ export class CodexAppServerClient implements CodexClient {
232
232
  if (method === "account/updated") this.modelCache = undefined;
233
233
  }, () => { this.clearLogin(); this.modelCache = undefined; });
234
234
  const initialized = await client.request("initialize", {
235
- clientInfo: { name: "bazis", title: "Bazis", version: "0.136.0" }, capabilities: { experimentalApi: true },
235
+ clientInfo: { name: "bazis", title: "Bazis", version: "0.137.0" }, capabilities: { experimentalApi: true },
236
236
  }, 15_000, signal);
237
237
  if (resolve(text(initialized.codexHome, 4096)) !== this.paths.home) throw new CodexError("PROTOCOL_ERROR");
238
238
  client.notify("initialized");
@@ -30,7 +30,9 @@ export interface PostgresConfigShape {
30
30
  readonly maxLifetime?: number;
31
31
  }
32
32
 
33
- export type PostgresSslMode = "disable" | "allow" | "prefer" | "require" | "verify-ca" | "verify-full";
33
+ /** The TLS modes of a PostgreSQL connection, for `configEnum(POSTGRES_SSL_MODES, "disable")`. */
34
+ export const POSTGRES_SSL_MODES = ["disable", "allow", "prefer", "require", "verify-ca", "verify-full"] as const;
35
+ export type PostgresSslMode = typeof POSTGRES_SSL_MODES[number];
34
36
  export interface PostgresConnectorOptions {
35
37
  readonly token?: InjectionToken<SQL>;
36
38
  /** Maximum pool drain before closing connections. Default 1000ms; 0 closes immediately. */
@@ -47,7 +49,7 @@ export function postgresConnectionOptions(config: AppConfig<PostgresConfigShape>
47
49
  return value;
48
50
  };
49
51
  const tls = c.has("tls") ? c.get("tls") : undefined;
50
- if (tls !== undefined && !["disable", "allow", "prefer", "require", "verify-ca", "verify-full"].includes(String(tls))) throw new InfraError("postgres tls is invalid");
52
+ if (tls !== undefined && !(POSTGRES_SSL_MODES as readonly string[]).includes(String(tls))) throw new InfraError("postgres tls is invalid");
51
53
  return {
52
54
  hostname: requireValue(c.get("host"), "host", "postgres"),
53
55
  port: integer("port", 1, 65535),
@@ -10,7 +10,7 @@ export { reader, requireValue, settingNames, type ConfigReader } from "./connect
10
10
  export { codexAppServerConnect, ICodex } from "./connectors/codex";
11
11
  export { CodexError, RU_CODEX_MESSAGES, type CodexErrorCode, CODEX_MODEL_ID, CODEX_REASONING_EFFORT, type CodexClient, type CodexConfigShape, type CodexLogin, type CodexLoginMethod, type CodexModel, type CodexStatus, type CodexRunInput, type CodexChatMessage, type CodexTool, type CodexToolCall, type CodexToolResult } from "./connectors/codex/contracts";
12
12
 
13
- export { postgres, IPostgres, postgresConnectionOptions, type PostgresConfigShape, type PostgresSslMode, type PostgresConnectorOptions } from "./connectors/postgres";
13
+ export { postgres, IPostgres, POSTGRES_SSL_MODES, postgresConnectionOptions, type PostgresConfigShape, type PostgresSslMode, type PostgresConnectorOptions } from "./connectors/postgres";
14
14
  export {
15
15
  redisConnect,
16
16
  IRedis,