@protobase/server 0.19.16 → 0.20.0

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.
@@ -0,0 +1,137 @@
1
+ import { badRequest } from "./problem.js";
2
+ import { readTransaction } from "./transactions.js";
3
+ import { parseParams } from "./params.js";
4
+ import { labelRequests, relatedLabels } from "./related-labels.js";
5
+ import { scoped, seriesRange } from "./aggregate-service.js";
6
+ import { groupTotals, series } from "@protobase/query";
7
+ import { z } from "zod";
8
+ //#region src/aggregate-groups.ts
9
+ const name = z.string().min(1);
10
+ const aggregateParams = z.object({
11
+ group_by: name,
12
+ filter: z.string().optional(),
13
+ sum: name.optional(),
14
+ average: name.optional(),
15
+ limit: z.coerce.number().int().optional(),
16
+ label: name.optional(),
17
+ range: z.enum([
18
+ "7d",
19
+ "30d",
20
+ "90d",
21
+ "1y",
22
+ "all"
23
+ ]).optional(),
24
+ from: z.string().optional(),
25
+ to: z.string().optional(),
26
+ granularity: z.enum([
27
+ "hour",
28
+ "day",
29
+ "week",
30
+ "month",
31
+ "year"
32
+ ]).optional(),
33
+ time_zone: z.string().optional()
34
+ });
35
+ const invalid = (parameter, message) => badRequest("invalid-parameter", `Invalid parameter ${parameter}: ${message}`, { errors: [{
36
+ parameter,
37
+ message
38
+ }] });
39
+ const measureOf = (params) => {
40
+ if (params.sum !== void 0 && params.average !== void 0) throw invalid("average", "Give sum or average, not both");
41
+ if (params.sum !== void 0) return {
42
+ kind: "sum",
43
+ field: params.sum
44
+ };
45
+ if (params.average !== void 0) return {
46
+ kind: "average",
47
+ field: params.average
48
+ };
49
+ };
50
+ const optionErrors = (params, dated) => {
51
+ const given = (names) => names.filter((option) => params[option] !== void 0);
52
+ if (dated) return given(["limit", "label"]).map((option) => invalid(option, `Not for a date or timestamp group_by`));
53
+ return given([
54
+ "range",
55
+ "from",
56
+ "to",
57
+ "granularity",
58
+ "time_zone"
59
+ ]).map((option) => invalid(option, "Only for a date or timestamp group_by"));
60
+ };
61
+ const isDated = (model, field) => {
62
+ const type = model.fields[field]?.type;
63
+ return type === "date" || type === "timestamp";
64
+ };
65
+ /**
66
+ * Records per value of a field, or per hour, day, week, month or year of a date field with the empty ones included,
67
+ * each with how many there are and, with `sum` or `average`, that of a numeric field. Read as a list is: the caller's
68
+ * filter on the model as they see it, inside their tenant and row filter; the scan guard judges the grouped field.
69
+ */
70
+ const aggregate = async (deps, entry, session, input) => {
71
+ const params = parseParams(aggregateParams, input);
72
+ const { access, model, filter, scope } = await scoped(deps, entry, session, params.filter);
73
+ if (!Object.hasOwn(model.fields, params.group_by)) throw invalid("group_by", `Unknown field "${params.group_by}"`);
74
+ const measure = measureOf(params);
75
+ const dated = isDated(model, params.group_by);
76
+ const [problem] = optionErrors(params, dated);
77
+ if (problem) throw problem;
78
+ const warning = await deps.guard(entry, {
79
+ ...filter && { filter },
80
+ groupBy: params.group_by
81
+ }, {
82
+ model,
83
+ scope
84
+ });
85
+ if (dated) {
86
+ const granularity = params.granularity ?? "day";
87
+ const timeZone = params.time_zone ?? "UTC";
88
+ const range = await seriesRange(deps, access, params.group_by, params, scope);
89
+ const groups = (range ? await readTransaction(deps.db, deps.statementTimeoutMs, (trx) => series(trx, model, params.group_by, {
90
+ range,
91
+ granularity,
92
+ timeZone,
93
+ ...filter && { filter },
94
+ ...measure && { measure },
95
+ scope
96
+ })) : []).map(({ bucket, ...totals }) => ({
97
+ value: bucket,
98
+ ...totals
99
+ }));
100
+ return {
101
+ body: {
102
+ group_by: params.group_by,
103
+ granularity,
104
+ time_zone: timeZone,
105
+ groups
106
+ },
107
+ warning
108
+ };
109
+ }
110
+ const labels = params.label === void 0 ? [] : await labelRequests(deps, session, model, [params.group_by], `${params.group_by}.${params.label}`);
111
+ return {
112
+ body: await readTransaction(deps.db, deps.statementTimeoutMs, async (trx) => {
113
+ const totals = await groupTotals(trx, model, params.group_by, {
114
+ statementTimeoutMs: deps.statementTimeoutMs,
115
+ ...filter && { filter },
116
+ ...measure && { measure },
117
+ ...params.limit !== void 0 && { limit: params.limit },
118
+ scope
119
+ });
120
+ const names = (await relatedLabels(trx, labels, totals.map((group) => ({ [params.group_by]: group.value }))))[params.group_by] ?? {};
121
+ const groups = totals.map((group) => {
122
+ const label = group.value === null ? void 0 : names[String(group.value)];
123
+ return label === void 0 ? group : {
124
+ ...group,
125
+ label
126
+ };
127
+ });
128
+ return {
129
+ group_by: params.group_by,
130
+ groups
131
+ };
132
+ }),
133
+ warning
134
+ };
135
+ };
136
+ //#endregion
137
+ export { aggregate };
@@ -103,25 +103,26 @@ const earliest = async (deps, access, fieldName, scope) => {
103
103
  return (await readTransaction(deps.db, deps.statementTimeoutMs, (trx) => sql`select min(${columnId(target)})::text as lowest from ${tableId(access.full)} as t${whereClause(conditions)}`.execute(trx))).rows[0]?.lowest ?? void 0;
104
104
  };
