@protobase/server 0.26.6 → 0.26.8
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/check-views.js +4 -2
- package/dist/create-admin.d.ts +1 -1
- package/dist/create-admin.js +13 -6
- 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/embed-params.js +64 -0
- package/dist/rest/embed-shape.js +42 -0
- package/dist/rest/embed-sql.js +24 -0
- package/dist/rest/field-path.js +25 -0
- package/dist/rest/filter-sql.js +62 -0
- package/dist/rest/find-relationship.js +39 -0
- package/dist/rest/foreign-keys.js +32 -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 +64 -0
- package/dist/rest/parse-select.js +42 -0
- package/dist/rest/read-rows.js +41 -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 +34 -0
- package/dist/rest/rest-scan.js +106 -0
- package/dist/rest/rpc-route.js +70 -0
- package/dist/rest/shape-sql.js +23 -0
- package/dist/rest/split-list.js +38 -0
- package/dist/rest/table-routes.js +196 -0
- package/dist/rest/whole-number.js +9 -0
- package/dist/rest/write-rows.js +50 -0
- package/dist/scan-guard.js +5 -1
- package/dist/transactions.js +2 -2
- package/dist/view-access.js +11 -1
- package/dist/view-action-when.js +23 -0
- package/package.json +4 -4
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { parseError } from "./rest-error.js";
|
|
2
|
+
import { parseFieldPath } from "./field-path.js";
|
|
3
|
+
import { splitList, unquote } from "./split-list.js";
|
|
4
|
+
//#region src/rest/parse-filter.ts
|
|
5
|
+
const operators = [
|
|
6
|
+
"eq",
|
|
7
|
+
"neq",
|
|
8
|
+
"gt",
|
|
9
|
+
"gte",
|
|
10
|
+
"lt",
|
|
11
|
+
"lte",
|
|
12
|
+
"like",
|
|
13
|
+
"ilike",
|
|
14
|
+
"match",
|
|
15
|
+
"imatch",
|
|
16
|
+
"in",
|
|
17
|
+
"is",
|
|
18
|
+
"isdistinct",
|
|
19
|
+
"fts",
|
|
20
|
+
"plfts",
|
|
21
|
+
"phfts",
|
|
22
|
+
"wfts",
|
|
23
|
+
"cs",
|
|
24
|
+
"cd",
|
|
25
|
+
"ov",
|
|
26
|
+
"sl",
|
|
27
|
+
"sr",
|
|
28
|
+
"nxr",
|
|
29
|
+
"nxl",
|
|
30
|
+
"adj"
|
|
31
|
+
];
|
|
32
|
+
/** The operators that compare with `any` or `all` of a list, `eq(any).{1,2}`. */
|
|
33
|
+
const quantifiable = /* @__PURE__ */ new Set([
|
|
34
|
+
"eq",
|
|
35
|
+
"gt",
|
|
36
|
+
"gte",
|
|
37
|
+
"lt",
|
|
38
|
+
"lte",
|
|
39
|
+
"like",
|
|
40
|
+
"ilike",
|
|
41
|
+
"match",
|
|
42
|
+
"imatch"
|
|
43
|
+
]);
|
|
44
|
+
/** The full text search operators, which take a language, `fts(english).cat`. */
|
|
45
|
+
const textSearch = /* @__PURE__ */ new Set([
|
|
46
|
+
"fts",
|
|
47
|
+
"plfts",
|
|
48
|
+
"phfts",
|
|
49
|
+
"wfts"
|
|
50
|
+
]);
|
|
51
|
+
const isValues = [
|
|
52
|
+
"null",
|
|
53
|
+
"not_null",
|
|
54
|
+
"true",
|
|
55
|
+
"false",
|
|
56
|
+
"unknown"
|
|
57
|
+
];
|
|
58
|
+
const isOperator = (name) => operators.includes(name);
|
|
59
|
+
const inList = (text) => {
|
|
60
|
+
if (!text.startsWith("(") || !text.endsWith(")")) throw parseError(`"failed to parse filter (in.${text})"`, "in takes a list in parentheses, such as in.(1,2)");
|
|
61
|
+
const inner = text.slice(1, -1);
|
|
62
|
+
return inner.trim() === "" ? [] : splitList(inner).map(unquote);
|
|
63
|
+
};
|
|
64
|
+
/**
|
|
65
|
+
* Parses what a filter says of `field`: `[not.]operator[(modifier)].value`. In a logic tree a value in double quotes
|
|
66
|
+
* loses them, so it may hold commas and parentheses.
|
|
67
|
+
*/
|
|
68
|
+
const parseCondition = (field, text, inTree = false) => {
|
|
69
|
+
const negated = text.startsWith("not.");
|
|
70
|
+
const rest = negated ? text.slice(4) : text;
|
|
71
|
+
const match = /^(\w+)(?:\((\w+)\))?\.([\s\S]*)$/.exec(rest);
|
|
72
|
+
const [, name = "", modifier, value = ""] = match ?? [];
|
|
73
|
+
if (!match || !isOperator(name)) throw parseError(`"failed to parse filter (${text})"`, `"${name || text}" is not an operator`);
|
|
74
|
+
const condition = {
|
|
75
|
+
kind: "condition",
|
|
76
|
+
field,
|
|
77
|
+
negated,
|
|
78
|
+
operator: name
|
|
79
|
+
};
|
|
80
|
+
if (name === "in") return {
|
|
81
|
+
...condition,
|
|
82
|
+
value: inList(value)
|
|
83
|
+
};
|
|
84
|
+
if (name === "is") {
|
|
85
|
+
if (!isValues.includes(value)) throw parseError(`"failed to parse filter (${text})"`, `is takes one of ${isValues.join(", ")}`);
|
|
86
|
+
return {
|
|
87
|
+
...condition,
|
|
88
|
+
value
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
const plain = inTree ? unquote(value) : value;
|
|
92
|
+
if (modifier === void 0) return {
|
|
93
|
+
...condition,
|
|
94
|
+
value: plain
|
|
95
|
+
};
|
|
96
|
+
if (textSearch.has(name)) return {
|
|
97
|
+
...condition,
|
|
98
|
+
language: modifier,
|
|
99
|
+
value: plain
|
|
100
|
+
};
|
|
101
|
+
if ((modifier === "any" || modifier === "all") && quantifiable.has(name)) return {
|
|
102
|
+
...condition,
|
|
103
|
+
quantifier: modifier,
|
|
104
|
+
value: plain
|
|
105
|
+
};
|
|
106
|
+
throw parseError(`"failed to parse filter (${text})"`, `${name} takes no "${modifier}"`);
|
|
107
|
+
};
|
|
108
|
+
const treeItem = (text) => {
|
|
109
|
+
const nested = /^(not\.)?(and|or)(\([\s\S]*\))$/.exec(text);
|
|
110
|
+
if (nested) return parseLogic(nested[2], Boolean(nested[1]), nested[3]);
|
|
111
|
+
const dot = text.indexOf(".");
|
|
112
|
+
if (dot <= 0) throw parseError(`"failed to parse logic tree (${text})"`, "Each condition is field.operator.value");
|
|
113
|
+
return parseCondition(parseFieldPath(text.slice(0, dot)), text.slice(dot + 1), true);
|
|
114
|
+
};
|
|
115
|
+
/** Parses the value of `or=`, `and=`, `not.or=` or `not.and=`: `(item,item,...)`, items nesting `and(...)` and `or(...)`. */
|
|
116
|
+
const parseLogic = (kind, negated, text) => {
|
|
117
|
+
if (!text.startsWith("(") || !text.endsWith(")")) throw parseError(`"failed to parse logic tree (${text})"`, "A logic tree is a list in parentheses");
|
|
118
|
+
return {
|
|
119
|
+
kind,
|
|
120
|
+
negated,
|
|
121
|
+
args: splitList(text.slice(1, -1)).map((item) => treeItem(item.trim()))
|
|
122
|
+
};
|
|
123
|
+
};
|
|
124
|
+
/** The filter of one query parameter: `column=op.value`, or a logic tree under `or`, `and`, `not.or` or `not.and`. */
|
|
125
|
+
const parseFilter = (key, value) => {
|
|
126
|
+
const logic = /^(not\.)?(and|or)$/.exec(key);
|
|
127
|
+
if (logic) return parseLogic(logic[2], Boolean(logic[1]), value);
|
|
128
|
+
return parseCondition(parseFieldPath(key), value);
|
|
129
|
+
};
|
|
130
|
+
//#endregion
|
|
131
|
+
export { isValues, operators, parseCondition, parseFilter, parseLogic, textSearch };
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { parseError } from "./rest-error.js";
|
|
2
|
+
import { parseFieldPath } from "./field-path.js";
|
|
3
|
+
import { splitList } from "./split-list.js";
|
|
4
|
+
//#region src/rest/parse-order.ts
|
|
5
|
+
const directions = /* @__PURE__ */ new Set(["asc", "desc"]);
|
|
6
|
+
const nulls = /* @__PURE__ */ new Set(["nullsfirst", "nullslast"]);
|
|
7
|
+
const parseTerm = (text) => {
|
|
8
|
+
const [fieldText = "", ...modifiers] = text.trim().split(".");
|
|
9
|
+
const term = { field: parseFieldPath(fieldText) };
|
|
10
|
+
for (const modifier of modifiers) if (directions.has(modifier) && !term.direction && !term.nulls) term.direction = modifier;
|
|
11
|
+
else if (nulls.has(modifier) && !term.nulls) term.nulls = modifier === "nullsfirst" ? "first" : "last";
|
|
12
|
+
else throw parseError(`"failed to parse order (${text})"`, `"${modifier}" is neither asc, desc, nullsfirst nor nullslast`);
|
|
13
|
+
return term;
|
|
14
|
+
};
|
|
15
|
+
/** Parses `order`: terms separated by commas, such as `priority.desc.nullslast,id`. */
|
|
16
|
+
const parseOrder = (text) => splitList(text).map(parseTerm);
|
|
17
|
+
//#endregion
|
|
18
|
+
export { parseOrder };
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
//#region src/rest/parse-prefer.ts
|
|
2
|
+
const known = {
|
|
3
|
+
return: [
|
|
4
|
+
"minimal",
|
|
5
|
+
"representation",
|
|
6
|
+
"headers-only"
|
|
7
|
+
],
|
|
8
|
+
count: [
|
|
9
|
+
"exact",
|
|
10
|
+
"planned",
|
|
11
|
+
"estimated"
|
|
12
|
+
],
|
|
13
|
+
resolution: ["merge-duplicates", "ignore-duplicates"],
|
|
14
|
+
missing: ["default", "null"]
|
|
15
|
+
};
|
|
16
|
+
/** Reads `Prefer: return=representation, count=exact`, in one header or several; the first of each kind counts. */
|
|
17
|
+
const parsePrefer = (header) => {
|
|
18
|
+
const preferences = {};
|
|
19
|
+
for (const part of header?.split(",") ?? []) {
|
|
20
|
+
const [name = "", value = ""] = part.trim().split("=").map((text) => text.trim());
|
|
21
|
+
if (name in known && !(name in preferences) && known[name].includes(value)) preferences[name] = value;
|
|
22
|
+
}
|
|
23
|
+
return preferences;
|
|
24
|
+
};
|
|
25
|
+
//#endregion
|
|
26
|
+
export { parsePrefer };
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { splitList, unquote } from "./split-list.js";
|
|
2
|
+
import { parseFilter } from "./parse-filter.js";
|
|
3
|
+
import { parseOrder } from "./parse-order.js";
|
|
4
|
+
import { parseSelect } from "./parse-select.js";
|
|
5
|
+
import { wholeNumber } from "./whole-number.js";
|
|
6
|
+
import { parseEmbedParams, splitEmbedKey } from "./embed-params.js";
|
|
7
|
+
//#region src/rest/parse-query.ts
|
|
8
|
+
const reserved = /* @__PURE__ */ new Set([
|
|
9
|
+
"select",
|
|
10
|
+
"order",
|
|
11
|
+
"limit",
|
|
12
|
+
"offset",
|
|
13
|
+
"on_conflict",
|
|
14
|
+
"columns"
|
|
15
|
+
]);
|
|
16
|
+
const names = (text) => splitList(text).map((name) => unquote(name.trim()));
|
|
17
|
+
/** `Range: 2-4` or `Range: 5-`, in items; anything else asks for every row, as PostgREST takes it. */
|
|
18
|
+
const rangeHeader = (header) => {
|
|
19
|
+
const match = header === null ? null : /^\s*(\d+)-(\d*)\s*$/.exec(header);
|
|
20
|
+
if (!match) return { offset: 0 };
|
|
21
|
+
const offset = Number(match[1]);
|
|
22
|
+
return match[2] === "" ? { offset } : {
|
|
23
|
+
offset,
|
|
24
|
+
limit: Math.max(0, Number(match[2]) - offset + 1)
|
|
25
|
+
};
|
|
26
|
+
};
|
|
27
|
+
const intersect = (a, b) => {
|
|
28
|
+
const offset = Math.max(a.offset, b.offset);
|
|
29
|
+
const ends = [a, b].flatMap((range) => range.limit === void 0 ? [] : [range.offset + range.limit]);
|
|
30
|
+
return ends.length === 0 ? { offset } : {
|
|
31
|
+
offset,
|
|
32
|
+
limit: Math.max(0, Math.min(...ends) - offset)
|
|
33
|
+
};
|
|
34
|
+
};
|
|
35
|
+
/**
|
|
36
|
+
* Parses a request's query parameters. The ones in `args` are a function's arguments, not filters. `select`, `order`,
|
|
37
|
+
* `limit`, `offset`, `on_conflict` and `columns` count once, the first time; a parameter under an embed's path, such
|
|
38
|
+
* as `comments.likes`, is the embed's (see `parseEmbedParams`); every other parameter is a filter.
|
|
39
|
+
*/
|
|
40
|
+
const parseQuery = (params, rangeText = null, args = /* @__PURE__ */ new Set()) => {
|
|
41
|
+
const first = (name) => params.find(([key]) => key === name)?.[1];
|
|
42
|
+
const limit = first("limit");
|
|
43
|
+
const offset = first("offset");
|
|
44
|
+
const order = first("order");
|
|
45
|
+
const onConflict = first("on_conflict");
|
|
46
|
+
const columns = first("columns");
|
|
47
|
+
const select = parseSelect(first("select"));
|
|
48
|
+
const own = params.filter(([key]) => !reserved.has(key) && !args.has(key));
|
|
49
|
+
const fromParams = {
|
|
50
|
+
offset: offset === void 0 ? 0 : wholeNumber("offset", offset),
|
|
51
|
+
...limit !== void 0 && { limit: wholeNumber("limit", limit) }
|
|
52
|
+
};
|
|
53
|
+
return {
|
|
54
|
+
select,
|
|
55
|
+
filters: own.filter(([key]) => splitEmbedKey(key).path === void 0).map(([key, value]) => parseFilter(key, value)),
|
|
56
|
+
embedded: parseEmbedParams(own, select),
|
|
57
|
+
order: order === void 0 ? [] : parseOrder(order),
|
|
58
|
+
range: intersect(fromParams, rangeHeader(rangeText)),
|
|
59
|
+
...onConflict !== void 0 && { onConflict: names(onConflict) },
|
|
60
|
+
...columns !== void 0 && { columns: names(columns) }
|
|
61
|
+
};
|
|
62
|
+
};
|
|
63
|
+
//#endregion
|
|
64
|
+
export { parseQuery };
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { parseError, restError } from "./rest-error.js";
|
|
2
|
+
import { parseFieldPath } from "./field-path.js";
|
|
3
|
+
import { splitList } from "./split-list.js";
|
|
4
|
+
//#region src/rest/parse-select.ts
|
|
5
|
+
const typeName = /^[A-Za-z_][A-Za-z0-9_]*(\[\])?$/;
|
|
6
|
+
const embedPattern = /^(?:([^:(!]+):(?!:))?([^!(]+)((?:![^!(]+)*)\(([\s\S]*)\)$/;
|
|
7
|
+
const parseEmbed = (item) => {
|
|
8
|
+
const [, alias, nameText = "", modifiers = "", inner = ""] = embedPattern.exec(item) ?? [];
|
|
9
|
+
const name = nameText.trim();
|
|
10
|
+
if (!name || name.startsWith("...") || name.includes(".") || name === "count") throw restError(400, "PGRST100", `"${item}" spreads a resource or calls an aggregate, which /rest/v1 does not support yet`);
|
|
11
|
+
const flags = modifiers.split("!").slice(1).map((flag) => flag.trim());
|
|
12
|
+
const hint = flags.find((flag) => flag !== "inner" && flag !== "left");
|
|
13
|
+
return {
|
|
14
|
+
kind: "embed",
|
|
15
|
+
name,
|
|
16
|
+
...alias?.trim() && { alias: alias.trim() },
|
|
17
|
+
...hint !== void 0 && { hint },
|
|
18
|
+
inner: flags.includes("inner"),
|
|
19
|
+
select: inner.trim() === "" ? [] : splitList(inner).map(parseItem)
|
|
20
|
+
};
|
|
21
|
+
};
|
|
22
|
+
const parseItem = (text) => {
|
|
23
|
+
const item = text.trim();
|
|
24
|
+
if (item === "*") return { kind: "all" };
|
|
25
|
+
if (item.includes("(")) return parseEmbed(item);
|
|
26
|
+
const aliased = /^([^:]+):(?!:)([\s\S]*)$/.exec(item);
|
|
27
|
+
const alias = aliased?.[1]?.trim();
|
|
28
|
+
const [fieldText = "", cast, ...more] = (aliased ? aliased[2] : item).split("::");
|
|
29
|
+
if (more.length > 0 || cast !== void 0 && !typeName.test(cast.trim())) throw parseError(`"failed to parse select parameter (${item})"`, `"${cast}" is not a type name`);
|
|
30
|
+
return {
|
|
31
|
+
kind: "field",
|
|
32
|
+
field: parseFieldPath(fieldText),
|
|
33
|
+
...alias && { alias },
|
|
34
|
+
...cast !== void 0 && { cast: cast.trim() }
|
|
35
|
+
};
|
|
36
|
+
};
|
|
37
|
+
/** Parses `select`: items separated by commas, `*` for every column. Missing or empty, it is `*`. */
|
|
38
|
+
const parseSelect = (text) => text === void 0 || text.trim() === "" ? [{ kind: "all" }] : splitList(text).map(parseItem);
|
|
39
|
+
/** The name an embed goes by in a row and in the parameters that filter it: its alias, else its name. */
|
|
40
|
+
const embedKey = (item) => item.alias ?? item.name;
|
|
41
|
+
//#endregion
|
|
42
|
+
export { embedKey, parseSelect };
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { whereSql } from "./filter-sql.js";
|
|
2
|
+
import { orderSql, rangeSql, selectSql } from "./shape-sql.js";
|
|
3
|
+
import { joinsSql } from "./embed-sql.js";
|
|
4
|
+
import { sql } from "kysely";
|
|
5
|
+
//#region src/rest/read-rows.ts
|
|
6
|
+
/** The most rows a read returns, as Supabase's `db-max-rows` default. */
|
|
7
|
+
const maxRows = 1e3;
|
|
8
|
+
const plannedRows = async (trx, source, filtered) => {
|
|
9
|
+
const { rows } = await sql`explain (format json) select 1 from ${source.from}${filtered}`.execute(trx);
|
|
10
|
+
return rows[0]["QUERY PLAN"][0].Plan["Plan Rows"];
|
|
11
|
+
};
|
|
12
|
+
/**
|
|
13
|
+
* Reads the rows of `source` the query asks for: filtered, shaped by `shape` (its `select`, embeds resolved), sorted, in its range and never more than
|
|
14
|
+
* `maxRows`, as one JSON array built by Postgres, so values reach the client exactly as Postgres writes them. `count`
|
|
15
|
+
* counts the matching rows: `exact` by counting, `planned` by the planner's estimate, `estimated` by counting up to
|
|
16
|
+
* `maxRows` and estimating beyond. The rows an inner embed drops are not counted.
|
|
17
|
+
*/
|
|
18
|
+
const readRows = async (trx, source, shape, query, count) => {
|
|
19
|
+
const where = whereSql(source.alias, query.filters);
|
|
20
|
+
const filtered = sql`${joinsSql(source.alias, shape.filter((item) => item.kind === "embed" && item.inner))}${where}`;
|
|
21
|
+
const range = {
|
|
22
|
+
offset: query.range.offset,
|
|
23
|
+
limit: Math.min(query.range.limit ?? 1e3, maxRows)
|
|
24
|
+
};
|
|
25
|
+
const columns = source.scalar ? sql`${sql.id(source.alias)} as pgrst_scalar` : selectSql(source.alias, shape);
|
|
26
|
+
const rows = sql`select ${columns} from ${source.from}${joinsSql(source.alias, shape)}${where}${orderSql(source.alias, query.order)}${rangeSql(range)}`;
|
|
27
|
+
const counted = count === "exact" || count === "estimated" ? sql`(select count(*) from ${source.from}${filtered})` : sql`null::bigint`;
|
|
28
|
+
const aggregate = source.scalar ? sql`json_agg(pgrst_rows.pgrst_scalar)` : sql`json_agg(pgrst_rows)`;
|
|
29
|
+
const { rows: [result] } = await sql`
|
|
30
|
+
select coalesce(${aggregate}, '[]')::text as body, count(*) as page, ${counted} as total from (${rows}) as pgrst_rows
|
|
31
|
+
`.execute(trx);
|
|
32
|
+
const exact = result.total === null ? void 0 : Number(result.total);
|
|
33
|
+
const total = count === "planned" || count === "estimated" && exact > 1e3 ? await plannedRows(trx, source, filtered) : exact;
|
|
34
|
+
return {
|
|
35
|
+
body: result.body,
|
|
36
|
+
page: Number(result.page),
|
|
37
|
+
...total !== void 0 && { total }
|
|
38
|
+
};
|
|
39
|
+
};
|
|
40
|
+
//#endregion
|
|
41
|
+
export { maxRows, readRows };
|
|
@@ -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,34 @@
|
|
|
1
|
+
import { HttpProblem } from "../problem.js";
|
|
2
|
+
import { foreignKeyCache } from "./foreign-keys.js";
|
|
3
|
+
import { restError, restErrorOf } from "./rest-error.js";
|
|
4
|
+
import { errorResponse } from "./respond.js";
|
|
5
|
+
import { rpcRoute } from "./rpc-route.js";
|
|
6
|
+
import { tableRoutes } from "./table-routes.js";
|
|
7
|
+
import { Hono } from "hono";
|
|
8
|
+
//#region src/rest/rest-routes.ts
|
|
9
|
+
/**
|
|
10
|
+
* The PostgREST-compatible API supabase-js's `from()` and `rpc()` call, mounted at `/rest/v1`. Each request runs in one
|
|
11
|
+
* transaction as `anon`, `authenticated` or the app's own role, so the tables' RLS policies decide what it may read and
|
|
12
|
+
* write. Errors come back as PostgREST's `{ code, details, hint, message }`; an unexpected one as a 500 that names
|
|
13
|
+
* nothing of it.
|
|
14
|
+
*/
|
|
15
|
+
const restRoutes = ({ deps, caller, report }) => {
|
|
16
|
+
const app = new Hono();
|
|
17
|
+
app.onError((error, c) => {
|
|
18
|
+
const known = restErrorOf(error, c.get("caller")?.role === "anon");
|
|
19
|
+
if (known) return errorResponse(known);
|
|
20
|
+
if (error instanceof HttpProblem) return errorResponse(restError(error.status, error.status === 401 ? "PGRST301" : "PGRST000", error.detail).withHeaders(error.headers));
|
|
21
|
+
report?.(error, c.req.raw);
|
|
22
|
+
return errorResponse(restError(500, "PGRST000", "The server failed to handle the request"));
|
|
23
|
+
});
|
|
24
|
+
app.use("*", async (c, next) => {
|
|
25
|
+
c.set("caller", await caller(c.req.raw));
|
|
26
|
+
await next();
|
|
27
|
+
});
|
|
28
|
+
app.route("/", rpcRoute(deps));
|
|
29
|
+
app.route("/", tableRoutes(deps, foreignKeyCache(deps.db)));
|
|
30
|
+
app.notFound(() => errorResponse(restError(404, "PGRST125", "Invalid path specified in request URL")));
|
|
31
|
+
return app;
|
|
32
|
+
};
|
|
33
|
+
//#endregion
|
|
34
|
+
export { restRoutes };
|
|
@@ -0,0 +1,106 @@
|
|
|
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 column an embed joins on, which equals one value per
|
|
75
|
+
* related row; each filtered field, by the kind of index its operator needs; and the first sort field. A view or a
|
|
76
|
+
* foreign table has no indexes of its own and is not judged.
|
|
77
|
+
*/
|
|
78
|
+
const uncoveredReads = (table, filters, order, joined = []) => {
|
|
79
|
+
if (table.kind !== "table") return [];
|
|
80
|
+
const pinned = /* @__PURE__ */ new Set([...joined, ...filters.flatMap(pinnedFields)]);
|
|
81
|
+
return [
|
|
82
|
+
...joined.map((column) => ({
|
|
83
|
+
field: {
|
|
84
|
+
column,
|
|
85
|
+
json: []
|
|
86
|
+
},
|
|
87
|
+
need: "equality",
|
|
88
|
+
use: "join"
|
|
89
|
+
})),
|
|
90
|
+
...filters.flatMap(filterReads),
|
|
91
|
+
...order[0] ? [{
|
|
92
|
+
field: order[0].field,
|
|
93
|
+
need: "btree",
|
|
94
|
+
use: "sort"
|
|
95
|
+
}] : []
|
|
96
|
+
].filter((read) => !table.indexes.some((index) => serves(index, read, pinned)));
|
|
97
|
+
};
|
|
98
|
+
/** The 400 error of a read no index serves, with the statement that adds one as its hint. */
|
|
99
|
+
const scanRefusal = (table, read) => {
|
|
100
|
+
const tableName = table.schema ? `${quoted(table.schema)}.${quoted(table.name)}` : quoted(table.name);
|
|
101
|
+
const what = read.use === "sort" ? "Sorting by" : read.use === "join" ? "Embedding through" : "Filtering on";
|
|
102
|
+
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";
|
|
103
|
+
return restError(400, "expensive-query", `${what} "${pathKey(read.field)}" needs ${index} that serves it`, null, suggestion(tableName, read));
|
|
104
|
+
};
|
|
105
|
+
//#endregion
|
|
106
|
+
export { scanRefusal, uncoveredReads };
|