jinaga-server 3.5.2 → 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 (57) 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 +93 -46
  17. package/dist/http/router.js.map +1 -1
  18. package/dist/postgres/postgres-store.js +7 -5
  19. package/dist/postgres/postgres-store.js.map +1 -1
  20. package/dist/postgres/purge-sql.js +15 -2
  21. package/dist/postgres/purge-sql.js.map +1 -1
  22. package/dist/postgres/specification-result-sql.d.ts +2 -0
  23. package/dist/postgres/specification-result-sql.js +49 -5
  24. package/dist/postgres/specification-result-sql.js.map +1 -1
  25. package/dist/postgres/specification-sql.js +28 -6
  26. package/dist/postgres/specification-sql.js.map +1 -1
  27. package/docs/design/feeds-persistent-proxy-plan.md +294 -0
  28. package/integration-test/integration-test/jinaga-test/package-lock.json +6 -0
  29. package/integration-test/jinaga-test/Dockerfile +1 -6
  30. package/integration-test/jinaga-test/http-test-helpers.js +77 -0
  31. package/integration-test/jinaga-test/jinaga.test.js +9 -9
  32. package/integration-test/jinaga-test/package-lock.json +329 -32
  33. package/integration-test/jinaga-test/package.json +5 -1
  34. package/integration-test/jinaga-test/read-csv.test.js +330 -0
  35. package/integration-test/jinaga-test/read-endpoint.test.js +442 -0
  36. package/integration-test/jinaga-test/read-performance.test.js +221 -0
  37. package/integration-test/jinaga-test/read-streaming.test.js +199 -0
  38. package/integration-test/jinaga-test/specification-conditions.test.js +147 -0
  39. package/integration-test/jinaga-test/test-helpers.js +12 -0
  40. package/integration-test/jinaga-test/time-projection.test.js +235 -0
  41. package/package.json +3 -2
  42. package/src/authorization/authorization-keystore.ts +4 -4
  43. package/src/http/csv-metadata.ts +34 -0
  44. package/src/http/csv-validator.ts +90 -0
  45. package/src/http/output-formatters.ts +196 -0
  46. package/src/http/result-stream.ts +69 -0
  47. package/src/http/router.ts +112 -33
  48. package/src/postgres/postgres-store.ts +13 -6
  49. package/src/postgres/purge-sql.ts +18 -2
  50. package/src/postgres/specification-result-sql.ts +70 -8
  51. package/src/postgres/specification-sql.ts +36 -8
  52. package/test/http/csv-validatorSpec.ts +205 -0
  53. package/test/http/result-streamSpec.ts +151 -0
  54. package/test/postgres/postgresReadSpec.ts +192 -14
  55. package/test/postgres/postgresSpecificationSpec.ts +3 -3
  56. package/test/postgres/specificationConditionsSpec.ts +268 -0
  57. package/documentation/streaming-feeds-full-results-solution.md +0 -230