105
105
  const bound = (model, fieldName, value) => model.fields[fieldName]?.type === "date" ? value.slice(0, 10) : value;
106
- const seriesRange = async (deps, access, params, scope) => {
106
+ /** The span a series of `field` covers: `from` and `to`, else the `range` up to now, `all` from the earliest row. */
107
+ const seriesRange = async (deps, access, field, params, scope) => {
107
108
  const to = params.to ?? (/* @__PURE__ */ new Date()).toISOString();
108
109
  const { model } = access;
109
110
  if (params.from) return {
110
- from: bound(model, params.field, params.from),
111
- to: bound(model, params.field, to)
111
+ from: bound(model, field, params.from),
112
+ to: bound(model, field, to)
112
113
  };
113
114
  const range = params.range ?? "30d";
114
115
  if (range !== "all") {
115
116
  const from = (/* @__PURE__ */ new Date(Date.now() - rangeDays[range] * 864e5)).toISOString();
116
117
  return {
117
- from: bound(model, params.field, from),
118
- to: bound(model, params.field, to)
118
+ from: bound(model, field, from),
119
+ to: bound(model, field, to)
119
120
  };
120
121
  }
121
- const lowest = await earliest(deps, access, params.field, scope);
122
+ const lowest = await earliest(deps, access, field, scope);
122
123
  return lowest ? {
123
- from: bound(model, params.field, lowest),
124
- to: bound(model, params.field, to)
124
+ from: bound(model, field, lowest),
125
+ to: bound(model, field, to)
125
126
  } : void 0;
126
127
  };
127
128
  /** Row counts per calendar bucket of a date or timestamp field, empty buckets included. */
@@ -132,7 +133,7 @@ const timeSeries = async (deps, entry, session, input) => {
132
133
  parameter: "field",
133
134
  message: "Unknown field"
134
135
  }] });
135
- const range = await seriesRange(deps, access, params, scope);
136
+ const range = await seriesRange(deps, access, params.field, params, scope);
136
137
  const timeZone = params.time_zone ?? "UTC";
