@jskit-ai/resource-crud-core 0.1.194 → 0.1.196
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/package.json +3 -3
- package/patterns/resource-contract/PATTERN.md +54 -1
- package/src/shared/crudFieldContract.js +34 -45
- package/src/shared/crudListFilterValues.js +650 -0
- package/src/shared/crudListFilters.js +17 -614
- package/src/shared/crudLookup.js +64 -15
- package/src/shared/crudResource.js +51 -25
- package/test/crudContractSupport.test.js +0 -23
- package/test/crudFieldDefaults.test.js +201 -0
- package/test/crudLookup.test.js +1 -38
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jskit-ai/resource-crud-core",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.196",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"scripts": {
|
|
6
6
|
"test": "node --test --test-concurrency=1"
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
"./shared/crudResource": "./src/shared/crudResource.js"
|
|
14
14
|
},
|
|
15
15
|
"dependencies": {
|
|
16
|
-
"@jskit-ai/resource-core": "0.1.
|
|
16
|
+
"@jskit-ai/resource-core": "0.1.196"
|
|
17
17
|
},
|
|
18
18
|
"description": "Shared CRUD resource, field, lookup, filter, and namespace contracts.",
|
|
19
19
|
"jskit": {
|
|
@@ -34,6 +34,6 @@
|
|
|
34
34
|
}
|
|
35
35
|
},
|
|
36
36
|
"peerDependencies": {
|
|
37
|
-
"@jskit-ai/kernel": "0.2.
|
|
37
|
+
"@jskit-ai/kernel": "0.2.5"
|
|
38
38
|
}
|
|
39
39
|
}
|
|
@@ -44,7 +44,9 @@ not from a JSKIT questionnaire.
|
|
|
44
44
|
- User ownership is represented by a non-null `user_id` column.
|
|
45
45
|
- The resource's hidden `userId` field maps to that ownership column.
|
|
46
46
|
- `autofilter: "user"` is used only for genuine current-user ownership.
|
|
47
|
-
-
|
|
47
|
+
- Each product field's output, create, and patch participation is its
|
|
48
|
+
`operations` block, or the default `defineCrudResource()` gives a field
|
|
49
|
+
without one (see Field operations and lookups below).
|
|
48
50
|
- Temporal database values use the declared storage serializer.
|
|
49
51
|
- The source-controlled migration and resource contract agree.
|
|
50
52
|
- Server policy tests prove that one user cannot read or mutate another user's
|
|
@@ -60,6 +62,57 @@ Higher layers should consume this resource through JSKIT's CRUD service,
|
|
|
60
62
|
repository, route, and UI APIs. They should not rebuild field validators or
|
|
61
63
|
serialize requests independently.
|
|
62
64
|
|
|
65
|
+
## Field operations and lookups
|
|
66
|
+
|
|
67
|
+
A field's `operations` name the operations it takes part in: `output` (the
|
|
68
|
+
record in every response), and `create` and `patch` (the request bodies; an
|
|
69
|
+
enabled `replace` uses the `create` fields unless a field names `replace`). A
|
|
70
|
+
field that leaves `operations` out takes the default:
|
|
71
|
+
|
|
72
|
+
| Field | Default `operations` |
|
|
73
|
+
| --- | --- |
|
|
74
|
+
| `primary: true` | `{}`: no operation; every record output already carries its `id` |
|
|
75
|
+
| `hidden: true` | `{}`: no operation |
|
|
76
|
+
| `readOnly: true` | `{ output: { required: true } }` |
|
|
77
|
+
| any other field | `{ output: { required: true }, create: { required: <the field's required> }, patch: { required: false } }` |
|
|
78
|
+
|
|
79
|
+
So a `required: true` field is required on create, and any other field is
|
|
80
|
+
optional there. An `operations` block you write always wins, for example
|
|
81
|
+
`operations: { output: {}, create: {}, patch: {} }` for a field that is
|
|
82
|
+
optional everywhere. Write the block only where it differs from the default:
|
|
83
|
+
|
|
84
|
+
```js
|
|
85
|
+
schema: {
|
|
86
|
+
userId: { type: "id", required: true, hidden: true },
|
|
87
|
+
title: { type: "string", maxLength: 255, required: true, search: true },
|
|
88
|
+
notes: { type: "string", maxLength: 65535, nullable: true, search: true },
|
|
89
|
+
createdAt: { type: "dateTime", default: "now()", readOnly: true, storage: { writeSerializer: "datetime-utc" } }
|
|
90
|
+
}
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
A field that looks up a record of another resource can name that resource's
|
|
94
|
+
namespace with `lookup`:
|
|
95
|
+
|
|
96
|
+
```js
|
|
97
|
+
shelfId: { type: "id", nullable: true, search: true, lookup: "shelves" }
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
`defineCrudResource()` expands it, where `lookup` stood, to:
|
|
101
|
+
|
|
102
|
+
```js
|
|
103
|
+
relation: { kind: "lookup", namespace: "shelves", valueKey: "id" },
|
|
104
|
+
belongsTo: "shelves", // the namespace's JSON REST resource, camel-cased
|
|
105
|
+
as: "shelf", // the field key without its Id suffix
|
|
106
|
+
ui: { formControl: "autocomplete" }
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
Keys you write beside `lookup` win: `belongsTo` and `as` replace the
|
|
110
|
+
filled-in values, and `relation` and `ui` are merged over them, so
|
|
111
|
+
`relation: { labelKey: "name" }` adds a label key and
|
|
112
|
+
`ui: { formControl: "select" }` picks a select. A field whose key has no `Id`
|
|
113
|
+
suffix must write `as`. The built resource equals one that writes the expanded
|
|
114
|
+
keys out.
|
|
115
|
+
|
|
63
116
|
## Example files
|
|
64
117
|
|
|
65
118
|
- `example/bookResource.js` is a complete resource contract for personal book
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { normalizeSingleSchemaDefinition } from "@jskit-ai/kernel/shared/validators";
|
|
2
2
|
import {
|
|
3
|
+
isRecord,
|
|
3
4
|
normalizeObject,
|
|
4
5
|
normalizeText
|
|
5
6
|
} from "@jskit-ai/kernel/shared/support/normalize";
|
|
@@ -10,31 +11,6 @@ const CRUD_FIELD_WRITE_SERIALIZER_DATETIME_UTC = "datetime-utc";
|
|
|
10
11
|
const CRUD_LOOKUP_FORM_CONTROL_AUTOCOMPLETE = "autocomplete";
|
|
11
12
|
const CRUD_LOOKUP_FORM_CONTROL_SELECT = "select";
|
|
12
13
|
|
|
13
|
-
function checkCrudLookupFormControl(
|
|
14
|
-
value,
|
|
15
|
-
{
|
|
16
|
-
context = "crud field ui.formControl",
|
|
17
|
-
defaultValue = CRUD_LOOKUP_FORM_CONTROL_AUTOCOMPLETE
|
|
18
|
-
} = {}
|
|
19
|
-
) {
|
|
20
|
-
const resolvedValue = value === undefined || value === null || value === "" ? defaultValue : value;
|
|
21
|
-
if (resolvedValue === "") {
|
|
22
|
-
return "";
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
if (
|
|
26
|
-
resolvedValue === CRUD_LOOKUP_FORM_CONTROL_AUTOCOMPLETE ||
|
|
27
|
-
resolvedValue === CRUD_LOOKUP_FORM_CONTROL_SELECT
|
|
28
|
-
) {
|
|
29
|
-
return resolvedValue;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
throw new Error(
|
|
33
|
-
`${context} must be "${CRUD_LOOKUP_FORM_CONTROL_AUTOCOMPLETE}" or "${CRUD_LOOKUP_FORM_CONTROL_SELECT}". ` +
|
|
34
|
-
`Received: ${JSON.stringify(resolvedValue)}.`
|
|
35
|
-
);
|
|
36
|
-
}
|
|
37
|
-
|
|
38
14
|
function cloneStructuredFieldMetadata(value = {}) {
|
|
39
15
|
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
40
16
|
return null;
|
|
@@ -75,10 +51,6 @@ function resolveCrudFieldSchemaProperties(value, { context = "crud resource fiel
|
|
|
75
51
|
context,
|
|
76
52
|
defaultMode: "patch"
|
|
77
53
|
});
|
|
78
|
-
if (!normalized) {
|
|
79
|
-
return {};
|
|
80
|
-
}
|
|
81
|
-
|
|
82
54
|
return normalizeObject(normalized.schema.getFieldDefinitions());
|
|
83
55
|
}
|
|
84
56
|
|
|
@@ -91,7 +63,7 @@ function normalizeCrudFieldStorageConfig(
|
|
|
91
63
|
) {
|
|
92
64
|
const normalizedFieldKey = normalizeText(fieldKey);
|
|
93
65
|
const actualField = normalizeText(fieldDefinition.actualField);
|
|
94
|
-
const storage = fieldDefinition
|
|
66
|
+
const storage = fieldDefinition.storage;
|
|
95
67
|
|
|
96
68
|
if (storage === undefined || storage === null) {
|
|
97
69
|
return Object.freeze({
|
|
@@ -211,7 +183,7 @@ function mergeFieldContractEntry(target, source, { context = "crud field contrac
|
|
|
211
183
|
return next;
|
|
212
184
|
}
|
|
213
185
|
|
|
214
|
-
function buildCrudFieldContractMap(resource
|
|
186
|
+
function buildCrudFieldContractMap(resource, { context = "crud resource field contract" } = {}) {
|
|
215
187
|
const sections = [
|
|
216
188
|
resolveCrudFieldSchemaProperties(resource?.operations?.view?.output, {
|
|
217
189
|
context: `${context}.operations.view.output`
|
|
@@ -276,11 +248,40 @@ function buildCrudFieldContractMap(resource = {}, { context = "crud resource fie
|
|
|
276
248
|
);
|
|
277
249
|
}
|
|
278
250
|
|
|
251
|
+
/**
|
|
252
|
+
* The operations a schema field takes part in. A field's own `operations` always
|
|
253
|
+
* win. A field without them takes the default: none when it is `primary: true`
|
|
254
|
+
* (every record output already carries its id) or `hidden: true`, output only
|
|
255
|
+
* when it is `readOnly: true`, and otherwise output, create (required when the
|
|
256
|
+
* field is `required: true`) and patch.
|
|
257
|
+
* @param {Record<string, any>} fieldDefinition
|
|
258
|
+
* @returns {Record<string, unknown>}
|
|
259
|
+
*/
|
|
260
|
+
function resolveCrudFieldOperations(fieldDefinition) {
|
|
261
|
+
if (fieldDefinition.operations !== undefined) {
|
|
262
|
+
return normalizeObject(fieldDefinition.operations);
|
|
263
|
+
}
|
|
264
|
+
if (fieldDefinition.primary === true || fieldDefinition.hidden === true) {
|
|
265
|
+
return {};
|
|
266
|
+
}
|
|
267
|
+
if (fieldDefinition.readOnly === true) {
|
|
268
|
+
return { output: { required: true } };
|
|
269
|
+
}
|
|
270
|
+
return {
|
|
271
|
+
output: { required: true },
|
|
272
|
+
create: { required: fieldDefinition.required === true },
|
|
273
|
+
patch: { required: false }
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
|
|
279
277
|
function buildCrudOperationSchemaFields(fields = {}, operationName = "") {
|
|
280
278
|
const definitions = {};
|
|
281
279
|
|
|
282
280
|
for (const [fieldKey, fieldDefinition] of Object.entries(fields)) {
|
|
283
|
-
|
|
281
|
+
if (!isRecord(fieldDefinition)) {
|
|
282
|
+
continue;
|
|
283
|
+
}
|
|
284
|
+
const operationConfig = resolveCrudFieldOperations(fieldDefinition)[operationName];
|
|
284
285
|
if (!operationConfig) {
|
|
285
286
|
continue;
|
|
286
287
|
}
|
|
@@ -300,26 +301,14 @@ function buildCrudOperationSchemaFields(fields = {}, operationName = "") {
|
|
|
300
301
|
return definitions;
|
|
301
302
|
}
|
|
302
303
|
|
|
303
|
-
function resolveCrudFieldContractEntry(resource = {}, fieldKey = "", options = {}) {
|
|
304
|
-
const normalizedFieldKey = normalizeText(fieldKey);
|
|
305
|
-
if (!normalizedFieldKey) {
|
|
306
|
-
return null;
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
const entries = buildCrudFieldContractMap(resource, options);
|
|
310
|
-
return entries[normalizedFieldKey] || null;
|
|
311
|
-
}
|
|
312
|
-
|
|
313
304
|
export {
|
|
314
305
|
CRUD_FIELD_STORAGE_COLUMN,
|
|
315
306
|
CRUD_FIELD_STORAGE_VIRTUAL,
|
|
316
307
|
CRUD_FIELD_WRITE_SERIALIZER_DATETIME_UTC,
|
|
317
308
|
CRUD_LOOKUP_FORM_CONTROL_AUTOCOMPLETE,
|
|
318
309
|
CRUD_LOOKUP_FORM_CONTROL_SELECT,
|
|
319
|
-
checkCrudLookupFormControl,
|
|
320
310
|
resolveCrudFieldSchemaProperties,
|
|
321
311
|
normalizeCrudFieldStorageConfig,
|
|
322
312
|
buildCrudOperationSchemaFields,
|
|
323
|
-
buildCrudFieldContractMap
|
|
324
|
-
resolveCrudFieldContractEntry
|
|
313
|
+
buildCrudFieldContractMap
|
|
325
314
|
};
|