@protobase/server 0.19.15 → 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 };
@@ -77,6 +77,11 @@ export type CreateAuthOptions = {
77
77
  encryptOAuthTokens?: boolean;
78
78
  /** Runs after a user is created, by any route: `createUser`, an admin, or a social sign-up. Default none. */
79
79
  onUserCreated?: UserCreatedHook;
80
+ /**
81
+ * The id of a new user, by any route. Default Better Auth's random 32-character text. `.owner()` fields hold it: a
82
+ * `text` column for the default, or an integer column when this takes the next value of the app's own users sequence.
83
+ */
84
+ generateUserId?: () => string | Promise<string>;
80
85
  /**
81
86
  * The identity provider the operator's staff sign in with to sign in as people of this app, for support. Defaults
82
87
  * to the one the platform passes in `PROTOBASE_OPERATOR_ISSUER`, `PROTOBASE_OPERATOR_CLIENT_ID` and
@@ -3744,7 +3749,15 @@ export declare const createAuth: (options: CreateAuthOptions) => import("better-
3744
3749
  name?: string;
3745
3750
  slug?: string;
3746
3751
  logo?: string | null;
3747
- metadata?: Record<string, any>;
3752
+ metadata?
3753
+ /**
3754
+ * The options `createAuth` gives Better Auth, with the roles, default role, mailer, operator provider and platform
3755
+ * sign-in provider already resolved.
3756
+ */
3757
+ /**
3758
+ * The options `createAuth` gives Better Auth, with the roles, default role, mailer, operator provider and platform
3759
+ * sign-in provider already resolved.
3760
+ */ : Record<string, any>;
3748
3761
  [key: string]: any;
3749
3762
  };
3750
3763
  }>;
@@ -4354,6 +4367,8 @@ export declare const createAuth: (options: CreateAuthOptions) => import("better-
4354
4367
  roleDefinitions: RoleDefinitions;
4355
4368
  organizations: ResolvedOrganizations | undefined;
4356
4369
  defaultRole: string | undefined;
4370
+ /** New users get their ids from `generateUserId` instead of Better Auth's random text. */
4371
+ generatesUserIds: boolean;
4357
4372
  mail: boolean;
4358
4373
  passwordReset: boolean;
4359
4374
  /** Open sign-up is on and offered: the app asks for it, and can mail the codes. */
@@ -163,10 +163,14 @@ const betterAuthOptions = (options, { origins = [], passkeyDomain, roles, defini
163
163
  const given = String(user.role ?? "").split(",").map((name) => name.trim()).filter(Boolean);
164
164
  const unknown = given.find((name) => !roles.includes(name));
165
165
  if (given.length === 0 || unknown !== void 0) throw new APIError("BAD_REQUEST", { message: `Choose a role for the new user: ${roles.join(", ")}` });
166
- return { data: context?.path === "/admin/create-user" ? {
166
+ const withId = options.generateUserId ? {
167
167
  ...user,
168
+ id: await options.generateUserId()
169
+ } : user;
170
+ return { data: context?.path === "/admin/create-user" ? {
171
+ ...withId,
168
172
  emailVerified: true
169
- } : user };
173
+ } : withId };
170
174
  },
171
175
  ...options.onUserCreated && { after: options.onUserCreated }
172
176
  } },
