@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jskit-ai/resource-crud-core",
3
- "version": "0.1.194",
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.194"
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.3"
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
- - Output, create, and patch participation is explicit on each product field.
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?.storage;
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 = {}, { context = "crud resource field contract" } = {}) {
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
- const operationConfig = fieldDefinition?.operations?.[operationName];
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
  };