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, Entity, Index, UUID } from "../../../orm";
1
+ import { Column, Entity, Index, UUID, Nullable } from "../../../orm";
2
2
 
3
3
  /**
4
4
  * `completed`, `cancelled` and `timedOut` are finished: retention deletes them.
@@ -23,76 +23,96 @@ export class WfInstance {
23
23
  id = "";
24
24
 
25
25
  /** The version (a Definitions row: a reference without a foreign key). */
26
+ @Nullable()
26
27
  @Column({ type: "text" })
27
28
  versionId = "";
28
29
 
30
+ @Nullable()
29
31
  @Column({ type: "text" })
30
32
  processKey = "";
31
33
 
34
+ @Nullable()
32
35
  @Column({ type: "integer" })
33
36
  version = 0;
34
37
 
35
38
  /** The application's scope: a space, an organization. Never interpreted. */
36
- @Column({ type: "text", nullable: true })
39
+ @Nullable()
40
+ @Column({ type: "text" })
37
41
  partition: string | null = null;
38
42
 
43
+ @Nullable()
39
44
  @Column({ type: "text" })
40
45
  status: InstanceStatus = "running";
41
46
 
47
+ @Nullable()
42
48
  @Column({ type: "json" })
43
49
  input: Record<string, unknown> = {};
44
50
 
51
+ @Nullable()
45
52
  @Column({ type: "json" })
46
53
  vars: Record<string, unknown> = {};
47
54
 
48
55
  /** The result of every passed step by node id: `$nodes.<node>.<field>`. */
56
+ @Nullable()
49
57
  @Column({ type: "json" })
50
58
  nodes: Record<string, unknown> = {};
51
59
 
52
60
  /** What the process gave back: its `out` variables when it completed. */
53
- @Column({ type: "json", nullable: true })
61
+ @Nullable()
62
+ @Column({ type: "json" })
54
63
  output: Record<string, unknown> | null = null;
55
64
 
56
65
  /** `$event`: the data of the event that started or last continued it. */
57
- @Column({ type: "json", nullable: true })
66
+ @Nullable()
67
+ @Column({ type: "json" })
58
68
  event: unknown = null;
59
69
 
60
70
  /** `$trigger`: how it was started. */
61
- @Column({ type: "json", nullable: true })
71
+ @Nullable()
72
+ @Column({ type: "json" })
62
73
  trigger: Record<string, unknown> | null = null;
63
74
 
64
75
  /** A trial run (WORKFLOW-002 §5.4, stage 5). */
76
+ @Nullable()
65
77
  @Column({ type: "boolean" })
66
78
  test = false;
67
79
 
68
80
  /** The instance whose `callProcess` started this one, and the waiting token there. */
69
81
  @Index()
70
- @Column({ type: "text", nullable: true })
82
+ @Nullable()
83
+ @Column({ type: "text" })
71
84
  parentInstanceId: string | null = null;
72
85
 
73
- @Column({ type: "text", nullable: true })
86
+ @Nullable()
87
+ @Column({ type: "text" })
74
88
  parentTokenId: string | null = null;
75
89
 
76
90
  @Index()
77
- @Column({ type: "text", nullable: true })
91
+ @Nullable()
92
+ @Column({ type: "text" })
78
93
  businessKey: string | null = null;
79
94
 
95
+ @Nullable()
80
96
  @Column({ type: "text" })
81
97
  startedVia: StartedVia = "manual";
82
98
 
99
+ @Nullable()
83
100
  @Column({ type: "text" })
84
101
  startedByKind: "user" | "system" = "user";
85
102
 
103
+ @Nullable()
86
104
  @Column({ type: "text" })
87
105
  startedById = "";
88
106
 
89
107
  /** Why the instance failed: node, code and message. */
90
- @Column({ type: "json", nullable: true })
108
+ @Nullable()
109
+ @Column({ type: "json" })
91
110
  error: { readonly node: string; readonly code: string; readonly message: string } | null = null;
92
111
 
93
112
  @Column({ type: "createdAt" })
94
113
  createdAt = new Date(0);
95
114
 
96
- @Column({ type: "datetime", nullable: true })
115
+ @Nullable()
116
+ @Column({ type: "datetime" })
97
117
  endedAt: Date | null = null;
98
118
  }
@@ -1,4 +1,4 @@
1
- import { Column, Entity, ForeignKey, Index, UUID } from "../../../orm";
1
+ import { Column, Entity, ForeignKey, Index, UUID, Nullable } from "../../../orm";
2
2
  import { WfInstance } from "./WfInstance.model";
3
3
 
4
4
  /**
@@ -24,50 +24,62 @@ export class WfJob {
24
24
  @UUID({ version: "v7" })
25
25
  id = "";
26
26
 
27
+ @Nullable()
27
28
  @Column({ type: "text" })
28
29
  kind: JobKind = "tool";
29
30
 
30
31
  /** `null` for `schedule`, `event` and `eventStart` jobs: the instance does not exist yet. */
31
32
  @Index()
32
- @Column({ type: "text", nullable: true })
33
+ @Nullable()
34
+ @Column({ type: "text" })
33
35
  instanceId: string | null = null;
34
36
 
35
37
  @Index()
36
- @Column({ type: "text", nullable: true })
38
+ @Nullable()
39
+ @Column({ type: "text" })
37
40
  tokenId: string | null = null;
38
41
 
39
42
  /** The version a `schedule` or `eventStart` job starts. */