@@ -266,6 +270,8 @@ const createAuth = (options) => {
266
270
  roleDefinitions: definitions,
267
271
  organizations,
268
272
  defaultRole,
273
+ /** New users get their ids from `generateUserId` instead of Better Auth's random text. */
274
+ generatesUserIds: Boolean(options.generateUserId),
269
275
  mail: Boolean(mailer),
270
276
  passwordReset: Boolean(mailer),
271
277
  /** Open sign-up is on and offered: the app asks for it, and can mail the codes. */
@@ -0,0 +1,15 @@
1
+ //#region src/check-owners.ts
2
+ /**
3
+ * Stops startup on an `.owner()` field that cannot hold the ids of the auth instance's users: Better Auth's own ids
4
+ * are random text, so the field must be text unless `generateUserId` makes the ids. Without an auth instance the
5
+ * ids come from the app's own authenticator, which this cannot see.
6
+ */
7
+ const checkOwnerFields = (registry, auth) => {
8
+ if (!auth || auth.generatesUserIds) return;
9
+ for (const entry of registry.entries) {
10
+ if (entry.ownerType === void 0 || entry.ownerType === "text") continue;
11
+ throw new Error(`${entry.name}.${entry.model.owner} is the owner of its records, so it holds the signed-in user's id, but it holds ${entry.ownerType} values and Better Auth's user ids are random text: make it a text column, or give createAuth a generateUserId that makes ${entry.ownerType} ids`);
12
+ }
13
+ };
14
+ //#endregion
15
+ export { checkOwnerFields };
@@ -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,9 +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
- import { actionInputErrors, addressErrors, addressPlaceholders, displayFields, fieldRefsIn, hrefField, hrefPlaceholders, linkActionError } from "@protobase/schema/internal";
8
+ import { editableErrors } from "./view-editable.js";
9
+ import { actionInputErrors, addressErrors, addressPlaceholders, boardErrors, chartFields, displayFields, fieldRefsIn, hrefField, hrefPlaceholders, linkActionError } from "@protobase/schema/internal";
8
10
  //#region src/check-views.ts
9
11
  const linkFields = (run) => run?.kind !== "link" ? [] : "to" in run ? addressPlaceholders(run.to) : hrefPlaceholders(run.href);
10
12
  const sensitiveErrors = (view, model) => {
@@ -19,7 +21,7 @@ const sensitiveErrors = (view, model) => {
19
21
  ]],
20
22
  ["a list display", (view.list?.displays ?? []).flatMap(displayFields)],
21
23
  ["a filter", view.filters.flatMap((widget) => widget.kind === "flags" ? widget.fields : [widget.field])],
22
- ["the chart", view.chart ? [view.chart.field] : []],
24
+ ["the chart", view.chart ? chartFields(view.chart) : []],
23
25
  ["the sidebar", view.layout.flatMap((item) => item.kind === "sidebar" ? item.fields : [])],
24
26
  ["a map", view.layout.flatMap((item) => item.kind === "map" ? [item.latitude, item.longitude] : [])],
25
27
  ["a gallery", view.layout.flatMap((item) => item.kind === "gallery" && item.image !== void 0 ? [item.image] : [])],
@@ -93,6 +95,7 @@ const relatedColumnsErrors = (view, model, models) => (view.list?.relatedColumns
93
95
  ...view.list.columns.includes(column.name) ? [] : ["it is not among the list's columns"],
94
96
  ...relatedColumnErrors(column, view.resource, models)
95
97
  ].map((error) => `related column "${column.name}": ${error}`));