@@ -0,0 +1,90 @@
1
+ import { Specification } from 'jinaga';
2
+ import { CsvMetadata } from './csv-metadata';
3
+
4
+ /**
5
+ * Validates a specification for CSV compatibility.
6
+ * Returns metadata including headers and validation results.
7
+ *
8
+ * CSV requires flat projections with single-valued components:
9
+ * - ✅ Fields (scalar values from matched facts)
10
+ * - ✅ Hashes
11
+ * - ✅ Timestamps
12
+ * - ❌ Arrays (nested specifications)
13
+ * - ❌ Nested objects (composite projections)
14
+ */
15
+ export function validateSpecificationForCsv(specification: Specification): CsvMetadata {
16
+ const headers: string[] = [];
17
+ const errors: string[] = [];
18
+
19
+ // Ensure projection is composite
20
+ if (specification.projection.type !== 'composite') {
21
+ return {
22
+ headers: [],
23
+ isValid: false,
24
+ errors: ['Specification projection must be composite for CSV export']
25
+ };
26
+ }
27
+
28
+ // Ensure that the composite has components.
29
+ // An empty composite represents a missing projection.
30
+ if (specification.projection.components.length === 0) {
31
+ return {
32
+ headers: [],
33
+ isValid: false,
34
+ errors: ['Specification must have a projection for CSV export']
35
+ };
36
+ }
37
+
38
+ for (const component of specification.projection.components) {
39
+ if (component.type === 'field' || component.type === 'hash' || component.type === 'time') {
40
+ headers.push(component.name);
41
+ }
42
+ else {
43
+ errors.push(`Unsupported projection type of field '${component.name}' for CSV export. Only flat field projections are allowed.`);
44
+ }
45
+ }
46
+
47
+ return {
48
+ headers,
49
+ isValid: errors.length === 0,
50
+ errors
51
+ };
52
+ }
53
+
54
+ /**
55
+ * Extract value from result object using projection label.
56
+ * Since CSV only supports flat projections, labels are simple property names.
57
+ */
58
+ export function extractValueByLabel(result: any, label: string): any {
59
+ if (result == null) {
60
+ return null;
61
+ }
62
+
63
+ // Direct property access
64
+ const value = result[label];
65
+
66
+ // Return null instead of undefined
67
+ return value ?? null;
68
+ }
69
+
70
+ /**
71
+ * Format a value for CSV output
72
+ */
73
+ export function formatValueForCsv(value: any): string {
74
+ if (value === null || value === undefined) {
75
+ return '';
76
+ }
77
+
78
+ // Handle dates
79
+ if (value instanceof Date) {
80
+ return value.toISOString();
81
+ }
82
+
83
+ // Handle booleans
84
+ if (typeof value === 'boolean') {
85
+ return value ? 'true' : 'false';
86
+ }
87
+
88
+ // Default: convert to string
89
+ return String(value);
90
+ }
@@ -0,0 +1,196 @@
1
+ import { stringify } from 'csv-stringify';
2
+ import { Response } from "express";
3
+ import { CsvMetadata } from "./csv-metadata";
4
+ import { extractValueByLabel } from "./csv-validator";
5
+ import { ResultStream } from "./result-stream";
6
+
7
+ /**
8
+ * Output read results with streaming support.
9
+ * Handles content negotiation and streams data when possible.
10
+ */
11
+ export async function outputReadResultsStreaming(
12
+ result: ResultStream<any>,
13
+ res: Response,
14
+ acceptType: string,
15
+ csvMetadata?: CsvMetadata
16
+ ): Promise<void> {
17
+ switch (acceptType) {
18
+ case "application/x-ndjson":
19
+ // NDJSON format - stream one JSON object per line
20
+ await streamAsNDJSON(result, res);
21
+ break;
22
+ case "text/csv":
23
+ // CSV format - stream as CSV using csv-stringify
24
+ if (csvMetadata) {
25
+ await streamAsCSVWithStringify(result, res, csvMetadata);
26
+ } else {
27
+ throw new Error("CSV metadata is required for CSV output format");
28
+ }
29
+ break;
30
+ case "application/json":
31
+ // Compact JSON
32
+ await collectAndSendCompactJSON(result, res);
33
+ break;
34
+ case "text/plain":
35
+ // Pretty-printed JSON
36
+ await collectAndSendPrettyJSON(result, res);
37
+ break;
38
+ default:
39
+ // Default to pretty-printed JSON for unrecognized types
40
+ await collectAndSendPrettyJSON(result, res);
41
+ break;
42
+ }
43
+ }
44
+
45
+ /**
46
+ * Stream results as NDJSON (newline-delimited JSON).
47
+ * Each result is written as a separate line.
48
+ */
49
+ export async function streamAsNDJSON(stream: ResultStream<any>, res: Response): Promise<void> {
50
+ res.type("application/x-ndjson");
51
+
52
+ try {
53
+ let item;
54
+ while ((item = await stream.next()) !== null) {
55
+ res.write(JSON.stringify(item) + '\n');
56
+ }
57
+ res.end();
58
+ } catch (error) {
59
+ // Send error as NDJSON line
60
+ const errorFrame = {
61
+ error: true,
62
+ message: error instanceof Error ? error.message : String(error)
63
+ };
64
+ res.write(JSON.stringify(errorFrame) + '\n');
65
+ res.end();
66
+ } finally {
67
+ await stream.close();
68
+ }
69
+ }
70
+
71
+ /**
72
+ * Collect all results from stream and send as compact JSON.
73
+ * Used for application/json format.
74
+ */
75
+ export async function collectAndSendCompactJSON(
76
+ stream: ResultStream<any>,
77
+ res: Response
78
+ ): Promise<void> {
79
+ try {
80
+ const results: any[] = [];
81
+ let item;
82
+ while ((item = await stream.next()) !== null) {
83
+ results.push(item);
84
+ }
85
+
86
+ res.type("application/json");
87
+ res.send(JSON.stringify(results));
88
+ } finally {
89
+ await stream.close();
90
+ }
91
+ }
92
+
93
+ /**
94
+ * Collect all results from stream and send as pretty-printed JSON.
95
+ * Used for text/plain format and as default for unrecognized Accept types.
96
+ */
97
+ export async function collectAndSendPrettyJSON(
98
+ stream: ResultStream<any>,
99
+ res: Response
100
+ ): Promise<void> {
101
+ try {
102
+ const results: any[] = [];
103
+ let item;
104
+ while ((item = await stream.next()) !== null) {
105
+ results.push(item);
106
+ }
107
+
108
+ res.type("text/plain");
109
+ res.send(JSON.stringify(results, null, 2));
110
+ } finally {
111
+ await stream.close();
112
+ }
113
+ }
114
+
115
+ /**
116
+ * Stream results as CSV using csv-stringify library.
117
+ * Headers are provided from the specification metadata.
118
+ */
119
+ export async function streamAsCSVWithStringify(
120
+ stream: ResultStream<any>,
121
+ res: Response,
122
+ csvMetadata: CsvMetadata
123
+ ): Promise<void> {
124
+ res.type("text/csv");
125
+
126
+ // Helper to await 'finish' or 'error' events on a stream
127
+ function finishedAsync(stream: NodeJS.EventEmitter): Promise<void> {
128
+ return new Promise((resolve, reject) => {
129
+ stream.once('finish', resolve);
130
+ stream.once('error', reject);
131
+ });
132
+ }
133
+
134
+ let stringifier: any;
135
+ try {
136
+ // Create csv-stringify stringifier with headers from specification
137
+ stringifier = stringify({
138
+ header: true,
139
+ columns: csvMetadata.headers,
140
+ cast: {
141
+ // Custom casting for special types
142
+ boolean: (value) => value ? 'true' : 'false',
143
+ date: (value) => {
144
+ if (value instanceof Date) {
145
+ return value.toISOString();
146
+ }
147
+ return String(value);
148
+ },
149
+ object: (value) => {
150
+ // Handle nested objects
151
+ if (value && typeof value === 'object') {
152
+ // Fact reference - just use hash
153
+ if (value.type && value.hash) {
154
+ return value.hash;
155
+ }
156
+ // Other objects - stringify
157
+ return JSON.stringify(value);
158
+ }
159
+ return String(value);
160
+ }
161
+ }
162
+ });
163
+
164
+ // Pipe stringifier to response
165
+ stringifier.pipe(res);
166
+
167
+ // Stream data through stringifier
168
+ let item: any = null;
169
+ while ((item = await stream.next()) !== null) {
170
+ // Extract values in header order
171
+ const row: any = {};
172
+ for (const header of csvMetadata.headers) {
173
+ const value = extractValueByLabel(item, header);
174
+ row[header] = value !== null && value !== undefined ? value : '';
175
+ }
176
+
177
+ // Write row to stringifier
178
+ stringifier.write(row);
179
+ }
180
+
181
+ // Signal end of data
182
+ stringifier.end();
183
+
184
+ // Await finish or error
185
+ await finishedAsync(stringifier);
186
+
187
+ } catch (error) {
188
+ console.error('Error in CSV streaming:', error);
189
+ if (!res.headersSent) {
190
+ res.status(500).send('Error generating CSV');
191
+ }
192
+ throw error;
193
+ } finally {
194
+ await stream.close();
195
+ }
196
+ }
@@ -0,0 +1,69 @@
1
+ /**
2
+ * ResultStream interface for streaming query results incrementally.
3
+ * This abstraction allows for memory-efficient processing of large result sets.
4
+ */
5
+ export interface ResultStream<T> {
6
+ /**
7
+ * Get the next item in the stream.
8
+ * @returns Promise resolving to the next item, or null if stream is exhausted
9
+ */
10
+ next(): Promise<T | null>;
11
+
12
+ /**
13
+ * Close the stream and release resources.
14
+ */
15
+ close(): Promise<void>;
16
+ }
17
+
18
+ /**
19
+ * Implements ResultStream for AsyncIterable sources.
20
+ * Useful for streaming data from database cursors or other async sources.
21
+ */
22
+ export class AsyncIterableResultStream<T> implements ResultStream<T> {
23
+ private iterator: AsyncIterator<T>;
24
+ private closed: boolean = false;
25
+
26
+ constructor(iterable: AsyncIterable<T>) {
27
+ this.iterator = iterable[Symbol.asyncIterator]();
28
+ }
29
+
30
+ async next(): Promise<T | null> {
31
+ if (this.closed) {
32
+ return null;
33
+ }
34
+
35
+ const result = await this.iterator.next();
36
+ if (result.done) {
37
+ await this.close();
38
+ return null;
39
+ }
40
+
41
+ return result.value;
42
+ }
43
+
44
+ async close(): Promise<void> {
45
+ if (!this.closed) {
46
+ this.closed = true;
47
+ if (this.iterator.return) {
48
+ await this.iterator.return();
49
+ }
50
+ }
51
+ }
52
+ }
53
+
54
+ /**
55
+ * Converts an array to a ResultStream.
56
+ * Useful for backward compatibility and testing.
57
+ */
58
+ export async function* arrayAsyncIterable<T>(array: T[]): AsyncIterable<T> {
59
+ for (const item of array) {
60
+ yield item;
61
+ }
62
+ }
63
+
64
+ /**
65
+ * Helper function to convert an array to a ResultStream.
66
+ */
67
+ export function arrayToResultStream<T>(array: T[]): ResultStream<T> {
68
+ return new AsyncIterableResultStream(arrayAsyncIterable(array));
69
+ }
@@ -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>(
@@ -213,26 +217,6 @@ function post<T, U>(
213
217
  };
214
218
  }
