Declarative, database‑driven REST endpoints generated from JSON configuration.
Ship full CRUD plus advanced data operations (GET / POST / PUT / DELETE / PATCH / TRACE, import & export) for any table — without writing boilerplate code. Built with Rust + Actix Web.
flx-nocode-api · supports MySQL · PostgreSQL · SQLite · MSSQL · MongoDB
- What is Flexurio?
- How it works
- Quick start (SQLite)
- Installation
- Environment variables (
.env) - Configuration layout (
LOC_CONFIG) - Entity schema reference
- Custom ID generation (
function) - Master‑Detail (Header‑Detail) transactional orchestration
- Declarative Action Triggers & ERP Cascading Workflows (
action_triggers) - Document Immutability & State Machine Governance (
locked_when&state_machine) - Database seeding (
seed) - Hooks & validation
- Formula placeholders
- Endpoint reference
- Authentication & authorization
- Import & export
- Column encryption
- Logging & observability
- Database feature flags (compile‑time)
- Multi‑target build script (
build.sh) - Troubleshooting
- Security checklist
- Contributing & license
Flexurio No‑Code API lets you stand up secure, multi‑database REST endpoints by describing each entity (table) in a JSON file. You write configuration, not code; the engine generates the HTTP surface, validates requests, talks to the database, and enforces authentication.
Typical use cases:
- Rapid prototyping of admin / data panels.
- Internal tooling and back‑office APIs.
- Putting a clean REST API over an existing MySQL / Postgres / SQLite / MSSQL / MongoDB database.
- Computed / formula‑driven flows and change‑capture journaling (PATCH & TRACE).
At startup the engine:
- Reads the active config profile path from the
LOC_CONFIGenvironment variable. - Loads enabled route names from
LOC_CONFIG/routes.json. - Loads one entity schema per route from
LOC_CONFIG/entity/<route>.json. - Ensures the core tables (
flx_users,flx_roles) exist and seeds a default admin if none exists. - Registers a uniform REST surface for every route (only the HTTP methods you enable per schema).
- Applies JWT authentication to all non‑public routes (with public overrides and an optional IP allow‑list).
┌────────────────────┐
routes.json │ enabled routes │
└─────────┬──────────┘
│ for each route
▼
LOC_CONFIG/entity/<route>.json ──► TableSchema
│
▼
┌──────────────────────────────────────────────────────────┐
│ Actix‑web router │
│ GET /<route> POST /<route> PUT /<route>/{id} │
│ DELETE /<route>/{id} PATCH /<route> TRACE /<route> │
│ POST /import/<route> GET /export/<route> │
│ GET /validate/<route> POST /generate/table/<route> │
└──────────────────────────────────────────────────────────┘
│
▼
DB adapter (MySQL · Postgres · SQLite · MSSQL · MongoDB)
The fastest way to try Flexurio — no external database needed.
# 1. Get a binary (build from source shown here; see §4 for installers)
cargo build --release
# 2. Create a minimal .env
cat > .env <<'EOF'
DB_TYPE=sqlite
SQLITE_URL=sqlite://data.db
LOC_CONFIG=config
SECRET_KEY=replace_with_a_long_random_secret
ENCRYPT_KEY=replace_with_another_random_secret
PORT=8080
REQUIRE_AUTH=True
DEBUG=True
LOGGING=True
EOF
# 3. Run it
./target/release/flx-nocode-apiOn first start the engine creates flx_users / flx_roles if missing and, if there is no admin yet, prints a generated password to the console:
Your admin Password: 1234
Log in with email admin and that password (see §16).
- macOS or Linux (Windows works natively or via WSL).
- A database. SQLite needs nothing extra and is ideal for a first run.
- Rust toolchain only if you build from source.
curl -fsSL https://raw.githubusercontent.com/flexurio/flx-nocode-api/main/install-flexurio.sh | bashThe script detects your OS/architecture, downloads the matching asset from the latest GitHub release, installs the binary plus a convenient flexurio wrapper into ~/.local/bin, and adds it to your PATH. The flexurio command automatically reads the .env in the current working directory.
Reload your shell afterwards (e.g. source ~/.zshrc), then run flexurio from any folder containing a .env.
Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass
.\install-flexurio.ps1
# open a new terminal, then:
flexurio# macOS (Apple Silicon)
curl -fsSL -o flx-nocode-aarch64-apple-darwin.pkg \
https://github.com/flexurio/flx-nocode-api/releases/latest/download/flx-nocode-aarch64-apple-darwin.pkg
sudo installer -pkg flx-nocode-aarch64-apple-darwin.pkg -target /
# Linux (x86_64)
curl -fsSL -o flx-nocode-x86_64-unknown-linux-gnu \
https://github.com/flexurio/flx-nocode-api/releases/latest/download/flx-nocode-x86_64-unknown-linux-gnu
chmod +x flx-nocode-x86_64-unknown-linux-gnu
install -m 0755 flx-nocode-x86_64-unknown-linux-gnu "$HOME/.local/bin/flx-nocode"git clone https://github.com/flexurio/flx-nocode-api.git
cd flx-nocode-api
cargo build --release
./target/release/flx-nocode-apiTo build a smaller binary with only the database backend(s) you need, see §20.
docker-compose.yaml is included as a reference. Mount your static/, config/, and .env:
services:
rust-app:
build: .
container_name: flx-nocode-api
restart: always
ports:
- "2121:8080" # access at http://localhost:2121
volumes:
- "./static:/app/static"
- "./config:/app/config"
- "./.env:/app/.env"Copy the bundled example and edit it:
cp env .envPick exactly one
DB_TYPEand its matching URL. Avoid duplicate keys.
| Variable | Required | Description |
|---|---|---|
PORT |
Yes | HTTP listen port (e.g. 8080). |
DB_TYPE |
Yes | One of mysql, postgres, sqlite, mssql, mongodb. |
MYSQL_URL / POSTGRES_URL / SQLITE_URL / MSSQL_URL / MONGODB_URI |
Cond. | Connection string for the selected backend. SQLite quick start: sqlite://data.db. |
MONGODB_DB |
Cond. | Database name when DB_TYPE=mongodb. |
SECRET_KEY |
Yes | HMAC secret used to sign Flexurio‑issued JWTs. |
ENCRYPT_KEY |
Yes | Symmetric key for encrypted columns (encrypt: true). |
LOC_CONFIG |
Yes | Path to the active config profile (contains routes.json + entity/). |
REQUIRE_AUTH |
No | True (default) to enforce JWT auth; False exposes routes without auth. |
BASE_URL |
No | External base URL used in logs / links. |
| Variable | Default | Description |
|---|---|---|
LOC_STATIC |
static |
Directory served under /static. |
LOC_IMAGE |
images |
Image upload directory (inside the static directory). |
LOC_LOGGING |
logs |
Directory for log files. |
LOC_AUDIT |
— | Path to the audit event log (keep outside static/ to avoid exposing it). |
LOC_SEED |
seed |
Directory containing seed data files (.json, .csv, .sql). |
| Variable | Description |
|---|---|
CUSTOME_JWT_QUERY |
SQL run at login to enrich the JWT cs claim. Use {:?} as the user‑id placeholder, e.g. SELECT email FROM flx_users WHERE id = {:?}. (CUSTOM_JWT_QUERY is also accepted.) |
WHITE_LIST_IP |
Comma‑separated IPs / CIDR ranges that bypass JWT validation. |
CONVERTER_JWT_SECRET |
HMAC secret to verify externally‑issued JWTs (converter‑token mode). |
CONVERTER_JWT_PUBLIC_KEY |
PEM public key (RS*/ES*/EdDSA) to verify external JWTs. Use literal \n for newlines. |
CONVERTER_JWT_ALG |
Algorithm for external JWT verification (e.g. HS256, RS256). |
CONVERTER_JWT_ISSUER / CONVERTER_JWT_AUDIENCE |
Optional issuer / audience claim checks (comma‑separated). |
CONVERTER_JWT_INSECURE_SKIP_VERIFY |
true to accept external JWTs without signature verification (only if an upstream gateway already validates them). |
Converter‑token mode is fail‑closed: if it is active and none of the verification variables are set, all converter‑token requests are rejected. See §16.
| Variable | Default | Description |
|---|---|---|
LIMIT_DEFAULT |
100 |
Default page size for GET. |
LIMIT_MAX |
1000 |
Maximum page size a client may request. |
JSON_LIMIT_KB |
512 |
Max JSON request body size. |
UPLOAD_LIMIT_MB |
5 |
Max upload size per file. |
UPLOAD_TEXT_LIMIT_KB |
512 |
Max size of a text form field. |
UPLOAD_MAX_FILES / UPLOAD_MAX_FIELDS |
5 / 100 |
Multipart limits. |
UPLOAD_EXT_ALLOW |
— | Comma‑separated allow‑list of upload file extensions. |
IMPORT_BATCH_SIZE |
— | Rows per batch during import. |
RATE_LIMIT_LOGIN_PER_MIN |
3 |
Login attempts per minute. |
RATE_LIMIT_MUTATE_PER_SEC |
20 |
Per‑second limit for mutating methods. |
RATE_LIMIT_GET_PER_SEC |
50 |
Per‑second limit for GET. |
RATE_LIMIT_LOGIN_FAIL_USER / RATE_LIMIT_LOGIN_FAIL_IP |
— | Failed‑login limits over a 5‑minute window. |
| Variable | Description |
|---|---|
ACTIX_WORKERS |
Number of worker threads (match CPU cores). |
HTTP_KEEPALIVE_SECS / HTTP_BACKLOG / HTTP_MAX_CONN_RATE / HTTP_MAX_CONNECTIONS |
HTTP server tuning. |
MAX_POOL / MIN_POOL / CONNECT_TIMEOUT / POOL_MAX_LIFETIME_SECS / POOL_IDLE_TIMEOUT_SECS |
DB connection‑pool tuning. |
WRITE_QUEUE_ENABLED / WRITE_CONCURRENCY / WRITE_QUEUE_MAX_LEN / WRITE_EXEC_RETRY_MAX |
Write‑queue / concurrency controls for high write throughput. |
DEFAULT_COLLATE |
Default collation for MySQL/MariaDB (e.g. utf8mb4_bin). |
MSSQL_ENCRYPTION / MSSQL_TRUST_CERT |
TLS options for MSSQL. |
REDIS_HOST / REDIS_PORT / REDIS_PASSWORD / REDIS_DB |
Redis connection (caching / extension). |
| Variable | Description |
|---|---|
DEBUG |
1/true/yes enables verbose debug logging. |
LOGGING |
True enables extended log output to LOC_LOGGING. |
LOG_MIN_LEVEL |
error|warn|info|debug|trace (default info). Drops messages below the level. |
LOG_MAX_BODY_BYTES |
Max bytes printed per log body (default 8192); longer bodies are truncated. |
LOG_SAMPLE_DEBUG_N |
Sample every N debug/trace logs (default 1 = no sampling). |
LOG_QUEUE_CAP |
Bounded logger queue capacity (default 2048). |
LOG_COLOR |
0/false/no disables ANSI colors. |
Logging runs on a non‑blocking background thread, so it never blocks the request path. In production prefer LOG_MIN_LEVEL=info (most SQL/param logs are at debug) and consider sampling under high load.
LOC_CONFIG/
routes.json # enabled routes + public routes
rules.json # (optional) role / endpoint authorization rules
entity/
<route>.json # one schema per route — file name must match the route
Several sample profiles are included: config, config/example, config/pos, config/tms, configmftl.
{
"routes": ["flx_users", "flx_roles", "banks", "bank_types"],
"route_publics": ["login", "register"]
}routes— entities to expose. Each must have a matchingentity/<name>.json.route_publics— routes reachable without a JWT.
- Add the name to
routesinroutes.json. - Create
entity/<route>.json(copy an existing one; ensuretableand the file name align). - (Optional)
POST /generate/table/<route>to create the physical table (requiresauto_generate: true). GET /validate/<route>to confirm the schema matches the database.- Use the CRUD endpoints.
Each entity/<route>.json deserializes into a TableSchema (see src/model.rs). Sections:
| Key | Purpose |
|---|---|
table |
Physical table / collection name. |
primary_key.columns |
Array of PK columns (supports composite keys). |
columns[] |
Column definitions (see below). |
foreign_keys[] |
{ column, reference_table, reference_column, on_delete, on_update }. Actions: cascade, restrict, set null, no action. |
details[] |
Array of DetailSchema for transactional master‑detail orchestration (see §9). |
action_triggers[] |
Array of ActionTrigger for declarative, multi‑table cascading ERP workflows, lot stock deductions, AR/GL posting, and state‑change triggers (see §10). Also accepts alias "triggers". |
locked_when |
Document immutability lock: { status: ["SHIPPED", "PAID"], except_columns: ["notes"] }. Prevents updating or deleting records once locked (see §11). |
state_machine |
Declarative state machine transition matrix: { field: "status", initial: "DRAFT", transitions: [...] } with role-based transition guards (see §11). |
indexes[] |
{ name, columns[], unique }. Unique indexes are enforced on insert/update. |
redis |
{ keys[], ttl } — cache blueprint. |
get |
Read pipeline (see below). |
post / put |
Create / update behavior + hooks (see §13). Suffix a name in columns with * to make that field required — e.g. "columns": ["name*", "phone"]. |
del |
{ enable_method, columns, type_delete, pre_process, post_process }; type_delete = soft or hard. |
patch |
Stored‑procedure / parameterized op: { enable_method, pre_process_sp, parameters[], return_mode }. return_mode = "" / rows / affected. For partial updates by id, see PATCH /<route>/{id} in §15. |
trace |
Advanced insert + select / upsert pipeline for journaling & change capture. |
seed |
If true, registers POST /seed/<route> and POST /generate/seed/<route> for database seeding (see §12). |
auto_generate |
If true, the POST /generate/table/<route> endpoint is exposed. |
collate |
Per‑table collation override. |
Each HTTP method is only registered when its section sets "enable_method": true.
{
"name": "id",
"type_data": "varchar(15)",
"auto_increment": false,
"nullable": false,
"function": "{request.id_trans}/%Y/%m/000ID",
"function_endpoint": "",
"function_endpoint_path": "data",
"encrypt": false,
"default": null
}| Field | Description |
|---|---|
name |
Column name. |
type_data |
SQL type, e.g. varchar(255), bigint, timestamp. |
auto_increment |
Auto‑increment integer PK. |
nullable |
Whether NULL is allowed. |
function |
(optional) A pattern that builds the column value automatically on insert — e.g. "{request.id_trans}/%Y/%m/000ID" produces SO/2026/01/0001. Empty string = no generation (the client supplies the value). Full token list in §8. |
function_endpoint |
(optional) When function contains a numeric …ID token, fetch the running number from this HTTP endpoint instead of computing MAX(id)+1. Empty string = use the built‑in MAX(id)+1. Supports {request.field} in the URL. Detail in §8. |
function_endpoint_path |
(optional) Dotted JSON path to the number inside the function_endpoint response. Defaults to data, i.e. a response of { "data": 1 }. Ignored when function_endpoint is empty. |
encrypt |
If true, the value is stored encrypted with ENCRYPT_KEY — see §18. |
default |
Default value used by generate/table. |
The three function* fields work together to auto‑generate an id; they only apply to POST/insert:
function— the format. Split on/; tokens like%Y/%m/%d(date),{request.field}(request value), andNNNID(zero‑padded running number) are resolved, everything else is literal.function_endpoint— where the running number comes from. Leave empty → the engine usesMAX(id)+1for ids sharing the same prefix. Set it → the engineGETs that URL (with the built prefix appended as?prefix=…and the request'sAuthorizationheader forwarded) and uses the returned number. There is no fallback: if the call fails the insert is aborted.function_endpoint_path— how to read the number out of the endpoint's JSON response (defaultdata).
A column with no id generation simply sets
"function": ""and omits the two endpoint fields. See §8 for worked examples.
"get": {
"enable_method": true,
"columns": ["banks.id", "banks.name", "bank_types.name"],
"parameters": ["name.eq", "bank_type_id.eq"],
"join_tables": [
{ "table": "bank_types", "columns": ["name"], "logical": "banks.bank_type_id = bank_types.id", "type_join": "left" }
],
"column_groups": [],
"having": [],
"order_by": ["banks.id"],
"where_clause": []
}parametersdeclares which query‑string filters are accepted, in the formcolumn.operator(e.g.name.eq,created_at.gte). Clients then callGET /banks?name.eq=BCA.- Pagination is controlled by
LIMIT_DEFAULT/LIMIT_MAX.
Set function on a column (typically id) to build a formatted identifier on insert — e.g. SO/2026/01/0001. The pattern is split on / and each token is resolved:
| Token | Result |
|---|---|
%Y |
Current 4‑digit year |
%m |
Current 2‑digit month |
%d |
Current 2‑digit day |
{request.field} |
A value from the request body (supports dotted paths) |
000ID (any digits + ID) |
The running/sequence number, zero‑padded to the number of leading digits (000ID → width 3 → 001) |
| anything else | Used literally |
Example pattern and the value it produces:
{ "name": "id", "type_data": "varchar(15)", "function": "{request.id_trans}/%Y/%m/000ID" }request.id_trans = "SO" → SO/2026/01/0001
By default the …ID token is computed as MAX(id) + 1 for ids sharing the same prefix, inside the same transaction as the insert. You can instead fetch it from an external endpoint — useful when sequence numbers are owned by another service.
Add function_endpoint (and optionally function_endpoint_path) to the same column:
{
"name": "id",
"type_data": "varchar(15)",
"function": "{request.id_trans}/%Y/%m/000ID",
"function_endpoint": "http://localhost:8080/api/next-sequence",
"function_endpoint_path": "data"
}Behavior when function_endpoint is set:
- The URL is interpolated (
{request.field}placeholders are filled from the request body). - The already‑built prefix is appended as a query param, URL‑encoded — e.g.
?prefix=SO%2F2026%2F01— so the endpoint can scope the sequence per prefix. - A
GETis sent; the inbound request'sAuthorizationheader is forwarded. - The response must be JSON. The number is read from
function_endpoint_path(dotted path, defaultdata) and coerced to an integer. - The number is zero‑padded to the token width and spliced into the id.
Expected response shape (with the default path data):
{ "data": 1 }→ produces SO/2026/01/0001.
No fallback: if the endpoint times out, returns a non‑2xx status, or the field is missing/non‑numeric, the insert is aborted with an error. Leave
function_endpointempty to use the built‑inMAX(id)+1strategy.
Implementation: src/nocode/repositories/data_create_repo.rs (fetch_next_number_from_endpoint and query_next_number_from_max).
Flexurio provides first-class, atomic transactional orchestration for Master‑Detail (Header‑Detail / Parent‑Child) business workflows — such as Purchase Orders with line items, Invoices with tax charges, or Sales Orders with products.
Instead of writing multiple manual API requests and managing partial failure rollbacks on the frontend, clients send a single payload with nested items. The engine orchestrates parent generation, foreign key injection, and child batching within a single ACID database transaction.
┌────────────────────────────────────────────────────────┐
Single POST │ { id_trans: "PO", customer: "ACME", items: [...] } │
└───────────────────────────┬────────────────────────────┘
│
▼
┌────────────────────────────────────────┐
│ Atomic Database Transaction (ACID) │
│ │
│ 1. INSERT Header (e.g. PO/2026/01/001)│
│ 2. Extract/Auto-gen Parent PK │
│ 3. Inject po_id into each child item │
│ 4. Bulk INSERT Detail Items │
│ 5. COMMIT (or ROLLBACK all on error) │
└────────────────────────────────────────┘
Configure one or more detail relationships inside LOC_CONFIG/entity/<parent_route>.json:
{
"table": "transaction_purchase_orders",
"primary_key": {
"columns": ["id"]
},
"columns": [
{ "name": "id", "type_data": "varchar(20)", "function": "{request.id_trans}/%Y/%m/000ID" },
{ "name": "customer", "type_data": "varchar(100)" },
{ "name": "total_amount", "type_data": "decimal(15,2)" }
],
"details": [
{
"field": "items",
"target_table": "transaction_purchase_order_items",
"foreign_key_column": "po_id",
"parent_key_column": "id",
"columns": ["item_code", "description", "qty", "unit_price", "subtotal"],
"update_strategy": "replace",
"cascade_delete": true
}
],
"post": { "enable_method": true, "columns": ["id_trans", "customer", "total_amount"] },
"put": { "enable_method": true, "columns": ["customer", "total_amount"] },
"get": { "enable_method": true, "columns": ["id", "customer", "total_amount"] },
"del": { "enable_method": true, "type_delete": "hard" }
}DetailSchema Field |
Default | Description |
|---|---|---|
field |
(required) | Key name in the JSON request payload containing the array of child records (e.g. "items", "details", "lines"). |
target_table |
(required) | Physical table name of the detail/child entity. |
foreign_key_column |
(required) | Column in the child table referencing the parent header's primary key (e.g. "po_id"). |
parent_key_column |
"id" |
Column on the parent table whose value is injected into child records. |
columns |
[] |
(optional) Column whitelist for child records. If specified, any extra keys in detail items are safely ignored. |
update_strategy |
"replace" |
Strategy on PUT /<route>/{id}: "replace" (delete old & insert new), "upsert" (update existing / insert new), or "append" (keep existing & insert new). |
cascade_delete |
true |
When true, deleting the parent via DELETE /<route>/{id} automatically deletes child records in the same transaction. |
Send a POST /<parent_route> with multipart/form-data or JSON containing the nested items array:
{
"id_trans": "PO",
"customer": "PT Maju Bersama",
"total_amount": 1500000,
"items": [
{
"item_code": "ITM-001",
"description": "Mechanical Keyboard",
"qty": 2,
"unit_price": 500000,
"subtotal": 1000000
},
{
"item_code": "ITM-002",
"description": "Ergonomic Mouse",
"qty": 1,
"unit_price": 500000,
"subtotal": 500000
}
]
}Execution Lifecycle:
- The engine generates or assigns the parent primary key (e.g.
PO/2026/01/0001viafunctionpattern or auto-increment). - The parent header is inserted into
transaction_purchase_orders. - The generated
idis automatically injected aspo_id: "PO/2026/01/0001"into each item initems. - All child items are bulk-inserted into
transaction_purchase_order_items. - The entire operation is committed atomically. If any detail record fails validation or DB constraint, the parent record is automatically rolled back.
Send PUT /<parent_route>/{id} with the updated header fields and new/modified detail items:
{
"customer": "PT Maju Bersama Perkasa",
"total_amount": 2000000,
"items": [
{ "item_code": "ITM-001", "description": "Mechanical Keyboard", "qty": 4, "unit_price": 500000, "subtotal": 2000000 }
]
}Under "update_strategy": "replace" (default), existing child items for that parent are deleted and the new list is inserted within the transaction.
When calling GET /<parent_route> or GET /<parent_route>/{id}, Flexurio automatically queries and embeds matching child records inside each parent item under the declared field name:
{
"success": true,
"data": [
{
"id": "PO/2026/01/0001",
"customer": "PT Maju Bersama",
"total_amount": 1500000,
"items": [
{ "id": 1, "po_id": "PO/2026/01/0001", "item_code": "ITM-001", "qty": 2, "subtotal": 1000000 },
{ "id": 2, "po_id": "PO/2026/01/0001", "item_code": "ITM-002", "qty": 1, "subtotal": 500000 }
]
}
],
"total_data": 1
}In enterprise ERP systems, a single primary transactional state change frequently triggers multiple automated secondary operations across inventory and financial ledgers.
Flexurio provides a native, declarative, engine-level action trigger framework configured under "action_triggers" (or alias "triggers") in the entity JSON schema. All trigger operations run within the same ACID database transaction as the primary update, ensuring complete financial rollbacks on error.
┌─────────────────────────────────────────────────────────┐
Single PATCH │ PATCH /transaction_sales_order/105 │
│ { "status": "SHIPPED" } │
└───────────────────────────┬─────────────────────────────┘
│
▼
┌────────────────────────────────────────┐
│ Single Atomic Transaction (ACID) │
│ │
│ 1. UPDATE status -> "SHIPPED" │
│ 2. Evaluate Action Trigger Condition │
│ (status: "CONFIRMED" -> "SHIPPED") │
│ 3. ITERATE detail items & DEDUCT │
│ stock from transaction_product_lot │
│ [Assert: qty_available >= 0] │
│ 4. INSERT AR invoice draft into │
│ transaction_account_receivable │
│ 5. INSERT BATCH balanced GL lines │
│ (Debit AR / Credit Revenue) │
│ 6. Write Audit Log (action: "TRIGGER")│
│ 7. COMMIT (or ROLLBACK ALL on error) │
└────────────────────────────────────────┘
When a warehouse manager approves the dispatch of goods by updating a Sales Order's status from 'CONFIRMED' (or 'APPROVED') to 'SHIPPED' (via PATCH /transaction_sales_order/{id}), the backend engine must automatically execute 3 critical cascading actions within a single atomic database transaction:
- Inventory Stock Deduction: Automatically query line items from
transaction_sales_order_itemand deduct the corresponding quantity (qty) from the finished goods lot inventory intransaction_product_lot. - Accounts Receivable (AR) Invoicing: Automatically create an AR invoice draft in
transaction_account_receivablematching the Sales Order's total amount and customer details. - General Ledger (GL) Auto-Posting: Automatically insert balanced double-entry journal lines into
transaction_general_ledger_line(Debit Accounts Receivable / Credit Sales Revenue).
Historically, developers attempted to use put.post_process with raw SQL strings. However, relying on post_process fails in complex ERP scenarios due to 4 fundamental architectural limitations:
-
Lack of Conditional Execution (Runs Unconditionally):
- Raw SQL in
post_processexecutes every single time an update is made, regardless of which field was modified. -
Example Problem: If an operator updates only the shipping address via
PATCH /transaction_sales_order/105with{"shipping_address": "Jakarta"}, staticpost_processSQL would erroneously deduct stock again. It cannot evaluate state transitions such as "Only execute whenstatustransitions from'CONFIRMED'to'SHIPPED'".
- Raw SQL in
-
Inability to Dynamically Loop Over Relational Detail Arrays (BOM / Line Items):
- A static SQL string cannot perform per-row dynamic iteration over relational child items.
-
Example Problem: A Sales Order may contain
$N$ different line items. A staticUPDATEquery cannot dynamically iterate over child rows intransaction_sales_order_itemand deduct matching stock intransaction_product_lotwithout resorting to complex, database-specific Stored Procedures.
-
Database Portability & Maintenance Overhead:
- Forcing multi-table logic into stored procedures (
JSON_TABLEin MySQL vsjson_to_recordsetin PostgreSQL) breaks Flexurio's cross-database no-code portability promise (SQLite, MySQL, PostgreSQL, MSSQL).
- Forcing multi-table logic into stored procedures (
-
Lack of Payload Variable Extraction for Indirect Data:
-
post_processcan only substitute direct fields present in the incoming HTTP request body. When receiving a clean status update payload{"status": "SHIPPED"}, variables like{product_id}and{qty}do not exist in the request body, causing placeholders to fail or evaluate toNULL.
-
Developers must avoid two common anti-patterns:
-
Anti-Pattern 1: Sequential Frontend HTTP Calls
1. PATCH /transaction_sales_order/105 (Update status -> SHIPPED) 2. PUT /transaction_product_lot/1 (Deduct stock Lot 1) 3. PUT /transaction_product_lot/2 (Deduct stock Lot 2) 4. POST /transaction_account_receivable (Create AR Invoice) 5. POST /transaction_general_ledger_line (Post GL Lines)
- Loss of Transactional Atomicity (No ACID Guarantee): If the client loses connection or crashes at Step 3, the database is left in a corrupted, half-processed state.
- Security & Data Tampering Risk: A malicious user can intentionally skip calling the stock deduction or AR creation endpoints.
- High Latency & Overhead: 5+ sequential round-trip network calls introduce significant lag.
- Complex Client Rollback Logic: Writing manual compensation/rollback code on the frontend is fragile and error-prone.
-
Anti-Pattern 2: Client Payload Detail Forcing
PATCH /transaction_sales_order/105 { "status": "SHIPPED", "items": [{ "product_id": 12, "qty": 5 }] }
- Forcing clients to re-send detail items in status update payloads creates severe security vulnerabilities (payload tampering) and violates the Single Source of Truth (SSOT) since items already reside canonically in database detail tables.
graph TD
A["transaction_sales_order<br/>(Header: id=105, status=SHIPPED)"] -->|"Iterate Details"| B["transaction_sales_order_item<br/>(Line Items: Product 12 qty=5, Product 14 qty=10)"]
B -->|"Deduct Stock"| C["transaction_product_lot<br/>(Lot A1: 100-5=95, Lot B2: 50-10=40)"]
A -->|"Auto-Generate Draft"| D["transaction_account_receivable<br/>(Invoice: Total 250,000, Due +30d)"]
A -->|"Auto-Post Balanced Entries"| E["transaction_general_ledger_line<br/>(Debit AR 250,000 / Credit Sales 250,000)"]
Below are the 5 canonical database tables involved in the PATCH /transaction_sales_order/105 shipment event:
id (int) |
so_number (varchar) |
customer_id (int) |
so_date (varchar) |
total_net (decimal) |
⚡ status (varchar) |
|---|---|---|---|---|---|
| 105 | SO/2026/09/0001 | 3 | 2026-09-05 | 250000.00 | SHIPPED (Patched) |
id (int) |
sales_order_id (int) |
product_id (int) |
lot_number (varchar) |
qty (int) |
unit_price (decimal) |
subtotal (decimal) |
|---|---|---|---|---|---|---|
| 10 | 105 | 12 | LOT-2026-A1 | 5 | 10000.00 | 50000.00 |
| 11 | 105 | 14 | LOT-2026-B2 | 10 | 20000.00 | 200000.00 |
id (int) |
product_id (int) |
lot_number (varchar) |
qty Before | ⚡ qty After Trigger | status |
|---|---|---|---|---|---|
| 1 | 12 | LOT-2026-A1 | 100 | 95 (Deducted 5) | AVAILABLE |
| 2 | 14 | LOT-2026-B2 | 50 | 40 (Deducted 10) | AVAILABLE |
id (int) |
so_id (int) |
customer_id (int) |
invoice_date | due_date | amount (decimal) |
status |
|---|---|---|---|---|---|---|
| 50 | 105 | 3 | 2026-09-05 | 2026-10-05 | 250000.00 | UNPAID |
id (int) |
reference_id (int) |
account_code (varchar) |
description (varchar) |
debit (decimal) |
credit (decimal) |
|---|---|---|---|---|---|
| 1 | 105 | 1120 | AR - Invoice SO/2026/09/0001 | 250000.00 | 0.00 |
| 2 | 105 | 4100 | Sales Revenue - SO/2026/09/0001 | 0.00 | 250000.00 |
Flexurio solves this natively with an enterprise Declarative Action Trigger Engine:
- ACID Transaction Scope: All trigger actions run within the primary update transaction (
tx). If any action fails (e.g., negative stock, DB constraint),tx.rollback()is executed automatically. - State Transition Evaluation: Triggers evaluate state transitions by comparing the pre-fetched
old_recordagainstnew_record. A trigger with"from": "CONFIRMED"and"to": "SHIPPED"fires only whenstatusactually transitions. Repeated updates or unrelated field modifications do not fire the trigger. - First-Class
PATCH /{route}/{id}Support: Clients can send partial JSON payloads (e.g.{"status": "SHIPPED"}) without resending the entire entity or detail arrays. - Automated Audit Trail: Every executed trigger logs an audit entry to
LOC_AUDITviawrite_auditwithaction: "TRIGGER"for regulatory compliance. - Cross-Database Unified Syntax: Works out of the box across MySQL, PostgreSQL, SQLite, and MSSQL.
Add an "action_triggers" (or "triggers") array to LOC_CONFIG/entity/<route>.json:
| Field | Type | Description |
|---|---|---|
name |
string |
Human-readable identifier for the trigger (appears in logs and audit trails). |
event |
string |
Lifecycle event to listen for: "on_create" (or "create"), "on_update" (or "update"), "on_delete" (or "delete"). |
condition |
object |
(optional) Filter specifying when the trigger activates. |
condition.field |
string |
Column name to monitor for changes (e.g. "status"). |
condition.from |
string / array |
Required previous value(s) (e.g. "CONFIRMED" or ["CONFIRMED", "APPROVED"]). Supports "*" for any. |
condition.to |
string |
Target value that activates the trigger (e.g. "SHIPPED"). Supports "*" for any. |
actions[] |
array |
Sequential list of actions executed atomically within the database transaction. |
| Action Type | Key Attributes | Purpose |
|---|---|---|
iterate_detail (or loop_detail) |
target_table, actions[] |
Queries relational detail line items matching the parent header and executes nested actions for each line item. |
lookup |
target_table, filter, as, optional |
Queries master reference data (e.g. product catalog, currency rates) by key and injects the row attributes into the trigger evaluation context under the specified alias (e.g. {product.cost_price}). If optional: false (default) and the record is not found, the transaction aborts with an HTTP 400 error. |
accumulate |
accumulate: { "<metric>": "<expression>" } |
Evaluates arithmetic expressions per detail line and accumulates a running total in the runtime context (accessible via {acc.<metric>}). |
update |
target_table, filter, set, validate, atomic |
Updates records in target_table. Supports dynamic arithmetic (set), boundary validations (validate.min), and row-level locking (atomic: true). |
insert |
target_table, values (or fields) |
Inserts a new single row into target_table with dynamic placeholder resolution, type coercion, and formula calculations. |
insert_batch |
target_table, rows[], validate |
Bulk-inserts multiple rows within the transaction (e.g. balanced GL journal vouchers). Supports validate.assert_balanced for double-entry ledger verification. |
sql |
statement, params[] |
Parameterized raw SQL escape hatch. Placeholders ({:?}, ?, $1, @p1) are automatically rehydrated per database dialect. |
High-volume ERP environments face race condition risks during inventory deductions and account balance updates (e.g. concurrent checkout requests attempting to fulfill orders against the same remaining stock).
To eliminate dirty reads and stock overdrafts:
- Flexurio defaults
atomic: trueonupdateactions. - For PostgreSQL and MySQL, the engine automatically injects a
SELECT ... FOR UPDATErow lock on the target records prior to modification. - For SQLite and MSSQL, operations are serialized within the exclusive ACID database transaction.
- If another transaction has locked the row, the incoming request waits until the lock is released or throws a transaction timeout, guaranteeing complete serializability.
Use validate inside update and insert_batch actions to enforce business invariants:
Prevents negative inventory balances:
"validate": {
"min": {
"qty_available": 0
},
"error_message": "Insufficient inventory for product {item.product_id} lot {item.lot_number}"
}If the resulting calculated value drops below the threshold, the transaction rolls back immediately with an HTTP 400 Bad Request.
In financial ledgers, unbalanced journal entries violate fundamental accounting principles:
"validate": {
"assert_balanced": {
"debit_field": "debit",
"credit_field": "credit",
"tolerance": 0.001
},
"error_message": "General Ledger transaction unbalanced: total debits must equal total credits"
}The engine sums all rows in insert_batch and verifies
Flexurio includes an enterprise recursive-descent math evaluator for dynamic calculations inside set, values, rows, and accumulate:
- Syntax:
{calc: expression}(e.g.{calc: item.qty * lookup.product.cost_price}or{calc: parent.subtotal * 0.11}). - Operators:
+,-,*,/,%with standard operator precedence and parentheses(...). - Math Functions:
round(value, decimals)— e.g.round(parent.subtotal * 0.11, 2)floor(value)ceil(value)abs(value)min(a, b)max(a, b)
- Direct Variable Support: Variables inside formulas can be referenced with or without braces:
{calc: item.qty * product.cost_price}or{calc: {item.qty} * {product.cost_price}}.
Individual actions within an action_triggers pipeline can be conditionally executed:
{
"type": "insert",
"target_table": "transaction_shipping_notice",
"condition": {
"field": "shipping_method",
"from": "*",
"to": "COURIER"
},
"values": { ... }
}Inline ternary conditional strings are also supported:
"{if: parent.is_taxable == 1 ? {calc: parent.subtotal * 0.11} : 0}"
Strings inside filter, set, values, rows, and error_message support dynamic token replacement:
| Token Pattern | Description | Example |
|---|---|---|
{parent.<column>} |
Value from the updated parent header document |
{parent.id}, {parent.customer_id}, {parent.total_amount}
|
{item.<column>} |
Value from the current child detail item (inside iterate_detail) |
{item.product_id}, {item.lot_number}, {item.qty}
|
{lookup.<alias>.<col>} or {<alias>.<col>}
|
Master record field retrieved via a lookup action |
{lookup.product.cost_price}, {product.standard_cost}
|
{acc.<name>} |
Running total aggregated across detail lines via accumulate
|
{acc.total_cogs}, {acc.total_weight}
|
{calc:<expression>} |
Arithmetic calculation evaluated via the math engine |
{calc: item.qty * product.cost_price}, {calc: round(parent.subtotal * 0.11, 2)}
|
{if: cond ? val1 : val2} |
Inline conditional ternary expression | {if: parent.is_taxable == 1 ? 11 : 0} |
{request.<field>} |
Value supplied in the incoming HTTP request payload |
{request.status}, {request.notes}
|
{now:FORMAT} |
Current timestamp formatted with date tokens |
{now:YYYY-MM-DD} 2026-09-05
|
{seq:KEY:PATTERN} or {seq:KEY}
|
Atomic running sequence number generator (e.g. {seq:INV:INV/{YYYY}/{MM}/{0000ID}}). Downstream steps can reuse {seq:KEY} to share the exact same number across documents. |
{seq:AR_INVOICE:INV/{YYYY}/{MM}/{0000ID}} INV/2026/09/0001
|
| `{token | default}` | Fallback syntax if the variable is null or absent |
Note on Action
update: Thesetdictionary inupdateactions supports arithmetic modifications ("qty": "qty - {item.qty}"), as well as non-numeric column assignments including status transitions ("status": "FULFILLED"), dates ("closed_at": "{now:YYYY-MM-DD}"), booleans ("is_closed": true), and null.State Machine &
locked_whenHarmony: When a document is locked (e.g.,status IN ('APPROVED', 'POSTED')), users with authorized roles listed instate_machine.transitions[].rolescan still submit status transitions (e.g.,APPROVED$\to$ SHIPPED). The engine strictly permits only the status change while prohibiting modifications to any locked business columns.
Here is the complete configuration for LOC_CONFIG/entity/transaction_sales_order.json featuring master data lookup, line item COGS accumulation, lot inventory deduction with row-level locks, AR invoice creation, and balanced GL journal posting:
{
"table": "transaction_sales_order",
"primary_key": {
"columns": ["id"],
"auto_increment": true
},
"columns": [
{ "name": "id", "type_data": "int", "auto_increment": true, "nullable": false },
{ "name": "so_number", "type_data": "varchar(50)", "nullable": false },
{ "name": "customer_id", "type_data": "int", "nullable": false },
{ "name": "order_date", "type_data": "date", "nullable": false },
{ "name": "subtotal", "type_data": "decimal(15,2)", "default_value": "0" },
{ "name": "total_amount", "type_data": "decimal(15,2)", "default_value": "0" },
{ "name": "status", "type_data": "varchar(20)", "default_value": "'DRAFT'" },
{ "name": "notes", "type_data": "text", "nullable": true }
],
"locked_when": {
"status": ["SHIPPED", "PAID", "POSTED", "CANCELLED"],
"except_columns": ["notes"]
},
"state_machine": {
"field": "status",
"initial": "DRAFT",
"transitions": [
{ "from": "DRAFT", "to": "CONFIRMED", "roles": ["sales", "sales_manager", "admin"] },
{ "from": "CONFIRMED", "to": "SHIPPED", "roles": ["warehouse", "logistics", "admin"] },
{ "from": "SHIPPED", "to": "PAID", "roles": ["finance", "admin"] },
{ "from": ["DRAFT", "CONFIRMED"], "to": "CANCELLED", "roles": ["sales_manager", "admin"] }
]
},
"details": [
{
"field": "items",
"target_table": "transaction_sales_order_item",
"foreign_key_column": "sales_order_id",
"parent_key_column": "id",
"columns": ["product_id", "lot_number", "qty", "unit_price"],
"update_strategy": "replace"
}
],
"action_triggers": [
{
"name": "sales_order_fulfillment",
"event": "on_update",
"condition": {
"field": "status",
"from": ["CONFIRMED", "APPROVED"],
"to": "SHIPPED"
},
"actions": [
{
"type": "iterate_detail",
"target_table": "transaction_sales_order_item",
"actions": [
{
"type": "lookup",
"target_table": "master_product",
"filter": {
"id": "{item.product_id}"
},
"as": "product"
},
{
"type": "accumulate",
"accumulate": {
"total_cogs": "{calc: item.qty * product.cost_price}"
}
},
{
"type": "update",
"target_table": "transaction_product_lot",
"atomic": true,
"filter": {
"product_id": "{item.product_id}",
"lot_number": "{item.lot_number}"
},
"set": {
"qty_available": "qty_available - {item.qty}"
},
"validate": {
"min": {
"qty_available": 0
},
"error_message": "Insufficient inventory for product {item.product_id} lot {item.lot_number}"
}
}
]
},
{
"type": "insert",
"target_table": "transaction_account_receivable",
"values": {
"so_id": "{parent.id}",
"customer_id": "{parent.customer_id}",
"invoice_date": "{now:YYYY-MM-DD}",
"due_date": "{now+30d:YYYY-MM-DD}",
"amount": "{parent.total_amount}",
"status": "UNPAID"
}
},
{
"type": "insert_batch",
"target_table": "transaction_general_ledger_line",
"validate": {
"assert_balanced": {
"debit_field": "debit",
"credit_field": "credit",
"tolerance": 0.001
},
"error_message": "GL Transaction unbalanced: debits and credits must match"
},
"rows": [
{
"reference_id": "{parent.id}",
"account_code": "1120",
"description": "AR - Invoice {parent.so_number}",
"debit": "{parent.total_amount}",
"credit": 0
},
{
"reference_id": "{parent.id}",
"account_code": "4100",
"description": "Sales Revenue - {parent.so_number}",
"debit": 0,
"credit": "{parent.subtotal}"
},
{
"reference_id": "{parent.id}",
"account_code": "2150",
"description": "VAT Output Tax (11%)",
"debit": 0,
"credit": "{calc: parent.total_amount - parent.subtotal}"
},
{
"reference_id": "{parent.id}",
"account_code": "5100",
"description": "Cost of Goods Sold - {parent.so_number}",
"debit": "{acc.total_cogs}",
"credit": 0
},
{
"reference_id": "{parent.id}",
"account_code": "1300",
"description": "Inventory Finished Goods Asset",
"debit": 0,
"credit": "{acc.total_cogs}"
}
]
}
]
}
],
"get": { "enable_method": true, "columns": ["id", "so_number", "customer_id", "subtotal", "total_amount", "status", "notes"] },
"post": { "enable_method": true, "columns": ["so_number*", "customer_id*", "order_date*", "subtotal*", "total_amount*", "status", "notes"] },
"put": { "enable_method": true, "columns": ["so_number", "customer_id", "order_date", "subtotal", "total_amount", "status", "notes"] }
}The client sends a clean partial update request:
PATCH /transaction_sales_order/105 HTTP/1.1
Host: api.flexurio.com
Authorization: Bearer <token>
Content-Type: application/json
{
"status": "SHIPPED"
}Success Response (200 OK):
{
"success": true,
"message": "Data updated successfully",
"total_data": 1,
"data": {
"id": 105,
"status": "SHIPPED"
}
}If any lot does not have enough stock to satisfy line item quantities, the engine halts immediately and executes a rollback:
Validation Failure Response (400 Bad Request):
{
"success": false,
"message": "Trigger 'sales_order_fulfillment' action failed: Insufficient inventory for product 12 lot LOT-2026-A1",
"total_data": 0,
"data": null
}Result: The Sales Order status remains CONFIRMED, no inventory is deducted, no AR invoice is created, and no GL lines are posted. Database integrity is 100% preserved.
If journal debits do not equal credits within the specified tolerance, the trigger transaction immediately rolls back:
{
"success": false,
"message": "Trigger 'sales_order_fulfillment' action failed: GL Transaction unbalanced: debits and credits must match (total_debit=250000.00, total_credit=225000.00)",
"total_data": 0,
"data": null
}In enterprise supply chain operations, shipping is frequently handled via a dedicated Delivery Order (Surat Jalan) entity rather than modifying the Sales Order directly. A single Sales Order can be fulfilled across multiple partial shipments.
Flexurio supports this natively by placing action_triggers on transaction_delivery_order:
{
"table": "transaction_delivery_order",
"action_triggers": [
{
"name": "delivery_order_dispatch",
"event": "on_update",
"condition": {
"field": "status",
"from": "DRAFT",
"to": "DISPATCHED"
},
"actions": [
{
"type": "iterate_detail",
"target_table": "transaction_delivery_order_item",
"actions": [
{
"type": "update",
"target_table": "transaction_product_lot",
"atomic": true,
"filter": {
"product_id": "{item.product_id}",
"lot_number": "{item.lot_number}"
},
"set": {
"qty_available": "qty_available - {item.qty_shipped}"
},
"validate": {
"min": { "qty_available": 0 },
"error_message": "Insufficient stock for product {item.product_id} lot {item.lot_number}"
}
},
{
"type": "update",
"target_table": "transaction_sales_order_item",
"filter": {
"sales_order_id": "{parent.sales_order_id}",
"product_id": "{item.product_id}"
},
"set": {
"qty_delivered": "qty_delivered + {item.qty_shipped}"
}
}
]
}
]
}
]
}Enterprise ERP and accounting software require strict governance guarantees over document lifecycles to comply with audit regulations (such as SOX, GAAP, and IFRS) and prevent unauthorized status manipulations.
Flexurio provides two native, declarative engine features directly inside each entity schema:
- Document Immutability Lock (
locked_when) - State Machine Transition Matrix (
state_machine)
Once a transactional document (e.g. Sales Order, Purchase Order, Bank Disbursement) reaches a finalized status such as SHIPPED, INVOICED, PAID, or POSTED, modifying line items, quantities, or financial totals corrupts inventory and ledger integrity.
Add "locked_when" to LOC_CONFIG/entity/<route>.json:
{
"table": "transaction_sales_order",
"locked_when": {
"status": ["SHIPPED", "PAID", "POSTED", "CANCELLED"],
"except_columns": ["notes", "internal_memo"]
}
}| Key | Type | Description |
|---|---|---|
<column_name> |
string / array |
The field and corresponding value(s) that trigger the immutability lock (e.g. "status": ["SHIPPED", "PAID"]). |
except_columns |
array |
(optional) List of non-financial columns that remain editable even when locked (e.g. "notes", "internal_memo"). System timestamp columns starting with updated_ are also permitted. |
- Updates (
PUT/PATCH):- Before applying any update, the engine reads the current record from the database.
- If the record matches the
locked_whenconditions and the incoming payload contains fields outsideexcept_columns, the transaction is rolled back immediately:{ "success": false, "message": "Cannot modify locked record: field 'status' is currently 'SHIPPED'", "total_data": 0, "data": null }
- Deletions (
DELETE):- Deleting a locked record is strictly forbidden. The delete request is rejected with HTTP
400 Bad Request:{ "success": false, "message": "Cannot delete locked record: field 'status' is currently 'SHIPPED'", "total_data": 0, "data": null }
- Deleting a locked record is strictly forbidden. The delete request is rejected with HTTP
Business documents must transition through predictable lifecycles (e.g. DRAFT CONFIRMED SHIPPED PAID). Unregulated updates could allow an operator to skip mandatory approval steps or resurrect a cancelled order.
Add "state_machine" to LOC_CONFIG/entity/<route>.json:
{
"table": "transaction_sales_order",
"state_machine": {
"field": "status",
"initial": "DRAFT",
"transitions": [
{ "from": "DRAFT", "to": "CONFIRMED", "roles": ["sales", "sales_manager", "admin"] },
{ "from": "CONFIRMED", "to": "SHIPPED", "roles": ["warehouse", "logistics", "admin"] },
{ "from": "SHIPPED", "to": "PAID", "roles": ["finance", "admin"] },
{ "from": ["DRAFT", "CONFIRMED"], "to": "CANCELLED", "roles": ["sales_manager", "admin"] }
]
}
}| Field | Type | Description |
|---|---|---|
field |
string |
The status column governed by the state machine (e.g. "status", "approval_stage"). |
initial |
string |
The mandatory initial state when a new record is created via POST (defaults to this value if omitted in the payload). |
transitions[] |
array |
List of allowed state transitions. |
transitions[].from |
string / array |
The current state(s) from which transition is valid. Supports "*" for any. |
transitions[].to |
string |
The target state. |
transitions[].roles |
array |
(optional) User roles authorized to perform this transition. If omitted or ["*"], any authenticated user may transition. |
- Illegal State Transitions:
- If a client attempts an illegal state jump (e.g. jumping from
DRAFTdirectly toPAID), the engine rejects the request with HTTP400 Bad Request:{ "success": false, "message": "Illegal state transition on 'status': cannot transition from 'DRAFT' to 'PAID'", "total_data": 0, "data": null }
- If a client attempts an illegal state jump (e.g. jumping from
- Role-Based Transition Guards:
- The user's role is extracted from their authenticated JWT claims (
roleorrl). - If a warehouse operator (
role: "warehouse") attempts to confirm a draft order (which requires role"sales"or"sales_manager"), the engine rejects the request with HTTP403 Forbidden:{ "success": false, "message": "Unauthorized state transition on 'status': role 'warehouse' is not permitted to transition from 'DRAFT' to 'CONFIRMED'", "total_data": 0, "data": null }
- The user's role is extracted from their authenticated JWT claims (
Flexurio supports declarative database seeding for initial master data, lookup tables, and test fixtures.
LOC_SEED/ (or seed/)
├── banks.json # JSON seed
├── bank_types.csv # CSV seed with schema-aware type casting
└── init_roles.sql # Multi-statement raw SQL script
Add "seed": true to LOC_CONFIG/entity/<route>.json:
{
"table": "banks",
"seed": true,
"columns": [
{ "name": "id", "type_data": "int", "auto_increment": true },
{ "name": "name", "type_data": "varchar(50)", "nullable": false },
{ "name": "code", "type_data": "varchar(10)", "nullable": false }
]
}When "seed": true is enabled, the engine registers two administrative endpoints:
POST /seed/<route>POST /generate/seed/<route>
Security: Seed endpoints require an Admin role token (
admin,administrator, or bitmask127/*/127). Non-admin requests receive403 Forbidden.
Seed files are stored in the directory configured by LOC_SEED (default: seed/). The engine automatically detects and loads files named <route>.* or <table_name>.*:
An array of objects matching column names:
[
{ "name": "Bank Central Asia", "code": "BCA" },
{ "name": "Bank Mandiri", "code": "MANDIRI" },
{ "name": "Bank Rakyat Indonesia", "code": "BRI" }
]Comma-separated values with a header row matching column names. Flexurio uses the entity schema to perform schema-aware type casting (converting integers, decimals, booleans, dates, timestamps, and JSON strings, while omitting empty auto-increment PKs):
name,code
Bank Central Asia,BCA
Bank Mandiri,MANDIRI
Bank Rakyat Indonesia,BRIRaw multi-statement DDL/DML script. The engine parses and splits statements safely (preserving semicolons within quotes and ignoring line/block comments) and executes them inside a transaction:
-- Initial seed for banks
INSERT INTO banks (name, code) VALUES ('Bank Central Asia', 'BCA');
INSERT INTO banks (name, code) VALUES ('Bank Mandiri', 'MANDIRI');
INSERT INTO banks (name, code) VALUES ('Bank Rakyat Indonesia', 'BRI');Trigger seeding by sending an authenticated POST request:
curl -X POST http://localhost:8080/seed/banks \
-H "Authorization: Bearer <ADMIN_JWT_TOKEN>"Response:
{
"success": true,
"message": "Seeding for 'banks' completed successfully from 'seed/banks.json' (3 records inserted)",
"total_data": 3,
"data": null
}post, put, and del can run extra logic around the main database operation. All hook strings are prefixed to indicate their kind; an empty string or a value without a recognized prefix is ignored.
| Schema field | Runs | Prefixes |
|---|---|---|
post.validate_data |
Before INSERT (reject on failure) | SQL: or API: |
post.pre_process |
Before INSERT | SQL: |
post.post_process |
After a successful INSERT | SQL: |
put.validate_data |
Before UPDATE (reject on failure) | SQL: or API: |
put.pre_process |
Before UPDATE | SQL: |
put.post_process |
After a successful UPDATE | SQL: |
del.pre_process |
Before DELETE | SQL: |
del.post_process |
After DELETE | SQL: |
These are the actual field names the engine reads. (Older drafts of this README referred to
before/after; those keys are not used.)
"post": {
"enable_method": true,
"pre_process": "SQL:UPDATE counters SET val = val + 1 WHERE name = 'menus'",
"post_process": "SQL:INSERT INTO audit_logs(entity, action, user_id) VALUES('menus','CREATE',{request.created_by_id})",
"columns": ["name"]
}- Placeholders are bound as parameters automatically (SQL‑injection safe) — you do not write
?yourself. - Each hook is a single statement. For multi‑table cascading transactional workflows (such as ERP lot deduction, AR generation, and GL posting), use declarative Action Triggers (§10). For stored procedures, use
patch.pre_process_sp. pre_process/post_processrun in the operation's transaction on SQL backends;validate_dataruns first and can reject the request.
Call an external endpoint and assert on its response before allowing the write. Format:
API:<METHOD>:<URL>|<operator>:<response_path>:<request_path>
Example — only allow a role that the /roles endpoint returns:
"validate_data": "API:GET:http://127.0.0.1:8080/roles|in:data:request.role"SQL: validation expects a query returning an is_valid boolean:
"validate_data": "SQL:SELECT CASE WHEN email NOT LIKE '%@%' THEN FALSE ELSE TRUE END AS is_valid FROM customers WHERE email = {request.email}"The post.columns array lists the fields the POST endpoint accepts. To make a
field mandatory, append a * to its name. The * is only a marker — the engine
strips it and uses the real column name everywhere.
"post": {
"enable_method": true,
"columns": ["name*", "email*", "phone"]
}Here name and email are required; phone is optional. A required field is
rejected when it is absent, null, an empty string, or the literal string
"null", returning:
400 Bad Request — Missing required field: name
Notes:
- A field is also treated as required (even without
*) when itscolumns[]definition has"nullable": falseand"auto_increment": false. The*suffix is the explicit way to enforce it for any column. - Empty datetime/timestamp values (
""or"null") are coerced to SQLNULLbefore the required check runs. - PUT (
put.columns) uses the exact same*convention for updates.
Placeholders are available inside hooks and formula values:
| Placeholder | Expands to |
|---|---|
{request.field} |
A value from the request body. Multipart form fields are supported; text that is valid JSON is parsed. Dotted paths work: {request.user.id}, {request.items.0.price}. |
{table[123].col} |
Subquery (SELECT col FROM table WHERE id = 123). |
{table[{request.id}].col} |
Subquery with a dynamic id taken from the request. |
Notes:
- For
PUT, the path parameter/{id}is not auto‑injected into hooks — includeidin the request body if a formula needs it. - For
POSTwith auto‑increment ids, the new id is not available via{request.*}. To reference it afterwards, use a custom id (columns[].function) or supplyidyourself. - Bindings are numeric/string‑inferred automatically. On PostgreSQL,
?placeholders are rewritten to$1, $2, …internally.
For a route <route> listed in routes.json (each method requires enable_method: true in its schema section):
| Method & path | Schema section | Description |
|---|---|---|
GET /<route>?col.op=value |
get |
Filtered read (automatically embeds child records if details[] configured). |
POST /<route> |
post |
Create (multipart/form‑data; supports file uploads & atomic master‑detail items). Required fields use * suffix — see §13. |
PUT /<route>/{id} |
put |
Update by id (synchronizes child detail records per update_strategy; executes action_triggers). |
PATCH /<route>/{id} |
put |
Partial update by id (updates only sent fields; executes action_triggers & writes audit trail). |
DELETE /<route>/{id} |
del |
Delete (soft or hard per del.type_delete; cascade deletes details if enabled). |
PATCH /<route> |
patch |
Stored‑procedure / parameterized operation. |
TRACE /<route> |
trace |
Custom select + insert / upsert pipeline. |
POST /seed/<route> |
seed |
Trigger database seeding from <LOC_SEED>/<route>.* (Admin only; requires "seed": true). |
POST /generate/seed/<route> |
seed |
Alternative seed endpoint (Admin only; requires "seed": true). |
POST /import/<route> |
post |
Bulk import (CSV / XLSX). See §17. |
GET /export/<route> |
get |
Export (CSV / XLSX). See §17. |
GET /validate/<route> |
— | Validate the entity JSON against the database. |
POST /generate/table/<route> |
— | Create the physical table (requires auto_generate: true; not for core tables). |
Core / system endpoints:
| Method & path | Description |
|---|---|
POST /login |
Authenticate, returns a JWT (see §16). |
POST /register |
Register a user (multipart). |
GET /roles |
List roles. |
GET /healthz |
Health check: { "status": "ok", "db": "up|down", "db_type": "…" }. Returns 503 if the DB is unreachable. |
GET /metrics |
Prometheus‑format metrics. |
GET /static/... |
Static files from LOC_STATIC (directory listing in debug mode). |
curl -X POST http://localhost:8080/login \
-H "Authorization: Basic $(printf 'admin:1234' | base64)"- Credentials are passed via HTTP Basic auth (
Basic base64(email:password)). - Success returns a JWT with claims such as
id,nm(name),rl(roles), and optionalcs(custom claim fromCUSTOME_JWT_QUERY). - Send it on every protected request:
Authorization: Bearer <token>.
The default admin (email admin) is seeded on first start; its generated password is printed to the console.
- Routes in
route_publics(plus/login,/register) are public. - All other routes require a valid
Bearertoken. - IPs/CIDRs in
WHITE_LIST_IPbypass token checks. - Fine‑grained, role‑/endpoint‑based rules can be defined in
LOC_CONFIG/rules.json(per‑methodpermission_id,allowed_fields, andifconditions such as$user.role/$user.id).
When routes.json defines a non‑default converter_token mapping, JWTs are issued by an external identity provider and Flexurio verifies their signature using the CONVERTER_JWT_* variables (§5). This is fail‑closed: with no verification key configured, converter‑token requests are rejected (unless CONVERTER_JWT_INSECURE_SKIP_VERIFY=true).
- Import —
POST /import/<route>with a multipart file. CSV and XLSX are supported; column headers must match the entity's insertable columns. Rows are inserted in batches (IMPORT_BATCH_SIZE). - Export —
GET /export/<route>?type=csv|xlsxreturns the route's data in the requested format (defaults to CSV; falls back to CSV if XLSX generation fails). The same filtering asGET /<route>applies.
Set "encrypt": true on a column to store its value encrypted at rest using ENCRYPT_KEY (AES‑GCM). The engine encrypts on write and decrypts on read transparently. Keep ENCRYPT_KEY secret and stable — rotating it requires re‑encrypting existing data.
- Structured logs cover endpoint registration and query execution; control verbosity with the
LOG_*,DEBUG, andLOGGINGvariables (§5). GET /healthzfor liveness/readiness probes.GET /metricsexposes Prometheus metrics.- An audit trail is written to
LOC_AUDIT. - Logs and static assets are served under
/static(e.g.GET /static/log/). Keep the audit log outsidestatic/so it is not publicly served.
Database backends are gated behind Cargo features so you can build a lean binary with only what you need.
[features]
default = ["mysql", "postgres", "sqlite", "mssql", "mongodb"]
mysql = ["sqlx/mysql", "sqlx/chrono"]
postgres = ["sqlx/postgres", "sqlx/chrono"]
sqlite = ["sqlx/sqlite", "sqlx/chrono"]
mssql = ["tiberius/chrono", "bb8"]
mongodb = ["dep:mongodb"]If you disable a backend but set DB_TYPE to it at runtime, the app exits with an error (e.g. mysql feature disabled).
# Only MySQL
cargo build --release --no-default-features --features mysql
# MySQL + SQLite
cargo build --release --no-default-features --features "mysql sqlite"
# Everything (default)
cargo build --release
# Build & run in one step
cargo run --release --no-default-features --features mysqlSmaller builds compile faster, produce smaller binaries, and remove unused code paths from production.
build.sh produces per‑database, per‑OS binaries with feature‑gated builds, and optionally signs/notarizes macOS artifacts when Apple credentials are present.
Use
./build.shorbash build.sh(notsh build.sh) — macOS ships Bash 3.2.
./build.sh [--db <list>] [--os <list>] [--arch <list>] [--help]--db—mysql,postgres,sqlite,all(defaultall).--os—macos,windows,linux,all(defaultall).--arch—x86_64,aarch64,all(filters after OS expansion, defaultall).
OS group expansion:
macos→x86_64-apple-darwin,aarch64-apple-darwinwindows→x86_64-pc-windows-gnulinux→x86_64-unknown-linux-gnu,aarch64-unknown-linux-gnu
Examples:
./build.sh # all DBs, all OS targets
./build.sh --db mysql # MySQL only, all OS
./build.sh --db mysql,sqlite --os macos # MySQL + SQLite for macOS (both arches)
./build.sh --db postgres --os macos --arch aarch64
./build.sh --db mysql --os windows,linux --arch x86_64Artifacts land in release/ as flx-nocode-<driver>-<target> (Windows adds .exe; signed macOS produces .pkg). With --db all (default) a single combined multi‑driver binary is emitted per target as flx-nocode-<target> for installer compatibility.
For each driver the script runs cargo build --release --target <triple> --no-default-features --features <driver>. macOS signing/notarization activates when APPLE_ID, APPLE_TEAM_ID, an app‑specific password, APPLE_IDENTITY, APPLE_IDENTITY_INS, PRIMARY_BUNDLE_ID, and KEYCHAIN_PROFILE are set; otherwise it just copies the binary.
| Symptom | Cause | Fix |
|---|---|---|
Exit: LOC_CONFIG not set |
Missing env var | Set LOC_CONFIG to a config profile path. |
Panic: Invalid routes.json / ROUTES NOT VALID |
Malformed routes.json |
Validate JSON; ensure at least one route. |
Panic: Cannot read entity file |
Route listed but entity/<route>.json missing |
Create the file or remove the route. |
| Duplicate table error | Two schemas share the same table value |
Rename one. |
401 Unauthorized |
Missing/invalid Authorization header |
Re‑login and send Bearer <token>. |
| Table not found | Table never created | POST /generate/table/<route> (needs auto_generate: true) or create it manually. |
<backend> feature disabled |
DB_TYPE points to a backend not compiled in |
Rebuild with that feature, or change DB_TYPE (§20). |
| Hooks not running | Used before/after keys |
Use pre_process / post_process with the SQL: prefix (§13). |
| Custom id insert fails | function_endpoint unreachable / bad response |
Endpoint must return 2xx JSON with the configured field; or clear function_endpoint to use MAX(id)+1 (§8). |
- Use long, random
SECRET_KEYandENCRYPT_KEY; keep them out of version control. - Rotate keys periodically (reissue tokens; re‑encrypt data if
ENCRYPT_KEYchanges). - Grant the database user least privilege.
- Keep the audit log (
LOC_AUDIT) outside the publicly servedstatic/directory. - Terminate TLS at a reverse proxy (nginx / traefik / Caddy).
- In converter‑token mode, always configure signature verification — avoid
CONVERTER_JWT_INSECURE_SKIP_VERIFY=truein production. - Validate any externally‑supplied formula inputs.
Contributing
- Fork and branch (
feat/<name>). - Make focused commits; keep example configs valid.
- Run
cargo build/cargo test. - Open a PR with a clear description and test notes.
License — see LICENSE (and LICENSE-AGPL).
Credits — Flexurio Engineering Team. Built with Rust + Actix Web.
- Configure
.env(DB_TYPE, URL,SECRET_KEY,ENCRYPT_KEY,LOC_CONFIG). - List routes in
routes.json; addentity/<route>.jsonschemas. - Run the binary. 4. (Optional)
POST /generate/table/<route>. 5.GET /validate/<route>. - Log in, then call the REST endpoints with
Authorization: Bearer <token>.
Happy building. 🚀