@jskit-ai/crud-core 0.1.179 → 0.1.181

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.181",
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.170",
27
+ "@jskit-ai/json-rest-api-core": "0.1.114",
28
+ "@jskit-ai/resource-crud-core": "0.1.112",
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.168",
49
+ "@jskit-ai/kernel": "0.1.170"
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.181",
12
+ "@jskit-ai/resource-crud-core": "0.1.112"
13
13
  },
14
14
  "jskit": {
15
15
  "kind": "runtime",
@@ -3,6 +3,7 @@ import {
3
3
  createSchema,
4
4
  recordIdParamsValidator
5
5
  } from "@jskit-ai/kernel/shared/validators";
6
+ import { normalizeJsonApiFieldsets } from "@jskit-ai/kernel/shared/support/jsonApiFieldsets";
6
7
  import { createEntityChangedActionEvent } from "@jskit-ai/kernel/server/actions";
7
8
  import {
8
9
  decodeJsonApiResourceResponse,
@@ -10,8 +11,13 @@ import {
10
11
  unwrapJsonApiResult
11
12
  } from "@jskit-ai/http-runtime/shared";
12
13
  import { resolveCrudRecordChangedEvent } from "@jskit-ai/resource-crud-core/shared/crudNamespaceSupport";
13
- import { resolveJsonApiRelationshipEntries } from "../routeContracts.js";
14
14
  import {
15
+ resolveJsonApiFieldsetContract,
16
+ resolveJsonApiRelationshipEntries,
17
+ resolveSchemaFieldDefinitions
18
+ } from "../jsonApiResourceContract.js";
19
+ import {
20
+ createJsonApiFieldsetsQueryValidator,
15
21
  createStandardCrudListQueryValidators,
16
22
  createStandardCrudViewQueryValidators
17
23
  } from "../listQueryValidators.js";
@@ -49,11 +55,6 @@ function resolveAssistantResultValue(result) {
49
55
  };
50
56
  }
51
57
 
52
- function resolveSchemaFieldDefinitions(definition = null) {
53
- const definitions = definition?.schema?.getFieldDefinitions?.();
54
- return isRecord(definitions) ? definitions : {};
55
- }
56
-
57
58
  function createProjectionRecordSchema(recordSchema, {
58
59
  lookupContainerKey = "",
59
60
  relationshipEntries = []
@@ -166,6 +167,102 @@ function createCrudAssistantTransport(resource, operation, relationshipEntries,
166
167
  });
167
168
  }
168
169
 
170
+ function projectCrudAssistantRecordFields(record, selectedFields = null, {
171
+ preserveKeys = []
172
+ } = {}) {
173
+ if (!isRecord(record) || !Array.isArray(selectedFields)) {
174
+ return record;
175
+ }
176
+
177
+ const allowedKeys = new Set(["id", ...preserveKeys, ...selectedFields]);
178
+ return Object.fromEntries(
179
+ Object.entries(record).filter(([key]) => allowedKeys.has(key))
180
+ );
181
+ }
182
+
183
+ function projectCrudAssistantLookupValue(value, selectedFields = null) {
184
+ if (Array.isArray(value)) {
185
+ return value.map((entry) => projectCrudAssistantRecordFields(entry, selectedFields));
186
+ }
187
+ return projectCrudAssistantRecordFields(value, selectedFields);
188
+ }
189
+
190
+ function projectCrudAssistantReadRecord(record, {
191
+ lookupContainerKey = "",
192
+ primaryFields = null,
193
+ relatedFieldsByLookupKey = new Map()
194
+ } = {}) {
195
+ if (!isRecord(record)) {
196
+ return record;
197
+ }
198
+
199
+ const primaryProjection = projectCrudAssistantRecordFields(record, primaryFields, {
200
+ preserveKeys: lookupContainerKey && isRecord(record[lookupContainerKey])
201
+ ? [lookupContainerKey]
202
+ : []
203
+ });
204
+ const projectedRecord = primaryProjection === record ? { ...record } : primaryProjection;
205
+ if (!lookupContainerKey || !isRecord(projectedRecord[lookupContainerKey])) {
206
+ return projectedRecord;
207
+ }
208
+
209
+ projectedRecord[lookupContainerKey] = Object.fromEntries(
210
+ Object.entries(projectedRecord[lookupContainerKey]).map(([lookupKey, value]) => {
211
+ return [
212
+ lookupKey,
213
+ projectCrudAssistantLookupValue(value, relatedFieldsByLookupKey.get(lookupKey))
214
+ ];
215
+ })
216
+ );
217
+ return projectedRecord;
218
+ }
219
+
220
+ function createCrudAssistantResultProjection(input = {}, resource = {}, relationshipEntries = []) {
221
+ const primaryType = String(resource?.namespace || "").trim();
222
+ const fieldsets = normalizeJsonApiFieldsets(input?.fields, {
223
+ primaryType
224
+ });
225
+ if (Object.keys(fieldsets).length < 1) {
226
+ return null;
227
+ }
228
+
229
+ const relatedFieldsByLookupKey = new Map();
230
+ for (const entry of relationshipEntries) {
231
+ const selectedFields = fieldsets[entry.relationshipType];
232
+ if (!Array.isArray(selectedFields)) {
233
+ continue;
234
+ }
235
+ relatedFieldsByLookupKey.set(entry.relationshipName, selectedFields);
236
+ relatedFieldsByLookupKey.set(entry.attributeKey, selectedFields);
237
+ }
238
+
239
+ return {
240
+ primaryFields: fieldsets[primaryType],
241
+ relatedFieldsByLookupKey
242
+ };
243
+ }
244
+
245
+ function projectCrudAssistantReadResult(result, projection = null, {
246
+ lookupContainerKey = ""
247
+ } = {}) {
248
+ if (!projection) {
249
+ return result;
250
+ }
251
+
252
+ const projectionOptions = {
253
+ ...projection,
254
+ lookupContainerKey
255
+ };
256
+ if (isRecord(result) && Array.isArray(result.items)) {
257
+ return {
258
+ ...result,
259
+ items: result.items.map((entry) => projectCrudAssistantReadRecord(entry, projectionOptions))
260
+ };
261
+ }
262
+
263
+ return projectCrudAssistantReadRecord(result, projectionOptions);
264
+ }
265
+
169
266
  function transformCrudAssistantResult(operation, result, {
170
267
  input = {},
171
268
  resource,
@@ -184,39 +281,84 @@ function transformCrudAssistantResult(operation, result, {
184
281
  }
185
282
 
186
283
  const resolved = resolveAssistantResultValue(result);
284
+ const projection = operation === "list" || operation === "view"
285
+ ? createCrudAssistantResultProjection(input, resource, relationshipEntries)
286
+ : null;
287
+ const projectReadResult = (value) => projectCrudAssistantReadResult(value, projection, {
288
+ lookupContainerKey
289
+ });
187
290
  if (operation === "list") {
188
291
  if (resolved.document.kind === "collection") {
189
292
  const decoded = decodeJsonApiResourceResponse(
190
293
  resolved.value,
191
294
  createCrudAssistantTransport(resource, operation, relationshipEntries, lookupContainerKey)
192
295
  );
193
- return {
296
+ return projectReadResult({
194
297
  items: decoded.items,
195
298
  nextCursor: normalizeOptionalCursor(decoded.nextCursor)
196
- };
299
+ });
197
300
  }
198
301
  if (Array.isArray(resolved.value)) {
199
- return {
302
+ return projectReadResult({
200
303
  items: resolved.value,
201
304
  nextCursor: null
202
- };
305
+ });
203
306
  }
204
307
  if (isRecord(resolved.value) && Array.isArray(resolved.value.items)) {
205
- return {
308
+ return projectReadResult({
206
309
  items: resolved.value.items,
207
310
  nextCursor: normalizeOptionalCursor(resolved.value.nextCursor)
208
- };
311
+ });
209
312
  }
210
313
  return resolved.value;
211
314
  }
212
315
 
213
316
  if (resolved.document.kind === "resource") {
214
- return decodeJsonApiResourceResponse(
317
+ return projectReadResult(decodeJsonApiResourceResponse(
215
318
  resolved.value,
216
319
  createCrudAssistantTransport(resource, operation, relationshipEntries, lookupContainerKey)
217
- );
320
+ ));
218
321
  }
219
- return resolved.value;
322
+ return projectReadResult(resolved.value);
323
+ }
324
+
325
+ function createCrudAssistantReadDescription({
326
+ fieldsetContract,
327
+ lookupContainerKey = "",
328
+ namespace = "",
329
+ operation = "list"
330
+ } = {}) {
331
+ const relationshipEntries = fieldsetContract.relationshipEntries;
332
+ const firstRelationship = relationshipEntries[0] || null;
333
+ const includeExample = relationshipEntries.length > 0
334
+ ? relationshipEntries.slice(0, 2).map((entry) => entry.relationshipName).join(",")
335
+ : "pet,service";
336
+ const fieldsExample = firstRelationship
337
+ ? ` fields can be {\"${namespace}\":[\"${firstRelationship.attributeKey}\"],` +
338
+ `\"${firstRelationship.relationshipType}\":[\"${firstRelationship.labelKey || "id"}\"]}.`
339
+ : ` fields can be {\"${namespace}\":[\"id\"]}.`;
340
+ const aliasGuidance = fieldsetContract.aliasMappings
341
+ .map((entry) => `use \"${entry.resourceType}\" instead of \"${entry.alias}\"`)
342
+ .join("; ");
343
+ const fieldsetKeyGuidance = fieldsetContract.resourceTypes.length > 0
344
+ ? ` fields keys must be JSON:API resource types: ${fieldsetContract.resourceTypes.map((entry) => `\"${entry}\"`).join(", ")}.` +
345
+ (aliasGuidance ? ` Relationship aliases are invalid fieldset keys; ${aliasGuidance}.` : "")
346
+ : "";
347
+ const primaryFieldGuidance = fieldsetContract.primaryFields.length > 0
348
+ ? ` Valid \"${namespace}\" fields: ${fieldsetContract.primaryFields.map((entry) => `\"${entry}\"`).join(", ")}.`
349
+ : "";
350
+ const relationshipGuidance = relationshipEntries.length > 0
351
+ ? ` Include relationships: ${relationshipEntries.map((entry) => {
352
+ const lookupPath = lookupContainerKey
353
+ ? ` -> ${operation === "list" ? "items[]." : ""}${lookupContainerKey}.${entry.relationshipName}`
354
+ : "";
355
+ return `\"${entry.relationshipName}\" -> resource type \"${entry.relationshipType}\"${lookupPath}`;
356
+ }).join("; ")}.`
357
+ : "";
358
+
359
+ const subject = operation === "list" ? `List ${namespace} records.` : `View a ${namespace} record.`;
360
+ return `${subject} include must be a comma-separated string such as \"${includeExample}\";` +
361
+ `${fieldsExample}${fieldsetKeyGuidance}${primaryFieldGuidance}${relationshipGuidance}`;
220
362
  }
