@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
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/check-views.js
CHANGED
|
@@ -6,6 +6,7 @@ import { chartErrors } from "./view-chart.js";
|
|
|
6
6
|
import { mapDisplayErrors, mapErrors } from "./view-map.js";
|
|
7
7
|
import { relatedColumnErrors } from "./view-related-column.js";
|
|
8
8
|
import { editableErrors } from "./view-editable.js";
|
|
9
|
+
import { actionWhenErrors, actionWhenFields } from "./view-action-when.js";
|
|
9
10
|
import { conditionProblems } from "@protobase/layout";
|
|
10
11
|
import { actionInputErrors, addressErrors, addressPlaceholders, boardErrors, chartFields, datesErrors, displayFields, fieldRefsIn, hrefField, hrefPlaceholders, layoutItems, linkActionError, promptError, promptFields, updateSetErrors, viewOfList } from "@protobase/schema/internal";
|
|
11
12
|
import { isRecordCondition } from "@protobase/schema";
|
|
@@ -33,7 +34,7 @@ const sensitiveErrors = (view, model) => {
|
|
|
33
34
|
["a map", layoutItems(view.layout).flatMap((item) => item.kind === "map" ? [item.latitude, item.longitude] : [])],
|
|
34
35
|
["a gallery", layoutItems(view.layout).flatMap((item) => item.kind === "gallery" && item.image !== void 0 ? [item.image] : [])],
|
|
35
36
|
["the recent records", view.nav?.recent ? [view.nav.recent.status] : []],
|
|
36
|
-
["an action", view.actions.flatMap((action) => actionFields(action.run))]
|
|
37
|
+
["an action", view.actions.flatMap((action) => [...actionFields(action.run), ...actionWhenFields(action.when)])]
|
|
37
38
|
].flatMap(([place, names]) => [...new Set(names.filter(sensitive))].map((name) => `sensitive field "${name}" cannot be in ${place}`));
|
|
38
39
|
};
|
|
39
40
|
const valueLabelErrors = (view, model) => Object.entries(view.fields).flatMap(([name, hints]) => {
|
|
@@ -147,7 +148,7 @@ const listOfErrors = (model, name, models) => {
|
|
|
147
148
|
* has a section, related section or sidebar whose `when` condition names what the app does not have, a related section or related list column, a cards display's or gallery's photos or a cards display's favorite button, a `listOf`, a "Save search" button or a
|
|
148
149
|
* `searchesOf` that do not fit the resources, cards
|
|
149
150
|
* displays of one list that name different photos, a chart per value that cannot group or add up its fields, a map that cannot show the
|
|
150
|
-
* record's position or a map display that cannot find the records in its view, a board whose columns are no filterable enum, a calendar or timeline whose dates are no filterable date or timestamp fields, a link to an address the app does not have, an AI action that cannot write its field, action inputs that do not fit, or a create page that is not one of `pages`.
|
|
151
|
+
* record's position or a map display that cannot find the records in its view, a board whose columns are no filterable enum, a calendar or timeline whose dates are no filterable date or timestamp fields, a link to an address the app does not have, an action whose `when` reads what the record does not have, an AI action that cannot write its field, action inputs that do not fit, or a create page that is not one of `pages`.
|
|
151
152
|
*/
|
|
152
153
|
const checkViews = (registry, views, pages = [], fieldTypes = {}) => {
|
|
153
154
|
const models = Object.fromEntries(registry.entries.map((entry) => [entry.name, entry.model]));
|
|
@@ -191,6 +192,7 @@ const checkViews = (registry, views, pages = [], fieldTypes = {}) => {
|
|
|
191
192
|
...chart,
|
|
192
193
|
...linkErrors(view, model, models, pageNames, createPages),
|
|
193
194
|
...aiActionErrors(view, model),
|
|
195
|
+
...actionWhenErrors(view, model),
|
|
194
196
|
...inputErrors(view, models),
|
|
195
197
|
...createErrors(view, pageNames)
|
|
196
198
|
];
|
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";
|
|
@@ -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 (
|
|
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
|
-
|
|
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
|
|
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 };
|
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,64 @@
|
|
|
1
|
+
import { restError } from "./rest-error.js";
|
|
2
|
+
import { parseFilter } from "./parse-filter.js";
|
|
3
|
+
import { parseOrder } from "./parse-order.js";
|
|
4
|
+
import { embedKey } from "./parse-select.js";
|
|
5
|
+
import { wholeNumber } from "./whole-number.js";
|
|
6
|
+
//#region src/rest/embed-params.ts
|
|
7
|
+
/**
|
|
8
|
+
* The embed path a parameter's key is under, and its own name: `comments.author.username` is `username` under
|
|
9
|
+
* `comments.author`, `comments.not.or` is `not.or` under `comments`. A key outside any embed has no path. A JSON path's
|
|
10
|
+
* keys may hold dots, so only the part before its first arrow is split.
|
|
11
|
+
*/
|
|
12
|
+
const splitEmbedKey = (key) => {
|
|
13
|
+
const arrow = key.indexOf("->");
|
|
14
|
+
const head = arrow < 0 ? key : key.slice(0, arrow);
|
|
15
|
+
const tail = arrow < 0 ? "" : key.slice(arrow);
|
|
16
|
+
const segments = head.split(".");
|
|
17
|
+
const own = segments.length >= 2 && segments.at(-2) === "not" && /^(and|or)$/.test(segments.at(-1)) ? 2 : 1;
|
|
18
|
+
if (segments.length <= own) return { name: key };
|
|
19
|
+
return {
|
|
20
|
+
path: segments.slice(0, -own).join("."),
|
|
21
|
+
name: segments.slice(-own).join(".") + tail
|
|
22
|
+
};
|
|
23
|
+
};
|
|
24
|
+
/** The path of every embed of `select`, nested ones as `comments.author`, by the names they go by. */
|
|
25
|
+
const embedPaths = (select, prefix = "") => select.flatMap((item) => {
|
|
26
|
+
if (item.kind !== "embed") return [];
|
|
27
|
+
const path = prefix + embedKey(item);
|
|
28
|
+
return [path, ...embedPaths(item.select, `${path}.`)];
|
|
29
|
+
});
|
|
30
|
+
/**
|
|
31
|
+
* The parameters under an embed's path, by path: `comments.likes=gte.2` filters the comments, `comments.order`,
|
|
32
|
+
* `comments.limit` and `comments.offset` sort and page them. A path no embed of `select` has is PGRST108.
|
|
33
|
+
*/
|
|
34
|
+
const parseEmbedParams = (params, select) => {
|
|
35
|
+
const known = new Set(embedPaths(select));
|
|
36
|
+
const byPath = /* @__PURE__ */ new Map();
|
|
37
|
+
for (const [key, value] of params) {
|
|
38
|
+
const { path, name } = splitEmbedKey(key);
|
|
39
|
+
if (path === void 0) continue;
|
|
40
|
+
if (!known.has(path)) {
|
|
41
|
+
const resource = path.split(".").at(-1);
|
|
42
|
+
throw restError(400, "PGRST108", `'${resource}' is not an embedded resource in this request`, null, `Verify that '${resource}' is included in the 'select' query parameter.`);
|
|
43
|
+
}
|
|
44
|
+
const embed = byPath.get(path) ?? {
|
|
45
|
+
filters: [],
|
|
46
|
+
order: [],
|
|
47
|
+
range: { offset: 0 }
|
|
48
|
+
};
|
|
49
|
+
byPath.set(path, embed);
|
|
50
|
+
if (name === "order") embed.order = parseOrder(value);
|
|
51
|
+
else if (name === "limit") embed.range = {
|
|
52
|
+
...embed.range,
|
|
53
|
+
limit: wholeNumber(key, value)
|
|
54
|
+
};
|
|
55
|
+
else if (name === "offset") embed.range = {
|
|
56
|
+
...embed.range,
|
|
57
|
+
offset: wholeNumber(key, value)
|
|
58
|
+
};
|
|
59
|
+
else embed.filters.push(parseFilter(name, value));
|
|
60
|
+
}
|
|
61
|
+
return byPath;
|
|
62
|
+
};
|
|
63
|
+
//#endregion
|
|
64
|
+
export { parseEmbedParams, splitEmbedKey };
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { embedKey } from "./parse-select.js";
|
|
2
|
+
import { findRelationship } from "./find-relationship.js";
|
|
3
|
+
//#region src/rest/embed-shape.ts
|
|
4
|
+
const noParams = {
|
|
5
|
+
filters: [],
|
|
6
|
+
order: [],
|
|
7
|
+
range: { offset: 0 }
|
|
8
|
+
};
|
|
9
|
+
/**
|
|
10
|
+
* Resolves the embeds of `select`, read from rows of `source`, to the relationships they go through, depth first:
|
|
11
|
+
* PGRST200 or PGRST201 for an embed without one or with several.
|
|
12
|
+
*/
|
|
13
|
+
const resolveShape = (context, source, select) => {
|
|
14
|
+
let count = 0;
|
|
15
|
+
const resolve = (table, items, prefix) => items.map((item) => {
|
|
16
|
+
if (item.kind !== "embed") return item;
|
|
17
|
+
const from = context.served(item.name);
|
|
18
|
+
const relationship = findRelationship(from ? context.foreignKeys : [], table, item.name, item.hint);
|
|
19
|
+
const path = prefix + embedKey(item);
|
|
20
|
+
const alias = `${item.name}_${++count}`;
|
|
21
|
+
return {
|
|
22
|
+
kind: "embed",
|
|
23
|
+
key: embedKey(item),
|
|
24
|
+
table: item.name,
|
|
25
|
+
from,
|
|
26
|
+
alias,
|
|
27
|
+
relationship,
|
|
28
|
+
toOne: relationship.cardinality !== "one-to-many",
|
|
29
|
+
inner: item.inner,
|
|
30
|
+
shown: item.select.length > 0,
|
|
31
|
+
shape: resolve(item.name, item.select, `${path}.`),
|
|
32
|
+
params: context.embedded.get(path) ?? noParams
|
|
33
|
+
};
|
|
34
|
+
});
|
|
35
|
+
return resolve(source, select, "");
|
|
36
|
+
};
|
|
37
|
+
/** Every embed of the shape, nested ones included. */
|
|
38
|
+
const embedsOf = (shape) => shape.flatMap((item) => item.kind === "embed" ? [item, ...embedsOf(item.shape)] : []);
|
|
39
|
+
/** The name of the lateral subquery an embed's JSON comes from, as `body`. */
|
|
40
|
+
const lateralName = (embed) => `pgrst_${embed.alias}`;
|
|
41
|
+
//#endregion
|
|
42
|
+
export { embedsOf, lateralName, resolveShape };
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { lateralName } from "./embed-shape.js";
|
|
2
|
+
import { filterSql } from "./filter-sql.js";
|
|
3
|
+
import { orderSql, rangeSql, selectSql } from "./shape-sql.js";
|
|
4
|
+
import { sql } from "kysely";
|
|
5
|
+
//#region src/rest/embed-sql.ts
|
|
6
|
+
const relatedRowsSql = (source, embed) => {
|
|
7
|
+
const related = embed.relationship.join.map((pair) => sql`${sql.id(embed.alias, pair.target)} = ${sql.id(source, pair.source)}`);
|
|
8
|
+
const where = sql.join([...related, ...embed.params.filters.map((filter) => filterSql(embed.alias, filter))], sql` and `);
|
|
9
|
+
const columns = selectSql(embed.alias, embed.shape);
|
|
10
|
+
return sql`select ${columns} from ${embed.from} as ${sql.id(embed.alias)}${joinsSql(embed.alias, embed.shape)} where ${where}${orderSql(embed.alias, embed.params.order)}${rangeSql(embed.params.range)}`;
|
|
11
|
+
};
|
|
12
|
+
const lateralSql = (source, embed) => {
|
|
13
|
+
const rows = relatedRowsSql(source, embed);
|
|
14
|
+
const body = embed.toOne ? sql`select row_to_json(pgrst_rows) as body from (${rows}) as pgrst_rows` : sql`select coalesce(json_agg(pgrst_rows), '[]') as body from (${rows}) as pgrst_rows${embed.inner ? sql` having count(*) > 0` : sql``}`;
|
|
15
|
+
return sql` ${sql.raw(embed.inner ? "inner" : "left")} join lateral (${body}) as ${sql.id(lateralName(embed))} on true`;
|
|
16
|
+
};
|
|
17
|
+
/**
|
|
18
|
+
* The lateral joins that read the embeds of the shape for each row of `source`, which `selectSql` picks their JSON
|
|
19
|
+
* from. They run in the same query as the caller, so the RLS policies of every embedded table apply. An inner embed's
|
|
20
|
+
* join drops the rows without a related row.
|
|
21
|
+
*/
|
|
22
|
+
const joinsSql = (source, shape) => sql.join(shape.flatMap((item) => item.kind === "embed" ? [lateralSql(source, item)] : []), sql``);
|
|
23
|
+
//#endregion
|
|
24
|
+
export { joinsSql };
|
|
@@ -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,39 @@
|
|
|
1
|
+
import { restError } from "./rest-error.js";
|
|
2
|
+
//#region src/rest/find-relationship.ts
|
|
3
|
+
const pairs = (sources, targets) => sources.map((source, i) => ({
|
|
4
|
+
source,
|
|
5
|
+
target: targets[i]
|
|
6
|
+
}));
|
|
7
|
+
/** The relationships from `source` to `target`: its foreign keys to `target`, and `target`'s foreign keys to it. */
|
|
8
|
+
const relationshipsBetween = (keys, source, target) => [...keys.filter((key) => key.table === source && key.foreignTable === target).map((key) => ({
|
|
9
|
+
foreignKey: key,
|
|
10
|
+
cardinality: key.unique ? "one-to-one" : "many-to-one",
|
|
11
|
+
join: pairs(key.columns, key.foreignColumns)
|
|
12
|
+
})), ...keys.filter((key) => key.foreignTable === source && key.table === target).map((key) => ({
|
|
13
|
+
foreignKey: key,
|
|
14
|
+
cardinality: key.unique ? "one-to-one" : "one-to-many",
|
|
15
|
+
join: pairs(key.foreignColumns, key.columns)
|
|
16
|
+
}))];
|
|
17
|
+
const matchesHint = (relationship, hint) => {
|
|
18
|
+
const { name, columns, foreignColumns } = relationship.foreignKey;
|
|
19
|
+
return name === hint || columns.length === 1 && (columns[0] === hint || foreignColumns[0] === hint);
|
|
20
|
+
};
|
|
21
|
+
const columnList = (table, columns) => `${table}(${columns.join(", ")})`;
|
|
22
|
+
const ambiguity = (source, target, found) => restError(300, "PGRST201", `Could not embed because more than one relationship was found for '${source}' and '${target}'`, found.map((one) => ({
|
|
23
|
+
cardinality: one.cardinality,
|
|
24
|
+
embedding: `${source} with ${target}`,
|
|
25
|
+
relationship: `${one.foreignKey.name} using ${columnList(source, one.join.map((pair) => pair.source))} and ${columnList(target, one.join.map((pair) => pair.target))}`
|
|
26
|
+
})), `Try changing '${target}' to one of the following: ${found.map((one) => `'${target}!${one.foreignKey.name}'`).join(", ")}. Find the desired relationship in the 'details' key.`);
|
|
27
|
+
/**
|
|
28
|
+
* The one relationship an embed of `target` in rows of `source` goes through, narrowed by its hint: PGRST200 when
|
|
29
|
+
* there is none, PGRST201 when several remain.
|
|
30
|
+
*/
|
|
31
|
+
const findRelationship = (keys, source, target, hint) => {
|
|
32
|
+
const found = relationshipsBetween(keys, source, target).filter((one) => hint === void 0 || matchesHint(one, hint));
|
|
33
|
+
if (found.length === 1) return found[0];
|
|
34
|
+
if (found.length > 1) throw ambiguity(source, target, found);
|
|
35
|
+
const using = hint === void 0 ? "" : ` using the hint '${hint}'`;
|
|
36
|
+
throw restError(400, "PGRST200", `Could not find a relationship between '${source}' and '${target}' in the schema cache`, `Searched for a foreign key relationship between '${source}' and '${target}'${using} in the schema 'public', but no matches were found.`);
|
|
37
|
+
};
|
|
38
|
+
//#endregion
|
|
39
|
+
export { findRelationship };
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { sql } from "kysely";
|
|
2
|
+
//#region src/rest/foreign-keys.ts
|
|
3
|
+
const readForeignKeys = async (db) => {
|
|
4
|
+
const { rows } = await sql`
|
|
5
|
+
select c.conname::text as name, s.relname::text as table, t.relname::text as "foreignTable",
|
|
6
|
+
array(select a.attname::text from unnest(c.conkey) with ordinality k(attnum, ord) join pg_attribute a on a.attrelid = c.conrelid and a.attnum = k.attnum order by k.ord) as columns,
|
|
7
|
+
array(select a.attname::text from unnest(c.confkey) with ordinality k(attnum, ord) join pg_attribute a on a.attrelid = c.confrelid and a.attnum = k.attnum order by k.ord) as "foreignColumns",
|
|
8
|
+
exists (select 1 from pg_constraint u where u.conrelid = c.conrelid and u.contype in ('p', 'u') and u.conkey @> c.conkey and u.conkey <@ c.conkey) as unique
|
|
9
|
+
from pg_constraint c
|
|
10
|
+
join pg_class s on s.oid = c.conrelid
|
|
11
|
+
join pg_class t on t.oid = c.confrelid
|
|
12
|
+
where c.contype = 'f' and s.relnamespace = 'public'::regnamespace and t.relnamespace = 'public'::regnamespace
|
|
13
|
+
order by c.conname
|
|
14
|
+
`.execute(db);
|
|
15
|
+
return rows;
|
|
16
|
+
};
|
|
17
|
+
/**
|
|
18
|
+
* The foreign keys of the `public` schema, which `/rest/v1` embeds through, read from the catalog once and kept for the
|
|
19
|
+
* process lifetime, as the scan guard keeps indexes: migrations run before a new version serves.
|
|
20
|
+
*/
|
|
21
|
+
const foreignKeyCache = (db) => {
|
|
22
|
+
let known;
|
|
23
|
+
return () => {
|
|
24
|
+
known ??= readForeignKeys(db).catch((error) => {
|
|
25
|
+
known = void 0;
|
|
26
|
+
throw error;
|
|
27
|
+
});
|
|
28
|
+
return known;
|
|
29
|
+
};
|
|
30
|
+
};
|
|
31
|
+
//#endregion
|
|
32
|
+
export { foreignKeyCache };
|
|
@@ -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 };
|