@protobase/schema 0.0.0-stage → 0.1.1
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/LICENSE +202 -0
- package/dist/access/capability.d.ts +10 -0
- package/dist/access/capability.js +30 -0
- package/dist/access/combine.js +39 -0
- package/dist/access/index.d.ts +8 -0
- package/dist/access/pick-view.d.ts +8 -0
- package/dist/access/pick-view.js +11 -0
- package/dist/access/resolve.d.ts +61 -0
- package/dist/access/resolve.js +150 -0
- package/dist/access/restrict.d.ts +16 -0
- package/dist/access/restrict.js +37 -0
- package/dist/access/roles.d.ts +30 -0
- package/dist/access/roles.js +55 -0
- package/dist/access/rule.d.ts +11 -0
- package/dist/access/rule.js +9 -0
- package/dist/access/types.d.ts +37 -0
- package/dist/enum-label.d.ts +4 -0
- package/dist/enum-label.js +5 -0
- package/dist/field.d.ts +69 -0
- package/dist/field.js +85 -0
- package/dist/fields/bigint.js +12 -0
- package/dist/fields/boolean.js +9 -0
- package/dist/fields/country.js +9 -0
- package/dist/fields/currency.js +9 -0
- package/dist/fields/date.js +9 -0
- package/dist/fields/decimal.js +22 -0
- package/dist/fields/enum.js +12 -0
- package/dist/fields/integer.js +14 -0
- package/dist/fields/json.js +9 -0
- package/dist/fields/relation.js +9 -0
- package/dist/fields/text.js +19 -0
- package/dist/fields/timestamp.js +9 -0
- package/dist/fields/uuid.js +9 -0
- package/dist/fields.d.ts +23 -0
- package/dist/fields.js +49 -0
- package/dist/filter/check-field.js +25 -0
- package/dist/filter/check-model.js +123 -0
- package/dist/filter/check-value.js +64 -0
- package/dist/filter/check.d.ts +16 -0
- package/dist/filter/check.js +22 -0
- package/dist/filter/datetime.js +33 -0
- package/dist/filter/duration.js +16 -0
- package/dist/filter/errors.js +29 -0
- package/dist/filter/evaluate.d.ts +12 -0
- package/dist/filter/evaluate.js +33 -0
- package/dist/filter/index.d.ts +7 -0
- package/dist/filter/lift.js +107 -0
- package/dist/filter/lower.js +170 -0
- package/dist/filter/operators.js +26 -0
- package/dist/filter/order-by.d.ts +23 -0
- package/dist/filter/order-by.js +51 -0
- package/dist/filter/parse.d.ts +20 -0
- package/dist/filter/parse.js +28 -0
- package/dist/filter/print.d.ts +5 -0
- package/dist/filter/print.js +7 -0
- package/dist/filter/profile.js +69 -0
- package/dist/filter/where.d.ts +58 -0
- package/dist/filter/where.js +121 -0
- package/dist/index.d.ts +28 -0
- package/dist/index.js +21 -0
- package/dist/keys.d.ts +12 -0
- package/dist/keys.js +57 -0
- package/dist/layout.d.ts +12 -0
- package/dist/layout.js +21 -0
- package/dist/model.d.ts +333 -0
- package/dist/naming.js +4 -0
- package/dist/nav-recent.d.ts +8 -0
- package/dist/nav-recent.js +37 -0
- package/dist/refs.d.ts +11 -0
- package/dist/refs.js +8 -0
- package/dist/relation-field.d.ts +6 -0
- package/dist/relation-field.js +12 -0
- package/dist/resource-model.d.ts +15 -0
- package/dist/resource-model.js +71 -0
- package/dist/resource-types.d.ts +21 -0
- package/dist/resource.d.ts +47 -0
- package/dist/resource.js +97 -0
- package/dist/search-match.d.ts +29 -0
- package/dist/search-match.js +50 -0
- package/dist/user-menu.d.ts +25 -0
- package/dist/user-menu.js +32 -0
- package/dist/view-actions.d.ts +24 -0
- package/dist/view-actions.js +35 -0
- package/dist/view-field.d.ts +16 -0
- package/dist/view-field.js +39 -0
- package/dist/view-widgets.d.ts +16 -0
- package/dist/view-widgets.js +24 -0
- package/dist/view.d.ts +69 -0
- package/dist/view.js +109 -0
- package/package.json +31 -3
- package/README.md +0 -3
package/dist/model.d.ts
ADDED
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
//#region src/model.d.ts
|
|
2
|
+
export type FieldType = 'text' | 'integer' | 'bigint' | 'decimal' | 'boolean' | 'date' | 'timestamp' | 'uuid' | 'enum' | 'json' | 'relation' | 'file' | 'currency' | 'country';
|
|
3
|
+
export type FieldModel = {
|
|
4
|
+
name: string;
|
|
5
|
+
column: string;
|
|
6
|
+
type: FieldType;
|
|
7
|
+
nullable: boolean;
|
|
8
|
+
readOnly: boolean;
|
|
9
|
+
filterable: boolean;
|
|
10
|
+
sortable: boolean;
|
|
11
|
+
aliases: string[];
|
|
12
|
+
enumValues?: string[];
|
|
13
|
+
relation?: {
|
|
14
|
+
resource: string;
|
|
15
|
+
columns: string[];
|
|
16
|
+
};
|
|
17
|
+
/** Never sent with the record; revealed one value at a time (`.sensitive()`). */
|
|
18
|
+
sensitive?: true;
|
|
19
|
+
/** A value the form prefills, or `db` when the database supplies it (identity, now(), ...). */
|
|
20
|
+
default?: {
|
|
21
|
+
value: unknown;
|
|
22
|
+
} | {
|
|
23
|
+
db: true;
|
|
24
|
+
};
|
|
25
|
+
};
|
|
26
|
+
/** `digitsEnd`: only the end of the value's digits, spaces, `+` and dashes ignored (phone numbers). */
|
|
27
|
+
export type SearchMatch = 'digitsEnd';
|
|
28
|
+
export type ResourceModel = {
|
|
29
|
+
name: string;
|
|
30
|
+
table: {
|
|
31
|
+
schema?: string;
|
|
32
|
+
name: string;
|
|
33
|
+
};
|
|
34
|
+
fields: Record<string, FieldModel>;
|
|
35
|
+
primaryKey: string[];
|
|
36
|
+
softDelete?: string;
|
|
37
|
+
tenant?: string;
|
|
38
|
+
/** Field names that the bare-text `search` filter matches against. */
|
|
39
|
+
search?: string[];
|
|
40
|
+
/** How a search field matches when not by containing the word; see `SearchMatch`. */
|
|
41
|
+
searchMatch?: Record<string, SearchMatch>;
|
|
42
|
+
/** The field holding the owning user's id; `.own` capability scopes filter on it. */
|
|
43
|
+
owner?: string;
|
|
44
|
+
};
|
|
45
|
+
export type SortSpec = Array<[field: string, direction: 'asc' | 'desc']>;
|
|
46
|
+
export type KeyValue = string | number;
|
|
47
|
+
export type ListQuery = {
|
|
48
|
+
filter?: CheckedFilter;
|
|
49
|
+
sort?: SortSpec;
|
|
50
|
+
columns?: string[];
|
|
51
|
+
cursor?: string;
|
|
52
|
+
direction?: 'after' | 'before';
|
|
53
|
+
limit: number;
|
|
54
|
+
};
|
|
55
|
+
export type FieldFormat = 'relative' | 'absolute' | 'compact' | 'percent' | 'badge' | 'code';
|
|
56
|
+
export type FieldViewModel = {
|
|
57
|
+
label?: string;
|
|
58
|
+
help?: string;
|
|
59
|
+
format?: FieldFormat;
|
|
60
|
+
prefix?: string;
|
|
61
|
+
decimals?: number;
|
|
62
|
+
/** What an enum value reads as wherever it is shown; a value without one reads as itself in words (`enumLabel`). */
|
|
63
|
+
valueLabels?: Record<string, string>;
|
|
64
|
+
};
|
|
65
|
+
export type ListModel = {
|
|
66
|
+
columns: string[];
|
|
67
|
+
sort?: SortSpec;
|
|
68
|
+
/** Defaults for the UI's search box. `ResourceModel.search` is authoritative for what `search(...)` matches on the server. */
|
|
69
|
+
search?: string[];
|
|
70
|
+
};
|
|
71
|
+
export type DatePreset = 'today' | 'yesterday' | '7d' | '30d' | '90d' | 'month' | 'quarter' | 'year';
|
|
72
|
+
export type FilterWidgetModel = {
|
|
73
|
+
kind: 'range';
|
|
74
|
+
field: string;
|
|
75
|
+
histogram: boolean;
|
|
76
|
+
} | {
|
|
77
|
+
kind: 'facets';
|
|
78
|
+
field: string;
|
|
79
|
+
search: boolean;
|
|
80
|
+
} | {
|
|
81
|
+
kind: 'dateRange';
|
|
82
|
+
field: string;
|
|
83
|
+
presets: DatePreset[];
|
|
84
|
+
} | {
|
|
85
|
+
kind: 'toggle';
|
|
86
|
+
field: string;
|
|
87
|
+
};
|
|
88
|
+
/**
|
|
89
|
+
* Records of another resource on the record page: those whose `field` holds this record's key or, with `through`,
|
|
90
|
+
* those whose `field` holds one of the `key` values of the `through` records that point at this record (a join table).
|
|
91
|
+
*/
|
|
92
|
+
export type RelatedModel = {
|
|
93
|
+
kind: 'related';
|
|
94
|
+
title: string;
|
|
95
|
+
help?: string;
|
|
96
|
+
resource: string;
|
|
97
|
+
field: string;
|
|
98
|
+
through?: {
|
|
99
|
+
resource: string;
|
|
100
|
+
field: string;
|
|
101
|
+
key?: string;
|
|
102
|
+
filter?: string;
|
|
103
|
+
};
|
|
104
|
+
filter?: string;
|
|
105
|
+
sort?: string;
|
|
106
|
+
/** Fields of `resource`, through relations too (`unitId.propertyId`). */
|
|
107
|
+
columns?: string[];
|
|
108
|
+
pageSize?: number;
|
|
109
|
+
empty?: string;
|
|
110
|
+
};
|
|
111
|
+
export type LayoutItemModel = {
|
|
112
|
+
kind: 'section';
|
|
113
|
+
title: string;
|
|
114
|
+
fields: string[];
|
|
115
|
+
help?: string;
|
|
116
|
+
} | {
|
|
117
|
+
kind: 'sidebar';
|
|
118
|
+
fields: string[];
|
|
119
|
+
} | RelatedModel;
|
|
120
|
+
export type ChartRange = '7d' | '30d' | '90d' | '1y' | 'all';
|
|
121
|
+
export type ChartGranularity = 'hour' | 'day' | 'week' | 'month';
|
|
122
|
+
export type ChartModel = {
|
|
123
|
+
field: string;
|
|
124
|
+
range: ChartRange;
|
|
125
|
+
granularity: ChartGranularity;
|
|
126
|
+
};
|
|
127
|
+
export type SaveFeedback = 'toast' | 'button';
|
|
128
|
+
/**
|
|
129
|
+
* What a named action does in the app: set fields of the record (`update`), delete it (`delete`) or open a link
|
|
130
|
+
* (`link`, with `{field}` replaced by the record's values). Without it the app runs a handler it registered by name.
|
|
131
|
+
*/
|
|
132
|
+
export type ActionRun = {
|
|
133
|
+
kind: 'update';
|
|
134
|
+
values: Record<string, unknown>;
|
|
135
|
+
} | {
|
|
136
|
+
kind: 'delete';
|
|
137
|
+
} | {
|
|
138
|
+
kind: 'link';
|
|
139
|
+
href: string;
|
|
140
|
+
};
|
|
141
|
+
export type ActionModel = {
|
|
142
|
+
name: string;
|
|
143
|
+
label: string;
|
|
144
|
+
icon?: string;
|
|
145
|
+
confirm?: string;
|
|
146
|
+
bulk: boolean;
|
|
147
|
+
run?: ActionRun;
|
|
148
|
+
};
|
|
149
|
+
/** The colour of a status dot in the sidebar. */
|
|
150
|
+
export type StatusTone = 'neutral' | 'info' | 'success' | 'warning' | 'danger';
|
|
151
|
+
/** A collapsible list of a few records under the resource's sidebar entry, each with a status dot. */
|
|
152
|
+
export type NavRecentModel = {
|
|
153
|
+
/** The field whose value picks each record's dot. */
|
|
154
|
+
status: string;
|
|
155
|
+
/** Dot colour per status value; values not listed are `neutral`. */
|
|
156
|
+
tones: Record<string, StatusTone>;
|
|
157
|
+
/** Status values whose dot pulses, for states that are in progress. */
|
|
158
|
+
pulse?: string[];
|
|
159
|
+
/** AIP-160 filter choosing the records that qualify, as the list API takes it. */
|
|
160
|
+
filter?: string;
|
|
161
|
+
/** AIP-132 order; the list's sort when omitted. */
|
|
162
|
+
orderBy?: string;
|
|
163
|
+
/** How many records to show, 1 to 20; 3 when omitted. */
|
|
164
|
+
limit?: number;
|
|
165
|
+
};
|
|
166
|
+
export type NavModel = {
|
|
167
|
+
/** Hide the resource from the sidebar, for line or link tables. */
|
|
168
|
+
hidden?: boolean;
|
|
169
|
+
group?: string;
|
|
170
|
+
order?: number;
|
|
171
|
+
recent?: NavRecentModel;
|
|
172
|
+
};
|
|
173
|
+
/** An entry in the menu under the signed-in user, at the bottom of the sidebar. */
|
|
174
|
+
export type UserMenuItemModel = {
|
|
175
|
+
kind: 'resource';
|
|
176
|
+
resource: string;
|
|
177
|
+
label?: string;
|
|
178
|
+
icon?: string;
|
|
179
|
+
} | {
|
|
180
|
+
kind: 'page';
|
|
181
|
+
page: string;
|
|
182
|
+
label?: string;
|
|
183
|
+
icon?: string;
|
|
184
|
+
} | {
|
|
185
|
+
kind: 'link';
|
|
186
|
+
label: string;
|
|
187
|
+
href: string;
|
|
188
|
+
icon?: string;
|
|
189
|
+
};
|
|
190
|
+
export type UserMenuModel = {
|
|
191
|
+
items: UserMenuItemModel[];
|
|
192
|
+
};
|
|
193
|
+
/**
|
|
194
|
+
* The title replaces `title` in search results; `subtitle` is the second line unless the search matched another search
|
|
195
|
+
* field, which then shows instead. Without a subtitle, the first search field not in the title is the default.
|
|
196
|
+
*/
|
|
197
|
+
export type SearchResultModel = {
|
|
198
|
+
title?: string[];
|
|
199
|
+
subtitle?: string;
|
|
200
|
+
};
|
|
201
|
+
export type ViewModel = {
|
|
202
|
+
resource: string;
|
|
203
|
+
/** Shown only to users with one of these roles; a view without roles is the default. */
|
|
204
|
+
roles?: string[];
|
|
205
|
+
/** Field used as the record's display title in breadcrumbs, relation links and search results. */
|
|
206
|
+
title?: string;
|
|
207
|
+
/** A record in the global search: a title of one or more fields joined by spaces, over a subtitle field by default. */
|
|
208
|
+
searchResult?: SearchResultModel;
|
|
209
|
+
nav?: NavModel;
|
|
210
|
+
names?: {
|
|
211
|
+
singular: string;
|
|
212
|
+
plural: string;
|
|
213
|
+
};
|
|
214
|
+
icon?: string;
|
|
215
|
+
help?: string;
|
|
216
|
+
fields: Record<string, FieldViewModel>;
|
|
217
|
+
list?: ListModel;
|
|
218
|
+
filters: FilterWidgetModel[];
|
|
219
|
+
layout: LayoutItemModel[];
|
|
220
|
+
chart?: ChartModel;
|
|
221
|
+
saveFeedback?: SaveFeedback;
|
|
222
|
+
actions: ActionModel[];
|
|
223
|
+
};
|
|
224
|
+
export type Span = {
|
|
225
|
+
start: number;
|
|
226
|
+
end: number;
|
|
227
|
+
};
|
|
228
|
+
export type FieldPath = {
|
|
229
|
+
kind: 'field';
|
|
230
|
+
path: string[];
|
|
231
|
+
span: Span;
|
|
232
|
+
};
|
|
233
|
+
export type DurationUnit = 's' | 'm' | 'h' | 'd' | 'w';
|
|
234
|
+
export type DurationLiteral = {
|
|
235
|
+
kind: 'duration';
|
|
236
|
+
amount: number;
|
|
237
|
+
unit: DurationUnit;
|
|
238
|
+
span: Span;
|
|
239
|
+
};
|
|
240
|
+
export type Literal = {
|
|
241
|
+
kind: 'string';
|
|
242
|
+
value: string;
|
|
243
|
+
span: Span;
|
|
244
|
+
} | {
|
|
245
|
+
kind: 'number';
|
|
246
|
+
value: number;
|
|
247
|
+
raw: string;
|
|
248
|
+
span: Span;
|
|
249
|
+
} | {
|
|
250
|
+
kind: 'boolean';
|
|
251
|
+
value: boolean;
|
|
252
|
+
span: Span;
|
|
253
|
+
} | DurationLiteral | {
|
|
254
|
+
kind: 'timestamp';
|
|
255
|
+
value: string;
|
|
256
|
+
span: Span;
|
|
257
|
+
};
|
|
258
|
+
export type NowValue = {
|
|
259
|
+
kind: 'now';
|
|
260
|
+
offset?: {
|
|
261
|
+
sign: '+' | '-';
|
|
262
|
+
duration: DurationLiteral;
|
|
263
|
+
};
|
|
264
|
+
span: Span;
|
|
265
|
+
};
|
|
266
|
+
export type FilterValue = Literal | NowValue;
|
|
267
|
+
export type CompareOp = '=' | '!=' | '<' | '<=' | '>' | '>=';
|
|
268
|
+
export type FilterOf<F> = {
|
|
269
|
+
kind: 'and';
|
|
270
|
+
args: FilterOf<F>[];
|
|
271
|
+
span: Span;
|
|
272
|
+
} | {
|
|
273
|
+
kind: 'or';
|
|
274
|
+
args: FilterOf<F>[];
|
|
275
|
+
span: Span;
|
|
276
|
+
} | {
|
|
277
|
+
kind: 'not';
|
|
278
|
+
arg: FilterOf<F>;
|
|
279
|
+
span: Span;
|
|
280
|
+
} | {
|
|
281
|
+
kind: 'compare';
|
|
282
|
+
op: CompareOp;
|
|
283
|
+
field: F;
|
|
284
|
+
value: FilterValue;
|
|
285
|
+
span: Span;
|
|
286
|
+
} | {
|
|
287
|
+
kind: 'has';
|
|
288
|
+
field: F;
|
|
289
|
+
value: Literal;
|
|
290
|
+
span: Span;
|
|
291
|
+
} | {
|
|
292
|
+
kind: 'present';
|
|
293
|
+
field: F;
|
|
294
|
+
span: Span;
|
|
295
|
+
} | {
|
|
296
|
+
kind: 'in';
|
|
297
|
+
field: F;
|
|
298
|
+
values: Literal[];
|
|
299
|
+
span: Span;
|
|
300
|
+
} | {
|
|
301
|
+
kind: 'search';
|
|
302
|
+
text: string;
|
|
303
|
+
span: Span;
|
|
304
|
+
} | {
|
|
305
|
+
kind: 'similar';
|
|
306
|
+
field: F;
|
|
307
|
+
text: string;
|
|
308
|
+
span: Span;
|
|
309
|
+
} | {
|
|
310
|
+
kind: 'regex';
|
|
311
|
+
field: F;
|
|
312
|
+
pattern: string;
|
|
313
|
+
span: Span;
|
|
314
|
+
} | {
|
|
315
|
+
kind: 'isNull';
|
|
316
|
+
field: F;
|
|
317
|
+
span: Span;
|
|
318
|
+
};
|
|
319
|
+
export type FilterExpr = FilterOf<FieldPath>;
|
|
320
|
+
export type CheckedFilter = FilterOf<FieldModel>;
|
|
321
|
+
export type FilterErrorCode = 'unexpected-character' | 'unterminated-string' | 'invalid-escape' | 'invalid-number' | 'invalid-timestamp' | 'unexpected-token' | 'expected-token' | 'unknown-function' | 'invalid-arguments' | 'unsupported' | 'too-deep' | 'unknown-field' | 'not-filterable' | 'not-sortable' | 'unsupported-traversal' | 'invalid-value' | 'operator-not-allowed' | 'wildcard-not-allowed' | 'invalid-unicode' | 'too-long' | 'invalid-traversal' | 'wrong-arity' | 'wrong-argument' | 'not-boolean' | 'expected-field' | 'no-search-fields';
|
|
322
|
+
export type FilterError = {
|
|
323
|
+
code: FilterErrorCode;
|
|
324
|
+
message: string;
|
|
325
|
+
hint: string;
|
|
326
|
+
span: Span;
|
|
327
|
+
};
|
|
328
|
+
export type OrderByItem = {
|
|
329
|
+
path: FieldPath;
|
|
330
|
+
direction: 'asc' | 'desc';
|
|
331
|
+
span: Span;
|
|
332
|
+
};
|
|
333
|
+
//#endregion
|
package/dist/naming.js
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { NavRecentModel, ResourceModel } from "./model.js";
|
|
2
|
+
//#region src/nav-recent.d.ts
|
|
3
|
+
export declare const statusTones: readonly ["neutral", "info", "success", "warning", "danger"];
|
|
4
|
+
/** How many records a sidebar group shows when `limit` is not set. */
|
|
5
|
+
export declare const defaultRecentLimit = 3;
|
|
6
|
+
/** What is wrong with a sidebar group's config for this model; empty when the group can be shown. */
|
|
7
|
+
export declare const navRecentErrors: (recent: NavRecentModel, model: ResourceModel) => string[];
|
|
8
|
+
//#endregion
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { parseFilter } from "./filter/parse.js";
|
|
2
|
+
import { checkFilter } from "./filter/check.js";
|
|
3
|
+
import { checkOrderBy, parseOrderBy } from "./filter/order-by.js";
|
|
4
|
+
//#region src/nav-recent.ts
|
|
5
|
+
const statusTones = [
|
|
6
|
+
"neutral",
|
|
7
|
+
"info",
|
|
8
|
+
"success",
|
|
9
|
+
"warning",
|
|
10
|
+
"danger"
|
|
11
|
+
];
|
|
12
|
+
/** How many records a sidebar group shows when `limit` is not set. */
|
|
13
|
+
const defaultRecentLimit = 3;
|
|
14
|
+
const maxLimit = 20;
|
|
15
|
+
const filterErrors = (model, source) => {
|
|
16
|
+
const parsed = parseFilter(source);
|
|
17
|
+
if (!parsed.ok) return parsed.errors;
|
|
18
|
+
if (!parsed.ast) return [];
|
|
19
|
+
const checked = checkFilter(model, parsed.ast);
|
|
20
|
+
return checked.ok ? [] : checked.errors;
|
|
21
|
+
};
|
|
22
|
+
const orderByErrors = (model, source) => {
|
|
23
|
+
const parsed = parseOrderBy(source);
|
|
24
|
+
if (!parsed.ok) return parsed.errors;
|
|
25
|
+
const checked = checkOrderBy(model, parsed.items);
|
|
26
|
+
return checked.ok ? [] : checked.errors;
|
|
27
|
+
};
|
|
28
|
+
/** What is wrong with a sidebar group's config for this model; empty when the group can be shown. */
|
|
29
|
+
const navRecentErrors = (recent, model) => [
|
|
30
|
+
...Object.hasOwn(model.fields, recent.status) ? [] : [`status field "${recent.status}" does not exist`],
|
|
31
|
+
...Object.entries(recent.tones).filter(([, tone]) => !statusTones.includes(tone)).map(([value, tone]) => `tone "${tone}" for "${value}" is not one of ${statusTones.join(", ")}`),
|
|
32
|
+
...recent.limit === void 0 || Number.isInteger(recent.limit) && recent.limit >= 1 && recent.limit <= maxLimit ? [] : [`limit must be a whole number from 1 to ${maxLimit}`],
|
|
33
|
+
...recent.filter === void 0 ? [] : filterErrors(model, recent.filter).map((error) => `filter: ${error.message}`),
|
|
34
|
+
...recent.orderBy === void 0 ? [] : orderByErrors(model, recent.orderBy).map((error) => `orderBy: ${error.message}`)
|
|
35
|
+
];
|
|
36
|
+
//#endregion
|
|
37
|
+
export { defaultRecentLimit, navRecentErrors, statusTones };
|
package/dist/refs.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { AnyField, Field } from "./field.js";
|
|
2
|
+
//#region src/refs.d.ts
|
|
3
|
+
export type FieldsInput = Record<string, AnyField | null>;
|
|
4
|
+
export type ActiveFields<F> = { [K in keyof F as F[K] extends null ? never : K]: F[K]; };
|
|
5
|
+
export type Ref<N extends string = string, V = unknown> = {
|
|
6
|
+
readonly name: N;
|
|
7
|
+
readonly $value?: V;
|
|
8
|
+
};
|
|
9
|
+
export type Refs<F> = { readonly [K in keyof F & string]: F[K] extends Field<infer V> ? Ref<K, V> : never; };
|
|
10
|
+
export type FieldRefs<F> = Refs<ActiveFields<F>>;
|
|
11
|
+
//#endregion
|
package/dist/refs.js
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
//#region src/refs.ts
|
|
2
|
+
const createRefs = (make, known) => new Proxy({}, { get: (_, name) => {
|
|
3
|
+
if (typeof name === "symbol") return void 0;
|
|
4
|
+
if (known && !known.includes(name)) throw new Error(`Unknown field "${name}"`);
|
|
5
|
+
return make(name);
|
|
6
|
+
} });
|
|
7
|
+
//#endregion
|
|
8
|
+
export { createRefs };
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { Field } from "./field.js";
|
|
2
|
+
//#region src/relation-field.ts
|
|
3
|
+
var RelationField = class extends Field {
|
|
4
|
+
columns(columns) {
|
|
5
|
+
return this.with({ relation: {
|
|
6
|
+
resource: this.meta.relation.resource,
|
|
7
|
+
columns
|
|
8
|
+
} });
|
|
9
|
+
}
|
|
10
|
+
};
|
|
11
|
+
//#endregion
|
|
12
|
+
export { RelationField };
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { SearchMatch } from "./model.js";
|
|
2
|
+
import { FieldsInput } from "./refs.js";
|
|
3
|
+
//#region src/resource-model.d.ts
|
|
4
|
+
export type ResourceState = {
|
|
5
|
+
name: string;
|
|
6
|
+
table?: string;
|
|
7
|
+
fields?: FieldsInput;
|
|
8
|
+
primaryKey?: string[];
|
|
9
|
+
softDelete?: string;
|
|
10
|
+
tenant?: string;
|
|
11
|
+
search?: string[];
|
|
12
|
+
searchMatch?: Record<string, SearchMatch>;
|
|
13
|
+
owner?: string;
|
|
14
|
+
};
|
|
15
|
+
//#endregion
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { snakeCase } from "./naming.js";
|
|
2
|
+
//#region src/resource-model.ts
|
|
3
|
+
const parseTable = (table) => {
|
|
4
|
+
const parts = table.split(".");
|
|
5
|
+
if (parts.length === 1) return { name: parts[0] };
|
|
6
|
+
if (parts.length === 2) return {
|
|
7
|
+
schema: parts[0],
|
|
8
|
+
name: parts[1]
|
|
9
|
+
};
|
|
10
|
+
throw new Error(`Invalid table "${table}", expected "table" or "schema.table"`);
|
|
11
|
+
};
|
|
12
|
+
const fieldModel = (name, field) => {
|
|
13
|
+
const { meta } = field;
|
|
14
|
+
const column = meta.column ?? snakeCase(name);
|
|
15
|
+
return {
|
|
16
|
+
name,
|
|
17
|
+
column,
|
|
18
|
+
type: meta.type,
|
|
19
|
+
nullable: meta.nullable,
|
|
20
|
+
readOnly: meta.readOnly,
|
|
21
|
+
filterable: meta.filterable,
|
|
22
|
+
sortable: meta.sortable,
|
|
23
|
+
aliases: [...meta.aliases],
|
|
24
|
+
...meta.hasDefault && { default: { value: meta.defaultValue } },
|
|
25
|
+
...meta.dbDefault && !meta.hasDefault && { default: { db: true } },
|
|
26
|
+
...meta.sensitive && { sensitive: true },
|
|
27
|
+
...meta.enumValues && { enumValues: [...meta.enumValues] },
|
|
28
|
+
...meta.relation && { relation: {
|
|
29
|
+
resource: meta.relation.resource,
|
|
30
|
+
columns: meta.relation.columns ?? [column]
|
|
31
|
+
} }
|
|
32
|
+
};
|
|
33
|
+
};
|
|
34
|
+
const assembleModel = (state) => {
|
|
35
|
+
const { name, table, fields, primaryKey } = state;
|
|
36
|
+
if (!table) throw new Error(`Resource "${name}" has no table`);
|
|
37
|
+
if (!fields) throw new Error(`Resource "${name}" has no fields`);
|
|
38
|
+
if (!primaryKey) throw new Error(`Resource "${name}" has no primary key`);
|
|
39
|
+
return {
|
|
40
|
+
name,
|
|
41
|
+
table: parseTable(table),
|
|
42
|
+
fields: Object.fromEntries(Object.entries(fields).filter((entry) => entry[1] !== null).map(([key, field]) => [key, fieldModel(key, field)])),
|
|
43
|
+
primaryKey: [...primaryKey],
|
|
44
|
+
...state.softDelete && { softDelete: state.softDelete },
|
|
45
|
+
...state.tenant && { tenant: state.tenant },
|
|
46
|
+
...state.search && { search: [...state.search] },
|
|
47
|
+
...state.searchMatch && Object.keys(state.searchMatch).length > 0 && { searchMatch: { ...state.searchMatch } },
|
|
48
|
+
...state.owner && { owner: state.owner }
|
|
49
|
+
};
|
|
50
|
+
};
|
|
51
|
+
const sensitiveProblems = (model) => Object.values(model.fields).filter((field) => field.sensitive).flatMap((field) => [
|
|
52
|
+
...field.filterable ? ["filterable"] : [],
|
|
53
|
+
...field.sortable ? ["sortable"] : [],
|
|
54
|
+
...field.aliases.length > 0 ? ["aliased"] : [],
|
|
55
|
+
...model.search?.includes(field.name) ? ["searched"] : [],
|
|
56
|
+
...[
|
|
57
|
+
...model.primaryKey,
|
|
58
|
+
model.tenant,
|
|
59
|
+
model.owner,
|
|
60
|
+
model.softDelete
|
|
61
|
+
].includes(field.name) ? ["a key, tenant, owner or soft delete field"] : []
|
|
62
|
+
].map((problem) => `sensitive field "${field.name}" cannot be ${problem}`));
|
|
63
|
+
const buildResourceModel = (state) => {
|
|
64
|
+
const model = assembleModel(state);
|
|
65
|
+
const problems = sensitiveProblems(model);
|
|
66
|
+
if (problems.length > 0) throw new Error(`Resource "${model.name}": ${problems.join("; ")}`);
|
|
67
|
+
return model;
|
|
68
|
+
};
|
|
69
|
+
const ignoredColumns = (fields = {}) => Object.entries(fields).filter(([, field]) => field === null).map(([key]) => snakeCase(key));
|
|
70
|
+
//#endregion
|
|
71
|
+
export { buildResourceModel, ignoredColumns };
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { AccessAction, AccessFn } from "./access/types.js";
|
|
2
|
+
import { Field } from "./field.js";
|
|
3
|
+
import { ActiveFields, Ref } from "./refs.js";
|
|
4
|
+
//#region src/resource-types.d.ts
|
|
5
|
+
type Prettify<T> = { [K in keyof T]: T[K]; } & {};
|
|
6
|
+
export type RecordOf<F> = Prettify<{ [K in keyof ActiveFields<F>]: ActiveFields<F>[K] extends Field<infer V> ? V : never; }>;
|
|
7
|
+
export type KeyRefs = Ref | readonly Ref[];
|
|
8
|
+
export type KeyOf<K> = K extends readonly Ref[] ? { -readonly [I in keyof K]: K[I] extends Ref<string, infer V> ? V : never; } : K extends Ref<string, infer V> ? V : never;
|
|
9
|
+
export type Issue<F> = {
|
|
10
|
+
field: keyof RecordOf<F> & string;
|
|
11
|
+
message: string;
|
|
12
|
+
};
|
|
13
|
+
/** Resource-level rules: operation, row filter and record-level check in one function. */
|
|
14
|
+
export type Access<F> = { [A in AccessAction]?: AccessFn<RecordOf<F>>; };
|
|
15
|
+
export type InferRecord<R extends {
|
|
16
|
+
readonly $fields: unknown;
|
|
17
|
+
}> = RecordOf<R['$fields']>;
|
|
18
|
+
export type RecordKey<R extends {
|
|
19
|
+
readonly $key: unknown;
|
|
20
|
+
}> = KeyOf<R['$key']>;
|
|
21
|
+
//#endregion
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { ResourceModel, SearchMatch } from "./model.js";
|
|
2
|
+
import { FieldAccess } from "./access/types.js";
|
|
3
|
+
import { FieldRefs, FieldsInput, Ref } from "./refs.js";
|
|
4
|
+
import { ResourceState } from "./resource-model.js";
|
|
5
|
+
import { Access, Issue, KeyRefs, RecordOf } from "./resource-types.js";
|
|
6
|
+
import { z } from "zod";
|
|
7
|
+
//#region src/resource.d.ts
|
|
8
|
+
type SearchRef = Ref & {
|
|
9
|
+
readonly match?: SearchMatch;
|
|
10
|
+
};
|
|
11
|
+
/** A field picked for search; `digitsEnd()` matches only the end of its digits, ignoring spaces, `+` and dashes. */
|
|
12
|
+
type SearchRefs<F> = { readonly [K in keyof FieldRefs<F>]: FieldRefs<F>[K] & {
|
|
13
|
+
digitsEnd(): SearchRef;
|
|
14
|
+
}; };
|
|
15
|
+
type Validator<F> = (record: RecordOf<F>) => Issue<F>[] | undefined;
|
|
16
|
+
export declare class ResourceBuilder<F extends FieldsInput = {}, K = never> {
|
|
17
|
+
readonly state: ResourceState;
|
|
18
|
+
readonly validators: readonly Validator<F>[];
|
|
19
|
+
readonly accessRules: Access<F>;
|
|
20
|
+
readonly $fields: F;
|
|
21
|
+
readonly $key: K;
|
|
22
|
+
constructor(state: ResourceState, validators?: readonly Validator<F>[], accessRules?: Access<F>);
|
|
23
|
+
table(table: string): ResourceBuilder<F, K>;
|
|
24
|
+
fields<const N extends FieldsInput>(fields: N): ResourceBuilder<N, never>;
|
|
25
|
+
primaryKey<const P extends KeyRefs>(pick: (r: FieldRefs<F>) => P): ResourceBuilder<F, P>;
|
|
26
|
+
softDelete(pick: (r: FieldRefs<F>) => Ref): ResourceBuilder<F, K>;
|
|
27
|
+
tenant(pick: (r: FieldRefs<F>) => Ref): ResourceBuilder<F, K>;
|
|
28
|
+
/**
|
|
29
|
+
* Fields matched by `search("...")` and bare words in filters. Authoritative over the UI's list search defaults.
|
|
30
|
+
* `r.phone.digitsEnd()` matches only the end of the field's digits, for phone numbers.
|
|
31
|
+
*/
|
|
32
|
+
search(pick: (r: SearchRefs<F>) => SearchRef[]): ResourceBuilder<F, K>;
|
|
33
|
+
/** The field holding the owning user's id, which `.own` capability scopes filter on. */
|
|
34
|
+
owner(pick: (r: FieldRefs<F>) => Ref): ResourceBuilder<F, K>;
|
|
35
|
+
validate(validator: Validator<F>): ResourceBuilder<F, K>;
|
|
36
|
+
access(rules: Access<F>): ResourceBuilder<F, K>;
|
|
37
|
+
/** Role-based rules per field, for fields that declared `.access(...)`. */
|
|
38
|
+
fieldAccess(): Record<string, FieldAccess>;
|
|
39
|
+
toModel(): ResourceModel;
|
|
40
|
+
recordSchema(): z.ZodType<RecordOf<F>>;
|
|
41
|
+
ignoredColumns(): string[];
|
|
42
|
+
private refs;
|
|
43
|
+
private knownFields;
|
|
44
|
+
private next;
|
|
45
|
+
}
|
|
46
|
+
export declare const resource: (name: string) => ResourceBuilder<{}, never>;
|
|
47
|
+
//#endregion
|
package/dist/resource.js
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { buildResourceModel, ignoredColumns } from "./resource-model.js";
|
|
2
|
+
import { createRefs } from "./refs.js";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
//#region src/resource.ts
|
|
5
|
+
var ResourceBuilder = class ResourceBuilder {
|
|
6
|
+
state;
|
|
7
|
+
validators;
|
|
8
|
+
accessRules;
|
|
9
|
+
constructor(state, validators = [], accessRules = {}) {
|
|
10
|
+
this.state = state;
|
|
11
|
+
this.validators = validators;
|
|
12
|
+
this.accessRules = accessRules;
|
|
13
|
+
}
|
|
14
|
+
table(table) {
|
|
15
|
+
return this.next({ table });
|
|
16
|
+
}
|
|
17
|
+
fields(fields) {
|
|
18
|
+
return new ResourceBuilder({
|
|
19
|
+
...this.state,
|
|
20
|
+
fields
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
primaryKey(pick) {
|
|
24
|
+
const picked = pick(this.refs());
|
|
25
|
+
const refs = Array.isArray(picked) ? picked : [picked];
|
|
26
|
+
return new ResourceBuilder({
|
|
27
|
+
...this.state,
|
|
28
|
+
primaryKey: refs.map((ref) => ref.name)
|
|
29
|
+
}, this.validators, this.accessRules);
|
|
30
|
+
}
|
|
31
|
+
softDelete(pick) {
|
|
32
|
+
return this.next({ softDelete: pick(this.refs()).name });
|
|
33
|
+
}
|
|
34
|
+
tenant(pick) {
|
|
35
|
+
return this.next({ tenant: pick(this.refs()).name });
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Fields matched by `search("...")` and bare words in filters. Authoritative over the UI's list search defaults.
|
|
39
|
+
* `r.phone.digitsEnd()` matches only the end of the field's digits, for phone numbers.
|
|
40
|
+
*/
|
|
41
|
+
search(pick) {
|
|
42
|
+
const picked = pick(createRefs((name) => ({
|
|
43
|
+
name,
|
|
44
|
+
digitsEnd: () => ({
|
|
45
|
+
name,
|
|
46
|
+
match: "digitsEnd"
|
|
47
|
+
})
|
|
48
|
+
}), this.knownFields()));
|
|
49
|
+
const searchMatch = Object.fromEntries(picked.flatMap((ref) => ref.match ? [[ref.name, ref.match]] : []));
|
|
50
|
+
return this.next({
|
|
51
|
+
search: picked.map((ref) => ref.name),
|
|
52
|
+
searchMatch
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
/** The field holding the owning user's id, which `.own` capability scopes filter on. */
|
|
56
|
+
owner(pick) {
|
|
57
|
+
return this.next({ owner: pick(this.refs()).name });
|
|
58
|
+
}
|
|
59
|
+
validate(validator) {
|
|
60
|
+
return new ResourceBuilder(this.state, [...this.validators, validator], this.accessRules);
|
|
61
|
+
}
|
|
62
|
+
access(rules) {
|
|
63
|
+
return new ResourceBuilder(this.state, this.validators, {
|
|
64
|
+
...this.accessRules,
|
|
65
|
+
...rules
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
/** Role-based rules per field, for fields that declared `.access(...)`. */
|
|
69
|
+
fieldAccess() {
|
|
70
|
+
return Object.fromEntries(Object.entries(this.state.fields ?? {}).flatMap(([key, field]) => field?.meta.access ? [[key, field.meta.access]] : []));
|
|
71
|
+
}
|
|
72
|
+
toModel() {
|
|
73
|
+
return buildResourceModel(this.state);
|
|
74
|
+
}
|
|
75
|
+
recordSchema() {
|
|
76
|
+
const shape = Object.fromEntries(Object.entries(this.state.fields ?? {}).flatMap(([key, field]) => field ? [[key, field.schema]] : []));
|
|
77
|
+
return z.object(shape);
|
|
78
|
+
}
|
|
79
|
+
ignoredColumns() {
|
|
80
|
+
return ignoredColumns(this.state.fields);
|
|
81
|
+
}
|
|
82
|
+
refs() {
|
|
83
|
+
return createRefs((name) => ({ name }), this.knownFields());
|
|
84
|
+
}
|
|
85
|
+
knownFields() {
|
|
86
|
+
return Object.entries(this.state.fields ?? {}).filter(([, field]) => field !== null).map(([key]) => key);
|
|
87
|
+
}
|
|
88
|
+
next(patch) {
|
|
89
|
+
return new ResourceBuilder({
|
|
90
|
+
...this.state,
|
|
91
|
+
...patch
|
|
92
|
+
}, this.validators, this.accessRules);
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
const resource = (name) => new ResourceBuilder({ name });
|
|
96
|
+
//#endregion
|
|
97
|
+
export { ResourceBuilder, resource };
|