@flusys/nestjs-entity-builder 9.1.2 → 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.
Files changed (59) hide show
  1. package/README.md +49 -25
  2. package/config/entity-builder.constants.d.ts +1 -0
  3. package/config/message-keys.d.ts +37 -2
  4. package/controllers/definition-bundle.controller.d.ts +2 -2
  5. package/controllers/entity-definition.controller.d.ts +1 -1
  6. package/controllers/field-definition.controller.d.ts +1 -1
  7. package/controllers/flow-definition.controller.d.ts +3 -1
  8. package/controllers/flow-runtime.controller.d.ts +4 -3
  9. package/dtos/definition-bundle.dto.d.ts +1 -0
  10. package/dtos/entity-definition.dto.d.ts +1 -2
  11. package/fesm/21.js +39 -5
  12. package/fesm/{458.js → 28.js} +110 -63
  13. package/fesm/677.js +1543 -513
  14. package/fesm/794.js +1 -1
  15. package/fesm/857.js +3 -3
  16. package/fesm/870.js +974 -558
  17. package/fesm/897.js +187 -51
  18. package/fesm/946.js +72 -43
  19. package/fesm/config/index.js +10 -14
  20. package/fesm/docs/index.js +1 -1
  21. package/fesm/dtos/index.js +11 -0
  22. package/fesm/guards/index.js +16 -10
  23. package/fesm/index.js +44 -26
  24. package/fesm/modules/index.js +4 -3
  25. package/fesm/services/index.js +40 -59
  26. package/flow-engine/flow-code-runner.d.ts +16 -0
  27. package/flow-engine/flow-graph.validator.d.ts +1 -0
  28. package/flow-engine/flow-input.validator.d.ts +16 -1
  29. package/flow-engine/flow-run.types.d.ts +16 -0
  30. package/flow-engine/flow.types.d.ts +18 -1
  31. package/guards/designer-writable.interceptor.d.ts +1 -0
  32. package/interfaces/definition-bundle.interface.d.ts +4 -1
  33. package/interfaces/entity-builder-module.interface.d.ts +9 -0
  34. package/modules/entity-builder.module.d.ts +1 -0
  35. package/modules/index.d.ts +1 -0
  36. package/modules/tenant-context-id.strategy.d.ts +16 -0
  37. package/package.json +3 -4
  38. package/services/definition-bundle.diff.d.ts +6 -2
  39. package/services/definition-bundle.file.d.ts +2 -1
  40. package/services/definition-bundle.service.d.ts +10 -3
  41. package/services/entity-builder-config.service.d.ts +3 -0
  42. package/services/entity-builder-datasource.provider.d.ts +1 -0
  43. package/services/entity-flow-scaffold.d.ts +2 -1
  44. package/services/entity-permission.service.d.ts +1 -0
  45. package/services/field-value-validator.d.ts +7 -0
  46. package/services/flow-definition.service.d.ts +11 -3
  47. package/services/flow-executor.service.d.ts +3 -0
  48. package/services/flow-runtime.service.d.ts +7 -1
  49. package/services/generic-entity-service.factory.d.ts +5 -1
  50. package/services/generic-entity.service.d.ts +25 -2
  51. package/services/identifier-rules.d.ts +0 -1
  52. package/services/save-loaded.d.ts +2 -0
  53. package/services/schema-change-executor.service.d.ts +1 -0
  54. package/services/schema-columns.d.ts +7 -2
  55. package/services/schema-dialect-adapter.service.d.ts +2 -0
  56. package/services/schema-evolution.service.d.ts +8 -2
  57. package/services/schema-registry.service.d.ts +12 -0
  58. package/services/schema-sync.service.d.ts +3 -1
  59. package/flow-engine/flow-code-sandbox.d.ts +0 -5
