@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
package/dist/admin-options.d.ts
CHANGED
|
@@ -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
|
|
18
|
-
* `ApiKeysOptions`); default `PROTOBASE_PUBLISHABLE_KEY` and
|
|
19
|
-
* `false`, the app serves
|
|
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. */
|
package/dist/api-cors.js
CHANGED
package/dist/create-admin.d.ts
CHANGED
|
@@ -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
|
package/dist/create-admin.js
CHANGED
|
@@ -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";
|
|
@@ -223,11 +224,13 @@ const createAdmin = ({ resources: appResources, views: appViews = [], pages = []
|
|
|
223
224
|
if (corsOrigins !== void 0 || ownOrigins.length > 1) {
|
|
224
225
|
const answering = [authBasePath, ...Object.entries(apiFunctions).filter(([, fn]) => fn.options.cors).map(([name]) => `${systemPath}/functions/${name}`)];
|
|
225
226
|
const skip = (path) => answering.some((prefix) => path === prefix || path.startsWith(`${prefix}/`));
|
|
226
|
-
|
|
227
|
+
const cors = apiCors({
|
|
227
228
|
own: ownOrigins,
|
|
228
229
|
allowed: corsOrigins ?? [],
|
|
229
230
|
skip
|
|
230
|
-
})
|
|
231
|
+
});
|
|
232
|
+
app.use(`${systemPath}/*`, cors);
|
|
233
|
+
if (auth && !"/auth/v1".startsWith(`${systemPath}/`)) app.use(`${goTruePath}/*`, cors);
|
|
231
234
|
}
|
|
232
235
|
if (auth) {
|
|
233
236
|
app.route(authBasePath, statusRoute(auth, {
|
|
@@ -277,16 +280,22 @@ const createAdmin = ({ resources: appResources, views: appViews = [], pages = []
|
|
|
277
280
|
report
|
|
278
281
|
});
|
|
279
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)));
|
|
280
|
-
if (
|
|
283
|
+
if (apiKeys.publishable || apiKeys.secret) {
|
|
281
284
|
const verify = verifyToken ?? (auth ? jwtVerifier(goTrueTokens({ auth })) : () => Promise.reject(unauthorized("This app signs no user tokens")));
|
|
282
|
-
|
|
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({
|
|
283
295
|
deps,
|
|
284
296
|
feed,
|
|
285
297
|
upgradeWebSocket,
|
|
286
|
-
caller
|
|
287
|
-
keys: apiKeys,
|
|
288
|
-
verify
|
|
289
|
-
}),
|
|
298
|
+
caller,
|
|
290
299
|
report
|
|
291
300
|
}));
|
|
292
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 };
|
package/dist/jobs/runtime.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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 };
|