@protobase/server 0.26.6 → 0.26.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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 };
@@ -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
- return async (entry, query, context) => {
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 };
@@ -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 };
@@ -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: ["run", "inputs"] });
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.6",
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.6",
36
- "@protobase/query": "0.26.6",
37
- "@protobase/schema": "0.26.6",
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",