@jskit-ai/kernel 0.1.55 → 0.1.56

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 (55) hide show
  1. package/package.json +3 -2
  2. package/server/actions/ActionRuntimeServiceProvider.test.js +23 -15
  3. package/server/http/lib/kernel.test.js +447 -0
  4. package/server/http/lib/routeRegistration.js +236 -15
  5. package/server/http/lib/routeTransport.js +126 -0
  6. package/server/http/lib/routeValidator.js +133 -198
  7. package/server/http/lib/routeValidator.test.js +385 -278
  8. package/server/http/lib/router.js +17 -2
  9. package/server/platform/providerRuntime.test.js +7 -7
  10. package/server/runtime/bootBootstrapRoutes.js +2 -18
  11. package/server/runtime/bootBootstrapRoutes.test.js +5 -14
  12. package/server/runtime/fastifyBootstrap.js +119 -0
  13. package/server/runtime/fastifyBootstrap.test.js +119 -1
  14. package/server/runtime/moduleConfig.js +32 -62
  15. package/server/runtime/moduleConfig.test.js +48 -24
  16. package/server/support/pageTargets.js +15 -9
  17. package/server/support/pageTargets.test.js +1 -1
  18. package/shared/actions/actionContributorHelpers.js +5 -11
  19. package/shared/actions/actionDefinitions.js +37 -150
  20. package/shared/actions/actionDefinitions.test.js +117 -136
  21. package/shared/actions/policies.js +25 -169
  22. package/shared/actions/policies.test.js +76 -87
  23. package/shared/actions/registry.test.js +24 -50
  24. package/shared/support/crudFieldContract.js +322 -0
  25. package/shared/support/crudFieldContract.test.js +67 -0
  26. package/shared/support/crudListFilters.js +582 -38
  27. package/shared/support/crudListFilters.test.js +178 -8
  28. package/shared/support/crudLookup.js +14 -7
  29. package/shared/support/crudLookup.test.js +91 -66
  30. package/shared/support/shellLayoutTargets.test.js +1 -1
  31. package/shared/validators/composeSchemaDefinitions.js +53 -0
  32. package/shared/validators/composeSchemaDefinitions.test.js +156 -0
  33. package/shared/validators/createCursorListValidator.js +22 -35
  34. package/shared/validators/createCursorListValidator.test.js +22 -23
  35. package/shared/validators/cursorPaginationQueryValidator.js +14 -24
  36. package/shared/validators/cursorPaginationQueryValidator.test.js +18 -8
  37. package/shared/validators/htmlTimeSchemas.js +6 -4
  38. package/shared/validators/index.js +15 -7
  39. package/shared/validators/jsonRestSchemaSupport.js +139 -0
  40. package/shared/validators/mergeObjectSchemas.js +44 -6
  41. package/shared/validators/mergeObjectSchemas.test.js +60 -35
  42. package/shared/validators/recordIdParamsValidator.js +19 -52
  43. package/shared/validators/recordIdParamsValidator.test.js +13 -8
  44. package/shared/validators/resourceRequiredMetadata.js +3 -3
  45. package/shared/validators/resourceRequiredMetadata.test.js +29 -16
  46. package/shared/validators/schemaDefinitions.js +126 -0
  47. package/shared/validators/schemaDefinitions.test.js +51 -0
  48. package/shared/validators/schemaPayloadValidation.js +65 -0
  49. package/test/barrelExposure.test.js +30 -0
  50. package/test/routeInputContractGuard.test.js +10 -6
  51. package/shared/validators/mergeValidators.js +0 -89
  52. package/shared/validators/mergeValidators.test.js +0 -116
  53. package/shared/validators/nestValidator.js +0 -53
  54. package/shared/validators/nestValidator.test.js +0 -60
  55. package/shared/validators/settingsFieldNormalization.js +0 -40
@@ -1,143 +1,132 @@
1
1
  import assert from "node:assert/strict";
2
2
  import test from "node:test";
3
- import { Type } from "typebox";
3
+ import { createSchema } from "json-rest-schema";
4
4
 
5
5
  import { ensureActionPermissionAllowed, normalizeActionInput, normalizeActionOutput } from "./policies.js";
6
6
 
