Skip to main content

postgrest-api

This skill should be used when the user wants to "use PostgREST", "make REST calls to PostgreSQL", "CRUD over HTTP on a PostgreSQL table", "upsert with on_conflict", "Prefer header options", "return=representation", "max-affected", "call a stored function over REST", "read PostgreSQL from an n8n HTTP Request node", or "read PostgreSQL from a Trigger.dev task with fetch".

Source facts

Repository
Agents-Store/opencode-plugins
Last source activity
October 3, 2026 at 23:16
Detected SKILL.md language
English
Stars
0
Forks
0

Install options

The review-first prompt is selected by default. You can switch to a direct command or download a local copy.

Review the source files

Read SKILL.md and any companion files shown by SkillsMP before deciding whether to install.

File Explorer
2 files

Showing SKILL.md

SKILL.md
Source instructions · Read-only preview
name
postgrest-api
description
This skill should be used when the user wants to "use PostgREST", "make REST calls to PostgreSQL", "CRUD over HTTP on a PostgreSQL table", "upsert with on_conflict", "Prefer header options", "return=representation", "max-affected", "call a stored function over REST", "read PostgreSQL from an n8n HTTP Request node", or "read PostgreSQL from a Trigger.dev task with fetch".
# PostgREST API PostgREST turns a PostgreSQL schema into a REST API: every table and view in an exposed schema gets endpoints, every function becomes `/rpc/<name>`. Use it when the caller is an HTTP client — an n8n HTTP Request node, a background task, `curl` — and an MCP connection is not available or not wanted. For SQL, schema inspection and administration over MCP, see `postgres-mcp-tools`. Tables you design with this plugin's `create-tables` and `relations` skills are served as they are. **Version.** This reference was run against PostgREST 16.3 (16.4 was current on 2026-10-02) and applies to 14–16. Do not hardcode a version: the server names itself in the `Server` response header (`Server: postgrest/16.3`), and `GET /` returns the OpenAPI description of what the current role can reach. PostgREST 16 needs PostgreSQL 14 or later, and changes two things you may meet in an old config — see **Upgrading to 16**. **Base URL:** `${POSTGRESQL_API_URL}` (no trailing slash) **Auth:** `Authorization: Bearer ${POSTGRESQL_API_TOKEN}` ## How Access Works A request runs as the PostgreSQL role named in the JWT `role` claim; with no token it runs as the configured anonymous role. Table and function `GRANT`s are the permission model — a missing grant answers `401` for the anonymous role and `403` for an authenticated one, with PostgreSQL code `42501` (`permission denied for table …`); it is not a PostgREST setting. After you change the schema (new table, column, grant), PostgREST must reload its schema cache (`NOTIFY pgrst, 'reload schema'` or a restart) before the API shows it. ## CRUD Operations ### Read Records (GET) ```bash # All records (the server may cap the page: db-max-rows) curl "${POSTGRESQL_API_URL}/orders" \ -H "Authorization: Bearer ${POSTGRESQL_API_TOKEN}" # Select specific columns curl "${POSTGRESQL_API_URL}/orders?select=id,title,status" \ -H "Authorization: Bearer ${POSTGRESQL_API_TOKEN}" # Filter records curl "${POSTGRESQL_API_URL}/orders?status=eq.pending&amount=gt.100" \ -H "Authorization: Bearer ${POSTGRESQL_API_TOKEN}" # Order and paginate curl "${POSTGRESQL_API_URL}/orders?order=created_at.desc&limit=20&offset=0" \ -H "Authorization: Bearer ${POSTGRESQL_API_TOKEN}" # One object instead of an array (406 unless exactly one row matches) curl "${POSTGRESQL_API_URL}/orders?id=eq.123" \ -H "Authorization: Bearer ${POSTGRESQL_API_TOKEN}" \ -H "Accept: application/vnd.pgrst.object+json" ``` ### Create Records (POST) ```bash # Single record curl -X POST "${POSTGRESQL_API_URL}/orders" \ -H "Authorization: Bearer ${POSTGRESQL_API_TOKEN}" \ -H "Content-Type: application/json" \ -H "Prefer: return=representation" \ -d '{"title": "New Order", "status": "pending"}' # Bulk insert — every object needs the same keys (see "Bulk insert with missing keys") curl -X POST "${POSTGRESQL_API_URL}/orders" \ -H "Authorization: Bearer ${POSTGRESQL_API_TOKEN}" \ -H "Content-Type: application/json" \ -H "Prefer: return=representation" \ -d '[{"title": "Order 1"}, {"title": "Order 2"}]' ``` ### Update Records (PATCH) ```bash # Update by filter — always send a filter curl -X PATCH "${POSTGRESQL_API_URL}/orders?id=eq.123" \ -H "Authorization: Bearer ${POSTGRESQL_API_TOKEN}" \ -H "Content-Type: application/json" \ -H "Prefer: return=representation" \ -d '{"status": "completed"}' ``` A `PATCH` or `DELETE` **without a filter changes every row** you may touch. Add the guard from **Safety Net for Bulk Writes** to every call that is built from variable input. ### Delete Records (DELETE) ```bash curl -X DELETE "${POSTGRESQL_API_URL}/orders?id=eq.123" \ -H "Authorization: Bearer ${POSTGRESQL_API_TOKEN}" ``` ### Upsert (POST with conflict resolution) `on_conflict` is a **query parameter**, not a header. It names the unique column (or comma-separated columns) that decides "same row"; without it the primary key is used. The `Prefer` header only chooses what happens on a conflict. ```bash # Merge on a unique business key, return the stored row curl -X POST "${POSTGRESQL_API_URL}/orders?on_conflict=external_ref" \ -H "Authorization: Bearer ${POSTGRESQL_API_TOKEN}" \ -H "Content-Type: application/json" \ -H "Prefer: resolution=merge-duplicates,return=representation" \ -d '{"external_ref": "ext-1", "title": "Updated Order", "status": "active"}' # Conflict on the primary key (on_conflict may be omitted) curl -X POST "${POSTGRESQL_API_URL}/orders?on_conflict=id" \ -H "Authorization: Bearer ${POSTGRESQL_API_TOKEN}" \ -H "Content-Type: application/json" \ -H "Prefer: resolution=merge-duplicates,return=representation" \ -d '{"id": 123, "title": "Updated Order", "status": "active"}' ``` An `on_conflict: external_ref` **header** is ignored: on a conflict with another unique column the call fails with `409` and PostgreSQL code `23505` (`duplicate key value violates unique constraint`). With `merge-duplicates` every column in the body is overwritten, the primary key included, so send the key you want to keep. `resolution=ignore-duplicates` skips conflicting rows instead of updating them. ### Call Stored Functions (RPC) ```bash curl -X POST "${POSTGRESQL_API_URL}/rpc/calculate_total" \ -H "Authorization: Bearer ${POSTGRESQL_API_TOKEN}" \ -H "Content-Type: application/json" \ -d '{"order_id": 123}' ``` A `STABLE` or `IMMUTABLE` function can also be called with `GET /rpc/calculate_total?order_id=123`. The role needs `EXECUTE` on the function. ## Filtering Operators | Operator | Meaning | Example | |----------|---------|---------| | `eq` | Equals | `?status=eq.active` | | `neq` | Not equals | `?status=neq.deleted` | | `gt` | Greater than | `?amount=gt.100` | | `gte` | Greater or equal | `?amount=gte.100` | | `lt` | Less than | `?amount=lt.50` | | `lte` | Less or equal | `?amount=lte.50` | | `like` | LIKE pattern (`*` is the wildcard) | `?name=like.*john*` | | `ilike` | Case-insensitive LIKE | `?name=ilike.*john*` | | `in` | In list | `?status=in.(active,pending)` | | `is` | IS (null, true, false) | `?deleted_at=is.null` | | `not` | Negate | `?status=not.eq.deleted` | | `cs` | Contains (arrays/JSON) | `?tags=cs.{urgent}` | | `cd` | Contained by | `?tags=cd.{urgent,important}` | | `or` | Any of several conditions | `?or=(status.eq.active,status.eq.pending)` | ## Prefer Header Options Combine options in one header, comma separated: `Prefer: return=representation,missing=default`. The server echoes what it applied in `Preference-Applied` — check it when an option seems to do nothing. | Value | Effect | |-------|--------| | `return=representation` | Return the created/updated rows in the body | | `return=minimal` | No body, `204` (the default for writes) | | `return=headers-only` | `201` with a `Location` header, no body (inserts) | | `resolution=merge-duplicates` | Upsert — update the existing row, insert new ones | | `resolution=ignore-duplicates` | Skip conflicting rows on insert | | `count=exact` | Exact row count in `Content-Range` (`0-0/4`); also `planned` and `estimated` (cheaper, approximate) | | `missing=default` | A key missing from a bulk-insert object takes the column `DEFAULT` instead of `NULL` — use it with `?columns=` | | `handling=strict` | Reject an unknown or unusable preference with `400` instead of ignoring it; required for `max-affected` | | `max-affected=N` | Fail with `400` / `PGRST124` when a write would touch more than N rows — needs `handling=strict` | | `tx=rollback` | Run the request and roll it back (a dry run) — needs `db-tx-end = commit-allow-override`; with the default `commit` the preference is silently ignored. (`db-tx-end` takes `commit`, `commit-allow-override`, `rollback`, `rollback-allow-override`; under `rollback-allow-override` every request rolls back unless the client sends `tx=commit`.) | `return=none` is not a PostgREST value; it is ignored and the default `minimal` applies. ### Safety Net for Bulk Writes For any `PATCH` or `DELETE` whose filter comes from variable input (a workflow, an agent), say how many rows you expect: ```bash curl -X PATCH "${POSTGRESQL_API_URL}/orders?status=eq.pending" \ -H "Authorization: Bearer ${POSTGRESQL_API_TOKEN}" \ -H "Content-Type: application/json" \ -H "Prefer: handling=strict,max-affected=50,return=representation" \ -d '{"status": "queued"}' ``` The same guard on a `DELETE`: ```bash curl -X DELETE "${POSTGRESQL_API_URL}/orders?status=eq.cancelled" \ -H "Authorization: Bearer ${POSTGRESQL_API_TOKEN}" \ -H "Prefer: handling=strict,max-affected=50" ``` If more than 50 rows match, nothing is changed and the answer is `400` / `PGRST124` with `"The query affects N rows"`. Without `handling=strict`, `max-affected` is ignored and a filterless `DELETE` removes everything. ### Bulk Insert With Missing Keys PostgREST rejects an array whose objects have different keys (`PGRST102`, `All object keys must match`). Name the columns once and say what a missing key means: ```bash curl -X POST "${POSTGRESQL_API_URL}/orders?columns=id,title,status" \ -H "Authorization: Bearer ${POSTGRESQL_API_TOKEN}" \ -H "Content-Type: application/json" \ -H "Prefer: missing=default,return=representation" \ -d '[{"id": 101, "title": "A", "status": "paid"}, {"id": 102, "title": "B"}]' ``` The second row gets the column default for `status`; without `missing=default` it would get `NULL`. ## PostgREST in n8n Workflows Use an **HTTP Request** node. Keep the token in a credential, not in an expression: ``` Method: GET / POST / PATCH / DELETE URL: <your PostgREST base URL>/orders (fixed in the node) Authentication: Generic Credential Type → Header Auth (Name: Authorization, Value: Bearer <POSTGRESQL_API_TOKEN>) Headers: Content-Type: application/json Prefer: return=representation ``` Do not build the URL or the token with `{{ $env.POSTGRESQL_API_URL }}`: n8n 2.x blocks `$env` in expressions and Code nodes by default (`N8N_BLOCK_ENV_ACCESS_IN_NODE`; the 2.0 breaking-changes list sets it to `true`, while the environment-variable reference page still shows `false` — verify on your instance). Use a credential plus a fixed URL. If you must read the environment, set `N8N_BLOCK_ENV_ACCESS_IN_NODE=false` on purpose and accept that every workflow author can then read the container's environment. In a Code node, do the data shaping and leave the call to the HTTP Request node. ## PostgREST in Trigger.dev Tasks Read the URL and token from the task's environment and check the status — `fetch` does not throw on a `4xx`: ```typescript const response = await fetch( `${process.env.POSTGRESQL_API_URL}/orders?status=eq.pending&limit=50`, { headers: { Authorization: `Bearer ${process.env.POSTGRESQL_API_TOKEN}`, "Content-Type": "application/json", }, } ); if (!response.ok) { throw new Error(`PostgREST ${response.status}: ${await response.text()}`); } const orders = await response.json(); ``` A deployed task only sees environment variables you sync to the Trigger.dev environment — see `trigger-dev:config-and-build` (`syncEnvVars`). ## Upgrading to 16 - If you set `jwt-role-claim-key`, it is now a JSON Path and must start with `$`: `.roles.read` becomes `$.roles.read`. If you never set it, nothing changes - Filtering or ordering on an embedded table by its **table name** when the embed has an alias (`?select=alias:table(*)&table.id=eq.1`) is deprecated — use the alias (`alias.id=eq.1`); the new behaviour needs `url-use-legacy-target-names` set to `false` - PostgreSQL 13 and older are not supported ## Best Practices - Always use `${POSTGRESQL_API_URL}` and `${POSTGRESQL_API_TOKEN}` — never hardcode a URL or token - Send `Prefer: return=representation` on POST/PATCH when you need the stored row back - Pass `on_conflict` in the query string for upserts on a business key - Guard every variable-input `PATCH`/`DELETE` with `handling=strict,max-affected=N` - Give the JWT role the narrowest grants the job needs; the API can do exactly what that role can do - Use filtering operators for simple lookups; use `execute_sql` (see `postgres-mcp-tools`) for JOINs, CTEs and anything that needs several round trips
View on GitHub