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.
- package/cli/main.ts +8 -0
- package/cli/parseCli.ts +9 -0
- package/cli/templates/module.ts +16 -19
- package/cli/templates/passport.ts +1 -1
- package/cli/upgrade/markColumns.ts +151 -0
- package/cli/upgrade/runUpgrade.ts +57 -0
- package/core/access/Access.module.ts +1 -1
- package/core/access/catalog/moduleFields.ts +16 -2
- package/core/access/catalog/permissions.ts +10 -0
- package/core/access/check/Access.ts +5 -5
- package/core/access/check/can.ts +2 -1
- package/core/access/index.ts +11 -1
- package/core/access/roles/roles.ts +4 -2
- package/core/agent/ToolRunner.ts +5 -0
- package/core/agent/session/entities.ts +45 -45
- package/core/http/Decorators/metadata.ts +3 -0
- package/core/http/Decorators/routes.ts +12 -0
- package/core/http/Errors/HttpError.ts +15 -1
- package/core/http/HttpServer.ts +19 -2
- package/core/http/Middleware/errorHandler.ts +16 -1
- package/core/http/OpenApi/openApiDocument.ts +43 -4
- package/core/http/Results/normalize.ts +38 -1
- package/core/http/Results/results.ts +5 -2
- package/core/http/Routing/RouterBuilder.ts +27 -2
- package/core/http/index.ts +3 -0
- package/core/http/middlewareTokens.ts +12 -0
- package/core/infra/connectors/codex/CodexAppServerClient.ts +1 -1
- package/core/infra/connectors/postgres.ts +4 -2
- package/core/infra/index.ts +1 -1
- package/core/kernel/config/defineConfig.ts +55 -19
- package/core/kernel/index.ts +3 -0
- package/core/orm/buildOrmProviders.ts +2 -1
- package/core/orm/contextEntities.ts +36 -0
- package/core/orm/databaseConnector.ts +9 -3
- package/core/orm/databaseHttpError.ts +21 -0
- package/core/orm/index.ts +12 -1
- package/core/orm/ormModule.ts +17 -7
- package/core/orm/ownedStoreContributions.ts +2 -1
- package/core/scripts/di-generate.ts +61 -4
- package/core/tools/StandardTools.module.ts +1 -1
- package/core/tools/contracts/Tools.config.ts +13 -7
- package/core/tools/generated/deps.ts +3 -3
- package/core/tools/http/HttpRequest.tool.ts +1 -2
- package/core/tools/http/publicAddress.ts +2 -2
- package/core/tools/orm/OrmEntity.references.ts +7 -8
- package/core/workflow/contracts/Workflow.config.ts +5 -5
- package/core/workflow/definitions_module/WorkflowDefinitions.module.ts +2 -2
- package/core/workflow/definitions_module/http/Processes.controller.ts +2 -2
- package/core/workflow/definitions_module/internal/PublishedVersions.ts +39 -0
- package/core/workflow/definitions_module/model/WfProcess.model.ts +24 -11
- package/core/workflow/definitions_module/model/WfVersion.model.ts +7 -1
- package/core/workflow/definitions_module/services/IWorkflowDefinitions.service.ts +4 -1
- package/core/workflow/definitions_module/services/WorkflowDefinitions.service.ts +17 -7
- package/core/workflow/generated/deps.ts +2 -1
- package/core/workflow/index.ts +9 -0
- package/core/workflow/internal/WorkflowLanguages.ts +1 -1
- package/core/workflow/internal/http.ts +2 -2
- package/core/workflow/runtime_module/background/WorkflowWorker.ts +6 -2
- package/core/workflow/runtime_module/http/Instances.controller.ts +1 -1
- package/core/workflow/runtime_module/internal/WorkflowJobs.ts +43 -1
- package/core/workflow/runtime_module/model/WfHistory.model.ts +13 -6
- package/core/workflow/runtime_module/model/WfInstance.model.ts +30 -10
- package/core/workflow/runtime_module/model/WfJob.model.ts +22 -10
- package/core/workflow/runtime_module/model/WfSubscription.model.ts +8 -3
- package/core/workflow/runtime_module/model/WfToken.model.ts +16 -7
- package/core/workflow/tasks_module/model/WfTask.model.ts +25 -9
- package/library/openapi/codegen.ts +2 -2
- package/library/orm/DbContext.ts +37 -1
- package/library/orm/Metadata/ModelBuilder.ts +23 -7
- package/library/orm/Metadata/decorators.ts +23 -10
- package/library/orm/Metadata/types.ts +2 -0
- package/library/orm/Transactions/TransactionScopeCoordinator.ts +3 -3
- package/library/orm/errors.ts +39 -1
- package/library/orm/index.ts +1 -0
- package/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"
|
|
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"
|
|
24
|
-
@Column({ type: "integer" }) ownerEpoch = 0;
|
|
25
|
-
@Column({ type: "datetime"
|
|
26
|
-
@Column({ type: "text"
|
|
27
|
-
@Column({ type: "datetime"
|
|
28
|
-
@Column({ type: "integer" }) nextSequence = 1;
|
|
29
|
-
@Column({ type: "integer"
|
|
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"
|
|
58
|
-
@Column({ type: "text" }) projectionJson = "{}";
|
|
59
|
-
@Column({ type: "text"
|
|
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
|
+
}
|
package/core/http/HttpServer.ts
CHANGED
|
@@ -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 (
|
|
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,
|
|
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
|
-
/**
|
|
9
|
-
|
|
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 = {
|
|
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[]>();
|
package/core/http/index.ts
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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 && !
|
|
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),
|
package/core/infra/index.ts
CHANGED
|
@@ -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,
|