package/README.md CHANGED
@@ -15,43 +15,46 @@ Works on PostgreSQL and MySQL (via `SchemaDialectAdapterService`). Import `Entit
15
15
 
16
16
  | Path | Purpose | Permission |
17
17
  | --- | --- | --- |
18
- | `entity-builder/entity-definitions/create-entity` | Create entity + table (+ initial fields) in one DDL transaction. `flowEndpoints` (any of the generic API controller's endpoints: `insert`, `insertMany`, `getById`, `getByIds`, `getAll`, `getByFilter`, `update`, `updateMany`, `bulkUpsert`, `delete`) also saves one ready-made flow for the entity after the commit - slug `<code-with-dashes>` (e.g. `customer-ticket`), named after the entity, every Request step with `permission` access asking for the entity's own permission (`entity_builder.entity.<code>.create` for insert / insertMany, `.read` for the reads, `.update` for update / updateMany, `.create` AND `.update` for bulkUpsert, `.delete` for delete - the actions created with the entity), since a flow's access is its only gate, each write endpoint transactional and each read endpoint without a transaction - with one Request step per endpoint on its own path, `POST api-flows/<code-with-dashes>/<endpoint-in-kebab-case>` (e.g. `customer-ticket/get-by-ids`, `ENTITY_FLOW_PATHS`), each with its own body; there is no Request step on the flow's own URL. Each endpoint's steps are ids `<endpoint>_<step>` (e.g. `getById_record`) laid out one below the other. Single endpoints are Request -> entity step -> respond with its result; `getAll` / `getByFilter` take an optional equality filter per scalar field (`getByFilter` answers the first match or 404); `getByIds` takes `ids` (`in` filter); `insertMany` / `updateMany` / `bulkUpsert` have a `list` body type - the request body is the list of records itself (`[{ ... }, { ... }]`), each item checked against the entity fields (insert: the insert inputs; update many: `id` required, the rest optional; bulk upsert: all optional) - are one write step on the list itself (`<endpoint>_records`, `target: many`, `items: input`, each item's fields read as `loop.item.<field>`, up to `FLOW_LIMITS.MAX_WRITE_ITEMS`), all or nothing in one transaction, answering the saved records in order (`context.<endpoint>_records.items`; `updateMany` updates each item by its own `id` and fails on one that does not exist; `bulkUpsert` is an update with `onNotFound: insert` - it updates an item with an `id` and creates one without, or whose record is gone). Every Request body declares each field by its type: a choice with its options, a multi-select as a list of those choices (`itemType: choice`, or `string` without options), a relation or file as an id (`uuid`), JSON as an object; write endpoints also copy the field's own `validationRules` a body can check (`min` / `max` on numbers, `minLength` / `maxLength` on text), so a bad value is refused per field before any step runs; read filters check only the type. `flowTexts` (`names` of each endpoint's Request step, `inputLabels` for `id` / `ids` / `search`, `noMatchMessage`) carries the text written into the flow in the creator's language - English for anything left out. The flow is saved after the commit: when it fails the entity stays and the failure comes back in the response's `warnings` (`entity_builder.schema.warning.endpoint.flow.failed`, with the cause as a nested message ref), next to a failed permission set-up (`...follow.up.failed`). It is saved unpublished (`version` 0), so its URLs answer 404 until someone publishes it. It is an ordinary flow the user can change or delete later. Needs `flow_definition.create` too; a slug already in use is a plan blocker | `entity_builder.entity_definition.create` |
18
+ | `entity-builder/entity-definitions/create-entity` | Create entity + table (+ initial fields) in one DDL transaction. `flowEndpoints` (any of the generic API controller's endpoints: `insert`, `insertMany`, `getById`, `getByIds`, `getAll`, `getByFilter`, `update`, `updateMany`, `bulkUpsert`, `delete`) also saves one ready-made flow for the entity after the commit - slug `<code-with-dashes>` (e.g. `customer-ticket`), named after the entity, every Request step with `permission` access asking for the entity's own permission (`entity_builder.entity.<code>.create` for insert / insertMany, `.read` for the reads, `.update` for update / updateMany, `.create` AND `.update` for bulkUpsert, `.delete` for delete - the actions created with the entity), since a flow's access is its only gate, each write endpoint transactional and each read endpoint without a transaction - with one Request step per endpoint on its own path, `POST api-flows/<code-with-dashes>/<endpoint-in-kebab-case>` (e.g. `customer-ticket/get-by-ids`, `ENTITY_FLOW_PATHS`), each with its own body; there is no Request step on the flow's own URL. Each endpoint's steps are ids `<endpoint>_<step>` (e.g. `getById_record`) laid out one below the other. Each Request step stands in for its controller endpoint (`config.contract`: `insert`, `insert_many`, `get_by_id`, `get_by_ids`, `get_all`, `get_by_filter`, `update`, `update_many`, `bulk_upsert`, `delete`) and every Respond step answers the controller envelope with its message key `<entity_code>.<action>.success` (e.g. `customer_ticket.get.all.success`, `.create.many.success`, `.delete.success`). Single endpoints are Request -> entity step -> `single` respond with its result (`getById` honours `select`); `getAll` takes `FilterAndPaginationDto` (`withDeleted` included) and `?q=` search through a `fromRequest` Find records step and answers `list` with paging `meta`; `getByFilter` takes an optional equality filter per scalar field and answers the first match or a 404 `message`; `getByIds` takes `ids` (`in` filter) and `select` (a `fromRequest` Find records step) and answers `list` with the controller's one-page `meta` (`page` 0, `pageSize` the count); `delete` takes `DeleteDto` (one id or a list), refuses `restore` / `permanent` with a Validate step (no flow step restores or purges) and answers `message` with `messageVariables.count`; `insertMany` / `updateMany` / `bulkUpsert` have a `list` body type - the request body is the list of records itself (`[{ ... }, { ... }]`), each item checked against the entity fields (insert: the insert inputs; update many: `id` required, the rest optional; bulk upsert: all optional) - are one write step on the list itself (`<endpoint>_records`, `target: many`, `items: input`, each item's fields read as `loop.item.<field>`, up to `FLOW_LIMITS.MAX_WRITE_ITEMS`), all or nothing in one transaction, answering `bulk` with the saved records in order (`context.<endpoint>_records.items`, `meta: { count, total, failed }`; `updateMany` updates each item by its own `id` and fails on one that does not exist; `bulkUpsert` is an update with `onNotFound: insert` - it updates an item with an `id` and creates one without, or whose record is gone). Every Request body declares each field by its type: a choice with its options, a multi-select as a list of those choices (`itemType: choice`, or `string` without options), a relation or file as an id (`uuid`), JSON as an object; write endpoints also copy the field's own `validationRules` a body can check (`min` / `max` on numbers, `minLength` / `maxLength` on text), so a bad value is refused per field before any step runs; read filters check only the type. `flowTexts` (`names` of each endpoint's Request step, `inputLabels.id` for get-by-filter / bulk-upsert, `noMatchMessage`, `deleteOnlyMessage`) carries the text written into the flow in the creator's language - English for anything left out. The flow is saved after the commit: when it fails the entity stays and the failure comes back in the response's `warnings` (`entity_builder.schema.warning.endpoint.flow.failed`, with the cause as a nested message ref), next to a failed permission set-up (`...follow.up.failed`). It is saved unpublished (`version` 0), so its URLs answer 404 until someone publishes it. It is an ordinary flow the user can change or delete later. Needs `flow_definition.create` too; a slug already in use is a plan blocker | `entity_builder.entity_definition.create` |
19
19
  | `entity-builder/entity-definitions/plan-create-entity` | Dry run of the above: the real `CREATE TABLE` SQL, nothing is created | `...entity_definition.create` |
20
20
  | `entity-builder/entity-definitions/plan-change` | Dry run of any change below: exact SQL + reverse SQL, data checks, blockers, warnings, affected flows. Plans count records (never show their values), so planning needs the permission that applies the change | `...entity_definition.update` (`...delete` for `drop_entity` / `purge_field`) |
21
21
  | `entity-builder/entity-definitions/apply-change` | Apply `add_field`, `update_field`, `deprecate_field`, `restore_field`, `update_entity`, `repair`. Answers the plan of what ran (`downSql` = the undo statements of what ran) plus `result` (the change's summary, e.g. repair's `fixed`) | `...entity_definition.update` |
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` | Every schema change with its SQL, failed attempts included | `...entity_definition.read` |
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` | `...flow_definition.read` |
36
- | `entity-builder/bundles/settings` | `{ readOnly, variables }`: whether the designer is locked here, and the names of the variables this environment defines | `...entity_definition.read` or `...flow_definition.read` |
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: file `bundle` + `deprecateMissingFields?`: every step the import would run, in order, with each previewable step's real plan; nothing changes | `...entity_definition.create/update` + `...flow_definition.create/update` |
39
- | `entity-builder/bundles/apply-import` | Multipart: file `bundle` + `deprecateMissingFields?`, `confirm?`, `expectedChecksum?`, `expectedPlanChecksum?` (required with `confirm`): runs the plan; works while the designer is read-only | same as `plan-import` |
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 examples), a unique constraint is refused while duplicates exist, and a required field on a table with records needs a default/backfill value. Unsupported conversions are refused with the reason. MySQL has no `USING` clause, so on MySQL single select <-> multi select and text -> boolean are refused too (add a new field, copy the data, deprecate the old one).
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.
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.
55
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`.
56
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`):
57
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).
@@ -61,10 +64,26 @@ Works on PostgreSQL and MySQL (via `SchemaDialectAdapterService`). Import `Entit
61
64
 
