@protobase/server 0.28.1 → 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.
@@ -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
- if (params.sum !== void 0 && params.average !== void 0) throw invalid("average", "Give sum or average, not both");
45
- if (params.sum !== void 0) return {
46
- kind: "sum",
47
- field: params.sum
48
- };
49
- if (params.average !== void 0) return {
50
- kind: "average",
51
- field: params.average
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 `average`, that of a numeric field. Read as a list is: the caller's
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 rangeDays = {
47
- "7d": 7,
48
- "30d": 30,
49
- "90d": 90,
50
- "1y": 365
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 - rangeDays[range] * 864e5, timeZone),
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);
@@ -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 [
@@ -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 `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`.",
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",
@@ -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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@protobase/server",
3
- "version": "0.28.1",
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.1",
36
- "@protobase/query": "0.28.1",
37
- "@protobase/schema": "0.28.1",
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",