@flusys/nestjs-entity-builder 9.2.0 → 9.2.1
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 +40 -22
- package/config/entity-builder.constants.d.ts +1 -0
- package/config/message-keys.d.ts +11 -2
- package/controllers/definition-bundle.controller.d.ts +2 -2
- package/controllers/entity-definition.controller.d.ts +0 -1
- package/controllers/field-definition.controller.d.ts +0 -1
- package/controllers/flow-definition.controller.d.ts +2 -1
- package/controllers/flow-runtime.controller.d.ts +4 -3
- package/fesm/21.js +13 -5
- package/fesm/{458.js → 28.js} +104 -62
- package/fesm/677.js +1234 -469
- package/fesm/794.js +1 -1
- package/fesm/857.js +1 -2
- package/fesm/870.js +415 -332
- package/fesm/897.js +23 -29
- package/fesm/946.js +67 -42
- package/fesm/config/index.js +10 -14
- package/fesm/docs/index.js +1 -1
- package/fesm/guards/index.js +15 -9
- package/fesm/index.js +25 -25
- package/fesm/modules/index.js +4 -3
- package/fesm/services/index.js +29 -58
- package/flow-engine/flow-code-runner.d.ts +16 -0
- package/flow-engine/flow-graph.validator.d.ts +1 -0
- package/flow-engine/flow-input.validator.d.ts +14 -0
- package/flow-engine/flow-run.types.d.ts +15 -0
- package/flow-engine/flow.types.d.ts +1 -1
- package/guards/designer-writable.interceptor.d.ts +1 -0
- package/interfaces/definition-bundle.interface.d.ts +2 -0
- package/interfaces/entity-builder-module.interface.d.ts +9 -0
- package/modules/entity-builder.module.d.ts +1 -0
- package/modules/index.d.ts +1 -0
- package/modules/tenant-context-id.strategy.d.ts +16 -0
- package/package.json +3 -4
- package/services/definition-bundle.diff.d.ts +0 -2
- package/services/definition-bundle.file.d.ts +2 -1
- package/services/definition-bundle.service.d.ts +0 -1
- package/services/entity-builder-config.service.d.ts +3 -0
- package/services/entity-builder-datasource.provider.d.ts +1 -0
- package/services/field-value-validator.d.ts +7 -0
- package/services/flow-definition.service.d.ts +10 -2
- package/services/flow-executor.service.d.ts +3 -0
- package/services/flow-runtime.service.d.ts +6 -1
- package/services/generic-entity-service.factory.d.ts +5 -1
- package/services/generic-entity.service.d.ts +24 -2
- package/services/identifier-rules.d.ts +0 -1
- package/services/save-loaded.d.ts +2 -0
- package/services/schema-columns.d.ts +7 -2
- package/services/schema-evolution.service.d.ts +8 -2
- package/services/schema-registry.service.d.ts +12 -0
- package/services/schema-sync.service.d.ts +3 -1
- package/flow-engine/flow-code-sandbox.d.ts +0 -5
package/README.md
CHANGED
|
@@ -21,38 +21,40 @@ Works on PostgreSQL and MySQL (via `SchemaDialectAdapterService`). Import `Entit
|
|
|
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` |
|
|
22
22
|
| `entity-builder/entity-definitions/apply-destructive-change` | Apply `purge_field` (drop column) or `drop_entity` (drop table) | `...entity_definition.delete` |
|
|
23
23
|
| `entity-builder/entity-definitions/inspect` | Drift check: entity definition vs the real table | `...entity_definition.read` |
|
|
24
|
-
| `entity-builder/entity-definitions/schema-history` |
|
|
24
|
+
| `entity-builder/entity-definitions/schema-history` | The newest 50 schema changes with their 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 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 |
|
|
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 | per Request step: public / API key / any user / a permission |
|
|
28
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.*` |
|
|
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**); `delete` removes the flow for good with its versions, run logs and execute permission, so its URL name is free again (`restore` answers 400 `entity_builder.flow.restore.unsupported`) | `...flow_definition.*` |
|
|
30
30
|
| `entity-builder/flows/publish` | `{ id, note? }`: makes the draft live as the next version | `...flow_definition.update` |
|
|
31
31
|
| `entity-builder/flows/{versions,version}` | `{ flowId }`: published versions, newest first; `{ flowId, version }`: one in full | `...flow_definition.read` |
|
|
32
32
|
| `entity-builder/flows/{restore-version,discard-draft}` | Put an old version back into the draft / drop the draft | `...flow_definition.update` |
|
|
33
33
|
| `entity-builder/flows/validate` | Check a draft without saving (errors block a save, warnings do not) | `...flow_definition.read` |
|
|
34
|
+
| `entity-builder/flows/code-packages` | The names a code step may `require` on this server (`flows.code.modules`) | `...flow_definition.read` |
|
|
34
35
|
| `entity-builder/flows/test-run` | Run a saved flow or an unsaved draft, with a per-node trace | `...flow_definition.test` |
|
|
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
|
|
36
|
-
| `entity-builder/bundles/settings` | `{ readOnly, variables }`: whether the designer is locked here,
|
|
36
|
+
| `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`. A live call's run is written once its answer is on the way (the caller never waits for the log); a test run's before the test answers | `...flow_definition.read` |
|
|
37
|
+
| `entity-builder/bundles/settings` | `{ readOnly, variables, maxFiles, maxBytes }`: whether the designer is locked here, the names of the variables this environment defines, and the upload limits (`ENTITY_BUNDLE_MAX_FILES` files, `ENTITY_BUNDLE_MAX_BYTES` bytes together) | `...entity_definition.read` or `...flow_definition.read` |
|
|
37
38
|
| `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:
|
|
39
|
-
| `entity-builder/bundles/apply-import` | Multipart:
|
|
39
|
+
| `entity-builder/bundles/plan-import` | Multipart: one or more files `bundle` (a split folder is merged) + `deprecateMissingFields?`, `deactivateMissingFlows?`: 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` |
|
|
40
|
+
| `entity-builder/bundles/apply-import` | Multipart: one or more files `bundle` + `deprecateMissingFields?`, `deactivateMissingFlows?`, `confirm?`, `expectedChecksum?`, `expectedPlanChecksum?` (required with `confirm`): runs the plan; works while the designer is read-only | same as `plan-import` |
|
|
40
41
|
|
|
41
42
|
`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.*`).
|
|
42
43
|
|
|
43
44
|
## Guarantees and limits
|
|
44
45
|
|
|
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
|
|
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).
|
|
46
|
+
- **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 the count of such records), 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).
|
|
47
|
+
- 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 - system columns too, except `id` - indexes, unique constraints, NOT NULL).
|
|
47
48
|
- 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`).
|
|
48
49
|
- **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`.
|
|
50
|
+
- A `RELATION` field is indexed unless the request sends `isIndexed: false` (create entity and add field), since child lists, parent-id filters and lookups read by it.
|
|
49
51
|
- `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.
|
|
50
52
|
- Dropping an entity is blocked while relation fields of other entities point at it (`...blocker.drop.relation.in.use`, listing `"entity.field"`).
|
|
51
53
|
- Destructive changes (`purge_field`, `drop_entity`, lossy conversions) need `confirm: true` and, for purge/drop, the code typed back (`confirmCode`). They also refuse while flows still use the field/entity.
|
|
52
54
|
- Each change runs in one locked transaction and is logged in `eb_schema_change_log` with the SQL that ran and the reverse SQL (`downSql`). On PostgreSQL a change that fails rolls back completely. MySQL commits every DDL statement at once (plans warn `...warning.mysql.no.rollback`): a failed change is undone by running the reverse of each statement that had run, newest first; the error carries `undo: { reverted, pendingSql, dataNotReverted }` and, when an undo statement fails, the key `entity_builder.schema.failed.not.reverted` with the pending statements also in the `FAILED` log row's `downSql`. Backfilled values are not undone. Either way a `FAILED` log row keeps the statement that failed and the error is mapped to a human-readable one. A follow-up step that fails after commit (permission cleanup) never fails the change; it is reported as a warning.
|
|
53
55
|
- 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.
|
|
54
56
|
- 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.
|
|
55
|
-
- **Caching**: entity, field and flow definition reads (`get-all` / `get-by-id`) are cached through the shared `HybridCache` with version stamps. Every write bumps the stamp **after it commits**: a schema change bumps all three (`DefinitionCacheService.clearAll`, also after a failed MySQL change whose DDL had already committed), flow save / delete / publish / draft restore or discard bump the flow stamp, and a bundle import goes through those same services. The
|
|
57
|
+
- **Caching**: entity, field and flow definition reads (`get-all` / `get-by-id`) are cached through the shared `HybridCache` with version stamps. Every write bumps the stamp **after it commits**: a schema change bumps all three (`DefinitionCacheService.clearAll`, also after a failed MySQL change whose DDL had already committed), flow save / delete / publish / draft restore or discard bump the flow stamp, and a bundle import goes through those same services. The published flow behind `api-flows/:slug` is cached under that same flow stamp (`FlowRuntimeService.findActive`, unknown slugs too, at most 60 s): the stamp lives in the shared store, so a flow switched off, unpublished or given a new API key on one instance stops working on every instance at once (with `USE_CACHE_LABEL=memory` there is no shared store - run one instance). Call flow steps and record access still read flows and entity definitions from the database on every call. The runtime `EntitySchema` of an entity - with its live field list - is kept in process per tenant + entity id and rebuilt whenever the `schemaVersion` read from the database moves (dropped entities are forgotten on the next lookup); every field change a run reads (label, options, rules, default, relation target, searchable, order) moves it. The first entity used per tenant registers every entity's schema with one TypeORM metadata rebuild (`SchemaRegistryService.warmUp`) instead of one rebuild per entity; a rebuild that fails puts the DataSource's entities back as they were. A run resolves one entity-service factory and builds each related entity once, however many rows and RELATION fields point at it. The per-step rate limit is counted in memory per instance and per tenant.
|
|
56
58
|
- 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`.
|
|
57
59
|
- 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`):
|
|
58
60
|
- **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).
|
|
@@ -62,10 +64,26 @@ Works on PostgreSQL and MySQL (via `SchemaDialectAdapterService`). Import `Entit
|
|
|
62
64
|
|
|
63
65
|
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`).
|
|
64
66
|
- **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.
|
|
65
|
-
- 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.
|
|
67
|
+
- 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. A `DATETIME` value is stored as UTC ISO text; date text without a zone is read as UTC, never in the server's timezone. `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.
|
|
66
68
|
- **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.
|
|
67
69
|
- `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`).
|
|
68
70
|
|
|
71
|
+
## Performance options
|
|
72
|
+
|
|
73
|
+
Each has the safe choice as its default.
|
|
74
|
+
|
|
75
|
+
| Option | Default | Effect |
|
|
76
|
+
| --- | --- | --- |
|
|
77
|
+
| `config.performance.fastUpdates` | `true` | An update hands the row it already read to TypeORM's own persistence (`saveLoaded`: `SubjectExecutor` - change detection, UPDATE of the changed columns, `updated_at`, reloaded values) instead of `save()`, which would read it again: one query less per updated record (an update by `filter` updates each match from the row it matched - 3 queries become 1, or none when nothing changed). Results are those of `save()`; `false` falls back to `save()`. |
|
|
78
|
+
| `config.performance.partialIndexes` | `false` | PostgreSQL: plain indexes of a soft-delete entity cover live rows only (`WHERE "deleted_at" IS NULL`) - smaller, and faster when many rows are deleted. New indexes take the shape at once; `inspect` reports an index of the other shape as missing and `repair` rebuilds it; switching soft delete on or off rebuilds the plain indexes to match. MySQL has no partial indexes and ignores it. |
|
|
79
|
+
| `tenantScopedServices` (module option, next to `global`) | `false` | The package's request-scoped services are NestJS durable providers: built once per tenant and reused by its requests instead of once per request (less CPU and garbage under heavy traffic). Takes effect only with `ContextIdFactory.apply(new TenantContextIdStrategy({ tenants }))` in `main.ts` (the strategy leaves every provider that is not durable per request). The tenant is read from `x-tenant-id`, exactly as the datasource provider reads it, so a shared set never serves another tenant's database; a shared service's `REQUEST` holds only that header. Pass `tenants` (the app's tenant ids, or a check): a request naming any other tenant, a malformed header, or more than `maxTenants` (1000) tenants is served per request - without `tenants`, made-up ids could use up the slots. A request without the header shares one set (single database). A service keeps its cached repository for its tenant, so an app that closes and reopens a tenant's connection at runtime should leave this off. |
|
|
80
|
+
|
|
81
|
+
```ts
|
|
82
|
+
EntityBuilderModule.forRootAsync({ tenantScopedServices: true, useFactory: () => ({ ...db, performance: { partialIndexes: true } }) });
|
|
83
|
+
// main.ts
|
|
84
|
+
ContextIdFactory.apply(new TenantContextIdStrategy({ tenants: tenantList.map((t) => t.id) })); // + maxTenants?: 1000
|
|
85
|
+
```
|
|
86
|
+
|
|
69
87
|
## Rules
|
|
70
88
|
|
|
71
89
|
Conditions and expressions are declarative JSON (`RuleGroup` / `RuleExpression`) evaluated by a closed `switch`; rules never evaluate code. Limits: depth 8, 100 conditions per group, 10 `lookup` expressions per evaluation. References: `input.`, `context.<stepId>.`, `vars.`, `loop.`, `request.`, `user.`; another entity's rows are read with a `lookup` expression.
|
|
@@ -82,7 +100,7 @@ Conditions and expressions are declarative JSON (`RuleGroup` / `RuleExpression`)
|
|
|
82
100
|
|
|
83
101
|
## Flows (virtual APIs)
|
|
84
102
|
|
|
85
|
-
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** (
|
|
103
|
+
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** (JavaScript run on the server), **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 (or `out` when nothing is wired to it); only steps that have an `error` port can be marked - on any other (Request, Validate, Check permission, Company & Branch check, Set variables, If, Switch, For each, Respond) saving refuses it (`flow.validation.node.continue.unsupported`), and Save progress keeps its own `node.commit.continue`. 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.
|
|
86
104
|
|
|
87
105
|
- **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).
|
|
88
106
|
- **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).
|
|
@@ -90,8 +108,8 @@ A flow is an endpoint (`POST /api-flows/<slug>`) whose behaviour is a graph of s
|
|
|
90
108
|
- **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.
|
|
91
109
|
- **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`).
|
|
92
110
|
- **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.
|
|
93
|
-
- **Code**: `{ code, timeoutMs? }` runs `code` as the body of
|
|
94
|
-
- **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).
|
|
111
|
+
- **Code**: `{ code, timeoutMs? }` runs `code` as the body of an **async** function in Node on the server (`flow-code-runner.ts`): a worker thread from a small pool (at most 4), so a busy code step never blocks the event loop, with a fresh `vm` context per run. The context holds 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)` / `hasAllPermissions(...codes)` (plain JavaScript over `ctx.user.permissions` with the server's wildcards), `console` (captured into the trace: `trace[].logs`, 50 lines of 500 characters, sensitive-looking keys masked), `setTimeout` / `setInterval` and their clears (cleared when the run ends), `fetch`, `require`, and `Buffer`, `URL`, `URLSearchParams`, `TextEncoder` / `TextDecoder`, `AbortController`, `atob` / `btoa`, `structuredClone`. There is no `process`, `module` or `__dirname`. **Packages**: `require(name)` loads only what the app lists in `flows.code.modules` (`{ dayjs: 'dayjs', _: 'lodash' }`: the name the code asks for -> the package the server loads; the `ENTITY_BUILDER_CODE_MODULES` env variable, `dayjs,_=lodash`, adds to it). Install those packages in the app; an ESM-only one comes back as its namespace (`require('x').default`). Allowed packages are loaded when the run starts, before the time limit counts, and anything else throws `The package "x" is not allowed`. Saving refuses a literal `require('x')` naming a package this server does not allow (`entity_builder.flow.validation.node.code.package.not.allowed`), so a bundle that needs a package fails its import plan where the package is missing. **`fetch(url, { method, headers, body })`** is carried out by the host through the HTTP step's client and rules: counted against the HTTP call limit, private addresses refused (unless `allowPrivateHttp`), no redirects, 1 MB, and not run in a test run (it rejects). It answers `{ status, ok, headers, text(), json() }`. `return` a JSON value, which becomes `context.<id>` (at most 1 MB). **Limits**: `timeoutMs` (default 1000, 10-5000, clamped to the time left in the flow) interrupts synchronous code; code still busy or waiting at the deadline fails and its worker is replaced (the host also kills a worker that overruns it by a second). Memory is capped at 128 MB per worker - the heap by the worker's own limit, and heap plus buffers by a host check every 20 ms. 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. The worker gets none of the server's env variables (only `NODE_ENV`, `TZ`, `LANG`). **This is not a security boundary**: a package's objects lead back to Node itself (files, network, child processes), so only trusted authors may write code steps - saving a flow that contains one needs `entity_builder.flow_definition.code`.
|
|
112
|
+
- **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 transaction a run opens, a single write's own included, gets the run's remaining time as its PostgreSQL `statement_timeout`, and that plus 10 s as its `idle_in_transaction_session_timeout`. A create in `many` mode without `children`, on an entity with no unique field and whose `fields` use no lookup, inserts its rows in one multi-row statement per chunk (`GenericEntityService.prepareInsert` / `firstMissingRelation` / `insertValidated`): each row is mapped, counted and checked in order and the first row that would have failed row by row is the failure reported, related records are checked with one query per RELATION field, and every saved record comes back as a one-row insert returns it. Update / delete by `filter` read one row past `limit` and count only when it is there; Find records counts only when the page came back full (or empty past the first page). Every record written counts against `FLOW_LIMITS.MAX_WRITES_PER_RUN` (5000, shared with called flows).
|
|
95
113
|
- **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.
|
|
96
114
|
- **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).
|
|
97
115
|
- **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.
|
|
@@ -100,16 +118,16 @@ A flow is an endpoint (`POST /api-flows/<slug>`) whose behaviour is a graph of s
|
|
|
100
118
|
- **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.
|
|
101
119
|
- **Request type** (a Request step's `config.contract`, left out for `custom`): which generic API controller endpoint the step stands in for (`FLOW_REQUEST_CONTRACTS`). It fixes the body type (`list` for `insert_many` / `update_many` / `bulk_upsert`, `object` otherwise; `bodyType` counts only for `custom`) and checks that endpoint's own DTO fields ahead of the declared ones (`contractFields`): `get_all` takes `FilterAndPaginationDto` - `filter`, `pagination: { currentPage, pageSize }` (defaults 0 / 10, at most `MAX_QUERY_LIMIT`), `sort` (`ASC` / `DESC` per plain field name), `select`, `withDeleted` - with search as `?q=`; `get_by_id` `{ id, select }`; `get_by_ids` `{ ids, select }` (`select` may be comma-separated); `update` / `update_many` a required `id` (with no declared fields the rest of the body is kept, only `id` checked); `delete` `DeleteDto` (`id` one id or a list, read as a list; `type`). A declared field may not reuse a contract field's name. Every query parameter is readable as `request.query.<name>` whatever the type.
|
|
102
120
|
- **Respond format** (a Respond step's `config.format`, default `raw`): `raw` answers `body` / `bodyExpression` as built. `single`, `list`, `bulk` and `message` answer the API controller envelope `{ success, message, messageKey, messageVariables?, data?, meta? }` with the author's `message`, `messageKey` (both required) and `messageVariables` (expressions); `success` is `statusCode < 400`, and `ResponseMetaInterceptor` adds `_meta` like any controller answer. `single`: `data` is the body. `list` (a `bodyExpression` list): `meta: { total, page, pageSize, count, hasMore, totalPages }`, where `total` / `page` / `pageSize` default to the list's length, the request's `pagination.currentPage` (else 0) and `pagination.pageSize` (else the list's length), exactly as `getAll` computes them. `bulk` (a list): `meta: { count, total, failed }`, `total` defaulting to the length of a list body. `message`: no data. A non-list `list` / `bulk` value or a negative / fractional count fails the step (`entity_builder.flow.error.respond.*`).
|
|
103
|
-
- **Find records from the request** (`entity_query` `config.fromRequest: true`): for a `get_all` Request step, the query also takes the request's `filter` (under the step's own filter, which wins), `sort` and `?q=` search (when the step sets none), `pagination` (else page 0 of `limit
|
|
121
|
+
- **Find records from the request** (`entity_query` `config.fromRequest: true`): for a `get_all` Request step, the query also takes the request's `filter` (under the step's own filter, which wins), `sort` and `?q=` search (when the step sets none), `pagination` (else page 0 of `limit`), `select` (`id` always kept) and `withDeleted: true` (soft-deleted records too). These are read by the get_all rules whatever the Request step's type: `pagination.currentPage` a whole number of 0 or more (left out: 0), `pagination.pageSize` one from 1 to `MAX_QUERY_LIMIT` (left out: the step's `limit`, default 50), numeric text accepted; `sort` `ASC` / `DESC` per plain field name; `select` a list of plain field names - anything else answers 400 `flow.input.invalid` with every problem in `errors` (`flow.input.page.invalid`, `.page.size.invalid`, `.sort.invalid`, `.select.invalid`) before the query runs. Output: `{ items, total, page, pageSize }` (every query). A `get_by_ids` step reads it too, for its `select`. Reached from another request type it is a save warning.
|
|
104
122
|
- **Get record from the request** (`entity_get` `config.fromRequest: true`): for a `get_by_id` Request step, the record carries only the fields its `select` names (`id` always kept; every field when it names none). Reached from another request type it is a save warning.
|
|
105
123
|
- **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 and tenant, fixed one-minute windows, at most 10,000 tracked callers - the oldest window is dropped beyond that).
|
|
106
124
|
- **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`).
|
|
107
125
|
- **Checks cannot be carried past**: a failed Validate or Check permission step (like Save progress) always ends the run, whatever its `onError` says.
|
|
108
126
|
- **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.
|
|
109
|
-
- **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.
|
|
127
|
+
- **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 commit order within the run (and the flows it calls; one run never waits on another's), without holding up the answer, 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.
|
|
110
128
|
- **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.
|
|
111
129
|
- **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).
|
|
112
|
-
- **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,
|
|
130
|
+
- **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, on a worker thread as described above.
|
|
113
131
|
- **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.
|
|
114
132
|
- **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`.
|
|
115
133
|
- **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.
|
|
@@ -119,7 +137,7 @@ A flow is an endpoint (`POST /api-flows/<slug>`) whose behaviour is a graph of s
|
|
|
119
137
|
|
|
120
138
|
## Promoting between environments
|
|
121
139
|
|
|
122
|
-
Entities and flows are data, so TypeORM migrations never see them. They move from dev to staging to production as a **bundle
|
|
140
|
+
Entities and flows are data, so TypeORM migrations never see them. They move from dev to staging to production as a **bundle** kept in git, applied to each environment through the same services the designer uses (`DefinitionBundleService`). A bundle is one JSON file, or a **split folder** with one complete bundle per entity (`entities/<code>.json`) and per flow (`flows/<slug>.json`): a change is then its own file in git, reviewed and cherry-picked on its own.
|
|
123
141
|
|
|
124
142
|
- **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}`. `entityCodes` / `flowSlugs` (CLI: `--entities a,b`, `--flows x,y`; an empty list there means everything) narrow the export. Its `warnings` then name what the selection leaves behind: a relation to an entity outside it (`entity_builder.bundle.export.relation.left.out`) and a Call flow step to a flow outside it (`...export.called.flow.left.out`). The environment that imports the bundle must already have those. An export also warns about each HTTP step that sends a credential written into the step, such as an `Authorization`/token/secret/key/password/cookie header or a query parameter of that kind. These warn as `entity_builder.bundle.flow.secret.written`, because the bundle holds them as plain text in git. A value from `${var:NAME}`, the input or the context is not flagged, and neither is a bare `Bearer` / `Basic` scheme.
|
|
125
143
|
- **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, then (with `deactivateMissingFlows`) the flows switched off, callers before the flows they call. A step switching a flow off is checked like any save of it: the importer must be allowed to author it, and it must still validate. 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. A created or updated flow whose step settings hold UUIDs (record, user, company or `emailConfigId` ids) warns `entity_builder.bundle.flow.fixed.ids` with the list: such an id belongs to the environment it was typed in. An id written as `${var:NAME}` is not counted, and an email template slug or a config id is not looked up in this environment. `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. **Permissions are never promoted** (roles and grants are IAM data). With IAM (`PERMISSION_ACTION_REGISTRY` and its optional `findMissingCodes`), the plan asks once about every code involved. A step that creates permissions no role holds here yet warns `entity_builder.bundle.permissions.to.grant`: a new entity's create/read/update/delete codes, and the execute code of a flow whose URL asks for it. Grant those after the import. A created or updated flow whose access rules, Permission check steps or literal `HAS_PERMISSION` / `HAS_ANY_PERMISSION` / `HAS_ALL_PERMISSIONS` codes name a code this environment does not have, and the import does not create, warns `entity_builder.bundle.flow.permission.unknown`, because that check refuses everyone. Wildcard codes are not checked.
|
|
@@ -130,10 +148,10 @@ Entities and flows are data, so TypeORM migrations never see them. They move fro
|
|
|
130
148
|
- **Ids**: a design-time id is reused only when no row has it, soft-deleted rows included.
|
|
131
149
|
- **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.
|
|
132
150
|
- **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.
|
|
133
|
-
- **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
|
|
134
|
-
- **Uploaded as a file**: `plan-import` and `apply-import` take the bundle as a `multipart/form-data` upload (field `bundle`), with `deprecateMissingFields`, `deactivateMissingFlows`, `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 `
|
|
151
|
+
- **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`, with `@DesignerAllowsRepair` on `apply-change` only, so a `repair` action sent to any other handler is refused; 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.
|
|
152
|
+
- **Uploaded as a file**: `plan-import` and `apply-import` take the bundle as a `multipart/form-data` upload (field `bundle`), with `deprecateMissingFields`, `deactivateMissingFlows`, `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 `FilesInterceptor`. **An app needs no setup in `main.ts`.** Several files sent under `bundle` (at most `ENTITY_BUNDLE_MAX_FILES`, 1000, else the upload answers 400; at most `ENTITY_BUNDLE_MAX_BYTES` together, else 413 `entity_builder.bundle.files.too.large`) are merged by `readBundleFiles()`: entities and flows in code / slug order, the order an export writes them, so a split folder has the same checksum as the single file of the same definitions; a definition found in two files stays twice and the plan blocks it as a duplicate. A problem in one of several files is named with its file (`flows/x.json.format`). `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`. Each entry carries its own key: `...bundle.file.value.invalid` (`{ field, constraints }`, the failed class-validator constraint names), or for a whole file `...file.not.json` / `...file.not.object` / `...file.empty` (`{ file }`). A missing file answers `entity_builder.bundle.file.required`.
|
|
135
153
|
|
|
136
|
-
Recommended flow: design in dev → export → commit
|
|
154
|
+
Recommended flow (the promote page shows it step by step): design in dev only → `npm run entity-bundle -- export entity-builder --split` (or *Save to folder* on the promote page) → commit one change per commit in a PR (reviewers read the diff) → merge or `git cherry-pick` the commit onto the staging branch → CI runs `entity-bundle plan entity-builder` then `apply entity-builder` against staging → smoke-test → the same commit runs `apply` against production, where `--confirm` is a manual approval → a scheduled `plan entity-builder --fail-on-changes` (exit 3 when the import would change anything; `extras`, which an import never removes, do not count) catches drift. Because an import is a desired state, not a sequence, a cherry-picked file only needs what it depends on (relation targets, called flows) to be on the target already or in the same pick; the plan blocks anything missing. `--deprecate-missing-fields` / `--deactivate-missing-flows` treat everything outside the input as missing, so use them only with the complete folder. `export <dir> --split` writes one file per entity and flow, and a full split export (no `--entities` / `--flows`) also removes the files of definitions no longer exported. `plan` and `apply` take any number of files and folders (every `*.json` under a folder except `entity-bundle-keys-*`). `scripts/entity-bundle.js` reads `FLUSYS_API_URL`, `FLUSYS_API_TOKEN` and optionally `FLUSYS_TENANT_ID` / `FLUSYS_TENANT_HEADER`. `--deprecate-missing-fields` and `--deactivate-missing-flows` set the import options. 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, 3 when `plan --fail-on-changes` finds changes.
|
|
137
155
|
|
|
138
156
|
## Messages and localization
|
|
139
157
|
|
|
@@ -141,4 +159,4 @@ Every user-facing message is key based (`config/message-keys.ts`, keys `entity_b
|
|
|
141
159
|
|
|
142
160
|
## Not in v1
|
|
143
161
|
|
|
144
|
-
Flows: scheduled/event triggers, a merge (join) node, a credential vault, a shared rate limit across instances.
|
|
162
|
+
Flows: scheduled/event triggers, a merge (join) node, a credential vault, a shared rate limit across instances. Fields: changing between unrelated types (e.g. `INTEGER` to `FILE`) - add a new field, copy the data, deprecate the old one.
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export declare const ENTITY_BUILDER_MODULE_OPTIONS = "ENTITY_BUILDER_MODULE_OPTIONS";
|
|
2
2
|
export declare const ENTITY_BUNDLE_MAX_BYTES: number;
|
|
3
|
+
export declare const ENTITY_BUNDLE_MAX_FILES = 1000;
|
|
3
4
|
export declare const BUNDLE_VARIABLE_NAME_PATTERN: RegExp;
|
|
4
5
|
export declare const ENTITY_CODE_PATTERN: RegExp;
|
|
5
6
|
export declare const ENTITY_CODE_MAX_LENGTH = 63;
|
package/config/message-keys.d.ts
CHANGED
|
@@ -29,7 +29,6 @@ export declare const FLOW_DEFINITION_MESSAGES: {
|
|
|
29
29
|
readonly CREATE_SUCCESS: "entity_builder.flow_definition.create.success";
|
|
30
30
|
readonly UPDATE_SUCCESS: "entity_builder.flow_definition.update.success";
|
|
31
31
|
readonly DELETE_SUCCESS: "entity_builder.flow_definition.delete.success";
|
|
32
|
-
readonly RESTORE_SUCCESS: "entity_builder.flow_definition.restore.success";
|
|
33
32
|
readonly GET_SUCCESS: "entity_builder.flow_definition.get.success";
|
|
34
33
|
readonly GET_BY_IDS_SUCCESS: "entity_builder.flow_definition.get.by.ids.success";
|
|
35
34
|
readonly GET_BY_FILTER_SUCCESS: "entity_builder.flow_definition.get.by.filter.success";
|
|
@@ -276,17 +275,21 @@ export declare const FLOW_MESSAGES: {
|
|
|
276
275
|
readonly INPUT_BODY_NOT_LIST: "entity_builder.flow.input.body.not.list";
|
|
277
276
|
readonly INPUT_SORT_INVALID: "entity_builder.flow.input.sort.invalid";
|
|
278
277
|
readonly INPUT_SELECT_INVALID: "entity_builder.flow.input.select.invalid";
|
|
278
|
+
readonly INPUT_PAGE_INVALID: "entity_builder.flow.input.page.invalid";
|
|
279
|
+
readonly INPUT_PAGE_SIZE_INVALID: "entity_builder.flow.input.page.size.invalid";
|
|
279
280
|
readonly INPUT_BODY_ITEM_NOT_OBJECT: "entity_builder.flow.input.body.item.not.object";
|
|
280
281
|
readonly CHECKS_FAILED: "entity_builder.flow.checks.failed";
|
|
281
282
|
readonly PERMISSION_DENIED: "entity_builder.flow.permission.denied";
|
|
282
283
|
readonly PUBLISH_SUCCESS: "entity_builder.flow.publish.success";
|
|
283
284
|
readonly NOTHING_TO_PUBLISH: "entity_builder.flow.nothing.to.publish";
|
|
284
285
|
readonly VERSIONS_SUCCESS: "entity_builder.flow.versions.success";
|
|
286
|
+
readonly CODE_PACKAGES_SUCCESS: "entity_builder.flow.code.packages.success";
|
|
285
287
|
readonly VERSION_SUCCESS: "entity_builder.flow.version.success";
|
|
286
288
|
readonly VERSION_NOT_FOUND: "entity_builder.flow.version.not.found";
|
|
287
289
|
readonly VERSION_RESTORED: "entity_builder.flow.version.restored";
|
|
288
290
|
readonly DRAFT_DISCARDED: "entity_builder.flow.draft.discarded";
|
|
289
291
|
readonly NO_DRAFT: "entity_builder.flow.no.draft";
|
|
292
|
+
readonly RESTORE_UNSUPPORTED: "entity_builder.flow.restore.unsupported";
|
|
290
293
|
};
|
|
291
294
|
export declare const FLOW_ERROR_MESSAGES: {
|
|
292
295
|
readonly NO_TRIGGER: "entity_builder.flow.error.no.trigger";
|
|
@@ -374,6 +377,7 @@ export declare const FLOW_VALIDATION_MESSAGES: {
|
|
|
374
377
|
readonly TRANSACTIONAL_HTTP: "entity_builder.flow.validation.transactional.http";
|
|
375
378
|
readonly WRITES_NOT_TRANSACTIONAL: "entity_builder.flow.validation.writes.not.transactional";
|
|
376
379
|
readonly NODE_COMMIT_CONTINUE: "entity_builder.flow.validation.node.commit.continue";
|
|
380
|
+
readonly NODE_CONTINUE_UNSUPPORTED: "entity_builder.flow.validation.node.continue.unsupported";
|
|
377
381
|
readonly COMMIT_NOT_TRANSACTIONAL: "entity_builder.flow.validation.commit.not.transactional";
|
|
378
382
|
readonly COMMIT_IN_LOOP: "entity_builder.flow.validation.commit.in.loop";
|
|
379
383
|
readonly COMMIT_IN_CALLED_FLOW: "entity_builder.flow.validation.commit.in.called.flow";
|
|
@@ -472,6 +476,7 @@ export declare const FLOW_VALIDATION_MESSAGES: {
|
|
|
472
476
|
readonly NODE_CODE_REQUIRED: "entity_builder.flow.validation.node.code.required";
|
|
473
477
|
readonly NODE_CODE_TOO_LONG: "entity_builder.flow.validation.node.code.too.long";
|
|
474
478
|
readonly NODE_CODE_TIMEOUT_RANGE: "entity_builder.flow.validation.node.code.timeout.range";
|
|
479
|
+
readonly NODE_CODE_PACKAGE_NOT_ALLOWED: "entity_builder.flow.validation.node.code.package.not.allowed";
|
|
475
480
|
};
|
|
476
481
|
export declare const FLOW_SHAPE_MESSAGES: {
|
|
477
482
|
readonly NOT_AN_EXPRESSION: "entity_builder.flow.shape.not.an.expression";
|
|
@@ -574,6 +579,11 @@ export declare const BUNDLE_MESSAGES: {
|
|
|
574
579
|
readonly RENAME_VIA_TEMPORARY: "entity_builder.bundle.rename.via.temporary";
|
|
575
580
|
readonly FILE_REQUIRED: "entity_builder.bundle.file.required";
|
|
576
581
|
readonly FILE_INVALID: "entity_builder.bundle.file.invalid";
|
|
582
|
+
readonly FILE_NOT_JSON: "entity_builder.bundle.file.not.json";
|
|
583
|
+
readonly FILE_NOT_OBJECT: "entity_builder.bundle.file.not.object";
|
|
584
|
+
readonly FILE_EMPTY: "entity_builder.bundle.file.empty";
|
|
585
|
+
readonly FILE_VALUE_INVALID: "entity_builder.bundle.file.value.invalid";
|
|
586
|
+
readonly FILES_TOO_LARGE: "entity_builder.bundle.files.too.large";
|
|
577
587
|
readonly CONFIRM_REQUIRED: "entity_builder.bundle.confirm.required";
|
|
578
588
|
readonly DUPLICATE_ENTITY: "entity_builder.bundle.duplicate.entity";
|
|
579
589
|
readonly DUPLICATE_FIELD: "entity_builder.bundle.duplicate.field";
|
|
@@ -581,7 +591,6 @@ export declare const BUNDLE_MESSAGES: {
|
|
|
581
591
|
readonly RELATION_TARGET_UNKNOWN: "entity_builder.bundle.relation.target.unknown";
|
|
582
592
|
readonly ENTITY_CODE_CHANGED: "entity_builder.bundle.entity.code.changed";
|
|
583
593
|
readonly VARIABLE_MISSING: "entity_builder.bundle.variable.missing";
|
|
584
|
-
readonly FLOW_SLUG_DELETED: "entity_builder.bundle.flow.slug.deleted";
|
|
585
594
|
readonly FLOW_UNPUBLISHED_SKIPPED: "entity_builder.bundle.flow.unpublished.skipped";
|
|
586
595
|
readonly FLOW_DRAFT_REPLACED: "entity_builder.bundle.flow.draft.replaced";
|
|
587
596
|
readonly FLOW_API_KEY_GENERATED: "entity_builder.bundle.flow.api.key.generated";
|
|
@@ -10,6 +10,6 @@ export declare class DefinitionBundleController {
|
|
|
10
10
|
constructor(bundles: DefinitionBundleService, config: EntityBuilderConfigService);
|
|
11
11
|
settings(): SingleResponseDto<IBundleSettings>;
|
|
12
12
|
export(dto: ExportBundleDto): Promise<SingleResponseDto<IBundleExport>>;
|
|
13
|
-
planImport(
|
|
14
|
-
applyImport(
|
|
13
|
+
planImport(files: IUploadedBundleFile[] | undefined, options: ImportBundleOptionsDto, user: ILoggedUserInfo): Promise<SingleResponseDto<IBundleImportPlan>>;
|
|
14
|
+
applyImport(files: IUploadedBundleFile[] | undefined, options: ImportBundleOptionsDto, user: ILoggedUserInfo): Promise<SingleResponseDto<IBundleImportResult>>;
|
|
15
15
|
}
|
|
@@ -22,7 +22,6 @@ declare const EntityDefinitionController_base: abstract new (service: EntityDefi
|
|
|
22
22
|
delete(deleteDto: import("@flusys/nestjs-shared").DeleteDto, user: ILoggedUserInfo | null): Promise<import("@flusys/nestjs-shared").MessageResponseDto>;
|
|
23
23
|
};
|
|
24
24
|
export declare class EntityDefinitionController extends EntityDefinitionController_base {
|
|
25
|
-
entityDefinitionService: EntityDefinitionService;
|
|
26
25
|
private readonly schemaSyncService;
|
|
27
26
|
private readonly evolution;
|
|
28
27
|
private readonly permissionCache;
|
|
@@ -17,7 +17,6 @@ declare const FieldDefinitionController_base: abstract new (service: FieldDefini
|
|
|
17
17
|
delete(deleteDto: import("@flusys/nestjs-shared").DeleteDto, user: import("@flusys/nestjs-shared").ILoggedUserInfo | null): Promise<import("@flusys/nestjs-shared").MessageResponseDto>;
|
|
18
18
|
};
|
|
19
19
|
export declare class FieldDefinitionController extends FieldDefinitionController_base {
|
|
20
|
-
fieldDefinitionService: FieldDefinitionService;
|
|
21
20
|
constructor(fieldDefinitionService: FieldDefinitionService);
|
|
22
21
|
}
|
|
23
22
|
export {};
|
|
@@ -21,11 +21,12 @@ declare const FlowDefinitionController_base: abstract new (service: FlowDefiniti
|
|
|
21
21
|
delete(deleteDto: import("@flusys/nestjs-shared").DeleteDto, user: ILoggedUserInfo | null): Promise<import("@flusys/nestjs-shared").MessageResponseDto>;
|
|
22
22
|
};
|
|
23
23
|
export declare class FlowDefinitionController extends FlowDefinitionController_base {
|
|
24
|
-
flowDefinitionService
|
|
24
|
+
private readonly flowDefinitionService;
|
|
25
25
|
private readonly runtime;
|
|
26
26
|
constructor(flowDefinitionService: FlowDefinitionService, runtime: FlowRuntimeService);
|
|
27
27
|
validate(dto: ValidateFlowDto): Promise<SingleResponseDto<IFlowValidationResult>>;
|
|
28
28
|
publish(dto: PublishFlowDto, user: ILoggedUserInfo): Promise<SingleResponseDto<IFlowDefinition>>;
|
|
29
|
+
codePackages(): SingleResponseDto<string[]>;
|
|
29
30
|
versions(dto: FlowIdDto): Promise<SingleResponseDto<IFlowVersion[]>>;
|
|
30
31
|
version(dto: FlowVersionRefDto): Promise<SingleResponseDto<IFlowVersion>>;
|
|
31
32
|
restoreVersion(dto: FlowVersionRefDto, user: ILoggedUserInfo): Promise<SingleResponseDto<IFlowDefinition>>;
|
|
@@ -1,10 +1,11 @@
|
|
|
1
|
+
import { type ILoggedUserInfo } from '@flusys/nestjs-shared/interfaces';
|
|
1
2
|
import { type FlowDefinition } from '../entities/flow-definition.entity.js';
|
|
2
3
|
import { type IFlowEndpoint } from '../flow-engine/flow.types.js';
|
|
3
4
|
import { FlowRuntimeService } from '../services/flow-runtime.service.js';
|
|
4
5
|
type FlowRequest = {
|
|
5
6
|
flowDefinition: FlowDefinition;
|
|
6
7
|
flowEndpoint: IFlowEndpoint;
|
|
7
|
-
user?:
|
|
8
|
+
user?: ILoggedUserInfo;
|
|
8
9
|
ip?: string;
|
|
9
10
|
headers: Record<string, unknown>;
|
|
10
11
|
query?: Record<string, unknown>;
|
|
@@ -13,10 +14,10 @@ type FlowRequest = {
|
|
|
13
14
|
export declare class FlowRuntimeController {
|
|
14
15
|
private readonly runtime;
|
|
15
16
|
constructor(runtime: FlowRuntimeService);
|
|
16
|
-
run(
|
|
17
|
+
run(body: unknown, req: FlowRequest, res: {
|
|
17
18
|
status(code: number): unknown;
|
|
18
19
|
}): Promise<unknown>;
|
|
19
|
-
runEndpoint(
|
|
20
|
+
runEndpoint(body: unknown, req: FlowRequest, res: {
|
|
20
21
|
status(code: number): unknown;
|
|
21
22
|
}): Promise<unknown>;
|
|
22
23
|
private answer;
|
package/fesm/21.js
CHANGED
|
@@ -2,7 +2,6 @@ export const __rspack_esm_id = 21;
|
|
|
2
2
|
export const __rspack_esm_ids = [21];
|
|
3
3
|
export const __webpack_modules__ = {
|
|
4
4
|
5732(__unused_rspack_module, __webpack_exports__, __webpack_require__) {
|
|
5
|
-
// ==================== ENTITY BUILDER MODULE MESSAGE KEYS ====================
|
|
6
5
|
// The CRUD `*_SUCCESS` keys are the ones createApiController emits for `entityName: 'entity_builder.<resource>'`.
|
|
7
6
|
const ENTITY_DEFINITION_MESSAGES = {
|
|
8
7
|
NOT_FOUND: 'entity_builder.entity_definition.not.found',
|
|
@@ -35,7 +34,6 @@ const FLOW_DEFINITION_MESSAGES = {
|
|
|
35
34
|
CREATE_SUCCESS: 'entity_builder.flow_definition.create.success',
|
|
36
35
|
UPDATE_SUCCESS: 'entity_builder.flow_definition.update.success',
|
|
37
36
|
DELETE_SUCCESS: 'entity_builder.flow_definition.delete.success',
|
|
38
|
-
RESTORE_SUCCESS: 'entity_builder.flow_definition.restore.success',
|
|
39
37
|
GET_SUCCESS: 'entity_builder.flow_definition.get.success',
|
|
40
38
|
GET_BY_IDS_SUCCESS: 'entity_builder.flow_definition.get.by.ids.success',
|
|
41
39
|
GET_BY_FILTER_SUCCESS: 'entity_builder.flow_definition.get.by.filter.success',
|
|
@@ -282,17 +280,21 @@ const FLOW_MESSAGES = {
|
|
|
282
280
|
INPUT_BODY_NOT_LIST: 'entity_builder.flow.input.body.not.list',
|
|
283
281
|
INPUT_SORT_INVALID: 'entity_builder.flow.input.sort.invalid',
|
|
284
282
|
INPUT_SELECT_INVALID: 'entity_builder.flow.input.select.invalid',
|
|
283
|
+
INPUT_PAGE_INVALID: 'entity_builder.flow.input.page.invalid',
|
|
284
|
+
INPUT_PAGE_SIZE_INVALID: 'entity_builder.flow.input.page.size.invalid',
|
|
285
285
|
INPUT_BODY_ITEM_NOT_OBJECT: 'entity_builder.flow.input.body.item.not.object',
|
|
286
286
|
CHECKS_FAILED: 'entity_builder.flow.checks.failed',
|
|
287
287
|
PERMISSION_DENIED: 'entity_builder.flow.permission.denied',
|
|
288
288
|
PUBLISH_SUCCESS: 'entity_builder.flow.publish.success',
|
|
289
289
|
NOTHING_TO_PUBLISH: 'entity_builder.flow.nothing.to.publish',
|
|
290
290
|
VERSIONS_SUCCESS: 'entity_builder.flow.versions.success',
|
|
291
|
+
CODE_PACKAGES_SUCCESS: 'entity_builder.flow.code.packages.success',
|
|
291
292
|
VERSION_SUCCESS: 'entity_builder.flow.version.success',
|
|
292
293
|
VERSION_NOT_FOUND: 'entity_builder.flow.version.not.found',
|
|
293
294
|
VERSION_RESTORED: 'entity_builder.flow.version.restored',
|
|
294
295
|
DRAFT_DISCARDED: 'entity_builder.flow.draft.discarded',
|
|
295
|
-
NO_DRAFT: 'entity_builder.flow.no.draft'
|
|
296
|
+
NO_DRAFT: 'entity_builder.flow.no.draft',
|
|
297
|
+
RESTORE_UNSUPPORTED: 'entity_builder.flow.restore.unsupported'
|
|
296
298
|
};
|
|
297
299
|
/** Why a run was stopped or a node failed (`trace[].error`, `body.messageKey`). */ const FLOW_ERROR_MESSAGES = {
|
|
298
300
|
NO_TRIGGER: 'entity_builder.flow.error.no.trigger',
|
|
@@ -380,6 +382,7 @@ const FLOW_MESSAGES = {
|
|
|
380
382
|
TRANSACTIONAL_HTTP: 'entity_builder.flow.validation.transactional.http',
|
|
381
383
|
WRITES_NOT_TRANSACTIONAL: 'entity_builder.flow.validation.writes.not.transactional',
|
|
382
384
|
NODE_COMMIT_CONTINUE: 'entity_builder.flow.validation.node.commit.continue',
|
|
385
|
+
NODE_CONTINUE_UNSUPPORTED: 'entity_builder.flow.validation.node.continue.unsupported',
|
|
383
386
|
COMMIT_NOT_TRANSACTIONAL: 'entity_builder.flow.validation.commit.not.transactional',
|
|
384
387
|
COMMIT_IN_LOOP: 'entity_builder.flow.validation.commit.in.loop',
|
|
385
388
|
COMMIT_IN_CALLED_FLOW: 'entity_builder.flow.validation.commit.in.called.flow',
|
|
@@ -477,7 +480,8 @@ const FLOW_MESSAGES = {
|
|
|
477
480
|
NODE_PERMISSION_OPTION_INVALID: 'entity_builder.flow.validation.node.permission.option.invalid',
|
|
478
481
|
NODE_CODE_REQUIRED: 'entity_builder.flow.validation.node.code.required',
|
|
479
482
|
NODE_CODE_TOO_LONG: 'entity_builder.flow.validation.node.code.too.long',
|
|
480
|
-
NODE_CODE_TIMEOUT_RANGE: 'entity_builder.flow.validation.node.code.timeout.range'
|
|
483
|
+
NODE_CODE_TIMEOUT_RANGE: 'entity_builder.flow.validation.node.code.timeout.range',
|
|
484
|
+
NODE_CODE_PACKAGE_NOT_ALLOWED: 'entity_builder.flow.validation.node.code.package.not.allowed'
|
|
481
485
|
};
|
|
482
486
|
/** Why an expression or rule inside a flow node is malformed (`node.expression.problem` variable). */ const FLOW_SHAPE_MESSAGES = {
|
|
483
487
|
NOT_AN_EXPRESSION: 'entity_builder.flow.shape.not.an.expression',
|
|
@@ -580,6 +584,11 @@ const FLOW_MESSAGES = {
|
|
|
580
584
|
RENAME_VIA_TEMPORARY: 'entity_builder.bundle.rename.via.temporary',
|
|
581
585
|
FILE_REQUIRED: 'entity_builder.bundle.file.required',
|
|
582
586
|
FILE_INVALID: 'entity_builder.bundle.file.invalid',
|
|
587
|
+
FILE_NOT_JSON: 'entity_builder.bundle.file.not.json',
|
|
588
|
+
FILE_NOT_OBJECT: 'entity_builder.bundle.file.not.object',
|
|
589
|
+
FILE_EMPTY: 'entity_builder.bundle.file.empty',
|
|
590
|
+
FILE_VALUE_INVALID: 'entity_builder.bundle.file.value.invalid',
|
|
591
|
+
FILES_TOO_LARGE: 'entity_builder.bundle.files.too.large',
|
|
583
592
|
CONFIRM_REQUIRED: 'entity_builder.bundle.confirm.required',
|
|
584
593
|
DUPLICATE_ENTITY: 'entity_builder.bundle.duplicate.entity',
|
|
585
594
|
DUPLICATE_FIELD: 'entity_builder.bundle.duplicate.field',
|
|
@@ -587,7 +596,6 @@ const FLOW_MESSAGES = {
|
|
|
587
596
|
RELATION_TARGET_UNKNOWN: 'entity_builder.bundle.relation.target.unknown',
|
|
588
597
|
ENTITY_CODE_CHANGED: 'entity_builder.bundle.entity.code.changed',
|
|
589
598
|
VARIABLE_MISSING: 'entity_builder.bundle.variable.missing',
|
|
590
|
-
FLOW_SLUG_DELETED: 'entity_builder.bundle.flow.slug.deleted',
|
|
591
599
|
FLOW_UNPUBLISHED_SKIPPED: 'entity_builder.bundle.flow.unpublished.skipped',
|
|
592
600
|
FLOW_DRAFT_REPLACED: 'entity_builder.bundle.flow.draft.replaced',
|
|
593
601
|
FLOW_API_KEY_GENERATED: 'entity_builder.bundle.flow.api.key.generated',
|