@flusys/nestjs-entity-builder 9.1.0 → 9.1.2
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/README.md +59 -26
- package/config/entity-builder.constants.d.ts +2 -0
- package/config/message-keys.d.ts +96 -12
- package/controllers/definition-bundle.controller.d.ts +15 -0
- package/controllers/flow-definition.controller.d.ts +7 -1
- package/controllers/index.d.ts +1 -1
- package/dtos/definition-bundle.dto.d.ts +57 -0
- package/dtos/flow.dto.d.ts +12 -20
- package/dtos/index.d.ts +1 -0
- package/entities/flow-definition.entity.d.ts +3 -9
- package/entities/flow-execution.entity.d.ts +3 -0
- package/entities/flow-version.entity.d.ts +10 -0
- package/entities/index.d.ts +3 -3
- package/fesm/21.js +101 -16
- package/fesm/458.js +695 -0
- package/fesm/{996.js → 677.js} +1582 -1047
- package/fesm/794.js +135 -240
- package/fesm/{489.js → 857.js} +216 -289
- package/fesm/{719.js → 870.js} +1955 -220
- package/fesm/{362.js → 897.js} +566 -2
- package/fesm/{606.js → 946.js} +564 -290
- package/fesm/config/index.js +15 -2
- package/fesm/controllers/index.js +9 -9
- package/fesm/docs/index.js +7 -4
- package/fesm/dtos/index.js +451 -256
- package/fesm/entities/index.js +58 -3
- package/fesm/guards/index.js +101 -22
- package/fesm/index.js +444 -948
- package/fesm/interfaces/index.js +92 -4
- package/fesm/modules/index.js +10 -628
- package/fesm/rule-engine/index.js +12 -3
- package/fesm/services/index.js +97 -27
- package/flow-engine/flow-api-key.d.ts +3 -7
- package/flow-engine/flow-graph.validator.d.ts +4 -2
- package/flow-engine/flow-permission-logic.d.ts +2 -0
- package/flow-engine/flow-run.types.d.ts +17 -8
- package/flow-engine/flow.types.d.ts +107 -15
- package/guards/designer-writable.interceptor.d.ts +12 -0
- package/guards/index.d.ts +1 -0
- package/interfaces/definition-bundle.interface.d.ts +111 -0
- package/interfaces/entity-builder-module.interface.d.ts +6 -0
- package/interfaces/flow-definition.interface.d.ts +13 -9
- package/interfaces/generic-record.interface.d.ts +11 -1
- package/interfaces/index.d.ts +1 -0
- package/package.json +5 -6
- package/rule-engine/rule-engine.service.d.ts +3 -1
- package/rule-engine/rule-permission.d.ts +3 -7
- package/rule-engine/rule-regex.d.ts +0 -2
- package/rule-engine/rule-values.d.ts +4 -1
- package/rule-engine/rule.interface.d.ts +2 -3
- package/services/definition-bundle.diff.d.ts +62 -0
- package/services/definition-bundle.file.d.ts +7 -0
- package/services/definition-bundle.service.d.ts +32 -0
- package/services/entity-builder-config.service.d.ts +2 -0
- package/services/entity-flow-scaffold.d.ts +1 -1
- package/services/flow-definition.service.d.ts +25 -12
- package/services/flow-execution.service.d.ts +7 -0
- package/services/flow-executor.service.d.ts +8 -8
- package/services/generic-entity.service.d.ts +5 -1
- package/services/index.d.ts +3 -1
- package/services/schema-evolution.service.d.ts +3 -1
- package/services/schema-sync.service.d.ts +5 -1
- package/controllers/flow-api-key.controller.d.ts +0 -14
- package/entities/flow-api-key.entity.d.ts +0 -10
- package/services/flow-api-key.service.d.ts +0 -31
package/README.md
CHANGED
|
@@ -9,13 +9,13 @@ Works on PostgreSQL and MySQL (via `SchemaDialectAdapterService`). Import `Entit
|
|
|
9
9
|
- **Multi-tenant** (`databaseMode: 'multi-tenant'`): definitions, runtime tables, flows and flow executions all live in the tenant's database - the tenant is the only isolation the package enforces. Services resolved through `ModuleRef` (flow entity access) find the tenant through the request context: `MultiTenantDataSourceService` falls back to it when there is no request.
|
|
10
10
|
- **Company feature** (`enableCompanyFeature`): changes nothing in the tables. Entity and field definitions, flows, their API keys and flow executions are tenant-wide - every company has the same schema and runs the same flows, and permission codes `entity_builder.entity.<code>.*` are global like other permission actions. A runtime table holds only the system columns (`id`, timestamps, soft delete and audit columns when enabled) plus the fields the designer defines; there is no automatic `company_id`, no company filter and no per-company unique.
|
|
11
11
|
- **Scoping records to a company or branch is the entity designer's choice**: add fields such as `company_id` / `branch_id` (plain field codes, not reserved), then map them in the flow - `user.companyId` / `user.branchId` on insert, and the same value in the filter of every get / find / update / delete / lookup step that must stay inside the caller's company. Public and API-key runs have no signed-in caller, so such a flow takes the company from its input instead. A unique field is unique across the whole table.
|
|
12
|
-
- IAM still applies per company: permission checks (`HAS_PERMISSION`, Check permission steps,
|
|
12
|
+
- IAM still applies per company: permission checks (`HAS_PERMISSION`, Check permission steps, a `permission` URL) use the caller's own company and branch.
|
|
13
13
|
|
|
14
14
|
## Endpoints (all POST)
|
|
15
15
|
|
|
16
16
|
| Path | Purpose | Permission |
|
|
17
17
|
| --- | --- | --- |
|
|
18
|
-
| `entity-builder/entity-definitions/create-entity` | Create entity + table (+ initial fields) in one DDL transaction. `flowEndpoints` (any of the generic API controller's endpoints: `insert`, `insertMany`, `getById`, `getByIds`, `getAll`, `getByFilter`, `update`, `updateMany`, `bulkUpsert`, `delete`) also saves one ready-made flow for the entity after the commit - slug `<code-with-dashes>` (e.g. `customer-ticket`), named after the entity, `
|
|
18
|
+
| `entity-builder/entity-definitions/create-entity` | Create entity + table (+ initial fields) in one DDL transaction. `flowEndpoints` (any of the generic API controller's endpoints: `insert`, `insertMany`, `getById`, `getByIds`, `getAll`, `getByFilter`, `update`, `updateMany`, `bulkUpsert`, `delete`) also saves one ready-made flow for the entity after the commit - slug `<code-with-dashes>` (e.g. `customer-ticket`), named after the entity, every Request step with `permission` access asking for the entity's own permission (`entity_builder.entity.<code>.create` for insert / insertMany, `.read` for the reads, `.update` for update / updateMany, `.create` AND `.update` for bulkUpsert, `.delete` for delete - the actions created with the entity), since a flow's access is its only gate, each write endpoint transactional and each read endpoint without a transaction - with one Request step per endpoint on its own path, `POST api-flows/<code-with-dashes>/<endpoint-in-kebab-case>` (e.g. `customer-ticket/get-by-ids`, `ENTITY_FLOW_PATHS`), each with its own body; there is no Request step on the flow's own URL. Each endpoint's steps are ids `<endpoint>_<step>` (e.g. `getById_record`) laid out one below the other. Single endpoints are Request -> entity step -> respond with its result; `getAll` / `getByFilter` take an optional equality filter per scalar field (`getByFilter` answers the first match or 404); `getByIds` takes `ids` (`in` filter); `insertMany` / `updateMany` / `bulkUpsert` have a `list` body type - the request body is the list of records itself (`[{ ... }, { ... }]`), each item checked against the entity fields (insert: the insert inputs; update many: `id` required, the rest optional; bulk upsert: all optional) - are one write step on the list itself (`<endpoint>_records`, `target: many`, `items: input`, each item's fields read as `loop.item.<field>`, up to `FLOW_LIMITS.MAX_WRITE_ITEMS`), all or nothing in one transaction, answering the saved records in order (`context.<endpoint>_records.items`; `updateMany` updates each item by its own `id` and fails on one that does not exist; `bulkUpsert` is an update with `onNotFound: insert` - it updates an item with an `id` and creates one without, or whose record is gone). Every Request body declares each field by its type: a choice with its options, a multi-select as a list of those choices (`itemType: choice`, or `string` without options), a relation or file as an id (`uuid`), JSON as an object; write endpoints also copy the field's own `validationRules` a body can check (`min` / `max` on numbers, `minLength` / `maxLength` on text), so a bad value is refused per field before any step runs; read filters check only the type. `flowTexts` (`names` of each endpoint's Request step, `inputLabels` for `id` / `ids` / `search`, `noMatchMessage`) carries the text written into the flow in the creator's language - English for anything left out. The flow is saved after the commit: when it fails the entity stays and the failure comes back in the response's `warnings` (`entity_builder.schema.warning.endpoint.flow.failed`, with the cause as a nested message ref), next to a failed permission set-up (`...follow.up.failed`). It is saved unpublished (`version` 0), so its URLs answer 404 until someone publishes it. It is an ordinary flow the user can change or delete later. Needs `flow_definition.create` too; a slug already in use is a plan blocker | `entity_builder.entity_definition.create` |
|
|
19
19
|
| `entity-builder/entity-definitions/plan-create-entity` | Dry run of the above: the real `CREATE TABLE` SQL, nothing is created | `...entity_definition.create` |
|
|
20
20
|
| `entity-builder/entity-definitions/plan-change` | Dry run of any change below: exact SQL + reverse SQL, data checks, blockers, warnings, affected flows. Plans count records (never show their values), so planning needs the permission that applies the change | `...entity_definition.update` (`...delete` for `drop_entity` / `purge_field`) |
|
|
21
21
|
| `entity-builder/entity-definitions/apply-change` | Apply `add_field`, `update_field`, `deprecate_field`, `restore_field`, `update_entity`, `repair`. Answers the plan of what ran (`downSql` = the undo statements of what ran) plus `result` (the change's summary, e.g. repair's `fixed`) | `...entity_definition.update` |
|
|
@@ -24,13 +24,19 @@ Works on PostgreSQL and MySQL (via `SchemaDialectAdapterService`). Import `Entit
|
|
|
24
24
|
| `entity-builder/entity-definitions/schema-history` | Every schema change with its SQL, failed attempts included | `...entity_definition.read` |
|
|
25
25
|
| `entity-builder/entity-definitions/{get-all,get/:id,get-by-ids,get-by-filter}` | Metadata read (writes only through the endpoints above) | `...entity_definition.read` |
|
|
26
26
|
| `entity-builder/field-definitions/{get-all,get/:id,get-by-ids,get-by-filter}` | Field metadata read | `...field_definition.read` |
|
|
27
|
-
| `api-flows/:slug` | **Call a flow** (a virtual API) from its Request step on the flow's own URL. Access is
|
|
28
|
-
| `api-flows/:slug/:endpoint` | **Call one of the flow's other URLs**: runs from the Request step whose `path` is `endpoint`, checked against that step's own body
|
|
29
|
-
| `entity-builder/flows/{insert,update,delete,get-all,get/:id,...}` | Flow CRUD - every save is validated as a whole | `...flow_definition.*` |
|
|
27
|
+
| `api-flows/:slug` | **Call a flow** (a virtual API) from its Request step on the flow's own URL. Access is that Request step's own (`config.authMode`; a step for other flows only answers 404); the answer is what its `respond` node says. Only the published version runs; a flow never published answers 404 |
|
|
28
|
+
| `api-flows/:slug/:endpoint` | **Call one of the flow's other URLs**: runs from the Request step whose `path` is `endpoint`, checked against that step's own body, access, rate limit, transaction and time limit | per Request step: public / API key / any user / a permission |
|
|
29
|
+
| `entity-builder/flows/{insert,update,delete,get-all,get/:id,...}` | Flow CRUD - every save is validated as a whole. `insert` creates the flow unpublished (`version` 0); `update` of a published flow saves into its `draft` (see **Versions**) | `...flow_definition.*` |
|
|
30
|
+
| `entity-builder/flows/publish` | `{ id, note? }`: makes the draft live as the next version | `...flow_definition.update` |
|
|
31
|
+
| `entity-builder/flows/{versions,version}` | `{ flowId }`: published versions, newest first; `{ flowId, version }`: one in full | `...flow_definition.read` |
|
|
32
|
+
| `entity-builder/flows/{restore-version,discard-draft}` | Put an old version back into the draft / drop the draft | `...flow_definition.update` |
|
|
30
33
|
| `entity-builder/flows/validate` | Check a draft without saving (errors block a save, warnings do not) | `...flow_definition.read` |
|
|
31
34
|
| `entity-builder/flows/test-run` | Run a saved flow or an unsaved draft, with a per-node trace | `...flow_definition.test` |
|
|
32
|
-
| `entity-builder/flow-
|
|
33
|
-
| `entity-builder/
|
|
35
|
+
| `entity-builder/flow-executions/{list,get}` | Run history; each run keeps the Request step it came in through (`endpointNodeId`, and `endpointPath` as it was then), and `list` filters by `status`, `trigger` (`webhook` / `test`) and `endpointNodeId` | `...flow_definition.read` |
|
|
36
|
+
| `entity-builder/bundles/settings` | `{ readOnly, variables }`: whether the designer is locked here, and the names of the variables this environment defines | `...entity_definition.read` or `...flow_definition.read` |
|
|
37
|
+
| `entity-builder/bundles/export` | `{ entityCodes?, flowSlugs? }`: entities and published flows as a bundle, with its `checksum` (see **Promoting between environments**) | `...entity_definition.read` + `...flow_definition.read` |
|
|
38
|
+
| `entity-builder/bundles/plan-import` | Multipart: file `bundle` + `deprecateMissingFields?`: every step the import would run, in order, with each previewable step's real plan; nothing changes | `...entity_definition.create/update` + `...flow_definition.create/update` |
|
|
39
|
+
| `entity-builder/bundles/apply-import` | Multipart: file `bundle` + `deprecateMissingFields?`, `confirm?`, `expectedChecksum?`, `expectedPlanChecksum?` (required with `confirm`): runs the plan; works while the designer is read-only | same as `plan-import` |
|
|
34
40
|
|
|
35
41
|
`entity_builder.entity.*` is a wildcard grant covering every dynamic entity (seeded by `seed:admin`); it covers only dynamic entities, not the static schema-admin permissions (`entity_builder.entity_definition.*`, `entity_builder.field_definition.*`, `entity_builder.flow_definition.*`).
|
|
36
42
|
|
|
@@ -38,7 +44,7 @@ Works on PostgreSQL and MySQL (via `SchemaDialectAdapterService`). Import `Entit
|
|
|
38
44
|
|
|
39
45
|
- **Every structural change is a plan first.** `plan-change` runs the same code as `apply-change` in a dry run (TypeORM SQL-memory mode), so the SQL you preview is the SQL that runs. Data is checked before anything changes: converting `TEXT` to `INTEGER` is refused while any value is not a whole number (with examples), a unique constraint is refused while duplicates exist, and a required field on a table with records needs a default/backfill value. Unsupported conversions are refused with the reason. MySQL has no `USING` clause, so on MySQL single select <-> multi select and text -> boolean are refused too (add a new field, copy the data, deprecate the old one).
|
|
40
46
|
- Supported: add field (optionally required/unique/indexed, with backfill), change type, required/optional, unique, index, default, validation rules, select options, label; **rename a field** (column renamed, flows that use it are rewritten, including `{{ ... }}` template placeholders); deprecate / restore; **permanently delete** a deprecated field; entity label/description/status/soft-delete/audit; **drop entity**; **repair** drift (missing columns, indexes, unique constraints, NOT NULL).
|
|
41
|
-
- Every change value is type-checked (`FieldChangesDto` / `EntityChangesDto`): `nullable: "false"`, `null` for a flag, an unknown field type or a too long label is a 400 `entity_builder.schema.invalid.change.value`. `update_field` can set `relationTargetEntityId` (RELATION fields only; the target must exist and is locked for the change; converting to RELATION needs one, `...blocker.relation.target.required`).
|
|
47
|
+
- Every change value is type-checked (`FieldChangesDto` / `EntityChangesDto`): `nullable: "false"`, `null` for a flag, an unknown field type or a too long label is a 400 `entity_builder.schema.invalid.change.value`. `update_field` can set `relationTargetEntityId` (RELATION fields only; the target must exist and is locked for the change; converting to RELATION needs one, `...blocker.relation.target.required`). `add_field` and `create-entity` hold new fields to the same rules: a RELATION field needs an existing target (`...blocker.relation.target.required`), a target on any other field type is not stored, and a default value the field would refuse is a blocker (`...blocker.default.invalid`).
|
|
42
48
|
- **Optimistic concurrency**: send `expectedUpdatedAt` (the `updatedAt` you loaded - of the field for field actions, of the entity otherwise; select `updatedAt` when reading) with `plan-change` / `apply-change`; a newer one answers 409 `entity_builder.schema.stale.definition`. Every run re-reads the definitions inside its own transaction (under the entity lock when applying) and only trusts those; a field renamed or deprecated meanwhile blocks with `...blocker.definition.changed`.
|
|
43
49
|
- `LONG_TEXT`, `JSON` and `MULTI_SELECT` fields cannot be unique or indexed (`entity_builder.schema.not.indexable`). Index and unique names are `idx_` / `uq_` + table + column cut to the driver limit (63 PostgreSQL, 64 MySQL) + a hash of table and column, so they never collide.
|
|
44
50
|
- Dropping an entity is blocked while relation fields of other entities point at it (`...blocker.drop.relation.in.use`, listing `"entity.field"`).
|
|
@@ -47,9 +53,15 @@ Works on PostgreSQL and MySQL (via `SchemaDialectAdapterService`). Import `Entit
|
|
|
47
53
|
- Changes made outside the entity builder (a column dropped by hand) show up in `inspect` and are fixed by `repair`; extra columns are reported and never dropped automatically.
|
|
48
54
|
- The entity/field metadata `update` endpoints are gone on purpose: every edit goes through `apply-change` so the table, the metadata and the schema cache cannot disagree.
|
|
49
55
|
- Identifiers must match `^[a-z][a-z0-9_]{2,59}$`, not be an SQL reserved word, and the table (`eb_<code>`) must not already exist. Every DDL change takes a per-entity lock (`pg_advisory_xact_lock` / `GET_LOCK`, names over 64 characters hashed) - plus the lock of any relation target it points at - and is written to `eb_schema_change_log`.
|
|
50
|
-
- Every filter/sort key of a flow's **Find records** step and of a `lookup` is checked against the entity's real fields; unknown keys are rejected (never interpolated into SQL). A filter value is a plain value (
|
|
56
|
+
- Every filter/sort key of a flow's **Find records** step and of a `lookup` is checked against the entity's real fields; unknown keys are rejected (never interpolated into SQL). A filter value is a plain value (`=`; `null` means `IS NULL`) or `{ op, value? }`. Each operator has a value kind (`FILTER_OP_VALUE_KIND`):
|
|
57
|
+
- **single** - `eq`, `ne` (`null` = is / is not null), `gt`, `gte`, `lt`, `lte`, and the case-insensitive text operators `ieq`, `contains`, `not_contains`, `starts_with`, `ends_with` (`%`, `_`, `\` in the text match literally; a blank text is refused, since it would match every record).
|
|
58
|
+
- **list** (at most 100 scalars; a single scalar counts as a one-item list) - `in` (empty: matches nothing), `not_in` (empty: matches everything), and the multi-select operators `has_any` (empty: nothing), `has_all`, `has_none` (empty: everything).
|
|
59
|
+
- **range** - `between` / `not_between` on `[from, to]`, both ends included (in a flow: `{ from, to }`, each its own expression).
|
|
60
|
+
- **none** - `is_null` / `not_null` (SQL null, any field), `is_empty` / `not_empty` (null or blank text, or null or an empty multi-select list).
|
|
61
|
+
|
|
62
|
+
Operators must fit the field (`entity_builder.generic_record.filter.operator.not.for.field` otherwise): text operators need a text, long text, email or single-select field; `has_*` a multi-select; blank checks a text or multi-select; on multi-select and JSON fields only the null and blank checks are allowed. As in SQL, a negation (`ne`, `not_in`, `not_contains`, `not_between`, `has_none`) never matches a record whose field has no value - combine with `is_null` in another filter when those are wanted. Text matching is `ILIKE` on PostgreSQL and `LIKE` on MySQL (case-insensitive under its default collation); multi-select matching is `@>` / `JSON_CONTAINS`. Every value is a bound parameter. A Find step's `sort` is `{ field, direction? }` with a column code and `ASC` / `DESC` (left out: `ASC`); anything else is a save error (`flow.validation.node.sort.invalid`).
|
|
51
63
|
- **Search** (a Find records step's search text) matches every searchable field by its text form, case-insensitively on both drivers (`CAST ... AS TEXT ILIKE` / `CAST ... AS CHAR LIKE`), so non-text fields can be searchable; `%` and `_` in the search text are literal.
|
|
52
|
-
- Each entity gets the IAM actions `entity_builder.entity.<code>.<create|read|update|delete>` (only when IAM is installed)
|
|
64
|
+
- Each entity gets the IAM actions `entity_builder.entity.<code>.<create|read|update|delete>` (only when IAM is installed). Flow steps do not check them (a flow's URL access is its gate): the entity's ready-made flow asks for them as its URLs' `permission` access, and any Request step or Check permission step can name them in its rule. DECIMAL fields are returned as numbers. `RELATION`/`FILE` are plain uuid columns (`char(36)` on MySQL, which has no uuid type) with no foreign-key constraint. Record events are `entity-builder.<code>.created` / `updated` / `deleted`, or `purged` for a delete on an entity with soft delete turned off - the same name whether the write ran inside a transactional flow or not.
|
|
53
65
|
- **Tenants never share schemas**: every tenant DataSource gets its own copy of the entities array (`getEntityBuilderEntities()` returns a copy), and runtime schemas are registered per DataSource.
|
|
54
66
|
- `forRootAsync` takes `useFactory`, `useClass`, `useExisting` or a static `config`; with none of them it throws at configuration time. The root entry also exports the flow types, errors and `validateFlow`, and the schema helpers (`schema-columns`, `identifier-rules`, `field-type-conversion`).
|
|
55
67
|
|
|
@@ -65,37 +77,58 @@ Conditions and expressions are declarative JSON (`RuleGroup` / `RuleExpression`)
|
|
|
65
77
|
- **Permission functions**: `HAS_PERMISSION(code, branch?)`, `HAS_ANY_PERMISSION(codes, branch?)`, `HAS_ALL_PERMISSIONS(codes, branch?)` give a boolean for the signed-in user (`codes`: a list or comma/space separated text, at most 50; `*` / `prefix.*` grants match). Use them as a condition's `left` with `is_true` ("user has") / `is_false` ("user doesn't have"), or as a value in Set / Switch. `branch` omitted = the user's current branch, null or empty = company-wide grants only, otherwise that branch id. Company and tenant are never arguments: a flow checks in the caller's own company, and the tenant database is the request's. Codes come from `PERMISSION_RESOLVER` (nestjs-iam: cached, rebuilt from roles and direct grants for a branch not loaded yet); without IAM the shared permission cache is used (only scopes the user has loaded), and with neither the check fails with `PermissionSystemUnavailableException`. A flow fetches each branch's codes once per run. No signed-in user = false (the validator warns about permission checks in `public` / `api_key` flows).
|
|
66
78
|
- **Request functions** (flows only; elsewhere `null` / false): `HEADER(name)` (any letter case; `authorization`, `cookie`, `x-api-key`, `proxy-authorization` are never visible), `QUERY_PARAM(name)`, `IP_IN_RANGE(ip, ranges)` (IPv4/IPv6 addresses and CIDR blocks, list or comma separated text, at most 100; an IPv4-mapped IPv6 caller compares as IPv4; a bad range is refused on save and fails with `rule_engine.invalid.ip.range` at run time).
|
|
67
79
|
- **Text functions**: `LOWER`, `UPPER`, `TRIM`, `LENGTH` (text or list), `SPLIT_PART(text, separator, position?)` (1-based, negative counts from the end, trimmed; e.g. the first `x-forwarded-for` address), `REGEX_EXTRACT(text, pattern, group?)` (RE2; default group 1 when the pattern has one, else the whole match), `REPLACE(text, find, with)` (plain text, every occurrence), `TO_NUMBER(value)` (`null` when not numeric).
|
|
68
|
-
- **Lookup** (`{ "type": "lookup", "entity", "filter", "mode": "exists" | "count" | "first", "field"?, "sort"? }`): reads another entity. `filter` maps a column to an expression (`=`) or `{ op, value? }` (the filter operators above). `exists` gives a boolean, `count` a number, `first` the first row (after `sort`) or its `field`, `null` when none. The read goes through the flow's per-entity
|
|
80
|
+
- **Lookup** (`{ "type": "lookup", "entity", "filter", "mode": "exists" | "count" | "first", "field"?, "sort"? }`): reads another entity. `filter` maps a column to an expression (`=`) or `{ op, value? }` (the filter operators above). `exists` gives a boolean, `count` a number, `first` the first row (after `sort`) or its `field`, `null` when none. The read goes through the flow's transaction, with no per-entity permission check (the URL's access is the gate); a rule evaluated outside a flow has no reader and fails with `rule_engine.lookup.resolver.unavailable`.
|
|
69
81
|
|
|
70
82
|
## Flows (virtual APIs)
|
|
71
83
|
|
|
72
|
-
A flow is an endpoint (`POST /api-flows/<slug>`) whose behaviour is a graph of steps, so a backend can be built without writing backend code. Steps: **Request** (trigger), **Validate** (checks that must pass), **Check permission**, **Code** (sandboxed JavaScript), **Set variables**, **If**, **Switch**, **For each**, **Create / Update / Delete / Get / Find record**, **Save progress**, **Call flow**, **HTTP request**, **Publish event**, **Respond**. Each step's result is `context.<stepId>`; the request body is `input.*`; also `vars.*`, `loop.item` / `loop.index` (inside a loop within a loop the outer loop is `loop.parent.item` / `loop.parent.index`, and so on up), `request.*`, `user.*`. `request` holds `ip`, `method`, `path`, `query` (text values, first of a repeated one, at most 50), `headers` (lower-case names, credential headers removed; read dashed names with `HEADER()`), and ready-made `userAgent`, `origin`, `referer`, `contentType`, `language` (first `accept-language` tag) and `receivedAt`. Text can use `{{ scope.path }}` placeholders (the `template` expression). Steps connect by output ports (`out`, `true`/`false`, `each`/`done`, switch cases, `error`); a step marked "carry on if it fails" stores `context.<id>.error` and follows its `error` port. Inside a transaction (transactional flows and every dry run) such a step runs in a savepoint, so its failure does not abort the flow's transaction.
|
|
84
|
+
A flow is an endpoint (`POST /api-flows/<slug>`) whose behaviour is a graph of steps, so a backend can be built without writing backend code. Steps: **Request** (trigger), **Validate** (checks that must pass), **Check permission**, **Company & Branch check**, **Code** (sandboxed JavaScript), **Set variables**, **If**, **Switch**, **For each**, **Create / Update / Delete / Get / Find record**, **Save progress**, **Call flow**, **HTTP request**, **Publish event**, **Send notification** / **Send email** (only when the app runs those modules), **Respond**. Each step's result is `context.<stepId>`; the request body is `input.*`; also `vars.*`, `loop.item` / `loop.index` (inside a loop within a loop the outer loop is `loop.parent.item` / `loop.parent.index`, and so on up), `request.*`, `user.*`. `request` holds `ip`, `method`, `path`, `query` (text values, first of a repeated one, at most 50), `headers` (lower-case names, credential headers removed; read dashed names with `HEADER()`), and ready-made `userAgent`, `origin`, `referer`, `contentType`, `language` (first `accept-language` tag) and `receivedAt`. Text can use `{{ scope.path }}` placeholders (the `template` expression). Steps connect by output ports (`out`, `true`/`false`, `each`/`done`, switch cases, `error`); a step marked "carry on if it fails" stores `context.<id>.error` and follows its `error` port. Inside a transaction (transactional flows and every dry run) such a step runs in a savepoint, so its failure does not abort the flow's transaction.
|
|
73
85
|
|
|
74
86
|
- **Loops hand data on** by `collect` (evaluated after each pass, reading `loop.*` and the body steps; the values become `context.<loop>.results` next to `count` - collect a Set/Code step to hand on several values), by variables (`vars` is flow-wide and survives passes; initialize before the loop), and by body step results (after the loop they hold the last pass).
|
|
75
87
|
- **Reference timing** (validation warnings): a step's result exists for the steps on a path after it; once a loop is done (`done` branch) its whole body's results exist too (their last pass). The validator warns when a step reads `context.<id>` of a step that has not run yet when it runs (later on the path, on another branch, or later in the same loop body), `loop.*` outside a loop's `each` branch (a foreach's own `collect` counts as inside), `loop.parent` beyond the loops around the step, or `context.<loop>.results` inside that loop (only `count` exists until it is done). Unreachable steps are not checked (they already warn).
|
|
76
88
|
|
|
77
89
|
- **Validate**: `{ checks: [{ id, rule, message, field? }], mode: 'all' | 'first', statusCode? }`. `all` (default) evaluates every check and rejects with every failure; `first` stops at the first. The answer is `statusCode` (default 400, or 403 when every failed check is a permission check) with `errors: [{ field, message }]` (`field` defaults to the check id); one failure uses its message as the top-level message, several use `flow.checks.failed`. Example (attendance check-out): check 1 `left` = lookup `attendance` with `employee_id = input.employeeId`, `out_time is_null`, `in_time gte START_OF_DAY(NOW())`, mode `exists`, `is_true`; check 2 `left` = `DATE_DIFF(NOW(), <same lookup, mode first, field in_time>, 'hour')` `greater_or_equal` 5.
|
|
78
|
-
- **Check permission** (`permission_check`): `{
|
|
90
|
+
- **Check permission** (`permission_check`): `{ permissions, subject?, userId?, companyId?, branchId?, onDenied: 'reject' | 'branch', statusCode?, message? }` - `permissions` is an AND / OR rule of permission codes (`ILogicNode` of nestjs-shared: `{ type: 'action', actionId: <code> }` or `{ type: 'group', operator: 'AND' | 'OR', children }`, nested up to 5 group levels, at most 50 codes; evaluated by `evaluatePermissionLogic()`, `*` / `prefix.*` grants match) `reject` (default) answers `statusCode` (403) with `message` (or `flow.permission.denied`) and stops, otherwise follows `out`; `branch` follows `allowed` / `denied` and never rejects. Checks the effective permissions (the same codes `my-permissions` returns) of the signed-in caller (`subject: 'caller'`, the default) or of the user `userId` names (`subject: 'user'`, signed in or not - e.g. the approver of a record, also on a public or API-key URL). The scope is `companyId` (left out: the caller's current company) plus `branchId` (left out: the caller's current branch when checking the caller in that company, otherwise company-wide grants only; resolving to nothing: company-wide only) - with the company feature a branch adds its own grants on top of the company-wide ones; without it IAM ignores both. The caller in their current company and branch uses the run's cached codes; any other scope is first checked against what the user is granted (`COMPANY_ACCESS_RESOLVER`: the company, and the branch inside it) - a company or branch they are not granted holds no permissions - then asked of `PERMISSION_RESOLVER` (`RuleEngineService.permissionCodes`). Output `{ allowed, missing }` - `missing` lists every code the rule names that the user does not hold, whether or not the rule passed. No user (a caller who is not signed in, or a `userId` that resolves to nothing) is denied; the validator warns when a check of the caller sits behind a `public` / `api_key` URL (not one of another user), and requires `userId` for `subject: 'user'` (`node.access.user.required`).
|
|
91
|
+
- **Company & Branch check** (`company_branch_check`, company feature): `{ subject?, userId?, companyIds?, branchIds?, branchMatch?, branchCompany?, onDenied?, statusCode?, message? }` - what a user may work in: the signed-in caller (`subject: 'caller'`, the default) or the user `userId` names (`subject: 'user'`, signed in or not - e.g. the owner of a record, also on a public or API-key URL). It needs no permission codes: it gets exactly what company select offers that user - the active companies granted to them and, inside those, the active branches granted to them. Output: `{ userId, currentCompanyId, currentBranchId, companies, branches }` - `companies` as `{ id, name }` (by name), `branches` as `{ id, companyId, parentId, name }` (per company, by serial); `currentCompanyId` / `currentBranchId` are the caller's current ones (null for another user). `companyIds` / `branchIds` (expressions: one id or a list, typically the record being changed; the designer starts the caller's on `user.companyId` / `user.branchId`) turn it into a guard: each id must be within reach (`allowed`, `missing: { companyIds, branchIds }`); a target that resolves to nothing is a denial. A company is within reach when it is granted; a branch per `branchMatch`: `direct` (default) - one of the granted branches; `within` - a granted branch or anywhere under one (`parentId` tree, `getDescendantIds(granted, { includeSelf: true })`, walked only when an asked-for branch is not granted itself), a child needing no grant of its own and the current branch playing no part. A user with no granted branch reaches none; without the branch tree `within` passes only granted branches. `branchCompany` picks whose branches count, for both matches (a walk starts only from the granted branches it keeps): `any` (default, stored as no key) - every company of the user; `current` - the caller's current company (refused on save for another user, who has none; no current company counts none). Then `onDenied` rejects (default, `statusCode` 403) or follows `allowed` / `denied` (which needs a target). No user means nothing is reachable. The data comes from `COMPANY_ACCESS_RESOLVER` and the tree from `BRANCH_HIERARCHY_RESOLVER` (nestjs-shared), both provided by nestjs-auth when the company feature is on (the grants `UserPermissionService` lists, kept to active companies and branches, looked up once per user per run); without them nothing is reachable. Save warns about a check of the caller on a URL reached without signing in (`permission.without.user`), not about one of another user. The designer offers the step only with the company feature.
|
|
79
92
|
- **Code**: `{ code, timeoutMs? }` runs `code` as the body of a synchronous function in a QuickJS WebAssembly sandbox (`quickjs-emscripten`) on a worker thread (a small pool, at most 4), so a busy code step never blocks the server's event loop; the host also kills a worker that overruns its deadline. It reads a deep-frozen JSON copy of `{ input, vars, context, loop, user: { id, email, name, companyId, branchId, permissions? } }` as the global `ctx` (no request headers; `permissions`, the caller's codes in the current branch, is fetched only when the code mentions permissions). `hasPermission(code)`, `hasAnyPermission(...codes)` and `hasAllPermissions(...codes)` are plain JavaScript inside the sandbox over `ctx.user.permissions` with the server's wildcards and `return`s a JSON value, which becomes `context.<id>`. Nothing of the host is reachable (no `require`, `process`, network, filesystem, timers or database); `console.log` is captured into the trace (`trace[].logs`, 50 lines of 500 characters, sensitive-looking keys masked). Every run gets a fresh runtime with a 32 MB memory limit, a 512 KB stack and an interrupt deadline of `timeoutMs` (default 1000, 10-5000) clamped to the time left in the flow; the output may be at most 1 MB. A throw, time-out or memory overflow is a node error (`flow.error.code.*`), so `onError: continue` and the `error` port work. Code nodes also run in dry runs. Saving a flow that contains a code node needs `entity_builder.flow_definition.code`.
|
|
80
93
|
- **Create / Update / Delete record** write several records in one step through `target`: `one` (default, left out) writes one record; `many` writes one per item of `items` (up to `FLOW_LIMITS.MAX_WRITE_ITEMS` = 1000), and the step's per-record values (`fields`, `id`) read the item as `loop.item` / `loop.index` with a loop around the step as `loop.parent`; `filter` (update / delete) writes every record matching `filter` (same shape as Find), read as `loop.item` (e.g. `stock = loop.item.stock - 1`). A filter that resolves to nothing is refused (`flow.error.write.filter.empty`) instead of changing every record, and more matches than `limit` (1-1000, default 100) fail the step (`flow.error.too.many.matches`) instead of changing some. In `many` mode `id` defaults to the item's own id (the item itself when it is text, else `item.id`). `onNotFound`: `error` (default), `skip`, or for update `insert` (upsert: a missing record, or in `many` mode an item with an empty id, is created with the same field values - needs the entity's `create` permission too). Create takes `children` (up to 10): `{ as, entityCode, parentField, items, fields }` saves child records under every saved record, `parentField` filled with the parent's id; `items` is read in the parent's scope (`loop.item.lines` in `many` mode, `input.lines` otherwise) and child `fields` read the child item as `loop.item` and the parent's scope as `loop.parent`. Results: create one = the record plus one array per `as`; create many = `{ items, count }`; update one = the record (`null` when skipped); update many / filter = `{ items, count, updated, created, skipped }`; delete one = `{ id, deleted }`; delete many / filter = `{ ids, count, skipped }` (an id listed twice is deleted once). A create never takes an `id` field (`generic_record.system.column.readonly`), so it cannot overwrite an existing record; an upsert's created record ignores a mapped `id`. A step that writes several records (`many`, `filter`, or any `children`) is **all-or-nothing on its own**: inside a transactional run it uses the run's transaction, otherwise it opens a transaction for just that step and publishes its record events after that commit. Every record written counts against `FLOW_LIMITS.MAX_WRITES_PER_RUN` (5000, shared with called flows).
|
|
81
|
-
- **
|
|
82
|
-
- **
|
|
83
|
-
- **
|
|
84
|
-
- **
|
|
85
|
-
- **
|
|
86
|
-
- **
|
|
87
|
-
- **
|
|
94
|
+
- **Send notification** (`send_notification`) and **Send email** (`send_email`) send through the notification and email modules, reached only through the `NOTIFICATION_ADAPTER` / `EMAIL_ADAPTER` tokens of `nestjs-shared` (`@Optional()`, so the package never imports either module). **Who gets it** (`target`): `one` (default, stored as no key) sends one message; `many` sends one per item of `items` (at most `MAX_MESSAGES_PER_RUN`), every other value read with the item as `loop.item` (the validator scopes `loop.*` for the step itself), an item with nobody to send to is skipped, and **every item is checked before the first message goes out** (the run's message budget is taken for all of them first, so a limit never leaves a batch half sent); a notification's `company` target notifies every active member (active grant, active and not deleted user) of `companyIds` (one or a list; with `branchIds`, only members also in one of those branches) through `COMPANY_ACCESS_RESOLVER.getCompanyMembers` (implemented by `nestjs-auth` on its existing company/branch reverse lookups), one notification per company shown under that company, at most `MAX_COMPANY_NOTIFICATION_RECIPIENTS` (5000) in all. The `company` target needs the company feature (refused on save without it: `validation.node.notification.company.unavailable`; at run time `error.notification.company.unavailable`), and it may notify any company the author names - as with entities, the URL's access is the gate. Outputs: many / company add `count` (messages) and, for a list, `skipped`; an email list hands on `messageIds` (one per email sent, in order). A step whose module the app does not run is refused on save (`validation.node.notification.unavailable` / `.email.unavailable`, checked by `FlowDefinitionService.validate`) and fails at run time (`error.notification.unavailable` / `.email.unavailable`). Notification: `{ userIds, title, message?, type?: info|success|warning|error, data?, companyId?, realtime? }` - `userIds` is one user id or a list (each once; anything but a UUID rejects with 400), `title` is cut to 255 characters (empty rejects), `companyId` defaults to the caller's current company (it decides where the notification shows with the company feature), `realtime` (default true) pushes to online users; one `sendToMany` call. Email: `{ to, cc?, bcc?, replyTo?, mode?: content|template, subject, body, format?: text|html, templateSlug, variables?, emailConfigId? }` - addresses are one, a list or comma / semicolon separated text, checked, each sent once (an address already on `to` is dropped from `cc` / `bcc`), at most `MAX_EMAIL_RECIPIENTS` (50) in all, `replyTo` a single one; `content` needs `subject` (line breaks removed, cut to 255) and `body`; with `format: html` the values a `template` expression fills in are HTML-escaped (`RuleEngineService.resolveHtml`), any other expression is sent as the HTML it holds; `template` needs `templateSlug` and fills it with `variables` (escaped by the email module); `emailConfigId` defaults to the company's default configuration. A provider that answers `success: false` or throws fails the step (`error.email.failed` / `error.notification.failed`, with the reason), so `onError: continue` and the `error` port work. Nobody to send to (empty `userIds` / `to`) skips the step (`skip.no.recipients`). Output: `{ status: 'sent' | 'queued', recipients }` (+ `messageId`, null while queued). Inside a transaction the message is **queued** and sent after the commit (with the entity and flow events, in order; dropped with them when the run, a savepoint or a joined call rolls back); a send that fails then is logged, not the run's failure - the validator warns (`message.after.commit`) on such a step marked to carry on. A test run checks everything (recipients, addresses, title, subject) and sends nothing (`skip.notification` / `skip.email`). At most `MAX_MESSAGES_PER_RUN` (100) sends per run, shared with called flows, and `MAX_NOTIFICATION_RECIPIENTS` (500) per notification. A `public` Request step in a flow with such a step is flagged (`public.message`): make sure a caller cannot choose the recipients.
|
|
95
|
+
- **Save progress** (`commit`, no settings, port `out`): commits everything the run has written so far, publishes the events held back until then, and starts a new transaction for the rest (the statement timeout is set again). A later failure rolls back only what came after it; the run's result then carries `savedUpTo: { nodeId, name, at }` (the last one that committed) and a failed answer's body `savedUpTo: '<step name>'`, so a caller knows the first part is already saved (make a retry check for it). It commits only in the transaction the run opened itself - it is skipped (trace `skipped`, `reasonKey` `flow.skip.commit.*`) in a test run (nothing is ever saved there), in a run whose Request step has no transaction setting (every step already saves on its own) and in a flow called inside its caller's transaction (only the caller may commit that one; a called flow that opened its own transaction commits normally). It cannot be set to carry on if it fails (a save error). Save-time warnings: a Save progress step reached from a Request step without the transaction setting (`commit.not.transactional`, naming both), inside a loop (it saves once per item - fine for batch imports) or in a flow other flows call; and two or more write steps reached from a Request step without the transaction setting (`writes.not.transactional`, naming that Request step).
|
|
96
|
+
- **Call flow** (`call_flow`): `{ flowSlug, endpoint?, input }` runs another active, published flow from one of its Request steps (`endpoint` = that step's path; left out, its own URL) - never from a step in the middle, since only a Request step declares what input it takes - as part of this run and puts its answer in `context.<id>`: its `respond` body, or the output of its last step. The called flow gets `input` checked against its own input schema (a mismatch rejects with 400), the same caller, request and test mode, and shares the run's step, HTTP-call and time budget. It joins the caller's transaction when there is one (so a dry run rolls back its writes too, and its events wait for the caller's commit; a joined call that fails or answers 4xx/5xx drops the events it queued, so a caller that continues past it with `onError: continue` never announces writes the savepoint rolled back); otherwise a called flow whose Request step is transactional commits on its own. A called flow that answers 4xx/5xx, or rejects (validate, permission), passes that answer on as the caller's; any other failure fails the step with `flow.error.called.flow.failed[.at.node]`, so `onError: continue` and the `error` port work. Access (of the Request step it starts at, checked against the run's user): any flow may start at an `internal` step or a `public` URL; a `jwt` URL needs a signed-in user, a `permission` one the user holding the permission; an `api_key` URL is never callable from a flow, which has no key to send. A chain that comes back to a running flow and nesting deeper than 5 levels stop the run. On save the step must name an existing flow the author can see (not the flow itself), and what the runtime would always refuse is refused then too: an `api_key` URL, a required input (without a default) left unmapped, a chain of calls that comes back to this flow, and a chain nested deeper than `FLOW_LIMITS.MAX_CALL_DEPTH`. Calling an inactive flow, and mapping an input the called flow does not declare (it is dropped), are warnings; a flow another flow calls cannot be deleted or have its URL name changed until those callers are changed. Publishing a flow (or saving a never-published one, which changes in place) also re-checks every active, published flow that calls it, directly or through other flows, against the flows as the change leaves them, and refuses the change (409, `entity_builder.flow.callers.broken`, with each broken caller's problems in `errors`) when it adds a problem the caller did not already have: a removed or renamed path, a step switched to `api_key`, a new required input, a body switched between object and list, the flow switched off, or a chain that now loops or nests too deep. Permission steps are judged per user at run time only.
|
|
97
|
+
- **Several URLs in one flow**: a flow can hold more than one **Request** step, and each one carries everything about its URL and its runs in its `config` (`ITriggerConfig`): `path`, body (`bodyType`, `inputSchema`), access (`authMode`, `permissions`, and `apiKey` for API-key access), `isTransactional`, `timeoutMs` and `rateLimitPerMinute` - each left out at its default. The flow itself has none of these: it only groups its URLs under one slug, switches them on and off together (`isActive`), keeps their run history (`retainExecutions`) and is versioned as a whole. The one without a `path` answers on `POST /api-flows/<slug>`; each other one sets `config.path` (1-63 lowercase letters, digits and dashes, starting with a letter) and answers on `POST /api-flows/<slug>/<path>` - e.g. a `customer-ticker` flow with `insert` (an object) and `insert-many` (a list) sharing the steps after them. Saving needs at least one Request step (`trigger.missing`), at most one without a path (`trigger.main.duplicate`) and distinct, valid paths (`trigger.path.duplicate` / `.invalid`); every Request step's fields are checked the same way (errors on a path's fields are named `<path>: <field>`). A flow whose every Request step has a path answers 404 on its own URL. `flowEndpoints()` / `findEndpoint()` / `startEndpoint()` (`flow.types.ts`) turn the Request steps into `IFlowEndpoint`s with every setting's default filled in; the run starts at `IFlowRunState.startAt` and takes that step's transaction and time limit. Save checks each step's settings (`trigger.transaction.invalid`, `trigger.timeout.range` 1-120 s, `trigger.rate.limit.range` 0-100,000), and every warning that depends on them - record steps open without sign-in (`keyless.entity`), transactions, HTTP inside a transaction - looks only at the steps that Request step's runs reach. The test run takes `endpoint` (a path; left out, the flow's own URL - an unknown one is refused with `flow.endpoint.not.found`), and a Call flow step takes `config.endpoint` to start the called flow at one of its paths, checked on save against that path's body (`node.flow.endpoint.unknown`, or `.required` when the called flow has no own URL) and at run time (`error.called.flow.endpoint.not.found`).
|
|
98
|
+
- **Body type** (a Request step's `config.bodyType`, default `object`, left out when `object`): `object` - the body is one object and the input fields are its keys (`input.<name>`); `list` - the body itself is a list, like `insert-many` (`POST api-flows/<slug>` with `[{...}, {...}]`), the input fields describe each item, every item is checked the same way (errors name the index: `[0].sku`; a body that is not a list is refused with `flow.input.body.not.list`), and the flow reads the list as `input` (a For each over `input`, or `input.0.<name>`). With no fields declared a list body is passed through as-is. A Call flow step sends a list-bodied flow one expression, `inputList`, instead of named `input`s; saving checks the step matches the called flow's body type (`node.flow.input.list.required` / `.unexpected`). The test run accepts a list `input` too.
|
|
99
|
+
- **Input**: each Request step declares its request fields (`config.inputSchema`) (type, required, default, min/max, choices). The body is checked and converted with the same validator entity records use; undeclared keys are dropped; every problem is returned at once (400). An `object` field may declare its own `fields`; an `array` field may declare an `itemType` (any type but `array`) that every item must have - min/max/length/options then apply to each item - and a list of objects (`itemType: 'object'`) declares the `fields` of each item. Nested values are checked the same way (undeclared keys inside them are dropped too), errors name their path (`address.city`, `items[0].qty`), and nesting goes at most `FLOW_LIMITS.MAX_INPUT_DEPTH` (4) levels. Without `fields` / `itemType` the value is only checked to be an object / a list. Generated entity flows declare `ids` as a list of ids and a multi-select field as a list of its choices; their bulk flows use a `list` body whose items are the entity fields.
|
|
100
|
+
- **Who can call it**: set on each Request step - there is no flow-level access. `config.authMode`: `jwt` (any signed-in user; the default, stored as no setting), `permission` (`config.permissions`: an AND / OR rule of permission codes (`ILogicNode` of nestjs-shared: `{ type: 'action', actionId: <code> }` or `{ type: 'group', operator: 'AND' | 'OR', children }`, nested up to 5 group levels, at most 50 codes; evaluated by `evaluatePermissionLogic()`, `*` / `prefix.*` grants match); none: `entity_builder.flow.<slug>.execute`, provisioned as an IAM action on publish - a rule names existing actions, which are not re-registered. The guard checks it with `SharedPermissionCacheService.assertPermissionLogic`), `api_key` (the step's own key, sent as `x-api-key`: the designer makes it (`fk_<8 hex>_<32 hex>`) and sends it in `config.apiKey` once; every save replaces it with `{ prefix, hash }` (SHA-256, `sealApiKeys()`), so the key itself is never stored and cannot be shown again. The guard compares in constant time against the published step's hash; a wrong or missing key answers 401 `flow.api.key.invalid`, and one step's key never opens another. A step without a key cannot be saved (`trigger.api.key.required`, or `.invalid` for a malformed one); a new key replaces the old one once the flow is published; a step that leaves API-key access drops its key), `public`, or `internal` - **other flows only**: the step has no URL (it answers 404), only other flows' Call flow steps start there, and that run takes the calling flow's user. A flow whose every Request step is `internal` is a **function** (`isFunctionFlow()`). A URL reached without signing in (`public`, `api_key`) runs with no `user`: permission checks and the caller's Company & Branch check deny, while its record steps run for anyone who can call it (validator warning `keyless.entity`, naming the URLs and the entities they read or change). Unknown, inactive and never-published flows all answer 404. Each Request step's per-caller-IP rate limit (`config.rateLimitPerMinute`, default 60, 0 = unlimited; counted per step) runs **before** credentials are checked (in memory per server instance, fixed one-minute windows, at most 10,000 tracked callers - the oldest window is dropped beyond that).
|
|
101
|
+
- **Find record** filters accept the `{ op, value }` operators (the value side is an expression). A filter entry is an operator only when it is authored as exactly `{ op, value? }` with a known `op` (any other key, or an unknown `op`, and it is not a condition - so an expression such as `{ type: 'arithmetic', op: 'add', ... }` is a plain value). A value resolved at run time (from input, a variable, a step result) is only ever an operand: an object or list is compared for equality and refused as not one value, never read as an operator, so `{ "op": "not_null" }` sent as input cannot widen a Find, lookup, update or delete. On a Find an entry that resolves to nothing (for a range: either bound) is left out; on an update / delete by filter it fails the step (`flow.error.filter.value.missing`) instead of widening the write. A range's value must be `{ from, to }` (`flow.shape.filter.range.invalid`). Flows and lookups resolve every entry through `resolveFilterEntry()` (`rule-values.ts`).
|
|
88
102
|
- **Checks cannot be carried past**: a failed Validate or Check permission step (like Save progress) always ends the run, whatever its `onError` says.
|
|
89
|
-
- **
|
|
90
|
-
- **Transactions
|
|
91
|
-
- **Test runs** need what running the flow for real would, for a saved flow as much as a draft and in both modes: `flow_definition.
|
|
92
|
-
- **Test runs**: `dry_run` (default) executes inside a transaction that is always rolled back and skips HTTP and
|
|
93
|
-
- **Safety limits**: at most 100 nodes, 5000 steps per run (room for a full 1000-item loop with a few steps per item
|
|
103
|
+
- **Access is the gate**: a Request step's *Who can call it* decides who may start a run, and then every step reads and writes records with no per-entity permission check (`entity_builder.entity.<entity>.<action>` counts only where an access rule or a Check permission step names it, as the ready-made entity flow does) and may notify any company. Finer rules are the flow's own: a **Check permission** step, a **Company & Branch check**, or `HAS_PERMISSION()` in a condition. Since whoever may save flows decides what each URL exposes, grant `flow_definition.create` / `.update` like deploy rights.
|
|
104
|
+
- **Transactions** (a Request step's `config.isTransactional`, the designer's "All writes together"): the entity writes of a run that starts at a transactional Request step commit or roll back together; the flow's other URLs open no transaction unless they set it too; entity events and **Publish event** steps are published only after the commit, in order, and never for a run that rolled back - nor for the rows of a step whose savepoint (`onError: continue`) rolled back. Metadata a transactional run needs (entity and field definitions, called flows) is read on the run's own transaction, so a run never waits on a second pooled connection. HTTP calls cannot be rolled back, so a transactional Request step whose runs reach an HTTP step is flagged in the warnings. In a run without a transaction each step commits on its own, except that a multi-record write step (many, or with child records) is always all-or-nothing (see above); make the Request step transactional when several steps must succeed or fail together, and add **Save progress** steps where the work so far must be kept even if a later step fails.
|
|
105
|
+
- **Test runs** need what running the flow for real would, for a saved flow as much as a draft and in both modes: `flow_definition.code` for a code node. A `live` test run also needs what makes it real: for a draft the permission to save it (`flow_definition.create`, or `update` when it has a `flowId`), for a `permission` Request step its execute permission. The draft is validated like a saved flow (DTO: retention bounds and no unknown flow setting; then `validateFlow`, which checks every Request step's access, transaction and limits); the server-owned fields of a loaded flow (`id`, `version`, timestamps) are ignored. Test runs of unsaved drafts are logged without a flow id and pruned together, keeping the newest 200.
|
|
106
|
+
- **Test runs**: `dry_run` (default) executes inside a transaction that is always rolled back and skips HTTP, events, notifications and emails; `live` does everything. Test runs return the real error text; live callers get a safe summary. `request: { headers?, query?, ip? }` simulates what a caller would send, on top of the designer's own request, so header / query / IP rules can be tried (credential headers are still removed).
|
|
107
|
+
- **Safety limits**: at most 100 nodes, 5000 steps per run (room for a full 1000-item loop with a few steps per item), 1000 items per loop, 10 HTTP calls, 100 notifications and emails, 120 s total; loops cannot nest deeper than 3; a flow cannot loop back on itself (use For each). Steps are data walked by a fixed engine; the only author-written code that runs is a code node's, inside the QuickJS sandbox described above.
|
|
108
|
+
- **Forwarding the caller's token**: an HTTP step with `forwardAuth: true` sends the signed-in caller's own `Authorization` header (unless its `headers` set one), so the next API sees the same user. The token lives only in the run state (`IFlowRunState.authorization`, passed on to called flows) - never in `request.headers`, expressions, code steps, traces or logs - and exists only when the caller was verified (signed-in URLs; a test run forwards the tester's own). The validator warns on every such step, since the token goes to that URL.
|
|
94
109
|
- **HTTP steps** never follow redirects, cap the response at 1 MB, and refuse loopback / private / link-local (cloud metadata) addresses **at connect time** (DNS-rebinding safe), including every IPv6 form that embeds or tunnels to one (IPv4-mapped `::ffff:`, IPv4-translated `::ffff:0:`, IPv4-compatible, NAT64 `64:ff9b::/96` and all of the local-use `64:ff9b:1::/48`, 6to4 `2002:`, and all of Teredo `2001::/32`). The timeout is one deadline for the whole request, response body included. For local development only, set `flows.allowPrivateHttp` in the module config or `ENTITY_BUILDER_FLOW_ALLOW_PRIVATE_HTTP=true`.
|
|
95
|
-
- **Runs are logged** (`eb_flow_execution`) with the redacted input, a trace of every step and the
|
|
110
|
+
- **Runs are logged** (`eb_flow_execution`) with the redacted input, a trace of every step, the output and `flowVersion` (the published version that ran; null for a test run of the designer's draft); the newest N (default 200) are kept. Dates in logged values are written as ISO text.
|
|
111
|
+
- **Versions**: the flow's columns are the published flow - what callers run. A new flow is created unpublished (`version` 0: callers get 404, Call flow steps refuse it, the validator treats it as inactive) and is edited in place until its first **publish** - so saving it is checked like a publish against other flows calling a URL name or path it drops. After that, saving (`update`) keeps the edits in `draft` (dropped again when they match what is live) and callers keep running the published columns. `publish` validates the draft like a save, plus what only going live can break (other flows calling a URL name or path it drops), copies it over the columns as `version + 1`, clears the draft and records the version in `eb_flow_version` (`FlowVersion`: the full snapshot, `note`, `publishedAt`, `publishedById`) `restore-version` copies any version into the draft (it needs the permissions its code steps call for, but is not validated, since the entities it names may have changed); `discard-draft` drops the draft. Everything but the flow's id is versioned (`FLOW_VERSIONED_FIELDS`), `isActive` included. Entity schema changes rewrite field references in the published flow, its draft, its kept versions and in deleted flows (so a restored version or flow keeps working), without a new version.
|
|
96
112
|
- **Schema changes are flow-aware**: renaming an entity field rewrites the flows that use it; dropping an entity or field lists (and for a drop, blocks on) the flows that use it. Tracked: the entity step's `fields` / `filter` / `sort`, a create step's `children[]` (`entityCode`, `fields` keys, `parentField`), lookups, and output paths holding records - `context.<id>.<field>` (get / create / update one), `context.<id>.items.<n>.<field>` (Find, many / filter writes), `context.<id>[.items.<n>].<as>.<n>.<field>` (saved children). References reached through a loop over query results (`loop.item.<field>`) are not tracked.
|
|
97
113
|
- Secrets typed into an HTTP header are stored in the flow definition (there is no credential vault yet); flows are readable only with `flow_definition.read`.
|
|
98
114
|
|
|
115
|
+
## Promoting between environments
|
|
116
|
+
|
|
117
|
+
Entities and flows are data, so TypeORM migrations never see them. They move from dev to staging to production as a **bundle**: a JSON file kept in git, applied to each environment through the same services the designer uses (`DefinitionBundleService`).
|
|
118
|
+
|
|
119
|
+
- **Export** (`bundles/export`, or `npm run entity-bundle -- export <file>` in `FLUSYS_NEST`) writes every entity that has a table and every **published** flow (a `DRAFT` entity and an unpublished flow are left out with a warning). Entities are keyed by `code` and fields by their `id` and `code`; a relation names its target by **entity code**, never by id. Entities, fields and flows are listed in code-point order, object keys are sorted at every level (PostgreSQL and MySQL return JSON keys in different orders), and there are no timestamps. The same definitions always give the same file and checksum, and a PR diff shows exactly what changes. Each Request step's API key is left out, and every occurrence of this environment's variable values in a flow becomes `${var:NAME}`.
|
|
120
|
+
- **Plan** (`bundles/plan-import`) compares the bundle with this environment. Entities and fields are matched by id first, then by code among those no id claimed (`matchDefinitions`). An import keeps the ids definitions were designed with when they are free, so a field renamed in dev arrives as a **rename** (`update_field` with `code`, which rewrites the flows that use it), not as a new field. Steps run in this order: entities switched back on, new entities (their relation fields wait until every new entity exists), restore / rename / update / add / deprecate fields of existing entities (renames ordered so none takes a code another field still has, see **Renames**), the new entities' relation fields, entities switched off, then flows with called flows before their callers. Each step that can be previewed on its own carries the real `plan-change` / `plan-create-entity` result: SQL, data checks, blockers. Only a step that runs on something an earlier step creates, restores or frees (a new entity, a relation to one, a change to a restored field, a rename or new field taking a code a rename frees) goes without a preview, and it says so. A flow is compared with its **published** version (API keys ignored). It is validated here when nothing it depends on changes in the same import, and it is always checked against the importer's own permissions: code steps need `flow_definition.code`, exactly as for a save. `mayLoseData` is `destructive` (a previewed step loses data) or a structural change that could not be previewed (type, required, unique, soft delete, audit). `checksum` is the bundle's; `planChecksum` covers the bundle, the options and every step (kind, subject, changes, whether its preview loses data), not the SQL or row counts. A `DRAFT` entity (no table) in the bundle or in this environment blocks the import.
|
|
121
|
+
- **Apply** (`bundles/apply-import`) refuses a bundle whose checksum is not `expectedChecksum` (409 `entity_builder.bundle.checksum.mismatch`: not the bundle that was reviewed). It then re-plans and refuses a plan that is not `expectedPlanChecksum` (409 `entity_builder.bundle.plan.changed`: the environment changed since the review), when anything is blocked (409, every reason in `errors` as `{ field: <subject>, messageKey, messageVariables }`, since `errors` is what the global exception filter passes on), or when `mayLoseData` and no `confirm: true` (400, the steps in `errors`) or no `expectedPlanChecksum` (400 `entity_builder.bundle.plan.checksum.required`: a confirmation covers the reviewed steps only, never ones the environment grew since). `confirm` is passed on to every step. Entity steps go through `SchemaSyncService.createEntity` / `SchemaEvolutionService.apply`, so each is one locked, logged transaction. Flow steps are saved through `FlowDefinitionService` (validated, permission-checked) and **published** as a new version with the note `Imported from bundle <checksum>`. The import is not one transaction (MySQL commits DDL at once): the first step that fails stops it (`completed: false`, its `error` and `blockers`), the earlier ones stay, and the later ones are `skipped`. Importing the same bundle again picks up where it stopped, because every step compares before it changes.
|
|
122
|
+
- **Never removes anything.** Entities, fields and flows this environment has and the bundle does not are listed as `extras`. With `deprecateMissingFields` the missing fields are deprecated, which keeps their columns and data. Dropping stays a deliberate `apply-destructive-change`. An entity whose code changed (same id, different code) blocks the import, because an entity code cannot be renamed.
|
|
123
|
+
- **API keys**: a Request step with API-key access keeps the key this environment already stores for that step (matched by node id). Otherwise a new key is generated, returned **once** in `apiKeys`, and only its hash is stored. A key is answered as soon as its flow is saved, so it is not lost when publishing fails afterwards (the next import keeps it).
|
|
124
|
+
- **Ids**: a design-time id is reused only when no row has it, soft-deleted rows included.
|
|
125
|
+
- **Renames**: renames run in an order that never takes a code another field still has. A rename waits for the one that frees its new code (`b -> c` before `a -> b`), a new field that takes a freed code is added after that rename, and a cycle (two fields that swapped codes) goes through a temporary code: `a -> tmp_<id>`, `b -> a`, `tmp_<id> -> b`, the first step warning `entity_builder.bundle.rename.via.temporary`. Each rename rewrites the references to its field, so they end up where the bundle has them.
|
|
126
|
+
- **Variables** (every non-empty `ENTITY_BUILDER_VAR_<NAME>` env variable, read by the package itself through `EntityBuilderConfigService.getBundleVariables()`; `config.bundles.variables` adds to them and wins on the same name; a name must match `[A-Za-z_][A-Za-z0-9_]*`, any other is ignored): an export replaces each value (at least 4 characters, longest first, in one pass so a written placeholder is never rewritten) with `${var:NAME}`, and an import puts this environment's value back. Only a step's settings (`config`) are rewritten, never its id, type, name or position. A placeholder this environment does not define blocks the import. Use variables for URLs, IDs, tokens and other values that differ per environment, such as an `emailConfigId`. Keep values distinctive: a short common word would be replaced wherever it appears in a step's settings.
|
|
127
|
+
- **Read-only designer** (the `ENTITY_BUILDER_READ_ONLY=true` env variable, read by the package itself through `isDesignerReadOnly()`, for production; `config.designer.readOnly` wins when set): `create-entity`, `apply-change`, `apply-destructive-change` and flow `insert` / `update` / `delete` / `publish` / `restore-version` / `discard-draft` answer **423** `entity_builder.designer.read.only` (`DesignerWritableInterceptor` + `@DesignerWrites`; an interceptor, so it runs after every guard: a caller not signed in or without the permission still gets 401 / 403). Imports, every read, and a `repair` through `apply-change` (it fixes drift without changing a definition) still work, so production changes only from git.
|
|
128
|
+
- **Uploaded as a file**: `plan-import` and `apply-import` take the bundle as a `multipart/form-data` upload (field `bundle`), with `deprecateMissingFields`, `confirm`, `expectedChecksum` and `expectedPlanChecksum` as form fields (`true` / `false` as text). Express's JSON parser never reads a multipart body, so the app's JSON body limit (100kb by default) never applies. The package enforces its own limit, `ENTITY_BUNDLE_MAX_BYTES` (20 MB, `@flusys/nestjs-entity-builder/config`, 413 above it), through `FileInterceptor`. **An app needs no setup in `main.ts`.** `readBundleFile()` parses the file (a UTF-8 BOM is allowed) and validates it as strictly as a request body: unknown keys are refused, and every problem comes back in `errors` named by its path (`entities.0.fields.2.code`), under `entity_builder.bundle.file.invalid`. A missing file answers `entity_builder.bundle.file.required`.
|
|
129
|
+
|
|
130
|
+
Recommended flow: design in dev → export → commit the bundle in a PR (reviewers read the diff) → CI runs `entity-bundle plan` then `entity-bundle apply` against staging → smoke-test → the same commit runs `apply` against production, where `--confirm` is a manual approval. `scripts/entity-bundle.js` reads `FLUSYS_API_URL`, `FLUSYS_API_TOKEN` and optionally `FLUSYS_TENANT_ID` / `FLUSYS_TENANT_HEADER`. It writes new API keys to a `0600` file (`--keys-out`, git-ignored by default) and never prints them. Exit codes: 1 when blocked, unreachable or failed, 2 when the plan may lose data and `--confirm` was not given.
|
|
131
|
+
|
|
99
132
|
## Messages and localization
|
|
100
133
|
|
|
101
134
|
Every user-facing message is key based (`config/message-keys.ts`, keys `entity_builder.*`): exceptions and responses carry `messageKey` (+ `messageVariables`), and diagnostics (plan `warnings`/`blockers`/step descriptions/`impact`, drift `findings`, flow validation `errors`/`warnings`, run `trace[].error`, record `errors[]`) are `IMessageRef`s from `@flusys/nestjs-shared`, never text. The CRUD controllers use `entityName: 'entity_builder.<resource>'` so the base controller's `*.success` keys match the constants. The English text and the Bengali/Arabic translations live in the app's seed (`flusysnest/src/persistence/localization/entity-builder.localization.ts`, module `entityBuilder`); the frontend `ENTITY_BUILDER_MESSAGES` carries the same English text. Adding a message means adding the constant and both entries.
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
export declare const ENTITY_BUILDER_MODULE_OPTIONS = "ENTITY_BUILDER_MODULE_OPTIONS";
|
|
2
|
+
export declare const ENTITY_BUNDLE_MAX_BYTES: number;
|
|
3
|
+
export declare const BUNDLE_VARIABLE_NAME_PATTERN: RegExp;
|
|
2
4
|
export declare const ENTITY_CODE_PATTERN: RegExp;
|
|
3
5
|
export declare const ENTITY_CODE_MAX_LENGTH = 63;
|
|
4
6
|
export declare const ENTITY_TABLE_PREFIX = "eb_";
|
package/config/message-keys.d.ts
CHANGED
|
@@ -56,6 +56,7 @@ export declare const GENERIC_RECORD_MESSAGES: {
|
|
|
56
56
|
readonly FILTER_OPERATOR_UNKNOWN: "entity_builder.generic_record.filter.operator.unknown";
|
|
57
57
|
readonly FILTER_VALUE_INVALID: "entity_builder.generic_record.filter.value.invalid";
|
|
58
58
|
readonly FILTER_TOO_MANY_VALUES: "entity_builder.generic_record.filter.too.many.values";
|
|
59
|
+
readonly FILTER_OPERATOR_NOT_FOR_FIELD: "entity_builder.generic_record.filter.operator.not.for.field";
|
|
59
60
|
};
|
|
60
61
|
export declare const SCHEMA_MESSAGES: {
|
|
61
62
|
readonly BUSY: "entity_builder.schema.busy";
|
|
@@ -256,15 +257,9 @@ export declare const FLOW_MESSAGES: {
|
|
|
256
257
|
readonly SLUG_TAKEN: "entity_builder.flow.slug.taken";
|
|
257
258
|
readonly RATE_LIMITED: "entity_builder.flow.rate.limited";
|
|
258
259
|
readonly API_KEY_INVALID: "entity_builder.flow.api.key.invalid";
|
|
259
|
-
readonly API_KEY_NOT_FOUND: "entity_builder.flow.api.key.not.found";
|
|
260
|
-
readonly API_KEY_CREATE_SUCCESS: "entity_builder.flow.api.key.create.success";
|
|
261
|
-
readonly API_KEY_LIST_SUCCESS: "entity_builder.flow.api.key.list.success";
|
|
262
|
-
readonly API_KEY_REVOKE_SUCCESS: "entity_builder.flow.api.key.revoke.success";
|
|
263
|
-
readonly API_KEY_DELETE_SUCCESS: "entity_builder.flow.api.key.delete.success";
|
|
264
|
-
readonly API_KEY_EXPIRED: "entity_builder.flow.api.key.expired";
|
|
265
|
-
readonly API_KEY_EXPIRY_IN_PAST: "entity_builder.flow.api.key.expiry.in.past";
|
|
266
260
|
readonly CALLED_BY_OTHER_FLOWS: "entity_builder.flow.called.by.other.flows";
|
|
267
261
|
readonly ENDPOINT_CALLED_BY_OTHER_FLOWS: "entity_builder.flow.endpoint.called.by.other.flows";
|
|
262
|
+
readonly CALLERS_BROKEN: "entity_builder.flow.callers.broken";
|
|
268
263
|
readonly EXECUTION_NOT_FOUND: "entity_builder.flow.execution.not.found";
|
|
269
264
|
readonly EXECUTIONS_SUCCESS: "entity_builder.flow.executions.success";
|
|
270
265
|
readonly EXECUTION_SUCCESS: "entity_builder.flow.execution.success";
|
|
@@ -282,6 +277,14 @@ export declare const FLOW_MESSAGES: {
|
|
|
282
277
|
readonly INPUT_BODY_ITEM_NOT_OBJECT: "entity_builder.flow.input.body.item.not.object";
|
|
283
278
|
readonly CHECKS_FAILED: "entity_builder.flow.checks.failed";
|
|
284
279
|
readonly PERMISSION_DENIED: "entity_builder.flow.permission.denied";
|
|
280
|
+
readonly PUBLISH_SUCCESS: "entity_builder.flow.publish.success";
|
|
281
|
+
readonly NOTHING_TO_PUBLISH: "entity_builder.flow.nothing.to.publish";
|
|
282
|
+
readonly VERSIONS_SUCCESS: "entity_builder.flow.versions.success";
|
|
283
|
+
readonly VERSION_SUCCESS: "entity_builder.flow.version.success";
|
|
284
|
+
readonly VERSION_NOT_FOUND: "entity_builder.flow.version.not.found";
|
|
285
|
+
readonly VERSION_RESTORED: "entity_builder.flow.version.restored";
|
|
286
|
+
readonly DRAFT_DISCARDED: "entity_builder.flow.draft.discarded";
|
|
287
|
+
readonly NO_DRAFT: "entity_builder.flow.no.draft";
|
|
285
288
|
};
|
|
286
289
|
export declare const FLOW_ERROR_MESSAGES: {
|
|
287
290
|
readonly NO_TRIGGER: "entity_builder.flow.error.no.trigger";
|
|
@@ -317,21 +320,34 @@ export declare const FLOW_ERROR_MESSAGES: {
|
|
|
317
320
|
readonly CALLED_FLOW_TOO_DEEP: "entity_builder.flow.error.called.flow.too.deep";
|
|
318
321
|
readonly CALLED_FLOW_FAILED: "entity_builder.flow.error.called.flow.failed";
|
|
319
322
|
readonly CALLED_FLOW_FAILED_AT_NODE: "entity_builder.flow.error.called.flow.failed.at.node";
|
|
320
|
-
readonly RUN_AS_NOT_ALLOWED: "entity_builder.flow.error.run.as.not.allowed";
|
|
321
323
|
readonly FILTER_VALUE_MISSING: "entity_builder.flow.error.filter.value.missing";
|
|
324
|
+
readonly TOO_MANY_MESSAGES: "entity_builder.flow.error.too.many.messages";
|
|
325
|
+
readonly TOO_MANY_RECIPIENTS: "entity_builder.flow.error.too.many.recipients";
|
|
326
|
+
readonly NOTIFICATION_UNAVAILABLE: "entity_builder.flow.error.notification.unavailable";
|
|
327
|
+
readonly NOTIFICATION_RECIPIENT_INVALID: "entity_builder.flow.error.notification.recipient.invalid";
|
|
328
|
+
readonly NOTIFICATION_COMPANY_INVALID: "entity_builder.flow.error.notification.company.invalid";
|
|
329
|
+
readonly NOTIFICATION_BRANCH_INVALID: "entity_builder.flow.error.notification.branch.invalid";
|
|
330
|
+
readonly NOTIFICATION_COMPANY_UNAVAILABLE: "entity_builder.flow.error.notification.company.unavailable";
|
|
331
|
+
readonly NOTIFICATION_TITLE_EMPTY: "entity_builder.flow.error.notification.title.empty";
|
|
332
|
+
readonly NOTIFICATION_FAILED: "entity_builder.flow.error.notification.failed";
|
|
333
|
+
readonly EMAIL_UNAVAILABLE: "entity_builder.flow.error.email.unavailable";
|
|
334
|
+
readonly EMAIL_ADDRESS_INVALID: "entity_builder.flow.error.email.address.invalid";
|
|
335
|
+
readonly EMAIL_SUBJECT_EMPTY: "entity_builder.flow.error.email.subject.empty";
|
|
336
|
+
readonly EMAIL_BODY_EMPTY: "entity_builder.flow.error.email.body.empty";
|
|
337
|
+
readonly EMAIL_FAILED: "entity_builder.flow.error.email.failed";
|
|
322
338
|
};
|
|
323
339
|
export declare const FLOW_SKIP_MESSAGES: {
|
|
324
340
|
readonly HTTP: "entity_builder.flow.skip.http";
|
|
325
341
|
readonly EVENTS: "entity_builder.flow.skip.events";
|
|
342
|
+
readonly NOTIFICATION: "entity_builder.flow.skip.notification";
|
|
343
|
+
readonly EMAIL: "entity_builder.flow.skip.email";
|
|
344
|
+
readonly NO_RECIPIENTS: "entity_builder.flow.skip.no.recipients";
|
|
326
345
|
readonly COMMIT_DRY_RUN: "entity_builder.flow.skip.commit.dry.run";
|
|
327
346
|
readonly COMMIT_NO_TRANSACTION: "entity_builder.flow.skip.commit.no.transaction";
|
|
328
347
|
readonly COMMIT_JOINED: "entity_builder.flow.skip.commit.joined";
|
|
329
348
|
};
|
|
330
349
|
export declare const FLOW_VALIDATION_MESSAGES: {
|
|
331
350
|
readonly SLUG_INVALID: "entity_builder.flow.validation.slug.invalid";
|
|
332
|
-
readonly RUN_AS_CALLER_NEEDS_AUTH: "entity_builder.flow.validation.run.as.caller.needs.auth";
|
|
333
|
-
readonly RUN_AS_INVALID: "entity_builder.flow.validation.run.as.invalid";
|
|
334
|
-
readonly AUTH_MODE_INVALID: "entity_builder.flow.validation.auth.mode.invalid";
|
|
335
351
|
readonly NO_NODES: "entity_builder.flow.validation.no.nodes";
|
|
336
352
|
readonly TOO_MANY_NODES: "entity_builder.flow.validation.too.many.nodes";
|
|
337
353
|
readonly TOO_MANY_EDGES: "entity_builder.flow.validation.too.many.edges";
|
|
@@ -356,8 +372,17 @@ export declare const FLOW_VALIDATION_MESSAGES: {
|
|
|
356
372
|
readonly COMMIT_NOT_TRANSACTIONAL: "entity_builder.flow.validation.commit.not.transactional";
|
|
357
373
|
readonly COMMIT_IN_LOOP: "entity_builder.flow.validation.commit.in.loop";
|
|
358
374
|
readonly COMMIT_IN_CALLED_FLOW: "entity_builder.flow.validation.commit.in.called.flow";
|
|
359
|
-
readonly PUBLIC_SYSTEM_ENTITY: "entity_builder.flow.validation.public.system.entity";
|
|
360
375
|
readonly PERMISSION_WITHOUT_USER: "entity_builder.flow.validation.permission.without.user";
|
|
376
|
+
readonly KEYLESS_ENTITY: "entity_builder.flow.validation.keyless.entity";
|
|
377
|
+
readonly TRIGGER_AUTH_INVALID: "entity_builder.flow.validation.trigger.auth.invalid";
|
|
378
|
+
readonly TRIGGER_TRANSACTION_INVALID: "entity_builder.flow.validation.trigger.transaction.invalid";
|
|
379
|
+
readonly TRIGGER_TIMEOUT_RANGE: "entity_builder.flow.validation.trigger.timeout.range";
|
|
380
|
+
readonly TRIGGER_RATE_LIMIT_RANGE: "entity_builder.flow.validation.trigger.rate.limit.range";
|
|
381
|
+
readonly TRIGGER_API_KEY_REQUIRED: "entity_builder.flow.validation.trigger.api.key.required";
|
|
382
|
+
readonly TRIGGER_API_KEY_INVALID: "entity_builder.flow.validation.trigger.api.key.invalid";
|
|
383
|
+
readonly NODE_FORWARDS_TOKEN: "entity_builder.flow.validation.node.forwards.token";
|
|
384
|
+
readonly NODE_ACCESS_TARGET_REQUIRED: "entity_builder.flow.validation.node.access.target.required";
|
|
385
|
+
readonly NODE_ACCESS_USER_REQUIRED: "entity_builder.flow.validation.node.access.user.required";
|
|
361
386
|
readonly INPUT_TOO_MANY: "entity_builder.flow.validation.input.too.many";
|
|
362
387
|
readonly INPUT_NAME_INVALID: "entity_builder.flow.validation.input.name.invalid";
|
|
363
388
|
readonly INPUT_NAME_DUPLICATE: "entity_builder.flow.validation.input.name.duplicate";
|
|
@@ -379,6 +404,7 @@ export declare const FLOW_VALIDATION_MESSAGES: {
|
|
|
379
404
|
readonly NODE_CASE_DUPLICATE: "entity_builder.flow.validation.node.case.duplicate";
|
|
380
405
|
readonly NODE_MAX_ITEMS_RANGE: "entity_builder.flow.validation.node.max.items.range";
|
|
381
406
|
readonly NODE_LIMIT_RANGE: "entity_builder.flow.validation.node.limit.range";
|
|
407
|
+
readonly NODE_SORT_INVALID: "entity_builder.flow.validation.node.sort.invalid";
|
|
382
408
|
readonly NODE_WRITE_TARGET_INVALID: "entity_builder.flow.validation.node.write.target.invalid";
|
|
383
409
|
readonly NODE_NOT_FOUND_INVALID: "entity_builder.flow.validation.node.not.found.invalid";
|
|
384
410
|
readonly NODE_FILTER_REQUIRED: "entity_builder.flow.validation.node.filter.required";
|
|
@@ -403,6 +429,15 @@ export declare const FLOW_VALIDATION_MESSAGES: {
|
|
|
403
429
|
readonly NODE_CHOOSE_METHOD: "entity_builder.flow.validation.node.choose.method";
|
|
404
430
|
readonly NODE_TIMEOUT_RANGE: "entity_builder.flow.validation.node.timeout.range";
|
|
405
431
|
readonly NODE_EVENT_NAME_INVALID: "entity_builder.flow.validation.node.event.name.invalid";
|
|
432
|
+
readonly NODE_NOTIFICATION_UNAVAILABLE: "entity_builder.flow.validation.node.notification.unavailable";
|
|
433
|
+
readonly NODE_NOTIFICATION_OPTION_INVALID: "entity_builder.flow.validation.node.notification.option.invalid";
|
|
434
|
+
readonly NODE_NOTIFICATION_COMPANY_UNAVAILABLE: "entity_builder.flow.validation.node.notification.company.unavailable";
|
|
435
|
+
readonly NODE_MESSAGE_TARGET_INVALID: "entity_builder.flow.validation.node.message.target.invalid";
|
|
436
|
+
readonly NODE_EMAIL_UNAVAILABLE: "entity_builder.flow.validation.node.email.unavailable";
|
|
437
|
+
readonly NODE_EMAIL_OPTION_INVALID: "entity_builder.flow.validation.node.email.option.invalid";
|
|
438
|
+
readonly NODE_EMAIL_TEMPLATE_REQUIRED: "entity_builder.flow.validation.node.email.template.required";
|
|
439
|
+
readonly MESSAGE_AFTER_COMMIT: "entity_builder.flow.validation.message.after.commit";
|
|
440
|
+
readonly PUBLIC_MESSAGE: "entity_builder.flow.validation.public.message";
|
|
406
441
|
readonly NODE_STATUS_2XX_5XX: "entity_builder.flow.validation.node.status.2xx.5xx";
|
|
407
442
|
readonly REF_BAD_SCOPE: "entity_builder.flow.validation.ref.bad.scope";
|
|
408
443
|
readonly REF_NODE_MISSING: "entity_builder.flow.validation.ref.node.missing";
|
|
@@ -456,6 +491,7 @@ export declare const FLOW_SHAPE_MESSAGES: {
|
|
|
456
491
|
readonly LOOKUP_FIELD_INVALID: "entity_builder.flow.shape.lookup.field.invalid";
|
|
457
492
|
readonly FILTER_OPERATOR_UNKNOWN: "entity_builder.flow.shape.filter.operator.unknown";
|
|
458
493
|
readonly FILTER_VALUE_REQUIRED: "entity_builder.flow.shape.filter.value.required";
|
|
494
|
+
readonly FILTER_RANGE_INVALID: "entity_builder.flow.shape.filter.range.invalid";
|
|
459
495
|
};
|
|
460
496
|
export declare const FLOW_SUBJECT_MESSAGES: {
|
|
461
497
|
readonly THE_RULE: "entity_builder.flow.subject.the.rule";
|
|
@@ -470,6 +506,23 @@ export declare const FLOW_SUBJECT_MESSAGES: {
|
|
|
470
506
|
readonly THE_URL: "entity_builder.flow.subject.the.url";
|
|
471
507
|
readonly THE_INPUT_LIST: "entity_builder.flow.subject.the.input.list";
|
|
472
508
|
readonly THE_RESPONSE: "entity_builder.flow.subject.the.response";
|
|
509
|
+
readonly THE_RECIPIENTS: "entity_builder.flow.subject.the.recipients";
|
|
510
|
+
readonly THE_TITLE: "entity_builder.flow.subject.the.title";
|
|
511
|
+
readonly THE_MESSAGE: "entity_builder.flow.subject.the.message";
|
|
512
|
+
readonly THE_COMPANY: "entity_builder.flow.subject.the.company";
|
|
513
|
+
readonly THE_COMPANIES: "entity_builder.flow.subject.the.companies";
|
|
514
|
+
readonly THE_BRANCHES: "entity_builder.flow.subject.the.branches";
|
|
515
|
+
readonly THE_BRANCH: "entity_builder.flow.subject.the.branch";
|
|
516
|
+
readonly THE_USER: "entity_builder.flow.subject.the.user";
|
|
517
|
+
readonly THE_CC: "entity_builder.flow.subject.the.cc";
|
|
518
|
+
readonly THE_BCC: "entity_builder.flow.subject.the.bcc";
|
|
519
|
+
readonly THE_REPLY_TO: "entity_builder.flow.subject.the.reply.to";
|
|
520
|
+
readonly THE_SUBJECT: "entity_builder.flow.subject.the.subject";
|
|
521
|
+
readonly THE_BODY: "entity_builder.flow.subject.the.body";
|
|
522
|
+
readonly DATA_VALUES: "entity_builder.flow.subject.data.values";
|
|
523
|
+
readonly DATA_VALUE: "entity_builder.flow.subject.data.value";
|
|
524
|
+
readonly TEMPLATE_VALUES: "entity_builder.flow.subject.template.values";
|
|
525
|
+
readonly TEMPLATE_VALUE: "entity_builder.flow.subject.template.value";
|
|
473
526
|
readonly VARIABLES: "entity_builder.flow.subject.variables";
|
|
474
527
|
readonly VARIABLE: "entity_builder.flow.subject.variable";
|
|
475
528
|
readonly FIELDS: "entity_builder.flow.subject.fields";
|
|
@@ -487,3 +540,34 @@ export declare const FLOW_SUBJECT_MESSAGES: {
|
|
|
487
540
|
readonly RESPONSE_VALUES: "entity_builder.flow.subject.response.values";
|
|
488
541
|
readonly RESPONSE_VALUE: "entity_builder.flow.subject.response.value";
|
|
489
542
|
};
|
|
543
|
+
export declare const BUNDLE_MESSAGES: {
|
|
544
|
+
readonly SETTINGS_SUCCESS: "entity_builder.bundle.settings.success";
|
|
545
|
+
readonly EXPORT_SUCCESS: "entity_builder.bundle.export.success";
|
|
546
|
+
readonly PLAN_SUCCESS: "entity_builder.bundle.plan.success";
|
|
547
|
+
readonly APPLY_SUCCESS: "entity_builder.bundle.apply.success";
|
|
548
|
+
readonly APPLY_PARTIAL: "entity_builder.bundle.apply.partial";
|
|
549
|
+
readonly APPLY_BLOCKED: "entity_builder.bundle.apply.blocked";
|
|
550
|
+
readonly CHECKSUM_MISMATCH: "entity_builder.bundle.checksum.mismatch";
|
|
551
|
+
readonly PLAN_CHANGED: "entity_builder.bundle.plan.changed";
|
|
552
|
+
readonly PLAN_CHECKSUM_REQUIRED: "entity_builder.bundle.plan.checksum.required";
|
|
553
|
+
readonly ENTITY_DRAFT_IN_BUNDLE: "entity_builder.bundle.entity.draft.in.bundle";
|
|
554
|
+
readonly ENTITY_DRAFT_HERE: "entity_builder.bundle.entity.draft.here";
|
|
555
|
+
readonly ENTITY_DRAFT_SKIPPED: "entity_builder.bundle.entity.draft.skipped";
|
|
556
|
+
readonly RENAME_VIA_TEMPORARY: "entity_builder.bundle.rename.via.temporary";
|
|
557
|
+
readonly FILE_REQUIRED: "entity_builder.bundle.file.required";
|
|
558
|
+
readonly FILE_INVALID: "entity_builder.bundle.file.invalid";
|
|
559
|
+
readonly CONFIRM_REQUIRED: "entity_builder.bundle.confirm.required";
|
|
560
|
+
readonly DUPLICATE_ENTITY: "entity_builder.bundle.duplicate.entity";
|
|
561
|
+
readonly DUPLICATE_FIELD: "entity_builder.bundle.duplicate.field";
|
|
562
|
+
readonly DUPLICATE_FLOW: "entity_builder.bundle.duplicate.flow";
|
|
563
|
+
readonly RELATION_TARGET_UNKNOWN: "entity_builder.bundle.relation.target.unknown";
|
|
564
|
+
readonly ENTITY_CODE_CHANGED: "entity_builder.bundle.entity.code.changed";
|
|
565
|
+
readonly VARIABLE_MISSING: "entity_builder.bundle.variable.missing";
|
|
566
|
+
readonly FLOW_SLUG_DELETED: "entity_builder.bundle.flow.slug.deleted";
|
|
567
|
+
readonly FLOW_UNPUBLISHED_SKIPPED: "entity_builder.bundle.flow.unpublished.skipped";
|
|
568
|
+
readonly FLOW_DRAFT_REPLACED: "entity_builder.bundle.flow.draft.replaced";
|
|
569
|
+
readonly FLOW_API_KEY_GENERATED: "entity_builder.bundle.flow.api.key.generated";
|
|
570
|
+
readonly STEP_AFTER_EARLIER: "entity_builder.bundle.step.after.earlier";
|
|
571
|
+
readonly FLOWS_CHECKED_ON_APPLY: "entity_builder.bundle.flows.checked.on.apply";
|
|
572
|
+
readonly DESIGNER_READ_ONLY: "entity_builder.designer.read.only";
|
|
573
|
+
};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { type ILoggedUserInfo, SingleResponseDto } from '@flusys/nestjs-shared';
|
|
2
|
+
import { ExportBundleDto, ImportBundleOptionsDto } from '../dtos/definition-bundle.dto.js';
|
|
3
|
+
import { type IBundleExport, type IBundleImportPlan, type IBundleImportResult, type IBundleSettings } from '../interfaces/definition-bundle.interface.js';
|
|
4
|
+
import { type IUploadedBundleFile } from '../services/definition-bundle.file.js';
|
|
5
|
+
import { DefinitionBundleService } from '../services/definition-bundle.service.js';
|
|
6
|
+
import { EntityBuilderConfigService } from '../services/entity-builder-config.service.js';
|
|
7
|
+
export declare class DefinitionBundleController {
|
|
8
|
+
private readonly bundles;
|
|
9
|
+
private readonly config;
|
|
10
|
+
constructor(bundles: DefinitionBundleService, config: EntityBuilderConfigService);
|
|
11
|
+
settings(): SingleResponseDto<IBundleSettings>;
|
|
12
|
+
export(dto: ExportBundleDto): Promise<SingleResponseDto<IBundleExport>>;
|
|
13
|
+
planImport(file: IUploadedBundleFile | undefined, options: ImportBundleOptionsDto, user: ILoggedUserInfo): Promise<SingleResponseDto<IBundleImportPlan>>;
|
|
14
|
+
applyImport(file: IUploadedBundleFile | undefined, options: ImportBundleOptionsDto, user: ILoggedUserInfo): Promise<SingleResponseDto<IBundleImportResult>>;
|
|
15
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { type ILoggedUserInfo, SingleResponseDto } from '@flusys/nestjs-shared';
|
|
2
|
-
import { CreateFlowDefinitionDto, FlowDefinitionResponseDto, TestFlowDto, UpdateFlowDefinitionDto, ValidateFlowDto } from '../dtos/flow.dto.js';
|
|
2
|
+
import { CreateFlowDefinitionDto, FlowDefinitionResponseDto, FlowIdDto, FlowVersionRefDto, PublishFlowDto, TestFlowDto, UpdateFlowDefinitionDto, ValidateFlowDto } from '../dtos/flow.dto.js';
|
|
3
|
+
import { type IFlowDefinition, type IFlowVersion } from '../interfaces/flow-definition.interface.js';
|
|
3
4
|
import { type IFlowValidationResult } from '../flow-engine/flow-graph.validator.js';
|
|
4
5
|
import { FlowDefinitionService } from '../services/flow-definition.service.js';
|
|
5
6
|
import { FlowRuntimeService } from '../services/flow-runtime.service.js';
|
|
@@ -23,6 +24,11 @@ export declare class FlowDefinitionController extends FlowDefinitionController_b
|
|
|
23
24
|
private readonly runtime;
|
|
24
25
|
constructor(flowDefinitionService: FlowDefinitionService, runtime: FlowRuntimeService);
|
|
25
26
|
validate(dto: ValidateFlowDto): Promise<SingleResponseDto<IFlowValidationResult>>;
|
|
27
|
+
publish(dto: PublishFlowDto, user: ILoggedUserInfo): Promise<SingleResponseDto<IFlowDefinition>>;
|
|
28
|
+
versions(dto: FlowIdDto): Promise<SingleResponseDto<IFlowVersion[]>>;
|
|
29
|
+
version(dto: FlowVersionRefDto): Promise<SingleResponseDto<IFlowVersion>>;
|
|
30
|
+
restoreVersion(dto: FlowVersionRefDto, user: ILoggedUserInfo): Promise<SingleResponseDto<IFlowDefinition>>;
|
|
31
|
+
discardDraft(dto: FlowIdDto, user: ILoggedUserInfo): Promise<SingleResponseDto<IFlowDefinition>>;
|
|
26
32
|
testRun(dto: TestFlowDto, user: ILoggedUserInfo, req: {
|
|
27
33
|
ip?: string;
|
|
28
34
|
headers: Record<string, unknown>;
|