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
@@ -1,4 +1,4 @@
1
- import { Column, Entity, Index, UUID } from "../../../orm";
1
+ import { Column, Entity, Index, UUID, Nullable } from "../../../orm";
2
2
  import type { FormSpec, LocalizedText } from "../../../../library/workflow";
3
3
 
4
4
  /** `open` waits for someone, `claimed` is taken by one person, `completed` and `cancelled` are done. */
@@ -17,55 +17,71 @@ export class WfTask {
17
17
 
18
18
  /** The waiting token: one task per token. */
19
19
  @Index({ unique: true })
20
+ @Nullable()
20
21
  @Column({ type: "text" })
21
22
  tokenId = "";
22
23
 
23
24
  @Index()
25
+ @Nullable()
24
26
  @Column({ type: "text" })
25
27
  instanceId = "";
26
28
 
29
+ @Nullable()
27
30
  @Column({ type: "text" })
28
31
  processKey = "";
29
32
 
33
+ @Nullable()
30
34
  @Column({ type: "text" })
31
35
  nodeId = "";
32
36
 
33
- @Column({ type: "text", nullable: true })
37
+ @Nullable()
38
+ @Column({ type: "text" })
34
39
  partition: string | null = null;
35
40
 
41
+ @Nullable()
36
42
  @Column({ type: "json" })
37
43
  title: LocalizedText = "";
38
44
 
39
45
  /** The task of a trial run (WORKFLOW-002 §5.4, stage 5). */
46
+ @Nullable()
40
47
  @Column({ type: "boolean" })
41
48
  test = false;
42
49
 
43
50
  /** `role:<code>` or `user:<id>`. */
51
+ @Nullable()
44
52
  @Column({ type: "text" })
45
53
  assignee = "";
46
54
 
47
- @Column({ type: "json", nullable: true })
55
+ @Nullable()
56
+ @Column({ type: "json" })
48
57
  form: FormSpec | null = null;
49
58
 
50
- @Column({ type: "datetime", nullable: true })
59
+ @Nullable()
60
+ @Column({ type: "datetime" })
51
61
  dueAt: Date | null = null;
52
62
 
63
+ @Nullable()
53
64
  @Column({ type: "text" })
54
65
  status: TaskStatus = "open";
55
66
 
56
- @Column({ type: "text", nullable: true })
67
+ @Nullable()
68
+ @Column({ type: "text" })
57
69
  claimedById: string | null = null;
58
70
 
59
- @Column({ type: "datetime", nullable: true })
71
+ @Nullable()
72
+ @Column({ type: "datetime" })
60
73
  claimedAt: Date | null = null;
61
74
 
62
- @Column({ type: "text", nullable: true })
75
+ @Nullable()
76
+ @Column({ type: "text" })
63
77
  completedById: string | null = null;
64
78
 
65
- @Column({ type: "datetime", nullable: true })
79
+ @Nullable()
80
+ @Column({ type: "datetime" })
66
81
  completedAt: Date | null = null;
67
82
 
68
- @Column({ type: "json", nullable: true })
83
+ @Nullable()
84
+ @Column({ type: "json" })
69
85
  output: Record<string, unknown> | null = null;
70
86
 
71
87
  @Column({ type: "createdAt" })
@@ -1,8 +1,10 @@
1
- import { DI } from "../../di";
1
+ import { DI } from "../../di/lowLevel";
2
2
  import "../generated/deps";
3
3
  import { startTestApp, type TestApp, type TestAppOptions } from "../../testing";
4
+ import { postgres } from "../../../library/orm";
4
5
  import { durationMs } from "../../../library/workflow";
5
6
  import { WorkflowClock } from "../contracts/WorkflowClock";
7
+ import type { CodeProcess } from "../definitions_module/code/defineProcess";
6
8
  import type { WorkflowActor } from "../contracts/types";
7
9
  import { WorkflowJobs } from "../runtime_module/internal/WorkflowJobs";
8
10
  import { IWorkflowRuntime, type EmitOptions, type EmitResult, type HistoryEntry, type InstanceDetails, type StartInstance } from "../runtime_module/services/IWorkflowRuntime.service";
@@ -57,7 +59,11 @@ export interface TestWorkflow {
57
59
  readonly app: TestApp;
58
60
  readonly clock: ManualWorkflowClock;
59
61
  now(): Date;
60
- /** Starts the latest version of a process from its manual start; jobs wait for {@link drain}. */
62
+ /**
63
+ * Starts the latest version of a process from its manual start; jobs wait for {@link drain}.
64
+ * Given the `defineProcess` result, the input is checked by its variables.
65
+ */
66
+ start<Input>(process: CodeProcess<Input, unknown>, options?: Partial<StartInstance<Input>>): Promise<InstanceDetails>;
61
67
  start(processKey: string, options?: Partial<StartInstance>): Promise<InstanceDetails>;
62
68
  emit(eventKey: string, payload?: unknown, options?: EmitOptions): Promise<EmitResult>;
63
69
  /** Completes the user task waiting at `nodeId` (through its task when the Tasks part is there). */
@@ -67,7 +73,11 @@ export interface TestWorkflow {
67
73
  /** Moves the clock forward (ISO duration or milliseconds), then {@link drain}. */
68
74
  advance(by: string | number): Promise<number>;
69
75
  instance(instanceId: string): Promise<InstanceDetails>;
76
+ /** The instance started with this `businessKey`, the newest one when there are several. */
77
+ instanceByKey(businessKey: string): Promise<InstanceDetails>;
70
78
  history(instanceId: string): Promise<readonly HistoryEntry[]>;
79
+ /** Calls a service of the application in a scope of its own, see `TestApp.use`. */
80
+ use: TestApp["use"];
71
81
  stop(): Promise<void>;
72
82
  }
73
83
 
@@ -77,7 +87,7 @@ export interface TestWorkflow {
77
87
  * deterministic. Needs PostgreSQL, as the workflow module does.
78
88
  *
79
89
  * ```ts
80
- * const wf = await testWorkflow(AppModule, { now: "2026-11-02T09:00:00Z" });
90
+ * const wf = await testWorkflow(OrdersApp, { now: "2026-11-02T09:00:00Z", http: false });
81
91
  * try {
82
92
  * const order = await wf.start("order-approval", { input: { amount: 5000 } });
83
93
  * await wf.drain();
@@ -127,7 +137,7 @@ export async function testWorkflow(root: Parameters<typeof startTestApp>[0], opt
127
137
  app,
128
138
  clock,
129
139
  now: () => clock.now(),
130
- start: (processKey, start = {}) => inScope((scope) => scope.resolve(IWorkflowRuntime).start(processKey, { ...start, actor: start.actor ?? actor })),
140
+ start: (process: string | CodeProcess<unknown, unknown>, start: Partial<StartInstance> = {}) => inScope((scope) => scope.resolve(IWorkflowRuntime).start(typeof process === "string" ? process : process.key, { ...start, actor: start.actor ?? actor })),
131
141
  emit: (eventKey, payload, emitOptions = {}) => inScope((scope) => scope.resolve(IWorkflowRuntime).emit(eventKey, payload, { ...emitOptions, actor: emitOptions.actor ?? actor })),
132
142
  complete: (instanceId, nodeId, output = {}, by = actor) => inScope(async (scope) => {
133
143
  const runtime = scope.resolve(IWorkflowRuntime);
@@ -151,11 +161,43 @@ export async function testWorkflow(root: Parameters<typeof startTestApp>[0], opt
151
161
  return await drain();
152
162
  },
153
163
  instance: (instanceId) => inScope((scope) => scope.resolve(IWorkflowRuntime).instance(instanceId)),
164
+ instanceByKey: (businessKey) => inScope(async (scope) => {
165
+ const runtime = scope.resolve(IWorkflowRuntime);
166
+ const found = (await runtime.instances({ businessKey })).items[0];
167
+ if (found === undefined) throw new Error(`No instance was started with the business key ${JSON.stringify(businessKey)}`);
168
+ return await runtime.instance(found.id);
169
+ }),
170
+ use: app.use,
154
171
  history: (instanceId) => inScope((scope) => scope.resolve(IWorkflowRuntime).history(instanceId)),
155
172
  stop: () => app.stop(),
156
173
  };
157
174
  }
158
175
 
176
+ /**
177
+ * Drops the tables of the workflow module, so the next start creates them anew:
178
+ * a clean state for a test database. Everything the module stored there is lost,
179
+ * so the name of the database must start with `test_` or end with `_test`, or
180
+ * `force` must say the loss is meant.
181
+ *
182
+ * ```ts
183
+ * beforeAll(() => dropWorkflowTables(process.env.TEST_DATABASE_URL!));
184
+ * ```
185
+ */
186
+ export async function dropWorkflowTables(url: string, options: { readonly force?: boolean } = {}): Promise<void> {
187
+ const database = new URL(url).pathname.slice(1);
188
+ if (options.force !== true && !/^test_|_test$/.test(database)) {
189
+ throw new Error(`dropWorkflowTables: the database "${database}" does not look like a test one (test_… or …_test). Pass { force: true } to drop its workflow tables anyway.`);
190
+ }
191
+ const sql = postgres({ url });
192
+ try {
193
+ const tables = (await sql.query("SELECT tablename FROM pg_tables WHERE schemaname = 'public' AND tablename LIKE 'bazis\\_wf%'", [])) as { tablename: string }[];
194
+ if (tables.length > 0) await sql.execute(`DROP TABLE ${tables.map((table) => `"${table.tablename}"`).join(", ")} CASCADE`, []);
195
+ await sql.execute(`DO $$ BEGIN IF to_regclass('public.__bazis_orm_owned_stores_v1') IS NOT NULL THEN DELETE FROM "__bazis_orm_owned_stores_v1" WHERE store_key LIKE 'bazis.workflow.%'; END IF; END $$`, []);
196
+ } finally {
197
+ await sql.close();
198
+ }
199
+ }
200
+
159
201
  function toTime(value: Date | string, what: string): number {
160
202
  const time = value instanceof Date ? value.getTime() : Date.parse(value);
161
203
  if (!Number.isFinite(time)) throw new Error(`The workflow clock ${what} must be a date, not ${JSON.stringify(value)}`);
package/index.ts CHANGED
@@ -46,8 +46,11 @@ export {
46
46
  type GrpcContext, type GrpcModuleOptions,
47
47
  } from "./core/grpc";
48
48
  export {
49
+ App,
49
50
  runApp,
50
- type RunAppOptions,
51
+ type AppClass,
52
+ type AppOptions,
53
+ type AppSettings,
51
54
  type RunAppUiAppInfo,
52
55
  type RunAppUiOptions,
53
56
  type RunAppUiSurfaceOptions,
@@ -67,8 +70,6 @@ export {
67
70
  createToken,
68
71
  scoped,
69
72
  singleton,
70
- singletonFactory,
71
- singletonFactoryWithResolver,
72
73
  singletonValue,
73
74
  transient,
74
75
  type ModuleConfig,
@@ -47,7 +47,7 @@ const ERROR_HELPER_STATUS: Readonly<Record<string, number>> = {
47
47
  BadRequest: 400, Unauthorized: 401, Forbidden: 403, NotFound: 404, Conflict: 409,
48
48
  };
49
49
  const ERROR_CLASS_STATUS: Readonly<Record<string, number>> = {
50
- BadRequestError: 400, UnauthorizedError: 401, ForbiddenError: 403, NotFoundError: 404,
50
+ BadRequestError: 400, UnauthorizedError: 401, ForbiddenError: 403, NotFoundError: 404, ConflictError: 409,
51
51
  PayloadTooLargeError: 413, UnsupportedMediaTypeError: 415, TooManyRequestsError: 429,
52
52
  };
53
53
  const OPENAPI_EMPTY_TYPE_NAMES = new Set(["HttpResult", "Response", "Blob", "ReadableStream", "Uint8Array"]);
@@ -333,7 +333,7 @@ export function createOpenApiCodegenAnalyzer(input: OpenApiCodegenAnalyzerInput)
333
333
  return schemaFromOptionalExpression(unwrapped.arguments[0]);
334
334
  }
335
335
  if (CREATED_RESULT_HELPERS.has(name)) {
336
- return schemaFromOptionalExpression(unwrapped.arguments[1]);
336
+ return schemaFromOptionalExpression(unwrapped.arguments[0]);
337
337
  }
338
338
  if (name === "StatusCode") {
339
339
  return schemaFromStatusCodeCall(unwrapped);
@@ -11,6 +11,9 @@ import { OrmTransaction, type OrmTransactionScopeOptions } from "./Transactions/
11
11
 
12
12
  type EntityClass<T extends object> = new () => T;
13
13
 
14
+ /** While a context is constructed only to read its sets: {@link DbContext.entitiesOf}. */
15
+ let discovered: Set<EntityClass<object>> | undefined;
16
+
14
17
  /**
15
18
  * Base class of a database context (a scoped DI service). A subclass declares the sets:
16
19
  *
@@ -32,10 +35,39 @@ export abstract class DbContext {
32
35
  readonly #options: DbContextOptions;
33
36
  readonly #runtime: DbContextRuntime;
34
37
 
38
+ /**
39
+ * The entities a context declares with `this.set(...)`, in the order of its
40
+ * fields. The class is constructed once without a database and without its
41
+ * own constructor dependencies only to read the sets: a constructor that needs
42
+ * them fails here, and the entities are then listed by hand.
43
+ */
44
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
45
+ static entitiesOf(context: new (options: DbContextOptions, ...dependencies: any[]) => DbContext): readonly EntityClass<object>[] {
46
+ const previous = discovered;
47
+ const found = new Set<EntityClass<object>>();
48
+ discovered = found;
49
+ try {
50
+ new context(undefined as unknown as DbContextOptions);
51
+ } catch (error) {
52
+ throw new OrmError(`The entities of ${context.name} cannot be read from its sets: its constructor failed without its dependencies (${error instanceof Error ? error.message : String(error)}). List them: entities: [...].`);
53
+ } finally {
54
+ discovered = previous;
55
+ }
56
+ return [...found];
57
+ }
58
+
35
59
  constructor(options: DbContextOptions) {
60
+ if (discovered !== undefined) {
61
+ // Only the sets are read: nothing of the context works, and it is thrown away.
62
+ this.#options = options;
63
+ this.changeTracker = undefined as unknown as ChangeTracker;
64
+ this.#runtime = undefined as unknown as DbContextRuntime;
65
+ this.database = undefined as unknown as DatabaseFacade;
66
+ return;
67
+ }
36
68
  this.#options = options;
37
69
  this.changeTracker = new ChangeTracker();
38
- const provider = observedProvider(this, options.provider);
70
+ const provider = observedProvider(this, options.provider, options.model);
39
71
  this.#runtime = { context: this, provider, models: options.model, tracker: this.changeTracker, runImmediateOperation: (operation) => monitorImmediateOperation(this, operation) };
40
72
  this.database = new DatabaseFacade(provider, options.model);
41
73
  }
@@ -47,6 +79,10 @@ export abstract class DbContext {
47
79
 
48
80
  /** Public access to a `DbSet` by entity class (for `Repository<T>` and generic scenarios). */
49
81
  setOf<T extends object>(entity: EntityClass<T>): DbSet<T> {
82
+ if (discovered !== undefined) {
83
+ discovered.add(entity);
84
+ return undefined as unknown as DbSet<T>;
85
+ }
50
86
  return new DbSet<T>(this.#options.model.requireByCtor(entity), this.#runtime);
51
87
  }
52
88
 
@@ -59,6 +59,7 @@ export class ModelBuilder {
59
59
  columns: [property.columnName],
60
60
  unique: rawProp.index.unique,
61
61
  ...(rawProp.index.where === undefined ? {} : { where: rawProp.index.where }),
62
+ ...conflictOf(ctor.name, rawProp.index),
62
63
  });
63
64
  }
64
65
  if (rawProp.fkTarget) {
@@ -179,7 +180,7 @@ export class ModelBuilder {
179
180
  return property.columnName;
180
181
  });
181
182
  if (new Set(columns).size !== columns.length) throw new ModelBuildError(`Entity "${ctor.name}": index contains duplicate properties.`);
182
- indexes.push({ name: declared.name ?? `ix_${(raw.table ?? defaultTableName(ctor.name)).toLowerCase()}_${columns.join("_")}`, columns, unique: declared.unique, ...(declared.where === undefined ? {} : { where: declared.where }) });
183
+ indexes.push({ name: declared.name ?? `ix_${(raw.table ?? defaultTableName(ctor.name)).toLowerCase()}_${columns.join("_")}`, columns, unique: declared.unique, ...(declared.where === undefined ? {} : { where: declared.where }), ...conflictOf(ctor.name, declared) });
183
184
  }
184
185
  const indexNames = new Set<string>();
185
186
  for (const index of indexes) {
@@ -255,12 +256,19 @@ export class ModelBuilder {
255
256
  `Entity "${entityName}": key "${raw.propertyName}" has keyGenerated=true but is not an integer identity key.`,
256
257
  );
257
258
  }
258
- // The key and semantic timestamp fields are always NOT NULL.
259
- const required =
260
- isKey ||
261
- raw.required === true ||
262
- raw.nullable === false ||
263
- resolved.impliedRequired === true;
259
+ // The key and semantic timestamp fields are always NOT NULL. Every other
260
+ // column says it itself: silence would be read as either by different readers.
261
+ const always = isKey || resolved.impliedRequired === true;
262
+ if (raw.required === true && raw.nullable === true) {
263
+ throw new ModelBuildError(`Entity "${entityName}": property "${raw.propertyName}" is marked both @Required() and @Nullable(). Keep one.`);
264
+ }
265
+ if (always && raw.nullable === true) {
266
+ throw new ModelBuildError(`Entity "${entityName}": property "${raw.propertyName}" is ${isKey ? "a key" : "a creation or change timestamp"} and cannot be @Nullable().`);
267
+ }
268
+ if (!always && raw.required !== true && raw.nullable !== true) {
269
+ throw new ModelBuildError(`Entity "${entityName}": property "${raw.propertyName}" does not say whether it may be empty. Mark it @Required() (NOT NULL) or @Nullable().`);
270
+ }
271
+ const required = always || raw.required === true;
264
272
  const databaseDefault = ModelBuilder.defaultFor(entityName, raw, storageType, isKey, generated, convention, required);
265
273
  const digits = ModelBuilder.decimalDigits(entityName, raw, storageType);
266
274
  return {
@@ -371,6 +379,14 @@ function isCanonicalRfc3339(value: string): boolean {
371
379
  * refused: PostgreSQL stores it in its own time zone form, so the declared
372
380
  * and the stored conditions could never be compared.
373
381
  */
382
+ /** The text of a unique index for a rejected value; on any other index it would never be used. */
383
+ function conflictOf(entity: string, index: { readonly unique: boolean; readonly conflict?: string }): { readonly conflict?: string } {
384
+ if (index.conflict === undefined) return {};
385
+ if (!index.unique) throw new ModelBuildError(`Entity "${entity}": conflict is the text of a unique index; this index is not unique.`);
386
+ if (typeof index.conflict !== "string" || index.conflict.trim() === "") throw new ModelBuildError(`Entity "${entity}": conflict of an index must be a non-empty text.`);
387
+ return { conflict: index.conflict };
388
+ }
389
+
374
390
  function validateIndexCondition(entity: string, index: string, where: CheckAst, properties: readonly PropertyModel[]): void {
375
391
  validateCheckAst(where, properties, `Entity "${entity}": index "${index}" condition`);
376
392
  const byName = new Map(properties.map((property) => [property.propertyName, property]));
@@ -30,8 +30,6 @@ export interface ColumnOptions {
30
30
  readonly name?: string;
31
31
  /** Type: physical (`text`, `datetime`, …) or semantic (`uuid`, `createdAt`, `updatedAt`). */
32
32
  readonly type?: ColumnOptionsType;
33
- /** Allow NULL (default true; `@Required` sets false). */
34
- readonly nullable?: boolean;
35
33
  /** Closed physical PostgreSQL default. It is not an application initializer or SQL fragment. */
36
34
  readonly default?: ColumnDefaultValue;
37
35
  /**
@@ -81,6 +79,12 @@ export interface IndexOptions<T extends object = any> {
81
79
  * The condition uses the same operators as `@Check`.
82
80
  */
83
81
  readonly where?: CheckPredicate<T>;
82
+ /**
83
+ * For a unique index: what to tell the client when a value is taken. Without it
84
+ * the answer is the standard text of 409. The names of the properties go to the
85
+ * details either way.
86
+ */
87
+ readonly conflict?: string;
84
88
  }
85
89
  export interface CompositeKeyOptions { readonly name?: string }
86
90
  export type ReferentialAction = "noAction" | "restrict" | "cascade" | "setNull";
@@ -104,7 +108,7 @@ export interface RawProperty {
104
108
  default?: ColumnDefaultValue;
105
109
  precision?: number;
106
110
  scale?: number;
107
- index?: { unique: boolean; name?: string; where?: import("../Schema/CheckExpression").CheckAst };
111
+ index?: { unique: boolean; name?: string; where?: import("../Schema/CheckExpression").CheckAst; conflict?: string };
108
112
  /** `@ForeignKey(() => Principal)` on a scalar column, for the DDL FK. */
109
113
  fkTarget?: () => EntityClass;
110
114
  /** `@HasConversion(...)`: conversion before/after the dialect. */
@@ -136,7 +140,7 @@ export interface RawEntity {
136
140
  /** Soft-delete property name (see `@SoftDelete` / `@Entity({ softDelete })`). */
137
141
  softDeleteProperty?: string;
138
142
  keyDeclaration?: { properties: readonly string[]; name?: string; anchor: string; composite: boolean };
139
- indexes?: Array<{ properties: readonly string[]; unique: boolean; name?: string; where?: import("../Schema/CheckExpression").CheckAst }>;
143
+ indexes?: Array<{ properties: readonly string[]; unique: boolean; name?: string; where?: import("../Schema/CheckExpression").CheckAst; conflict?: string }>;
140
144
  foreignKeys?: Array<{ properties: readonly string[]; target: () => EntityClass; name?: string; onDelete?: ReferentialAction; onUpdate?: ReferentialAction }>;
141
145
  checks?: Array<{ name: string; expression: import("../Schema/CheckExpression").CheckAst }>;
142
146
  }
@@ -297,19 +301,28 @@ export function Column(options: ColumnOptions = {}) {
297
301
  prop.default = options.default;
298
302
  prop.precision = options.precision;
299
303
  prop.scale = options.scale;
300
- if (options.nullable !== undefined) {
301
- prop.nullable = options.nullable;
302
- }
303
304
  };
304
305
  }
305
306
 
306
- /** NOT NULL. */
307
+ /**
308
+ * The column is NOT NULL. Every column says whether it may be empty, with this
309
+ * or with {@link Nullable}: a reader sees it on the column, and a column that
310
+ * says neither stops the start. Keys and the creation and change timestamps
311
+ * need no mark: they are always NOT NULL.
312
+ */
307
313
  export function Required() {
308
314
  return (_value: undefined, context: ClassFieldDecoratorContext): void => {
309
315
  ownProperty(context.metadata, fieldName(context)).required = true;
310
316
  };
311
317
  }
312
318
 
319
+ /** The column allows NULL: the other of the two marks, see {@link Required}. */
320
+ export function Nullable() {
321
+ return (_value: undefined, context: ClassFieldDecoratorContext): void => {
322
+ ownProperty(context.metadata, fieldName(context)).nullable = true;
323
+ };
324
+ }
325
+
313
326
  /** Index on the property's column. */
314
327
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
315
328
  export function Index<T extends object = any>(options?: IndexOptions<T>): (value: undefined, context: ClassFieldDecoratorContext) => void;
@@ -319,13 +332,13 @@ export function Index(first: IndexOptions | readonly [string, ...string[]] = {},
319
332
  if (Array.isArray(first)) {
320
333
  const where = options.where === undefined ? undefined : compileCheck(options.where);
321
334
  return (_value: abstract new (...args: never[]) => unknown, context: ClassDecoratorContext): void => {
322
- (ownRaw(context.metadata).indexes ??= []).push({ properties: [...first], unique: options.unique === true, name: options.name, ...(where === undefined ? {} : { where }) });
335
+ (ownRaw(context.metadata).indexes ??= []).push({ properties: [...first], unique: options.unique === true, name: options.name, ...(where === undefined ? {} : { where }), ...(options.conflict === undefined ? {} : { conflict: options.conflict }) });
323
336
  };
324
337
  }
325
338
  const propertyOptions = first as IndexOptions;
326
339
  const where = propertyOptions.where === undefined ? undefined : compileCheck(propertyOptions.where);
327
340
  return (_value: undefined, context: ClassFieldDecoratorContext): void => {
328
- ownProperty(context.metadata, fieldName(context)).index = { unique: propertyOptions.unique === true, name: propertyOptions.name, ...(where === undefined ? {} : { where }) };
341
+ ownProperty(context.metadata, fieldName(context)).index = { unique: propertyOptions.unique === true, name: propertyOptions.name, ...(where === undefined ? {} : { where }), ...(propertyOptions.conflict === undefined ? {} : { conflict: propertyOptions.conflict }) };
329
342
  };
330
343
  }
331
344
 
@@ -77,6 +77,8 @@ export interface IndexModel {
77
77
  readonly unique: boolean;
78
78
  /** Partial index condition over property names (`@Index({ where })`). */
79
79
  readonly where?: CheckAst;
80
+ /** What to tell the client when a unique index rejects a value (`@Index({ conflict })`). */
81
+ readonly conflict?: string;
80
82
  }
81
83
  export interface CheckModel { readonly name: string; readonly expression: CheckAst }
82
84
 
@@ -1,6 +1,6 @@
1
1
  import { AsyncLocalStorage } from "node:async_hooks";
2
2
  import type { DbContext } from "../DbContext";
3
- import { ConcurrentTransactionScopeError, differentProviderMessage, OrmDatabaseTimeError, OrmProviderIdentityMismatchError, OrmTransactionScopeError, translateDatabaseError } from "../errors";
3
+ import { ConcurrentTransactionScopeError, differentProviderMessage, OrmDatabaseTimeError, OrmProviderIdentityMismatchError, OrmTransactionScopeError, describeViolation, translateDatabaseError, type ViolationModels } from "../errors";
4
4
  import { baseProvider, withPostgresScopeOptions, createObservedProvider, postgresTransactionCapability, rootAuthority, withProviderDispatchObserver, withoutProviderDispatchObserver, type CancellableProviderDispatch } from "../Providers/ormTransactionRuntime";
5
5
  import type { DatabaseProvider } from "../Providers/types";
6
6
  import { OrmTransaction, type OrmDatabaseTimeV1, type OrmTransactionScopeOptions } from "./OrmTransaction";
@@ -84,7 +84,7 @@ export function contextToken(context: object): object {
84
84
  return token;
85
85
  }
86
86
 
87
- export function observedProvider(context: object, provider: DatabaseProvider): DatabaseProvider {
87
+ export function observedProvider(context: object, provider: DatabaseProvider, models?: ViolationModels): DatabaseProvider {
88
88
  contextProviders.set(context, provider);
89
89
  const token = contextToken(context);
90
90
  const authority = rootAuthority(provider);
@@ -92,7 +92,7 @@ export function observedProvider(context: object, provider: DatabaseProvider): D
92
92
  if (uncertainContexts.has(token)) return Promise.reject(new TransactionOutcomeUnknownError());
93
93
  // Known SQLSTATEs (unique, foreign key, check, not null, lock timeout)
94
94
  // reach the context's caller as ORM errors; the outcome check sees the raw one.
95
- const guarded = () => operation().catch((error) => { if (postgresTransactionCapability(provider)?.isOutcomeUncertain?.(error)) uncertainContexts.add(token); throw translateDatabaseError(error); });
95
+ const guarded = () => operation().catch((error) => { if (postgresTransactionCapability(provider)?.isOutcomeUncertain?.(error)) uncertainContexts.add(token); const translated = translateDatabaseError(error); throw models === undefined ? translated : describeViolation(translated, models); });
96
96
  if (!view) return guarded();
97
97
  const frame = view.frame;
98
98
  if (frame.authority !== authority || !view.enrolled.has(token) || frame.state !== "active" || frame.activeChild !== undefined) {
@@ -103,7 +103,7 @@ export class EntityNotMappedError extends OrmError {
103
103
  ? `No entity or dynamic model named "${name}" is registered in this DbContext. Register a dynamic table first: options.model.registerModel(buildDynamicModel({ name: "${name}", fields: [...] })).`
104
104
  : kind === "plain"
105
105
  ? `A plain object is not an entity class. For a dynamic table add the row through its set: db.setByName("Table").add(row).`
106
- : `Type "${name}" is not mapped in this DbContext. Add it to ormModule({ entities: [...] }) and annotate it with @Entity().`,
106
+ : `Type "${name}" is not mapped in this DbContext. Declare a set for it in the context (this.set(${name})) or add it to entities of ormBazis, and annotate it with @Entity().`,
107
107
  );
108
108
  }
109
109
  }
@@ -168,6 +168,13 @@ export class OrmUndeclaredConflictTargetError extends OrmError {
168
168
  export class UniqueViolationError extends DbUpdateError {
169
169
  readonly constraint: string | undefined;
170
170
  readonly table: string | undefined;
171
+ /**
172
+ * The properties of the index or key that rejected the value, when a context
173
+ * with the entity of the table made the call; otherwise empty.
174
+ */
175
+ properties: readonly string[] = [];
176
+ /** `conflict` of the index: what to tell the client. */
177
+ conflict: string | undefined;
171
178
 
172
179
  constructor(cause: object) {
173
180
  const field = (name: string): string | undefined => {
@@ -284,3 +291,34 @@ export class OrmValidationError extends DbUpdateError {
284
291
  super(`Validation failed for "${entityName}": ${errors.map((error) => error.message).join("; ")}`);
285
292
  }
286
293
  }
294
+
295
+ /** What a context knows of a table, to name the properties of a violated constraint. */
296
+ export interface ViolationModels {
297
+ readonly entities: readonly {
298
+ readonly tableName: string;
299
+ readonly keyName?: string;
300
+ readonly key: readonly { readonly propertyName: string }[];
301
+ readonly properties: readonly { readonly propertyName: string; readonly columnName: string }[];
302
+ readonly indexes: readonly { readonly name: string; readonly columns: readonly string[]; readonly unique: boolean; readonly conflict?: string }[];
303
+ }[];
304
+ }
305
+
306
+ /** Names the properties behind a unique violation by the model of the context. Returns the same error. */
307
+ export function describeViolation(error: unknown, models: ViolationModels): unknown {
308
+ if (!(error instanceof UniqueViolationError) || error.constraint === undefined || error.properties.length > 0) return error;
309
+ for (const entity of models.entities) {
310
+ if (error.table !== undefined && entity.tableName !== error.table) continue;
311
+ const index = entity.indexes.find((item) => item.unique && item.name === error.constraint);
312
+ if (index !== undefined) {
313
+ error.properties = index.columns.map((column) => entity.properties.find((property) => property.columnName === column)?.propertyName ?? column);
314
+ error.conflict = index.conflict;
315
+ return error;
316
+ }
317
+ // `pk_<table>` is the name this ORM gives; `<table>_pkey` is PostgreSQL's own, from an older schema.
318
+ if (error.constraint === (entity.keyName ?? `pk_${entity.tableName}`) || error.constraint === `${entity.tableName}_pkey`) {
319
+ error.properties = entity.key.map((property) => property.propertyName);
320
+ return error;
321
+ }
322
+ }
323
+ return error;
324
+ }
@@ -12,6 +12,7 @@ export {
12
12
  OneToMany,
13
13
  QueryFilter,
14
14
  Required,
15
+ Nullable,
15
16
  Schema,
16
17
  SoftDelete,
17
18
  UpdatedAt,
@@ -148,7 +148,7 @@ const en = {
148
148
  dns: "the host name does not resolve — check the address",
149
149
  database: "the database does not exist — create it or fix the name",
150
150
  deadline: "give connect() its own deadline and fail with a clear message",
151
- raiseLimit: "or raise the limit: runApp(…, { kernel: { startupTimeoutMs } })",
151
+ raiseLimit: "or raise the limit: @App({ kernel: { startupTimeoutMs } })",
152
152
  },
153
153
  },
154
154
  schema: { add: "+", none: "schema is up to date", warnings: "schema warnings" },
@@ -168,7 +168,7 @@ const en = {
168
168
  ready: {
169
169
  running: (app: string, ms: string) => `${app} is running · ready in ${ms}`,
170
170
  local: "Local", network: "Network", health: "Health", docs: "OpenAPI",
171
- notExposed: (host: string) => `not exposed — listening on ${host} (set HOST=0.0.0.0)`,
171
+ notExposed: (host: string) => `not exposed — listening on ${host} (set BAZIS_HTTP__HOST=0.0.0.0)`,
172
172
  watching: (src: string) => `watching ${src} · restarts on change · logs below · Ctrl+C to stop`,
173
173
  noWatch: "logs below · Ctrl+C to stop",
174
174
  worker: (app: string, ms: string) => `${app} started · ready in ${ms}`,
@@ -240,7 +240,7 @@ const en = {
240
240
  codegenSkipped: "codegen skipped (--no-codegen) — run bazis codegen",
241
241
  needsDb: "this module uses a database: connect one with @Infra({ db: ormBazisConnect(dbConfig) })",
242
242
  needsTable: (moduleFile: string) => `its table is not created: add ensureCreated: true to ormBazis in ${moduleFile}, or write a migration`,
243
- needsCache: "this module caches: pass runApp(AppModule, { cache: memory() })",
243
+ needsCache: "this module caches: add cache: memory() to @App",
244
244
  keptOnFailure: "scaffold files were kept — fix the error and run bazis codegen",
245
245
  },
246
246
  workflow: {
@@ -325,7 +325,7 @@ const ru: Messages = {
325
325
  dns: "имя хоста не разрешается — проверьте адрес",
326
326
  database: "базы данных нет — создайте её или исправьте имя",
327
327
  deadline: "дайте connect() свой срок и понятную ошибку",
328
- raiseLimit: "или увеличьте предел: runApp(…, { kernel: { startupTimeoutMs } })",
328
+ raiseLimit: "или увеличьте предел: @App({ kernel: { startupTimeoutMs } })",
329
329
  },
330
330
  },
331
331
  schema: { add: "+", none: "схема актуальна", warnings: "предупреждения схемы" },
@@ -345,7 +345,7 @@ const ru: Messages = {
345
345
  ready: {
346
346
  running: (app, ms) => `${app} работает · готово за ${ms}`,
347
347
  local: "Локально", network: "Сеть", health: "Здоровье", docs: "OpenAPI",
348
- notExposed: (host) => `недоступно снаружи — слушает ${host} (задайте HOST=0.0.0.0)`,
348
+ notExposed: (host) => `недоступно снаружи — слушает ${host} (задайте BAZIS_HTTP__HOST=0.0.0.0)`,
349
349
  watching: (src) => `слежу за ${src} · перезапуск при изменениях · журнал ниже · Ctrl+C — остановить`,
350
350
  noWatch: "журнал ниже · Ctrl+C — остановить",
351
351
  worker: (app, ms) => `${app} запущено · готово за ${ms}`,
@@ -417,7 +417,7 @@ const ru: Messages = {
417
417
  codegenSkipped: "кодогенерация пропущена (--no-codegen) — запустите bazis codegen",
418
418
  needsDb: "модулю нужна база данных: подключите её через @Infra({ db: ormBazisConnect(dbConfig) })",
419
419
  needsTable: (moduleFile) => `таблицу шаблон не создаёт: добавьте ensureCreated: true в ormBazis в ${moduleFile} или напишите миграцию`,
420
- needsCache: "модуль кэширует: передайте runApp(AppModule, { cache: memory() })",
420
+ needsCache: "модуль кэширует: добавьте cache: memory() в @App",
421
421
  keptOnFailure: "файлы модуля оставлены — исправьте ошибку и запустите bazis codegen",
422
422
  },
423
423
  workflow: {
@@ -48,7 +48,7 @@ More scenarios (complex conditions, async, nested objects) are in
48
48
  | `RuleEngine.ts` | the engine: one pass over the plan, sync/async, nested, cycle protection |
49
49
  | `RuleCompiler.ts` | compiles class rules into a plan + WeakMap cache (RegExp, enum Set) |
50
50
  | `RuleSet.ts` | rules as data without a class: `Validator.ruleSet({ field: options })` |
51
- | `MessageRegistry.ts` | message templates, placeholders, priority local > global > built-in; built-in texts are English, `RU_VALIDATION_MESSAGES` is a Russian set for `setDefaults` and `runApp({ language: "ru" })` |
51
+ | `MessageRegistry.ts` | message templates, placeholders, priority local > global > built-in; built-in texts are English, `RU_VALIDATION_MESSAGES` is a Russian set for `setDefaults` and `@App({ language: "ru" })` |
52
52
  | `metadata.ts` | stores rules through `context.metadata` / `Symbol.metadata` |
53
53
  | `types/ValidatorOptions.ts` | all decorator options |
54
54
  | `types/ValidationCode.ts` | error codes |
@@ -473,7 +473,7 @@ so a valid object pays no allocations for it.
473
473
  Template priority: **the decorator's local `message` > global (`setDefaultMessages`) >
474
474
  built-in**. A string returned from `custom` wins over all of them.
475
475
  Built-in messages are English (`EN_VALIDATION_MESSAGES`); `RU_VALIDATION_MESSAGES` is a Russian set for `setDefaultMessages`.
476
- An HTTP application picks the language with `runApp({ language: "ru", messages })`: each
476
+ An HTTP application picks the language with `@App({ language: "ru", messages })`: each
477
477
  `ValidationError` built from a template carries `params`, and the HTTP error handler
478
478
  re-renders it in the application language (a rule's own `message` has no `params` and
479
479
  stays as is). Priority there: `messages` > `setDefaultMessages` > the language set > English.
@@ -20,6 +20,7 @@ export {
20
20
  type ProcessNode,
21
21
  type ProcessSettings,
22
22
  } from "./ProcessDocument";
23
+ export type { BoundaryNodeKind, ConfiguredNodeKind, InputMapping, NodeConfigs, ProcessNodeOf, RetryPolicy, TimerConfig, TypedProcessFlow, TypedProcessNode } from "./nodeConfigs";
23
24
  export { NODE_KINDS, nodeKindOf, ISO_DURATION, MAX_ITERATIONS, MAX_LOOP_PARALLEL, PROCESS_KEY, ISO_MOMENT, type JsonSchema, type NodeKindDefinition, type NodeRole } from "./nodeKinds";
24
25
  export { PROCESS_LIMITS, validateProcess, hasErrors, type ProcessIssue, type ProcessIssueCode, type ValidateProcessOptions } from "./validateProcess";
25
26
  export { DATA_PATH, DATA_PATHS, isPath, readPath, resolveInput, secretNames, type InstanceData } from "./paths";