@jskit-ai/crud-core 0.1.179 → 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.179",
3
+ "version": "0.1.180",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "test": "node --test"
@@ -23,9 +23,9 @@
23
23
  "./server/routeContracts": "./src/server/routeContracts.js"
24
24
  },
25
25
  "dependencies": {
26
- "@jskit-ai/database-runtime": "0.1.168",
27
- "@jskit-ai/json-rest-api-core": "0.1.112",
28
- "@jskit-ai/resource-crud-core": "0.1.110",
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",
29
29
  "json-rest-schema": "^1.0.17"
30
30
  },
31
31
  "description": "Shared server-side CRUD service, repository, route, and query helpers.",
@@ -45,7 +45,7 @@
45
45
  }
46
46
  },
47
47
  "peerDependencies": {
48
- "@jskit-ai/http-runtime": "0.1.166",
49
- "@jskit-ai/kernel": "0.1.168"
48
+ "@jskit-ai/http-runtime": "0.1.167",
49
+ "@jskit-ai/kernel": "0.1.169"
50
50
  }
51
51
  }
@@ -8,8 +8,8 @@
8
8
  "./shared": "./src/shared/index.js"
9
9
  },
10
10
  "dependencies": {
11
- "@jskit-ai/crud-core": "0.1.179",
12
- "@jskit-ai/resource-crud-core": "0.1.110"
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",
@@ -10,8 +10,13 @@ import {
10
10
  unwrapJsonApiResult
11
11
  } from "@jskit-ai/http-runtime/shared";
12
12
  import { resolveCrudRecordChangedEvent } from "@jskit-ai/resource-crud-core/shared/crudNamespaceSupport";
13
- import { resolveJsonApiRelationshipEntries } from "../routeContracts.js";
14
13
  import {
14
+ resolveJsonApiFieldsetContract,
15
+ resolveJsonApiRelationshipEntries,
16
+ resolveSchemaFieldDefinitions
17
+ } from "../jsonApiResourceContract.js";
18
+ import {
19
+ createJsonApiFieldsetsQueryValidator,
15
20
  createStandardCrudListQueryValidators,
16
21
  createStandardCrudViewQueryValidators
17
22
  } from "../listQueryValidators.js";
@@ -49,11 +54,6 @@ function resolveAssistantResultValue(result) {
49
54
  };
50
55
  }
51
56
 
