{
  "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- `gipity db create <name>` / `gipity db drop <name>` - create or drop databases\n- `gipity db list` - list existing databases\n- `gipity db query \"<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 `gipity plan` to see), 500 rows / 128 KB per query, 50,000 chars per statement.\n\nDestructive statements (DROP, TRUNCATE) run as sent. Take a `gipity db checkpoint` first if you may want the data back (`gipity db restore`).\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 `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 one isolation fact that bites\n\n`gipity test` gives every test **file** its own throwaway copy of your database, reset before the file starts - see [app-testing](app-testing.md) for the harness. Nothing outside the file can touch it, so the only lifetime to know is inside it:\n\n- **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)`.\n"
}
