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.
- 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 +93 -46
- package/dist/http/router.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/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 +112 -33
- 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/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
|
+
}
|
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>(
|
|
@@ -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,
|
|
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
|
-
|
|
427
|
-
|
|
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
|
-
|
|
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,
|
|
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(
|
|
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(
|
|
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:
|
|
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;
|