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.
Files changed (32) hide show
  1. package/dist/authorization/authorization-keystore.d.ts +7 -1
  2. package/dist/authorization/authorization-keystore.js +16 -5
  3. package/dist/authorization/authorization-keystore.js.map +1 -1
  4. package/dist/feeds/feed-stream-session.d.ts +84 -0
  5. package/dist/feeds/feed-stream-session.js +331 -0
  6. package/dist/feeds/feed-stream-session.js.map +1 -0
  7. package/dist/http/router.d.ts +7 -3
  8. package/dist/http/router.js +130 -238
  9. package/dist/http/router.js.map +1 -1
  10. package/dist/index.d.ts +2 -1
  11. package/dist/index.js +5 -1
  12. package/dist/index.js.map +1 -1
  13. package/dist/jinaga-server.d.ts +7 -0
  14. package/dist/jinaga-server.js +1 -1
  15. package/dist/jinaga-server.js.map +1 -1
  16. package/docs/design/feeds-persistent-proxy-plan.md +11 -1
  17. package/integration-test/jinaga-test/package-lock.json +4 -4
  18. package/integration-test/jinaga-test/package.json +1 -1
  19. package/package.json +2 -2
  20. package/src/authorization/authorization-keystore.ts +33 -7
  21. package/src/feeds/feed-stream-session.ts +386 -0
  22. package/src/http/router.ts +149 -289
  23. package/src/index.ts +2 -1
  24. package/src/jinaga-server.ts +9 -2
  25. package/test/authorization/intermediateProjectionFeedSpec.ts +189 -0
  26. package/test/authorization/subscriptionIntersectionSpec.ts +12 -0
  27. package/test/feeds/feedStreamSessionRegressionSpec.ts +227 -0
  28. package/test/feeds/feedStreamSessionSpec.ts +351 -0
  29. package/test/http/feedStreamSessionIntegrationSpec.ts +202 -0
  30. package/test/http/graphSaveMultiHopAuthSpec.ts +126 -0
  31. package/test/http/lateAuthRecoverySpec.ts +191 -1
  32. package/test/http/streamFeedAnchorSpec.ts +3 -1