62
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`).
63
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.
64
- - Each entity gets the IAM actions `entity_builder.entity.<code>.<create|read|update|delete>` (only when IAM is installed). Flow steps do not check them (a flow's URL access is its gate): the entity's ready-made flow asks for them as its URLs' `permission` access, and any Request step or Check permission step can name them in its rule. DECIMAL fields are returned as numbers. `RELATION`/`FILE` are plain uuid columns (`char(36)` on MySQL, which has no uuid type) with no foreign-key constraint. Record events are `entity-builder.<code>.created` / `updated` / `deleted`, or `purged` for a delete on an entity with soft delete turned off - the same name whether the write ran inside a transactional flow or not.
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.
65
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.
66
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`).
67
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
+
68
87
  ## Rules
69
88
 
70
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.
@@ -81,7 +100,7 @@ Conditions and expressions are declarative JSON (`RuleGroup` / `RuleExpression`)
81
100
 
82
101
  ## Flows (virtual APIs)
83
102
 
84
- A flow is an endpoint (`POST /api-flows/<slug>`) whose behaviour is a graph of steps, so a backend can be built without writing backend code. Steps: **Request** (trigger), **Validate** (checks that must pass), **Check permission**, **Company & Branch check**, **Code** (sandboxed JavaScript), **Set variables**, **If**, **Switch**, **For each**, **Create / Update / Delete / Get / Find record**, **Save progress**, **Call flow**, **HTTP request**, **Publish event**, **Send notification** / **Send email** (only when the app runs those modules), **Respond**. Each step's result is `context.<stepId>`; the request body is `input.*`; also `vars.*`, `loop.item` / `loop.index` (inside a loop within a loop the outer loop is `loop.parent.item` / `loop.parent.index`, and so on up), `request.*`, `user.*`. `request` holds `ip`, `method`, `path`, `query` (text values, first of a repeated one, at most 50), `headers` (lower-case names, credential headers removed; read dashed names with `HEADER()`), and ready-made `userAgent`, `origin`, `referer`, `contentType`, `language` (first `accept-language` tag) and `receivedAt`. Text can use `{{ scope.path }}` placeholders (the `template` expression). Steps connect by output ports (`out`, `true`/`false`, `each`/`done`, switch cases, `error`); a step marked "carry on if it fails" stores `context.<id>.error` and follows its `error` port. Inside a transaction (transactional flows and every dry run) such a step runs in a savepoint, so its failure does not abort the flow's transaction.
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.
85
104
 
