@jskit-ai/crud-core 0.1.29 → 0.1.31

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.29",
4
+ version: "0.1.31",
5
5
  kind: "runtime",
6
6
  description: "Shared CRUD helpers used by CRUD modules.",
7
7
  dependsOn: [
@@ -26,7 +26,7 @@ export default Object.freeze({
26
26
  mutations: {
27
27
  dependencies: {
28
28
  runtime: {
29
- "@jskit-ai/crud-core": "0.1.29"
29
+ "@jskit-ai/crud-core": "0.1.31"
30
30
  },
31
31
  dev: {}
32
32
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jskit-ai/crud-core",
3
- "version": "0.1.29",
3
+ "version": "0.1.31",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "test": "node --test"
@@ -14,6 +14,7 @@
14
14
  "./shared/crudNamespaceSupport": "./src/shared/crudNamespaceSupport.js",
15
15
  "./server/repositorySupport": "./src/server/repositorySupport.js",
16
16
  "./server/repositoryMethods": "./src/server/repositoryMethods.js",
17
+ "./server/createHooksToCollectChildren": "./src/server/createHooksToCollectChildren.js",
17
18
  "./server/createCrudRepositoryFromResource": "./src/server/createCrudRepositoryFromResource.js",
18
19
  "./server/lookupProviders": "./src/server/lookupProviders.js",
19
20
  "./server/serviceEvents": "./src/server/serviceEvents.js",
@@ -24,11 +25,11 @@
24
25
  },
25
26
  "dependencies": {
26
27
  "@tanstack/vue-query": "^5.90.5",
27
- "@jskit-ai/kernel": "0.1.21",
28
- "@jskit-ai/realtime": "0.1.20",
29
- "@jskit-ai/shell-web": "0.1.20",
30
- "@jskit-ai/users-core": "0.1.30",
31
- "@jskit-ai/users-web": "0.1.35",
28
+ "@jskit-ai/kernel": "0.1.23",
29
+ "@jskit-ai/realtime": "0.1.22",
30
+ "@jskit-ai/shell-web": "0.1.22",
31
+ "@jskit-ai/users-core": "0.1.32",
32
+ "@jskit-ai/users-web": "0.1.37",
32
33
  "typebox": "^1.0.81"
33
34
  }
34
35
  }
@@ -3,6 +3,7 @@ import {
3
3
  crudRepositoryList,
4
4
  crudRepositoryFindById,
5
5
  crudRepositoryListByIds,
6
+ crudRepositoryListByForeignIds,
6
7
  crudRepositoryCreate,
7
8
  crudRepositoryUpdateById,
8
9
  crudRepositoryDeleteById
@@ -19,34 +20,39 @@ function createCrudRepositoryFromResource(resource = {}, { context = "crudReposi
19
20
  throw new TypeError("crudRepository requires knex.");
20
21
  }
21
22
 
22
- async function listRecords(query = {}, callOptions = {}) {
23
- return crudRepositoryList(runtime, knex, query, options, callOptions);
23
+ async function listRecords(query = {}, callOptions = {}, hooks = null) {
24
+ return crudRepositoryList(runtime, knex, query, options, callOptions, hooks);
24
25
  }
25
26
 
26
- async function findById(recordId, callOptions = {}) {
27
- return crudRepositoryFindById(runtime, knex, recordId, options, callOptions);
27
+ async function findById(recordId, callOptions = {}, hooks = null) {
28
+ return crudRepositoryFindById(runtime, knex, recordId, options, callOptions, hooks);
28
29
  }
29
30
 
30
- async function listByIds(ids = [], callOptions = {}) {
31
- return crudRepositoryListByIds(runtime, knex, ids, options, callOptions);
31
+ async function listByIds(ids = [], callOptions = {}, hooks = null) {
32
+ return crudRepositoryListByIds(runtime, knex, ids, options, callOptions, hooks);
32
33
  }
33
34
 
34
- async function create(payload = {}, callOptions = {}) {
35
- return crudRepositoryCreate(runtime, knex, payload, options, callOptions);
35
+ async function listByForeignIds(ids = [], foreignKey = "", callOptions = {}, hooks = null) {
36
+ return crudRepositoryListByForeignIds(runtime, knex, ids, foreignKey, options, callOptions, hooks);
36
37
  }
37
38
 
38
- async function updateById(recordId, patch = {}, callOptions = {}) {
39
- return crudRepositoryUpdateById(runtime, knex, recordId, patch, options, callOptions);
39
+ async function create(payload = {}, callOptions = {}, hooks = null) {
40
+ return crudRepositoryCreate(runtime, knex, payload, options, callOptions, hooks);
40
41
  }
41
42
 
42
- async function deleteById(recordId, callOptions = {}) {
43
- return crudRepositoryDeleteById(runtime, knex, recordId, options, callOptions);
43
+ async function updateById(recordId, patch = {}, callOptions = {}, hooks = null) {
44
+ return crudRepositoryUpdateById(runtime, knex, recordId, patch, options, callOptions, hooks);
45
+ }
46
+
47
+ async function deleteById(recordId, callOptions = {}, hooks = null) {
48
+ return crudRepositoryDeleteById(runtime, knex, recordId, options, callOptions, hooks);
44
49
  }
45
50
 
46
51
  return Object.freeze({
47
52
  list: listRecords,
48
53
  findById,
49
54
  listByIds,
55
+ listByForeignIds,
50
56
  create,
51
57
  updateById,
52
58
  deleteById
@@ -0,0 +1,247 @@
1
+ import { normalizeText } from "@jskit-ai/kernel/shared/support/normalize";
2
+
3
+ function normalizeOwnerKey(value) {
4
+ if (value === null || value === undefined) {
5
+ return "";
6
+ }
7
+
8
+ if (typeof value === "string") {
9
+ return value.trim();
10
+ }
11
+ if (typeof value === "number") {
12
+ return Number.isFinite(value) ? String(value) : "";
13
+ }
14
+ if (typeof value === "bigint") {
15
+ return String(value);
16
+ }
17
+
18
+ return String(value).trim();
19
+ }
20
+
21
+ function toDefaultChildCallOptions(callOptions = {}) {
22
+ const nextOptions = {};
23
+ if (Object.hasOwn(callOptions, "trx")) {
24
+ nextOptions.trx = callOptions.trx;
25
+ }
26
+ if (Object.hasOwn(callOptions, "visibilityContext")) {
27
+ nextOptions.visibilityContext = callOptions.visibilityContext;
28
+ }
29
+
30
+ return nextOptions;
31
+ }
32
+
33
+ function resolveListChildrenHandler(options = {}, { context = "createHooksToCollectChildren" } = {}) {
34
+ if (typeof options.listChildren === "function") {
35
+ return options.listChildren;
36
+ }
37
+
38
+ const childRepository = options.childRepository;
39
+ const childListMethod = normalizeText(options.childListMethod) || "listByIds";
40
+ const childForeignKey = normalizeText(options.childForeignKey);
41
+ if (!childRepository || typeof childRepository !== "object" || Array.isArray(childRepository)) {
42
+ throw new TypeError(
43
+ `${context} requires listChildren(ids, options, ctx) or childRepository.`
44
+ );
45
+ }
46
+
47
+ const listChildren = childRepository[childListMethod];
48
+ if (typeof listChildren !== "function") {
49
+ throw new TypeError(`${context} requires childRepository.${childListMethod} to be a function.`);
50
+ }
51
+
52
+ if (childListMethod === "listByIds") {
53
+ if (!childForeignKey) {
54
+ throw new TypeError(`${context} requires childForeignKey when using childRepository.listByIds.`);
55
+ }
56
+
57
+ return (ids = [], childCallOptions = {}, hookContext = {}) =>
58
+ listChildren.call(childRepository, ids, {
59
+ ...childCallOptions,
60
+ valueKey: childForeignKey
61
+ }, hookContext);
62
+ }
63
+
64
+ if (childListMethod === "listByForeignIds") {
65
+ if (!childForeignKey) {
66
+ throw new TypeError(`${context} requires childForeignKey when using childRepository.listByForeignIds.`);
67
+ }
68
+
69
+ return (ids = [], childCallOptions = {}, hookContext = {}) =>
70
+ listChildren.call(childRepository, ids, childForeignKey, childCallOptions, hookContext);
71
+ }
72
+
73
+ return (ids = [], childCallOptions = {}, hookContext = {}) => {
74
+ if (childForeignKey) {
75
+ return listChildren.call(childRepository, ids, childForeignKey, childCallOptions, hookContext);
76
+ }
77
+ return listChildren.call(childRepository, ids, childCallOptions, hookContext);
78
+ };
79
+ }
80
+
81
+ function resolveGetChildOwnerId(options = {}, { context = "createHooksToCollectChildren" } = {}) {
82
+ if (typeof options.getChildOwnerId === "function") {
83
+ return options.getChildOwnerId;
84
+ }
85
+
86
+ const childOwnerIdKey = normalizeText(options.childOwnerIdKey);
87
+ const childForeignKey = normalizeText(options.childForeignKey);
88
+ const ownerKey = childOwnerIdKey || childForeignKey;
89
+ if (!ownerKey) {
90
+ throw new TypeError(`${context} requires childOwnerIdKey, childForeignKey, or getChildOwnerId.`);
91
+ }
92
+
93
+ return (child = {}) => {
94
+ if (!child || typeof child !== "object" || Array.isArray(child)) {
95
+ return undefined;
96
+ }
97
+ if (childOwnerIdKey && Object.hasOwn(child, childOwnerIdKey)) {
98
+ return child[childOwnerIdKey];
99
+ }
100
+ return child[ownerKey];
101
+ };
102
+ }
103
+
104
+ function resolveLookupContainerKey(record = {}, hookContext = {}, options = {}) {
105
+ const explicitContainerKey = normalizeText(options.lookupContainerKey);
106
+ if (explicitContainerKey) {
107
+ return explicitContainerKey;
108
+ }
109
+
110
+ const runtimeContainerKey = normalizeText(hookContext?.runtime?.lookup?.containerKey);
111
+ if (runtimeContainerKey) {
112
+ return runtimeContainerKey;
113
+ }
114
+
115
+ if (Object.hasOwn(record, "lookups")) {
116
+ return "lookups";
117
+ }
118
+
119
+ return "lookups";
120
+ }
121
+
122
+ function createHooksToCollectChildren(options = {}) {
123
+ const context = normalizeText(options.context) || "createHooksToCollectChildren";
124
+ const childKey = normalizeText(options.childKey);
125
+ if (!childKey) {
126
+ throw new TypeError(`${context} requires childKey.`);
127
+ }
128
+
129
+ const listChildren = resolveListChildrenHandler(options, {
130
+ context
131
+ });
132
+ const getParentId = typeof options.getParentId === "function"
133
+ ? options.getParentId
134
+ : (record = {}) => record?.id;
135
+ const getChildOwnerId = resolveGetChildOwnerId(options, {
136
+ context
137
+ });
138
+ const normalizeCollectionOwnerKey = typeof options.normalizeOwnerKey === "function"
139
+ ? options.normalizeOwnerKey
140
+ : normalizeOwnerKey;
141
+ const buildChildCallOptions = typeof options.buildChildCallOptions === "function"
142
+ ? options.buildChildCallOptions
143
+ : ({ callOptions = {} } = {}) => toDefaultChildCallOptions(callOptions);
144
+ const stateMapKey = options.stateMapKey || Symbol(`crud.children.${childKey}`);
145
+ const attachToLookupContainer = options.attachToLookupContainer !== false;
146
+ const attachChildren = typeof options.attachChildren === "function"
147
+ ? options.attachChildren
148
+ : (record = {}, children = [], hookContext = {}) => {
149
+ if (!attachToLookupContainer) {
150
+ return {
151
+ ...record,
152
+ [childKey]: children
153
+ };
154
+ }
155
+
156
+ const containerKey = resolveLookupContainerKey(record, hookContext, options);
157
+ const sourceContainer = record?.[containerKey];
158
+ const normalizedContainer =
159
+ sourceContainer && typeof sourceContainer === "object" && !Array.isArray(sourceContainer)
160
+ ? sourceContainer
161
+ : {};
162
+
163
+ return {
164
+ ...record,
165
+ [containerKey]: {
166
+ ...normalizedContainer,
167
+ [childKey]: children
168
+ }
169
+ };
170
+ };
171
+
172
+ return Object.freeze({
173
+ async afterQuery(records = [], ctx = {}) {
174
+ const normalizedRecords = Array.isArray(records) ? records : [];
175
+ const ownerIds = [];
176
+ const seenOwnerKeys = new Set();
177
+
178
+ for (const record of normalizedRecords) {
179
+ const ownerId = getParentId(record, ctx);
180
+ const ownerKey = normalizeCollectionOwnerKey(ownerId);
181
+ if (!ownerKey || seenOwnerKeys.has(ownerKey)) {
182
+ continue;
183
+ }
184
+
185
+ seenOwnerKeys.add(ownerKey);
186
+ ownerIds.push(ownerId);
187
+ }
188
+
189
+ const state = ctx?.state && typeof ctx.state === "object" ? ctx.state : null;
190
+ if (!state) {
191
+ throw new TypeError(`${context} requires ctx.state object.`);
192
+ }
193
+
194
+ if (ownerIds.length < 1) {
195
+ state[stateMapKey] = new Map();
196
+ return;
197
+ }
198
+
199
+ const childCallOptions = buildChildCallOptions({
200
+ callOptions: ctx.callOptions || {},
201
+ records: normalizedRecords,
202
+ ownerIds,
203
+ context: ctx
204
+ });
205
+ const children = await listChildren(ownerIds, childCallOptions, ctx);
206
+ if (!Array.isArray(children)) {
207
+ throw new TypeError(`${context} listChildren must return an array.`);
208
+ }
209
+
210
+ const childrenByOwnerKey = new Map();
211
+ for (const child of children) {
212
+ const childOwnerId = getChildOwnerId(child, ctx);
213
+ const childOwnerKey = normalizeCollectionOwnerKey(childOwnerId);
214
+ if (!childOwnerKey) {
215
+ continue;
216
+ }
217
+
218
+ const currentList = childrenByOwnerKey.get(childOwnerKey);
219
+ if (currentList) {
220
+ currentList.push(child);
221
+ continue;
222
+ }
223
+
224
+ childrenByOwnerKey.set(childOwnerKey, [child]);
225
+ }
226
+
227
+ state[stateMapKey] = childrenByOwnerKey;
228
+ },
229
+
230
+ transformReturnedRecord(record = {}, ctx = {}) {
231
+ if (!record || typeof record !== "object" || Array.isArray(record)) {
232
+ return record;
233
+ }
234
+
235
+ const state = ctx?.state && typeof ctx.state === "object" ? ctx.state : null;
236
+ const childrenByOwnerKey = state ? state[stateMapKey] : null;
237
+ const ownerKey = normalizeCollectionOwnerKey(getParentId(record, ctx));
238
+ const children = ownerKey && childrenByOwnerKey instanceof Map
239
+ ? (childrenByOwnerKey.get(ownerKey) || [])
240
+ : [];
241
+
242
+ return attachChildren(record, children, ctx);
243
+ }
244
+ });
245
+ }
246
+
247
+ export { createHooksToCollectChildren };
@@ -1,4 +1,5 @@
1
1
  import { normalizeOpaqueId, normalizeText, normalizeUniqueTextList } from "@jskit-ai/kernel/shared/support/normalize";
2
+ import { normalizeVisibilityContext } from "@jskit-ai/kernel/shared/support/visibility";
2
3
  import {
3
4
  normalizeCrudLookupNamespace,
4
5
  resolveCrudLookupApiPathFromNamespace,
@@ -10,6 +11,13 @@ import { normalizeCrudLookupApiPath } from "./lookupPathSupport.js";
10
11
  const DEFAULT_LOOKUP_INCLUDE = "*";
11
12
  const DEFAULT_LOOKUP_MAX_DEPTH = 3;
12
13
  const MAX_LOOKUP_MAX_DEPTH = 10;
14
+ const LOOKUP_PROVIDER_OWNERSHIP_FILTER_VALUES = Object.freeze([
15
+ "public",
16
+ "user",
17
+ "workspace",
18
+ "workspace_user"
19
+ ]);
20
+ const LOOKUP_PROVIDER_OWNERSHIP_FILTER_SET = new Set(LOOKUP_PROVIDER_OWNERSHIP_FILTER_VALUES);
13
21
 
14
22
  function normalizeLookupRelationEntry(entry = {}, outputKeys = new Set()) {
15
23
  if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
@@ -17,7 +25,7 @@ function normalizeLookupRelationEntry(entry = {}, outputKeys = new Set()) {
17
25
  }
18
26
 
19
27
  const key = normalizeText(entry.key);
20
- if (!key || (outputKeys instanceof Set && !outputKeys.has(key))) {
28
+ if (!key) {
21
29
  return null;
22
30
  }
23
31
 
@@ -27,7 +35,7 @@ function normalizeLookupRelationEntry(entry = {}, outputKeys = new Set()) {
27
35
  }
28
36
 
29
37
  const relationKind = normalizeText(relation.kind).toLowerCase();
30
- if (relationKind !== "lookup") {
38
+ if (relationKind !== "lookup" && relationKind !== "collection") {
31
39
  return null;
32
40
  }
33
41
 
@@ -40,15 +48,43 @@ function normalizeLookupRelationEntry(entry = {}, outputKeys = new Set()) {
40
48
  const explicitApiPath = normalizeCrudLookupApiPath(relation.apiPath);
41
49
  const apiPath = explicitApiPath || resolveCrudLookupApiPathFromNamespace(namespace);
42
50
 
43
- const valueKey = normalizeText(relation.valueKey) || "id";
51
+ if (relationKind === "lookup") {
52
+ if (outputKeys instanceof Set && !outputKeys.has(key)) {
53
+ return null;
54
+ }
55
+ const valueKey = normalizeText(relation.valueKey) || "id";
56
+
57
+ return {
58
+ key,
59
+ relation: {
60
+ kind: "lookup",
61
+ namespace,
62
+ apiPath,
63
+ valueKey,
64
+ hydrateOnList: relation.hydrateOnList !== false,
65
+ hydrateOnView: relation.hydrateOnView !== false
66
+ }
67
+ };
68
+ }
69
+
70
+ const foreignKey = normalizeText(relation.foreignKey);
71
+ if (!foreignKey) {
72
+ return null;
73
+ }
74
+
75
+ const parentValueKey = normalizeText(relation.parentValueKey) || "id";
76
+ if (outputKeys instanceof Set && !outputKeys.has(parentValueKey)) {
77
+ return null;
78
+ }
44
79
 
45
80
  return {
46
81
  key,
47
82
  relation: {
48
- kind: "lookup",
83
+ kind: "collection",
49
84
  namespace,
50
85
  apiPath,
51
- valueKey,
86
+ foreignKey,
87
+ parentValueKey,
52
88
  hydrateOnList: relation.hydrateOnList !== false,
53
89
  hydrateOnView: relation.hydrateOnView !== false
54
90
  }
@@ -125,9 +161,13 @@ function createCrudLookupRuntime(resource = {}, { outputKeys = [] } = {}) {
125
161
  const containerKey = resolveCrudLookupContainerKey(resource, {
126
162
  context: "crud lookup runtime container key"
127
163
  });
164
+ const namespace =
165
+ normalizeCrudLookupNamespace(resource?.resource) ||
166
+ normalizeCrudLookupNamespace(resource?.apiPath);
128
167
  const defaults = resolveLookupRuntimeDefaults(resource);
129
168
 
130
169
  return {
170
+ namespace,
131
171
  containerKey,
132
172
  defaultInclude: defaults.defaultInclude,
133
173
  maxDepth: defaults.maxDepth,
@@ -216,7 +256,8 @@ function buildLookupHydrationPlan(
216
256
  {
217
257
  mode = "list",
218
258
  context = "crudRepository",
219
- includeWasExplicit = false
259
+ includeWasExplicit = false,
260
+ skippedNamespaces = new Set()
220
261
  } = {}
221
262
  ) {
222
263
  const entries = Array.isArray(runtime?.entries) ? runtime.entries : [];
@@ -246,6 +287,9 @@ function buildLookupHydrationPlan(
246
287
  if (!entry) {
247
288
  return null;
248
289
  }
290
+ if (skippedNamespaces instanceof Set && skippedNamespaces.has(entry?.relation?.namespace)) {
291
+ return null;
292
+ }
249
293
 
250
294
  if (!includeWasExplicit && !shouldHydrateByMode(entry)) {
251
295
  return null;
@@ -353,7 +397,10 @@ function resolveLookupDepthRuntime(runtime = {}, repositoryOptions = {}, callOpt
353
397
  }
354
398
 
355
399
  function buildLookupGroupKey(relation = {}) {
356
- return `${relation.namespace}::${relation.valueKey}`;
400
+ if (relation?.kind === "collection") {
401
+ return `collection::${relation.namespace}::${relation.foreignKey}::${relation.parentValueKey}`;
402
+ }
403
+ return `lookup::${relation.namespace}::${relation.valueKey}`;
357
404
  }
358
405
 
359
406
  function normalizeLookupRelationValues(records = [], entries = [], childIncludeByKey = {}) {
@@ -379,7 +426,10 @@ function normalizeLookupRelationValues(records = [], entries = [], childIncludeB
379
426
  const seen = new Set();
380
427
  for (const record of records) {
381
428
  for (const entry of group.entries) {
382
- const rawValue = record?.[entry.key];
429
+ const relation = entry?.relation || {};
430
+ const rawValue = relation.kind === "collection"
431
+ ? record?.[relation.parentValueKey]
432
+ : record?.[entry.key];
383
433
  const normalized = normalizeLookupIdentifier(rawValue);
384
434
  if (!normalized || seen.has(normalized)) {
385
435
  continue;
@@ -437,6 +487,54 @@ function normalizeLookupProvider(provider, relation = {}, { context = "crudRepos
437
487
  return provider;
438
488
  }
439
489
 
490
+ function resolveLookupProviderOwnershipFilter(provider = {}, { context = "crudRepository" } = {}) {
491
+ const normalizedOwnershipFilter = normalizeText(provider?.ownershipFilter).toLowerCase();
492
+ if (!normalizedOwnershipFilter) {
493
+ return "";
494
+ }
495
+
496
+ if (LOOKUP_PROVIDER_OWNERSHIP_FILTER_SET.has(normalizedOwnershipFilter)) {
497
+ return normalizedOwnershipFilter;
498
+ }
499
+
500
+ throw new TypeError(
501
+ `${context} lookup provider ownershipFilter must be one of: ${LOOKUP_PROVIDER_OWNERSHIP_FILTER_VALUES.join(", ")}.`
502
+ );
503
+ }
504
+
505
+ function resolveLookupVisibilityContext(
506
+ provider = {},
507
+ relation = {},
508
+ callOptions = {},
509
+ { context = "crudRepository" } = {}
510
+ ) {
511
+ const parentVisibilityContext = normalizeVisibilityContext(callOptions?.visibilityContext);
512
+ const providerOwnershipFilter = resolveLookupProviderOwnershipFilter(provider, {
513
+ context
514
+ });
515
+
516
+ if (!providerOwnershipFilter) {
517
+ if (parentVisibilityContext.visibility !== "public") {
518
+ throw new Error(
519
+ `${context} lookup provider for namespace "${relation.namespace}" must declare ownershipFilter when parent visibility is "${parentVisibilityContext.visibility}".`
520
+ );
521
+ }
522
+ return callOptions?.visibilityContext;
523
+ }
524
+
525
+ const nextVisibilityContext = {
526
+ visibility: providerOwnershipFilter
527
+ };
528
+ if (providerOwnershipFilter === "workspace" || providerOwnershipFilter === "workspace_user") {
529
+ nextVisibilityContext.scopeOwnerId = parentVisibilityContext.scopeOwnerId;
530
+ }
531
+ if (providerOwnershipFilter === "user" || providerOwnershipFilter === "workspace_user") {
532
+ nextVisibilityContext.userOwnerId = parentVisibilityContext.userOwnerId;
533
+ }
534
+
535
+ return nextVisibilityContext;
536
+ }
537
+
440
538
  function buildLookupRecordMap(records = [], valueKey = "") {
441
539
  const lookupMap = new Map();
442
540
  for (const record of Array.isArray(records) ? records : []) {
@@ -449,6 +547,47 @@ function buildLookupRecordMap(records = [], valueKey = "") {
449
547
  return lookupMap;
450
548
  }
451
549
 
550
+ function buildLookupCollectionMap(records = [], foreignKey = "") {
551
+ const collectionMap = new Map();
552
+ for (const record of Array.isArray(records) ? records : []) {
553
+ const ownerId = normalizeLookupIdentifier(record?.[foreignKey]);
554
+ if (!ownerId) {
555
+ continue;
556
+ }
557
+ const currentRecords = collectionMap.get(ownerId);
558
+ if (currentRecords) {
559
+ currentRecords.push(record);
560
+ continue;
561
+ }
562
+ collectionMap.set(ownerId, [record]);
563
+ }
564
+ return collectionMap;
565
+ }
566
+
567
+ function resolveLookupVisitedNamespaces(runtime = {}, callOptions = {}) {
568
+ const namespaces = [];
569
+ const seen = new Set();
570
+
571
+ function appendNamespace(value) {
572
+ const normalized = normalizeCrudLookupNamespace(value);
573
+ if (!normalized || seen.has(normalized)) {
574
+ return;
575
+ }
576
+ seen.add(normalized);
577
+ namespaces.push(normalized);
578
+ }
579
+
580
+ const sourceVisitedNamespaces = Array.isArray(callOptions?.lookupVisitedNamespaces)
581
+ ? callOptions.lookupVisitedNamespaces
582
+ : [];
583
+ for (const namespace of sourceVisitedNamespaces) {
584
+ appendNamespace(namespace);
585
+ }
586
+ appendNamespace(runtime?.namespace);
587
+
588
+ return namespaces;
589
+ }
590
+
452
591
  async function hydrateCrudLookupRecords(
453
592
  records = [],
454
593
  runtime = {},
@@ -472,11 +611,13 @@ async function hydrateCrudLookupRecords(
472
611
  const lookupContainerKey = normalizeCrudLookupContainerKey(runtime?.containerKey, {
473
612
  context: `${runtime?.context || "crudRepository"} lookup runtime container key`
474
613
  });
614
+ const visitedNamespaces = resolveLookupVisitedNamespaces(runtime, callOptions);
475
615
 
476
616
  const lookupPlan = buildLookupHydrationPlan(runtime, include, {
477
617
  mode,
478
618
  context: runtime?.context || "crudRepository",
479
- includeWasExplicit: normalizeText(include).length > 0
619
+ includeWasExplicit: normalizeText(include).length > 0,
620
+ skippedNamespaces: new Set(visitedNamespaces)
480
621
  });
481
622
  const selectedEntries = lookupPlan.entries;
482
623
  if (selectedEntries.length < 1) {
@@ -498,17 +639,32 @@ async function hydrateCrudLookupRecords(
498
639
  const provider = normalizeLookupProvider(resolveLookupProvider(group.relation), group.relation, {
499
640
  context: runtime?.context || "crudRepository"
500
641
  });
642
+ const childVisibilityContext = resolveLookupVisibilityContext(
643
+ provider,
644
+ group.relation,
645
+ callOptions,
646
+ {
647
+ context: runtime?.context || "crudRepository"
648
+ }
649
+ );
501
650
  const childInclude = resolveGroupChildInclude(group);
651
+ const groupValueKey = group?.relation?.kind === "collection"
652
+ ? group.relation.foreignKey
653
+ : group.relation.valueKey;
502
654
  const groupRecords = await provider.listByIds(group.values, {
503
655
  ...callOptions,
656
+ visibilityContext: childVisibilityContext,
504
657
  include: childInclude,
505
658
  lookupDepth: depthRuntime.depth + 1,
506
659
  lookupMaxDepth: depthRuntime.maxDepth,
507
- valueKey: group.relation.valueKey
660
+ lookupVisitedNamespaces: visitedNamespaces,
661
+ valueKey: groupValueKey
508
662
  });
509
663
  groupRecordMaps.set(
510
664
  buildLookupGroupKey(group.relation),
511
- buildLookupRecordMap(groupRecords, group.relation.valueKey)
665
+ group?.relation?.kind === "collection"
666
+ ? buildLookupCollectionMap(groupRecords, group.relation.foreignKey)
667
+ : buildLookupRecordMap(groupRecords, group.relation.valueKey)
512
668
  );
513
669
  }
514
670
 
@@ -525,6 +681,11 @@ async function hydrateCrudLookupRecords(
525
681
  for (const entry of selectedEntries) {
526
682
  const relation = entry.relation;
527
683
  const lookupMap = groupRecordMaps.get(buildLookupGroupKey(relation)) || new Map();
684
+ if (relation.kind === "collection") {
685
+ const ownerId = normalizeLookupIdentifier(baseRecord?.[relation.parentValueKey]);
686
+ nextLookups[entry.key] = ownerId ? (lookupMap.get(ownerId) || []) : [];
687
+ continue;
688
+ }
528
689
  const lookupId = normalizeLookupIdentifier(baseRecord?.[entry.key]);
529
690
  if (!lookupId) {
530
691
  nextLookups[entry.key] = null;