215
219
 
216
- function postString<U>(method: (user: RequestUser, message: string) => Promise<U>): Handler {
217
- return (req, res, next) => {
218
- const user = <RequestUser>(req as any).user;
219
- const input = parseString(req.body);
220
- if (!input || typeof (input) !== 'string') {
221
- res.type("text");
222
- res.status(500).send('Expected Content-Type text/plain. Ensure that you have called app.use(express.text()).');
223
- }
224
- else {
225
- method(user, input)
226
- .then(response => {
227
- res.type("text");
228
- res.send(JSON.stringify(response, null, 2));
229
- next();
230
- })
231
- .catch(error => handleError(error, req, res, next));
232
- }
233
- };
234
- }
235
-
236
220
  function postCreate<T>(
237
221
  parse: (request: Request) => T,
238
222
  method: (user: RequestUser, message: T) => Promise<void>
@@ -268,6 +252,40 @@ function postStringCreate(method: (user: RequestUser, message: string) => Promis
268
252
  };
269
253
  }
270
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
+
271
289
  function serializeUserIdentity(user: RequestUser | null): UserIdentity | null {
272
290
  if (!user) {
273
291
  return null;
@@ -324,6 +342,22 @@ function outputFeeds(result: FeedsResponse, res: Response, accepts: (type: strin
324
342
  res.send(JSON.stringify(result));
325
343
  }
326
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
+
327
361
  export interface RequestUser {
328
362
  provider: string;
329
363
  id: string;
@@ -352,7 +386,13 @@ export class HttpRouter {
352
386
  (user, graphSource) => this.save(user, graphSource)
353
387
  ));
354
388
 
355
- 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
+ ));
356
396
  router.post('/write', applyAllowOrigin, postStringCreate((user, input: string) => this.write(user, input)));
357
397
  router.post('/feeds', applyAllowOrigin, post(
358
398
  parseString,
@@ -423,26 +463,46 @@ export class HttpRouter {
423
463
  });
424
464
  }
425
465
 
426
- private read(user: RequestUser | null, input: string): Promise<any[]> {
427
- 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 () => {
428
481
  const knownFacts = await this.getKnownFacts(user);
429
482
  const parser = new SpecificationParser(input);
430
483
  parser.skipWhitespace();
431
484
  const declaration = parser.parseDeclaration(knownFacts);
432
485
  const specification = parser.parseSpecification();
433
486
  parser.expectEnd();
434
- const start = this.selectStart(specification, declaration);
435
487
 
436
488
  var failures: string[] = this.factManager.testSpecificationForCompliance(specification);
437
489
  if (failures.length > 0) {
438
490
  throw new Invalid(failures.join("\n"));
439
491
  }
440
492
 
493
+ const csvMetadata = preProcess(specification);
494
+
441
495
  const userIdentity = serializeUserIdentity(user);
496
+ const start = this.selectStart(specification, declaration);
442
497
  const results = await this.authorization.read(userIdentity, start, specification);
443
498
  const extracted = extractResults(results);
444
499
  Trace.counter("facts_read", extracted.count);
445
- return extracted.result;
500
+ const resultStream = arrayToResultStream(extracted.result);
501
+
502
+ return {
503
+ resultStream,
504
+ csvMetadata
505
+ };
446
506
  });
447
507
  }