86
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).
87
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).
@@ -89,22 +108,26 @@ A flow is an endpoint (`POST /api-flows/<slug>`) whose behaviour is a graph of s
89
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.
90
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`).
91
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.
92
- - **Code**: `{ code, timeoutMs? }` runs `code` as the body of a synchronous function in a QuickJS WebAssembly sandbox (`quickjs-emscripten`) on a worker thread (a small pool, at most 4), so a busy code step never blocks the server's event loop; the host also kills a worker that overruns its deadline. It reads a deep-frozen JSON copy of `{ input, vars, context, loop, user: { id, email, name, companyId, branchId, permissions? } }` as the global `ctx` (no request headers; `permissions`, the caller's codes in the current branch, is fetched only when the code mentions permissions). `hasPermission(code)`, `hasAnyPermission(...codes)` and `hasAllPermissions(...codes)` are plain JavaScript inside the sandbox over `ctx.user.permissions` with the server's wildcards and `return`s a JSON value, which becomes `context.<id>`. Nothing of the host is reachable (no `require`, `process`, network, filesystem, timers or database); `console.log` is captured into the trace (`trace[].logs`, 50 lines of 500 characters, sensitive-looking keys masked). Every run gets a fresh runtime with a 32 MB memory limit, a 512 KB stack and an interrupt deadline of `timeoutMs` (default 1000, 10-5000) clamped to the time left in the flow; the output may be at most 1 MB. A throw, time-out or memory overflow is a node error (`flow.error.code.*`), so `onError: continue` and the `error` port work. Code nodes also run in dry runs. Saving a flow that contains a code node needs `entity_builder.flow_definition.code`.
93
- - **Create / Update / Delete record** write several records in one step through `target`: `one` (default, left out) writes one record; `many` writes one per item of `items` (up to `FLOW_LIMITS.MAX_WRITE_ITEMS` = 1000), and the step's per-record values (`fields`, `id`) read the item as `loop.item` / `loop.index` with a loop around the step as `loop.parent`; `filter` (update / delete) writes every record matching `filter` (same shape as Find), read as `loop.item` (e.g. `stock = loop.item.stock - 1`). A filter that resolves to nothing is refused (`flow.error.write.filter.empty`) instead of changing every record, and more matches than `limit` (1-1000, default 100) fail the step (`flow.error.too.many.matches`) instead of changing some. In `many` mode `id` defaults to the item's own id (the item itself when it is text, else `item.id`). `onNotFound`: `error` (default), `skip`, or for update `insert` (upsert: a missing record, or in `many` mode an item with an empty id, is created with the same field values - needs the entity's `create` permission too). Create takes `children` (up to 10): `{ as, entityCode, parentField, items, fields }` saves child records under every saved record, `parentField` filled with the parent's id; `items` is read in the parent's scope (`loop.item.lines` in `many` mode, `input.lines` otherwise) and child `fields` read the child item as `loop.item` and the parent's scope as `loop.parent`. Results: create one = the record plus one array per `as`; create many = `{ items, count }`; update one = the record (`null` when skipped); update many / filter = `{ items, count, updated, created, skipped }`; delete one = `{ id, deleted }`; delete many / filter = `{ ids, count, skipped }` (an id listed twice is deleted once). A create never takes an `id` field (`generic_record.system.column.readonly`), so it cannot overwrite an existing record; an upsert's created record ignores a mapped `id`. A step that writes several records (`many`, `filter`, or any `children`) is **all-or-nothing on its own**: inside a transactional run it uses the run's transaction, otherwise it opens a transaction for just that step and publishes its record events after that commit. Every record written counts against `FLOW_LIMITS.MAX_WRITES_PER_RUN` (5000, shared with called flows).
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).
94
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.
95
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).
96
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.
97
116
  - **Several URLs in one flow**: a flow can hold more than one **Request** step, and each one carries everything about its URL and its runs in its `config` (`ITriggerConfig`): `path`, body (`bodyType`, `inputSchema`), access (`authMode`, `permissions`, and `apiKey` for API-key access), `isTransactional`, `timeoutMs` and `rateLimitPerMinute` - each left out at its default. The flow itself has none of these: it only groups its URLs under one slug, switches them on and off together (`isActive`), keeps their run history (`retainExecutions`) and is versioned as a whole. The one without a `path` answers on `POST /api-flows/<slug>`; each other one sets `config.path` (1-63 lowercase letters, digits and dashes, starting with a letter) and answers on `POST /api-flows/<slug>/<path>` - e.g. a `customer-ticker` flow with `insert` (an object) and `insert-many` (a list) sharing the steps after them. Saving needs at least one Request step (`trigger.missing`), at most one without a path (`trigger.main.duplicate`) and distinct, valid paths (`trigger.path.duplicate` / `.invalid`); every Request step's fields are checked the same way (errors on a path's fields are named `<path>: <field>`). A flow whose every Request step has a path answers 404 on its own URL. `flowEndpoints()` / `findEndpoint()` / `startEndpoint()` (`flow.types.ts`) turn the Request steps into `IFlowEndpoint`s with every setting's default filled in; the run starts at `IFlowRunState.startAt` and takes that step's transaction and time limit. Save checks each step's settings (`trigger.transaction.invalid`, `trigger.timeout.range` 1-120 s, `trigger.rate.limit.range` 0-100,000), and every warning that depends on them - record steps open without sign-in (`keyless.entity`), transactions, HTTP inside a transaction - looks only at the steps that Request step's runs reach. The test run takes `endpoint` (a path; left out, the flow's own URL - an unknown one is refused with `flow.endpoint.not.found`), and a Call flow step takes `config.endpoint` to start the called flow at one of its paths, checked on save against that path's body (`node.flow.endpoint.unknown`, or `.required` when the called flow has no own URL) and at run time (`error.called.flow.endpoint.not.found`).
98
117
  - **Body type** (a Request step's `config.bodyType`, default `object`, left out when `object`): `object` - the body is one object and the input fields are its keys (`input.<name>`); `list` - the body itself is a list, like `insert-many` (`POST api-flows/<slug>` with `[{...}, {...}]`), the input fields describe each item, every item is checked the same way (errors name the index: `[0].sku`; a body that is not a list is refused with `flow.input.body.not.list`), and the flow reads the list as `input` (a For each over `input`, or `input.0.<name>`). With no fields declared a list body is passed through as-is. A Call flow step sends a list-bodied flow one expression, `inputList`, instead of named `input`s; saving checks the step matches the called flow's body type (`node.flow.input.list.required` / `.unexpected`). The test run accepts a list `input` too.
99
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.
100
- - **Who can call it**: set on each Request step - there is no flow-level access. `config.authMode`: `jwt` (any signed-in user; the default, stored as no setting), `permission` (`config.permissions`: an AND / OR rule of permission codes (`ILogicNode` of nestjs-shared: `{ type: 'action', actionId: <code> }` or `{ type: 'group', operator: 'AND' | 'OR', children }`, nested up to 5 group levels, at most 50 codes; evaluated by `evaluatePermissionLogic()`, `*` / `prefix.*` grants match); none: `entity_builder.flow.<slug>.execute`, provisioned as an IAM action on publish - a rule names existing actions, which are not re-registered. The guard checks it with `SharedPermissionCacheService.assertPermissionLogic`), `api_key` (the step's own key, sent as `x-api-key`: the designer makes it (`fk_<8 hex>_<32 hex>`) and sends it in `config.apiKey` once; every save replaces it with `{ prefix, hash }` (SHA-256, `sealApiKeys()`), so the key itself is never stored and cannot be shown again. The guard compares in constant time against the published step's hash; a wrong or missing key answers 401 `flow.api.key.invalid`, and one step's key never opens another. A step without a key cannot be saved (`trigger.api.key.required`, or `.invalid` for a malformed one); a new key replaces the old one once the flow is published; a step that leaves API-key access drops its key), `public`, or `internal` - **other flows only**: the step has no URL (it answers 404), only other flows' Call flow steps start there, and that run takes the calling flow's user. A flow whose every Request step is `internal` is a **function** (`isFunctionFlow()`). A URL reached without signing in (`public`, `api_key`) runs with no `user`: permission checks and the caller's Company & Branch check deny, while its record steps run for anyone who can call it (validator warning `keyless.entity`, naming the URLs and the entities they read or change). Unknown, inactive and never-published flows all answer 404. Each Request step's per-caller-IP rate limit (`config.rateLimitPerMinute`, default 60, 0 = unlimited; counted per step) runs **before** credentials are checked (in memory per server instance, fixed one-minute windows, at most 10,000 tracked callers - the oldest window is dropped beyond that).
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.
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.*`).
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.
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.
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).
101
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`).
102
125
  - **Checks cannot be carried past**: a failed Validate or Check permission step (like Save progress) always ends the run, whatever its `onError` says.
103
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.
104
- - **Transactions** (a Request step's `config.isTransactional`, the designer's "All writes together"): the entity writes of a run that starts at a transactional Request step commit or roll back together; the flow's other URLs open no transaction unless they set it too; entity events and **Publish event** steps are published only after the commit, in order, and never for a run that rolled back - nor for the rows of a step whose savepoint (`onError: continue`) rolled back. Metadata a transactional run needs (entity and field definitions, called flows) is read on the run's own transaction, so a run never waits on a second pooled connection. HTTP calls cannot be rolled back, so a transactional Request step whose runs reach an HTTP step is flagged in the warnings. In a run without a transaction each step commits on its own, except that a multi-record write step (many, or with child records) is always all-or-nothing (see above); make the Request step transactional when several steps must succeed or fail together, and add **Save progress** steps where the work so far must be kept even if a later step fails.
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.
105
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.
106
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).
107
- - **Safety limits**: at most 100 nodes, 5000 steps per run (room for a full 1000-item loop with a few steps per item), 1000 items per loop, 10 HTTP calls, 100 notifications and emails, 120 s total; loops cannot nest deeper than 3; a flow cannot loop back on itself (use For each). Steps are data walked by a fixed engine; the only author-written code that runs is a code node's, inside the QuickJS sandbox described above.
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.
108
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.
109
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`.
110
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.
@@ -114,20 +137,21 @@ A flow is an endpoint (`POST /api-flows/<slug>`) whose behaviour is a graph of s
114
137
 
115
138
  ## Promoting between environments
116
139
 
117
- Entities and flows are data, so TypeORM migrations never see them. They move from dev to staging to production as a **bundle**: a JSON file kept in git, applied to each environment through the same services the designer uses (`DefinitionBundleService`).
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.
118
141
 
