@jskit-ai/kernel 0.1.55 → 0.1.57
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 -2
- package/server/actions/ActionRuntimeServiceProvider.test.js +23 -15
- package/server/http/lib/kernel.test.js +447 -0
- package/server/http/lib/routeRegistration.js +236 -15
- package/server/http/lib/routeTransport.js +126 -0
- package/server/http/lib/routeValidator.js +133 -198
- package/server/http/lib/routeValidator.test.js +385 -278
- package/server/http/lib/router.js +17 -2
- package/server/platform/providerRuntime.test.js +7 -7
- package/server/runtime/bootBootstrapRoutes.js +2 -18
- package/server/runtime/bootBootstrapRoutes.test.js +5 -14
- package/server/runtime/fastifyBootstrap.js +119 -0
- package/server/runtime/fastifyBootstrap.test.js +119 -1
- package/server/runtime/moduleConfig.js +32 -62
- package/server/runtime/moduleConfig.test.js +48 -24
- package/server/support/pageTargets.js +15 -9
- package/server/support/pageTargets.test.js +1 -1
- package/shared/actions/actionContributorHelpers.js +5 -11
- package/shared/actions/actionDefinitions.js +37 -150
- package/shared/actions/actionDefinitions.test.js +117 -136
- package/shared/actions/policies.js +25 -169
- package/shared/actions/policies.test.js +76 -87
- package/shared/actions/registry.test.js +24 -50
- package/shared/support/crudFieldContract.js +322 -0
- package/shared/support/crudFieldContract.test.js +67 -0
- package/shared/support/crudListFilters.js +582 -38
- package/shared/support/crudListFilters.test.js +178 -8
- package/shared/support/crudLookup.js +14 -7
- package/shared/support/crudLookup.test.js +91 -66
- package/shared/support/normalize.js +7 -0
- package/shared/support/normalize.test.js +4 -2
- package/shared/support/shellLayoutTargets.test.js +1 -1
- package/shared/validators/composeSchemaDefinitions.js +53 -0
- package/shared/validators/composeSchemaDefinitions.test.js +156 -0
- package/shared/validators/createCursorListValidator.js +22 -35
- package/shared/validators/createCursorListValidator.test.js +22 -23
- package/shared/validators/cursorPaginationQueryValidator.js +14 -24
- package/shared/validators/cursorPaginationQueryValidator.test.js +18 -8
- package/shared/validators/htmlTimeSchemas.js +6 -4
- package/shared/validators/index.js +15 -7
- package/shared/validators/jsonRestSchemaSupport.js +139 -0
- package/shared/validators/mergeObjectSchemas.js +44 -6
- package/shared/validators/mergeObjectSchemas.test.js +60 -35
- package/shared/validators/recordIdParamsValidator.js +19 -52
- package/shared/validators/recordIdParamsValidator.test.js +13 -8
- package/shared/validators/resourceRequiredMetadata.js +3 -3
- package/shared/validators/resourceRequiredMetadata.test.js +29 -16
- package/shared/validators/schemaDefinitions.js +126 -0
- package/shared/validators/schemaDefinitions.test.js +51 -0
- package/shared/validators/schemaPayloadValidation.js +65 -0
- package/test/barrelExposure.test.js +30 -0
- package/test/routeInputContractGuard.test.js +10 -6
- package/shared/validators/mergeValidators.js +0 -89
- package/shared/validators/mergeValidators.test.js +0 -116
- package/shared/validators/nestValidator.js +0 -53
- package/shared/validators/nestValidator.test.js +0 -60
- 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 {
|
|
3
|
+
import { createSchema } from "json-rest-schema";
|
|
4
4
|
|
|
5
5
|
import { ensureActionPermissionAllowed, normalizeActionInput, normalizeActionOutput } from "./policies.js";
|
|
6
6
|
|
|
7
|
-
test("
|
|
7
|
+
test("plain json-rest-schema action schemas validate input without reshaping it", async () => {
|
|
8
8
|
const definition = {
|
|
9
|
-
id: "tests.
|
|
9
|
+
id: "tests.schema",
|
|
10
10
|
version: 1,
|
|
11
|
-
|
|
12
|
-
schema: (
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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, {
|
|
22
|
-
assert.deepEqual(result, {
|
|
23
|
+
const result = await normalizeActionInput(definition, { workspaceSlug: " ACME " }, {});
|
|
24
|
+
assert.deepEqual(result, { workspaceSlug: "ACME" });
|
|
23
25
|
});
|
|
24
26
|
|
|
25
|
-
test("
|
|
27
|
+
test("json-rest-schema input validation surfaces plain field keys", async () => {
|
|
26
28
|
const definition = {
|
|
27
|
-
id: "tests.
|
|
29
|
+
id: "tests.schema.errors",
|
|
28
30
|
version: 1,
|
|
29
|
-
|
|
30
|
-
schema: (
|
|
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, {
|
|
44
|
+
() => normalizeActionInput(definition, { name: "too long" }, {}),
|
|
36
45
|
(error) => {
|
|
37
|
-
|
|
38
|
-
assert.
|
|
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("
|
|
54
|
+
test("action output validation preserves valid json-rest-schema output", async () => {
|
|
45
55
|
const definition = {
|
|
46
|
-
id: "tests.
|
|
47
|
-
version:
|
|
48
|
-
|
|
49
|
-
schema: (
|
|
50
|
-
ok:
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
() =>
|
|
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("
|
|
78
|
+
test("json-rest-schema action validators normalize action input", async () => {
|
|
71
79
|
const definition = {
|
|
72
|
-
id: "tests.
|
|
80
|
+
id: "tests.json-rest-schema",
|
|
73
81
|
version: 1,
|
|
74
|
-
|
|
75
|
-
schema:
|
|
76
|
-
{
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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, {
|
|
90
|
-
assert.deepEqual(result, {
|
|
97
|
+
const result = await normalizeActionInput(definition, { name: " Acme " }, {});
|
|
98
|
+
assert.deepEqual(result, { name: "Acme" });
|
|
91
99
|
});
|
|
92
100
|
|
|
93
|
-
test("
|
|
101
|
+
test("json-rest-schema action validators surface field errors", async () => {
|
|
94
102
|
const definition = {
|
|
95
|
-
id: "tests.
|
|
103
|
+
id: "tests.json-rest-schema.errors",
|
|
96
104
|
version: 1,
|
|
97
|
-
|
|
98
|
-
schema:
|
|
99
|
-
{
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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: "
|
|
121
|
+
() => normalizeActionInput(definition, { name: " " }, {}),
|
|
109
122
|
(error) => {
|
|
110
|
-
|
|
111
|
-
assert.equal(
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
100
|
-
{
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
};
|