jinaga-server 3.5.1 → 3.5.3
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.js +2 -2
- package/dist/authorization/authorization-keystore.js.map +1 -1
- package/dist/http/csv-metadata.d.ts +24 -0
- package/dist/http/csv-metadata.js +3 -0
- package/dist/http/csv-metadata.js.map +1 -0
- package/dist/http/csv-validator.d.ts +23 -0
- package/dist/http/csv-validator.js +83 -0
- package/dist/http/csv-validator.js.map +1 -0
- package/dist/http/output-formatters.d.ts +28 -0
- package/dist/http/output-formatters.js +197 -0
- package/dist/http/output-formatters.js.map +1 -0
- package/dist/http/result-stream.d.ts +35 -0
- package/dist/http/result-stream.js +78 -0
- package/dist/http/result-stream.js.map +1 -0
- package/dist/http/router.d.ts +7 -1
- package/dist/http/router.js +276 -70
- package/dist/http/router.js.map +1 -1
- package/dist/http/stream.d.ts +3 -0
- package/dist/http/stream.js +79 -5
- package/dist/http/stream.js.map +1 -1
- package/dist/postgres/postgres-store.js +7 -5
- package/dist/postgres/postgres-store.js.map +1 -1
- package/dist/postgres/purge-sql.js +15 -2
- package/dist/postgres/purge-sql.js.map +1 -1
- package/dist/postgres/specification-result-sql.d.ts +2 -0
- package/dist/postgres/specification-result-sql.js +49 -5
- package/dist/postgres/specification-result-sql.js.map +1 -1
- package/dist/postgres/specification-sql.js +28 -6
- package/dist/postgres/specification-sql.js.map +1 -1
- package/docs/analysis/DIAGNOSTIC_LOGGING_README.md +201 -0
- package/docs/analysis/security-audit-feeds-hash-handler.md +407 -0
- package/docs/design/feeds-persistent-proxy-plan.md +294 -0
- package/integration-test/integration-test/jinaga-test/package-lock.json +6 -0
- package/integration-test/jinaga-test/Dockerfile +1 -6
- package/integration-test/jinaga-test/http-test-helpers.js +77 -0
- package/integration-test/jinaga-test/jinaga.test.js +9 -9
- package/integration-test/jinaga-test/package-lock.json +329 -32
- package/integration-test/jinaga-test/package.json +5 -1
- package/integration-test/jinaga-test/read-csv.test.js +330 -0
- package/integration-test/jinaga-test/read-endpoint.test.js +442 -0
- package/integration-test/jinaga-test/read-performance.test.js +221 -0
- package/integration-test/jinaga-test/read-streaming.test.js +199 -0
- package/integration-test/jinaga-test/specification-conditions.test.js +147 -0
- package/integration-test/jinaga-test/test-helpers.js +12 -0
- package/integration-test/jinaga-test/time-projection.test.js +235 -0
- package/package.json +3 -2
- package/src/authorization/authorization-keystore.ts +4 -4
- package/src/http/csv-metadata.ts +34 -0
- package/src/http/csv-validator.ts +90 -0
- package/src/http/output-formatters.ts +196 -0
- package/src/http/result-stream.ts +69 -0
- package/src/http/router.ts +344 -62
- package/src/http/stream.ts +97 -5
- package/src/postgres/postgres-store.ts +13 -6
- package/src/postgres/purge-sql.ts +18 -2
- package/src/postgres/specification-result-sql.ts +70 -8
- package/src/postgres/specification-sql.ts +36 -8
- package/test/http/csv-validatorSpec.ts +205 -0
- package/test/http/result-streamSpec.ts +151 -0
- package/test/postgres/postgresReadSpec.ts +192 -14
- package/test/postgres/postgresSpecificationSpec.ts +3 -3
- package/test/postgres/specificationConditionsSpec.ts +268 -0
- package/test-concurrent-streams.js +219 -0
- package/documentation/streaming-feeds-full-results-solution.md +0 -230
package/src/http/router.ts
CHANGED
|
@@ -32,7 +32,11 @@ import {
|
|
|
32
32
|
UserIdentity,
|
|
33
33
|
verifyEnvelopes
|
|
34
34
|
} from "jinaga";
|
|
35
|
+
import { CsvMetadata } from "./csv-metadata";
|
|
36
|
+
import { validateSpecificationForCsv } from "./csv-validator";
|
|
35
37
|
import { createLineReader } from "./line-reader";
|
|
38
|
+
import { outputReadResultsStreaming } from "./output-formatters";
|
|
39
|
+
import { arrayToResultStream, ResultStream } from "./result-stream";
|
|
36
40
|
import { Stream } from "./stream";
|
|
37
41
|
|
|
38
42
|
function getOrStream<U>(
|
|
@@ -40,53 +44,131 @@ function getOrStream<U>(
|
|
|
40
44
|
streamMethod: ((req: RequestUser, params: { [key: string]: string }, query: qs.ParsedQs) => Promise<Stream<U> | null>)
|
|
41
45
|
): Handler {
|
|
42
46
|
return (req, res, next) => {
|
|
47
|
+
const connectionId = Math.random().toString(36).substring(2, 10);
|
|
43
48
|
const user = <RequestUser>(req as any).user;
|
|
44
49
|
const accept = req.headers["accept"];
|
|
50
|
+
|
|
51
|
+
console.log(`[HttpConnection:${connectionId}] New request - User: ${user?.id || 'anonymous'}, Accept: ${accept}, URL: ${req.url}`);
|
|
52
|
+
|
|
45
53
|
if (accept && accept.indexOf("application/x-jinaga-feed-stream") >= 0) {
|
|
54
|
+
console.log(`[HttpConnection:${connectionId}] STREAMING request detected`);
|
|
55
|
+
|
|
46
56
|
streamMethod(user, req.params, req.query)
|
|
47
57
|
.then(response => {
|
|
48
58
|
if (!response) {
|
|
59
|
+
console.log(`[HttpConnection:${connectionId}] Stream method returned null - sending 404`);
|
|
49
60
|
res.sendStatus(404);
|
|
50
61
|
next();
|
|
51
62
|
}
|
|
52
63
|
else {
|
|
64
|
+
console.log(`[HttpConnection:${connectionId}] Setting up streaming response`);
|
|
65
|
+
|
|
53
66
|
res.type("application/x-jinaga-feed-stream");
|
|
54
67
|
res.set("Connection", "keep-alive");
|
|
55
68
|
res.set("Cache-Control", "no-cache");
|
|
56
69
|
res.set("Access-Control-Allow-Origin", "*");
|
|
57
70
|
res.flushHeaders();
|
|
71
|
+
|
|
72
|
+
console.log(`[HttpConnection:${connectionId}] Headers flushed - setting up event handlers`);
|
|
73
|
+
|
|
74
|
+
let clientDisconnected = false;
|
|
75
|
+
let timeoutTriggered = false;
|
|
76
|
+
let streamClosed = false;
|
|
77
|
+
|
|
78
|
+
// Client disconnect handler
|
|
58
79
|
req.on("close", () => {
|
|
59
|
-
|
|
80
|
+
console.log(`[HttpConnection:${connectionId}] CLIENT DISCONNECTED`);
|
|
81
|
+
clientDisconnected = true;
|
|
82
|
+
if (!streamClosed) {
|
|
83
|
+
console.log(`[HttpConnection:${connectionId}] Closing stream due to client disconnect`);
|
|
84
|
+
response.close();
|
|
85
|
+
streamClosed = true;
|
|
86
|
+
}
|
|
60
87
|
});
|
|
88
|
+
|
|
89
|
+
// Timeout handler
|
|
61
90
|
const timeout = setTimeout(() => {
|
|
62
|
-
|
|
91
|
+
console.log(`[HttpConnection:${connectionId}] TIMEOUT TRIGGERED (5 minutes)`);
|
|
92
|
+
timeoutTriggered = true;
|
|
93
|
+
if (!streamClosed) {
|
|
94
|
+
console.log(`[HttpConnection:${connectionId}] Closing stream due to timeout`);
|
|
95
|
+
response.close();
|
|
96
|
+
streamClosed = true;
|
|
97
|
+
}
|
|
63
98
|
}, 5 * 60 * 1000);
|
|
99
|
+
|
|
100
|
+
let messageCount = 0;
|
|
101
|
+
|
|
64
102
|
response
|
|
65
103
|
.next(data => {
|
|
66
|
-
|
|
104
|
+
messageCount++;
|
|
105
|
+
const writeStart = Date.now();
|
|
106
|
+
|
|
107
|
+
if (clientDisconnected) {
|
|
108
|
+
console.warn(`[HttpConnection:${connectionId}] Attempted to write to disconnected client - Message #${messageCount}`);
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
try {
|
|
113
|
+
const jsonData = JSON.stringify(data) + "\n\n";
|
|
114
|
+
res.write(jsonData);
|
|
115
|
+
const writeDuration = Date.now() - writeStart;
|
|
116
|
+
|
|
117
|
+
console.log(`[HttpConnection:${connectionId}] Message #${messageCount} sent - Size: ${jsonData.length} bytes, Duration: ${writeDuration}ms`);
|
|
118
|
+
|
|
119
|
+
if (writeDuration > 50) {
|
|
120
|
+
console.warn(`[HttpConnection:${connectionId}] SLOW write operation - Duration: ${writeDuration}ms`);
|
|
121
|
+
}
|
|
122
|
+
} catch (error) {
|
|
123
|
+
console.error(`[HttpConnection:${connectionId}] ERROR writing message #${messageCount}: ${error}`);
|
|
124
|
+
}
|
|
67
125
|
})
|
|
68
126
|
.done(() => {
|
|
127
|
+
console.log(`[HttpConnection:${connectionId}] STREAM DONE - Messages sent: ${messageCount}, Client disconnected: ${clientDisconnected}, Timeout triggered: ${timeoutTriggered}`);
|
|
128
|
+
|
|
69
129
|
clearTimeout(timeout);
|
|
70
|
-
|
|
130
|
+
streamClosed = true;
|
|
131
|
+
|
|
132
|
+
try {
|
|
133
|
+
if (!clientDisconnected) {
|
|
134
|
+
console.log(`[HttpConnection:${connectionId}] Ending socket connection`);
|
|
135
|
+
res.socket?.end();
|
|
136
|
+
} else {
|
|
137
|
+
console.log(`[HttpConnection:${connectionId}] Client already disconnected - skipping socket end`);
|
|
138
|
+
}
|
|
139
|
+
} catch (error) {
|
|
140
|
+
console.error(`[HttpConnection:${connectionId}] ERROR ending socket: ${error}`);
|
|
141
|
+
}
|
|
71
142
|
});
|
|
143
|
+
|
|
144
|
+
console.log(`[HttpConnection:${connectionId}] Stream handlers configured`);
|
|
72
145
|
}
|
|
73
146
|
})
|
|
74
|
-
.catch(error =>
|
|
147
|
+
.catch(error => {
|
|
148
|
+
console.error(`[HttpConnection:${connectionId}] ERROR in stream method: ${error}`);
|
|
149
|
+
handleError(error, req, res, next);
|
|
150
|
+
});
|
|
75
151
|
}
|
|
76
152
|
else {
|
|
153
|
+
console.log(`[HttpConnection:${connectionId}] Regular GET request`);
|
|
77
154
|
getMethod(user, req.params, req.query)
|
|
78
155
|
.then(response => {
|
|
79
156
|
if (!response) {
|
|
157
|
+
console.log(`[HttpConnection:${connectionId}] Get method returned null - sending 404`);
|
|
80
158
|
res.sendStatus(404);
|
|
81
159
|
next();
|
|
82
160
|
}
|
|
83
161
|
else {
|
|
162
|
+
console.log(`[HttpConnection:${connectionId}] Sending JSON response`);
|
|
84
163
|
res.type("json");
|
|
85
164
|
res.send(JSON.stringify(response));
|
|
86
165
|
next();
|
|
87
166
|
}
|
|
88
167
|
})
|
|
89
|
-
.catch(error =>
|
|
168
|
+
.catch(error => {
|
|
169
|
+
console.error(`[HttpConnection:${connectionId}] ERROR in get method: ${error}`);
|
|
170
|
+
handleError(error, req, res, next);
|
|
171
|
+
});
|
|
90
172
|
}
|
|
91
173
|
};
|
|
92
174
|
}
|
|
@@ -135,26 +217,6 @@ function post<T, U>(
|
|
|
135
217
|
};
|
|
136
218
|
}
|
|
137
219
|
|
|
138
|
-
function postString<U>(method: (user: RequestUser, message: string) => Promise<U>): Handler {
|
|
139
|
-
return (req, res, next) => {
|
|
140
|
-
const user = <RequestUser>(req as any).user;
|
|
141
|
-
const input = parseString(req.body);
|
|
142
|
-
if (!input || typeof (input) !== 'string') {
|
|
143
|
-
res.type("text");
|
|
144
|
-
res.status(500).send('Expected Content-Type text/plain. Ensure that you have called app.use(express.text()).');
|
|
145
|
-
}
|
|
146
|
-
else {
|
|
147
|
-
method(user, input)
|
|
148
|
-
.then(response => {
|
|
149
|
-
res.type("text");
|
|
150
|
-
res.send(JSON.stringify(response, null, 2));
|
|
151
|
-
next();
|
|
152
|
-
})
|
|
153
|
-
.catch(error => handleError(error, req, res, next));
|
|
154
|
-
}
|
|
155
|
-
};
|
|
156
|
-
}
|
|
157
|
-
|
|
158
220
|
function postCreate<T>(
|
|
159
221
|
parse: (request: Request) => T,
|
|
160
222
|
method: (user: RequestUser, message: T) => Promise<void>
|
|
@@ -190,6 +252,40 @@ function postStringCreate(method: (user: RequestUser, message: string) => Promis
|
|
|
190
252
|
};
|
|
191
253
|
}
|
|
192
254
|
|
|
255
|
+
function postReadWithStreaming(
|
|
256
|
+
method: (user: RequestUser, message: string, acceptType: string) => Promise<{
|
|
257
|
+
resultStream: ResultStream<any>,
|
|
258
|
+
csvMetadata?: CsvMetadata
|
|
259
|
+
}>
|
|
260
|
+
): Handler {
|
|
261
|
+
return (req, res, next) => {
|
|
262
|
+
const user = <RequestUser>(req as any).user;
|
|
263
|
+
const input = parseString(req.body);
|
|
264
|
+
if (!input || typeof (input) !== 'string') {
|
|
265
|
+
res.type("text");
|
|
266
|
+
res.status(500).send('Expected Content-Type text/plain. Ensure that you have called app.use(express.text()).');
|
|
267
|
+
}
|
|
268
|
+
else {
|
|
269
|
+
// Check if Accept header explicitly prefers a specific format
|
|
270
|
+
const acceptHeader = req.get('Accept');
|
|
271
|
+
let acceptType: string = 'text/plain'; // Default for backward compatibility
|
|
272
|
+
|
|
273
|
+
if (acceptHeader && acceptHeader !== '*/*') {
|
|
274
|
+
// Only use req.accepts() when there's a specific preference
|
|
275
|
+
const preferredType = req.accepts(['text/csv', 'application/x-ndjson', 'application/json', 'text/plain']);
|
|
276
|
+
acceptType = preferredType ? String(preferredType) : 'text/plain';
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
method(user, input, acceptType)
|
|
280
|
+
.then(({resultStream, csvMetadata}) => {
|
|
281
|
+
outputReadResults(resultStream, res, acceptType, csvMetadata);
|
|
282
|
+
next();
|
|
283
|
+
})
|
|
284
|
+
.catch(error => handleError(error, req, res, next));
|
|
285
|
+
}
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
|
|
193
289
|
function serializeUserIdentity(user: RequestUser | null): UserIdentity | null {
|
|
194
290
|
if (!user) {
|
|
195
291
|
return null;
|
|
@@ -246,6 +342,22 @@ function outputFeeds(result: FeedsResponse, res: Response, accepts: (type: strin
|
|
|
246
342
|
res.send(JSON.stringify(result));
|
|
247
343
|
}
|
|
248
344
|
|
|
345
|
+
function outputReadResults(
|
|
346
|
+
result: ResultStream<any>,
|
|
347
|
+
res: Response,
|
|
348
|
+
acceptType: string,
|
|
349
|
+
csvMetadata?: CsvMetadata
|
|
350
|
+
) {
|
|
351
|
+
// Use streaming output formatter
|
|
352
|
+
outputReadResultsStreaming(result, res, acceptType, csvMetadata)
|
|
353
|
+
.catch(error => {
|
|
354
|
+
console.error('Error in outputReadResults:', error);
|
|
355
|
+
if (!res.headersSent) {
|
|
356
|
+
res.status(500).send('Internal server error');
|
|
357
|
+
}
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
|
|
249
361
|
export interface RequestUser {
|
|
250
362
|
provider: string;
|
|
251
363
|
id: string;
|
|
@@ -274,7 +386,13 @@ export class HttpRouter {
|
|
|
274
386
|
(user, graphSource) => this.save(user, graphSource)
|
|
275
387
|
));
|
|
276
388
|
|
|
277
|
-
router.post('/read', applyAllowOrigin,
|
|
389
|
+
router.post('/read', applyAllowOrigin, postReadWithStreaming((user, input: string, acceptType: string) =>
|
|
390
|
+
this.readWithStreaming(
|
|
391
|
+
user,
|
|
392
|
+
input,
|
|
393
|
+
acceptType === 'text/csv' ? preProcessForCsv : preProcessForOther
|
|
394
|
+
)
|
|
395
|
+
));
|
|
278
396
|
router.post('/write', applyAllowOrigin, postStringCreate((user, input: string) => this.write(user, input)));
|
|
279
397
|
router.post('/feeds', applyAllowOrigin, post(
|
|
280
398
|
parseString,
|
|
@@ -345,26 +463,46 @@ export class HttpRouter {
|
|
|
345
463
|
});
|
|
346
464
|
}
|
|
347
465
|
|
|
348
|
-
|
|
349
|
-
|
|
466
|
+
/**
|
|
467
|
+
* Read with streaming support.
|
|
468
|
+
* Uses readStream if available on the authorization object, otherwise falls back to read().
|
|
469
|
+
*
|
|
470
|
+
* When CSV format is requested, validates that the specification contains only flat projections.
|
|
471
|
+
*/
|
|
472
|
+
private async readWithStreaming(
|
|
473
|
+
user: RequestUser | null,
|
|
474
|
+
input: string,
|
|
475
|
+
preProcess: (specification: Specification) => CsvMetadata | undefined
|
|
476
|
+
): Promise<{
|
|
477
|
+
resultStream: ResultStream<any>,
|
|
478
|
+
csvMetadata?: CsvMetadata
|
|
479
|
+
}> {
|
|
480
|
+
return Trace.dependency("readWithStreaming", "", async () => {
|
|
350
481
|
const knownFacts = await this.getKnownFacts(user);
|
|
351
482
|
const parser = new SpecificationParser(input);
|
|
352
483
|
parser.skipWhitespace();
|
|
353
484
|
const declaration = parser.parseDeclaration(knownFacts);
|
|
354
485
|
const specification = parser.parseSpecification();
|
|
355
486
|
parser.expectEnd();
|
|
356
|
-
const start = this.selectStart(specification, declaration);
|
|
357
487
|
|
|
358
488
|
var failures: string[] = this.factManager.testSpecificationForCompliance(specification);
|
|
359
489
|
if (failures.length > 0) {
|
|
360
490
|
throw new Invalid(failures.join("\n"));
|
|
361
491
|
}
|
|
362
492
|
|
|
493
|
+
const csvMetadata = preProcess(specification);
|
|
494
|
+
|
|
363
495
|
const userIdentity = serializeUserIdentity(user);
|
|
496
|
+
const start = this.selectStart(specification, declaration);
|
|
364
497
|
const results = await this.authorization.read(userIdentity, start, specification);
|
|
365
498
|
const extracted = extractResults(results);
|
|
366
499
|
Trace.counter("facts_read", extracted.count);
|
|
367
|
-
|
|
500
|
+
const resultStream = arrayToResultStream(extracted.result);
|
|
501
|
+
|
|
502
|
+
return {
|
|
503
|
+
resultStream,
|
|
504
|
+
csvMetadata
|
|
505
|
+
};
|
|
368
506
|
});
|
|
369
507
|
}
|
|
370
508
|
|
|
@@ -408,8 +546,8 @@ export class HttpRouter {
|
|
|
408
546
|
}
|
|
409
547
|
// Verify that the input type matches the start fact type
|
|
410
548
|
for (let i = 0; i < start.length; i++) {
|
|
411
|
-
if (start[i].type !== specification.given[i].type) {
|
|
412
|
-
throw new Invalid(`The type of start fact ${i} (${start[i].type}) does not match the type of input ${i} (${specification.given[i].type})`);
|
|
549
|
+
if (start[i].type !== specification.given[i].label.type) {
|
|
550
|
+
throw new Invalid(`The type of start fact ${i} (${start[i].type}) does not match the type of input ${i} (${specification.given[i].label.type})`);
|
|
413
551
|
}
|
|
414
552
|
}
|
|
415
553
|
// Verify that the specification is compliant with purge conditions
|
|
@@ -418,9 +556,9 @@ export class HttpRouter {
|
|
|
418
556
|
throw new Invalid(failures.join("\n"));
|
|
419
557
|
}
|
|
420
558
|
|
|
421
|
-
const namedStart = specification.given.reduce((map,
|
|
559
|
+
const namedStart = specification.given.reduce((map, g, index) => ({
|
|
422
560
|
...map,
|
|
423
|
-
[label.name]: start[index]
|
|
561
|
+
[g.label.name]: start[index]
|
|
424
562
|
}), {} as ReferencesByName);
|
|
425
563
|
|
|
426
564
|
// Verify that I can distribute all feeds to the user.
|
|
@@ -451,7 +589,7 @@ export class HttpRouter {
|
|
|
451
589
|
const bookmark = query["b"] as string ?? "";
|
|
452
590
|
|
|
453
591
|
const userIdentity = serializeUserIdentity(user);
|
|
454
|
-
const start = feedDefinition.feed.given.map(
|
|
592
|
+
const start = feedDefinition.feed.given.map(g => feedDefinition.namedStart[g.label.name]);
|
|
455
593
|
const results = await this.authorization.feed(userIdentity, feedDefinition.feed, start, bookmark);
|
|
456
594
|
// Return distinct fact references from all the tuples.
|
|
457
595
|
const references = results.tuples.flatMap(t => t.facts).filter((value, index, self) =>
|
|
@@ -466,46 +604,113 @@ export class HttpRouter {
|
|
|
466
604
|
}
|
|
467
605
|
|
|
468
606
|
private async streamFeed(user: RequestUser | null, params: { [key: string]: string }, query: qs.ParsedQs): Promise<Stream<FeedResponse> | null> {
|
|
607
|
+
const connectionId = Math.random().toString(36).substring(2, 10);
|
|
608
|
+
const startTime = Date.now();
|
|
609
|
+
|
|
610
|
+
console.log(`[StreamFeed:${connectionId}] NEW CONNECTION - User: ${user?.id || 'anonymous'}, Hash: ${params["hash"]}`);
|
|
611
|
+
|
|
469
612
|
const feedHash = params["hash"];
|
|
470
613
|
if (!feedHash) {
|
|
614
|
+
console.log(`[StreamFeed:${connectionId}] No feed hash provided`);
|
|
471
615
|
return null;
|
|
472
616
|
}
|
|
473
617
|
|
|
474
618
|
const feedDefinition = await this.feedCache.getFeed(feedHash);
|
|
475
619
|
if (!feedDefinition) {
|
|
620
|
+
console.log(`[StreamFeed:${connectionId}] Feed definition not found for hash: ${feedHash}`);
|
|
476
621
|
return null;
|
|
477
622
|
}
|
|
478
623
|
|
|
479
624
|
let bookmark = query["b"] as string ?? "";
|
|
625
|
+
console.log(`[StreamFeed:${connectionId}] Initial bookmark: ${bookmark || 'empty'}`);
|
|
480
626
|
|
|
481
627
|
const userIdentity = serializeUserIdentity(user);
|
|
482
|
-
const start = feedDefinition.feed.given.map(
|
|
628
|
+
const start = feedDefinition.feed.given.map(g => feedDefinition.namedStart[g.label.name]);
|
|
483
629
|
const givenHash = computeObjectHash(feedDefinition.namedStart);
|
|
484
630
|
|
|
631
|
+
console.log(`[StreamFeed:${connectionId}] Feed setup - Given hash: ${givenHash.substring(0, 8)}..., Start facts: ${start.length}`);
|
|
632
|
+
|
|
485
633
|
const stream = new Stream<FeedResponse>();
|
|
486
634
|
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
635
|
+
try {
|
|
636
|
+
// Continuous initial query until exhausted
|
|
637
|
+
console.log(`[StreamFeed:${connectionId}] Starting initial data streaming...`);
|
|
638
|
+
const initialStart = Date.now();
|
|
639
|
+
bookmark = await this.streamAllInitialResults(userIdentity, feedDefinition, start, bookmark, stream);
|
|
640
|
+
const initialDuration = Date.now() - initialStart;
|
|
641
|
+
console.log(`[StreamFeed:${connectionId}] Initial data streaming complete - Duration: ${initialDuration}ms, Final bookmark: ${bookmark || 'empty'}`);
|
|
642
|
+
|
|
643
|
+
// Set up real-time listeners after initial data is complete
|
|
644
|
+
console.log(`[StreamFeed:${connectionId}] Setting up real-time listeners...`);
|
|
645
|
+
const inverses = invertSpecification(feedDefinition.feed);
|
|
646
|
+
console.log(`[StreamFeed:${connectionId}] Created ${inverses.length} inverse specifications`);
|
|
647
|
+
|
|
648
|
+
const listeners = inverses.map((inverse, index) => {
|
|
649
|
+
console.log(`[StreamFeed:${connectionId}] Adding listener ${index + 1}/${inverses.length}`);
|
|
650
|
+
|
|
651
|
+
return this.factManager.addSpecificationListener(
|
|
652
|
+
inverse.inverseSpecification,
|
|
653
|
+
async (results) => {
|
|
654
|
+
const eventStart = Date.now();
|
|
655
|
+
console.log(`[StreamFeed:${connectionId}] EVENT RECEIVED - Listener ${index + 1}, Results: ${results.length}`);
|
|
656
|
+
|
|
657
|
+
try {
|
|
658
|
+
// Filter out results that do not match the given.
|
|
659
|
+
const matchingResults = results.filter(pr =>
|
|
660
|
+
givenHash === computeTupleSubsetHash(pr.tuple, inverse.givenSubset));
|
|
661
|
+
|
|
662
|
+
console.log(`[StreamFeed:${connectionId}] Filtered results - Matching: ${matchingResults.length}/${results.length}`);
|
|
663
|
+
|
|
664
|
+
if (matchingResults.length != 0) {
|
|
665
|
+
console.log(`[StreamFeed:${connectionId}] Processing matching results...`);
|
|
666
|
+
const responseStart = Date.now();
|
|
667
|
+
bookmark = await this.streamFeedResponse(userIdentity, feedDefinition, start, bookmark, stream, true);
|
|
668
|
+
const responseDuration = Date.now() - responseStart;
|
|
669
|
+
console.log(`[StreamFeed:${connectionId}] Feed response sent - Duration: ${responseDuration}ms, New bookmark: ${bookmark || 'empty'}`);
|
|
670
|
+
} else {
|
|
671
|
+
console.log(`[StreamFeed:${connectionId}] No matching results - skipping response`);
|
|
672
|
+
}
|
|
673
|
+
} catch (error) {
|
|
674
|
+
console.error(`[StreamFeed:${connectionId}] ERROR processing event: ${error}`);
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
const eventDuration = Date.now() - eventStart;
|
|
678
|
+
if (eventDuration > 100) {
|
|
679
|
+
console.warn(`[StreamFeed:${connectionId}] SLOW event processing - Duration: ${eventDuration}ms`);
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
);
|
|
683
|
+
});
|
|
684
|
+
|
|
685
|
+
console.log(`[StreamFeed:${connectionId}] All listeners registered - Count: ${listeners.length}`);
|
|
686
|
+
|
|
687
|
+
stream.done(() => {
|
|
688
|
+
const cleanupStart = Date.now();
|
|
689
|
+
console.log(`[StreamFeed:${connectionId}] CLEANUP STARTED - Removing ${listeners.length} listeners`);
|
|
690
|
+
|
|
691
|
+
for (let i = 0; i < listeners.length; i++) {
|
|
692
|
+
try {
|
|
693
|
+
this.factManager.removeSpecificationListener(listeners[i]);
|
|
694
|
+
console.log(`[StreamFeed:${connectionId}] Removed listener ${i + 1}/${listeners.length}`);
|
|
695
|
+
} catch (error) {
|
|
696
|
+
console.error(`[StreamFeed:${connectionId}] ERROR removing listener ${i + 1}: ${error}`);
|
|
697
|
+
}
|
|
500
698
|
}
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
699
|
+
|
|
700
|
+
const cleanupDuration = Date.now() - cleanupStart;
|
|
701
|
+
const totalDuration = Date.now() - startTime;
|
|
702
|
+
console.log(`[StreamFeed:${connectionId}] CLEANUP COMPLETE - Cleanup duration: ${cleanupDuration}ms, Total connection duration: ${totalDuration}ms`);
|
|
703
|
+
});
|
|
704
|
+
|
|
705
|
+
const setupDuration = Date.now() - startTime;
|
|
706
|
+
console.log(`[StreamFeed:${connectionId}] Stream setup complete - Duration: ${setupDuration}ms`);
|
|
707
|
+
|
|
708
|
+
return stream;
|
|
709
|
+
|
|
710
|
+
} catch (error) {
|
|
711
|
+
console.error(`[StreamFeed:${connectionId}] ERROR during setup: ${error}`);
|
|
712
|
+
throw error;
|
|
713
|
+
}
|
|
509
714
|
}
|
|
510
715
|
|
|
511
716
|
private async streamAllInitialResults(
|
|
@@ -515,37 +720,59 @@ export class HttpRouter {
|
|
|
515
720
|
initialBookmark: string,
|
|
516
721
|
stream: Stream<FeedResponse>
|
|
517
722
|
): Promise<string> {
|
|
723
|
+
const streamId = Math.random().toString(36).substring(2, 8);
|
|
724
|
+
console.log(`[InitialResults:${streamId}] Starting initial data fetch - Bookmark: ${initialBookmark || 'empty'}`);
|
|
725
|
+
|
|
518
726
|
let bookmark = initialBookmark;
|
|
519
727
|
let hasMoreResults = true;
|
|
520
728
|
let pageCount = 0;
|
|
729
|
+
let totalReferences = 0;
|
|
521
730
|
const maxPages = 1000; // Safety limit to prevent infinite loops
|
|
731
|
+
const startTime = Date.now();
|
|
522
732
|
|
|
523
733
|
while (hasMoreResults && pageCount < maxPages) {
|
|
734
|
+
const pageStart = Date.now();
|
|
735
|
+
console.log(`[InitialResults:${streamId}] Fetching page ${pageCount + 1} - Bookmark: ${bookmark || 'empty'}`);
|
|
736
|
+
|
|
524
737
|
const results = await this.authorization.feed(userIdentity, feedDefinition.feed, start, bookmark);
|
|
738
|
+
const fetchDuration = Date.now() - pageStart;
|
|
739
|
+
|
|
740
|
+
console.log(`[InitialResults:${streamId}] Page ${pageCount + 1} fetched - Tuples: ${results.tuples.length}, Duration: ${fetchDuration}ms`);
|
|
525
741
|
|
|
526
742
|
// Check if we got results
|
|
527
743
|
if (results.tuples.length === 0) {
|
|
744
|
+
console.log(`[InitialResults:${streamId}] No more results - ending pagination`);
|
|
528
745
|
hasMoreResults = false;
|
|
529
746
|
break;
|
|
530
747
|
}
|
|
531
748
|
|
|
532
749
|
// Process and send results
|
|
750
|
+
const processStart = Date.now();
|
|
533
751
|
const references = results.tuples.flatMap(t => t.facts).filter((value, index, self) =>
|
|
534
752
|
self.findIndex(f => f.hash === value.hash && f.type === value.type) === index
|
|
535
753
|
);
|
|
754
|
+
const processDuration = Date.now() - processStart;
|
|
755
|
+
|
|
756
|
+
console.log(`[InitialResults:${streamId}] Page ${pageCount + 1} processed - References: ${references.length}, Process duration: ${processDuration}ms`);
|
|
536
757
|
|
|
537
758
|
if (references.length > 0) {
|
|
538
759
|
const response: FeedResponse = {
|
|
539
760
|
references,
|
|
540
761
|
bookmark: results.bookmark
|
|
541
762
|
};
|
|
763
|
+
|
|
764
|
+
const feedStart = Date.now();
|
|
542
765
|
stream.feed(response);
|
|
766
|
+
const feedDuration = Date.now() - feedStart;
|
|
767
|
+
|
|
768
|
+
totalReferences += references.length;
|
|
769
|
+
console.log(`[InitialResults:${streamId}] Page ${pageCount + 1} sent to stream - Feed duration: ${feedDuration}ms`);
|
|
543
770
|
}
|
|
544
771
|
|
|
545
772
|
// Update bookmark for next iteration
|
|
546
773
|
const newBookmark = results.bookmark;
|
|
547
774
|
if (newBookmark === bookmark) {
|
|
548
|
-
|
|
775
|
+
console.log(`[InitialResults:${streamId}] Bookmark unchanged - ending pagination`);
|
|
549
776
|
hasMoreResults = false;
|
|
550
777
|
} else {
|
|
551
778
|
bookmark = newBookmark;
|
|
@@ -553,6 +780,7 @@ export class HttpRouter {
|
|
|
553
780
|
|
|
554
781
|
// Check if we got fewer results than the page size (indicates end)
|
|
555
782
|
if (results.tuples.length < 100) {
|
|
783
|
+
console.log(`[InitialResults:${streamId}] Partial page received (${results.tuples.length} < 100) - ending pagination`);
|
|
556
784
|
hasMoreResults = false;
|
|
557
785
|
}
|
|
558
786
|
|
|
@@ -560,25 +788,60 @@ export class HttpRouter {
|
|
|
560
788
|
|
|
561
789
|
// Add small delay to prevent overwhelming the database
|
|
562
790
|
if (hasMoreResults && pageCount % 10 === 0) {
|
|
791
|
+
console.log(`[InitialResults:${streamId}] Adding delay after ${pageCount} pages`);
|
|
563
792
|
await new Promise(resolve => setTimeout(resolve, 10));
|
|
564
793
|
}
|
|
565
794
|
}
|
|
566
795
|
|
|
796
|
+
const totalDuration = Date.now() - startTime;
|
|
797
|
+
console.log(`[InitialResults:${streamId}] Initial data streaming complete - Pages: ${pageCount}, Total references: ${totalReferences}, Duration: ${totalDuration}ms, Final bookmark: ${bookmark || 'empty'}`);
|
|
798
|
+
|
|
799
|
+
if (pageCount >= maxPages) {
|
|
800
|
+
console.warn(`[InitialResults:${streamId}] Hit maximum page limit (${maxPages}) - possible infinite loop`);
|
|
801
|
+
}
|
|
802
|
+
|
|
567
803
|
return bookmark;
|
|
568
804
|
}
|
|
569
805
|
|
|
570
806
|
private async streamFeedResponse(userIdentity: UserIdentity | null, feedDefinition: FeedObject, start: FactReference[], bookmark: string, stream: Stream<FeedResponse>, skipIfEmpty = false): Promise<string> {
|
|
807
|
+
const responseId = Math.random().toString(36).substring(2, 6);
|
|
808
|
+
const startTime = Date.now();
|
|
809
|
+
|
|
810
|
+
console.log(`[FeedResponse:${responseId}] Starting feed response - Bookmark: ${bookmark || 'empty'}, Skip if empty: ${skipIfEmpty}`);
|
|
811
|
+
|
|
812
|
+
const feedStart = Date.now();
|
|
571
813
|
const results = await this.authorization.feed(userIdentity, feedDefinition.feed, start, bookmark);
|
|
814
|
+
const feedDuration = Date.now() - feedStart;
|
|
815
|
+
|
|
816
|
+
console.log(`[FeedResponse:${responseId}] Authorization feed complete - Tuples: ${results.tuples.length}, Duration: ${feedDuration}ms`);
|
|
817
|
+
|
|
572
818
|
// Return distinct fact references from all the tuples.
|
|
573
|
-
const
|
|
819
|
+
const processStart = Date.now();
|
|
820
|
+
const references = results.tuples.flatMap(t => t.facts).filter((value, index, self) =>
|
|
821
|
+
self.findIndex(f => f.hash === value.hash && f.type === value.type) === index
|
|
574
822
|
);
|
|
823
|
+
const processDuration = Date.now() - processStart;
|
|
824
|
+
|
|
825
|
+
console.log(`[FeedResponse:${responseId}] References processed - Count: ${references.length}, Duration: ${processDuration}ms`);
|
|
826
|
+
|
|
575
827
|
if (!skipIfEmpty || references.length > 0) {
|
|
576
828
|
const response: FeedResponse = {
|
|
577
829
|
references,
|
|
578
830
|
bookmark: results.bookmark
|
|
579
831
|
};
|
|
832
|
+
|
|
833
|
+
const streamStart = Date.now();
|
|
580
834
|
stream.feed(response);
|
|
835
|
+
const streamDuration = Date.now() - streamStart;
|
|
836
|
+
|
|
837
|
+
console.log(`[FeedResponse:${responseId}] Response sent to stream - Duration: ${streamDuration}ms`);
|
|
838
|
+
} else {
|
|
839
|
+
console.log(`[FeedResponse:${responseId}] Skipping empty response`);
|
|
581
840
|
}
|
|
841
|
+
|
|
842
|
+
const totalDuration = Date.now() - startTime;
|
|
843
|
+
console.log(`[FeedResponse:${responseId}] Feed response complete - New bookmark: ${results.bookmark || 'empty'}, Total duration: ${totalDuration}ms`);
|
|
844
|
+
|
|
582
845
|
return results.bookmark;
|
|
583
846
|
}
|
|
584
847
|
|
|
@@ -609,9 +872,9 @@ export class HttpRouter {
|
|
|
609
872
|
private selectStart(specification: Specification, declaration: Declaration): FactReference[] {
|
|
610
873
|
// Select starting facts that match the inputs
|
|
611
874
|
return specification.given.map(input => {
|
|
612
|
-
const declaredFact = declaration.find(d => d.name === input.name);
|
|
875
|
+
const declaredFact = declaration.find(d => d.name === input.label.name);
|
|
613
876
|
if (!declaredFact) {
|
|
614
|
-
throw new Invalid(`No fact named ${input.name} was declared`);
|
|
877
|
+
throw new Invalid(`No fact named ${input.label.name} was declared`);
|
|
615
878
|
}
|
|
616
879
|
return declaredFact.declared.reference;
|
|
617
880
|
});
|
|
@@ -690,6 +953,25 @@ export class HttpRouter {
|
|
|
690
953
|
}
|
|
691
954
|
}
|
|
692
955
|
|
|
956
|
+
function preProcessForCsv(specification: Specification): CsvMetadata | undefined {
|
|
957
|
+
const csvMetadata = validateSpecificationForCsv(specification);
|
|
958
|
+
|
|
959
|
+
if (!csvMetadata.isValid) {
|
|
960
|
+
throw new Invalid(
|
|
961
|
+
`Specification is not compatible with CSV format:\n\n` +
|
|
962
|
+
csvMetadata.errors.join('\n\n') +
|
|
963
|
+
`\n\nHint: CSV requires flat projections with single-valued fields. ` +
|
|
964
|
+
`Avoid nested specifications.`
|
|
965
|
+
);
|
|
966
|
+
}
|
|
967
|
+
return csvMetadata;
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
function preProcessForOther(specification: Specification): CsvMetadata | undefined {
|
|
971
|
+
// For non-CSV formats, no special preprocessing is needed.
|
|
972
|
+
return undefined;
|
|
973
|
+
}
|
|
974
|
+
|
|
693
975
|
function parseString(input: any): string {
|
|
694
976
|
if (typeof input !== 'string') {
|
|
695
977
|
throw new Invalid("Expected a string. Check the content type of the request.");
|