@protobase/schema 0.0.0-stage → 0.1.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.
Files changed (91) hide show
  1. package/LICENSE +202 -0
  2. package/dist/access/capability.d.ts +10 -0
  3. package/dist/access/capability.js +30 -0
  4. package/dist/access/combine.js +39 -0
  5. package/dist/access/index.d.ts +8 -0
  6. package/dist/access/pick-view.d.ts +8 -0
  7. package/dist/access/pick-view.js +11 -0
  8. package/dist/access/resolve.d.ts +61 -0
  9. package/dist/access/resolve.js +150 -0
  10. package/dist/access/restrict.d.ts +16 -0
  11. package/dist/access/restrict.js +37 -0
  12. package/dist/access/roles.d.ts +30 -0
  13. package/dist/access/roles.js +55 -0
  14. package/dist/access/rule.d.ts +11 -0
  15. package/dist/access/rule.js +9 -0
  16. package/dist/access/types.d.ts +37 -0
  17. package/dist/enum-label.d.ts +4 -0
  18. package/dist/enum-label.js +5 -0
  19. package/dist/field.d.ts +69 -0
  20. package/dist/field.js +85 -0
  21. package/dist/fields/bigint.js +12 -0
  22. package/dist/fields/boolean.js +9 -0
  23. package/dist/fields/country.js +9 -0
  24. package/dist/fields/currency.js +9 -0
  25. package/dist/fields/date.js +9 -0
  26. package/dist/fields/decimal.js +22 -0
  27. package/dist/fields/enum.js +12 -0
  28. package/dist/fields/integer.js +14 -0
  29. package/dist/fields/json.js +9 -0
  30. package/dist/fields/relation.js +9 -0
  31. package/dist/fields/text.js +19 -0
  32. package/dist/fields/timestamp.js +9 -0
  33. package/dist/fields/uuid.js +9 -0
  34. package/dist/fields.d.ts +23 -0
  35. package/dist/fields.js +49 -0
  36. package/dist/filter/check-field.js +25 -0
  37. package/dist/filter/check-model.js +123 -0
  38. package/dist/filter/check-value.js +64 -0
  39. package/dist/filter/check.d.ts +16 -0
  40. package/dist/filter/check.js +22 -0
  41. package/dist/filter/datetime.js +33 -0
  42. package/dist/filter/duration.js +16 -0
  43. package/dist/filter/errors.js +29 -0
  44. package/dist/filter/evaluate.d.ts +12 -0
  45. package/dist/filter/evaluate.js +33 -0
  46. package/dist/filter/index.d.ts +7 -0
  47. package/dist/filter/lift.js +107 -0
  48. package/dist/filter/lower.js +170 -0
  49. package/dist/filter/operators.js +26 -0
  50. package/dist/filter/order-by.d.ts +23 -0
  51. package/dist/filter/order-by.js +51 -0
  52. package/dist/filter/parse.d.ts +20 -0
  53. package/dist/filter/parse.js +28 -0
  54. package/dist/filter/print.d.ts +5 -0
  55. package/dist/filter/print.js +7 -0
  56. package/dist/filter/profile.js +69 -0
  57. package/dist/filter/where.d.ts +58 -0
  58. package/dist/filter/where.js +121 -0
  59. package/dist/index.d.ts +28 -0
  60. package/dist/index.js +21 -0
  61. package/dist/keys.d.ts +12 -0
  62. package/dist/keys.js +57 -0
  63. package/dist/layout.d.ts +12 -0
  64. package/dist/layout.js +21 -0
  65. package/dist/model.d.ts +333 -0
  66. package/dist/naming.js +4 -0
  67. package/dist/nav-recent.d.ts +8 -0
  68. package/dist/nav-recent.js +37 -0
  69. package/dist/refs.d.ts +11 -0
  70. package/dist/refs.js +8 -0
  71. package/dist/relation-field.d.ts +6 -0
  72. package/dist/relation-field.js +12 -0
  73. package/dist/resource-model.d.ts +15 -0
  74. package/dist/resource-model.js +71 -0
  75. package/dist/resource-types.d.ts +21 -0
  76. package/dist/resource.d.ts +47 -0
  77. package/dist/resource.js +97 -0
  78. package/dist/search-match.d.ts +29 -0
  79. package/dist/search-match.js +50 -0
  80. package/dist/user-menu.d.ts +25 -0
  81. package/dist/user-menu.js +32 -0
  82. package/dist/view-actions.d.ts +24 -0
  83. package/dist/view-actions.js +35 -0
  84. package/dist/view-field.d.ts +16 -0
  85. package/dist/view-field.js +39 -0
  86. package/dist/view-widgets.d.ts +16 -0
  87. package/dist/view-widgets.js +24 -0
  88. package/dist/view.d.ts +69 -0
  89. package/dist/view.js +109 -0
  90. package/package.json +31 -3
  91. package/README.md +0 -3