119
- - **Export** (`bundles/export`, or `npm run entity-bundle -- export <file>` in `FLUSYS_NEST`) writes every entity that has a table and every **published** flow (a `DRAFT` entity and an unpublished flow are left out with a warning). Entities are keyed by `code` and fields by their `id` and `code`; a relation names its target by **entity code**, never by id. Entities, fields and flows are listed in code-point order, object keys are sorted at every level (PostgreSQL and MySQL return JSON keys in different orders), and there are no timestamps. The same definitions always give the same file and checksum, and a PR diff shows exactly what changes. Each Request step's API key is left out, and every occurrence of this environment's variable values in a flow becomes `${var:NAME}`.
120
- - **Plan** (`bundles/plan-import`) compares the bundle with this environment. Entities and fields are matched by id first, then by code among those no id claimed (`matchDefinitions`). An import keeps the ids definitions were designed with when they are free, so a field renamed in dev arrives as a **rename** (`update_field` with `code`, which rewrites the flows that use it), not as a new field. Steps run in this order: entities switched back on, new entities (their relation fields wait until every new entity exists), restore / rename / update / add / deprecate fields of existing entities (renames ordered so none takes a code another field still has, see **Renames**), the new entities' relation fields, entities switched off, then flows with called flows before their callers. Each step that can be previewed on its own carries the real `plan-change` / `plan-create-entity` result: SQL, data checks, blockers. Only a step that runs on something an earlier step creates, restores or frees (a new entity, a relation to one, a change to a restored field, a rename or new field taking a code a rename frees) goes without a preview, and it says so. A flow is compared with its **published** version (API keys ignored). It is validated here when nothing it depends on changes in the same import, and it is always checked against the importer's own permissions: code steps need `flow_definition.code`, exactly as for a save. `mayLoseData` is `destructive` (a previewed step loses data) or a structural change that could not be previewed (type, required, unique, soft delete, audit). `checksum` is the bundle's; `planChecksum` covers the bundle, the options and every step (kind, subject, changes, whether its preview loses data), not the SQL or row counts. A `DRAFT` entity (no table) in the bundle or in this environment blocks the import.
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.
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.
121
144
  - **Apply** (`bundles/apply-import`) refuses a bundle whose checksum is not `expectedChecksum` (409 `entity_builder.bundle.checksum.mismatch`: not the bundle that was reviewed). It then re-plans and refuses a plan that is not `expectedPlanChecksum` (409 `entity_builder.bundle.plan.changed`: the environment changed since the review), when anything is blocked (409, every reason in `errors` as `{ field: <subject>, messageKey, messageVariables }`, since `errors` is what the global exception filter passes on), or when `mayLoseData` and no `confirm: true` (400, the steps in `errors`) or no `expectedPlanChecksum` (400 `entity_builder.bundle.plan.checksum.required`: a confirmation covers the reviewed steps only, never ones the environment grew since). `confirm` is passed on to every step. Entity steps go through `SchemaSyncService.createEntity` / `SchemaEvolutionService.apply`, so each is one locked, logged transaction. Flow steps are saved through `FlowDefinitionService` (validated, permission-checked) and **published** as a new version with the note `Imported from bundle <checksum>`. The import is not one transaction (MySQL commits DDL at once): the first step that fails stops it (`completed: false`, its `error` and `blockers`), the earlier ones stay, and the later ones are `skipped`. Importing the same bundle again picks up where it stopped, because every step compares before it changes.
122
- - **Never removes anything.** Entities, fields and flows this environment has and the bundle does not are listed as `extras`. With `deprecateMissingFields` the missing fields are deprecated, which keeps their columns and data. Dropping stays a deliberate `apply-destructive-change`. An entity whose code changed (same id, different code) blocks the import, because an entity code cannot be renamed.
145
+ - **Never removes anything.** Entities, fields and flows this environment has and the bundle does not are listed as `extras`. With `deprecateMissingFields` the missing fields are deprecated, which keeps their columns and data. With `deactivateMissingFlows` every missing flow that is published and active gets a `deactivate_flow` step instead: its live version is saved with `isActive: false` and published (callers get 404; an unpublished draft is replaced, never published along with it, with `entity_builder.bundle.flow.draft.replaced`). A step for a flow that an active flow of the bundle still calls is blocked (`entity_builder.bundle.flow.deactivate.called`). This is how a flow deleted where it is designed stops answering in a read-only environment, where `delete` is refused. Dropping stays a deliberate `apply-destructive-change`. An entity whose code changed (same id, different code) blocks the import, because an entity code cannot be renamed.
146
+ - **One import at a time**: `apply-import` takes a session lock (`eb_bundle_import`: `pg_try_advisory_lock` on PostgreSQL, `GET_LOCK(name, 0)` on MySQL, through `SchemaDialectAdapterService.tryAcquireSessionLock`). It holds the lock on a connection of its own for the plan and every step, and releases it before that connection goes back to the pool. A second import meanwhile answers 409 `entity_builder.bundle.import.in.progress` at once. Planning takes no lock.
123
147
  - **API keys**: a Request step with API-key access keeps the key this environment already stores for that step (matched by node id). Otherwise a new key is generated, returned **once** in `apiKeys`, and only its hash is stored. A key is answered as soon as its flow is saved, so it is not lost when publishing fails afterwards (the next import keeps it).
124
148
  - **Ids**: a design-time id is reused only when no row has it, soft-deleted rows included.
125
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.
126
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.
127
- - **Read-only designer** (the `ENTITY_BUILDER_READ_ONLY=true` env variable, read by the package itself through `isDesignerReadOnly()`, for production; `config.designer.readOnly` wins when set): `create-entity`, `apply-change`, `apply-destructive-change` and flow `insert` / `update` / `delete` / `publish` / `restore-version` / `discard-draft` answer **423** `entity_builder.designer.read.only` (`DesignerWritableInterceptor` + `@DesignerWrites`; an interceptor, so it runs after every guard: a caller not signed in or without the permission still gets 401 / 403). Imports, every read, and a `repair` through `apply-change` (it fixes drift without changing a definition) still work, so production changes only from git.
128
- - **Uploaded as a file**: `plan-import` and `apply-import` take the bundle as a `multipart/form-data` upload (field `bundle`), with `deprecateMissingFields`, `confirm`, `expectedChecksum` and `expectedPlanChecksum` as form fields (`true` / `false` as text). Express's JSON parser never reads a multipart body, so the app's JSON body limit (100kb by default) never applies. The package enforces its own limit, `ENTITY_BUNDLE_MAX_BYTES` (20 MB, `@flusys/nestjs-entity-builder/config`, 413 above it), through `FileInterceptor`. **An app needs no setup in `main.ts`.** `readBundleFile()` parses the file (a UTF-8 BOM is allowed) and validates it as strictly as a request body: unknown keys are refused, and every problem comes back in `errors` named by its path (`entities.0.fields.2.code`), under `entity_builder.bundle.file.invalid`. A missing file answers `entity_builder.bundle.file.required`.
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`.
129
153
 
130
- Recommended flow: design in dev → export → commit the bundle in a PR (reviewers read the diff) → CI runs `entity-bundle plan` then `entity-bundle apply` against staging → smoke-test → the same commit runs `apply` against production, where `--confirm` is a manual approval. `scripts/entity-bundle.js` reads `FLUSYS_API_URL`, `FLUSYS_API_TOKEN` and optionally `FLUSYS_TENANT_ID` / `FLUSYS_TENANT_HEADER`. It writes new API keys to a `0600` file (`--keys-out`, git-ignored by default) and never prints them. Exit codes: 1 when blocked, unreachable or failed, 2 when the plan may lose data and `--confirm` was not given.
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.
131
155
 
132
156
  ## Messages and localization
133
157
 
@@ -135,4 +159,4 @@ Every user-facing message is key based (`config/message-keys.ts`, keys `entity_b
135
159
 
