npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@flusys/nestjs-entity-builder

v9.0.1

Published

Dynamic entity builder module - runtime entity/field metadata, driven DDL, and generic CRUD

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 through ModuleRef (flow entity access) find the tenant through the request context: MultiTenantDataSourceService falls 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 codes entity_builder.entity.<code>.* are global like other permission actions. A runtime table holds only the system columns (id, timestamps, soft delete and audit columns when enabled) plus the fields the designer defines; there is no automatic company_id, no company filter and no per-company unique.
  • Scoping records to a company or branch is the entity designer's choice: add fields such as company_id / branch_id (plain field codes, not reserved), then map them in the flow - user.companyId / user.branchId on insert, and the same value in the filter of every get / find / update / delete / lookup step that must stay inside the caller's company. Public and API-key runs have no signed-in caller, so such a flow takes the company from its input instead. A unique field is unique across the whole table.
  • 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-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).
  • 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", 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).
  • 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.
  • 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.
  • 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) need confirm: true and, 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_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.
  • 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.
  • 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.
  • 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.
  • 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 (=) or { op, value? } with op one of eq, 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'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).
  • 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/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.
  • 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.
  • 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).

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) with value: a literal, { "$field": "<ref>" } or { "$expr": <expression> }. Old conditions (field + literal / $field) are unchanged.
  • Evaluation: AND / OR / NOT evaluate 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 inside NOT.
  • Missing values: greater_than / less_than / greater_or_equal / less_or_equal are false when either side is null or missing. equals / not_equals (and in, is_any_of, list contains) treat null and missing as the same value and compare a Date with 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_matches search 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 with rule_engine.regex.invalid / rule_engine.regex.too.complex at run time.
  • Date functions (UTC, plain Date math; values are ISO text, Dates or epoch ms, null in gives null out): DATE_DIFF(a, b, unit) = a - b in second / minute / hour / day, fractional; START_OF_DAY(date, offsetMinutes?) / END_OF_DAY(date, offsetMinutes?) (optional offset, e.g. 360 for 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's left with is_true ("user has") / is_false ("user doesn't have"), or as a value in Set / Switch. branch omitted = the user's current branch, null or empty = company-wide grants only, otherwise that branch id. Company and tenant are never arguments: a flow checks in the caller's own company, and the tenant database is the request's. Codes come from PERMISSION_RESOLVER (nestjs-iam: cached, rebuilt from roles and direct grants for a branch not loaded yet); without IAM the shared permission cache is used (only scopes the user has loaded), and with neither the check fails with PermissionSystemUnavailableException. A flow fetches each branch's codes once per run. No signed-in user = false (the validator warns about permission checks in public / api_key flows).
  • Request functions (flows only; elsewhere null / false): HEADER(name) (any letter case; authorization, cookie, x-api-key, proxy-authorization are never visible), QUERY_PARAM(name), IP_IN_RANGE(ip, ranges) (IPv4/IPv6 addresses and CIDR blocks, list or comma separated text, at most 100; an IPv4-mapped IPv6 caller compares as IPv4; a bad range is refused on save and fails with rule_engine.invalid.ip.range at run time).
  • Text functions: LOWER, UPPER, TRIM, LENGTH (text or list), SPLIT_PART(text, separator, position?) (1-based, negative counts from the end, trimmed; e.g. the first x-forwarded-for address), REGEX_EXTRACT(text, pattern, group?) (RE2; default group 1 when the pattern has one, else the whole match), REPLACE(text, find, with) (plain text, every occurrence), TO_NUMBER(value) (null when not numeric).
  • Lookup ({ "type": "lookup", "entity", "filter", "mode": "exists" | "count" | "first", "field"?, "sort"? }): reads another entity. filter maps a column to an expression (=) or { op, value? } (the filter operators above). exists gives a boolean, count a number, first the first row (after sort) or its field, null when none. The read goes through the flow's per-entity read check (run-as caller) and transaction; a rule evaluated outside a flow has no reader and fails with rule_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, 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).

  • 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).

  • 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.

  • Check permission (permission_check): { codes, match: 'any' | 'all', onDenied: 'reject' | 'branch', statusCode?, message? }. 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 caller's effective permissions in their current company and branch (the same codes my-permissions returns). Output { allowed, missing }. A caller who is not signed in is denied (and the validator warns when any URL of the flow is public / 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 } - companies as { id, name } (by name), branches as { 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. 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 (nestjs-shared), provided by nestjs-auth when the company feature is on (the grants UserPermissionService lists, 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? } 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 returns 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.

  • 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).

  • 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 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 warns company.notification.without.user when callers of a flow running as the caller may not sign in). 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.

  • 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).

  • 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): any flow may start at an internal step, which then runs as its caller's run does (runAs inherited, 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 - public and jwt ones, and permission ones when the caller holds the permission, never an api_key one (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: an api_key URL from a step that a Request step running as the caller reaches (only a warning when just internal Request 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 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 under 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, and apiKey for API-key access), runAs, 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 IFlowEndpoints with every setting's default filled in; the run starts at IFlowRunState.startAt and takes that step's run-as, transaction and time limit. Save checks each step's settings (run.as.invalid, trigger.transaction.invalid, trigger.timeout.range 1-120 s, trigger.rate.limit.range 0-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 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).

  • 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 inputs; 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.

  • 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.

  • 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, or entity_builder.flow.<slug>.execute when empty; provisioned as an IAM action on publish), 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 and run-as. 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 and branch checks deny, and a flow running as the caller cannot touch entities there (validator warning keyless.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 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 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 onError says.

  • Runs as (a Request step's config.runAs; an internal step ignores it and runs as its caller): caller (the default; every entity step and lookup re-checks dynamicEntityPermission(entity, action) = entity_builder.entity.<entity>.<action> for the caller) or system (no checks - so saving a flow with such a step needs entity_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 holds flow_definition.system) - otherwise every entity step and lookup answers 403 flow.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.system for run-as system, 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, 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; 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).

  • 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: 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.

  • 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.

  • 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.

  • Versions: the flow's columns are the published flow - what callers run. A new flow is created unpublished (version 0: callers get 404, Call flow steps refuse it, the validator treats it as inactive) and is edited in place until its first publish - so saving it is checked like a publish against other flows calling a URL name or path it drops. After that, saving (update) keeps the edits in draft (dropped again when they match what is live) and callers keep running the published columns. publish validates the draft like a save, plus what only going live can break (other flows calling a URL name or path it drops), copies it over the columns as version + 1, clears the draft and records the version in eb_flow_version (FlowVersion: the full snapshot, note, publishedAt, publishedById); a version that went live before versions were kept is recorded first, with publishedAt null. restore-version copies 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-draft drops the draft. Everything but the flow's id is versioned (FLOW_VERSIONED_FIELDS), isActive included. Entity schema changes rewrite field references in the published flow, its draft, its kept versions and in deleted flows (so a restored version or flow keeps working), without a new version.

  • Schema changes are flow-aware: renaming an entity field rewrites the flows that use it; dropping an entity or field lists (and for a drop, blocks on) the flows that use it. Tracked: the entity step's fields / filter / sort, a create step's children[] (entityCode, fields keys, parentField), lookups, and output paths holding records - context.<id>.<field> (get / create / update one), context.<id>.items.<n>.<field> (Find, many / filter writes), context.<id>[.items.<n>].<as>.<n>.<field> (saved children). References reached through a loop over query results (loop.item.<field>) are not tracked.

  • 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.