@jskit-ai/crud-core 0.1.177 → 0.1.179

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.177",
3
+ "version": "0.1.179",
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.166",
27
- "@jskit-ai/http-runtime": "0.1.164",
28
- "@jskit-ai/json-rest-api-core": "0.1.110",
29
- "@jskit-ai/kernel": "0.1.166",
30
- "@jskit-ai/resource-crud-core": "0.1.108",
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",
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.166",
49
+ "@jskit-ai/kernel": "0.1.168"
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.177",
12
- "@jskit-ai/resource-crud-core": "0.1.108"
11
+ "@jskit-ai/crud-core": "0.1.179",
12
+ "@jskit-ai/resource-crud-core": "0.1.110"
13
13
  },
14
14
  "jskit": {
15
15
  "kind": "runtime",
@@ -1,9 +1,16 @@
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";
7
+ import {
8
+ decodeJsonApiResourceResponse,
9
+ normalizeJsonApiDocument,
10
+ unwrapJsonApiResult
11
+ } from "@jskit-ai/http-runtime/shared";
6
12
  import { resolveCrudRecordChangedEvent } from "@jskit-ai/resource-crud-core/shared/crudNamespaceSupport";
13
+ import { resolveJsonApiRelationshipEntries } from "../routeContracts.js";
7
14
  import {
8
15
  createStandardCrudListQueryValidators,
9
16
  createStandardCrudViewQueryValidators
@@ -12,6 +19,255 @@ import {
12
19
  const CRUD_OPERATION_NAMES = Object.freeze(["list", "view", "create", "update", "delete"]);
13
20
  const CRUD_MUTATION_NAMES = new Set(["create", "update", "delete"]);
14
21
  const CRUD_LIFECYCLE_PHASES = Object.freeze(["before", "execute", "after", "afterCommit"]);
22
+ const CRUD_ASSISTANT_RESOURCE_OPERATION = Object.freeze({
23
+ list: "list",
24
+ view: "view",
25
+ create: "create",
26
+ update: "patch",
27
+ delete: "delete"
28
+ });
29
+
30
+ function isRecord(value) {
31
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
32
+ }
33
+
34
+ function normalizeOptionalCursor(value) {
35
+ if (value == null) {
36
+ return null;
37
+ }
38
+ const normalized = String(value).trim();
39
+ return normalized || null;
40
+ }
41
+
42
+ function resolveAssistantResultValue(result) {
43
+ const taggedResult = unwrapJsonApiResult(result);
44
+ const value = taggedResult ? taggedResult.value : result;
45
+ const document = normalizeJsonApiDocument(value);
46
+ return {
47
+ value,
48
+ document
49
+ };
50
+ }
51
+
52
+ function resolveSchemaFieldDefinitions(definition = null) {
53
+ const definitions = definition?.schema?.getFieldDefinitions?.();
54
+ return isRecord(definitions) ? definitions : {};
55
+ }
56
+
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
+ } = {}) {
175
+ if (operation === "delete") {
176
+ const resolved = resolveAssistantResultValue(result);
177
+ if (isRecord(resolved.value) && resolved.value.deleted === true && resolved.value.id != null) {
178
+ return resolved.value;
179
+ }
180
+ return {
181
+ id: String(input.recordId || ""),
182
+ deleted: true
183
+ };
184
+ }
185
+
186
+ const resolved = resolveAssistantResultValue(result);
187
+ if (operation === "list") {
188
+ if (resolved.document.kind === "collection") {
189
+ const decoded = decodeJsonApiResourceResponse(
190
+ resolved.value,
191
+ createCrudAssistantTransport(resource, operation, relationshipEntries, lookupContainerKey)
192
+ );
193
+ return {
194
+ items: decoded.items,
195
+ nextCursor: normalizeOptionalCursor(decoded.nextCursor)
196
+ };
197
+ }
198
+ if (Array.isArray(resolved.value)) {
199
+ return {
200
+ items: resolved.value,
201
+ nextCursor: null
202
+ };
203
+ }
204
+ if (isRecord(resolved.value) && Array.isArray(resolved.value.items)) {
205
+ return {
206
+ items: resolved.value.items,
207
+ nextCursor: normalizeOptionalCursor(resolved.value.nextCursor)
208
+ };
209
+ }
210
+ return resolved.value;
211
+ }
212
+
213
+ if (resolved.document.kind === "resource") {
214
+ return decodeJsonApiResourceResponse(
215
+ resolved.value,
216
+ createCrudAssistantTransport(resource, operation, relationshipEntries, lookupContainerKey)
217
+ );
218
+ }
219
+ return resolved.value;
220
+ }
221
+
222
+ function createCrudAssistantExtension(resource, namespace, operation) {
223
+ const resourceOperation = CRUD_ASSISTANT_RESOURCE_OPERATION[operation];
224
+ const nativeOutput = resource?.operations?.[resourceOperation]?.output || null;
225
+ const recordOutput = operation === "list"
226
+ ? Object.freeze({
227
+ schema: resolveSchemaFieldDefinitions(nativeOutput).items?.items,
228
+ mode: "replace"
229
+ })
230
+ : nativeOutput;
231
+ const relationshipEntries = resolveJsonApiRelationshipEntries(recordOutput);
232
+ const lookupContainerKey = String(resource?.contract?.lookup?.containerKey || "").trim();
233
+ const output = createProjectionOutputDefinition(
234
+ nativeOutput,
235
+ operation,
236
+ relationshipEntries,
237
+ lookupContainerKey
238
+ );
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.`;
257
+
258
+ return Object.freeze({
259
+ description: actionLabel,
260
+ output,
261
+ transformResult(result, context) {
262
+ return transformCrudAssistantResult(operation, result, {
263
+ ...context,
264
+ resource,
265
+ relationshipEntries,
266
+ lookupContainerKey
267
+ });
268
+ }
269
+ });
270
+ }
15
271
 
16
272
  function createActionInput(
17
273
  resource,
@@ -303,6 +559,9 @@ function createCrudJsonApiActions({
303
559
  permission: permission(definition.operation),
304
560
  input: input(definition.operation),
305
561
  output: null,
562
+ extensions: Object.freeze({
563
+ assistant: createCrudAssistantExtension(resource, namespace, definition.operation)
564
+ }),
306
565
  idempotency: definition.idempotency,
307
566
  audit: { actionName: id },
308
567
  observability: {},
@@ -19,7 +19,10 @@ const lookupIncludeQueryValidator = Object.freeze({
19
19
  schema: createSchema({
20
20
  include: {
21
21
  type: "string",
22
- required: false
22
+ required: false,
23
+ messages: {
24
+ default: "include expects a comma-separated string such as \"pet,service\"."
25
+ }
23
26
  }
24
27
  }),
25
28
  mode: "patch"
@@ -30,6 +33,9 @@ const jsonApiFieldsetsQueryValidator = Object.freeze({
30
33
  fields: {
31
34
  type: "object",
32
35
  required: false,
36
+ messages: {
37
+ default: "fields expects an object such as {\"bookings\":[\"petId\"],\"pets\":[\"name\"]}."
38
+ },
33
39
  values: {
34
40
  type: "array",
35
41
  items: {
@@ -44,6 +44,7 @@ function resolveJsonApiRelationshipEntries(definition = null) {
44
44
  attributeKey: fieldKey,
45
45
  relationshipName,
46
46
  relationshipType,
47
+ labelKey: String(normalizedFieldDefinition?.relation?.labelKey || "").trim(),
47
48
  required: normalizedFieldDefinition.required === true,
48
49
  nullable: normalizedFieldDefinition.nullable === true
49
50
  }));
@@ -75,6 +76,7 @@ function resolveJsonApiRelationshipEntries(definition = null) {
75
76
  attributeKey: fieldKey,
76
77
  relationshipName: collectionRelationshipName,
77
78
  relationshipType: collectionRelationshipType,
79
+ labelKey: String(relation.labelKey || "").trim(),
78
80
  many: true,
79
81
  required: normalizedFieldDefinition.required === true,
80
82
  nullable: normalizedFieldDefinition.nullable === true
@@ -574,4 +576,4 @@ function createCrudJsonApiRouteContracts({
574
576
  });
575
577
  }
576
578
 
577
- export { createCrudJsonApiRouteContracts };
579
+ export { createCrudJsonApiRouteContracts, resolveJsonApiRelationshipEntries };
@@ -0,0 +1,344 @@
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
+ assert.equal(contract.result.inputSchema.properties.fields.type, "object");
210
+ assert.equal(Object.hasOwn(contract.result.inputSchema.properties, "workspaceSlug"), false);
211
+ 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);
214
+ const primaryOutputSchema = resolveLocalSchemaReference(
215
+ contract.result.outputSchema,
216
+ contract.result.outputSchema.properties.items.items
217
+ );
218
+ const lookupOutputSchema = resolveLocalSchemaReference(
219
+ contract.result.outputSchema,
220
+ primaryOutputSchema.properties.lookups
221
+ );
222
+ const petOutputSchema = resolveLocalSchemaReference(
223
+ contract.result.outputSchema,
224
+ lookupOutputSchema.properties.pet
225
+ );
226
+ assert.ok(petOutputSchema.properties.name.anyOf.some((entry) => entry.type === "string"));
227
+
228
+ await t.test("full list without fields or include", async () => {
229
+ const response = await executeList(fixture, toolSet, { limit: 5 });
230
+ assert.equal(response.ok, true);
231
+ assert.deepEqual(response.result.result.items, [
232
+ { id: "41", petId: 7, summary: "Wash and trim" },
233
+ { id: "42", petId: 8, summary: "Nail trim" }
234
+ ]);
235
+ assert.equal(response.result.result.nextCursor, null);
236
+ });
237
+
238
+ await t.test("sparse primary fields only", async () => {
239
+ const response = await executeList(fixture, toolSet, {
240
+ fields: { bookings: ["petId"] },
241
+ limit: 5
242
+ });
243
+ assert.equal(response.ok, true);
244
+ assert.deepEqual(response.result.result.items, [
245
+ { id: "41", petId: 7 },
246
+ { id: "42", petId: 8 }
247
+ ]);
248
+ });
249
+
250
+ await t.test("included relationship without sparse fields", async () => {
251
+ const response = await executeList(fixture, toolSet, { include: "pet", limit: 5 });
252
+ assert.equal(response.ok, true);
253
+ assert.equal(response.result.result.items[0].lookups.pet.name, "Fido");
254
+ assert.equal(response.result.result.items[0].lookups.pet.species, "dog");
255
+ assert.equal(response.result.result.items[1].lookups.pet.name, "Mabel");
256
+ });
257
+
258
+ await t.test("included relationship plus sparse primary and related fields", async () => {
259
+ const response = await executeList(fixture, toolSet, {
260
+ workspaceSlug: "model-controlled-workspace",
261
+ include: "pet",
262
+ fields: { bookings: ["petId"], pets: ["name"] },
263
+ limit: 5
264
+ });
265
+ assert.equal(response.ok, true);
266
+ assert.notEqual(response.error?.code, "assistant_tool_output_invalid");
267
+ assert.deepEqual(JSON.parse(JSON.stringify(response.result.result.items)), [
268
+ {
269
+ id: "41",
270
+ petId: 7,
271
+ lookups: {
272
+ petId: { id: "7", name: "Fido" },
273
+ pet: { id: "7", name: "Fido" }
274
+ }
275
+ },
276
+ {
277
+ id: "42",
278
+ petId: 8,
279
+ lookups: {
280
+ petId: { id: "8", name: "Mabel" },
281
+ pet: { id: "8", name: "Mabel" }
282
+ }
283
+ }
284
+ ]);
285
+ assert.equal(Object.hasOwn(response.result.result.items[0], "summary"), false);
286
+ assert.equal(Object.hasOwn(response.result.result.items[0].lookups.pet, "species"), false);
287
+ assert.deepEqual(
288
+ validateSchemaPayload(listDefinition.extensions.assistant.output, response.result.result, {
289
+ phase: "output"
290
+ }),
291
+ response.result.result
292
+ );
293
+ assert.equal(fixture.calls.at(-1).query.workspaceSlug, undefined);
294
+ assert.equal(fixture.calls.at(-1).context.workspace.slug, "north-clinic");
295
+ });
296
+
297
+ await t.test("limit and cursor truncation remain bounded", async () => {
298
+ const response = await executeList(fixture, toolSet, { limit: 1 });
299
+ assert.equal(response.ok, true);
300
+ assert.equal(response.result.result.items.length, 1);
301
+ assert.equal(response.result.result.nextCursor, "after-41");
302
+ assert.equal(fixture.calls.at(-1).query.limit, 1);
303
+ });
304
+
305
+ await t.test("invalid array-shaped include gives an actionable validation result", async () => {
306
+ const response = await executeList(fixture, toolSet, { include: ["pet"] });
307
+ assert.equal(response.ok, false);
308
+ assert.equal(response.error.code, "ACTION_VALIDATION_FAILED");
309
+ assert.equal(response.error.status, 400);
310
+ assert.match(response.error.message, /include expects a comma-separated string such as "pet,service"/u);
311
+ });
312
+
313
+ await t.test("invalid array-shaped fields gives an actionable validation result", async () => {
314
+ const response = await executeList(fixture, toolSet, { fields: ["pet.name"] });
315
+ assert.equal(response.ok, false);
316
+ assert.equal(response.error.code, "ACTION_VALIDATION_FAILED");
317
+ assert.equal(response.error.status, 400);
318
+ assert.match(
319
+ response.error.message,
320
+ /fields expects an object such as \{"bookings":\["petId"\],"pets":\["name"\]\}/u
321
+ );
322
+ });
323
+
324
+ await t.test("permission denial removes the generated action from native discovery", async () => {
325
+ const deniedContext = {
326
+ ...fixture.context,
327
+ permissions: []
328
+ };
329
+ const deniedToolSet = fixture.catalog.resolveToolSet(deniedContext);
330
+ assert.deepEqual(deniedToolSet.tools, []);
331
+ assert.deepEqual(await fixture.catalog.executeToolCall({
332
+ toolName: "crud_bookings_list",
333
+ argumentsText: "{}",
334
+ context: deniedContext,
335
+ toolSet: deniedToolSet
336
+ }), {
337
+ ok: false,
338
+ error: {
339
+ code: "assistant_tool_unknown",
340
+ message: "Unknown tool."
341
+ }
342
+ });
343
+ });
344
+ });
@@ -4,6 +4,8 @@ import { createSchema } from "json-rest-schema";
4
4
 
5
5
  import { createActionProvider } from "@jskit-ai/kernel/server/actions";
6
6
  import { createCapabilityRuntime, defineProvider } from "@jskit-ai/kernel/shared/capabilities";
7
+ import { validateSchemaPayload } from "@jskit-ai/kernel/shared/validators";
8
+ import { returnJsonApiDocument } from "@jskit-ai/http-runtime/shared";
7
9
  import { defineCrudResource } from "@jskit-ai/resource-crud-core/shared/crudResource";
8
10
  import { defineCrudJsonApiFeature } from "../src/server/defineCrudJsonApiFeature.js";
9
11
  import { createCrudJsonApiActions } from "../src/server/jsonApiModule/actions.js";
@@ -187,6 +189,91 @@ test("defineCrudJsonApiFeature makes workspace scope and permissions explicit",
187
189
  await runtime.shutdown();
188
190
  });
189
191
 
192
+ test("generated CRUD actions expose truthful assistant contracts without changing native results", async () => {
193
+ const resource = createBookResource();
194
+ const document = (id, title) => ({
195
+ data: {
196
+ type: "books",
197
+ id,
198
+ attributes: { title }
199
+ }
200
+ });
201
+ const actions = createCrudJsonApiActions({
202
+ namespace: "books",
203
+ resource,
204
+ service: {
205
+ async queryDocuments() {
206
+ return returnJsonApiDocument({
207
+ data: [document("1", "Kindred").data],
208
+ meta: { page: { nextCursor: "cursor-2" } }
209
+ });
210
+ },
211
+ async getDocumentById() {
212
+ return returnJsonApiDocument(document("1", "Kindred"));
213
+ },
214
+ async createDocument() {
215
+ return returnJsonApiDocument(document("2", "Parable of the Sower"));
216
+ },
217
+ async patchDocumentById() {
218
+ return returnJsonApiDocument(document("1", "Kindred (updated)"));
219
+ },
220
+ async deleteDocumentById() {
221
+ return null;
222
+ }
223
+ },
224
+ surface: "admin",
225
+ permissionForOperation: () => ({ require: "authenticated" })
226
+ });
227
+ const byOperation = new Map(actions.map((entry) => [entry.id.split(".").at(-1), entry]));
228
+
229
+ assert.ok(actions.every((entry) => entry.output === null));
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
+ assert.equal(byOperation.get("create").extensions.assistant.output, resource.operations.create.output);
233
+ assert.equal(byOperation.get("update").extensions.assistant.output, resource.operations.patch.output);
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
+ );
242
+
243
+ const transform = async (operation, input = {}) => {
244
+ const definition = byOperation.get(operation);
245
+ const nativeResult = await definition.execute(input, {});
246
+ const assistantResult = await definition.extensions.assistant.transformResult(nativeResult, { input });
247
+ return validateSchemaPayload(definition.extensions.assistant.output, assistantResult, {
248
+ phase: "output"
249
+ });
250
+ };
251
+
252
+ assert.deepEqual(await transform("list"), {
253
+ items: [{ id: "1", title: "Kindred" }],
254
+ nextCursor: "cursor-2"
255
+ });
256
+ assert.deepEqual(await transform("view", { recordId: "1" }), {
257
+ id: "1",
258
+ title: "Kindred"
259
+ });
260
+ assert.deepEqual(await transform("create", { title: "Parable of the Sower" }), {
261
+ id: "2",
262
+ title: "Parable of the Sower"
263
+ });
264
+ assert.deepEqual(await transform("update", { recordId: "1", title: "Kindred (updated)" }), {
265
+ id: "1",
266
+ title: "Kindred (updated)"
267
+ });
268
+ assert.deepEqual(await transform("delete", { recordId: "1" }), {
269
+ id: "1",
270
+ deleted: true
271
+ });
272
+
273
+ assert.deepEqual(await byOperation.get("view").execute({ recordId: "1" }, {}),
274
+ returnJsonApiDocument(document("1", "Kindred")));
275
+ });
276
+
190
277
  test("defineCrudJsonApiFeature lets product services name exact capability dependencies", async () => {
191
278
  const routes = [];
192
279
  const access = {