52
- function resolveSchemaFieldDefinitions(definition = null) {
53
- const definitions = definition?.schema?.getFieldDefinitions?.();
54
- return isRecord(definitions) ? definitions : {};
55
- }
56
-
57
57
  function createProjectionRecordSchema(recordSchema, {
58
58
  lookupContainerKey = "",
59
59
  relationshipEntries = []
@@ -219,6 +219,45 @@ function transformCrudAssistantResult(operation, result, {
219
219
  return resolved.value;
220
220
  }
221
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
+
222
261
  function createCrudAssistantExtension(resource, namespace, operation) {
223
262
  const resourceOperation = CRUD_ASSISTANT_RESOURCE_OPERATION[operation];
224
263
  const nativeOutput = resource?.operations?.[resourceOperation]?.output || null;
@@ -236,24 +275,18 @@ function createCrudAssistantExtension(resource, namespace, operation) {
236
275
  relationshipEntries,
237
276
  lookupContainerKey
238
277
  );
239
- const firstRelationship = relationshipEntries[0] || null;
240
- const fieldsExample = firstRelationship
241
- ? ` fields can be {\"${namespace}\":[\"${firstRelationship.attributeKey}\"],` +
242
- `\"${firstRelationship.relationshipType}\":[\"${firstRelationship.labelKey || "id"}\"]}.`
243
- : ` fields can be {\"${namespace}\":[\"id\"]}.`;
244
- const lookupExample = firstRelationship && lookupContainerKey
245
- ? ` Included fields are returned under ${operation === "list" ? "items[]." : ""}${lookupContainerKey}.` +
246
- `${firstRelationship.relationshipName}.${firstRelationship.labelKey || "<selectedField>"}.`
247
- : "";
248
- const actionLabel = operation === "list"
249
- ? `List ${namespace} records. include must be a comma-separated string such as \"pet,service\";${fieldsExample}${lookupExample}`
250
- : operation === "view"
251
- ? `View a ${namespace} record. include must be a comma-separated string such as \"pet,service\";${fieldsExample}${lookupExample}`
252
- : operation === "create"
253
- ? `Create a ${namespace} record.`
254
- : operation === "update"
255
- ? `Update a ${namespace} record.`
256
- : `Delete a ${namespace} record.`;
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.`;
257
290
 
258
291
  return Object.freeze({
259
292
  description: actionLabel,
@@ -278,9 +311,15 @@ function createActionInput(
278
311
  ) {
279
312
  const definitions = scopeInputValidator ? [scopeInputValidator] : [];
280
313
  if (operation === "list") {
281
- definitions.push(...createStandardCrudListQueryValidators({ resource, listFilterQueryValidator }));
314
+ definitions.push(...createStandardCrudListQueryValidators({
315
+ resource,
316
+ listFilterQueryValidator,
317
+ fieldsetsQueryValidator: createJsonApiFieldsetsQueryValidator({ resource })
318
+ }));
282
319
  } else if (operation === "view") {
283
- definitions.push(recordIdParamsValidator, ...createStandardCrudViewQueryValidators());
320
+ definitions.push(recordIdParamsValidator, ...createStandardCrudViewQueryValidators({
321
+ fieldsetsQueryValidator: createJsonApiFieldsetsQueryValidator({ resource })
322
+ }));
284
323
  } else if (operation === "create") {
285
324
  definitions.push(operationInputs.create || resource.operations.create.body);
286
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({
@@ -28,6 +29,28 @@ const lookupIncludeQueryValidator = Object.freeze({
28
29
  mode: "patch"
29
30
  });
30
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
+
31
54
  const jsonApiFieldsetsQueryValidator = Object.freeze({
32
55
  schema: createSchema({
33
56
  fields: {
@@ -36,18 +59,57 @@ const jsonApiFieldsetsQueryValidator = Object.freeze({
36
59
  messages: {
37
60
  default: "fields expects an object such as {\"bookings\":[\"petId\"],\"pets\":[\"name\"]}."
38
61
  },
39
- values: {
40
- type: "array",
41
- items: {
42
- type: "string",
43
- minLength: 1
44
- }
45
- }
62
+ values: jsonApiFieldsetValueDefinition
46
63
  }
47
64
  }),
48
65
  mode: "patch"
49
66
  });
50
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
+
51
113
  function resolveCrudListUsesOrderedCursor(list = {}) {
52
114
  const entries = Array.isArray(list?.orderBy)
53
115
  ? list.orderBy
@@ -115,7 +177,8 @@ function createStandardCrudListQueryValidators({
115
177
  resource = {},
116
178
  listFilterQueryValidator = null,
117
179
  searchQueryValidator = listSearchQueryValidator,
118
- includeQueryValidator = lookupIncludeQueryValidator
180
+ includeQueryValidator = lookupIncludeQueryValidator,
181
+ fieldsetsQueryValidator = jsonApiFieldsetsQueryValidator
119
182
  } = {}) {
120
183
  const resolvedListFilterQueryValidator =
121
184
  listFilterQueryValidator
@@ -132,21 +195,23 @@ function createStandardCrudListQueryValidators({
132
195
  ? [resolvedListFilterQueryValidator]
133
196
  : []),
134
197
  includeQueryValidator,
135
- jsonApiFieldsetsQueryValidator
198
+ fieldsetsQueryValidator
136
199
  ];
137
200
  }
138
201
 
139
202
  function createStandardCrudViewQueryValidators({
140
- includeQueryValidator = lookupIncludeQueryValidator
203
+ includeQueryValidator = lookupIncludeQueryValidator,
204
+ fieldsetsQueryValidator = jsonApiFieldsetsQueryValidator
141
205
  } = {}) {
142
206
  return [
143
207
  includeQueryValidator,
144
- jsonApiFieldsetsQueryValidator
208
+ fieldsetsQueryValidator
145
209
  ];
146
210
  }
147
211
 
148
212
  export {
149
213
  createCrudCursorPaginationQueryValidator,
214
+ createJsonApiFieldsetsQueryValidator,
150
215
  listSearchQueryValidator,
151
216
  lookupIncludeQueryValidator,
152
217
  jsonApiFieldsetsQueryValidator,
@@ -6,86 +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
- labelKey: String(normalizedFieldDefinition?.relation?.labelKey || "").trim(),
48
- required: normalizedFieldDefinition.required === true,
49
- nullable: normalizedFieldDefinition.nullable === true
50
- }));
51
- continue;
52
- }
53
-
54
- const relation = isRecord(normalizedFieldDefinition.relation)
55
- ? normalizedFieldDefinition.relation
56
- : {};
57
- if (String(relation.kind || "").trim().toLowerCase() !== "collection") {
58
- continue;
59
- }
60
-
61
- const collectionRelationshipType = resolveCrudResourceScopeName(
62
- relation.target || relation.targetResource || relation.namespace || relation.apiPath
63
- );
64
- if (!collectionRelationshipType) {
65
- continue;
66
- }
67
-
68
- const collectionRelationshipName = String(
69
- relation.as || normalizedFieldDefinition.as || fieldKey || ""
70
- ).trim();
71
- if (!collectionRelationshipName) {
72
- continue;
73
- }
74
-
75
- entries.push(Object.freeze({
76
- attributeKey: fieldKey,
77
- relationshipName: collectionRelationshipName,
78
- relationshipType: collectionRelationshipType,
79
- labelKey: String(relation.labelKey || "").trim(),
80
- many: true,
81
- required: normalizedFieldDefinition.required === true,
82
- nullable: normalizedFieldDefinition.nullable === true
83
- }));
84
- }
85
-
86
- return Object.freeze(entries);
87
- }
88
-
89
21
  function readOwnValue(source = {}, key = "") {
90
22
  if (isRecord(source) && Object.hasOwn(source, key)) {
91
23
  return {
@@ -206,11 +206,23 @@ test("generated CRUD list contracts conform through native assistant discovery a
206
206
  assert.deepEqual(search.result.items.map((entry) => entry.actionId), ["crud.bookings.list"]);
207
207
  assert.equal(contract.ok, true);
208
208
  assert.equal(contract.result.inputSchema.properties.include.type, "string");
209
- assert.equal(contract.result.inputSchema.properties.fields.type, "object");
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);
210
217
  assert.equal(Object.hasOwn(contract.result.inputSchema.properties, "workspaceSlug"), false);
211
218
  assert.match(contract.result.description, /include must be a comma-separated string/u);
212
219
  assert.match(contract.result.description, /\{"bookings":\["petId"\],"pets":\["name"\]\}/u);
213
- assert.match(contract.result.description, /items\[\]\.lookups\.pet\.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
+ );
214
226
  const primaryOutputSchema = resolveLocalSchemaReference(
215
227
  contract.result.outputSchema,
216
228
  contract.result.outputSchema.properties.items.items
@@ -317,10 +329,26 @@ test("generated CRUD list contracts conform through native assistant discovery a
317
329
  assert.equal(response.error.status, 400);
318
330
  assert.match(
319
331
  response.error.message,
320
- /fields expects an object such as \{"bookings":\["petId"\],"pets":\["name"\]\}/u
332
+ /fields expects an object keyed by JSON:API resource type, such as \{"bookings":\["petId"\],"pets":\["name"\]\}/u
321
333
  );
322
334
  });
323
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
+
324
352
  await t.test("permission denial removes the generated action from native discovery", async () => {
325
353
  const deniedContext = {
326
354
  ...fixture.context,
@@ -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