98
+ const listEditErrors = (view, model) => (view.list?.editable ?? []).flatMap((name) => [...view.list.columns.includes(name) ? [] : ["it is not among the list's columns"], ...editableErrors(name, model, view.fields[name])].map((error) => `editable field "${name}": ${error}`));
96
99
  const galleryErrors = (item, resource, model, models) => {
97
100
  const image = item.image === void 0 ? void 0 : model.fields[item.image];
98
101
  return [...image && image.type !== "file" ? [`"${item.image}" is not a file field`] : [], ...item.photos ? cardPhotosErrors(item.photos, resource, models).map((error) => `photos: ${error}`) : []];
@@ -106,10 +109,11 @@ const listOfErrors = (model, name, models) => {
106
109
  * Fails at startup when a view uses a sensitive field where it would show empty, a display or custom section names a
107
110
  * field the resource does not have, labels or colours values its enum does not have, shows a field with a field type of
108
111
  * `fieldTypes` (each with the storage types it shows) that does not show it,
112
+ * edits a list column in its cell that is not a field with an editor of one line the API may write,
109
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
110
114
  * `searchesOf` that do not fit the resources, cards
111
- * displays of one list that name different photos, a map that cannot show the
112
- * record's position or a map display that cannot find the records in its view, 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`.
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
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`.
113
117
  */
114
118
  const checkViews = (registry, views, pages = [], fieldTypes = {}) => {
115
119
  const models = Object.fromEntries(registry.entries.map((entry) => [entry.name, entry.model]));
@@ -127,12 +131,15 @@ const checkViews = (registry, views, pages = [], fieldTypes = {}) => {
127
131
  const listOf = view.listOf === void 0 ? [] : listOfErrors(model, view.listOf, models).map((error) => `listOf: ${error}`);
128
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}`) : []];
129
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}`) : [])];
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}`) : [];
130
136
  return [
131
137
  ...sensitiveErrors(view, model),
132
138
  ...unknownFieldErrors(view, model),
133
139
  ...valueLabelErrors(view, model),
134
140
  ...valueColorErrors(view, model),
135
141
  ...textHintErrors(view, model),
142
+ ...listEditErrors(view, model),
136
143
  ...widgetErrors(view, model, fieldTypes),
137
144
  ...related,
138
145
  ...photos,
@@ -140,6 +147,8 @@ const checkViews = (registry, views, pages = [], fieldTypes = {}) => {
140
147
  ...listOf,
141
148
  ...searches,
142
149
  ...maps,
150
+ ...boards,
151
+ ...chart,
143
152
  ...linkErrors(view, model, models, pageNames),
144
153
  ...inputErrors(view, models),
145
154
  ...createErrors(view, pageNames)
@@ -16,6 +16,7 @@ import { webhookTable } from "./webhooks/table/webhook-table.js";
16
16
  import { subscriptionWriteCheck } from "./webhooks/table/validate-hook.js";
17
17
  import { withAnonymous } from "./anonymous.js";
18
18
  import { checkBottomBars, checkShell } from "./check-shell.js";
19
+ import { checkOwnerFields } from "./check-owners.js";
19
20
  import { checkRoleLimits } from "./check-roles.js";
20
21
  import { checkViews } from "./check-views.js";
21
22
  import { errorReportingSettings } from "./error-reporting/error-reporting-settings.js";
@@ -59,6 +60,7 @@ const createAdmin = ({ resources: appResources, views: appViews = [], pages = []
59
60
  checkBottomBars(registry, bottomBarModels, pageModels.map((entry) => entry.name), declaredRoles(auth, options.roles));
60
61
  checkViews(registry, viewModels, pageModels.map((entry) => entry.name), fieldTypes);
61
62
  checkRoleLimits(registry, options.roles?.limits ?? []);
63
+ checkOwnerFields(registry, auth);
62
64
  checkPages(pageModels, {
63
65
  models: Object.fromEntries(registry.entries.map((entry) => [entry.name, entry.model])),
64
66
  actions: (resource) => new Set(viewModels.filter((view) => view.resource === resource).flatMap((view) => view.actions.map((action) => action.name)))
@@ -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,
@@ -0,0 +1,21 @@
1
+ import { HttpProblem } from "./problem.js";
2
+ import { fitsKeyType } from "@protobase/schema";
3
+ //#region src/owner-id.ts
4
+ const mentions = (filter, name) => {
5
+ if (filter.kind === "and" || filter.kind === "or") return filter.args.some((arg) => mentions(arg, name));
6
+ if (filter.kind === "not") return mentions(filter.arg, name);
7
+ return "field" in filter && filter.field.name === name;
8
+ };
9
+ /**
10
+ * Refuses a request whose row filters compare the `.owner()` field with a user id it cannot hold, such as Better
11
+ * Auth's random text in an integer column, naming the field, instead of failing as a value of the wrong format.
12
+ */
13
+ const checkOwnerId = (entry, user, resolved) => {
14
+ const { ownerType, model } = entry;
15
+ if (ownerType === void 0 || model.owner === void 0 || fitsKeyType(String(user.id), ownerType)) return;
16
+ const owner = model.owner;
17
+ if (!Object.values(resolved.rowFilter).some((filter) => filter && mentions(filter, owner))) return;
18
+ throw new HttpProblem(500, "owner-id-mismatch", "Internal Server Error", `${entry.name}.${owner} holds ${ownerType} user ids, and the signed-in user's id "${user.id}" is not one, so their own records cannot be found`);
19
+ };
20
+ //#endregion
21
+ export { checkOwnerId };
package/dist/registry.js CHANGED
@@ -13,6 +13,14 @@ const etagSource = (model) => {
13
13
  kind: "timestamp"
14
14
  };
15
15
  };
16
+ const isKeyType = (type) => type === "text" || type === "integer" || type === "bigint" || type === "uuid";
17
+ const ownerType = (model, resolve) => {
18
+ const field = model.owner === void 0 ? void 0 : model.fields[model.owner];
19
+ if (!field) return void 0;
20
+ if (isKeyType(field.type)) return field.type;
21
+ const target = field.type === "relation" && field.relation ? resolve(field.relation.resource) : void 0;
22
+ return target && keyTypes(target, resolve)[0];
23
+ };
16
24
  const buildRegistry = (resources) => {
17
25
  const built = resources.map((source) => ({
18
26
  source,
@@ -25,11 +33,13 @@ const buildRegistry = (resources) => {
25
33
  const types = keyTypes(model, (name) => models.get(name));
26
34
  if (types.length !== model.primaryKey.length) throw new Error(`Not implemented: resource "${model.name}" has a primary key field that points at a composite-key resource; the server maps one key part per key field`);
27
35
  const etag = etagSource(model);
36
+ const owner = ownerType(model, (name) => models.get(name));
28
37
  return {
29
38
  name: model.name,
30
39
  source,
31
40
  model,
32
41
  keyTypes: types,
42
+ ...owner && { ownerType: owner },
33
43
  ...etag && { etag }
34
44
  };
35
45
  });
@@ -1,5 +1,6 @@
1
1
  import { forbidden, notFound, unauthorized } from "./problem.js";
2
2
  import { checkedFilter } from "./filter-input.js";
3
+ import { checkOwnerId } from "./owner-id.js";
3
4
  import { tenantScope } from "./tenant.js";
4
5
  import { checkFilter, resolveAccess, restrictModel } from "@protobase/schema";
5
6
  //#region src/request-access.ts
@@ -16,6 +17,7 @@ const requestAccess = async (deps, entry, session) => {
16
17
  },
17
18
  ...organizationOf(session)
18
19
  }, deps.accessOptions);
20
+ checkOwnerId(entry, session.user, resolved);
19
21
  const readable = restrictModel(entry.model, resolved);
20
22
  return {
21
23
  entry,
@@ -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,10 +4,13 @@ import { writableFields } from "./request-access.js";
4
4
  const fail = (errors) => badRequest("invalid-record", `The request body is invalid: ${errors[0].field}: ${errors[0].message}`, { errors });
5
5
  const isObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
6
6
  const reserved = ["etag", "permissions"];
7
+ const isEmptyText = (field, value) => field.type === "text" && !field.nullable && value === "";
7
8
  /**
8
9
  * Checks a create or update body for this caller. A field the caller cannot read is an unknown field, one they can read
9
10
  * but not write is read-only (the tenant column and the API's own `etag` and `permissions` keys are always read-only);
10
- * every provided value goes through its field's zod schema, then `.validate()` rules run on the result.
11
+ * every provided value goes through its field's zod schema, then `.validate()` rules run on the result. An empty text
12
+ * for a text field that may not be null counts as not given: a create fills in its default or reports it required,
13
+ * an update reports it required. Only the values written are checked, so a stored '' still reads.
11
14
  * Returns only the values to write: on create the defaults are filled in, on update only the provided fields.
12
15
  * `current` is the stored record an update merges into for `.validate()` rules.
13
16
  */
@@ -36,7 +39,16 @@ const validateBody = (access, mode, body, current = {}) => {
36
39
  for (const [name, source] of Object.entries(entry.source.state.fields ?? {})) {
37
40
  const field = full.fields[name];
38
41
  if (!source || !field || !writable.has(name) || name === full.tenant) continue;
39
- const provided = Object.hasOwn(body, name);
42
+ const empty = Object.hasOwn(body, name) && isEmptyText(field, body[name]);
43
+ if (empty && mode === "update") {
44
+ errors.push({
45
+ field: name,
46
+ code: "required",
47
+ message: `"${name}" is required`
48
+ });
49
+ continue;
50
+ }
51
+ const provided = Object.hasOwn(body, name) && !empty;
40
52
  if (!provided && mode === "update") continue;
41
53
  if (!provided && field.default && "db" in field.default) continue;
42
54
  const parsed = source.schema.safeParse(provided ? body[name] : void 0);
@@ -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;
@@ -27,15 +27,26 @@ const pruneGallery = (item, resource, model, models) => {
27
27
  ...item.when !== void 0 && { when: item.when }
28
28
  };
29
29
  };
30
- const tidyList = (model) => ({ default: opens, displays, columns, relatedColumns, ...list }) => {
31
- const shown = displays && displays.length > 0 ? displays : void 0;
32
- const kept = opens === tableDisplay || shown?.some((display) => display.name === opens);
30
+ const withKeptDots = (display, related = []) => {
31
+ if (display.kind !== "cards" || display.dots === void 0) return display;
32
+ const { dots, ...card } = display;
33
+ const kept = dots.filter((name) => related.some((column) => column.name === name));
34
+ return kept.length > 0 ? {
35
+ ...card,
36
+ dots: kept
37
+ } : card;
38
+ };
39
+ const tidyList = (model) => ({ default: opens, displays, columns, relatedColumns, editable, ...list }) => {
33
40
  const related = relatedColumns && relatedColumns.length > 0 ? relatedColumns : void 0;
41
+ const shown = displays && displays.length > 0 ? displays.map((display) => withKeptDots(display, related)) : void 0;
42
+ const kept = opens === tableDisplay || shown?.some((display) => display.name === opens);
34
43
  const visible = columns.filter((name) => Object.hasOwn(model.fields, name) || related?.some((column) => column.name === name));
44
+ const writable = editable?.filter((name) => visible.includes(name) && !model.fields[name].readOnly);
35
45
  return {
36
46
  ...list,
37
47
  columns: visible,
38
48
  ...related && { relatedColumns: related },
49
+ ...writable && writable.length > 0 && { editable: writable },
39
50
  ...shown && { displays: shown },
40
51
  ...opens !== void 0 && kept && { default: opens }
41
52
  };
@@ -45,6 +56,7 @@ const listShape = (resource, model, models, prompted) => shapes.object({
45
56
  relatedColumns: shapes.list(shapes.map((column) => relatedColumnErrors(column, resource, models).length === 0 ? column : void 0)),
46
57
  sort: shapes.list(shapes.tuple(shapes.field, shapes.data)),
47
58
  search: shapes.list(shapes.field),
59
+ editable: shapes.list(shapes.field),
48
60
  displays: shapes.list(shapes.oneOf({
49
61
  cards: shapes.object({
50
62
  kind: shapes.data,
@@ -59,6 +71,7 @@ const listShape = (resource, model, models, prompted) => shapes.object({
59
71
  meta: shapes.list(shapes.field),
60
72
  footer: shapes.list(shapes.field),
61
73
  flags: shapes.list(shapes.field),
74
+ dots: shapes.data,
62
75
  card: shapes.data,
63
76
  photos: shapes.map((photos) => cardPhotosErrors(photos, resource, models).length === 0 ? photos : void 0),
64
77
  favorite: shapes.map((favorite) => prompted.has(favorite.resource) || cardFavoriteErrors(favorite, resource, models).length === 0 ? favorite : void 0)
@@ -77,6 +90,17 @@ const listShape = (resource, model, models, prompted) => shapes.object({
77
90
  defaultRadius: shapes.data,
78
91
  area: shapes.field
79
92
  }, { requires: ["latitude", "longitude"] }),
93
+ board: shapes.object({
94
+ kind: shapes.data,
95
+ name: shapes.data,
96
+ label: shapes.data,
97
+ icon: shapes.data,
98
+ by: shapes.field,
99
+ title: shapes.field,
100
+ subtitle: shapes.field,
101
+ badge: shapes.field,
102
+ meta: shapes.list(shapes.field)
103
+ }, { requires: ["by"] }),
80
104
  custom: shapes.object({
81
105
  kind: shapes.data,
82
106
  name: shapes.data,
@@ -194,11 +218,7 @@ const viewShape = (resource, model, models, prompted) => shapes.object({
194
218
  list: listShape(resource, model, models, prompted),
195
219
  filters: shapes.list(widgetShape),
196
220
  layout: shapes.list(layoutShape(resource, model, models)),
197
- chart: shapes.object({
198
- field: shapes.field,
199
- range: shapes.data,
200
- granularity: shapes.data
201
- }, { requires: ["field"] }),
221
+ chart: shapes.map((chart) => chartFields(chart).every((name) => Object.hasOwn(model.fields, name)) ? chart : void 0),
202
222
  recordMode: shapes.data,
203
223
  helpWhen: shapes.data,
204
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 };
@@ -0,0 +1,23 @@
1
+ //#region src/view-editable.ts
2
+ const cellTypes = /* @__PURE__ */ new Set([
3
+ "text",
4
+ "integer",
5
+ "bigint",
6
+ "decimal",
7
+ "date",
8
+ "enum",
9
+ "boolean",
10
+ "currency",
11
+ "country"
12
+ ]);
13
+ /** What is wrong with editing the field `name` of the list in its cell; empty when it can be. */
14
+ const editableErrors = (name, model, hints) => {
15
+ const field = Object.hasOwn(model.fields, name) ? model.fields[name] : void 0;
16
+ if (!field) return [`"${name}" is not a field of ${model.name}`];
17
+ if (field.readOnly) return [`"${name}" is read-only`];
18
+ if (!cellTypes.has(field.type)) return [`"${name}" is a ${field.type} field, which is not edited in a cell`];
19
+ if (hints?.multiline || hints?.markdown) return [`"${name}" runs over several lines, which a cell does not hold`];
20
+ return [];
21
+ };
22
+ //#endregion
23
+ export { editableErrors };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@protobase/server",
3
- "version": "0.19.15",
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.15",
34
- "@protobase/query": "0.19.15",
35
- "@protobase/schema": "0.19.15",
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",