feat: a top-level write option turns writes on for every table; 0.4.0
This commit is contained in:
@@ -6,6 +6,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [0.4.0] - 2026-11-03
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- A top-level `write` option turns writes on for every exposed table at once: `write: true`, or `{ create, update, delete, bulk }`. A resource's own `write` replaces it, and views stay read-only unless their resource allows writes.
|
||||||
|
- Every write example in the docs states its status, and the security page describes sensitive columns and how to serve one.
|
||||||
|
|
||||||
## [0.3.1] - 2026-11-03
|
## [0.3.1] - 2026-11-03
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ Every table and view of `app` is now a resource, read-only:
|
|||||||
curl -g 'http://localhost:3000/api/todos?select=id,name,owner(name)&status=eq.open'
|
curl -g 'http://localhost:3000/api/todos?select=id,name,owner(name)&status=eq.open'
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Add `write: true` beside `expose` to create, update and delete rows in every table too. Do it once your `authorize` hook decides who may write: [Writes](./writes.md#writes-on-every-table).
|
||||||
|
|
||||||
## Next
|
## Next
|
||||||
|
|
||||||
- [Discovery and overrides](./discovery.md): what becomes a resource, which columns are hidden, how relations are named.
|
- [Discovery and overrides](./discovery.md): what becomes a resource, which columns are hidden, how relations are named.
|
||||||
|
|||||||
+21
-1
@@ -1,6 +1,6 @@
|
|||||||
# Writes and procedures
|
# Writes and procedures
|
||||||
|
|
||||||
Writes are off until a resource enables them. The examples on this page run with these options, against the [demo schema](../conformance/fixtures/postgres.sql):
|
Writes are off until you turn them on, for every table at once or one resource at a time. The examples on this page run with these options, against the [demo schema](../conformance/fixtures/postgres.sql):
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
resources: {
|
resources: {
|
||||||
@@ -16,6 +16,26 @@ procedures: {
|
|||||||
|
|
||||||
`write: true` allows create, update and delete of every visible column. A list allows only the columns named. Generated and identity columns are never writable, and neither is a key on update.
|
`write: true` allows create, update and delete of every visible column. A list allows only the columns named. Generated and identity columns are never writable, and neither is a key on update.
|
||||||
|
|
||||||
|
## Writes on every table
|
||||||
|
|
||||||
|
For an API that writes everywhere, set `write` once, beside `expose`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
createREST({
|
||||||
|
db,
|
||||||
|
expose: { schemas: ['app'] },
|
||||||
|
write: true,
|
||||||
|
resources: {
|
||||||
|
audit_log: { write: false },
|
||||||
|
users: { write: { update: ['name'] } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
It applies to every exposed table; views stay read-only until their resource allows writes, since many views cannot be written. A resource that sets its own `write` uses it instead: `write: false` keeps one table read-only, a column list narrows another. `write: { create: true, update: true, delete: true, bulk: true }` adds updates and deletes by filter everywhere.
|
||||||
|
|
||||||
|
What is never writable stays so: hidden and sensitive columns, generated columns, and keys on update; a bulk request still needs a filter. Who may write is decided by your `authorize` hook, which receives every write with its action: see [Security](./security.md#hooks).
|
||||||
|
|
||||||
| Method | Path | Does | Answers |
|
| Method | Path | Does | Answers |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| POST | `/tags` | creates one row, or an array of rows | 201 |
|
| POST | `/tags` | creates one row, or an array of rows | 201 |
|
||||||
|
|||||||
Generated
+13
-13
@@ -3314,7 +3314,7 @@
|
|||||||
},
|
},
|
||||||
"packages/core": {
|
"packages/core": {
|
||||||
"name": "@crudy/restloom",
|
"name": "@crudy/restloom",
|
||||||
"version": "0.3.1",
|
"version": "0.4.0",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^24.0.0",
|
"@types/node": "^24.0.0",
|
||||||
@@ -3326,10 +3326,10 @@
|
|||||||
},
|
},
|
||||||
"packages/express": {
|
"packages/express": {
|
||||||
"name": "@crudy/restloom-express",
|
"name": "@crudy/restloom-express",
|
||||||
"version": "0.3.1",
|
"version": "0.4.0",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@crudy/restloom": "^0.3.0"
|
"@crudy/restloom": "^0.4.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/express": "^5.0.0",
|
"@types/express": "^5.0.0",
|
||||||
@@ -3346,10 +3346,10 @@
|
|||||||
},
|
},
|
||||||
"packages/fastify": {
|
"packages/fastify": {
|
||||||
"name": "@crudy/restloom-fastify",
|
"name": "@crudy/restloom-fastify",
|
||||||
"version": "0.3.1",
|
"version": "0.4.0",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@crudy/restloom": "^0.3.0"
|
"@crudy/restloom": "^0.4.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^24.0.0",
|
"@types/node": "^24.0.0",
|
||||||
@@ -3365,10 +3365,10 @@
|
|||||||
},
|
},
|
||||||
"packages/mssql": {
|
"packages/mssql": {
|
||||||
"name": "@crudy/restloom-mssql",
|
"name": "@crudy/restloom-mssql",
|
||||||
"version": "0.3.1",
|
"version": "0.4.0",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@crudy/restloom": "^0.3.0"
|
"@crudy/restloom": "^0.4.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/mssql": "^12.0.0",
|
"@types/mssql": "^12.0.0",
|
||||||
@@ -3390,10 +3390,10 @@
|
|||||||
},
|
},
|
||||||
"packages/mysql": {
|
"packages/mysql": {
|
||||||
"name": "@crudy/restloom-mysql",
|
"name": "@crudy/restloom-mysql",
|
||||||
"version": "0.3.1",
|
"version": "0.4.0",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@crudy/restloom": "^0.3.0"
|
"@crudy/restloom": "^0.4.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^24.0.0",
|
"@types/node": "^24.0.0",
|
||||||
@@ -3414,10 +3414,10 @@
|
|||||||
},
|
},
|
||||||
"packages/postgres": {
|
"packages/postgres": {
|
||||||
"name": "@crudy/restloom-postgres",
|
"name": "@crudy/restloom-postgres",
|
||||||
"version": "0.3.1",
|
"version": "0.4.0",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@crudy/restloom": "^0.3.0"
|
"@crudy/restloom": "^0.4.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^24.0.0",
|
"@types/node": "^24.0.0",
|
||||||
@@ -3439,10 +3439,10 @@
|
|||||||
},
|
},
|
||||||
"packages/sqlite": {
|
"packages/sqlite": {
|
||||||
"name": "@crudy/restloom-sqlite",
|
"name": "@crudy/restloom-sqlite",
|
||||||
"version": "0.3.1",
|
"version": "0.4.0",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@crudy/restloom": "^0.3.0"
|
"@crudy/restloom": "^0.4.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/better-sqlite3": "^7.6.0",
|
"@types/better-sqlite3": "^7.6.0",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@crudy/restloom",
|
"name": "@crudy/restloom",
|
||||||
"version": "0.3.1",
|
"version": "0.4.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "A REST API generated from your database's catalog: discovery, a curl-friendly query grammar, relations of every kind, safe defaults",
|
"description": "A REST API generated from your database's catalog: discovery, a curl-friendly query grammar, relations of every kind, safe defaults",
|
||||||
"keywords": ["rest", "api", "postgrest", "sql", "database", "crud", "generator"],
|
"keywords": ["rest", "api", "postgrest", "sql", "database", "crud", "generator"],
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ export { createREST, type Rest } from './rest.js';
|
|||||||
export { nodeListener, type NodeListenerOptions } from './node.js';
|
export { nodeListener, type NodeListenerOptions } from './node.js';
|
||||||
export { RestError, badRequest, notFound } from './errors.js';
|
export { RestError, badRequest, notFound } from './errors.js';
|
||||||
export { DEFAULT_LIMITS, DEFAULT_SENSITIVE } from './options.js';
|
export { DEFAULT_LIMITS, DEFAULT_SENSITIVE } from './options.js';
|
||||||
export type { Hooks, Limits, ProcedureOptions, RelationOptions, ResourceOptions, RestOptions, RestRequest, RestResponse, WriteOptions } from './options.js';
|
export type { DefaultWriteOptions, Hooks, Limits, ProcedureOptions, RelationOptions, ResourceOptions, RestOptions, RestRequest, RestResponse, WriteOptions } from './options.js';
|
||||||
export type { Capabilities, Dialect, Tx } from './dialect.js';
|
export type { Capabilities, Dialect, Tx } from './dialect.js';
|
||||||
export type { Catalog, CatalogColumn, CatalogForeignKey, CatalogFunction, CatalogTable, LogicalType } from './catalog.js';
|
export type { Catalog, CatalogColumn, CatalogForeignKey, CatalogFunction, CatalogTable, LogicalType } from './catalog.js';
|
||||||
export type { Column, Junction, Relation, Resource } from './resources.js';
|
export type { Column, Junction, Relation, Resource } from './resources.js';
|
||||||
|
|||||||
@@ -34,6 +34,14 @@ export interface WriteOptions {
|
|||||||
bulk?: boolean;
|
bulk?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** What a global `write` allows on each table: whole methods, since a column list belongs to one resource. */
|
||||||
|
export interface DefaultWriteOptions {
|
||||||
|
create?: boolean;
|
||||||
|
update?: boolean;
|
||||||
|
delete?: boolean;
|
||||||
|
bulk?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ResourceOptions {
|
export interface ResourceOptions {
|
||||||
/** false removes a discovered table or view from the API. */
|
/** false removes a discovered table or view from the API. */
|
||||||
expose?: boolean;
|
expose?: boolean;
|
||||||
@@ -92,6 +100,11 @@ export interface RestOptions<C = unknown> {
|
|||||||
db: Dialect;
|
db: Dialect;
|
||||||
/** Nothing is served until a schema or a table is named here. */
|
/** Nothing is served until a schema or a table is named here. */
|
||||||
expose: { schemas?: string[]; tables?: string[] };
|
expose: { schemas?: string[]; tables?: string[] };
|
||||||
|
/**
|
||||||
|
* Writes on every exposed table, unless its resource sets its own `write`.
|
||||||
|
* Views stay read-only until their resource allows writes.
|
||||||
|
*/
|
||||||
|
write?: boolean | DefaultWriteOptions;
|
||||||
resources?: Record<string, ResourceOptions>;
|
resources?: Record<string, ResourceOptions>;
|
||||||
procedures?: Record<string, ProcedureOptions>;
|
procedures?: Record<string, ProcedureOptions>;
|
||||||
/** Replaces the default list of sensitive name patterns. */
|
/** Replaces the default list of sensitive name patterns. */
|
||||||
|
|||||||
@@ -92,8 +92,9 @@ export function buildResources(catalog: Catalog, options: RestOptions<unknown>):
|
|||||||
const resources = new Map<string, Resource>();
|
const resources = new Map<string, Resource>();
|
||||||
for (const t of tables) {
|
for (const t of tables) {
|
||||||
const name = named.get(t)!;
|
const name = named.get(t)!;
|
||||||
const o = config[name] ?? {};
|
const own = config[name] ?? {};
|
||||||
if (o.expose === false) continue;
|
if (own.expose === false) continue;
|
||||||
|
const o = own.write === undefined && t.kind === 'table' ? { ...own, write: options.write } : own;
|
||||||
const key = o.key ?? (t.primaryKey.length ? t.primaryKey : t.columns.some((c) => c.name === 'id') ? ['id'] : []);
|
const key = o.key ?? (t.primaryKey.length ? t.primaryKey : t.columns.some((c) => c.name === 'id') ? ['id'] : []);
|
||||||
const w = o.write === true ? { delete: true, bulk: false } : o.write || {};
|
const w = o.write === true ? { delete: true, bulk: false } : o.write || {};
|
||||||
const columns = columnsOf(t, o, sensitive);
|
const columns = columnsOf(t, o, sensitive);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { test } from 'node:test';
|
import { test } from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { resources } from './fixture.mjs';
|
import { buildResources } from '../dist/resources.js';
|
||||||
|
import { catalog, resources } from './fixture.mjs';
|
||||||
|
|
||||||
test('only exposed schemas become resources', () => {
|
test('only exposed schemas become resources', () => {
|
||||||
const r = resources();
|
const r = resources();
|
||||||
@@ -40,6 +41,23 @@ test('writes are off by default and never reach generated or key columns', () =>
|
|||||||
assert.equal(a.write.delete, true);
|
assert.equal(a.write.delete, true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('a global write applies to every table that does not set its own', () => {
|
||||||
|
const r = resources({ write: { create: true, update: true, delete: true }, resources: { tags: { write: false }, users: { write: { update: ['name'] } } } });
|
||||||
|
assert.deepEqual(r.get('todos').write, { create: true, update: true, delete: true, bulk: false });
|
||||||
|
assert.equal(r.get('todos').columns.get('id').update, false);
|
||||||
|
assert.deepEqual(r.get('tags').write, { create: false, update: false, delete: false, bulk: false });
|
||||||
|
assert.deepEqual(r.get('users').write, { create: false, update: true, delete: false, bulk: false });
|
||||||
|
assert.equal(r.get('users').columns.has('password_hash'), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a global write leaves views read-only', () => {
|
||||||
|
const c = catalog();
|
||||||
|
c.tables.push({ ...c.tables.find((t) => t.name === 'tags'), name: 'tag_view', kind: 'view', primaryKey: [] });
|
||||||
|
const r = buildResources(c, { db: null, expose: { schemas: ['demo'] }, write: true });
|
||||||
|
assert.equal(r.get('tag_view').write.create, false);
|
||||||
|
assert.equal(r.get('tags').write.create, true);
|
||||||
|
});
|
||||||
|
|
||||||
test('options naming unknown resources or columns fail at load', () => {
|
test('options naming unknown resources or columns fail at load', () => {
|
||||||
assert.throws(() => resources({ resources: { nope: {} } }), /no exposed table/);
|
assert.throws(() => resources({ resources: { nope: {} } }), /no exposed table/);
|
||||||
assert.throws(() => resources({ resources: { users: { hide: ['nope'] } } }), /no column nope/);
|
assert.throws(() => resources({ resources: { users: { hide: ['nope'] } } }), /no column nope/);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@crudy/restloom-express",
|
"name": "@crudy/restloom-express",
|
||||||
"version": "0.3.1",
|
"version": "0.4.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Express adapter for restloom",
|
"description": "Express adapter for restloom",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
@@ -36,7 +36,7 @@
|
|||||||
"prepack": "npm run build"
|
"prepack": "npm run build"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@crudy/restloom": "^0.3.0"
|
"@crudy/restloom": "^0.4.0"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"express": "^5.0.0"
|
"express": "^5.0.0"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@crudy/restloom-fastify",
|
"name": "@crudy/restloom-fastify",
|
||||||
"version": "0.3.1",
|
"version": "0.4.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Fastify adapter for restloom",
|
"description": "Fastify adapter for restloom",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
@@ -36,7 +36,7 @@
|
|||||||
"prepack": "npm run build"
|
"prepack": "npm run build"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@crudy/restloom": "^0.3.0"
|
"@crudy/restloom": "^0.4.0"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"fastify": "^5.0.0"
|
"fastify": "^5.0.0"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@crudy/restloom-mssql",
|
"name": "@crudy/restloom-mssql",
|
||||||
"version": "0.3.1",
|
"version": "0.4.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "SQL Server dialect for restloom",
|
"description": "SQL Server dialect for restloom",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
@@ -37,7 +37,7 @@
|
|||||||
"prepack": "npm run build"
|
"prepack": "npm run build"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@crudy/restloom": "^0.3.0"
|
"@crudy/restloom": "^0.4.0"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"mssql": "^12.0.0"
|
"mssql": "^12.0.0"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@crudy/restloom-mysql",
|
"name": "@crudy/restloom-mysql",
|
||||||
"version": "0.3.1",
|
"version": "0.4.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "MySQL dialect for restloom",
|
"description": "MySQL dialect for restloom",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
@@ -36,7 +36,7 @@
|
|||||||
"prepack": "npm run build"
|
"prepack": "npm run build"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@crudy/restloom": "^0.3.0"
|
"@crudy/restloom": "^0.4.0"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"mysql2": "^3.11.0"
|
"mysql2": "^3.11.0"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@crudy/restloom-postgres",
|
"name": "@crudy/restloom-postgres",
|
||||||
"version": "0.3.1",
|
"version": "0.4.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Postgres dialect for restloom",
|
"description": "Postgres dialect for restloom",
|
||||||
"keywords": ["restloom", "postgres", "postgresql", "rest", "api"],
|
"keywords": ["restloom", "postgres", "postgresql", "rest", "api"],
|
||||||
@@ -18,7 +18,7 @@
|
|||||||
"prepack": "npm run build"
|
"prepack": "npm run build"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@crudy/restloom": "^0.3.0"
|
"@crudy/restloom": "^0.4.0"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"pg": "^8.11.0"
|
"pg": "^8.11.0"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@crudy/restloom-sqlite",
|
"name": "@crudy/restloom-sqlite",
|
||||||
"version": "0.3.1",
|
"version": "0.4.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "SQLite dialect for restloom",
|
"description": "SQLite dialect for restloom",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
@@ -36,7 +36,7 @@
|
|||||||
"prepack": "npm run build"
|
"prepack": "npm run build"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@crudy/restloom": "^0.3.0"
|
"@crudy/restloom": "^0.4.0"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"better-sqlite3": ">=12"
|
"better-sqlite3": ">=12"
|
||||||
|
|||||||
Reference in New Issue
Block a user