jinaga-server 3.6.0 → 3.7.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/dist/authorization/authorization-keystore.d.ts +7 -1
- package/dist/authorization/authorization-keystore.js +9 -2
- 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 +96 -237
- 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 +23 -4
- package/src/feeds/feed-stream-session.ts +386 -0
- package/src/http/router.ts +110 -288
- 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/lateAuthRecoverySpec.ts +191 -1
- package/test/http/streamFeedAnchorSpec.ts +3 -1
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import {
|
|
2
|
+
AuthorizationRules,
|
|
3
|
+
buildModel,
|
|
4
|
+
dehydrateFact,
|
|
5
|
+
DistributionRules,
|
|
6
|
+
FactManager,
|
|
7
|
+
Forbidden,
|
|
8
|
+
MemoryStore,
|
|
9
|
+
NetworkNoOp,
|
|
10
|
+
ObservableSource,
|
|
11
|
+
PassThroughFork,
|
|
12
|
+
User
|
|
13
|
+
} from "jinaga";
|
|
14
|
+
|
|
15
|
+
import { AuthorizationKeystore } from "../../src/authorization/authorization-keystore";
|
|
16
|
+
import { MemoryKeystore } from "../../src/memory/memory-keystore";
|
|
17
|
+
|
|
18
|
+
// Integration coverage for jinaga issue #204: a distribution rule whose
|
|
19
|
+
// `.select()` projection traverses *through* an intermediate fact (here
|
|
20
|
+
// `Finalist`, used only to reach the `competitor` it projects) must authorize an
|
|
21
|
+
// authorized user's direct query for that intermediate fact. The same
|
|
22
|
+
// DistributionEngine that JinagaTest uses also backs this server-side
|
|
23
|
+
// authorization path (`AuthorizationKeystore.read` -> `canDistributeToAll`,
|
|
24
|
+
// constructed with isTest=false), so the behavior must match.
|
|
25
|
+
//
|
|
26
|
+
// The sub-feed authorization relaxation this exercises landed in jinaga 6.8.2.
|
|
27
|
+
|
|
28
|
+
class Tenant {
|
|
29
|
+
public static Type = "Tenant" as const;
|
|
30
|
+
public type = Tenant.Type;
|
|
31
|
+
constructor(public creator: User) { }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
class Administrator {
|
|
35
|
+
public static Type = "Administrator" as const;
|
|
36
|
+
public type = Administrator.Type;
|
|
37
|
+
constructor(public tenant: Tenant, public user: User, public createdAt: Date | string) { }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
class Event {
|
|
41
|
+
public static Type = "Event" as const;
|
|
42
|
+
public type = Event.Type;
|
|
43
|
+
constructor(public tenant: Tenant, public id: string) { }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
class Competitor {
|
|
47
|
+
public static Type = "Competitor" as const;
|
|
48
|
+
public type = Competitor.Type;
|
|
49
|
+
constructor(public tenant: Tenant) { }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Finalist is the intermediate: the projection joins Event -> Finalist only to
|
|
53
|
+
// reach the Competitor predecessor; Finalist is never a named component.
|
|
54
|
+
class Finalist {
|
|
55
|
+
public static Type = "Finalist" as const;
|
|
56
|
+
public type = Finalist.Type;
|
|
57
|
+
constructor(public competitor: Competitor, public event: Event) { }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
class CompetitorName {
|
|
61
|
+
public static Type = "CompetitorName" as const;
|
|
62
|
+
public type = CompetitorName.Type;
|
|
63
|
+
constructor(public competitor: Competitor, public value: string, public prior: CompetitorName[]) { }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const model = buildModel(b => b
|
|
67
|
+
.type(User)
|
|
68
|
+
.type(Tenant, m => m.predecessor("creator", User))
|
|
69
|
+
.type(Administrator, m => m
|
|
70
|
+
.predecessor("tenant", Tenant)
|
|
71
|
+
.predecessor("user", User))
|
|
72
|
+
.type(Event, m => m.predecessor("tenant", Tenant))
|
|
73
|
+
.type(Competitor, m => m.predecessor("tenant", Tenant))
|
|
74
|
+
.type(Finalist, m => m
|
|
75
|
+
.predecessor("competitor", Competitor)
|
|
76
|
+
.predecessor("event", Event))
|
|
77
|
+
.type(CompetitorName, m => m
|
|
78
|
+
.predecessor("competitor", Competitor)
|
|
79
|
+
.predecessor("prior", CompetitorName))
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
const finalistsOfEvent = model.given(Event).match((event, facts) =>
|
|
83
|
+
facts.ofType(Finalist).join(finalist => finalist.event, event)
|
|
84
|
+
).specification;
|
|
85
|
+
|
|
86
|
+
const subscriberIdentity = { provider: "mock", id: "subscriber" };
|
|
87
|
+
const creatorIdentity = { provider: "mock", id: "creator" };
|
|
88
|
+
|
|
89
|
+
describe("AuthorizationKeystore.read with intermediate projection facts (jinaga #204)", () => {
|
|
90
|
+
it("authorizes an administrator's direct query for an intermediate projection fact", async () => {
|
|
91
|
+
const setup = await givenAuthorization(/* subscriberIsAdmin */ true);
|
|
92
|
+
|
|
93
|
+
const eventRef = dehydrateFact(setup.event).find(f => f.type === Event.Type)!;
|
|
94
|
+
|
|
95
|
+
// Should resolve (not throw Forbidden) and return the seeded finalist.
|
|
96
|
+
const results = await setup.authorization.read(
|
|
97
|
+
subscriberIdentity, [eventRef], finalistsOfEvent);
|
|
98
|
+
|
|
99
|
+
expect(results).toHaveLength(1);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("still denies a non-administrator's direct query for the same fact", async () => {
|
|
103
|
+
const setup = await givenAuthorization(/* subscriberIsAdmin */ false);
|
|
104
|
+
|
|
105
|
+
const eventRef = dehydrateFact(setup.event).find(f => f.type === Event.Type)!;
|
|
106
|
+
|
|
107
|
+
// Assert the specific authorization denial, not just any error, so a
|
|
108
|
+
// setup/engine failure can't masquerade as a passing regression guard.
|
|
109
|
+
const read = setup.authorization.read(subscriberIdentity, [eventRef], finalistsOfEvent);
|
|
110
|
+
await expect(read).rejects.toThrow(Forbidden);
|
|
111
|
+
await expect(read).rejects.toThrow(/Cannot distribute/);
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
async function givenAuthorization(subscriberIsAdmin: boolean) {
|
|
116
|
+
const storage = new MemoryStore();
|
|
117
|
+
const keystore = new MemoryKeystore();
|
|
118
|
+
|
|
119
|
+
const subscriberFact = await keystore.getOrCreateUserFact(subscriberIdentity);
|
|
120
|
+
const creatorFact = await keystore.getOrCreateUserFact(creatorIdentity);
|
|
121
|
+
const subscriber = new User(subscriberFact.fields.publicKey);
|
|
122
|
+
const creator = new User(creatorFact.fields.publicKey);
|
|
123
|
+
|
|
124
|
+
const tenant = new Tenant(creator);
|
|
125
|
+
const event = new Event(tenant, "event-id");
|
|
126
|
+
const competitor = new Competitor(tenant);
|
|
127
|
+
const finalist = new Finalist(competitor, event);
|
|
128
|
+
|
|
129
|
+
const facts = [
|
|
130
|
+
...dehydrateFact(subscriber),
|
|
131
|
+
...dehydrateFact(creator),
|
|
132
|
+
...dehydrateFact(event),
|
|
133
|
+
...dehydrateFact(competitor),
|
|
134
|
+
...dehydrateFact(finalist)
|
|
135
|
+
];
|
|
136
|
+
if (subscriberIsAdmin) {
|
|
137
|
+
const administrator = new Administrator(tenant, subscriber, new Date("2024-01-01"));
|
|
138
|
+
facts.push(...dehydrateFact(administrator));
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Dedupe by hash+type to avoid double-saving shared predecessors.
|
|
142
|
+
const seenKeys = new Set<string>();
|
|
143
|
+
const uniqueFacts = facts.filter(f => {
|
|
144
|
+
const k = `${f.type}|${f.hash}`;
|
|
145
|
+
if (seenKeys.has(k)) return false;
|
|
146
|
+
seenKeys.add(k);
|
|
147
|
+
return true;
|
|
148
|
+
});
|
|
149
|
+
await storage.save(uniqueFacts.map(f => ({ fact: f, signatures: [] })));
|
|
150
|
+
|
|
151
|
+
const fork = new PassThroughFork(storage);
|
|
152
|
+
const observable = new ObservableSource(storage);
|
|
153
|
+
const network = new NetworkNoOp();
|
|
154
|
+
const factManager = new FactManager(fork, observable, storage, network, []);
|
|
155
|
+
|
|
156
|
+
const authorizationRules = new AuthorizationRules(model)
|
|
157
|
+
.no(User)
|
|
158
|
+
.any(Tenant)
|
|
159
|
+
.any(Administrator)
|
|
160
|
+
.any(Event)
|
|
161
|
+
.any(Competitor)
|
|
162
|
+
.any(Finalist)
|
|
163
|
+
.any(CompetitorName);
|
|
164
|
+
|
|
165
|
+
const distributionRules = new DistributionRules([])
|
|
166
|
+
.share(model.given(Event).select((event, facts) => ({
|
|
167
|
+
// Finalist is only a traversal step toward the competitor projection.
|
|
168
|
+
finalists: facts.ofType(Finalist)
|
|
169
|
+
.join(finalist => finalist.event, event)
|
|
170
|
+
.selectMany(finalist => finalist.competitor.predecessor()
|
|
171
|
+
.select(competitor => ({
|
|
172
|
+
competitorNames: facts.ofType(CompetitorName)
|
|
173
|
+
.join(name => name.competitor, competitor)
|
|
174
|
+
.notExists(name => facts.ofType(CompetitorName)
|
|
175
|
+
.join(next => next.prior, name)
|
|
176
|
+
)
|
|
177
|
+
}))
|
|
178
|
+
)
|
|
179
|
+
})))
|
|
180
|
+
.with(model.given(Event).match((event, facts) =>
|
|
181
|
+
facts.ofType(Administrator)
|
|
182
|
+
.join(a => a.tenant, event.tenant)
|
|
183
|
+
.selectMany(a => facts.ofType(User).join(u => u, a.user))
|
|
184
|
+
));
|
|
185
|
+
|
|
186
|
+
const authorization = new AuthorizationKeystore(
|
|
187
|
+
factManager, storage, keystore, authorizationRules, distributionRules);
|
|
188
|
+
return { authorization, event };
|
|
189
|
+
}
|
|
@@ -68,6 +68,8 @@ describe("AuthorizationKeystore.verifyDistributionOrIntersect", () => {
|
|
|
68
68
|
expect(result.branches).toHaveLength(1);
|
|
69
69
|
expect(result.branches[0].specification).toBe(officeSpec);
|
|
70
70
|
expect(result.branches[0].start).toEqual([companyRef]);
|
|
71
|
+
// Pass-through authorized: no engine denial is carried.
|
|
72
|
+
expect(result.denial).toBeUndefined();
|
|
71
73
|
});
|
|
72
74
|
|
|
73
75
|
it("returns intersected branches when the user is not yet authorized", async () => {
|
|
@@ -100,6 +102,12 @@ describe("AuthorizationKeystore.verifyDistributionOrIntersect", () => {
|
|
|
100
102
|
type: setup.subscriberFact.type,
|
|
101
103
|
hash: setup.subscriberFact.hash
|
|
102
104
|
});
|
|
105
|
+
// Authorized-via-intersection (reactive) carries the engine failure
|
|
106
|
+
// that triggered it: the authenticated subscriber is excluded until
|
|
107
|
+
// the authorizing Administrator fact arrives.
|
|
108
|
+
expect(result.denial).toBeDefined();
|
|
109
|
+
expect(result.denial?.code).toBe("principal-excluded");
|
|
110
|
+
expect(result.denial?.reason).toBeTruthy();
|
|
103
111
|
});
|
|
104
112
|
|
|
105
113
|
it("returns denied when no distribution rule applies to the spec", async () => {
|
|
@@ -122,6 +130,8 @@ describe("AuthorizationKeystore.verifyDistributionOrIntersect", () => {
|
|
|
122
130
|
expect(result.type).toBe("denied");
|
|
123
131
|
if (result.type !== "denied") return;
|
|
124
132
|
expect(result.reason).toBeTruthy();
|
|
133
|
+
// No rule covers this spec at all.
|
|
134
|
+
expect(result.code).toBe("no-matching-rule");
|
|
125
135
|
});
|
|
126
136
|
|
|
127
137
|
it("returns a passthrough branch when there are no distribution rules at all", async () => {
|
|
@@ -144,6 +154,8 @@ describe("AuthorizationKeystore.verifyDistributionOrIntersect", () => {
|
|
|
144
154
|
if (result.type !== "success") return;
|
|
145
155
|
expect(result.branches).toHaveLength(1);
|
|
146
156
|
expect(result.branches[0].specification).toBe(officeSpec);
|
|
157
|
+
// No distribution engine, so nothing is denied — pure pass-through.
|
|
158
|
+
expect(result.denial).toBeUndefined();
|
|
147
159
|
});
|
|
148
160
|
});
|
|
149
161
|
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import {
|
|
2
|
+
buildFeeds,
|
|
3
|
+
computeObjectHash,
|
|
4
|
+
FactFeed,
|
|
5
|
+
FactManager,
|
|
6
|
+
FactReference,
|
|
7
|
+
FeedObject,
|
|
8
|
+
FeedResponse,
|
|
9
|
+
ProjectedResult,
|
|
10
|
+
ReferencesByName,
|
|
11
|
+
Specification,
|
|
12
|
+
SpecificationListener,
|
|
13
|
+
SpecificationParser
|
|
14
|
+
} from "jinaga";
|
|
15
|
+
|
|
16
|
+
import { FeedResult } from "../../src/authorization/authorization-keystore";
|
|
17
|
+
import { FeedStreamSession } from "../../src/feeds/feed-stream-session";
|
|
18
|
+
import { Stream } from "../../src/http/stream";
|
|
19
|
+
|
|
20
|
+
// Regression coverage for scenarios the primary unit spec does not exercise:
|
|
21
|
+
// a store whose bookmark actually advances across a real-time update, recovery
|
|
22
|
+
// of a fact that was dropped from the waitlist as "stale", and disposal while
|
|
23
|
+
// a query is in flight. These lock down the core streaming guarantees against
|
|
24
|
+
// future changes to the cycle/waitlist logic.
|
|
25
|
+
|
|
26
|
+
function buildFeedDefinition(): { feedDefinition: FeedObject; givenHash: string } {
|
|
27
|
+
const anchorHash = "anchorhash000000000000000000000000000000000000";
|
|
28
|
+
const input =
|
|
29
|
+
`let p: Jinaga.User = #${anchorHash}\n` +
|
|
30
|
+
`(p: Jinaga.User) {\n` +
|
|
31
|
+
` post: Test.Post [\n` +
|
|
32
|
+
` post->author: Jinaga.User = p\n` +
|
|
33
|
+
` ]\n` +
|
|
34
|
+
`} => post`;
|
|
35
|
+
const parser = new SpecificationParser(input);
|
|
36
|
+
parser.skipWhitespace();
|
|
37
|
+
const declaration = parser.parseDeclaration([]);
|
|
38
|
+
const specification = parser.parseSpecification();
|
|
39
|
+
const start = specification.given.map(g => {
|
|
40
|
+
const declared = declaration.find(d => d.name === g.label.name);
|
|
41
|
+
return declared!.declared.reference;
|
|
42
|
+
});
|
|
43
|
+
const namedStart = specification.given.reduce((map, g, index) => ({
|
|
44
|
+
...map,
|
|
45
|
+
[g.label.name]: start[index]
|
|
46
|
+
}), {} as ReferencesByName);
|
|
47
|
+
const feed = buildFeeds(specification)[0];
|
|
48
|
+
const feedDefinition: FeedObject = { feed, namedStart };
|
|
49
|
+
const givenHash = computeObjectHash(namedStart);
|
|
50
|
+
return { feedDefinition, givenHash };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
class FakeFactManager {
|
|
54
|
+
public listeners: { specification: Specification; onResult: (results: ProjectedResult[]) => Promise<void> }[] = [];
|
|
55
|
+
public removed: SpecificationListener[] = [];
|
|
56
|
+
addSpecificationListener(
|
|
57
|
+
specification: Specification,
|
|
58
|
+
onResult: (results: ProjectedResult[]) => Promise<void>
|
|
59
|
+
): SpecificationListener {
|
|
60
|
+
const listener = { specification, onResult } as unknown as SpecificationListener;
|
|
61
|
+
this.listeners.push({ specification, onResult });
|
|
62
|
+
return listener;
|
|
63
|
+
}
|
|
64
|
+
removeSpecificationListener(listener: SpecificationListener): void {
|
|
65
|
+
this.removed.push(listener);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function ref(type: string, hash: string): FactReference {
|
|
70
|
+
return { type, hash };
|
|
71
|
+
}
|
|
72
|
+
function tuple(...facts: FactReference[]): { facts: FactReference[]; bookmark: string } {
|
|
73
|
+
return { facts, bookmark: "" };
|
|
74
|
+
}
|
|
75
|
+
function page(bookmark: string, tuples: { facts: FactReference[]; bookmark: string }[]): FeedResult {
|
|
76
|
+
return { type: "success", feed: { tuples, bookmark } as FactFeed };
|
|
77
|
+
}
|
|
78
|
+
function emptyPage(bookmark: string): FeedResult {
|
|
79
|
+
return page(bookmark, []);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function makeSession(
|
|
83
|
+
query: (bookmark: string) => Promise<FeedResult>,
|
|
84
|
+
options: {
|
|
85
|
+
initialBookmark?: string;
|
|
86
|
+
config?: ConstructorParameters<typeof FeedStreamSession>[8];
|
|
87
|
+
stream?: Stream<FeedResponse>;
|
|
88
|
+
factManager?: FakeFactManager;
|
|
89
|
+
} = {}
|
|
90
|
+
): { session: FeedStreamSession; stream: Stream<FeedResponse>; received: FeedResponse[]; factManager: FakeFactManager } {
|
|
91
|
+
const { feedDefinition, givenHash } = buildFeedDefinition();
|
|
92
|
+
const start = feedDefinition.feed.given.map(g => feedDefinition.namedStart[g.label.name]);
|
|
93
|
+
const stream = options.stream ?? new Stream<FeedResponse>();
|
|
94
|
+
const received: FeedResponse[] = [];
|
|
95
|
+
stream.next(r => received.push(r));
|
|
96
|
+
const factManager = options.factManager ?? new FakeFactManager();
|
|
97
|
+
const session = new FeedStreamSession(
|
|
98
|
+
query,
|
|
99
|
+
factManager as unknown as FactManager,
|
|
100
|
+
feedDefinition,
|
|
101
|
+
start,
|
|
102
|
+
givenHash,
|
|
103
|
+
stream,
|
|
104
|
+
options.initialBookmark ?? "",
|
|
105
|
+
"regression-conn",
|
|
106
|
+
options.config ?? {}
|
|
107
|
+
);
|
|
108
|
+
return { session, stream, received, factManager };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function settle(): Promise<void> {
|
|
112
|
+
for (let i = 0; i < 20; i++) {
|
|
113
|
+
await new Promise<void>(resolve => setImmediate(resolve));
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
describe("FeedStreamSession regression", () => {
|
|
118
|
+
it("delivers a post-SYNC update on a paginating store and advances the bookmark", async () => {
|
|
119
|
+
const A = ref("Test.Post", "a");
|
|
120
|
+
const B = ref("Test.Post", "b");
|
|
121
|
+
// A realistic paginating store keyed by bookmark: A is history at "";
|
|
122
|
+
// after SYNC, B is delivered past "bm1" and the bookmark moves to "bm2".
|
|
123
|
+
const query = jest.fn(async (bookmark: string) => {
|
|
124
|
+
if (bookmark === "") return page("bm1", [tuple(A)]);
|
|
125
|
+
if (bookmark === "bm1") return emptyPage("bm1");
|
|
126
|
+
if (bookmark === "bm1+B") return page("bm2", [tuple(B)]);
|
|
127
|
+
return emptyPage("bm2");
|
|
128
|
+
});
|
|
129
|
+
// Second cycle needs a distinct bookmark once B is available; emulate
|
|
130
|
+
// that by having onInverseMatch flip the store's view.
|
|
131
|
+
let bReady = false;
|
|
132
|
+
const wrapped = jest.fn(async (bookmark: string) => {
|
|
133
|
+
if (bookmark === "bm1" && bReady) return page("bm2", [tuple(B)]);
|
|
134
|
+
return query(bookmark);
|
|
135
|
+
});
|
|
136
|
+
const { session, received } = makeSession(wrapped, { config: { pageDelayEvery: 0 } });
|
|
137
|
+
|
|
138
|
+
session.start();
|
|
139
|
+
await settle();
|
|
140
|
+
// Backfill delivered A then a SYNC frame at bm1.
|
|
141
|
+
expect(received).toEqual([
|
|
142
|
+
{ references: [A], bookmark: "bm1" },
|
|
143
|
+
{ references: [], bookmark: "bm1" }
|
|
144
|
+
]);
|
|
145
|
+
|
|
146
|
+
// A real-time update arrives after SYNC.
|
|
147
|
+
bReady = true;
|
|
148
|
+
session.onInverseMatch([B]);
|
|
149
|
+
await settle();
|
|
150
|
+
|
|
151
|
+
expect(received.slice(2)).toEqual([
|
|
152
|
+
{ references: [B], bookmark: "bm2" },
|
|
153
|
+
{ references: [], bookmark: "bm2" }
|
|
154
|
+
]);
|
|
155
|
+
// Bookmark advanced monotonically and never regressed.
|
|
156
|
+
expect(received.map(r => r.bookmark)).toEqual(["bm1", "bm1", "bm2", "bm2"]);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("re-delivers a fact that was dropped as stale once a later trigger makes it completable", async () => {
|
|
160
|
+
const A = ref("Test.Post", "a");
|
|
161
|
+
let completable = false;
|
|
162
|
+
// Bookmark-respecting store: A only becomes visible past "" once its
|
|
163
|
+
// tuple is completable; afterward the bookmark advances to "bm1".
|
|
164
|
+
const query = jest.fn(async (bookmark: string) => {
|
|
165
|
+
if (completable && bookmark === "") return page("bm1", [tuple(A)]);
|
|
166
|
+
return emptyPage(bookmark === "" ? "" : "bm1");
|
|
167
|
+
});
|
|
168
|
+
const { session, received } = makeSession(query);
|
|
169
|
+
|
|
170
|
+
// First trigger: A's tuple cannot complete, so it is dropped and a
|
|
171
|
+
// SYNC frame is emitted without ever delivering A.
|
|
172
|
+
session.onInverseMatch([A]);
|
|
173
|
+
await settle();
|
|
174
|
+
expect(received).toEqual([{ references: [], bookmark: "" }]);
|
|
175
|
+
expect(session.waitlistSize).toBe(0);
|
|
176
|
+
|
|
177
|
+
// A becomes completable; a later trigger re-adds it and it is delivered.
|
|
178
|
+
completable = true;
|
|
179
|
+
session.onInverseMatch([A]);
|
|
180
|
+
await settle();
|
|
181
|
+
expect(received.slice(1)).toEqual([
|
|
182
|
+
{ references: [A], bookmark: "bm1" },
|
|
183
|
+
{ references: [], bookmark: "bm1" }
|
|
184
|
+
]);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it("does not run concurrent cycles under a burst of triggers", async () => {
|
|
188
|
+
let concurrent = 0;
|
|
189
|
+
let maxConcurrent = 0;
|
|
190
|
+
const query = jest.fn(async () => {
|
|
191
|
+
concurrent++;
|
|
192
|
+
maxConcurrent = Math.max(maxConcurrent, concurrent);
|
|
193
|
+
await new Promise<void>(resolve => setImmediate(resolve));
|
|
194
|
+
concurrent--;
|
|
195
|
+
return emptyPage("");
|
|
196
|
+
});
|
|
197
|
+
const A = ref("Test.Post", "a");
|
|
198
|
+
const { session } = makeSession(query);
|
|
199
|
+
|
|
200
|
+
session.start();
|
|
201
|
+
for (let i = 0; i < 8; i++) {
|
|
202
|
+
session.onInverseMatch([A]);
|
|
203
|
+
}
|
|
204
|
+
await settle();
|
|
205
|
+
|
|
206
|
+
expect(maxConcurrent).toBe(1);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it("streams nothing once disposed while a query is in flight", async () => {
|
|
210
|
+
const A = ref("Test.Post", "a");
|
|
211
|
+
let resolveQuery: (value: FeedResult) => void = () => { };
|
|
212
|
+
const query = jest.fn(() => new Promise<FeedResult>(resolve => { resolveQuery = resolve; }));
|
|
213
|
+
const { session, received } = makeSession(query);
|
|
214
|
+
|
|
215
|
+
session.start();
|
|
216
|
+
// Let the cycle enter the awaited query.
|
|
217
|
+
await new Promise<void>(resolve => setImmediate(resolve));
|
|
218
|
+
|
|
219
|
+
// Dispose mid-query, then let the query resolve with a full page.
|
|
220
|
+
session.dispose();
|
|
221
|
+
resolveQuery(page("bm1", [tuple(A)]));
|
|
222
|
+
await settle();
|
|
223
|
+
|
|
224
|
+
// The post-dispose page must not be streamed.
|
|
225
|
+
expect(received).toEqual([]);
|
|
226
|
+
});
|
|
227
|
+
});
|