jinaga-server 3.6.0 → 3.7.1
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/dist/authorization/authorization-keystore.d.ts +7 -1
- package/dist/authorization/authorization-keystore.js +16 -5
- package/dist/authorization/authorization-keystore.js.map +1 -1
- package/dist/feeds/feed-stream-session.d.ts +84 -0
- package/dist/feeds/feed-stream-session.js +331 -0
- package/dist/feeds/feed-stream-session.js.map +1 -0
- package/dist/http/router.d.ts +7 -3
- package/dist/http/router.js +130 -238
- package/dist/http/router.js.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.js +5 -1
- package/dist/index.js.map +1 -1
- package/dist/jinaga-server.d.ts +7 -0
- package/dist/jinaga-server.js +1 -1
- package/dist/jinaga-server.js.map +1 -1
- package/docs/design/feeds-persistent-proxy-plan.md +11 -1
- package/integration-test/jinaga-test/package-lock.json +4 -4
- package/integration-test/jinaga-test/package.json +1 -1
- package/package.json +2 -2
- package/src/authorization/authorization-keystore.ts +33 -7
- package/src/feeds/feed-stream-session.ts +386 -0
- package/src/http/router.ts +149 -289
- package/src/index.ts +2 -1
- package/src/jinaga-server.ts +9 -2
- package/test/authorization/intermediateProjectionFeedSpec.ts +189 -0
- package/test/authorization/subscriptionIntersectionSpec.ts +12 -0
- package/test/feeds/feedStreamSessionRegressionSpec.ts +227 -0
- package/test/feeds/feedStreamSessionSpec.ts +351 -0
- package/test/http/feedStreamSessionIntegrationSpec.ts +202 -0
- package/test/http/graphSaveMultiHopAuthSpec.ts +126 -0
- package/test/http/lateAuthRecoverySpec.ts +191 -1
- package/test/http/streamFeedAnchorSpec.ts +3 -1
package/src/http/router.ts
CHANGED
|
@@ -3,14 +3,14 @@ import {
|
|
|
3
3
|
Authorization,
|
|
4
4
|
buildFeeds,
|
|
5
5
|
computeObjectHash,
|
|
6
|
-
computeTupleSubsetHash,
|
|
7
6
|
Declaration,
|
|
7
|
+
DistributionDenialCode,
|
|
8
8
|
FactEnvelope,
|
|
9
9
|
FactManager,
|
|
10
10
|
FactRecord,
|
|
11
11
|
FactReference,
|
|
12
12
|
FeedCache,
|
|
13
|
-
|
|
13
|
+
FeedDecision,
|
|
14
14
|
FeedResponse,
|
|
15
15
|
FeedsResponse,
|
|
16
16
|
Forbidden,
|
|
@@ -18,7 +18,6 @@ import {
|
|
|
18
18
|
GraphSerializer,
|
|
19
19
|
GraphSource,
|
|
20
20
|
Invalid,
|
|
21
|
-
invertSpecification,
|
|
22
21
|
LoadMessage,
|
|
23
22
|
LoadResponse,
|
|
24
23
|
parseLoadMessage,
|
|
@@ -33,6 +32,7 @@ import {
|
|
|
33
32
|
verifyEnvelopes
|
|
34
33
|
} from "jinaga";
|
|
35
34
|
import { DistributionIntersectionBranch, FeedResult, SubscriptionAuthorizer } from "../authorization/authorization-keystore";
|
|
35
|
+
import { FeedStreamSession, FeedStreamSessionConfig } from "../feeds/feed-stream-session";
|
|
36
36
|
import { CsvMetadata } from "./csv-metadata";
|
|
37
37
|
import { validateSpecificationForCsv } from "./csv-validator";
|
|
38
38
|
import { createLineReader } from "./line-reader";
|
|
@@ -385,7 +385,8 @@ export class HttpRouter {
|
|
|
385
385
|
private factManager: FactManager,
|
|
386
386
|
private authorization: Authorization & SubscriptionAuthorizer,
|
|
387
387
|
private feedCache: FeedCache,
|
|
388
|
-
private allowedOrigin: string | string[] | ((origin: string, callback: (err: Error | null, allow?: boolean) => void) => void)
|
|
388
|
+
private allowedOrigin: string | string[] | ((origin: string, callback: (err: Error | null, allow?: boolean) => void) => void),
|
|
389
|
+
private feedStreamConfig: Partial<FeedStreamSessionConfig> = {}
|
|
389
390
|
) {
|
|
390
391
|
const router = Router();
|
|
391
392
|
const applyAllowOrigin = this.applyAllowOrigin.bind(this);
|
|
@@ -468,12 +469,24 @@ export class HttpRouter {
|
|
|
468
469
|
private save(user: RequestUser | null, graphSource: GraphSource): Promise<void> {
|
|
469
470
|
return Trace.dependency("save", "", async () => {
|
|
470
471
|
const userIdentity = serializeUserIdentity(user);
|
|
472
|
+
// Signatures are verified per flush as they stream in, but the
|
|
473
|
+
// envelopes are accumulated and authorized as a single batch.
|
|
474
|
+
// Otherwise, a graph body that flushes in chunks (GraphDeserializer
|
|
475
|
+
// flushes every 20 facts) would authorize each chunk independently,
|
|
476
|
+
// and a fact whose rule needs a predecessor from an earlier chunk
|
|
477
|
+
// would fail with "The fact <type>:<hash> is not defined." (issue #175).
|
|
478
|
+
const allEnvelopes: FactEnvelope[] = [];
|
|
471
479
|
await graphSource.read(async (envelopes) => {
|
|
472
480
|
if (!verifyEnvelopes(envelopes)) {
|
|
473
481
|
throw new Forbidden("The signatures on the facts are invalid.");
|
|
474
482
|
}
|
|
475
|
-
|
|
483
|
+
allEnvelopes.push(...envelopes);
|
|
476
484
|
});
|
|
485
|
+
try {
|
|
486
|
+
await this.authorization.save(userIdentity, allEnvelopes);
|
|
487
|
+
} catch (error) {
|
|
488
|
+
throw toSaveAuthorizationError(error, allEnvelopes);
|
|
489
|
+
}
|
|
477
490
|
});
|
|
478
491
|
}
|
|
479
492
|
|
|
@@ -591,7 +604,17 @@ export class HttpRouter {
|
|
|
591
604
|
let branches: DistributionIntersectionBranch[];
|
|
592
605
|
let intersected: boolean;
|
|
593
606
|
if (intersectResult.type === "denied") {
|
|
594
|
-
|
|
607
|
+
// Structured, dashboard-observable signal that a feed was
|
|
608
|
+
// registered without an applicable distribution rule (issue
|
|
609
|
+
// #168 S3). The metric name is stable so a backend sees
|
|
610
|
+
// bounded cardinality; the denial code (a fixed 4-value enum)
|
|
611
|
+
// is the measurement dimension, so authoring errors
|
|
612
|
+
// (no-matching-rule / spec-more-restrictive-than-rule) are
|
|
613
|
+
// distinguishable from auth states (principal-excluded /
|
|
614
|
+
// not-authenticated) in CI and dashboards without grepping
|
|
615
|
+
// logs. The human-readable line is retained for correlation.
|
|
616
|
+
Trace.metric("distribution.unmatched", { [intersectResult.code]: 1 });
|
|
617
|
+
Trace.warn(`/feeds accepting spec without applicable distribution rule (${intersectResult.code}): ${intersectResult.reason}`);
|
|
595
618
|
branches = [{ start, specification }];
|
|
596
619
|
intersected = false;
|
|
597
620
|
} else {
|
|
@@ -599,8 +622,44 @@ export class HttpRouter {
|
|
|
599
622
|
intersected = branches.length !== 1 || branches[0].specification !== specification;
|
|
600
623
|
}
|
|
601
624
|
|
|
625
|
+
// Classify the spec's distribution decision once; it applies to
|
|
626
|
+
// every feed hash the spec produces (issue #168 S1). Registration
|
|
627
|
+
// and keep-alive are unchanged — reactive and denied feeds are
|
|
628
|
+
// still accepted and served exactly as before; this only reports
|
|
629
|
+
// the decision. `reactive` is authorized-via-intersection: denied
|
|
630
|
+
// now, self-heals when the authorizing fact arrives.
|
|
631
|
+
//
|
|
632
|
+
// S2: the decision is recomputed from verifyDistributionOrIntersect
|
|
633
|
+
// on every POST /feeds — it is NOT cached by feed hash — so a
|
|
634
|
+
// repeated request always returns the correct *per-user* decision.
|
|
635
|
+
// This matters because a non-intersected feed hash is shared across
|
|
636
|
+
// users (it excludes the requester's identity): the same hash is
|
|
637
|
+
// `authorized` for a permitted user and `denied` for another, and
|
|
638
|
+
// a `reactive` decision becomes `authorized` once the authorizing
|
|
639
|
+
// fact arrives. Caching the FeedDecision by hash would leak one
|
|
640
|
+
// user's decision to another. addFeeds stays idempotent on the
|
|
641
|
+
// hash; only the classification is per-request.
|
|
642
|
+
let decision: FeedDecision["decision"];
|
|
643
|
+
let decisionCode: DistributionDenialCode | undefined;
|
|
644
|
+
let decisionReason: string;
|
|
645
|
+
if (intersectResult.type === "denied") {
|
|
646
|
+
decision = "denied";
|
|
647
|
+
decisionCode = intersectResult.code;
|
|
648
|
+
decisionReason = intersectResult.reason;
|
|
649
|
+
} else if (intersected) {
|
|
650
|
+
decision = "reactive";
|
|
651
|
+
decisionCode = intersectResult.denial?.code;
|
|
652
|
+
decisionReason = intersectResult.denial?.reason
|
|
653
|
+
?? "Authorized via intersection; awaiting the authorizing fact.";
|
|
654
|
+
} else {
|
|
655
|
+
decision = "authorized";
|
|
656
|
+
decisionCode = undefined;
|
|
657
|
+
decisionReason = "Distribution authorized.";
|
|
658
|
+
}
|
|
659
|
+
|
|
602
660
|
const ownerKey = subscriptionOwnerKey(userIdentity);
|
|
603
661
|
const feedHashes: string[] = [];
|
|
662
|
+
const decisions: FeedDecision[] = [];
|
|
604
663
|
for (const branch of branches) {
|
|
605
664
|
const branchNamedStart = branch.specification.given.reduce((map, g, index) => ({
|
|
606
665
|
...map,
|
|
@@ -609,6 +668,14 @@ export class HttpRouter {
|
|
|
609
668
|
const branchFeeds = buildFeeds(branch.specification);
|
|
610
669
|
const branchHashes = this.feedCache.addFeeds(branchFeeds, branchNamedStart);
|
|
611
670
|
feedHashes.push(...branchHashes);
|
|
671
|
+
for (const hash of branchHashes) {
|
|
672
|
+
decisions.push({
|
|
673
|
+
feed: hash,
|
|
674
|
+
decision,
|
|
675
|
+
...(decisionCode !== undefined ? { code: decisionCode } : {}),
|
|
676
|
+
reason: decisionReason
|
|
677
|
+
});
|
|
678
|
+
}
|
|
612
679
|
if (intersected) {
|
|
613
680
|
for (const hash of branchHashes) {
|
|
614
681
|
// Bind the hash to its owner so a different
|
|
@@ -635,7 +702,8 @@ export class HttpRouter {
|
|
|
635
702
|
}
|
|
636
703
|
|
|
637
704
|
return {
|
|
638
|
-
feeds: feedHashes
|
|
705
|
+
feeds: feedHashes,
|
|
706
|
+
decisions
|
|
639
707
|
}
|
|
640
708
|
});
|
|
641
709
|
}
|
|
@@ -664,12 +732,16 @@ export class HttpRouter {
|
|
|
664
732
|
return Trace.dependency("feed", params["hash"], async () => {
|
|
665
733
|
const feedHash = params["hash"];
|
|
666
734
|
if (!feedHash) {
|
|
735
|
+
// No hash in the route at all — a wrong URL, not a feed lookup.
|
|
667
736
|
return null;
|
|
668
737
|
}
|
|
669
738
|
|
|
670
739
|
const feedDefinition = await this.feedCache.getFeed(feedHash);
|
|
671
740
|
if (!feedDefinition) {
|
|
672
|
-
|
|
741
|
+
// Known route, but the feed hash is unknown or expired. Signal
|
|
742
|
+
// this distinctly (issue #168 S4) so the client can re-register
|
|
743
|
+
// via POST /feeds instead of treating it as a routing error.
|
|
744
|
+
throw new FeedNotFound(feedHash);
|
|
673
745
|
}
|
|
674
746
|
|
|
675
747
|
const bookmark = query["b"] as string ?? "";
|
|
@@ -698,304 +770,51 @@ export class HttpRouter {
|
|
|
698
770
|
|
|
699
771
|
private async streamFeed(user: RequestUser | null, params: { [key: string]: string }, query: qs.ParsedQs): Promise<Stream<FeedResponse> | null> {
|
|
700
772
|
const connectionId = Math.random().toString(36).substring(2, 10);
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
console.log(`[StreamFeed:${connectionId}] NEW CONNECTION - User: ${user?.id || 'anonymous'}, Hash: ${params["hash"]}`);
|
|
704
|
-
|
|
773
|
+
|
|
705
774
|
const feedHash = params["hash"];
|
|
706
775
|
if (!feedHash) {
|
|
707
|
-
|
|
776
|
+
// No hash in the route at all — a wrong URL, not a feed lookup.
|
|
708
777
|
return null;
|
|
709
778
|
}
|
|
710
779
|
|
|
711
780
|
const feedDefinition = await this.feedCache.getFeed(feedHash);
|
|
712
781
|
if (!feedDefinition) {
|
|
713
|
-
|
|
714
|
-
|
|
782
|
+
// Known route, but the feed hash is unknown or expired. Signal
|
|
783
|
+
// this distinctly (issue #168 S4) so the client can re-register
|
|
784
|
+
// via POST /feeds instead of treating it as a routing error.
|
|
785
|
+
throw new FeedNotFound(feedHash);
|
|
715
786
|
}
|
|
716
787
|
|
|
717
|
-
|
|
718
|
-
console.log(`[StreamFeed:${connectionId}] Initial bookmark: ${bookmark || 'empty'}`);
|
|
788
|
+
const bookmark = query["b"] as string ?? "";
|
|
719
789
|
|
|
720
790
|
const userIdentity = serializeUserIdentity(user);
|
|
721
791
|
const start = feedDefinition.feed.given.map(g => feedDefinition.namedStart[g.label.name]);
|
|
722
792
|
const givenHash = computeObjectHash(feedDefinition.namedStart);
|
|
723
793
|
|
|
724
|
-
console.log(`[StreamFeed:${connectionId}] Feed setup - Given hash: ${givenHash.substring(0, 8)}..., Start facts: ${start.length}`);
|
|
725
|
-
|
|
726
794
|
const stream = new Stream<FeedResponse>();
|
|
727
|
-
|
|
728
|
-
try {
|
|
729
|
-
// Continuous initial query until exhausted
|
|
730
|
-
console.log(`[StreamFeed:${connectionId}] Starting initial data streaming...`);
|
|
731
|
-
const initialStart = Date.now();
|
|
732
|
-
bookmark = await this.streamAllInitialResults(feedHash, userIdentity, feedDefinition, start, bookmark, stream);
|
|
733
|
-
const initialDuration = Date.now() - initialStart;
|
|
734
|
-
console.log(`[StreamFeed:${connectionId}] Initial data streaming complete - Duration: ${initialDuration}ms, Final bookmark: ${bookmark || 'empty'}`);
|
|
735
|
-
|
|
736
|
-
// Set up real-time listeners after initial data is complete
|
|
737
|
-
console.log(`[StreamFeed:${connectionId}] Setting up real-time listeners...`);
|
|
738
|
-
const inverses = invertSpecification(feedDefinition.feed);
|
|
739
|
-
console.log(`[StreamFeed:${connectionId}] Created ${inverses.length} inverse specifications`);
|
|
740
|
-
|
|
741
|
-
const listeners = inverses.map((inverse, index) => {
|
|
742
|
-
console.log(`[StreamFeed:${connectionId}] Adding listener ${index + 1}/${inverses.length}`);
|
|
743
|
-
|
|
744
|
-
return this.factManager.addSpecificationListener(
|
|
745
|
-
inverse.inverseSpecification,
|
|
746
|
-
async (results) => {
|
|
747
|
-
const eventStart = Date.now();
|
|
748
|
-
console.log(`[StreamFeed:${connectionId}] EVENT RECEIVED - Listener ${index + 1}, Results: ${results.length}`);
|
|
749
|
-
|
|
750
|
-
try {
|
|
751
|
-
// Filter out results that do not match the given.
|
|
752
|
-
const matchingResults = results.filter(pr =>
|
|
753
|
-
givenHash === computeTupleSubsetHash(pr.tuple, inverse.givenSubset));
|
|
754
|
-
|
|
755
|
-
console.log(`[StreamFeed:${connectionId}] Filtered results - Matching: ${matchingResults.length}/${results.length}`);
|
|
756
|
-
|
|
757
|
-
if (matchingResults.length != 0) {
|
|
758
|
-
console.log(`[StreamFeed:${connectionId}] Processing matching results...`);
|
|
759
|
-
const responseStart = Date.now();
|
|
760
|
-
bookmark = await this.streamFeedResponse(feedHash, userIdentity, feedDefinition, start, bookmark, stream, true);
|
|
761
|
-
const responseDuration = Date.now() - responseStart;
|
|
762
|
-
console.log(`[StreamFeed:${connectionId}] Feed response sent - Duration: ${responseDuration}ms, New bookmark: ${bookmark || 'empty'}`);
|
|
763
|
-
} else {
|
|
764
|
-
console.log(`[StreamFeed:${connectionId}] No matching results - skipping response`);
|
|
765
|
-
}
|
|
766
|
-
} catch (error) {
|
|
767
|
-
console.error(`[StreamFeed:${connectionId}] ERROR processing event: ${error}`);
|
|
768
|
-
}
|
|
769
|
-
|
|
770
|
-
const eventDuration = Date.now() - eventStart;
|
|
771
|
-
if (eventDuration > 100) {
|
|
772
|
-
console.warn(`[StreamFeed:${connectionId}] SLOW event processing - Duration: ${eventDuration}ms`);
|
|
773
|
-
}
|
|
774
|
-
}
|
|
775
|
-
);
|
|
776
|
-
});
|
|
777
|
-
|
|
778
|
-
console.log(`[StreamFeed:${connectionId}] All listeners registered - Count: ${listeners.length}`);
|
|
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
|
-
|
|
814
|
-
stream.done(() => {
|
|
815
|
-
const cleanupStart = Date.now();
|
|
816
|
-
console.log(`[StreamFeed:${connectionId}] CLEANUP STARTED - Removing ${listeners.length + anchorListeners.length} listener(s)`);
|
|
817
|
-
|
|
818
|
-
for (let i = 0; i < listeners.length; i++) {
|
|
819
|
-
try {
|
|
820
|
-
this.factManager.removeSpecificationListener(listeners[i]);
|
|
821
|
-
console.log(`[StreamFeed:${connectionId}] Removed listener ${i + 1}/${listeners.length}`);
|
|
822
|
-
} catch (error) {
|
|
823
|
-
console.error(`[StreamFeed:${connectionId}] ERROR removing listener ${i + 1}: ${error}`);
|
|
824
|
-
}
|
|
825
|
-
}
|
|
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
|
-
|
|
834
|
-
const cleanupDuration = Date.now() - cleanupStart;
|
|
835
|
-
const totalDuration = Date.now() - startTime;
|
|
836
|
-
console.log(`[StreamFeed:${connectionId}] CLEANUP COMPLETE - Cleanup duration: ${cleanupDuration}ms, Total connection duration: ${totalDuration}ms`);
|
|
837
|
-
});
|
|
838
|
-
|
|
839
|
-
const setupDuration = Date.now() - startTime;
|
|
840
|
-
console.log(`[StreamFeed:${connectionId}] Stream setup complete - Duration: ${setupDuration}ms`);
|
|
841
|
-
|
|
842
|
-
return stream;
|
|
843
|
-
|
|
844
|
-
} catch (error) {
|
|
845
|
-
console.error(`[StreamFeed:${connectionId}] ERROR during setup: ${error}`);
|
|
846
|
-
throw error;
|
|
847
|
-
}
|
|
848
|
-
}
|
|
849
|
-
|
|
850
|
-
private async streamAllInitialResults(
|
|
851
|
-
feedHash: string,
|
|
852
|
-
userIdentity: UserIdentity | null,
|
|
853
|
-
feedDefinition: FeedObject,
|
|
854
|
-
start: FactReference[],
|
|
855
|
-
initialBookmark: string,
|
|
856
|
-
stream: Stream<FeedResponse>
|
|
857
|
-
): Promise<string> {
|
|
858
|
-
const streamId = Math.random().toString(36).substring(2, 8);
|
|
859
|
-
console.log(`[InitialResults:${streamId}] Starting initial data fetch - Bookmark: ${initialBookmark || 'empty'}`);
|
|
860
|
-
|
|
861
|
-
let bookmark = initialBookmark;
|
|
862
|
-
let hasMoreResults = true;
|
|
863
|
-
let pageCount = 0;
|
|
864
|
-
let totalReferences = 0;
|
|
865
|
-
const maxPages = 1000; // Safety limit to prevent infinite loops
|
|
866
|
-
const startTime = Date.now();
|
|
867
|
-
|
|
868
|
-
while (hasMoreResults && pageCount < maxPages) {
|
|
869
|
-
const pageStart = Date.now();
|
|
870
|
-
console.log(`[InitialResults:${streamId}] Fetching page ${pageCount + 1} - Bookmark: ${bookmark || 'empty'}`);
|
|
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;
|
|
883
|
-
const fetchDuration = Date.now() - pageStart;
|
|
884
795
|
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
if (references.length > 0) {
|
|
904
|
-
const response: FeedResponse = {
|
|
905
|
-
references,
|
|
906
|
-
bookmark: results.bookmark
|
|
907
|
-
};
|
|
908
|
-
|
|
909
|
-
const feedStart = Date.now();
|
|
910
|
-
stream.feed(response);
|
|
911
|
-
const feedDuration = Date.now() - feedStart;
|
|
912
|
-
|
|
913
|
-
totalReferences += references.length;
|
|
914
|
-
console.log(`[InitialResults:${streamId}] Page ${pageCount + 1} sent to stream - Feed duration: ${feedDuration}ms`);
|
|
915
|
-
}
|
|
916
|
-
|
|
917
|
-
// Update bookmark for next iteration
|
|
918
|
-
const newBookmark = results.bookmark;
|
|
919
|
-
if (newBookmark === bookmark) {
|
|
920
|
-
console.log(`[InitialResults:${streamId}] Bookmark unchanged - ending pagination`);
|
|
921
|
-
hasMoreResults = false;
|
|
922
|
-
} else {
|
|
923
|
-
bookmark = newBookmark;
|
|
924
|
-
}
|
|
925
|
-
|
|
926
|
-
// Check if we got fewer results than the page size (indicates end)
|
|
927
|
-
if (results.tuples.length < 100) {
|
|
928
|
-
console.log(`[InitialResults:${streamId}] Partial page received (${results.tuples.length} < 100) - ending pagination`);
|
|
929
|
-
hasMoreResults = false;
|
|
930
|
-
}
|
|
931
|
-
|
|
932
|
-
pageCount++;
|
|
933
|
-
|
|
934
|
-
// Add small delay to prevent overwhelming the database
|
|
935
|
-
if (hasMoreResults && pageCount % 10 === 0) {
|
|
936
|
-
console.log(`[InitialResults:${streamId}] Adding delay after ${pageCount} pages`);
|
|
937
|
-
await new Promise(resolve => setTimeout(resolve, 10));
|
|
938
|
-
}
|
|
939
|
-
}
|
|
940
|
-
|
|
941
|
-
const totalDuration = Date.now() - startTime;
|
|
942
|
-
console.log(`[InitialResults:${streamId}] Initial data streaming complete - Pages: ${pageCount}, Total references: ${totalReferences}, Duration: ${totalDuration}ms, Final bookmark: ${bookmark || 'empty'}`);
|
|
943
|
-
|
|
944
|
-
if (pageCount >= maxPages) {
|
|
945
|
-
console.warn(`[InitialResults:${streamId}] Hit maximum page limit (${maxPages}) - possible infinite loop`);
|
|
946
|
-
}
|
|
947
|
-
|
|
948
|
-
return bookmark;
|
|
949
|
-
}
|
|
796
|
+
// The session serializes every fetch and stream operation through a
|
|
797
|
+
// single bookmark-driven query cycle. Inverse and anchor observers
|
|
798
|
+
// only enqueue references onto a waitlist and wake the cycle; they
|
|
799
|
+
// never fetch or stream directly. This removes the race between
|
|
800
|
+
// listener callbacks and bookmark mutation, guarantees tuple
|
|
801
|
+
// completeness, and ensures no update is lost between pages.
|
|
802
|
+
const session = new FeedStreamSession(
|
|
803
|
+
(b: string) => this.queryFeed(feedHash, userIdentity, feedDefinition.feed, start, b),
|
|
804
|
+
this.factManager,
|
|
805
|
+
feedDefinition,
|
|
806
|
+
start,
|
|
807
|
+
givenHash,
|
|
808
|
+
stream,
|
|
809
|
+
bookmark,
|
|
810
|
+
connectionId,
|
|
811
|
+
this.feedStreamConfig
|
|
812
|
+
);
|
|
950
813
|
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
const startTime = Date.now();
|
|
954
|
-
|
|
955
|
-
console.log(`[FeedResponse:${responseId}] Starting feed response - Bookmark: ${bookmark || 'empty'}, Skip if empty: ${skipIfEmpty}`);
|
|
956
|
-
|
|
957
|
-
const feedStart = Date.now();
|
|
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;
|
|
967
|
-
const feedDuration = Date.now() - feedStart;
|
|
814
|
+
stream.done(() => session.dispose());
|
|
815
|
+
session.start();
|
|
968
816
|
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
// Return distinct fact references from all the tuples.
|
|
972
|
-
const processStart = Date.now();
|
|
973
|
-
const references = results.tuples.flatMap(t => t.facts).filter((value, index, self) =>
|
|
974
|
-
self.findIndex(f => f.hash === value.hash && f.type === value.type) === index
|
|
975
|
-
);
|
|
976
|
-
const processDuration = Date.now() - processStart;
|
|
977
|
-
|
|
978
|
-
console.log(`[FeedResponse:${responseId}] References processed - Count: ${references.length}, Duration: ${processDuration}ms`);
|
|
979
|
-
|
|
980
|
-
if (!skipIfEmpty || references.length > 0) {
|
|
981
|
-
const response: FeedResponse = {
|
|
982
|
-
references,
|
|
983
|
-
bookmark: results.bookmark
|
|
984
|
-
};
|
|
985
|
-
|
|
986
|
-
const streamStart = Date.now();
|
|
987
|
-
stream.feed(response);
|
|
988
|
-
const streamDuration = Date.now() - streamStart;
|
|
989
|
-
|
|
990
|
-
console.log(`[FeedResponse:${responseId}] Response sent to stream - Duration: ${streamDuration}ms`);
|
|
991
|
-
} else {
|
|
992
|
-
console.log(`[FeedResponse:${responseId}] Skipping empty response`);
|
|
993
|
-
}
|
|
994
|
-
|
|
995
|
-
const totalDuration = Date.now() - startTime;
|
|
996
|
-
console.log(`[FeedResponse:${responseId}] Feed response complete - New bookmark: ${results.bookmark || 'empty'}, Total duration: ${totalDuration}ms`);
|
|
997
|
-
|
|
998
|
-
return results.bookmark;
|
|
817
|
+
return stream;
|
|
999
818
|
}
|
|
1000
819
|
|
|
1001
820
|
private async getKnownFacts(user: RequestUser | null): Promise<Declaration> {
|
|
@@ -1158,9 +977,50 @@ function extractResults(obj: any): { result: any, count: number } {
|
|
|
1158
977
|
}
|
|
1159
978
|
}
|
|
1160
979
|
|
|
980
|
+
// A known/expired feed hash missed the feed cache. Distinct from a route
|
|
981
|
+
// miss (wrong URL) so clients can tell "unknown/expired feed hash" — which is
|
|
982
|
+
// recoverable by re-registering via POST /feeds — from "wrong URL" (issue
|
|
983
|
+
// #168 S4). handleError maps this to a 404 with a machine-readable body.
|
|
984
|
+
export class FeedNotFound extends Error {
|
|
985
|
+
constructor(public readonly feedHash: string) {
|
|
986
|
+
super(`Feed not found: ${feedHash}`);
|
|
987
|
+
this.name = "FeedNotFound";
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
// Authorization rule evaluation reports a missing predecessor as a plain
|
|
992
|
+
// Error of this shape (see authorizationRules.ts / specification-runner.ts
|
|
993
|
+
// in jinaga). It surfaces when the client's save request didn't include the
|
|
994
|
+
// full closure of facts a rule needs to walk. Map it to a diagnosable 4xx
|
|
995
|
+
// instead of an opaque 500 (issue #175).
|
|
996
|
+
const FACT_NOT_DEFINED_PATTERN = /^The fact (\S+):(\S+) is not defined\.$/;
|
|
997
|
+
|
|
998
|
+
function toSaveAuthorizationError(error: any, envelopes: FactEnvelope[]): any {
|
|
999
|
+
if (error instanceof Error) {
|
|
1000
|
+
const match = FACT_NOT_DEFINED_PATTERN.exec(error.message);
|
|
1001
|
+
if (match) {
|
|
1002
|
+
const [, factType, factHash] = match;
|
|
1003
|
+
const factTypesInBatch = Array.from(new Set(envelopes.map(e => e.fact.type))).join(", ");
|
|
1004
|
+
Trace.warn(
|
|
1005
|
+
`Save authorization could not resolve predecessor ${factType}:${factHash} ` +
|
|
1006
|
+
`among ${envelopes.length} fact(s) in the request (types: ${factTypesInBatch}).`
|
|
1007
|
+
);
|
|
1008
|
+
return new Invalid(
|
|
1009
|
+
`The fact ${factType}:${factHash} is required to authorize this save but was not included in the request. ` +
|
|
1010
|
+
`Ensure the full closure of predecessors needed by your authorization rules is sent together.`
|
|
1011
|
+
);
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
1014
|
+
return error;
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1161
1017
|
function handleError(error: any, req: Request, res: Response, next: NextFunction) {
|
|
1162
1018
|
const requestPath = req.path;
|
|
1163
|
-
if (error instanceof
|
|
1019
|
+
if (error instanceof FeedNotFound) {
|
|
1020
|
+
Trace.warn(`Feed not found: ${error.feedHash} (Path: ${requestPath})`);
|
|
1021
|
+
res.type("text");
|
|
1022
|
+
res.status(404).send("feed_not_found");
|
|
1023
|
+
} else if (error instanceof Forbidden) {
|
|
1164
1024
|
Trace.warn(`Forbidden: ${error.message} (Path: ${requestPath})`);
|
|
1165
1025
|
res.type("text");
|
|
1166
1026
|
res.status(403).send(error.message);
|
package/src/index.ts
CHANGED
|
@@ -2,8 +2,9 @@ export { AuthenticationDevice } from "./authentication/authentication-device";
|
|
|
2
2
|
export { AuthenticationSession } from "./authentication/authentication-session";
|
|
3
3
|
export { AuthorizationKeystore } from "./authorization/authorization-keystore";
|
|
4
4
|
export { DistributedFactCache } from "./authorization/distributed-fact-cache";
|
|
5
|
+
export { FeedStreamSession, FeedStreamSessionConfig, defaultFeedStreamSessionConfig } from "./feeds/feed-stream-session";
|
|
5
6
|
export { createLineReader } from "./http/line-reader";
|
|
6
|
-
export { HttpRouter, RequestUser } from "./http/router";
|
|
7
|
+
export { FeedNotFound, HttpRouter, RequestUser } from "./http/router";
|
|
7
8
|
export { Stream } from "./http/stream";
|
|
8
9
|
export { JinagaServer, JinagaServerConfig, JinagaServerInstance, tracePool } from "./jinaga-server";
|
|
9
10
|
export { Keystore } from "./keystore";
|
package/src/jinaga-server.ts
CHANGED
|
@@ -37,6 +37,7 @@ import { Pool } from "pg";
|
|
|
37
37
|
import { AuthenticationDevice } from "./authentication/authentication-device";
|
|
38
38
|
import { AuthenticationSession } from "./authentication/authentication-session";
|
|
39
39
|
import { AuthorizationKeystore, DistributionBranchesResult, FeedResult, SubscriptionAuthorizer } from "./authorization/authorization-keystore";
|
|
40
|
+
import { FeedStreamSessionConfig } from "./feeds/feed-stream-session";
|
|
40
41
|
import { HttpRouter, RequestUser } from "./http/router";
|
|
41
42
|
import { Keystore } from "./keystore";
|
|
42
43
|
import { PostgresKeystore } from "./postgres/postgres-keystore";
|
|
@@ -56,7 +57,13 @@ export type JinagaServerConfig = {
|
|
|
56
57
|
authorization?: (a: AuthorizationRules) => AuthorizationRules,
|
|
57
58
|
distribution?: (d: DistributionRules) => DistributionRules,
|
|
58
59
|
purgeConditions?: (p: PurgeConditions) => PurgeConditions,
|
|
59
|
-
origin?: string | string[] | ((origin: string, callback: (err: Error | null, allow?: boolean) => void) => void)
|
|
60
|
+
origin?: string | string[] | ((origin: string, callback: (err: Error | null, allow?: boolean) => void) => void),
|
|
61
|
+
/**
|
|
62
|
+
* Tuning for the per-connection feed streaming session
|
|
63
|
+
* (/feeds/:hash with Accept: application/x-jinaga-feed-stream).
|
|
64
|
+
* See FeedStreamSessionConfig for the available limits and defaults.
|
|
65
|
+
*/
|
|
66
|
+
feedStream?: Partial<FeedStreamSessionConfig>
|
|
60
67
|
};
|
|
61
68
|
|
|
62
69
|
export type JinagaServerInstance = {
|
|
@@ -91,7 +98,7 @@ export class JinagaServer {
|
|
|
91
98
|
const purgeConditions = createPurgeConditions(config);
|
|
92
99
|
const factManager = new FactManager(fork, source, store, network, purgeConditions);
|
|
93
100
|
const authorization = createAuthorization(authorizationRules, distributionRules, factManager, store, keystore);
|
|
94
|
-
const router = new HttpRouter(factManager, authorization, feedCache, config.origin || '*');
|
|
101
|
+
const router = new HttpRouter(factManager, authorization, feedCache, config.origin || '*', config.feedStream || {});
|
|
95
102
|
const j: Jinaga = new Jinaga(authentication, factManager, syncStatusNotifier);
|
|
96
103
|
|
|
97
104
|
async function close() {
|