221
363
 
222
364
  function createCrudAssistantExtension(resource, namespace, operation) {
@@ -236,24 +378,18 @@ function createCrudAssistantExtension(resource, namespace, operation) {
236
378
  relationshipEntries,
237
379
  lookupContainerKey
238
380
  );
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.`;
381
+ const actionLabel = operation === "list" || operation === "view"
382
+ ? createCrudAssistantReadDescription({
383
+ fieldsetContract: resolveJsonApiFieldsetContract(resource),
384
+ lookupContainerKey,
385
+ namespace,
386
+ operation
387
+ })
388
+ : operation === "create"
389
+ ? `Create a ${namespace} record.`
390
+ : operation === "update"
391
+ ? `Update a ${namespace} record.`
392
+ : `Delete a ${namespace} record.`;
257
393
 
258
394
  return Object.freeze({
259
395
  description: actionLabel,
@@ -278,9 +414,15 @@ function createActionInput(
278
414
  ) {
279
415
  const definitions = scopeInputValidator ? [scopeInputValidator] : [];
280
416
  if (operation === "list") {
281
- definitions.push(...createStandardCrudListQueryValidators({ resource, listFilterQueryValidator }));
417
+ definitions.push(...createStandardCrudListQueryValidators({
418
+ resource,
419
+ listFilterQueryValidator,
420
+ fieldsetsQueryValidator: createJsonApiFieldsetsQueryValidator({ resource })
421
+ }));
282
422
  } else if (operation === "view") {
283
- definitions.push(recordIdParamsValidator, ...createStandardCrudViewQueryValidators());
423
+ definitions.push(recordIdParamsValidator, ...createStandardCrudViewQueryValidators({
424
+ fieldsetsQueryValidator: createJsonApiFieldsetsQueryValidator({ resource })
425
+ }));
284
426
  } else if (operation === "create") {
285
427
  definitions.push(operationInputs.create || resource.operations.create.body);
286
428
  } 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 {
@@ -11,14 +11,46 @@ import { defineCrudResource } from "@jskit-ai/resource-crud-core/shared/crudReso
11
11
  import { createCrudJsonApiActions } from "../src/server/jsonApiModule/actions.js";
12
12
 
13
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" })
14
+ Object.freeze({
15
+ id: "41",
16
+ serviceId: "11",
17
+ productId: "21",
18
+ contactId: "31",
19
+ petId: "7",
20
+ summary: "Wash and trim"
21
+ }),
22
+ Object.freeze({
23
+ id: "42",
24
+ serviceId: "12",
25
+ productId: "22",
26
+ contactId: "32",
27
+ petId: "8",
28
+ summary: "Nail trim"
29
+ })
16
30
  ]);
17
31
  const PETS = Object.freeze({
18
- "7": Object.freeze({ id: "7", name: "Fido", species: "dog" }),
19
- "8": Object.freeze({ id: "8", name: "Mabel", species: "cat" })
32
+ "7": Object.freeze({ id: "7", contactId: "31", breedId: "91", name: "Fido", species: "dog" }),
33
+ "8": Object.freeze({ id: "8", contactId: "32", breedId: "92", name: "Mabel", species: "cat" })
20
34
  });
21
35
 
36
+ function lookupField(namespace, relationshipName) {
37
+ return {
38
+ type: "id",
39
+ required: true,
40
+ belongsTo: namespace,
41
+ as: relationshipName,
42
+ relation: {
43
+ kind: "lookup",
44
+ namespace,
45
+ valueKey: "id",
46
+ labelKey: "name"
47
+ },
48
+ operations: {
49
+ output: { required: true }
50
+ }
51
+ };
52
+ }
53
+
22
54
  function createBookingsResource() {
23
55
  return defineCrudResource({
24
56
  namespace: "bookings",
@@ -30,21 +62,10 @@ function createBookingsResource() {
30
62
  }
31
63
  },
32
64
  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
- },
65
+ serviceId: lookupField("services", "service"),
66
+ productId: lookupField("products", "product"),
67
+ contactId: lookupField("contacts", "contact"),
68
+ petId: lookupField("pets", "pet"),
48
69
  summary: {
49
70
  type: "string",
50
71
  required: true,
@@ -77,20 +98,24 @@ function createBookingDocument(query = {}) {
77
98
  .includes("pet");
78
99
  const rows = BOOKINGS.slice(0, limit);
79
100
  const data = rows.map((booking) => {
80
- const includePetLinkage = !selectedBookings || selectedBookings.includes("petId");
81
101
  return {
82
102
  type: "bookings",
83
103
  id: booking.id,
84
104
  attributes: selectFields({ summary: booking.summary }, selectedBookings),
85
- ...(includePetLinkage
86
- ? {
87
- relationships: {
88
- pet: {
89
- data: { type: "pets", id: booking.petId }
90
- }
91
- }
92
- }
93
- : {})
105
+ relationships: {
106
+ service: {
107
+ data: { type: "services", id: booking.serviceId }
108
+ },
109
+ product: {
110
+ data: { type: "products", id: booking.productId }
111
+ },
112
+ contact: {
113
+ data: { type: "contacts", id: booking.contactId }
114
+ },
115
+ pet: {
116
+ data: { type: "pets", id: booking.petId }
117
+ }
118
+ }
94
119
  };
95
120
  });
96
121
  const included = includesPet
@@ -100,7 +125,15 @@ function createBookingDocument(query = {}) {
100
125
  attributes: selectFields({
101
126
  name: PETS[booking.petId].name,
102
127
  species: PETS[booking.petId].species
103
- }, selectedPets)
128
+ }, selectedPets),
129
+ relationships: {
130
+ contact: {
131
+ data: { type: "contacts", id: PETS[booking.petId].contactId }
132
+ },
133
+ breed: {
134
+ data: { type: "breeds", id: PETS[booking.petId].breedId }
135
+ }
136
+ }
104
137
  }))
105
138
  : [];
106
139
 
@@ -123,8 +156,12 @@ function createFixture() {
123
156
  calls.push({ query, context });
124
157
  return returnJsonApiDocument(createBookingDocument(query));
125
158
  },
126
- async getDocumentById() {
127
- return returnJsonApiDocument({ data: createBookingDocument().data[0] });
159
+ async getDocumentById(_recordId, query) {
160
+ const document = createBookingDocument(query);
161
+ return returnJsonApiDocument({
162
+ data: document.data[0],
163
+ ...(document.included ? { included: document.included } : {})
164
+ });
128
165
  }
129
166
  };
130
167
  const definitions = createCrudJsonApiActions({
@@ -206,11 +243,32 @@ test("generated CRUD list contracts conform through native assistant discovery a
206
243
  assert.deepEqual(search.result.items.map((entry) => entry.actionId), ["crud.bookings.list"]);
207
244
  assert.equal(contract.ok, true);
208
245
  assert.equal(contract.result.inputSchema.properties.include.type, "string");
209
- assert.equal(contract.result.inputSchema.properties.fields.type, "object");
246
+ const fieldsetInputSchema = resolveLocalSchemaReference(
247
+ contract.result.inputSchema,
248
+ contract.result.inputSchema.properties.fields
249
+ );
250
+ assert.deepEqual(
251
+ Object.keys(fieldsetInputSchema.properties),
252
+ ["bookings", "services", "products", "contacts", "pets"]
253
+ );
254
+ assert.equal(fieldsetInputSchema.additionalProperties, false);
255
+ assert.deepEqual(
256
+ fieldsetInputSchema.properties.bookings.items.enum,
257
+ ["id", "serviceId", "productId", "contactId", "petId", "summary"]
258
+ );
259
+ assert.equal(Object.hasOwn(fieldsetInputSchema.properties.pets.items, "enum"), false);
210
260
  assert.equal(Object.hasOwn(contract.result.inputSchema.properties, "workspaceSlug"), false);
211
261
  assert.match(contract.result.description, /include must be a comma-separated string/u);
212
- assert.match(contract.result.description, /\{"bookings":\["petId"\],"pets":\["name"\]\}/u);
213
- assert.match(contract.result.description, /items\[\]\.lookups\.pet\.name/u);
262
+ assert.match(contract.result.description, /\{"bookings":\["serviceId"\],"services":\["name"\]\}/u);
263
+ assert.match(
264
+ contract.result.description,
265
+ /fields keys must be JSON:API resource types: "bookings", "services", "products", "contacts", "pets"/u
266
+ );
267
+ assert.match(contract.result.description, /use "pets" instead of "pet"/u);
268
+ assert.match(
269
+ contract.result.description,
270
+ /"pet" -> resource type "pets" -> items\[\]\.lookups\.pet/u
271
+ );
214
272
  const primaryOutputSchema = resolveLocalSchemaReference(
215
273
  contract.result.outputSchema,
216
274
  contract.result.outputSchema.properties.items.items
@@ -229,21 +287,35 @@ test("generated CRUD list contracts conform through native assistant discovery a
229
287
  const response = await executeList(fixture, toolSet, { limit: 5 });
230
288
  assert.equal(response.ok, true);
231
289
  assert.deepEqual(response.result.result.items, [
232
- { id: "41", petId: 7, summary: "Wash and trim" },
233
- { id: "42", petId: 8, summary: "Nail trim" }
290
+ {
291
+ id: "41",
292
+ serviceId: 11,
293
+ productId: 21,
294
+ contactId: 31,
295
+ petId: 7,
296
+ summary: "Wash and trim"
297
+ },
298
+ {
299
+ id: "42",
300
+ serviceId: 12,
301
+ productId: 22,
302
+ contactId: 32,
303
+ petId: 8,
304
+ summary: "Nail trim"
305
+ }
234
306
  ]);
235
307
  assert.equal(response.result.result.nextCursor, null);
236
308
  });
237
309
 
238
310
  await t.test("sparse primary fields only", async () => {
239
311
  const response = await executeList(fixture, toolSet, {
240
- fields: { bookings: ["petId"] },
312
+ fields: { bookings: ["serviceId"] },
241
313
  limit: 5
242
314
  });
243
315
  assert.equal(response.ok, true);
244
316
  assert.deepEqual(response.result.result.items, [
245
- { id: "41", petId: 7 },
246
- { id: "42", petId: 8 }
317
+ { id: "41", serviceId: 11 },
318
+ { id: "42", serviceId: 12 }
247
319
  ]);
248
320
  });
249
321
 
@@ -252,6 +324,8 @@ test("generated CRUD list contracts conform through native assistant discovery a
252
324
  assert.equal(response.ok, true);
253
325
  assert.equal(response.result.result.items[0].lookups.pet.name, "Fido");
254
326
  assert.equal(response.result.result.items[0].lookups.pet.species, "dog");
327
+ assert.equal(response.result.result.items[0].lookups.pet.contactId, "31");
328
+ assert.equal(response.result.result.items[0].lookups.pet.breedId, "91");
255
329
  assert.equal(response.result.result.items[1].lookups.pet.name, "Mabel");
256
330
  });
257
331
 
@@ -259,7 +333,7 @@ test("generated CRUD list contracts conform through native assistant discovery a
259
333
  const response = await executeList(fixture, toolSet, {
260
334
  workspaceSlug: "model-controlled-workspace",
261
335
  include: "pet",
262
- fields: { bookings: ["petId"], pets: ["name"] },
336
+ fields: { bookings: ["id", "serviceId"], pets: ["name"] },
263
337
  limit: 5
264
338
  });
265
339
  assert.equal(response.ok, true);
@@ -267,7 +341,7 @@ test("generated CRUD list contracts conform through native assistant discovery a
267
341
  assert.deepEqual(JSON.parse(JSON.stringify(response.result.result.items)), [
268
342
  {
269
343
  id: "41",
270
- petId: 7,
344
+ serviceId: 11,
271
345
  lookups: {
272
346
  petId: { id: "7", name: "Fido" },
273
347
  pet: { id: "7", name: "Fido" }
@@ -275,7 +349,7 @@ test("generated CRUD list contracts conform through native assistant discovery a
275
349
  },
276
350
  {
277
351
  id: "42",
278
- petId: 8,
352
+ serviceId: 12,
279
353
  lookups: {
280
354
  petId: { id: "8", name: "Mabel" },
281
355
  pet: { id: "8", name: "Mabel" }
@@ -283,7 +357,12 @@ test("generated CRUD list contracts conform through native assistant discovery a
283
357
  }
284
358
  ]);
285
359
  assert.equal(Object.hasOwn(response.result.result.items[0], "summary"), false);
360
+ assert.equal(Object.hasOwn(response.result.result.items[0], "productId"), false);
361
+ assert.equal(Object.hasOwn(response.result.result.items[0], "contactId"), false);
362
+ assert.equal(Object.hasOwn(response.result.result.items[0], "petId"), false);
286
363
  assert.equal(Object.hasOwn(response.result.result.items[0].lookups.pet, "species"), false);
364
+ assert.equal(Object.hasOwn(response.result.result.items[0].lookups.pet, "contactId"), false);
365
+ assert.equal(Object.hasOwn(response.result.result.items[0].lookups.pet, "breedId"), false);
287
366
  assert.deepEqual(
288
367
  validateSchemaPayload(listDefinition.extensions.assistant.output, response.result.result, {
289
368
  phase: "output"
@@ -291,9 +370,47 @@ test("generated CRUD list contracts conform through native assistant discovery a
291
370
  response.result.result
292
371
  );
293
372
  assert.equal(fixture.calls.at(-1).query.workspaceSlug, undefined);
373
+ assert.deepEqual(fixture.calls.at(-1).query.fields, {
374
+ bookings: ["id", "serviceId"],
375
+ pets: ["name"]
376
+ });
294
377
  assert.equal(fixture.calls.at(-1).context.workspace.slug, "north-clinic");
295
378
  });
296
379
 
380
+ await t.test("sparse view projection removes unselected relationship linkage", async () => {
381
+ const viewContract = await fixture.catalog.executeToolCall({
382
+ toolName: "assistant_action_contract",
383
+ argumentsText: JSON.stringify({ actionId: "crud.bookings.view", version: 1 }),
384
+ context: fixture.context,
385
+ toolSet
386
+ });
387
+ assert.equal(viewContract.ok, true);
388
+
389
+ const response = await fixture.catalog.executeToolCall({
390
+ toolName: "assistant_action_execute",
391
+ argumentsText: JSON.stringify({
392
+ actionId: "crud.bookings.view",
393
+ version: 1,
394
+ input: {
395
+ recordId: "41",
396
+ include: "pet",
397
+ fields: { bookings: ["serviceId"], pets: ["name"] }
398
+ }
399
+ }),
400
+ context: fixture.context,
401
+ toolSet
402
+ });
403
+ assert.equal(response.ok, true);
404
+ assert.deepEqual(JSON.parse(JSON.stringify(response.result.result)), {
405
+ id: "41",
406
+ serviceId: 11,
407
+ lookups: {
408
+ petId: { id: "7", name: "Fido" },
409
+ pet: { id: "7", name: "Fido" }
410
+ }
411
+ });
412
+ });
413
+
297
414
  await t.test("limit and cursor truncation remain bounded", async () => {
298
415
  const response = await executeList(fixture, toolSet, { limit: 1 });
299
416
  assert.equal(response.ok, true);
@@ -317,8 +434,27 @@ test("generated CRUD list contracts conform through native assistant discovery a
317
434
  assert.equal(response.error.status, 400);
318
435
  assert.match(
319
436
  response.error.message,
320
- /fields expects an object such as \{"bookings":\["petId"\],"pets":\["name"\]\}/u
437
+ /fields expects an object keyed by JSON:API resource type, such as \{"bookings":\["serviceId"\],"services":\["name"\]\}/u
438
+ );
439
+ });
440
+
441
+ await t.test("relationship aliases are rejected as sparse-fieldset resource keys", async () => {
442
+ const callCount = fixture.calls.length;
443
+ const response = await executeList(fixture, toolSet, {
444
+ include: "pet",
445
+ fields: { pet: ["name"] },
446
+ limit: 3
447
+ });
448
+ assert.equal(response.ok, false);
449
+ assert.equal(response.error.code, "ACTION_VALIDATION_FAILED");
450
+ assert.equal(response.error.status, 400);
451
+ assert.match(response.error.message, /fields\.pet: fields keys must be JSON:API resource types/u);
452
+ assert.match(
453
+ response.error.message,
454
+ /Allowed keys: "bookings", "services", "products", "contacts", "pets"/u
321
455
  );
456
+ assert.match(response.error.message, /use "pets" instead of "pet"/u);
457
+ assert.equal(fixture.calls.length, callCount);
322
458
  });
323
459
 
324
460
  await t.test("permission denial removes the generated action from native discovery", async () => {
@@ -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