7
- test("function schema returns normalized value when ok", async () => {
7
+ test("plain json-rest-schema action schemas validate input without reshaping it", async () => {
8
8
  const definition = {
9
- id: "tests.ok",
9
+ id: "tests.schema",
10
10
  version: 1,
11
- inputValidator: {
12
- schema: () => ({
13
- ok: true,
14
- value: {
15
- normalized: true
11
+ input: {
12
+ schema: createSchema({
13
+ workspaceSlug: {
14
+ type: "string",
15
+ required: true,
16
+ minLength: 1
16
17
  }
17
- })
18
+ }),
19
+ mode: "replace"
18
20
  }
19
21
  };
20
22
 
21
- const result = await normalizeActionInput(definition, { raw: true }, {});
22
- assert.deepEqual(result, { normalized: true });
23
+ const result = await normalizeActionInput(definition, { workspaceSlug: " ACME " }, {});
24
+ assert.deepEqual(result, { workspaceSlug: "ACME" });
23
25
  });
24
26
 
25
- test("function schema rejects non-validator results", async () => {
27
+ test("json-rest-schema input validation surfaces plain field keys", async () => {
26
28
  const definition = {
27
- id: "tests.invalid",
29
+ id: "tests.schema.errors",
28
30
  version: 1,
29
- inputValidator: {
30
- schema: () => false
31
+ input: {
32
+ schema: createSchema({
33
+ name: {
34
+ type: "string",
35
+ required: true,
36
+ maxLength: 1
37
+ }
38
+ }),
39
+ mode: "replace"
31
40
  }
32
41
  };
33
42
 
34
43
  await assert.rejects(
35
- () => normalizeActionInput(definition, { raw: true }, {}),
44
+ () => normalizeActionInput(definition, { name: "too long" }, {}),
36
45
  (error) => {
37
- assert.equal(error.code, "ACTION_VALIDATION_FAILED");
38
- assert.match(error.details?.error || "", /Schema validator must return/);
46
+ const fieldErrors = error.details?.fieldErrors || {};
47
+ assert.equal(typeof fieldErrors.name, "string");
48
+ assert.equal(Object.hasOwn(fieldErrors, "/name"), false);
39
49
  return true;
40
50
  }
41
51
  );
42
52
  });
43
53
 
44
- test("function schema propagates validation errors", async () => {
54
+ test("action output validation preserves valid json-rest-schema output", async () => {
45
55
  const definition = {
46
- id: "tests.errors",
47
- version: 2,
48
- inputValidator: {
49
- schema: () => ({
50
- ok: false,
51
- errors: {
52
- input: "input is required"
56
+ id: "tests.output",
57
+ version: 1,
58
+ output: {
59
+ schema: createSchema({
60
+ ok: {
61
+ type: "boolean",
62
+ required: true
53
63
  }
54
- })
64
+ }),
65
+ mode: "replace"
55
66
  }
56
67
  };
57
68
 
58
69
  await assert.rejects(
59
- () => normalizeActionInput(definition, null, {}),
60
- (error) => {
61
- assert.equal(error.code, "ACTION_VALIDATION_FAILED");
62
- assert.deepEqual(error.details?.fieldErrors, {
63
- input: "input is required"
64
- });
65
- return true;
66
- }
70
+ () => normalizeActionOutput(definition, { ok: 2 }, {}),
71
+ (error) => error?.code === "ACTION_OUTPUT_VALIDATION_FAILED"
67
72
  );
73
+
74
+ const result = await normalizeActionOutput(definition, { ok: true }, {});
75
+ assert.deepEqual(result, { ok: true });
68
76
  });
69
77
 
70
- test("raw TypeBox action schemas validate normalized action input", async () => {
78
+ test("json-rest-schema action validators normalize action input", async () => {
71
79
  const definition = {
72
- id: "tests.typebox",
80
+ id: "tests.json-rest-schema",
73
81
  version: 1,
74
- inputValidator: {
75
- schema: Type.Object(
76
- {
77
- workspaceSlug: Type.String({ minLength: 1 })
78
- },
79
- { additionalProperties: false }
80
- ),
81
- normalize(value = {}) {
82
- return {
83
- workspaceSlug: String(value.workspaceSlug || "").trim().toLowerCase()
84
- };
85
- }
82
+ input: {
83
+ schema: createSchema({
84
+ name: {
85
+ type: "string",
86
+ required: true,
87
+ minLength: 1,
88
+ messages: {
89
+ minLength: "Name is required."
90
+ }
91
+ }
92
+ }),
93
+ mode: "patch"
86
94
  }
87
95
  };
88
96
 
89
- const result = await normalizeActionInput(definition, { workspaceSlug: " ACME " }, {});
90
- assert.deepEqual(result, { workspaceSlug: "acme" });
97
+ const result = await normalizeActionInput(definition, { name: " Acme " }, {});
98
+ assert.deepEqual(result, { name: "Acme" });
91
99
  });
92
100
 
93
- test("typebox input validation normalizes pointer field errors to plain keys", async () => {
101
+ test("json-rest-schema action validators surface field errors", async () => {
94
102
  const definition = {
95
- id: "tests.typebox.errors",
103
+ id: "tests.json-rest-schema.errors",
96
104
  version: 1,
97
- inputValidator: {
98
- schema: Type.Object(
99
- {
100
- name: Type.String({ maxLength: 1 })
101
- },
102
- { additionalProperties: false }
103
- )
105
+ input: {
106
+ schema: createSchema({
107
+ name: {
108
+ type: "string",
109
+ required: true,
110
+ minLength: 1,
111
+ messages: {
112
+ minLength: "Name is required."
113
+ }
114
+ }
115
+ }),
116
+ mode: "patch"
104
117
  }
105
118
  };
106
119
 
107
120
  await assert.rejects(
108
- () => normalizeActionInput(definition, { name: "too long" }, {}),
121
+ () => normalizeActionInput(definition, { name: " " }, {}),
109
122
  (error) => {
110
- const fieldErrors = error.details?.fieldErrors || {};
111
- assert.equal(typeof fieldErrors.name, "string");
112
- assert.equal(Object.hasOwn(fieldErrors, "/name"), false);
123
+ assert.equal(error.code, "ACTION_VALIDATION_FAILED");
124
+ assert.equal(error.details?.fieldErrors?.name, "Name is required.");
113
125
  return true;
114
126
  }
115
127
  );
116
128
  });
117
129
 
118
- test("action output normalization runs before output validation", async () => {
119
- const definition = {
120
- id: "tests.output",
121
- version: 1,
122
- outputValidator: {
123
- schema: Type.Object(
124
- {
125
- ok: Type.Boolean()
126
- },
127
- { additionalProperties: false }
128
- ),
129
- normalize(payload = {}) {
130
- return {
131
- ok: Boolean(payload.ok)
132
- };
133
- }
134
- }
135
- };
136
-
137
- const result = await normalizeActionOutput(definition, { ok: 1 }, {});
138
- assert.deepEqual(result, { ok: true });
139
- });
140
-
141
130
  test("action permission denies unauthenticated access when required", () => {
142
131
  assert.throws(
143
132
  () =>
@@ -1,15 +1,11 @@
1
1
  import assert from "node:assert/strict";
2
2
  import test from "node:test";
3
- import { Type } from "typebox";
3
+ import { createSchema } from "json-rest-schema";
4
4
 
5
5
  import { createActionRegistry } from "./registry.js";
6
6
 
7
7
  function createPassThroughSchema() {
8
- return {
9
- parse(value) {
10
- return value;
11
- }
12
- };
8
+ return createSchema({});
13
9
  }
14
10
 
15
11
  test("action registry executes latest version by default", async () => {
@@ -28,7 +24,7 @@ test("action registry executes latest version by default", async () => {
28
24
  kind: "query",
29
25
  channels: ["api"],
30
26
  surfaces: ["app", "admin", "console"],
31
- inputValidator: { schema: createPassThroughSchema() },
27
+ input: { schema: createPassThroughSchema() },
32
28
  idempotency: "none",
33
29
  audit: {
34
30
  actionName: "settings.read"
@@ -48,7 +44,7 @@ test("action registry executes latest version by default", async () => {
48
44
  kind: "query",
49
45
  channels: ["api"],
50
46
  surfaces: ["app", "admin", "console"],
51
- inputValidator: { schema: createPassThroughSchema() },
47
+ input: { schema: createPassThroughSchema() },
52
48
  idempotency: "none",
53
49
  audit: {
54
50
  actionName: "settings.read"
@@ -82,7 +78,7 @@ test("action registry executes latest version by default", async () => {
82
78
  assert.deepEqual(calls, ["v2"]);
83
79
  });
84
80
 
85
- test("action registry merges action input validators", async () => {
81
+ test("action registry validates action input with a single schema contract", async () => {
86
82
  const registry = createActionRegistry({
87
83
  contributors: [
88
84
  {
@@ -96,42 +92,20 @@ test("action registry merges action input validators", async () => {
96
92
  kind: "command",
97
93
  channels: ["api"],
98
94
  surfaces: ["app"],
99
- inputValidator: [
100
- {
101
- schema: Type.Object(
102
- {
103
- workspaceSlug: Type.Optional(Type.String({ minLength: 1 }))
104
- },
105
- { additionalProperties: false }
106
- ),
107
- normalize(input = {}) {
108
- if (!Object.hasOwn(input, "workspaceSlug")) {
109
- return {};
110
- }
111
-
112
- return {
113
- workspaceSlug: String(input.workspaceSlug || "").trim().toLowerCase()
114
- };
115
- }
116
- },
117
- {
118
- schema: Type.Object(
119
- {
120
- invitesEnabled: Type.Optional(Type.Boolean())
121
- },
122
- { additionalProperties: false }
123
- ),
124
- normalize(input = {}) {
125
- if (!Object.hasOwn(input, "invitesEnabled")) {
126
- return {};
127
- }
128
-
129
- return {
130
- invitesEnabled: input.invitesEnabled === true
131
- };
95
+ input: {
96
+ schema: createSchema({
97
+ workspaceSlug: {
98
+ type: "string",
99
+ required: false,
100
+ minLength: 1,
101
+ lowercase: true
102
+ },
103
+ invitesEnabled: {
104
+ type: "boolean",
105
+ required: false
132
106
  }
133
- }
134
- ],
107
+ })
108
+ },
135
109
  idempotency: "optional",
136
110
  audit: {
137
111
  actionName: "workspace.settings.update"
@@ -181,7 +155,7 @@ test("action registry fails startup on duplicate action id + version", () => {
181
155
  kind: "command",
182
156
  channels: ["api"],
183
157
  surfaces: ["app"],
184
- inputValidator: { schema: createPassThroughSchema() },
158
+ input: { schema: createPassThroughSchema() },
185
159
  idempotency: "optional",
186
160
  audit: {
187
161
  actionName: "settings.profile.update"
@@ -206,7 +180,7 @@ test("action registry fails startup on duplicate action id + version", () => {
206
180
  kind: "command",
207
181
  channels: ["api"],
208
182
  surfaces: ["app"],
209
- inputValidator: { schema: createPassThroughSchema() },
183
+ input: { schema: createPassThroughSchema() },
210
184
  idempotency: "optional",
211
185
  audit: {
212
186
  actionName: "settings.profile.update"
@@ -240,7 +214,7 @@ test("action registry rejects invalid version requests", async () => {
240
214
  kind: "query",
241
215
  channels: ["api"],
242
216
  surfaces: ["app"],
243
- inputValidator: { schema: createPassThroughSchema() },
217
+ input: { schema: createPassThroughSchema() },
244
218
  idempotency: "none",
245
219
  audit: {
246
220
  actionName: "settings.read"
@@ -277,7 +251,7 @@ test("action registry rejects invalid version requests", async () => {
277
251
  );
278
252
  });
279
253
 
280
- test("action registry ignores unknown legacy fields", async () => {
254
+ test("action registry ignores unknown extra fields", async () => {
281
255
  const registry = createActionRegistry({
282
256
  contributors: [
283
257
  {
@@ -292,7 +266,7 @@ test("action registry ignores unknown legacy fields", async () => {
292
266
  channels: ["api", "internal"],
293
267
  surfaces: ["app"],
294
268
  consoleUsersOnly: true,
295
- inputValidator: { schema: createPassThroughSchema() },
269
+ input: { schema: createPassThroughSchema() },
296
270
  idempotency: "none",
297
271
  audit: {
298
272
  actionName: "settings.internal.ping"
@@ -335,7 +309,7 @@ test("action registry enforces action-level permissions", async () => {
335
309
  require: "all",
336
310
  permissions: ["workspace.settings.update"]
337
311
  },
338
- inputValidator: { schema: createPassThroughSchema() },
312
+ input: { schema: createPassThroughSchema() },
339
313
  idempotency: "optional",
340
314
  audit: {
341
315
  actionName: "workspace.settings.update"
@@ -0,0 +1,322 @@
1
+ import { normalizeSchemaDefinition } from "../validators/schemaDefinitions.js";
2
+ import { normalizeObject, normalizeText } from "./normalize.js";
3
+
4
+ const CRUD_FIELD_STORAGE_COLUMN = "column";
5
+ const CRUD_FIELD_STORAGE_VIRTUAL = "virtual";
6
+ const CRUD_FIELD_WRITE_SERIALIZER_DATETIME_UTC = "datetime-utc";
7
+ const CRUD_LOOKUP_FORM_CONTROL_AUTOCOMPLETE = "autocomplete";
8
+ const CRUD_LOOKUP_FORM_CONTROL_SELECT = "select";
9
+
10
+ function checkCrudLookupFormControl(
11
+ value,
12
+ {
13
+ context = "crud field ui.formControl",
14
+ defaultValue = CRUD_LOOKUP_FORM_CONTROL_AUTOCOMPLETE
15
+ } = {}
16
+ ) {
17
+ const resolvedValue = value === undefined || value === null || value === "" ? defaultValue : value;
18
+ if (resolvedValue === "") {
19
+ return "";
20
+ }
21
+
22
+ if (
23
+ resolvedValue === CRUD_LOOKUP_FORM_CONTROL_AUTOCOMPLETE ||
24
+ resolvedValue === CRUD_LOOKUP_FORM_CONTROL_SELECT
25
+ ) {
26
+ return resolvedValue;
27
+ }
28
+
29
+ throw new Error(
30
+ `${context} must be "${CRUD_LOOKUP_FORM_CONTROL_AUTOCOMPLETE}" or "${CRUD_LOOKUP_FORM_CONTROL_SELECT}". ` +
31
+ `Received: ${JSON.stringify(resolvedValue)}.`
32
+ );
33
+ }
34
+
35
+ function cloneStructuredFieldMetadata(value = {}) {
36
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
37
+ return null;
38
+ }
39
+
40
+ const normalized = {};
41
+ for (const [key, entry] of Object.entries(value)) {
42
+ if (entry === undefined) {
43
+ continue;
44
+ }
45
+
46
+ if (Array.isArray(entry)) {
47
+ normalized[key] = entry.map((item) =>
48
+ item && typeof item === "object" && !Array.isArray(item)
49
+ ? cloneStructuredFieldMetadata(item) || {}
50
+ : item
51
+ );
52
+ continue;
53
+ }
54
+
55
+ if (entry && typeof entry === "object") {
56
+ normalized[key] = cloneStructuredFieldMetadata(entry) || {};
57
+ continue;
58
+ }
59
+
60
+ normalized[key] = entry;
61
+ }
62
+
63
+ return Object.keys(normalized).length > 0 ? Object.freeze(normalized) : null;
64
+ }
65
+
66
+ function resolveCrudFieldSchemaProperties(value, { context = "crud resource field definitions" } = {}) {
67
+ if (value == null) {
68
+ return {};
69
+ }
70
+
71
+ const normalized = normalizeSchemaDefinition(value, {
72
+ context,
73
+ defaultMode: "patch"
74
+ });
75
+ if (!normalized) {
76
+ return {};
77
+ }
78
+
79
+ return normalizeObject(normalized.schema.getFieldDefinitions());
80
+ }
81
+
82
+ function normalizeCrudFieldStorageConfig(
83
+ fieldDefinition = {},
84
+ {
85
+ context = "crud field storage",
86
+ fieldKey = ""
87
+ } = {}
88
+ ) {
89
+ const normalizedFieldKey = normalizeText(fieldKey);
90
+ const actualField = normalizeText(fieldDefinition.actualField);
91
+ const storage = fieldDefinition?.storage;
92
+
93
+ if (storage === undefined || storage === null) {
94
+ return Object.freeze({
95
+ mode: CRUD_FIELD_STORAGE_COLUMN,
96
+ column: actualField,
97
+ writeSerializer: ""
98
+ });
99
+ }
100
+
101
+ if (!storage || typeof storage !== "object" || Array.isArray(storage)) {
102
+ throw new TypeError(
103
+ `${context}${normalizedFieldKey ? `["${normalizedFieldKey}"]` : ""} must be an object when provided.`
104
+ );
105
+ }
106
+
107
+ for (const storageKey of Object.keys(storage)) {
108
+ if (storageKey !== "column" && storageKey !== "virtual" && storageKey !== "writeSerializer") {
109
+ throw new Error(
110
+ `${context}${normalizedFieldKey ? `["${normalizedFieldKey}"]` : ""} does not support storage.${storageKey}.`
111
+ );
112
+ }
113
+ }
114
+
115
+ const column = normalizeText(storage.column) || actualField;
116
+ const virtual = storage.virtual === true;
117
+ const writeSerializer = normalizeText(storage.writeSerializer).toLowerCase();
118
+
119
+ if (actualField && normalizeText(storage.column) && normalizeText(storage.column) !== actualField) {
120
+ throw new Error(
121
+ `${context}${normalizedFieldKey ? `["${normalizedFieldKey}"]` : ""} actualField and storage.column must match when both are provided.`
122
+ );
123
+ }
124
+
125
+ if (writeSerializer && writeSerializer !== CRUD_FIELD_WRITE_SERIALIZER_DATETIME_UTC) {
126
+ throw new Error(
127
+ `${context}${normalizedFieldKey ? `["${normalizedFieldKey}"]` : ""} storage.writeSerializer must be ` +
128
+ `"${CRUD_FIELD_WRITE_SERIALIZER_DATETIME_UTC}" when provided.`
129
+ );
130
+ }
131
+
132
+ if (virtual && column) {
133
+ throw new Error(
134
+ `${context}${normalizedFieldKey ? `["${normalizedFieldKey}"]` : ""} virtual fields cannot define actualField or storage.column.`
135
+ );
136
+ }
137
+
138
+ if (virtual && writeSerializer) {
139
+ throw new Error(
140
+ `${context}${normalizedFieldKey ? `["${normalizedFieldKey}"]` : ""} virtual fields cannot define storage.writeSerializer.`
141
+ );
142
+ }
143
+
144
+ return Object.freeze({
145
+ mode: virtual ? CRUD_FIELD_STORAGE_VIRTUAL : CRUD_FIELD_STORAGE_COLUMN,
146
+ column,
147
+ writeSerializer
148
+ });
149
+ }
150
+
151
+ function mergeFieldContractEntry(target, source, { context = "crud field contract", fieldKey = "" } = {}) {
152
+ if (!source || typeof source !== "object") {
153
+ return target;
154
+ }
155
+
156
+ const next = target ? { ...target } : {};
157
+ const normalizedFieldKey = normalizeText(fieldKey);
158
+
159
+ const mergeScalar = (key) => {
160
+ const value = normalizeText(source[key]);
161
+ if (!value) {
162
+ return;
163
+ }
164
+ if (next[key] && next[key] !== value) {
165
+ throw new Error(`${context}["${normalizedFieldKey}"] has conflicting ${key} metadata.`);
166
+ }
167
+ next[key] = value;
168
+ };
169
+
170
+ mergeScalar("actualField");
171
+ mergeScalar("parentRouteParamKey");
172
+
173
+ const storage = source.storage && typeof source.storage === "object" ? source.storage : null;
174
+ if (storage) {
175
+ const currentStorage = next.storage || {};
176
+ if (storage.mode && currentStorage.mode && currentStorage.mode !== storage.mode) {
177
+ throw new Error(`${context}["${normalizedFieldKey}"] has conflicting storage.mode metadata.`);
178
+ }
179
+ if (storage.column && currentStorage.column && currentStorage.column !== storage.column) {
180
+ throw new Error(`${context}["${normalizedFieldKey}"] has conflicting storage.column metadata.`);
181
+ }
182
+ if (storage.writeSerializer &&
183
+ currentStorage.writeSerializer &&
184
+ currentStorage.writeSerializer !== storage.writeSerializer
185
+ ) {
186
+ throw new Error(`${context}["${normalizedFieldKey}"] has conflicting storage.writeSerializer metadata.`);
187
+ }
188
+ next.storage = {
189
+ ...currentStorage,
190
+ ...storage
191
+ };
192
+ }
193
+
194
+ if (source.relation && typeof source.relation === "object") {
195
+ next.relation = {
196
+ ...(next.relation && typeof next.relation === "object" ? next.relation : {}),
197
+ ...source.relation
198
+ };
199
+ }
200
+
201
+ if (source.ui && typeof source.ui === "object") {
202
+ next.ui = {
203
+ ...(next.ui && typeof next.ui === "object" ? next.ui : {}),
204
+ ...source.ui
205
+ };
206
+ }
207
+
208
+ return next;
209
+ }
210
+
211
+ function buildCrudFieldContractMap(resource = {}, { context = "crud resource field contract" } = {}) {
212
+ const sections = [
213
+ resolveCrudFieldSchemaProperties(resource?.operations?.view?.output, {
214
+ context: `${context}.operations.view.output`
215
+ }),
216
+ resolveCrudFieldSchemaProperties(resource?.operations?.create?.body, {
217
+ context: `${context}.operations.create.body`
218
+ }),
219
+ resolveCrudFieldSchemaProperties(resource?.operations?.patch?.body, {
220
+ context: `${context}.operations.patch.body`
221
+ })
222
+ ];
223
+
224
+ const entries = {};
225
+ for (const definitions of sections) {
226
+ for (const [rawKey, rawDefinition] of Object.entries(definitions)) {
227
+ const key = normalizeText(rawKey);
228
+ if (!key) {
229
+ continue;
230
+ }
231
+ const definition = normalizeObject(rawDefinition);
232
+ const storage = normalizeCrudFieldStorageConfig(definition, {
233
+ context: `${context}.storage`,
234
+ fieldKey: key
235
+ });
236
+ const relation = cloneStructuredFieldMetadata(definition.relation);
237
+ const ui = cloneStructuredFieldMetadata(definition.ui);
238
+ const parentRouteParamKey =
239
+ normalizeText(definition.parentRouteParamKey) ||
240
+ normalizeText(definition?.relation?.parentRouteParamKey);
241
+
242
+ entries[key] = mergeFieldContractEntry(entries[key], {
243
+ actualField: normalizeText(definition.actualField),
244
+ parentRouteParamKey,
245
+ storage,
246
+ relation,
247
+ ui
248
+ }, {
249
+ context,
250
+ fieldKey: key
251
+ });
252
+ }
253
+ }
254
+
255
+ return Object.freeze(
256
+ Object.fromEntries(
257
+ Object.entries(entries).map(([key, value]) => [
258
+ key,
259
+ Object.freeze({
260
+ key,
261
+ actualField: normalizeText(value.actualField),
262
+ parentRouteParamKey: normalizeText(value.parentRouteParamKey),
263
+ storage: Object.freeze({
264
+ mode: normalizeText(value?.storage?.mode) || CRUD_FIELD_STORAGE_COLUMN,
265
+ column: normalizeText(value?.storage?.column),
266
+ writeSerializer: normalizeText(value?.storage?.writeSerializer).toLowerCase()
267
+ }),
268
+ relation: cloneStructuredFieldMetadata(value.relation),
269
+ ui: cloneStructuredFieldMetadata(value.ui)
270
+ })
271
+ ])
272
+ )
273
+ );
274
+ }
275
+
276
+ function buildCrudOperationSchemaFields(fields = {}, operationName = "") {
277
+ const definitions = {};
278
+
279
+ for (const [fieldKey, fieldDefinition] of Object.entries(fields)) {
280
+ const operationConfig = fieldDefinition?.operations?.[operationName];
281
+ if (!operationConfig) {
282
+ continue;
283
+ }
284
+
285
+ const nextDefinition = {
286
+ ...fieldDefinition
287
+ };
288
+ delete nextDefinition.operations;
289
+
290
+ if (operationConfig !== true) {
291
+ Object.assign(nextDefinition, operationConfig);
292
+ }
293
+
294
+ definitions[fieldKey] = nextDefinition;
295
+ }
296
+
297
+ return definitions;
298
+ }
299
+
300
+ function resolveCrudFieldContractEntry(resource = {}, fieldKey = "", options = {}) {
301
+ const normalizedFieldKey = normalizeText(fieldKey);
302
+ if (!normalizedFieldKey) {
303
+ return null;
304
+ }
305
+
306
+ const entries = buildCrudFieldContractMap(resource, options);
307
+ return entries[normalizedFieldKey] || null;
308
+ }
309
+
310
+ export {
311
+ CRUD_FIELD_STORAGE_COLUMN,
312
+ CRUD_FIELD_STORAGE_VIRTUAL,
313
+ CRUD_FIELD_WRITE_SERIALIZER_DATETIME_UTC,
314
+ CRUD_LOOKUP_FORM_CONTROL_AUTOCOMPLETE,
315
+ CRUD_LOOKUP_FORM_CONTROL_SELECT,
316
+ checkCrudLookupFormControl,
317
+ resolveCrudFieldSchemaProperties,
318
+ normalizeCrudFieldStorageConfig,
319
+ buildCrudOperationSchemaFields,
320
+ buildCrudFieldContractMap,
321
+ resolveCrudFieldContractEntry
322
+ };