@protobase/server 0.14.1 → 0.14.3

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.
@@ -4,6 +4,7 @@ import { checkedFilter } from "./filter-input.js";
4
4
  import { readScope, requestAccess, requireOperation } from "./request-access.js";
5
5
  import { readTransaction } from "./transactions.js";
6
6
  import { parseParams } from "./params.js";
7
+ import { labelRequests, relatedLabels } from "./related-labels.js";
7
8
  import { facetCounts, histogram, series } from "@protobase/query";
8
9
  import { sql } from "kysely";
9
10
  import { z } from "zod";
@@ -13,7 +14,8 @@ const filter = z.string().optional();
13
14
  const facetParams = z.object({
14
15
  field,
15
16
  filter,
16
- limit: z.coerce.number().int().optional()
17
+ limit: z.coerce.number().int().optional(),
18
+ label: z.string().min(1).optional()
17
19
  });
18
20
  const seriesParams = z.object({
19
21
  field,
@@ -56,19 +58,33 @@ const scoped = async (deps, entry, session, source) => {
56
58
  scope: readScope(access)
57
59
  };
58
60
  };
59
- /** Counts per value of a field, ignoring the filter's own terms on that field (AIP-160 filters, UI facet panels). */
61
+ /**
62
+ * Counts per value of a field, ignoring the filter's own terms on that field (AIP-160 filters, UI facet panels). With
63
+ * `label`, a relation's values carry the `label` field of the record each points at, read as a list's `labels` reads it.
64
+ */
60
65
  const facets = async (deps, entry, session, input) => {
61
66
  const params = parseParams(facetParams, input);
62
67
  const { model, filter: checked, scope } = await scoped(deps, entry, session, params.filter);
63
- const counts = await readTransaction(deps.db, deps.statementTimeoutMs, (trx) => facetCounts(trx, model, params.field, checked, {
64
- statementTimeoutMs: deps.statementTimeoutMs,
65
- ...params.limit !== void 0 && { limit: params.limit },
66
- scope
67
- }));
68
- return {
69
- field: params.field,
70
- facets: counts
71
- };
68
+ const labels = params.label === void 0 ? [] : await labelRequests(deps, session, model, [params.field], `${params.field}.${params.label}`);
69
+ return readTransaction(deps.db, deps.statementTimeoutMs, async (trx) => {
70
+ const counts = await facetCounts(trx, model, params.field, checked, {
71
+ statementTimeoutMs: deps.statementTimeoutMs,
72
+ ...params.limit !== void 0 && { limit: params.limit },
73
+ scope
74
+ });
75
+ const names = (await relatedLabels(trx, labels, counts.map((facet) => ({ [params.field]: facet.value }))))[params.field] ?? {};
76
+ const named = counts.map((facet) => {
77
+ const label = facet.value === null ? void 0 : names[String(facet.value)];
78
+ return label === void 0 ? facet : {
79
+ ...facet,
80
+ label
81
+ };
82
+ });
83
+ return {
84
+ field: params.field,
85
+ facets: named
86
+ };
87
+ });
72
88
  };
73
89
  const earliest = async (deps, access, fieldName, scope) => {
74
90
  const target = access.model.fields[fieldName];
@@ -1,5 +1,6 @@
1
1
  import { relatedErrors } from "./view-related.js";
2
2
  import { cardPhotosErrors } from "./view-card-photos.js";
3
+ import { cardFavoriteErrors, favoriteOwnerErrors } from "./view-card-favorite.js";
3
4
  import { mapDisplayErrors, mapErrors } from "./view-map.js";
4
5
  import { addressErrors, addressPlaceholders, appHrefError, displayFields, fieldRefsIn, hrefPlaceholders } from "@protobase/schema/internal";
5
6
  //#region src/check-views.ts
@@ -57,11 +58,25 @@ const widgetErrors = (view, model, fieldTypes) => Object.entries(view.fields).fl
57
58
  if (!of || !field || of.includes(field.type)) return [];
58
59
  return [`field "${name}" is ${field.type}, and its field type "${hints.widget.name}" shows only ${of.join(", ")}`];
59
60
  });
61
+ const sharedPhotosErrors = (view) => {
62
+ const shown = (view.list?.displays ?? []).flatMap((display) => display.kind === "cards" && display.photos ? [{
63
+ name: display.name,
64
+ photos: display.photos
65
+ }] : []);
66
+ const same = (a, b) => a.resource === b.resource && a.field === b.field && a.image === b.image && a.sort === b.sort;
67
+ return shown.filter(({ photos }) => !same(photos, shown[0].photos)).map(({ name }) => `display "${name}" photos: the list's records load with one set of photos, so name those of display "${shown[0].name}"`);
68
+ };
69
+ const listOfErrors = (model, name, models) => {
70
+ const target = models[model.fields[name]?.relation?.resource ?? ""];
71
+ if (!target) return [`"${name}" points at no resource of the app`];
72
+ return target.primaryKey.length === 1 ? [] : [`${target.name} has a composite key, so its records cannot be listed by key`];
73
+ };
60
74
  /**
61
75
  * Fails at startup when a view uses a sensitive field where it would show empty, a display or custom section names a
62
76
  * field the resource does not have, labels or colours values its enum does not have, shows a field with a field type of
63
77
  * `fieldTypes` (each with the storage types it shows) that does not show it,
64
- * has a related section or a cards display's photos that do not fit the resources, a map that cannot show the
78
+ * has a related section, a cards display's photos or favorite button or a `listOf` that do not fit the resources, cards
79
+ * displays of one list that name different photos, a map that cannot show the
65
80
  * record's position or a map display that cannot find the records in its view, or a link to an address the app does not have.
66
81
  */
