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,442 @@
|
|
|
1
|
+
const request = require('supertest');
|
|
2
|
+
const { JinagaServer } = require('./jinaga-server');
|
|
3
|
+
const express = require('express');
|
|
4
|
+
|
|
5
|
+
const host = "db";
|
|
6
|
+
const connectionString = `postgresql://dev:devpw@${host}:5432/integrationtest`;
|
|
7
|
+
|
|
8
|
+
// Test model classes
|
|
9
|
+
class Root {
|
|
10
|
+
static Type = "ReadTest.Root";
|
|
11
|
+
type = Root.Type;
|
|
12
|
+
|
|
13
|
+
constructor(identifier) {
|
|
14
|
+
this.identifier = identifier;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
class Successor {
|
|
19
|
+
static Type = "ReadTest.Successor";
|
|
20
|
+
type = Successor.Type;
|
|
21
|
+
|
|
22
|
+
constructor(identifier, predecessor) {
|
|
23
|
+
this.identifier = identifier;
|
|
24
|
+
this.pred = predecessor;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Helper to create test app
|
|
29
|
+
async function createTestApp() {
|
|
30
|
+
const { handler, j, close } = JinagaServer.create({
|
|
31
|
+
pgKeystore: connectionString,
|
|
32
|
+
pgStore: connectionString
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
const app = express();
|
|
36
|
+
app.use(express.json());
|
|
37
|
+
app.use(express.text());
|
|
38
|
+
app.use(handler);
|
|
39
|
+
|
|
40
|
+
return { app, j, close };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Helper to create test data
|
|
44
|
+
async function createTestData(j, count) {
|
|
45
|
+
const root = await j.fact(new Root(Math.random().toString(36).substring(2, 10)));
|
|
46
|
+
const successors = [];
|
|
47
|
+
|
|
48
|
+
for (let i = 0; i < count; i++) {
|
|
49
|
+
const successor = await j.fact(new Successor(`successor-${i}`, root));
|
|
50
|
+
successors.push(successor);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return { root, successors };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Helper to parse NDJSON
|
|
57
|
+
function parseNDJSON(text) {
|
|
58
|
+
return text
|
|
59
|
+
.trim()
|
|
60
|
+
.split('\n')
|
|
61
|
+
.filter(line => line.trim() !== '')
|
|
62
|
+
.map(line => JSON.parse(line));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
describe('Read Endpoint - Phase 1: Content Negotiation', () => {
|
|
66
|
+
let app;
|
|
67
|
+
let j;
|
|
68
|
+
let close;
|
|
69
|
+
let root;
|
|
70
|
+
let rootHash;
|
|
71
|
+
let successors;
|
|
72
|
+
|
|
73
|
+
beforeEach(async () => {
|
|
74
|
+
({ app, j, close } = await createTestApp());
|
|
75
|
+
await j.local();
|
|
76
|
+
|
|
77
|
+
// Create test data: 1 Root and 2 Successors
|
|
78
|
+
({ root, successors } = await createTestData(j, 2));
|
|
79
|
+
rootHash = j.hash(root);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
afterEach(async () => {
|
|
83
|
+
if (close) {
|
|
84
|
+
await close();
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
describe('1.1: Default JSON Response (Backward Compatibility)', () => {
|
|
89
|
+
it('should return pretty-printed JSON array with default Accept header', async () => {
|
|
90
|
+
const specification = `let root: ReadTest.Root = #${rootHash}
|
|
91
|
+
|
|
92
|
+
(root: ReadTest.Root) {
|
|
93
|
+
s: ReadTest.Successor [
|
|
94
|
+
s->pred: ReadTest.Root = root
|
|
95
|
+
]
|
|
96
|
+
} => {
|
|
97
|
+
successorId = s.identifier
|
|
98
|
+
successorHash = #s
|
|
99
|
+
}`;
|
|
100
|
+
|
|
101
|
+
const response = await request(app)
|
|
102
|
+
.post('/read')
|
|
103
|
+
.set('Content-Type', 'text/plain')
|
|
104
|
+
.send(specification);
|
|
105
|
+
|
|
106
|
+
if (response.status !== 200) {
|
|
107
|
+
expect(response.text).toBe('');
|
|
108
|
+
expect(response.status).toBe(200);
|
|
109
|
+
}
|
|
110
|
+
expect(response.status).toBe(200);
|
|
111
|
+
expect(response.type).toBe('text/plain');
|
|
112
|
+
|
|
113
|
+
// Parse the response
|
|
114
|
+
const results = JSON.parse(response.text);
|
|
115
|
+
expect(Array.isArray(results)).toBe(true);
|
|
116
|
+
expect(results.length).toBe(2);
|
|
117
|
+
|
|
118
|
+
// Verify projection fields
|
|
119
|
+
results.forEach(item => {
|
|
120
|
+
expect(item).toHaveProperty('successorId');
|
|
121
|
+
expect(item).toHaveProperty('successorHash');
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
// Check that response is pretty-printed (has whitespace)
|
|
125
|
+
expect(response.text).toContain('\n');
|
|
126
|
+
expect(response.text).toContain(' '); // 2-space indentation
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
describe('1.2: Compact JSON Response', () => {
|
|
131
|
+
it('should return compact JSON when Accept: application/json', async () => {
|
|
132
|
+
const specification = `let root: ReadTest.Root = #${rootHash}
|
|
133
|
+
|
|
134
|
+
(root: ReadTest.Root) {
|
|
135
|
+
s: ReadTest.Successor [
|
|
136
|
+
s->pred: ReadTest.Root = root
|
|
137
|
+
]
|
|
138
|
+
} => {
|
|
139
|
+
successor = s
|
|
140
|
+
}`;
|
|
141
|
+
|
|
142
|
+
const response = await request(app)
|
|
143
|
+
.post('/read')
|
|
144
|
+
.set('Content-Type', 'text/plain')
|
|
145
|
+
.set('Accept', 'application/json')
|
|
146
|
+
.send(specification);
|
|
147
|
+
|
|
148
|
+
if (response.status !== 200) {
|
|
149
|
+
expect(response.text).toBe('');
|
|
150
|
+
expect(response.status).toBe(200);
|
|
151
|
+
}
|
|
152
|
+
expect(response.status).toBe(200);
|
|
153
|
+
expect(response.type).toBe('application/json');
|
|
154
|
+
|
|
155
|
+
// Parse the response
|
|
156
|
+
const results = JSON.parse(response.text);
|
|
157
|
+
expect(Array.isArray(results)).toBe(true);
|
|
158
|
+
expect(results.length).toBe(2);
|
|
159
|
+
|
|
160
|
+
// Check that response is compact (minimal whitespace)
|
|
161
|
+
// Compact JSON should not have formatting newlines or multiple spaces
|
|
162
|
+
const withoutData = response.text.replace(/"[^"]*"/g, '""'); // Remove string contents
|
|
163
|
+
expect(withoutData).not.toContain('\n ');
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
describe('1.3: Pretty JSON for Debugging', () => {
|
|
168
|
+
it('should return pretty JSON when Accept: text/plain', async () => {
|
|
169
|
+
const specification = `let root: ReadTest.Root = #${rootHash}
|
|
170
|
+
|
|
171
|
+
(root: ReadTest.Root) {
|
|
172
|
+
s: ReadTest.Successor [
|
|
173
|
+
s->pred: ReadTest.Root = root
|
|
174
|
+
]
|
|
175
|
+
} => {
|
|
176
|
+
successor = s
|
|
177
|
+
}`;
|
|
178
|
+
|
|
179
|
+
const response = await request(app)
|
|
180
|
+
.post('/read')
|
|
181
|
+
.set('Content-Type', 'text/plain')
|
|
182
|
+
.set('Accept', 'text/plain')
|
|
183
|
+
.send(specification);
|
|
184
|
+
|
|
185
|
+
if (response.status !== 200) {
|
|
186
|
+
expect(response.text).toBe('');
|
|
187
|
+
expect(response.status).toBe(200);
|
|
188
|
+
}
|
|
189
|
+
expect(response.status).toBe(200);
|
|
190
|
+
expect(response.type).toBe('text/plain');
|
|
191
|
+
|
|
192
|
+
// Parse the response
|
|
193
|
+
const results = JSON.parse(response.text);
|
|
194
|
+
expect(Array.isArray(results)).toBe(true);
|
|
195
|
+
expect(results.length).toBe(2);
|
|
196
|
+
|
|
197
|
+
// Check 2-space indentation
|
|
198
|
+
const lines = response.text.split('\n');
|
|
199
|
+
const indentedLines = lines.filter(line => line.startsWith(' '));
|
|
200
|
+
expect(indentedLines.length).toBeGreaterThan(0);
|
|
201
|
+
});
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
describe('1.4: NDJSON Format', () => {
|
|
205
|
+
it('should return newline-delimited JSON when Accept: application/x-ndjson', async () => {
|
|
206
|
+
// Create 5 successors for this test
|
|
207
|
+
await j.fact(new Successor(`successor-2`, root));
|
|
208
|
+
await j.fact(new Successor(`successor-3`, root));
|
|
209
|
+
await j.fact(new Successor(`successor-4`, root));
|
|
210
|
+
|
|
211
|
+
const specification = `let root: ReadTest.Root = #${rootHash}
|
|
212
|
+
|
|
213
|
+
(root: ReadTest.Root) {
|
|
214
|
+
s: ReadTest.Successor [
|
|
215
|
+
s->pred: ReadTest.Root = root
|
|
216
|
+
]
|
|
217
|
+
} => {
|
|
218
|
+
successor = s
|
|
219
|
+
}`;
|
|
220
|
+
|
|
221
|
+
const response = await request(app)
|
|
222
|
+
.post('/read')
|
|
223
|
+
.set('Content-Type', 'text/plain')
|
|
224
|
+
.set('Accept', 'application/x-ndjson')
|
|
225
|
+
.send(specification);
|
|
226
|
+
|
|
227
|
+
if (response.status !== 200) {
|
|
228
|
+
expect(response.text).toBe('');
|
|
229
|
+
expect(response.status).toBe(200);
|
|
230
|
+
}
|
|
231
|
+
expect(response.status).toBe(200);
|
|
232
|
+
expect(response.type).toBe('application/x-ndjson');
|
|
233
|
+
|
|
234
|
+
// Parse NDJSON response
|
|
235
|
+
const results = parseNDJSON(response.text);
|
|
236
|
+
expect(results.length).toBe(5);
|
|
237
|
+
|
|
238
|
+
// Each line should be valid JSON
|
|
239
|
+
results.forEach(item => {
|
|
240
|
+
expect(item).toHaveProperty('successor');
|
|
241
|
+
});
|
|
242
|
+
});
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
describe('1.5: OPTIONS Endpoint Update', () => {
|
|
246
|
+
it('should return supported content types in OPTIONS response', async () => {
|
|
247
|
+
const response = await request(app)
|
|
248
|
+
.options('/read');
|
|
249
|
+
|
|
250
|
+
expect(response.status).toBe(204);
|
|
251
|
+
|
|
252
|
+
const acceptPost = response.headers['accept-post'];
|
|
253
|
+
expect(acceptPost).toBeDefined();
|
|
254
|
+
expect(acceptPost).toContain('text/plain');
|
|
255
|
+
|
|
256
|
+
// After implementation, should also include:
|
|
257
|
+
// expect(acceptPost).toContain('application/json');
|
|
258
|
+
// expect(acceptPost).toContain('application/x-ndjson');
|
|
259
|
+
});
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
describe('1.6: Large Result Set', () => {
|
|
263
|
+
it('should handle 1000 results with all formats', async () => {
|
|
264
|
+
// Create 1000 successors
|
|
265
|
+
const largeRoot = await j.fact(new Root('large-test-' + Date.now()));
|
|
266
|
+
const largeRootHash = j.hash(largeRoot);
|
|
267
|
+
|
|
268
|
+
// Create in batches to avoid memory issues
|
|
269
|
+
for (let i = 0; i < 1000; i++) {
|
|
270
|
+
await j.fact(new Successor(`large-successor-${i}`, largeRoot));
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const specification = `let root: ReadTest.Root = #${largeRootHash}
|
|
274
|
+
|
|
275
|
+
(root: ReadTest.Root) {
|
|
276
|
+
s: ReadTest.Successor [
|
|
277
|
+
s->pred: ReadTest.Root = root
|
|
278
|
+
]
|
|
279
|
+
} => {
|
|
280
|
+
successor = s
|
|
281
|
+
}`;
|
|
282
|
+
|
|
283
|
+
// Test with application/json
|
|
284
|
+
const jsonResponse = await request(app)
|
|
285
|
+
.post('/read')
|
|
286
|
+
.set('Content-Type', 'text/plain')
|
|
287
|
+
.set('Accept', 'application/json')
|
|
288
|
+
.send(specification);
|
|
289
|
+
|
|
290
|
+
if (jsonResponse.status !== 200) {
|
|
291
|
+
expect(jsonResponse.text).toBe('');
|
|
292
|
+
expect(jsonResponse.status).toBe(200);
|
|
293
|
+
}
|
|
294
|
+
expect(jsonResponse.status).toBe(200);
|
|
295
|
+
const jsonResults = JSON.parse(jsonResponse.text);
|
|
296
|
+
expect(jsonResults.length).toBe(1000);
|
|
297
|
+
|
|
298
|
+
// Test with text/plain
|
|
299
|
+
const plainResponse = await request(app)
|
|
300
|
+
.post('/read')
|
|
301
|
+
.set('Content-Type', 'text/plain')
|
|
302
|
+
.set('Accept', 'text/plain')
|
|
303
|
+
.send(specification);
|
|
304
|
+
|
|
305
|
+
if (plainResponse.status !== 200) {
|
|
306
|
+
expect(plainResponse.text).toBe('');
|
|
307
|
+
expect(plainResponse.status).toBe(200);
|
|
308
|
+
}
|
|
309
|
+
expect(plainResponse.status).toBe(200);
|
|
310
|
+
const plainResults = JSON.parse(plainResponse.text);
|
|
311
|
+
expect(plainResults.length).toBe(1000);
|
|
312
|
+
|
|
313
|
+
// Test with application/x-ndjson
|
|
314
|
+
const ndjsonResponse = await request(app)
|
|
315
|
+
.post('/read')
|
|
316
|
+
.set('Content-Type', 'text/plain')
|
|
317
|
+
.set('Accept', 'application/x-ndjson')
|
|
318
|
+
.send(specification);
|
|
319
|
+
|
|
320
|
+
if (ndjsonResponse.status !== 200) {
|
|
321
|
+
expect(ndjsonResponse.text).toBe('');
|
|
322
|
+
expect(ndjsonResponse.status).toBe(200);
|
|
323
|
+
}
|
|
324
|
+
expect(ndjsonResponse.status).toBe(200);
|
|
325
|
+
const ndjsonResults = parseNDJSON(ndjsonResponse.text);
|
|
326
|
+
expect(ndjsonResults.length).toBe(1000);
|
|
327
|
+
}, 60000); // 60 second timeout for large dataset
|
|
328
|
+
});
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
describe('Read Endpoint - Regression Tests', () => {
|
|
332
|
+
let app;
|
|
333
|
+
let j;
|
|
334
|
+
let close;
|
|
335
|
+
|
|
336
|
+
// Test model for nested projections
|
|
337
|
+
class TestSite {
|
|
338
|
+
static Type = "ReadTest.Site";
|
|
339
|
+
type = TestSite.Type;
|
|
340
|
+
|
|
341
|
+
constructor(domain) {
|
|
342
|
+
this.domain = domain;
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
class TestPost {
|
|
347
|
+
static Type = "ReadTest.Post";
|
|
348
|
+
type = TestPost.Type;
|
|
349
|
+
|
|
350
|
+
constructor(site, createdAt) {
|
|
351
|
+
this.site = site;
|
|
352
|
+
this.createdAt = createdAt;
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
class TestTitle {
|
|
357
|
+
static Type = "ReadTest.Title";
|
|
358
|
+
type = TestTitle.Type;
|
|
359
|
+
|
|
360
|
+
constructor(post, value, prior) {
|
|
361
|
+
this.post = post;
|
|
362
|
+
this.value = value;
|
|
363
|
+
this.prior = prior;
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
beforeEach(async () => {
|
|
368
|
+
({ app, j, close } = await createTestApp());
|
|
369
|
+
await j.local();
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
afterEach(async () => {
|
|
373
|
+
if (close) {
|
|
374
|
+
await close();
|
|
375
|
+
}
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
it('should handle nested projections without CSV validation when no Accept header is set', async () => {
|
|
379
|
+
// Create test data with nested structure
|
|
380
|
+
const site = await j.fact(new TestSite('test.com'));
|
|
381
|
+
const siteHash = j.hash(site);
|
|
382
|
+
|
|
383
|
+
// Create posts
|
|
384
|
+
const post1 = await j.fact(new TestPost(site, new Date().toISOString()));
|
|
385
|
+
const post2 = await j.fact(new TestPost(site, new Date().toISOString()));
|
|
386
|
+
|
|
387
|
+
// Create titles for posts (nested projections)
|
|
388
|
+
await j.fact(new TestTitle(post1, 'First Post Title', null));
|
|
389
|
+
await j.fact(new TestTitle(post2, 'Second Post Title', null));
|
|
390
|
+
await j.fact(new TestTitle(post2, 'Updated Second Post Title', null));
|
|
391
|
+
|
|
392
|
+
// Specification with nested projection (similar to the bug report)
|
|
393
|
+
const specification = `let site: ReadTest.Site = #${siteHash}
|
|
394
|
+
|
|
395
|
+
(site: ReadTest.Site) {
|
|
396
|
+
post: ReadTest.Post [
|
|
397
|
+
post->site: ReadTest.Site = site
|
|
398
|
+
]
|
|
399
|
+
} => {
|
|
400
|
+
id = #post
|
|
401
|
+
createdAt = post.createdAt
|
|
402
|
+
titles = {
|
|
403
|
+
title: ReadTest.Title [
|
|
404
|
+
title->post: ReadTest.Post = post
|
|
405
|
+
]
|
|
406
|
+
} => title.value
|
|
407
|
+
}`;
|
|
408
|
+
|
|
409
|
+
// Make request WITHOUT setting Accept header (this was triggering CSV validation bug)
|
|
410
|
+
const response = await request(app)
|
|
411
|
+
.post('/read')
|
|
412
|
+
.set('Content-Type', 'text/plain')
|
|
413
|
+
.send(specification);
|
|
414
|
+
|
|
415
|
+
// Verify request succeeds
|
|
416
|
+
expect(response.status).toBe(200);
|
|
417
|
+
|
|
418
|
+
// Verify response is JSON (text/plain is the default format, returns JSON)
|
|
419
|
+
expect(response.type).toBe('text/plain');
|
|
420
|
+
|
|
421
|
+
// Parse and verify the response is valid JSON
|
|
422
|
+
const results = JSON.parse(response.text);
|
|
423
|
+
expect(Array.isArray(results)).toBe(true);
|
|
424
|
+
expect(results.length).toBe(2);
|
|
425
|
+
|
|
426
|
+
// Verify nested projection data is included
|
|
427
|
+
results.forEach(post => {
|
|
428
|
+
expect(post).toHaveProperty('id');
|
|
429
|
+
expect(post).toHaveProperty('createdAt');
|
|
430
|
+
expect(post).toHaveProperty('titles');
|
|
431
|
+
expect(Array.isArray(post.titles)).toBe(true);
|
|
432
|
+
expect(post.titles.length).toBeGreaterThan(0);
|
|
433
|
+
post.titles.forEach(title => {
|
|
434
|
+
expect(typeof title).toBe('string');
|
|
435
|
+
});
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
// Verify no CSV validation error was thrown (we successfully got JSON back)
|
|
439
|
+
expect(response.text).not.toContain('CSV');
|
|
440
|
+
expect(response.text).not.toContain('error');
|
|
441
|
+
});
|
|
442
|
+
});
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
const request = require('supertest');
|
|
2
|
+
const { JinagaServer } = require('./jinaga-server');
|
|
3
|
+
const express = require('express');
|
|
4
|
+
|
|
5
|
+
const host = "db";
|
|
6
|
+
const connectionString = `postgresql://dev:devpw@${host}:5432/integrationtest`;
|
|
7
|
+
|
|
8
|
+
// Test model classes
|
|
9
|
+
class Root {
|
|
10
|
+
static Type = "PerfTest.Root";
|
|
11
|
+
type = Root.Type;
|
|
12
|
+
|
|
13
|
+
constructor(identifier) {
|
|
14
|
+
this.identifier = identifier;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
class Successor {
|
|
19
|
+
static Type = "PerfTest.Successor";
|
|
20
|
+
type = Successor.Type;
|
|
21
|
+
|
|
22
|
+
constructor(identifier, predecessor) {
|
|
23
|
+
this.identifier = identifier;
|
|
24
|
+
this.pred = predecessor;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Helper to create test app
|
|
29
|
+
async function createTestApp() {
|
|
30
|
+
const { handler, j, close } = JinagaServer.create({
|
|
31
|
+
pgKeystore: connectionString,
|
|
32
|
+
pgStore: connectionString
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
const app = express();
|
|
36
|
+
app.use(express.json());
|
|
37
|
+
app.use(express.text());
|
|
38
|
+
app.use(handler);
|
|
39
|
+
|
|
40
|
+
return { app, j, close };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Helper to parse NDJSON
|
|
44
|
+
function parseNDJSON(text) {
|
|
45
|
+
return text
|
|
46
|
+
.trim()
|
|
47
|
+
.split('\n')
|
|
48
|
+
.filter(line => line.trim() !== '')
|
|
49
|
+
.map(line => JSON.parse(line));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Helper to measure memory
|
|
53
|
+
function getMemoryUsage() {
|
|
54
|
+
return process.memoryUsage().heapUsed / 1024 / 1024; // MB
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
describe('Read Endpoint - Performance Benchmarks', () => {
|
|
58
|
+
let app;
|
|
59
|
+
let j;
|
|
60
|
+
let close;
|
|
61
|
+
|
|
62
|
+
beforeEach(async () => {
|
|
63
|
+
({ app, j, close } = await createTestApp());
|
|
64
|
+
await j.local();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
afterEach(async () => {
|
|
68
|
+
if (close) {
|
|
69
|
+
await close();
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
describe('Performance: 1000 results', () => {
|
|
74
|
+
it('should complete within reasonable time', async () => {
|
|
75
|
+
const root = await j.fact(new Root('perf-1k-' + Date.now()));
|
|
76
|
+
const rootHash = j.hash(root);
|
|
77
|
+
|
|
78
|
+
for (let i = 0; i < 1000; i++) {
|
|
79
|
+
await j.fact(new Successor(`perf-successor-${i}`, root));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const specification = `let root: PerfTest.Root = #${rootHash}
|
|
83
|
+
|
|
84
|
+
(root: PerfTest.Root) {
|
|
85
|
+
s: PerfTest.Successor [
|
|
86
|
+
s->pred: PerfTest.Root = root
|
|
87
|
+
]
|
|
88
|
+
} => {
|
|
89
|
+
successor = s
|
|
90
|
+
}`;
|
|
91
|
+
|
|
92
|
+
const startTime = Date.now();
|
|
93
|
+
const response = await request(app)
|
|
94
|
+
.post('/read')
|
|
95
|
+
.set('Content-Type', 'text/plain')
|
|
96
|
+
.set('Accept', 'application/json')
|
|
97
|
+
.send(specification);
|
|
98
|
+
|
|
99
|
+
const duration = Date.now() - startTime;
|
|
100
|
+
|
|
101
|
+
if (response.status !== 200) {
|
|
102
|
+
expect(response.text).toBe('');
|
|
103
|
+
expect(response.status).toBe(200);
|
|
104
|
+
}
|
|
105
|
+
expect(response.status).toBe(200);
|
|
106
|
+
const results = JSON.parse(response.text);
|
|
107
|
+
expect(results.length).toBe(1000);
|
|
108
|
+
|
|
109
|
+
console.log(`1000 results completed in ${duration}ms`);
|
|
110
|
+
expect(duration).toBeLessThan(30000); // 30 seconds
|
|
111
|
+
}, 60000);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
describe('Performance: NDJSON vs JSON', () => {
|
|
115
|
+
it('should measure time to first byte for NDJSON', async () => {
|
|
116
|
+
const root = await j.fact(new Root('perf-ttfb-' + Date.now()));
|
|
117
|
+
const rootHash = j.hash(root);
|
|
118
|
+
|
|
119
|
+
for (let i = 0; i < 100; i++) {
|
|
120
|
+
await j.fact(new Successor(`perf-successor-${i}`, root));
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const specification = `let root: PerfTest.Root = #${rootHash}
|
|
124
|
+
|
|
125
|
+
(root: PerfTest.Root) {
|
|
126
|
+
s: PerfTest.Successor [
|
|
127
|
+
s->pred: PerfTest.Root = root
|
|
128
|
+
]
|
|
129
|
+
} => {
|
|
130
|
+
successor = s
|
|
131
|
+
}`;
|
|
132
|
+
|
|
133
|
+
// Test NDJSON
|
|
134
|
+
const ndjsonStart = Date.now();
|
|
135
|
+
const ndjsonResponse = await request(app)
|
|
136
|
+
.post('/read')
|
|
137
|
+
.set('Content-Type', 'text/plain')
|
|
138
|
+
.set('Accept', 'application/x-ndjson')
|
|
139
|
+
.send(specification);
|
|
140
|
+
const ndjsonDuration = Date.now() - ndjsonStart;
|
|
141
|
+
|
|
142
|
+
if (ndjsonResponse.status !== 200) {
|
|
143
|
+
expect(ndjsonResponse.text).toBe('');
|
|
144
|
+
expect(ndjsonResponse.status).toBe(200);
|
|
145
|
+
}
|
|
146
|
+
expect(ndjsonResponse.status).toBe(200);
|
|
147
|
+
const ndjsonResults = parseNDJSON(ndjsonResponse.text);
|
|
148
|
+
expect(ndjsonResults.length).toBe(100);
|
|
149
|
+
|
|
150
|
+
// Test JSON
|
|
151
|
+
const jsonStart = Date.now();
|
|
152
|
+
const jsonResponse = await request(app)
|
|
153
|
+
.post('/read')
|
|
154
|
+
.set('Content-Type', 'text/plain')
|
|
155
|
+
.set('Accept', 'application/json')
|
|
156
|
+
.send(specification);
|
|
157
|
+
const jsonDuration = Date.now() - jsonStart;
|
|
158
|
+
|
|
159
|
+
if (jsonResponse.status !== 200) {
|
|
160
|
+
expect(jsonResponse.text).toBe('');
|
|
161
|
+
expect(jsonResponse.status).toBe(200);
|
|
162
|
+
}
|
|
163
|
+
expect(jsonResponse.status).toBe(200);
|
|
164
|
+
const jsonResults = JSON.parse(jsonResponse.text);
|
|
165
|
+
expect(jsonResults.length).toBe(100);
|
|
166
|
+
|
|
167
|
+
console.log(`NDJSON: ${ndjsonDuration}ms, JSON: ${jsonDuration}ms`);
|
|
168
|
+
}, 60000);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
describe('Concurrent streams', () => {
|
|
172
|
+
it('should handle multiple concurrent requests', async () => {
|
|
173
|
+
const root = await j.fact(new Root('perf-concurrent-' + Date.now()));
|
|
174
|
+
const rootHash = j.hash(root);
|
|
175
|
+
|
|
176
|
+
for (let i = 0; i < 100; i++) {
|
|
177
|
+
await j.fact(new Successor(`perf-successor-${i}`, root));
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const specification = `let root: PerfTest.Root = #${rootHash}
|
|
181
|
+
|
|
182
|
+
(root: PerfTest.Root) {
|
|
183
|
+
s: PerfTest.Successor [
|
|
184
|
+
s->pred: PerfTest.Root = root
|
|
185
|
+
]
|
|
186
|
+
} => {
|
|
187
|
+
successor = s
|
|
188
|
+
}`;
|
|
189
|
+
|
|
190
|
+
// Launch 5 concurrent requests
|
|
191
|
+
const promises = [];
|
|
192
|
+
for (let i = 0; i < 5; i++) {
|
|
193
|
+
promises.push(
|
|
194
|
+
request(app)
|
|
195
|
+
.post('/read')
|
|
196
|
+
.set('Content-Type', 'text/plain')
|
|
197
|
+
.set('Accept', 'application/x-ndjson')
|
|
198
|
+
.send(specification)
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const startTime = Date.now();
|
|
203
|
+
const responses = await Promise.all(promises);
|
|
204
|
+
const duration = Date.now() - startTime;
|
|
205
|
+
|
|
206
|
+
// All should succeed
|
|
207
|
+
responses.forEach(response => {
|
|
208
|
+
if (response.status !== 200) {
|
|
209
|
+
expect(response.text).toBe('');
|
|
210
|
+
expect(response.status).toBe(200);
|
|
211
|
+
}
|
|
212
|
+
expect(response.status).toBe(200);
|
|
213
|
+
const results = parseNDJSON(response.text);
|
|
214
|
+
expect(results.length).toBe(100);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
console.log(`5 concurrent requests completed in ${duration}ms`);
|
|
218
|
+
expect(duration).toBeLessThan(30000); // 30 seconds
|
|
219
|
+
}, 60000);
|
|
220
|
+
});
|
|
221
|
+
});
|