{
  "name": "app-database",
  "title": "App Database - Postgres for Functions",
  "description": "App database for functions: Postgres schema in migrations, the db helper (query/findOne/insert/update/delete/tx), per-invocation limits, table permissions, views and globs",
  "guid": "sk_plat_adb",
  "category": "App development",
  "requiredTools": [
    "db_manage",
    "db_list",
    "db_sql"
  ],
  "content": "# App Database\n\nA 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](app-development.md), and for the migration / deploy phase see [deploy](deploy.md).\n\n## Setup\n- `db_manage` - create or drop databases\n- `db_list` - list existing databases\n- `db_sql` - execute SQL (SELECT, INSERT, UPDATE, DELETE, CREATE TABLE, etc.)\n\nUse PostgreSQL syntax: `SERIAL`, `BOOLEAN`, `TIMESTAMPTZ`, `JSONB`, `TEXT`.\n\n**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.\n\nLimits: database count is per-plan (run `credits_products` to see), 500 rows / 128 KB per query, 50,000 chars per statement.\n\nDestructive operations (DROP, TRUNCATE) are auto-confirmed by the platform - just call db_sql directly.\n\n**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](deploy.md)). 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.\n\nListing a table under a function's `tables:` in `gipity.yaml` (see [app-development](app-development.md#gipityyaml---function-permissions)) 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.\n\n## Database Helpers in Functions (via `db`)\n\n```js\n// Raw SQL with parameters\nconst { rows } = await db.query('SELECT * FROM orders WHERE user_id = $1', [userId]);\n\n// Convenience methods\nconst user  = await db.findOne('users', { id: userId });\nconst items = await db.findMany('orders', {\n  where: { status: 'pending' },\n  orderBy: 'created_at DESC',\n  limit: 10,\n  offset: 0,\n});\nconst insertedRow  = await db.insert('orders', { user_id: 1, total: 99.99 });  // the new row\nconst updatedCount = await db.update('orders', { id: orderId }, { status: 'shipped' });  // a number\nconst deletedCount = await db.delete('orders', { id: orderId });  // a number\n\n// Column metadata for a declared table or view (works on empty tables too)\nconst cols = await db.describe('orders');  // [{ name, type, nullable, default }, …]\n```\n\n**`db` writes do NOT fire record.* workflow triggers.** `record.after_insert` /\n`after_update` / `after_delete` workflows only fire on writes made through the\nRecords API (the `records_*` tools, the `/api/<appGuid>/records/<table>` REST\nendpoints, workflow `record` steps). A raw `db.query('INSERT ...')` or\n`db.insert(...)` in a function changes the data but never triggers the workflow.\nIf a write must trigger one (e.g. a contact form that emails the owner), route\nthat write through the Records API - see [workflow](workflow.md) \"Record triggers\".\nFor an **anonymous public form**, expose the table with a `records` phase in\n`gipity.yaml` (`auth_level: public`, after the `database` phase) - that's the\nnative Records API and needs no kit. Do **not** `gipity add records` for this: the\nrecords *kit* is signed-in member CRUD and can't take anonymous writes.\n\n**Write-helper return shapes differ - don't guess.** `db.insert` returns the\n**inserted row** (it appends `RETURNING *`), so read fields off it directly\n(`insertedRow.id`). `db.update` and `db.delete` return the **affected row count**\nas a plain number, *not* a row and not a result object - so `updated.id` is\n`undefined` and `updated.rowCount` is `undefined`. To get updated rows back, use\n`db.query('UPDATE ... RETURNING *', [...])` and read `rows`.\n\n**`db.query` contract:** returns `{ rows, rowCount }`. `rowCount` follows node-pg\nsemantics - rows **affected** for `INSERT`/`UPDATE`/`DELETE`, rows **returned** for\n`SELECT` - so a bare `DELETE ... WHERE ...` (no `RETURNING`) still reports the real\ncount. There is no separate `affectedRows` field. To get back inserted/updated\nrows, add `RETURNING` and read `rows`.\n\n**Passing arrays:** pass the array as a single parameter and match it in SQL with\n`ANY($1)` (or `= ANY($1::text[])`) - do **not** expand it into `IN (...)` yourself:\n\n```js\nconst ids = ['id_a', 'id_b', 'id_c'];\nconst { rows } = await db.query('SELECT * FROM orders WHERE id = ANY($1)', [ids]);\nconst del = await db.query('DELETE FROM orders WHERE id = ANY($1::text[])', [ids]);\n// del.rowCount === number of rows deleted\n```\n\n**Upserts** work normally - `INSERT ... ON CONFLICT (id) DO UPDATE SET ...` is fine;\ndeclare only the real table in `gipity.yaml` permissions.\n\n**Transactions - `db.tx`.** Each `db.query` runs as its own statement on its own\nconnection; there is **no implicit transaction** per invocation. When two or more\nwrites must succeed or fail together (a data row plus its audit/outbox/ledger event),\nwrap them in `db.tx` - the callback's queries run on one pinned connection inside\n`BEGIN`/`COMMIT`; it commits when the callback resolves and rolls back (and rethrows)\nwhen it throws:\n\n```js\nexport default async function (ctx, { db }) {\n  const record = await db.tx(async (tx) => {\n    const row = await tx.insert('assets', { name: ctx.body.name, owner: ctx.auth.userGuid });\n    await tx.insert('events', { entity_id: row.id, kind: 'asset.created', actor: ctx.auth.userGuid });\n    return row;  // db.tx returns the callback's return value\n  });\n  return { data: record };\n}\n```\n\nThe `tx` handle has the same `query`/`findOne`/`findMany`/`insert`/`update`/`delete`\nhelpers as `db`. Table permissions and query/row limits apply unchanged inside the\ntransaction. One transaction at a time - `db.tx` cannot be nested. Raw\n`db.query('BEGIN')`/`'COMMIT'` is rejected (it never worked - each query ran on its\nown connection); `db.tx` is the only transaction API. If the function errors or\ntimes out mid-transaction, the runtime rolls it back automatically.\n\n**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.\n\n**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:\n\n```yaml\nfunction_definitions:\n  - name: bulk-move\n    auth: user\n    tables: [nodes]\n    limits:\n      max_rows_affected: 50000   # default 10,000, platform max 100,000\n```\n\nCeilings: 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.\n**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.\n\n**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.\n\n**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.\n\n**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`.\n\n## Testing database code - the two isolation facts that bite\n\n`gipity test` runs your functions against an **isolated, throwaway copy of your database** - see [app-testing](app-testing.md) for the harness. Two lifetimes trip up naturally-written tests, so know them before writing your first assertion:\n\n- **The test database resets once per RUN, not per test.** Rows inserted by earlier tests in the same run are still there when your test executes - an \"insert three rows, assert the list returns exactly three\" test fails on its first run with someone else's rows in the diff. Assert on rows you can identify (filter or count by your own marker), not on the table's total contents.\n- **`ctx.testId` is stable per FILE, not per test.** It exists to keep *files* from colliding on unique names; every test in one file shares the same id, so it cannot isolate tests from their same-file siblings. Namespace per-test data with your own suffix on top of it when a test needs exclusivity.\n"
}