67
82
  const checkViews = (registry, views, pages = [], fieldTypes = {}) => {
@@ -71,7 +86,9 @@ const checkViews = (registry, views, pages = [], fieldTypes = {}) => {
71
86
  const model = models[view.resource];
72
87
  if (!model) return [];
73
88
  const related = view.layout.flatMap((item) => item.kind === "related" ? relatedErrors(item, view.resource, models).map((error) => `related "${item.title}": ${error}`) : []);
74
- const photos = (view.list?.displays ?? []).flatMap((display) => display.kind === "cards" && display.photos ? cardPhotosErrors(display.photos, view.resource, models).map((error) => `display "${display.name}" photos: ${error}`) : []);
89
+ const photos = [...(view.list?.displays ?? []).flatMap((display) => display.kind === "cards" && display.photos ? cardPhotosErrors(display.photos, view.resource, models).map((error) => `display "${display.name}" photos: ${error}`) : []), ...sharedPhotosErrors(view)];
90
+ const favorites = (view.list?.displays ?? []).flatMap((display) => display.kind === "cards" && display.favorite ? [...cardFavoriteErrors(display.favorite, view.resource, models), ...favoriteOwnerErrors(display.favorite, models)].map((error) => `display "${display.name}" favorite: ${error}`) : []);
91
+ const listOf = view.listOf === void 0 ? [] : listOfErrors(model, view.listOf, models).map((error) => `listOf: ${error}`);
75
92
  const maps = [...view.layout.flatMap((item) => item.kind === "map" ? mapErrors(item, model).map((error) => `map "${item.title}": ${error}`) : []), ...(view.list?.displays ?? []).flatMap((display) => display.kind === "map" ? mapDisplayErrors(display, model).map((error) => `display "${display.name}": ${error}`) : [])];
76
93
  return [
77
94
  ...sensitiveErrors(view, model),
@@ -81,6 +98,8 @@ const checkViews = (registry, views, pages = [], fieldTypes = {}) => {
81
98
  ...widgetErrors(view, model, fieldTypes),
82
99
  ...related,
83
100
  ...photos,
101
+ ...favorites,
102
+ ...listOf,
84
103
  ...maps,
85
104
  ...linkErrors(view, model, models, pageNames)
86
105
  ].map((problem) => `View "${view.resource}": ${problem}`);
@@ -0,0 +1,101 @@
1
+ import { badRequest } from "./problem.js";
2
+ import { presentFiles } from "./files/present.js";
3
+ import { exposeRow } from "./rows.js";
4
+ import { checkedSort } from "./filter-input.js";
5
+ import { readScope, requestAccess } from "./request-access.js";
6
+ import { readTransaction } from "./transactions.js";
7
+ import { cardPhotosErrors } from "./view-card-photos.js";
8
+ import { checkFilter, createWhere } from "@protobase/schema";
9
+ import { cardPhotoLimit } from "@protobase/schema/internal";
10
+ import { relatedRows } from "@protobase/query";
11
+ //#region src/list-photos.ts
12
+ const where = createWhere();
13
+ const invalid = (parameter, message) => badRequest("invalid-parameter", `Invalid ${parameter}: ${message}`, { errors: [{
14
+ parameter,
15
+ message
16
+ }] });
17
+ /**
18
+ * Checks `photos` (`resource.field.image`) and `photos_order_by` against the listed resource and the caller's access
19
+ * to the photos' resource. The caller gets photos only where a cards display would keep its `photos` for them: they
20
+ * can list that resource, and read and use `field`, `image` and the sorted fields. Anything less answers no photos
21
+ * rather than an error, so the parameter never tells a resource or field exists.
22
+ */
23
+ const photosRequest = async (deps, session, model, photos, orderBy) => {
24
+ if (photos === void 0) {
25
+ if (orderBy !== void 0) throw invalid("photos_order_by", "Name the photos it orders with photos");
26
+ return;
27
+ }
28
+ const [resource, field, image, ...rest] = photos.split(".").map((part) => part.trim());
29
+ if (!resource || !field || !image || rest.length > 0) throw invalid("photos", "Name the photos' resource, its relation to this one and its file field, as resource.field.image");
30
+ const named = {
31
+ resource,
32
+ field,
33
+ image,
34
+ ...orderBy?.trim() && { sort: orderBy }
35
+ };
36
+ const target = deps.registry.find(resource);
37
+ if (!target) return { photos: named };
38
+ const access = await requestAccess(deps, target, session);
39
+ const models = {
40
+ [resource]: access.model,
41
+ [model.name]: model
42
+ };
43
+ return access.resolved.operations.list && cardPhotosErrors(named, model.name, models).length === 0 ? {
44
+ photos: named,
45
+ access
46
+ } : { photos: named };
47
+ };
48
+ const photoColumns = ({ photos, access }) => {
49
+ const placeholder = access.model.fields[photos.image].file?.placeholder;
50
+ return [
51
+ ...access.full.primaryKey,
52
+ photos.image,
53
+ ...placeholder === void 0 ? [] : [placeholder]
54
+ ].filter((name) => Object.hasOwn(access.model.fields, name));
55
+ };
56
+ /**
57
+ * The first photos of each row, at most `cardPhotoLimit`, read as the caller lists the photos' resource: its tenant,
58
+ * row filter, soft delete and readable fields apply, and its files are linked for them. One query reads them all, after
59
+ * the scan guard has checked an index serves the lookup.
60
+ */
61
+ const recordPhotos = async (deps, request, model, rows) => {
62
+ const keyField = model.primaryKey[0];
63
+ const keys = [...new Set(rows.map((row) => row[keyField]).filter((key) => key !== null && key !== void 0).map(String))];
64
+ if (!request.access || keys.length === 0) return { photos: {} };
65
+ const { photos, access } = request;
66
+ const sort = checkedSort(access.model, photos.sort);
67
+ const filter = checkFilter(access.model, where.in(photos.field, keys));
68
+ if (!filter.ok) throw new Error(`Keys of "${model.name}" do not make a filter of "${photos.resource}": ${filter.errors[0].message}`);
69
+ const scope = readScope(access);
70
+ const warning = await deps.guard(access.entry, {
71
+ filter: filter.filter,
72
+ ...sort && { sort },
73
+ limit: cardPhotoLimit
74
+ }, {
75
+ model: access.model,
76
+ scope
77
+ });
78
+ const found = await readTransaction(deps.db, deps.statementTimeoutMs, (trx) => relatedRows(trx, access.model, {
79
+ field: photos.field,
80
+ values: keys,
81
+ columns: photoColumns({
82
+ photos,
83
+ access
84
+ }),
85
+ ...sort && { sort },
86
+ limit: cardPhotoLimit
87
+ }, scope));
88
+ const shown = await presentFiles(access, found.map((row) => exposeRow(access.model, row)));
89
+ const byRecord = {};
90
+ for (const row of shown) {
91
+ if (row[photos.image] === null || row[photos.image] === void 0) continue;
92
+ const key = String(row[photos.field]);
93
+ byRecord[key] = [...byRecord[key] ?? [], row[photos.image]];
94
+ }
95
+ return {
96
+ photos: byRecord,
97
+ ...warning && { warning }
98
+ };
99
+ };
100
+ //#endregion
101
+ export { photosRequest, recordPhotos };
@@ -7,12 +7,16 @@ import { readScope, requestAccess, requireOperation } from "./request-access.js"
7
7
  import { rowPermissions } from "./row-permissions.js";
8
8
  import { readTransaction } from "./transactions.js";
9
9
  import { deletedRows, listParams, pageSizeOf, parseParams, seekParams, selectedFields } from "./params.js";
10
+ import { labelRequests, relatedLabels } from "./related-labels.js";
11
+ import { photosRequest, recordPhotos } from "./list-photos.js";
10
12
  import { estimateCount, exactCount, listQuery, seekToPosition } from "@protobase/query";
11
13
  //#region src/list-service.ts
12
14
  /**
13
15
  * AIP-132 list, the `:search` variant and (with `jump`) `:seek`: both take the same parameters, from the query string or a JSON body.
14
16
  * Everything the caller names (filter, order_by, fields, search) is checked against the model as this caller sees it,
15
17
  * so a hidden field is refused exactly like an unknown one; the access row filter is applied by the query layer.
18
+ * `labels` names the records the returned relations point at, each read under the related resource's own access, and
19
+ * `photos` brings each record's first photos, read under the photos' resource's own access.
16
20
  */
17
21
  const listRecords = async (deps, entry, session, input, maxFilterLength, jump = false) => {
18
22
  const params = jump ? parseParams(seekParams, input) : parseParams(listParams, input);
@@ -30,6 +34,8 @@ const listRecords = async (deps, entry, session, input, maxFilterLength, jump =
30
34
  const filter = checkedFilter(model, params.filter, maxFilterLength);
31
35
  const sort = checkedSort(model, params.order_by);
32
36
  const columns = selectedFields(model, params.fields);
37
+ const labels = await labelRequests(deps, session, model, columns, params.labels);
38
+ const photos = await photosRequest(deps, session, model, params.photos, params.photos_order_by);
33
39
  const limit = pageSizeOf(params.page_size);
34
40
  const scope = readScope(access, full);
35
41
  const etagField = entry.etag && !model.fields[entry.etag.field.name] ? full.fields[entry.etag.field.name] : void 0;
@@ -40,7 +46,7 @@ const listRecords = async (deps, entry, session, input, maxFilterLength, jump =
40
46
  [etagField.name]: etagField
41
47
  }
42
48
  } : model;
43
- const withKey = columns?.some((name) => model.fields[name].type === "file") ? [.../* @__PURE__ */ new Set([...columns, ...entry.model.primaryKey.filter((name) => model.fields[name])])] : columns;
49
+ const withKey = (photos !== void 0 || columns?.some((name) => model.fields[name].type === "file")) && columns ? [.../* @__PURE__ */ new Set([...columns, ...entry.model.primaryKey.filter((name) => model.fields[name])])] : columns;
44
50
  const read = entry.etag && withKey ? withKey : Object.keys(model.fields);
45
51
  const shape = {
46
52
  ...filter && { filter },
@@ -76,13 +82,16 @@ const listRecords = async (deps, entry, session, input, maxFilterLength, jump =
76
82
  etags: await Promise.all(rows.map((row, i) => etagOf(entry, page.rows[i], row))),
77
83
  permissions: deps.rowPermissions(entry.name) ? await rowPermissions(trx, access, rows) : void 0,
78
84
  estimate: await estimateCount(trx, queryModel, filter, scope),
79
- ...params.count === "exact" && { total: await exactCount(trx, queryModel, filter, scope) }
85
+ ...params.count === "exact" && { total: await exactCount(trx, queryModel, filter, scope) },
86
+ ...params.labels !== void 0 && { labels: await relatedLabels(trx, labels, rows) }
80
87
  };
81
88
  });
89
+ const shown = await presentFiles(access, result.rows);
90
+ const included = photos && await recordPhotos(deps, photos, model, result.rows);
82
91
  return {
83
- warning,
92
+ warning: warning ?? included?.warning,
84
93
  body: {
85
- items: (await presentFiles(access, result.rows)).map((row, i) => ({
94
+ items: shown.map((row, i) => ({
86
95
  ...pickFields(row, columns),
87
96
  etag: result.etags[i],
88
97
  ...result.permissions && { permissions: result.permissions[i] }
@@ -90,7 +99,9 @@ const listRecords = async (deps, entry, session, input, maxFilterLength, jump =
90
99
  next_page_token: result.page.nextCursor ?? "",
91
100
  ...position !== void 0 && { prev_page_token: result.page.prevCursor ?? "" },
92
101
  total_size_estimate: result.estimate,
93
- ...result.total !== void 0 && { total_size: result.total }
102
+ ...result.total !== void 0 && { total_size: result.total },
103
+ ...result.labels && { labels: result.labels },
104
+ ...included && { photos: included.photos }
94
105
  }
95
106
  };
96
107
  };
@@ -1,7 +1,9 @@
1
1
  import { checkedFilter } from "./filter-input.js";
2
2
  import { readScope, requestAccess, requireOperation } from "./request-access.js";
3
3
  import { readTransaction } from "./transactions.js";
4
- import { parseParams } from "./params.js";
4
+ import { parseParams, selectedFields } from "./params.js";
5
+ import { labelRequests } from "./related-labels.js";
6
+ import { photosRequest } from "./list-photos.js";
5
7
  import { listRecords } from "./list-service.js";
6
8
  import { checkMapPosition, countUpTo, mapBuckets, maxMapZoom } from "@protobase/query";
7
9
  import { z } from "zod";
@@ -11,7 +13,10 @@ const mapParams = z.object({
11
13
  zoom: z.coerce.number().int().min(0).max(maxMapZoom),
12
14
  filter: z.string().optional(),
13
15
  order_by: z.string().optional(),
14
- fields: z.union([z.string(), z.array(z.string())]).optional()
16
+ fields: z.union([z.string(), z.array(z.string())]).optional(),
17
+ labels: z.string().optional(),
18
+ photos: z.string().optional(),
19
+ photos_order_by: z.string().optional()
15
20
  });
16
21
  /**
17
22
  * The matching records for a map at `zoom`, the bounding box being part of the filter: the records themselves when
@@ -31,10 +36,9 @@ const mapRecords = async (deps, entry, session, input) => {
31
36
  const scope = readScope(access);
32
37
  const found = await readTransaction(deps.db, deps.statementTimeoutMs, (trx) => countUpTo(trx, access.model, 201, filter, scope));
33
38
  if (found <= 200 || params.zoom >= 16) {
39
+ const { latitude: _latitude, longitude: _longitude, zoom: _zoom, ...asked } = params;
34
40
  const list = {
35
- filter: params.filter,
36
- order_by: params.order_by,
37
- fields: params.fields,
41
+ ...asked,
38
42
  page_size: 200,
39
43
  ...found > 200 && { count: "exact" }
40
44
  };
@@ -44,17 +48,23 @@ const mapRecords = async (deps, entry, session, input) => {
44
48
  body: {
45
49
  items: body.items,
46
50
  buckets: [],
47
- total_size: body.total_size ?? found
51
+ total_size: body.total_size ?? found,
52
+ ...body.labels && { labels: body.labels },
53
+ ...body.photos && { photos: body.photos }
48
54
  }
49
55
  };
50
56
  }
57
+ if (params.labels !== void 0) await labelRequests(deps, session, access.model, selectedFields(access.model, params.fields), params.labels);
58
+ await photosRequest(deps, session, access.model, params.photos, params.photos_order_by);
51
59
  const result = await readTransaction(deps.db, deps.statementTimeoutMs, (trx) => mapBuckets(trx, access.model, position, params.zoom, filter, scope));
52
60
  return {
53
61
  warning: void 0,
54
62
  body: {
55
63
  items: [],
56
64
  buckets: result.buckets,
57
- total_size: result.total
65
+ total_size: result.total,
66
+ ...params.labels !== void 0 && { labels: {} },
67
+ ...params.photos !== void 0 && { photos: {} }
58
68
  }
59
69
  };
60
70
  };
package/dist/meta.js CHANGED
@@ -10,21 +10,35 @@ import { prunePage } from "@protobase/layout";
10
10
  import { pickView } from "@protobase/schema";
11
11
  import { bottomBarFor } from "@protobase/schema/internal";
12
12
  //#region src/meta.ts
13
+ const signInView = (view) => {
14
+ if (!view?.signIn) return [];
15
+ const { recent: _recent, ...nav } = view.nav ?? {};
16
+ return [{
17
+ resource: view.resource,
18
+ ...view.names && { names: view.names },
19
+ ...view.nav && { nav },
20
+ signIn: view.signIn
21
+ }];
22
+ };
13
23
  const modelsHash = (deps) => sha256Hex(stableJson(deps.registry.entries.map((entry) => entry.model))).then((hash) => hash.slice(0, 32));
14
24
  /**
15
25
  * `/meta` as one caller sees it: the resources they can do anything with, each as the restricted model (hidden fields
16
26
  * absent, `readOnly` unless writable), the view for their roles with hidden fields pruned, what they may do, and the
17
- * user menu and their bottom bar without the resources they cannot see.
27
+ * user menu and their bottom bar without the resources they cannot see. A visitor also gets the prompts of the resources
28
+ * they could use once signed in, which keep their place in the bottom bar and the user menu.
18
29
  * The ETag hashes all of it, so it differs per user and per roles.
19
30
  */
20
31
  const createMeta = (deps) => {
21
32
  const base = modelsHash(deps);
22
33
  const forCaller = async (session) => {
23
- const visible = (await Promise.all(deps.registry.entries.map((entry) => requestAccess(deps, entry, session)))).filter((access) => Object.values(access.resolved.operations).some(Boolean));
34
+ const accesses = await Promise.all(deps.registry.entries.map((entry) => requestAccess(deps, entry, session)));
35
+ const visible = accesses.filter((access) => Object.values(access.resolved.operations).some(Boolean));
24
36
  const listed = Object.fromEntries(visible.map((access) => [access.entry.name, access.model]));
37
+ const signIn = session.anonymous ? accesses.filter((access) => !visible.includes(access)).flatMap((access) => signInView(pickView(deps.views, session.user.roles, access.entry.name))) : [];
38
+ const prompted = new Set(signIn.map((entry) => entry.resource));
25
39
  const views = visible.flatMap((access) => {
26
40
  const view = pickView(deps.views, session.user.roles, access.entry.name);
27
- return view ? [pruneView(view, access.readable, listed)] : [];
41
+ return view ? [pruneView(view, access.readable, listed, prompted)] : [];
28
42
  });
29
43
  const callerAccess = {
30
44
  models: listed,
@@ -35,7 +49,7 @@ const createMeta = (deps) => {
35
49
  actions: (resource) => new Set(views.find((view) => view.resource === resource)?.actions.map((action) => action.name) ?? [])
36
50
  };
37
51
  const pages = deps.pages.flatMap((page) => prunePage(page, session.user.roles, callerAccess) ?? []);
38
- const shown = (item) => item.kind === "link" || (item.kind === "resource" ? visible.some((access) => access.entry.name === item.resource) : pages.some((page) => page.name === item.page));
52
+ const shown = (item) => item.kind === "link" || (item.kind === "resource" ? visible.some((access) => access.entry.name === item.resource) || prompted.has(item.resource) : pages.some((page) => page.name === item.page));
39
53
  const bar = bottomBarFor(deps.bottomBars, activeRole(session));
40
54
  const barItems = bar?.items.filter(shown) ?? [];
41
55
  const plugins = pluginsFor(deps.plugins, session.user.roles);
@@ -55,6 +69,7 @@ const createMeta = (deps) => {
55
69
  "delete"
56
70
  ].filter((op) => resolved.operations[op] && (resolved.recordChecks.includes(op) || resolved.rowFilter[op] !== void 0))
57
71
  }])),
72
+ ...signIn.length > 0 && { signIn },
58
73
  ...deps.userMenu && { userMenu: { items: deps.userMenu.items.filter(shown) } },
59
74
  ...bar && barItems.length > 0 && { bottomBar: {
60
75
  label: bar.label,
@@ -1,3 +1,4 @@
1
+ import { cardPhotoLimit } from "@protobase/schema/internal";
1
2
  //#region src/openapi/components.ts
2
3
  const problemContent = { "application/problem+json": { schema: { $ref: "#/components/schemas/Problem" } } };
3
4
  const problem = (description) => ({
@@ -98,6 +99,24 @@ const parameters = {
98
99
  enum: ["exact"]
99
100
  }
100
101
  },
102
+ labels: {
103
+ name: "labels",
104
+ in: "query",
105
+ description: "Comma separated `relation.field` entries, such as `place_id.name`: the answer's `labels` maps each relation to the names of the records it points at, by key, each the `field` of that record. Only for returned relations to single-key resources. A related record the caller cannot list, or whose `field` they cannot read, is left out, so it goes by its key.",
106
+ schema: { type: "string" }
107
+ },
108
+ photos: {
109
+ name: "photos",
110
+ in: "query",
111
+ description: `A \`resource.field.image\` entry, such as \`listing_photos.listing_id.photo\`: the answer's \`photos\` maps each returned record's key to the \`image\` files of the first ${cardPhotoLimit} records of \`resource\` whose relation \`field\` points at it, read in one query as the caller lists \`resource\`. A record without any is left out, and so is every record when the caller cannot list \`resource\` or read and use \`field\`, \`image\` and the sorted fields. Only for resources with a single-field key.`,
112
+ schema: { type: "string" }
113
+ },
114
+ photosOrderBy: {
115
+ name: "photos_order_by",
116
+ in: "query",
117
+ description: "The order of each record's `photos`, as `order_by` on their resource: `position`. Their primary key breaks ties.",
118
+ schema: { type: "string" }
119
+ },
101
120
  key: {
102
121
  name: "key",
103
122
  in: "path",
@@ -150,6 +169,19 @@ const searchBody = {
150
169
  type: "string",
151
170
  enum: ["exact"]
152
171
  },
172
+ labels: {
173
+ type: "array",
174
+ items: { type: "string" },
175
+ description: parameters.labels.description
176
+ },
177
+ photos: {
178
+ type: "string",
179
+ description: parameters.photos.description
180
+ },
181
+ photos_order_by: {
182
+ type: "string",
183
+ description: parameters.photosOrderBy.description
184
+ },
153
185
  show_deleted: {
154
186
  type: "boolean",
155
187
  description: "Soft-delete resources only"
@@ -12,6 +12,9 @@ const listParameters = (entry) => [
12
12
  "pageSize",
13
13
  "pageToken",
14
14
  "fields",
15
+ "labels",
16
+ "photos",
17
+ "photosOrderBy",
15
18
  "count",
16
19
  ...entry.model.softDelete ? ["showDeleted"] : []
17
20
  ].map(param);
@@ -34,6 +37,22 @@ const record = (name, entry) => ({
34
37
  headers: { ETag: { schema: { type: "string" } } },
35
38
  content: json(ref(name))
36
39
  });
40
+ const labels = {
41
+ type: "object",
42
+ description: "Only with `labels`: per relation, the names of the records it points at, by key",
43
+ additionalProperties: {
44
+ type: "object",
45
+ additionalProperties: { type: "string" }
46
+ }
47
+ };
48
+ const photos = {
49
+ type: "object",
50
+ description: "Only with `photos`: per record, by key, its first photos in their order, each as a file field returns it",
51
+ additionalProperties: {
52
+ type: "array",
53
+ items: { type: "object" }
54
+ }
55
+ };
37
56
  const page = (name, seek = false) => json({
38
57
  type: "object",
39
58
  required: [
@@ -61,7 +80,9 @@ const page = (name, seek = false) => json({
61
80
  total_size: {
62
81
  type: "integer",
63
82
  description: "Only with count=exact"
64
- }
83
+ },
84
+ labels,
85
+ photos
65
86
  }
66
87
  });
67
88
  const bounds = {
@@ -100,7 +121,9 @@ const mapResult = (name) => json({
100
121
  total_size: {
101
122
  type: "integer",
102
123
  description: "How many records match"
103
- }
124
+ },
125
+ labels,
126
+ photos
104
127
  }
105
128
  });
106
129
  const needs = {
@@ -309,7 +332,8 @@ const resourcePaths = (access) => {
309
332
  parameters: [
310
333
  field("A filterable field"),
311
334
  param("filter"),
312
- query("limit", "Most values to return", { type: "integer" })
335
+ query("limit", "Most values to return", { type: "integer" }),
336
+ query("label", "For a relation to a single-key resource: the field of that resource whose value each facet carries as `label`, as a list's `labels` names records")
313
337
  ],
314
338
  responses: {
315
339
  200: {
@@ -324,7 +348,11 @@ const resourcePaths = (access) => {
324
348
  type: "object",
325
349
  properties: {
326
350
  value: {},
327
- count: { type: "integer" }
351
+ count: { type: "integer" },
352
+ label: {
353
+ type: "string",
354
+ description: "Only with `label`, for a record the caller may name"
355
+ }
328
356
  }
329
357
  }
330
358
  }
@@ -442,7 +470,10 @@ const resourcePaths = (access) => {
442
470
  }, true),
443
471
  param("filter"),
444
472
  param("orderBy"),
445
- param("fields")
473
+ param("fields"),
474
+ param("labels"),
475
+ param("photos"),
476
+ param("photosOrderBy")
446
477
  ],
447
478
  responses: {
448
479
  200: {
@@ -467,6 +498,9 @@ const resourcePaths = (access) => {
467
498
  param("filter"),
468
499
  param("pageSize"),
469
500
  param("fields"),
501
+ param("labels"),
502
+ param("photos"),
503
+ param("photosOrderBy"),
470
504
  param("count")
471
505
  ],
472
506
  responses: {
package/dist/params.js CHANGED
@@ -18,6 +18,9 @@ const listParams = z.object({
18
18
  page_token: z.string().optional(),
19
19
  fields: z.union([z.string(), z.array(z.string())]).optional(),
20
20
  count: z.literal("exact").optional(),
21
+ labels: z.union([z.string(), z.array(z.string())]).optional(),
22
+ photos: z.string().optional(),
23
+ photos_order_by: z.string().optional(),
21
24
  show_deleted: z.union([z.boolean(), z.enum(["true", "false"]).transform((value) => value === "true")]).optional()
22
25
  });
23
26
  /** The list parameters plus the row position to jump to (`:seek`). */
@@ -0,0 +1,77 @@
1
+ import { badRequest } from "./problem.js";
2
+ import { exposeRow } from "./rows.js";
3
+ import { readScope, requestAccess } from "./request-access.js";
4
+ import { checkFilter, createWhere } from "@protobase/schema";
5
+ import { listQuery } from "@protobase/query";
6
+ //#region src/related-labels.ts
7
+ const where = createWhere();
8
+ const invalid = (entry, message) => badRequest("invalid-parameter", `Invalid labels entry "${entry}": ${message}`, { errors: [{
9
+ parameter: "labels",
10
+ message
11
+ }] });
12
+ const unnameable = /* @__PURE__ */ new Set(["file", "json"]);
13
+ /**
14
+ * Checks `labels` against the listed resource as the caller sees it: each entry names a returned relation to a
15
+ * single-key resource, once. What the caller may read of the related resource is resolved here too, so a related
16
+ * resource or field they cannot read answers no names rather than an error that would tell it exists.
17
+ */
18
+ const labelRequests = async (deps, session, model, returned, labels) => {
19
+ if (labels === void 0) return [];
20
+ const entries = (Array.isArray(labels) ? labels : labels.split(",")).map((entry) => entry.trim()).filter(Boolean);
21
+ const seen = /* @__PURE__ */ new Set();
22
+ return Promise.all(entries.map(async (entry) => {
23
+ const [relation, field, ...rest] = entry.split(".");
24
+ if (!relation || !field || rest.length > 0) throw invalid(entry, "Name a relation and the field that names its records, as relation.field");
25
+ const owner = Object.hasOwn(model.fields, relation) ? model.fields[relation] : void 0;
26
+ if (!owner) throw invalid(entry, `Unknown field "${relation}"`);
27
+ const target = owner.type === "relation" && owner.relation ? deps.registry.find(owner.relation.resource) : void 0;
28
+ if (!target || target.model.primaryKey.length !== 1) throw invalid(entry, `"${relation}" is not a relation to a resource with a single-field key`);
29
+ if (returned && !returned.includes(relation)) throw invalid(entry, `"${relation}" is not among the returned fields`);
30
+ if (seen.has(relation)) throw invalid(entry, `"${relation}" is named twice`);
31
+ seen.add(relation);
32
+ const access = await requestAccess(deps, target, session);
33
+ const named = Object.hasOwn(access.model.fields, field) ? access.model.fields[field] : void 0;
34
+ if (!access.resolved.operations.list || !named) return {
35
+ relation,
36
+ field
37
+ };
38
+ if (unnameable.has(named.type)) throw invalid(entry, `"${field}" is a ${named.type} field, which cannot name a record`);
39
+ return {
40
+ relation,
41
+ field,
42
+ access
43
+ };
44
+ }));
45
+ };
46
+ const namesOf = async (trx, { field, access }, keys) => {
47
+ const key = access.full.primaryKey[0];
48
+ const model = {
49
+ ...access.model,
50
+ fields: {
51
+ ...access.model.fields,
52
+ [key]: {
53
+ ...access.model.fields[key],
54
+ filterable: true
55
+ }
56
+ }
57
+ };
58
+ const filter = checkFilter(model, where.in(key, keys));
59
+ if (!filter.ok) throw new Error(`Keys of "${access.entry.name}" do not make a filter: ${filter.errors[0].message}`);
60
+ const named = (await listQuery(trx, model, {
61
+ filter: filter.filter,
62
+ columns: [key, field],
63
+ limit: keys.length
64
+ }, readScope(access))).rows.map((row) => exposeRow(model, row)).filter((row) => row[field] !== null && row[field] !== void 0);
65
+ return Object.fromEntries(named.map((row) => [String(row[key]), String(row[field])]));
66
+ };
67
+ /** The names of the records the rows' relations point at, one query per requested relation. */
68
+ const relatedLabels = async (trx, requests, rows) => {
69
+ const labels = {};
70
+ for (const request of requests) {
71
+ const keys = [...new Set(rows.flatMap((row) => row[request.relation] === null || row[request.relation] === void 0 ? [] : [String(row[request.relation])]))];
72
+ labels[request.relation] = request.access && keys.length > 0 ? await namesOf(trx, request, keys) : {};
73
+ }
74
+ return labels;
75
+ };
76
+ //#endregion
77
+ export { labelRequests, relatedLabels };
@@ -1,5 +1,6 @@
1
1
  import { relatedErrors } from "./view-related.js";
2
2
  import { cardPhotosErrors } from "./view-card-photos.js";
3
+ import { cardFavoriteErrors } from "./view-card-favorite.js";
3
4
  import { resolveFieldPath } from "@protobase/layout";
4
5
  import { addressErrors, hrefPlaceholders, navRecentErrors, pruneByShape, shapes, tableDisplay } from "@protobase/schema/internal";
5
6
  //#region src/view-access.ts
@@ -21,7 +22,7 @@ const keepDefault = ({ default: opens, displays, ...list }) => {
21
22
  ...opens !== void 0 && kept && { default: opens }
22
23
  };
23
24
  };
24
- const listShape = (resource, models) => shapes.object({
25
+ const listShape = (resource, models, prompted) => shapes.object({
25
26
  columns: shapes.list(shapes.field),
26
27
  sort: shapes.list(shapes.tuple(shapes.field, shapes.data)),
27
28
  search: shapes.list(shapes.field),
@@ -40,7 +41,8 @@ const listShape = (resource, models) => shapes.object({
40
41
  footer: shapes.list(shapes.field),
41
42
  flags: shapes.list(shapes.field),
42
43
  card: shapes.data,
43
- photos: shapes.map((photos) => cardPhotosErrors(photos, resource, models).length === 0 ? photos : void 0)
44
+ photos: shapes.map((photos) => cardPhotosErrors(photos, resource, models).length === 0 ? photos : void 0),
45
+ favorite: shapes.map((favorite) => prompted.has(favorite.resource) || cardFavoriteErrors(favorite, resource, models).length === 0 ? favorite : void 0)
44
46
  }),
45
47
  map: shapes.object({
46
48
  kind: shapes.data,
@@ -138,7 +140,7 @@ const actionShape = (model, models) => shapes.object({
138
140
  link: linkShape(model, models)
139
141
  })
140
142
  }, { requires: ["run"] });
141
- const viewShape = (resource, model, models) => shapes.object({
143
+ const viewShape = (resource, model, models, prompted) => shapes.object({
142
144
  resource: shapes.data,
143
145
  roles: shapes.data,
144
146
  title: shapes.field,
@@ -156,7 +158,7 @@ const viewShape = (resource, model, models) => shapes.object({
156
158
  icon: shapes.data,
157
159
  help: shapes.data,
158
160
  fields: shapes.keys(),
159
- list: listShape(resource, models),
161
+ list: listShape(resource, models, prompted),
160
162
  filters: shapes.list(widgetShape),
161
163
  layout: shapes.list(layoutShape(resource, models)),
162
164
  chart: shapes.object({
@@ -165,16 +167,19 @@ const viewShape = (resource, model, models) => shapes.object({
165
167
  granularity: shapes.data
166
168
  }, { requires: ["field"] }),
167
169
  saveFeedback: shapes.data,
168
- actions: shapes.list(actionShape(model, models))
170
+ actions: shapes.list(actionShape(model, models)),
171
+ listOf: shapes.field,
172
+ signIn: shapes.data
169
173
  });
170
174
  /**
171
175
  * The view with every reference to a field the caller cannot read removed: list columns, sort, search and displays,
172
176
  * field hints, filter widgets, record sections, maps and custom components' props, the chart, title and search result, the
173
177
  * actions that set or read one, and the sidebar's `recent` group. Nothing in it may name a hidden field. Related sections
174
- * and links are checked against `models`, the caller's view of every resource.
178
+ * and links are checked against `models`, the caller's view of every resource, and so are cards' favorite buttons,
179
+ * which stay for a visitor when their resource is one of `prompted`, those that ask the visitor to sign in.
175
180
  */
176
- const pruneView = (view, model, models) => {
177
- const pruned = pruneByShape(view, viewShape(view.resource, model, models), (name) => Object.hasOwn(model.fields, name));
181
+ const pruneView = (view, model, models, prompted = /* @__PURE__ */ new Set()) => {
182
+ const pruned = pruneByShape(view, viewShape(view.resource, model, models, prompted), (name) => Object.hasOwn(model.fields, name));
178
183
  if (!pruned) throw new Error(`The view of ${view.resource} is not an object`);
179
184
  return pruned;
180
185
  };
@@ -0,0 +1,23 @@
1
+ import { matchErrors } from "./view-related.js";
2
+ //#region src/view-card-favorite.ts
3
+ /**
4
+ * What is wrong with the favorite button of a cards display on `resource`, against these models; empty when the cards
5
+ * can save to it. Against the full models it checks the config; against a caller's models, whether that caller can
6
+ * list the saved records and write the field that points at the card.
7
+ */
8
+ const cardFavoriteErrors = (favorite, resource, models) => {
9
+ const owner = models[resource];
10
+ const target = models[favorite.resource];
11
+ if (!owner || !target) return [`unknown resource "${owner ? favorite.resource : resource}"`];
12
+ if (owner.primaryKey.length !== 1) return [`${resource} has a composite key, so its cards cannot be saved`];
13
+ const match = matchErrors(target, favorite.field, resource);
14
+ if (match.length > 0) return match;
15
+ return target.fields[favorite.field].readOnly ? [`"${favorite.field}" on ${target.name} is read-only, so a press cannot save the card`] : [];
16
+ };
17
+ /** What is wrong with the favorite button's resource itself: it has an owner, so each user lists and saves their own. */
18
+ const favoriteOwnerErrors = (favorite, models) => {
19
+ const target = models[favorite.resource];
20
+ return target && target.owner === void 0 ? [`${target.name} has no owner, so its records are not each user's own`] : [];
21
+ };
22
+ //#endregion
23
+ export { cardFavoriteErrors, favoriteOwnerErrors };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@protobase/server",
3
- "version": "0.14.1",
3
+ "version": "0.14.3",
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": {
@@ -30,9 +30,9 @@
30
30
  },
31
31
  "dependencies": {
32
32
  "@better-auth/passkey": "^1.7.7",
33
- "@protobase/layout": "0.14.1",
34
- "@protobase/query": "0.14.1",
35
- "@protobase/schema": "0.14.1",
33
+ "@protobase/layout": "0.14.3",
34
+ "@protobase/query": "0.14.3",
35
+ "@protobase/schema": "0.14.3",
36
36
  "@scalar/hono-api-reference": "^0.12.10",
37
37
  "@sentry/core": "^11.7.0",
38
38
  "@simplewebauthn/server": "^13.3.1",