@protobase/server 0.26.6 → 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.
@@ -14,9 +14,9 @@ export type AdminOptions = {
14
14
  /** Default `/api/v1`. */
15
15
  basePath?: string;
16
16
  /**
17
- * The app's publishable and secret keys, with which supabase-js calls the Realtime-compatible `/realtime/v1` (see
18
- * `ApiKeysOptions`); default `PROTOBASE_PUBLISHABLE_KEY` and `PROTOBASE_SECRET_KEY`. Without either, or with
19
- * `false`, the app serves no `/realtime/v1`.
17
+ * The app's publishable and secret keys, with which supabase-js calls the PostgREST-compatible `/rest/v1` and the
18
+ * Realtime-compatible `/realtime/v1` (see `ApiKeysOptions`); default `PROTOBASE_PUBLISHABLE_KEY` and
19
+ * `PROTOBASE_SECRET_KEY`. Without either, or with `false`, the app serves neither.
20
20
  */
21
21
  apiKeys?: ApiKeysOptions | false;
22
22
  /** Applied to every request's transaction with SET LOCAL. Default 15000. */
@@ -60,7 +60,7 @@ export type CreateAdminInput = {
60
60
  formsDifficulty?: Difficulty;
61
61
  /** @internal The users comments name, mention and mail, which tests fake; from `auth` otherwise. */
62
62
  commentUsers?: CommentUsers;
63
- /** @internal Verifies a user's token on `/realtime/v1` and gives its claims, which tests fake; those `/auth/v1` signs otherwise. */
63
+ /** @internal Verifies a user's token on `/rest/v1` and `/realtime/v1` and gives its claims, which tests fake; those `/auth/v1` signs otherwise. */
64
64
  verifyToken?: (token: string) => Promise<JWTPayload>;
65
65
  /**
66
66
  * The host's WebSocket upgrade, such as `@hono/node-server`'s, with which an app with keys and live resources serves
@@ -15,6 +15,7 @@ import { apiKeySettings } from "./api-keys/api-key-settings.js";
15
15
  import { apiKeyCaller } from "./api-keys/resolve-caller.js";
16
16
  import { jwtVerifier } from "./auth/jwt.js";
17
17
  import { realtimeApp } from "./realtime/realtime-app.js";
18
+ import { restRoutes } from "./rest/rest-routes.js";
18
19
  import { readAppName } from "./app-name.js";
19
20
  import { goTruePath } from "./gotrue/keys.js";
20
21
  import { authBasePath } from "./better-auth/auth-base-path.js";
@@ -279,16 +280,22 @@ const createAdmin = ({ resources: appResources, views: appViews = [], pages = []
279
280
  report
280
281
  });
281
282
  if (appModels.some((model) => model.live)) app.route(systemPath || "/", guarded((live) => void live.route("/", liveRoutes(feed, (session) => visibleNotices(deps, session))), withAnonymous(authenticate, options.roles)));
282
- if (upgradeWebSocket && (apiKeys.publishable || apiKeys.secret)) {
283
+ if (apiKeys.publishable || apiKeys.secret) {
283
284
  const verify = verifyToken ?? (auth ? jwtVerifier(goTrueTokens({ auth })) : () => Promise.reject(unauthorized("This app signs no user tokens")));
284
- app.route("/realtime/v1", realtimeApp({
285
+ const caller = apiKeyCaller({
286
+ keys: apiKeys,
287
+ verify
288
+ });
289
+ app.route("/rest/v1", restRoutes({
290
+ deps,
291
+ caller,
292
+ report
293
+ }));
294
+ if (upgradeWebSocket) app.route("/realtime/v1", realtimeApp({
285
295
  deps,
286
296
  feed,
287
297
  upgradeWebSocket,
288
- caller: apiKeyCaller({
289
- keys: apiKeys,
290
- verify
291
- }),
298
+ caller,
292
299
  report
293
300
  }));
294
301
  }
@@ -0,0 +1,18 @@
1
+ //#region src/jobs/busy-database.ts
2
+ /** pg-boss's database, counting its statements under way: `idle()` says whether none is, such as a worker's fetch. */
3
+ const busyDatabase = (database) => {
4
+ let running = 0;
5
+ return {
6
+ db: { executeSql: async (text, values) => {
7
+ running++;
8
+ try {
9
+ return await database.executeSql(text, values);
10
+ } finally {
11
+ running--;
12
+ }
13
+ } },
14
+ idle: () => running === 0
15
+ };
16
+ };
17
+ //#endregion
18
+ export { busyDatabase };
@@ -4,6 +4,7 @@ import "./schedule.js";
4
4
  import { runJob, validatePayload } from "./run-job.js";
5
5
  import { platformMailer } from "../mail/platform-mailer.js";
6
6
  import { runInBackground } from "../background-work.js";
7
+ import { busyDatabase } from "./busy-database.js";
7
8
  import { jobsDatabase } from "./database.js";
8
9
  import { queueOptions, queueSettingsDiffer, sendOptions, workOptions } from "./options.js";
9
10
  import { createSchedules } from "./schedules.js";
@@ -11,7 +12,7 @@ import { sql } from "kysely";
11
12
  import { PgBoss } from "pg-boss";
12
13
  //#region src/jobs/runtime.ts
13
14
  const createBoss = (db, work) => new PgBoss({
14
- db: jobsDatabase(db),
15
+ db,
15
16
  schema: jobsSchema,
16
17
  migrate: false,
17
18
  createSchema: false,
@@ -29,7 +30,8 @@ const createBoss = (db, work) => new PgBoss({
29
30
  */
30
31
  const startJobs = async ({ db, jobs, work = true, pollSeconds = 2, shutdownSeconds = 30, onError, now = () => /* @__PURE__ */ new Date(), mailer }) => {
31
32
  const mail = mailer === void 0 ? platformMailer() : mailer || void 0;
32
- const boss = createBoss(db, work);
33
+ const database = busyDatabase(jobsDatabase(db));
34
+ const boss = createBoss(database.db, work);
33
35
  boss.on("error", onError);
34
36
  await boss.start();
35
37
  const schedules = createSchedules(boss, db, now);
@@ -111,9 +113,11 @@ const startJobs = async ({ db, jobs, work = true, pollSeconds = 2, shutdownSecon
111
113
  or (j.state = 'active' and j.started_on + j.expire_seconds * interval '1s' > now()))`.execute(db);
112
114
  return rows[0].count;
113
115
  };
116
+ const settled = () => database.idle() && boss.getWipData().every((worker) => worker.count === 0);
114
117
  const drain = async (seconds) => {
115
118
  if (!work) throw new Error("This process runs no jobs, so it cannot drain them");
116
119
  const deadline = Date.now() + seconds * 1e3;
120
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
117
121
  for (;;) {
118
122
  for (const worker of workers.values()) boss.notifyWorker(worker.id);
119
123
  const remaining = await pending();
@@ -122,7 +126,8 @@ const startJobs = async ({ db, jobs, work = true, pollSeconds = 2, shutdownSecon
122
126
  remaining,
123
127
  nextDue: await nextDue()
124
128
  };
125
- await new Promise((resolve) => setTimeout(resolve, 100));
129
+ await sleep(100);
130
+ while (!settled() && Date.now() < deadline) await sleep(10);
126
131
  }
127
132
  };
128
133
  const wake = (name) => {
@@ -0,0 +1,14 @@
1
+ import { restError } from "./rest-error.js";
2
+ //#region src/rest/content-range.ts
3
+ const contentRange = (offset, page, total) => `${page === 0 ? "*" : `${offset}-${offset + page - 1}`}/${total ?? "*"}`;
4
+ /**
5
+ * The status of a counted read: 206 when the page holds fewer rows than there are, 416 (PGRST103) when it starts past
6
+ * them, else 200; uncounted, always 200.
7
+ */
8
+ const rangeStatus = (offset, page, total) => {
9
+ if (total === void 0) return 200;
10
+ if (offset > total) throw restError(416, "PGRST103", "Requested range not satisfiable", `An offset of ${offset} was requested, but there are only ${total} rows.`).withHeaders({ "content-range": contentRange(offset, 0, total) });
11
+ return page < total ? 206 : 200;
12
+ };
13
+ //#endregion
14
+ export { contentRange, rangeStatus };
@@ -0,0 +1,25 @@
1
+ import { parseError } from "./rest-error.js";
2
+ //#region src/rest/field-path.ts
3
+ /** Parses `column`, `column->key` or `column->a->>b`; `->` may not end it or follow a `->>`. */
4
+ const parseFieldPath = (text) => {
5
+ const parts = text.trim().split(/(->>|->)/);
6
+ const column = parts[0].trim();
7
+ if (!column) throw parseError(`"${text}" names no column`);
8
+ const json = [];
9
+ for (let i = 1; i < parts.length; i += 2) {
10
+ const key = parts[i + 1].trim();
11
+ if (!key) throw parseError(`"${text}" ends in an arrow`);
12
+ json.push({
13
+ arrow: parts[i],
14
+ key: /^-?\d+$/.test(key) ? Number(key) : key
15
+ });
16
+ }
17
+ return {
18
+ column,
19
+ json
20
+ };
21
+ };
22
+ /** The name a JSON path's value goes by in a row: its last key, else the column. */
23
+ const pathName = (path) => String(path.json.at(-1)?.key ?? path.column);
24
+ //#endregion
25
+ export { parseFieldPath, pathName };
@@ -0,0 +1,62 @@
1
+ import { sql } from "kysely";
2
+ //#region src/rest/filter-sql.ts
3
+ /** A field of the row source named `source`: `"todos"."meta"->$1::text`. Every key is a parameter. */
4
+ const fieldSql = (source, path) => path.json.reduce((value, step) => sql`(${value}${sql.raw(step.arrow)}${typeof step.key === "number" ? sql`${step.key}::integer` : sql`${step.key}::text`})`, sql`${sql.id(source, path.column)}`);
5
+ const comparisons = {
6
+ eq: "=",
7
+ neq: "<>",
8
+ gt: ">",
9
+ gte: ">=",
10
+ lt: "<",
11
+ lte: "<=",
12
+ like: "like",
13
+ ilike: "ilike",
14
+ match: "~",
15
+ imatch: "~*",
16
+ isdistinct: "is distinct from",
17
+ cs: "@>",
18
+ cd: "<@",
19
+ ov: "&&",
20
+ sl: "<<",
21
+ sr: ">>",
22
+ nxr: "&<",
23
+ nxl: "&>",
24
+ adj: "-|-"
25
+ };
26
+ const tsQueries = {
27
+ fts: "to_tsquery",
28
+ plfts: "plainto_tsquery",
29
+ phfts: "phraseto_tsquery",
30
+ wfts: "websearch_to_tsquery"
31
+ };
32
+ const isTests = {
33
+ null: "is null",
34
+ not_null: "is not null",
35
+ true: "is true",
36
+ false: "is false",
37
+ unknown: "is unknown"
38
+ };
39
+ /** A Postgres array literal of the values, each quoted, so Postgres reads it as an array of the column's type. */
40
+ const arrayLiteral = (values) => `{${values.map((value) => `"${value.replace(/[\\"]/g, "\\$&")}"`).join(",")}}`;
41
+ const pattern = (operator, value) => operator === "like" || operator === "ilike" ? value.replaceAll("*", "%") : value;
42
+ const conditionSql = (source, condition) => {
43
+ const field = fieldSql(source, condition.field);
44
+ const { operator, value } = condition;
45
+ if (operator === "in") return sql`${field} = any(${arrayLiteral(value)})`;
46
+ if (operator === "is") return sql`${field} ${sql.raw(isTests[value])}`;
47
+ const text = pattern(operator, value);
48
+ const tsQuery = tsQueries[operator];
49
+ if (tsQuery) return condition.language === void 0 ? sql`${field} @@ ${sql.raw(tsQuery)}(${text})` : sql`${field} @@ ${sql.raw(tsQuery)}(${condition.language}, ${text})`;
50
+ const comparison = sql.raw(comparisons[operator]);
51
+ if (condition.quantifier) return sql`${field} ${comparison} ${sql.raw(condition.quantifier)}(${text})`;
52
+ return sql`${field} ${comparison} ${text}`;
53
+ };
54
+ /** A filter as a condition on the rows of `source`; every value is a parameter, every name a quoted identifier. */
55
+ const filterSql = (source, filter) => {
56
+ const condition = filter.kind === "condition" ? conditionSql(source, filter) : filter.args.length === 0 ? sql.raw(filter.kind === "and" ? "true" : "false") : sql`(${sql.join(filter.args.map((arg) => filterSql(source, arg)), sql.raw(` ${filter.kind} `))})`;
57
+ return filter.negated ? sql`not (${condition})` : condition;
58
+ };
59
+ /** ` where ...` for the filters, all of which hold; nothing without any. */
60
+ const whereSql = (source, filters) => filters.length === 0 ? sql`` : sql` where ${sql.join(filters.map((filter) => filterSql(source, filter)), sql` and `)}`;
61
+ //#endregion
62
+ export { arrayLiteral, fieldSql, filterSql, whereSql };
@@ -0,0 +1,92 @@
1
+ import { restError } from "./rest-error.js";
2
+ import { sql } from "kysely";
3
+ //#region src/rest/functions.ts
4
+ /** The functions of the `public` schema called `name`, every overload. */
5
+ const findFunctions = async (db, name) => {
6
+ const { rows } = await sql`
7
+ select p.proname as name, p.proargnames as arg_names, p.proargmodes::text[] as arg_modes, p.pronargdefaults::int as defaults,
8
+ array(select format_type(t.oid, null) from unnest(p.proargtypes::oid[]) with ordinality as t(oid, i) order by t.i) as input_types,
9
+ format_type(p.prorettype, null) as returns, r.typtype as return_kind, p.proretset as set
10
+ from pg_proc p join pg_namespace n on n.oid = p.pronamespace join pg_type r on r.oid = p.prorettype
11
+ where n.nspname = 'public' and p.proname = ${name} and p.prokind = 'f'
12
+ order by p.oid
13
+ `.execute(db);
14
+ return rows.map((row) => {
15
+ const names = (row.arg_names ?? []).filter((_, i) => !row.arg_modes || [
16
+ "i",
17
+ "b",
18
+ "v"
19
+ ].includes(row.arg_modes[i]));
20
+ const returns = row.returns === "void" ? "void" : row.return_kind === "c" || row.returns === "record" ? "row" : "scalar";
21
+ return {
22
+ name: row.name,
23
+ inputs: row.input_types.map((type, i) => ({
24
+ name: names[i] ?? "",
25
+ type
26
+ })),
27
+ defaults: row.defaults,
28
+ returns,
29
+ set: row.set
30
+ };
31
+ });
32
+ };
33
+ const takesBody = (info) => info.inputs.length === 1 && info.inputs[0].name === "" && ["json", "jsonb"].includes(info.inputs[0].type);
34
+ const listed = (names) => [...names].sort().join(", ");
35
+ /**
36
+ * The overload a call with these argument names reaches: one whose inputs include every name and whose inputs without
37
+ * a default are all given; else one that takes the body as its single unnamed json or jsonb argument. None is 404
38
+ * (PGRST202) and more than one 300 (PGRST203), worded as PostgREST words them.
39
+ */
40
+ const chooseFunction = (name, candidates, given) => {
41
+ const byName = candidates.filter((info) => {
42
+ const inputs = info.inputs.map((input) => input.name);
43
+ const required = inputs.slice(0, inputs.length - info.defaults);
44
+ return given.every((arg) => inputs.includes(arg)) && required.every((arg) => given.includes(arg));
45
+ });
46
+ const matches = byName.length > 0 ? byName.map((info) => ({
47
+ info,
48
+ by: "name",
49
+ args: info.inputs.map((input) => input.name).filter((arg) => given.includes(arg))
50
+ })) : candidates.filter(takesBody).map((info) => ({
51
+ info,
52
+ by: "body"
53
+ }));
54
+ if (matches.length === 1) return matches[0];
55
+ const signature = given.length === 0 ? `public.${name} without parameters` : `public.${name}(${listed(given)})`;
56
+ if (matches.length > 1) {
57
+ const overloads = matches.map((match) => `public.${name}(${match.info.inputs.map((input) => `${input.name} => ${input.type}`.trim()).join(", ")})`);
58
+ throw restError(300, "PGRST203", `Could not choose the best candidate function between: ${overloads.join(", ")}`, null, "Try renaming the parameters or the function itself in the database so function overloading can be resolved");
59
+ }
60
+ const searched = given.length === 0 ? `public.${name} without parameters` : `public.${name} with parameters ${listed(given)}`;
61
+ const hint = candidates[0] ? `Perhaps you meant to call the function public.${name}(${candidates[0].inputs.map((input) => input.name).join(", ")})` : null;
62
+ throw restError(404, "PGRST202", `Could not find the function ${signature} in the schema cache`, `Searched for the function ${searched} or with a single unnamed json/jsonb parameter, but no matches were found in the schema cache.`, hint);
63
+ };
64
+ /**
65
+ * The call in FROM, its rows named after the function: arguments read by name from the JSON object `args` as the types
66
+ * the catalog gives them, so Postgres converts them as it reads JSON into a row; or `args` itself as the one argument.
67
+ * Types come from the catalog, never from the request.
68
+ */
69
+ const callSource = (call, args) => {
70
+ const fn = sql.id("public", call.info.name);
71
+ const alias = call.info.name;
72
+ const scalar = call.info.returns !== "row";
73
+ if (call.by === "body") return {
74
+ from: sql`${fn}(cast(${JSON.stringify(args)} as ${sql.raw(call.info.inputs[0].type)})) as ${sql.id(alias)}`,
75
+ alias,
76
+ scalar
77
+ };
78
+ if (call.args.length === 0) return {
79
+ from: sql`${fn}() as ${sql.id(alias)}`,
80
+ alias,
81
+ scalar
82
+ };
83
+ const types = call.info.inputs.filter((input) => call.args.includes(input.name)).map((input) => sql`${sql.id(input.name)} ${sql.raw(input.type)}`);
84
+ const named = sql.join(call.args.map((arg) => sql`${sql.id(arg)} := pgrst_args.${sql.id(arg)}`));
85
+ return {
86
+ from: sql`json_to_record(${JSON.stringify(args)}::json) as pgrst_args(${sql.join(types)}) cross join lateral ${fn}(${named}) as ${sql.id(alias)}`,
87
+ alias,
88
+ scalar
89
+ };
90
+ };
91
+ //#endregion
92
+ export { callSource, chooseFunction, findFunctions };
@@ -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,63 @@
1
+ import { parseError } from "./rest-error.js";
2
+ import { splitList, unquote } from "./split-list.js";
3
+ import { parseFilter } from "./parse-filter.js";
4
+ import { parseOrder } from "./parse-order.js";
5
+ import { parseSelect } from "./parse-select.js";
6
+ //#region src/rest/parse-query.ts
7
+ const reserved = /* @__PURE__ */ new Set([
8
+ "select",
9
+ "order",
10
+ "limit",
11
+ "offset",
12
+ "on_conflict",
13
+ "columns"
14
+ ]);
15
+ const count = (name, text) => {
16
+ if (!/^\d+$/.test(text)) throw parseError(`"failed to parse ${name} (${text})"`, `${name} takes a whole number`);
17
+ return Number(text);
18
+ };
19
+ const names = (text) => splitList(text).map((name) => unquote(name.trim()));
20
+ /** `Range: 2-4` or `Range: 5-`, in items; anything else asks for every row, as PostgREST takes it. */
21
+ const rangeHeader = (header) => {
22
+ const match = header === null ? null : /^\s*(\d+)-(\d*)\s*$/.exec(header);
23
+ if (!match) return { offset: 0 };
24
+ const offset = Number(match[1]);
25
+ return match[2] === "" ? { offset } : {
26
+ offset,
27
+ limit: Math.max(0, Number(match[2]) - offset + 1)
28
+ };
29
+ };
30
+ const intersect = (a, b) => {
31
+ const offset = Math.max(a.offset, b.offset);
32
+ const ends = [a, b].flatMap((range) => range.limit === void 0 ? [] : [range.offset + range.limit]);
33
+ return ends.length === 0 ? { offset } : {
34
+ offset,
35
+ limit: Math.max(0, Math.min(...ends) - offset)
36
+ };
37
+ };
38
+ /**
39
+ * Parses a request's query parameters. The ones in `args` are a function's arguments, not filters. `select`, `order`,
40
+ * `limit`, `offset`, `on_conflict` and `columns` count once, the first time; every other parameter is a filter.
41
+ */
42
+ const parseQuery = (params, rangeText = null, args = /* @__PURE__ */ new Set()) => {
43
+ const first = (name) => params.find(([key]) => key === name)?.[1];
44
+ const limit = first("limit");
45
+ const offset = first("offset");
46
+ const order = first("order");
47
+ const onConflict = first("on_conflict");
48
+ const columns = first("columns");
49
+ const fromParams = {
50
+ offset: offset === void 0 ? 0 : count("offset", offset),
51
+ ...limit !== void 0 && { limit: count("limit", limit) }
52
+ };
53
+ return {
54
+ select: parseSelect(first("select")),
55
+ filters: params.filter(([key]) => !reserved.has(key) && !args.has(key)).map(([key, value]) => parseFilter(key, value)),
56
+ order: order === void 0 ? [] : parseOrder(order),
57
+ range: intersect(fromParams, rangeHeader(rangeText)),
58
+ ...onConflict !== void 0 && { onConflict: names(onConflict) },
59
+ ...columns !== void 0 && { columns: names(columns) }
60
+ };
61
+ };
62
+ //#endregion
63
+ export { parseQuery };
@@ -0,0 +1,24 @@
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 parseItem = (text) => {
7
+ const item = text.trim();
8
+ if (item === "*") return { kind: "all" };
9
+ if (item.includes("(")) throw restError(400, "PGRST100", `"${item}" embeds a resource or calls an aggregate, which /rest/v1 does not support yet`);
10
+ const aliased = /^([^:]+):(?!:)([\s\S]*)$/.exec(item);
11
+ const alias = aliased?.[1]?.trim();
12
+ const [fieldText = "", cast, ...more] = (aliased ? aliased[2] : item).split("::");
13
+ 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`);
14
+ return {
15
+ kind: "field",
16
+ field: parseFieldPath(fieldText),
17
+ ...alias && { alias },
18
+ ...cast !== void 0 && { cast: cast.trim() }
19
+ };
20
+ };
21
+ /** Parses `select`: items separated by commas, `*` for every column. Missing or empty, it is `*`. */
22
+ const parseSelect = (text) => text === void 0 || text.trim() === "" ? [{ kind: "all" }] : splitList(text).map(parseItem);
23
+ //#endregion
24
+ export { parseSelect };
@@ -0,0 +1,39 @@
1
+ import { whereSql } from "./filter-sql.js";
2
+ import { orderSql, rangeSql, selectSql } from "./shape-sql.js";
3
+ import { sql } from "kysely";
4
+ //#region src/rest/read-rows.ts
5
+ /** The most rows a read returns, as Supabase's `db-max-rows` default. */
6
+ const maxRows = 1e3;
7
+ const plannedRows = async (trx, source, query) => {
8
+ const { rows } = await sql`explain (format json) select 1 from ${source.from}${whereSql(source.alias, query.filters)}`.execute(trx);
9
+ return rows[0]["QUERY PLAN"][0].Plan["Plan Rows"];
10
+ };
11
+ /**
12
+ * Reads the rows of `source` the query asks for: filtered, shaped by `select`, sorted, in its range and never more than
13
+ * `maxRows`, as one JSON array built by Postgres, so values reach the client exactly as Postgres writes them. `count`
14
+ * counts the matching rows: `exact` by counting, `planned` by the planner's estimate, `estimated` by counting up to
15
+ * `maxRows` and estimating beyond.
16
+ */
17
+ const readRows = async (trx, source, query, count) => {
18
+ const where = whereSql(source.alias, query.filters);
19
+ const range = {
20
+ offset: query.range.offset,
21
+ limit: Math.min(query.range.limit ?? 1e3, maxRows)
22
+ };
23
+ const columns = source.scalar ? sql`${sql.id(source.alias)} as pgrst_scalar` : selectSql(source.alias, query.select);
24
+ const rows = sql`select ${columns} from ${source.from}${where}${orderSql(source.alias, query.order)}${rangeSql(range)}`;
25
+ const counted = count === "exact" || count === "estimated" ? sql`(select count(*) from ${source.from}${where})` : sql`null::bigint`;
26
+ const aggregate = source.scalar ? sql`json_agg(pgrst_rows.pgrst_scalar)` : sql`json_agg(pgrst_rows)`;
27
+ const { rows: [result] } = await sql`
28
+ select coalesce(${aggregate}, '[]')::text as body, count(*) as page, ${counted} as total from (${rows}) as pgrst_rows
29
+ `.execute(trx);
30
+ const exact = result.total === null ? void 0 : Number(result.total);
31
+ const total = count === "planned" || count === "estimated" && exact > 1e3 ? await plannedRows(trx, source, query) : exact;
32
+ return {
33
+ body: result.body,
34
+ page: Number(result.page),
35
+ ...total !== void 0 && { total }
36
+ };
37
+ };
38
+ //#endregion
39
+ export { maxRows, readRows };