@jskit-ai/crud-core 0.1.92 → 0.1.93

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.
@@ -1,7 +1,7 @@
1
1
  export default Object.freeze({
2
2
  packageVersion: 1,
3
3
  packageId: "@jskit-ai/crud-core",
4
- version: "0.1.92",
4
+ version: "0.1.93",
5
5
  kind: "runtime",
6
6
  description: "Shared CRUD helpers used by CRUD modules.",
7
7
  dependsOn: [
@@ -28,7 +28,7 @@ export default Object.freeze({
28
28
  mutations: {
29
29
  dependencies: {
30
30
  runtime: {
31
- "@jskit-ai/crud-core": "0.1.92"
31
+ "@jskit-ai/crud-core": "0.1.93"
32
32
  },
33
33
  dev: {}
34
34
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jskit-ai/crud-core",
3
- "version": "0.1.92",
3
+ "version": "0.1.93",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "test": "node --test"
@@ -27,15 +27,15 @@
27
27
  "./server/routeContracts": "./src/server/routeContracts.js"
28
28
  },
29
29
  "dependencies": {
30
- "@jskit-ai/database-runtime": "0.1.84",
31
- "@jskit-ai/http-runtime": "0.1.83",
32
- "@jskit-ai/kernel": "0.1.84",
30
+ "@jskit-ai/database-runtime": "0.1.85",
31
+ "@jskit-ai/http-runtime": "0.1.84",
32
+ "@jskit-ai/kernel": "0.1.85",
33
33
  "json-rest-schema": "1.x.x",
34
- "@jskit-ai/realtime": "0.1.83",
35
- "@jskit-ai/resource-crud-core": "0.1.29",
36
- "@jskit-ai/shell-web": "0.1.83",
37
- "@jskit-ai/users-core": "0.1.94",
38
- "@jskit-ai/users-web": "0.1.99"
34
+ "@jskit-ai/realtime": "0.1.84",
35
+ "@jskit-ai/resource-crud-core": "0.1.30",
36
+ "@jskit-ai/shell-web": "0.1.84",
37
+ "@jskit-ai/users-core": "0.1.95",
38
+ "@jskit-ai/users-web": "0.1.100"
39
39
  },
40
40
  "peerDependencies": {
41
41
  "@tanstack/vue-query": "^5.90.5",
@@ -6,6 +6,7 @@ import {
6
6
  composeSchemaDefinitions,
7
7
  recordIdParamsValidator
8
8
  } from "@jskit-ai/kernel/shared/validators";
9
+ import { resolveCrudResourceScopeName } from "@jskit-ai/kernel/shared/support/crudLookup";
9
10
  import {
10
11
  createCrudCursorPaginationQueryValidator,
11
12
  listSearchQueryValidator as defaultListSearchQueryValidator,
@@ -33,19 +34,48 @@ function resolveJsonApiRelationshipEntries(definition = null) {
33
34
  for (const [fieldKey, fieldDefinition] of Object.entries(resolveSchemaFieldDefinitions(definition))) {
34
35
  const normalizedFieldDefinition = isRecord(fieldDefinition) ? fieldDefinition : {};
35
36
  const relationshipType = String(normalizedFieldDefinition.belongsTo || "").trim();
36
- if (!relationshipType) {
37
+ if (relationshipType) {
38
+ const relationshipName = String(normalizedFieldDefinition.as || fieldKey || "").trim();
39
+ if (!relationshipName) {
40
+ continue;
41
+ }
42
+
43
+ entries.push(Object.freeze({
44
+ attributeKey: fieldKey,
45
+ relationshipName,
46
+ relationshipType,
47
+ required: normalizedFieldDefinition.required === true,
48
+ nullable: normalizedFieldDefinition.nullable === true
49
+ }));
50
+ continue;
51
+ }
52
+
53
+ const relation = isRecord(normalizedFieldDefinition.relation)
54
+ ? normalizedFieldDefinition.relation
55
+ : {};
56
+ if (String(relation.kind || "").trim().toLowerCase() !== "collection") {
37
57
  continue;
38
58
  }
39
59
 
40
- const relationshipName = String(normalizedFieldDefinition.as || fieldKey || "").trim();
41
- if (!relationshipName) {
60
+ const collectionRelationshipType = resolveCrudResourceScopeName(
61
+ relation.target || relation.targetResource || relation.namespace || relation.apiPath
62
+ );
63
+ if (!collectionRelationshipType) {
64
+ continue;
65
+ }
66
+
67
+ const collectionRelationshipName = String(
68
+ relation.as || normalizedFieldDefinition.as || fieldKey || ""
69
+ ).trim();
70
+ if (!collectionRelationshipName) {
42
71
  continue;
43
72
  }
44
73
 
45
74
  entries.push(Object.freeze({
46
75
  attributeKey: fieldKey,
47
- relationshipName,
48
- relationshipType,
76
+ relationshipName: collectionRelationshipName,
77
+ relationshipType: collectionRelationshipType,
78
+ many: true,
49
79
  required: normalizedFieldDefinition.required === true,
50
80
  nullable: normalizedFieldDefinition.nullable === true
51
81
  }));
@@ -54,6 +84,79 @@ function resolveJsonApiRelationshipEntries(definition = null) {
54
84
  return Object.freeze(entries);
55
85
  }
56
86
 
87
+ function readOwnValue(source = {}, key = "") {
88
+ if (isRecord(source) && Object.hasOwn(source, key)) {
89
+ return {
90
+ found: true,
91
+ value: source[key]
92
+ };
93
+ }
94
+
95
+ return {
96
+ found: false,
97
+ value: undefined
98
+ };
99
+ }
100
+
101
+ function resolveLookupContainer(record = {}, lookupContainerKey = "") {
102
+ const normalizedLookupContainerKey = String(lookupContainerKey || "").trim();
103
+ if (!normalizedLookupContainerKey || !isRecord(record?.[normalizedLookupContainerKey])) {
104
+ return {};
105
+ }
106
+
107
+ return record[normalizedLookupContainerKey];
108
+ }
109
+
110
+ function resolveRelationshipValueSource(record = {}, entry = {}, {
111
+ lookupContainerKey = "",
112
+ preferLookup = false
113
+ } = {}) {
114
+ const lookups = resolveLookupContainer(record, lookupContainerKey);
115
+ const lookupValueByAttributeKey = readOwnValue(lookups, entry.attributeKey);
116
+ const lookupValue = lookupValueByAttributeKey.found
117
+ ? lookupValueByAttributeKey
118
+ : readOwnValue(lookups, entry.relationshipName);
119
+ const recordValue = readOwnValue(record, entry.attributeKey);
120
+
121
+ if (preferLookup) {
122
+ return lookupValue.found ? lookupValue : recordValue;
123
+ }
124
+
125
+ return recordValue.found ? recordValue : lookupValue;
126
+ }
127
+
128
+ function normalizeLookupId(value) {
129
+ if (value == null) {
130
+ return null;
131
+ }
132
+
133
+ const normalized = String(value).trim();
134
+ return normalized || null;
135
+ }
136
+
137
+ function normalizeRelationshipIdentifierId(value = null) {
138
+ if (isRecord(value)) {
139
+ if (isRecord(value.data)) {
140
+ return normalizeLookupId(value.data.id);
141
+ }
142
+ return normalizeLookupId(value.id);
143
+ }
144
+
145
+ return normalizeLookupId(value);
146
+ }
147
+
148
+ function createRelationshipIdentifier(entry = {}, value = null) {
149
+ const id = normalizeRelationshipIdentifierId(value);
150
+ if (!id) {
151
+ return null;
152
+ }
153
+
154
+ return {
155
+ type: entry.relationshipType,
156
+ id
157
+ };
158
+ }
159
+
57
160
  function createRecordAttributesResolver(definition = null, {
58
161
  excludeKeys = []
59
162
  } = {}) {
@@ -91,7 +194,9 @@ function createRecordAttributesResolver(definition = null, {
91
194
  };
92
195
  }
93
196
 
94
- function createRecordRelationshipsResolver(definition = null) {
197
+ function createRecordRelationshipsResolver(definition = null, {
198
+ lookupContainerKey = ""
199
+ } = {}) {
95
200
  const relationshipEntries = resolveJsonApiRelationshipEntries(definition);
96
201
  if (relationshipEntries.length < 1) {
97
202
  return null;
@@ -105,18 +210,27 @@ function createRecordRelationshipsResolver(definition = null) {
105
210
  const relationships = {};
106
211
 
107
212
  for (const entry of relationshipEntries) {
108
- if (!Object.hasOwn(record, entry.attributeKey)) {
213
+ const relationshipSource = resolveRelationshipValueSource(record, entry, {
214
+ lookupContainerKey,
215
+ preferLookup: entry.many === true
216
+ });
217
+ if (!relationshipSource.found) {
218
+ continue;
219
+ }
220
+
221
+ const value = relationshipSource.value;
222
+ if (entry.many === true) {
223
+ const items = Array.isArray(value) ? value : [];
224
+ relationships[entry.relationshipName] = {
225
+ data: items
226
+ .map((item) => createRelationshipIdentifier(entry, item))
227
+ .filter(Boolean)
228
+ };
109
229
  continue;
110
230
  }
111
231
 
112
- const value = record[entry.attributeKey];
113
232
  relationships[entry.relationshipName] = {
114
- data: value == null || (typeof value === "string" && value.trim() === "")
115
- ? null
116
- : {
117
- type: entry.relationshipType,
118
- id: String(value)
119
- }
233
+ data: createRelationshipIdentifier(entry, value)
120
234
  };
121
235
  }
122
236
 
@@ -145,7 +259,16 @@ function createRequestRelationshipMapper(definition = null) {
145
259
  const data = relationship.data;
146
260
 
147
261
  if (data == null) {
148
- mapped[entry.attributeKey] = null;
262
+ mapped[entry.attributeKey] = entry.many === true ? [] : null;
263
+ continue;
264
+ }
265
+
266
+ if (entry.many === true) {
267
+ mapped[entry.attributeKey] = Array.isArray(data)
268
+ ? data
269
+ .map((item) => normalizeRelationshipIdentifierId(item))
270
+ .filter(Boolean)
271
+ : [];
149
272
  continue;
150
273
  }
151
274
 
@@ -167,15 +290,6 @@ function resolveLookupContainerKey(resource = {}) {
167
290
  return String(resource?.contract?.lookup?.containerKey || "").trim();
168
291
  }
169
292
 
170
- function normalizeLookupId(value) {
171
- if (value == null) {
172
- return null;
173
- }
174
-
175
- const normalized = String(value).trim();
176
- return normalized || null;
177
- }
178
-
179
293
  function normalizeIncludedLookupRecord(source = null, fallbackId = null) {
180
294
  if (!isRecord(source)) {
181
295
  return null;
@@ -224,6 +338,41 @@ function normalizeIncludedLookupRecord(source = null, fallbackId = null) {
224
338
  };
225
339
  }
226
340
 
341
+ function appendIncludedRelationshipResource({
342
+ included = [],
343
+ seen = new Set(),
344
+ entry = {},
345
+ lookupRecord = null,
346
+ fallbackId = null
347
+ } = {}) {
348
+ const normalizedLookupRecord = normalizeIncludedLookupRecord(lookupRecord, fallbackId);
349
+ const includedId = normalizeLookupId(normalizedLookupRecord?.id ?? fallbackId);
350
+ if (!normalizedLookupRecord || !includedId) {
351
+ return;
352
+ }
353
+
354
+ const resourceKey = `${entry.relationshipType}:${includedId}`;
355
+ if (seen.has(resourceKey)) {
356
+ return;
357
+ }
358
+ seen.add(resourceKey);
359
+
360
+ const attributes = {
361
+ ...normalizedLookupRecord
362
+ };
363
+ delete attributes.id;
364
+ delete attributes.type;
365
+ delete attributes.relationships;
366
+ delete attributes.links;
367
+ delete attributes.meta;
368
+
369
+ included.push(createJsonApiResourceObject({
370
+ type: entry.relationshipType,
371
+ id: includedId,
372
+ attributes
373
+ }));
374
+ }
375
+
227
376
  function createLookupIncludedResolver(definition = null, {
228
377
  lookupContainerKey = ""
229
378
  } = {}) {
@@ -254,36 +403,33 @@ function createLookupIncludedResolver(definition = null, {
254
403
  : {};
255
404
 
256
405
  for (const entry of relationshipEntries) {
257
- const relationshipId = normalizeLookupId(record[entry.attributeKey]);
258
- const lookupRecord = normalizeIncludedLookupRecord(
259
- sourceLookups[entry.attributeKey] ?? sourceLookups[entry.relationshipName],
260
- relationshipId
261
- );
262
- const includedId = normalizeLookupId(lookupRecord?.id ?? relationshipId);
263
- if (!lookupRecord || !includedId) {
406
+ if (entry.many === true) {
407
+ const relationshipSource = resolveRelationshipValueSource(record, entry, {
408
+ lookupContainerKey: normalizedLookupContainerKey,
409
+ preferLookup: true
410
+ });
411
+ const lookupRecords = Array.isArray(relationshipSource.value) ? relationshipSource.value : [];
412
+ for (const lookupRecord of lookupRecords) {
413
+ appendIncludedRelationshipResource({
414
+ included,
415
+ seen,
416
+ entry,
417
+ lookupRecord,
418
+ fallbackId: lookupRecord?.id
419
+ });
420
+ }
264
421
  continue;
265
422
  }
266
423
 
267
- const resourceKey = `${entry.relationshipType}:${includedId}`;
268
- if (seen.has(resourceKey)) {
269
- continue;
270
- }
271
- seen.add(resourceKey);
272
-
273
- const attributes = {
274
- ...lookupRecord
275
- };
276
- delete attributes.id;
277
- delete attributes.type;
278
- delete attributes.relationships;
279
- delete attributes.links;
280
- delete attributes.meta;
281
-
282
- included.push(createJsonApiResourceObject({
283
- type: entry.relationshipType,
284
- id: includedId,
285
- attributes
286
- }));
424
+ const relationshipId = normalizeLookupId(record[entry.attributeKey]);
425
+ const lookupRecord = sourceLookups[entry.attributeKey] ?? sourceLookups[entry.relationshipName];
426
+ appendIncludedRelationshipResource({
427
+ included,
428
+ seen,
429
+ entry,
430
+ lookupRecord,
431
+ fallbackId: relationshipId
432
+ });
287
433
  }
288
434
  }
289
435
 
@@ -329,15 +475,21 @@ function createCrudJsonApiRouteContracts({
329
475
  const viewRecordAttributes = createRecordAttributesResolver(viewOutput, {
330
476
  excludeKeys: outputAttributeExcludeKeys
331
477
  });
332
- const viewRecordRelationships = createRecordRelationshipsResolver(viewOutput);
478
+ const viewRecordRelationships = createRecordRelationshipsResolver(viewOutput, {
479
+ lookupContainerKey
480
+ });
333
481
  const createRecordAttributes = createRecordAttributesResolver(createOutput, {
334
482
  excludeKeys: outputAttributeExcludeKeys
335
483
  });
336
- const createRecordRelationships = createRecordRelationshipsResolver(createOutput);
484
+ const createRecordRelationships = createRecordRelationshipsResolver(createOutput, {
485
+ lookupContainerKey
486
+ });
337
487
  const patchRecordAttributes = createRecordAttributesResolver(patchOutput, {
338
488
  excludeKeys: outputAttributeExcludeKeys
339
489
  });
340
- const patchRecordRelationships = createRecordRelationshipsResolver(patchOutput);
490
+ const patchRecordRelationships = createRecordRelationshipsResolver(patchOutput, {
491
+ lookupContainerKey
492
+ });
341
493
  const createRequestRelationships = createRequestRelationshipMapper(createBody);
342
494
  const patchRequestRelationships = createRequestRelationshipMapper(patchBody);
343
495
  const viewIncluded = createLookupIncludedResolver(viewOutput, {
@@ -244,6 +244,26 @@ test("createCrudJsonApiRouteContracts builds default CRUD JSON:API contracts", a
244
244
  }
245
245
  ]);
246
246
 
247
+ const lookupOnlyRelationshipDocument = contracts.viewRouteContract.transport.response(returnJsonApiData({
248
+ id: "contact-3",
249
+ name: "Charlie",
250
+ lookups: {
251
+ owner: {
252
+ id: "user-8",
253
+ name: "User Eight"
254
+ }
255
+ }
256
+ }));
257
+
258
+ assert.deepEqual(lookupOnlyRelationshipDocument.data.relationships, {
259
+ owner: {
260
+ data: {
261
+ type: "userProfiles",
262
+ id: "user-8"
263
+ }
264
+ }
265
+ });
266
+
247
267
  const listResponseDocument = contracts.listRouteContract.transport.response(returnJsonApiData({
248
268
  items: [
249
269
  {
@@ -284,6 +304,112 @@ test("createCrudJsonApiRouteContracts builds default CRUD JSON:API contracts", a
284
304
  ]);
285
305
  });
286
306
 
307
+ test("createCrudJsonApiRouteContracts serializes collection relationships from hydrated lookups", () => {
308
+ const output = createSchemaDefinition({
309
+ id: {
310
+ type: "string",
311
+ required: true
312
+ },
313
+ name: {
314
+ type: "string",
315
+ required: true
316
+ },
317
+ pets: {
318
+ type: "array",
319
+ required: false,
320
+ relation: {
321
+ kind: "collection",
322
+ namespace: "pets",
323
+ foreignKey: "contactId"
324
+ }
325
+ },
326
+ lookups: {
327
+ type: "object",
328
+ required: false
329
+ }
330
+ }, "replace");
331
+ const body = createSchemaDefinition({
332
+ name: {
333
+ type: "string",
334
+ required: true
335
+ }
336
+ }, "create");
337
+ const contracts = createCrudJsonApiRouteContracts({
338
+ resource: {
339
+ namespace: "contacts",
340
+ contract: {
341
+ lookup: {
342
+ containerKey: "lookups"
343
+ }
344
+ },
345
+ operations: {
346
+ view: { output },
347
+ create: { body, output },
348
+ patch: { body, output }
349
+ }
350
+ }
351
+ });
352
+
353
+ const responseDocument = contracts.viewRouteContract.transport.response(returnJsonApiData({
354
+ id: "contact-1",
355
+ name: "Alice",
356
+ lookups: {
357
+ pets: [
358
+ {
359
+ id: "pet-1",
360
+ name: "Ada",
361
+ contactId: "contact-1"
362
+ },
363
+ {
364
+ id: "pet-2",
365
+ name: "Bert",
366
+ contactId: "contact-1"
367
+ }
368
+ ]
369
+ }
370
+ }));
371
+ const resourceSchema = contracts.viewRouteContract.responses[200].transportSchema.definitions.contactsSuccessResource;
372
+ const relationshipDataSchema = resourceSchema.properties.relationships.properties.pets.properties.data;
373
+
374
+ assert.deepEqual(responseDocument.data.attributes, {
375
+ name: "Alice"
376
+ });
377
+ assert.deepEqual(responseDocument.data.relationships, {
378
+ pets: {
379
+ data: [
380
+ {
381
+ type: "pets",
382
+ id: "pet-1"
383
+ },
384
+ {
385
+ type: "pets",
386
+ id: "pet-2"
387
+ }
388
+ ]
389
+ }
390
+ });
391
+ assert.deepEqual(responseDocument.included, [
392
+ {
393
+ type: "pets",
394
+ id: "pet-1",
395
+ attributes: {
396
+ name: "Ada",
397
+ contactId: "contact-1"
398
+ }
399
+ },
400
+ {
401
+ type: "pets",
402
+ id: "pet-2",
403
+ attributes: {
404
+ name: "Bert",
405
+ contactId: "contact-1"
406
+ }
407
+ }
408
+ ]);
409
+ assert.equal(relationshipDataSchema.anyOf[0].type, "array");
410
+ assert.equal(relationshipDataSchema.anyOf[0].items.properties.type.const, "pets");
411
+ });
412
+
287
413
  test("createCrudJsonApiRouteContracts falls back to recordId params when no route params validator is provided", () => {
288
414
  const contracts = createCrudJsonApiRouteContracts({
289
415
  resource: createCrudResource()