@jskit-ai/crud-core 0.1.178 → 0.1.180

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/crud-core",
3
- "version": "0.1.178",
3
+ "version": "0.1.180",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "test": "node --test"
@@ -23,11 +23,9 @@
23
23
  "./server/routeContracts": "./src/server/routeContracts.js"
24
24
  },
25
25
  "dependencies": {
26
- "@jskit-ai/database-runtime": "0.1.167",
27
- "@jskit-ai/http-runtime": "0.1.165",
28
- "@jskit-ai/json-rest-api-core": "0.1.111",
29
- "@jskit-ai/kernel": "0.1.167",
30
- "@jskit-ai/resource-crud-core": "0.1.109",
26
+ "@jskit-ai/database-runtime": "0.1.169",
27
+ "@jskit-ai/json-rest-api-core": "0.1.113",
28
+ "@jskit-ai/resource-crud-core": "0.1.111",
31
29
  "json-rest-schema": "^1.0.17"
32
30
  },
33
31
  "description": "Shared server-side CRUD service, repository, route, and query helpers.",
@@ -45,5 +43,9 @@
45
43
  "providers": []
46
44
  }
47
45
  }
46
+ },
47
+ "peerDependencies": {
48
+ "@jskit-ai/http-runtime": "0.1.167",
49
+ "@jskit-ai/kernel": "0.1.169"
48
50
  }
49
51
  }
@@ -8,8 +8,8 @@
8
8
  "./shared": "./src/shared/index.js"
9
9
  },
10
10
  "dependencies": {
11
- "@jskit-ai/crud-core": "0.1.178",
12
- "@jskit-ai/resource-crud-core": "0.1.109"
11
+ "@jskit-ai/crud-core": "0.1.180",
12
+ "@jskit-ai/resource-crud-core": "0.1.111"
13
13
  },
