@protobase/server 0.22.5 → 0.22.7
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 +50 -3
- package/dist/aggregate-splits.js +41 -0
- package/dist/check-view-queries.js +3 -2
- package/dist/check-views.js +12 -5
- package/dist/openapi/resource-paths.js +34 -3
- package/dist/view-access.js +44 -31
- package/package.json +4 -4
package/dist/aggregate-groups.js
CHANGED
|
@@ -2,6 +2,7 @@ import { badRequest } from "./problem.js";
|
|
|
2
2
|
import { readTransaction } from "./transactions.js";
|
|
3
3
|
import { parseParams } from "./params.js";
|
|
4
4
|
import { labelRequests, relatedLabels } from "./related-labels.js";
|
|
5
|
+
import { splitGroups } from "./aggregate-splits.js";
|
|
5
6
|
import { scoped, seriesRange } from "./aggregate-service.js";
|
|
6
7
|
import { z } from "zod";
|
|
7
8
|
import { groupTotals, series } from "@protobase/query";
|
|
@@ -30,7 +31,10 @@ const aggregateParams = z.object({
|
|
|
30
31
|
"month",
|
|
31
32
|
"year"
|
|
32
33
|
]).optional(),
|
|
33
|
-
time_zone: z.string().optional()
|
|
34
|
+
time_zone: z.string().optional(),
|
|
35
|
+
split_by: name.optional(),
|
|
36
|
+
split_limit: z.coerce.number().int().optional(),
|
|
37
|
+
split_label: name.optional()
|
|
34
38
|
});
|
|
35
39
|
const invalid = (parameter, message) => badRequest("invalid-parameter", `Invalid parameter ${parameter}: ${message}`, { errors: [{
|
|
36
40
|
parameter,
|
|
@@ -49,13 +53,20 @@ const measureOf = (params) => {
|
|
|
49
53
|
};
|
|
50
54
|
const optionErrors = (params, dated) => {
|
|
51
55
|
const given = (names) => names.filter((option) => params[option] !== void 0);
|
|
52
|
-
if (dated)
|
|
56
|
+
if (dated) {
|
|
57
|
+
const errors = given(["limit", "label"]).map((option) => invalid(option, `Not for a date or timestamp group_by`));
|
|
58
|
+
if (params.split_by === void 0 && given(["split_limit", "split_label"]).length > 0) errors.push(invalid("split_by", "split_limit and split_label need split_by"));
|
|
59
|
+
return errors;
|
|
60
|
+
}
|
|
53
61
|
return given([
|
|
54
62
|
"range",
|
|
55
63
|
"from",
|
|
56
64
|
"to",
|
|
57
65
|
"granularity",
|
|
58
|
-
"time_zone"
|
|
66
|
+
"time_zone",
|
|
67
|
+
"split_by",
|
|
68
|
+
"split_limit",
|
|
69
|
+
"split_label"
|
|
59
70
|
]).map((option) => invalid(option, "Only for a date or timestamp group_by"));
|
|
60
71
|
};
|
|
61
72
|
const isDated = (model, field) => {
|
|
@@ -75,6 +86,7 @@ const aggregate = async (deps, entry, session, input) => {
|
|
|
75
86
|
const dated = isDated(model, params.group_by);
|
|
76
87
|
const [problem] = optionErrors(params, dated);
|
|
77
88
|
if (problem) throw problem;
|
|
89
|
+
if (params.split_by !== void 0 && !Object.hasOwn(model.fields, params.split_by)) throw invalid("split_by", `Unknown field "${params.split_by}"`);
|
|
78
90
|
const warning = await deps.guard(entry, {
|
|
79
91
|
...filter && { filter },
|
|
80
92
|
groupBy: params.group_by
|
|
@@ -82,10 +94,45 @@ const aggregate = async (deps, entry, session, input) => {
|
|
|
82
94
|
model,
|
|
83
95
|
scope
|
|
84
96
|
});
|
|
97
|
+
const splitWarning = params.split_by === void 0 ? void 0 : await deps.guard(entry, {
|
|
98
|
+
...filter && { filter },
|
|
99
|
+
groupBy: params.split_by
|
|
100
|
+
}, {
|
|
101
|
+
model,
|
|
102
|
+
scope
|
|
103
|
+
});
|
|
85
104
|
if (dated) {
|
|
86
105
|
const granularity = params.granularity ?? "day";
|
|
87
106
|
const timeZone = params.time_zone ?? "UTC";
|
|
88
107
|
const range = await seriesRange(deps, access, params.group_by, params, scope);
|
|
108
|
+
if (params.split_by !== void 0) {
|
|
109
|
+
const split = {
|
|
110
|
+
by: params.split_by,
|
|
111
|
+
...params.split_limit !== void 0 && { limit: params.split_limit },
|
|
112
|
+
...params.split_label !== void 0 && { label: params.split_label }
|
|
113
|
+
};
|
|
114
|
+
const parts = range ? await splitGroups(deps, session, model, params.group_by, split, {
|
|
115
|
+
range,
|
|
116
|
+
granularity,
|
|
117
|
+
timeZone,
|
|
118
|
+
...filter && { filter },
|
|
119
|
+
...measure && { measure },
|
|
120
|
+
scope
|
|
121
|
+
}) : {
|
|
122
|
+
splits: [],
|
|
123
|
+
groups: []
|
|
124
|
+
};
|
|
125
|
+
return {
|
|
126
|
+
body: {
|
|
127
|
+
group_by: params.group_by,
|
|
128
|
+
split_by: params.split_by,
|
|
129
|
+
granularity,
|
|
130
|
+
time_zone: timeZone,
|
|
131
|
+
...parts
|
|
132
|
+
},
|
|
133
|
+
warning: warning ?? splitWarning
|
|
134
|
+
};
|
|
135
|
+
}
|
|
89
136
|
const groups = (range ? await readTransaction(deps.db, deps.statementTimeoutMs, (trx) => series(trx, model, params.group_by, {
|
|
90
137
|
range,
|
|
91
138
|
granularity,
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { readTransaction } from "./transactions.js";
|
|
2
|
+
import { labelRequests, relatedLabels } from "./related-labels.js";
|
|
3
|
+
import { series, splitSeries } from "@protobase/query";
|
|
4
|
+
//#region src/aggregate-splits.ts
|
|
5
|
+
/**
|
|
6
|
+
* The buckets of a date field with each split into its parts: per bucket the shown values that have rows in it, in
|
|
7
|
+
* rank order. The bucket's own totals stay what `series` finds, so what the other values add up to is total minus parts.
|
|
8
|
+
* With `label`, a relation's values carry the `label` field of the record each points at.
|
|
9
|
+
*/
|
|
10
|
+
const splitGroups = async (deps, session, model, field, split, options) => {
|
|
11
|
+
const labels = split.label === void 0 ? [] : await labelRequests(deps, session, model, [split.by], `${split.by}.${split.label}`);
|
|
12
|
+
return readTransaction(deps.db, deps.statementTimeoutMs, async (trx) => {
|
|
13
|
+
const buckets = await series(trx, model, field, options);
|
|
14
|
+
const { splits, points } = await splitSeries(trx, model, field, split.by, {
|
|
15
|
+
...options,
|
|
16
|
+
...split.limit !== void 0 && { limit: split.limit }
|
|
17
|
+
});
|
|
18
|
+
const names = (await relatedLabels(trx, labels, splits.map((value) => ({ [split.by]: value }))))[split.by] ?? {};
|
|
19
|
+
const partsOf = /* @__PURE__ */ new Map();
|
|
20
|
+
for (const { bucket, split: value, ...totals } of points) partsOf.set(bucket, [...partsOf.get(bucket) ?? [], {
|
|
21
|
+
value,
|
|
22
|
+
...totals
|
|
23
|
+
}]);
|
|
24
|
+
return {
|
|
25
|
+
splits: splits.map((value) => {
|
|
26
|
+
const label = value === null ? void 0 : names[String(value)];
|
|
27
|
+
return label === void 0 ? { value } : {
|
|
28
|
+
value,
|
|
29
|
+
label
|
|
30
|
+
};
|
|
31
|
+
}),
|
|
32
|
+
groups: buckets.map(({ bucket, ...totals }) => ({
|
|
33
|
+
value: bucket,
|
|
34
|
+
...totals,
|
|
35
|
+
splits: partsOf.get(bucket) ?? []
|
|
36
|
+
}))
|
|
37
|
+
};
|
|
38
|
+
});
|
|
39
|
+
};
|
|
40
|
+
//#endregion
|
|
41
|
+
export { splitGroups };
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { isGroupChart } from "@protobase/schema/internal";
|
|
1
|
+
import { isGroupChart, viewOfList } from "@protobase/schema/internal";
|
|
2
2
|
import { checkFilter, parseFilter } from "@protobase/schema";
|
|
3
3
|
//#region src/check-view-queries.ts
|
|
4
4
|
const widgetName = {
|
|
@@ -45,7 +45,8 @@ const checkViewQueries = (models, views) => {
|
|
|
45
45
|
const byName = new Map(models.map((model) => [model.name, model]));
|
|
46
46
|
const problems = views.flatMap((view) => {
|
|
47
47
|
const model = byName.get(view.resource);
|
|
48
|
-
|
|
48
|
+
if (!model) return [];
|
|
49
|
+
return [...viewQueryErrors(view, model).map((problem) => `View "${view.resource}": ${problem}`), ...(view.lists ?? []).flatMap((named) => viewQueryErrors(viewOfList(view, named.name), model).map((problem) => `View "${view.resource}" list "${named.name}": ${problem}`))];
|
|
49
50
|
});
|
|
50
51
|
if (problems.length > 0) throw new Error(`The views ask for what their resources do not allow:\n${problems.map((problem) => ` - ${problem}`).join("\n")}`);
|
|
51
52
|
};
|
package/dist/check-views.js
CHANGED
|
@@ -7,7 +7,7 @@ import { mapDisplayErrors, mapErrors } from "./view-map.js";
|
|
|
7
7
|
import { relatedColumnErrors } from "./view-related-column.js";
|
|
8
8
|
import { editableErrors } from "./view-editable.js";
|
|
9
9
|
import { conditionProblems } from "@protobase/layout";
|
|
10
|
-
import { actionInputErrors, addressErrors, addressPlaceholders, boardErrors, calendarErrors, chartFields, displayFields, fieldRefsIn, hrefField, hrefPlaceholders, layoutItems, linkActionError, promptError, promptFields, updateSetErrors } from "@protobase/schema/internal";
|
|
10
|
+
import { actionInputErrors, addressErrors, addressPlaceholders, boardErrors, calendarErrors, chartFields, displayFields, fieldRefsIn, hrefField, hrefPlaceholders, layoutItems, linkActionError, promptError, promptFields, updateSetErrors, viewOfList } from "@protobase/schema/internal";
|
|
11
11
|
import { isRecordCondition } from "@protobase/schema";
|
|
12
12
|
//#region src/check-views.ts
|
|
13
13
|
const linkFields = (run) => run?.kind !== "link" ? [] : "to" in run ? addressPlaceholders(run.to) : hrefPlaceholders(run.href);
|
|
@@ -151,9 +151,7 @@ const listOfErrors = (model, name, models) => {
|
|
|
151
151
|
const checkViews = (registry, views, pages = [], fieldTypes = {}) => {
|
|
152
152
|
const models = Object.fromEntries(registry.entries.map((entry) => [entry.name, entry.model]));
|
|
153
153
|
const pageNames = new Set(pages);
|
|
154
|
-
const
|
|
155
|
-
const model = models[view.resource];
|
|
156
|
-
if (!model) return [];
|
|
154
|
+
const viewProblems = (view, model) => {
|
|
157
155
|
const related = [...layoutItems(view.layout).flatMap((item) => item.kind === "related" ? relatedErrors(item, view.resource, models).map((error) => `related "${item.title}": ${error}`) : []), ...relatedColumnsErrors(view, model, models)];
|
|
158
156
|
const photos = [
|
|
159
157
|
...(view.list?.displays ?? []).flatMap((display) => display.kind === "cards" && display.photos ? cardPhotosErrors(display.photos, view.resource, models).map((error) => `display "${display.name}" photos: ${error}`) : []),
|
|
@@ -193,7 +191,16 @@ const checkViews = (registry, views, pages = [], fieldTypes = {}) => {
|
|
|
193
191
|
...aiActionErrors(view, model),
|
|
194
192
|
...inputErrors(view, models),
|
|
195
193
|
...createErrors(view, pageNames)
|
|
196
|
-
]
|
|
194
|
+
];
|
|
195
|
+
};
|
|
196
|
+
const problems = views.flatMap((view) => {
|
|
197
|
+
const model = models[view.resource];
|
|
198
|
+
if (!model) return [];
|
|
199
|
+
return [...viewProblems(view, model).map((problem) => `View "${view.resource}": ${problem}`), ...(view.lists ?? []).flatMap((named) => viewProblems({
|
|
200
|
+
...viewOfList(view, named.name),
|
|
201
|
+
layout: [],
|
|
202
|
+
actions: []
|
|
203
|
+
}, model).map((problem) => `View "${view.resource}" list "${named.name}": ${problem}`))];
|
|
197
204
|
});
|
|
198
205
|
if (problems.length > 0) throw new Error(`The views do not match the resources:\n${problems.map((problem) => ` - ${problem}`).join("\n")}`);
|
|
199
206
|
};
|
|
@@ -590,7 +590,7 @@ 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 `average`, 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.",
|
|
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 `average`, 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"),
|
|
@@ -620,7 +620,10 @@ const resourcePaths = (access) => {
|
|
|
620
620
|
}),
|
|
621
621
|
query("from", "For a date group_by: the explicit start (ISO 8601); with `to`, replaces range"),
|
|
622
622
|
query("to", "For a date group_by: the exclusive end (ISO 8601), default now"),
|
|
623
|
-
query("time_zone", "For a date group_by: the IANA time zone of the buckets, default UTC")
|
|
623
|
+
query("time_zone", "For a date group_by: the IANA time zone of the buckets, default UTC"),
|
|
624
|
+
query("split_by", "For a date group_by: a filterable field of values that splits each bucket into parts; an index must serve it"),
|
|
625
|
+
query("split_limit", "For a date group_by: how many values of `split_by` to keep, the largest over the range (by the sum, else by count), default 8, at most 100", { type: "integer" }),
|
|
626
|
+
query("split_label", "For a date group_by with a relation `split_by`: the field of the related resource whose value each split carries as `label`")
|
|
624
627
|
],
|
|
625
628
|
responses: {
|
|
626
629
|
200: {
|
|
@@ -631,6 +634,21 @@ const resourcePaths = (access) => {
|
|
|
631
634
|
group_by: { type: "string" },
|
|
632
635
|
granularity: { type: "string" },
|
|
633
636
|
time_zone: { type: "string" },
|
|
637
|
+
split_by: {
|
|
638
|
+
type: "string",
|
|
639
|
+
description: "Only with `split_by`"
|
|
640
|
+
},
|
|
641
|
+
splits: {
|
|
642
|
+
type: "array",
|
|
643
|
+
description: "Only with `split_by`: the kept values, largest first",
|
|
644
|
+
items: {
|
|
645
|
+
type: "object",
|
|
646
|
+
properties: {
|
|
647
|
+
value: {},
|
|
648
|
+
label: { type: "string" }
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
},
|
|
634
652
|
groups: {
|
|
635
653
|
type: "array",
|
|
636
654
|
items: {
|
|
@@ -640,7 +658,20 @@ const resourcePaths = (access) => {
|
|
|
640
658
|
label: { type: "string" },
|
|
641
659
|
count: { type: "integer" },
|
|
642
660
|
sum: { type: ["number", "null"] },
|
|
643
|
-
average: { type: ["number", "null"] }
|
|
661
|
+
average: { type: ["number", "null"] },
|
|
662
|
+
splits: {
|
|
663
|
+
type: "array",
|
|
664
|
+
description: "Only with `split_by`: the kept values that have rows in the group, in rank order",
|
|
665
|
+
items: {
|
|
666
|
+
type: "object",
|
|
667
|
+
properties: {
|
|
668
|
+
value: {},
|
|
669
|
+
count: { type: "integer" },
|
|
670
|
+
sum: { type: ["number", "null"] },
|
|
671
|
+
average: { type: ["number", "null"] }
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
}
|
|
644
675
|
}
|
|
645
676
|
}
|
|
646
677
|
}
|
package/dist/view-access.js
CHANGED
|
@@ -255,41 +255,54 @@ const actionShape = (model, models) => shapes.object({
|
|
|
255
255
|
}),
|
|
256
256
|
inputs: inputsShape(models)
|
|
257
257
|
}, { requires: ["run", "inputs"] });
|
|
258
|
-
const viewShape = (resource, model, models, prompted) =>
|
|
259
|
-
|
|
260
|
-
roles: shapes.data,
|
|
261
|
-
title: shapes.field,
|
|
262
|
-
searchResult: shapes.object({
|
|
263
|
-
title: shapes.list(shapes.field, { nonEmpty: true }),
|
|
264
|
-
subtitle: shapes.field
|
|
265
|
-
}),
|
|
266
|
-
nav: shapes.object({
|
|
258
|
+
const viewShape = (resource, model, models, prompted) => {
|
|
259
|
+
const nav = shapes.object({
|
|
267
260
|
hidden: shapes.data,
|
|
268
261
|
group: shapes.data,
|
|
269
262
|
order: shapes.data,
|
|
270
263
|
recent: shapes.map((recent) => navRecentErrors(recent, model).length === 0 ? recent : void 0)
|
|
271
|
-
})
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
264
|
+
});
|
|
265
|
+
const chart = shapes.map((chart) => chartFields(chart).every((name) => Object.hasOwn(model.fields, name)) ? chart : void 0);
|
|
266
|
+
return shapes.object({
|
|
267
|
+
resource: shapes.data,
|
|
268
|
+
roles: shapes.data,
|
|
269
|
+
title: shapes.field,
|
|
270
|
+
searchResult: shapes.object({
|
|
271
|
+
title: shapes.list(shapes.field, { nonEmpty: true }),
|
|
272
|
+
subtitle: shapes.field
|
|
273
|
+
}),
|
|
274
|
+
nav,
|
|
275
|
+
names: shapes.data,
|
|
276
|
+
icon: shapes.data,
|
|
277
|
+
help: shapes.data,
|
|
278
|
+
fields: shapes.keys(),
|
|
279
|
+
list: listShape(resource, model, models, prompted),
|
|
280
|
+
filters: shapes.list(widgetShape),
|
|
281
|
+
layout: shapes.list(layoutShape(resource, model, models)),
|
|
282
|
+
chart,
|
|
283
|
+
recordMode: shapes.data,
|
|
284
|
+
helpWhen: shapes.data,
|
|
285
|
+
saveFeedback: shapes.data,
|
|
286
|
+
actions: shapes.list(actionShape(model, models)),
|
|
287
|
+
listOf: shapes.field,
|
|
288
|
+
signIn: shapes.data,
|
|
289
|
+
saveSearch: shapes.map((search) => prompted.has(search.resource) || models[search.resource] ? search : void 0),
|
|
290
|
+
searchesOf: shapes.map((searches) => searchesOfFor(searches, resource, models)),
|
|
291
|
+
import: shapes.data,
|
|
292
|
+
export: shapes.data,
|
|
293
|
+
create: shapes.data,
|
|
294
|
+
forms: shapes.map(() => void 0),
|
|
295
|
+
lists: shapes.list(shapes.object({
|
|
296
|
+
name: shapes.data,
|
|
297
|
+
names: shapes.data,
|
|
298
|
+
nav,
|
|
299
|
+
help: shapes.data,
|
|
300
|
+
list: listShape(resource, model, models, prompted),
|
|
301
|
+
filters: shapes.list(widgetShape),
|
|
302
|
+
chart
|
|
303
|
+
}))
|
|
304
|
+
});
|
|
305
|
+
};
|
|
293
306
|
/**
|
|
294
307
|
* The view with every reference to a field the caller cannot read removed: list columns, sort, search and displays,
|
|
295
308
|
* field hints, filter widgets, record sections, maps, galleries and custom components' props, the chart, title and search result, the
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@protobase/server",
|
|
3
|
-
"version": "0.22.
|
|
3
|
+
"version": "0.22.7",
|
|
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": {
|
|
@@ -31,9 +31,9 @@
|
|
|
31
31
|
"dependencies": {
|
|
32
32
|
"@better-auth/passkey": "^1.7.7",
|
|
33
33
|
"@better-auth/sso": "^1.7.7",
|
|
34
|
-
"@protobase/layout": "0.22.
|
|
35
|
-
"@protobase/query": "0.22.
|
|
36
|
-
"@protobase/schema": "0.22.
|
|
34
|
+
"@protobase/layout": "0.22.7",
|
|
35
|
+
"@protobase/query": "0.22.7",
|
|
36
|
+
"@protobase/schema": "0.22.7",
|
|
37
37
|
"@react-email/render": "^2.1.0",
|
|
38
38
|
"@scalar/hono-api-reference": "^0.12.10",
|
|
39
39
|
"@sentry/core": "^11.7.0",
|