docs: a to-do app as the example schema in the docs, fixtures and tests

This commit is contained in:
2026-11-02 22:19:20 +01:00
parent 93427d8cb6
commit 8fc42bc33b
18 changed files with 215 additions and 215 deletions
+1 -1
View File
@@ -7,7 +7,7 @@ const response = await rest.handle({ method, path, query, headers, body, context
// { status, headers, body }
```
`handle` never throws: every failure is a response. `path` is the path below where the API is mounted (`/assets/4`), `query` a `URLSearchParams`, `body` the parsed JSON.
`handle` never throws: every failure is a response. `path` is the path below where the API is mounted (`/todos/4`), `query` a `URLSearchParams`, `body` the parsed JSON.
## Node
+5 -5
View File
@@ -8,16 +8,16 @@ restloom reads the database's catalog once, on the first request, and builds the
```ts
expose: { schemas: ['app'] } // every table and view of app
expose: { tables: ['app.assets', 'app.users'] } // only these
expose: { tables: ['app.todos', 'app.users'] } // only these
```
A resource is named after its table. When two exposed schemas hold a table of the same name, both are named `schema_table`. `resources.<name>.expose: false` removes one, for example a junction table you only want used as a relation:
```ts
resources: { asset_tags: { expose: false } }
resources: { todo_tags: { expose: false } }
```
A resource's key is its primary key. A view has none, so it uses a column named `id` when it has one, or the columns you give it: `resources: { asset_rows: { key: ['asset_id'] } }`. A resource without a key can still be listed, but has no `/{id}` path and no stable page order.
A resource's key is its primary key. A view has none, so it uses a column named `id` when it has one, or the columns you give it: `resources: { todo_rows: { key: ['todo_id'] } }`. A resource without a key can still be listed, but has no `/{id}` path and no stable page order.
## Columns
@@ -36,8 +36,8 @@ Relations come from foreign keys between exposed resources:
| In the catalog | Gives |
|---|---|
| `assets.owner_id` references `users.id` | `assets.owner`, to-one; `users.assets`, to-many |
| `asset_tags` has a primary key made of a key to `assets` and a key to `tags` | `assets.tags` and `tags.assets`, many-to-many |
| `todos.owner_id` references `users.id` | `todos.owner`, to-one; `users.todos`, to-many |
| `todo_tags` has a primary key made of a key to `todos` and a key to `tags` | `todos.tags` and `tags.todos`, many-to-many |
Names:
+1 -1
View File
@@ -34,7 +34,7 @@ http.createServer(nodeListener(rest, { prefix: '/api' })).listen(3000);
Every table and view of `app` is now a resource, read-only:
```sh
curl -g 'http://localhost:3000/api/assets?select=id,name,owner(name)&status=eq.open'
curl -g 'http://localhost:3000/api/todos?select=id,name,owner(name)&status=eq.open'
```
## Next
+2 -2
View File
@@ -11,8 +11,8 @@ restloom keeps what makes PostgREST pleasant, the catalog as the source and a qu
| Writes | everything the role may write | off until a resource enables them, by column |
| Authorisation | database roles and row security | your hooks, plus row security where the database has it |
| Relations | inferred from foreign keys | inferred from foreign keys, overridable, removable |
| Filtering through a to-many relation | an embed with `!inner` | `tags.name=eq.pci`: at least one; `not.`: none |
| Ordering by a related column | on embedded resources | `order=owner.name`, `order=incidents.count` |
| Filtering through a to-many relation | an embed with `!inner` | `tags.name=eq.work`: at least one; `not.`: none |
| Ordering by a related column | on embedded resources | `order=owner.name`, `order=steps.count` |
| A name that is a keyword | not addressable as a filter | quote it: `"limit"=eq.5` |
| Over-sized limit | capped | refused with `limit_exceeded` |
| Errors | database messages | stable codes, no database text |
+34 -34
View File
@@ -6,10 +6,10 @@ The demo schema has four resources:
| Resource | Columns | Relations |
|---|---|---|
| `users` | id, name, email, limit, manager_id | `manager` (one user), `users` (the users they manage), `assets` |
| `assets` | id, name, status, owner_id, cost, active, created_at | `owner` (one user), `tags` (many, through `asset_tags`), `incidents` (many) |
| `tags` | id, name | `assets` (many, through `asset_tags`) |
| `incidents` | id, title, asset_id, severity, opened_on | `asset` (one asset) |
| `users` | id, name, email, limit, manager_id | `manager` (one user), `users` (the users they manage), `todos` |
| `todos` | id, name, status, owner_id, estimate, pinned, created_at | `owner` (one user), `tags` (many, through `todo_tags`), `steps` (many) |
| `tags` | id, name | `todos` (many, through `todo_tags`) |
| `steps` | id, title, todo_id, priority, due_on | `todo` (one todo) |
`users.password_hash` exists in the table but is never served: its name looks sensitive, so it is hidden.
@@ -20,21 +20,21 @@ Examples are shown unencoded. A client encodes them like any query string: `curl
Without `select`, a row has every visible column. With it, only the listed ones, in that order.
```http
GET /assets?select=id,name&status=eq.open&order=id
GET /todos?select=id,name&status=eq.open&order=id
```
```json
[{ "id": 1, "name": "vpn gateway" }, { "id": 2, "name": "mail relay" }, { "id": 4, "name": "payroll db" }]
[{ "id": 1, "name": "water plants" }, { "id": 2, "name": "file taxes" }, { "id": 4, "name": "pay rent" }]
```
One row is read by its key. It answers 404 when the row does not exist or is not visible.
```http
GET /assets/1?select=id,name,owner(name)
GET /todos/1?select=id,name,owner(name)
```
```json
{ "id": 1, "name": "vpn gateway", "owner": { "name": "Ada" } }
{ "id": 1, "name": "water plants", "owner": { "name": "Ada" } }
```
## Related rows: embedding
@@ -42,7 +42,7 @@ GET /assets/1?select=id,name,owner(name)
`relation(columns)` puts related rows inside each row: an object (or `null`) for a to-one relation, an array for a to-many one. `relation(*)` takes every visible column. Embeds nest up to the depth limit (2 by default).
```http
GET /assets?status=eq.open&select=id,owner(name,manager(name))
GET /todos?status=eq.open&select=id,owner(name,manager(name))
```
```json
@@ -70,15 +70,15 @@ GET /users?select=id,name,manager(name),users(name)
An embedded array is ordered by the related key, unless `relation.order` says otherwise. `relation.limit` and `relation.offset` page it. Without a limit it holds at most `maxPage` rows (1000).
```http
GET /assets?select=id,incidents(title)&incidents.order=severity.desc&incidents.limit=1&order=incidents.count.desc
GET /todos?select=id,steps(title)&steps.order=priority.desc&steps.limit=1&order=steps.count.desc
```
```json
[
{ "id": 1, "incidents": [{ "title": "Brute force" }] },
{ "id": 2, "incidents": [{ "title": "Spoofed mail" }] },
{ "id": 3, "incidents": [] },
{ "id": 4, "incidents": [] }
{ "id": 1, "steps": [{ "title": "Buy a watering can" }] },
{ "id": 2, "steps": [{ "title": "Find the receipts" }] },
{ "id": 3, "steps": [] },
{ "id": 4, "steps": [] }
]
```
@@ -96,16 +96,16 @@ A filter is `column=operator.value`. Every filter must hold.
| `ov.{a,b}`, `cs.{a,b}`, `cd.{a,b}` | the array overlaps, contains, or is contained by the list | array columns (Postgres) |
| `is.null`, `is.true`, `is.false` | null, or a boolean value | `null` any column, `true` and `false` booleans |
`not.` before the operator negates it: `status=not.in.(retired,lost)`.
`not.` before the operator negates it: `status=not.in.(done,cancelled)`.
A value is checked against the column's type before any SQL runs: `id=eq.abc` answers `invalid_value`. An operator that does not fit the column is refused:
```http
GET /assets?cost=like.1*
GET /todos?estimate=like.1*
```
```json
{ "error": "bad_operator", "message": "like on cost" }
{ "error": "bad_operator", "message": "like on estimate" }
```
### Through relations
@@ -117,29 +117,29 @@ A filter can name a column of a related resource: `relation.column=operator.valu
- **With `not.`** on a relation path: no related row may match.
```http
GET /assets?select=id,name&owner.name=ilike.*a*
GET /todos?select=id,name&owner.name=ilike.*a*
```
```json
[{ "id": 1, "name": "vpn gateway" }, { "id": 2, "name": "mail relay" }]
[{ "id": 1, "name": "water plants" }, { "id": 2, "name": "file taxes" }]
```
```http
GET /assets?select=id,name,owner(name),tags(name)&tags.name=in.(pci,gdpr)&order=owner.name.desc
GET /todos?select=id,name,owner(name),tags(name)&tags.name=in.(work,home)&order=owner.name.desc
```
```json
[
{ "id": 4, "name": "payroll db", "owner": { "name": "Linus" }, "tags": [{ "name": "pci" }, { "name": "gdpr" }] },
{ "id": 2, "name": "mail relay", "owner": { "name": "Grace" }, "tags": [{ "name": "gdpr" }] },
{ "id": 1, "name": "vpn gateway", "owner": { "name": "Ada" }, "tags": [{ "name": "pci" }, { "name": "internal" }] }
{ "id": 4, "name": "pay rent", "owner": { "name": "Linus" }, "tags": [{ "name": "work" }, { "name": "home" }] },
{ "id": 2, "name": "file taxes", "owner": { "name": "Grace" }, "tags": [{ "name": "home" }] },
{ "id": 1, "name": "water plants", "owner": { "name": "Ada" }, "tags": [{ "name": "work" }, { "name": "urgent" }] }
]
```
Assets with no `pci` tag, including assets with no tag at all:
Todos with no `work` tag, including todos with no tag at all:
```http
GET /assets?select=id&tags.name=not.eq.pci
GET /todos?select=id&tags.name=not.eq.work
```
```json
@@ -149,7 +149,7 @@ GET /assets?select=id&tags.name=not.eq.pci
`relation=is.empty` keeps rows with no related row, `relation=not.is.empty` rows with at least one. `is.null` means the same.
```http
GET /assets?select=id&tags=is.empty
GET /todos?select=id&tags=is.empty
```
```json
@@ -161,7 +161,7 @@ GET /assets?select=id&tags=is.empty
`or=(term,term)` holds when one term does. A term is `path.operator.value`, with the same operators and relation paths. `and=(...)` groups terms inside an `or`, one level deep. `not.or` and `not.and` negate the group.
```http
GET /assets?select=id&or=(status.eq.retired,owner.name.eq.Linus)
GET /todos?select=id&or=(status.eq.done,owner.name.eq.Linus)
```
```json
@@ -173,16 +173,16 @@ GET /assets?select=id&or=(status.eq.retired,owner.name.eq.Linus)
`order=column.direction,...` with `asc` (default) or `desc`, and optionally `nullsfirst` or `nullslast`.
- **To-one paths:** sort by a related column: `order=owner.name.desc`.
- **To-many relations:** sort by how many related rows each row has: `order=incidents.count.desc`. A to-many relation cannot be sorted by one of its columns, because a row has many values.
- **To-many relations:** sort by how many related rows each row has: `order=steps.count.desc`. A to-many relation cannot be sorted by one of its columns, because a row has many values.
- **Stable pages:** the key is appended to every order.
- **Nulls:** they sort last ascending and first descending, as in Postgres, on every database.
```http
GET /assets?select=id,cost&order=cost.desc
GET /todos?select=id,estimate&order=estimate.desc
```
```json
[{ "id": 3, "cost": null }, { "id": 4, "cost": 4000 }, { "id": 1, "cost": 1200.5 }, { "id": 2, "cost": 300 }]
[{ "id": 3, "estimate": null }, { "id": 4, "estimate": 4 }, { "id": 1, "estimate": 2.5 }, { "id": 2, "estimate": 1 }]
```
## Pages and counts
@@ -190,7 +190,7 @@ GET /assets?select=id,cost&order=cost.desc
`limit` (default 50) and `offset` page a read. A limit over 1000 is refused, never silently reduced:
```http
GET /assets?limit=1001
GET /todos?limit=1001
```
```json
@@ -200,7 +200,7 @@ GET /assets?limit=1001
Every list answers a `Content-Range` header: `0-49/*`, or `*/0` for an empty page. `count=exact` replaces `*` with the total, and `count=estimated` with the planner's estimate where the database has one.
```http
GET /assets?select=id&limit=2&offset=1&count=exact
GET /todos?select=id&limit=2&offset=1&count=exact
```
```json
@@ -214,11 +214,11 @@ The answer above comes with `Content-Range: 1-2/4`.
`count()`, and `column.sum()`, `.avg()`, `.min()`, `.max()`, group by every plain column selected. `sum` and `avg` take numbers; `min` and `max` anything orderable. An aggregate read cannot embed relations, and orders by its selected columns only.
```http
GET /assets?select=status,count(),cost.sum()&order=status
GET /todos?select=status,count(),estimate.sum()&order=status
```
```json
[{ "status": "open", "count": 3, "sum": 5500.5 }, { "status": "retired", "count": 1, "sum": null }]
[{ "status": "done", "count": 1, "sum": null }, { "status": "open", "count": 3, "sum": 7.5 }]
```
## Names that are keywords
+23 -23
View File
@@ -5,12 +5,12 @@ Writes are off until a resource enables them. The examples on this page run with
```ts
resources: {
tags: { write: true },
assets: { write: { create: ['name', 'status', 'owner_id', 'cost'], update: ['name', 'status', 'owner_id'], delete: false } },
incidents: { write: { create: true, update: true, delete: true, bulk: true } },
todos: { write: { create: ['name', 'status', 'owner_id', 'estimate'], update: ['name', 'status', 'owner_id'], delete: false } },
steps: { write: { create: true, update: true, delete: true, bulk: true } },
},
procedures: {
'assets/{asset}/retire': { function: 'demo.retire_asset' },
'tags/{tag}/assets': { function: 'demo.assets_tagged' },
'todos/{todo}/complete': { function: 'demo.complete_todo' },
'tags/{tag}/todos': { function: 'demo.todos_tagged' },
},
```
@@ -21,7 +21,7 @@ procedures: {
| POST | `/tags` | creates one row, or an array of rows | 201 |
| PATCH | `/tags/4` | updates one row | 204, or 200 with the row |
| DELETE | `/tags/4` | deletes one row | 204, or 200 with the row |
| PATCH, DELETE | `/incidents?severity=lt.3` | by filter, only where `bulk: true` | 204, or 200 with the rows |
| PATCH, DELETE | `/steps?priority=lt.3` | by filter, only where `bulk: true` | 204, or 200 with the rows |
## Getting the rows back
@@ -63,13 +63,13 @@ POST /tags
A column outside the resource's writable list is refused, whatever its value:
```http
POST /assets
POST /todos
{ "name": "new laptop", "status": "open", "active": false }
{ "name": "book dentist", "status": "open", "pinned": false }
```
```json
{ "error": "not_writable", "message": "active" }
{ "error": "not_writable", "message": "pinned" }
```
## Updating and deleting
@@ -77,7 +77,7 @@ POST /assets
PATCH and DELETE act on one row, by its key. A row that does not exist, or is not visible, answers 404.
```http
PATCH /assets/2?select=id,name,owner(name)
PATCH /todos/2?select=id,name,owner(name)
Prefer: return=representation
{ "owner_id": 1 }
@@ -86,7 +86,7 @@ Prefer: return=representation
Answer: 200
```json
{ "id": 2, "name": "mail relay", "owner": { "name": "Ada" } }
{ "id": 2, "name": "file taxes", "owner": { "name": "Ada" } }
```
```http
@@ -106,7 +106,7 @@ DELETE /tags/3
```
```http
DELETE /assets/1
DELETE /todos/1
```
```json
@@ -116,18 +116,18 @@ DELETE /assets/1
A resource with `bulk: true` also takes PATCH and DELETE on the collection, with at least one filter. Without a filter they are refused, so no request can empty a table by accident.
```http
DELETE /incidents?severity=lt.3&select=id,title
DELETE /steps?priority=lt.3&select=id,title
Prefer: return=representation
```
Answer: 200
```json
[{ "id": 2, "title": "Spoofed mail" }]
[{ "id": 2, "title": "Find the receipts" }]
```
```http
DELETE /incidents
DELETE /steps
```
```json
@@ -149,9 +149,9 @@ POST /tags
```
```http
POST /incidents
POST /steps
{ "id": 9, "title": "Lost badge", "asset_id": 99, "severity": 1, "opened_on": "2026-04-01" }
{ "id": 9, "title": "Call the clinic", "todo_id": 99, "priority": 1, "due_on": "2026-04-01" }
```
```json
@@ -165,9 +165,9 @@ A procedure calls a database function at a path you choose. A `{name}` in the pa
A procedure is a POST unless declared `method: 'GET'`. A GET procedure takes its arguments from the query string (`/search?q=vpn&limit=10`), each once, and runs in a read-only transaction: use it for a function without side effects.
```http
POST /assets/3/retire
POST /todos/3/complete
{ "reason": "end of life" }
{ "note": "bought a new one" }
```
```json
@@ -177,20 +177,20 @@ POST /assets/3/retire
A function returning a set answers an array of its rows. Mail relay belongs to Ada since the update above.
```http
POST /tags/gdpr/assets
POST /tags/home/todos
```
```json
[
{ "id": 2, "name": "mail relay", "status": "open", "owner_id": 1, "cost": 300, "active": true, "created_at": "2026-01-05T09:30:00+00:00" },
{ "id": 4, "name": "payroll db", "status": "open", "owner_id": 3, "cost": 4000, "active": true, "created_at": "2026-02-11T16:45:00+00:00" }
{ "id": 2, "name": "file taxes", "status": "open", "owner_id": 1, "estimate": 1, "pinned": true, "created_at": "2026-01-05T09:30:00+00:00" },
{ "id": 4, "name": "pay rent", "status": "open", "owner_id": 3, "estimate": 4, "pinned": true, "created_at": "2026-02-11T16:45:00+00:00" }
]
```
```http
POST /assets/3/retire
POST /todos/3/complete
{ "why": "no reason" }
{ "why": "too busy" }
```
```json