14
14
  "jskit": {
15
15
  "kind": "runtime",
@@ -1,15 +1,22 @@
1
1
  import {
2
2
  composeSchemaDefinitions,
3
+ createSchema,
3
4
  recordIdParamsValidator
4
5
  } from "@jskit-ai/kernel/shared/validators";
5
6
  import { createEntityChangedActionEvent } from "@jskit-ai/kernel/server/actions";
6
7
  import {
8
+ decodeJsonApiResourceResponse,
7
9
  normalizeJsonApiDocument,
8
- simplifyJsonApiDocument,
9
10
  unwrapJsonApiResult
10
11
  } from "@jskit-ai/http-runtime/shared";
11
12
  import { resolveCrudRecordChangedEvent } from "@jskit-ai/resource-crud-core/shared/crudNamespaceSupport";
12
13
  import {
14
+ resolveJsonApiFieldsetContract,
15
+ resolveJsonApiRelationshipEntries,
16
+ resolveSchemaFieldDefinitions
17
+ } from "../jsonApiResourceContract.js";
18
+ import {
19
+ createJsonApiFieldsetsQueryValidator,
13
20
  createStandardCrudListQueryValidators,
14
21
  createStandardCrudViewQueryValidators
15
22
  } from "../listQueryValidators.js";
@@ -37,12 +44,6 @@ function normalizeOptionalCursor(value) {
37
44
  return normalized || null;
38
45
  }
39
46
 
40
- function resolveDocumentNextCursor(document = {}) {
41
- return normalizeOptionalCursor(
42
- document?.meta?.page?.nextCursor ?? document?.meta?.pagination?.cursor?.next
43
- );
44
- }
45
-
46
47
  function resolveAssistantResultValue(result) {
47
48
  const taggedResult = unwrapJsonApiResult(result);
48
49
  const value = taggedResult ? taggedResult.value : result;
@@ -53,7 +54,124 @@ function resolveAssistantResultValue(result) {
53
54
  };
54
55
  }
55
56
 
56
- function transformCrudAssistantResult(operation, result, { input = {} } = {}) {
57
+ function createProjectionRecordSchema(recordSchema, {
58
+ lookupContainerKey = "",
59
+ relationshipEntries = []
60
+ } = {}) {
61
+ const definitions = recordSchema?.getFieldDefinitions?.();
62
+ if (!isRecord(definitions)) {
63
+ return recordSchema;
64
+ }
65
+
66
+ const fields = Object.fromEntries(
67
+ Object.entries(definitions).map(([fieldKey, fieldDefinition]) => [
68
+ fieldKey,
69
+ {
70
+ ...fieldDefinition,
71
+ required: fieldKey === "id" && fieldDefinition?.required === true
72
+ }
73
+ ])
74
+ );
75
+ if (lookupContainerKey && isRecord(fields[lookupContainerKey])) {
76
+ const createRelatedRecordSchema = (entry) => createSchema({
77
+ id: {
78
+ type: "string",
79
+ required: true
80
+ },
81
+ ...(entry.labelKey && entry.labelKey !== "id"
82
+ ? {
83
+ [entry.labelKey]: {
84
+ type: "string",
85
+ required: false,
86
+ nullable: true
87
+ }
88
+ }
89
+ : {})
90
+ });
91
+ const lookupFields = Object.fromEntries(
92
+ relationshipEntries.map((entry) => [
93
+ entry.relationshipName,
94
+ entry.many === true
95
+ ? {
96
+ type: "array",
97
+ required: false,
98
+ items: {
99
+ type: "object",
100
+ schema: createRelatedRecordSchema(entry),
101
+ additionalProperties: true
102
+ }
103
+ }
104
+ : {
105
+ type: "object",
106
+ required: false,
107
+ schema: createRelatedRecordSchema(entry),
108
+ additionalProperties: true
109
+ }
110
+ ])
111
+ );
112
+ fields[lookupContainerKey] = {
113
+ ...fields[lookupContainerKey],
114
+ type: "object",
115
+ required: false,
116
+ ...(Object.keys(lookupFields).length > 0 ? { schema: createSchema(lookupFields) } : {}),
117
+ additionalProperties: true
118
+ };
119
+ }
120
+
121
+ return createSchema(fields);
122
+ }
123
+
124
+ function createProjectionOutputDefinition(output, operation, relationshipEntries, lookupContainerKey) {
125
+ if (!output || (operation !== "list" && operation !== "view")) {
126
+ return output;
127
+ }
128
+
129
+ if (operation === "view") {
130
+ return Object.freeze({
131
+ schema: createProjectionRecordSchema(output.schema, { lookupContainerKey, relationshipEntries }),
132
+ mode: "replace"
133
+ });
134
+ }
135
+
136
+ const listFields = resolveSchemaFieldDefinitions(output);
137
+ const items = listFields.items;
138
+ if (!isRecord(items) || items.type !== "array" || typeof items.items?.getFieldDefinitions !== "function") {
139
+ return output;
140
+ }
141
+
142
+ return Object.freeze({
143
+ schema: createSchema({
144
+ ...listFields,
145
+ items: {
146
+ ...items,
147
+ items: createProjectionRecordSchema(items.items, { lookupContainerKey, relationshipEntries })
148
+ }
149
+ }),
150
+ mode: "replace"
151
+ });
152
+ }
153
+
154
+ function createCrudAssistantTransport(resource, operation, relationshipEntries, lookupContainerKey) {
155
+ const lookupFieldMap = Object.fromEntries(
156
+ relationshipEntries
157
+ .filter((entry) => entry.many !== true)
158
+ .map((entry) => [entry.relationshipName, entry.attributeKey])
159
+ );
160
+ return Object.freeze({
161
+ kind: "jsonapi-resource",
162
+ responseType: resource.namespace,
163
+ responseKind: operation === "list" ? "collection" : "record",
164
+ ...(lookupContainerKey ? { lookupContainerKey } : {}),
165
+ ...(Object.keys(lookupFieldMap).length > 0 ? { lookupFieldMap } : {})
166
+ });
167
+ }
168
+
169
+ function transformCrudAssistantResult(operation, result, {
170
+ input = {},
171
+ resource,
172
+ relationshipEntries = [],
173
+ lookupContainerKey = ""
174
+ } = {}) {
57
175
  if (operation === "delete") {
58
176
  const resolved = resolveAssistantResultValue(result);
59
177
  if (isRecord(resolved.value) && resolved.value.deleted === true && resolved.value.id != null) {
@@ -68,9 +186,13 @@ function transformCrudAssistantResult(operation, result, { input = {} } = {}) {
68
186
  const resolved = resolveAssistantResultValue(result);
69
187
  if (operation === "list") {
70
188
  if (resolved.document.kind === "collection") {
189
+ const decoded = decodeJsonApiResourceResponse(
190
+ resolved.value,
191
+ createCrudAssistantTransport(resource, operation, relationshipEntries, lookupContainerKey)
192
+ );
71
193
  return {
72
- items: simplifyJsonApiDocument(resolved.value),
73
- nextCursor: resolveDocumentNextCursor(resolved.document)
194
+ items: decoded.items,
195
+ nextCursor: normalizeOptionalCursor(decoded.nextCursor)
74
196
  };
75
197
  }
76
198
  if (Array.isArray(resolved.value)) {
@@ -89,29 +211,93 @@ function transformCrudAssistantResult(operation, result, { input = {} } = {}) {
89
211
  }
90
212
 
91
213
  if (resolved.document.kind === "resource") {
92
- return simplifyJsonApiDocument(resolved.value);
214
+ return decodeJsonApiResourceResponse(
215
+ resolved.value,
216
+ createCrudAssistantTransport(resource, operation, relationshipEntries, lookupContainerKey)
217
+ );
93
218
  }
94
219
  return resolved.value;
95
220
  }
96
221
 
222
+ function createCrudAssistantReadDescription({
223
+ fieldsetContract,
224
+ lookupContainerKey = "",
225
+ namespace = "",
226
+ operation = "list"
227
+ } = {}) {
228
+ const relationshipEntries = fieldsetContract.relationshipEntries;
229
+ const firstRelationship = relationshipEntries[0] || null;
230
+ const includeExample = relationshipEntries.length > 0
231
+ ? relationshipEntries.slice(0, 2).map((entry) => entry.relationshipName).join(",")
232
+ : "pet,service";
233
+ const fieldsExample = firstRelationship
234
+ ? ` fields can be {\"${namespace}\":[\"${firstRelationship.attributeKey}\"],` +
235
+ `\"${firstRelationship.relationshipType}\":[\"${firstRelationship.labelKey || "id"}\"]}.`
236
+ : ` fields can be {\"${namespace}\":[\"id\"]}.`;
237
+ const aliasGuidance = fieldsetContract.aliasMappings
238
+ .map((entry) => `use \"${entry.resourceType}\" instead of \"${entry.alias}\"`)
239
+ .join("; ");
240
+ const fieldsetKeyGuidance = fieldsetContract.resourceTypes.length > 0
241
+ ? ` fields keys must be JSON:API resource types: ${fieldsetContract.resourceTypes.map((entry) => `\"${entry}\"`).join(", ")}.` +
242
+ (aliasGuidance ? ` Relationship aliases are invalid fieldset keys; ${aliasGuidance}.` : "")
243
+ : "";
244
+ const primaryFieldGuidance = fieldsetContract.primaryFields.length > 0
245
+ ? ` Valid \"${namespace}\" fields: ${fieldsetContract.primaryFields.map((entry) => `\"${entry}\"`).join(", ")}.`
246
+ : "";
247
+ const relationshipGuidance = relationshipEntries.length > 0
248
+ ? ` Include relationships: ${relationshipEntries.map((entry) => {
249
+ const lookupPath = lookupContainerKey
250
+ ? ` -> ${operation === "list" ? "items[]." : ""}${lookupContainerKey}.${entry.relationshipName}`
251
+ : "";
252
+ return `\"${entry.relationshipName}\" -> resource type \"${entry.relationshipType}\"${lookupPath}`;
253
+ }).join("; ")}.`
254
+ : "";
255
+
256
+ const subject = operation === "list" ? `List ${namespace} records.` : `View a ${namespace} record.`;
257
+ return `${subject} include must be a comma-separated string such as \"${includeExample}\";` +
258
+ `${fieldsExample}${fieldsetKeyGuidance}${primaryFieldGuidance}${relationshipGuidance}`;
259
+ }
260
+
97
261
  function createCrudAssistantExtension(resource, namespace, operation) {
98
262
  const resourceOperation = CRUD_ASSISTANT_RESOURCE_OPERATION[operation];
99
- const output = resource?.operations?.[resourceOperation]?.output || null;
100
- const actionLabel = operation === "list"
101
- ? `List ${namespace} records.`
102
- : operation === "view"
103
- ? `View a ${namespace} record.`
104
- : operation === "create"
105
- ? `Create a ${namespace} record.`
106
- : operation === "update"
107
- ? `Update a ${namespace} record.`
108
- : `Delete a ${namespace} record.`;
263
+ const nativeOutput = resource?.operations?.[resourceOperation]?.output || null;
264
+ const recordOutput = operation === "list"
265
+ ? Object.freeze({
266
+ schema: resolveSchemaFieldDefinitions(nativeOutput).items?.items,
267
+ mode: "replace"
268
+ })
269
+ : nativeOutput;
270
+ const relationshipEntries = resolveJsonApiRelationshipEntries(recordOutput);
271
+ const lookupContainerKey = String(resource?.contract?.lookup?.containerKey || "").trim();
272
+ const output = createProjectionOutputDefinition(
273
+ nativeOutput,
274
+ operation,
275
+ relationshipEntries,
276
+ lookupContainerKey
277
+ );
278
+ const actionLabel = operation === "list" || operation === "view"
279
+ ? createCrudAssistantReadDescription({
280
+ fieldsetContract: resolveJsonApiFieldsetContract(resource),
281
+ lookupContainerKey,
282
+ namespace,
283
+ operation
284
+ })
285
+ : operation === "create"
286
+ ? `Create a ${namespace} record.`
287
+ : operation === "update"
288
+ ? `Update a ${namespace} record.`
289
+ : `Delete a ${namespace} record.`;
109
290
 
110
291
  return Object.freeze({
111
292
  description: actionLabel,
112
293
  output,
113
294
  transformResult(result, context) {
114
- return transformCrudAssistantResult(operation, result, context);
295
+ return transformCrudAssistantResult(operation, result, {
296
+ ...context,
297
+ resource,
298
+ relationshipEntries,
299
+ lookupContainerKey
300
+ });
115
301
  }
116
302
  });
117
303
  }
@@ -125,9 +311,15 @@ function createActionInput(
125
311
  ) {
126
312
  const definitions = scopeInputValidator ? [scopeInputValidator] : [];
127
313
  if (operation === "list") {
128
- definitions.push(...createStandardCrudListQueryValidators({ resource, listFilterQueryValidator }));
314
+ definitions.push(...createStandardCrudListQueryValidators({
315
+ resource,
316
+ listFilterQueryValidator,
317
+ fieldsetsQueryValidator: createJsonApiFieldsetsQueryValidator({ resource })
318
+ }));
129
319
  } else if (operation === "view") {
130
- definitions.push(recordIdParamsValidator, ...createStandardCrudViewQueryValidators());
320
+ definitions.push(recordIdParamsValidator, ...createStandardCrudViewQueryValidators({
321
+ fieldsetsQueryValidator: createJsonApiFieldsetsQueryValidator({ resource })
322
+ }));
131
323
  } else if (operation === "create") {
132
324
  definitions.push(operationInputs.create || resource.operations.create.body);
133
325
  } else if (operation === "update") {
@@ -0,0 +1,119 @@
1
+ import { normalizeText } from "@jskit-ai/kernel/shared/support/normalize";
2
+ import { resolveCrudResourceScopeName } from "@jskit-ai/resource-crud-core/shared/crudLookup";
3
+
4
+ function isRecord(value) {
5
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
6
+ }
7
+
8
+ function resolveSchemaFieldDefinitions(definition = null) {
9
+ const schema = definition?.schema;
10
+ if (!schema || typeof schema.getFieldDefinitions !== "function") {
11
+ return {};
12
+ }
13
+
14
+ const definitions = schema.getFieldDefinitions();
15
+ return isRecord(definitions) ? definitions : {};
16
+ }
17
+
18
+ function resolveJsonApiRelationshipEntries(definition = null) {
19
+ const entries = [];
20
+
21
+ for (const [fieldKey, fieldDefinition] of Object.entries(resolveSchemaFieldDefinitions(definition))) {
22
+ const normalizedFieldDefinition = isRecord(fieldDefinition) ? fieldDefinition : {};
23
+ const relationshipType = String(normalizedFieldDefinition.belongsTo || "").trim();
24
+ if (relationshipType) {
25
+ const relationshipName = String(normalizedFieldDefinition.as || fieldKey || "").trim();
26
+ if (!relationshipName) {
27
+ continue;
28
+ }
29
+
30
+ entries.push(Object.freeze({
31
+ attributeKey: fieldKey,
32
+ relationshipName,
33
+ relationshipType,
34
+ labelKey: String(normalizedFieldDefinition?.relation?.labelKey || "").trim(),
35
+ required: normalizedFieldDefinition.required === true,
36
+ nullable: normalizedFieldDefinition.nullable === true
37
+ }));
38
+ continue;
39
+ }
40
+
41
+ const relation = isRecord(normalizedFieldDefinition.relation)
42
+ ? normalizedFieldDefinition.relation
43
+ : {};
44
+ if (String(relation.kind || "").trim().toLowerCase() !== "collection") {
45
+ continue;
46
+ }
47
+
48
+ const collectionRelationshipType = resolveCrudResourceScopeName(
49
+ relation.target || relation.targetResource || relation.namespace || relation.apiPath
50
+ );
51
+ if (!collectionRelationshipType) {
52
+ continue;
53
+ }
54
+
55
+ const collectionRelationshipName = String(
56
+ relation.as || normalizedFieldDefinition.as || fieldKey || ""
57
+ ).trim();
58
+ if (!collectionRelationshipName) {
59
+ continue;
60
+ }
61
+
62
+ entries.push(Object.freeze({
63
+ attributeKey: fieldKey,
64
+ relationshipName: collectionRelationshipName,
65
+ relationshipType: collectionRelationshipType,
66
+ labelKey: String(relation.labelKey || "").trim(),
67
+ many: true,
68
+ required: normalizedFieldDefinition.required === true,
69
+ nullable: normalizedFieldDefinition.nullable === true
70
+ }));
71
+ }
72
+
73
+ return Object.freeze(entries);
74
+ }
75
+
76
+ function resolveJsonApiFieldsetContract(resource = {}) {
77
+ const primaryType = normalizeText(resource?.namespace);
78
+ const relationshipEntries = resolveJsonApiRelationshipEntries(resource?.operations?.view?.output);
79
+ const lookupContainerKey = normalizeText(resource?.contract?.lookup?.containerKey);
80
+ const primaryFields = Object.freeze(
81
+ Object.keys(resolveSchemaFieldDefinitions(resource?.operations?.view?.output))
82
+ .map((entry) => normalizeText(entry))
83
+ .filter((entry) => entry && entry !== lookupContainerKey)
84
+ );
85
+ const aliasMappings = Object.freeze(
86
+ relationshipEntries
87
+ .filter((entry) => entry.relationshipName !== entry.relationshipType)
88
+ .map((entry) => Object.freeze({
89
+ alias: entry.relationshipName,
90
+ resourceType: entry.relationshipType
91
+ }))
92
+ );
93
+ const resourceTypes = [];
94
+ const seenTypes = new Set();
95
+ for (const resourceType of [
96
+ primaryType,
97
+ ...relationshipEntries.map((entry) => normalizeText(entry.relationshipType))
98
+ ]) {
99
+ if (!resourceType || seenTypes.has(resourceType)) {
100
+ continue;
101
+ }
102
+ seenTypes.add(resourceType);
103
+ resourceTypes.push(resourceType);
104
+ }
105
+
106
+ return Object.freeze({
107
+ aliasMappings,
108
+ primaryType,
109
+ primaryFields,
110
+ relationshipEntries,
111
+ resourceTypes: Object.freeze(resourceTypes)
112
+ });
113
+ }
114
+
115
+ export {
116
+ resolveJsonApiFieldsetContract,
117
+ resolveJsonApiRelationshipEntries,
118
+ resolveSchemaFieldDefinitions
119
+ };
@@ -4,6 +4,7 @@ import {
4
4
  } from "@jskit-ai/kernel/shared/validators";
5
5
  import { normalizeText } from "@jskit-ai/kernel/shared/support/normalize";
6
6
  import { resolveCrudParentFilterKeys as resolveSharedCrudParentFilterKeys } from "@jskit-ai/resource-crud-core/shared/crudLookup";
7
+ import { resolveJsonApiFieldsetContract } from "./jsonApiResourceContract.js";
7
8
 
8
9
  const listSearchQueryValidator = Object.freeze({
9
10
  schema: createSchema({
@@ -19,29 +20,96 @@ const lookupIncludeQueryValidator = Object.freeze({
19
20
  schema: createSchema({
20
21
  include: {
21
22
  type: "string",
22
- required: false
23
+ required: false,
24
+ messages: {
25
+ default: "include expects a comma-separated string such as \"pet,service\"."
26
+ }
23
27
  }
24
28
  }),
25
29
  mode: "patch"
26
30
  });
27
31
 
32
+ function createJsonApiFieldsetValueDefinition({ allowedFields = [] } = {}) {
33
+ const normalizedAllowedFields = [...new Set(
34
+ (Array.isArray(allowedFields) ? allowedFields : [])
35
+ .map((entry) => normalizeText(entry))
36
+ .filter(Boolean)
37
+ )];
38
+
39
+ return Object.freeze({
40
+ type: "array",
41
+ required: false,
42
+ items: {
43
+ type: "string",
44
+ minLength: 1,
45
+ ...(normalizedAllowedFields.length > 0
46
+ ? { enum: Object.freeze(normalizedAllowedFields) }
47
+ : {})
48
+ }
49
+ });
50
+ }
51
+
52
+ const jsonApiFieldsetValueDefinition = createJsonApiFieldsetValueDefinition();
53
+
28
54
  const jsonApiFieldsetsQueryValidator = Object.freeze({
29
55
  schema: createSchema({
30
56
  fields: {
31
57
  type: "object",
32
58
  required: false,
33
- values: {
34
- type: "array",
35
- items: {
36
- type: "string",
37
- minLength: 1
38
- }
39
- }
59
+ messages: {
60
+ default: "fields expects an object such as {\"bookings\":[\"petId\"],\"pets\":[\"name\"]}."
61
+ },
62
+ values: jsonApiFieldsetValueDefinition
40
63
  }
41
64
  }),
42
65
  mode: "patch"
43
66
  });
44
67
 
68
+ function createJsonApiFieldsetsQueryValidator({ resource = {} } = {}) {
69
+ const contract = resolveJsonApiFieldsetContract(resource);
70
+ if (!contract.primaryType || contract.resourceTypes.length < 1) {
71
+ return jsonApiFieldsetsQueryValidator;
72
+ }
73
+
74
+ const firstRelationship = contract.relationshipEntries[0] || null;
75
+ const example = firstRelationship
76
+ ? `{"${contract.primaryType}":["${firstRelationship.attributeKey}"],` +
77
+ `"${firstRelationship.relationshipType}":["${firstRelationship.labelKey || "id"}"]}`
78
+ : `{"${contract.primaryType}":["id"]}`;
79
+ const allowedTypes = contract.resourceTypes.map((entry) => `"${entry}"`).join(", ");
80
+ const aliasGuidance = contract.aliasMappings
81
+ .map((entry) => `use "${entry.resourceType}" instead of "${entry.alias}"`)
82
+ .join("; ");
83
+ const additionalPropertiesMessage =
84
+ `fields keys must be JSON:API resource types. Allowed keys: ${allowedTypes}.` +
85
+ (aliasGuidance ? ` Relationship aliases are invalid keys; ${aliasGuidance}.` : "");
86
+ const fieldsetSchema = createSchema(
87
+ Object.fromEntries(
88
+ contract.resourceTypes.map((resourceType) => [
89
+ resourceType,
90
+ createJsonApiFieldsetValueDefinition({
91
+ allowedFields: resourceType === contract.primaryType ? contract.primaryFields : []
92
+ })
93
+ ])
94
+ )
95
+ );
96
+
97
+ return Object.freeze({
98
+ schema: createSchema({
99
+ fields: {
100
+ type: "object",
101
+ required: false,
102
+ schema: fieldsetSchema,
103
+ messages: {
104
+ default: `fields expects an object keyed by JSON:API resource type, such as ${example}.`,
105
+ additionalProperties: additionalPropertiesMessage
106
+ }
107
+ }
108
+ }),
109
+ mode: "patch"
110
+ });
111
+ }
112
+
45
113
  function resolveCrudListUsesOrderedCursor(list = {}) {
46
114
  const entries = Array.isArray(list?.orderBy)
47
115
  ? list.orderBy
@@ -109,7 +177,8 @@ function createStandardCrudListQueryValidators({
109
177
  resource = {},
110
178
  listFilterQueryValidator = null,
111
179
  searchQueryValidator = listSearchQueryValidator,
112
- includeQueryValidator = lookupIncludeQueryValidator
180
+ includeQueryValidator = lookupIncludeQueryValidator,
181
+ fieldsetsQueryValidator = jsonApiFieldsetsQueryValidator
113
182
  } = {}) {
114
183
  const resolvedListFilterQueryValidator =
115
184
  listFilterQueryValidator
@@ -126,21 +195,23 @@ function createStandardCrudListQueryValidators({
126
195
  ? [resolvedListFilterQueryValidator]
127
196
  : []),
128
197
  includeQueryValidator,
129
- jsonApiFieldsetsQueryValidator
198
+ fieldsetsQueryValidator
130
199
  ];
131
200
  }
132
201
 
133
202
  function createStandardCrudViewQueryValidators({
134
- includeQueryValidator = lookupIncludeQueryValidator
203
+ includeQueryValidator = lookupIncludeQueryValidator,
204
+ fieldsetsQueryValidator = jsonApiFieldsetsQueryValidator
135
205
  } = {}) {
136
206
  return [
137
207
  includeQueryValidator,
138
- jsonApiFieldsetsQueryValidator
208
+ fieldsetsQueryValidator
139
209
  ];
140
210
  }
141
211
 
142
212
  export {
143
213
  createCrudCursorPaginationQueryValidator,
214
+ createJsonApiFieldsetsQueryValidator,
144
215
  listSearchQueryValidator,
145
216
  lookupIncludeQueryValidator,
146
217
  jsonApiFieldsetsQueryValidator,
@@ -6,84 +6,18 @@ import {
6
6
  composeSchemaDefinitions,
7
7
  recordIdParamsValidator
8
8
  } from "@jskit-ai/kernel/shared/validators";
9
- import { resolveCrudResourceScopeName } from "@jskit-ai/resource-crud-core/shared/crudLookup";
10
9
  import {
11
10
  createStandardCrudListQueryValidators,
12
11
  createStandardCrudViewQueryValidators,
13
12
  listSearchQueryValidator as defaultListSearchQueryValidator,
14
13
  lookupIncludeQueryValidator as defaultLookupIncludeQueryValidator
15
14
  } from "./listQueryValidators.js";
15
+ import { resolveJsonApiRelationshipEntries } from "./jsonApiResourceContract.js";
16
16
 
17
17
  function isRecord(value) {
18
18
  return Boolean(value) && typeof value === "object" && !Array.isArray(value);
19
19
  }
20
20
 
21
- function resolveSchemaFieldDefinitions(definition = null) {
22
- const schema = definition?.schema;
23
- if (!schema || typeof schema.getFieldDefinitions !== "function") {
24
- return {};
25
- }
26
-
27
- const definitions = schema.getFieldDefinitions();
28
- return isRecord(definitions) ? definitions : {};
29
- }
30
-
31
- function resolveJsonApiRelationshipEntries(definition = null) {
32
- const entries = [];
33
-
34
- for (const [fieldKey, fieldDefinition] of Object.entries(resolveSchemaFieldDefinitions(definition))) {
35
- const normalizedFieldDefinition = isRecord(fieldDefinition) ? fieldDefinition : {};
36
- const relationshipType = String(normalizedFieldDefinition.belongsTo || "").trim();
37
- if (relationshipType) {
38
- const relationshipName = String(normalizedFieldDefinition.as || fieldKey || "").trim();
39
- if (!relationshipName) {
40
- continue;
41
- }
42
-
43
- entries.push(Object.freeze({
44
- attributeKey: fieldKey,
45
- relationshipName,
46
- relationshipType,
47
- required: normalizedFieldDefinition.required === true,
48
- nullable: normalizedFieldDefinition.nullable === true
49
- }));
50
- continue;
51
- }
52
-
53
- const relation = isRecord(normalizedFieldDefinition.relation)
54
- ? normalizedFieldDefinition.relation
55
- : {};
56
- if (String(relation.kind || "").trim().toLowerCase() !== "collection") {
57
- continue;
58
- }
59
-
60
- const collectionRelationshipType = resolveCrudResourceScopeName(
61
- relation.target || relation.targetResource || relation.namespace || relation.apiPath
62
- );
63
- if (!collectionRelationshipType) {
64
- continue;
65
- }
66
-
67
- const collectionRelationshipName = String(
68
- relation.as || normalizedFieldDefinition.as || fieldKey || ""
69
- ).trim();
70
- if (!collectionRelationshipName) {
71
- continue;
72
- }
73
-
74
- entries.push(Object.freeze({
75
- attributeKey: fieldKey,
76
- relationshipName: collectionRelationshipName,
77
- relationshipType: collectionRelationshipType,
78
- many: true,
79
- required: normalizedFieldDefinition.required === true,
80
- nullable: normalizedFieldDefinition.nullable === true
81
- }));
82
- }
83
-
84
- return Object.freeze(entries);
85
- }
86
-
87
21
  function readOwnValue(source = {}, key = "") {
88
22
  if (isRecord(source) && Object.hasOwn(source, key)) {
89
23
  return {
@@ -574,4 +508,4 @@ function createCrudJsonApiRouteContracts({
574
508
  });
575
509
  }
576
510
 
577
- export { createCrudJsonApiRouteContracts };
511
+ export { createCrudJsonApiRouteContracts, resolveJsonApiRelationshipEntries };
@@ -0,0 +1,372 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { createServiceToolCatalog } from "@jskit-ai/assistant-core/server";
4
+ import { createActionCatalogue } from "@jskit-ai/kernel/server/actions";
5
+ import {
6
+ createSchema,
7
+ validateSchemaPayload
8
+ } from "@jskit-ai/kernel/shared/validators";
9
+ import { returnJsonApiDocument } from "@jskit-ai/http-runtime/shared";
10
+ import { defineCrudResource } from "@jskit-ai/resource-crud-core/shared/crudResource";
11
+ import { createCrudJsonApiActions } from "../src/server/jsonApiModule/actions.js";
12
+
13
+ const BOOKINGS = Object.freeze([
14
+ Object.freeze({ id: "41", petId: "7", summary: "Wash and trim" }),
15
+ Object.freeze({ id: "42", petId: "8", summary: "Nail trim" })
16
+ ]);
17
+ const PETS = Object.freeze({
18
+ "7": Object.freeze({ id: "7", name: "Fido", species: "dog" }),
19
+ "8": Object.freeze({ id: "8", name: "Mabel", species: "cat" })
20
+ });
21
+
22
+ function createBookingsResource() {
23
+ return defineCrudResource({
24
+ namespace: "bookings",
25
+ tableName: "bookings",
26
+ crudOperations: ["list", "view"],
27
+ contract: {
28
+ lookup: {
29
+ containerKey: "lookups"
30
+ }
31
+ },
32
+ schema: {
33
+ petId: {
34
+ type: "id",
35
+ required: true,
36
+ belongsTo: "pets",
37
+ as: "pet",
38
+ relation: {
39
+ kind: "lookup",
40
+ namespace: "pets",
41
+ valueKey: "id",
42
+ labelKey: "name"
43
+ },
44
+ operations: {
45
+ output: { required: true }
46
+ }
47
+ },
48
+ summary: {
49
+ type: "string",
50
+ required: true,
51
+ operations: {
52
+ output: { required: true }
53
+ }
54
+ }
55
+ }
56
+ });
57
+ }
58
+
59
+ function selectFields(source, selectedFields = null) {
60
+ if (!Array.isArray(selectedFields)) {
61
+ return { ...source };
62
+ }
63
+ return Object.fromEntries(
64
+ selectedFields
65
+ .filter((field) => Object.hasOwn(source, field))
66
+ .map((field) => [field, source[field]])
67
+ );
68
+ }
69
+
70
+ function createBookingDocument(query = {}) {
71
+ const limit = Math.min(Number(query.limit) || BOOKINGS.length, BOOKINGS.length);
72
+ const selectedBookings = query.fields?.bookings || null;
73
+ const selectedPets = query.fields?.pets || null;
74
+ const includesPet = String(query.include || "")
75
+ .split(",")
76
+ .map((entry) => entry.trim())
77
+ .includes("pet");
78
+ const rows = BOOKINGS.slice(0, limit);
79
+ const data = rows.map((booking) => {
80
+ const includePetLinkage = !selectedBookings || selectedBookings.includes("petId");
81
+ return {
82
+ type: "bookings",
83
+ id: booking.id,
84
+ attributes: selectFields({ summary: booking.summary }, selectedBookings),
85
+ ...(includePetLinkage
86
+ ? {
87
+ relationships: {
88
+ pet: {
89
+ data: { type: "pets", id: booking.petId }
90
+ }
91
+ }
92
+ }
93
+ : {})
94
+ };
95
+ });
96
+ const included = includesPet
97
+ ? rows.map((booking) => ({
98
+ type: "pets",
99
+ id: booking.petId,
100
+ attributes: selectFields({
101
+ name: PETS[booking.petId].name,
102
+ species: PETS[booking.petId].species
103
+ }, selectedPets)
104
+ }))
105
+ : [];
106
+
107
+ return {
108
+ data,
109
+ ...(included.length > 0 ? { included } : {}),
110
+ meta: {
111
+ page: {
112
+ nextCursor: limit < BOOKINGS.length ? `after-${rows.at(-1)?.id}` : null
113
+ }
114
+ }
115
+ };
116
+ }
117
+
118
+ function createFixture() {
119
+ const resource = createBookingsResource();
120
+ const calls = [];
121
+ const service = {
122
+ async queryDocuments(query, { context }) {
123
+ calls.push({ query, context });
124
+ return returnJsonApiDocument(createBookingDocument(query));
125
+ },
126
+ async getDocumentById() {
127
+ return returnJsonApiDocument({ data: createBookingDocument().data[0] });
128
+ }
129
+ };
130
+ const definitions = createCrudJsonApiActions({
131
+ namespace: "bookings",
132
+ resource,
133
+ service,
134
+ surface: "admin",
135
+ operations: ["list", "view"],
136
+ scopeInputValidator: Object.freeze({
137
+ schema: createSchema({
138
+ workspaceSlug: { type: "string", required: true }
139
+ }),
140
+ mode: "patch"
141
+ }),
142
+ scopeInputKeys: ["workspaceSlug"],
143
+ permissionForOperation: () => ({
144
+ require: "all",
145
+ permissions: ["bookings.list"]
146
+ })
147
+ });
148
+ const actions = createActionCatalogue();
149
+ actions.register({
150
+ contributorId: "test.generated-bookings",
151
+ domain: "bookings",
152
+ actions: definitions
153
+ });
154
+ const catalog = createServiceToolCatalog(actions, { maxDirectTools: 0 });
155
+ const context = Object.freeze({
156
+ actor: Object.freeze({ id: "user-7" }),
157
+ permissions: Object.freeze(["bookings.list"]),
158
+ surface: "admin",
159
+ workspace: Object.freeze({ slug: "north-clinic" })
160
+ });
161
+ return { actions, calls, catalog, context, definitions, resource };
162
+ }
163
+
164
+ async function loadListContract(fixture, context = fixture.context) {
165
+ const toolSet = fixture.catalog.resolveToolSet(context);
166
+ const search = await fixture.catalog.executeToolCall({
167
+ toolName: "assistant_action_search",
168
+ argumentsText: JSON.stringify({ query: "bookings list" }),
169
+ context,
170
+ toolSet
171
+ });
172
+ const contract = await fixture.catalog.executeToolCall({
173
+ toolName: "assistant_action_contract",
174
+ argumentsText: JSON.stringify({ actionId: "crud.bookings.list", version: 1 }),
175
+ context,
176
+ toolSet
177
+ });
178
+ return { contract, search, toolSet };
179
+ }
180
+
181
+ async function executeList(fixture, toolSet, input, context = fixture.context) {
182
+ return fixture.catalog.executeToolCall({
183
+ toolName: "assistant_action_execute",
184
+ argumentsText: JSON.stringify({
185
+ actionId: "crud.bookings.list",
186
+ version: 1,
187
+ input
188
+ }),
189
+ context,
190
+ toolSet
191
+ });
192
+ }
193
+
194
+ function resolveLocalSchemaReference(schema, node) {
195
+ const reference = node?.allOf?.[0]?.$ref;
196
+ assert.match(reference || "", /^#\/definitions\//u);
197
+ return schema.definitions[reference.slice("#/definitions/".length)];
198
+ }
199
+
200
+ test("generated CRUD list contracts conform through native assistant discovery and execution", async (t) => {
201
+ const fixture = createFixture();
202
+ const { contract, search, toolSet } = await loadListContract(fixture);
203
+ const listDefinition = fixture.definitions.find((entry) => entry.id === "crud.bookings.list");
204
+
205
+ assert.equal(search.ok, true);
206
+ assert.deepEqual(search.result.items.map((entry) => entry.actionId), ["crud.bookings.list"]);
207
+ assert.equal(contract.ok, true);
208
+ assert.equal(contract.result.inputSchema.properties.include.type, "string");
209
+ const fieldsetInputSchema = resolveLocalSchemaReference(
210
+ contract.result.inputSchema,
211
+ contract.result.inputSchema.properties.fields
212
+ );
213
+ assert.deepEqual(Object.keys(fieldsetInputSchema.properties), ["bookings", "pets"]);
214
+ assert.equal(fieldsetInputSchema.additionalProperties, false);
215
+ assert.deepEqual(fieldsetInputSchema.properties.bookings.items.enum, ["id", "petId", "summary"]);
216
+ assert.equal(Object.hasOwn(fieldsetInputSchema.properties.pets.items, "enum"), false);
217
+ assert.equal(Object.hasOwn(contract.result.inputSchema.properties, "workspaceSlug"), false);
218
+ assert.match(contract.result.description, /include must be a comma-separated string/u);
219
+ assert.match(contract.result.description, /\{"bookings":\["petId"\],"pets":\["name"\]\}/u);
220
+ assert.match(contract.result.description, /fields keys must be JSON:API resource types: "bookings", "pets"/u);
221
+ assert.match(contract.result.description, /use "pets" instead of "pet"/u);
222
+ assert.match(
223
+ contract.result.description,
224
+ /"pet" -> resource type "pets" -> items\[\]\.lookups\.pet/u
225
+ );
226
+ const primaryOutputSchema = resolveLocalSchemaReference(
227
+ contract.result.outputSchema,
228
+ contract.result.outputSchema.properties.items.items
229
+ );
230
+ const lookupOutputSchema = resolveLocalSchemaReference(
231
+ contract.result.outputSchema,
232
+ primaryOutputSchema.properties.lookups
233
+ );
234
+ const petOutputSchema = resolveLocalSchemaReference(
235
+ contract.result.outputSchema,
236
+ lookupOutputSchema.properties.pet
237
+ );
238
+ assert.ok(petOutputSchema.properties.name.anyOf.some((entry) => entry.type === "string"));
239
+
240
+ await t.test("full list without fields or include", async () => {
241
+ const response = await executeList(fixture, toolSet, { limit: 5 });
242
+ assert.equal(response.ok, true);
243
+ assert.deepEqual(response.result.result.items, [
244
+ { id: "41", petId: 7, summary: "Wash and trim" },
245
+ { id: "42", petId: 8, summary: "Nail trim" }
246
+ ]);
247
+ assert.equal(response.result.result.nextCursor, null);
248
+ });
249
+
250
+ await t.test("sparse primary fields only", async () => {
251
+ const response = await executeList(fixture, toolSet, {
252
+ fields: { bookings: ["petId"] },
253
+ limit: 5
254
+ });
255
+ assert.equal(response.ok, true);
256
+ assert.deepEqual(response.result.result.items, [
257
+ { id: "41", petId: 7 },
258
+ { id: "42", petId: 8 }
259
+ ]);
260
+ });
261
+
262
+ await t.test("included relationship without sparse fields", async () => {
263
+ const response = await executeList(fixture, toolSet, { include: "pet", limit: 5 });
264
+ assert.equal(response.ok, true);
265
+ assert.equal(response.result.result.items[0].lookups.pet.name, "Fido");
266
+ assert.equal(response.result.result.items[0].lookups.pet.species, "dog");
267
+ assert.equal(response.result.result.items[1].lookups.pet.name, "Mabel");
268
+ });
269
+
270
+ await t.test("included relationship plus sparse primary and related fields", async () => {
271
+ const response = await executeList(fixture, toolSet, {
272
+ workspaceSlug: "model-controlled-workspace",
273
+ include: "pet",
274
+ fields: { bookings: ["petId"], pets: ["name"] },
275
+ limit: 5
276
+ });
277
+ assert.equal(response.ok, true);
278
+ assert.notEqual(response.error?.code, "assistant_tool_output_invalid");
279
+ assert.deepEqual(JSON.parse(JSON.stringify(response.result.result.items)), [
280
+ {
281
+ id: "41",
282
+ petId: 7,
283
+ lookups: {
284
+ petId: { id: "7", name: "Fido" },
285
+ pet: { id: "7", name: "Fido" }
286
+ }
287
+ },
288
+ {
289
+ id: "42",
290
+ petId: 8,
291
+ lookups: {
292
+ petId: { id: "8", name: "Mabel" },
293
+ pet: { id: "8", name: "Mabel" }
294
+ }
295
+ }
296
+ ]);
297
+ assert.equal(Object.hasOwn(response.result.result.items[0], "summary"), false);
298
+ assert.equal(Object.hasOwn(response.result.result.items[0].lookups.pet, "species"), false);
299
+ assert.deepEqual(
300
+ validateSchemaPayload(listDefinition.extensions.assistant.output, response.result.result, {
301
+ phase: "output"
302
+ }),
303
+ response.result.result
304
+ );
305
+ assert.equal(fixture.calls.at(-1).query.workspaceSlug, undefined);
306
+ assert.equal(fixture.calls.at(-1).context.workspace.slug, "north-clinic");
307
+ });
308
+
309
+ await t.test("limit and cursor truncation remain bounded", async () => {
310
+ const response = await executeList(fixture, toolSet, { limit: 1 });
311
+ assert.equal(response.ok, true);
312
+ assert.equal(response.result.result.items.length, 1);
313
+ assert.equal(response.result.result.nextCursor, "after-41");
314
+ assert.equal(fixture.calls.at(-1).query.limit, 1);
315
+ });
316
+
317
+ await t.test("invalid array-shaped include gives an actionable validation result", async () => {
318
+ const response = await executeList(fixture, toolSet, { include: ["pet"] });
319
+ assert.equal(response.ok, false);
320
+ assert.equal(response.error.code, "ACTION_VALIDATION_FAILED");
321
+ assert.equal(response.error.status, 400);
322
+ assert.match(response.error.message, /include expects a comma-separated string such as "pet,service"/u);
323
+ });
324
+
325
+ await t.test("invalid array-shaped fields gives an actionable validation result", async () => {
326
+ const response = await executeList(fixture, toolSet, { fields: ["pet.name"] });
327
+ assert.equal(response.ok, false);
328
+ assert.equal(response.error.code, "ACTION_VALIDATION_FAILED");
329
+ assert.equal(response.error.status, 400);
330
+ assert.match(
331
+ response.error.message,
332
+ /fields expects an object keyed by JSON:API resource type, such as \{"bookings":\["petId"\],"pets":\["name"\]\}/u
333
+ );
334
+ });
335
+
336
+ await t.test("relationship aliases are rejected as sparse-fieldset resource keys", async () => {
337
+ const callCount = fixture.calls.length;
338
+ const response = await executeList(fixture, toolSet, {
339
+ include: "pet",
340
+ fields: { pet: ["name"] },
341
+ limit: 3
342
+ });
343
+ assert.equal(response.ok, false);
344
+ assert.equal(response.error.code, "ACTION_VALIDATION_FAILED");
345
+ assert.equal(response.error.status, 400);
346
+ assert.match(response.error.message, /fields\.pet: fields keys must be JSON:API resource types/u);
347
+ assert.match(response.error.message, /Allowed keys: "bookings", "pets"/u);
348
+ assert.match(response.error.message, /use "pets" instead of "pet"/u);
349
+ assert.equal(fixture.calls.length, callCount);
350
+ });
351
+
352
+ await t.test("permission denial removes the generated action from native discovery", async () => {
353
+ const deniedContext = {
354
+ ...fixture.context,
355
+ permissions: []
356
+ };
357
+ const deniedToolSet = fixture.catalog.resolveToolSet(deniedContext);
358
+ assert.deepEqual(deniedToolSet.tools, []);
359
+ assert.deepEqual(await fixture.catalog.executeToolCall({
360
+ toolName: "crud_bookings_list",
361
+ argumentsText: "{}",
362
+ context: deniedContext,
363
+ toolSet: deniedToolSet
364
+ }), {
365
+ ok: false,
366
+ error: {
367
+ code: "assistant_tool_unknown",
368
+ message: "Unknown tool."
369
+ }
370
+ });
371
+ });
372
+ });
@@ -227,11 +227,18 @@ test("generated CRUD actions expose truthful assistant contracts without changin
227
227
  const byOperation = new Map(actions.map((entry) => [entry.id.split(".").at(-1), entry]));
228
228
 
229
229
  assert.ok(actions.every((entry) => entry.output === null));
230
- assert.equal(byOperation.get("list").extensions.assistant.output, resource.operations.list.output);
231
- assert.equal(byOperation.get("view").extensions.assistant.output, resource.operations.view.output);
230
+ assert.notEqual(byOperation.get("list").extensions.assistant.output, resource.operations.list.output);
231
+ assert.notEqual(byOperation.get("view").extensions.assistant.output, resource.operations.view.output);
232
232
  assert.equal(byOperation.get("create").extensions.assistant.output, resource.operations.create.output);
233
233
  assert.equal(byOperation.get("update").extensions.assistant.output, resource.operations.patch.output);
234
234
  assert.equal(byOperation.get("delete").extensions.assistant.output, resource.operations.delete.output);
235
+ const sparseListContract = byOperation.get("list").extensions.assistant.output.schema.toJsonSchema({ mode: "replace" });
236
+ const sparseListItemContract = Object.values(sparseListContract.definitions)[0];
237
+ assert.deepEqual(sparseListItemContract.required, ["id"]);
238
+ assert.deepEqual(
239
+ byOperation.get("view").extensions.assistant.output.schema.toJsonSchema({ mode: "replace" }).required,
240
+ ["id"]
241
+ );
235
242
 
236
243
  const transform = async (operation, input = {}) => {
237
244
  const definition = byOperation.get(operation);
@@ -10,6 +10,7 @@ import {
10
10
  listSearchQueryValidator,
11
11
  lookupIncludeQueryValidator,
12
12
  createCrudCursorPaginationQueryValidator,
13
+ createJsonApiFieldsetsQueryValidator,
13
14
  createCrudParentFilterQueryValidator,
14
15
  createStandardCrudListQueryValidators,
15
16
  createStandardCrudViewQueryValidators,
@@ -26,11 +27,13 @@ function composeSchemaDefinition(...definitions) {
26
27
  }
27
28
 
28
29
  function createCrudResource({
30
+ namespace = "",
29
31
  viewFields = {},
30
32
  createFields = {},
31
33
  patchFields = {}
32
34
  } = {}) {
33
35
  return {
36
+ ...(namespace ? { namespace } : {}),
34
37
  operations: {
35
38
  view: {
36
39
  output: {
@@ -90,6 +93,62 @@ test("lookupIncludeQueryValidator keeps include optional when merged with pagina
90
93
  assert.deepEqual(compiled.schema.querystring.required || [], []);
91
94
  });
92
95
 
96
+ test("resource-aware sparse fieldsets enumerate JSON:API types and reject relationship aliases", () => {
97
+ const resource = createCrudResource({
98
+ namespace: "bookings",
99
+ viewFields: {
100
+ petId: {
101
+ type: "integer",
102
+ belongsTo: "pets",
103
+ as: "pet",
104
+ relation: {
105
+ labelKey: "name"
106
+ }
107
+ },
108
+ serviceId: {
109
+ type: "integer",
110
+ belongsTo: "services",
111
+ as: "service",
112
+ relation: {
113
+ labelKey: "name"
114
+ }
115
+ }
116
+ }
117
+ });
118
+ const validator = createJsonApiFieldsetsQueryValidator({ resource });
119
+ const transportSchema = validator.schema.toJsonSchema({ mode: "patch" });
120
+ const fieldsetReference = transportSchema.properties.fields.allOf[0].$ref;
121
+ const fieldsetSchema = transportSchema.definitions[fieldsetReference.slice("#/definitions/".length)];
122
+
123
+ assert.deepEqual(Object.keys(fieldsetSchema.properties), ["bookings", "pets", "services"]);
124
+ assert.equal(fieldsetSchema.additionalProperties, false);
125
+ assert.deepEqual(fieldsetSchema.properties.bookings.items.enum, ["petId", "serviceId"]);
126
+ assert.equal(Object.hasOwn(fieldsetSchema.properties.pets.items, "enum"), false);
127
+ assert.deepEqual(validateSchemaPayload(validator, {
128
+ fields: {
129
+ bookings: ["petId"],
130
+ pets: ["name"]
131
+ }
132
+ }, { phase: "input" }), {
133
+ fields: {
134
+ bookings: ["petId"],
135
+ pets: ["name"]
136
+ }
137
+ });
138
+ assert.throws(
139
+ () => validateSchemaPayload(validator, {
140
+ fields: {
141
+ pet: ["name"]
142
+ }
143
+ }, { phase: "input" }),
144
+ (error) => {
145
+ assert.match(error.fieldErrors?.["fields.pet"] || "", /Allowed keys: "bookings", "pets", "services"/u);
146
+ assert.match(error.fieldErrors?.["fields.pet"] || "", /use "pets" instead of "pet"/u);
147
+ return true;
148
+ }
149
+ );
150
+ });
151
+
93
152
  test("createCrudCursorPaginationQueryValidator keeps numeric cursor validation for unordered lists", () => {
94
153
  const validator = createCrudCursorPaginationQueryValidator({});
95
154
 
@@ -180,7 +180,8 @@ test("createCrudJsonApiRouteContracts builds default CRUD JSON:API contracts", a
180
180
  limit: "25",
181
181
  fields: {
182
182
  contacts: ["id", "name"],
183
- userProfiles: ["id", "name"]
183
+ userProfiles: ["id", "name"],
184
+ organizations: ["name"]
184
185
  }
185
186
  }, { phase: "input" });
186
187
 
@@ -192,7 +193,8 @@ test("createCrudJsonApiRouteContracts builds default CRUD JSON:API contracts", a
192
193
  limit: 25,
193
194
  fields: {
194
195
  contacts: ["id", "name"],
195
- userProfiles: ["id", "name"]
196
+ userProfiles: ["id", "name"],
197
+ organizations: ["name"]
196
198
  }
197
199
  });
198
200