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.
Files changed (40) hide show
  1. package/.github/workflows/publish.yml +5 -2
  2. package/.github/workflows/upgrade-jinaga.yml +51 -0
  3. package/CLAUDE.md +77 -0
  4. package/dist/authorization/authorization-keystore.d.ts +27 -1
  5. package/dist/authorization/authorization-keystore.js +52 -4
  6. package/dist/authorization/authorization-keystore.js.map +1 -1
  7. package/dist/http/router.d.ts +4 -1
  8. package/dist/http/router.js +157 -14
  9. package/dist/http/router.js.map +1 -1
  10. package/dist/jinaga-server.js +18 -1
  11. package/dist/jinaga-server.js.map +1 -1
  12. package/dist/memory/memory-keystore.js +1 -1
  13. package/dist/memory/memory-keystore.js.map +1 -1
  14. package/dist/postgres/query-description.d.ts +1 -0
  15. package/dist/postgres/query-description.js +34 -3
  16. package/dist/postgres/query-description.js.map +1 -1
  17. package/dist/postgres/specification-sql.js +7 -1
  18. package/dist/postgres/specification-sql.js.map +1 -1
  19. package/docs/analysis/feed-builder-nested-existential-bug.md +191 -0
  20. package/integration-test/jinaga-test/distribution-multi-given.test.js +184 -0
  21. package/integration-test/jinaga-test/distribution-negating-feed.test.js +202 -0
  22. package/integration-test/jinaga-test/feed-existential-input.test.js +241 -0
  23. package/integration-test/jinaga-test/feeds-endpoint.test.js +140 -0
  24. package/integration-test/jinaga-test/package-lock.json +4 -4
  25. package/integration-test/jinaga-test/package.json +3 -2
  26. package/integration-test/jinaga-test/subscription-anchor-arrival.test.js +129 -0
  27. package/integration-test/jinaga-test/subscription-helpers.js +60 -0
  28. package/integration-test/jinaga-test/subscription-late-auth.test.js +245 -0
  29. package/integration-test/jinaga-test/subscription-no-rule.test.js +137 -0
  30. package/package.json +2 -2
  31. package/src/authorization/authorization-keystore.ts +87 -5
  32. package/src/http/router.ts +174 -21
  33. package/src/jinaga-server.ts +36 -3
  34. package/src/memory/memory-keystore.ts +1 -1
  35. package/src/postgres/query-description.ts +66 -11
  36. package/src/postgres/specification-sql.ts +7 -1
  37. package/test/authorization/subscriptionIntersectionSpec.ts +208 -0
  38. package/test/http/anchorListenerSpec.ts +117 -0
  39. package/test/http/lateAuthRecoverySpec.ts +245 -0
  40. package/test/http/streamFeedAnchorSpec.ts +169 -0
@@ -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
- // Verify that I can distribute all feeds to the user.
565
- const feeds = buildFeeds(specification);
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.verifyDistribution(userIdentity, feeds, namedStart);
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 feedHashes = this.feedCache.addFeeds(feeds, namedStart);
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 results = await this.authorization.feed(userIdentity, feedDefinition.feed, start, bookmark);
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 = results.tuples.flatMap(t => t.facts).filter((value, index, self) =>
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: results.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} listeners`);
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 results = await this.authorization.feed(userIdentity, feedDefinition.feed, start, bookmark);
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 results = await this.authorization.feed(userIdentity, feedDefinition.feed, start, bookmark);
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.
@@ -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
- function createAuthorization(authorizationRules: AuthorizationRules | null, distributionRules: DistributionRules | null, factManager: FactManager, store: Storage, keystore: Keystore | null): Authorization {
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 AuthorizationNoOp(factManager, store);
228
+ return new AuthorizationNoOpWithSubscriptions(factManager, store);
196
229
  }
197
230
  }
198
231
 
@@ -75,6 +75,6 @@ export class MemoryKeystore implements Keystore {
75
75
  private generateKeyPair(key: string) {
76
76
  const keyPair = generateKeyPair();
77
77
  this.keyPairs[key] = keyPair;
78
- return keyPair.privatePem;
78
+ return keyPair.publicPem;
79
79
  }
80
80
  }
@@ -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
- const { queryDescription: newQueryDescription, factDescription } = queryDescription.withInputParameter(
331
- given[givenIndex],
332
- factTypeId,
333
- start[givenIndex].hash,
334
- path
335
- );
336
- queryDescription = newQueryDescription;
337
- knownFacts = {
338
- ...knownFacts,
339
- [condition.labelRight]: factDescription
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
- return `SELECT 1 FROM ${schema}.edge e${firstEdge.edgeIndex}${joins.join("")} WHERE ${whereClause.join(" AND ")}`;
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[] {