136
160
  ## Not in v1
137
161
 
138
- Flows: scheduled/event triggers, a merge (join) node, a credential vault, a shared rate limit across instances. Bulk endpoints (`insert-many`, `update-many`, `bulk-upsert`, `get-by-ids`), a send-email flow node, changing a field between unrelated types (e.g. `INTEGER` to `FILE`): add a new field, copy the data, deprecate the old one.
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;
@@ -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";
@@ -274,17 +273,23 @@ export declare const FLOW_MESSAGES: {
274
273
  readonly TEST_SUCCESS: "entity_builder.flow.test.success";
275
274
  readonly INPUT_INVALID: "entity_builder.flow.input.invalid";
276
275
  readonly INPUT_BODY_NOT_LIST: "entity_builder.flow.input.body.not.list";
276
+ readonly INPUT_SORT_INVALID: "entity_builder.flow.input.sort.invalid";
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";
277
280
  readonly INPUT_BODY_ITEM_NOT_OBJECT: "entity_builder.flow.input.body.item.not.object";
278
281
  readonly CHECKS_FAILED: "entity_builder.flow.checks.failed";
279
282
  readonly PERMISSION_DENIED: "entity_builder.flow.permission.denied";
280
283
  readonly PUBLISH_SUCCESS: "entity_builder.flow.publish.success";
281
284
  readonly NOTHING_TO_PUBLISH: "entity_builder.flow.nothing.to.publish";
282
285
  readonly VERSIONS_SUCCESS: "entity_builder.flow.versions.success";
286
+ readonly CODE_PACKAGES_SUCCESS: "entity_builder.flow.code.packages.success";
283
287
  readonly VERSION_SUCCESS: "entity_builder.flow.version.success";
284
288
  readonly VERSION_NOT_FOUND: "entity_builder.flow.version.not.found";
285
289
  readonly VERSION_RESTORED: "entity_builder.flow.version.restored";
286
290
  readonly DRAFT_DISCARDED: "entity_builder.flow.draft.discarded";
287
291
  readonly NO_DRAFT: "entity_builder.flow.no.draft";
292
+ readonly RESTORE_UNSUPPORTED: "entity_builder.flow.restore.unsupported";
288
293
  };
