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, 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
|
-
@
|
|
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
|
-
@
|
|
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
|
-
@
|
|
66
|
+
@Nullable()
|
|
67
|
+
@Column({ type: "json" })
|
|
58
68
|
event: unknown = null;
|
|
59
69
|
|
|
60
70
|
/** `$trigger`: how it was started. */
|
|
61
|
-
@
|
|
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
|
-
@
|
|
82
|
+
@Nullable()
|
|
83
|
+
@Column({ type: "text" })
|
|
71
84
|
parentInstanceId: string | null = null;
|
|
72
85
|
|
|
73
|
-
@
|
|
86
|
+
@Nullable()
|
|
87
|
+
@Column({ type: "text" })
|
|
74
88
|
parentTokenId: string | null = null;
|
|
75
89
|
|
|
76
90
|
@Index()
|
|
77
|
-
@
|
|
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
|
-
@
|
|
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
|
-
@
|
|
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
|
-
@
|
|
33
|
+
@Nullable()
|
|
34
|
+
@Column({ type: "text" })
|
|
33
35
|
instanceId: string | null = null;
|
|
34
36
|
|
|
35
37
|
@Index()
|
|
36
|
-
@
|
|
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
|
-
@
|
|
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
|
-
@
|
|
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
|
-
@
|
|
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
|
-
@
|
|
64
|
+
@Nullable()
|
|
65
|
+
@Column({ type: "text" })
|
|
58
66
|
partition: string | null = null;
|
|
59
67
|
|
|
60
|
-
@
|
|
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
|
-
@
|
|
77
|
+
@Nullable()
|
|
78
|
+
@Column({ type: "datetime" })
|
|
68
79
|
lockedUntil: Date | null = null;
|
|
69
80
|
|
|
70
|
-
@
|
|
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
|
-
@
|
|
25
|
+
@Nullable()
|
|
26
|
+
@Column({ type: "text" })
|
|
23
27
|
correlation: string | null = null;
|
|
24
28
|
|
|
25
|
-
@
|
|
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
|
-
@
|
|
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
|
-
@
|
|
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
|
-
@
|
|
42
|
+
@Nullable()
|
|
43
|
+
@Column({ type: "text" })
|
|
38
44
|
parentTokenId: string | null = null;
|
|
39
45
|
|
|
40
|
-
@
|
|
46
|
+
@Nullable()
|
|
47
|
+
@Column({ type: "integer" })
|
|
41
48
|
iteration: number | null = null;
|
|
42
49
|
|
|
43
|
-
@
|
|
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
|
-
@
|
|
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
|
-
@
|
|
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
|
-
@
|
|
55
|
+
@Nullable()
|
|
56
|
+
@Column({ type: "json" })
|
|
48
57
|
form: FormSpec | null = null;
|
|
49
58
|
|
|
50
|
-
@
|
|
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
|
-
@
|
|
67
|
+
@Nullable()
|
|
68
|
+
@Column({ type: "text" })
|
|
57
69
|
claimedById: string | null = null;
|
|
58
70
|
|
|
59
|
-
@
|
|
71
|
+
@Nullable()
|
|
72
|
+
@Column({ type: "datetime" })
|
|
60
73
|
claimedAt: Date | null = null;
|
|
61
74
|
|
|
62
|
-
@
|
|
75
|
+
@Nullable()
|
|
76
|
+
@Column({ type: "text" })
|
|
63
77
|
completedById: string | null = null;
|
|
64
78
|
|
|
65
|
-
@
|
|
79
|
+
@Nullable()
|
|
80
|
+
@Column({ type: "datetime" })
|
|
66
81
|
completedAt: Date | null = null;
|
|
67
82
|
|
|
68
|
-
@
|
|
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[
|
|
336
|
+
return schemaFromOptionalExpression(unwrapped.arguments[0]);
|
|
337
337
|
}
|
|
338
338
|
if (name === "StatusCode") {
|
|
339
339
|
return schemaFromStatusCodeCall(unwrapped);
|
package/library/orm/DbContext.ts
CHANGED
|
@@ -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
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
raw.
|
|
263
|
-
|
|
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
|
-
/**
|
|
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);
|
|
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) {
|