@protobase/server 0.26.7 → 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.
@@ -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
  ];
@@ -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,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 };
@@ -1,8 +1,9 @@
1
- import { parseError } from "./rest-error.js";
2
1
  import { splitList, unquote } from "./split-list.js";
3
2
  import { parseFilter } from "./parse-filter.js";
4
3
  import { parseOrder } from "./parse-order.js";
5
4
  import { parseSelect } from "./parse-select.js";
5
+ import { wholeNumber } from "./whole-number.js";
6
+ import { parseEmbedParams, splitEmbedKey } from "./embed-params.js";
6
7
  //#region src/rest/parse-query.ts
7
8
  const reserved = /* @__PURE__ */ new Set([
8
9
  "select",
@@ -12,10 +13,6 @@ const reserved = /* @__PURE__ */ new Set([
12
13
  "on_conflict",
13
14
  "columns"
14
15
  ]);
15
- const count = (name, text) => {
16
- if (!/^\d+$/.test(text)) throw parseError(`"failed to parse ${name} (${text})"`, `${name} takes a whole number`);
17
- return Number(text);
18
- };
19
16
  const names = (text) => splitList(text).map((name) => unquote(name.trim()));
20
17
  /** `Range: 2-4` or `Range: 5-`, in items; anything else asks for every row, as PostgREST takes it. */
21
18
  const rangeHeader = (header) => {
@@ -37,7 +34,8 @@ const intersect = (a, b) => {
37
34
  };
38
35
  /**
39
36
  * Parses a request's query parameters. The ones in `args` are a function's arguments, not filters. `select`, `order`,
40
- * `limit`, `offset`, `on_conflict` and `columns` count once, the first time; every other parameter is a filter.
37
+ * `limit`, `offset`, `on_conflict` and `columns` count once, the first time; a parameter under an embed's path, such
38
+ * as `comments.likes`, is the embed's (see `parseEmbedParams`); every other parameter is a filter.
41
39
  */
42
40
  const parseQuery = (params, rangeText = null, args = /* @__PURE__ */ new Set()) => {
43
41
  const first = (name) => params.find(([key]) => key === name)?.[1];
@@ -46,13 +44,16 @@ const parseQuery = (params, rangeText = null, args = /* @__PURE__ */ new Set())
46
44
  const order = first("order");
47
45
  const onConflict = first("on_conflict");
48
46
  const columns = first("columns");
47
+ const select = parseSelect(first("select"));
48
+ const own = params.filter(([key]) => !reserved.has(key) && !args.has(key));
49
49
  const fromParams = {
50
- offset: offset === void 0 ? 0 : count("offset", offset),
51
- ...limit !== void 0 && { limit: count("limit", limit) }
50
+ offset: offset === void 0 ? 0 : wholeNumber("offset", offset),
51
+ ...limit !== void 0 && { limit: wholeNumber("limit", limit) }
52
52
  };
53
53
  return {
54
- select: parseSelect(first("select")),
55
- filters: params.filter(([key]) => !reserved.has(key) && !args.has(key)).map(([key, value]) => parseFilter(key, value)),
54
+ select,
55
+ filters: own.filter(([key]) => splitEmbedKey(key).path === void 0).map(([key, value]) => parseFilter(key, value)),
56
+ embedded: parseEmbedParams(own, select),
56
57
  order: order === void 0 ? [] : parseOrder(order),
57
58
  range: intersect(fromParams, rangeHeader(rangeText)),
58
59
  ...onConflict !== void 0 && { onConflict: names(onConflict) },
@@ -3,10 +3,26 @@ import { parseFieldPath } from "./field-path.js";
3
3
  import { splitList } from "./split-list.js";
4
4
  //#region src/rest/parse-select.ts
5
5
  const typeName = /^[A-Za-z_][A-Za-z0-9_]*(\[\])?$/;
6
+ const embedPattern = /^(?:([^:(!]+):(?!:))?([^!(]+)((?:![^!(]+)*)\(([\s\S]*)\)$/;
7
+ const parseEmbed = (item) => {
8
+ const [, alias, nameText = "", modifiers = "", inner = ""] = embedPattern.exec(item) ?? [];
9
+ const name = nameText.trim();
10
+ if (!name || name.startsWith("...") || name.includes(".") || name === "count") throw restError(400, "PGRST100", `"${item}" spreads a resource or calls an aggregate, which /rest/v1 does not support yet`);
11
+ const flags = modifiers.split("!").slice(1).map((flag) => flag.trim());
12
+ const hint = flags.find((flag) => flag !== "inner" && flag !== "left");
13
+ return {
14
+ kind: "embed",
15
+ name,
16
+ ...alias?.trim() && { alias: alias.trim() },
17
+ ...hint !== void 0 && { hint },
18
+ inner: flags.includes("inner"),
19
+ select: inner.trim() === "" ? [] : splitList(inner).map(parseItem)
20
+ };
21
+ };
6
22
  const parseItem = (text) => {
7
23
  const item = text.trim();
8
24
  if (item === "*") return { kind: "all" };
9
- if (item.includes("(")) throw restError(400, "PGRST100", `"${item}" embeds a resource or calls an aggregate, which /rest/v1 does not support yet`);
25
+ if (item.includes("(")) return parseEmbed(item);
10
26
  const aliased = /^([^:]+):(?!:)([\s\S]*)$/.exec(item);
11
27
  const alias = aliased?.[1]?.trim();
12
28
  const [fieldText = "", cast, ...more] = (aliased ? aliased[2] : item).split("::");
@@ -20,5 +36,7 @@ const parseItem = (text) => {
20
36
  };
21
37
  /** Parses `select`: items separated by commas, `*` for every column. Missing or empty, it is `*`. */
22
38
  const parseSelect = (text) => text === void 0 || text.trim() === "" ? [{ kind: "all" }] : splitList(text).map(parseItem);
39
+ /** The name an embed goes by in a row and in the parameters that filter it: its alias, else its name. */
40
+ const embedKey = (item) => item.alias ?? item.name;
23
41
  //#endregion
24
- export { parseSelect };
42
+ export { embedKey, parseSelect };
@@ -1,34 +1,36 @@
1
1
  import { whereSql } from "./filter-sql.js";
2
2
  import { orderSql, rangeSql, selectSql } from "./shape-sql.js";
3
+ import { joinsSql } from "./embed-sql.js";
3
4
  import { sql } from "kysely";
4
5
  //#region src/rest/read-rows.ts
5
6
  /** The most rows a read returns, as Supabase's `db-max-rows` default. */
6
7
  const maxRows = 1e3;
7
- const plannedRows = async (trx, source, query) => {
8
- const { rows } = await sql`explain (format json) select 1 from ${source.from}${whereSql(source.alias, query.filters)}`.execute(trx);
8
+ const plannedRows = async (trx, source, filtered) => {
9
+ const { rows } = await sql`explain (format json) select 1 from ${source.from}${filtered}`.execute(trx);
9
10
  return rows[0]["QUERY PLAN"][0].Plan["Plan Rows"];
10
11
  };
11
12
  /**
12
- * Reads the rows of `source` the query asks for: filtered, shaped by `select`, sorted, in its range and never more than
13
+ * Reads the rows of `source` the query asks for: filtered, shaped by `shape` (its `select`, embeds resolved), sorted, in its range and never more than
13
14
  * `maxRows`, as one JSON array built by Postgres, so values reach the client exactly as Postgres writes them. `count`
14
15
  * counts the matching rows: `exact` by counting, `planned` by the planner's estimate, `estimated` by counting up to
15
- * `maxRows` and estimating beyond.
16
+ * `maxRows` and estimating beyond. The rows an inner embed drops are not counted.
16
17
  */
17
- const readRows = async (trx, source, query, count) => {
18
+ const readRows = async (trx, source, shape, query, count) => {
18
19
  const where = whereSql(source.alias, query.filters);
20
+ const filtered = sql`${joinsSql(source.alias, shape.filter((item) => item.kind === "embed" && item.inner))}${where}`;
19
21
  const range = {
20
22
  offset: query.range.offset,
21
23
  limit: Math.min(query.range.limit ?? 1e3, maxRows)
22
24
  };
23
- const columns = source.scalar ? sql`${sql.id(source.alias)} as pgrst_scalar` : selectSql(source.alias, query.select);
24
- const rows = sql`select ${columns} from ${source.from}${where}${orderSql(source.alias, query.order)}${rangeSql(range)}`;
25
- const counted = count === "exact" || count === "estimated" ? sql`(select count(*) from ${source.from}${where})` : sql`null::bigint`;
25
+ const columns = source.scalar ? sql`${sql.id(source.alias)} as pgrst_scalar` : selectSql(source.alias, shape);
26
+ const rows = sql`select ${columns} from ${source.from}${joinsSql(source.alias, shape)}${where}${orderSql(source.alias, query.order)}${rangeSql(range)}`;
27
+ const counted = count === "exact" || count === "estimated" ? sql`(select count(*) from ${source.from}${filtered})` : sql`null::bigint`;
26
28
  const aggregate = source.scalar ? sql`json_agg(pgrst_rows.pgrst_scalar)` : sql`json_agg(pgrst_rows)`;
27
29
  const { rows: [result] } = await sql`
28
30
  select coalesce(${aggregate}, '[]')::text as body, count(*) as page, ${counted} as total from (${rows}) as pgrst_rows
29
31
  `.execute(trx);
30
32
  const exact = result.total === null ? void 0 : Number(result.total);
31
- const total = count === "planned" || count === "estimated" && exact > 1e3 ? await plannedRows(trx, source, query) : exact;
33
+ const total = count === "planned" || count === "estimated" && exact > 1e3 ? await plannedRows(trx, source, filtered) : exact;
32
34
  return {
33
35
  body: result.body,
34
36
  page: Number(result.page),
@@ -1,4 +1,5 @@
1
1
  import { HttpProblem } from "../problem.js";
2
+ import { foreignKeyCache } from "./foreign-keys.js";
2
3
  import { restError, restErrorOf } from "./rest-error.js";
3
4
  import { errorResponse } from "./respond.js";
4
5
  import { rpcRoute } from "./rpc-route.js";
@@ -25,7 +26,7 @@ const restRoutes = ({ deps, caller, report }) => {
25
26
  await next();
26
27
  });
27
28
  app.route("/", rpcRoute(deps));
28
- app.route("/", tableRoutes(deps));
29
+ app.route("/", tableRoutes(deps, foreignKeyCache(deps.db)));
29
30
  app.notFound(() => errorResponse(restError(404, "PGRST125", "Invalid path specified in request URL")));
30
31
  return app;
31
32
  };
@@ -71,22 +71,34 @@ const suggestion = (table, read) => {
71
71
  return `create index on ${table} (${column})`;
72
72
  };
73
73
  /**
74
- * The reads of a request no index of the table serves: each filtered field, by the kind of index its operator needs,
75
- * and the first sort field. A view or a foreign table has no indexes of its own and is not judged.
74
+ * The reads of a request no index of the table serves: each column an embed joins on, which equals one value per
75
+ * related row; each filtered field, by the kind of index its operator needs; and the first sort field. A view or a
76
+ * foreign table has no indexes of its own and is not judged.
76
77
  */
77
- const uncoveredReads = (table, filters, order) => {
78
+ const uncoveredReads = (table, filters, order, joined = []) => {
78
79
  if (table.kind !== "table") return [];
79
- const pinned = new Set(filters.flatMap(pinnedFields));
80
- return [...filters.flatMap(filterReads), ...order[0] ? [{
81
- field: order[0].field,
82
- need: "btree",
83
- use: "sort"
84
- }] : []].filter((read) => !table.indexes.some((index) => serves(index, read, pinned)));
80
+ const pinned = /* @__PURE__ */ new Set([...joined, ...filters.flatMap(pinnedFields)]);
81
+ return [
82
+ ...joined.map((column) => ({
83
+ field: {
84
+ column,
85
+ json: []
86
+ },
87
+ need: "equality",
88
+ use: "join"
89
+ })),
90
+ ...filters.flatMap(filterReads),
91
+ ...order[0] ? [{
92
+ field: order[0].field,
93
+ need: "btree",
94
+ use: "sort"
95
+ }] : []
96
+ ].filter((read) => !table.indexes.some((index) => serves(index, read, pinned)));
85
97
  };
86
98
  /** The 400 error of a read no index serves, with the statement that adds one as its hint. */
87
99
  const scanRefusal = (table, read) => {
88
100
  const tableName = table.schema ? `${quoted(table.schema)}.${quoted(table.name)}` : quoted(table.name);
89
- const what = read.use === "sort" ? "Sorting by" : "Filtering on";
101
+ const what = read.use === "sort" ? "Sorting by" : read.use === "join" ? "Embedding through" : "Filtering on";
90
102
  const index = read.need === "btree" || read.need === "equality" ? "an index" : read.need === "trigram" ? "a pg_trgm index" : read.need === "gist" ? "a GiST index" : "a GIN index";
91
103
  return restError(400, "expensive-query", `${what} "${pathKey(read.field)}" needs ${index} that serves it`, null, suggestion(tableName, read));
92
104
  };
@@ -1,3 +1,4 @@
1
+ import { restError } from "./rest-error.js";
1
2
  import { jsonType, onlyElement, pageResponse, wantsObject } from "./respond.js";
2
3
  import { callSource, chooseFunction, findFunctions } from "./functions.js";
3
4
  import { parsePrefer } from "./parse-prefer.js";
@@ -36,6 +37,10 @@ const call = async (deps, c) => {
36
37
  const args = get ? Object.fromEntries(params.filter(([key]) => given.includes(key))) : body;
37
38
  const source = callSource(chosen, args);
38
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
+ });
39
44
  if (chosen.info.returns === "void") {
40
45
  await sql`select 1 from ${source.from}`.execute(trx);
41
46
  return new Response(null, {
@@ -44,7 +49,7 @@ const call = async (deps, c) => {
44
49
  });
45
50
  }
46
51
  if (!chosen.info.set) {
47
- const page = await readRows(trx, source, query);
52
+ const page = await readRows(trx, source, shape, query);
48
53
  return new Response(onlyElement(page.body), {
49
54
  status: 200,
50
55
  headers: {
@@ -53,7 +58,7 @@ const call = async (deps, c) => {
53
58
  }
54
59
  });
55
60
  }
56
- return pageResponse(await readRows(trx, source, query, prefer.count), query.range.offset, object);
61
+ return pageResponse(await readRows(trx, source, shape, query, prefer.count), query.range.offset, object);
57
62
  });
58
63
  };
59
64
  const rpcRoute = (deps) => {
@@ -1,16 +1,19 @@
1
1
  import { pathName } from "./field-path.js";
2
+ import { lateralName } from "./embed-shape.js";
2
3
  import { fieldSql } from "./filter-sql.js";
3
4
  import { sql } from "kysely";
4
5
  //#region src/rest/shape-sql.ts
5
6
  /**
6
- * The select list of `select` on the rows of `source`: `*` its columns, a field under its alias, its JSON path's last
7
- * key or its column, cast when asked. Cast types are checked to be bare type names when parsed.
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.
8
10
  */
9
- const selectSql = (source, items) => sql.join(items.map((item) => {
10
- if (item.kind === "all") return sql`${sql.id(source)}.*`;
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)}`] : [];
11
14
  const value = fieldSql(source, item.field);
12
15
  const cast = item.cast === void 0 ? value : sql`cast(${value} as ${sql.raw(item.cast)})`;
13
- return sql`${cast} as ${sql.id(item.alias ?? pathName(item.field))}`;
16
+ return [sql`${cast} as ${sql.id(item.alias ?? pathName(item.field))}`];
14
17
  }));
15
18
  /** ` order by ...` for the terms; nothing without any. */
16
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}` : "")}`))}`;
@@ -4,6 +4,7 @@ import { contentRange } from "./content-range.js";
4
4
  import { checkSingle, jsonType, objectType, onlyElement, pageResponse, wantsObject } from "./respond.js";
5
5
  import { parsePrefer } from "./parse-prefer.js";
6
6
  import { parseQuery } from "./parse-query.js";
7
+ import { embedsOf, resolveShape } from "./embed-shape.js";
7
8
  import { readRows } from "./read-rows.js";
8
9
  import { asCaller, queryParams, readBody } from "./rest-context.js";
9
10
  import { scanRefusal, uncoveredReads } from "./rest-scan.js";
@@ -15,25 +16,56 @@ import { sql } from "kysely";
15
16
  * The resource whose table `name` is, in the `public` schema or the search path's: `/rest/v1` serves the tables the app
16
17
  * declares as resources, as PostgREST serves those of its exposed schema. Any other is 404 (PGRST205).
17
18
  */
19
+ const servedEntry = (deps, name) => deps.registry.entries.find((one) => one.model.table.name === name && (one.model.table.schema ?? "public") === "public");
18
20
  const tableEntry = (deps, name) => {
19
- const entry = deps.registry.entries.find((one) => one.model.table.name === name && (one.model.table.schema ?? "public") === "public");
21
+ const entry = servedEntry(deps, name);
20
22
  if (!entry) throw restError(404, "PGRST205", `Could not find the table 'public.${name}' in the schema cache`);
21
23
  return entry;
22
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
+ };
23
38
  const targetOf = (entry) => ({
24
39
  table: tableId(entry.model),
25
40
  alias: entry.model.table.name
26
41
  });
27
- /** Refuses filters and a sort no index serves, as the scan guard does on `/api/v1`; in `warn` mode, a warning header instead. */
28
- const scanCheck = async (deps, entry, filters, order) => {
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 = []) => {
29
47
  if (deps.guard.mode === "off") return {};
30
- const [first] = uncoveredReads(await deps.guard.indexesOf(entry), filters, order);
31
- if (!first) return {};
32
- const refusal = scanRefusal(entry.model.table, first);
33
- if (deps.guard.mode === "warn") return { "X-Protobase-Warning": refusal.body.message };
34
- throw refusal;
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 {};
35
67
  };
36
- const returningOf = (prefer, query) => prefer.return === "representation" ? { select: query.select } : void 0;
68
+ const returningOf = (prefer, shape) => prefer.return === "representation" ? { shape } : void 0;
37
69
  const counted = (prefer, total) => prefer.count === void 0 ? void 0 : total;
38
70
  /** A write's response: the rows it returns when asked for, as an array or one object; else no body, with `emptyStatus`. */
39
71
  const writeResponse = (written, object, status, emptyStatus, range) => {
@@ -49,6 +81,10 @@ const writeResponse = (written, object, status, emptyStatus, range) => {
49
81
  }
50
82
  });
51
83
  };
84
+ const withHeaders = (response, headers) => {
85
+ for (const [name, value] of Object.entries(headers)) response.headers.set(name, value);
86
+ return response;
87
+ };
52
88
  const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
53
89
  /**
54
90
  * The rows and columns of an insert's body: an object or an array of objects. The columns are `columns` when given,
@@ -69,24 +105,27 @@ const insertRowsOf = (body, query) => {
69
105
  columns
70
106
  };
71
107
  };
72
- const read = async (deps, c) => {
108
+ const read = async (deps, foreignKeys, c) => {
73
109
  const entry = tableEntry(deps, c.req.param("table"));
74
110
  const query = parseQuery(queryParams(c), c.req.header("range") ?? null);
75
111
  const prefer = parsePrefer(c.req.header("prefer") ?? null);
76
- const warning = await scanCheck(deps, entry, query.filters, query.order);
112
+ const shape = await shapeOf(deps, foreignKeys, entry, query);
113
+ const warning = await scanCheck(deps, entry, query.filters, query.order, shape);
77
114
  const alias = entry.model.table.name;
78
115
  const source = {
79
116
  from: sql`${tableId(entry.model)} as ${sql.id(alias)}`,
80
117
  alias
81
118
  };
82
- const page = await asCaller(deps, c, true, (trx) => readRows(trx, source, query, prefer.count));
119
+ const page = await asCaller(deps, c, true, (trx) => readRows(trx, source, shape, query, prefer.count));
83
120
  return pageResponse(page, query.range.offset, wantsObject(c.req.raw), warning);
84
121
  };
85
- const insert = async (deps, c) => {
122
+ const insert = async (deps, foreignKeys, c) => {
86
123
  const entry = tableEntry(deps, c.req.param("table"));
87
124
  const query = parseQuery(queryParams(c));
88
125
  const prefer = parsePrefer(c.req.header("prefer") ?? null);
89
126
  const object = wantsObject(c.req.raw);
127
+ const shape = await shapeOf(deps, foreignKeys, entry, query);
128
+ const warning = await scanCheck(deps, entry, [], [], shape);
90
129
  const { rows, columns } = insertRowsOf(await readBody(c), query);
91
130
  if (columns.length === 0 && rows.length > 1) throw restError(400, "PGRST102", "Several rows without any column cannot be inserted at once");
92
131
  if (rows.length === 0 && object) checkSingle(0);
@@ -101,55 +140,56 @@ const insert = async (deps, c) => {
101
140
  defaults: prefer.missing === "default",
102
141
  resolution: prefer.resolution,
103
142
  conflict
104
- }, returningOf(prefer, query));
143
+ }, returningOf(prefer, shape));
105
144
  if (object) checkSingle(result.total);
106
145
  return result;
107
146
  });
108
147
  const status = written.inserted === 0 ? 200 : 201;
109
- return writeResponse(written, object, status, status, `*/${counted(prefer, written.total) ?? "*"}`);
148
+ return withHeaders(writeResponse(written, object, status, status, `*/${counted(prefer, written.total) ?? "*"}`), warning);
110
149
  };
111
- const update = async (deps, c) => {
150
+ const update = async (deps, foreignKeys, c) => {
112
151
  const entry = tableEntry(deps, c.req.param("table"));
113
152
  const query = parseQuery(queryParams(c));
114
153
  const prefer = parsePrefer(c.req.header("prefer") ?? null);
115
154
  const object = wantsObject(c.req.raw);
116
155
  const body = await readBody(c);
117
156
  if (!isRecord(body)) throw restError(400, "PGRST102", "Empty or invalid json");
118
- const warning = await scanCheck(deps, entry, query.filters, []);
157
+ const shape = await shapeOf(deps, foreignKeys, entry, query);
158
+ const warning = await scanCheck(deps, entry, query.filters, [], shape);
119
159
  const written = Object.keys(body).length === 0 ? {
120
160
  total: 0,
121
161
  ...prefer.return === "representation" && { body: "[]" }
122
162
  } : await asCaller(deps, c, false, async (trx) => {
123
- const result = await updateRows(trx, targetOf(entry), body, query.filters, returningOf(prefer, query));
163
+ const result = await updateRows(trx, targetOf(entry), body, query.filters, returningOf(prefer, shape));
124
164
  if (object) checkSingle(result.total);
125
165
  return result;
126
166
  });
127
- const response = writeResponse(written, object, 200, 204, contentRange(0, written.total, counted(prefer, written.total)));
128
- for (const [name, value] of Object.entries(warning)) response.headers.set(name, value);
129
- return response;
167
+ return withHeaders(writeResponse(written, object, 200, 204, contentRange(0, written.total, counted(prefer, written.total))), warning);
130
168
  };
131
- const remove = async (deps, c) => {
169
+ const remove = async (deps, foreignKeys, c) => {
132
170
  const entry = tableEntry(deps, c.req.param("table"));
133
171
  const query = parseQuery(queryParams(c));
134
172
  const prefer = parsePrefer(c.req.header("prefer") ?? null);
135
173
  const object = wantsObject(c.req.raw);
136
- const warning = await scanCheck(deps, entry, query.filters, []);
174
+ const shape = await shapeOf(deps, foreignKeys, entry, query);
175
+ const warning = await scanCheck(deps, entry, query.filters, [], shape);
137
176
  const written = await asCaller(deps, c, false, async (trx) => {
138
- const result = await deleteRows(trx, targetOf(entry), query.filters, returningOf(prefer, query));
177
+ const result = await deleteRows(trx, targetOf(entry), query.filters, returningOf(prefer, shape));
139
178
  if (object) checkSingle(result.total);
140
179
  return result;
141
180
  });
142
- const response = writeResponse(written, object, 200, 204, `*/${counted(prefer, written.total) ?? "*"}`);
143
- for (const [name, value] of Object.entries(warning)) response.headers.set(name, value);
144
- return response;
181
+ return withHeaders(writeResponse(written, object, 200, 204, `*/${counted(prefer, written.total) ?? "*"}`), warning);
145
182
  };
146
- /** `/rest/v1/<table>`: read with GET and HEAD, insert and upsert with POST, update with PATCH, delete with DELETE. */
147
- const tableRoutes = (deps) => {
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) => {
148
188
  const app = new Hono();
149
- app.get("/:table", (c) => read(deps, c));
150
- app.post("/:table", (c) => insert(deps, c));
151
- app.patch("/:table", (c) => update(deps, c));
152
- app.delete("/:table", (c) => remove(deps, c));
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));
153
193
  return app;
154
194
  };
155
195
  //#endregion
@@ -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 };
@@ -1,5 +1,6 @@
1
1
  import { whereSql } from "./filter-sql.js";
2
2
  import { selectSql } from "./shape-sql.js";
3
+ import { joinsSql } from "./embed-sql.js";
3
4
  import { sql } from "kysely";
4
5
  //#region src/rest/write-rows.ts
5
6
  const returningSql = (target, returning, upsert) => {
@@ -8,7 +9,7 @@ const returningSql = (target, returning, upsert) => {
8
9
  };
9
10
  const run = async (trx, target, payload, write, returning, upsert) => {
10
11
  const inserted = upsert ? sql`(select count(*) from pgrst_source where pgrst_inserted)` : sql`null::bigint`;
11
- const body = returning ? sql`(select coalesce(json_agg(pgrst_rows), '[]')::text from (select ${selectSql(target.alias, returning.select)} from (select (pgrst_source.pgrst_row).* from pgrst_source) as ${sql.id(target.alias)}) as pgrst_rows)` : sql`null::text`;
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`;
12
13
  const { rows: [result] } = await sql`
13
14
  with pgrst_payload as (select ${JSON.stringify(payload ?? null)}::json as body), pgrst_source as (${write}${returningSql(target, returning, upsert)})
14
15
  select (select count(*) from pgrst_source) as total, ${inserted} as inserted, ${body} as body
@@ -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.7",
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.7",
36
- "@protobase/query": "0.26.7",
37
- "@protobase/schema": "0.26.7",
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",