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,199 @@
|
|
|
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 = "StreamTest.Root";
|
|
11
|
+
type = Root.Type;
|
|
12
|
+
|
|
13
|
+
constructor(identifier) {
|
|
14
|
+
this.identifier = identifier;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
class Successor {
|
|
19
|
+
static Type = "StreamTest.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
|
+
describe('Read Endpoint - Phase 2: Streaming Infrastructure', () => {
|
|
53
|
+
let app;
|
|
54
|
+
let j;
|
|
55
|
+
let close;
|
|
56
|
+
|
|
57
|
+
beforeEach(async () => {
|
|
58
|
+
({ app, j, close } = await createTestApp());
|
|
59
|
+
await j.local();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
afterEach(async () => {
|
|
63
|
+
if (close) {
|
|
64
|
+
await close();
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
describe('2.3: NDJSON Streaming Output', () => {
|
|
69
|
+
it('should stream NDJSON without loading all into memory', async () => {
|
|
70
|
+
// Create 5000 successors
|
|
71
|
+
const root = await j.fact(new Root('stream-test-' + Date.now()));
|
|
72
|
+
const rootHash = j.hash(root);
|
|
73
|
+
|
|
74
|
+
// Create in batches
|
|
75
|
+
for (let i = 0; i < 5000; i++) {
|
|
76
|
+
await j.fact(new Successor(`stream-successor-${i}`, root));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const specification = `(root: StreamTest.Root) {
|
|
80
|
+
s: StreamTest.Successor [
|
|
81
|
+
s->pred: StreamTest.Root = root
|
|
82
|
+
]
|
|
83
|
+
} => {
|
|
84
|
+
s = s
|
|
85
|
+
}`;
|
|
86
|
+
|
|
87
|
+
const startTime = Date.now();
|
|
88
|
+
const response = await request(app)
|
|
89
|
+
.post('/read')
|
|
90
|
+
.set('Content-Type', 'text/plain')
|
|
91
|
+
.set('Accept', 'application/x-ndjson')
|
|
92
|
+
.send(`let root: StreamTest.Root = #${rootHash}\n${specification}`);
|
|
93
|
+
|
|
94
|
+
const duration = Date.now() - startTime;
|
|
95
|
+
|
|
96
|
+
if (response.status !== 200) {
|
|
97
|
+
expect(response.text).toBe('');
|
|
98
|
+
expect(response.status).toBe(200);
|
|
99
|
+
}
|
|
100
|
+
expect(response.status).toBe(200);
|
|
101
|
+
expect(response.type).toBe('application/x-ndjson');
|
|
102
|
+
|
|
103
|
+
// Parse NDJSON response
|
|
104
|
+
const results = parseNDJSON(response.text);
|
|
105
|
+
expect(results.length).toBe(5000);
|
|
106
|
+
|
|
107
|
+
// Should start streaming within 1 second
|
|
108
|
+
expect(duration).toBeLessThan(10000); // 10 seconds for large set
|
|
109
|
+
|
|
110
|
+
// Verify structure
|
|
111
|
+
expect(results[0]).toHaveProperty('s');
|
|
112
|
+
expect(results[0].s).toHaveProperty('type', 'StreamTest.Successor');
|
|
113
|
+
}, 120000); // 120 second timeout for large dataset
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
describe('2.4: Stream Error Handling', () => {
|
|
117
|
+
it('should handle normal completion without errors', async () => {
|
|
118
|
+
const root = await j.fact(new Root('error-test-' + Date.now()));
|
|
119
|
+
const rootHash = j.hash(root);
|
|
120
|
+
await j.fact(new Successor(`error-successor-1`, root));
|
|
121
|
+
await j.fact(new Successor(`error-successor-2`, root));
|
|
122
|
+
await j.fact(new Successor(`error-successor-3`, root));
|
|
123
|
+
|
|
124
|
+
const specification = `(root: StreamTest.Root) {
|
|
125
|
+
s: StreamTest.Successor [
|
|
126
|
+
s->pred: StreamTest.Root = root
|
|
127
|
+
]
|
|
128
|
+
} => {
|
|
129
|
+
s = s
|
|
130
|
+
}`;
|
|
131
|
+
|
|
132
|
+
const response = await request(app)
|
|
133
|
+
.post('/read')
|
|
134
|
+
.set('Content-Type', 'text/plain')
|
|
135
|
+
.set('Accept', 'application/x-ndjson')
|
|
136
|
+
.send(`let root: StreamTest.Root = #${rootHash}\n${specification}`);
|
|
137
|
+
|
|
138
|
+
if (response.status !== 200) {
|
|
139
|
+
expect(response.text).toBe('');
|
|
140
|
+
expect(response.status).toBe(200);
|
|
141
|
+
}
|
|
142
|
+
expect(response.status).toBe(200);
|
|
143
|
+
|
|
144
|
+
const results = parseNDJSON(response.text);
|
|
145
|
+
expect(results.length).toBe(3);
|
|
146
|
+
|
|
147
|
+
// None of the results should be error frames
|
|
148
|
+
results.forEach(item => {
|
|
149
|
+
expect(item.error).toBeUndefined();
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
describe('2.5: Backward Compatibility with Arrays', () => {
|
|
155
|
+
it('should accept and format array results', async () => {
|
|
156
|
+
const root = await j.fact(new Root('compat-test-' + Date.now()));
|
|
157
|
+
const rootHash = j.hash(root);
|
|
158
|
+
await j.fact(new Successor(`compat-successor-1`, root));
|
|
159
|
+
await j.fact(new Successor(`compat-successor-2`, root));
|
|
160
|
+
|
|
161
|
+
const specification = `(root: StreamTest.Root) {
|
|
162
|
+
s: StreamTest.Successor [
|
|
163
|
+
s->pred: StreamTest.Root = root
|
|
164
|
+
]
|
|
165
|
+
} => {
|
|
166
|
+
s = s
|
|
167
|
+
}`;
|
|
168
|
+
|
|
169
|
+
// Test all formats work with array-based results
|
|
170
|
+
const jsonResponse = await request(app)
|
|
171
|
+
.post('/read')
|
|
172
|
+
.set('Content-Type', 'text/plain')
|
|
173
|
+
.set('Accept', 'application/json')
|
|
174
|
+
.send(`let root: StreamTest.Root = #${rootHash}\n${specification}`);
|
|
175
|
+
|
|
176
|
+
if (jsonResponse.status !== 200) {
|
|
177
|
+
expect(jsonResponse.text).toBe('');
|
|
178
|
+
expect(jsonResponse.status).toBe(200);
|
|
179
|
+
}
|
|
180
|
+
expect(jsonResponse.status).toBe(200);
|
|
181
|
+
const jsonResults = JSON.parse(jsonResponse.text);
|
|
182
|
+
expect(jsonResults.length).toBe(2);
|
|
183
|
+
|
|
184
|
+
const ndjsonResponse = await request(app)
|
|
185
|
+
.post('/read')
|
|
186
|
+
.set('Content-Type', 'text/plain')
|
|
187
|
+
.set('Accept', 'application/x-ndjson')
|
|
188
|
+
.send(`let root: StreamTest.Root = #${rootHash}\n${specification}`);
|
|
189
|
+
|
|
190
|
+
if (ndjsonResponse.status !== 200) {
|
|
191
|
+
expect(ndjsonResponse.text).toBe('');
|
|
192
|
+
expect(ndjsonResponse.status).toBe(200);
|
|
193
|
+
}
|
|
194
|
+
expect(ndjsonResponse.status).toBe(200);
|
|
195
|
+
const ndjsonResults = parseNDJSON(ndjsonResponse.text);
|
|
196
|
+
expect(ndjsonResults.length).toBe(2);
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
});
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
const { JinagaServer } = require("./jinaga-server");
|
|
2
|
+
const { parseSpecification } = require("./test-helpers");
|
|
3
|
+
|
|
4
|
+
const host = "db";
|
|
5
|
+
// const host = "localhost";
|
|
6
|
+
const connectionString = `postgresql://dev:devpw@${host}:5432/integrationtest`;
|
|
7
|
+
|
|
8
|
+
class Department {
|
|
9
|
+
constructor(identifier) {
|
|
10
|
+
this.type = Department.Type;
|
|
11
|
+
this.identifier = identifier;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
Department.Type = "IntegrationTest.Conditions.Department";
|
|
15
|
+
|
|
16
|
+
class DepartmentActive {
|
|
17
|
+
constructor(department) {
|
|
18
|
+
this.type = DepartmentActive.Type;
|
|
19
|
+
this.department = department;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
DepartmentActive.Type = "IntegrationTest.Conditions.DepartmentActive";
|
|
23
|
+
|
|
24
|
+
class Employee {
|
|
25
|
+
constructor(department, employeeId) {
|
|
26
|
+
this.type = Employee.Type;
|
|
27
|
+
this.department = department;
|
|
28
|
+
this.employeeId = employeeId;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
Employee.Type = "IntegrationTest.Conditions.Employee";
|
|
32
|
+
|
|
33
|
+
describe("Specifications with conditions", () => {
|
|
34
|
+
let j;
|
|
35
|
+
let close;
|
|
36
|
+
|
|
37
|
+
beforeEach(() => {
|
|
38
|
+
({ j, close } = JinagaServer.create({
|
|
39
|
+
pgKeystore: connectionString,
|
|
40
|
+
pgStore: connectionString
|
|
41
|
+
}));
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
afterEach(async () => {
|
|
45
|
+
await close();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("should return employees when department is active (has DepartmentActive fact)", async () => {
|
|
49
|
+
// Create an active department (has DepartmentActive marker fact)
|
|
50
|
+
const department = await j.fact(new Department("HR-001"));
|
|
51
|
+
await j.fact(new DepartmentActive(department));
|
|
52
|
+
|
|
53
|
+
// Add employees to the department
|
|
54
|
+
await j.fact(new Employee(department, "EMP-001"));
|
|
55
|
+
await j.fact(new Employee(department, "EMP-002"));
|
|
56
|
+
|
|
57
|
+
// Query with positive existential condition: only return employees for departments that HAVE an active marker
|
|
58
|
+
const specification = parseSpecification(`
|
|
59
|
+
(department: IntegrationTest.Conditions.Department [
|
|
60
|
+
E {
|
|
61
|
+
active: IntegrationTest.Conditions.DepartmentActive [
|
|
62
|
+
active->department: IntegrationTest.Conditions.Department = department
|
|
63
|
+
]
|
|
64
|
+
}
|
|
65
|
+
]) {
|
|
66
|
+
employee: IntegrationTest.Conditions.Employee [
|
|
67
|
+
employee->department: IntegrationTest.Conditions.Department = department
|
|
68
|
+
]
|
|
69
|
+
} => employee
|
|
70
|
+
`);
|
|
71
|
+
|
|
72
|
+
const results = await j.query(specification, department);
|
|
73
|
+
|
|
74
|
+
// Should return both employees since department has active marker
|
|
75
|
+
expect(results.length).toBe(2);
|
|
76
|
+
expect(results).toEqual(
|
|
77
|
+
expect.arrayContaining([
|
|
78
|
+
expect.objectContaining({ employeeId: "EMP-001" }),
|
|
79
|
+
expect.objectContaining({ employeeId: "EMP-002" })
|
|
80
|
+
])
|
|
81
|
+
);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("should NOT return employees for inactive department (with positive existential condition)", async () => {
|
|
85
|
+
// Create an inactive department (no DepartmentActive marker fact)
|
|
86
|
+
const department = await j.fact(new Department("HR-002"));
|
|
87
|
+
|
|
88
|
+
// Add employees to the department
|
|
89
|
+
await j.fact(new Employee(department, "EMP-003"));
|
|
90
|
+
await j.fact(new Employee(department, "EMP-004"));
|
|
91
|
+
|
|
92
|
+
// Query with positive existential condition: only return employees for departments that HAVE an active marker
|
|
93
|
+
const specification = parseSpecification(`
|
|
94
|
+
(department: IntegrationTest.Conditions.Department [
|
|
95
|
+
E {
|
|
96
|
+
active: IntegrationTest.Conditions.DepartmentActive [
|
|
97
|
+
active->department: IntegrationTest.Conditions.Department = department
|
|
98
|
+
]
|
|
99
|
+
}
|
|
100
|
+
]) {
|
|
101
|
+
employee: IntegrationTest.Conditions.Employee [
|
|
102
|
+
employee->department: IntegrationTest.Conditions.Department = department
|
|
103
|
+
]
|
|
104
|
+
} => employee
|
|
105
|
+
`);
|
|
106
|
+
|
|
107
|
+
const results = await j.query(specification, department);
|
|
108
|
+
|
|
109
|
+
// Should return NO employees since department has no active marker
|
|
110
|
+
expect(results).toEqual([]);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("should return employees for inactive department using negative existential condition", async () => {
|
|
114
|
+
// Create two departments: one active, one inactive
|
|
115
|
+
const activeDept = await j.fact(new Department("HR-ACTIVE"));
|
|
116
|
+
await j.fact(new DepartmentActive(activeDept));
|
|
117
|
+
await j.fact(new Employee(activeDept, "EMP-ACTIVE-1"));
|
|
118
|
+
|
|
119
|
+
const inactiveDept = await j.fact(new Department("HR-INACTIVE"));
|
|
120
|
+
// No DepartmentActive fact for this one
|
|
121
|
+
await j.fact(new Employee(inactiveDept, "EMP-INACTIVE-1"));
|
|
122
|
+
|
|
123
|
+
// Query with negative existential condition: only return employees for departments that DON'T HAVE an active marker
|
|
124
|
+
const specification = parseSpecification(`
|
|
125
|
+
(department: IntegrationTest.Conditions.Department [
|
|
126
|
+
!E {
|
|
127
|
+
active: IntegrationTest.Conditions.DepartmentActive [
|
|
128
|
+
active->department: IntegrationTest.Conditions.Department = department
|
|
129
|
+
]
|
|
130
|
+
}
|
|
131
|
+
]) {
|
|
132
|
+
employee: IntegrationTest.Conditions.Employee [
|
|
133
|
+
employee->department: IntegrationTest.Conditions.Department = department
|
|
134
|
+
]
|
|
135
|
+
} => employee
|
|
136
|
+
`);
|
|
137
|
+
|
|
138
|
+
// Query active department - should return NO employees (has active marker, condition excludes it)
|
|
139
|
+
const activeResults = await j.query(specification, activeDept);
|
|
140
|
+
expect(activeResults).toEqual([]);
|
|
141
|
+
|
|
142
|
+
// Query inactive department - should return employees (no active marker, condition includes it)
|
|
143
|
+
const inactiveResults = await j.query(specification, inactiveDept);
|
|
144
|
+
expect(inactiveResults.length).toBe(1);
|
|
145
|
+
expect(inactiveResults[0]).toEqual(expect.objectContaining({ employeeId: "EMP-INACTIVE-1" }));
|
|
146
|
+
});
|
|
147
|
+
});
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
const { SpecificationParser, SpecificationOf } = require("jinaga");
|
|
2
|
+
|
|
3
|
+
function parseSpecification(input) {
|
|
4
|
+
const parser = new SpecificationParser(input);
|
|
5
|
+
parser.skipWhitespace();
|
|
6
|
+
const spec = parser.parseSpecification();
|
|
7
|
+
return new SpecificationOf(spec);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
module.exports = {
|
|
11
|
+
parseSpecification
|
|
12
|
+
};
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
const { parseSpecification } = require("./test-helpers");
|
|
2
|
+
const { JinagaServer } = require("./jinaga-server");
|
|
3
|
+
|
|
4
|
+
const host = "db";
|
|
5
|
+
// const host = "localhost";
|
|
6
|
+
const connectionString = `postgresql://dev:devpw@${host}:5432/integrationtest`;
|
|
7
|
+
|
|
8
|
+
class Tenant {
|
|
9
|
+
constructor(identifier) {
|
|
10
|
+
this.type = Tenant.Type;
|
|
11
|
+
this.identifier = identifier;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
Tenant.Type = "IntegrationTest.TimeProjection.Tenant";
|
|
15
|
+
|
|
16
|
+
class Event {
|
|
17
|
+
constructor(tenant, description) {
|
|
18
|
+
this.type = Event.Type;
|
|
19
|
+
this.tenant = tenant;
|
|
20
|
+
this.description = description;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
Event.Type = "IntegrationTest.TimeProjection.Event";
|
|
24
|
+
|
|
25
|
+
class EventCancelled {
|
|
26
|
+
constructor(event, reason) {
|
|
27
|
+
this.type = EventCancelled.Type;
|
|
28
|
+
this.event = event;
|
|
29
|
+
this.reason = reason;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
EventCancelled.Type = "IntegrationTest.TimeProjection.EventCancelled";
|
|
33
|
+
|
|
34
|
+
describe("Time projection", () => {
|
|
35
|
+
let j;
|
|
36
|
+
let close;
|
|
37
|
+
|
|
38
|
+
beforeEach(() => {
|
|
39
|
+
({ j, close } = JinagaServer.create({
|
|
40
|
+
pgKeystore: connectionString,
|
|
41
|
+
pgStore: connectionString
|
|
42
|
+
}));
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
afterEach(async () => {
|
|
46
|
+
await close();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("should retrieve timestamp for a fact", async () => {
|
|
50
|
+
const beforeTime = new Date();
|
|
51
|
+
const tenant = await j.fact(new Tenant("tenant-1"));
|
|
52
|
+
const event = await j.fact(new Event(tenant, "Test event"));
|
|
53
|
+
const afterTime = new Date();
|
|
54
|
+
|
|
55
|
+
const specification = parseSpecification(`
|
|
56
|
+
(tenant: IntegrationTest.TimeProjection.Tenant) {
|
|
57
|
+
event: IntegrationTest.TimeProjection.Event [
|
|
58
|
+
event->tenant: IntegrationTest.TimeProjection.Tenant = tenant
|
|
59
|
+
]
|
|
60
|
+
} => @event
|
|
61
|
+
`);
|
|
62
|
+
|
|
63
|
+
const results = await j.query(specification, tenant);
|
|
64
|
+
|
|
65
|
+
expect(results).toHaveLength(1);
|
|
66
|
+
expect(results[0]).toBeInstanceOf(Date);
|
|
67
|
+
expect(results[0].getTime()).toBeGreaterThanOrEqual(beforeTime.getTime() - 1000);
|
|
68
|
+
expect(results[0].getTime()).toBeLessThanOrEqual(afterTime.getTime() + 1000);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("should retrieve timestamps for multiple facts in order", async () => {
|
|
72
|
+
const tenant = await j.fact(new Tenant("tenant-2"));
|
|
73
|
+
const event1 = await j.fact(new Event(tenant, "First event"));
|
|
74
|
+
|
|
75
|
+
// Add a small delay to ensure timestamps differ
|
|
76
|
+
await new Promise(resolve => setTimeout(resolve, 10));
|
|
77
|
+
|
|
78
|
+
const event2 = await j.fact(new Event(tenant, "Second event"));
|
|
79
|
+
|
|
80
|
+
await new Promise(resolve => setTimeout(resolve, 10));
|
|
81
|
+
|
|
82
|
+
const event3 = await j.fact(new Event(tenant, "Third event"));
|
|
83
|
+
|
|
84
|
+
const specification = parseSpecification(`
|
|
85
|
+
(tenant: IntegrationTest.TimeProjection.Tenant) {
|
|
86
|
+
event: IntegrationTest.TimeProjection.Event [
|
|
87
|
+
event->tenant: IntegrationTest.TimeProjection.Tenant = tenant
|
|
88
|
+
]
|
|
89
|
+
} => {
|
|
90
|
+
event = event
|
|
91
|
+
timestamp = @event
|
|
92
|
+
}
|
|
93
|
+
`);
|
|
94
|
+
|
|
95
|
+
const results1 = await j.query(specification, tenant);
|
|
96
|
+
const results2 = await j.query(specification, tenant);
|
|
97
|
+
const results3 = await j.query(specification, tenant);
|
|
98
|
+
|
|
99
|
+
expect(results1).toHaveLength(3);
|
|
100
|
+
expect(results2).toHaveLength(3);
|
|
101
|
+
expect(results3).toHaveLength(3);
|
|
102
|
+
|
|
103
|
+
const time1 = results1[0].timestamp;
|
|
104
|
+
const time2 = results2[0].timestamp;
|
|
105
|
+
const time3 = results3[0].timestamp;
|
|
106
|
+
|
|
107
|
+
expect(time1).toBeInstanceOf(Date);
|
|
108
|
+
expect(time2).toBeInstanceOf(Date);
|
|
109
|
+
expect(time3).toBeInstanceOf(Date);
|
|
110
|
+
|
|
111
|
+
// Verify timestamps are ordered (allowing for small timing variations)
|
|
112
|
+
expect(time2.getTime()).toBeGreaterThanOrEqual(time1.getTime());
|
|
113
|
+
expect(time3.getTime()).toBeGreaterThanOrEqual(time2.getTime());
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("should project timestamp along with fact data", async () => {
|
|
117
|
+
const tenant = await j.fact(new Tenant("tenant-3"));
|
|
118
|
+
const event = await j.fact(new Event(tenant, "Complex test event"));
|
|
119
|
+
|
|
120
|
+
const specification = parseSpecification(`
|
|
121
|
+
(tenant: IntegrationTest.TimeProjection.Tenant) {
|
|
122
|
+
event: IntegrationTest.TimeProjection.Event [
|
|
123
|
+
event->tenant: IntegrationTest.TimeProjection.Tenant = tenant
|
|
124
|
+
]
|
|
125
|
+
} => {
|
|
126
|
+
description = event.description
|
|
127
|
+
timestamp = @event
|
|
128
|
+
}
|
|
129
|
+
`);
|
|
130
|
+
|
|
131
|
+
const results = await j.query(specification, tenant);
|
|
132
|
+
|
|
133
|
+
expect(results).toHaveLength(1);
|
|
134
|
+
expect(results[0].description).toBe("Complex test event");
|
|
135
|
+
expect(results[0].timestamp).toBeInstanceOf(Date);
|
|
136
|
+
expect(results[0].timestamp.getTime()).toBeLessThanOrEqual(Date.now() + 1000);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("should project timestamps in complex query with related facts", async () => {
|
|
140
|
+
const tenant = await j.fact(new Tenant("tenant-4"));
|
|
141
|
+
const event = await j.fact(new Event(tenant, "Event to be cancelled"));
|
|
142
|
+
|
|
143
|
+
// Add delay to ensure different timestamps
|
|
144
|
+
await new Promise(resolve => setTimeout(resolve, 10));
|
|
145
|
+
|
|
146
|
+
const cancellation = await j.fact(new EventCancelled(event, "No longer needed"));
|
|
147
|
+
|
|
148
|
+
const specification = parseSpecification(`
|
|
149
|
+
(tenant: IntegrationTest.TimeProjection.Tenant) {
|
|
150
|
+
event: IntegrationTest.TimeProjection.Event [
|
|
151
|
+
event->tenant: IntegrationTest.TimeProjection.Tenant = tenant
|
|
152
|
+
]
|
|
153
|
+
cancellation: IntegrationTest.TimeProjection.EventCancelled [
|
|
154
|
+
cancellation->event: IntegrationTest.TimeProjection.Event = event
|
|
155
|
+
]
|
|
156
|
+
} => {
|
|
157
|
+
event = event
|
|
158
|
+
eventTimestamp = @event
|
|
159
|
+
cancellation = cancellation
|
|
160
|
+
cancellationTimestamp = @cancellation
|
|
161
|
+
}
|
|
162
|
+
`);
|
|
163
|
+
|
|
164
|
+
const results = await j.query(specification, tenant);
|
|
165
|
+
|
|
166
|
+
expect(results).toHaveLength(1);
|
|
167
|
+
expect(results[0].event).toEqual(event);
|
|
168
|
+
expect(results[0].eventTimestamp).toBeInstanceOf(Date);
|
|
169
|
+
expect(results[0].cancellation).toEqual(cancellation);
|
|
170
|
+
expect(results[0].cancellationTimestamp).toBeInstanceOf(Date);
|
|
171
|
+
|
|
172
|
+
// Cancellation timestamp should be after or equal to event timestamp
|
|
173
|
+
expect(results[0].cancellationTimestamp.getTime())
|
|
174
|
+
.toBeGreaterThanOrEqual(results[0].eventTimestamp.getTime());
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it("should project only timestamp without other data", async () => {
|
|
178
|
+
const tenant = await j.fact(new Tenant("tenant-6"));
|
|
179
|
+
const event = await j.fact(new Event(tenant, "Timestamp only"));
|
|
180
|
+
|
|
181
|
+
const specification = parseSpecification(`
|
|
182
|
+
(tenant: IntegrationTest.TimeProjection.Tenant) {
|
|
183
|
+
event: IntegrationTest.TimeProjection.Event [
|
|
184
|
+
event->tenant: IntegrationTest.TimeProjection.Tenant = tenant
|
|
185
|
+
]
|
|
186
|
+
} => @event
|
|
187
|
+
`);
|
|
188
|
+
|
|
189
|
+
const results = await j.query(specification, tenant);
|
|
190
|
+
|
|
191
|
+
expect(results).toHaveLength(1);
|
|
192
|
+
expect(results[0]).toBeInstanceOf(Date);
|
|
193
|
+
// Should not include any other properties, just the Date
|
|
194
|
+
expect(typeof results[0].getTime).toBe('function');
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it("should handle multiple time projections in same query", async () => {
|
|
198
|
+
const tenant = await j.fact(new Tenant("tenant-7"));
|
|
199
|
+
const event1 = await j.fact(new Event(tenant, "First"));
|
|
200
|
+
|
|
201
|
+
await new Promise(resolve => setTimeout(resolve, 10));
|
|
202
|
+
|
|
203
|
+
const event2 = await j.fact(new Event(tenant, "Second"));
|
|
204
|
+
|
|
205
|
+
const specification = parseSpecification(`
|
|
206
|
+
(tenant: IntegrationTest.TimeProjection.Tenant) {
|
|
207
|
+
event: IntegrationTest.TimeProjection.Event [
|
|
208
|
+
event->tenant: IntegrationTest.TimeProjection.Tenant = tenant
|
|
209
|
+
]
|
|
210
|
+
} => {
|
|
211
|
+
description = event.description
|
|
212
|
+
timestamp = @event
|
|
213
|
+
}
|
|
214
|
+
`);
|
|
215
|
+
|
|
216
|
+
const results1 = await j.query(specification, tenant);
|
|
217
|
+
const results2 = await j.query(specification, tenant);
|
|
218
|
+
|
|
219
|
+
expect(results1).toHaveLength(2);
|
|
220
|
+
expect(results2).toHaveLength(2);
|
|
221
|
+
|
|
222
|
+
// Find the specific events in the results
|
|
223
|
+
const result1First = results1.find(r => r.description === "First");
|
|
224
|
+
const result2Second = results2.find(r => r.description === "Second");
|
|
225
|
+
|
|
226
|
+
expect(result1First).toBeDefined();
|
|
227
|
+
expect(result2Second).toBeDefined();
|
|
228
|
+
expect(result1First.timestamp).toBeInstanceOf(Date);
|
|
229
|
+
expect(result2Second.timestamp).toBeInstanceOf(Date);
|
|
230
|
+
|
|
231
|
+
// Verify second event timestamp is not before first
|
|
232
|
+
expect(result2Second.timestamp.getTime())
|
|
233
|
+
.toBeGreaterThanOrEqual(result1First.timestamp.getTime());
|
|
234
|
+
});
|
|
235
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "jinaga-server",
|
|
3
|
-
"version": "3.5.
|
|
3
|
+
"version": "3.5.3",
|
|
4
4
|
"description": "Data management for web and mobile applications.",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -47,8 +47,9 @@
|
|
|
47
47
|
"typescript": "^4.7.2"
|
|
48
48
|
},
|
|
49
49
|
"dependencies": {
|
|
50
|
+
"csv-stringify": "^6.6.0",
|
|
50
51
|
"express": "^4.21.2",
|
|
51
|
-
"jinaga": "^6.7.
|
|
52
|
+
"jinaga": "^6.7.14",
|
|
52
53
|
"pg": "^8.13.1"
|
|
53
54
|
}
|
|
54
55
|
}
|
|
@@ -55,9 +55,9 @@ export class AuthorizationKeystore implements Authorization {
|
|
|
55
55
|
const userReference: FactReference | null = userIdentity
|
|
56
56
|
? await this.keystore.getUserFact(userIdentity)
|
|
57
57
|
: null;
|
|
58
|
-
const namedStart = specification.given.reduce((map,
|
|
58
|
+
const namedStart = specification.given.reduce((map, g, index) => ({
|
|
59
59
|
...map,
|
|
60
|
-
[label.name]: start[index]
|
|
60
|
+
[g.label.name]: start[index]
|
|
61
61
|
}), {} as ReferencesByName);
|
|
62
62
|
// Break the specification into feeds and check distribution.
|
|
63
63
|
const feeds = buildFeeds(specification);
|
|
@@ -74,9 +74,9 @@ export class AuthorizationKeystore implements Authorization {
|
|
|
74
74
|
const userReference: FactReference | null = userIdentity
|
|
75
75
|
? await this.keystore.getUserFact(userIdentity)
|
|
76
76
|
: null;
|
|
77
|
-
const namedStart = specification.given.reduce((map,
|
|
77
|
+
const namedStart = specification.given.reduce((map, g, index) => ({
|
|
78
78
|
...map,
|
|
79
|
-
[label.name]: start[index]
|
|
79
|
+
[g.label.name]: start[index]
|
|
80
80
|
}), {} as ReferencesByName);
|
|
81
81
|
const canDistribute = await this.distributionEngine.canDistributeToAll([specification], namedStart, userReference);
|
|
82
82
|
if (canDistribute.type === "failure") {
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Metadata for CSV export derived from specification
|
|
3
|
+
*/
|
|
4
|
+
export interface CsvMetadata {
|
|
5
|
+
/** Column headers in order (from specification projection labels) */
|
|
6
|
+
headers: string[];
|
|
7
|
+
|
|
8
|
+
/** Whether the projection is valid for CSV */
|
|
9
|
+
isValid: boolean;
|
|
10
|
+
|
|
11
|
+
/** Validation errors if any */
|
|
12
|
+
errors: string[];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Projection component types
|
|
17
|
+
*/
|
|
18
|
+
export type ProjectionComponentType =
|
|
19
|
+
| 'field' // Scalar field (string, number, boolean)
|
|
20
|
+
| 'hash' // Fact hash
|
|
21
|
+
| 'type' // Fact type
|
|
22
|
+
| 'array' // Array/collection (invalid for CSV)
|
|
23
|
+
| 'nested' // Nested object (invalid for CSV)
|
|
24
|
+
| 'unknown';
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Information about a single projection component
|
|
28
|
+
*/
|
|
29
|
+
export interface ProjectionComponent {
|
|
30
|
+
label: string;
|
|
31
|
+
type: ProjectionComponentType;
|
|
32
|
+
isValid: boolean;
|
|
33
|
+
reason?: string;
|
|
34
|
+
}
|