A deployed app reads and writes a per-app PostgreSQL database. You write the schema as migrations/, grant each function table access in gipity.yaml, and query from function code through the injected db helper. This is the database reference; for writing the functions themselves see app-development, and for the migration / deploy phase see deploy.
Setup
db_manage- create or drop databasesdb_list- list existing databasesdb_sql- execute SQL (SELECT, INSERT, UPDATE, DELETE, CREATE TABLE, etc.)
Use PostgreSQL syntax: SERIAL, BOOLEAN, TIMESTAMPTZ, JSONB, TEXT.
Primary keys: use a short_guid VARCHAR(20) PK (as the seeded tables do), not BIGSERIAL - don't expose raw integer ids. Mint the value with the injected guid(prefix) peer (second-arg service, no declaration): guid('adv') → adv_a7k2mq9pd3xe ({prefix}_ + 12 unambiguous crypto-random chars; prefix ≤ 7 chars so the result fits VARCHAR(20)). bigint/BIGSERIAL columns return from functions as JSON strings (node-pg behavior), so coerce/compare them as strings in tests and clients.
Limits: database count is per-plan (run credits_products to see), 500 rows / 128 KB per query, 50,000 chars per statement.
Destructive operations (DROP, TRUNCATE) are auto-confirmed by the platform - just call db_sql directly.
Schema lives in migrations/, not in one-off commands. Every table a function reads or writes must be created by a migrations/NNN-name.sql file (idempotent CREATE TABLE IF NOT EXISTS …) that runs via a database deploy phase in gipity.yaml (see deploy). Creating tables imperatively with a bare db_sql / gipity db query "CREATE TABLE …" works in the moment but the schema isn't captured anywhere: it doesn't ship with the app, a fresh deploy / prod / another user's install has no tables, and the signed-in features that write to them fail at runtime - which looks like broken auth, not a missing table.
Listing a table under a function's tables: in gipity.yaml (see app-development) is a permission - it lets the function touch that table. It does not create the table; the migration does. Both are needed: the migration to make searches exist, and tables: [searches] to let the function use it.
Database Helpers in Functions (via db)
// Raw SQL with parameters
const { rows } = await db.query('SELECT * FROM orders WHERE user_id = $1', [userId]);
// Convenience methods
const user = await db.findOne('users', { id: userId });
const items = await db.findMany('orders', {
where: { status: 'pending' },
orderBy: 'created_at DESC',
limit: 10,
offset: 0,
});
const insertedRow = await db.insert('orders', { user_id: 1, total: 99.99 }); // the new row
const updatedCount = await db.update('orders', { id: orderId }, { status: 'shipped' }); // a number
const deletedCount = await db.delete('orders', { id: orderId }); // a number
// Column metadata for a declared table or view (works on empty tables too)
const cols = await db.describe('orders'); // [{ name, type, nullable, default }, …]
db writes do NOT fire record. workflow triggers.* record.after_insert /
after_update / after_delete workflows only fire on writes made through the
Records API (the records_* tools, the /api/<appGuid>/records/<table> REST
endpoints, workflow record steps). A raw db.query('INSERT ...') or
db.insert(...) in a function changes the data but never triggers the workflow.
If a write must trigger one (e.g. a contact form that emails the owner), route
that write through the Records API - see workflow "Record triggers".
For an anonymous public form, expose the table with a records phase in
gipity.yaml (auth_level: public, after the database phase) - that's the
native Records API and needs no kit. Do not gipity add records for this: the
records kit is signed-in member CRUD and can't take anonymous writes.
Write-helper return shapes differ - don't guess. db.insert returns the
inserted row (it appends RETURNING *), so read fields off it directly
(insertedRow.id). db.update and db.delete return the affected row count
as a plain number, not a row and not a result object - so updated.id is
undefined and updated.rowCount is undefined. To get updated rows back, use
db.query('UPDATE ... RETURNING *', [...]) and read rows.
db.query contract: returns { rows, rowCount }. rowCount follows node-pg
semantics - rows affected for INSERT/UPDATE/DELETE, rows returned for
SELECT - so a bare DELETE ... WHERE ... (no RETURNING) still reports the real
count. There is no separate affectedRows field. To get back inserted/updated
rows, add RETURNING and read rows.
Passing arrays: pass the array as a single parameter and match it in SQL with
ANY($1) (or = ANY($1::text[])) - do not expand it into IN (...) yourself:
const ids = ['id_a', 'id_b', 'id_c'];
const { rows } = await db.query('SELECT * FROM orders WHERE id = ANY($1)', [ids]);
const del = await db.query('DELETE FROM orders WHERE id = ANY($1::text[])', [ids]);
// del.rowCount === number of rows deleted
Upserts work normally - INSERT ... ON CONFLICT (id) DO UPDATE SET ... is fine;
declare only the real table in gipity.yaml permissions.
Transactions - db.tx. Each db.query runs as its own statement on its own
connection; there is no implicit transaction per invocation. When two or more
writes must succeed or fail together (a data row plus its audit/outbox/ledger event),
wrap them in db.tx - the callback's queries run on one pinned connection inside
BEGIN/COMMIT; it commits when the callback resolves and rolls back (and rethrows)
when it throws:
export default async function (ctx, { db }) {
const record = await db.tx(async (tx) => {
const row = await tx.insert('assets', { name: ctx.body.name, owner: ctx.auth.userGuid });
await tx.insert('events', { entity_id: row.id, kind: 'asset.created', actor: ctx.auth.userGuid });
return row; // db.tx returns the callback's return value
});
return { data: record };
}
The tx handle has the same query/findOne/findMany/insert/update/delete
helpers as db. Table permissions and query/row limits apply unchanged inside the
transaction. One transaction at a time - db.tx cannot be nested. Raw
db.query('BEGIN')/'COMMIT' is rejected (it never worked - each query ran on its
own connection); db.tx is the only transaction API. If the function errors or
times out mid-transaction, the runtime rolls it back automatically.
Limits (per invocation): max_queries 50, max_rows_read 10,000, max_rows_affected 10,000. These are governor defaults, not Postgres limits - Postgres handles far larger statements fine. Bulk writes must be set-based (one UPDATE … WHERE id = ANY($1)), because a row-by-row loop hits max_queries (100) first; a set statement counts as 1 query and its rowCount counts against max_rows_affected.
Raising a limit. Add a limits: block to the function's gipity.yaml entry to lift any governor up to the platform ceiling - so you don't have to chunk or page prematurely:
function_definitions:
- name: bulk-move
auth: user
tables: [nodes]
limits:
max_rows_affected: 50000 # default 10,000, platform max 100,000
Ceilings: max_queries 100, max_rows_read 50,000, max_rows_affected 100,000 (other governors have their own caps). A value over the max fails the deploy with a clear message; an unknown/typo'd key is rejected, not silently dropped. For genuinely huge bulk (>100k rows in one shot) use a Job - it runs on a longer envelope built for batch work, not the 30s function window.
Security: Only declared tables are accessible. DDL (CREATE, ALTER, DROP) is blocked inside functions - schema changes always go through migrations/ (the pattern above). If your app generates its schema (e.g. a metadata-driven data plane), have the agent generate migrations/NNN-*.sql files from your schema source and deploy; don't try to issue DDL from a function.
SQL views work in tables:. A view created in a migration (CREATE OR REPLACE VIEW report_summary AS …) can be listed in a function's tables: and queried with db.query like any table - useful for report/aggregation endpoints. Caveat: schema introspection tooling lists base tables only, so views won't appear in schema listings; the permission and queries still work.
Globs in tables:. A tables: entry may contain * (e.g. tables: ['kit_*']) to allow every matching table/view - opt-in per entry; entries without * stay exact matches. Use it for registry-driven functions that legitimately operate over a family of same-prefix tables, so each new object doesn't require editing every function's tables: list. Keep write-path functions on explicit lists unless they truly are generic.
Schema introspection - db.describe. await db.describe('orders') returns column metadata (shape shown in the helpers block above) for one declared table or view. It respects the function's tables: permission (globs included) and works on empty tables - use it instead of inferring columns from the first result row. information_schema itself is not queryable through db.query.
Testing database code - the one isolation fact that bites
gipity test gives every test file its own throwaway copy of your database, reset before the file starts - see app-testing for the harness. Nothing outside the file can touch it, so the only lifetime to know is inside it:
- The reset is per FILE, not per test. Rows inserted by earlier tests in the same file are still there when your test executes - an "insert three rows, assert the list returns exactly three" test fails if a sibling test in the same file already inserted one. Assert on rows you can identify (filter or count by your own marker), or put the setup in
setup(fn).