jinaga-server 3.5.5 → 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 +34 -2
- package/dist/authorization/authorization-keystore.js +59 -4
- 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 +10 -3
- package/dist/http/router.js +186 -184
- 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 +19 -2
- 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/design/feeds-persistent-proxy-plan.md +11 -1
- 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 +106 -5
- package/src/feeds/feed-stream-session.ts +386 -0
- package/src/http/router.ts +212 -237
- package/src/index.ts +2 -1
- package/src/jinaga-server.ts +45 -5
- 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/intermediateProjectionFeedSpec.ts +189 -0
- package/test/authorization/subscriptionIntersectionSpec.ts +220 -0
- package/test/feeds/feedStreamSessionRegressionSpec.ts +227 -0
- package/test/feeds/feedStreamSessionSpec.ts +351 -0
- package/test/http/anchorListenerSpec.ts +117 -0
- package/test/http/feedStreamSessionIntegrationSpec.ts +202 -0
- package/test/http/lateAuthRecoverySpec.ts +435 -0
- package/test/http/streamFeedAnchorSpec.ts +171 -0
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,351 @@
|
|
|
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
|
+
// Build a real, invertible feed specification so the session can register
|
|
21
|
+
// inverse and anchor listeners during start().
|
|
22
|
+
function buildFeedDefinition(): { feedDefinition: FeedObject; givenHash: string } {
|
|
23
|
+
const anchorHash = "anchorhash000000000000000000000000000000000000";
|
|
24
|
+
const input =
|
|
25
|
+
`let p: Jinaga.User = #${anchorHash}\n` +
|
|
26
|
+
`(p: Jinaga.User) {\n` +
|
|
27
|
+
` post: Test.Post [\n` +
|
|
28
|
+
` post->author: Jinaga.User = p\n` +
|
|
29
|
+
` ]\n` +
|
|
30
|
+
`} => post`;
|
|
31
|
+
const parser = new SpecificationParser(input);
|
|
32
|
+
parser.skipWhitespace();
|
|
33
|
+
const declaration = parser.parseDeclaration([]);
|
|
34
|
+
const specification = parser.parseSpecification();
|
|
35
|
+
const start = specification.given.map(g => {
|
|
36
|
+
const declared = declaration.find(d => d.name === g.label.name);
|
|
37
|
+
return declared!.declared.reference;
|
|
38
|
+
});
|
|
39
|
+
const namedStart = specification.given.reduce((map, g, index) => ({
|
|
40
|
+
...map,
|
|
41
|
+
[g.label.name]: start[index]
|
|
42
|
+
}), {} as ReferencesByName);
|
|
43
|
+
const feed = buildFeeds(specification)[0];
|
|
44
|
+
const feedDefinition: FeedObject = { feed, namedStart };
|
|
45
|
+
const givenHash = computeObjectHash(namedStart);
|
|
46
|
+
return { feedDefinition, givenHash };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// A FactManager test double that records listeners and lets tests drive them.
|
|
50
|
+
class FakeFactManager {
|
|
51
|
+
public listeners: { specification: Specification; onResult: (results: ProjectedResult[]) => Promise<void> }[] = [];
|
|
52
|
+
public removed: SpecificationListener[] = [];
|
|
53
|
+
|
|
54
|
+
addSpecificationListener(
|
|
55
|
+
specification: Specification,
|
|
56
|
+
onResult: (results: ProjectedResult[]) => Promise<void>
|
|
57
|
+
): SpecificationListener {
|
|
58
|
+
const listener = { specification, onResult } as unknown as SpecificationListener;
|
|
59
|
+
this.listeners.push({ specification, onResult });
|
|
60
|
+
return listener;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
removeSpecificationListener(listener: SpecificationListener): void {
|
|
64
|
+
this.removed.push(listener);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function ref(type: string, hash: string): FactReference {
|
|
69
|
+
return { type, hash };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function tuple(...facts: FactReference[]): { facts: FactReference[]; bookmark: string } {
|
|
73
|
+
return { facts, bookmark: "" };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function page(bookmark: string, tuples: { facts: FactReference[]; bookmark: string }[]): FeedResult {
|
|
77
|
+
const feed: FactFeed = { tuples, bookmark };
|
|
78
|
+
return { type: "success", feed };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function emptyPage(bookmark: string): FeedResult {
|
|
82
|
+
return page(bookmark, []);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function makeSession(
|
|
86
|
+
query: (bookmark: string) => Promise<FeedResult>,
|
|
87
|
+
options: {
|
|
88
|
+
initialBookmark?: string;
|
|
89
|
+
config?: ConstructorParameters<typeof FeedStreamSession>[8];
|
|
90
|
+
stream?: Stream<FeedResponse>;
|
|
91
|
+
factManager?: FakeFactManager;
|
|
92
|
+
} = {}
|
|
93
|
+
): { session: FeedStreamSession; stream: Stream<FeedResponse>; received: FeedResponse[]; factManager: FakeFactManager } {
|
|
94
|
+
const { feedDefinition, givenHash } = buildFeedDefinition();
|
|
95
|
+
const start = feedDefinition.feed.given.map(g => feedDefinition.namedStart[g.label.name]);
|
|
96
|
+
const stream = options.stream ?? new Stream<FeedResponse>();
|
|
97
|
+
const received: FeedResponse[] = [];
|
|
98
|
+
stream.next(r => received.push(r));
|
|
99
|
+
const factManager = options.factManager ?? new FakeFactManager();
|
|
100
|
+
const session = new FeedStreamSession(
|
|
101
|
+
query,
|
|
102
|
+
factManager as unknown as FactManager,
|
|
103
|
+
feedDefinition,
|
|
104
|
+
start,
|
|
105
|
+
givenHash,
|
|
106
|
+
stream,
|
|
107
|
+
options.initialBookmark ?? "",
|
|
108
|
+
"test-conn",
|
|
109
|
+
options.config ?? {}
|
|
110
|
+
);
|
|
111
|
+
return { session, stream, received, factManager };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async function settle(): Promise<void> {
|
|
115
|
+
for (let i = 0; i < 10; i++) {
|
|
116
|
+
await new Promise<void>(resolve => setImmediate(resolve));
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
describe("FeedStreamSession", () => {
|
|
121
|
+
it("backfills to exhaustion then emits a SYNC frame", async () => {
|
|
122
|
+
const A = ref("Test.Post", "a");
|
|
123
|
+
const B = ref("Test.Post", "b");
|
|
124
|
+
const C = ref("Test.Post", "c");
|
|
125
|
+
const responses = [
|
|
126
|
+
page("bm1", [tuple(A), tuple(B)]),
|
|
127
|
+
page("bm2", [tuple(C)]),
|
|
128
|
+
emptyPage("bm2")
|
|
129
|
+
];
|
|
130
|
+
let call = 0;
|
|
131
|
+
const { session, received } = makeSession(async () => responses[call++]);
|
|
132
|
+
|
|
133
|
+
session.start();
|
|
134
|
+
await settle();
|
|
135
|
+
|
|
136
|
+
expect(received).toEqual([
|
|
137
|
+
{ references: [A, B], bookmark: "bm1" },
|
|
138
|
+
{ references: [C], bookmark: "bm2" },
|
|
139
|
+
{ references: [], bookmark: "bm2" }
|
|
140
|
+
]);
|
|
141
|
+
// The last frame is the SYNC frame: empty references, current bookmark.
|
|
142
|
+
expect(received[received.length - 1].references).toEqual([]);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("emits a SYNC frame immediately when there is no history", async () => {
|
|
146
|
+
const { session, received } = makeSession(async () => emptyPage(""));
|
|
147
|
+
|
|
148
|
+
session.start();
|
|
149
|
+
await settle();
|
|
150
|
+
|
|
151
|
+
expect(received).toEqual([{ references: [], bookmark: "" }]);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("deduplicates facts across tuples within a page", async () => {
|
|
155
|
+
const A = ref("Test.Post", "a");
|
|
156
|
+
const B = ref("Test.Post", "b");
|
|
157
|
+
const C = ref("Test.Post", "c");
|
|
158
|
+
const responses = [
|
|
159
|
+
page("bm1", [tuple(A, B), tuple(B, C)]),
|
|
160
|
+
emptyPage("bm1")
|
|
161
|
+
];
|
|
162
|
+
let call = 0;
|
|
163
|
+
const { session, received } = makeSession(async () => responses[call++]);
|
|
164
|
+
|
|
165
|
+
session.start();
|
|
166
|
+
await settle();
|
|
167
|
+
|
|
168
|
+
expect(received[0].references).toEqual([A, B, C]);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it("continues querying when facts arrive during a query (waitlist continuation)", async () => {
|
|
172
|
+
const A = ref("Test.Post", "a");
|
|
173
|
+
let call = 0;
|
|
174
|
+
let sessionRef: FeedStreamSession | null = null;
|
|
175
|
+
const query = jest.fn(async () => {
|
|
176
|
+
call++;
|
|
177
|
+
if (call === 1) {
|
|
178
|
+
// A fact arrives mid-query: enqueue it onto the waitlist.
|
|
179
|
+
sessionRef!.onInverseMatch([A]);
|
|
180
|
+
return emptyPage("");
|
|
181
|
+
}
|
|
182
|
+
if (call === 2) {
|
|
183
|
+
return page("bm1", [tuple(A)]);
|
|
184
|
+
}
|
|
185
|
+
return emptyPage("bm1");
|
|
186
|
+
});
|
|
187
|
+
const made = makeSession(query);
|
|
188
|
+
sessionRef = made.session;
|
|
189
|
+
|
|
190
|
+
made.session.start();
|
|
191
|
+
await settle();
|
|
192
|
+
|
|
193
|
+
// First empty + waitlist non-empty triggered a re-query that found A,
|
|
194
|
+
// then a final empty query produced the SYNC frame.
|
|
195
|
+
expect(made.received).toEqual([
|
|
196
|
+
{ references: [A], bookmark: "bm1" },
|
|
197
|
+
{ references: [], bookmark: "bm1" }
|
|
198
|
+
]);
|
|
199
|
+
expect(query).toHaveBeenCalledTimes(3);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it("removes a fact from the waitlist only when its tuple is streamed", async () => {
|
|
203
|
+
const A = ref("Test.Post", "a");
|
|
204
|
+
const responses = [
|
|
205
|
+
page("bm1", [tuple(A)]),
|
|
206
|
+
emptyPage("bm1")
|
|
207
|
+
];
|
|
208
|
+
let call = 0;
|
|
209
|
+
const { session, received } = makeSession(async () => responses[call++ ] ?? emptyPage("bm1"));
|
|
210
|
+
|
|
211
|
+
session.onInverseMatch([A]);
|
|
212
|
+
expect(session.waitlistSize).toBe(1);
|
|
213
|
+
await settle();
|
|
214
|
+
|
|
215
|
+
// Once A's tuple is streamed, it is removed and the cycle reaches SYNC.
|
|
216
|
+
expect(session.waitlistSize).toBe(0);
|
|
217
|
+
expect(received).toEqual([
|
|
218
|
+
{ references: [A], bookmark: "bm1" },
|
|
219
|
+
{ references: [], bookmark: "bm1" }
|
|
220
|
+
]);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it("drops stale waitlist entries that never complete and emits SYNC", async () => {
|
|
224
|
+
const A = ref("Test.Post", "a");
|
|
225
|
+
const { session, received } = makeSession(async () => emptyPage(""));
|
|
226
|
+
|
|
227
|
+
session.onInverseMatch([A]);
|
|
228
|
+
expect(session.waitlistSize).toBe(1);
|
|
229
|
+
await settle();
|
|
230
|
+
|
|
231
|
+
// The query never returns A's tuple and no new trigger arrives, so the
|
|
232
|
+
// stale entry is dropped and a SYNC frame is emitted.
|
|
233
|
+
expect(session.waitlistSize).toBe(0);
|
|
234
|
+
expect(received).toEqual([{ references: [], bookmark: "" }]);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it("runs only one cycle at a time", async () => {
|
|
238
|
+
const A = ref("Test.Post", "a");
|
|
239
|
+
let concurrent = 0;
|
|
240
|
+
let maxConcurrent = 0;
|
|
241
|
+
let call = 0;
|
|
242
|
+
const query = jest.fn(async (_bookmark: string) => {
|
|
243
|
+
concurrent++;
|
|
244
|
+
maxConcurrent = Math.max(maxConcurrent, concurrent);
|
|
245
|
+
await new Promise<void>(resolve => setImmediate(resolve));
|
|
246
|
+
concurrent--;
|
|
247
|
+
call++;
|
|
248
|
+
return emptyPage("");
|
|
249
|
+
});
|
|
250
|
+
const { session } = makeSession(query);
|
|
251
|
+
|
|
252
|
+
// Fire several wake-ups in quick succession.
|
|
253
|
+
session.onInverseMatch([A]);
|
|
254
|
+
session.onInverseMatch([A]);
|
|
255
|
+
session.onInverseMatch([A]);
|
|
256
|
+
await settle();
|
|
257
|
+
|
|
258
|
+
expect(maxConcurrent).toBe(1);
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it("caps the waitlist and closes the stream when exceeded", async () => {
|
|
262
|
+
const stream = new Stream<FeedResponse>();
|
|
263
|
+
const closeSpy = jest.spyOn(stream, "close");
|
|
264
|
+
// A query that never advances so the cycle cannot drain the waitlist.
|
|
265
|
+
const { session, factManager } = makeSession(
|
|
266
|
+
async () => emptyPage(""),
|
|
267
|
+
{ stream, config: { waitlistCap: 2 } }
|
|
268
|
+
);
|
|
269
|
+
|
|
270
|
+
session.onInverseMatch([
|
|
271
|
+
ref("Test.Post", "a"),
|
|
272
|
+
ref("Test.Post", "b"),
|
|
273
|
+
ref("Test.Post", "c")
|
|
274
|
+
]);
|
|
275
|
+
|
|
276
|
+
expect(closeSpy).toHaveBeenCalled();
|
|
277
|
+
// Listeners are torn down on graceful close.
|
|
278
|
+
expect(factManager.removed.length).toBe(factManager.listeners.length);
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
it("stops after the configured maximum number of pages", async () => {
|
|
282
|
+
let call = 0;
|
|
283
|
+
const query = jest.fn(async () => page(`bm${++call}`, [tuple(ref("Test.Post", `p${call}`))]));
|
|
284
|
+
const { session, received } = makeSession(query, { config: { maxInitialPages: 2, pageDelayEvery: 0 } });
|
|
285
|
+
|
|
286
|
+
session.start();
|
|
287
|
+
await settle();
|
|
288
|
+
|
|
289
|
+
// Exactly two pages streamed, then the cycle stops (no SYNC frame).
|
|
290
|
+
expect(query).toHaveBeenCalledTimes(2);
|
|
291
|
+
expect(received.length).toBe(2);
|
|
292
|
+
expect(received.every(r => r.references.length > 0)).toBe(true);
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
it("keeps the stream alive without SYNC when a query is denied", async () => {
|
|
296
|
+
const denied: FeedResult = { type: "denied", reason: "not authorized" };
|
|
297
|
+
const { session, received, stream } = makeSession(async () => denied);
|
|
298
|
+
|
|
299
|
+
session.start();
|
|
300
|
+
await settle();
|
|
301
|
+
|
|
302
|
+
expect(received).toEqual([]);
|
|
303
|
+
// Stream remains open so a later authorizing fact can wake the cycle.
|
|
304
|
+
expect((stream as any).closed).toBe(false);
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
it("only enqueues inverse results whose given subset matches", async () => {
|
|
308
|
+
const A = ref("Test.Post", "a");
|
|
309
|
+
const query = jest.fn(async () => emptyPage(""));
|
|
310
|
+
const { session, factManager } = makeSession(query);
|
|
311
|
+
|
|
312
|
+
session.start();
|
|
313
|
+
await settle();
|
|
314
|
+
query.mockClear();
|
|
315
|
+
|
|
316
|
+
const inverseListener = factManager.listeners[0];
|
|
317
|
+
expect(inverseListener).toBeDefined();
|
|
318
|
+
|
|
319
|
+
// A result whose tuple does not match this subscription's given.
|
|
320
|
+
const nonMatching: ProjectedResult = {
|
|
321
|
+
tuple: { p: ref("Jinaga.User", "someone-else"), post: A },
|
|
322
|
+
result: A
|
|
323
|
+
};
|
|
324
|
+
await inverseListener.onResult([nonMatching]);
|
|
325
|
+
await settle();
|
|
326
|
+
|
|
327
|
+
// The non-matching result is filtered out: no new cycle/query runs.
|
|
328
|
+
expect(query).not.toHaveBeenCalled();
|
|
329
|
+
expect(session.waitlistSize).toBe(0);
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
it("removes all listeners and stops cycling on dispose", async () => {
|
|
333
|
+
const A = ref("Test.Post", "a");
|
|
334
|
+
const query = jest.fn(async () => emptyPage(""));
|
|
335
|
+
const { session, factManager } = makeSession(query);
|
|
336
|
+
|
|
337
|
+
session.start();
|
|
338
|
+
await settle();
|
|
339
|
+
const registered = factManager.listeners.length;
|
|
340
|
+
expect(registered).toBeGreaterThan(0);
|
|
341
|
+
|
|
342
|
+
session.dispose();
|
|
343
|
+
expect(factManager.removed.length).toBe(registered);
|
|
344
|
+
|
|
345
|
+
query.mockClear();
|
|
346
|
+
// After dispose, further triggers are ignored.
|
|
347
|
+
session.onInverseMatch([A]);
|
|
348
|
+
await settle();
|
|
349
|
+
expect(query).not.toHaveBeenCalled();
|
|
350
|
+
});
|
|
351
|
+
});
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import {
|
|
2
|
+
buildModel,
|
|
3
|
+
dehydrateFact,
|
|
4
|
+
FactManager,
|
|
5
|
+
MemoryStore,
|
|
6
|
+
NetworkNoOp,
|
|
7
|
+
ObservableSource,
|
|
8
|
+
PassThroughFork,
|
|
9
|
+
Specification,
|
|
10
|
+
User
|
|
11
|
+
} from "jinaga";
|
|
12
|
+
|
|
13
|
+
// Validates the same listener pattern HttpRouter.streamFeed uses to wake
|
|
14
|
+
// subscriptions when an anchor (given) fact finally arrives at the
|
|
15
|
+
// server. The pattern is: register a SpecificationListener on a spec
|
|
16
|
+
// whose only given matches the anchor's type and whose matches array is
|
|
17
|
+
// empty, then filter the projected results by the anchor hash.
|
|
18
|
+
class Note {
|
|
19
|
+
public static Type = "anchor.Note" as const;
|
|
20
|
+
public type = Note.Type;
|
|
21
|
+
constructor(public author: User, public body: string) { }
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const model = buildModel(b => b
|
|
25
|
+
.type(User)
|
|
26
|
+
.type(Note, m => m.predecessor("author", User))
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
function makeFactManager() {
|
|
30
|
+
const storage = new MemoryStore();
|
|
31
|
+
const fork = new PassThroughFork(storage);
|
|
32
|
+
const observable = new ObservableSource(storage);
|
|
33
|
+
const network = new NetworkNoOp();
|
|
34
|
+
const factManager = new FactManager(fork, observable, storage, network, []);
|
|
35
|
+
return { storage, factManager };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
describe("anchor listener pattern", () => {
|
|
39
|
+
it("fires when a fact matching the anchor's (type, hash) is saved", async () => {
|
|
40
|
+
const { factManager } = makeFactManager();
|
|
41
|
+
|
|
42
|
+
const author = new User("anchor-author-key");
|
|
43
|
+
const authorRef = dehydrateFact(author)[0];
|
|
44
|
+
|
|
45
|
+
let firedFor: string | null = null;
|
|
46
|
+
const anchorSpec: Specification = {
|
|
47
|
+
given: [{ label: { name: "x", type: authorRef.type }, conditions: [] }],
|
|
48
|
+
matches: [],
|
|
49
|
+
projection: { type: "fact", label: "x" }
|
|
50
|
+
};
|
|
51
|
+
const listener = factManager.addSpecificationListener(anchorSpec, async (results) => {
|
|
52
|
+
for (const r of results) {
|
|
53
|
+
const ref = (r.tuple as any).x;
|
|
54
|
+
if (ref && ref.type === authorRef.type && ref.hash === authorRef.hash) {
|
|
55
|
+
firedFor = ref.hash;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
await factManager.save(dehydrateFact(author).map(f => ({ fact: f, signatures: [] })));
|
|
61
|
+
|
|
62
|
+
factManager.removeSpecificationListener(listener);
|
|
63
|
+
expect(firedFor).toBe(authorRef.hash);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("does not fire for facts of an unrelated type", async () => {
|
|
67
|
+
const { factManager } = makeFactManager();
|
|
68
|
+
|
|
69
|
+
const author = new User("anchor-author-key");
|
|
70
|
+
const note = new Note(author, "hi");
|
|
71
|
+
const noteRef = dehydrateFact(note).find(f => f.type === Note.Type)!;
|
|
72
|
+
|
|
73
|
+
let fired = false;
|
|
74
|
+
const anchorSpec: Specification = {
|
|
75
|
+
given: [{ label: { name: "x", type: noteRef.type }, conditions: [] }],
|
|
76
|
+
matches: [],
|
|
77
|
+
projection: { type: "fact", label: "x" }
|
|
78
|
+
};
|
|
79
|
+
const listener = factManager.addSpecificationListener(anchorSpec, async () => {
|
|
80
|
+
fired = true;
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
// Save a fact of a different type only.
|
|
84
|
+
await factManager.save(dehydrateFact(author).map(f => ({ fact: f, signatures: [] })));
|
|
85
|
+
|
|
86
|
+
factManager.removeSpecificationListener(listener);
|
|
87
|
+
expect(fired).toBe(false);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("does not fire for facts of the right type but a different hash", async () => {
|
|
91
|
+
const { factManager } = makeFactManager();
|
|
92
|
+
|
|
93
|
+
const expected = new User("expected-key");
|
|
94
|
+
const expectedRef = dehydrateFact(expected)[0];
|
|
95
|
+
const other = new User("other-key");
|
|
96
|
+
|
|
97
|
+
let matched = false;
|
|
98
|
+
const anchorSpec: Specification = {
|
|
99
|
+
given: [{ label: { name: "x", type: expectedRef.type }, conditions: [] }],
|
|
100
|
+
matches: [],
|
|
101
|
+
projection: { type: "fact", label: "x" }
|
|
102
|
+
};
|
|
103
|
+
const listener = factManager.addSpecificationListener(anchorSpec, async (results) => {
|
|
104
|
+
for (const r of results) {
|
|
105
|
+
const ref = (r.tuple as any).x;
|
|
106
|
+
if (ref && ref.type === expectedRef.type && ref.hash === expectedRef.hash) {
|
|
107
|
+
matched = true;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
await factManager.save(dehydrateFact(other).map(f => ({ fact: f, signatures: [] })));
|
|
113
|
+
|
|
114
|
+
factManager.removeSpecificationListener(listener);
|
|
115
|
+
expect(matched).toBe(false);
|
|
116
|
+
});
|
|
117
|
+
});
|