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,7 +1,7 @@
1
1
  // This file is auto-generated by `bun run di:generate` (framework.packs of bazis.config.json).
2
2
  // Do not edit manually.
3
3
  import { registerFrameworkClassDeps } from "../../di/module/autoDeps";
4
- import { ServiceProvider } from "../../di/ServiceProvider";
4
+ import { ToolRunner } from "../../agent/ToolRunner";
5
5
  import { Configuration } from "../../kernel/config/Configuration";
6
6
  import { HttpRequestTool } from "../http/HttpRequest.tool";
7
7
  import { OrmEntityReferences, OrmFieldReferences } from "../orm/OrmEntity.references";
@@ -10,6 +10,6 @@ import { ConfigSecretStore } from "../secrets/SecretStore";
10
10
  registerFrameworkClassDeps([
11
11
  [ConfigSecretStore, [Configuration]],
12
12
  [HttpRequestTool, ["Config:tools"]],
13
- [OrmEntityReferences, [ServiceProvider]],
14
- [OrmFieldReferences, [ServiceProvider]],
13
+ [OrmEntityReferences, [ToolRunner]],
14
+ [OrmFieldReferences, [ToolRunner]],
15
15
  ]);
@@ -82,8 +82,7 @@ export class HttpRequestTool {
82
82
  constructor(private readonly config: ConfigView<ToolsSettings>) {}
83
83
 
84
84
  async execute(raw: unknown, context: AgentToolExecutionContext): Promise<unknown> {
85
- const callers = this.config.get("http.callers").split(",").map((item) => item.trim());
86
- if (context.caller !== undefined && !callers.includes(context.caller)) throw new ToolError(`tools.http.callers does not list ${context.caller}`, { reason: "caller_not_allowed" });
85
+ if (context.caller !== undefined && !this.config.get("http.callers").includes(context.caller)) throw new ToolError(`tools.http.callers does not list ${context.caller}`, { reason: "caller_not_allowed" });
87
86
  const request = this.requestOf(raw);
88
87
  if (context.dryRun === true && !SAFE_METHODS.has(request.method)) return { dryRun: true };
89
88
  const signal = AbortSignal.any([context.signal, AbortSignal.timeout(request.timeoutMs)]);
@@ -82,9 +82,9 @@ export function isPublicAddress(address: string): boolean {
82
82
  }
83
83
 
84
84
  /** Whether `tools.http.allow` lets this host and port through: `api.example.com`, `*.example.com`, `localhost:8080`. */
85
- export function hostAllowed(allow: string, hostname: string, port: string): boolean {
85
+ export function hostAllowed(allow: readonly string[], hostname: string, port: string): boolean {
86
86
  const host = hostname.toLowerCase().replace(/\.$/, "");
87
- return allow.split(",").map((item) => item.trim().toLowerCase()).filter((item) => item !== "").some((item) => {
87
+ return allow.map((item) => item.toLowerCase()).some((item) => {
88
88
  // An IPv6 address goes in brackets, as in a URL: [::1]:8080.
89
89
  const match = /^\[(.+)\](?::(\d+))?$/.exec(item) ?? /^(.*?)(?::(\d+))?$/.exec(item)!;
90
90
  const [name, wanted] = [match[1]!, match[2]];
@@ -1,13 +1,12 @@
1
1
  import type { ReferenceContext, ReferenceItem, ReferencePage } from "../../access";
2
2
  import { References } from "../../access";
3
- import { AgentRegistry } from "../../agent/AgentRegistry";
4
- import type { ServiceProvider } from "../../di";
3
+ import type { ToolRunner } from "../../agent";
5
4
  import { ormEntityToolInfo, type OrmEntityToolInfo } from "./ormEntityTools";
6
5
 
7
6
  /** What `ormEntityTools` opened in this application: only that is referenced. */
8
- function opened(services: ServiceProvider): readonly OrmEntityToolInfo[] {
7
+ function opened(tools: ToolRunner): readonly OrmEntityToolInfo[] {
9
8
  const entities = new Map<string, Set<string>>();
10
- for (const tool of AgentRegistry.fromContainer(services).listTools()) {
9
+ for (const tool of tools.listTools()) {
11
10
  const info = ormEntityToolInfo(tool.target);
12
11
  if (info === undefined) continue;
13
12
  const fields = entities.get(info.entity) ?? new Set<string>();
@@ -24,12 +23,12 @@ function page(ids: readonly string[], text: string, { limit, offset }: Reference
24
23
 
25
24
  /** Reference kind `orm.entity`: an entity open to processes, `Order`. */
26
25
  export class OrmEntityReferences extends References {
27
- constructor(private readonly services: ServiceProvider) {
26
+ constructor(private readonly tools: ToolRunner) {
28
27
  super();
29
28
  }
30
29
 
31
30
  private ids(): readonly string[] {
32
- return opened(this.services).map((item) => item.entity);
31
+ return opened(this.tools).map((item) => item.entity);
33
32
  }
34
33
 
35
34
  async exists(ids: readonly string[]): Promise<ReadonlySet<string>> {
@@ -48,12 +47,12 @@ export class OrmEntityReferences extends References {
48
47
 
49
48
  /** Reference kind `orm.field`: a field open to processes, `Order.status`, or `status` within `Order`. */
50
49
  export class OrmFieldReferences extends References {
51
- constructor(private readonly services: ServiceProvider) {
50
+ constructor(private readonly tools: ToolRunner) {
52
51
  super();
53
52
  }
54
53
 
55
54
  private ids(): readonly string[] {
56
- return opened(this.services).flatMap((item) => item.fields.map((field) => `${item.entity}.${field}`));
55
+ return opened(this.tools).flatMap((item) => item.fields.map((field) => `${item.entity}.${field}`));
57
56
  }
58
57
 
59
58
  async exists(ids: readonly string[], context: ReferenceContext): Promise<ReadonlySet<string>> {
@@ -1,15 +1,15 @@
1
- import { defineConfig } from "../../kernel";
1
+ import { configList, defineConfig } from "../../kernel";
2
2
 
3
3
  /**
4
4
  * Settings of the workflow pack, from the application configuration:
5
5
  * `BAZIS_WORKFLOW__WORKER__ENABLED=false` keeps an instance from running jobs,
6
6
  * for example a web-only one (WORKFLOW-001 §4). `language` is the default
7
- * language of texts, `languages` the comma-separated list a request may ask for
7
+ * language of texts, `languages` the list a request may ask for (comma-separated in the environment)
8
8
  * with `Accept-Language` (WORKFLOW-002 §6).
9
9
  */
10
10
  export interface WorkflowSettings {
11
11
  readonly language: string;
12
- readonly languages: string;
12
+ readonly languages: readonly string[];
13
13
  readonly "worker.enabled": boolean;
14
14
  readonly "worker.intervalMs": number;
15
15
  readonly "worker.batchSize": number;
@@ -23,7 +23,7 @@ const LANGUAGE = /^[a-z]{2,3}(-[A-Za-z0-9]{2,8})*$/;
23
23
  export const workflowConfig = defineConfig<WorkflowSettings>("workflow", {
24
24
  default: {
25
25
  language: "en",
26
- languages: "en",
26
+ languages: configList(["en"]),
27
27
  "worker.enabled": true,
28
28
  "worker.intervalMs": 1000,
29
29
  "worker.batchSize": 10,
@@ -33,7 +33,7 @@ export const workflowConfig = defineConfig<WorkflowSettings>("workflow", {
33
33
  },
34
34
  validate: {
35
35
  language: (value) => (LANGUAGE.test(value) ? undefined : "must be a language: en, ru, pt-BR"),
36
- languages: (value) => (value.split(",").every((item) => LANGUAGE.test(item.trim())) ? undefined : "must be a comma-separated list of languages"),
36
+ languages: (value) => (value.every((item) => LANGUAGE.test(item)) ? undefined : "must be a list of languages: en, ru, pt-BR"),
37
37
  "worker.intervalMs": (value) => positive(value, 3_600_000),
38
38
  "worker.batchSize": (value) => positive(value, 1000),
39
39
  "retention.intervalMs": (value) => positive(value, 86_400_000),
@@ -17,6 +17,7 @@ import { CatalogImport } from "./code/CatalogImport";
17
17
  import { CodeProcessCatalog } from "./code/CodeProcessCatalog";
18
18
  import { CodeProcessPublisher } from "./code/CodeProcessPublisher";
19
19
  import { ProcessesController } from "./http/Processes.controller";
20
+ import { PublishedVersions } from "./internal/PublishedVersions";
20
21
  import { DefinitionsDbContext, definitionsStore } from "./model/Definitions.dbContext";
21
22
  import { WfProcess } from "./model/WfProcess.model";
22
23
  import { WfVersion } from "./model/WfVersion.model";
@@ -39,6 +40,7 @@ import { WorkflowDefinitions } from "./services/WorkflowDefinitions.service";
39
40
  singleton(WorkflowClock, SystemWorkflowClock),
40
41
  singleton(WorkflowTools),
41
42
  singleton(CodeProcessCatalog),
43
+ singleton(PublishedVersions),
42
44
  singleton(WorkflowLanguages),
43
45
  singleton(WorkflowExtensions, DefaultWorkflowExtensions),
44
46
  singleton(WorkflowPartitions, DefaultWorkflowPartitions),
@@ -46,8 +48,6 @@ import { WorkflowDefinitions } from "./services/WorkflowDefinitions.service";
46
48
  scoped(WorkflowReferences),
47
49
  scoped(CatalogImport),
48
50
  scoped(IWorkflowDefinitions, WorkflowDefinitions),
49
- scoped(ProcessReferences),
50
- scoped(FolderReferences),
51
51
  scoped(ProcessFolderLookup),
52
52
  scoped(ProcessPartitionLookup),
53
53
  singleton(HOSTED_SERVICE, CodeProcessPublisher),
@@ -45,7 +45,7 @@ export class ProcessesController {
45
45
  owner: optionalString(request.owner, "owner") ?? null,
46
46
  retentionDays: optionalInteger(request.retentionDays, "retentionDays") ?? null,
47
47
  }, actor, { request: from });
48
- return Created(`${ctx.path}/${process.key}`, withTitleText(process, language, defaultLanguage(ctx)));
48
+ return Created(withTitleText(process, language, defaultLanguage(ctx)), `${ctx.path}/${process.key}`);
49
49
  }
50
50
 
51
51
  @Get("processes/:key")
@@ -105,7 +105,7 @@ export class ProcessesController {
105
105
  const key = param(ctx, "key");
106
106
  const { actor, request, language } = await callerOf(ctx, "workflow.processes.publish", await this.objectOf(key));
107
107
  const version = await this.definitions.publish(key, actor, { request, language });
108
- return Created(`${ctx.path.replace(/\/publish$/, "")}/versions/${version.version}`, version);
108
+ return Created(version, `${ctx.path.replace(/\/publish$/, "")}/versions/${version.version}`);
109
109
  }
110
110
 
111
111
  @Get("processes/:key/versions")
@@ -0,0 +1,39 @@
1
+ import type { VersionDetails } from "../services/IWorkflowDefinitions.service";
2
+
3
+ /** A process with a published version, as a list query reads it: no documents. */
4
+ export interface PublishedHead {
5
+ readonly id: string;
6
+ readonly key: string;
7
+ readonly latestVersion: number;
8
+ }
9
+
10
+ /**
11
+ * The latest published versions, kept between calls. Versions are immutable, so
12
+ * what was read once is read no more: an event or a firing of a schedule costs
13
+ * one query of the heads, and documents are loaded only for versions published
14
+ * since. One per application instance; another instance sees a publication with
15
+ * its next query of the heads.
16
+ */
17
+ export class PublishedVersions {
18
+ private readonly byProcess = new Map<string, VersionDetails>();
19
+
20
+ /** The latest version of each head; `load` reads the documents this cache does not hold yet. */
21
+ async resolve(heads: readonly PublishedHead[], load: (missing: readonly PublishedHead[]) => Promise<readonly VersionDetails[]>): Promise<readonly VersionDetails[]> {
22
+ const current = new Set(heads.map((head) => head.id));
23
+ for (const id of this.byProcess.keys()) if (!current.has(id)) this.byProcess.delete(id);
24
+ const missing = heads.filter((head) => this.byProcess.get(head.id)?.version !== head.latestVersion);
25
+ if (missing.length > 0) {
26
+ const loaded = new Map((await load(missing)).map((version) => [version.key, version]));
27
+ for (const head of missing) {
28
+ const version = loaded.get(head.key);
29
+ // A version another instance has just replaced is read on the next call.
30
+ if (version?.version === head.latestVersion) this.byProcess.set(head.id, version);
31
+ else this.byProcess.delete(head.id);
32
+ }
33
+ }
34
+ return heads.flatMap((head) => {
35
+ const version = this.byProcess.get(head.id);
36
+ return version === undefined ? [] : [version];
37
+ });
38
+ }
39
+ }
@@ -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 { LocalizedText } from "../../../../library/workflow";
3
3
 
4
4
  /**
@@ -12,50 +12,63 @@ export class WfProcess {
12
12
  id = "";
13
13
 
14
14
  @Index({ unique: true })
15
+ @Nullable()
15
16
  @Column({ type: "text" })
16
17
  key = "";
17
18
 
19
+ @Nullable()
18
20
  @Column({ type: "json" })
19
21
  title: LocalizedText = "";
20
22
 
21
23
  /** Every text of `title`, the default language first: what a list searches and sorts by. */
24
+ @Nullable()
22
25
  @Column({ type: "text" })
23
26
  titleText = "";
24
27
 
25
- @Column({ type: "json", nullable: true })
28
+ @Nullable()
29
+ @Column({ type: "json" })
26
30
  description: LocalizedText | null = null;
27
31
 
28
32
  /** A path, `Sales/Orders`: there is no folder entity. */
29
33
  @Index()
30
- @Column({ type: "text", nullable: true })
34
+ @Nullable()
35
+ @Column({ type: "text" })
31
36
  folder: string | null = null;
32
37
 
33
38
  /** Whose process it is: an opaque id of the application. */
34
- @Column({ type: "text", nullable: true })
39
+ @Nullable()
40
+ @Column({ type: "text" })
35
41
  owner: string | null = null;
36
42
 
37
43
  /** The draft document; kept with validation errors too. */
38
- @Column({ type: "json", nullable: true })
44
+ @Nullable()
45
+ @Column({ type: "json" })
39
46
  draft: unknown = null;
40
47
 
41
- @Column({ type: "datetime", nullable: true })
48
+ @Nullable()
49
+ @Column({ type: "datetime" })
42
50
  draftUpdatedAt: Date | null = null;
43
51
 
44
- @Column({ type: "text", nullable: true })
52
+ @Nullable()
53
+ @Column({ type: "text" })
45
54
  draftUpdatedByKind: string | null = null;
46
55
 
47
- @Column({ type: "text", nullable: true })
56
+ @Nullable()
57
+ @Column({ type: "text" })
48
58
  draftUpdatedById: string | null = null;
49
59
 
50
- @Column({ type: "integer", nullable: true })
60
+ @Nullable()
61
+ @Column({ type: "integer" })
51
62
  latestVersion: number | null = null;
52
63
 
53
64
  /** Days to keep completed and cancelled instances; `null` keeps them. */
54
- @Column({ type: "integer", nullable: true })
65
+ @Nullable()
66
+ @Column({ type: "integer" })
55
67
  retentionDays: number | null = null;
56
68
 
57
69
  /** An archived process is not started. */
58
- @Column({ type: "datetime", nullable: true })
70
+ @Nullable()
71
+ @Column({ type: "datetime" })
59
72
  archivedAt: Date | null = null;
60
73
 
61
74
  @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 { WfProcess } from "./WfProcess.model";
3
3
 
4
4
  /** A published version of a process: an immutable snapshot of its document. */
@@ -10,25 +10,31 @@ export class WfVersion {
10
10
  id = "";
11
11
 
12
12
  /** A reference: the foreign key makes the column a native uuid. A non-key `type: "uuid"` would generate a value. */
13
+ @Nullable()
13
14
  @Column({ type: "text" })
14
15
  processId = "";
15
16
 
17
+ @Nullable()
16
18
  @Column({ type: "integer" })
17
19
  version = 0;
18
20
 
21
+ @Nullable()
19
22
  @Column({ type: "json" })
20
23
  document: unknown = null;
21
24
 
22
25
  /** sha-256 of the document: publishing the same document again adds no version. */
26
+ @Nullable()
23
27
  @Column({ type: "text" })
24
28
  documentHash = "";
25
29
 
26
30
  @Column({ type: "createdAt" })
27
31
  publishedAt = new Date(0);
28
32
 
33
+ @Nullable()
29
34
  @Column({ type: "text" })
30
35
  publishedByKind = "";
31
36
 
37
+ @Nullable()
32
38
  @Column({ type: "text" })
33
39
  publishedById = "";
34
40
  }
@@ -146,7 +146,10 @@ export abstract class IWorkflowDefinitions {
146
146
  abstract version(key: string, version: number): Promise<VersionDetails>;
147
147
  /** The latest published version of a process that is not archived. */
148
148
  abstract latest(key: string): Promise<VersionDetails>;
149
- /** The latest version of every process that is not archived: where event and scheduled starts come from. */
149
+ /**
150
+ * The latest version of every process that is not archived: where event and scheduled
151
+ * starts come from. Documents are kept between calls and must not be changed.
152
+ */
150
153
  abstract published(): Promise<readonly VersionDetails[]>;
151
154
  /** A version by id, for the engine; `undefined` if there is none. */
152
155
  abstract versionById(id: string): Promise<VersionDetails | undefined>;
@@ -19,6 +19,7 @@ import type { CodeProcessCatalog } from "../code/CodeProcessCatalog";
19
19
  import type { OwnedCodeProcess } from "../code/codeProcesses";
20
20
  import { WORKFLOW_PROCESS_CHANGED, type ProcessChange } from "../events/Definitions.events";
21
21
  import type { DefinitionsDbContext } from "../model/Definitions.dbContext";
22
+ import type { PublishedVersions } from "../internal/PublishedVersions";
22
23
  import { WfProcess } from "../model/WfProcess.model";
23
24
  import { WfVersion } from "../model/WfVersion.model";
24
25
  import {
@@ -52,6 +53,7 @@ export class WorkflowDefinitions extends IWorkflowDefinitions {
52
53
  private readonly events: WorkflowEvents,
53
54
  private readonly languages: WorkflowLanguages,
54
55
  private readonly references: WorkflowReferences,
56
+ private readonly publishedVersions: PublishedVersions,
55
57
  /** The scope: the port is asked here, so that the application's own registration replaces the default. */
56
58
  private readonly services: ServiceProvider,
57
59
  ) {
@@ -351,13 +353,21 @@ export class WorkflowDefinitions extends IWorkflowDefinitions {
351
353
  }
352
354
 
353
355
  async published(): Promise<readonly VersionDetails[]> {
354
- const processes = await this.db.processes.asNoTracking().where((p) => p.archivedAt.isNull().and(p.latestVersion.isNotNull())).toList();
355
- if (processes.length === 0) return [];
356
- const byId = new Map(processes.map((process) => [process.id, process]));
357
- const versions = await this.db.versions.asNoTracking().where((v) => v.processId.in([...byId.keys()])).toList();
358
- return versions
359
- .filter((version) => byId.get(version.processId)?.latestVersion === version.version)
360
- .map((version) => versionOf(byId.get(version.processId)!.key, version));
356
+ // Without the drafts and documents: this runs on every event and every firing of a schedule.
357
+ const rows = await this.db.processes.asNoTracking()
358
+ .where((p) => p.archivedAt.isNull().and(p.latestVersion.isNotNull()))
359
+ .select((p) => ({ id: p.id, key: p.key, latestVersion: p.latestVersion }))
360
+ .toList();
361
+ const heads = rows.map((row) => ({ id: row.id, key: row.key, latestVersion: row.latestVersion! }));
362
+ return await this.publishedVersions.resolve(heads, async (missing) => {
363
+ const versions = await this.db.versions.asNoTracking()
364
+ .where((v) => v.processId.in(missing.map((head) => head.id)).and(v.version.in([...new Set(missing.map((head) => head.latestVersion))])))
365
+ .toList();
366
+ const byId = new Map(missing.map((head) => [head.id, head]));
367
+ return versions
368
+ .filter((version) => byId.get(version.processId)?.latestVersion === version.version)
369
+ .map((version) => versionOf(byId.get(version.processId)!.key, version));
370
+ });
361
371
  }
362
372
 
363
373
  async versionById(id: string): Promise<VersionDetails | undefined> {
@@ -14,6 +14,7 @@ import { CatalogImport } from "../definitions_module/code/CatalogImport";
14
14
  import { CodeProcessCatalog } from "../definitions_module/code/CodeProcessCatalog";
15
15
  import { CodeProcessPublisher } from "../definitions_module/code/CodeProcessPublisher";
16
16
  import { ProcessesController } from "../definitions_module/http/Processes.controller";
17
+ import { PublishedVersions } from "../definitions_module/internal/PublishedVersions";
17
18
  import { DefinitionsDbContext } from "../definitions_module/model/Definitions.dbContext";
18
19
  import { IWorkflowDefinitions } from "../definitions_module/services/IWorkflowDefinitions.service";
19
20
  import { WorkflowDefinitions } from "../definitions_module/services/WorkflowDefinitions.service";
@@ -66,7 +67,7 @@ registerFrameworkClassDeps([
66
67
  [TasksDbContext, [DbContextOptions]],
67
68
  [TimerStep, [RuntimeDbContext, WorkflowEngine]],
68
69
  [ToolStep, [RuntimeDbContext, WorkflowEngine, WorkflowTools, WorkflowReferences, WorkflowLanguages, ServiceProvider]],
69
- [WorkflowDefinitions, [DefinitionsDbContext, WorkflowTools, CodeProcessCatalog, WorkflowClock, WorkflowEvents, WorkflowLanguages, WorkflowReferences, ServiceProvider]],
70
+ [WorkflowDefinitions, [DefinitionsDbContext, WorkflowTools, CodeProcessCatalog, WorkflowClock, WorkflowEvents, WorkflowLanguages, WorkflowReferences, PublishedVersions, ServiceProvider]],
70
71
  [WorkflowEngine, [RuntimeDbContext, IWorkflowDefinitions, GraphCache, WorkflowEvents, WorkflowClock]],
71
72
  [WorkflowEvents, [EventBus, ServiceProvider, "optional:Logger"]],
72
73
  [WorkflowLanguages, ["Config:workflow"]],
@@ -3,7 +3,11 @@
3
3
  * with the Definitions and Runtime parts, their services, HTTP, events and
4
4
  * extension points. The document format is `bazis/library/workflow`.
5
5
  */
6
+ import type { PermissionsOf } from "../access";
7
+ import type { definitionsPermissions } from "./definitions_module/access/Definitions.permissions";
6
8
  import type { CodeProcess } from "./definitions_module/code/defineProcess";
9
+ import type { runtimePermissions } from "./runtime_module/access/Runtime.permissions";
10
+ import type { tasksPermissions } from "./tasks_module/access/Tasks.permissions";
7
11
  import "./generated/deps";
8
12
 
9
13
  export { WorkflowModule } from "./Workflow.module";
@@ -73,6 +77,11 @@ export { WorkflowPartitions, DefaultWorkflowPartitions, type ProcessPartition }
73
77
  export { WorkflowError, type WorkflowErrorCode } from "./contracts/WorkflowError";
74
78
  export type { WorkflowActor, WorkflowAuditOptions, WorkflowPage, WorkflowPaging, WorkflowRequest, WorkflowToolMetadata } from "./contracts/types";
75
79
 
80
+ // The pack's own permissions: an application that imports the pack may name them in `can` and `defineRoles`.
81
+ declare module "../access/index" {
82
+ interface BazisPermissions extends PermissionsOf<typeof definitionsPermissions>, PermissionsOf<typeof runtimePermissions>, PermissionsOf<typeof tasksPermissions> {}
83
+ }
84
+
76
85
  // Augment the declaring file, not the "../di" re-export: re-export targets
77
86
  // depend on program file order and silently stop merging in some projects.
78
87
  declare module "../di/module/types/BazisModule" {
@@ -16,7 +16,7 @@ export class WorkflowLanguages {
16
16
  /** The best language of an `Accept-Language` header: by weight, then by order; `ru-RU` also finds `ru`. */
17
17
  of(header: string | undefined): string {
18
18
  if (header === undefined) return this.default;
19
- const known = new Set([this.default, ...this.config.get("languages").split(",").map((item) => item.trim())]);
19
+ const known = new Set([this.default, ...this.config.get("languages")]);
20
20
  const wanted = header.split(",")
21
21
  .map((part, index) => {
22
22
  const [tag = "", ...parameters] = part.trim().split(";");
@@ -1,4 +1,4 @@
1
- import { IAccess, type AccessObject, type SubjectAccess } from "../../access";
1
+ import { IAccess, type AccessObject, type SubjectAccess, type PermissionKey } from "../../access";
2
2
  import { ForbiddenError, type HttpContext } from "../../http";
3
3
  import { frameworkBadRequest } from "../../http/Errors/HttpError";
4
4
  import { ListQueryError, parseListQuery, type ListQuery, type ListQueryOptions, type ListQueryProblem } from "../../../library/jsonapi";
@@ -32,7 +32,7 @@ export function defaultLanguage(ctx: HttpContext): string {
32
32
  * right. The actor's id is the first owner id, so that `{ own: true }` means
33
33
  * the instances the subject started.
34
34
  */
35
- export async function callerOf(ctx: HttpContext, permission?: string, object?: AccessObject): Promise<WorkflowCaller> {
35
+ export async function callerOf(ctx: HttpContext, permission?: PermissionKey, object?: AccessObject): Promise<WorkflowCaller> {
36
36
  const access = await ctx.services.resolve(IAccess).for(ctx);
37
37
  if (permission !== undefined && !await access.can(permission, object)) throw new ForbiddenError();
38
38
  const sub = access.subject.sub;
@@ -38,8 +38,12 @@ export class WorkflowWorker extends BackgroundService {
38
38
  try {
39
39
  const jobs = await this.jobs.claim(batchSize);
40
40
  claimed = jobs.length;
41
- for (const job of jobs) {
42
- if (signal.aborted) return;
41
+ for (const [index, job] of jobs.entries()) {
42
+ if (signal.aborted) {
43
+ // What was claimed and will not run is free for another worker at once, not in a lease.
44
+ await this.jobs.release(jobs.slice(index));
45
+ return;
46
+ }
43
47
  await this.jobs.run(job, signal);
44
48
  }
45
49
  } catch (error) {
@@ -32,7 +32,7 @@ export class InstancesController {
32
32
  language,
33
33
  ...(request.test === true ? { test: true } : {}),
34
34
  });
35
- return Created(`${ctx.path}/${instance.id}`, instance);
35
+ return Created(instance, `${ctx.path}/${instance.id}`);
36
36
  }
37
37
 
38
38
  /** A start per element, for an action over many records (WORKFLOW-002 §5.1). */
@@ -26,7 +26,10 @@ export class WorkflowJobs {
26
26
  private readonly report: JobFailureReport,
27
27
  ) {}
28
28
 
29
- /** Takes up to `batchSize` due jobs with `FOR UPDATE SKIP LOCKED` and a lease, so instances never take one job twice. */
29
+ /**
30
+ * Takes up to `batchSize` due jobs with `FOR UPDATE SKIP LOCKED` and a lease, so
31
+ * instances never take one job twice. {@link run} renews the lease of each as it starts.
32
+ */
30
33
  async claim(batchSize: number): Promise<WfJob[]> {
31
34
  const scope = this.services.createScope();
32
35
  try {
@@ -51,7 +54,46 @@ export class WorkflowJobs {
51
54
  }
52
55
  }
53
56
 
57
+ /**
58
+ * The lease of a claimed job starts when the job does: a batch runs one job after
59
+ * another, and a job that waited for the ones before it longer than a lease may
60
+ * have been taken by another worker. `false` then: the job is not this worker's.
61
+ */
62
+ private async begin(job: WfJob): Promise<boolean> {
63
+ const scope = this.services.createScope();
64
+ try {
65
+ const db = scope.resolve(RuntimeDbContext);
66
+ const lockedUntil = new Date(scope.resolve(WorkflowClock).now().getTime() + LEASE_MS);
67
+ const held = job.lockedUntil;
68
+ const { affectedRows } = await db.jobs.asNoTracking()
69
+ .where((j) => j.id.eq(job.id).and(held === null ? j.lockedUntil.isNull() : j.lockedUntil.eq(held)))
70
+ .executeUpdate({ lockedUntil });
71
+ if (affectedRows === 0) return false;
72
+ job.lockedUntil = lockedUntil;
73
+ return true;
74
+ } finally {
75
+ await scope.dispose();
76
+ }
77
+ }
78
+
79
+ /** Gives back claimed jobs that will not run, a stopping worker's: they are free at once, and the claim does not count as an attempt. */
80
+ async release(jobs: readonly WfJob[]): Promise<void> {
81
+ if (jobs.length === 0) return;
82
+ const scope = this.services.createScope();
83
+ try {
84
+ const db = scope.resolve(RuntimeDbContext);
85
+ for (const job of jobs) {
86
+ const held = job.lockedUntil;
87
+ if (held === null) continue;
88
+ await db.jobs.asNoTracking().where((j) => j.id.eq(job.id).and(j.lockedUntil.eq(held))).executeUpdate({ lockedUntil: null, attempts: Math.max(job.attempts - 1, 0) });
89
+ }
90
+ } finally {
91
+ await scope.dispose();
92
+ }
93
+ }
94
+
54
95
  async run(job: WfJob, signal: AbortSignal): Promise<void> {
96
+ if (!await this.begin(job).catch((error: unknown) => { this.report("job could not begin", error, { job: job.id }); return false; })) return;
55
97
  const scope = this.services.createScope();
56
98
  try {
57
99
  switch (job.kind) {
@@ -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
  export type HistoryType = "started" | "queued" | "released" | "entered" | "left" | "tool" | "assigned" | "iteration" | "called" | "returned" | "skipped" | "timer" | "event" | "error" | "retried" | "cancelled" | "timedOut" | "handler" | "completed";
@@ -11,26 +11,33 @@ export class WfHistory {
11
11
  @UUID({ version: "v7" })
12
12
  id = "";
13
13
 
14
+ @Nullable()
14
15
  @Column({ type: "text" })
15
16
  instanceId = "";
16
17
 
17
- @Column({ type: "text", nullable: true })
18
+ @Nullable()
19
+ @Column({ type: "text" })
18
20
  tokenId: string | null = null;
19
21
 
20
- @Column({ type: "text", nullable: true })
22
+ @Nullable()
23
+ @Column({ type: "text" })
21
24
  nodeId: string | null = null;
22
25
 
26
+ @Nullable()
23
27
  @Column({ type: "text" })
24
28
  type: HistoryType = "entered";
25
29
 
26
- @Column({ type: "json", nullable: true })
30
+ @Nullable()
31
+ @Column({ type: "json" })
27
32
  data: Record<string, unknown> | null = null;
28
33
 
29
34
  /** Who did it: cancel, retry, task completion; `null` for engine steps. */
30
- @Column({ type: "text", nullable: true })
35
+ @Nullable()
36
+ @Column({ type: "text" })
31
37
  actorKind: string | null = null;
32
38
 
33
- @Column({ type: "text", nullable: true })
39
+ @Nullable()
40
+ @Column({ type: "text" })
34
41
  actorId: string | null = null;
35
42
 
36
43
  @Column({ type: "createdAt" })