@protobase/server 0.10.0 → 0.11.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.
@@ -1,5 +1,7 @@
1
1
  import { relatedErrors } from "./view-related.js";
2
+ import { addressErrors, addressPlaceholders, appHrefError, displayFields, fieldRefsIn, hrefPlaceholders } from "@protobase/schema";
2
3
  //#region src/check-views.ts
4
+ const linkFields = (run) => run?.kind !== "link" ? [] : "to" in run ? addressPlaceholders(run.to) : hrefPlaceholders(run.href);
3
5
  const sensitiveErrors = (view, model) => {
4
6
  const sensitive = (name) => Boolean(model.fields[name]?.sensitive);
5
7
  return [
@@ -10,11 +12,12 @@ const sensitiveErrors = (view, model) => {
10
12
  ...(view.list?.sort ?? []).map(([name]) => name),
11
13
  ...view.list?.search ?? []
12
14
  ]],
15
+ ["a list display", (view.list?.displays ?? []).flatMap(displayFields)],
13
16
  ["a filter", view.filters.map((widget) => widget.field)],
14
17
  ["the chart", view.chart ? [view.chart.field] : []],
15
18
  ["the sidebar", view.layout.flatMap((item) => item.kind === "sidebar" ? item.fields : [])],
16
19
  ["the recent records", view.nav?.recent ? [view.nav.recent.status] : []],
17
- ["an action", view.actions.flatMap((action) => action.run?.kind === "update" ? Object.keys(action.run.values) : action.run?.kind === "link" ? [...action.run.href.matchAll(/\{(\w+)\}/g)].map((match) => match[1]) : [])]
20
+ ["an action", view.actions.flatMap((action) => action.run?.kind === "update" ? Object.keys(action.run.values) : linkFields(action.run))]
18
21
  ].flatMap(([place, names]) => [...new Set(names.filter(sensitive))].map((name) => `sensitive field "${name}" cannot be in ${place}`));
19
22
  };
