@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.
- package/dist/aggregate-service.js +27 -11
- package/dist/check-views.js +21 -2
- package/dist/list-photos.js +101 -0
- package/dist/list-service.js +16 -5
- package/dist/map-service.js +17 -7
- package/dist/meta.js +19 -4
- package/dist/openapi/components.js +32 -0
- package/dist/openapi/resource-paths.js +39 -5
- package/dist/params.js +3 -0
- package/dist/related-labels.js +77 -0
- package/dist/view-access.js +13 -8
- package/dist/view-card-favorite.js +23 -0
- package/package.json +4 -4
|
@@ -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
|
-
/**
|
|
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
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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];
|
package/dist/check-views.js
CHANGED
|
@@ -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
|
|
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 };
|
package/dist/list-service.js
CHANGED
|
@@ -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:
|
|
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
|
};
|
package/dist/map-service.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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 };
|
package/dist/view-access.js
CHANGED
|
@@ -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.
|
|
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.
|
|
34
|
-
"@protobase/query": "0.14.
|
|
35
|
-
"@protobase/schema": "0.14.
|
|
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",
|