@flusys/nestjs-entity-builder
v9.0.1
Published
Dynamic entity builder module - runtime entity/field metadata, driven DDL, and generic CRUD
Maintainers
Readme
@flusys/nestjs-entity-builder
Runtime "backendless" entities: define an entity and its fields through the API and get a real database table with per-entity permissions. The package only manages entities (definitions, fields, schema changes, table health); it exposes no record API. Every read and write of records goes through a flow (visual virtual API): its entity steps (create / update / delete / get / find) are the only way data moves, so logic that must run before a write (checks, computed values, other writes in the same transaction) lives in the flow that performs the write, and a flow reuses another through a Call flow step. There are no record hooks or separate operations.
Works on PostgreSQL and MySQL (via SchemaDialectAdapterService). Import EntityBuilderModule after IAMModule so permission Actions can be provisioned. Swagger: entityBuilderSwaggerConfig() from @flusys/nestjs-entity-builder/docs (served at api/docs/entity-builder).
Tenancy and company scoping
- Multi-tenant (
databaseMode: 'multi-tenant'): definitions, runtime tables, flows and flow executions all live in the tenant's database - the tenant is the only isolation the package enforces. Services resolved throughModuleRef(flow entity access) find the tenant through the request context:MultiTenantDataSourceServicefalls back to it when there is no request. - Company feature (
enableCompanyFeature): changes nothing in the tables. Entity and field definitions, flows, their API keys and flow executions are tenant-wide - every company has the same schema and runs the same flows, and permission codesentity_builder.entity.<code>.*are global like other permission actions. A runtime table holds only the system columns (id, timestamps, soft delete and audit columns when enabled) plus the fields the designer defines; there is no automaticcompany_id, no company filter and no per-company unique. - Scoping records to a company or branch is the entity designer's choice: add fields such as
company_id/branch_id(plain field codes, not reserved), then map them in the flow -user.companyId/user.branchIdon insert, and the same value in the filter of every get / find / update / delete / lookup step that must stay inside the caller's company. Public and API-key runs have no signed-in caller, so such a flow takes the company from its input instead. A unique field is unique across the whole table. - IAM still applies per company: permission checks (
HAS_PERMISSION, Check permission steps, entity permissions of flows running as the caller) use the caller's own company and branch.
Endpoints (all POST)
| Path | Purpose | Permission |
| --- | --- | --- |
| 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 jwt auth and run as the caller (entity permissions apply), 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 |
| entity-builder/entity-definitions/plan-create-entity | Dry run of the above: the real CREATE TABLE SQL, nothing is created | ...entity_definition.create |
| 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) |
| 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 |
| entity-builder/entity-definitions/apply-destructive-change | Apply purge_field (drop column) or drop_entity (drop table) | ...entity_definition.delete |
| entity-builder/entity-definitions/inspect | Drift check: entity definition vs the real table | ...entity_definition.read |
| entity-builder/entity-definitions/schema-history | Every schema change with its SQL, failed attempts included | ...entity_definition.read |
| 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 |
| entity-builder/field-definitions/{get-all,get/:id,get-by-ids,get-by-filter} | Field metadata read | ...field_definition.read |
| 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 |
| 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, run-as, transaction and time limit | per Request step: public / API key / any user / a permission |
| 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.* |
| entity-builder/flows/publish | { id, note? }: makes the draft live as the next version | ...flow_definition.update |
| entity-builder/flows/{versions,version} | { flowId }: published versions, newest first; { flowId, version }: one in full | ...flow_definition.read |
| entity-builder/flows/{restore-version,discard-draft} | Put an old version back into the draft / drop the draft | ...flow_definition.update |
| entity-builder/flows/validate | Check a draft without saving (errors block a save, warnings do not) | ...flow_definition.read |
| entity-builder/flows/test-run | Run a saved flow or an unsaved draft, with a per-node trace | ...flow_definition.test |
| entity-builder/flow-executions/{list,get} | Run history | ...flow_definition.read |
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.*).
Guarantees and limits
- Every structural change is a plan first.
plan-changeruns the same code asapply-changein a dry run (TypeORM SQL-memory mode), so the SQL you preview is the SQL that runs. Data is checked before anything changes: convertingTEXTtoINTEGERis 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 noUSINGclause, so on MySQL single select <-> multi select and text -> boolean are refused too (add a new field, copy the data, deprecate the old one). - 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). - Every change value is type-checked (
FieldChangesDto/EntityChangesDto):nullable: "false",nullfor a flag, an unknown field type or a too long label is a 400entity_builder.schema.invalid.change.value.update_fieldcan setrelationTargetEntityId(RELATION fields only; the target must exist and is locked for the change; converting to RELATION needs one,...blocker.relation.target.required).add_fieldandcreate-entityhold 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). - Optimistic concurrency: send
expectedUpdatedAt(theupdatedAtyou loaded - of the field for field actions, of the entity otherwise; selectupdatedAtwhen reading) withplan-change/apply-change; a newer one answers 409entity_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. LONG_TEXT,JSONandMULTI_SELECTfields cannot be unique or indexed (entity_builder.schema.not.indexable). Index and unique names areidx_/uq_+ table + column cut to the driver limit (63 PostgreSQL, 64 MySQL) + a hash of table and column, so they never collide.- Dropping an entity is blocked while relation fields of other entities point at it (
...blocker.drop.relation.in.use, listing"entity.field"). - Destructive changes (
purge_field,drop_entity, lossy conversions) needconfirm: trueand, for purge/drop, the code typed back (confirmCode). They also refuse while flows still use the field/entity. - Each change runs in one locked transaction and is logged in
eb_schema_change_logwith 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 carriesundo: { reverted, pendingSql, dataNotReverted }and, when an undo statement fails, the keyentity_builder.schema.failed.not.revertedwith the pending statements also in theFAILEDlog row'sdownSql. Backfilled values are not undone. Either way aFAILEDlog 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. - Changes made outside the entity builder (a column dropped by hand) show up in
inspectand are fixed byrepair; extra columns are reported and never dropped automatically. - The entity/field metadata
updateendpoints are gone on purpose: every edit goes throughapply-changeso the table, the metadata and the schema cache cannot disagree. - 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 toeb_schema_change_log. - Every filter/sort key of a flow's Find records step and of a
lookupis checked against the entity's real fields; unknown keys are rejected (never interpolated into SQL). A filter value is a plain value (=) or{ op, value? }withopone ofeq,ne,gt,gte,lt,lte,in(a list of at most 100 scalars; an empty list matches nothing),is_null,not_null. Operators map to a fixed SQL table and every value is a bound parameter. A Find step'ssortis{ field, direction? }with a column code andASC/DESC(left out:ASC); anything else is a save error (flow.validation.node.sort.invalid). - 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. - Each entity gets the IAM actions
entity_builder.entity.<code>.<create|read|update|delete>(only when IAM is installed); flows running as the caller check them on every entity step. DECIMAL fields are returned as numbers.RELATION/FILEare plain uuid columns (char(36)on MySQL, which has no uuid type) with no foreign-key constraint. Record events areentity-builder.<code>.created/updated/deleted, orpurgedfor a delete on an entity with soft delete turned off - the same name whether the write ran inside a transactional flow or not. - 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. forRootAsynctakesuseFactory,useClass,useExistingor a staticconfig; with none of them it throws at configuration time. The root entry also exports the flow types, errors andvalidateFlow, and the schema helpers (schema-columns,identifier-rules,field-type-conversion).
Rules
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.
- Operands: a condition compares
field(a reference) or, when set,left(any expression) withvalue: a literal,{ "$field": "<ref>" }or{ "$expr": <expression> }. Old conditions (field+ literal /$field) are unchanged. - Evaluation:
AND/OR/NOTevaluate their children in order and stop at the first decisive one, so an earlier condition can guard a later lookup or permission check. An unknown comparison or group operator fails the evaluation (rule_engine.unknown.comparison/rule_engine.unknown.group.operator), also insideNOT. - Missing values:
greater_than/less_than/greater_or_equal/less_or_equalare false when either side is null or missing.equals/not_equals(andin,is_any_of, listcontains) treat null and missing as the same value and compare aDatewith date text by instant; everything else compares strictly. Date text without a zone (2026-04-03,2026-04-03T09:00,2026-04-03 09:00:00) is read as UTC, never in the server's timezone; other formats need an explicit zone. - Regex:
matches/not_matchessearch the text with RE2 (re2js): linear time, no backtracking, so a pattern cannot hang the server (no backreferences or lookaround;(?i)for case-insensitive). Patterns are at most 500 characters, input at most 10,000. Counted repeats are unrolled by RE2, so{n,m}may count at most 100, nested counts may multiply to at most 100, and all counted repeats together may add at most 300 to the pattern's size (*,+,?are unlimited). An invalid or too complex pattern is refused when a flow is saved and fails withrule_engine.regex.invalid/rule_engine.regex.too.complexat run time. - Date functions (UTC, plain
Datemath; values are ISO text,Dates or epoch ms,nullin givesnullout):DATE_DIFF(a, b, unit)= a - b insecond/minute/hour/day, fractional;START_OF_DAY(date, offsetMinutes?)/END_OF_DAY(date, offsetMinutes?)(optional offset, e.g.360for UTC+6, to take "today" in the caller's zone);ADD_DURATION(date, amount, unit);NOW(). - Permission functions:
HAS_PERMISSION(code, branch?),HAS_ANY_PERMISSION(codes, branch?),HAS_ALL_PERMISSIONS(codes, branch?)give a boolean for the signed-in user (codes: a list or comma/space separated text, at most 50;*/prefix.*grants match). Use them as a condition'sleftwithis_true("user has") /is_false("user doesn't have"), or as a value in Set / Switch.branchomitted = the user's current branch, null or empty = company-wide grants only, otherwise that branch id. Company and tenant are never arguments: a flow checks in the caller's own company, and the tenant database is the request's. Codes come fromPERMISSION_RESOLVER(nestjs-iam: cached, rebuilt from roles and direct grants for a branch not loaded yet); without IAM the shared permission cache is used (only scopes the user has loaded), and with neither the check fails withPermissionSystemUnavailableException. A flow fetches each branch's codes once per run. No signed-in user = false (the validator warns about permission checks inpublic/api_keyflows). - Request functions (flows only; elsewhere
null/ false):HEADER(name)(any letter case;authorization,cookie,x-api-key,proxy-authorizationare never visible),QUERY_PARAM(name),IP_IN_RANGE(ip, ranges)(IPv4/IPv6 addresses and CIDR blocks, list or comma separated text, at most 100; an IPv4-mapped IPv6 caller compares as IPv4; a bad range is refused on save and fails withrule_engine.invalid.ip.rangeat run time). - Text functions:
LOWER,UPPER,TRIM,LENGTH(text or list),SPLIT_PART(text, separator, position?)(1-based, negative counts from the end, trimmed; e.g. the firstx-forwarded-foraddress),REGEX_EXTRACT(text, pattern, group?)(RE2; default group 1 when the pattern has one, else the whole match),REPLACE(text, find, with)(plain text, every occurrence),TO_NUMBER(value)(nullwhen not numeric). - Lookup (
{ "type": "lookup", "entity", "filter", "mode": "exists" | "count" | "first", "field"?, "sort"? }): reads another entity.filtermaps a column to an expression (=) or{ op, value? }(the filter operators above).existsgives a boolean,counta number,firstthe first row (aftersort) or itsfield,nullwhen none. The read goes through the flow's per-entityreadcheck (run-as caller) and transaction; a rule evaluated outside a flow has no reader and fails withrule_engine.lookup.resolver.unavailable.
Flows (virtual APIs)
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.
Loops hand data on by
collect(evaluated after each pass, readingloop.*and the body steps; the values becomecontext.<loop>.resultsnext tocount- collect a Set/Code step to hand on several values), by variables (varsis flow-wide and survives passes; initialize before the loop), and by body step results (after the loop they hold the last pass).Reference timing (validation warnings): a step's result exists for the steps on a path after it; once a loop is done (
donebranch) its whole body's results exist too (their last pass). The validator warns when a step readscontext.<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'seachbranch (a foreach's owncollectcounts as inside),loop.parentbeyond the loops around the step, orcontext.<loop>.resultsinside that loop (onlycountexists until it is done). Unreachable steps are not checked (they already warn).Validate:
{ checks: [{ id, rule, message, field? }], mode: 'all' | 'first', statusCode? }.all(default) evaluates every check and rejects with every failure;firststops at the first. The answer isstatusCode(default 400, or 403 when every failed check is a permission check) witherrors: [{ field, message }](fielddefaults to the check id); one failure uses its message as the top-level message, several useflow.checks.failed. Example (attendance check-out): check 1left= lookupattendancewithemployee_id = input.employeeId,out_time is_null,in_time gte START_OF_DAY(NOW()), modeexists,is_true; check 2left=DATE_DIFF(NOW(), <same lookup, mode first, field in_time>, 'hour')greater_or_equal5.Check permission (
permission_check):{ codes, match: 'any' | 'all', onDenied: 'reject' | 'branch', statusCode?, message? }.reject(default) answersstatusCode(403) withmessage(orflow.permission.denied) and stops, otherwise followsout;branchfollowsallowed/deniedand never rejects. Checks the caller's effective permissions in their current company and branch (the same codesmy-permissionsreturns). Output{ allowed, missing }. A caller who is not signed in is denied (and the validator warns when any URL of the flow ispublic/api_key).Company & Branch check (
company_branch_check, company feature):{ companyIds?, branchIds?, onDenied?, statusCode?, message? }- what the signed-in user may work in. It needs no permission codes: it gets exactly what company select offers the user - the active companies granted to them and, inside those, the active branches granted to them (a grant on a branch does not reach the branches below it). Output:{ userId, companies, branches }-companiesas{ id, name }(by name),branchesas{ id, companyId, name }(per company, by serial).companyIds/branchIds(expressions: one id or a list, typically the record being changed) turn it into a guard: each id must be within reach (allowed,missing: { companyIds, branchIds }); a target that resolves to nothing is a denial. ThenonDeniedrejects (default,statusCode403) or followsallowed/denied(which needs a target). No user means nothing is reachable. The data comes fromCOMPANY_ACCESS_RESOLVER(nestjs-shared), provided by nestjs-auth when the company feature is on (the grantsUserPermissionServicelists, kept to active companies and branches, looked up once per run); without it nothing is reachable. The designer offers the step only with the company feature.Code:
{ code, timeoutMs? }runscodeas 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 globalctx(no request headers;permissions, the caller's codes in the current branch, is fetched only when the code mentions permissions).hasPermission(code),hasAnyPermission(...codes)andhasAllPermissions(...codes)are plain JavaScript inside the sandbox overctx.user.permissionswith the server's wildcards andreturns a JSON value, which becomescontext.<id>. Nothing of the host is reachable (norequire,process, network, filesystem, timers or database);console.logis 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 oftimeoutMs(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.*), soonError: continueand theerrorport work. Code nodes also run in dry runs. Saving a flow that contains a code node needsentity_builder.flow_definition.code.Create / Update / Delete record write several records in one step through
target:one(default, left out) writes one record;manywrites one per item ofitems(up toFLOW_LIMITS.MAX_WRITE_ITEMS= 1000), and the step's per-record values (fields,id) read the item asloop.item/loop.indexwith a loop around the step asloop.parent;filter(update / delete) writes every record matchingfilter(same shape as Find), read asloop.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 thanlimit(1-1000, default 100) fail the step (flow.error.too.many.matches) instead of changing some. Inmanymodeiddefaults to the item's own id (the item itself when it is text, elseitem.id).onNotFound:error(default),skip, or for updateinsert(upsert: a missing record, or inmanymode an item with an empty id, is created with the same field values - needs the entity'screatepermission too). Create takeschildren(up to 10):{ as, entityCode, parentField, items, fields }saves child records under every saved record,parentFieldfilled with the parent's id;itemsis read in the parent's scope (loop.item.linesinmanymode,input.linesotherwise) and childfieldsread the child item asloop.itemand the parent's scope asloop.parent. Results: create one = the record plus one array peras; create many ={ items, count }; update one = the record (nullwhen 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 anidfield (generic_record.system.column.readonly), so it cannot overwrite an existing record; an upsert's created record ignores a mappedid. A step that writes several records (many,filter, or anychildren) 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 againstFLOW_LIMITS.MAX_WRITES_PER_RUN(5000, shared with called flows).Send notification (
send_notification) and Send email (send_email) send through the notification and email modules, reached only through theNOTIFICATION_ADAPTER/EMAIL_ADAPTERtokens ofnestjs-shared(@Optional(), so the package never imports either module). Who gets it (target):one(default, stored as no key) sends one message;manysends one per item ofitems(at mostMAX_MESSAGES_PER_RUN), every other value read with the item asloop.item(the validator scopesloop.*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'scompanytarget notifies every active member (active grant, active and not deleted user) ofcompanyIds(one or a list; withbranchIds, only members also in one of those branches) throughCOMPANY_ACCESS_RESOLVER.getCompanyMembers(implemented bynestjs-authon its existing company/branch reverse lookups), one notification per company shown under that company, at mostMAX_COMPANY_NOTIFICATION_RECIPIENTS(5000) in all. Thecompanytarget needs the company feature (refused on save without it:validation.node.notification.company.unavailable; at run timeerror.notification.company.unavailable), and a run may notify only companies it may speak for: any, when it runs as the system with that granted; otherwise only the companies the signed-in caller works in (error.notification.company.denied, 403; the validator warnscompany.notification.without.userwhen callers of a flow running as the caller may not sign in). Outputs: many / company addcount(messages) and, for a list,skipped; an email list hands onmessageIds(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 byFlowDefinitionService.validate) and fails at run time (error.notification.unavailable/.email.unavailable). Notification:{ userIds, title, message?, type?: info|success|warning|error, data?, companyId?, realtime? }-userIdsis one user id or a list (each once; anything but a UUID rejects with 400),titleis cut to 255 characters (empty rejects),companyIddefaults to the caller's current company (it decides where the notification shows with the company feature),realtime(default true) pushes to online users; onesendToManycall. 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 ontois dropped fromcc/bcc), at mostMAX_EMAIL_RECIPIENTS(50) in all,replyToa single one;contentneedssubject(line breaks removed, cut to 255) andbody; withformat: htmlthe values atemplateexpression fills in are HTML-escaped (RuleEngineService.resolveHtml), any other expression is sent as the HTML it holds;templateneedstemplateSlugand fills it withvariables(escaped by the email module);emailConfigIddefaults to the company's default configuration. A provider that answerssuccess: falseor throws fails the step (error.email.failed/error.notification.failed, with the reason), soonError: continueand theerrorport work. Nobody to send to (emptyuserIds/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 mostMAX_MESSAGES_PER_RUN(100) sends per run, shared with called flows, andMAX_NOTIFICATION_RECIPIENTS(500) per notification. ApublicRequest step in a flow with such a step is flagged (public.message): make sure a caller cannot choose the recipients.Save progress (
commit, no settings, portout): 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 carriessavedUpTo: { nodeId, name, at }(the last one that committed) and a failed answer's bodysavedUpTo: '<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 (traceskipped,reasonKeyflow.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).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 incontext.<id>: itsrespondbody, or the output of its last step. The called flow getsinputchecked 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 withonError: continuenever 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 withflow.error.called.flow.failed[.at.node], soonError: continueand theerrorport work. Access (of the Request step it starts at): any flow may start at aninternalstep, which then runs as its caller's run does (runAsinherited, its own ignored); a run as the system may call any flow; a run as the caller may call only the URLs that caller could call directly -publicandjwtones, andpermissionones when the caller holds the permission, never anapi_keyone (judged by the called Request step's own access). 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: anapi_keyURL from a step that a Request step running as the caller reaches (only a warning when justinternalRequest steps reach it, since they run as whoever calls them), a required input (without a default) left unmapped, a chain of calls that comes back to this flow, and a chain nested deeper thanFLOW_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 inerrors) when it adds a problem the caller did not already have: a removed or renamed path, a step switched toapi_keyunder a caller running as the caller, 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.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,permissionCode, andapiKeyfor API-key access),runAs,isTransactional,timeoutMsandrateLimitPerMinute- 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 apathanswers onPOST /api-flows/<slug>; each other one setsconfig.path(1-63 lowercase letters, digits and dashes, starting with a letter) and answers onPOST /api-flows/<slug>/<path>- e.g. acustomer-tickerflow withinsert(an object) andinsert-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 intoIFlowEndpoints with every setting's default filled in; the run starts atIFlowRunState.startAtand takes that step's run-as, transaction and time limit. Save checks each step's settings (run.as.invalid,trigger.transaction.invalid,trigger.timeout.range1-120 s,trigger.rate.limit.range0-100,000), and every warning that depends on them - public as the system, keyless as the caller, transactions, HTTP inside a transaction - looks only at the steps that Request step's runs reach. The test run takesendpoint(a path; left out, the flow's own URL - an unknown one is refused withflow.endpoint.not.found), and a Call flow step takesconfig.endpointto start the called flow at one of its paths, checked on save against that path's body (node.flow.endpoint.unknown, or.requiredwhen the called flow has no own URL) and at run time (error.called.flow.endpoint.not.found).Body type (a Request step's
config.bodyType, defaultobject, left out whenobject):object- the body is one object and the input fields are its keys (input.<name>);list- the body itself is a list, likeinsert-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 withflow.input.body.not.list), and the flow reads the list asinput(a For each overinput, orinput.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 namedinputs; saving checks the step matches the called flow's body type (node.flow.input.list.required/.unexpected). The test run accepts a listinputtoo.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). Anobjectfield may declare its ownfields; anarrayfield may declare anitemType(any type butarray) that every item must have - min/max/length/options then apply to each item - and a list of objects (itemType: 'object') declares thefieldsof 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 mostFLOW_LIMITS.MAX_INPUT_DEPTH(4) levels. Withoutfields/itemTypethe value is only checked to be an object / a list. Generated entity flows declareidsas a list of ids and a multi-select field as a list of its choices; their bulk flows use alistbody whose items are the entity fields.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.permissionCode, orentity_builder.flow.<slug>.executewhen empty; provisioned as an IAM action on publish),api_key(the step's own key, sent asx-api-key: the designer makes it (fk_<8 hex>_<32 hex>) and sends it inconfig.apiKeyonce; 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 401flow.api.key.invalid, and one step's key never opens another. A step without a key cannot be saved (trigger.api.key.required, or.invalidfor 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, orinternal- 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 and run-as. A flow whose every Request step isinternalis a function (isFunctionFlow()). A URL reached without signing in (public,api_key) runs with nouser: permission and branch checks deny, and a flow running as the caller cannot touch entities there (validator warningkeyless.endpoint.as.caller). 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).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 knownop(any other key, or an unknownop, 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 is left out; on an update / delete by filter it fails the step (flow.error.filter.value.missing) instead of widening the write.Checks cannot be carried past: a failed Validate or Check permission step (like Save progress) always ends the run, whatever its
onErrorsays.Runs as (a Request step's
config.runAs; aninternalstep ignores it and runs as its caller):caller(the default; every entity step and lookup re-checksdynamicEntityPermission(entity, action)=entity_builder.entity.<entity>.<action>for the caller) orsystem(no checks - so saving a flow with such a step needsentity_builder.flow_definition.system, and a public step running as the system that reaches entities is flagged in the warnings). It fails closed: any other value is refused on save and at run time, and a system run touches entities only when it was granted the system (a saved flow, or a test run whose tester holdsflow_definition.system) - otherwise every entity step and lookup answers 403flow.error.run.as.not.allowed.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.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.systemfor run-as system,flow_definition.codefor a code node. Alivetest run also needs what makes it real: for a draft the permission to save it (flow_definition.create, orupdatewhen it has aflowId), for apermissionRequest step its execute permission. The draft is validated like a saved flow (DTO: retention bounds and no unknown flow setting; thenvalidateFlow, which checks every Request step's access, run-as, 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.Test runs:
dry_run(default) executes inside a transaction that is always rolled back and skips HTTP, events, notifications and emails;livedoes 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).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.
Forwarding the caller's token: an HTTP step with
forwardAuth: truesends the signed-in caller's ownAuthorizationheader (unless itsheadersset 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 inrequest.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.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, NAT6464:ff9b::/96and all of the local-use64:ff9b:1::/48, 6to42002:, and all of Teredo2001::/32). The timeout is one deadline for the whole request, response body included. For local development only, setflows.allowPrivateHttpin the module config orENTITY_BUILDER_FLOW_ALLOW_PRIVATE_HTTP=true.Runs are logged (
eb_flow_execution) with the redacted input, a trace of every step, the output andflowVersion(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.Versions: the flow's columns are the published flow - what callers run. A new flow is created unpublished (
version0: callers get 404, Call flow steps refuse it, the validator treats it as inactive) and is edited in place until its first publish - so saving it is checked like a publish against other flows calling a URL name or path it drops. After that, saving (update) keeps the edits indraft(dropped again when they match what is live) and callers keep running the published columns.publishvalidates the draft like a save, plus what only going live can break (other flows calling a URL name or path it drops), copies it over the columns asversion + 1, clears the draft and records the version ineb_flow_version(FlowVersion: the full snapshot,note,publishedAt,publishedById); a version that went live before versions were kept is recorded first, withpublishedAtnull.restore-versioncopies any version into the draft (it needs the permissions its run-as and code steps call for, but is not validated, since the entities it names may have changed);discard-draftdrops the draft. Everything but the flow's id is versioned (FLOW_VERSIONED_FIELDS),isActiveincluded. Entity schema changes rewrite field references in the published flow, its draft, its kept versions and in deleted flows (so a restored version or flow keeps working), without a new version.Schema changes are flow-aware: renaming an entity field rewrites the flows that use it; dropping an entity or field lists (and for a drop, blocks on) the flows that use it. Tracked: the entity step's
fields/filter/sort, a create step'schildren[](entityCode,fieldskeys,parentField), lookups, and output paths holding records -context.<id>.<field>(get / create / update one),context.<id>.items.<n>.<field>(Find, many / filter writes),context.<id>[.items.<n>].<as>.<n>.<field>(saved children). References reached through a loop over query results (loop.item.<field>) are not tracked.Secrets typed into an HTTP header are stored in the flow definition (there is no credential vault yet); flows are readable only with
flow_definition.read.
Messages and localization
Every user-facing message is key based (config/message-keys.ts, keys entity_builder.*): exceptions and responses carry messageKey (+ messageVariables), and diagnostics (plan warnings/blockers/step descriptions/impact, drift findings, flow validation errors/warnings, run trace[].error, record errors[]) are IMessageRefs from @flusys/nestjs-shared, never text. The CRUD controllers use entityName: 'entity_builder.<resource>' so the base controller's *.success keys match the constants. The English text and the Bengali/Arabic translations live in the app's seed (flusysnest/src/persistence/localization/entity-builder.localization.ts, module entityBuilder); the frontend ENTITY_BUILDER_MESSAGES carries the same English text. Adding a message means adding the constant and both entries.
Not in v1
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.