289
294
  export declare const FLOW_ERROR_MESSAGES: {
290
295
  readonly NO_TRIGGER: "entity_builder.flow.error.no.trigger";
@@ -295,6 +300,8 @@ export declare const FLOW_ERROR_MESSAGES: {
295
300
  readonly UNKNOWN_NODE_TYPE: "entity_builder.flow.error.unknown.node.type";
296
301
  readonly RECORD_ID_MISSING: "entity_builder.flow.error.record.id.missing";
297
302
  readonly LIST_NOT_A_LIST: "entity_builder.flow.error.list.not.a.list";
303
+ readonly RESPOND_DATA_NOT_LIST: "entity_builder.flow.error.respond.data.not.list";
304
+ readonly RESPOND_META_INVALID: "entity_builder.flow.error.respond.meta.invalid";
298
305
  readonly TOO_MANY_WRITES: "entity_builder.flow.error.too.many.writes";
299
306
  readonly WRITE_FILTER_EMPTY: "entity_builder.flow.error.write.filter.empty";
300
307
  readonly TOO_MANY_MATCHES: "entity_builder.flow.error.too.many.matches";
@@ -357,6 +364,7 @@ export declare const FLOW_VALIDATION_MESSAGES: {
357
364
  readonly TRIGGER_MISSING: "entity_builder.flow.validation.trigger.missing";
358
365
  readonly TRIGGER_MAIN_DUPLICATE: "entity_builder.flow.validation.trigger.main.duplicate";
359
366
  readonly TRIGGER_PATH_INVALID: "entity_builder.flow.validation.trigger.path.invalid";
367
+ readonly TRIGGER_CONTRACT_INVALID: "entity_builder.flow.validation.trigger.contract.invalid";
360
368
  readonly TRIGGER_PATH_DUPLICATE: "entity_builder.flow.validation.trigger.path.duplicate";
361
369
  readonly EDGE_ID_DUPLICATE: "entity_builder.flow.validation.edge.id.duplicate";
362
370
  readonly EDGE_DANGLING: "entity_builder.flow.validation.edge.dangling";
@@ -369,6 +377,7 @@ export declare const FLOW_VALIDATION_MESSAGES: {
369
377
  readonly TRANSACTIONAL_HTTP: "entity_builder.flow.validation.transactional.http";
370
378
  readonly WRITES_NOT_TRANSACTIONAL: "entity_builder.flow.validation.writes.not.transactional";
371
379
  readonly NODE_COMMIT_CONTINUE: "entity_builder.flow.validation.node.commit.continue";
380
+ readonly NODE_CONTINUE_UNSUPPORTED: "entity_builder.flow.validation.node.continue.unsupported";
372
381
  readonly COMMIT_NOT_TRANSACTIONAL: "entity_builder.flow.validation.commit.not.transactional";
373
382
  readonly COMMIT_IN_LOOP: "entity_builder.flow.validation.commit.in.loop";
374
383
  readonly COMMIT_IN_CALLED_FLOW: "entity_builder.flow.validation.commit.in.called.flow";
@@ -386,6 +395,7 @@ export declare const FLOW_VALIDATION_MESSAGES: {
386
395
  readonly INPUT_TOO_MANY: "entity_builder.flow.validation.input.too.many";
387
396
  readonly INPUT_NAME_INVALID: "entity_builder.flow.validation.input.name.invalid";
388
397
  readonly INPUT_NAME_DUPLICATE: "entity_builder.flow.validation.input.name.duplicate";
398
+ readonly INPUT_NAME_RESERVED: "entity_builder.flow.validation.input.name.reserved";
389
399
  readonly INPUT_TYPE_UNKNOWN: "entity_builder.flow.validation.input.type.unknown";
390
400
  readonly INPUT_CHOICE_NO_OPTIONS: "entity_builder.flow.validation.input.choice.no.options";
391
401
  readonly INPUT_ITEM_TYPE_UNKNOWN: "entity_builder.flow.validation.input.item.type.unknown";
@@ -404,6 +414,9 @@ export declare const FLOW_VALIDATION_MESSAGES: {
404
414
  readonly NODE_CASE_DUPLICATE: "entity_builder.flow.validation.node.case.duplicate";
405
415
  readonly NODE_MAX_ITEMS_RANGE: "entity_builder.flow.validation.node.max.items.range";
406
416
  readonly NODE_LIMIT_RANGE: "entity_builder.flow.validation.node.limit.range";
417
+ readonly NODE_QUERY_FROM_REQUEST_INVALID: "entity_builder.flow.validation.node.query.from.request.invalid";
418
+ readonly NODE_QUERY_NOT_GET_ALL: "entity_builder.flow.validation.node.query.not.get.all";
419
+ readonly NODE_GET_NOT_GET_BY_ID: "entity_builder.flow.validation.node.get.not.get.by.id";
407
420
  readonly NODE_SORT_INVALID: "entity_builder.flow.validation.node.sort.invalid";
408
421
  readonly NODE_WRITE_TARGET_INVALID: "entity_builder.flow.validation.node.write.target.invalid";
409
422
  readonly NODE_NOT_FOUND_INVALID: "entity_builder.flow.validation.node.not.found.invalid";
@@ -439,6 +452,9 @@ export declare const FLOW_VALIDATION_MESSAGES: {
439
452
  readonly MESSAGE_AFTER_COMMIT: "entity_builder.flow.validation.message.after.commit";
440
453
  readonly PUBLIC_MESSAGE: "entity_builder.flow.validation.public.message";
441
454
  readonly NODE_STATUS_2XX_5XX: "entity_builder.flow.validation.node.status.2xx.5xx";
455
+ readonly NODE_RESPOND_FORMAT_INVALID: "entity_builder.flow.validation.node.respond.format.invalid";
456
+ readonly NODE_RESPOND_MESSAGE_REQUIRED: "entity_builder.flow.validation.node.respond.message.required";
457
+ readonly NODE_RESPOND_LIST_VALUE_REQUIRED: "entity_builder.flow.validation.node.respond.list.value.required";
442
458
  readonly REF_BAD_SCOPE: "entity_builder.flow.validation.ref.bad.scope";
443
459
  readonly REF_NODE_MISSING: "entity_builder.flow.validation.ref.node.missing";
444
460
  readonly REF_SELF: "entity_builder.flow.validation.ref.self";
@@ -460,6 +476,7 @@ export declare const FLOW_VALIDATION_MESSAGES: {
460
476
  readonly NODE_CODE_REQUIRED: "entity_builder.flow.validation.node.code.required";
461
477
  readonly NODE_CODE_TOO_LONG: "entity_builder.flow.validation.node.code.too.long";
462
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";
463
480
  };
464
481
  export declare const FLOW_SHAPE_MESSAGES: {
465
482
  readonly NOT_AN_EXPRESSION: "entity_builder.flow.shape.not.an.expression";
@@ -506,6 +523,12 @@ export declare const FLOW_SUBJECT_MESSAGES: {
506
523
  readonly THE_URL: "entity_builder.flow.subject.the.url";
507
524
  readonly THE_INPUT_LIST: "entity_builder.flow.subject.the.input.list";
508
525
  readonly THE_RESPONSE: "entity_builder.flow.subject.the.response";
526
+ readonly THE_DATA: "entity_builder.flow.subject.the.data";
527
+ readonly THE_TOTAL: "entity_builder.flow.subject.the.total";
528
+ readonly THE_PAGE: "entity_builder.flow.subject.the.page";
529
+ readonly THE_PAGE_SIZE: "entity_builder.flow.subject.the.page.size";
530
+ readonly MESSAGE_VARIABLES: "entity_builder.flow.subject.message.variables";
531
+ readonly MESSAGE_VARIABLE: "entity_builder.flow.subject.message.variable";
509
532
  readonly THE_RECIPIENTS: "entity_builder.flow.subject.the.recipients";
510
533
  readonly THE_TITLE: "entity_builder.flow.subject.the.title";
511
534
  readonly THE_MESSAGE: "entity_builder.flow.subject.the.message";
@@ -556,6 +579,11 @@ export declare const BUNDLE_MESSAGES: {
556
579
  readonly RENAME_VIA_TEMPORARY: "entity_builder.bundle.rename.via.temporary";
557
580
  readonly FILE_REQUIRED: "entity_builder.bundle.file.required";
558
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";
559
587
  readonly CONFIRM_REQUIRED: "entity_builder.bundle.confirm.required";
560
588
  readonly DUPLICATE_ENTITY: "entity_builder.bundle.duplicate.entity";
561
589
  readonly DUPLICATE_FIELD: "entity_builder.bundle.duplicate.field";
@@ -563,10 +591,17 @@ export declare const BUNDLE_MESSAGES: {
563
591
  readonly RELATION_TARGET_UNKNOWN: "entity_builder.bundle.relation.target.unknown";
564
592
  readonly ENTITY_CODE_CHANGED: "entity_builder.bundle.entity.code.changed";
565
593
  readonly VARIABLE_MISSING: "entity_builder.bundle.variable.missing";
566
- readonly FLOW_SLUG_DELETED: "entity_builder.bundle.flow.slug.deleted";
567
594
  readonly FLOW_UNPUBLISHED_SKIPPED: "entity_builder.bundle.flow.unpublished.skipped";
568
595
  readonly FLOW_DRAFT_REPLACED: "entity_builder.bundle.flow.draft.replaced";
569
596
  readonly FLOW_API_KEY_GENERATED: "entity_builder.bundle.flow.api.key.generated";
597
+ readonly FLOW_FIXED_IDS: "entity_builder.bundle.flow.fixed.ids";
598
+ readonly FLOW_DEACTIVATE_CALLED: "entity_builder.bundle.flow.deactivate.called";
599
+ readonly FLOW_SECRET_WRITTEN: "entity_builder.bundle.flow.secret.written";
600
+ readonly FLOW_PERMISSION_UNKNOWN: "entity_builder.bundle.flow.permission.unknown";
601
+ readonly PERMISSIONS_TO_GRANT: "entity_builder.bundle.permissions.to.grant";
602
+ readonly EXPORT_RELATION_LEFT_OUT: "entity_builder.bundle.export.relation.left.out";
603
+ readonly EXPORT_CALLED_FLOW_LEFT_OUT: "entity_builder.bundle.export.called.flow.left.out";
604
+ readonly IMPORT_IN_PROGRESS: "entity_builder.bundle.import.in.progress";
570
605
  readonly STEP_AFTER_EARLIER: "entity_builder.bundle.step.after.earlier";
571
606
  readonly FLOWS_CHECKED_ON_APPLY: "entity_builder.bundle.flows.checked.on.apply";
572
607
  readonly DESIGNER_READ_ONLY: "entity_builder.designer.read.only";
@@ -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(file: IUploadedBundleFile | undefined, options: ImportBundleOptionsDto, user: ILoggedUserInfo): Promise<SingleResponseDto<IBundleImportPlan>>;
14
- applyImport(file: IUploadedBundleFile | undefined, options: ImportBundleOptionsDto, user: ILoggedUserInfo): Promise<SingleResponseDto<IBundleImportResult>>;
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
  }
@@ -9,6 +9,7 @@ declare const EntityDefinitionController_base: abstract new (service: EntityDefi
9
9
  readonly enabledEndpoints: import("@flusys/nestjs-shared").ApiEndpoint[] | "all";
10
10
  service: EntityDefinitionService;
11
11
  isEnabled(endpoint: import("@flusys/nestjs-shared").ApiEndpoint): boolean;
12
+ assertEnabled(endpoint: import("@flusys/nestjs-shared").ApiEndpoint): void;
12
13
  insert(addDto: CreateDynamicEntityDto, user: ILoggedUserInfo | null): Promise<SingleResponseDto<EntityDefinitionResponseDto>>;
13
14
  insertMany(addDto: CreateDynamicEntityDto[], user: ILoggedUserInfo | null): Promise<import("@flusys/nestjs-shared").BulkResponseDto<EntityDefinitionResponseDto>>;
14
15
  getById(id: string, body: import("@flusys/nestjs-shared").GetByIdBodyDto, user: ILoggedUserInfo | null): Promise<SingleResponseDto<EntityDefinitionResponseDto>>;
@@ -21,7 +22,6 @@ declare const EntityDefinitionController_base: abstract new (service: EntityDefi
21
22
  delete(deleteDto: import("@flusys/nestjs-shared").DeleteDto, user: ILoggedUserInfo | null): Promise<import("@flusys/nestjs-shared").MessageResponseDto>;
22
23
  };
23
24
  export declare class EntityDefinitionController extends EntityDefinitionController_base {
24
- entityDefinitionService: EntityDefinitionService;
25
25
  private readonly schemaSyncService;
26
26
  private readonly evolution;
27
27
  private readonly permissionCache;
@@ -4,6 +4,7 @@ declare const FieldDefinitionController_base: abstract new (service: FieldDefini
4
4
  readonly enabledEndpoints: import("@flusys/nestjs-shared").ApiEndpoint[] | "all";
5
5
  service: FieldDefinitionService;
6
6
  isEnabled(endpoint: import("@flusys/nestjs-shared").ApiEndpoint): boolean;
7
+ assertEnabled(endpoint: import("@flusys/nestjs-shared").ApiEndpoint): void;
7
8
  insert(addDto: CreateFieldDefinitionDto, user: import("@flusys/nestjs-shared").ILoggedUserInfo | null): Promise<import("@flusys/nestjs-shared").SingleResponseDto<FieldDefinitionResponseDto>>;
8
9
  insertMany(addDto: CreateFieldDefinitionDto[], user: import("@flusys/nestjs-shared").ILoggedUserInfo | null): Promise<import("@flusys/nestjs-shared").BulkResponseDto<FieldDefinitionResponseDto>>;
9
10
  getById(id: string, body: import("@flusys/nestjs-shared").GetByIdBodyDto, user: import("@flusys/nestjs-shared").ILoggedUserInfo | null): Promise<import("@flusys/nestjs-shared").SingleResponseDto<FieldDefinitionResponseDto>>;
@@ -16,7 +17,6 @@ declare const FieldDefinitionController_base: abstract new (service: FieldDefini
16
17
  delete(deleteDto: import("@flusys/nestjs-shared").DeleteDto, user: import("@flusys/nestjs-shared").ILoggedUserInfo | null): Promise<import("@flusys/nestjs-shared").MessageResponseDto>;
17
18
  };
18
19
  export declare class FieldDefinitionController extends FieldDefinitionController_base {
19
- fieldDefinitionService: FieldDefinitionService;
20
20
  constructor(fieldDefinitionService: FieldDefinitionService);
21
21
  }
22
22
  export {};
@@ -8,6 +8,7 @@ declare const FlowDefinitionController_base: abstract new (service: FlowDefiniti
8
8
  readonly enabledEndpoints: import("@flusys/nestjs-shared").ApiEndpoint[] | "all";
9
9
  service: FlowDefinitionService;
10
10
  isEnabled(endpoint: import("@flusys/nestjs-shared").ApiEndpoint): boolean;
11
+ assertEnabled(endpoint: import("@flusys/nestjs-shared").ApiEndpoint): void;
11
12
  insert(addDto: CreateFlowDefinitionDto, user: ILoggedUserInfo | null): Promise<SingleResponseDto<FlowDefinitionResponseDto>>;
12
13
  insertMany(addDto: CreateFlowDefinitionDto[], user: ILoggedUserInfo | null): Promise<import("@flusys/nestjs-shared").BulkResponseDto<FlowDefinitionResponseDto>>;
13
14
  getById(id: string, body: import("@flusys/nestjs-shared").GetByIdBodyDto, user: ILoggedUserInfo | null): Promise<SingleResponseDto<FlowDefinitionResponseDto>>;
@@ -20,11 +21,12 @@ declare const FlowDefinitionController_base: abstract new (service: FlowDefiniti
20
21
  delete(deleteDto: import("@flusys/nestjs-shared").DeleteDto, user: ILoggedUserInfo | null): Promise<import("@flusys/nestjs-shared").MessageResponseDto>;
21
22
  };
22
23
  export declare class FlowDefinitionController extends FlowDefinitionController_base {
23
- flowDefinitionService: FlowDefinitionService;
24
+ private readonly flowDefinitionService;
24
25
  private readonly runtime;
25
26
  constructor(flowDefinitionService: FlowDefinitionService, runtime: FlowRuntimeService);
26
27
  validate(dto: ValidateFlowDto): Promise<SingleResponseDto<IFlowValidationResult>>;
27
28
  publish(dto: PublishFlowDto, user: ILoggedUserInfo): Promise<SingleResponseDto<IFlowDefinition>>;
29
+ codePackages(): SingleResponseDto<string[]>;
28
30
  versions(dto: FlowIdDto): Promise<SingleResponseDto<IFlowVersion[]>>;
29
31
  version(dto: FlowVersionRefDto): Promise<SingleResponseDto<IFlowVersion>>;
30
32
  restoreVersion(dto: FlowVersionRefDto, user: ILoggedUserInfo): Promise<SingleResponseDto<IFlowDefinition>>;