137
138
  const points = range ? await readTransaction(deps.db, deps.statementTimeoutMs, (trx) => series(trx, model, params.field, {
138
139
  range,
@@ -159,4 +160,4 @@ const numericHistogram = async (deps, entry, session, input) => {
159
160
  };
160
161
  };
161
162
  //#endregion
162
- export { facets, numericHistogram, timeSeries };
163
+ export { facets, numericHistogram, scoped, seriesRange, timeSeries };
@@ -1,3 +1,4 @@
1
+ import { isGroupChart } from "@protobase/schema/internal";
1
2
  //#region src/check-view-queries.ts
2
3
  const widgetName = {
3
4
  facets: "facets filter",
@@ -24,7 +25,7 @@ const searchErrors = (model, view) => (view.list?.search ?? []).filter((name) =>
24
25
  */
25
26
  const viewQueryErrors = (view, model) => [
26
27
  ...view.filters.flatMap((widget) => filterableErrors(model, widget.kind === "flags" ? widget.fields : [widget.field], `the ${widgetName[widget.kind]}`)),
27
- ...view.chart ? filterableErrors(model, [view.chart.field], "the chart") : [],
28
+ ...view.chart ? filterableErrors(model, [isGroupChart(view.chart) ? view.chart.groupBy : view.chart.field], "the chart") : [],
28
29
  ...listSortErrors(model, view),
29
30
  ...searchErrors(model, view)
30
31
  ];
@@ -2,10 +2,11 @@ import { relatedErrors } from "./view-related.js";
2
2
  import { cardPhotosErrors } from "./view-card-photos.js";
3
3
  import { cardFavoriteErrors, favoriteOwnerErrors } from "./view-card-favorite.js";
4
4
  import { saveSearchErrors, searchesOfErrors } from "./view-saved-search.js";
5
+ import { chartErrors } from "./view-chart.js";
5
6
  import { mapDisplayErrors, mapErrors } from "./view-map.js";
6
7
  import { relatedColumnErrors } from "./view-related-column.js";
7
8
  import { editableErrors } from "./view-editable.js";
8
- import { actionInputErrors, addressErrors, addressPlaceholders, boardErrors, displayFields, fieldRefsIn, hrefField, hrefPlaceholders, linkActionError } from "@protobase/schema/internal";
9
+ import { actionInputErrors, addressErrors, addressPlaceholders, boardErrors, chartFields, displayFields, fieldRefsIn, hrefField, hrefPlaceholders, linkActionError } from "@protobase/schema/internal";
9
10
  //#region src/check-views.ts
10
11
  const linkFields = (run) => run?.kind !== "link" ? [] : "to" in run ? addressPlaceholders(run.to) : hrefPlaceholders(run.href);
11
12
  const sensitiveErrors = (view, model) => {
@@ -20,7 +21,7 @@ const sensitiveErrors = (view, model) => {
20
21
  ]],
21
22
  ["a list display", (view.list?.displays ?? []).flatMap(displayFields)],
22
23
  ["a filter", view.filters.flatMap((widget) => widget.kind === "flags" ? widget.fields : [widget.field])],
23
- ["the chart", view.chart ? [view.chart.field] : []],
24
+ ["the chart", view.chart ? chartFields(view.chart) : []],
24
25
  ["the sidebar", view.layout.flatMap((item) => item.kind === "sidebar" ? item.fields : [])],
25
26
  ["a map", view.layout.flatMap((item) => item.kind === "map" ? [item.latitude, item.longitude] : [])],
26
27
  ["a gallery", view.layout.flatMap((item) => item.kind === "gallery" && item.image !== void 0 ? [item.image] : [])],
@@ -111,7 +112,7 @@ const listOfErrors = (model, name, models) => {
111
112
  * edits a list column in its cell that is not a field with an editor of one line the API may write,
112
113
  * has 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
113
114
  * `searchesOf` that do not fit the resources, cards
114
- * displays of one list that name different photos, a map that cannot show the
115
+ * 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
115
116
  * record's position or a map display that cannot find the records in its view, a board whose columns are no filterable enum, a link to an address the app does not have, action inputs that do not fit, or a create page that is not one of `pages`.
116
117
  */
117
118
  const checkViews = (registry, views, pages = [], fieldTypes = {}) => {
@@ -131,6 +132,7 @@ const checkViews = (registry, views, pages = [], fieldTypes = {}) => {
131
132
  const searches = [...view.saveSearch ? saveSearchErrors(view.saveSearch, view.resource, models, views).map((error) => `saveSearch: ${error}`) : [], ...view.searchesOf ? searchesOfErrors(view.searchesOf, view.resource, models).map((error) => `searchesOf: ${error}`) : []];
132
133
  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}`) : [])];
133
134
  const boards = (view.list?.displays ?? []).flatMap((display) => display.kind === "board" ? boardErrors(display, model).map((error) => `display "${display.name}": ${error}`) : []);
135
+ const chart = view.chart ? chartErrors(view.chart, model).map((error) => `chart: ${error}`) : [];
134
136
  return [
135
137
  ...sensitiveErrors(view, model),
136
138
  ...unknownFieldErrors(view, model),
@@ -146,6 +148,7 @@ const checkViews = (registry, views, pages = [], fieldTypes = {}) => {
146
148
  ...searches,
147
149
  ...maps,
148
150
  ...boards,
151
+ ...chart,
149
152
  ...linkErrors(view, model, models, pageNames),
150
153
  ...inputErrors(view, models),
151
154
  ...createErrors(view, pageNames)
@@ -130,6 +130,7 @@ const needs = {
130
130
  list: "list",
131
131
  search: "list",
132
132
  facets: "list",
133
+ aggregate: "list",
133
134
  series: "list",
134
135
  histogram: "list",
135
136
  map: "list",
@@ -363,6 +364,71 @@ const resourcePaths = (access) => {
363
364
  ...auth
364
365
  }
365
366
  } },
367
+ [`/${entry.name}:aggregate`]: { get: {
368
+ operationId: `aggregate_${entry.name}`,
369
+ tags: tag,
370
+ summary: `Totals per group of ${entry.name} records`,
371
+ 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.",
372
+ parameters: [
373
+ query("group_by", "A filterable field", { type: "string" }, true),
374
+ param("filter"),
375
+ query("sum", "A numeric field to add up per group"),
376
+ query("average", "A numeric field to average per group, instead of `sum`"),
377
+ query("limit", "Most value groups to return, default 100, at most 1000", { type: "integer" }),
378
+ query("label", "For a relation to a single-key resource: the field of that resource whose value each group carries as `label`"),
379
+ query("granularity", "For a date group_by: the bucket, default day", {
380
+ type: "string",
381
+ enum: [
382
+ "hour",
383
+ "day",
384
+ "week",
385
+ "month",
386
+ "year"
387
+ ]
388
+ }),
389
+ query("range", "For a date group_by: the relative range ending now (default 30d); `all` starts at the earliest row", {
390
+ type: "string",
391
+ enum: [
392
+ "7d",
393
+ "30d",
394
+ "90d",
395
+ "1y",
396
+ "all"
397
+ ]
398
+ }),
399
+ query("from", "For a date group_by: the explicit start (ISO 8601); with `to`, replaces range"),
400
+ query("to", "For a date group_by: the exclusive end (ISO 8601), default now"),
401
+ query("time_zone", "For a date group_by: the IANA time zone of the buckets, default UTC")
402
+ ],
403
+ responses: {
404
+ 200: {
405
+ description: "The groups; a date group's `value` is its bucket in local ISO time",
406
+ content: json({
407
+ type: "object",
408
+ properties: {
409
+ group_by: { type: "string" },
410
+ granularity: { type: "string" },
411
+ time_zone: { type: "string" },
412
+ groups: {
413
+ type: "array",
414
+ items: {
415
+ type: "object",
416
+ properties: {
417
+ value: {},
418
+ label: { type: "string" },
419
+ count: { type: "integer" },
420
+ sum: { type: ["number", "null"] },
421
+ average: { type: ["number", "null"] }
422
+ }
423
+ }
424
+ }
425
+ }
426
+ })
427
+ },
428
+ 400: error("BadRequest"),
429
+ ...auth
430
+ }
431
+ } },
366
432
  [`/${entry.name}:series`]: { get: {
367
433
  operationId: `series_${entry.name}`,
368
434
  tags: tag,
@@ -1,6 +1,7 @@
1
1
  import { runWrite } from "../write-pipeline.js";
2
2
  import { listRecords } from "../list-service.js";
3
3
  import { facets, numericHistogram, timeSeries } from "../aggregate-service.js";
4
+ import { aggregate } from "../aggregate-groups.js";
4
5
  import { mapRecords } from "../map-service.js";
5
6
  import { uploadFile } from "../files/upload.js";
6
7
  import { returnPreference } from "./preferences.js";
@@ -25,6 +26,10 @@ const collectionRoutes = (deps) => {
25
26
  const { body, warning } = await facets(deps, entry, session, query);
26
27
  return c.json(body, 200, warningHeader(warning));
27
28
  }
29
+ if (action === "aggregate") {
30
+ const { body, warning } = await aggregate(deps, entry, session, query);
31
+ return c.json(body, 200, warningHeader(warning));
32
+ }
28
33
  if (action === "series") return c.json(await timeSeries(deps, entry, session, query));
29
34
  if (action === "histogram") return c.json(await numericHistogram(deps, entry, session, query));
30
35
  if (action === "map") {
@@ -4,7 +4,7 @@ import { cardFavoriteErrors } from "./view-card-favorite.js";
4
4
  import { searchesOfFor } from "./view-saved-search.js";
5
5
  import { relatedColumnErrors } from "./view-related-column.js";
6
6
  import { resolveFieldPath } from "@protobase/layout";
7
- import { addressErrors, hrefPlaceholders, navRecentErrors, pruneByShape, shapes, tableDisplay } from "@protobase/schema/internal";
7
+ import { addressErrors, chartFields, hrefPlaceholders, navRecentErrors, pruneByShape, shapes, tableDisplay } from "@protobase/schema/internal";
8
8
  //#region src/view-access.ts
9
9
  const pruneRelated = (item, resource, models) => {
10
10
  const { columns, ...rest } = item;
@@ -218,11 +218,7 @@ const viewShape = (resource, model, models, prompted) => shapes.object({
218
218
  list: listShape(resource, model, models, prompted),
219
219
  filters: shapes.list(widgetShape),
220
220
  layout: shapes.list(layoutShape(resource, model, models)),
221
- chart: shapes.object({
222
- field: shapes.field,
223
- range: shapes.data,
224
- granularity: shapes.data
225
- }, { requires: ["field"] }),
221
+ chart: shapes.map((chart) => chartFields(chart).every((name) => Object.hasOwn(model.fields, name)) ? chart : void 0),
226
222
  recordMode: shapes.data,
227
223
  helpWhen: shapes.data,
228
224
  saveFeedback: shapes.data,
@@ -0,0 +1,33 @@
1
+ import { isGroupChart } from "@protobase/schema/internal";
2
+ //#region src/view-chart.ts
3
+ const numericTypes = [
4
+ "integer",
5
+ "bigint",
6
+ "decimal"
7
+ ];
8
+ const ungroupable = [
9
+ "date",
10
+ "timestamp",
11
+ "json",
12
+ "file"
13
+ ];
14
+ /**
15
+ * What is wrong with a list's chart per value of a field: a field to group by that has no values to share out, or one
16
+ * to add up that is no number. The filterable and sensitive checks of every chart are with the other fields'.
17
+ */
18
+ const chartErrors = (chart, model) => {
19
+ if (!isGroupChart(chart)) return [];
20
+ const fieldOf = (name) => Object.hasOwn(model.fields, name) ? model.fields[name] : void 0;
21
+ const group = fieldOf(chart.groupBy);
22
+ const measured = chart.sum ?? chart.average;
23
+ const measure = measured === void 0 ? void 0 : fieldOf(measured);
24
+ return [
25
+ ...!group ? [`groupBy: unknown field "${chart.groupBy}" on ${model.name}`] : [],
26
+ ...group && ungroupable.includes(group.type) ? [`groupBy: "${chart.groupBy}" is a ${group.type} field; chart a date with { field, range, granularity }, and group by a field of values`] : [],
27
+ ...measured !== void 0 && !measure ? [`unknown field "${measured}" on ${model.name} to add up`] : [],
28
+ ...measure && !numericTypes.includes(measure.type) ? [`"${measured}" is ${measure.type}, and only a number can be added up`] : [],
29
+ ...chart.type === "donut" && chart.average !== void 0 ? ["a donut shares out a total, and averages add up to none: count or sum instead"] : []
30
+ ];
31
+ };
32
+ //#endregion
33
+ export { chartErrors };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@protobase/server",
3
- "version": "0.19.16",
3
+ "version": "0.20.0",
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.19.16",
34
- "@protobase/query": "0.19.16",
35
- "@protobase/schema": "0.19.16",
33
+ "@protobase/layout": "0.20.0",
34
+ "@protobase/query": "0.20.0",
35
+ "@protobase/schema": "0.20.0",
36
36
  "@react-email/render": "^2.1.0",
37
37
  "@scalar/hono-api-reference": "^0.12.10",
38
38
  "@sentry/core": "^11.7.0",