@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.
@@ -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 };