40
- @Column({ type: "text", nullable: true })
43
+ @Nullable()
44
+ @Column({ type: "text" })
41
45
  versionId: string | null = null;
42
46
 
43
47
  @Index()
48
+ @Nullable()
44
49
  @Column({ type: "datetime" })
45
50
  dueAt = new Date(0);
46
51
 
47
52
  /** What a job carries besides its token: the caller to tell, the process to release. */
48
- @Column({ type: "json", nullable: true })
53
+ @Nullable()
54
+ @Column({ type: "json" })
49
55
  payload: unknown = null;
50
56
 
51
57
  /** Jobs with one key merge into the last: `debounce` of an event start. */
52
58
  @Index()
53
- @Column({ type: "text", nullable: true })
59
+ @Nullable()
60
+ @Column({ type: "text" })
54
61
  dedupeKey: string | null = null;
55
62
 
56
63
  /** The partition and the start node of a `schedule` or `eventStart` job. */
57
- @Column({ type: "text", nullable: true })
64
+ @Nullable()
65
+ @Column({ type: "text" })
58
66
  partition: string | null = null;
59
67
 
60
- @Column({ type: "text", nullable: true })
68
+ @Nullable()
69
+ @Column({ type: "text" })
61
70
  nodeId: string | null = null;
62
71
 
72
+ @Nullable()
63
73
  @Column({ type: "integer" })
64
74
  attempts = 0;
65
75
 
66
76
  /** The worker holds the job until then; if it dies, the job becomes free again. */
67
- @Column({ type: "datetime", nullable: true })
77
+ @Nullable()
78
+ @Column({ type: "datetime" })
68
79
  lockedUntil: Date | null = null;
69
80
 
70
- @Column({ type: "text", nullable: true })
81
+ @Nullable()
82
+ @Column({ type: "text" })
71
83
  lastError: string | null = null;
72
84
 
73
85
  @Column({ type: "createdAt" })
@@ -1,4 +1,4 @@
1
- import { Column, Entity, ForeignKey, Index, UUID } from "../../../orm";
1
+ import { Column, Entity, ForeignKey, Index, UUID, Nullable } from "../../../orm";
2
2
  import { WfInstance } from "./WfInstance.model";
3
3
 
4
4
  /** A token waiting for an event: `eventWait` or `boundaryEvent`. Matched by key and correlation. */
@@ -9,20 +9,25 @@ export class WfSubscription {
9
9
  @UUID({ version: "v7" })
10
10
  id = "";
11
11
 
12
+ @Nullable()
12
13
  @Column({ type: "text" })
13
14
  instanceId = "";
14
15
 
15
16
  @Index()
17
+ @Nullable()
16
18
  @Column({ type: "text" })
17
19
  tokenId = "";
18
20
 
21
+ @Nullable()
19
22
  @Column({ type: "text" })
20
23
  eventKey = "";
21
24
 
22
- @Column({ type: "text", nullable: true })
25
+ @Nullable()
26
+ @Column({ type: "text" })
23
27
  correlation: string | null = null;
24
28
 
25
- @Column({ type: "text", nullable: true })
29
+ @Nullable()
30
+ @Column({ type: "text" })
26
31
  partition: string | null = null;
27
32
 
28
33
  @Column({ type: "createdAt" })
@@ -1,4 +1,4 @@
1
- import { Column, Entity, ForeignKey, Index, UUID } from "../../../orm";
1
+ import { Column, Entity, ForeignKey, Index, UUID, Nullable } from "../../../orm";
2
2
  import { WfInstance } from "./WfInstance.model";
3
3
 
4
4
  /**
@@ -16,36 +16,45 @@ export class WfToken {
16
16
  @UUID({ version: "v7" })
17
17
  id = "";
18
18
 
19
+ @Nullable()
19
20
  @Column({ type: "text" })
20
21
  instanceId = "";
21
22
 
23
+ @Nullable()
22
24
  @Column({ type: "text" })
23
25
  nodeId = "";
24
26
 
25
27
  /** The flow the token came by; `null` on a start node. */
26
- @Column({ type: "text", nullable: true })
28
+ @Nullable()
29
+ @Column({ type: "text" })
27
30
  viaFlowId: string | null = null;
28
31
 
32
+ @Nullable()
29
33
  @Column({ type: "text" })
30
34
  status: TokenStatus = "active";
31
35
 
32
36
  /** A step's result; a loop keeps its progress here while it runs. */
33
- @Column({ type: "json", nullable: true })
37
+ @Nullable()
38
+ @Column({ type: "json" })
34
39
  output: unknown = null;
35
40
 
36
41
  /** The loop token this one runs inside, with its iteration and what only the body sees: `Locals` of the engine. */
37
- @Column({ type: "text", nullable: true })
42
+ @Nullable()
43
+ @Column({ type: "text" })
38
44
  parentTokenId: string | null = null;
39
45
 
40
- @Column({ type: "integer", nullable: true })
46
+ @Nullable()
47
+ @Column({ type: "integer" })
41
48
  iteration: number | null = null;
42
49
 
43
- @Column({ type: "json", nullable: true })
50
+ @Nullable()
51
+ @Column({ type: "json" })
44
52
  locals: Record<string, unknown> | null = null;
45
53
 
46
54
  @Column({ type: "createdAt" })
47
55
  createdAt = new Date(0);
48
56
 
49
- @Column({ type: "datetime", nullable: true })
57
+ @Nullable()
58
+ @Column({ type: "datetime" })
50
59
  endedAt: Date | null = null;
51
60
  }
@@ -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" })
@@ -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) {