@protobase/server 0.26.6 → 0.26.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/admin-options.d.ts +3 -3
- package/dist/check-views.js +4 -2
- package/dist/create-admin.d.ts +1 -1
- package/dist/create-admin.js +13 -6
- package/dist/jobs/busy-database.js +18 -0
- package/dist/jobs/runtime.js +8 -3
- package/dist/rest/content-range.js +14 -0
- package/dist/rest/embed-params.js +64 -0
- package/dist/rest/embed-shape.js +42 -0
- package/dist/rest/embed-sql.js +24 -0
- package/dist/rest/field-path.js +25 -0
- package/dist/rest/filter-sql.js +62 -0
- package/dist/rest/find-relationship.js +39 -0
- package/dist/rest/foreign-keys.js +32 -0
- package/dist/rest/functions.js +92 -0
- package/dist/rest/parse-filter.js +131 -0
- package/dist/rest/parse-order.js +18 -0
- package/dist/rest/parse-prefer.js +26 -0
- package/dist/rest/parse-query.js +64 -0
- package/dist/rest/parse-select.js +42 -0
- package/dist/rest/read-rows.js +41 -0
- package/dist/rest/respond.js +35 -0
- package/dist/rest/rest-context.js +31 -0
- package/dist/rest/rest-error.js +80 -0
- package/dist/rest/rest-routes.js +34 -0
- package/dist/rest/rest-scan.js +106 -0
- package/dist/rest/rpc-route.js +70 -0
- package/dist/rest/shape-sql.js +23 -0
- package/dist/rest/split-list.js +38 -0
- package/dist/rest/table-routes.js +196 -0
- package/dist/rest/whole-number.js +9 -0
- package/dist/rest/write-rows.js +50 -0
- package/dist/scan-guard.js +5 -1
- package/dist/transactions.js +2 -2
- package/dist/view-access.js +11 -1
- package/dist/view-action-when.js +23 -0
- package/package.json +4 -4
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { restError } from "./rest-error.js";
|
|
2
|
+
import { jsonType, onlyElement, pageResponse, wantsObject } from "./respond.js";
|
|
3
|
+
import { callSource, chooseFunction, findFunctions } from "./functions.js";
|
|
4
|
+
import { parsePrefer } from "./parse-prefer.js";
|
|
5
|
+
import { parseQuery } from "./parse-query.js";
|
|
6
|
+
import { readRows } from "./read-rows.js";
|
|
7
|
+
import { asCaller, queryParams, readBody } from "./rest-context.js";
|
|
8
|
+
import { Hono } from "hono";
|
|
9
|
+
import { sql } from "kysely";
|
|
10
|
+
//#region src/rest/rpc-route.ts
|
|
11
|
+
const reserved = /* @__PURE__ */ new Set([
|
|
12
|
+
"select",
|
|
13
|
+
"order",
|
|
14
|
+
"limit",
|
|
15
|
+
"offset",
|
|
16
|
+
"on_conflict",
|
|
17
|
+
"columns"
|
|
18
|
+
]);
|
|
19
|
+
/**
|
|
20
|
+
* Calls a function of the `public` schema: by POST with its arguments in a JSON object, or the whole body as its one
|
|
21
|
+
* unnamed json argument; by GET with them in the query string, in a read-only transaction. A function returning rows
|
|
22
|
+
* is read like a table, with `select`, filters, `order`, a range and a count; one returning one row gives an object,
|
|
23
|
+
* a scalar its JSON value, `void` 204.
|
|
24
|
+
*/
|
|
25
|
+
const call = async (deps, c) => {
|
|
26
|
+
const name = c.req.param("fn");
|
|
27
|
+
const get = c.req.method === "GET";
|
|
28
|
+
const params = queryParams(c);
|
|
29
|
+
const body = get ? void 0 : await readBody(c, {});
|
|
30
|
+
const prefer = parsePrefer(c.req.header("prefer") ?? null);
|
|
31
|
+
const object = wantsObject(c.req.raw);
|
|
32
|
+
return asCaller(deps, c, get, async (trx) => {
|
|
33
|
+
const candidates = await findFunctions(trx, name);
|
|
34
|
+
const inputs = new Set(candidates.flatMap((info) => info.inputs.map((input) => input.name)));
|
|
35
|
+
const given = get ? params.map(([key]) => key).filter((key) => !reserved.has(key) && inputs.has(key)) : typeof body === "object" && body !== null && !Array.isArray(body) ? Object.keys(body) : [];
|
|
36
|
+
const chosen = chooseFunction(name, candidates, given);
|
|
37
|
+
const args = get ? Object.fromEntries(params.filter(([key]) => given.includes(key))) : body;
|
|
38
|
+
const source = callSource(chosen, args);
|
|
39
|
+
const query = parseQuery(params, c.req.header("range") ?? null, new Set(given));
|
|
40
|
+
const shape = query.select.map((item) => {
|
|
41
|
+
if (item.kind === "embed") throw restError(400, "PGRST100", `"${item.name}" embeds a resource in a function's rows, which /rest/v1 does not support yet`);
|
|
42
|
+
return item;
|
|
43
|
+
});
|
|
44
|
+
if (chosen.info.returns === "void") {
|
|
45
|
+
await sql`select 1 from ${source.from}`.execute(trx);
|
|
46
|
+
return new Response(null, {
|
|
47
|
+
status: 204,
|
|
48
|
+
headers: { "content-range": "0-0/*" }
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
if (!chosen.info.set) {
|
|
52
|
+
const page = await readRows(trx, source, shape, query);
|
|
53
|
+
return new Response(onlyElement(page.body), {
|
|
54
|
+
status: 200,
|
|
55
|
+
headers: {
|
|
56
|
+
"content-range": "0-0/*",
|
|
57
|
+
"content-type": jsonType
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
return pageResponse(await readRows(trx, source, shape, query, prefer.count), query.range.offset, object);
|
|
62
|
+
});
|
|
63
|
+
};
|
|
64
|
+
const rpcRoute = (deps) => {
|
|
65
|
+
const app = new Hono();
|
|
66
|
+
app.on(["GET", "POST"], "/rpc/:fn", (c) => call(deps, c));
|
|
67
|
+
return app;
|
|
68
|
+
};
|
|
69
|
+
//#endregion
|
|
70
|
+
export { rpcRoute };
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { pathName } from "./field-path.js";
|
|
2
|
+
import { lateralName } from "./embed-shape.js";
|
|
3
|
+
import { fieldSql } from "./filter-sql.js";
|
|
4
|
+
import { sql } from "kysely";
|
|
5
|
+
//#region src/rest/shape-sql.ts
|
|
6
|
+
/**
|
|
7
|
+
* The select list of a shape on the rows of `source`: `*` its columns, a field under its alias, its JSON path's last
|
|
8
|
+
* key or its column, cast when asked, and an embed's JSON from its lateral subquery (see `joinsSql`) under its key; an
|
|
9
|
+
* embed of nothing shows nothing. Cast types are checked to be bare type names when parsed.
|
|
10
|
+
*/
|
|
11
|
+
const selectSql = (source, items) => sql.join(items.flatMap((item) => {
|
|
12
|
+
if (item.kind === "all") return [sql`${sql.id(source)}.*`];
|
|
13
|
+
if (item.kind === "embed") return item.shown ? [sql`${sql.id(lateralName(item))}.body as ${sql.id(item.key)}`] : [];
|
|
14
|
+
const value = fieldSql(source, item.field);
|
|
15
|
+
const cast = item.cast === void 0 ? value : sql`cast(${value} as ${sql.raw(item.cast)})`;
|
|
16
|
+
return [sql`${cast} as ${sql.id(item.alias ?? pathName(item.field))}`];
|
|
17
|
+
}));
|
|
18
|
+
/** ` order by ...` for the terms; nothing without any. */
|
|
19
|
+
const orderSql = (source, terms) => terms.length === 0 ? sql`` : sql` order by ${sql.join(terms.map((term) => sql`${fieldSql(source, term.field)}${sql.raw(term.direction ? ` ${term.direction}` : "")}${sql.raw(term.nulls ? ` nulls ${term.nulls}` : "")}`))}`;
|
|
20
|
+
/** ` limit ... offset ...` for the range. */
|
|
21
|
+
const rangeSql = (range) => sql`${range.limit === void 0 ? sql`` : sql` limit ${range.limit}`}${range.offset === 0 ? sql`` : sql` offset ${range.offset}`}`;
|
|
22
|
+
//#endregion
|
|
23
|
+
export { orderSql, rangeSql, selectSql };
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { parseError } from "./rest-error.js";
|
|
2
|
+
//#region src/rest/split-list.ts
|
|
3
|
+
const closers = {
|
|
4
|
+
"(": ")",
|
|
5
|
+
"{": "}"
|
|
6
|
+
};
|
|
7
|
+
/**
|
|
8
|
+
* Splits `text` at the commas outside double quotes, parentheses and braces: `a,in.(1,2),b.cs.{x,y}` gives three parts. A
|
|
9
|
+
* backslash in quotes escapes the next character. An unclosed quote, parenthesis or brace does not parse.
|
|
10
|
+
*/
|
|
11
|
+
const splitList = (text) => {
|
|
12
|
+
const parts = [];
|
|
13
|
+
const open = [];
|
|
14
|
+
let quoted = false;
|
|
15
|
+
let start = 0;
|
|
16
|
+
for (let i = 0; i < text.length; i++) {
|
|
17
|
+
const char = text[i];
|
|
18
|
+
if (quoted) {
|
|
19
|
+
if (char === "\\") i++;
|
|
20
|
+
else if (char === "\"") quoted = false;
|
|
21
|
+
continue;
|
|
22
|
+
}
|
|
23
|
+
if (char === "\"") quoted = true;
|
|
24
|
+
else if (char in closers) open.push(closers[char]);
|
|
25
|
+
else if (char === open.at(-1)) open.pop();
|
|
26
|
+
else if (char === "," && open.length === 0) {
|
|
27
|
+
parts.push(text.slice(start, i));
|
|
28
|
+
start = i + 1;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
if (quoted || open.length > 0) throw parseError(`"${text}" has an unclosed quote, parenthesis or brace`);
|
|
32
|
+
parts.push(text.slice(start));
|
|
33
|
+
return parts;
|
|
34
|
+
};
|
|
35
|
+
/** A value in double quotes without them and with its escapes undone; any other value as it is. */
|
|
36
|
+
const unquote = (text) => text.length >= 2 && text.startsWith("\"") && text.endsWith("\"") ? text.slice(1, -1).replace(/\\(.)/g, "$1") : text;
|
|
37
|
+
//#endregion
|
|
38
|
+
export { splitList, unquote };
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { tableId } from "../sql-parts.js";
|
|
2
|
+
import { restError } from "./rest-error.js";
|
|
3
|
+
import { contentRange } from "./content-range.js";
|
|
4
|
+
import { checkSingle, jsonType, objectType, onlyElement, pageResponse, wantsObject } from "./respond.js";
|
|
5
|
+
import { parsePrefer } from "./parse-prefer.js";
|
|
6
|
+
import { parseQuery } from "./parse-query.js";
|
|
7
|
+
import { embedsOf, resolveShape } from "./embed-shape.js";
|
|
8
|
+
import { readRows } from "./read-rows.js";
|
|
9
|
+
import { asCaller, queryParams, readBody } from "./rest-context.js";
|
|
10
|
+
import { scanRefusal, uncoveredReads } from "./rest-scan.js";
|
|
11
|
+
import { deleteRows, insertRows, updateRows } from "./write-rows.js";
|
|
12
|
+
import { Hono } from "hono";
|
|
13
|
+
import { sql } from "kysely";
|
|
14
|
+
//#region src/rest/table-routes.ts
|
|
15
|
+
/**
|
|
16
|
+
* The resource whose table `name` is, in the `public` schema or the search path's: `/rest/v1` serves the tables the app
|
|
17
|
+
* declares as resources, as PostgREST serves those of its exposed schema. Any other is 404 (PGRST205).
|
|
18
|
+
*/
|
|
19
|
+
const servedEntry = (deps, name) => deps.registry.entries.find((one) => one.model.table.name === name && (one.model.table.schema ?? "public") === "public");
|
|
20
|
+
const tableEntry = (deps, name) => {
|
|
21
|
+
const entry = servedEntry(deps, name);
|
|
22
|
+
if (!entry) throw restError(404, "PGRST205", `Could not find the table 'public.${name}' in the schema cache`);
|
|
23
|
+
return entry;
|
|
24
|
+
};
|
|
25
|
+
/** The select list of the query on rows of `entry`'s table, its embeds resolved through the foreign keys of served tables. */
|
|
26
|
+
const shapeOf = async (deps, foreignKeys, entry, query) => {
|
|
27
|
+
if (!query.select.some((item) => item.kind === "embed")) return query.select;
|
|
28
|
+
const served = (name) => {
|
|
29
|
+
const target = servedEntry(deps, name);
|
|
30
|
+
return target && tableId(target.model);
|
|
31
|
+
};
|
|
32
|
+
return resolveShape({
|
|
33
|
+
foreignKeys: await foreignKeys(),
|
|
34
|
+
served,
|
|
35
|
+
embedded: query.embedded
|
|
36
|
+
}, entry.model.table.name, query.select);
|
|
37
|
+
};
|
|
38
|
+
const targetOf = (entry) => ({
|
|
39
|
+
table: tableId(entry.model),
|
|
40
|
+
alias: entry.model.table.name
|
|
41
|
+
});
|
|
42
|
+
/**
|
|
43
|
+
* Refuses filters and a sort no index serves, as the scan guard does on `/api/v1`, on the table and on every embedded
|
|
44
|
+
* one, whose join columns need an index too; in `warn` mode, a warning header instead.
|
|
45
|
+
*/
|
|
46
|
+
const scanCheck = async (deps, entry, filters, order, shape = []) => {
|
|
47
|
+
if (deps.guard.mode === "off") return {};
|
|
48
|
+
const reads = [{
|
|
49
|
+
entry,
|
|
50
|
+
filters,
|
|
51
|
+
order,
|
|
52
|
+
joined: []
|
|
53
|
+
}, ...embedsOf(shape).map((embed) => ({
|
|
54
|
+
entry: tableEntry(deps, embed.table),
|
|
55
|
+
filters: embed.params.filters,
|
|
56
|
+
order: embed.params.order,
|
|
57
|
+
joined: embed.relationship.join.map((pair) => pair.target)
|
|
58
|
+
}))];
|
|
59
|
+
for (const read of reads) {
|
|
60
|
+
const [first] = uncoveredReads(await deps.guard.indexesOf(read.entry), read.filters, read.order, read.joined);
|
|
61
|
+
if (!first) continue;
|
|
62
|
+
const refusal = scanRefusal(read.entry.model.table, first);
|
|
63
|
+
if (deps.guard.mode === "warn") return { "X-Protobase-Warning": refusal.body.message };
|
|
64
|
+
throw refusal;
|
|
65
|
+
}
|
|
66
|
+
return {};
|
|
67
|
+
};
|
|
68
|
+
const returningOf = (prefer, shape) => prefer.return === "representation" ? { shape } : void 0;
|
|
69
|
+
const counted = (prefer, total) => prefer.count === void 0 ? void 0 : total;
|
|
70
|
+
/** A write's response: the rows it returns when asked for, as an array or one object; else no body, with `emptyStatus`. */
|
|
71
|
+
const writeResponse = (written, object, status, emptyStatus, range) => {
|
|
72
|
+
if (written.body === void 0) return new Response(null, {
|
|
73
|
+
status: emptyStatus,
|
|
74
|
+
headers: { "content-range": range }
|
|
75
|
+
});
|
|
76
|
+
return new Response(object ? onlyElement(written.body) : written.body, {
|
|
77
|
+
status,
|
|
78
|
+
headers: {
|
|
79
|
+
"content-range": range,
|
|
80
|
+
"content-type": object ? objectType : jsonType
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
};
|
|
84
|
+
const withHeaders = (response, headers) => {
|
|
85
|
+
for (const [name, value] of Object.entries(headers)) response.headers.set(name, value);
|
|
86
|
+
return response;
|
|
87
|
+
};
|
|
88
|
+
const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
89
|
+
/**
|
|
90
|
+
* The rows and columns of an insert's body: an object or an array of objects. The columns are `columns` when given,
|
|
91
|
+
* as supabase-js sends with several rows; otherwise the keys, which every row must then share (PGRST102).
|
|
92
|
+
*/
|
|
93
|
+
const insertRowsOf = (body, query) => {
|
|
94
|
+
const rows = Array.isArray(body) ? body : [body];
|
|
95
|
+
if (!rows.every(isRecord)) throw restError(400, "PGRST102", "Empty or invalid json");
|
|
96
|
+
if (query.columns) return {
|
|
97
|
+
rows,
|
|
98
|
+
columns: query.columns
|
|
99
|
+
};
|
|
100
|
+
const columns = Object.keys(rows[0] ?? {});
|
|
101
|
+
const same = (row) => Object.keys(row).length === columns.length && columns.every((column) => Object.hasOwn(row, column));
|
|
102
|
+
if (!rows.every(same)) throw restError(400, "PGRST102", "All object keys must match");
|
|
103
|
+
return {
|
|
104
|
+
rows,
|
|
105
|
+
columns
|
|
106
|
+
};
|
|
107
|
+
};
|
|
108
|
+
const read = async (deps, foreignKeys, c) => {
|
|
109
|
+
const entry = tableEntry(deps, c.req.param("table"));
|
|
110
|
+
const query = parseQuery(queryParams(c), c.req.header("range") ?? null);
|
|
111
|
+
const prefer = parsePrefer(c.req.header("prefer") ?? null);
|
|
112
|
+
const shape = await shapeOf(deps, foreignKeys, entry, query);
|
|
113
|
+
const warning = await scanCheck(deps, entry, query.filters, query.order, shape);
|
|
114
|
+
const alias = entry.model.table.name;
|
|
115
|
+
const source = {
|
|
116
|
+
from: sql`${tableId(entry.model)} as ${sql.id(alias)}`,
|
|
117
|
+
alias
|
|
118
|
+
};
|
|
119
|
+
const page = await asCaller(deps, c, true, (trx) => readRows(trx, source, shape, query, prefer.count));
|
|
120
|
+
return pageResponse(page, query.range.offset, wantsObject(c.req.raw), warning);
|
|
121
|
+
};
|
|
122
|
+
const insert = async (deps, foreignKeys, c) => {
|
|
123
|
+
const entry = tableEntry(deps, c.req.param("table"));
|
|
124
|
+
const query = parseQuery(queryParams(c));
|
|
125
|
+
const prefer = parsePrefer(c.req.header("prefer") ?? null);
|
|
126
|
+
const object = wantsObject(c.req.raw);
|
|
127
|
+
const shape = await shapeOf(deps, foreignKeys, entry, query);
|
|
128
|
+
const warning = await scanCheck(deps, entry, [], [], shape);
|
|
129
|
+
const { rows, columns } = insertRowsOf(await readBody(c), query);
|
|
130
|
+
if (columns.length === 0 && rows.length > 1) throw restError(400, "PGRST102", "Several rows without any column cannot be inserted at once");
|
|
131
|
+
if (rows.length === 0 && object) checkSingle(0);
|
|
132
|
+
const conflict = query.onConflict ?? entry.model.primaryKey.map((name) => entry.model.fields[name].column);
|
|
133
|
+
const written = rows.length === 0 ? {
|
|
134
|
+
total: 0,
|
|
135
|
+
...prefer.return === "representation" && { body: "[]" }
|
|
136
|
+
} : await asCaller(deps, c, false, async (trx) => {
|
|
137
|
+
const result = await insertRows(trx, targetOf(entry), {
|
|
138
|
+
rows,
|
|
139
|
+
columns,
|
|
140
|
+
defaults: prefer.missing === "default",
|
|
141
|
+
resolution: prefer.resolution,
|
|
142
|
+
conflict
|
|
143
|
+
}, returningOf(prefer, shape));
|
|
144
|
+
if (object) checkSingle(result.total);
|
|
145
|
+
return result;
|
|
146
|
+
});
|
|
147
|
+
const status = written.inserted === 0 ? 200 : 201;
|
|
148
|
+
return withHeaders(writeResponse(written, object, status, status, `*/${counted(prefer, written.total) ?? "*"}`), warning);
|
|
149
|
+
};
|
|
150
|
+
const update = async (deps, foreignKeys, c) => {
|
|
151
|
+
const entry = tableEntry(deps, c.req.param("table"));
|
|
152
|
+
const query = parseQuery(queryParams(c));
|
|
153
|
+
const prefer = parsePrefer(c.req.header("prefer") ?? null);
|
|
154
|
+
const object = wantsObject(c.req.raw);
|
|
155
|
+
const body = await readBody(c);
|
|
156
|
+
if (!isRecord(body)) throw restError(400, "PGRST102", "Empty or invalid json");
|
|
157
|
+
const shape = await shapeOf(deps, foreignKeys, entry, query);
|
|
158
|
+
const warning = await scanCheck(deps, entry, query.filters, [], shape);
|
|
159
|
+
const written = Object.keys(body).length === 0 ? {
|
|
160
|
+
total: 0,
|
|
161
|
+
...prefer.return === "representation" && { body: "[]" }
|
|
162
|
+
} : await asCaller(deps, c, false, async (trx) => {
|
|
163
|
+
const result = await updateRows(trx, targetOf(entry), body, query.filters, returningOf(prefer, shape));
|
|
164
|
+
if (object) checkSingle(result.total);
|
|
165
|
+
return result;
|
|
166
|
+
});
|
|
167
|
+
return withHeaders(writeResponse(written, object, 200, 204, contentRange(0, written.total, counted(prefer, written.total))), warning);
|
|
168
|
+
};
|
|
169
|
+
const remove = async (deps, foreignKeys, c) => {
|
|
170
|
+
const entry = tableEntry(deps, c.req.param("table"));
|
|
171
|
+
const query = parseQuery(queryParams(c));
|
|
172
|
+
const prefer = parsePrefer(c.req.header("prefer") ?? null);
|
|
173
|
+
const object = wantsObject(c.req.raw);
|
|
174
|
+
const shape = await shapeOf(deps, foreignKeys, entry, query);
|
|
175
|
+
const warning = await scanCheck(deps, entry, query.filters, [], shape);
|
|
176
|
+
const written = await asCaller(deps, c, false, async (trx) => {
|
|
177
|
+
const result = await deleteRows(trx, targetOf(entry), query.filters, returningOf(prefer, shape));
|
|
178
|
+
if (object) checkSingle(result.total);
|
|
179
|
+
return result;
|
|
180
|
+
});
|
|
181
|
+
return withHeaders(writeResponse(written, object, 200, 204, `*/${counted(prefer, written.total) ?? "*"}`), warning);
|
|
182
|
+
};
|
|
183
|
+
/**
|
|
184
|
+
* `/rest/v1/<table>`: read with GET and HEAD, insert and upsert with POST, update with PATCH, delete with DELETE. Reads
|
|
185
|
+
* and the rows a write returns embed related rows through `foreignKeys`.
|
|
186
|
+
*/
|
|
187
|
+
const tableRoutes = (deps, foreignKeys) => {
|
|
188
|
+
const app = new Hono();
|
|
189
|
+
app.get("/:table", (c) => read(deps, foreignKeys, c));
|
|
190
|
+
app.post("/:table", (c) => insert(deps, foreignKeys, c));
|
|
191
|
+
app.patch("/:table", (c) => update(deps, foreignKeys, c));
|
|
192
|
+
app.delete("/:table", (c) => remove(deps, foreignKeys, c));
|
|
193
|
+
return app;
|
|
194
|
+
};
|
|
195
|
+
//#endregion
|
|
196
|
+
export { tableRoutes };
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { parseError } from "./rest-error.js";
|
|
2
|
+
//#region src/rest/whole-number.ts
|
|
3
|
+
/** The whole number a `limit` or `offset` parameter named `name` gives. */
|
|
4
|
+
const wholeNumber = (name, text) => {
|
|
5
|
+
if (!/^\d+$/.test(text)) throw parseError(`"failed to parse ${name} (${text})"`, `${name} takes a whole number`);
|
|
6
|
+
return Number(text);
|
|
7
|
+
};
|
|
8
|
+
//#endregion
|
|
9
|
+
export { wholeNumber };
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { whereSql } from "./filter-sql.js";
|
|
2
|
+
import { selectSql } from "./shape-sql.js";
|
|
3
|
+
import { joinsSql } from "./embed-sql.js";
|
|
4
|
+
import { sql } from "kysely";
|
|
5
|
+
//#region src/rest/write-rows.ts
|
|
6
|
+
const returningSql = (target, returning, upsert) => {
|
|
7
|
+
const row = returning ? sql`${sql.id(target.alias)} as pgrst_row` : sql`1 as pgrst_one`;
|
|
8
|
+
return upsert ? sql` returning ${row}, (${sql.id(target.alias)}.xmax = 0) as pgrst_inserted` : sql` returning ${row}`;
|
|
9
|
+
};
|
|
10
|
+
const run = async (trx, target, payload, write, returning, upsert) => {
|
|
11
|
+
const inserted = upsert ? sql`(select count(*) from pgrst_source where pgrst_inserted)` : sql`null::bigint`;
|
|
12
|
+
const body = returning ? sql`(select coalesce(json_agg(pgrst_rows), '[]')::text from (select ${selectSql(target.alias, returning.shape)} from (select (pgrst_source.pgrst_row).* from pgrst_source) as ${sql.id(target.alias)}${joinsSql(target.alias, returning.shape)}) as pgrst_rows)` : sql`null::text`;
|
|
13
|
+
const { rows: [result] } = await sql`
|
|
14
|
+
with pgrst_payload as (select ${JSON.stringify(payload ?? null)}::json as body), pgrst_source as (${write}${returningSql(target, returning, upsert)})
|
|
15
|
+
select (select count(*) from pgrst_source) as total, ${inserted} as inserted, ${body} as body
|
|
16
|
+
`.execute(trx);
|
|
17
|
+
return {
|
|
18
|
+
total: Number(result.total),
|
|
19
|
+
...result.inserted !== null && { inserted: Number(result.inserted) },
|
|
20
|
+
...result.body !== null && { body: result.body }
|
|
21
|
+
};
|
|
22
|
+
};
|
|
23
|
+
const valuesSql = (target, insert) => {
|
|
24
|
+
const columns = sql.join(insert.columns.map((column) => sql.id(column)));
|
|
25
|
+
if (!insert.defaults) return sql`(${columns}) select ${sql.join(insert.columns.map((column) => sql`pgrst_body.${sql.id(column)}`))} from pgrst_payload, json_populate_recordset(null::${target.table}, pgrst_payload.body) as pgrst_body`;
|
|
26
|
+
const rows = insert.rows.map((row, i) => sql`(${sql.join(insert.columns.map((column) => Object.hasOwn(row, column) ? sql`(json_populate_record(null::${target.table}, (select body -> ${sql.raw(String(i))} from pgrst_payload))).${sql.id(column)}` : sql`default`))})`);
|
|
27
|
+
return sql`(${columns}) values ${sql.join(rows)}`;
|
|
28
|
+
};
|
|
29
|
+
const conflictSql = (insert) => {
|
|
30
|
+
if (!insert.resolution) return sql``;
|
|
31
|
+
const target = sql.join(insert.conflict.map((column) => sql.id(column)));
|
|
32
|
+
if (insert.resolution === "ignore-duplicates") return sql` on conflict (${target}) do nothing`;
|
|
33
|
+
return sql` on conflict (${target}) do update set ${sql.join(insert.columns.map((column) => sql`${sql.id(column)} = excluded.${sql.id(column)}`))}`;
|
|
34
|
+
};
|
|
35
|
+
/** Inserts the rows, or upserts them with a resolution, in one statement. */
|
|
36
|
+
const insertRows = (trx, target, insert, returning) => {
|
|
37
|
+
const values = insert.columns.length === 0 ? sql` default values` : sql` ${valuesSql(target, insert)}`;
|
|
38
|
+
const write = sql`insert into ${target.table} as ${sql.id(target.alias)}${values}${conflictSql(insert)}`;
|
|
39
|
+
return run(trx, target, insert.rows, write, returning, insert.resolution !== void 0);
|
|
40
|
+
};
|
|
41
|
+
/** Sets the columns of the body on every row the filters keep. */
|
|
42
|
+
const updateRows = (trx, target, values, filters, returning) => {
|
|
43
|
+
const set = sql.join(Object.keys(values).map((column) => sql`${sql.id(column)} = pgrst_body.${sql.id(column)}`));
|
|
44
|
+
const from = sql`json_populate_record(null::${target.table}, (select body from pgrst_payload)) as pgrst_body`;
|
|
45
|
+
return run(trx, target, values, sql`update ${target.table} as ${sql.id(target.alias)} set ${set} from ${from}${whereSql(target.alias, filters)}`, returning, false);
|
|
46
|
+
};
|
|
47
|
+
/** Deletes every row the filters keep. */
|
|
48
|
+
const deleteRows = (trx, target, filters, returning) => run(trx, target, null, sql`delete from ${target.table} as ${sql.id(target.alias)}${whereSql(target.alias, filters)}`, returning, false);
|
|
49
|
+
//#endregion
|
|
50
|
+
export { deleteRows, insertRows, updateRows };
|
package/dist/scan-guard.js
CHANGED
|
@@ -28,7 +28,7 @@ const createScanGuard = (db, options = {}) => {
|
|
|
28
28
|
return read;
|
|
29
29
|
};
|
|
30
30
|
/** Throws a 400 problem for a column no index serves in `reject` mode; in `warn` mode returns the warning text. */
|
|
31
|
-
|
|
31
|
+
const check = async (entry, query, context) => {
|
|
32
32
|
if (mode === "off") return void 0;
|
|
33
33
|
const columns = uncoveredColumns(context.model, await indexesOf(entry), query, context.scope);
|
|
34
34
|
const first = columns[0];
|
|
@@ -40,6 +40,10 @@ const createScanGuard = (db, options = {}) => {
|
|
|
40
40
|
suggestion: first.suggestion
|
|
41
41
|
});
|
|
42
42
|
};
|
|
43
|
+
return Object.assign(check, {
|
|
44
|
+
mode,
|
|
45
|
+
indexesOf
|
|
46
|
+
});
|
|
43
47
|
};
|
|
44
48
|
//#endregion
|
|
45
49
|
export { createScanGuard };
|
package/dist/transactions.js
CHANGED
|
@@ -13,9 +13,9 @@ const writeTransaction = (db, timeoutMs, run, settings = {}) => db.transaction()
|
|
|
13
13
|
return run(trx);
|
|
14
14
|
});
|
|
15
15
|
/** Same, and Postgres refuses any write inside it. */
|
|
16
|
-
const readTransaction = (db, timeoutMs, run) => writeTransaction(db, timeoutMs, async (trx) => {
|
|
16
|
+
const readTransaction = (db, timeoutMs, run, settings = {}) => writeTransaction(db, timeoutMs, async (trx) => {
|
|
17
17
|
await sql`set transaction read only`.execute(trx);
|
|
18
18
|
return run(trx);
|
|
19
|
-
});
|
|
19
|
+
}, settings);
|
|
20
20
|
//#endregion
|
|
21
21
|
export { readTransaction, writeTransaction };
|
package/dist/view-access.js
CHANGED
|
@@ -3,6 +3,7 @@ import { cardPhotosErrors } from "./view-card-photos.js";
|
|
|
3
3
|
import { cardFavoriteErrors } from "./view-card-favorite.js";
|
|
4
4
|
import { searchesOfFor } from "./view-saved-search.js";
|
|
5
5
|
import { relatedColumnErrors } from "./view-related-column.js";
|
|
6
|
+
import { actionWhenFields } from "./view-action-when.js";
|
|
6
7
|
import { conditionReadable, resolveFieldPath } from "@protobase/layout";
|
|
7
8
|
import { addressErrors, chartFields, hrefPlaceholders, navRecentErrors, promptFields, pruneByShape, shapes, tableDisplay } from "@protobase/schema/internal";
|
|
8
9
|
import { isRecordCondition } from "@protobase/schema";
|
|
@@ -258,12 +259,17 @@ const aiShape = (model) => shapes.map((run) => {
|
|
|
258
259
|
} : void 0;
|
|
259
260
|
});
|
|
260
261
|
const inputsShape = (models) => shapes.map((inputs) => inputs.every((input) => input.resource === void 0 || Object.hasOwn(models, input.resource)) ? inputs : void 0);
|
|
262
|
+
const actionWhenShape = (model) => shapes.map((when) => actionWhenFields(when).every((name) => Object.hasOwn(model.fields, name)) ? when : void 0);
|
|
261
263
|
const actionShape = (model, models, createPages) => shapes.object({
|
|
262
264
|
name: shapes.data,
|
|
263
265
|
label: shapes.data,
|
|
264
266
|
icon: shapes.data,
|
|
265
267
|
confirm: shapes.data,
|
|
266
268
|
bulk: shapes.data,
|
|
269
|
+
when: actionWhenShape(model),
|
|
270
|
+
otherwise: shapes.data,
|
|
271
|
+
reason: shapes.data,
|
|
272
|
+
variant: shapes.data,
|
|
267
273
|
run: shapes.oneOf({
|
|
268
274
|
update: shapes.object({
|
|
269
275
|
kind: shapes.data,
|
|
@@ -274,7 +280,11 @@ const actionShape = (model, models, createPages) => shapes.object({
|
|
|
274
280
|
ai: aiShape(model)
|
|
275
281
|
}),
|
|
276
282
|
inputs: inputsShape(models)
|
|
277
|
-
}, { requires: [
|
|
283
|
+
}, { requires: [
|
|
284
|
+
"run",
|
|
285
|
+
"inputs",
|
|
286
|
+
"when"
|
|
287
|
+
] });
|
|
278
288
|
const viewShape = (resource, model, models, prompted, createPages) => {
|
|
279
289
|
const nav = shapes.object({
|
|
280
290
|
hidden: shapes.data,
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { parseCondition } from "@protobase/layout";
|
|
2
|
+
import { actionConditionError } from "@protobase/schema/internal";
|
|
3
|
+
//#region src/view-action-when.ts
|
|
4
|
+
/** The fields an action's `when` reads, or none when it has none or does not parse, which startup refuses. */
|
|
5
|
+
const actionWhenFields = (when) => {
|
|
6
|
+
if (when === void 0) return [];
|
|
7
|
+
const parsed = parseCondition(when);
|
|
8
|
+
return parsed.ok ? parsed.paths : [];
|
|
9
|
+
};
|
|
10
|
+
/**
|
|
11
|
+
* An action's `when` parses and reads only fields the record has: a list decides it for every row from the row alone,
|
|
12
|
+
* so it follows no relation and counts no records. A view written as data is checked as the builder checks its options.
|
|
13
|
+
*/
|
|
14
|
+
const actionWhenErrors = (view, model) => view.actions.flatMap((action) => {
|
|
15
|
+
const option = actionConditionError(action);
|
|
16
|
+
if (option) return [`action "${action.name}": ${option}`];
|
|
17
|
+
if (action.when === void 0) return [];
|
|
18
|
+
const parsed = parseCondition(action.when);
|
|
19
|
+
if (!parsed.ok) return [`action "${action.name}": "when" is not a valid condition: ${parsed.message}`];
|
|
20
|
+
return parsed.paths.filter((path) => !Object.hasOwn(model.fields, path)).map((path) => `action "${action.name}": "when" names "${path}", but an action's condition reads only fields ${model.name} has`);
|
|
21
|
+
});
|
|
22
|
+
//#endregion
|
|
23
|
+
export { actionWhenErrors, actionWhenFields };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@protobase/server",
|
|
3
|
-
"version": "0.26.
|
|
3
|
+
"version": "0.26.8",
|
|
4
4
|
"description": "The Protobase REST API as a Hono app, with Better Auth sign-in.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"repository": {
|
|
@@ -32,9 +32,9 @@
|
|
|
32
32
|
"dependencies": {
|
|
33
33
|
"@better-auth/passkey": "^1.7.7",
|
|
34
34
|
"@better-auth/sso": "^1.7.7",
|
|
35
|
-
"@protobase/layout": "0.26.
|
|
36
|
-
"@protobase/query": "0.26.
|
|
37
|
-
"@protobase/schema": "0.26.
|
|
35
|
+
"@protobase/layout": "0.26.8",
|
|
36
|
+
"@protobase/query": "0.26.8",
|
|
37
|
+
"@protobase/schema": "0.26.8",
|
|
38
38
|
"@react-email/render": "^2.1.0",
|
|
39
39
|
"@scalar/hono-api-reference": "^0.12.10",
|
|
40
40
|
"@sentry/core": "^11.7.0",
|