20
23
  const valueLabelErrors = (view, model) => Object.entries(view.fields).flatMap(([name, hints]) => {
@@ -23,20 +26,40 @@ const valueLabelErrors = (view, model) => Object.entries(view.fields).flatMap(([
23
26
  if (field?.type !== "enum") return [`value labels of "${name}" need an enum field`];
24
27
  return Object.keys(hints.valueLabels).filter((value) => !field.enumValues?.includes(value)).map((value) => `value label "${value}" is not a value of "${name}"`);
25
28
  });
29
+ const linkErrors = (view, model, models, pages) => view.actions.flatMap(({ name, run }) => {
30
+ if (run?.kind !== "link") return [];
31
+ if ("href" in run) {
32
+ const error = appHrefError(run.href);
33
+ return error ? [`action "${name}": ${error}`] : [];
34
+ }
35
+ return addressErrors(run.to, {
36
+ models,
37
+ pages,
38
+ source: model
39
+ }).map((error) => `action "${name}": ${error}`);
40
+ });
41
+ const unknownFieldErrors = (view, model) => {
42
+ const unknown = (names, what) => names.filter((name) => !Object.hasOwn(model.fields, name)).map((name) => `${what} names field "${name}", which ${model.name} does not have`);
43
+ return [...(view.list?.displays ?? []).flatMap((display) => unknown(displayFields(display), `display "${display.name}"`)), ...view.layout.flatMap((item) => item.kind === "custom" ? unknown(fieldRefsIn(item.props), `custom section ${item.component}`) : [])];
44
+ };
26
45
  /**
27
- * Fails at startup when a view uses a sensitive field where it would show empty, labels values its enum does not have,
28
- * or has a related section that does not fit the resources.
46
+ * Fails at startup when a view uses a sensitive field where it would show empty, a display or custom section names a
47
+ * field the resource does not have, labels values its enum does not have,
48
+ * has a related section that does not fit the resources, or a link to an address the app does not have.
29
49
  */
30
- const checkViews = (registry, views) => {
50
+ const checkViews = (registry, views, pages = []) => {
31
51
  const models = Object.fromEntries(registry.entries.map((entry) => [entry.name, entry.model]));
52
+ const pageNames = new Set(pages);
32
53
  const problems = views.flatMap((view) => {
33
54
  const model = models[view.resource];
34
55
  if (!model) return [];
35
56
  const related = view.layout.flatMap((item) => item.kind === "related" ? relatedErrors(item, view.resource, models).map((error) => `related "${item.title}": ${error}`) : []);
36
57
  return [
37
58
  ...sensitiveErrors(view, model),
59
+ ...unknownFieldErrors(view, model),
38
60
  ...valueLabelErrors(view, model),
39
- ...related
61
+ ...related,
62
+ ...linkErrors(view, model, models, pageNames)
40
63
  ].map((problem) => `View "${view.resource}": ${problem}`);
41
64
  });
42
65
  if (problems.length > 0) throw new Error(`The views do not match the resources:\n${problems.map((problem) => ` - ${problem}`).join("\n")}`);
@@ -50,7 +50,7 @@ const createAdmin = ({ resources: appResources, views: appViews = [], pages = []
50
50
  checkShell(registry, viewModels, userMenuModel, pageModels.map((entry) => entry.name));
51
51
  const bottomBarModels = bottomBars.map((bar) => bar.toBottomBarModel());
52
52
  checkBottomBars(registry, bottomBarModels, pageModels.map((entry) => entry.name), declaredRoles(auth, options.roles));
53
- checkViews(registry, viewModels);
53
+ checkViews(registry, viewModels, pageModels.map((entry) => entry.name));
54
54
  checkPages(pageModels, {
55
55
  models: Object.fromEntries(registry.entries.map((entry) => [entry.name, entry.model])),
56
56
  actions: (resource) => new Set(viewModels.filter((view) => view.resource === resource).flatMap((view) => view.actions.map((action) => action.name)))
@@ -1,6 +1,6 @@
1
1
  import { relatedErrors } from "./view-related.js";
2
2
  import { resolveFieldPath } from "@protobase/layout";
3
- import { navRecentErrors } from "@protobase/schema";
3
+ import { addressErrors, hrefPlaceholders, navRecentErrors, pruneByShape, shapes, tableDisplay } from "@protobase/schema";
4
4
  //#region src/view-access.ts
5
5
  const pruneRelated = (item, resource, models) => {
6
6
  const { columns, ...rest } = item;
@@ -9,46 +9,145 @@ const pruneRelated = (item, resource, models) => {
9
9
  ...rest,
10
10
  columns: visible
11
11
  } : rest;
12
- return relatedErrors(narrowed, resource, models).length === 0 ? [narrowed] : [];
12
+ return relatedErrors(narrowed, resource, models).length === 0 ? narrowed : void 0;
13
13
  };
14
+ const keepDefault = ({ default: opens, displays, ...list }) => {
15
+ const shown = displays && displays.length > 0 ? displays : void 0;
16
+ const kept = opens === tableDisplay || shown?.some((display) => display.name === opens);
17
+ return {
18
+ ...list,
19
+ ...shown && { displays: shown },
20
+ ...opens !== void 0 && kept && { default: opens }
21
+ };
22
+ };
23
+ const listShape = shapes.object({
24
+ columns: shapes.list(shapes.field),
25
+ sort: shapes.list(shapes.tuple(shapes.field, shapes.data)),
26
+ search: shapes.list(shapes.field),
27
+ displays: shapes.list(shapes.oneOf({
28
+ cards: shapes.object({
29
+ kind: shapes.data,
30
+ name: shapes.data,
31
+ label: shapes.data,
32
+ icon: shapes.data,
33
+ title: shapes.field,
34
+ subtitle: shapes.field,
35
+ badge: shapes.field,
36
+ meta: shapes.list(shapes.field)
37
+ }),
38
+ custom: shapes.object({
39
+ kind: shapes.data,
40
+ name: shapes.data,
41
+ label: shapes.data,
42
+ icon: shapes.data,
43
+ component: shapes.data,
44
+ fields: shapes.list(shapes.field),
45
+ props: shapes.refs
46
+ }, { requires: ["props"] })
47
+ })),
48
+ default: shapes.data
49
+ }, { after: keepDefault });
50
+ const widgetShape = shapes.oneOf({
51
+ range: shapes.object({
52
+ kind: shapes.data,
53
+ field: shapes.field,
54
+ histogram: shapes.data
55
+ }, { requires: ["field"] }),
56
+ facets: shapes.object({
57
+ kind: shapes.data,
58
+ field: shapes.field,
59
+ search: shapes.data
60
+ }, { requires: ["field"] }),
61
+ dateRange: shapes.object({
62
+ kind: shapes.data,
63
+ field: shapes.field,
64
+ presets: shapes.data
65
+ }, { requires: ["field"] }),
66
+ toggle: shapes.object({
67
+ kind: shapes.data,
68
+ field: shapes.field
69
+ }, { requires: ["field"] })
70
+ });
71
+ const layoutShape = (resource, models) => shapes.oneOf({
72
+ section: shapes.object({
73
+ kind: shapes.data,
74
+ title: shapes.data,
75
+ help: shapes.data,
76
+ fields: shapes.list(shapes.field, { nonEmpty: true })
77
+ }, { requires: ["fields"] }),
78
+ sidebar: shapes.object({
79
+ kind: shapes.data,
80
+ fields: shapes.list(shapes.field, { nonEmpty: true })
81
+ }, { requires: ["fields"] }),
82
+ related: shapes.map((item) => pruneRelated(item, resource, models)),
83
+ custom: shapes.object({
84
+ kind: shapes.data,
85
+ component: shapes.data,
86
+ title: shapes.data,
87
+ sidebar: shapes.data,
88
+ props: shapes.refs
89
+ }, { requires: ["props"] })
90
+ });
91
+ const linkShape = (model, models) => shapes.map((run) => {
92
+ return ("to" in run ? addressErrors(run.to, {
93
+ models,
94
+ source: model
95
+ }).length === 0 : hrefPlaceholders(run.href).every((name) => Object.hasOwn(model.fields, name))) ? run : void 0;
96
+ });
97
+ const actionShape = (model, models) => shapes.object({
98
+ name: shapes.data,
99
+ label: shapes.data,
100
+ icon: shapes.data,
101
+ confirm: shapes.data,
102
+ bulk: shapes.data,
103
+ run: shapes.oneOf({
104
+ update: shapes.object({
105
+ kind: shapes.data,
106
+ values: shapes.keys({ all: true })
107
+ }, { requires: ["values"] }),
108
+ delete: shapes.data,
109
+ link: linkShape(model, models)
110
+ })
111
+ }, { requires: ["run"] });
112
+ const viewShape = (resource, model, models) => shapes.object({
113
+ resource: shapes.data,
114
+ roles: shapes.data,
115
+ title: shapes.field,
116
+ searchResult: shapes.object({
117
+ title: shapes.list(shapes.field, { nonEmpty: true }),
118
+ subtitle: shapes.field
119
+ }),
120
+ nav: shapes.object({
121
+ hidden: shapes.data,
122
+ group: shapes.data,
123
+ order: shapes.data,
124
+ recent: shapes.map((recent) => navRecentErrors(recent, model).length === 0 ? recent : void 0)
125
+ }),
126
+ names: shapes.data,
127
+ icon: shapes.data,
128
+ help: shapes.data,
129
+ fields: shapes.keys(),
130
+ list: listShape,
131
+ filters: shapes.list(widgetShape),
132
+ layout: shapes.list(layoutShape(resource, models)),
133
+ chart: shapes.object({
134
+ field: shapes.field,
135
+ range: shapes.data,
136
+ granularity: shapes.data
137
+ }, { requires: ["field"] }),
138
+ saveFeedback: shapes.data,
139
+ actions: shapes.list(actionShape(model, models))
140
+ });
14
141
  /**
15
- * The view with every reference to a field the caller cannot read removed: columns, sort, search, field labels,
16
- * filter widgets, layout, chart, title, search result, the update actions that set one, and the sidebar's `recent` group
17
- * when its status, filter or order uses a hidden field. Nothing in it may name a hidden field. Related sections are checked against
18
- * `models`, the caller's view of every resource.
142
+ * The view with every reference to a field the caller cannot read removed: list columns, sort, search and displays,
143
+ * field hints, filter widgets, record sections and custom components' props, the chart, title and search result, the
144
+ * actions that set or read one, and the sidebar's `recent` group. Nothing in it may name a hidden field. Related sections
145
+ * and links are checked against `models`, the caller's view of every resource.
19
146
  */
20
147
  const pruneView = (view, model, models) => {
21
- const visible = (name) => Object.hasOwn(model.fields, name);
22
- const { title, chart, list, nav, searchResult, ...rest } = view;
23
- const { recent, ...placement } = nav ?? {};
24
- const resultTitle = searchResult?.title?.filter(visible);
25
- const subtitle = searchResult?.subtitle;
26
- return {
27
- ...rest,
28
- ...searchResult && { searchResult: {
29
- ...resultTitle?.length && { title: resultTitle },
30
- ...subtitle !== void 0 && visible(subtitle) && { subtitle }
31
- } },
32
- ...nav && { nav: recent && navRecentErrors(recent, model).length === 0 ? nav : placement },
33
- ...title !== void 0 && visible(title) && { title },
34
- ...chart && visible(chart.field) && { chart },
35
- ...list && { list: {
36
- columns: list.columns.filter(visible),
37
- ...list.sort && { sort: list.sort.filter(([name]) => visible(name)) },
38
- ...list.search && { search: list.search.filter(visible) }
39
- } },
40
- fields: Object.fromEntries(Object.entries(view.fields).filter(([name]) => visible(name))),
41
- filters: view.filters.filter((widget) => visible(widget.field)),
42
- actions: view.actions.filter((action) => action.run?.kind !== "update" || Object.keys(action.run.values).every(visible)),
43
- layout: view.layout.flatMap((item) => {
44
- if (item.kind === "related") return pruneRelated(item, view.resource, models);
45
- const fields = item.fields.filter(visible);
46
- return fields.length > 0 ? [{
47
- ...item,
48
- fields
49
- }] : [];
50
- })
51
- };
148
+ const pruned = pruneByShape(view, viewShape(view.resource, model, models), (name) => Object.hasOwn(model.fields, name));
149
+ if (!pruned) throw new Error(`The view of ${view.resource} is not an object`);
150
+ return pruned;
52
151
  };
53
152
  //#endregion
54
153
  export { pruneView };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@protobase/server",
3
- "version": "0.10.0",
3
+ "version": "0.11.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.10.0",
34
- "@protobase/query": "0.10.0",
35
- "@protobase/schema": "0.10.0",
33
+ "@protobase/layout": "0.11.0",
34
+ "@protobase/query": "0.11.0",
35
+ "@protobase/schema": "0.11.0",
36
36
  "@scalar/hono-api-reference": "^0.12.10",
37
37
  "@simplewebauthn/server": "^13.3.1",
38
38
  "better-auth": "^1.7.7",