Using Relational Databases
Golem provides an API to integrate with popular relational database systems from any of the supported languages. The currently supported databases are:
- PostgreSQL
- MySQL
- Apache Ignite 2
SQL permissions and analysis
Golem analyzes each SQL statement before it sends the statement to the database. It checks every
directly identified table target against the agent’s permission cards.
Ordinary grants use database.schema.table resources, with * allowed in each component:
rdbms(?env) @ ?agent : query : mydb.public.notes
rdbms(?env) @ ?agent : mutate : mydb.public.notesquery permits reads; mutate permits writes, table DDL, and table locks. These verbs describe the
SQL operation, not the API method: a write through query still needs mutate. Statements that
read one table and write another need permission for both targets.
The analyzer supports these statement kinds when it can determine their table targets:
SELECT, including joins, subqueries, and common table expressions (CTEs).INSERT,UPDATE,DELETE, andMERGE, including reads from source tables.CREATE TABLE, supportedALTER TABLEoperations,DROP TABLE, andTRUNCATE.CREATE INDEXandLOCK TABLE, which requiremutateon the affected tables.SELECT ... FOR UPDATEandSELECT ... FOR SHARE, which requiremutateon the locked tables. AnOFclause selects those tables through their names or aliases.- PostgreSQL
ON CONFLICT (columns) WHERE predicate, when the predicate contains no subquery.
CTE names are local to their SQL scope, not database table targets. Reads and writes inside CTEs
still require the corresponding grants on the underlying tables. Supported table DDL can also
require query on source or referenced tables, for example in CREATE TABLE ... AS SELECT.
Support for a statement kind does not mean that every dialect-specific clause is supported.
Send one statement per call.
PostgreSQL relation names
For analyzed PostgreSQL statements, an unqualified physical relation name means the public
schema, not the session’s search_path. Golem qualifies these names in executable SQL so that
execution uses the same relation namespace as the permission check. This includes read and write
targets, created tables, index target tables, and foreign-key references. For example,
SELECT * FROM notes reads public.notes, and CREATE TABLE notes (...) creates public.notes.
Use an explicit schema to select another namespace. Explicit schemas remain explicit.
CTE references, aliases, column names, and comments are not relation targets and remain unchanged.
A PostgreSQL RENAME destination stays in the source relation’s schema; it does not default to
public. Golem does not issue session SET commands to implement this contract.
Unsupported SQL
Not all SQL can be analyzed. For example, CREATE SCHEMA and CREATE EXTENSION do not have ordinary
table targets. Table functions, derived VALUES tables, SELECT INTO, CREATE VIEW, EXPLAIN, and
CALL are also unsupported. PostgreSQL temporary CREATE TABLE requires explicit unanalyzed
authority rather than deterministic relation qualification. Row locks require a plain SELECT with direct table references;
OF must name one unqualified table name or alias per lock clause, not a CTE. Locks through derived
tables, FOR NO KEY UPDATE, and FOR KEY SHARE are not supported.
Unquoted PostgreSQL relation-modifier ONLY forms in SELECT, UPDATE, and DELETE require
explicit unanalyzed authority. Supported LOCK TABLE ONLY remains available with ordinary grants.
Parsing or target-analysis failures are denied before execution unless the agent
has an explicit grant for unanalyzed SQL on the connection’s database:
rdbms(?env) @ ?agent : unanalyzed : database(mydb)This capability requires the explicit unanalyzed verb and a database(...) resource.
It is not included in the default agent card, wildcard table grants such as mydb.*.*, or wildcard
verbs. Prefer a concrete database name. unanalyzed : database(*) deliberately covers every
connection database; it is not a table wildcard. Grant this capability only to trusted code that
needs unsupported SQL.
Golem always attempts analysis first, even with this grant. If analysis succeeds, every table
target must pass the ordinary query or mutate permission check. Insufficient table permission
is a denial, not a reason to fall back to unanalyzed. Only unsupported analysis permits the
explicit database-scoped fallback. Permission and analysis denials surface as query-execution
failures; inspect the error message to distinguish an unsupported statement from a missing grant.
The fallback executes the original SQL without deterministic namespace rewriting.
An unanalyzed grant does not make invalid SQL valid: the database can still reject it.
database(mydb) scopes the fallback to a connection whose database is mydb. It does not prove
that arbitrary SQL affects only that database. The database role still enforces its privileges.
Use a least-privilege database role; do not treat this grant as table-level isolation.
Executable expressions and the database trust boundary
A statement is not safe to analyze merely because it has no table references. A function call
such as SELECT protected_reader() can read or change database data. Explicit function calls,
custom operators, and casts must belong to the deliberately supported expression subset;
unresolved or unsupported forms require unanalyzed. The same rule applies inside nested
statements and partial-index conflict predicates. Explicit operator classes and index access
methods must also be supported; an ordinary table grant does not authorize arbitrary executable
implementations selected by index DDL.
The supported PostgreSQL exceptions include zero-argument pg_catalog.now() and casts to
pg_catalog.regclass or pg_catalog.uuid from a string literal or a parameter with the built-in
text type. Golem qualifies unqualified now(), ::regclass, and ::uuid before execution. A custom-typed
parameter does not qualify for these cast exceptions, even if the database can convert it.
Bare built-in PostgreSQL DEFAULT CURRENT_TIMESTAMP also works with ordinary grants, alongside
now(). This does not authorize every timestamp routine or form.
For approved regclass casts, Golem normalizes one-part string literals offline into safely
escaped E'public.name' literals. Explicitly schema-qualified literals remain unchanged. Keeping
these casts constant preserves PostgreSQL early OID binding, stored constraints after table
renames, and index predicate immutability. Only built-in text parameter placeholders use a trusted
pg_catalog.regexp_match expression to detect dots outside double quotes, then
pg_catalog.format('public.%s', input) to prefix one-part names at runtime. Offline literals use
the same quote/dot boundary rule. Both follow PostgreSQL’s leading-quote rule: a double quote starts
quoting only at the beginning of a name part. Explicit multi-part input remains unchanged. Both forms preserve
native regclass spelling, including unquoted migration-log, rather than enforce SQL identifier
grammar. They also preserve quoted case and explicit schemas. Golem changes
only executable SQL: the original bound parameters and the durable SQL request remain unchanged.
Literal and built-in text parameter normalization recognize exactly PostgreSQL scanner whitespace:
ASCII space, tab, LF, CR, vertical tab, and form feed. Unicode whitespace remains part of an unquoted name.
Ordinary MySQL NOW() uses the built-in CURRENT_TIMESTAMP form. Other explicit routines,
casts, operator classes, and index access methods require unanalyzed unless supported.
Ordinary grants authorize directly identified relations and supported expressions. They do not enumerate all indirect effects of the database objects involved. A view can read other tables; triggers, defaults, policies, constraints, cascades, index expressions, and custom types can run database code. Database administrators must control these objects and trusted routines. Use a least-privilege database role as well as permission cards. The analyzer is not a sandbox for arbitrary server-side code. In particular, ordinary operator syntax can invoke routines selected by custom parameter or column types; those types and their implicit operations remain trusted.
The database namespace is also trusted administrator-controlled configuration. Administrators
must control the schemas in the session’s search_path and prevent untrusted objects from
shadowing built-in types or routines. This applies to connection options and session changes,
not only to the SQL submitted by the agent. Golem does not enforce a safe search_path.
SQLx can issue internal metadata queries that do not pass through Golem’s SQL analyzer. For
example, its type discovery uses oid::regtype::text; a shadow text domain can run a constraint
function during that query. Catalog qualification of the guest statement does not protect these
driver queries. Permission cards do not isolate an agent from a hostile database namespace,
even when its submitted statement belongs to the supported subset or has no table targets.
Transactions and migrations
Starting a transaction currently requires both query and mutate on mydb.*.*, even when the
transaction only reads or touches one table. Statements inside the transaction are still analyzed
and authorized individually. An unanalyzed grant does not replace the transaction-start grants.
Normal migrations that create or alter tables, create indexes, lock tables, or update migration
tracking rows should use ordinary grants. Give the migration agent access to its tracking table
and every application table it reads or changes. Add unanalyzed only if a required statement
cannot be analyzed, such as CREATE EXTENSION, after reviewing its effects and database role.
The Effect adapter works with Migrator from effect/unstable/sql through the canonical SqlClient
service. This path does not automatically grant broad permissions or bypass SQL analysis. See
the Effect PostgreSQL guide for adapter setup.
PostgreSQL regclass results, including Migrator’s table-existence check, use the API’s OID representation.
See selected API types and signatures
TypeScript
declare module 'golem:rdbms/types@1.5.0' {
export type Uuid = {
highBits: bigint;
lowBits: bigint;
};
export type Date = {
year: number;
month: number;
day: number;
};
export type Time = {
hour: number;
minute: number;
second: number;
nanosecond: number;
};
export type Timestamp = {
date: Date;
time: Time;
};
export type Timestamptz = {
timestamp: Timestamp;
offset: number;
};
export type Timetz = {
time: Time;
offset: number;
};
}
declare module 'golem:rdbms/postgres@1.5.0' {
import * as golemRdbms150Types from 'golem:rdbms/types@1.5.0';
export class LazyDbValue {
constructor(value: DbValue);
get(): DbValue;
}
export class LazyDbColumnType {
constructor(value: DbColumnType);
get(): DbColumnType;
}
export class DbResultStream {
/** @throws Error */
getColumns(): DbColumn[];
/** @throws Error */
getNext(): DbRow[] | undefined;
}
export class DbConnection {
/** @throws Error */
static open(address: string): DbConnection;
/** @throws Error */
query(statement: string, params: DbValue[]): DbResult;
/** @throws Error */
queryStream(statement: string, params: DbValue[]): DbResultStream;
/** @throws Error */
execute(statement: string, params: DbValue[]): bigint;
/** @throws Error */
beginTransaction(): DbTransaction;
}
export class DbTransaction {
/** @throws Error */
query(statement: string, params: DbValue[]): DbResult;
/** @throws Error */
queryStream(statement: string, params: DbValue[]): DbResultStream;
/** @throws Error */
execute(statement: string, params: DbValue[]): bigint;
/** @throws Error */
commit(): void;
/** @throws Error */
rollback(): void;
}
export type Timestamp = golemRdbms150Types.Timestamp;
export type Timestamptz = golemRdbms150Types.Timestamptz;
export type Date = golemRdbms150Types.Date;
export type Time = golemRdbms150Types.Time;
export type Timetz = golemRdbms150Types.Timetz;
export type Uuid = golemRdbms150Types.Uuid;
export type Error =
| { tag: 'connection-failure'; val: string }
| { tag: 'query-parameter-failure'; val: string }
| { tag: 'query-execution-failure'; val: string }
| { tag: 'query-response-failure'; val: string }
| { tag: 'other'; val: string }
| { tag: 'resource-lost'; val: string };
export type SparseVec = {
dim: number;
indices: number[];
values: number[];
};
export type DbValue =
| { tag: 'text'; val: string }
| { tag: 'varchar'; val: string }
| { tag: 'timestamp'; val: Timestamp }
| { tag: 'timestamptz'; val: Timestamptz }
| { tag: 'json'; val: string }
| { tag: 'jsonb'; val: string }
| { tag: 'uuid'; val: Uuid }
| { tag: 'array'; val: LazyDbValue[] }
| { tag: 'vector'; val: number[] }
| { tag: 'halfvec'; val: number[] }
| { tag: 'sparsevec'; val: SparseVec }
| { tag: 'null' };
export type DbColumnType =
| { tag: 'text' }
| { tag: 'varchar' }
| { tag: 'timestamp' }
| { tag: 'timestamptz' }
| { tag: 'json' }
| { tag: 'jsonb' }
| { tag: 'uuid' }
| { tag: 'array'; val: LazyDbColumnType }
| { tag: 'vector' }
| { tag: 'halfvec' }
| { tag: 'sparsevec' };
export type DbColumn = {
ordinal: bigint;
name: string;
dbType: DbColumnType;
dbTypeName: string;
};
export type DbRow = {
values: DbValue[];
};
export type DbResult = {
columns: DbColumn[];
rows: DbRow[];
};
}
declare module 'golem:rdbms/mysql@1.5.0' {
import * as golemRdbms150Types from 'golem:rdbms/types@1.5.0';
export class DbResultStream {
/** @throws Error */
getColumns(): DbColumn[];
/** @throws Error */
getNext(): DbRow[] | undefined;
}
export class DbConnection {
/** @throws Error */
static open(address: string): DbConnection;
/** @throws Error */
query(statement: string, params: DbValue[]): DbResult;
/** @throws Error */
queryStream(statement: string, params: DbValue[]): DbResultStream;
/** @throws Error */
execute(statement: string, params: DbValue[]): bigint;
/** @throws Error */
beginTransaction(): DbTransaction;
}
export class DbTransaction {
/** @throws Error */
query(statement: string, params: DbValue[]): DbResult;
/** @throws Error */
queryStream(statement: string, params: DbValue[]): DbResultStream;
/** @throws Error */
execute(statement: string, params: DbValue[]): bigint;
/** @throws Error */
commit(): void;
/** @throws Error */
rollback(): void;
}
export type Date = golemRdbms150Types.Date;
export type Time = golemRdbms150Types.Time;
export type Timestamp = golemRdbms150Types.Timestamp;
export type Error =
| { tag: 'connection-failure'; val: string }
| { tag: 'query-parameter-failure'; val: string }
| { tag: 'query-execution-failure'; val: string }
| { tag: 'query-response-failure'; val: string }
| { tag: 'other'; val: string }
| { tag: 'resource-lost'; val: string };
export type DbColumnType =
| { tag: 'varchar' }
| { tag: 'text' }
| { tag: 'timestamp' }
| { tag: 'json' };
export type DbColumn = {
ordinal: bigint;
name: string;
dbType: DbColumnType;
dbTypeName: string;
};
export type DbValue =
| { tag: 'varchar'; val: string }
| { tag: 'text'; val: string }
| { tag: 'timestamp'; val: Timestamp }
| { tag: 'json'; val: string }
| { tag: 'null' };
export type DbRow = {
values: DbValue[];
};
export type DbResult = {
columns: DbColumn[];
rows: DbRow[];
};
}Executing SQL statements
To execute an SQL statement with golem-rdbms, first create a db-connection resource and call execute on it:
The Effect examples below run inside a Golem Effect agent, whose runtime supplies the database host services. Creating an Effect does not execute it. Database failures use the SqlError error channel; handle them or map them to the agent method’s declared error type.
MySQL
TypeScript
import { DbConnection } from "golem:rdbms/mysql@1.5.0";
// Connecting to the database called 'test' with user 'root'
const conn = DbConnection.open("mysql://root@localhost:3306/test");
conn.execute(
`CREATE TABLE IF NOT EXISTS test_users
(
user_id varchar(25) NOT NULL,
name varchar(255) NOT NULL,
created_on timestamp NOT NULL DEFAULT NOW(),
PRIMARY KEY (user_id)
);`,
[]
)The functions in the golem:rdbms/mysql@1.5.0 module are not async. This is due to a limitation of the current Golem runtime, and are subject to change in future releases.
PostgreSQL
TypeScript
import { DbConnection } from "golem:rdbms/postgres@1.5.0";
// Connecting to the database called 'test' with user 'user'
const conn = DbConnection.open("postgresql://user@localhost:5432/test");
conn.execute(
`CREATE TABLE IF NOT EXISTS test_users
(
user_id varchar(25) NOT NULL,
name varchar(255) NOT NULL,
created_on timestamp NOT NULL DEFAULT NOW(),
PRIMARY KEY (user_id)
);`,
[]
)The functions in the golem:rdbms/postgres@1.5.0 module are not async. This is due to a limitation of the current Golem runtime, and are subject to change in future releases.
Apache Ignite
TypeScript
import { DbConnection } from "golem:rdbms/ignite2@1.5.0";
// Connecting to an Apache Ignite 2 instance
const conn = DbConnection.open("ignite://localhost:10800");
conn.execute(
`CREATE TABLE IF NOT EXISTS test_users
(
user_id varchar(25) NOT NULL,
name varchar(255) NOT NULL,
PRIMARY KEY (user_id)
) WITH "CACHE_NAME=TestUsers";`,
[]
)
// Querying data
const result = conn.query("SELECT user_id, name FROM test_users WHERE name = ?", [
{ tag: "db-string", val: "hello" }
]);
// Using transactions
const tx = conn.beginTransaction();
tx.execute("INSERT INTO test_users (user_id, name) VALUES (?, ?)", [
{ tag: "db-string", val: "1" },
{ tag: "db-string", val: "Alice" }
]);
tx.commit();The functions in the golem:rdbms/ignite2@1.5.0 module are not async. This is due to a limitation of the current Golem runtime, and are subject to change in future releases.
API
Additionally you can:
queryexecutes a SQL statement and returns a resultquery-streamexecutes a SQL statement and returns a streaming result where the selected SDK exposes streamingbegin-transactioncreates a transaction resource on which, in addition to thequeryandexecutefunctions, there is also acommitand arollbackmethod.
At the WIT boundary, a result stream’s accessors are fallible:
get-columns: func() -> result<list<db-column>, error>;
get-next: func() -> result<option<list<db-row>>, error>;Handle the error arm before consuming columns or rows. Only a successful none from get-next
means the stream ended. Recorded results replay unchanged. A transaction or stream that cannot
continue after reconstruction reports resource-lost (ResourceLost in the SDKs) at its live
boundary. This error is permanent for that resource; retrying it does not restore the transaction
or stream. Golem does not silently rerun the transaction or neighboring effects. Open a new
transaction explicitly and reconcile uncertain external outcomes before repeating business work.
Loss does not prove a prior write failed.