@protobase/server 0.28.0 → 0.28.2
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-groups.js +17 -9
- package/dist/aggregate-service.js +14 -6
- package/dist/check-parents.js +16 -0
- package/dist/check-views.js +3 -2
- package/dist/comments/comment-access.js +1 -1
- package/dist/create-admin.js +2 -0
- package/dist/error-handler.js +1 -0
- package/dist/fetch-readable.js +33 -0
- package/dist/history/read-history.js +1 -1
- package/dist/meta.js +2 -2
- package/dist/openapi/resource-paths.js +15 -2
- package/dist/parent-access.js +84 -0
- package/dist/read-service.js +3 -29
- package/dist/records.js +2 -1
- package/dist/reveal-service.js +1 -1
- package/dist/row-permissions.js +19 -2
- package/dist/view-access.js +13 -1
- package/dist/view-record-chart.js +42 -0
- package/dist/write-pipeline.js +5 -1
- package/dist/write-shared.js +13 -1
- package/dist/write-update.js +11 -3
- package/package.json +4 -4
package/dist/aggregate-groups.js
CHANGED
|
@@ -13,9 +13,12 @@ const aggregateParams = z.object({
|
|
|
13
13
|
filter: z.string().optional(),
|
|
14
14
|
sum: name.optional(),
|
|
15
15
|
average: name.optional(),
|
|
16
|
+
maximum: name.optional(),
|
|
16
17
|
limit: z.coerce.number().int().optional(),
|
|
17
18
|
label: name.optional(),
|
|
18
19
|
range: z.enum([
|
|
20
|
+
"1h",
|
|
21
|
+
"24h",
|
|
19
22
|
"7d",
|
|
20
23
|
"30d",
|
|
21
24
|
"90d",
|
|
@@ -25,6 +28,9 @@ const aggregateParams = z.object({
|
|
|
25
28
|
from: z.string().optional(),
|
|
26
29
|
to: z.string().optional(),
|
|
27
30
|
granularity: z.enum([
|
|
31
|
+
"minute",
|
|
32
|
+
"5_minutes",
|
|
33
|
+
"15_minutes",
|
|
28
34
|
"hour",
|
|
29
35
|
"day",
|
|
30
36
|
"week",
|
|
@@ -41,14 +47,16 @@ const invalid = (parameter, message) => badRequest("invalid-parameter", `Invalid
|
|
|
41
47
|
message
|
|
42
48
|
}] });
|
|
43
49
|
const measureOf = (params) => {
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
if (
|
|
50
|
-
|
|
51
|
-
|
|
50
|
+
const given = [
|
|
51
|
+
"sum",
|
|
52
|
+
"average",
|
|
53
|
+
"maximum"
|
|
54
|
+
].filter((kind) => params[kind] !== void 0);
|
|
55
|
+
if (given.length > 1) throw invalid(given[1], "Give one of sum, average and maximum");
|
|
56
|
+
const [kind] = given;
|
|
57
|
+
return kind === void 0 ? void 0 : {
|
|
58
|
+
kind,
|
|
59
|
+
field: params[kind]
|
|
52
60
|
};
|
|
53
61
|
};
|
|
54
62
|
const optionErrors = (params, dated) => {
|
|
@@ -75,7 +83,7 @@ const isDated = (model, field) => {
|
|
|
75
83
|
};
|
|
76
84
|
/**
|
|
77
85
|
* Records per value of a field, or per hour, day, week, month or year of a date field with the empty ones included,
|
|
78
|
-
* each with how many there are and, with `sum` or `
|
|
86
|
+
* each with how many there are and, with `sum`, `average` or `maximum`, that of a numeric field. Read as a list is: the caller's
|
|
79
87
|
* filter on the model as they see it, inside their tenant and row filter; the scan guard judges the grouped field.
|
|
80
88
|
*/
|
|
81
89
|
const aggregate = async (deps, entry, session, input) => {
|
|
@@ -22,6 +22,8 @@ const seriesParams = z.object({
|
|
|
22
22
|
field,
|
|
23
23
|
filter,
|
|
24
24
|
range: z.enum([
|
|
25
|
+
"1h",
|
|
26
|
+
"24h",
|
|
25
27
|
"7d",
|
|
26
28
|
"30d",
|
|
27
29
|
"90d",
|
|
@@ -31,6 +33,9 @@ const seriesParams = z.object({
|
|
|
31
33
|
from: z.string().optional(),
|
|
32
34
|
to: z.string().optional(),
|
|
33
35
|
granularity: z.enum([
|
|
36
|
+
"minute",
|
|
37
|
+
"5_minutes",
|
|
38
|
+
"15_minutes",
|
|
34
39
|
"hour",
|
|
35
40
|
"day",
|
|
36
41
|
"week",
|
|
@@ -43,11 +48,14 @@ const histogramParams = z.object({
|
|
|
43
48
|
filter,
|
|
44
49
|
buckets: z.coerce.number().int().default(20)
|
|
45
50
|
});
|
|
46
|
-
const
|
|
47
|
-
|
|
48
|
-
"
|
|
49
|
-
"
|
|
50
|
-
"
|
|
51
|
+
const hourMs = 36e5;
|
|
52
|
+
const rangeMs = {
|
|
53
|
+
"1h": hourMs,
|
|
54
|
+
"24h": 24 * hourMs,
|
|
55
|
+
"7d": 168 * hourMs,
|
|
56
|
+
"30d": 720 * hourMs,
|
|
57
|
+
"90d": 2160 * hourMs,
|
|
58
|
+
"1y": 8760 * hourMs
|
|
51
59
|
};
|
|
52
60
|
const scoped = async (deps, entry, session, source) => {
|
|
53
61
|
const access = await requestAccess(deps, entry, session);
|
|
@@ -119,7 +127,7 @@ const seriesRange = async (deps, access, field, params, timeZone, scope) => {
|
|
|
119
127
|
};
|
|
120
128
|
const range = params.range ?? "30d";
|
|
121
129
|
if (range !== "all") return {
|
|
122
|
-
from: rangeStart(type, now -
|
|
130
|
+
from: rangeStart(type, now - rangeMs[range], timeZone),
|
|
123
131
|
to: bound(model, field, to)
|
|
124
132
|
};
|
|
125
133
|
const lowest = await earliest(deps, access, field, scope);
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
//#region src/check-parents.ts
|
|
2
|
+
/**
|
|
3
|
+
* Stops startup on a `.parent()` whose relation cannot find the parent record: a resource the app does not have, or one
|
|
4
|
+
* whose key is not exactly one field, the relation's one column.
|
|
5
|
+
*/
|
|
6
|
+
const checkParents = (registry) => {
|
|
7
|
+
const problems = registry.entries.flatMap(({ name, model }) => (model.parents ?? []).flatMap(({ field }) => {
|
|
8
|
+
const resource = model.fields[field].relation.resource;
|
|
9
|
+
const parent = registry.find(resource)?.model;
|
|
10
|
+
if (!parent) return [`${name} parent "${field}": unknown resource "${resource}"`];
|
|
11
|
+
return parent.primaryKey.length === 1 ? [] : [`${name} parent "${field}": "${resource}" has a key of ${parent.primaryKey.length} fields, not one`];
|
|
12
|
+
}));
|
|
13
|
+
if (problems.length > 0) throw new Error(`The parents cannot find the records they point at:\n${problems.map((problem) => ` - ${problem}`).join("\n")}`);
|
|
14
|
+
};
|
|
15
|
+
//#endregion
|
|
16
|
+
export { checkParents };
|
package/dist/check-views.js
CHANGED
|
@@ -4,6 +4,7 @@ import { cardFavoriteErrors, favoriteOwnerErrors } from "./view-card-favorite.js
|
|
|
4
4
|
import { saveSearchErrors, searchesOfErrors } from "./view-saved-search.js";
|
|
5
5
|
import { chartErrors } from "./view-chart.js";
|
|
6
6
|
import { mapDisplayErrors, mapErrors } from "./view-map.js";
|
|
7
|
+
import { recordChartErrors } from "./view-record-chart.js";
|
|
7
8
|
import { relatedColumnErrors } from "./view-related-column.js";
|
|
8
9
|
import { editableErrors } from "./view-editable.js";
|
|
9
10
|
import { actionWhenErrors, actionWhenFields } from "./view-action-when.js";
|
|
@@ -147,7 +148,7 @@ const listOfErrors = (model, name, models) => {
|
|
|
147
148
|
* edits a list column in its cell that is not a field with an editor of one line the API may write,
|
|
148
149
|
* 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
|
|
149
150
|
* `searchesOf` that do not fit the resources, cards
|
|
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
|
|
151
|
+
* displays of one list that name different photos, a chart per value that cannot group or add up its fields, a chart of related records that does not fit them, a map that cannot show the
|
|
151
152
|
* 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`.
|
|
152
153
|
*/
|
|
153
154
|
const checkViews = (registry, views, pages = [], fieldTypes = {}) => {
|
|
@@ -167,7 +168,7 @@ const checkViews = (registry, views, pages = [], fieldTypes = {}) => {
|
|
|
167
168
|
const maps = [...layoutItems(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}`) : [])];
|
|
168
169
|
const boards = (view.list?.displays ?? []).flatMap((display) => display.kind === "board" ? boardErrors(display, model).map((error) => `display "${display.name}": ${error}`) : []);
|
|
169
170
|
const calendars = (view.list?.displays ?? []).flatMap((display) => display.kind === "calendar" || display.kind === "timeline" ? datesErrors(display, model).map((error) => `display "${display.name}": ${error}`) : []);
|
|
170
|
-
const chart = view.chart ? chartErrors(view.chart, model).map((error) => `chart: ${error}`) : [];
|
|
171
|
+
const chart = [...view.chart ? chartErrors(view.chart, model).map((error) => `chart: ${error}`) : [], ...layoutItems(view.layout).flatMap((item) => item.kind === "chart" ? recordChartErrors(item, view.resource, models).map((error) => `chart "${item.title}": ${error}`) : [])];
|
|
171
172
|
const history = model.history ? [] : layoutItems(view.layout).flatMap((item) => item.kind === "history" ? [`history "${item.title}" needs .history() on resource "${view.resource}"`] : []);
|
|
172
173
|
const comments = model.comments ? [] : layoutItems(view.layout).flatMap((item) => item.kind === "comments" ? [`comments "${item.title}" needs .comments() on resource "${view.resource}"`] : []);
|
|
173
174
|
return [
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { forbidden, notFound } from "../problem.js";
|
|
2
2
|
import { requestAccess, requireOperation } from "../request-access.js";
|
|
3
|
-
import { fetchReadable } from "../
|
|
3
|
+
import { fetchReadable } from "../fetch-readable.js";
|
|
4
4
|
//#region src/comments/comment-access.ts
|
|
5
5
|
/** The caller's access to a resource with comments, who must be allowed to read it; a resource without is a 404. */
|
|
6
6
|
const commentAccess = async (deps, entry, session) => {
|
package/dist/create-admin.js
CHANGED
|
@@ -41,6 +41,7 @@ import { visitorsBrowse, withAnonymous } from "./anonymous.js";
|
|
|
41
41
|
import { checkBottomBars, checkShell } from "./check-shell.js";
|
|
42
42
|
import { checkOwnerFields } from "./check-owners.js";
|
|
43
43
|
import { checkRoleLimits } from "./check-roles.js";
|
|
44
|
+
import { checkParents } from "./check-parents.js";
|
|
44
45
|
import { checkPositionAfter } from "./check-position-after.js";
|
|
45
46
|
import { checkViews } from "./check-views.js";
|
|
46
47
|
import { liveRoutes } from "./live/live-route.js";
|
|
@@ -111,6 +112,7 @@ const createAdmin = ({ resources: appResources, views: appViews = [], pages = []
|
|
|
111
112
|
checkRoleLimits(registry, options.roles?.limits ?? []);
|
|
112
113
|
checkOwnerFields(registry, auth);
|
|
113
114
|
checkPositionAfter(registry);
|
|
115
|
+
checkParents(registry);
|
|
114
116
|
checkPages(pageModels, {
|
|
115
117
|
models: Object.fromEntries(registry.entries.map((entry) => [entry.name, entry.model])),
|
|
116
118
|
actions: (resource) => new Set(viewModels.filter((view) => view.resource === resource).flatMap((view) => view.actions.map((action) => action.name))),
|
package/dist/error-handler.js
CHANGED
|
@@ -28,6 +28,7 @@ const databaseProblem = (error) => {
|
|
|
28
28
|
if (error.code === "57014") return new HttpProblem(504, "statement-timeout", "Gateway Timeout", "The query exceeded the statement timeout");
|
|
29
29
|
if (error.code === "23505") return new HttpProblem(409, "conflict", "Conflict", "A record with the same unique value already exists");
|
|
30
30
|
if (error.code === "23503") return new HttpProblem(409, "conflict", "Conflict", "The record points at, or is pointed at by, another record");
|
|
31
|
+
if (error.code === "40P01") return new HttpProblem(409, "conflict", "Conflict", "The record was written at the same time as another; send it again");
|
|
31
32
|
if (error.code === "23502") return new HttpProblem(400, "invalid-record", "Bad Request", "A required column has no value");
|
|
32
33
|
if (error.code === "23514") return new HttpProblem(400, "invalid-record", "Bad Request", "A check constraint rejected the record");
|
|
33
34
|
if (error.code.startsWith("22")) return new HttpProblem(400, "invalid-value", "Bad Request", "A value has the wrong format for its column");
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { tenantScope } from "./tenant.js";
|
|
2
|
+
import { recordDecision, targetFilter } from "./request-access.js";
|
|
3
|
+
import { fetchRecord } from "./records.js";
|
|
4
|
+
//#region src/fetch-readable.ts
|
|
5
|
+
/**
|
|
6
|
+
* The row with this key if the caller may read it: inside their tenant and row filter, and passing a record-level read
|
|
7
|
+
* rule. Such a rule may look at fields the caller cannot see, so then the whole row is read; the caller cuts it down.
|
|
8
|
+
* `lock` locks the row the first read finds.
|
|
9
|
+
*/
|
|
10
|
+
const fetchReadable = async (trx, access, key, deleted, { fields, lock, ...shape }) => {
|
|
11
|
+
const { entry } = access;
|
|
12
|
+
const tenant = tenantScope(entry, access.session)?.tenantValue;
|
|
13
|
+
const recordRule = access.resolved.recordChecks.includes("read");
|
|
14
|
+
const filter = targetFilter(access, "read");
|
|
15
|
+
const current = await fetchRecord(trx, entry, key, tenant, {
|
|
16
|
+
deleted,
|
|
17
|
+
...filter && { filter },
|
|
18
|
+
...lock && { lock },
|
|
19
|
+
...shape,
|
|
20
|
+
...!recordRule && fields && { fields }
|
|
21
|
+
});
|
|
22
|
+
if (!current || !recordRule) return current;
|
|
23
|
+
const decision = await recordDecision(access, "read", current.record);
|
|
24
|
+
if (decision === false) return void 0;
|
|
25
|
+
if (decision !== true && !await fetchRecord(trx, entry, key, tenant, {
|
|
26
|
+
deleted,
|
|
27
|
+
filter: decision,
|
|
28
|
+
fields: [access.full.fields[access.full.primaryKey[0]]]
|
|
29
|
+
})) return void 0;
|
|
30
|
+
return current;
|
|
31
|
+
};
|
|
32
|
+
//#endregion
|
|
33
|
+
export { fetchReadable };
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { notFound } from "../problem.js";
|
|
2
2
|
import { tenantScope } from "../tenant.js";
|
|
3
3
|
import { requestAccess, requireOperation } from "../request-access.js";
|
|
4
|
+
import { fetchReadable } from "../fetch-readable.js";
|
|
4
5
|
import { readTransaction } from "../transactions.js";
|
|
5
|
-
import { fetchReadable } from "../read-service.js";
|
|
6
6
|
import { quoteIdent } from "../changes/capture-sql.js";
|
|
7
7
|
import { parseParams } from "../params.js";
|
|
8
8
|
import { encodeKey } from "@protobase/schema/internal";
|
package/dist/meta.js
CHANGED
|
@@ -66,7 +66,7 @@ const createMeta = (deps, appName, appIcon) => {
|
|
|
66
66
|
resources: visible.map((access) => access.readable),
|
|
67
67
|
views,
|
|
68
68
|
pages,
|
|
69
|
-
permissions: Object.fromEntries(visible.map(({ entry, resolved }) => [entry.name, {
|
|
69
|
+
permissions: Object.fromEntries(visible.map(({ entry, resolved, full }) => [entry.name, {
|
|
70
70
|
read: resolved.operations.read || resolved.operations.list,
|
|
71
71
|
create: resolved.operations.create,
|
|
72
72
|
update: resolved.operations.update,
|
|
@@ -75,7 +75,7 @@ const createMeta = (deps, appName, appIcon) => {
|
|
|
75
75
|
"create",
|
|
76
76
|
"update",
|
|
77
77
|
"delete"
|
|
78
|
-
].filter((op) => resolved.operations[op] && (resolved.recordChecks.includes(op) || resolved.rowFilter[op] !== void 0))
|
|
78
|
+
].filter((op) => resolved.operations[op] && (resolved.recordChecks.includes(op) || resolved.rowFilter[op] !== void 0 || (full.parents?.length ?? 0) > 0))
|
|
79
79
|
}])),
|
|
80
80
|
...signIn.length > 0 && { signIn },
|
|
81
81
|
...deps.userMenu && { userMenu: { items: deps.userMenu.items.filter(shown) } },
|
|
@@ -590,17 +590,21 @@ const resourcePaths = (access) => {
|
|
|
590
590
|
operationId: `aggregate_${entry.name}`,
|
|
591
591
|
tags: tag,
|
|
592
592
|
summary: `Totals per group of ${entry.name} records`,
|
|
593
|
-
description: "Groups the records the filter finds, its own terms on `group_by` included, by the value of a field, or by the hour, day, week, month or year of a date or timestamp field with the empty ones included. Each group has its count and, with `sum` or `
|
|
593
|
+
description: "Groups the records the filter finds, its own terms on `group_by` included, by the value of a field, or by the minute, 5 or 15 minutes, hour, day, week, month or year of a date or timestamp field with the empty ones included. Each group has its count and, with `sum`, `average` or `maximum`, that of a numeric field, `null` when no record of the group has a value. Value groups come largest first. An index must serve the grouped field. A date group can be split: see `split_by`.",
|
|
594
594
|
parameters: [
|
|
595
595
|
query("group_by", "A filterable field", { type: "string" }, true),
|
|
596
596
|
param("filter"),
|
|
597
597
|
query("sum", "A numeric field to add up per group"),
|
|
598
598
|
query("average", "A numeric field to average per group, instead of `sum`"),
|
|
599
|
+
query("maximum", "A numeric field whose largest value per group it returns, instead of `sum`"),
|
|
599
600
|
query("limit", "Most value groups to return, default 100, at most 1000", { type: "integer" }),
|
|
600
601
|
query("label", "For a relation to a single-key resource: the field of that resource whose value each group carries as `label`"),
|
|
601
602
|
query("granularity", "For a date group_by: the bucket, default day", {
|
|
602
603
|
type: "string",
|
|
603
604
|
enum: [
|
|
605
|
+
"minute",
|
|
606
|
+
"5_minutes",
|
|
607
|
+
"15_minutes",
|
|
604
608
|
"hour",
|
|
605
609
|
"day",
|
|
606
610
|
"week",
|
|
@@ -611,6 +615,8 @@ const resourcePaths = (access) => {
|
|
|
611
615
|
query("range", "For a date group_by: the relative range ending now (default 30d); `all` starts at the earliest row", {
|
|
612
616
|
type: "string",
|
|
613
617
|
enum: [
|
|
618
|
+
"1h",
|
|
619
|
+
"24h",
|
|
614
620
|
"7d",
|
|
615
621
|
"30d",
|
|
616
622
|
"90d",
|
|
@@ -659,6 +665,7 @@ const resourcePaths = (access) => {
|
|
|
659
665
|
count: { type: "integer" },
|
|
660
666
|
sum: { type: ["number", "null"] },
|
|
661
667
|
average: { type: ["number", "null"] },
|
|
668
|
+
maximum: { type: ["number", "null"] },
|
|
662
669
|
splits: {
|
|
663
670
|
type: "array",
|
|
664
671
|
description: "Only with `split_by`: the kept values that have rows in the group, in rank order",
|
|
@@ -668,7 +675,8 @@ const resourcePaths = (access) => {
|
|
|
668
675
|
value: {},
|
|
669
676
|
count: { type: "integer" },
|
|
670
677
|
sum: { type: ["number", "null"] },
|
|
671
|
-
average: { type: ["number", "null"] }
|
|
678
|
+
average: { type: ["number", "null"] },
|
|
679
|
+
maximum: { type: ["number", "null"] }
|
|
672
680
|
}
|
|
673
681
|
}
|
|
674
682
|
}
|
|
@@ -692,6 +700,8 @@ const resourcePaths = (access) => {
|
|
|
692
700
|
query("range", "Relative range ending now (default 30d); `all` starts at the earliest row", {
|
|
693
701
|
type: "string",
|
|
694
702
|
enum: [
|
|
703
|
+
"1h",
|
|
704
|
+
"24h",
|
|
695
705
|
"7d",
|
|
696
706
|
"30d",
|
|
697
707
|
"90d",
|
|
@@ -704,6 +714,9 @@ const resourcePaths = (access) => {
|
|
|
704
714
|
query("granularity", "Default day", {
|
|
705
715
|
type: "string",
|
|
706
716
|
enum: [
|
|
717
|
+
"minute",
|
|
718
|
+
"5_minutes",
|
|
719
|
+
"15_minutes",
|
|
707
720
|
"hour",
|
|
708
721
|
"day",
|
|
709
722
|
"week",
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { tenantScope } from "./tenant.js";
|
|
2
|
+
import { recordDecision, requestAccess } from "./request-access.js";
|
|
3
|
+
import { fetchRecord } from "./records.js";
|
|
4
|
+
import { fetchReadable } from "./fetch-readable.js";
|
|
5
|
+
//#region src/parent-access.ts
|
|
6
|
+
/** A record's identity across resources; `1` and `"1"` are the same record. */
|
|
7
|
+
const pathKey = (resource, key) => JSON.stringify([resource, ...key.map(String)]);
|
|
8
|
+
/**
|
|
9
|
+
* The parents these records name through `model.parents`, a null or missing relation naming none, sorted by path key:
|
|
10
|
+
* the order they are locked in, so two writes naming the same parents lock them in the same order. A parent named twice
|
|
11
|
+
* with the same need is checked once.
|
|
12
|
+
*/
|
|
13
|
+
const parentTargets = (model, records) => {
|
|
14
|
+
const targets = records.flatMap((record) => (model.parents ?? []).flatMap(({ field, access }) => {
|
|
15
|
+
const value = record[field];
|
|
16
|
+
if (value === void 0 || value === null) return [];
|
|
17
|
+
const resource = model.fields[field].relation.resource;
|
|
18
|
+
return [{
|
|
19
|
+
field,
|
|
20
|
+
access,
|
|
21
|
+
resource,
|
|
22
|
+
value,
|
|
23
|
+
key: pathKey(resource, [value])
|
|
24
|
+
}];
|
|
25
|
+
}));
|
|
26
|
+
return targets.filter((target, index) => targets.findIndex((other) => other.key === target.key && other.access === target.access) === index).sort((a, b) => a.key < b.key ? -1 : a.key > b.key ? 1 : 0);
|
|
27
|
+
};
|
|
28
|
+
const mayOn = async (scope, entry, key, need, path) => {
|
|
29
|
+
const access = await scope.accessOf(entry);
|
|
30
|
+
if (!access.resolved.operations.read) return false;
|
|
31
|
+
const found = await fetchReadable(scope.trx, access, key, "hide", scope.lock ? { lock: "no-key-update" } : {});
|
|
32
|
+
if (!found) return false;
|
|
33
|
+
if (need === "read") return true;
|
|
34
|
+
if (!access.resolved.operations.update) return false;
|
|
35
|
+
const tenant = tenantScope(entry, access.session)?.tenantValue;
|
|
36
|
+
const keyField = [access.full.fields[access.full.primaryKey[0]]];
|
|
37
|
+
const filter = access.resolved.rowFilter.update;
|
|
38
|
+
if (filter && !await fetchRecord(scope.trx, entry, key, tenant, {
|
|
39
|
+
filter,
|
|
40
|
+
fields: keyField
|
|
41
|
+
})) return false;
|
|
42
|
+
if (access.resolved.recordChecks.includes("update")) {
|
|
43
|
+
const decision = await recordDecision(access, "update", found.record, {});
|
|
44
|
+
if (decision === false) return false;
|
|
45
|
+
if (decision !== true && !await fetchRecord(scope.trx, entry, key, tenant, {
|
|
46
|
+
filter: decision,
|
|
47
|
+
fields: keyField
|
|
48
|
+
})) return false;
|
|
49
|
+
}
|
|
50
|
+
return parentsAllow(scope, access, [found.record], [...path, pathKey(entry.name, key)]);
|
|
51
|
+
};
|
|
52
|
+
const parentsAllow = async (scope, access, records, path) => {
|
|
53
|
+
for (const target of parentTargets(access.full, records)) {
|
|
54
|
+
if (path.includes(target.key)) return false;
|
|
55
|
+
const entry = scope.deps.registry.find(target.resource);
|
|
56
|
+
if (!entry) throw new Error(`Parent "${target.field}" of "${access.entry.name}" points at "${target.resource}", which the app does not have`);
|
|
57
|
+
if (!await mayOn(scope, entry, [target.value], target.access, path)) return false;
|
|
58
|
+
}
|
|
59
|
+
return true;
|
|
60
|
+
};
|
|
61
|
+
const ownPath = (access, records) => records.filter((record) => access.full.primaryKey.every((name) => record[name] !== void 0 && record[name] !== null)).map((record) => pathKey(access.entry.name, access.full.primaryKey.map((name) => record[name])));
|
|
62
|
+
/**
|
|
63
|
+
* Whether the caller may write records of `access`'s resource given the parents they name (`.parent()`): an update passes
|
|
64
|
+
* the record before and after, so the parent it leaves and the one it moves to are locked in one order. With `lock`,
|
|
65
|
+
* each parent is locked FOR NO KEY UPDATE until the transaction ends, so a parent changed or deleted at the same moment
|
|
66
|
+
* cannot slip through; a read-only transaction cannot lock. Every call checks again; only each resource's access for
|
|
67
|
+
* the session is reused.
|
|
68
|
+
*/
|
|
69
|
+
const parentGuard = (trx, access, lock) => {
|
|
70
|
+
const resolved = /* @__PURE__ */ new Map();
|
|
71
|
+
const accessOf = (entry) => {
|
|
72
|
+
if (!resolved.has(entry.name)) resolved.set(entry.name, requestAccess(access.deps, entry, access.session));
|
|
73
|
+
return resolved.get(entry.name);
|
|
74
|
+
};
|
|
75
|
+
const scope = {
|
|
76
|
+
trx,
|
|
77
|
+
deps: access.deps,
|
|
78
|
+
lock,
|
|
79
|
+
accessOf
|
|
80
|
+
};
|
|
81
|
+
return (...records) => parentsAllow(scope, access, records, ownPath(access, records));
|
|
82
|
+
};
|
|
83
|
+
//#endregion
|
|
84
|
+
export { parentGuard, parentTargets, pathKey };
|
package/dist/read-service.js
CHANGED
|
@@ -1,40 +1,14 @@
|
|
|
1
1
|
import { notFound } from "./problem.js";
|
|
2
|
-
import {
|
|
3
|
-
import { recordDecision, requestAccess, requireOperation, targetFilter, visibleFields, writableFields } from "./request-access.js";
|
|
2
|
+
import { requestAccess, requireOperation, visibleFields, writableFields } from "./request-access.js";
|
|
4
3
|
import { presentFiles } from "./files/present.js";
|
|
5
4
|
import { pickFields } from "./rows.js";
|
|
6
|
-
import {
|
|
5
|
+
import { fetchReadable } from "./fetch-readable.js";
|
|
7
6
|
import { rowPermissions } from "./row-permissions.js";
|
|
8
7
|
import { readTransaction } from "./transactions.js";
|
|
9
8
|
//#region src/read-service.ts
|
|
10
9
|
/** `edit` for fields the caller may change on this record, `read` for the rest (sensitive ones included); hidden fields are not listed at all. */
|
|
11
10
|
const fieldModes = (access, canUpdate) => Object.fromEntries(Object.keys(access.readable.fields).map((name) => [name, canUpdate && writableFields(access, "update").includes(name) ? "edit" : "read"]));
|
|
12
11
|
/**
|
|
13
|
-
* The row with this key if the caller may read it: inside their tenant and row filter, and passing a record-level read
|
|
14
|
-
* rule. Such a rule may look at fields the caller cannot see, so then the whole row is read; the caller cuts it down.
|
|
15
|
-
*/
|
|
16
|
-
const fetchReadable = async (trx, access, key, deleted, { fields, ...shape }) => {
|
|
17
|
-
const { entry } = access;
|
|
18
|
-
const tenant = tenantScope(entry, access.session)?.tenantValue;
|
|
19
|
-
const recordRule = access.resolved.recordChecks.includes("read");
|
|
20
|
-
const filter = targetFilter(access, "read");
|
|
21
|
-
const current = await fetchRecord(trx, entry, key, tenant, {
|
|
22
|
-
deleted,
|
|
23
|
-
...filter && { filter },
|
|
24
|
-
...shape,
|
|
25
|
-
...!recordRule && fields && { fields }
|
|
26
|
-
});
|
|
27
|
-
if (!current || !recordRule) return current;
|
|
28
|
-
const decision = await recordDecision(access, "read", current.record);
|
|
29
|
-
if (decision === false) return void 0;
|
|
30
|
-
if (decision !== true && !await fetchRecord(trx, entry, key, tenant, {
|
|
31
|
-
deleted,
|
|
32
|
-
filter: decision,
|
|
33
|
-
fields: [access.full.fields[access.full.primaryKey[0]]]
|
|
34
|
-
})) return void 0;
|
|
35
|
-
return current;
|
|
36
|
-
};
|
|
37
|
-
/**
|
|
38
12
|
* The record with this key as the caller may see it: readable fields only, inside the caller's tenant and row filter.
|
|
39
13
|
* A key outside the tenant, outside the row filter, or failing a record-level read rule is a plain 404.
|
|
40
14
|
*/
|
|
@@ -66,4 +40,4 @@ const readRecord = async (deps, entry, session, key, deleted = "hide") => {
|
|
|
66
40
|
};
|
|
67
41
|
};
|
|
68
42
|
//#endregion
|
|
69
|
-
export {
|
|
43
|
+
export { fieldModes, readRecord };
|
package/dist/records.js
CHANGED
|
@@ -12,6 +12,7 @@ const stored = async (entry, raw, shape = {}) => {
|
|
|
12
12
|
};
|
|
13
13
|
};
|
|
14
14
|
const rowsOf = async (db, query) => (await query.execute(db)).rows;
|
|
15
|
+
const lockClause = (lock) => lock === "no-key-update" ? sql` for no key update` : lock ? sql` for update` : sql``;
|
|
15
16
|
/** The row of the caller's tenant with this key (live unless `deleted` says otherwise), optionally narrowed by a filter. */
|
|
16
17
|
const fetchRecord = async (db, entry, key, tenant, options = {}) => {
|
|
17
18
|
const { filter, deleted, lock = false, ...shape } = options;
|
|
@@ -20,7 +21,7 @@ const fetchRecord = async (db, entry, key, tenant, options = {}) => {
|
|
|
20
21
|
...keyConditions(entry.model, key),
|
|
21
22
|
...filterCondition(entry.model, filter)
|
|
22
23
|
];
|
|
23
|
-
const query = sql`select ${returningList(entry, shape.fields)} from ${tableId(entry.model)} as t${whereClause(conditions)} limit 1${lock
|
|
24
|
+
const query = sql`select ${returningList(entry, shape.fields)} from ${tableId(entry.model)} as t${whereClause(conditions)} limit 1${lockClause(lock)}`;
|
|
24
25
|
return stored(entry, (await rowsOf(db, query))[0], shape);
|
|
25
26
|
};
|
|
26
27
|
const writeValue = (field, value) => field.type === "json" && value !== null ? JSON.stringify(value) : value;
|
package/dist/reveal-service.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { badRequest, notFound } from "./problem.js";
|
|
2
2
|
import { tenantScope } from "./tenant.js";
|
|
3
3
|
import { requestAccess, requireOperation } from "./request-access.js";
|
|
4
|
+
import { fetchReadable } from "./fetch-readable.js";
|
|
4
5
|
import { readTransaction } from "./transactions.js";
|
|
5
|
-
import { fetchReadable } from "./read-service.js";
|
|
6
6
|
import { encodeKey } from "@protobase/schema/internal";
|
|
7
7
|
//#region src/reveal-service.ts
|
|
8
8
|
/**
|
package/dist/row-permissions.js
CHANGED
|
@@ -2,6 +2,7 @@ import { tenantScope } from "./tenant.js";
|
|
|
2
2
|
import { recordDecision } from "./request-access.js";
|
|
3
3
|
import { aliasedColumn, columnId, scopeConditions, tableId, whereClause } from "./sql-parts.js";
|
|
4
4
|
import { fetchRecord } from "./records.js";
|
|
5
|
+
import { parentGuard } from "./parent-access.js";
|
|
5
6
|
import { sql } from "kysely";
|
|
6
7
|
import { compileFilter } from "@protobase/query";
|
|
7
8
|
//#region src/row-permissions.ts
|
|
@@ -21,8 +22,8 @@ const matching = async (trx, access, rows, filter) => {
|
|
|
21
22
|
};
|
|
22
23
|
/**
|
|
23
24
|
* What the caller may do with each of these (exposed) rows: the operation must be allowed, the row must match the
|
|
24
|
-
* operation's row filter (checked in the database),
|
|
25
|
-
* informs the UI; every write is checked again by the write pipeline.
|
|
25
|
+
* operation's row filter (checked in the database), a record-level rule must pass for the stored record, and its parents
|
|
26
|
+
* (`.parent()`) must allow writing it. This only informs the UI; every write is checked again by the write pipeline.
|
|
26
27
|
*/
|
|
27
28
|
const rowPermissions = async (trx, access, rows) => {
|
|
28
29
|
const { resolved, entry, session } = access;
|
|
@@ -63,7 +64,23 @@ const rowPermissions = async (trx, access, rows) => {
|
|
|
63
64
|
if (decision !== true) result[i][operation] = (await matching(trx, access, [row], decision)).size > 0;
|
|
64
65
|
}
|
|
65
66
|
}
|
|
67
|
+
await withParents(trx, access, rows, result);
|
|
66
68
|
return result;
|
|
67
69
|
};
|
|
70
|
+
const withParents = async (trx, access, rows, result) => {
|
|
71
|
+
const { full, entry, session } = access;
|
|
72
|
+
if (!full.parents?.length) return;
|
|
73
|
+
const guard = parentGuard(trx, access, false);
|
|
74
|
+
const fields = [.../* @__PURE__ */ new Set([...full.primaryKey, ...full.parents.map((parent) => parent.field)])].map((name) => full.fields[name]);
|
|
75
|
+
const tenant = tenantScope(entry, session)?.tenantValue;
|
|
76
|
+
for (const [i, row] of rows.entries()) {
|
|
77
|
+
const permissions = result[i];
|
|
78
|
+
if (!permissions.update && !permissions.delete) continue;
|
|
79
|
+
const stored = await fetchRecord(trx, entry, full.primaryKey.map((name) => row[name]), tenant, { fields });
|
|
80
|
+
const allowed = stored !== void 0 && await guard(stored.record);
|
|
81
|
+
permissions.update &&= allowed;
|
|
82
|
+
permissions.delete &&= allowed;
|
|
83
|
+
}
|
|
84
|
+
};
|
|
68
85
|
//#endregion
|
|
69
86
|
export { rowPermissions };
|
package/dist/view-access.js
CHANGED
|
@@ -2,6 +2,7 @@ import { openErrors, relatedErrors } from "./view-related.js";
|
|
|
2
2
|
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
|
+
import { recordChartErrors } from "./view-record-chart.js";
|
|
5
6
|
import { relatedColumnErrors } from "./view-related-column.js";
|
|
6
7
|
import { actionWhenFields } from "./view-action-when.js";
|
|
7
8
|
import { conditionReadable, resolveFieldPath } from "@protobase/layout";
|
|
@@ -25,6 +26,16 @@ const pruneRelated = (item, resource, models) => {
|
|
|
25
26
|
} : narrowed;
|
|
26
27
|
return relatedErrors(opened, resource, models).length === 0 ? opened : void 0;
|
|
27
28
|
};
|
|
29
|
+
const pruneRecordChart = (item, resource, models) => {
|
|
30
|
+
const target = models[item.resource];
|
|
31
|
+
const series = target ? item.series.filter((name) => Object.hasOwn(target.fields, name)) : [];
|
|
32
|
+
if (series.length === 0) return void 0;
|
|
33
|
+
const narrowed = {
|
|
34
|
+
...item,
|
|
35
|
+
series
|
|
36
|
+
};
|
|
37
|
+
return recordChartErrors(narrowed, resource, models).length === 0 ? narrowed : void 0;
|
|
38
|
+
};
|
|
28
39
|
const recordWhenShape = (resource, models) => shapes.map((when) => isRecordCondition(when) && !conditionReadable(when, resource, models) ? void 0 : when);
|
|
29
40
|
const pruneGallery = (item, resource, model, models) => {
|
|
30
41
|
const image = item.image !== void 0 && Object.hasOwn(model.fields, item.image) ? item.image : void 0;
|
|
@@ -218,6 +229,7 @@ const layoutShape = (resource, model, models) => {
|
|
|
218
229
|
sidebar: shapes.data,
|
|
219
230
|
when: shapes.data
|
|
220
231
|
}, { requires: ["latitude", "longitude"] }),
|
|
232
|
+
chart: shapes.map((item) => pruneRecordChart(item, resource, models)),
|
|
221
233
|
gallery: shapes.map((item) => pruneGallery(item, resource, model, models)),
|
|
222
234
|
history: shapes.object({
|
|
223
235
|
kind: shapes.data,
|
|
@@ -336,7 +348,7 @@ const viewShape = (resource, model, models, prompted, createPages) => {
|
|
|
336
348
|
/**
|
|
337
349
|
* The view with every reference to a field the caller cannot read removed: list columns, sort, search and displays,
|
|
338
350
|
* field hints, filter widgets, record sections, maps, galleries and custom components' props, the chart, title and search result, the
|
|
339
|
-
* actions that set or read one, and the sidebar's `recent` group. Nothing in it may name a hidden field. Related sections, related list columns
|
|
351
|
+
* actions that set or read one, and the sidebar's `recent` group. Nothing in it may name a hidden field. Related sections, charts of related records, related list columns
|
|
340
352
|
* and links are checked against `models`, the caller's view of every resource, and so are `searchesOf`, cards' favorite
|
|
341
353
|
* buttons and the "Save search" button; the buttons stay for a visitor when their resource is one of `prompted`, those
|
|
342
354
|
* that ask the visitor to sign in. A create link may give values of any name to a resource of `createPages`, whose view
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { matchErrors } from "./view-related.js";
|
|
2
|
+
//#region src/view-record-chart.ts
|
|
3
|
+
const numericTypes = [
|
|
4
|
+
"integer",
|
|
5
|
+
"bigint",
|
|
6
|
+
"decimal"
|
|
7
|
+
];
|
|
8
|
+
const ranges = [
|
|
9
|
+
"1h",
|
|
10
|
+
"24h",
|
|
11
|
+
"7d"
|
|
12
|
+
];
|
|
13
|
+
const atErrors = (model, name) => {
|
|
14
|
+
const field = Object.hasOwn(model.fields, name) ? model.fields[name] : void 0;
|
|
15
|
+
if (!field) return [`at: unknown field "${name}" on ${model.name}`];
|
|
16
|
+
if (field.type !== "timestamp") return [`at: "${name}" on ${model.name} is ${field.type}, not a timestamp`];
|
|
17
|
+
return field.filterable ? [] : [`at: "${name}" on ${model.name} is not filterable`];
|
|
18
|
+
};
|
|
19
|
+
const seriesErrors = (model, name) => {
|
|
20
|
+
const field = Object.hasOwn(model.fields, name) ? model.fields[name] : void 0;
|
|
21
|
+
if (!field) return [`series: unknown field "${name}" on ${model.name}`];
|
|
22
|
+
if (!numericTypes.includes(field.type)) return [`series: "${name}" on ${model.name} is ${field.type}, not a number`];
|
|
23
|
+
return field.sensitive ? [`series: "${name}" on ${model.name} is sensitive`] : [];
|
|
24
|
+
};
|
|
25
|
+
/**
|
|
26
|
+
* What is wrong with a chart of related records on the record page of `resource`, against these models; empty when it
|
|
27
|
+
* can be shown. Against the full models it checks the config; against a caller's models, whether that caller can use it.
|
|
28
|
+
*/
|
|
29
|
+
const recordChartErrors = (item, resource, models) => {
|
|
30
|
+
const owner = models[resource];
|
|
31
|
+
const target = models[item.resource];
|
|
32
|
+
if (!owner || !target) return [`unknown resource "${owner ? item.resource : resource}"`];
|
|
33
|
+
if (owner.primaryKey.length !== 1) return [`${resource} has a composite key, so a chart cannot match its records`];
|
|
34
|
+
return [
|
|
35
|
+
...matchErrors(target, item.field, resource),
|
|
36
|
+
...atErrors(target, item.at),
|
|
37
|
+
...item.series.length === 0 ? ["series: name at least one field"] : item.series.flatMap((name) => seriesErrors(target, name)),
|
|
38
|
+
...item.range === void 0 || ranges.includes(item.range) ? [] : [`range must be one of ${ranges.join(", ")}, not "${String(item.range)}"`]
|
|
39
|
+
];
|
|
40
|
+
};
|
|
41
|
+
//#endregion
|
|
42
|
+
export { recordChartErrors };
|
package/dist/write-pipeline.js
CHANGED
|
@@ -8,7 +8,7 @@ import { isDatabaseError } from "./error-handler.js";
|
|
|
8
8
|
import { attachFiles } from "./files/attach.js";
|
|
9
9
|
import { actorSettings, wakeDispatch } from "./changes/actor.js";
|
|
10
10
|
import { validateBody } from "./validate-body.js";
|
|
11
|
-
import { assertWritten, checkPrecondition, decide, denied, firstKeyField, keyOf, missing, notifier, shown } from "./write-shared.js";
|
|
11
|
+
import { assertWritten, checkParents, checkPrecondition, decide, denied, firstKeyField, keyOf, missing, notifier, shown } from "./write-shared.js";
|
|
12
12
|
import { applyUpdate, planUpdate } from "./write-update.js";
|
|
13
13
|
import { checkRankAfter } from "./rank-after.js";
|
|
14
14
|
//#region src/write-pipeline.ts
|
|
@@ -31,8 +31,11 @@ const create = async (trx, deps, access, body) => {
|
|
|
31
31
|
...tenantValues
|
|
32
32
|
};
|
|
33
33
|
const filter = await decide(access, "create", void 0, row);
|
|
34
|
+
const parents = checkParents(trx, access, "create");
|
|
35
|
+
await parents(row);
|
|
34
36
|
const created = await insertRecord(trx, entry, row, { etagFields: Object.keys(access.model.fields) });
|
|
35
37
|
await assertWritten(trx, access, created, "create", access.resolved.rowFilter.create, filter);
|
|
38
|
+
await parents(created.record);
|
|
36
39
|
if (full.position) await checkRankAfter(trx, deps, access, full.position.field, [{
|
|
37
40
|
key: keyOf(access, created.record),
|
|
38
41
|
before: null
|
|
@@ -77,6 +80,7 @@ const removeOrRestore = async (trx, deps, access, request) => {
|
|
|
77
80
|
filter,
|
|
78
81
|
fields: [firstKeyField(access)]
|
|
79
82
|
})) throw denied(entry, operation);
|
|
83
|
+
await checkParents(trx, access, operation)(current.record);
|
|
80
84
|
const notify = notifier(deps, access);
|
|
81
85
|
if (undelete) {
|
|
82
86
|
const restored = await restoreRecord(trx, entry, request.key, scope?.tenantValue, shape);
|
package/dist/write-shared.js
CHANGED
|
@@ -5,6 +5,7 @@ import { presentFiles } from "./files/present.js";
|
|
|
5
5
|
import { ifMatchSatisfied } from "./etag.js";
|
|
6
6
|
import { pickFields } from "./rows.js";
|
|
7
7
|
import { fetchRecord } from "./records.js";
|
|
8
|
+
import { parentGuard } from "./parent-access.js";
|
|
8
9
|
import { scheduleRemovedFiles } from "./files/attach.js";
|
|
9
10
|
//#region src/write-shared.ts
|
|
10
11
|
const missing = (entry) => notFound(`No ${entry.name} with this key`);
|
|
@@ -22,6 +23,17 @@ const decide = async (access, operation, record, input) => {
|
|
|
22
23
|
if (decision === false) throw denied(access.entry, operation);
|
|
23
24
|
return decision === true ? void 0 : decision;
|
|
24
25
|
};
|
|
26
|
+
/**
|
|
27
|
+
* Refuses a write whose records' parents (`.parent()`) do not give the caller the access it needs, with the same answer
|
|
28
|
+
* as a refused record-level rule; each parent stays locked until the write commits.
|
|
29
|
+
*/
|
|
30
|
+
const checkParents = (trx, access, operation) => {
|
|
31
|
+
if (!access.full.parents?.length) return async (..._records) => {};
|
|
32
|
+
const guard = parentGuard(trx, access, true);
|
|
33
|
+
return async (...records) => {
|
|
34
|
+
if (!await guard(...records)) throw denied(access.entry, operation);
|
|
35
|
+
};
|
|
36
|
+
};
|
|
25
37
|
const keyOf = (access, record) => access.full.primaryKey.map((name) => record[name]);
|
|
26
38
|
const firstKeyField = (access) => access.full.fields[access.full.primaryKey[0]];
|
|
27
39
|
const assertWritten = async (trx, access, written, operation, ...filters) => {
|
|
@@ -53,4 +65,4 @@ const notifier = (deps, access) => async (trx, kind, before, after) => {
|
|
|
53
65
|
await scheduleRemovedFiles(access, kind, before, after);
|
|
54
66
|
};
|
|
55
67
|
//#endregion
|
|
56
|
-
export { assertWritten, checkPrecondition, decide, denied, firstKeyField, keyOf, missing, notifier, shown };
|
|
68
|
+
export { assertWritten, checkParents, checkPrecondition, decide, denied, firstKeyField, keyOf, missing, notifier, shown };
|
package/dist/write-update.js
CHANGED
|
@@ -3,11 +3,12 @@ import { targetFilter } from "./request-access.js";
|
|
|
3
3
|
import { fetchRecord, updateRecord } from "./records.js";
|
|
4
4
|
import { attachFiles } from "./files/attach.js";
|
|
5
5
|
import { validateBody } from "./validate-body.js";
|
|
6
|
-
import { assertWritten, checkPrecondition, decide, denied, firstKeyField, missing, notifier } from "./write-shared.js";
|
|
6
|
+
import { assertWritten, checkParents, checkPrecondition, decide, denied, firstKeyField, missing, notifier } from "./write-shared.js";
|
|
7
7
|
//#region src/write-update.ts
|
|
8
8
|
/**
|
|
9
9
|
* Everything an update needs before it writes: the row (locked, inside the caller's tenant and row filters), the ETag
|
|
10
|
-
* precondition, validation against the fields the caller may write,
|
|
10
|
+
* precondition, validation against the fields the caller may write, the record-level rule with its filter, and the
|
|
11
|
+
* parents (`.parent()`) it leaves and moves to, checked in one call so they are locked in one order.
|
|
11
12
|
*/
|
|
12
13
|
const planUpdate = async (trx, access, key, body, ifMatch, required = true) => {
|
|
13
14
|
const { entry, full } = access;
|
|
@@ -27,10 +28,16 @@ const planUpdate = async (trx, access, key, body, ifMatch, required = true) => {
|
|
|
27
28
|
filter,
|
|
28
29
|
fields: [firstKeyField(access)]
|
|
29
30
|
})) throw denied(entry, "update");
|
|
31
|
+
const parents = checkParents(trx, access, "update");
|
|
32
|
+
if (Object.keys(values).length > 0) await parents(current.record, {
|
|
33
|
+
...current.record,
|
|
34
|
+
...values
|
|
35
|
+
});
|
|
30
36
|
return {
|
|
31
37
|
current,
|
|
32
38
|
values,
|
|
33
|
-
...filter && { filter }
|
|
39
|
+
...filter && { filter },
|
|
40
|
+
parents
|
|
34
41
|
};
|
|
35
42
|
};
|
|
36
43
|
/** Writes a planned update, checks the row as written against the filters, and runs the hooks. */
|
|
@@ -39,6 +46,7 @@ const applyUpdate = async (trx, deps, access, key, plan) => {
|
|
|
39
46
|
const tenant = tenantScope(entry, access.session)?.tenantValue;
|
|
40
47
|
const updated = await updateRecord(trx, entry, key, tenant, plan.values, { etagFields: Object.keys(access.model.fields) });
|
|
41
48
|
await assertWritten(trx, access, updated, "update", access.resolved.rowFilter.update, plan.filter);
|
|
49
|
+
await plan.parents(updated.record);
|
|
42
50
|
await notifier(deps, access)(trx, "update", plan.current.record, updated.record);
|
|
43
51
|
return updated;
|
|
44
52
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@protobase/server",
|
|
3
|
-
"version": "0.28.
|
|
3
|
+
"version": "0.28.2",
|
|
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.28.
|
|
36
|
-
"@protobase/query": "0.28.
|
|
37
|
-
"@protobase/schema": "0.28.
|
|
35
|
+
"@protobase/layout": "0.28.2",
|
|
36
|
+
"@protobase/query": "0.28.2",
|
|
37
|
+
"@protobase/schema": "0.28.2",
|
|
38
38
|
"@react-email/render": "^2.1.0",
|
|
39
39
|
"@scalar/hono-api-reference": "^0.12.10",
|
|
40
40
|
"@sentry/core": "^11.7.0",
|