@@ -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,202 @@
1
+ import {
2
+ AuthorizationRules,
3
+ buildModel,
4
+ dehydrateFact,
5
+ FactManager,
6
+ FeedCache,
7
+ FeedResponse,
8
+ MemoryStore,
9
+ NetworkNoOp,
10
+ ObservableSource,
11
+ PassThroughFork,
12
+ User
13
+ } from "jinaga";
14
+
15
+ import { AuthorizationKeystore } from "../../src/authorization/authorization-keystore";
16
+ import { HttpRouter, RequestUser } from "../../src/http/router";
17
+ import { Stream } from "../../src/http/stream";
18
+ import { MemoryKeystore } from "../../src/memory/memory-keystore";
19
+
20
+ // End-to-end coverage of the FeedStreamSession wired through HttpRouter
21
+ // against a real in-memory FactManager. Exercises backfill, mid-backfill
22
+ // inserts, the SYNC frame, and post-SYNC real-time updates.
23
+ class Post {
24
+ public static Type = "stream.Post" as const;
25
+ public type = Post.Type;
26
+ constructor(public author: User, public body: string) { }
27
+ }
28
+
29
+ const model = buildModel(b => b
30
+ .type(User)
31
+ .type(Post, m => m.predecessor("author", User))
32
+ );
33
+
34
+ const userIdentity = { provider: "mock", id: "subscriber" };
35
+
36
+ interface Harness {
37
+ router: HttpRouter;
38
+ factManager: FactManager;
39
+ storage: MemoryStore;
40
+ requestUser: RequestUser;
41
+ author: User;
42
+ authorRef: { type: string; hash: string };
43
+ }
44
+
45
+ async function makeHarness(): Promise<Harness> {
46
+ const storage = new MemoryStore();
47
+ const keystore = new MemoryKeystore();
48
+ const userFact = await keystore.getOrCreateUserFact(userIdentity);
49
+ const author = new User(userFact.fields.publicKey);
50
+ const authorRef = dehydrateFact(author)[0];
51
+
52
+ const fork = new PassThroughFork(storage);
53
+ const observable = new ObservableSource(storage);
54
+ const network = new NetworkNoOp();
55
+ const factManager = new FactManager(fork, observable, storage, network, []);
56
+
57
+ const authorizationRules = new AuthorizationRules(model)
58
+ .any(User)
59
+ .any(Post);
60
+ const authorization = new AuthorizationKeystore(
61
+ factManager, storage, keystore, authorizationRules, null);
62
+ const feedCache = new FeedCache();
63
+
64
+ const router = new HttpRouter(factManager, authorization, feedCache, "*");
65
+
66
+ return {
67
+ router,
68
+ factManager,
69
+ storage,
70
+ requestUser: { provider: userIdentity.provider, id: userIdentity.id, profile: {} as any },
71
+ author,
72
+ authorRef
73
+ };
74
+ }
75
+
76
+ async function waitForMicrotasks(): Promise<void> {
77
+ for (let i = 0; i < 10; i++) {
78
+ await new Promise<void>(resolve => setImmediate(resolve));
79
+ }
80
+ }
81
+
82
+ function feedInput(authorHash: string): string {
83
+ return (
84
+ `let p: Jinaga.User = #${authorHash}\n` +
85
+ `(p: Jinaga.User) {\n` +
86
+ ` post: stream.Post [\n` +
87
+ ` post->author: Jinaga.User = p\n` +
88
+ ` ]\n` +
89
+ `} => post`
90
+ );
91
+ }
92
+
93
+ async function savePost(h: Harness, body: string): Promise<void> {
94
+ const post = new Post(h.author, body);
95
+ const envelopes = dehydrateFact(post).map(f => ({ fact: f, signatures: [] }));
96
+ await h.factManager.save(envelopes);
97
+ }
98
+
99
+ describe("HttpRouter streamFeed session integration", () => {
100
+ it("backfills all historical posts and ends with a SYNC frame", async () => {
101
+ const h = await makeHarness();
102
+ await h.factManager.save(dehydrateFact(h.author).map(f => ({ fact: f, signatures: [] })));
103
+ for (let i = 0; i < 5; i++) {
104
+ await savePost(h, `post-${i}`);
105
+ }
106
+
107
+ const feedsResponse = await (h.router as any).feeds(h.requestUser, feedInput(h.authorRef.hash));
108
+ const feedHash: string = feedsResponse.feeds[0];
109
+
110
+ const stream: Stream<FeedResponse> = await (h.router as any).streamFeed(
111
+ h.requestUser, { hash: feedHash }, {});
112
+ const received: FeedResponse[] = [];
113
+ stream.next(r => received.push(r));
114
+
115
+ await waitForMicrotasks();
116
+ stream.close();
117
+
118
+ const distinctPosts = new Set(received.flatMap(r => r.references)
119
+ .filter(r => r.type === Post.Type).map(r => r.hash));
120
+ expect(distinctPosts.size).toBe(5);
121
+ // The final frame is the SYNC signal: empty references.
122
+ expect(received[received.length - 1].references).toEqual([]);
123
+ });
124
+
125
+ it("does not lose posts inserted during backfill", async () => {
126
+ const h = await makeHarness();
127
+ await h.factManager.save(dehydrateFact(h.author).map(f => ({ fact: f, signatures: [] })));
128
+ await savePost(h, "historical");
129
+
130
+ const feedsResponse = await (h.router as any).feeds(h.requestUser, feedInput(h.authorRef.hash));
131
+ const feedHash: string = feedsResponse.feeds[0];
132
+
133
+ const stream: Stream<FeedResponse> = await (h.router as any).streamFeed(
134
+ h.requestUser, { hash: feedHash }, {});
135
+ const received: FeedResponse[] = [];
136
+ stream.next(r => received.push(r));
137
+
138
+ // Insert a post immediately, racing the backfill.
139
+ await savePost(h, "raced");
140
+ await waitForMicrotasks();
141
+ stream.close();
142
+
143
+ const distinctPosts = new Set(received.flatMap(r => r.references)
144
+ .filter(r => r.type === Post.Type).map(r => r.hash));
145
+ // Both the historical and the raced post are delivered.
146
+ expect(distinctPosts.size).toBe(2);
147
+ expect(received[received.length - 1].references).toEqual([]);
148
+ });
149
+
150
+ it("delivers post-SYNC updates in subsequent frames", async () => {
151
+ const h = await makeHarness();
152
+ await h.factManager.save(dehydrateFact(h.author).map(f => ({ fact: f, signatures: [] })));
153
+
154
+ const feedsResponse = await (h.router as any).feeds(h.requestUser, feedInput(h.authorRef.hash));
155
+ const feedHash: string = feedsResponse.feeds[0];
156
+
157
+ const stream: Stream<FeedResponse> = await (h.router as any).streamFeed(
158
+ h.requestUser, { hash: feedHash }, {});
159
+ const received: FeedResponse[] = [];
160
+ stream.next(r => received.push(r));
161
+
162
+ await waitForMicrotasks();
163
+ const framesBeforeUpdate = received.length;
164
+ // The first frame is the immediate SYNC (no history).
165
+ expect(received[framesBeforeUpdate - 1].references).toEqual([]);
166
+
167
+ // A new post after SYNC should arrive in a later frame.
168
+ await savePost(h, "after-sync");
169
+ await waitForMicrotasks();
170
+ stream.close();
171
+
172
+ const newFrames = received.slice(framesBeforeUpdate);
173
+ const distinctPosts = new Set(newFrames.flatMap(r => r.references)
174
+ .filter(r => r.type === Post.Type).map(r => r.hash));
175
+ expect(distinctPosts.size).toBe(1);
176
+ });
177
+
178
+ it("cleans up listeners on disconnect", async () => {
179
+ const h = await makeHarness();
180
+ await h.factManager.save(dehydrateFact(h.author).map(f => ({ fact: f, signatures: [] })));
181
+
182
+ const removeSpy = jest.spyOn(h.factManager, "removeSpecificationListener");
183
+
184
+ const feedsResponse = await (h.router as any).feeds(h.requestUser, feedInput(h.authorRef.hash));
185
+ const feedHash: string = feedsResponse.feeds[0];
186
+
187
+ const stream: Stream<FeedResponse> = await (h.router as any).streamFeed(
188
+ h.requestUser, { hash: feedHash }, {});
189
+ stream.next(() => { });
190
+ await waitForMicrotasks();
191
+
192
+ const removedBefore = removeSpy.mock.calls.length;
193
+ stream.close();
194
+
195
+ // Closing the stream disposes the session, removing every listener.
196
+ expect(removeSpy.mock.calls.length).toBeGreaterThan(removedBefore);
197
+
198
+ // A post saved after disconnect must not reach the closed stream.
199
+ await savePost(h, "after-close");
200
+ await waitForMicrotasks();
201
+ });
202
+ });
@@ -0,0 +1,126 @@
1
+ import {
2
+ AuthorizationRules,
3
+ buildModel,
4
+ dehydrateFact,
5
+ DistributionRules,
6
+ FactEnvelope,
7
+ FactManager,
8
+ FactRecord,
9
+ FeedCache,
10
+ GraphDeserializer,
11
+ GraphSerializer,
12
+ MemoryStore,
13
+ NetworkNoOp,
14
+ ObservableSource,
15
+ PassThroughFork,
16
+ User
17
+ } from "jinaga";
18
+
19
+ import { AuthorizationKeystore } from "../../src/authorization/authorization-keystore";
20
+ import { HttpRouter, RequestUser } from "../../src/http/router";
21
+ import { MemoryKeystore } from "../../src/memory/memory-keystore";
22
+
23
+ class Filler {
24
+ static Type = "GraphSave.Filler" as const;
25
+ public type = Filler.Type;
26
+ constructor(public value: number) { }
27
+ }
28
+
29
+ class Workspace {
30
+ static Type = "GraphSave.Workspace" as const;
31
+ public type = Workspace.Type;
32
+ constructor(public owner: User, public identifier: string) { }
33
+ }
34
+
35
+ class Application {
36
+ static Type = "GraphSave.Application" as const;
37
+ public type = Application.Type;
38
+ constructor(public workspace: Workspace, public name: string) { }
39
+ }
40
+
41
+ const model = buildModel(b => b
42
+ .type(User)
43
+ .type(Filler)
44
+ .type(Workspace, w => w.predecessor("owner", User))
45
+ .type(Application, a => a.predecessor("workspace", Workspace))
46
+ );
47
+
48
+ function createReadLine(input: string) {
49
+ const lines = input.split("\n");
50
+ if (lines[lines.length - 1] === "") {
51
+ lines.pop();
52
+ }
53
+ return async () => {
54
+ const line = lines.shift();
55
+ return line !== undefined ? line : null;
56
+ };
57
+ }
58
+
59
+ // Regression test for issue #175: HttpRouter.save() used to pass its
60
+ // authorization call directly as GraphDeserializer's per-flush callback, so
61
+ // a graph body that split across flush boundaries (every 20 facts)
62
+ // authorized each flush independently. A fact whose rule needs a multi-hop
63
+ // predecessor chain landing in an earlier flush then failed with
64
+ // "The fact <type>:<hash> is not defined." (surfaced to clients as a 500). This drives
65
+ // the real application/x-jinaga-graph-v1 code path end to end.
66
+ describe("POST /save with a multi-flush graph body", () => {
67
+ it("authorizes a fact whose multi-hop predecessors landed in a prior flush batch", async () => {
68
+ const store = new MemoryStore();
69
+ const keystore = new MemoryKeystore();
70
+ const ownerIdentity = { provider: "mock", id: "owner" };
71
+ const ownerFact = await keystore.getOrCreateUserFact(ownerIdentity);
72
+ const owner = new User(ownerFact.fields.publicKey);
73
+
74
+ const fork = new PassThroughFork(store);
75
+ const observable = new ObservableSource(store);
76
+ const network = new NetworkNoOp();
77
+ const factManager = new FactManager(fork, observable, store, network, []);
78
+
79
+ const authorizationRules = new AuthorizationRules(model)
80
+ .any(Filler.Type)
81
+ .any(User.Type)
82
+ .any(Workspace.Type)
83
+ .type(Application, app => app.workspace.owner);
84
+ const distributionRules = new DistributionRules([]);
85
+ const authorization = new AuthorizationKeystore(
86
+ factManager, store, keystore, authorizationRules, distributionRules);
87
+ const feedCache = new FeedCache();
88
+ const router = new HttpRouter(factManager, authorization, feedCache, "*");
89
+
90
+ const requestUser: RequestUser = {
91
+ provider: ownerIdentity.provider,
92
+ id: ownerIdentity.id,
93
+ profile: {} as any
94
+ };
95
+
96
+ // Pad the graph with enough leading facts that the real predecessor
97
+ // chain (owner -> workspace -> application) crosses the
98
+ // GraphDeserializer's default 20-fact flush threshold, landing
99
+ // Application in a later flush than its predecessors.
100
+ const fillerRecords: FactRecord[] = [];
101
+ for (let i = 0; i < 18; i++) {
102
+ fillerRecords.push(...dehydrateFact(new Filler(i)));
103
+ }
104
+ const chainRecords = dehydrateFact(new Application(new Workspace(owner, "workspace-1"), "application-1"));
105
+
106
+ const allRecords = [...fillerRecords, ...chainRecords];
107
+ expect(allRecords.length).toBeGreaterThan(20);
108
+ expect(allRecords[allRecords.length - 1].type).toBe(Application.Type);
109
+
110
+ const envelopes: FactEnvelope[] = allRecords.map(fact => ({ fact, signatures: [] }));
111
+
112
+ // Round-trip through the wire protocol so the test exercises the
113
+ // real GraphDeserializer flush boundary, not a hand-picked batch.
114
+ const lines: string[] = [];
115
+ new GraphSerializer(chunk => lines.push(chunk)).serialize(envelopes);
116
+ const wireText = lines.join("");
117
+ const readLine = createReadLine(wireText);
118
+ const graphSource = new GraphDeserializer(readLine);
119
+
120
+ await expect((router as any).save(requestUser, graphSource)).resolves.toBeUndefined();
121
+
122
+ const applicationRecord = chainRecords.find(f => f.type === Application.Type)!;
123
+ const saved = await store.whichExist([{ type: Application.Type, hash: applicationRecord.hash }]);
124
+ expect(saved.length).toBe(1);
125
+ });
126
+ });