@jskit-ai/crud-core 0.1.30 → 0.1.32
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.descriptor.mjs +2 -2
- package/package.json +8 -6
- package/src/server/createCrudRepositoryFromResource.js +18 -12
- package/src/server/createCrudServiceFromResource.js +14 -60
- package/src/server/createHooksToCollectChildren.js +247 -0
- package/src/server/listQueryValidators.js +57 -9
- package/src/server/lookupHydration.js +172 -11
- package/src/server/lookupProviders.js +33 -1
- package/src/server/repositoryMethods.js +1136 -62
- package/src/server/repositorySupport.js +30 -4
- package/src/server/serviceMethods.js +140 -0
- package/test/createCrudRepositoryFromResource.test.js +1447 -121
- package/test/createCrudServiceFromResource.test.js +92 -0
- package/test/createHooksToCollectChildren.test.js +251 -0
- package/test/listQueryValidators.test.js +60 -0
- package/test/lookupProviders.test.js +32 -0
- package/test/repositorySupport.test.js +59 -0
- package/test/serviceMethods.test.js +161 -0
|
@@ -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
|
|
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
|
-
|
|
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: "
|
|
83
|
+
kind: "collection",
|
|
49
84
|
namespace,
|
|
50
85
|
apiPath,
|
|
51
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
660
|
+
lookupVisitedNamespaces: visitedNamespaces,
|
|
661
|
+
valueKey: groupValueKey
|
|
508
662
|
});
|
|
509
663
|
groupRecordMaps.set(
|
|
510
664
|
buildLookupGroupKey(group.relation),
|
|
511
|
-
|
|
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;
|
|
@@ -1,8 +1,32 @@
|
|
|
1
|
+
import { normalizeText } from "@jskit-ai/kernel/shared/support/normalize";
|
|
1
2
|
import {
|
|
2
3
|
resolveCrudLookupProviderToken,
|
|
3
4
|
resolveCrudLookupNamespaceFromRelation
|
|
4
5
|
} from "./lookupPathSupport.js";
|
|
5
6
|
|
|
7
|
+
const LOOKUP_PROVIDER_OWNERSHIP_FILTER_VALUES = Object.freeze([
|
|
8
|
+
"public",
|
|
9
|
+
"user",
|
|
10
|
+
"workspace",
|
|
11
|
+
"workspace_user"
|
|
12
|
+
]);
|
|
13
|
+
const LOOKUP_PROVIDER_OWNERSHIP_FILTER_SET = new Set(LOOKUP_PROVIDER_OWNERSHIP_FILTER_VALUES);
|
|
14
|
+
|
|
15
|
+
function normalizeLookupProviderOwnershipFilter(value, { context = "crudLookupProvider ownershipFilter" } = {}) {
|
|
16
|
+
const normalized = normalizeText(value).toLowerCase();
|
|
17
|
+
if (!normalized) {
|
|
18
|
+
return "";
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
if (LOOKUP_PROVIDER_OWNERSHIP_FILTER_SET.has(normalized)) {
|
|
22
|
+
return normalized;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
throw new TypeError(
|
|
26
|
+
`${context} must be one of: ${LOOKUP_PROVIDER_OWNERSHIP_FILTER_VALUES.join(", ")}.`
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
|
|
6
30
|
function createCrudLookupProviderResolver(scope, { context = "crudLookupProvider" } = {}) {
|
|
7
31
|
if (!scope || typeof scope.make !== "function") {
|
|
8
32
|
throw new Error(`${context} requires scope.make().`);
|
|
@@ -20,12 +44,20 @@ function createCrudLookupProviderResolver(scope, { context = "crudLookupProvider
|
|
|
20
44
|
};
|
|
21
45
|
}
|
|
22
46
|
|
|
23
|
-
function createCrudLookupProvider(repository, { context = "crudLookupProvider" } = {}) {
|
|
47
|
+
function createCrudLookupProvider(repository, { context = "crudLookupProvider", ownershipFilter = "" } = {}) {
|
|
24
48
|
if (!repository || typeof repository.listByIds !== "function") {
|
|
25
49
|
throw new Error(`${context} requires repository.listByIds(ids, options).`);
|
|
26
50
|
}
|
|
27
51
|
|
|
52
|
+
const normalizedOwnershipFilter = normalizeLookupProviderOwnershipFilter(
|
|
53
|
+
ownershipFilter || repository?.ownershipFilter,
|
|
54
|
+
{
|
|
55
|
+
context: `${context} ownershipFilter`
|
|
56
|
+
}
|
|
57
|
+
);
|
|
58
|
+
|
|
28
59
|
return Object.freeze({
|
|
60
|
+
ownershipFilter: normalizedOwnershipFilter || null,
|
|
29
61
|
async listByIds(ids = [], options = {}) {
|
|
30
62
|
const include = options?.include === undefined ? "none" : options.include;
|
|
31
63
|
return repository.listByIds(ids, {
|