@protobase/schema 0.55.2 → 0.55.4

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.
package/dist/model.d.ts CHANGED
@@ -149,6 +149,11 @@ export type ResourceModel = {
149
149
  comments?: true;
150
150
  /** Whether open pages refresh as soon as its records change (`.live()`). */
151
151
  live?: true;
152
+ /**
153
+ * The fields of a unique key whose conflict on create answers the record already holding those values, to a caller
154
+ * who may read it (`.openExisting()`).
155
+ */
156
+ openExisting?: string[];
152
157
  };
153
158
  /** What happened to a record: `delete` and `undelete` include setting and clearing a soft delete field. */
154
159
  export type EventOperation = 'create' | 'update' | 'delete' | 'undelete';
@@ -1118,6 +1123,11 @@ export type NavRecentModel = {
1118
1123
  orderBy?: string;
1119
1124
  /** How many records to show, 1 to 20; 3 when omitted. */
1120
1125
  limit?: number;
1126
+ /**
1127
+ * Marks each record for the person reading, such as bold with a count of unread messages: the name of a hook in the
1128
+ * project UI's `marks`, which the sidebar calls in the browser. Presentation only: it is no field, filter or sort key.
1129
+ */
1130
+ marks?: string;
1121
1131
  };
1122
1132
  /** Where a resource sits in the sidebar (`.nav(...)`); see [Sidebar](website/src/content/docs/reference/sidebar.md). */
