Run SQL
Actionaction.database_sql
Runs one SQL statement you write yourself against a local SQLite database, with {{variables}} filled in as bound values, never spliced into the text.
The other four Database nodes cover the everyday shapes with no SQL at all. Reach for Run SQL when you want the real thing: a join, a GROUP BY, an index, a view, a wide CREATE TABLE you want to shape by hand.
A variable holding '; DROP TABLE entries;-- is stored as those exact characters, in a column, and the statement stays the statement you wrote. Binding is not a setting to turn on; it is the only way this node ever puts a value into a statement.
Each database is one ordinary .sqlite file under Watchflows' Application Support folder. Open it in any SQLite tool you like; it is your file. Naming, privacy and deletion live in Databases.
Ports
| Direction | Name | Data Type | Description |
|---|---|---|---|
| Input | Input | Any | Incoming payload, passed through, and the source of every {{variable}} the node resolves |
| Output | Output | Any | The incoming payload merged with this node's result keys |
Writing the statement
SELECT body, created_at
FROM clips
WHERE source = {{app}}
ORDER BY created_at DESC
Because values are bound, there are two places a variable cannot go, and both are refused before SQLite ever sees the statement:
- Where a name belongs.
SELECT * FROM {{table}}can't work; SQL binds values, not names. Type the table name. - Inside quotes.
LIKE '%{{q}}%'would store the braces as text. Write it outside the quotes instead:
SELECT * FROM clips WHERE body LIKE '%' || {{q}} || '%'
If Watchflows reads a variable as a name when you meant a value, wrap it in its own parentheses, ({{x}}), which always means "this is a value".
A few more rules: one statement at a time (a trailing semicolon is fine, two statements are not); a variable inside a -- or /* */ comment is left alone; and a hand-typed ? or :name is refused, because every value comes from a {{variable}}. A variable that isn't in the node's input stops the run and names the keys that are available; in SQL, an empty string would change which rows the statement touches.
Anything longer than a line is easier in the SQL Workbench: click ⇲ on the SQL field and the window becomes an editor with your results beside it, ⌘⏎ to run. See The Workbench.
What this node hands downstream
This is the one Database node whose output shape depends on what you wrote: a SELECT hands on the read keys below, a write statement hands on rowsAffected and rowId.
| Key | When | Description |
|---|---|---|
| records | SELECT | The rows. A real array; Loop over it, or index it: {{records.0.body}}. |
| first | SELECT | The leading row, or null on an empty result. |
| count | SELECT | How many records came back. Always the length of records. |
| columns | SELECT | Column names in selection order. A name that appears twice (a join) is suffixed _2. |
| columnTypes | SELECT | text, integer, real, blob or null, one per column. |
| truncated | SELECT | true when the result hit the row or byte ceiling. truncated_reason then says row_limit or byte_limit. |
| rowsAffected | write statement | How many rows the statement changed. 0 is a normal answer. |
| rowId | INSERT | The managed id of the row just written, when the statement was an insert. |
Caps on reads. A SELECT never returns more than 5,000 rows or 8 MB. When a result is cut, it says so, in truncated, in the run log, and under the results table.
A run that returned rows also draws them as a table in the run log, under the step; sortable by clicking a header, with copy-as-TSV/CSV/JSON on the right-click menu.
What this node cannot do
A handful of statements are turned away before SQLite sees them. Each refusal explains itself in plain English rather than surfacing a raw SQLite error.
| Refused | Why |
|---|---|
ATTACH / DETACH | Attaching is how one database reaches another. This node reaches the one you picked and nothing else. |
| Pragmas that write | They change how the file itself behaves. Read-only introspection, PRAGMA table_info and friends, stays available. |
CREATE TRIGGER | A trigger is stored logic that fires on somebody else's write. If a flow should react to a write, wire the flow. |
BEGIN / COMMIT / SAVEPOINT | Watchflows already wraps each statement in its own transaction. A half-open one left behind by a failed run would hold a lock nothing releases. |
| Extension loading, file-reading functions | Both turn SQL into a way to reach the rest of your Mac. |
Everything else you would expect is there, including CREATE TABLE, ALTER TABLE, CREATE INDEX, views, and DROP; refusing those would be theater when the app can delete a whole database. A statement also stops on its own after 30 seconds, so a runaway join can't hold a flow open.
Sharing a flow that uses a database
This node finds its database by id, not by name, so renaming a database in Settings never breaks a flow. That id is per-machine, though: when a flow arrives from somebody else (a Community install, an emailed .watchflow), its Database field is blanked and marked needs setup, and the flow can't be enabled until you pick a database. It keeps the author's name for it, so you can see what it was. Watchflows never invents a database for an id it doesn't recognise.
Asking the Flow Builder for one
The AI Flow Builder can author this node, including the statement itself. It refers to a database by name, the way you do, and Watchflows turns that name into the id. It is never handed an id, and it never invents one.
- The match is exact, ignoring capitalisation; near misses are not guessed at, because the cost of running SQL against the wrong database is far higher than the cost of asking.
- Private and deleted databases are never listed to it and never resolve. Marking a database private hides it from the AI as thoroughly as from the picker.
- A name that matches nothing, including on a Mac with no databases yet, leaves the node marked needs setup. The flow is built; you pick the database.
- Once a node is pointed at a database, chat cannot move it. Naming a different one in a later turn leaves the binding where it is and says so in the checklist; the node's own Database field is what changes it, because a node aimed at the wrong database runs real SQL against the wrong place and reports success.