@@ -0,0 +1,29 @@
1
+ import { ResourceModel, SearchMatch } from "./model.js";
2
+ //#region src/search-match.d.ts
3
+ /**
4
+ * One part of a search text. Every word must match a search field; `whole` is the entire text, tried only against
5
+ * `digitsEnd` fields when it holds several words, so a phone number typed with spaces ("+31 6 4706") still finds it.
6
+ */
7
+ export type SearchTerm = {
8
+ text: string;
9
+ digits: string;
10
+ whole: boolean;
11
+ };
12
+ /** The text's words, and the whole text first when it is a phone number of several words. */
13
+ export declare const searchTerms: (text: string) => SearchTerm[];
14
+ /**
15
+ * Whether one field value matches a term. A plain search field contains the word, ignoring case; a `digitsEnd` field's
16
+ * digits (spaces, `+` and dashes ignored) end with the term's digits. Null never matches.
17
+ */
18
+ export declare const termMatches: (value: unknown, term: SearchTerm, match: SearchMatch | undefined) => boolean;
19
+ /**
20
+ * The server's `search("text")` in memory: every word matches a search field, or the whole text, as a phone number,
21
+ * ends a `digitsEnd` field. Text without words matches nothing. The SQL in `compileSearch` follows the same rules.
22
+ */
23
+ export declare const matchesSearch: (model: ResourceModel, row: Record<string, unknown>, text: string) => boolean;
24
+ /**
25
+ * The search field that explains why the record matched when the `shown` fields do not: the first search field matching
26
+ * the first term that matches no shown field. Undefined when the shown fields explain every term the record matched.
27
+ */
28
+ export declare const matchedSearchField: (model: ResourceModel, row: Record<string, unknown>, text: string, shown: string[]) => string | undefined;
29
+ //#endregion
@@ -0,0 +1,50 @@
1
+ //#region src/search-match.ts
2
+ const phoneLike = /^[\d\s+\-().\/]+$/;
3
+ const digitsOf = (text) => phoneLike.test(text) ? text.replace(/\D/g, "") : "";
4
+ /** The text's words, and the whole text first when it is a phone number of several words. */
5
+ const searchTerms = (text) => {
6
+ const words = text.split(/\s+/).filter(Boolean).map((word) => ({
7
+ text: word,
8
+ digits: digitsOf(word),
9
+ whole: false
10
+ }));
11
+ const digits = words.length > 1 ? digitsOf(text.trim()) : "";
12
+ return digits ? [{
13
+ text: text.trim(),
14
+ digits,
15
+ whole: true
16
+ }, ...words] : words;
17
+ };
18
+ /**
19
+ * Whether one field value matches a term. A plain search field contains the word, ignoring case; a `digitsEnd` field's
20
+ * digits (spaces, `+` and dashes ignored) end with the term's digits. Null never matches.
21
+ */
22
+ const termMatches = (value, term, match) => {
23
+ if (value === null || value === void 0) return false;
24
+ if (match === "digitsEnd") return term.digits !== "" && String(value).replace(/\D/g, "").endsWith(term.digits);
25
+ return !term.whole && String(value).toLowerCase().includes(term.text.toLowerCase());
26
+ };
27
+ const matchingFields = (model, row, term) => (model.search ?? []).filter((name) => termMatches(row[name], term, model.searchMatch?.[name]));
28
+ /**
29
+ * The server's `search("text")` in memory: every word matches a search field, or the whole text, as a phone number,
30
+ * ends a `digitsEnd` field. Text without words matches nothing. The SQL in `compileSearch` follows the same rules.
31
+ */
32
+ const matchesSearch = (model, row, text) => {
33
+ const terms = searchTerms(text);
34
+ const words = terms.filter((term) => !term.whole);
35
+ const whole = terms.find((term) => term.whole);
36
+ if (whole && matchingFields(model, row, whole).length > 0) return true;
37
+ return words.length > 0 && words.every((term) => matchingFields(model, row, term).length > 0);
38
+ };
39
+ /**
40
+ * The search field that explains why the record matched when the `shown` fields do not: the first search field matching
41
+ * the first term that matches no shown field. Undefined when the shown fields explain every term the record matched.
42
+ */
43
+ const matchedSearchField = (model, row, text, shown) => {
44
+ for (const term of searchTerms(text)) {
45
+ const fields = matchingFields(model, row, term);
46
+ if (fields.length > 0 && !fields.some((name) => shown.includes(name))) return fields[0];
47
+ }
48
+ };
49
+ //#endregion
50
+ export { matchedSearchField, matchesSearch, searchTerms, termMatches };
@@ -0,0 +1,25 @@
1
+ import { UserMenuItemModel, UserMenuModel } from "./model.js";
2
+ //#region src/user-menu.d.ts
3
+ declare const items: {
4
+ /** Opens the resource's list. A resource in the user menu is left out of the sidebar. */
5
+ resource: (resource: string, options?: {
6
+ label?: string;
7
+ icon?: string;
8
+ }) => UserMenuItemModel;
9
+ /** Opens a composed page (`page()` from @protobase/layout). A page in the user menu is left out of the sidebar. */
10
+ page: (page: string, options?: {
11
+ label?: string;
12
+ icon?: string;
13
+ }) => UserMenuItemModel;
14
+ /** An address outside the admin, opened in a new tab: `https:`, `http:` or `mailto:`. */
15
+ link: (label: string, href: string, options?: {
16
+ icon?: string;
17
+ }) => UserMenuItemModel;
18
+ };
19
+ /** What the server needs from a `userMenu(...)` export of the config module. */
20
+ export type UserMenuSource = {
21
+ toUserMenuModel(): UserMenuModel;
22
+ };
23
+ /** The pages under the signed-in user, at the bottom of the sidebar, above the built-in "Sign out". */
24
+ export declare const userMenu: (build: (m: typeof items) => UserMenuItemModel[]) => UserMenuSource;
25
+ //#endregion
@@ -0,0 +1,32 @@
1
+ //#region src/user-menu.ts
2
+ const items = {
3
+ /** Opens the resource's list. A resource in the user menu is left out of the sidebar. */
4
+ resource: (resource, options = {}) => ({
5
+ kind: "resource",
6
+ resource,
7
+ ...options
8
+ }),
9
+ /** Opens a composed page (`page()` from @protobase/layout). A page in the user menu is left out of the sidebar. */
10
+ page: (page, options = {}) => ({
11
+ kind: "page",
12
+ page,
13
+ ...options
14
+ }),
15
+ /** An address outside the admin, opened in a new tab: `https:`, `http:` or `mailto:`. */
16
+ link: (label, href, options = {}) => {
17
+ if (!/^(https?:\/\/|mailto:)/i.test(href)) throw new Error(`User menu link "${label}": href must start with https://, http:// or mailto:`);
18
+ return {
19
+ kind: "link",
20
+ label,
21
+ href,
22
+ ...options
23
+ };
24
+ }
25
+ };
26
+ /** The pages under the signed-in user, at the bottom of the sidebar, above the built-in "Sign out". */
27
+ const userMenu = (build) => {
28
+ const model = { items: build(items) };
29
+ return { toUserMenuModel: () => structuredClone(model) };
30
+ };
31
+ //#endregion
32
+ export { userMenu };
@@ -0,0 +1,24 @@
1
+ import { ActionModel } from "./model.js";
2
+ //#region src/view-actions.d.ts
3
+ type ActionOptions = {
4
+ label: string;
5
+ icon?: string;
6
+ confirm?: string;
7
+ };
8
+ export declare const actions: {
9
+ /** An action the app implements: it runs the handler registered under `name`. */
10
+ action: (name: string, o: ActionOptions & {
11
+ bulk?: boolean;
12
+ }) => ActionModel;
13
+ /** Sets fields of the record, as one update with its ETag, for example `{ status: 'paid' }`. */
14
+ update: (name: string, o: ActionOptions & {
15
+ set: Record<string, unknown>;
16
+ }) => ActionModel;
17
+ /** Deletes the record (soft delete when the resource has one). */
18
+ remove: (name: string, o: ActionOptions) => ActionModel;
19
+ /** Opens `href`: a path in the app or a URL; `{field}` is replaced by the record's value, such as `/orders/{orderId}`. */
20
+ link: (name: string, o: Omit<ActionOptions, "confirm"> & {
21
+ href: string;
22
+ }) => ActionModel;
23
+ };
24
+ //#endregion
@@ -0,0 +1,35 @@
1
+ //#region src/view-actions.ts
2
+ const named = (name, o) => ({
3
+ name,
4
+ label: o.label,
5
+ bulk: o.bulk ?? false,
6
+ ...o.icon !== void 0 && { icon: o.icon },
7
+ ...o.confirm !== void 0 && { confirm: o.confirm }
8
+ });
9
+ const actions = {
10
+ /** An action the app implements: it runs the handler registered under `name`. */
11
+ action: (name, o) => named(name, o),
12
+ /** Sets fields of the record, as one update with its ETag, for example `{ status: 'paid' }`. */
13
+ update: (name, o) => ({
14
+ ...named(name, o),
15
+ run: {
16
+ kind: "update",
17
+ values: o.set
18
+ }
19
+ }),
20
+ /** Deletes the record (soft delete when the resource has one). */
21
+ remove: (name, o) => ({
22
+ ...named(name, o),
23
+ run: { kind: "delete" }
24
+ }),
25
+ /** Opens `href`: a path in the app or a URL; `{field}` is replaced by the record's value, such as `/orders/{orderId}`. */
26
+ link: (name, o) => ({
27
+ ...named(name, o),
28
+ run: {
29
+ kind: "link",
30
+ href: o.href
31
+ }
32
+ })
33
+ };
34
+ //#endregion
35
+ export { actions };
@@ -0,0 +1,16 @@
1
+ import { FieldFormat, FieldViewModel } from "./model.js";
2
+ //#region src/view-field.d.ts
3
+ export declare class FieldView<N extends string = string, V = unknown> {
4
+ readonly name: N;
5
+ readonly props: FieldViewModel;
6
+ constructor(name: N, props?: FieldViewModel);
7
+ label(label: string): FieldView<N, V>;
8
+ help(help: string): FieldView<N, V>;
9
+ format(format: FieldFormat): FieldView<N, V>;
10
+ prefix(prefix: string): FieldView<N, V>;
11
+ decimals(decimals: number): FieldView<N, V>;
12
+ /** Labels for an enum's values, such as `{ co_signer: 'Co-signer' }`; values left out read as themselves in words. */
13
+ valueLabels(labels: { readonly [P in Extract<V, string>]?: string; }): FieldView<N, V>;
14
+ private with;
15
+ }
16
+ //#endregion
@@ -0,0 +1,39 @@
1
+ //#region src/view-field.ts
2
+ var FieldView = class FieldView {
3
+ name;
4
+ props;
5
+ constructor(name, props = {}) {
6
+ this.name = name;
7
+ this.props = props;
8
+ }
9
+ label(label) {
10
+ return this.with({ label });
11
+ }
12
+ help(help) {
13
+ return this.with({ help });
14
+ }
15
+ format(format) {
16
+ return this.with({ format });
17
+ }
18
+ prefix(prefix) {
19
+ return this.with({ prefix });
20
+ }
21
+ decimals(decimals) {
22
+ return this.with({ decimals });
23
+ }
24
+ /** Labels for an enum's values, such as `{ co_signer: 'Co-signer' }`; values left out read as themselves in words. */
25
+ valueLabels(labels) {
26
+ return this.with({ valueLabels: {
27
+ ...this.props.valueLabels,
28
+ ...labels
29
+ } });
30
+ }
31
+ with(patch) {
32
+ return new FieldView(this.name, {
33
+ ...this.props,
34
+ ...patch
35
+ });
36
+ }
37
+ };
38
+ //#endregion
39
+ export { FieldView };
@@ -0,0 +1,16 @@
1
+ import { DatePreset, FilterWidgetModel } from "./model.js";
2
+ import { Ref } from "./refs.js";
3
+ //#region src/view-widgets.d.ts
4
+ export declare const widgets: {
5
+ range: (ref: Ref, o?: {
6
+ histogram?: boolean;
7
+ }) => FilterWidgetModel;
8
+ facets: (ref: Ref, o?: {
9
+ search?: boolean;
10
+ }) => FilterWidgetModel;
11
+ dateRange: (ref: Ref, o?: {
12
+ presets?: DatePreset[];
13
+ }) => FilterWidgetModel;
14
+ toggle: (ref: Ref) => FilterWidgetModel;
15
+ };
16
+ //#endregion
@@ -0,0 +1,24 @@
1
+ //#region src/view-widgets.ts
2
+ const widgets = {
3
+ range: (ref, o = {}) => ({
4
+ kind: "range",
5
+ field: ref.name,
6
+ histogram: o.histogram ?? false
7
+ }),
8
+ facets: (ref, o = {}) => ({
9
+ kind: "facets",
10
+ field: ref.name,
11
+ search: o.search ?? false
12
+ }),
13
+ dateRange: (ref, o = {}) => ({
14
+ kind: "dateRange",
15
+ field: ref.name,
16
+ presets: o.presets ?? []
17
+ }),
18
+ toggle: (ref) => ({
19
+ kind: "toggle",
20
+ field: ref.name
21
+ })
22
+ };
23
+ //#endregion
24
+ export { widgets };
package/dist/view.d.ts ADDED
@@ -0,0 +1,69 @@
1
+ import { ActionModel, ChartGranularity, ChartRange, FilterWidgetModel, LayoutItemModel, NavModel, NavRecentModel, ResourceModel, SaveFeedback, ViewModel } from "./model.js";
2
+ import { FieldRefs, Ref } from "./refs.js";
3
+ import { actions } from "./view-actions.js";
4
+ import { FieldView } from "./view-field.js";
5
+ import { widgets } from "./view-widgets.js";
6
+ //#region src/view.d.ts
7
+ type FieldsOf<R extends {
8
+ readonly $fields: unknown;
9
+ }> = R['$fields'];
10
+ /** `nav(...)` with `recent.status` limited to the resource's fields. */
11
+ type NavInput<R extends {
12
+ readonly $fields: unknown;
13
+ }> = Omit<NavModel, 'recent'> & {
14
+ recent?: Omit<NavRecentModel, 'status'> & {
15
+ status: keyof FieldsOf<R> & string;
16
+ };
17
+ };
18
+ type FieldViews<R extends {
19
+ readonly $fields: unknown;
20
+ }> = { readonly [K in keyof FieldRefs<FieldsOf<R>> & string]: FieldView<K, NonNullable<FieldRefs<FieldsOf<R>>[K]['$value']>>; };
21
+ export declare class ViewBuilder<R extends {
22
+ readonly $fields: unknown;
23
+ }> {
24
+ readonly state: ViewModel;
25
+ constructor(state: ViewModel);
26
+ names(names: {
27
+ singular: string;
28
+ plural: string;
29
+ }): ViewBuilder<R>;
30
+ icon(icon: string): ViewBuilder<R>;
31
+ /** Shows this view only to users with one of these roles; a view without roles is the default. */
32
+ forRoles(roles: string[]): ViewBuilder<R>;
33
+ /** The field shown as this record's title. Throws when the field does not exist. */
34
+ title(pick: (r: FieldRefs<FieldsOf<R>>) => Ref): ViewBuilder<R>;
35
+ /**
36
+ * How a record shows in the global search: a bold `title` of one or more fields joined by spaces (default: `title`)
37
+ * and a `subtitle` field under it, replaced by the search field the text matched when that is neither.
38
+ */
39
+ searchResult(pick: (r: FieldRefs<FieldsOf<R>>) => {
40
+ title?: Ref | Ref[];
41
+ subtitle?: Ref;
42
+ }): ViewBuilder<R>;
43
+ /** Where the resource sits in the sidebar; `recent` lists a few of its records under the entry (see https://docs.protobase.net/reference/sidebar/). */
44
+ nav(nav: NavInput<R>): ViewBuilder<R>;
45
+ help(help: string): ViewBuilder<R>;
46
+ fields(build: (r: FieldViews<R>) => Record<string, FieldView>): ViewBuilder<R>;
47
+ list(build: (r: FieldRefs<FieldsOf<R>>) => {
48
+ columns: Ref[];
49
+ sort?: Array<[Ref, 'asc' | 'desc']>;
50
+ search?: Ref[];
51
+ }): ViewBuilder<R>;
52
+ filters(build: (r: FieldRefs<FieldsOf<R>>, w: typeof widgets) => FilterWidgetModel[]): ViewBuilder<R>;
53
+ layout(build: (r: FieldRefs<FieldsOf<R>>) => LayoutItemModel[]): ViewBuilder<R>;
54
+ chart(build: (r: FieldRefs<FieldsOf<R>>) => {
55
+ field: Ref;
56
+ range: ChartRange;
57
+ granularity: ChartGranularity;
58
+ }): ViewBuilder<R>;
59
+ saveFeedback(saveFeedback: SaveFeedback): ViewBuilder<R>;
60
+ actions(build: (a: typeof actions) => ActionModel[]): ViewBuilder<R>;
61
+ /** With the resource's model, also verifies the title and search result fields and the sidebar's `recent` group. */
62
+ toModel(resource?: ResourceModel): ViewModel;
63
+ private refs;
64
+ private next;
65
+ }
66
+ export declare const view: <R extends {
67
+ readonly $fields: unknown;
68
+ }>(resource: string) => ViewBuilder<R>;
69
+ //#endregion
package/dist/view.js ADDED
@@ -0,0 +1,109 @@
1
+ import { createRefs } from "./refs.js";
2
+ import { navRecentErrors } from "./nav-recent.js";
3
+ import { actions } from "./view-actions.js";
4
+ import { FieldView } from "./view-field.js";
5
+ import { widgets } from "./view-widgets.js";
6
+ //#region src/view.ts
7
+ var ViewBuilder = class ViewBuilder {
8
+ state;
9
+ constructor(state) {
10
+ this.state = state;
11
+ }
12
+ names(names) {
13
+ return this.next({ names });
14
+ }
15
+ icon(icon) {
16
+ return this.next({ icon });
17
+ }
18
+ /** Shows this view only to users with one of these roles; a view without roles is the default. */
19
+ forRoles(roles) {
20
+ return this.next({ roles });
21
+ }
22
+ /** The field shown as this record's title. Throws when the field does not exist. */
23
+ title(pick) {
24
+ return this.next({ title: pick(this.refs()).name });
25
+ }
26
+ /**
27
+ * How a record shows in the global search: a bold `title` of one or more fields joined by spaces (default: `title`)
28
+ * and a `subtitle` field under it, replaced by the search field the text matched when that is neither.
29
+ */
30
+ searchResult(pick) {
31
+ const { title, subtitle } = pick(this.refs());
32
+ const titles = title === void 0 ? [] : Array.isArray(title) ? title : [title];
33
+ return this.next({ searchResult: {
34
+ ...titles.length > 0 && { title: titles.map((ref) => ref.name) },
35
+ ...subtitle && { subtitle: subtitle.name }
36
+ } });
37
+ }
38
+ /** Where the resource sits in the sidebar; `recent` lists a few of its records under the entry (see https://docs.protobase.net/reference/sidebar/). */
39
+ nav(nav) {
40
+ return this.next({ nav });
41
+ }
42
+ help(help) {
43
+ return this.next({ help });
44
+ }
45
+ fields(build) {
46
+ const built = build(createRefs((name) => new FieldView(name)));
47
+ const fields = Object.fromEntries(Object.values(built).map((v) => [v.name, v.props]));
48
+ return this.next({ fields: {
49
+ ...this.state.fields,
50
+ ...fields
51
+ } });
52
+ }
53
+ list(build) {
54
+ const { columns, sort, search } = build(this.refs());
55
+ return this.next({ list: {
56
+ columns: columns.map((ref) => ref.name),
57
+ ...sort && { sort: sort.map(([ref, dir]) => [ref.name, dir]) },
58
+ ...search && { search: search.map((ref) => ref.name) }
59
+ } });
60
+ }
61
+ filters(build) {
62
+ return this.next({ filters: build(this.refs(), widgets) });
63
+ }
64
+ layout(build) {
65
+ return this.next({ layout: build(this.refs()) });
66
+ }
67
+ chart(build) {
68
+ const { field, range, granularity } = build(this.refs());
69
+ return this.next({ chart: {
70
+ field: field.name,
71
+ range,
72
+ granularity
73
+ } });
74
+ }
75
+ saveFeedback(saveFeedback) {
76
+ return this.next({ saveFeedback });
77
+ }
78
+ actions(build) {
79
+ return this.next({ actions: build(actions) });
80
+ }
81
+ /** With the resource's model, also verifies the title and search result fields and the sidebar's `recent` group. */
82
+ toModel(resource) {
83
+ const { title, nav, searchResult } = this.state;
84
+ if (resource && title !== void 0 && !Object.hasOwn(resource.fields, title)) throw new Error(`View "${this.state.resource}": title field "${title}" does not exist on the resource`);
85
+ const missing = resource ? [...searchResult?.title ?? [], ...searchResult?.subtitle ? [searchResult.subtitle] : []].filter((name) => !Object.hasOwn(resource.fields, name)) : [];
86
+ if (missing.length > 0) throw new Error(`View "${this.state.resource}": search result field "${missing[0]}" does not exist on the resource`);
87
+ const recentErrors = resource && nav?.recent ? navRecentErrors(nav.recent, resource) : [];
88
+ if (recentErrors.length > 0) throw new Error(`View "${this.state.resource}": nav.recent ${recentErrors.join("; ")}`);
89
+ return structuredClone(this.state);
90
+ }
91
+ refs() {
92
+ return createRefs((name) => ({ name }));
93
+ }
94
+ next(patch) {
95
+ return new ViewBuilder({
96
+ ...this.state,
97
+ ...patch
98
+ });
99
+ }
100
+ };
101
+ const view = (resource) => new ViewBuilder({
102
+ resource,
103
+ fields: {},
104
+ filters: [],
105
+ layout: [],
106
+ actions: []
107
+ });
108
+ //#endregion
109
+ export { ViewBuilder, view };
package/package.json CHANGED
@@ -1,6 +1,34 @@
1
1
  {
2
2
  "name": "@protobase/schema",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.1.0",
4
+ "description": "Resource, field, view and access definitions for Protobase configs.",
5
+ "license": "Apache-2.0",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/PrototeamHQ/protobase.git",
9
+ "directory": "packages/schema"
10
+ },
11
+ "homepage": "https://docs.protobase.net",
12
+ "type": "module",
13
+ "sideEffects": false,
14
+ "engines": {
15
+ "node": ">=22.12.0"
16
+ },
17
+ "exports": {
18
+ ".": "./dist/index.js"
19
+ },
20
+ "types": "./dist/index.d.ts",
21
+ "files": [
22
+ "dist"
23
+ ],
24
+ "publishConfig": {
25
+ "access": "public"
26
+ },
27
+ "dependencies": {
28
+ "aip-parsers": "^1.0.0",
29
+ "zod": "^4.6.5"
30
+ },
31
+ "scripts": {
32
+ "build": "tsdown"
33
+ }
6
34
  }
package/README.md DELETED
@@ -1,3 +0,0 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.