@protobase/server 0.26.5 → 0.26.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/admin-options.d.ts +3 -3
- package/dist/api-cors.js +4 -1
- package/dist/create-admin.d.ts +1 -1
- package/dist/create-admin.js +17 -8
- package/dist/jobs/busy-database.js +18 -0
- package/dist/jobs/runtime.js +8 -3
- package/dist/rest/content-range.js +14 -0
- package/dist/rest/field-path.js +25 -0
- package/dist/rest/filter-sql.js +62 -0
- package/dist/rest/functions.js +92 -0
- package/dist/rest/parse-filter.js +131 -0
- package/dist/rest/parse-order.js +18 -0
- package/dist/rest/parse-prefer.js +26 -0
- package/dist/rest/parse-query.js +63 -0
- package/dist/rest/parse-select.js +24 -0
- package/dist/rest/read-rows.js +39 -0
- package/dist/rest/respond.js +35 -0
- package/dist/rest/rest-context.js +31 -0
- package/dist/rest/rest-error.js +80 -0
- package/dist/rest/rest-routes.js +33 -0
- package/dist/rest/rest-scan.js +94 -0
- package/dist/rest/rpc-route.js +65 -0
- package/dist/rest/shape-sql.js +20 -0
- package/dist/rest/split-list.js +38 -0
- package/dist/rest/table-routes.js +156 -0
- package/dist/rest/write-rows.js +49 -0
- package/dist/scan-guard.js +5 -1
- package/dist/transactions.js +2 -2
- package/package.json +4 -4
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { restError } from "./rest-error.js";
|
|
2
|
+
import { contentRange, rangeStatus } from "./content-range.js";
|
|
3
|
+
//#region src/rest/respond.ts
|
|
4
|
+
const jsonType = "application/json; charset=utf-8";
|
|
5
|
+
const objectType = "application/vnd.pgrst.object+json; charset=utf-8";
|
|
6
|
+
/** Whether the client asked for one object rather than an array, as `.single()` and `.maybeSingle()` do. */
|
|
7
|
+
const wantsObject = (request) => (request.headers.get("accept") ?? "").includes("application/vnd.pgrst.object+json");
|
|
8
|
+
/** PGRST116 unless there is exactly one row, which an object response needs. */
|
|
9
|
+
const checkSingle = (rows) => {
|
|
10
|
+
if (rows !== 1) throw restError(406, "PGRST116", "Cannot coerce the result to a single JSON object", `The result contains ${rows} rows`);
|
|
11
|
+
};
|
|
12
|
+
/** The one element of a JSON array of one element, as Postgres's `json_agg` writes it: `[` and `]` around it. */
|
|
13
|
+
const onlyElement = (array) => array.slice(1, -1);
|
|
14
|
+
/** The response of a page of rows read from `offset`: an array, or one object when asked for. */
|
|
15
|
+
const pageResponse = (page, offset, object, headers = {}) => {
|
|
16
|
+
const status = rangeStatus(offset, page.page, page.total);
|
|
17
|
+
if (object) checkSingle(page.page);
|
|
18
|
+
return new Response(object ? onlyElement(page.body) : page.body, {
|
|
19
|
+
status,
|
|
20
|
+
headers: {
|
|
21
|
+
"content-range": contentRange(offset, page.page, page.total),
|
|
22
|
+
"content-type": object ? objectType : jsonType,
|
|
23
|
+
...headers
|
|
24
|
+
}
|
|
25
|
+
});
|
|
26
|
+
};
|
|
27
|
+
const errorResponse = (error) => new Response(JSON.stringify(error.body), {
|
|
28
|
+
status: error.status,
|
|
29
|
+
headers: {
|
|
30
|
+
"content-type": jsonType,
|
|
31
|
+
...error.headers
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
//#endregion
|
|
35
|
+
export { checkSingle, errorResponse, jsonType, objectType, onlyElement, pageResponse, wantsObject };
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { readTransaction, writeTransaction } from "../transactions.js";
|
|
2
|
+
import { wakeDispatch } from "../changes/actor.js";
|
|
3
|
+
import { callerSettings } from "./caller-settings.js";
|
|
4
|
+
import { restError } from "./rest-error.js";
|
|
5
|
+
//#region src/rest/rest-context.ts
|
|
6
|
+
/**
|
|
7
|
+
* Runs `work` in one transaction as the request's caller (see `callerSettings`), under the statement timeout;
|
|
8
|
+
* read-only for a read, so a function that writes fails as on PostgREST.
|
|
9
|
+
*/
|
|
10
|
+
const asCaller = async (deps, c, readOnly, work) => {
|
|
11
|
+
const settings = callerSettings(c.get("caller"), deps.actorSetting);
|
|
12
|
+
if (readOnly) return readTransaction(deps.db, deps.statementTimeoutMs, work, settings);
|
|
13
|
+
const result = await writeTransaction(deps.db, deps.statementTimeoutMs, work, settings);
|
|
14
|
+
wakeDispatch(deps);
|
|
15
|
+
return result;
|
|
16
|
+
};
|
|
17
|
+
/** The request's query parameters in order, decoded as `URLSearchParams` decodes them. */
|
|
18
|
+
const queryParams = (c) => [...new URL(c.req.url).searchParams];
|
|
19
|
+
/** The JSON body; `empty` when there is none, as a function called without arguments may send. Invalid JSON is PGRST102. */
|
|
20
|
+
const readBody = async (c, empty) => {
|
|
21
|
+
const text = await c.req.text();
|
|
22
|
+
if (text.trim() === "" && empty !== void 0) return empty;
|
|
23
|
+
try {
|
|
24
|
+
return JSON.parse(text);
|
|
25
|
+
} catch (error) {
|
|
26
|
+
if (error instanceof SyntaxError) throw restError(400, "PGRST102", "Empty or invalid json");
|
|
27
|
+
throw error;
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
//#endregion
|
|
31
|
+
export { asCaller, queryParams, readBody };
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { isDatabaseError } from "../error-handler.js";
|
|
2
|
+
//#region src/rest/rest-error.ts
|
|
3
|
+
/** A `/rest/v1` error: its status, the body, and headers such as `Content-Range`. Thrown anywhere below the route. */
|
|
4
|
+
var RestError = class RestError extends Error {
|
|
5
|
+
status;
|
|
6
|
+
body;
|
|
7
|
+
headers;
|
|
8
|
+
constructor(status, body, headers = {}) {
|
|
9
|
+
super(body.message);
|
|
10
|
+
this.status = status;
|
|
11
|
+
this.body = body;
|
|
12
|
+
this.headers = headers;
|
|
13
|
+
}
|
|
14
|
+
withHeaders(headers) {
|
|
15
|
+
return new RestError(this.status, this.body, {
|
|
16
|
+
...this.headers,
|
|
17
|
+
...headers
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
};
|
|
21
|
+
const restError = (status, code, message, details = null, hint = null) => new RestError(status, {
|
|
22
|
+
code,
|
|
23
|
+
details,
|
|
24
|
+
hint,
|
|
25
|
+
message
|
|
26
|
+
});
|
|
27
|
+
/** PGRST100: a query parameter that does not parse. */
|
|
28
|
+
const parseError = (message, details = null) => restError(400, "PGRST100", message, details);
|
|
29
|
+
const prefixStatus = [
|
|
30
|
+
["08", 503],
|
|
31
|
+
["09", 500],
|
|
32
|
+
["0L", 403],
|
|
33
|
+
["0P", 403],
|
|
34
|
+
["25", 500],
|
|
35
|
+
["28", 403],
|
|
36
|
+
["2D", 500],
|
|
37
|
+
["38", 500],
|
|
38
|
+
["39", 500],
|
|
39
|
+
["3B", 500],
|
|
40
|
+
["40", 500],
|
|
41
|
+
["53", 503],
|
|
42
|
+
["54", 500],
|
|
43
|
+
["55", 500],
|
|
44
|
+
["57", 500],
|
|
45
|
+
["58", 500],
|
|
46
|
+
["F0", 500],
|
|
47
|
+
["HV", 500],
|
|
48
|
+
["P0", 500],
|
|
49
|
+
["XX", 500]
|
|
50
|
+
];
|
|
51
|
+
const codeStatus = {
|
|
52
|
+
"23503": 409,
|
|
53
|
+
"23505": 409,
|
|
54
|
+
"25006": 405,
|
|
55
|
+
"53400": 500,
|
|
56
|
+
"57P01": 503,
|
|
57
|
+
P0001: 400,
|
|
58
|
+
"42883": 404,
|
|
59
|
+
"42P01": 404,
|
|
60
|
+
"42P17": 500
|
|
61
|
+
};
|
|
62
|
+
const databaseStatus = (code, message, anonymous) => {
|
|
63
|
+
if (code === "42501") return anonymous ? 401 : 403;
|
|
64
|
+
if (code.startsWith("PT")) return Number(code.slice(2)) || 500;
|
|
65
|
+
if (code === "21000") return message.endsWith("requires a WHERE clause") ? 400 : 500;
|
|
66
|
+
return codeStatus[code] ?? prefixStatus.find(([prefix]) => code.startsWith(prefix))?.[1] ?? 400;
|
|
67
|
+
};
|
|
68
|
+
/**
|
|
69
|
+
* The `/rest/v1` error an error stands for: its own, or a database error with PostgREST's status and the error's
|
|
70
|
+
* message, detail and hint, which PostgREST passes on as they are. Other errors give `undefined`. Refusing a write by
|
|
71
|
+
* RLS is 401 for an anonymous caller and 403 for others, as on Supabase.
|
|
72
|
+
*/
|
|
73
|
+
const restErrorOf = (error, anonymous) => {
|
|
74
|
+
if (error instanceof RestError) return error;
|
|
75
|
+
if (!isDatabaseError(error)) return void 0;
|
|
76
|
+
const { code, message, detail, hint } = error;
|
|
77
|
+
return restError(databaseStatus(code, message, anonymous), code, message, detail ?? null, hint ?? null);
|
|
78
|
+
};
|
|
79
|
+
//#endregion
|
|
80
|
+
export { RestError, parseError, restError, restErrorOf };
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { HttpProblem } from "../problem.js";
|
|
2
|
+
import { restError, restErrorOf } from "./rest-error.js";
|
|
3
|
+
import { errorResponse } from "./respond.js";
|
|
4
|
+
import { rpcRoute } from "./rpc-route.js";
|
|
5
|
+
import { tableRoutes } from "./table-routes.js";
|
|
6
|
+
import { Hono } from "hono";
|
|
7
|
+
//#region src/rest/rest-routes.ts
|
|
8
|
+
/**
|
|
9
|
+
* The PostgREST-compatible API supabase-js's `from()` and `rpc()` call, mounted at `/rest/v1`. Each request runs in one
|
|
10
|
+
* transaction as `anon`, `authenticated` or the app's own role, so the tables' RLS policies decide what it may read and
|
|
11
|
+
* write. Errors come back as PostgREST's `{ code, details, hint, message }`; an unexpected one as a 500 that names
|
|
12
|
+
* nothing of it.
|
|
13
|
+
*/
|
|
14
|
+
const restRoutes = ({ deps, caller, report }) => {
|
|
15
|
+
const app = new Hono();
|
|
16
|
+
app.onError((error, c) => {
|
|
17
|
+
const known = restErrorOf(error, c.get("caller")?.role === "anon");
|
|
18
|
+
if (known) return errorResponse(known);
|
|
19
|
+
if (error instanceof HttpProblem) return errorResponse(restError(error.status, error.status === 401 ? "PGRST301" : "PGRST000", error.detail).withHeaders(error.headers));
|
|
20
|
+
report?.(error, c.req.raw);
|
|
21
|
+
return errorResponse(restError(500, "PGRST000", "The server failed to handle the request"));
|
|
22
|
+
});
|
|
23
|
+
app.use("*", async (c, next) => {
|
|
24
|
+
c.set("caller", await caller(c.req.raw));
|
|
25
|
+
await next();
|
|
26
|
+
});
|
|
27
|
+
app.route("/", rpcRoute(deps));
|
|
28
|
+
app.route("/", tableRoutes(deps));
|
|
29
|
+
app.notFound(() => errorResponse(restError(404, "PGRST125", "Invalid path specified in request URL")));
|
|
30
|
+
return app;
|
|
31
|
+
};
|
|
32
|
+
//#endregion
|
|
33
|
+
export { restRoutes };
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { restError } from "./rest-error.js";
|
|
2
|
+
//#region src/rest/rest-scan.ts
|
|
3
|
+
const needs = {
|
|
4
|
+
eq: "equality",
|
|
5
|
+
neq: "btree",
|
|
6
|
+
gt: "btree",
|
|
7
|
+
gte: "btree",
|
|
8
|
+
lt: "btree",
|
|
9
|
+
lte: "btree",
|
|
10
|
+
in: "btree",
|
|
11
|
+
is: "btree",
|
|
12
|
+
isdistinct: "btree",
|
|
13
|
+
like: "trigram",
|
|
14
|
+
ilike: "trigram",
|
|
15
|
+
match: "trigram",
|
|
16
|
+
imatch: "trigram",
|
|
17
|
+
fts: "gin",
|
|
18
|
+
plfts: "gin",
|
|
19
|
+
phfts: "gin",
|
|
20
|
+
wfts: "gin",
|
|
21
|
+
cs: "containment",
|
|
22
|
+
cd: "containment",
|
|
23
|
+
ov: "containment",
|
|
24
|
+
sl: "gist",
|
|
25
|
+
sr: "gist",
|
|
26
|
+
nxr: "gist",
|
|
27
|
+
nxl: "gist",
|
|
28
|
+
adj: "gist"
|
|
29
|
+
};
|
|
30
|
+
const trigramClasses = ["gin_trgm_ops", "gist_trgm_ops"];
|
|
31
|
+
const quoted = (name) => `"${name.replaceAll("\"", "\"\"")}"`;
|
|
32
|
+
const pathKey = (path) => path.column + path.json.map((step) => `${step.arrow}${typeof step.key === "number" ? step.key : `'${step.key}'`}`).join("");
|
|
33
|
+
const normalized = (definition) => definition.replaceAll("::text", "").replace(/[\s()"]/g, "");
|
|
34
|
+
const keyMatches = (key, path) => path.json.length === 0 ? key.column === path.column : key.column === null && normalized(key.definition) === pathKey(path);
|
|
35
|
+
const keyName = (key) => key.column ?? normalized(key.definition);
|
|
36
|
+
const serves = (index, read, pinned) => {
|
|
37
|
+
if (index.partial) return false;
|
|
38
|
+
const position = index.keys.findIndex((key) => keyMatches(key, read.field));
|
|
39
|
+
if (position < 0) return false;
|
|
40
|
+
switch (read.need) {
|
|
41
|
+
case "equality":
|
|
42
|
+
if (trigramClasses.includes(index.keys[position].opclass)) return true;
|
|
43
|
+
return index.method === "btree" && index.keys.slice(0, position).every((key) => pinned.has(keyName(key)));
|
|
44
|
+
case "btree": return index.method === "btree" && index.keys.slice(0, position).every((key) => pinned.has(keyName(key)));
|
|
45
|
+
case "trigram": return trigramClasses.includes(index.keys[position].opclass);
|
|
46
|
+
case "gin":
|
|
47
|
+
case "containment": return index.method === "gin" || index.method === "gist";
|
|
48
|
+
case "gist": return index.method === "gist";
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
const pinnedFields = (filter) => {
|
|
52
|
+
if (filter.negated) return [];
|
|
53
|
+
if (filter.kind === "and") return filter.args.flatMap(pinnedFields);
|
|
54
|
+
if (filter.kind === "condition" && [
|
|
55
|
+
"eq",
|
|
56
|
+
"in",
|
|
57
|
+
"is"
|
|
58
|
+
].includes(filter.operator) && !filter.quantifier) return [pathKey(filter.field)];
|
|
59
|
+
return [];
|
|
60
|
+
};
|
|
61
|
+
const filterReads = (filter) => filter.kind === "condition" ? [{
|
|
62
|
+
field: filter.field,
|
|
63
|
+
need: needs[filter.operator],
|
|
64
|
+
use: "filter"
|
|
65
|
+
}] : filter.args.flatMap(filterReads);
|
|
66
|
+
const suggestion = (table, read) => {
|
|
67
|
+
const column = read.field.json.length === 0 ? quoted(read.field.column) : `(${pathKey(read.field).replace(read.field.column, quoted(read.field.column))})`;
|
|
68
|
+
if (read.need === "trigram") return `create index on ${table} using gin (${column} gin_trgm_ops)`;
|
|
69
|
+
if (read.need === "gin" || read.need === "containment") return `create index on ${table} using gin (${column})`;
|
|
70
|
+
if (read.need === "gist") return `create index on ${table} using gist (${column})`;
|
|
71
|
+
return `create index on ${table} (${column})`;
|
|
72
|
+
};
|
|
73
|
+
/**
|
|
74
|
+
* The reads of a request no index of the table serves: each filtered field, by the kind of index its operator needs,
|
|
75
|
+
* and the first sort field. A view or a foreign table has no indexes of its own and is not judged.
|
|
76
|
+
*/
|
|
77
|
+
const uncoveredReads = (table, filters, order) => {
|
|
78
|
+
if (table.kind !== "table") return [];
|
|
79
|
+
const pinned = new Set(filters.flatMap(pinnedFields));
|
|
80
|
+
return [...filters.flatMap(filterReads), ...order[0] ? [{
|
|
81
|
+
field: order[0].field,
|
|
82
|
+
need: "btree",
|
|
83
|
+
use: "sort"
|
|
84
|
+
}] : []].filter((read) => !table.indexes.some((index) => serves(index, read, pinned)));
|
|
85
|
+
};
|
|
86
|
+
/** The 400 error of a read no index serves, with the statement that adds one as its hint. */
|
|
87
|
+
const scanRefusal = (table, read) => {
|
|
88
|
+
const tableName = table.schema ? `${quoted(table.schema)}.${quoted(table.name)}` : quoted(table.name);
|
|
89
|
+
const what = read.use === "sort" ? "Sorting by" : "Filtering on";
|
|
90
|
+
const index = read.need === "btree" || read.need === "equality" ? "an index" : read.need === "trigram" ? "a pg_trgm index" : read.need === "gist" ? "a GiST index" : "a GIN index";
|
|
91
|
+
return restError(400, "expensive-query", `${what} "${pathKey(read.field)}" needs ${index} that serves it`, null, suggestion(tableName, read));
|
|
92
|
+
};
|
|
93
|
+
//#endregion
|
|
94
|
+
export { scanRefusal, uncoveredReads };
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { jsonType, onlyElement, pageResponse, wantsObject } from "./respond.js";
|
|
2
|
+
import { callSource, chooseFunction, findFunctions } from "./functions.js";
|
|
3
|
+
import { parsePrefer } from "./parse-prefer.js";
|
|
4
|
+
import { parseQuery } from "./parse-query.js";
|
|
5
|
+
import { readRows } from "./read-rows.js";
|
|
6
|
+
import { asCaller, queryParams, readBody } from "./rest-context.js";
|
|
7
|
+
import { Hono } from "hono";
|
|
8
|
+
import { sql } from "kysely";
|
|
9
|
+
//#region src/rest/rpc-route.ts
|
|
10
|
+
const reserved = /* @__PURE__ */ new Set([
|
|
11
|
+
"select",
|
|
12
|
+
"order",
|
|
13
|
+
"limit",
|
|
14
|
+
"offset",
|
|
15
|
+
"on_conflict",
|
|
16
|
+
"columns"
|
|
17
|
+
]);
|
|
18
|
+
/**
|
|
19
|
+
* Calls a function of the `public` schema: by POST with its arguments in a JSON object, or the whole body as its one
|
|
20
|
+
* unnamed json argument; by GET with them in the query string, in a read-only transaction. A function returning rows
|
|
21
|
+
* is read like a table, with `select`, filters, `order`, a range and a count; one returning one row gives an object,
|
|
22
|
+
* a scalar its JSON value, `void` 204.
|
|
23
|
+
*/
|
|
24
|
+
const call = async (deps, c) => {
|
|
25
|
+
const name = c.req.param("fn");
|
|
26
|
+
const get = c.req.method === "GET";
|
|
27
|
+
const params = queryParams(c);
|
|
28
|
+
const body = get ? void 0 : await readBody(c, {});
|
|
29
|
+
const prefer = parsePrefer(c.req.header("prefer") ?? null);
|
|
30
|
+
const object = wantsObject(c.req.raw);
|
|
31
|
+
return asCaller(deps, c, get, async (trx) => {
|
|
32
|
+
const candidates = await findFunctions(trx, name);
|
|
33
|
+
const inputs = new Set(candidates.flatMap((info) => info.inputs.map((input) => input.name)));
|
|
34
|
+
const given = get ? params.map(([key]) => key).filter((key) => !reserved.has(key) && inputs.has(key)) : typeof body === "object" && body !== null && !Array.isArray(body) ? Object.keys(body) : [];
|
|
35
|
+
const chosen = chooseFunction(name, candidates, given);
|
|
36
|
+
const args = get ? Object.fromEntries(params.filter(([key]) => given.includes(key))) : body;
|
|
37
|
+
const source = callSource(chosen, args);
|
|
38
|
+
const query = parseQuery(params, c.req.header("range") ?? null, new Set(given));
|
|
39
|
+
if (chosen.info.returns === "void") {
|
|
40
|
+
await sql`select 1 from ${source.from}`.execute(trx);
|
|
41
|
+
return new Response(null, {
|
|
42
|
+
status: 204,
|
|
43
|
+
headers: { "content-range": "0-0/*" }
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
if (!chosen.info.set) {
|
|
47
|
+
const page = await readRows(trx, source, query);
|
|
48
|
+
return new Response(onlyElement(page.body), {
|
|
49
|
+
status: 200,
|
|
50
|
+
headers: {
|
|
51
|
+
"content-range": "0-0/*",
|
|
52
|
+
"content-type": jsonType
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
return pageResponse(await readRows(trx, source, query, prefer.count), query.range.offset, object);
|
|
57
|
+
});
|
|
58
|
+
};
|
|
59
|
+
const rpcRoute = (deps) => {
|
|
60
|
+
const app = new Hono();
|
|
61
|
+
app.on(["GET", "POST"], "/rpc/:fn", (c) => call(deps, c));
|
|
62
|
+
return app;
|
|
63
|
+
};
|
|
64
|
+
//#endregion
|
|
65
|
+
export { rpcRoute };
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { pathName } from "./field-path.js";
|
|
2
|
+
import { fieldSql } from "./filter-sql.js";
|
|
3
|
+
import { sql } from "kysely";
|
|
4
|
+
//#region src/rest/shape-sql.ts
|
|
5
|
+
/**
|
|
6
|
+
* The select list of `select` on the rows of `source`: `*` its columns, a field under its alias, its JSON path's last
|
|
7
|
+
* key or its column, cast when asked. Cast types are checked to be bare type names when parsed.
|
|
8
|
+
*/
|
|
9
|
+
const selectSql = (source, items) => sql.join(items.map((item) => {
|
|
10
|
+
if (item.kind === "all") return sql`${sql.id(source)}.*`;
|
|
11
|
+
const value = fieldSql(source, item.field);
|
|
12
|
+
const cast = item.cast === void 0 ? value : sql`cast(${value} as ${sql.raw(item.cast)})`;
|
|
13
|
+
return sql`${cast} as ${sql.id(item.alias ?? pathName(item.field))}`;
|
|
14
|
+
}));
|
|
15
|
+
/** ` order by ...` for the terms; nothing without any. */
|
|
16
|
+
const orderSql = (source, terms) => terms.length === 0 ? sql`` : sql` order by ${sql.join(terms.map((term) => sql`${fieldSql(source, term.field)}${sql.raw(term.direction ? ` ${term.direction}` : "")}${sql.raw(term.nulls ? ` nulls ${term.nulls}` : "")}`))}`;
|
|
17
|
+
/** ` limit ... offset ...` for the range. */
|
|
18
|
+
const rangeSql = (range) => sql`${range.limit === void 0 ? sql`` : sql` limit ${range.limit}`}${range.offset === 0 ? sql`` : sql` offset ${range.offset}`}`;
|
|
19
|
+
//#endregion
|
|
20
|
+
export { orderSql, rangeSql, selectSql };
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { parseError } from "./rest-error.js";
|
|
2
|
+
//#region src/rest/split-list.ts
|
|
3
|
+
const closers = {
|
|
4
|
+
"(": ")",
|
|
5
|
+
"{": "}"
|
|
6
|
+
};
|
|
7
|
+
/**
|
|
8
|
+
* Splits `text` at the commas outside double quotes, parentheses and braces: `a,in.(1,2),b.cs.{x,y}` gives three parts. A
|
|
9
|
+
* backslash in quotes escapes the next character. An unclosed quote, parenthesis or brace does not parse.
|
|
10
|
+
*/
|
|
11
|
+
const splitList = (text) => {
|
|
12
|
+
const parts = [];
|
|
13
|
+
const open = [];
|
|
14
|
+
let quoted = false;
|
|
15
|
+
let start = 0;
|
|
16
|
+
for (let i = 0; i < text.length; i++) {
|
|
17
|
+
const char = text[i];
|
|
18
|
+
if (quoted) {
|
|
19
|
+
if (char === "\\") i++;
|
|
20
|
+
else if (char === "\"") quoted = false;
|
|
21
|
+
continue;
|
|
22
|
+
}
|
|
23
|
+
if (char === "\"") quoted = true;
|
|
24
|
+
else if (char in closers) open.push(closers[char]);
|
|
25
|
+
else if (char === open.at(-1)) open.pop();
|
|
26
|
+
else if (char === "," && open.length === 0) {
|
|
27
|
+
parts.push(text.slice(start, i));
|
|
28
|
+
start = i + 1;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
if (quoted || open.length > 0) throw parseError(`"${text}" has an unclosed quote, parenthesis or brace`);
|
|
32
|
+
parts.push(text.slice(start));
|
|
33
|
+
return parts;
|
|
34
|
+
};
|
|
35
|
+
/** A value in double quotes without them and with its escapes undone; any other value as it is. */
|
|
36
|
+
const unquote = (text) => text.length >= 2 && text.startsWith("\"") && text.endsWith("\"") ? text.slice(1, -1).replace(/\\(.)/g, "$1") : text;
|
|
37
|
+
//#endregion
|
|
38
|
+
export { splitList, unquote };
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { tableId } from "../sql-parts.js";
|
|
2
|
+
import { restError } from "./rest-error.js";
|
|
3
|
+
import { contentRange } from "./content-range.js";
|
|
4
|
+
import { checkSingle, jsonType, objectType, onlyElement, pageResponse, wantsObject } from "./respond.js";
|
|
5
|
+
import { parsePrefer } from "./parse-prefer.js";
|
|
6
|
+
import { parseQuery } from "./parse-query.js";
|
|
7
|
+
import { readRows } from "./read-rows.js";
|
|
8
|
+
import { asCaller, queryParams, readBody } from "./rest-context.js";
|
|
9
|
+
import { scanRefusal, uncoveredReads } from "./rest-scan.js";
|
|
10
|
+
import { deleteRows, insertRows, updateRows } from "./write-rows.js";
|
|
11
|
+
import { Hono } from "hono";
|
|
12
|
+
import { sql } from "kysely";
|
|
13
|
+
//#region src/rest/table-routes.ts
|
|
14
|
+
/**
|
|
15
|
+
* The resource whose table `name` is, in the `public` schema or the search path's: `/rest/v1` serves the tables the app
|
|
16
|
+
* declares as resources, as PostgREST serves those of its exposed schema. Any other is 404 (PGRST205).
|
|
17
|
+
*/
|
|
18
|
+
const tableEntry = (deps, name) => {
|
|
19
|
+
const entry = deps.registry.entries.find((one) => one.model.table.name === name && (one.model.table.schema ?? "public") === "public");
|
|
20
|
+
if (!entry) throw restError(404, "PGRST205", `Could not find the table 'public.${name}' in the schema cache`);
|
|
21
|
+
return entry;
|
|
22
|
+
};
|
|
23
|
+
const targetOf = (entry) => ({
|
|
24
|
+
table: tableId(entry.model),
|
|
25
|
+
alias: entry.model.table.name
|
|
26
|
+
});
|
|
27
|
+
/** Refuses filters and a sort no index serves, as the scan guard does on `/api/v1`; in `warn` mode, a warning header instead. */
|
|
28
|
+
const scanCheck = async (deps, entry, filters, order) => {
|
|
29
|
+
if (deps.guard.mode === "off") return {};
|
|
30
|
+
const [first] = uncoveredReads(await deps.guard.indexesOf(entry), filters, order);
|
|
31
|
+
if (!first) return {};
|
|
32
|
+
const refusal = scanRefusal(entry.model.table, first);
|
|
33
|
+
if (deps.guard.mode === "warn") return { "X-Protobase-Warning": refusal.body.message };
|
|
34
|
+
throw refusal;
|
|
35
|
+
};
|
|
36
|
+
const returningOf = (prefer, query) => prefer.return === "representation" ? { select: query.select } : void 0;
|
|
37
|
+
const counted = (prefer, total) => prefer.count === void 0 ? void 0 : total;
|
|
38
|
+
/** A write's response: the rows it returns when asked for, as an array or one object; else no body, with `emptyStatus`. */
|
|
39
|
+
const writeResponse = (written, object, status, emptyStatus, range) => {
|
|
40
|
+
if (written.body === void 0) return new Response(null, {
|
|
41
|
+
status: emptyStatus,
|
|
42
|
+
headers: { "content-range": range }
|
|
43
|
+
});
|
|
44
|
+
return new Response(object ? onlyElement(written.body) : written.body, {
|
|
45
|
+
status,
|
|
46
|
+
headers: {
|
|
47
|
+
"content-range": range,
|
|
48
|
+
"content-type": object ? objectType : jsonType
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
};
|
|
52
|
+
const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
53
|
+
/**
|
|
54
|
+
* The rows and columns of an insert's body: an object or an array of objects. The columns are `columns` when given,
|
|
55
|
+
* as supabase-js sends with several rows; otherwise the keys, which every row must then share (PGRST102).
|
|
56
|
+
*/
|
|
57
|
+
const insertRowsOf = (body, query) => {
|
|
58
|
+
const rows = Array.isArray(body) ? body : [body];
|
|
59
|
+
if (!rows.every(isRecord)) throw restError(400, "PGRST102", "Empty or invalid json");
|
|
60
|
+
if (query.columns) return {
|
|
61
|
+
rows,
|
|
62
|
+
columns: query.columns
|
|
63
|
+
};
|
|
64
|
+
const columns = Object.keys(rows[0] ?? {});
|
|
65
|
+
const same = (row) => Object.keys(row).length === columns.length && columns.every((column) => Object.hasOwn(row, column));
|
|
66
|
+
if (!rows.every(same)) throw restError(400, "PGRST102", "All object keys must match");
|
|
67
|
+
return {
|
|
68
|
+
rows,
|
|
69
|
+
columns
|
|
70
|
+
};
|
|
71
|
+
};
|
|
72
|
+
const read = async (deps, c) => {
|
|
73
|
+
const entry = tableEntry(deps, c.req.param("table"));
|
|
74
|
+
const query = parseQuery(queryParams(c), c.req.header("range") ?? null);
|
|
75
|
+
const prefer = parsePrefer(c.req.header("prefer") ?? null);
|
|
76
|
+
const warning = await scanCheck(deps, entry, query.filters, query.order);
|
|
77
|
+
const alias = entry.model.table.name;
|
|
78
|
+
const source = {
|
|
79
|
+
from: sql`${tableId(entry.model)} as ${sql.id(alias)}`,
|
|
80
|
+
alias
|
|
81
|
+
};
|
|
82
|
+
const page = await asCaller(deps, c, true, (trx) => readRows(trx, source, query, prefer.count));
|
|
83
|
+
return pageResponse(page, query.range.offset, wantsObject(c.req.raw), warning);
|
|
84
|
+
};
|
|
85
|
+
const insert = async (deps, c) => {
|
|
86
|
+
const entry = tableEntry(deps, c.req.param("table"));
|
|
87
|
+
const query = parseQuery(queryParams(c));
|
|
88
|
+
const prefer = parsePrefer(c.req.header("prefer") ?? null);
|
|
89
|
+
const object = wantsObject(c.req.raw);
|
|
90
|
+
const { rows, columns } = insertRowsOf(await readBody(c), query);
|
|
91
|
+
if (columns.length === 0 && rows.length > 1) throw restError(400, "PGRST102", "Several rows without any column cannot be inserted at once");
|
|
92
|
+
if (rows.length === 0 && object) checkSingle(0);
|
|
93
|
+
const conflict = query.onConflict ?? entry.model.primaryKey.map((name) => entry.model.fields[name].column);
|
|
94
|
+
const written = rows.length === 0 ? {
|
|
95
|
+
total: 0,
|
|
96
|
+
...prefer.return === "representation" && { body: "[]" }
|
|
97
|
+
} : await asCaller(deps, c, false, async (trx) => {
|
|
98
|
+
const result = await insertRows(trx, targetOf(entry), {
|
|
99
|
+
rows,
|
|
100
|
+
columns,
|
|
101
|
+
defaults: prefer.missing === "default",
|
|
102
|
+
resolution: prefer.resolution,
|
|
103
|
+
conflict
|
|
104
|
+
}, returningOf(prefer, query));
|
|
105
|
+
if (object) checkSingle(result.total);
|
|
106
|
+
return result;
|
|
107
|
+
});
|
|
108
|
+
const status = written.inserted === 0 ? 200 : 201;
|
|
109
|
+
return writeResponse(written, object, status, status, `*/${counted(prefer, written.total) ?? "*"}`);
|
|
110
|
+
};
|
|
111
|
+
const update = async (deps, c) => {
|
|
112
|
+
const entry = tableEntry(deps, c.req.param("table"));
|
|
113
|
+
const query = parseQuery(queryParams(c));
|
|
114
|
+
const prefer = parsePrefer(c.req.header("prefer") ?? null);
|
|
115
|
+
const object = wantsObject(c.req.raw);
|
|
116
|
+
const body = await readBody(c);
|
|
117
|
+
if (!isRecord(body)) throw restError(400, "PGRST102", "Empty or invalid json");
|
|
118
|
+
const warning = await scanCheck(deps, entry, query.filters, []);
|
|
119
|
+
const written = Object.keys(body).length === 0 ? {
|
|
120
|
+
total: 0,
|
|
121
|
+
...prefer.return === "representation" && { body: "[]" }
|
|
122
|
+
} : await asCaller(deps, c, false, async (trx) => {
|
|
123
|
+
const result = await updateRows(trx, targetOf(entry), body, query.filters, returningOf(prefer, query));
|
|
124
|
+
if (object) checkSingle(result.total);
|
|
125
|
+
return result;
|
|
126
|
+
});
|
|
127
|
+
const response = writeResponse(written, object, 200, 204, contentRange(0, written.total, counted(prefer, written.total)));
|
|
128
|
+
for (const [name, value] of Object.entries(warning)) response.headers.set(name, value);
|
|
129
|
+
return response;
|
|
130
|
+
};
|
|
131
|
+
const remove = async (deps, c) => {
|
|
132
|
+
const entry = tableEntry(deps, c.req.param("table"));
|
|
133
|
+
const query = parseQuery(queryParams(c));
|
|
134
|
+
const prefer = parsePrefer(c.req.header("prefer") ?? null);
|
|
135
|
+
const object = wantsObject(c.req.raw);
|
|
136
|
+
const warning = await scanCheck(deps, entry, query.filters, []);
|
|
137
|
+
const written = await asCaller(deps, c, false, async (trx) => {
|
|
138
|
+
const result = await deleteRows(trx, targetOf(entry), query.filters, returningOf(prefer, query));
|
|
139
|
+
if (object) checkSingle(result.total);
|
|
140
|
+
return result;
|
|
141
|
+
});
|
|
142
|
+
const response = writeResponse(written, object, 200, 204, `*/${counted(prefer, written.total) ?? "*"}`);
|
|
143
|
+
for (const [name, value] of Object.entries(warning)) response.headers.set(name, value);
|
|
144
|
+
return response;
|
|
145
|
+
};
|
|
146
|
+
/** `/rest/v1/<table>`: read with GET and HEAD, insert and upsert with POST, update with PATCH, delete with DELETE. */
|
|
147
|
+
const tableRoutes = (deps) => {
|
|
148
|
+
const app = new Hono();
|
|
149
|
+
app.get("/:table", (c) => read(deps, c));
|
|
150
|
+
app.post("/:table", (c) => insert(deps, c));
|
|
151
|
+
app.patch("/:table", (c) => update(deps, c));
|
|
152
|
+
app.delete("/:table", (c) => remove(deps, c));
|
|
153
|
+
return app;
|
|
154
|
+
};
|
|
155
|
+
//#endregion
|
|
156
|
+
export { tableRoutes };
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { whereSql } from "./filter-sql.js";
|
|
2
|
+
import { selectSql } from "./shape-sql.js";
|
|
3
|
+
import { sql } from "kysely";
|
|
4
|
+
//#region src/rest/write-rows.ts
|
|
5
|
+
const returningSql = (target, returning, upsert) => {
|
|
6
|
+
const row = returning ? sql`${sql.id(target.alias)} as pgrst_row` : sql`1 as pgrst_one`;
|
|
7
|
+
return upsert ? sql` returning ${row}, (${sql.id(target.alias)}.xmax = 0) as pgrst_inserted` : sql` returning ${row}`;
|
|
8
|
+
};
|
|
9
|
+
const run = async (trx, target, payload, write, returning, upsert) => {
|
|
10
|
+
const inserted = upsert ? sql`(select count(*) from pgrst_source where pgrst_inserted)` : sql`null::bigint`;
|
|
11
|
+
const body = returning ? sql`(select coalesce(json_agg(pgrst_rows), '[]')::text from (select ${selectSql(target.alias, returning.select)} from (select (pgrst_source.pgrst_row).* from pgrst_source) as ${sql.id(target.alias)}) as pgrst_rows)` : sql`null::text`;
|
|
12
|
+
const { rows: [result] } = await sql`
|
|
13
|
+
with pgrst_payload as (select ${JSON.stringify(payload ?? null)}::json as body), pgrst_source as (${write}${returningSql(target, returning, upsert)})
|
|
14
|
+
select (select count(*) from pgrst_source) as total, ${inserted} as inserted, ${body} as body
|
|
15
|
+
`.execute(trx);
|
|
16
|
+
return {
|
|
17
|
+
total: Number(result.total),
|
|
18
|
+
...result.inserted !== null && { inserted: Number(result.inserted) },
|
|
19
|
+
...result.body !== null && { body: result.body }
|
|
20
|
+
};
|
|
21
|
+
};
|
|
22
|
+
const valuesSql = (target, insert) => {
|
|
23
|
+
const columns = sql.join(insert.columns.map((column) => sql.id(column)));
|
|
24
|
+
if (!insert.defaults) return sql`(${columns}) select ${sql.join(insert.columns.map((column) => sql`pgrst_body.${sql.id(column)}`))} from pgrst_payload, json_populate_recordset(null::${target.table}, pgrst_payload.body) as pgrst_body`;
|
|
25
|
+
const rows = insert.rows.map((row, i) => sql`(${sql.join(insert.columns.map((column) => Object.hasOwn(row, column) ? sql`(json_populate_record(null::${target.table}, (select body -> ${sql.raw(String(i))} from pgrst_payload))).${sql.id(column)}` : sql`default`))})`);
|
|
26
|
+
return sql`(${columns}) values ${sql.join(rows)}`;
|
|
27
|
+
};
|
|
28
|
+
const conflictSql = (insert) => {
|
|
29
|
+
if (!insert.resolution) return sql``;
|
|
30
|
+
const target = sql.join(insert.conflict.map((column) => sql.id(column)));
|
|
31
|
+
if (insert.resolution === "ignore-duplicates") return sql` on conflict (${target}) do nothing`;
|
|
32
|
+
return sql` on conflict (${target}) do update set ${sql.join(insert.columns.map((column) => sql`${sql.id(column)} = excluded.${sql.id(column)}`))}`;
|
|
33
|
+
};
|
|
34
|
+
/** Inserts the rows, or upserts them with a resolution, in one statement. */
|
|
35
|
+
const insertRows = (trx, target, insert, returning) => {
|
|
36
|
+
const values = insert.columns.length === 0 ? sql` default values` : sql` ${valuesSql(target, insert)}`;
|
|
37
|
+
const write = sql`insert into ${target.table} as ${sql.id(target.alias)}${values}${conflictSql(insert)}`;
|
|
38
|
+
return run(trx, target, insert.rows, write, returning, insert.resolution !== void 0);
|
|
39
|
+
};
|
|
40
|
+
/** Sets the columns of the body on every row the filters keep. */
|
|
41
|
+
const updateRows = (trx, target, values, filters, returning) => {
|
|
42
|
+
const set = sql.join(Object.keys(values).map((column) => sql`${sql.id(column)} = pgrst_body.${sql.id(column)}`));
|
|
43
|
+
const from = sql`json_populate_record(null::${target.table}, (select body from pgrst_payload)) as pgrst_body`;
|
|
44
|
+
return run(trx, target, values, sql`update ${target.table} as ${sql.id(target.alias)} set ${set} from ${from}${whereSql(target.alias, filters)}`, returning, false);
|
|
45
|
+
};
|
|
46
|
+
/** Deletes every row the filters keep. */
|
|
47
|
+
const deleteRows = (trx, target, filters, returning) => run(trx, target, null, sql`delete from ${target.table} as ${sql.id(target.alias)}${whereSql(target.alias, filters)}`, returning, false);
|
|
48
|
+
//#endregion
|
|
49
|
+
export { deleteRows, insertRows, updateRows };
|
package/dist/scan-guard.js
CHANGED
|
@@ -28,7 +28,7 @@ const createScanGuard = (db, options = {}) => {
|
|
|
28
28
|
return read;
|
|
29
29
|
};
|
|
30
30
|
/** Throws a 400 problem for a column no index serves in `reject` mode; in `warn` mode returns the warning text. */
|
|
31
|
-
|
|
31
|
+
const check = async (entry, query, context) => {
|
|
32
32
|
if (mode === "off") return void 0;
|
|
33
33
|
const columns = uncoveredColumns(context.model, await indexesOf(entry), query, context.scope);
|
|
34
34
|
const first = columns[0];
|
|
@@ -40,6 +40,10 @@ const createScanGuard = (db, options = {}) => {
|
|
|
40
40
|
suggestion: first.suggestion
|
|
41
41
|
});
|
|
42
42
|
};
|
|
43
|
+
return Object.assign(check, {
|
|
44
|
+
mode,
|
|
45
|
+
indexesOf
|
|
46
|
+
});
|
|
43
47
|
};
|
|
44
48
|
//#endregion
|
|
45
49
|
export { createScanGuard };
|
package/dist/transactions.js
CHANGED
|
@@ -13,9 +13,9 @@ const writeTransaction = (db, timeoutMs, run, settings = {}) => db.transaction()
|
|
|
13
13
|
return run(trx);
|
|
14
14
|
});
|
|
15
15
|
/** Same, and Postgres refuses any write inside it. */
|
|
16
|
-
const readTransaction = (db, timeoutMs, run) => writeTransaction(db, timeoutMs, async (trx) => {
|
|
16
|
+
const readTransaction = (db, timeoutMs, run, settings = {}) => writeTransaction(db, timeoutMs, async (trx) => {
|
|
17
17
|
await sql`set transaction read only`.execute(trx);
|
|
18
18
|
return run(trx);
|
|
19
|
-
});
|
|
19
|
+
}, settings);
|
|
20
20
|
//#endregion
|
|
21
21
|
export { readTransaction, writeTransaction };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@protobase/server",
|
|
3
|
-
"version": "0.26.
|
|
3
|
+
"version": "0.26.7",
|
|
4
4
|
"description": "The Protobase REST API as a Hono app, with Better Auth sign-in.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"repository": {
|
|
@@ -32,9 +32,9 @@
|
|
|
32
32
|
"dependencies": {
|
|
33
33
|
"@better-auth/passkey": "^1.7.7",
|
|
34
34
|
"@better-auth/sso": "^1.7.7",
|
|
35
|
-
"@protobase/layout": "0.26.
|
|
36
|
-
"@protobase/query": "0.26.
|
|
37
|
-
"@protobase/schema": "0.26.
|
|
35
|
+
"@protobase/layout": "0.26.7",
|
|
36
|
+
"@protobase/query": "0.26.7",
|
|
37
|
+
"@protobase/schema": "0.26.7",
|
|
38
38
|
"@react-email/render": "^2.1.0",
|
|
39
39
|
"@scalar/hono-api-reference": "^0.12.10",
|
|
40
40
|
"@sentry/core": "^11.7.0",
|