448
508
 
@@ -486,8 +546,8 @@ export class HttpRouter {
486
546
  }
487
547
  // Verify that the input type matches the start fact type
488
548
  for (let i = 0; i < start.length; i++) {
489
- if (start[i].type !== specification.given[i].type) {
490
- 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})`);
491
551
  }
492
552
  }
493
553
  // Verify that the specification is compliant with purge conditions
@@ -496,9 +556,9 @@ export class HttpRouter {
496
556
  throw new Invalid(failures.join("\n"));
497
557
  }
498
558
 
499
- const namedStart = specification.given.reduce((map, label, index) => ({
559
+ const namedStart = specification.given.reduce((map, g, index) => ({
500
560
  ...map,
501
- [label.name]: start[index]
561
+ [g.label.name]: start[index]
502
562
  }), {} as ReferencesByName);
503
563
 
504
564
  // Verify that I can distribute all feeds to the user.
@@ -529,7 +589,7 @@ export class HttpRouter {
529
589
  const bookmark = query["b"] as string ?? "";
530
590
 
531
591
  const userIdentity = serializeUserIdentity(user);
532
- const start = feedDefinition.feed.given.map(label => feedDefinition.namedStart[label.name]);
592
+ const start = feedDefinition.feed.given.map(g => feedDefinition.namedStart[g.label.name]);
533
593
  const results = await this.authorization.feed(userIdentity, feedDefinition.feed, start, bookmark);
534
594
  // Return distinct fact references from all the tuples.
535
595
  const references = results.tuples.flatMap(t => t.facts).filter((value, index, self) =>
@@ -565,7 +625,7 @@ export class HttpRouter {
565
625
  console.log(`[StreamFeed:${connectionId}] Initial bookmark: ${bookmark || 'empty'}`);
566
626
 
567
627
  const userIdentity = serializeUserIdentity(user);
568
- const start = feedDefinition.feed.given.map(label => feedDefinition.namedStart[label.name]);
628
+ const start = feedDefinition.feed.given.map(g => feedDefinition.namedStart[g.label.name]);
569
629
  const givenHash = computeObjectHash(feedDefinition.namedStart);
570
630
 
571
631
  console.log(`[StreamFeed:${connectionId}] Feed setup - Given hash: ${givenHash.substring(0, 8)}..., Start facts: ${start.length}`);
@@ -812,9 +872,9 @@ export class HttpRouter {
812
872
  private selectStart(specification: Specification, declaration: Declaration): FactReference[] {
813
873
  // Select starting facts that match the inputs
814
874
  return specification.given.map(input => {
815
- const declaredFact = declaration.find(d => d.name === input.name);
875
+ const declaredFact = declaration.find(d => d.name === input.label.name);
816
876
  if (!declaredFact) {
817
- throw new Invalid(`No fact named ${input.name} was declared`);
877
+ throw new Invalid(`No fact named ${input.label.name} was declared`);
818
878
  }
819
879
  return declaredFact.declared.reference;
820
880
  });
@@ -893,6 +953,25 @@ export class HttpRouter {
893
953
  }
894
954
  }
895
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
+
896
975
  function parseString(input: any): string {
897
976
  if (typeof input !== 'string') {
898
977
  throw new Invalid("Expected a string. Check the content type of the request.");
@@ -46,6 +46,10 @@ import { ResultSetFact, ResultSetRow, ResultSetTree, resultSqlFromSpecification,
46
46
  import { sqlFromFeed } from "./specification-sql";
47
47
  import { purgeDescendantsSql, purgeSqlFromSpecification } from "./purge-sql";
48
48
 
49
+ interface PostgresFactRecord extends FactRecord {
50
+ timestamp: Date; // Maps to date_learned column
51
+ }
52
+
49
53
  interface FactTypeResult {
50
54
  rows: {
51
55
  fact_type_id: number;
@@ -91,6 +95,7 @@ interface AncestorResult {
91
95
  data: string;
92
96
  public_key: string;
93
97
  signature: string;
98
+ date_learned: Date;
94
99
  }[];
95
100
  }
96
101
 
@@ -353,15 +358,15 @@ export class PostgresStore implements Storage {
353
358
  const factParameters = flatten(references, (f) =>
354
359
  [f.hash, factTypes.get(f.type)]);
355
360
  const sql =
356
- 'SELECT f.fact_type_id, f.name, f.hash, f.data, s.public_key, s.signature ' +
357
- 'FROM (SELECT f.fact_id, f.fact_type_id, t.name, f.hash, f.data ' +
361
+ 'SELECT f.fact_type_id, f.name, f.hash, f.data, f.date_learned, s.public_key, s.signature ' +
362
+ 'FROM (SELECT f.fact_id, f.fact_type_id, t.name, f.hash, f.data, f.date_learned ' +
358
363
  `FROM ${this.schema}.fact f ` +
359
364
  `JOIN ${this.schema}.fact_type t ` +
360
365
  ' ON f.fact_type_id = t.fact_type_id ' +
361
366
  'JOIN (VALUES ' + factValues.join(', ') + ') AS v (hash, fact_type_id) ' +
362
367
  ' ON v.fact_type_id = f.fact_type_id AND v.hash = f.hash ' +
363
368
  'UNION ' +
364
- 'SELECT f2.fact_id, f2.fact_type_id, t.name, f2.hash, f2.data ' +
369
+ 'SELECT f2.fact_id, f2.fact_type_id, t.name, f2.hash, f2.data, f2.date_learned ' +
365
370
  `FROM ${this.schema}.fact f1 ` +
366
371
  'JOIN (VALUES ' + factValues.join(', ') + ') AS v (hash, fact_type_id) ' +
367
372
  ' ON v.fact_type_id = f1.fact_type_id AND v.hash = f1.hash ' +
@@ -387,11 +392,12 @@ export class PostgresStore implements Storage {
387
392
  this.factTypeMap = mergeFactTypes(this.factTypeMap, resultFactTypes);
388
393
  const records = result.rows.map((r) => {
389
394
  const { fields, predecessors }: { fields: {}, predecessors: PredecessorCollection } = r.data as any;
390
- const fact: FactRecord = {
395
+ const fact: PostgresFactRecord = {
391
396
  type: r.name,
392
397
  hash: r.hash,
393
398
  fields,
394
- predecessors
399
+ predecessors,
400
+ timestamp: r.date_learned
395
401
  };
396
402
  const signature: FactSignature = {
397
403
  publicKey: r.public_key,
@@ -523,7 +529,8 @@ async function executeQueryTree(sqlQueryTree: SqlQueryTree, connection: PoolClie
523
529
  const fact: ResultSetFact = {
524
530
  hash: dataRow[`hash${label.index}`],
525
531
  factId: dataRow[`id${label.index}`],
526
- data: dataRow[`data${label.index}`]
532
+ data: dataRow[`data${label.index}`],
533
+ timestamp: dataRow[`timestamp${label.index}`],
527
534
  };
528
535
  return {
529
536
  ...acc,
@@ -47,9 +47,25 @@ export function purgeSqlFromSpecification(specification: Specification, factType
47
47
 
48
48
  let queryDescription = QueryDescription.unsatisfiable;
49
49
  let knownFacts: FactByLabel = {};
50
- const given = specification.given;
51
- const start: FactReference[] = specification.given.map(g => ({ type: g.type, hash: 'xxxxx' }));
50
+ const given = specification.given.map(g => g.label);
51
+ const start: FactReference[] = specification.given.map(g => ({ type: g.label.type, hash: 'xxxxx' }));
52
+
53
+ // Process main matches
52
54
  ({ queryDescription, knownFacts } = queryDescriptionBuilder.addEdges(queryDescription, given, start, knownFacts, [], specification.matches));
55
+
56
+ // Process conditions from given
57
+ for (const givenItem of specification.given) {
58
+ if (givenItem.conditions && givenItem.conditions.length > 0) {
59
+ for (const condition of givenItem.conditions) {
60
+ const { query: queryDescriptionWithExistential, path: conditionalPath } = queryDescription.withExistentialCondition(condition.exists, []);
61
+ const { queryDescription: queryDescriptionConditional } = queryDescriptionBuilder.addEdges(queryDescriptionWithExistential, given, start, knownFacts, conditionalPath, condition.matches);
62
+
63
+ if (queryDescriptionConditional.isSatisfiable()) {
64
+ queryDescription = queryDescriptionConditional;
65
+ }
66
+ }
67
+ }
68
+ }
53
69
 
54
70
  if (!queryDescription.isSatisfiable()) {
55
71
  return null;