1123
1133
  export type NavModel = {
@@ -1135,6 +1145,14 @@ export type NavModel = {
1135
1145
  * filter and the recent records' filter as `tenantField = "<tenant>"`, which the person can clear or change.
1136
1146
  */
1137
1147
  tenant?: 'current';
1148
+ /**
1149
+ * A link under the entry to the resource's create form (its create page when the view has one), for whoever may
1150
+ * create; `true` reads "New <singular>", `{ label }` its own text.
1151
+ */
1152
+ create?: true | {
1153
+ /** The link's text, in place of "New <singular>". */
1154
+ label: string;
1155
+ };
1138
1156
  };
1139
1157
  /** An entry in the menu under the signed-in user, at the bottom of the sidebar. */
1140
1158
  export type UserMenuItemModel = {
@@ -32,7 +32,8 @@ const navRecentErrors = (recent, model) => [
32
32
  ...Object.entries(recent.tones ?? {}).filter(([, tone]) => !statusTones.includes(tone)).map(([value, tone]) => `tone "${tone}" for "${value}" is not one of ${statusTones.join(", ")}`),
33
33
  ...recent.limit === void 0 || Number.isInteger(recent.limit) && recent.limit >= 1 && recent.limit <= maxLimit ? [] : [`limit must be a whole number from 1 to ${maxLimit}`],
34
34
  ...recent.filter === void 0 ? [] : filterErrors(model, recent.filter).map((error) => `filter: ${error.message}`),
35
- ...recent.orderBy === void 0 ? [] : orderByErrors(model, recent.orderBy).map((error) => `orderBy: ${error.message}`)
35
+ ...recent.orderBy === void 0 ? [] : orderByErrors(model, recent.orderBy).map((error) => `orderBy: ${error.message}`),
36
+ ...recent.marks === void 0 || typeof recent.marks === "string" && recent.marks !== "" ? [] : ["marks needs the name of a hook in the project UI's marks"]
36
37
  ];
37
38
  //#endregion
38
39
  export { defaultRecentLimit, navRecentErrors, statusTones };
@@ -0,0 +1,16 @@
1
+ //#region src/open-existing-problems.ts
2
+ const openExistingProblems = (model) => {
3
+ const names = model.openExisting;
4
+ if (!names) return [];
5
+ if (names.length === 0) return ["openExisting names no field"];
6
+ return names.flatMap((name, index) => {
7
+ const field = model.fields[name];
8
+ return [
9
+ ...field.encrypted ? ["is encrypted"] : [],
10
+ ...field.file ? ["is a file field"] : [],
11
+ ...names.indexOf(name) !== index ? ["is given twice"] : []
12
+ ].map((problem) => `openExisting field "${name}" ${problem}`);
13
+ });
14
+ };
15
+ //#endregion
16
+ export { openExistingProblems };
@@ -24,6 +24,7 @@ export type ResourceState = {
24
24
  history?: boolean;
25
25
  comments?: boolean;
26
26
  live?: boolean;
27
+ openExisting?: string[];
27
28
  };
28
29
  export declare const parseTable: (table: string) => {
29
30
  name: string;
@@ -1,6 +1,7 @@
1
1
  import { promptFields } from "./ai-prompt.js";
2
2
  import { buildEventModels } from "./events/event-models.js";
3
3
  import { computedProblems } from "./computed/computed-problems.js";
4
+ import { openExistingProblems } from "./open-existing-problems.js";
4
5
  import { snakeCase } from "./naming.js";
5
6
  //#region src/resource-model.ts
6
7
  const parseTable = (table) => {
@@ -75,7 +76,8 @@ const assembleModel = (state) => {
75
76
  ...state.comments && { comments: true },
76
77
  ...state.live && { live: true },
77
78
  ...state.position && { position: { ...state.position } },
78
- ...state.parents && { parents: state.parents.map((parent) => ({ ...parent })) }
79
+ ...state.parents && { parents: state.parents.map((parent) => ({ ...parent })) },
80
+ ...state.openExisting && { openExisting: [...state.openExisting] }
79
81
  };
80
82
  };
81
83
  const sensitiveProblems = (model) => Object.values(model.fields).filter((field) => field.sensitive).flatMap((field) => [
@@ -168,6 +170,7 @@ const buildResourceModel = (state) => {
168
170
  ...positionProblems(model),
169
171
  ...parentProblems(model),
170
172
  ...aiProblems(model),
173
+ ...openExistingProblems(model),
171
174
  ...computedProblems(model),
172
175
  ...events?.problems ?? []
173
176
  ];
@@ -82,6 +82,18 @@ export declare class ResourceBuilder<F extends FieldsInput = {}, K = never> {
82
82
  search(pick: (r: SearchRefs<F>) => SearchRef[]): ResourceBuilder<F, K>;
83
83
  /** The field holding the owning user's id, which `.own` capability scopes filter on. */
84
84
  owner(pick: (r: FieldRefs<F>) => Ref): ResourceBuilder<F, K>;
85
+ /**
86
+ * A unique key of the table, as its fields, whose conflict on create opens the record already holding those values:
87
+ * the API's 409 names that record's key (`existing.key`) to a caller who may read it, and the create form opens it.
88
+ * The fields must be exactly the columns of a unique index or constraint, such as one over a generated column. See
89
+ * [Opening an existing record](website/src/content/docs/reference/data-config.mdx#opening-an-existing-record).
90
+ *
91
+ * @example
92
+ * ```ts
93
+ * .openExisting((r) => [r.organization_id, r.member_key])
94
+ * ```
95
+ */
96
+ openExisting(pick: (r: FieldRefs<F>) => Ref[]): ResourceBuilder<F, K>;
85
97
  /**
86
98
  * The field holding the id of the user who created the record, such as a chat message's sender: every create sets it
87
99
  * to the signed-in user's id, and no request may write it, so it is never the client's word. A create by no user
package/dist/resource.js CHANGED
@@ -84,6 +84,20 @@ var ResourceBuilder = class ResourceBuilder {
84
84
  return this.next({ owner: pick(this.refs()).name });
85
85
  }
86
86
  /**
87
+ * A unique key of the table, as its fields, whose conflict on create opens the record already holding those values:
88
+ * the API's 409 names that record's key (`existing.key`) to a caller who may read it, and the create form opens it.
89
+ * The fields must be exactly the columns of a unique index or constraint, such as one over a generated column. See
90
+ * [Opening an existing record](website/src/content/docs/reference/data-config.mdx#opening-an-existing-record).
91
+ *
92
+ * @example
93
+ * ```ts
94
+ * .openExisting((r) => [r.organization_id, r.member_key])
95
+ * ```
96
+ */
97
+ openExisting(pick) {
98
+ return this.next({ openExisting: pick(this.refs()).map((ref) => ref.name) });
99
+ }
100
+ /**
87
101
  * The field holding the id of the user who created the record, such as a chat message's sender: every create sets it
88
102
  * to the signed-in user's id, and no request may write it, so it is never the client's word. A create by no user
89
103
  * leaves it empty.
package/dist/view.d.ts CHANGED
@@ -74,7 +74,8 @@ export declare class ViewBuilder<R extends {
74
74
  /**
75
75
  * Where the resource sits in the sidebar; `recent` lists a few of its records under the entry (see
76
76
  * [Sidebar](website/src/content/docs/reference/sidebar.md#recent-records)), and `tenant: 'current'` opens it on the
77
- * caller's own tenant (see [The current organization](website/src/content/docs/reference/sidebar.md#the-current-organization)).
77
+ * caller's own tenant (see [The current organization](website/src/content/docs/reference/sidebar.md#the-current-organization)),
78
+ * and `create` links to the create form under it (see [Create link](website/src/content/docs/reference/sidebar.md#create-link)).
78
79
  */
79
80
  nav(nav: NavInput<R>): ViewBuilder<R>;
80
81
  /** A line under the list's title. */
package/dist/view.js CHANGED
@@ -86,7 +86,8 @@ var ViewBuilder = class ViewBuilder {
86
86
  /**
87
87
  * Where the resource sits in the sidebar; `recent` lists a few of its records under the entry (see
88
88
  * [Sidebar](website/src/content/docs/reference/sidebar.md#recent-records)), and `tenant: 'current'` opens it on the
89
- * caller's own tenant (see [The current organization](website/src/content/docs/reference/sidebar.md#the-current-organization)).
89
+ * caller's own tenant (see [The current organization](website/src/content/docs/reference/sidebar.md#the-current-organization)),
90
+ * and `create` links to the create form under it (see [Create link](website/src/content/docs/reference/sidebar.md#create-link)).
90
91
  */
91
92
  nav(nav) {
92
93
  return this.next({ nav });
@@ -354,6 +355,7 @@ var ViewBuilder = class ViewBuilder {
354
355
  if (nav?.tenant === "current" && !list) throw new Error(`View "${this.state.resource}": nav.tenant narrows the opening filter of the list, so the view needs .list(...)`);
355
356
  const tenantField = resource?.tenant === void 0 ? void 0 : resource.fields[resource.tenant];
356
357
  if (resource && nav?.tenant === "current" && !tenantField?.filterable) throw new Error(`View "${this.state.resource}": nav.tenant opens the list on the current tenant, so the resource needs .tenant() on a .filterable() field`);
358
+ if (typeof nav?.create === "object" && nav.create.label.trim() === "") throw new Error(`View "${this.state.resource}": nav.create needs a label, or true for "New <singular>"`);
357
359
  const recentErrors = resource && nav?.recent ? navRecentErrors(nav.recent, resource) : [];
358
360
  if (recentErrors.length > 0) throw new Error(`View "${this.state.resource}": nav.recent ${recentErrors.join("; ")}`);
359
361
  if (this.state.lists?.length && (listOf !== void 0 || this.state.searchesOf)) throw new Error(`View "${this.state.resource}": named lists cannot go with listOf or searchesOf, which replace the list page`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@protobase/schema",
3
- "version": "0.55.2",
3
+ "version": "0.55.4",
4
4
  "description": "Resource, field, view and access definitions for Protobase configs.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {