Skip to content
falang.ioRU

Databases

A database credential connects a scenario directly to a Postgres or MySQL database — read/write rows through typed actions, or fall back to raw SQL for anything the typed actions don’t cover. Once the schema is synced, the editor knows the connected database’s tables: a “Select” node on orders offers orders as a table choice, its result is typed row by row, and Monaco completes column names as you type a filter.

Add a Postgres or MySQL credential the same way as any other integration, with a single connection string — postgres://user:pass@host:5432/db or mysql://user:pass@host:3306/db — rather than separate host/port/user/password fields, because a dev and a production database are usually two entirely different servers, not just a different password on the same one; a connection string is also what every driver, ORM and cloud console already hands you. An ssl setting (prefer/require/disable) sits alongside it. Like every credential in the engine, the connection string is stored separately for the dev and prod environments and encrypted at rest — the editor and “Sync structure” always use the dev one, a published (production) run always uses prod.

Press “Sync structure” on the credential to make the editor read the database’s real tables and columns. This connects once (using the dev connection string), reads the schema, and stores it — no live database round trip happens while you’re editing a node afterward. Re-run it any time the schema changes (a column added, a table renamed) to bring the editor’s picture back in sync; nothing breaks if you forget, a node referencing a table that no longer matches just fails with a plain database error the next time it runs.

Once synced, every table gets four typed shapes the editor uses automatically: Row (every column, as it comes back from a query), Insert (every column, with columns that have a default value or allow NULL made optional), Patch (every column optional, for a partial update) and Where (see below). Column types map the way you’d expect — integers and floats to numbers, text/dates/UUIDs to strings, booleans to booleans, JSON columns and anything unrecognized to a permissive “any” type.

Each dialect (Postgres, MySQL, SQLite) has the same six actions:

  • Select — returns every matching row as a Row array. Takes an optional where filter, an orderBy (e.g. created_at desc) and a limit.

  • Select one — same as Select but returns a single Row and fails if nothing matches; use Select with a limit of 1 instead when “no rows” is a normal, expected outcome rather than an error.

  • Insert — inserts one row (typed against Insert) and returns it back as a Row, including any column the database filled in itself (an auto-incrementing id, a default timestamp).

  • Update — applies a Patch to every row matching where and returns the number of rows changed. where is required and must not be empty — there is no way to accidentally update every row in a table by leaving the filter blank.

  • Delete — same required-non-empty where rule as Update, returns the number of rows removed.

  • Query — the escape hatch for joins, aggregates, or anything else the five actions above can’t express: a raw SQL statement plus a separate list of parameters bound in as $1/$2 (Postgres) or ? (MySQL/SQLite) placeholders. The SQL text itself is never interpolated with values — parameters always travel separately, the same protection against SQL injection every parameterized-query API gives you.

where is a small filter language, not just equality. A bare value on a column means “equals” (and null means IS NULL); to use anything else, give that column an object with one or more operators — eq, ne, gt, gte, lt, lte, in, notIn, isNull, and, for text columns, like/ilike/ startsWith/contains. Multiple columns are combined with AND by default; wrap alternatives in an explicit or (or and) array to nest further:

// status is "new" or "paid", and total is over 100
{ status: { in: ['new', 'paid'] }, total: { gt: 100 } }
// id is 1 or 2
{ or: [{ id: 1 }, { id: 2 }] }
// note contains "urgent", case-insensitively (Postgres)
{ note: { ilike: '%urgent%' } }

Every database action runs as its own step in the scenario’s durable execution, and like any other step it retries automatically on failure — a network blip, a dropped connection, a momentarily unreachable database. This is usually exactly what you want: a failed write is treated as “the row was never created”, and a retry finishes the job. The one edge case worth knowing about is a write that the database actually committed, but whose acknowledgement never made it back before the connection dropped — a retry in that rare case can insert or update the same row twice. If a table is written by a scenario that might retry, give it a unique constraint (or an explicit idempotency-key column) so a duplicate attempt is rejected by the database itself rather than silently succeeding twice.

SQLite is available as a third dialect, backed by a plain file (or an in-memory database) rather than a server — no separate service to run, which makes it convenient for local development, self-hosted single-server deployments with a mounted volume, and tests. It is not a production choice for a deployment that scales runner pods up and down: a runner pod’s own filesystem does not persist across restarts, so a SQLite file living inside one does not survive a scale-to-zero cycle. Reach for Postgres or MySQL for anything that has to outlive the pod that wrote it.