bazis 0.131.0 → 0.132.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/templates/process.ts +1 -1
- package/core/infra/connectors/codex/CodexAppServerClient.ts +1 -1
- package/core/workflow/{runtime_module/config/Runtime.config.ts → contracts/Workflow.config.ts} +15 -6
- package/core/workflow/contracts/WorkflowExtensions.ts +40 -0
- package/core/workflow/contracts/WorkflowInputValidator.ts +6 -5
- package/core/workflow/definitions_module/WorkflowDefinitions.module.ts +16 -3
- package/core/workflow/definitions_module/access/Definitions.permissions.ts +4 -3
- package/core/workflow/definitions_module/access/Folder.references.ts +37 -0
- package/core/workflow/definitions_module/access/Process.references.ts +1 -1
- package/core/workflow/definitions_module/access/ProcessFolder.lookup.ts +20 -0
- package/core/workflow/definitions_module/code/CatalogImport.ts +95 -0
- package/core/workflow/definitions_module/code/CodeProcessPublisher.ts +26 -16
- package/core/workflow/definitions_module/code/codeProcesses.ts +7 -3
- package/core/workflow/definitions_module/code/defineProcess.ts +10 -4
- package/core/workflow/definitions_module/http/Processes.controller.ts +65 -23
- package/core/workflow/definitions_module/model/Definitions.dbContext.ts +2 -2
- package/core/workflow/definitions_module/model/WfProcess.model.ts +19 -5
- package/core/workflow/definitions_module/model/WfVersion.model.ts +1 -1
- package/core/workflow/definitions_module/services/IWorkflowDefinitions.service.ts +33 -10
- package/core/workflow/definitions_module/services/WorkflowDefinitions.service.ts +88 -28
- package/core/workflow/generated/deps.ts +11 -3
- package/core/workflow/index.ts +4 -2
- package/core/workflow/internal/WorkflowLanguages.ts +35 -0
- package/core/workflow/internal/canonicalJson.ts +8 -0
- package/core/workflow/internal/http.ts +20 -1
- package/core/workflow/runtime_module/WorkflowRuntime.module.ts +0 -2
- package/core/workflow/runtime_module/access/Runtime.permissions.ts +5 -3
- package/core/workflow/runtime_module/background/WorkflowRetention.ts +1 -1
- package/core/workflow/runtime_module/background/WorkflowWorker.ts +2 -2
- package/core/workflow/runtime_module/events/Runtime.events.ts +2 -2
- package/core/workflow/runtime_module/http/Instances.controller.ts +16 -11
- package/core/workflow/runtime_module/internal/Schedules.ts +2 -2
- package/core/workflow/runtime_module/internal/ToolStep.ts +40 -9
- package/core/workflow/runtime_module/internal/WorkflowEngine.ts +60 -10
- package/core/workflow/runtime_module/internal/WorkflowJobs.ts +2 -4
- package/core/workflow/runtime_module/model/Runtime.dbContext.ts +2 -2
- package/core/workflow/runtime_module/model/WfHistory.model.ts +2 -2
- package/core/workflow/runtime_module/model/WfInstance.model.ts +28 -3
- package/core/workflow/runtime_module/model/WfJob.model.ts +17 -1
- package/core/workflow/runtime_module/model/WfSubscription.model.ts +1 -1
- package/core/workflow/runtime_module/model/WfToken.model.ts +11 -1
- package/core/workflow/runtime_module/services/IWorkflowRuntime.service.ts +5 -1
- package/core/workflow/runtime_module/services/WorkflowRuntime.service.ts +9 -7
- package/core/workflow/tasks_module/http/Tasks.controller.ts +14 -13
- package/core/workflow/tasks_module/model/Tasks.dbContext.ts +2 -2
- package/core/workflow/tasks_module/model/WfTask.model.ts +8 -4
- package/core/workflow/tasks_module/services/IWorkflowTasks.service.ts +3 -3
- package/core/workflow/tasks_module/services/WorkflowTasks.service.ts +3 -3
- package/library/openapi/codegen.ts +3 -0
- package/library/validation/types/ValidatorOptions.ts +2 -0
- package/library/workflow/ProcessDocument.ts +59 -23
- package/library/workflow/expressionSyntax.ts +243 -0
- package/library/workflow/expressions.ts +343 -0
- package/library/workflow/formFields.ts +118 -33
- package/library/workflow/index.ts +48 -9
- package/library/workflow/localized.ts +35 -0
- package/library/workflow/masking.ts +58 -0
- package/library/workflow/mermaid.ts +18 -8
- package/library/workflow/nodeKinds.ts +88 -17
- package/library/workflow/palette.ts +108 -0
- package/library/workflow/paths.ts +31 -6
- package/library/workflow/structure.ts +282 -0
- package/library/workflow/upgradeDocument.ts +84 -0
- package/library/workflow/validateProcess.ts +130 -48
- package/package.json +1 -1
- package/library/workflow/conditions.ts +0 -44
package/cli/templates/process.ts
CHANGED
|
@@ -14,7 +14,7 @@ export const ${process.module}Process = defineProcess({
|
|
|
14
14
|
key: "${process.folder}",
|
|
15
15
|
title: "${title}",
|
|
16
16
|
document: {
|
|
17
|
-
schemaVersion: "bazis.workflow/
|
|
17
|
+
schemaVersion: "bazis.workflow/v2",
|
|
18
18
|
nodes: {
|
|
19
19
|
Start: { kind: "manualStart", title: "Start" },
|
|
20
20
|
End: { kind: "end", title: "End" },
|
|
@@ -232,7 +232,7 @@ export class CodexAppServerClient implements CodexClient {
|
|
|
232
232
|
if (method === "account/updated") this.modelCache = undefined;
|
|
233
233
|
}, () => { this.clearLogin(); this.modelCache = undefined; });
|
|
234
234
|
const initialized = await client.request("initialize", {
|
|
235
|
-
clientInfo: { name: "bazis", title: "Bazis", version: "0.
|
|
235
|
+
clientInfo: { name: "bazis", title: "Bazis", version: "0.132.0" }, capabilities: { experimentalApi: true },
|
|
236
236
|
}, 15_000, signal);
|
|
237
237
|
if (resolve(text(initialized.codexHome, 4096)) !== this.paths.home) throw new CodexError("PROTOCOL_ERROR");
|
|
238
238
|
client.notify("initialized");
|
package/core/workflow/{runtime_module/config/Runtime.config.ts → contracts/Workflow.config.ts}
RENAMED
|
@@ -1,12 +1,15 @@
|
|
|
1
|
-
import { defineConfig } from "
|
|
1
|
+
import { defineConfig } from "../../kernel";
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* Settings of the workflow
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* Settings of the workflow pack, from the application configuration:
|
|
5
|
+
* `BAZIS_WORKFLOW__WORKER__ENABLED=false` keeps an instance from running jobs,
|
|
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
|
|
8
|
+
* with `Accept-Language` (WORKFLOW-002 §6).
|
|
7
9
|
*/
|
|
8
|
-
/** The settings of the workflow background services, `workflow.*` in the configuration. */
|
|
9
10
|
export interface WorkflowSettings {
|
|
11
|
+
readonly language: string;
|
|
12
|
+
readonly languages: string;
|
|
10
13
|
readonly "worker.enabled": boolean;
|
|
11
14
|
readonly "worker.intervalMs": number;
|
|
12
15
|
readonly "worker.batchSize": number;
|
|
@@ -15,8 +18,12 @@ export interface WorkflowSettings {
|
|
|
15
18
|
readonly "retention.batchSize": number;
|
|
16
19
|
}
|
|
17
20
|
|
|
18
|
-
|
|
21
|
+
const LANGUAGE = /^[a-z]{2,3}(-[A-Za-z0-9]{2,8})*$/;
|
|
22
|
+
|
|
23
|
+
export const workflowConfig = defineConfig<WorkflowSettings>("workflow", {
|
|
19
24
|
default: {
|
|
25
|
+
language: "en",
|
|
26
|
+
languages: "en",
|
|
20
27
|
"worker.enabled": true,
|
|
21
28
|
"worker.intervalMs": 1000,
|
|
22
29
|
"worker.batchSize": 10,
|
|
@@ -25,6 +32,8 @@ export const workflowRuntimeConfig = defineConfig<WorkflowSettings>("workflow",
|
|
|
25
32
|
"retention.batchSize": 500,
|
|
26
33
|
},
|
|
27
34
|
validate: {
|
|
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"),
|
|
28
37
|
"worker.intervalMs": (value) => positive(value, 3_600_000),
|
|
29
38
|
"worker.batchSize": (value) => positive(value, 1000),
|
|
30
39
|
"retention.intervalMs": (value) => positive(value, 86_400_000),
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { WorkflowActor } from "./types";
|
|
2
|
+
|
|
3
|
+
/** What every port of the pack learns about the call (WORKFLOW-002 §6). */
|
|
4
|
+
export interface WorkflowPortContext {
|
|
5
|
+
readonly partition: string | null;
|
|
6
|
+
/** The language of the request; `workflow.language` for events, schedules and the background. */
|
|
7
|
+
readonly language: string;
|
|
8
|
+
/** `null` for a call from code. */
|
|
9
|
+
readonly actor: WorkflowActor | null;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Where the `extensions` are: a node, a variable, or neither for the root of the document. */
|
|
13
|
+
export interface ExtensionTarget {
|
|
14
|
+
readonly node?: string;
|
|
15
|
+
readonly variable?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Checks the application's data in a document (WORKFLOW-002 §3.1) when a draft
|
|
20
|
+
* is saved and a version is published: the application lists its namespaces and
|
|
21
|
+
* registers its implementation in any module. A namespace nobody checks is an
|
|
22
|
+
* error. A published version with such a key still runs: the engine does not
|
|
23
|
+
* read `extensions`, so a removed module does not break live processes.
|
|
24
|
+
*/
|
|
25
|
+
export abstract class WorkflowExtensions {
|
|
26
|
+
abstract namespaces(): readonly string[];
|
|
27
|
+
/** Every problem of `value`, the data of one namespace at one place. */
|
|
28
|
+
abstract check(namespace: string, value: unknown, target: ExtensionTarget, context: WorkflowPortContext): readonly string[] | Promise<readonly string[]>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** By default there are no namespaces. */
|
|
32
|
+
export class DefaultWorkflowExtensions extends WorkflowExtensions {
|
|
33
|
+
namespaces(): readonly string[] {
|
|
34
|
+
return [];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
check(): readonly string[] {
|
|
38
|
+
return [];
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -1,16 +1,17 @@
|
|
|
1
1
|
import { validateForm, type FormIssue, type FormSpec } from "../../../library/workflow";
|
|
2
|
+
import type { WorkflowPortContext } from "./WorkflowExtensions";
|
|
2
3
|
|
|
3
4
|
/** What a value is checked for: a process input or a task form. */
|
|
4
|
-
export interface WorkflowInputContext {
|
|
5
|
+
export interface WorkflowInputContext extends WorkflowPortContext {
|
|
5
6
|
readonly purpose: "input" | "task";
|
|
6
7
|
readonly processKey: string;
|
|
7
|
-
readonly partition: string | null;
|
|
8
8
|
}
|
|
9
9
|
|
|
10
10
|
/**
|
|
11
|
-
* Checks a process input and task forms against their fields (WORKFLOW-
|
|
12
|
-
*
|
|
13
|
-
* own field rules registers its implementation of
|
|
11
|
+
* Checks a process input and task forms against their fields (WORKFLOW-002 §3.4):
|
|
12
|
+
* a field carries the application's rules in `extensions`. The default checks the
|
|
13
|
+
* types; an application with its own field rules registers its implementation of
|
|
14
|
+
* this class in any module.
|
|
14
15
|
*/
|
|
15
16
|
export abstract class WorkflowInputValidator {
|
|
16
17
|
abstract validate(spec: FormSpec | undefined, value: unknown, context: WorkflowInputContext): { readonly value: Record<string, unknown>; readonly issues: readonly FormIssue[] } | Promise<{ readonly value: Record<string, unknown>; readonly issues: readonly FormIssue[] }>;
|
|
@@ -1,10 +1,16 @@
|
|
|
1
1
|
import { AccessModule } from "../../access";
|
|
2
2
|
import { HOSTED_SERVICE, Module, scoped, singleton } from "../../di";
|
|
3
3
|
import { SystemWorkflowClock, WorkflowClock } from "../contracts/WorkflowClock";
|
|
4
|
+
import { workflowConfig } from "../contracts/Workflow.config";
|
|
5
|
+
import { DefaultWorkflowExtensions, WorkflowExtensions } from "../contracts/WorkflowExtensions";
|
|
4
6
|
import { WorkflowEvents } from "../internal/WorkflowEvents";
|
|
7
|
+
import { WorkflowLanguages } from "../internal/WorkflowLanguages";
|
|
5
8
|
import { WorkflowTools } from "../internal/WorkflowTools";
|
|
6
9
|
import { definitionsPermissions } from "./access/Definitions.permissions";
|
|
10
|
+
import { FolderReferences } from "./access/Folder.references";
|
|
7
11
|
import { ProcessReferences } from "./access/Process.references";
|
|
12
|
+
import { ProcessFolderLookup } from "./access/ProcessFolder.lookup";
|
|
13
|
+
import { CatalogImport } from "./code/CatalogImport";
|
|
8
14
|
import { CodeProcessCatalog } from "./code/CodeProcessCatalog";
|
|
9
15
|
import { CodeProcessPublisher } from "./code/CodeProcessPublisher";
|
|
10
16
|
import { ProcessesController } from "./http/Processes.controller";
|
|
@@ -16,23 +22,30 @@ import { WorkflowDefinitions } from "./services/WorkflowDefinitions.service";
|
|
|
16
22
|
|
|
17
23
|
/**
|
|
18
24
|
* The process catalog: drafts and published versions, and the processes
|
|
19
|
-
* declared in code ([passport](MODULE.md)).
|
|
25
|
+
* declared in code ([passport](MODULE.md)). The application checks its own
|
|
26
|
+
* `extensions` by registering its `WorkflowExtensions`.
|
|
20
27
|
*/
|
|
21
28
|
@Module({
|
|
22
29
|
imports: [AccessModule],
|
|
23
30
|
permissions: definitionsPermissions,
|
|
24
|
-
references: { "workflow.process": ProcessReferences },
|
|
31
|
+
references: { "workflow.process": ProcessReferences, "workflow.folder": FolderReferences },
|
|
32
|
+
config: [workflowConfig],
|
|
25
33
|
ormBazis: { context: DefinitionsDbContext, entities: [WfProcess, WfVersion], ownedStore: definitionsStore, registerRepositories: false },
|
|
26
34
|
providers: [
|
|
27
35
|
singleton(WorkflowClock, SystemWorkflowClock),
|
|
28
36
|
singleton(WorkflowTools),
|
|
29
37
|
singleton(CodeProcessCatalog),
|
|
38
|
+
singleton(WorkflowLanguages),
|
|
39
|
+
singleton(WorkflowExtensions, DefaultWorkflowExtensions),
|
|
30
40
|
scoped(WorkflowEvents),
|
|
41
|
+
scoped(CatalogImport),
|
|
31
42
|
scoped(IWorkflowDefinitions, WorkflowDefinitions),
|
|
32
43
|
scoped(ProcessReferences),
|
|
44
|
+
scoped(FolderReferences),
|
|
45
|
+
scoped(ProcessFolderLookup),
|
|
33
46
|
singleton(HOSTED_SERVICE, CodeProcessPublisher),
|
|
34
47
|
],
|
|
35
48
|
controllers: [ProcessesController],
|
|
36
|
-
exports: [IWorkflowDefinitions, WorkflowTools, WorkflowClock, WorkflowEvents],
|
|
49
|
+
exports: [IWorkflowDefinitions, WorkflowTools, WorkflowClock, WorkflowEvents, WorkflowLanguages, WorkflowExtensions, ProcessFolderLookup],
|
|
37
50
|
})
|
|
38
51
|
export class WorkflowDefinitionsModule {}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { definePermissions } from "../../../access";
|
|
2
2
|
|
|
3
3
|
const process = { ref: "workflow.process", column: "key" };
|
|
4
|
+
const folder = { ref: "workflow.folder", column: "folder" };
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* The rights of the process catalog (WORKFLOW-002 §6.2). A process is one per
|
|
@@ -10,14 +11,14 @@ const process = { ref: "workflow.process", column: "key" };
|
|
|
10
11
|
export const definitionsPermissions = definePermissions({
|
|
11
12
|
"workflow.processes.read": {
|
|
12
13
|
title: { en: "See processes", ru: "Видеть процессы" },
|
|
13
|
-
on: { process },
|
|
14
|
+
on: { process, folder },
|
|
14
15
|
},
|
|
15
16
|
"workflow.processes.manage": {
|
|
16
17
|
title: { en: "Edit processes", ru: "Редактировать процессы" },
|
|
17
|
-
on: { process },
|
|
18
|
+
on: { process, folder },
|
|
18
19
|
},
|
|
19
20
|
"workflow.processes.publish": {
|
|
20
21
|
title: { en: "Publish processes", ru: "Публиковать процессы" },
|
|
21
|
-
on: { process },
|
|
22
|
+
on: { process, folder },
|
|
22
23
|
},
|
|
23
24
|
});
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { References, type ReferenceItem, type ReferencePage } from "../../../access";
|
|
2
|
+
import type { DefinitionsDbContext } from "../model/Definitions.dbContext";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Reference kind `workflow.folder`: the folders of the catalog. A folder exists
|
|
6
|
+
* while a process lies in it or below it: there is no folder entity.
|
|
7
|
+
*/
|
|
8
|
+
export class FolderReferences extends References {
|
|
9
|
+
constructor(private readonly db: DefinitionsDbContext) {
|
|
10
|
+
super();
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
async exists(ids: readonly string[]): Promise<ReadonlySet<string>> {
|
|
14
|
+
const folders = await this.folders();
|
|
15
|
+
return new Set(ids.filter((id) => folders.has(id)));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async describe(ids: readonly string[]): Promise<readonly ReferenceItem[]> {
|
|
19
|
+
const folders = await this.folders();
|
|
20
|
+
return ids.filter((id) => folders.has(id)).map((id) => ({ id, title: id }));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async search(text: string, _context: unknown, page: ReferencePage): Promise<readonly ReferenceItem[]> {
|
|
24
|
+
const found = text.trim().toLowerCase();
|
|
25
|
+
return [...await this.folders()].filter((folder) => folder.toLowerCase().includes(found)).sort().slice(page.offset, page.offset + page.limit).map((id) => ({ id, title: id }));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
private async folders(): Promise<ReadonlySet<string>> {
|
|
29
|
+
const rows = await this.db.processes.asNoTracking().where((p) => p.folder.isNotNull()).select((p) => ({ folder: p.folder })).toList();
|
|
30
|
+
const all = new Set<string>();
|
|
31
|
+
for (const { folder } of rows) {
|
|
32
|
+
const parts = folder!.split("/");
|
|
33
|
+
for (let length = 1; length <= parts.length; length += 1) all.add(parts.slice(0, length).join("/"));
|
|
34
|
+
}
|
|
35
|
+
return all;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -20,7 +20,7 @@ export class ProcessReferences extends References {
|
|
|
20
20
|
async search(text: string, _context: unknown, page: ReferencePage): Promise<readonly ReferenceItem[]> {
|
|
21
21
|
const found = text.trim();
|
|
22
22
|
let source = this.db.processes.asNoTracking();
|
|
23
|
-
if (found !== "") source = source.where((p) => p.key.contains(found, { ignoreCase: true }).or(p.
|
|
23
|
+
if (found !== "") source = source.where((p) => p.key.contains(found, { ignoreCase: true }).or(p.titleText.contains(found, { ignoreCase: true })));
|
|
24
24
|
const rows = await source.orderBy((p) => p.key).skip(page.offset).take(page.limit).toList();
|
|
25
25
|
return rows.map((row) => ({ id: row.key, title: row.title }));
|
|
26
26
|
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { AttributeLookup, type AccessValue, type LookupColumn } from "../../../access";
|
|
2
|
+
import type { DefinitionsDbContext } from "../model/Definitions.dbContext";
|
|
3
|
+
|
|
4
|
+
/** `folder` of rows that only know their process: folder → the keys of its processes (WORKFLOW-002 §6.2). */
|
|
5
|
+
export class ProcessFolderLookup extends AttributeLookup {
|
|
6
|
+
constructor(private readonly db: DefinitionsDbContext) {
|
|
7
|
+
super();
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
async toColumn(values: readonly AccessValue[]): Promise<LookupColumn> {
|
|
11
|
+
const folders = values.filter((value): value is string => typeof value === "string");
|
|
12
|
+
const withoutFolder = values.includes(null);
|
|
13
|
+
if (folders.length === 0 && !withoutFolder) return { column: "processKey", in: [] };
|
|
14
|
+
const rows = await this.db.processes.asNoTracking()
|
|
15
|
+
.where((p) => (folders.length === 0 ? p.folder.isNull() : withoutFolder ? p.folder.isNull().or(p.folder.in(folders)) : p.folder.in(folders)))
|
|
16
|
+
.select((p) => ({ key: p.key }))
|
|
17
|
+
.toList();
|
|
18
|
+
return { column: "processKey", in: rows.map((row) => row.key) };
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { upgradeDocument, type LocalizedText } from "../../../../library/workflow";
|
|
3
|
+
import { canonicalJson } from "../../internal/canonicalJson";
|
|
4
|
+
import type { WorkflowLanguages } from "../../internal/WorkflowLanguages";
|
|
5
|
+
import type { DefinitionsDbContext } from "../model/Definitions.dbContext";
|
|
6
|
+
import { WfProcess } from "../model/WfProcess.model";
|
|
7
|
+
import { WfVersion } from "../model/WfVersion.model";
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Brings the catalog of the store before WORKFLOW-002 into the new one, once, at
|
|
12
|
+
* start (§9): processes and versions, documents through `upgradeDocument`,
|
|
13
|
+
* a title into the default language. Nothing happens without the old tables or
|
|
14
|
+
* when the new catalog has processes. Instances are not brought over. The old
|
|
15
|
+
* tables stay until they are dropped by hand (`upgrading.md`). Goes away with
|
|
16
|
+
* reading of version 1.
|
|
17
|
+
*/
|
|
18
|
+
export class CatalogImport {
|
|
19
|
+
constructor(
|
|
20
|
+
private readonly db: DefinitionsDbContext,
|
|
21
|
+
private readonly languages: WorkflowLanguages,
|
|
22
|
+
) {}
|
|
23
|
+
|
|
24
|
+
/** How many processes came over. */
|
|
25
|
+
async run(): Promise<number> {
|
|
26
|
+
return await this.db.transactionScope(async () => {
|
|
27
|
+
// One importer at a time across application instances.
|
|
28
|
+
await this.db.database.querySqlRaw("SELECT pg_advisory_xact_lock(hashtext('bazis.workflow.import'))");
|
|
29
|
+
const [{ old } = { old: null }] = await this.db.database.querySqlRaw("SELECT to_regclass('public.bazis_wf_def_processes')::text AS old");
|
|
30
|
+
if (old === null || await this.db.processes.asNoTracking().count() > 0) return 0;
|
|
31
|
+
const processes = await this.db.database.querySqlRaw('SELECT * FROM "bazis_wf_def_processes"');
|
|
32
|
+
const versions = await this.db.database.querySqlRaw('SELECT * FROM "bazis_wf_def_versions"');
|
|
33
|
+
const stamps: { readonly table: string; readonly column: string; readonly id: string; readonly at: Date }[] = [];
|
|
34
|
+
for (const row of processes) {
|
|
35
|
+
const title = text(row.title);
|
|
36
|
+
const process = Object.assign(new WfProcess(), {
|
|
37
|
+
id: String(row.id),
|
|
38
|
+
key: text(row.key),
|
|
39
|
+
title: { [this.languages.default]: title } satisfies LocalizedText,
|
|
40
|
+
titleText: title,
|
|
41
|
+
description: row.description === null ? null : { [this.languages.default]: text(row.description) } satisfies LocalizedText,
|
|
42
|
+
draft: upgradeDocument(json(row.draft)),
|
|
43
|
+
draftUpdatedAt: date(row.draftUpdatedAt),
|
|
44
|
+
draftUpdatedByKind: row.draftUpdatedByKind as string | null,
|
|
45
|
+
draftUpdatedById: row.draftUpdatedById as string | null,
|
|
46
|
+
latestVersion: row.latestVersion as number | null,
|
|
47
|
+
retentionDays: row.retentionDays as number | null,
|
|
48
|
+
archivedAt: date(row.archivedAt),
|
|
49
|
+
});
|
|
50
|
+
this.db.processes.add(process);
|
|
51
|
+
stamps.push({ table: "bazis_wf2_def_processes", column: "createdAt", id: process.id, at: date(row.createdAt)! });
|
|
52
|
+
}
|
|
53
|
+
await this.db.saveChanges();
|
|
54
|
+
for (const row of versions) {
|
|
55
|
+
const document = upgradeDocument(json(row.document));
|
|
56
|
+
const version = Object.assign(new WfVersion(), {
|
|
57
|
+
id: String(row.id),
|
|
58
|
+
processId: String(row.processId),
|
|
59
|
+
version: row.version as number,
|
|
60
|
+
document,
|
|
61
|
+
documentHash: createHash("sha256").update(canonicalJson(document)).digest("hex"),
|
|
62
|
+
publishedByKind: text(row.publishedByKind),
|
|
63
|
+
publishedById: text(row.publishedById),
|
|
64
|
+
});
|
|
65
|
+
this.db.versions.add(version);
|
|
66
|
+
stamps.push({ table: "bazis_wf2_def_versions", column: "publishedAt", id: version.id, at: date(row.publishedAt)! });
|
|
67
|
+
}
|
|
68
|
+
await this.db.saveChanges();
|
|
69
|
+
// These columns fill themselves on insert: the history keeps its dates.
|
|
70
|
+
for (const { table, column, id, at } of stamps) {
|
|
71
|
+
await this.db.database.executeSqlRaw(`UPDATE "${table}" SET "${column}" = {0} WHERE "id" = {1}::uuid`, at, id);
|
|
72
|
+
}
|
|
73
|
+
return processes.length;
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function text(value: unknown): string {
|
|
79
|
+
return typeof value === "string" ? value : String(value ?? "");
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function date(value: unknown): Date | null {
|
|
83
|
+
return value === null || value === undefined ? null : new Date(value as string | Date);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** A raw `json` column may come parsed or as text, depending on the driver. */
|
|
87
|
+
function json(value: unknown): unknown {
|
|
88
|
+
if (typeof value !== "string") return value;
|
|
89
|
+
try {
|
|
90
|
+
return JSON.parse(value);
|
|
91
|
+
} catch {
|
|
92
|
+
return value;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
@@ -3,14 +3,16 @@ import type { HostedService } from "../../../di/extensions/hosted-service";
|
|
|
3
3
|
import { LOGGER } from "../../../kernel";
|
|
4
4
|
import { WorkflowTools } from "../../internal/WorkflowTools";
|
|
5
5
|
import { IWorkflowDefinitions } from "../services/IWorkflowDefinitions.service";
|
|
6
|
+
import { CatalogImport } from "./CatalogImport";
|
|
6
7
|
import { CodeProcessCatalog } from "./CodeProcessCatalog";
|
|
7
8
|
import { definitionProblems, WorkflowSetupError } from "./codeProcesses";
|
|
8
9
|
|
|
9
10
|
/**
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
11
|
+
* At start, after the tables exist: brings over the catalog of the old store
|
|
12
|
+
* (WORKFLOW-002 §9), then publishes the processes of `@Module({ processes })`
|
|
13
|
+
* (WORKFLOW-001 §14). A document with an unknown Tool or an `extensions` namespace
|
|
14
|
+
* nobody checks stops the start with every finding of every process; the same
|
|
15
|
+
* document again gives no new version.
|
|
14
16
|
*/
|
|
15
17
|
export class CodeProcessPublisher implements HostedService {
|
|
16
18
|
readonly diagnosticName = "workflow:processes";
|
|
@@ -18,20 +20,28 @@ export class CodeProcessPublisher implements HostedService {
|
|
|
18
20
|
constructor(private readonly services: ServiceProvider) {}
|
|
19
21
|
|
|
20
22
|
async start(): Promise<void> {
|
|
21
|
-
const
|
|
22
|
-
if (entries.length === 0) return;
|
|
23
|
-
const tools = this.services.resolve(WorkflowTools);
|
|
24
|
-
const problems = entries.flatMap(({ process, module }) => {
|
|
25
|
-
const found = definitionProblems(process, (name) => tools.has(name));
|
|
26
|
-
return found.length === 0 ? [] : [`${module}: process "${process.key}":\n${found.map((line) => ` - ${line}`).join("\n")}`];
|
|
27
|
-
});
|
|
28
|
-
if (problems.length > 0) {
|
|
29
|
-
throw markConfigurationError(new WorkflowSetupError(`Processes declared in code cannot be published:\n${problems.join("\n")}`));
|
|
30
|
-
}
|
|
23
|
+
const logger = this.services.tryResolve(LOGGER);
|
|
31
24
|
const scope = this.services.createScope();
|
|
32
25
|
try {
|
|
33
|
-
const
|
|
34
|
-
|
|
26
|
+
const imported = await scope.resolve(CatalogImport).run();
|
|
27
|
+
if (imported > 0) logger?.info("workflow catalog brought over from the old store", { processes: imported });
|
|
28
|
+
const entries = this.services.resolve(CodeProcessCatalog).list();
|
|
29
|
+
if (entries.length === 0) return;
|
|
30
|
+
const tools = this.services.resolve(WorkflowTools);
|
|
31
|
+
const definitions = scope.resolve(IWorkflowDefinitions);
|
|
32
|
+
const problems: string[] = [];
|
|
33
|
+
for (const { process, module } of entries) {
|
|
34
|
+
const found = [...definitionProblems(process, (name) => tools.has(name))];
|
|
35
|
+
for (const issue of await definitions.validate(process.document)) {
|
|
36
|
+
if (issue.severity === "error" && (issue.code === "extension_unknown" || issue.code === "extension_invalid")) found.push(`${issue.message} (${issue.code})`);
|
|
37
|
+
}
|
|
38
|
+
if (found.length > 0) problems.push(`${module}: process "${process.key}":\n${found.map((line) => ` - ${line}`).join("\n")}`);
|
|
39
|
+
}
|
|
40
|
+
if (problems.length > 0) {
|
|
41
|
+
throw markConfigurationError(new WorkflowSetupError(`Processes declared in code cannot be published:\n${problems.join("\n")}`));
|
|
42
|
+
}
|
|
43
|
+
const results = await definitions.publishCode(entries);
|
|
44
|
+
logger?.info("workflow processes from code", {
|
|
35
45
|
published: results.filter((result) => result.published).map((result) => `${result.key} v${result.version}`),
|
|
36
46
|
unchanged: results.filter((result) => !result.published).map((result) => result.key),
|
|
37
47
|
});
|
|
@@ -6,7 +6,7 @@ import {
|
|
|
6
6
|
type BazisModuleMetadata,
|
|
7
7
|
type ServiceProvider,
|
|
8
8
|
} from "../../../di";
|
|
9
|
-
import { validateProcess, type ProcessIssue } from "../../../../library/workflow";
|
|
9
|
+
import { checkLocalizedText, validateProcess, type ProcessIssue } from "../../../../library/workflow";
|
|
10
10
|
import { IWorkflowDefinitions } from "../services/IWorkflowDefinitions.service";
|
|
11
11
|
import { isCodeProcess, type CodeProcess } from "./defineProcess";
|
|
12
12
|
|
|
@@ -66,8 +66,12 @@ export function codeProcessesOf(services: ServiceProvider): readonly OwnedCodePr
|
|
|
66
66
|
export function definitionProblems(process: CodeProcess, tools?: (name: string) => boolean): readonly string[] {
|
|
67
67
|
const problems: string[] = [];
|
|
68
68
|
if (typeof process.key !== "string" || !KEY.test(process.key)) problems.push("key must match ^[a-z][a-z0-9_.-]{0,63}$");
|
|
69
|
-
|
|
70
|
-
if (
|
|
69
|
+
const title = checkLocalizedText(process.title, 200);
|
|
70
|
+
if (title) problems.push(`title ${title}`);
|
|
71
|
+
const description = process.description == null ? null : checkLocalizedText(process.description, 2000, 0);
|
|
72
|
+
if (description) problems.push(`description ${description}`);
|
|
73
|
+
if (process.folder != null && (typeof process.folder !== "string" || !/^[^/\s](?:[^/]*[^/\s])?(?:\/[^/\s](?:[^/]*[^/\s])?)*$/.test(process.folder))) problems.push("folder must be a path like Sales/Orders");
|
|
74
|
+
if (process.owner != null && (typeof process.owner !== "string" || process.owner === "" || process.owner.length > 200)) problems.push("owner must be up to 200 characters");
|
|
71
75
|
if (process.retentionDays != null && (!Number.isSafeInteger(process.retentionDays) || process.retentionDays < 1 || process.retentionDays > 36_500)) problems.push("retentionDays must be an integer 1–36500");
|
|
72
76
|
problems.push(...documentErrors(validateProcess(process.document, tools === undefined ? {} : { tools })));
|
|
73
77
|
return problems;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ProcessDocument } from "../../../../library/workflow";
|
|
1
|
+
import type { LocalizedText, ProcessDocument } from "../../../../library/workflow";
|
|
2
2
|
|
|
3
3
|
const CODE_PROCESS = Symbol.for("bazis/core/workflow/code-process");
|
|
4
4
|
|
|
@@ -6,8 +6,12 @@ const CODE_PROCESS = Symbol.for("bazis/core/workflow/code-process");
|
|
|
6
6
|
export interface ProcessDefinition {
|
|
7
7
|
/** The key it is started by: `^[a-z][a-z0-9_.-]{0,63}$`. */
|
|
8
8
|
readonly key: string;
|
|
9
|
-
readonly title:
|
|
10
|
-
readonly description?:
|
|
9
|
+
readonly title: LocalizedText;
|
|
10
|
+
readonly description?: LocalizedText | null;
|
|
11
|
+
/** A path in the catalog: `Sales/Orders`. */
|
|
12
|
+
readonly folder?: string | null;
|
|
13
|
+
/** Whose process it is: an opaque id. */
|
|
14
|
+
readonly owner?: string | null;
|
|
11
15
|
/** Days to keep completed and cancelled instances; omitted keeps them. */
|
|
12
16
|
readonly retentionDays?: number | null;
|
|
13
17
|
readonly document: ProcessDocument;
|
|
@@ -27,9 +31,11 @@ export interface CodeProcess extends ProcessDefinition {
|
|
|
27
31
|
* export const OrderApprovalProcess = defineProcess({
|
|
28
32
|
* key: "order-approval",
|
|
29
33
|
* title: "Order approval",
|
|
30
|
-
* document: { schemaVersion: "bazis.workflow/
|
|
34
|
+
* document: { schemaVersion: "bazis.workflow/v2", nodes: { … }, flows: { … } },
|
|
31
35
|
* });
|
|
32
36
|
* ```
|
|
37
|
+
*
|
|
38
|
+
* A document of version 1 goes through `upgradeDocument`.
|
|
33
39
|
*/
|
|
34
40
|
export function defineProcess(definition: ProcessDefinition): CodeProcess {
|
|
35
41
|
return Object.freeze({ ...definition, [CODE_PROCESS]: true as const });
|