@jskit-ai/crud-core 0.1.180 → 0.1.182

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.180",
3
+ "version": "0.1.182",
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.169",
27
- "@jskit-ai/json-rest-api-core": "0.1.113",
28
- "@jskit-ai/resource-crud-core": "0.1.111",
26
+ "@jskit-ai/database-runtime": "0.1.171",
27
+ "@jskit-ai/json-rest-api-core": "0.1.115",
28
+ "@jskit-ai/resource-crud-core": "0.1.113",
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.167",
49
- "@jskit-ai/kernel": "0.1.169"
48
+ "@jskit-ai/http-runtime": "0.1.169",
49
+ "@jskit-ai/kernel": "0.1.171"
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.180",
12
- "@jskit-ai/resource-crud-core": "0.1.111"
11
+ "@jskit-ai/crud-core": "0.1.182",
12
+ "@jskit-ai/resource-crud-core": "0.1.113"
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,
@@ -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,45 @@ 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);
220
323
  }
221
324
 
222
325
  function createCrudAssistantReadDescription({
@@ -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({
@@ -210,14 +247,23 @@ test("generated CRUD list contracts conform through native assistant discovery a
210
247
  contract.result.inputSchema,
211
248
  contract.result.inputSchema.properties.fields
212
249
  );
213
- assert.deepEqual(Object.keys(fieldsetInputSchema.properties), ["bookings", "pets"]);
250
+ assert.deepEqual(
251
+ Object.keys(fieldsetInputSchema.properties),
252
+ ["bookings", "services", "products", "contacts", "pets"]
253
+ );
214
254
  assert.equal(fieldsetInputSchema.additionalProperties, false);
215
- assert.deepEqual(fieldsetInputSchema.properties.bookings.items.enum, ["id", "petId", "summary"]);
255
+ assert.deepEqual(
256
+ fieldsetInputSchema.properties.bookings.items.enum,
257
+ ["id", "serviceId", "productId", "contactId", "petId", "summary"]
258
+ );
216
259
  assert.equal(Object.hasOwn(fieldsetInputSchema.properties.pets.items, "enum"), false);
217
260
  assert.equal(Object.hasOwn(contract.result.inputSchema.properties, "workspaceSlug"), false);
218
261
  assert.match(contract.result.description, /include must be a comma-separated string/u);
219
- assert.match(contract.result.description, /\{"bookings":\["petId"\],"pets":\["name"\]\}/u);
220
- assert.match(contract.result.description, /fields keys must be JSON:API resource types: "bookings", "pets"/u);
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
+ );
221
267
  assert.match(contract.result.description, /use "pets" instead of "pet"/u);
222
268
  assert.match(
223
269
  contract.result.description,
@@ -241,21 +287,35 @@ test("generated CRUD list contracts conform through native assistant discovery a
241
287
  const response = await executeList(fixture, toolSet, { limit: 5 });
242
288
  assert.equal(response.ok, true);
243
289
  assert.deepEqual(response.result.result.items, [
244
- { id: "41", petId: 7, summary: "Wash and trim" },
245
- { 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
+ }
246
306
  ]);
247
307
  assert.equal(response.result.result.nextCursor, null);
248
308
  });
249
309
 
250
310
  await t.test("sparse primary fields only", async () => {
251
311
  const response = await executeList(fixture, toolSet, {
252
- fields: { bookings: ["petId"] },
312
+ fields: { bookings: ["serviceId"] },
253
313
  limit: 5
254
314
  });
255
315
  assert.equal(response.ok, true);
256
316
  assert.deepEqual(response.result.result.items, [
257
- { id: "41", petId: 7 },
258
- { id: "42", petId: 8 }
317
+ { id: "41", serviceId: 11 },
318
+ { id: "42", serviceId: 12 }
259
319
  ]);
260
320
  });
261
321
 
@@ -264,6 +324,8 @@ test("generated CRUD list contracts conform through native assistant discovery a
264
324
  assert.equal(response.ok, true);
265
325
  assert.equal(response.result.result.items[0].lookups.pet.name, "Fido");
266
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");
267
329
  assert.equal(response.result.result.items[1].lookups.pet.name, "Mabel");
268
330
  });
269
331
 
@@ -271,7 +333,7 @@ test("generated CRUD list contracts conform through native assistant discovery a
271
333
  const response = await executeList(fixture, toolSet, {
272
334
  workspaceSlug: "model-controlled-workspace",
273
335
  include: "pet",
274
- fields: { bookings: ["petId"], pets: ["name"] },
336
+ fields: { bookings: ["id", "serviceId"], pets: ["name"] },
275
337
  limit: 5
276
338
  });
277
339
  assert.equal(response.ok, true);
@@ -279,7 +341,7 @@ test("generated CRUD list contracts conform through native assistant discovery a
279
341
  assert.deepEqual(JSON.parse(JSON.stringify(response.result.result.items)), [
280
342
  {
281
343
  id: "41",
282
- petId: 7,
344
+ serviceId: 11,
283
345
  lookups: {
284
346
  petId: { id: "7", name: "Fido" },
285
347
  pet: { id: "7", name: "Fido" }
@@ -287,7 +349,7 @@ test("generated CRUD list contracts conform through native assistant discovery a
287
349
  },
288
350
  {
289
351
  id: "42",
290
- petId: 8,
352
+ serviceId: 12,
291
353
  lookups: {
292
354
  petId: { id: "8", name: "Mabel" },
293
355
  pet: { id: "8", name: "Mabel" }
@@ -295,7 +357,12 @@ test("generated CRUD list contracts conform through native assistant discovery a
295
357
  }
296
358
  ]);
297
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);
298
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);
299
366
  assert.deepEqual(
300
367
  validateSchemaPayload(listDefinition.extensions.assistant.output, response.result.result, {
301
368
  phase: "output"
@@ -303,9 +370,47 @@ test("generated CRUD list contracts conform through native assistant discovery a
303
370
  response.result.result
304
371
  );
305
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
+ });
306
377
  assert.equal(fixture.calls.at(-1).context.workspace.slug, "north-clinic");
307
378
  });
308
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
+
309
414
  await t.test("limit and cursor truncation remain bounded", async () => {
310
415
  const response = await executeList(fixture, toolSet, { limit: 1 });
311
416
  assert.equal(response.ok, true);
@@ -329,7 +434,7 @@ test("generated CRUD list contracts conform through native assistant discovery a
329
434
  assert.equal(response.error.status, 400);
330
435
  assert.match(
331
436
  response.error.message,
332
- /fields expects an object keyed by JSON:API resource type, 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
333
438
  );
334
439
  });
335
440
 
@@ -344,7 +449,10 @@ test("generated CRUD list contracts conform through native assistant discovery a
344
449
  assert.equal(response.error.code, "ACTION_VALIDATION_FAILED");
345
450
  assert.equal(response.error.status, 400);
346
451
  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);
452
+ assert.match(
453
+ response.error.message,
454
+ /Allowed keys: "bookings", "services", "products", "contacts", "pets"/u
455
+ );
348
456
  assert.match(response.error.message, /use "pets" instead of "pet"/u);
349
457
  assert.equal(fixture.calls.length, callCount);
350
458
  });