jinaga-server 3.5.4 → 3.6.0
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/.github/workflows/publish.yml +5 -2
- package/.github/workflows/upgrade-jinaga.yml +51 -0
- package/CLAUDE.md +77 -0
- package/dist/authorization/authorization-keystore.d.ts +27 -1
- package/dist/authorization/authorization-keystore.js +52 -4
- package/dist/authorization/authorization-keystore.js.map +1 -1
- package/dist/http/router.d.ts +4 -1
- package/dist/http/router.js +157 -14
- package/dist/http/router.js.map +1 -1
- package/dist/jinaga-server.js +18 -1
- package/dist/jinaga-server.js.map +1 -1
- package/dist/memory/memory-keystore.js +1 -1
- package/dist/memory/memory-keystore.js.map +1 -1
- package/dist/postgres/query-description.d.ts +1 -0
- package/dist/postgres/query-description.js +34 -3
- package/dist/postgres/query-description.js.map +1 -1
- package/dist/postgres/specification-sql.js +7 -1
- package/dist/postgres/specification-sql.js.map +1 -1
- package/docs/analysis/feed-builder-nested-existential-bug.md +191 -0
- package/integration-test/jinaga-test/distribution-multi-given.test.js +184 -0
- package/integration-test/jinaga-test/distribution-negating-feed.test.js +202 -0
- package/integration-test/jinaga-test/feed-existential-input.test.js +241 -0
- package/integration-test/jinaga-test/feeds-endpoint.test.js +140 -0
- package/integration-test/jinaga-test/package-lock.json +4 -4
- package/integration-test/jinaga-test/package.json +3 -2
- package/integration-test/jinaga-test/subscription-anchor-arrival.test.js +129 -0
- package/integration-test/jinaga-test/subscription-helpers.js +60 -0
- package/integration-test/jinaga-test/subscription-late-auth.test.js +245 -0
- package/integration-test/jinaga-test/subscription-no-rule.test.js +137 -0
- package/package.json +2 -2
- package/src/authorization/authorization-keystore.ts +87 -5
- package/src/http/router.ts +174 -21
- package/src/jinaga-server.ts +36 -3
- package/src/memory/memory-keystore.ts +1 -1
- package/src/postgres/query-description.ts +66 -11
- package/src/postgres/specification-sql.ts +7 -1
- package/test/authorization/subscriptionIntersectionSpec.ts +208 -0
- package/test/http/anchorListenerSpec.ts +117 -0
- package/test/http/lateAuthRecoverySpec.ts +245 -0
- package/test/http/streamFeedAnchorSpec.ts +169 -0
package/src/http/router.ts
CHANGED
|
@@ -32,6 +32,7 @@ import {
|
|
|
32
32
|
UserIdentity,
|
|
33
33
|
verifyEnvelopes
|
|
34
34
|
} from "jinaga";
|
|
35
|
+
import { DistributionIntersectionBranch, FeedResult, SubscriptionAuthorizer } from "../authorization/authorization-keystore";
|
|
35
36
|
import { CsvMetadata } from "./csv-metadata";
|
|
36
37
|
import { validateSpecificationForCsv } from "./csv-validator";
|
|
37
38
|
import { createLineReader } from "./line-reader";
|
|
@@ -286,6 +287,10 @@ function postReadWithStreaming(
|
|
|
286
287
|
};
|
|
287
288
|
}
|
|
288
289
|
|
|
290
|
+
function subscriptionOwnerKey(userIdentity: UserIdentity | null): string | null {
|
|
291
|
+
return userIdentity ? `${userIdentity.provider}|${userIdentity.id}` : null;
|
|
292
|
+
}
|
|
293
|
+
|
|
289
294
|
function serializeUserIdentity(user: RequestUser | null): UserIdentity | null {
|
|
290
295
|
if (!user) {
|
|
291
296
|
return null;
|
|
@@ -366,10 +371,19 @@ export interface RequestUser {
|
|
|
366
371
|
|
|
367
372
|
export class HttpRouter {
|
|
368
373
|
handler: Handler;
|
|
374
|
+
// Map from feed hash → owning user key, for feeds the server cleared
|
|
375
|
+
// via intersection. The cached spec self-filters by the lifted auth
|
|
376
|
+
// condition (the requesting user's fact is baked into start), so it
|
|
377
|
+
// is safe to skip the per-query distribution check — but ONLY for
|
|
378
|
+
// the same user who originally requested the subscription. Another
|
|
379
|
+
// authenticated user who somehow obtained the hash must go through
|
|
380
|
+
// the normal authorization.feed path, which will deny their request
|
|
381
|
+
// since the intersected spec is not authorizable in its own right.
|
|
382
|
+
private intersectedFeedOwners = new Map<string, string | null>();
|
|
369
383
|
|
|
370
384
|
constructor(
|
|
371
385
|
private factManager: FactManager,
|
|
372
|
-
private authorization: Authorization,
|
|
386
|
+
private authorization: Authorization & SubscriptionAuthorizer,
|
|
373
387
|
private feedCache: FeedCache,
|
|
374
388
|
private allowedOrigin: string | string[] | ((origin: string, callback: (err: Error | null, allow?: boolean) => void) => void)
|
|
375
389
|
) {
|
|
@@ -555,18 +569,70 @@ export class HttpRouter {
|
|
|
555
569
|
if (failures.length > 0) {
|
|
556
570
|
throw new Invalid(failures.join("\n"));
|
|
557
571
|
}
|
|
558
|
-
|
|
572
|
+
|
|
559
573
|
const namedStart = specification.given.reduce((map, g, index) => ({
|
|
560
574
|
...map,
|
|
561
575
|
[g.label.name]: start[index]
|
|
562
576
|
}), {} as ReferencesByName);
|
|
563
577
|
|
|
564
|
-
//
|
|
565
|
-
|
|
578
|
+
// Resolve distribution. When the user is not authorized for the
|
|
579
|
+
// original spec but the distribution rules can rewrite it via
|
|
580
|
+
// intersection (jinaga.js#130), accept the subscription with
|
|
581
|
+
// empty results and let the inverse engine activate it later.
|
|
582
|
+
//
|
|
583
|
+
// Even when no rule applies at all, accept the subscription:
|
|
584
|
+
// the per-query distribution check inside streamFeed /
|
|
585
|
+
// feed returns "denied" and keeps the stream alive so the
|
|
586
|
+
// client can be notified the moment the authorizing fact
|
|
587
|
+
// arrives. Failing /feeds here would force the client to
|
|
588
|
+
// re-subscribe on a poll loop.
|
|
566
589
|
const userIdentity = serializeUserIdentity(user);
|
|
567
|
-
await this.authorization.
|
|
590
|
+
const intersectResult = await this.authorization.verifyDistributionOrIntersect(userIdentity, specification, namedStart);
|
|
591
|
+
let branches: DistributionIntersectionBranch[];
|
|
592
|
+
let intersected: boolean;
|
|
593
|
+
if (intersectResult.type === "denied") {
|
|
594
|
+
Trace.warn(`/feeds accepting spec without applicable distribution rule: ${intersectResult.reason}`);
|
|
595
|
+
branches = [{ start, specification }];
|
|
596
|
+
intersected = false;
|
|
597
|
+
} else {
|
|
598
|
+
branches = intersectResult.branches;
|
|
599
|
+
intersected = branches.length !== 1 || branches[0].specification !== specification;
|
|
600
|
+
}
|
|
568
601
|
|
|
569
|
-
const
|
|
602
|
+
const ownerKey = subscriptionOwnerKey(userIdentity);
|
|
603
|
+
const feedHashes: string[] = [];
|
|
604
|
+
for (const branch of branches) {
|
|
605
|
+
const branchNamedStart = branch.specification.given.reduce((map, g, index) => ({
|
|
606
|
+
...map,
|
|
607
|
+
[g.label.name]: branch.start[index]
|
|
608
|
+
}), {} as ReferencesByName);
|
|
609
|
+
const branchFeeds = buildFeeds(branch.specification);
|
|
610
|
+
const branchHashes = this.feedCache.addFeeds(branchFeeds, branchNamedStart);
|
|
611
|
+
feedHashes.push(...branchHashes);
|
|
612
|
+
if (intersected) {
|
|
613
|
+
for (const hash of branchHashes) {
|
|
614
|
+
// Bind the hash to its owner so a different
|
|
615
|
+
// authenticated user who obtains it cannot reuse
|
|
616
|
+
// the cached spec — which carries the original
|
|
617
|
+
// owner's user fact in its start — to fetch
|
|
618
|
+
// facts authorized for that owner.
|
|
619
|
+
//
|
|
620
|
+
// The map allows only one owner per hash, which
|
|
621
|
+
// relies on the invariant that intersected specs
|
|
622
|
+
// bind the requesting user's fact into start —
|
|
623
|
+
// making the hash user-specific. If two distinct
|
|
624
|
+
// owners ever collide on the same hash, that
|
|
625
|
+
// invariant has broken in intersectForSubscribe;
|
|
626
|
+
// warn so we notice instead of silently
|
|
627
|
+
// overwriting.
|
|
628
|
+
const existing = this.intersectedFeedOwners.get(hash);
|
|
629
|
+
if (existing !== undefined && existing !== ownerKey) {
|
|
630
|
+
Trace.warn(`Intersected feed hash ${hash} re-bound from owner ${existing} to ${ownerKey}; intersection invariant may be broken`);
|
|
631
|
+
}
|
|
632
|
+
this.intersectedFeedOwners.set(hash, ownerKey);
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
}
|
|
570
636
|
|
|
571
637
|
return {
|
|
572
638
|
feeds: feedHashes
|
|
@@ -574,6 +640,26 @@ export class HttpRouter {
|
|
|
574
640
|
});
|
|
575
641
|
}
|
|
576
642
|
|
|
643
|
+
private async queryFeed(
|
|
644
|
+
feedHash: string,
|
|
645
|
+
userIdentity: UserIdentity | null,
|
|
646
|
+
specification: Specification,
|
|
647
|
+
start: FactReference[],
|
|
648
|
+
bookmark: string
|
|
649
|
+
): Promise<FeedResult> {
|
|
650
|
+
const cachedOwner = this.intersectedFeedOwners.get(feedHash);
|
|
651
|
+
const requesterOwner = subscriptionOwnerKey(userIdentity);
|
|
652
|
+
if (cachedOwner !== undefined && cachedOwner === requesterOwner) {
|
|
653
|
+
const feed = await this.authorization.feedPreVerified(userIdentity, specification, start, bookmark);
|
|
654
|
+
return { type: "success", feed };
|
|
655
|
+
}
|
|
656
|
+
// For everyone else (different user, anonymous mismatch, or a
|
|
657
|
+
// non-intersected hash) go through the normal distribution-checked
|
|
658
|
+
// path. A "denied" result means the streaming / polling caller
|
|
659
|
+
// serves an empty page and keeps the subscription alive.
|
|
660
|
+
return await this.authorization.feedWithDistribution(userIdentity, specification, start, bookmark);
|
|
661
|
+
}
|
|
662
|
+
|
|
577
663
|
private feed(user: RequestUser | null, params: { [key: string]: string }, query: qs.ParsedQs): Promise<FeedResponse | null> {
|
|
578
664
|
return Trace.dependency("feed", params["hash"], async () => {
|
|
579
665
|
const feedHash = params["hash"];
|
|
@@ -590,14 +676,21 @@ export class HttpRouter {
|
|
|
590
676
|
|
|
591
677
|
const userIdentity = serializeUserIdentity(user);
|
|
592
678
|
const start = feedDefinition.feed.given.map(g => feedDefinition.namedStart[g.label.name]);
|
|
593
|
-
const
|
|
679
|
+
const result = await this.queryFeed(feedHash, userIdentity, feedDefinition.feed, start, bookmark);
|
|
680
|
+
if (result.type === "denied") {
|
|
681
|
+
// The subscription has been accepted; treat poll-time
|
|
682
|
+
// distribution failures as an empty page so the client
|
|
683
|
+
// can keep polling and pick up results when an
|
|
684
|
+
// authorizing fact arrives.
|
|
685
|
+
return { references: [], bookmark } as FeedResponse;
|
|
686
|
+
}
|
|
594
687
|
// Return distinct fact references from all the tuples.
|
|
595
|
-
const references =
|
|
688
|
+
const references = result.feed.tuples.flatMap(t => t.facts).filter((value, index, self) =>
|
|
596
689
|
self.findIndex(f => f.hash === value.hash && f.type === value.type) === index
|
|
597
690
|
);
|
|
598
691
|
const response: FeedResponse = {
|
|
599
692
|
references,
|
|
600
|
-
bookmark:
|
|
693
|
+
bookmark: result.feed.bookmark
|
|
601
694
|
};
|
|
602
695
|
return response;
|
|
603
696
|
});
|
|
@@ -636,7 +729,7 @@ export class HttpRouter {
|
|
|
636
729
|
// Continuous initial query until exhausted
|
|
637
730
|
console.log(`[StreamFeed:${connectionId}] Starting initial data streaming...`);
|
|
638
731
|
const initialStart = Date.now();
|
|
639
|
-
bookmark = await this.streamAllInitialResults(userIdentity, feedDefinition, start, bookmark, stream);
|
|
732
|
+
bookmark = await this.streamAllInitialResults(feedHash, userIdentity, feedDefinition, start, bookmark, stream);
|
|
640
733
|
const initialDuration = Date.now() - initialStart;
|
|
641
734
|
console.log(`[StreamFeed:${connectionId}] Initial data streaming complete - Duration: ${initialDuration}ms, Final bookmark: ${bookmark || 'empty'}`);
|
|
642
735
|
|
|
@@ -664,7 +757,7 @@ export class HttpRouter {
|
|
|
664
757
|
if (matchingResults.length != 0) {
|
|
665
758
|
console.log(`[StreamFeed:${connectionId}] Processing matching results...`);
|
|
666
759
|
const responseStart = Date.now();
|
|
667
|
-
bookmark = await this.streamFeedResponse(userIdentity, feedDefinition, start, bookmark, stream, true);
|
|
760
|
+
bookmark = await this.streamFeedResponse(feedHash, userIdentity, feedDefinition, start, bookmark, stream, true);
|
|
668
761
|
const responseDuration = Date.now() - responseStart;
|
|
669
762
|
console.log(`[StreamFeed:${connectionId}] Feed response sent - Duration: ${responseDuration}ms, New bookmark: ${bookmark || 'empty'}`);
|
|
670
763
|
} else {
|
|
@@ -683,11 +776,45 @@ export class HttpRouter {
|
|
|
683
776
|
});
|
|
684
777
|
|
|
685
778
|
console.log(`[StreamFeed:${connectionId}] All listeners registered - Count: ${listeners.length}`);
|
|
686
|
-
|
|
779
|
+
|
|
780
|
+
// Anchor listeners (jinaga.js#129): the inverse-listener path
|
|
781
|
+
// above only fires when a descendant of the given arrives. If
|
|
782
|
+
// the given fact itself isn't yet in the store when the client
|
|
783
|
+
// subscribes, neither the initial query nor any later inverse
|
|
784
|
+
// can deliver results until the given is recorded — and the
|
|
785
|
+
// given fact has no descendants whose save would re-trigger the
|
|
786
|
+
// inverse with the matching givenHash. Register a listener per
|
|
787
|
+
// unique anchor that re-runs streamFeedResponse the moment a
|
|
788
|
+
// saved fact matches the anchor's (type, hash).
|
|
789
|
+
const uniqueAnchors = start.filter((s, i, arr) =>
|
|
790
|
+
arr.findIndex(other => other.type === s.type && other.hash === s.hash) === i);
|
|
791
|
+
console.log(`[StreamFeed:${connectionId}] Registering ${uniqueAnchors.length} anchor listener(s)`);
|
|
792
|
+
const anchorListeners = uniqueAnchors.map(anchor => {
|
|
793
|
+
const anchorSpec: Specification = {
|
|
794
|
+
given: [{ label: { name: "x", type: anchor.type }, conditions: [] }],
|
|
795
|
+
matches: [],
|
|
796
|
+
projection: { type: "fact", label: "x" }
|
|
797
|
+
};
|
|
798
|
+
return this.factManager.addSpecificationListener(anchorSpec, async (results) => {
|
|
799
|
+
try {
|
|
800
|
+
const matched = results.some(pr => {
|
|
801
|
+
const ref = pr.tuple && (pr.tuple as any).x;
|
|
802
|
+
return ref && ref.type === anchor.type && ref.hash === anchor.hash;
|
|
803
|
+
});
|
|
804
|
+
if (matched) {
|
|
805
|
+
console.log(`[StreamFeed:${connectionId}] Anchor fact arrived - Type: ${anchor.type}, Hash: ${anchor.hash.substring(0, 8)}...`);
|
|
806
|
+
bookmark = await this.streamFeedResponse(feedHash, userIdentity, feedDefinition, start, bookmark, stream, true);
|
|
807
|
+
}
|
|
808
|
+
} catch (error) {
|
|
809
|
+
console.error(`[StreamFeed:${connectionId}] ERROR in anchor listener: ${error}`);
|
|
810
|
+
}
|
|
811
|
+
});
|
|
812
|
+
});
|
|
813
|
+
|
|
687
814
|
stream.done(() => {
|
|
688
815
|
const cleanupStart = Date.now();
|
|
689
|
-
console.log(`[StreamFeed:${connectionId}] CLEANUP STARTED - Removing ${listeners.length}
|
|
690
|
-
|
|
816
|
+
console.log(`[StreamFeed:${connectionId}] CLEANUP STARTED - Removing ${listeners.length + anchorListeners.length} listener(s)`);
|
|
817
|
+
|
|
691
818
|
for (let i = 0; i < listeners.length; i++) {
|
|
692
819
|
try {
|
|
693
820
|
this.factManager.removeSpecificationListener(listeners[i]);
|
|
@@ -696,7 +823,14 @@ export class HttpRouter {
|
|
|
696
823
|
console.error(`[StreamFeed:${connectionId}] ERROR removing listener ${i + 1}: ${error}`);
|
|
697
824
|
}
|
|
698
825
|
}
|
|
699
|
-
|
|
826
|
+
for (let i = 0; i < anchorListeners.length; i++) {
|
|
827
|
+
try {
|
|
828
|
+
this.factManager.removeSpecificationListener(anchorListeners[i]);
|
|
829
|
+
} catch (error) {
|
|
830
|
+
console.error(`[StreamFeed:${connectionId}] ERROR removing anchor listener ${i + 1}: ${error}`);
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
|
|
700
834
|
const cleanupDuration = Date.now() - cleanupStart;
|
|
701
835
|
const totalDuration = Date.now() - startTime;
|
|
702
836
|
console.log(`[StreamFeed:${connectionId}] CLEANUP COMPLETE - Cleanup duration: ${cleanupDuration}ms, Total connection duration: ${totalDuration}ms`);
|
|
@@ -714,6 +848,7 @@ export class HttpRouter {
|
|
|
714
848
|
}
|
|
715
849
|
|
|
716
850
|
private async streamAllInitialResults(
|
|
851
|
+
feedHash: string,
|
|
717
852
|
userIdentity: UserIdentity | null,
|
|
718
853
|
feedDefinition: FeedObject,
|
|
719
854
|
start: FactReference[],
|
|
@@ -733,10 +868,20 @@ export class HttpRouter {
|
|
|
733
868
|
while (hasMoreResults && pageCount < maxPages) {
|
|
734
869
|
const pageStart = Date.now();
|
|
735
870
|
console.log(`[InitialResults:${streamId}] Fetching page ${pageCount + 1} - Bookmark: ${bookmark || 'empty'}`);
|
|
736
|
-
|
|
737
|
-
const
|
|
871
|
+
|
|
872
|
+
const queryResult = await this.queryFeed(feedHash, userIdentity, feedDefinition.feed, start, bookmark);
|
|
873
|
+
if (queryResult.type === "denied") {
|
|
874
|
+
// The subscription has already been accepted; tearing
|
|
875
|
+
// down the stream on a distribution failure would force
|
|
876
|
+
// the client to re-subscribe just to keep waiting.
|
|
877
|
+
// Treat as empty and let the listener path re-evaluate
|
|
878
|
+
// when an authorizing fact arrives.
|
|
879
|
+
console.log(`[InitialResults:${streamId}] Distribution failed (${queryResult.reason}) - serving empty and keeping stream live`);
|
|
880
|
+
return bookmark;
|
|
881
|
+
}
|
|
882
|
+
const results = queryResult.feed;
|
|
738
883
|
const fetchDuration = Date.now() - pageStart;
|
|
739
|
-
|
|
884
|
+
|
|
740
885
|
console.log(`[InitialResults:${streamId}] Page ${pageCount + 1} fetched - Tuples: ${results.tuples.length}, Duration: ${fetchDuration}ms`);
|
|
741
886
|
|
|
742
887
|
// Check if we got results
|
|
@@ -803,16 +948,24 @@ export class HttpRouter {
|
|
|
803
948
|
return bookmark;
|
|
804
949
|
}
|
|
805
950
|
|
|
806
|
-
private async streamFeedResponse(userIdentity: UserIdentity | null, feedDefinition: FeedObject, start: FactReference[], bookmark: string, stream: Stream<FeedResponse>, skipIfEmpty = false): Promise<string> {
|
|
951
|
+
private async streamFeedResponse(feedHash: string, userIdentity: UserIdentity | null, feedDefinition: FeedObject, start: FactReference[], bookmark: string, stream: Stream<FeedResponse>, skipIfEmpty = false): Promise<string> {
|
|
807
952
|
const responseId = Math.random().toString(36).substring(2, 6);
|
|
808
953
|
const startTime = Date.now();
|
|
809
954
|
|
|
810
955
|
console.log(`[FeedResponse:${responseId}] Starting feed response - Bookmark: ${bookmark || 'empty'}, Skip if empty: ${skipIfEmpty}`);
|
|
811
956
|
|
|
812
957
|
const feedStart = Date.now();
|
|
813
|
-
const
|
|
958
|
+
const queryResult = await this.queryFeed(feedHash, userIdentity, feedDefinition.feed, start, bookmark);
|
|
959
|
+
if (queryResult.type === "denied") {
|
|
960
|
+
// The subscription stays live across distribution failures
|
|
961
|
+
// so the inverse / anchor listeners can deliver results
|
|
962
|
+
// once the authorizing fact arrives.
|
|
963
|
+
console.log(`[FeedResponse:${responseId}] Distribution failed (${queryResult.reason}) - keeping stream live`);
|
|
964
|
+
return bookmark;
|
|
965
|
+
}
|
|
966
|
+
const results = queryResult.feed;
|
|
814
967
|
const feedDuration = Date.now() - feedStart;
|
|
815
|
-
|
|
968
|
+
|
|
816
969
|
console.log(`[FeedResponse:${responseId}] Authorization feed complete - Tuples: ${results.tuples.length}, Duration: ${feedDuration}ms`);
|
|
817
970
|
|
|
818
971
|
// Return distinct fact references from all the tuples.
|
package/src/jinaga-server.ts
CHANGED
|
@@ -5,7 +5,9 @@ import {
|
|
|
5
5
|
AuthorizationNoOp,
|
|
6
6
|
AuthorizationRules,
|
|
7
7
|
DistributionRules,
|
|
8
|
+
FactFeed,
|
|
8
9
|
FactManager,
|
|
10
|
+
FactReference,
|
|
9
11
|
FeedCache,
|
|
10
12
|
FetchConnection,
|
|
11
13
|
Fork,
|
|
@@ -20,6 +22,7 @@ import {
|
|
|
20
22
|
PassThroughFork,
|
|
21
23
|
PersistentFork,
|
|
22
24
|
PurgeConditions,
|
|
25
|
+
ReferencesByName,
|
|
23
26
|
Specification,
|
|
24
27
|
Storage,
|
|
25
28
|
SyncStatusNotifier,
|
|
@@ -33,7 +36,7 @@ import { Pool } from "pg";
|
|
|
33
36
|
|
|
34
37
|
import { AuthenticationDevice } from "./authentication/authentication-device";
|
|
35
38
|
import { AuthenticationSession } from "./authentication/authentication-session";
|
|
36
|
-
import { AuthorizationKeystore } from "./authorization/authorization-keystore";
|
|
39
|
+
import { AuthorizationKeystore, DistributionBranchesResult, FeedResult, SubscriptionAuthorizer } from "./authorization/authorization-keystore";
|
|
37
40
|
import { HttpRouter, RequestUser } from "./http/router";
|
|
38
41
|
import { Keystore } from "./keystore";
|
|
39
42
|
import { PostgresKeystore } from "./postgres/postgres-keystore";
|
|
@@ -186,13 +189,43 @@ function createKeystore(config: JinagaServerConfig, pools: { [uri: string]: Pool
|
|
|
186
189
|
}
|
|
187
190
|
}
|
|
188
191
|
|
|
189
|
-
|
|
192
|
+
class AuthorizationNoOpWithSubscriptions extends AuthorizationNoOp implements SubscriptionAuthorizer {
|
|
193
|
+
async verifyDistributionOrIntersect(
|
|
194
|
+
_userIdentity: UserIdentity | null,
|
|
195
|
+
specification: Specification,
|
|
196
|
+
namedStart: ReferencesByName
|
|
197
|
+
): Promise<DistributionBranchesResult> {
|
|
198
|
+
const start = specification.given.map(g => namedStart[g.label.name]);
|
|
199
|
+
return { type: "success", branches: [{ start, specification }] };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
feedPreVerified(
|
|
203
|
+
userIdentity: UserIdentity | null,
|
|
204
|
+
specification: Specification,
|
|
205
|
+
start: FactReference[],
|
|
206
|
+
bookmark: string
|
|
207
|
+
): Promise<FactFeed> {
|
|
208
|
+
return this.feed(userIdentity!, specification, start, bookmark);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
async feedWithDistribution(
|
|
212
|
+
userIdentity: UserIdentity | null,
|
|
213
|
+
specification: Specification,
|
|
214
|
+
start: FactReference[],
|
|
215
|
+
bookmark: string
|
|
216
|
+
): Promise<FeedResult> {
|
|
217
|
+
const feed = await this.feed(userIdentity!, specification, start, bookmark);
|
|
218
|
+
return { type: "success", feed };
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function createAuthorization(authorizationRules: AuthorizationRules | null, distributionRules: DistributionRules | null, factManager: FactManager, store: Storage, keystore: Keystore | null): Authorization & SubscriptionAuthorizer {
|
|
190
223
|
if (keystore) {
|
|
191
224
|
const authorization = new AuthorizationKeystore(factManager, store, keystore, authorizationRules, distributionRules);
|
|
192
225
|
return authorization;
|
|
193
226
|
}
|
|
194
227
|
else {
|
|
195
|
-
return new
|
|
228
|
+
return new AuthorizationNoOpWithSubscriptions(factManager, store);
|
|
196
229
|
}
|
|
197
230
|
}
|
|
198
231
|
|
|
@@ -116,6 +116,40 @@ export class QueryDescription {
|
|
|
116
116
|
}
|
|
117
117
|
}
|
|
118
118
|
|
|
119
|
+
public withInputOnExistingFact(label: Label, factTypeId: number, factHash: string, factIndex: number, path: number[]): QueryDescription {
|
|
120
|
+
const factTypeParameter = this.parameters.length + 1;
|
|
121
|
+
const factHashParameter = factTypeParameter + 1;
|
|
122
|
+
const input: InputDescription = {
|
|
123
|
+
label: label.name,
|
|
124
|
+
type: label.type,
|
|
125
|
+
factIndex,
|
|
126
|
+
factTypeParameter,
|
|
127
|
+
factHashParameter
|
|
128
|
+
};
|
|
129
|
+
const parameters = this.parameters.concat(factTypeId, factHash);
|
|
130
|
+
if (path.length === 0) {
|
|
131
|
+
return new QueryDescription(
|
|
132
|
+
[...this.inputs, input],
|
|
133
|
+
parameters,
|
|
134
|
+
this.outputs,
|
|
135
|
+
this.facts,
|
|
136
|
+
this.edges,
|
|
137
|
+
this.existentialConditions
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
else {
|
|
141
|
+
const existentialConditions = existentialsWithInput(this.existentialConditions, input, path);
|
|
142
|
+
return new QueryDescription(
|
|
143
|
+
this.inputs,
|
|
144
|
+
parameters,
|
|
145
|
+
this.outputs,
|
|
146
|
+
this.facts,
|
|
147
|
+
this.edges,
|
|
148
|
+
existentialConditions
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
119
153
|
public withFact(type: string): { query: QueryDescription; factIndex: number; } {
|
|
120
154
|
const factIndex = this.facts.length + 1;
|
|
121
155
|
const fact = { factIndex, type };
|
|
@@ -327,17 +361,38 @@ export class QueryDescriptionBuilder {
|
|
|
327
361
|
if (!factTypeId) {
|
|
328
362
|
return { queryDescription: QueryDescription.unsatisfiable, knownFacts };
|
|
329
363
|
}
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
364
|
+
// When this is an identity path condition (no roles on either side)
|
|
365
|
+
// and the unknown has already been allocated, unify the given with
|
|
366
|
+
// the existing fact: register the type+hash filter on the existing
|
|
367
|
+
// factIndex rather than allocating a disconnected fact node.
|
|
368
|
+
const existingUnknown = knownFacts[unknown.name];
|
|
369
|
+
const isIdentity = condition.rolesLeft.length === 0 && condition.rolesRight.length === 0;
|
|
370
|
+
if (isIdentity && existingUnknown) {
|
|
371
|
+
queryDescription = queryDescription.withInputOnExistingFact(
|
|
372
|
+
given[givenIndex],
|
|
373
|
+
factTypeId,
|
|
374
|
+
start[givenIndex].hash,
|
|
375
|
+
existingUnknown.factIndex,
|
|
376
|
+
path
|
|
377
|
+
);
|
|
378
|
+
knownFacts = {
|
|
379
|
+
...knownFacts,
|
|
380
|
+
[condition.labelRight]: existingUnknown
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
else {
|
|
384
|
+
const { queryDescription: newQueryDescription, factDescription } = queryDescription.withInputParameter(
|
|
385
|
+
given[givenIndex],
|
|
386
|
+
factTypeId,
|
|
387
|
+
start[givenIndex].hash,
|
|
388
|
+
path
|
|
389
|
+
);
|
|
390
|
+
queryDescription = newQueryDescription;
|
|
391
|
+
knownFacts = {
|
|
392
|
+
...knownFacts,
|
|
393
|
+
[condition.labelRight]: factDescription
|
|
394
|
+
};
|
|
395
|
+
}
|
|
341
396
|
}
|
|
342
397
|
|
|
343
398
|
// Determine whether we have already written the output.
|
|
@@ -140,7 +140,13 @@ function generateNotExistsWhereClause(schema: string, notExistsWhereClause: Exis
|
|
|
140
140
|
}
|
|
141
141
|
const tailJoins: string[] = generateJoins(schema, notExistsWhereClause.edges.slice(1), writtenFactIndexes);
|
|
142
142
|
const joins = firstJoin.concat(tailJoins);
|
|
143
|
-
|
|
143
|
+
const inputWhereClauses = notExistsWhereClause.inputs
|
|
144
|
+
.map(input => ` AND f${input.factIndex}.fact_type_id = $${input.factTypeParameter} AND f${input.factIndex}.hash = $${input.factHashParameter}`)
|
|
145
|
+
.join("");
|
|
146
|
+
const nestedExistsWhereClauses = notExistsWhereClause.existentialConditions
|
|
147
|
+
.map(nested => ` AND ${nested.exists ? "EXISTS" : "NOT EXISTS"} (${generateNotExistsWhereClause(schema, nested, writtenFactIndexes)})`)
|
|
148
|
+
.join("");
|
|
149
|
+
return `SELECT 1 FROM ${schema}.edge e${firstEdge.edgeIndex}${joins.join("")} WHERE ${whereClause.join(" AND ")}${inputWhereClauses}${nestedExistsWhereClauses}`;
|
|
144
150
|
}
|
|
145
151
|
|
|
146
152
|
function parseBookmark(bookmark: string): number[] {
|