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.
Files changed (64) hide show
  1. package/dist/authorization/authorization-keystore.js +2 -2
  2. package/dist/authorization/authorization-keystore.js.map +1 -1
  3. package/dist/http/csv-metadata.d.ts +24 -0
  4. package/dist/http/csv-metadata.js +3 -0
  5. package/dist/http/csv-metadata.js.map +1 -0
  6. package/dist/http/csv-validator.d.ts +23 -0
  7. package/dist/http/csv-validator.js +83 -0
  8. package/dist/http/csv-validator.js.map +1 -0
  9. package/dist/http/output-formatters.d.ts +28 -0
  10. package/dist/http/output-formatters.js +197 -0
  11. package/dist/http/output-formatters.js.map +1 -0
  12. package/dist/http/result-stream.d.ts +35 -0
  13. package/dist/http/result-stream.js +78 -0
  14. package/dist/http/result-stream.js.map +1 -0
  15. package/dist/http/router.d.ts +7 -1
  16. package/dist/http/router.js +276 -70
  17. package/dist/http/router.js.map +1 -1
  18. package/dist/http/stream.d.ts +3 -0
  19. package/dist/http/stream.js +79 -5
  20. package/dist/http/stream.js.map +1 -1
  21. package/dist/postgres/postgres-store.js +7 -5
  22. package/dist/postgres/postgres-store.js.map +1 -1
  23. package/dist/postgres/purge-sql.js +15 -2
  24. package/dist/postgres/purge-sql.js.map +1 -1
  25. package/dist/postgres/specification-result-sql.d.ts +2 -0
  26. package/dist/postgres/specification-result-sql.js +49 -5
  27. package/dist/postgres/specification-result-sql.js.map +1 -1
  28. package/dist/postgres/specification-sql.js +28 -6
  29. package/dist/postgres/specification-sql.js.map +1 -1
  30. package/docs/analysis/DIAGNOSTIC_LOGGING_README.md +201 -0
  31. package/docs/analysis/security-audit-feeds-hash-handler.md +407 -0
  32. package/docs/design/feeds-persistent-proxy-plan.md +294 -0
  33. package/integration-test/integration-test/jinaga-test/package-lock.json +6 -0
  34. package/integration-test/jinaga-test/Dockerfile +1 -6
  35. package/integration-test/jinaga-test/http-test-helpers.js +77 -0
  36. package/integration-test/jinaga-test/jinaga.test.js +9 -9
  37. package/integration-test/jinaga-test/package-lock.json +329 -32
  38. package/integration-test/jinaga-test/package.json +5 -1
  39. package/integration-test/jinaga-test/read-csv.test.js +330 -0
  40. package/integration-test/jinaga-test/read-endpoint.test.js +442 -0
  41. package/integration-test/jinaga-test/read-performance.test.js +221 -0
  42. package/integration-test/jinaga-test/read-streaming.test.js +199 -0
  43. package/integration-test/jinaga-test/specification-conditions.test.js +147 -0
  44. package/integration-test/jinaga-test/test-helpers.js +12 -0
  45. package/integration-test/jinaga-test/time-projection.test.js +235 -0
  46. package/package.json +3 -2
  47. package/src/authorization/authorization-keystore.ts +4 -4
  48. package/src/http/csv-metadata.ts +34 -0
  49. package/src/http/csv-validator.ts +90 -0
  50. package/src/http/output-formatters.ts +196 -0
  51. package/src/http/result-stream.ts +69 -0
  52. package/src/http/router.ts +344 -62
  53. package/src/http/stream.ts +97 -5
  54. package/src/postgres/postgres-store.ts +13 -6
  55. package/src/postgres/purge-sql.ts +18 -2
  56. package/src/postgres/specification-result-sql.ts +70 -8
  57. package/src/postgres/specification-sql.ts +36 -8
  58. package/test/http/csv-validatorSpec.ts +205 -0
  59. package/test/http/result-streamSpec.ts +151 -0
  60. package/test/postgres/postgresReadSpec.ts +192 -14
  61. package/test/postgres/postgresSpecificationSpec.ts +3 -3
  62. package/test/postgres/specificationConditionsSpec.ts +268 -0
  63. package/test-concurrent-streams.js +219 -0
  64. package/documentation/streaming-feeds-full-results-solution.md +0 -230
@@ -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
- response.close();
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
- response.close();
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
- res.write(JSON.stringify(data) + "\n\n");
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
- res.socket?.end();
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 => handleError(error, req, res, next));
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 => handleError(error, req, res, next));
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, postString((user, input: string) => this.read(user, input)));
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
- private read(user: RequestUser | null, input: string): Promise<any[]> {
349
- return Trace.dependency("read", "", async () => {
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
- return extracted.result;
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, label, index) => ({
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(label => feedDefinition.namedStart[label.name]);
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(label => feedDefinition.namedStart[label.name]);
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
- // Continuous initial query until exhausted
488
- bookmark = await this.streamAllInitialResults(userIdentity, feedDefinition, start, bookmark, stream);
489
-
490
- // Set up real-time listeners after initial data is complete
491
- const inverses = invertSpecification(feedDefinition.feed);
492
- const listeners = inverses.map(inverse => this.factManager.addSpecificationListener(
493
- inverse.inverseSpecification,
494
- async (results) => {
495
- // Filter out results that do not match the given.
496
- const matchingResults = results.filter(pr =>
497
- givenHash === computeTupleSubsetHash(pr.tuple, inverse.givenSubset));
498
- if (matchingResults.length != 0) {
499
- bookmark = await this.streamFeedResponse(userIdentity, feedDefinition, start, bookmark, stream, true);
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
- stream.done(() => {
504
- for (const listener of listeners) {
505
- this.factManager.removeSpecificationListener(listener);
506
- }
507
- });
508
- return stream;
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
- // No progress made, avoid infinite loop
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 references = results.tuples.flatMap(t => t.facts).filter((value, index, self) => self.findIndex(f => f.hash === value.hash && f.type === value.type) === index
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.");