signalk-questdb 2.1.0 → 2.1.2
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/ilp-writer.d.ts +1 -0
- package/dist/ilp-writer.js +17 -1
- package/dist/ilp-writer.js.map +1 -1
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/migration.js +40 -45
- package/dist/migration.js.map +1 -1
- package/dist/wal-monitor.js +1 -1
- package/dist/wal-monitor.js.map +1 -1
- package/package.json +2 -2
- package/dist/test/config.test.d.ts +0 -1
- package/dist/test/config.test.js +0 -161
- package/dist/test/config.test.js.map +0 -1
- package/dist/test/configpanel-responses.test.d.ts +0 -1
- package/dist/test/configpanel-responses.test.js +0 -197
- package/dist/test/configpanel-responses.test.js.map +0 -1
- package/dist/test/console-proxy.test.d.ts +0 -1
- package/dist/test/console-proxy.test.js +0 -664
- package/dist/test/console-proxy.test.js.map +0 -1
- package/dist/test/container-env.test.d.ts +0 -1
- package/dist/test/container-env.test.js +0 -70
- package/dist/test/container-env.test.js.map +0 -1
- package/dist/test/delta-routing.test.d.ts +0 -1
- package/dist/test/delta-routing.test.js +0 -258
- package/dist/test/delta-routing.test.js.map +0 -1
- package/dist/test/full-export-range.test.d.ts +0 -1
- package/dist/test/full-export-range.test.js +0 -78
- package/dist/test/full-export-range.test.js.map +0 -1
- package/dist/test/history-v1.test.d.ts +0 -1
- package/dist/test/history-v1.test.js +0 -676
- package/dist/test/history-v1.test.js.map +0 -1
- package/dist/test/history-v2.test.d.ts +0 -1
- package/dist/test/history-v2.test.js +0 -1001
- package/dist/test/history-v2.test.js.map +0 -1
- package/dist/test/host-limits.test.d.ts +0 -1
- package/dist/test/host-limits.test.js +0 -81
- package/dist/test/host-limits.test.js.map +0 -1
- package/dist/test/ilp-writer.test.d.ts +0 -1
- package/dist/test/ilp-writer.test.js +0 -419
- package/dist/test/ilp-writer.test.js.map +0 -1
- package/dist/test/influx-detect.test.d.ts +0 -1
- package/dist/test/influx-detect.test.js +0 -287
- package/dist/test/influx-detect.test.js.map +0 -1
- package/dist/test/migration.test.d.ts +0 -1
- package/dist/test/migration.test.js +0 -851
- package/dist/test/migration.test.js.map +0 -1
- package/dist/test/package-e2e.test.d.ts +0 -1
- package/dist/test/package-e2e.test.js +0 -306
- package/dist/test/package-e2e.test.js.map +0 -1
- package/dist/test/path-matcher.test.d.ts +0 -1
- package/dist/test/path-matcher.test.js +0 -224
- package/dist/test/path-matcher.test.js.map +0 -1
- package/dist/test/query-client.test.d.ts +0 -1
- package/dist/test/query-client.test.js +0 -299
- package/dist/test/query-client.test.js.map +0 -1
- package/dist/test/questdb-endpoint.test.d.ts +0 -1
- package/dist/test/questdb-endpoint.test.js +0 -297
- package/dist/test/questdb-endpoint.test.js.map +0 -1
- package/dist/test/restore.test.d.ts +0 -1
- package/dist/test/restore.test.js +0 -543
- package/dist/test/restore.test.js.map +0 -1
- package/dist/test/time-range.test.d.ts +0 -1
- package/dist/test/time-range.test.js +0 -53
- package/dist/test/time-range.test.js.map +0 -1
- package/dist/test/wal-monitor.test.d.ts +0 -1
- package/dist/test/wal-monitor.test.js +0 -405
- package/dist/test/wal-monitor.test.js.map +0 -1
|
@@ -1,1001 +0,0 @@
|
|
|
1
|
-
import { describe, it } from "node:test";
|
|
2
|
-
import assert from "node:assert/strict";
|
|
3
|
-
import { createHistoryProviderV2 } from "../history-v2.js";
|
|
4
|
-
function makeMockClient(captured) {
|
|
5
|
-
return {
|
|
6
|
-
exec: async (sql) => {
|
|
7
|
-
captured.push({ sql });
|
|
8
|
-
return { columns: [], dataset: [], count: 0, timestamp: 0 };
|
|
9
|
-
},
|
|
10
|
-
};
|
|
11
|
-
}
|
|
12
|
-
const SELF_CONTEXT = "vessels.urn:mrn:imo:mmsi:123456789";
|
|
13
|
-
describe("history-v2 context normalization", () => {
|
|
14
|
-
it("translates vessels.self to stored 'self'", async () => {
|
|
15
|
-
const captured = [];
|
|
16
|
-
const provider = createHistoryProviderV2(makeMockClient(captured), SELF_CONTEXT);
|
|
17
|
-
await provider.getValues({
|
|
18
|
-
from: { toString: () => "2024-01-01T00:00:00Z" },
|
|
19
|
-
to: { toString: () => "2024-01-01T01:00:00Z" },
|
|
20
|
-
context: "vessels.self",
|
|
21
|
-
pathSpecs: [
|
|
22
|
-
{
|
|
23
|
-
path: "navigation.speedOverGround",
|
|
24
|
-
aggregate: "average",
|
|
25
|
-
parameter: [],
|
|
26
|
-
},
|
|
27
|
-
],
|
|
28
|
-
});
|
|
29
|
-
assert.ok(captured[0].sql.includes("context = 'self'"), `Expected query to use stored context 'self', got: ${captured[0].sql}`);
|
|
30
|
-
});
|
|
31
|
-
it("translates fully-qualified self context to stored 'self'", async () => {
|
|
32
|
-
const captured = [];
|
|
33
|
-
const provider = createHistoryProviderV2(makeMockClient(captured), SELF_CONTEXT);
|
|
34
|
-
await provider.getValues({
|
|
35
|
-
from: { toString: () => "2024-01-01T00:00:00Z" },
|
|
36
|
-
to: { toString: () => "2024-01-01T01:00:00Z" },
|
|
37
|
-
context: SELF_CONTEXT,
|
|
38
|
-
pathSpecs: [
|
|
39
|
-
{
|
|
40
|
-
path: "navigation.speedOverGround",
|
|
41
|
-
aggregate: "average",
|
|
42
|
-
parameter: [],
|
|
43
|
-
},
|
|
44
|
-
],
|
|
45
|
-
});
|
|
46
|
-
assert.ok(captured[0].sql.includes("context = 'self'"), `Expected query to use stored context 'self', got: ${captured[0].sql}`);
|
|
47
|
-
});
|
|
48
|
-
it("passes through 'self' context unchanged", async () => {
|
|
49
|
-
const captured = [];
|
|
50
|
-
const provider = createHistoryProviderV2(makeMockClient(captured), SELF_CONTEXT);
|
|
51
|
-
await provider.getValues({
|
|
52
|
-
from: { toString: () => "2024-01-01T00:00:00Z" },
|
|
53
|
-
to: { toString: () => "2024-01-01T01:00:00Z" },
|
|
54
|
-
context: "self",
|
|
55
|
-
pathSpecs: [
|
|
56
|
-
{
|
|
57
|
-
path: "navigation.speedOverGround",
|
|
58
|
-
aggregate: "average",
|
|
59
|
-
parameter: [],
|
|
60
|
-
},
|
|
61
|
-
],
|
|
62
|
-
});
|
|
63
|
-
assert.ok(captured[0].sql.includes("context = 'self'"));
|
|
64
|
-
});
|
|
65
|
-
it("passes through other vessel contexts unchanged", async () => {
|
|
66
|
-
const captured = [];
|
|
67
|
-
const provider = createHistoryProviderV2(makeMockClient(captured), SELF_CONTEXT);
|
|
68
|
-
const otherVessel = "vessels.urn:mrn:imo:mmsi:987654321";
|
|
69
|
-
await provider.getValues({
|
|
70
|
-
from: { toString: () => "2024-01-01T00:00:00Z" },
|
|
71
|
-
to: { toString: () => "2024-01-01T01:00:00Z" },
|
|
72
|
-
context: otherVessel,
|
|
73
|
-
pathSpecs: [
|
|
74
|
-
{
|
|
75
|
-
path: "navigation.speedOverGround",
|
|
76
|
-
aggregate: "average",
|
|
77
|
-
parameter: [],
|
|
78
|
-
},
|
|
79
|
-
],
|
|
80
|
-
});
|
|
81
|
-
assert.ok(captured[0].sql.includes(`context = '${otherVessel}'`), `Expected query to use other vessel context, got: ${captured[0].sql}`);
|
|
82
|
-
});
|
|
83
|
-
it("defaults to vessels.self in response when context omitted", async () => {
|
|
84
|
-
const captured = [];
|
|
85
|
-
const provider = createHistoryProviderV2(makeMockClient(captured), SELF_CONTEXT);
|
|
86
|
-
const result = await provider.getValues({
|
|
87
|
-
from: { toString: () => "2024-01-01T00:00:00Z" },
|
|
88
|
-
to: { toString: () => "2024-01-01T01:00:00Z" },
|
|
89
|
-
pathSpecs: [
|
|
90
|
-
{
|
|
91
|
-
path: "navigation.speedOverGround",
|
|
92
|
-
aggregate: "average",
|
|
93
|
-
parameter: [],
|
|
94
|
-
},
|
|
95
|
-
],
|
|
96
|
-
});
|
|
97
|
-
assert.equal(result.context, "vessels.self");
|
|
98
|
-
assert.ok(captured[0].sql.includes("context = 'self'"));
|
|
99
|
-
});
|
|
100
|
-
});
|
|
101
|
-
describe("history-v2 navigation.position aggregate", () => {
|
|
102
|
-
async function capturePositionSql(aggregate) {
|
|
103
|
-
const captured = [];
|
|
104
|
-
const provider = createHistoryProviderV2(makeMockClient(captured), SELF_CONTEXT);
|
|
105
|
-
await provider.getValues({
|
|
106
|
-
from: { toString: () => "2024-01-01T00:00:00Z" },
|
|
107
|
-
to: { toString: () => "2024-01-01T01:00:00Z" },
|
|
108
|
-
resolution: 60,
|
|
109
|
-
pathSpecs: [{ path: "navigation.position", aggregate, parameter: [] }],
|
|
110
|
-
});
|
|
111
|
-
return captured[0].sql;
|
|
112
|
-
}
|
|
113
|
-
it("uses first(lat)/first(lon) for aggregate 'first'", async () => {
|
|
114
|
-
const sql = await capturePositionSql("first");
|
|
115
|
-
assert.ok(sql.includes("first(lat)") && sql.includes("first(lon)"), `Expected first(lat)/first(lon), got: ${sql}`);
|
|
116
|
-
});
|
|
117
|
-
it("honors aggregate 'last' with last(lat)/last(lon)", async () => {
|
|
118
|
-
const sql = await capturePositionSql("last");
|
|
119
|
-
assert.ok(sql.includes("last(lat)") && sql.includes("last(lon)"), `Expected last(lat)/last(lon), got: ${sql}`);
|
|
120
|
-
});
|
|
121
|
-
it("falls back to first for non-pair-preserving aggregates", async () => {
|
|
122
|
-
for (const aggregate of ["average", "min", "max", "mid", "middle_index"]) {
|
|
123
|
-
const sql = await capturePositionSql(aggregate);
|
|
124
|
-
assert.ok(sql.includes("first(lat)") && sql.includes("first(lon)"), `Expected first(lat)/first(lon) for '${aggregate}', got: ${sql}`);
|
|
125
|
-
}
|
|
126
|
-
});
|
|
127
|
-
});
|
|
128
|
-
describe("history-v2 sample bucket guard", () => {
|
|
129
|
-
it("rejects a resolution that would fabricate millions of FILL(NULL) rows", async () => {
|
|
130
|
-
const captured = [];
|
|
131
|
-
const provider = createHistoryProviderV2(makeMockClient(captured), SELF_CONTEXT);
|
|
132
|
-
// 60 days at 1s resolution = 5.18M buckets — over the 1M cap.
|
|
133
|
-
await assert.rejects(provider.getValues({
|
|
134
|
-
from: { toString: () => "2024-01-01T00:00:00Z" },
|
|
135
|
-
to: { toString: () => "2024-03-01T00:00:00Z" },
|
|
136
|
-
resolution: 1,
|
|
137
|
-
pathSpecs: [
|
|
138
|
-
{ path: "navigation.position", aggregate: "first", parameter: [] },
|
|
139
|
-
],
|
|
140
|
-
}), /sample buckets/);
|
|
141
|
-
// The guard must fire before any SQL reaches QuestDB.
|
|
142
|
-
assert.equal(captured.length, 0);
|
|
143
|
-
});
|
|
144
|
-
it("allows the same range at a sane resolution", async () => {
|
|
145
|
-
const captured = [];
|
|
146
|
-
const provider = createHistoryProviderV2(makeMockClient(captured), SELF_CONTEXT);
|
|
147
|
-
await provider.getValues({
|
|
148
|
-
from: { toString: () => "2024-01-01T00:00:00Z" },
|
|
149
|
-
to: { toString: () => "2024-03-01T00:00:00Z" },
|
|
150
|
-
resolution: 2600, // ~2000 buckets, the well-behaved-client budget
|
|
151
|
-
pathSpecs: [
|
|
152
|
-
{ path: "navigation.position", aggregate: "first", parameter: [] },
|
|
153
|
-
],
|
|
154
|
-
});
|
|
155
|
-
assert.equal(captured.length, 1);
|
|
156
|
-
assert.ok(captured[0].sql.includes("SAMPLE BY 2600s"));
|
|
157
|
-
});
|
|
158
|
-
it("clamps fractional resolutions to 1s instead of SAMPLE BY 0s", async () => {
|
|
159
|
-
const captured = [];
|
|
160
|
-
const provider = createHistoryProviderV2(makeMockClient(captured), SELF_CONTEXT);
|
|
161
|
-
await provider.getValues({
|
|
162
|
-
from: { toString: () => "2024-01-01T00:00:00Z" },
|
|
163
|
-
to: { toString: () => "2024-01-01T01:00:00Z" },
|
|
164
|
-
resolution: 0.5,
|
|
165
|
-
pathSpecs: [
|
|
166
|
-
{
|
|
167
|
-
path: "navigation.speedOverGround",
|
|
168
|
-
aggregate: "average",
|
|
169
|
-
parameter: [],
|
|
170
|
-
},
|
|
171
|
-
],
|
|
172
|
-
});
|
|
173
|
-
// The mock returns no rows, so the string-table fallback also runs; both
|
|
174
|
-
// queries must carry the clamped period, never `SAMPLE BY 0s`. Assert the
|
|
175
|
-
// fallback actually fired, so this cannot pass by silently skipping it.
|
|
176
|
-
const stringQuery = captured.find(({ sql }) => sql.includes("signalk_str"));
|
|
177
|
-
assert.ok(stringQuery, "expected a string-table fallback query");
|
|
178
|
-
for (const { sql } of captured) {
|
|
179
|
-
assert.ok(sql.includes("SAMPLE BY 1s"), `expected SAMPLE BY 1s, got: ${sql}`);
|
|
180
|
-
}
|
|
181
|
-
});
|
|
182
|
-
it("caps the fabricated total across multiple sampled paths", async () => {
|
|
183
|
-
const captured = [];
|
|
184
|
-
const provider = createHistoryProviderV2(makeMockClient(captured), SELF_CONTEXT);
|
|
185
|
-
// 7 days at 1s = ~605K buckets per series: one series passes the 1M cap,
|
|
186
|
-
// two series must not slip past it (1.21M fabricated rows total).
|
|
187
|
-
await assert.rejects(provider.getValues({
|
|
188
|
-
from: { toString: () => "2024-01-01T00:00:00Z" },
|
|
189
|
-
to: { toString: () => "2024-01-08T00:00:00Z" },
|
|
190
|
-
resolution: 1,
|
|
191
|
-
pathSpecs: [
|
|
192
|
-
{ path: "navigation.position", aggregate: "first", parameter: [] },
|
|
193
|
-
{
|
|
194
|
-
path: "navigation.speedOverGround",
|
|
195
|
-
aggregate: "average",
|
|
196
|
-
parameter: [],
|
|
197
|
-
},
|
|
198
|
-
],
|
|
199
|
-
}), /sample buckets/);
|
|
200
|
-
assert.equal(captured.length, 0);
|
|
201
|
-
});
|
|
202
|
-
it("budgets the string-table fallback's second SAMPLE BY", async () => {
|
|
203
|
-
// A non-numeric path costs two SAMPLE BY queries (numeric miss, then the
|
|
204
|
-
// signalk_str fallback). Counting only the first would let a request of
|
|
205
|
-
// boolean/string paths run at ~2x the cap this guard exists to enforce.
|
|
206
|
-
// 7 days at 1s = ~605k buckets: one query fits under 1M, two do not.
|
|
207
|
-
const captured = [];
|
|
208
|
-
const provider = createHistoryProviderV2(makeMockClient(captured), SELF_CONTEXT);
|
|
209
|
-
await assert.rejects(provider.getValues({
|
|
210
|
-
from: { toString: () => "2024-01-01T00:00:00Z" },
|
|
211
|
-
to: { toString: () => "2024-01-08T00:00:00Z" },
|
|
212
|
-
resolution: 1,
|
|
213
|
-
pathSpecs: [
|
|
214
|
-
{
|
|
215
|
-
path: "electrical.switches.bilgePump.state",
|
|
216
|
-
aggregate: "first",
|
|
217
|
-
parameter: [],
|
|
218
|
-
},
|
|
219
|
-
],
|
|
220
|
-
}), /sample buckets/);
|
|
221
|
-
assert.equal(captured.length, 0, "must reject before querying QuestDB");
|
|
222
|
-
});
|
|
223
|
-
it("does not double-count navigation.position, which never falls back", async () => {
|
|
224
|
-
// Position is served by its own table, so it costs exactly one query —
|
|
225
|
-
// budgeting it as two would reject requests that are actually fine.
|
|
226
|
-
const captured = [];
|
|
227
|
-
const provider = createHistoryProviderV2(makeMockClient(captured), SELF_CONTEXT);
|
|
228
|
-
await provider.getValues({
|
|
229
|
-
from: { toString: () => "2024-01-01T00:00:00Z" },
|
|
230
|
-
to: { toString: () => "2024-01-08T00:00:00Z" },
|
|
231
|
-
resolution: 1,
|
|
232
|
-
pathSpecs: [
|
|
233
|
-
{ path: "navigation.position", aggregate: "first", parameter: [] },
|
|
234
|
-
],
|
|
235
|
-
});
|
|
236
|
-
assert.ok(captured.length >= 1, "expected the position query to run");
|
|
237
|
-
});
|
|
238
|
-
it("does not reject client-side aggregates, which never SAMPLE BY", async () => {
|
|
239
|
-
// 60 days at 1s would be 5.18M buckets — but every client-side aggregate
|
|
240
|
-
// reads raw rows under a LIMIT, so the cap must not apply to any of them.
|
|
241
|
-
const cases = [
|
|
242
|
-
["sma", ["5"]],
|
|
243
|
-
["ema", ["0.2"]],
|
|
244
|
-
["middle_index", []],
|
|
245
|
-
];
|
|
246
|
-
for (const [aggregate, parameter] of cases) {
|
|
247
|
-
const captured = [];
|
|
248
|
-
const provider = createHistoryProviderV2(makeMockClient(captured), SELF_CONTEXT);
|
|
249
|
-
await provider.getValues({
|
|
250
|
-
from: { toString: () => "2024-01-01T00:00:00Z" },
|
|
251
|
-
to: { toString: () => "2024-03-01T00:00:00Z" },
|
|
252
|
-
resolution: 1,
|
|
253
|
-
pathSpecs: [
|
|
254
|
-
{ path: "navigation.speedOverGround", aggregate, parameter },
|
|
255
|
-
],
|
|
256
|
-
});
|
|
257
|
-
assert.equal(captured.length, 1, `no query captured for '${aggregate}'`);
|
|
258
|
-
assert.ok(captured[0].sql.includes("LIMIT 50000") &&
|
|
259
|
-
!captured[0].sql.includes("SAMPLE BY"), `expected raw-row LIMIT query for '${aggregate}', got: ${captured[0].sql}`);
|
|
260
|
-
}
|
|
261
|
-
});
|
|
262
|
-
});
|
|
263
|
-
describe("history-v2 string-table fallback", () => {
|
|
264
|
-
// Values live in `signalk` (numeric) or `signalk_str` (strings, and
|
|
265
|
-
// booleans stored as "true"/"false" since #79). getValues only ever queried
|
|
266
|
-
// the numeric table, so every non-numeric path was listed by getPaths but
|
|
267
|
-
// returned no data at all.
|
|
268
|
-
function mockClient(captured, responder) {
|
|
269
|
-
return {
|
|
270
|
-
exec: async (sql) => {
|
|
271
|
-
captured.push({ sql });
|
|
272
|
-
const dataset = responder(sql);
|
|
273
|
-
return { columns: [], dataset, count: dataset.length, timestamp: 0 };
|
|
274
|
-
},
|
|
275
|
-
};
|
|
276
|
-
}
|
|
277
|
-
const query = (path, extra = {}) => {
|
|
278
|
-
const { aggregate = "first", ...rest } = extra;
|
|
279
|
-
return {
|
|
280
|
-
from: { toString: () => "2024-01-01T00:00:00Z" },
|
|
281
|
-
to: { toString: () => "2024-01-01T01:00:00Z" },
|
|
282
|
-
context: "self",
|
|
283
|
-
pathSpecs: [{ path, aggregate, parameter: [] }],
|
|
284
|
-
...rest,
|
|
285
|
-
};
|
|
286
|
-
};
|
|
287
|
-
it("serves a boolean path from signalk_str", async () => {
|
|
288
|
-
const captured = [];
|
|
289
|
-
const provider = createHistoryProviderV2(mockClient(captured, (sql) => sql.includes("signalk_str")
|
|
290
|
-
? [["2024-01-01T00:00:01.000000Z", "true"]]
|
|
291
|
-
: []), SELF_CONTEXT);
|
|
292
|
-
const result = await provider.getValues(query("watermaker.brineomatic.high_pressure_pump_on"));
|
|
293
|
-
assert.deepEqual(result.data, [["2024-01-01T00:00:01.000000Z", "true"]]);
|
|
294
|
-
assert.ok(captured.some((c) => c.sql.includes("signalk_str")));
|
|
295
|
-
});
|
|
296
|
-
it("does not query the string table when numeric data exists", async () => {
|
|
297
|
-
const captured = [];
|
|
298
|
-
const provider = createHistoryProviderV2(mockClient(captured, () => [["2024-01-01T00:00:01.000000Z", 4.2]]), SELF_CONTEXT);
|
|
299
|
-
const result = await provider.getValues(query("environment.depth.belowKeel"));
|
|
300
|
-
assert.deepEqual(result.data, [["2024-01-01T00:00:01.000000Z", 4.2]]);
|
|
301
|
-
assert.ok(!captured.some((c) => c.sql.includes("signalk_str")), "numeric paths must not pay for a second query");
|
|
302
|
-
});
|
|
303
|
-
it("falls back when SAMPLE BY fabricated only FILL(NULL) rows", async () => {
|
|
304
|
-
// A downsampled numeric query returns a row per bucket even with no data,
|
|
305
|
-
// so emptiness has to be judged on values, not row count.
|
|
306
|
-
const captured = [];
|
|
307
|
-
const provider = createHistoryProviderV2(mockClient(captured, (sql) => sql.includes("signalk_str")
|
|
308
|
-
? [["2024-01-01T00:00:00.000000Z", "false"]]
|
|
309
|
-
: [
|
|
310
|
-
["2024-01-01T00:00:00.000000Z", null],
|
|
311
|
-
["2024-01-01T00:10:00.000000Z", null],
|
|
312
|
-
]), SELF_CONTEXT);
|
|
313
|
-
const result = await provider.getValues(query("electrical.switches.bilgePump.state", { resolution: 600 }));
|
|
314
|
-
assert.deepEqual(result.data, [["2024-01-01T00:00:00.000000Z", "false"]]);
|
|
315
|
-
});
|
|
316
|
-
it("reports last() as the method actually applied when downsampling", async () => {
|
|
317
|
-
// The response's `method` labels the series for consumers like Grafana;
|
|
318
|
-
// echoing the requested "average" would name an aggregate that never ran.
|
|
319
|
-
const captured = [];
|
|
320
|
-
const provider = createHistoryProviderV2(mockClient(captured, (sql) => sql.includes("signalk_str")
|
|
321
|
-
? [["2024-01-01T00:00:00.000000Z", "on"]]
|
|
322
|
-
: []), SELF_CONTEXT);
|
|
323
|
-
const result = await provider.getValues(query("navigation.state", { resolution: 600, aggregate: "average" }));
|
|
324
|
-
assert.equal(result.values[0].method, "last");
|
|
325
|
-
});
|
|
326
|
-
it("keeps the requested method for raw (non-downsampled) string reads", async () => {
|
|
327
|
-
// Without a resolution no aggregate is applied at all, so nothing is
|
|
328
|
-
// being misreported and the caller's choice stands.
|
|
329
|
-
const provider = createHistoryProviderV2(mockClient([], (sql) => sql.includes("signalk_str")
|
|
330
|
-
? [["2024-01-01T00:00:00.000000Z", "on"]]
|
|
331
|
-
: []), SELF_CONTEXT);
|
|
332
|
-
const result = await provider.getValues(query("navigation.state"));
|
|
333
|
-
assert.equal(result.values[0].method, "first");
|
|
334
|
-
});
|
|
335
|
-
it("replays a tagged boolean as a boolean, matching v1", async () => {
|
|
336
|
-
// The same path must not read `true` through v1 and `"true"` through v2.
|
|
337
|
-
const provider = createHistoryProviderV2(mockClient([], (sql) => sql.includes("signalk_str")
|
|
338
|
-
? [["2024-01-01T00:00:00.000000Z", "true", "boolean"]]
|
|
339
|
-
: []), SELF_CONTEXT);
|
|
340
|
-
const result = await provider.getValues(query("electrical.switches.bilgePump.state"));
|
|
341
|
-
assert.equal(result.data[0][1], true);
|
|
342
|
-
});
|
|
343
|
-
it('leaves an untagged "true" as text', async () => {
|
|
344
|
-
// A path whose value genuinely is the word "true" carries no tag, as do
|
|
345
|
-
// all rows written before value_kind existed.
|
|
346
|
-
const provider = createHistoryProviderV2(mockClient([], (sql) => sql.includes("signalk_str")
|
|
347
|
-
? [["2024-01-01T00:00:00.000000Z", "true", null]]
|
|
348
|
-
: []), SELF_CONTEXT);
|
|
349
|
-
const result = await provider.getValues(query("navigation.state"));
|
|
350
|
-
assert.equal(result.data[0][1], "true");
|
|
351
|
-
});
|
|
352
|
-
it("degrades to text when value_kind is not migrated yet", async () => {
|
|
353
|
-
// A read racing ensureTables(), or an external QuestDB, must not lose the
|
|
354
|
-
// whole response to "Invalid column: value_kind".
|
|
355
|
-
const captured = [];
|
|
356
|
-
const provider = createHistoryProviderV2(mockClient(captured, (sql) => {
|
|
357
|
-
if (sql.includes("value_kind") && !sql.includes("NULL as value_kind")) {
|
|
358
|
-
throw new Error("QuestDB query failed (400): Invalid column: value_kind");
|
|
359
|
-
}
|
|
360
|
-
return sql.includes("signalk_str")
|
|
361
|
-
? [["2024-01-01T00:00:00.000000Z", "true", null]]
|
|
362
|
-
: [];
|
|
363
|
-
}), SELF_CONTEXT);
|
|
364
|
-
const result = await provider.getValues(query("some.path"));
|
|
365
|
-
assert.equal(result.data[0][1], "true");
|
|
366
|
-
});
|
|
367
|
-
it("aggregates a downsampled string path with last(), not avg()", async () => {
|
|
368
|
-
const captured = [];
|
|
369
|
-
const provider = createHistoryProviderV2(mockClient(captured, (sql) => sql.includes("signalk_str")
|
|
370
|
-
? [["2024-01-01T00:00:00.000000Z", "on"]]
|
|
371
|
-
: []), SELF_CONTEXT);
|
|
372
|
-
await provider.getValues(query("navigation.state", { resolution: 600 }));
|
|
373
|
-
const strSql = captured.find((c) => c.sql.includes("signalk_str")).sql;
|
|
374
|
-
assert.ok(strSql.includes("last(value_str)"), `expected last(value_str), got: ${strSql}`);
|
|
375
|
-
assert.ok(!strSql.includes("avg("));
|
|
376
|
-
});
|
|
377
|
-
});
|
|
378
|
-
describe("history-v2 path and context discovery", () => {
|
|
379
|
-
function captureClient(captured) {
|
|
380
|
-
return {
|
|
381
|
-
exec: async (sql) => {
|
|
382
|
-
captured.push({ sql });
|
|
383
|
-
return { columns: [], dataset: [], count: 0, timestamp: 0 };
|
|
384
|
-
},
|
|
385
|
-
};
|
|
386
|
-
}
|
|
387
|
-
const range = {
|
|
388
|
-
from: { toString: () => "2024-01-01T00:00:00Z", add: () => undefined },
|
|
389
|
-
to: { toString: () => "2024-01-01T01:00:00Z" },
|
|
390
|
-
};
|
|
391
|
-
it("advertises navigation.position in getPaths", async () => {
|
|
392
|
-
// The track table has no `path` column, so it was omitted entirely:
|
|
393
|
-
// getValues served the position series while getPaths never listed it,
|
|
394
|
-
// leaving clients unable to discover the one series they most want.
|
|
395
|
-
const captured = [];
|
|
396
|
-
const provider = createHistoryProviderV2(captureClient(captured), SELF_CONTEXT);
|
|
397
|
-
await provider.getPaths(range);
|
|
398
|
-
assert.ok(captured[0].sql.includes("signalk_position"), `getPaths must consult the track table, got: ${captured[0].sql}`);
|
|
399
|
-
assert.ok(captured[0].sql.includes("'navigation.position'"));
|
|
400
|
-
});
|
|
401
|
-
it("includes the track table in getContexts", async () => {
|
|
402
|
-
// A vessel can be position-only (an AIS target whose other paths are
|
|
403
|
-
// filtered out), and would otherwise be invisible.
|
|
404
|
-
const captured = [];
|
|
405
|
-
const provider = createHistoryProviderV2(captureClient(captured), SELF_CONTEXT);
|
|
406
|
-
await provider.getContexts(range);
|
|
407
|
-
assert.ok(captured[0].sql.includes("signalk_position"), `getContexts must consult the track table, got: ${captured[0].sql}`);
|
|
408
|
-
});
|
|
409
|
-
});
|
|
410
|
-
describe("history-v2 sourceRef filtering", () => {
|
|
411
|
-
// The server delivers `paths=<path>|<sourceRef>` (signalk-server #2737) as
|
|
412
|
-
// PathSpec.sourceRef. Filtering happens in SQL — the rows carry a `source`
|
|
413
|
-
// column since the same-named migration — and the spec's sourceRef is
|
|
414
|
-
// echoed in the response metadata so a caller can tell the columns apart.
|
|
415
|
-
function mockClient(captured, responder) {
|
|
416
|
-
return {
|
|
417
|
-
exec: async (sql) => {
|
|
418
|
-
captured.push({ sql });
|
|
419
|
-
const dataset = responder(sql);
|
|
420
|
-
return { columns: [], dataset, count: dataset.length, timestamp: 0 };
|
|
421
|
-
},
|
|
422
|
-
};
|
|
423
|
-
}
|
|
424
|
-
const range = {
|
|
425
|
-
from: { toString: () => "2024-01-01T00:00:00Z" },
|
|
426
|
-
to: { toString: () => "2024-01-01T01:00:00Z" },
|
|
427
|
-
context: "self",
|
|
428
|
-
};
|
|
429
|
-
it("adds a source clause when the spec carries a sourceRef", async () => {
|
|
430
|
-
const captured = [];
|
|
431
|
-
const provider = createHistoryProviderV2(mockClient(captured, () => [["2024-01-01T00:00:01.000000Z", 4.2]]), SELF_CONTEXT);
|
|
432
|
-
await provider.getValues({
|
|
433
|
-
...range,
|
|
434
|
-
pathSpecs: [
|
|
435
|
-
{
|
|
436
|
-
path: "navigation.speedOverGround",
|
|
437
|
-
aggregate: "average",
|
|
438
|
-
parameter: [],
|
|
439
|
-
sourceRef: "n2k-on-ve.can0.115",
|
|
440
|
-
},
|
|
441
|
-
],
|
|
442
|
-
});
|
|
443
|
-
assert.ok(captured[0].sql.includes("AND source = 'n2k-on-ve.can0.115'"), `expected a source filter, got: ${captured[0].sql}`);
|
|
444
|
-
});
|
|
445
|
-
it("omits the source clause when no sourceRef is given", async () => {
|
|
446
|
-
const captured = [];
|
|
447
|
-
const provider = createHistoryProviderV2(mockClient(captured, () => [["2024-01-01T00:00:01.000000Z", 4.2]]), SELF_CONTEXT);
|
|
448
|
-
await provider.getValues({
|
|
449
|
-
...range,
|
|
450
|
-
pathSpecs: [
|
|
451
|
-
{
|
|
452
|
-
path: "navigation.speedOverGround",
|
|
453
|
-
aggregate: "average",
|
|
454
|
-
parameter: [],
|
|
455
|
-
},
|
|
456
|
-
],
|
|
457
|
-
});
|
|
458
|
-
assert.ok(!captured[0].sql.includes("source ="), `expected no source filter, got: ${captured[0].sql}`);
|
|
459
|
-
});
|
|
460
|
-
it("filters the position table by source too", async () => {
|
|
461
|
-
const captured = [];
|
|
462
|
-
const provider = createHistoryProviderV2(mockClient(captured, () => [["2024-01-01T00:00:01.000000Z", 60.1, 24.9]]), SELF_CONTEXT);
|
|
463
|
-
await provider.getValues({
|
|
464
|
-
...range,
|
|
465
|
-
pathSpecs: [
|
|
466
|
-
{
|
|
467
|
-
path: "navigation.position",
|
|
468
|
-
aggregate: "first",
|
|
469
|
-
parameter: [],
|
|
470
|
-
sourceRef: "gps.main",
|
|
471
|
-
},
|
|
472
|
-
],
|
|
473
|
-
});
|
|
474
|
-
assert.ok(captured[0].sql.includes("signalk_position") &&
|
|
475
|
-
captured[0].sql.includes("AND source = 'gps.main'"), `expected a source-filtered position query, got: ${captured[0].sql}`);
|
|
476
|
-
});
|
|
477
|
-
it("keeps the source clause on the string-table fallback", async () => {
|
|
478
|
-
const captured = [];
|
|
479
|
-
const provider = createHistoryProviderV2(mockClient(captured, (sql) => sql.includes("signalk_str")
|
|
480
|
-
? [["2024-01-01T00:00:01.000000Z", "true"]]
|
|
481
|
-
: []), SELF_CONTEXT);
|
|
482
|
-
await provider.getValues({
|
|
483
|
-
...range,
|
|
484
|
-
pathSpecs: [
|
|
485
|
-
{
|
|
486
|
-
path: "switches.bilge.state",
|
|
487
|
-
aggregate: "first",
|
|
488
|
-
parameter: [],
|
|
489
|
-
sourceRef: "n2k-on-ve.can0.42",
|
|
490
|
-
},
|
|
491
|
-
],
|
|
492
|
-
});
|
|
493
|
-
const fallback = captured.find((c) => c.sql.includes("signalk_str"));
|
|
494
|
-
assert.ok(fallback, "expected the string-table fallback to run");
|
|
495
|
-
assert.ok(fallback.sql.includes("AND source = 'n2k-on-ve.can0.42'"), `fallback must keep the source filter, got: ${fallback.sql}`);
|
|
496
|
-
});
|
|
497
|
-
it("echoes the sourceRef in the values metadata", async () => {
|
|
498
|
-
const provider = createHistoryProviderV2(mockClient([], () => [["2024-01-01T00:00:01.000000Z", 4.2]]), SELF_CONTEXT);
|
|
499
|
-
const result = await provider.getValues({
|
|
500
|
-
...range,
|
|
501
|
-
pathSpecs: [
|
|
502
|
-
{
|
|
503
|
-
path: "navigation.speedOverGround",
|
|
504
|
-
aggregate: "average",
|
|
505
|
-
parameter: [],
|
|
506
|
-
sourceRef: "gps.main",
|
|
507
|
-
},
|
|
508
|
-
{
|
|
509
|
-
path: "navigation.speedOverGround",
|
|
510
|
-
aggregate: "average",
|
|
511
|
-
parameter: [],
|
|
512
|
-
},
|
|
513
|
-
],
|
|
514
|
-
});
|
|
515
|
-
// signalk-server #2817 renamed the RESPONSE-side field to `$source`
|
|
516
|
-
// while the request-side PathSpec kept `sourceRef`. Emitting the old key
|
|
517
|
-
// left consumers unable to tell the columns apart.
|
|
518
|
-
assert.equal(result.values[0].$source, "gps.main");
|
|
519
|
-
assert.ok(!("$source" in result.values[1]), "an unfiltered spec must not grow a $source");
|
|
520
|
-
assert.ok(!("sourceRef" in result.values[0]), "the pre-#2817 key must not linger alongside $source");
|
|
521
|
-
});
|
|
522
|
-
it("serves the same path from two sources as two distinct columns", async () => {
|
|
523
|
-
// sourceRef filtering is what makes the same path twice in one request
|
|
524
|
-
// meaningful; keyed by path the second spec's rows would overwrite the
|
|
525
|
-
// first's and both columns would show the same series.
|
|
526
|
-
const provider = createHistoryProviderV2(mockClient([], (sql) => {
|
|
527
|
-
if (sql.includes("source = 'gps.main'"))
|
|
528
|
-
return [["2024-01-01T00:00:01.000000Z", 1.1]];
|
|
529
|
-
if (sql.includes("source = 'gps.backup'"))
|
|
530
|
-
return [["2024-01-01T00:00:01.000000Z", 2.2]];
|
|
531
|
-
return [];
|
|
532
|
-
}), SELF_CONTEXT);
|
|
533
|
-
const result = await provider.getValues({
|
|
534
|
-
...range,
|
|
535
|
-
pathSpecs: [
|
|
536
|
-
{
|
|
537
|
-
path: "navigation.speedOverGround",
|
|
538
|
-
aggregate: "average",
|
|
539
|
-
parameter: [],
|
|
540
|
-
sourceRef: "gps.main",
|
|
541
|
-
},
|
|
542
|
-
{
|
|
543
|
-
path: "navigation.speedOverGround",
|
|
544
|
-
aggregate: "average",
|
|
545
|
-
parameter: [],
|
|
546
|
-
sourceRef: "gps.backup",
|
|
547
|
-
},
|
|
548
|
-
],
|
|
549
|
-
});
|
|
550
|
-
assert.deepEqual(result.data, [["2024-01-01T00:00:01.000000Z", 1.1, 2.2]]);
|
|
551
|
-
});
|
|
552
|
-
it("rejects a sourceRef that is not a safe identifier", async () => {
|
|
553
|
-
const provider = createHistoryProviderV2(mockClient([], () => []), SELF_CONTEXT);
|
|
554
|
-
await assert.rejects(() => provider.getValues({
|
|
555
|
-
...range,
|
|
556
|
-
pathSpecs: [
|
|
557
|
-
{
|
|
558
|
-
path: "navigation.speedOverGround",
|
|
559
|
-
aggregate: "average",
|
|
560
|
-
parameter: [],
|
|
561
|
-
sourceRef: "x'; DROP TABLE signalk",
|
|
562
|
-
},
|
|
563
|
-
],
|
|
564
|
-
}));
|
|
565
|
-
});
|
|
566
|
-
});
|
|
567
|
-
/** Mock whose responses depend on the SQL, so DISTINCT can be answered. */
|
|
568
|
-
function makeSourceAwareClient(captured, sourcesByTable, dataRows = []) {
|
|
569
|
-
return {
|
|
570
|
-
exec: async (sql) => {
|
|
571
|
-
captured.push({ sql });
|
|
572
|
-
if (/SELECT DISTINCT source FROM (\w+)/.test(sql)) {
|
|
573
|
-
const table = /FROM (\w+)/.exec(sql)[1];
|
|
574
|
-
const list = sourcesByTable[table];
|
|
575
|
-
if (list === undefined)
|
|
576
|
-
throw new Error(`table does not exist: ${table}`);
|
|
577
|
-
return {
|
|
578
|
-
columns: [],
|
|
579
|
-
dataset: list.map((s) => [s]),
|
|
580
|
-
count: list.length,
|
|
581
|
-
timestamp: 0,
|
|
582
|
-
};
|
|
583
|
-
}
|
|
584
|
-
return {
|
|
585
|
-
columns: [],
|
|
586
|
-
dataset: dataRows.map(([ts, v]) => [ts, v]),
|
|
587
|
-
count: dataRows.length,
|
|
588
|
-
timestamp: 0,
|
|
589
|
-
};
|
|
590
|
-
},
|
|
591
|
-
};
|
|
592
|
-
}
|
|
593
|
-
describe("history-v2 sourcePolicy=all", () => {
|
|
594
|
-
const RANGE = {
|
|
595
|
-
from: { toString: () => "2024-01-01T00:00:00Z" },
|
|
596
|
-
to: { toString: () => "2024-01-01T01:00:00Z" },
|
|
597
|
-
};
|
|
598
|
-
const SPEC = {
|
|
599
|
-
path: "navigation.speedOverGround",
|
|
600
|
-
aggregate: "average",
|
|
601
|
-
parameter: [],
|
|
602
|
-
};
|
|
603
|
-
it("is inert until the operator enables it", async () => {
|
|
604
|
-
const captured = [];
|
|
605
|
-
// Third argument omitted = disabled, which is the shipped default.
|
|
606
|
-
const provider = createHistoryProviderV2(makeSourceAwareClient(captured, { signalk: ["gps.main", "gps.aux"] }), SELF_CONTEXT);
|
|
607
|
-
const result = await provider.getValues({
|
|
608
|
-
...RANGE,
|
|
609
|
-
sourcePolicy: "all",
|
|
610
|
-
pathSpecs: [SPEC],
|
|
611
|
-
});
|
|
612
|
-
assert.equal(result.values.length, 1, "must not expand while disabled");
|
|
613
|
-
assert.ok(!("$source" in result.values[0]));
|
|
614
|
-
assert.ok(!captured.some((c) => /SELECT DISTINCT source/.test(c.sql)), "a disabled provider must not even probe for sources");
|
|
615
|
-
});
|
|
616
|
-
it("expands one path into one column per source when enabled", async () => {
|
|
617
|
-
const captured = [];
|
|
618
|
-
const provider = createHistoryProviderV2(makeSourceAwareClient(captured, {
|
|
619
|
-
signalk: ["gps.aux", "gps.main"],
|
|
620
|
-
signalk_str: [],
|
|
621
|
-
}), SELF_CONTEXT, true);
|
|
622
|
-
const result = await provider.getValues({
|
|
623
|
-
...RANGE,
|
|
624
|
-
sourcePolicy: "all",
|
|
625
|
-
pathSpecs: [SPEC],
|
|
626
|
-
});
|
|
627
|
-
assert.deepEqual(result.values.map((v) => v.$source), ["gps.aux", "gps.main"], "one column per source, named sources sorted for stable order");
|
|
628
|
-
// Each column must be FILTERED to its own source, or every column would
|
|
629
|
-
// carry identical merged data under different labels.
|
|
630
|
-
assert.ok(captured.some((c) => /source = 'gps.main'/.test(c.sql)));
|
|
631
|
-
assert.ok(captured.some((c) => /source = 'gps.aux'/.test(c.sql)));
|
|
632
|
-
});
|
|
633
|
-
// The data array must be as wide as `values`. Assembling rows against the
|
|
634
|
-
// REQUESTED spec count instead of the expanded column count leaves every
|
|
635
|
-
// expanded column without a slot — the metadata promises N series and the
|
|
636
|
-
// rows carry one.
|
|
637
|
-
it("gives every expanded column its own slot in each data row", async () => {
|
|
638
|
-
const captured = [];
|
|
639
|
-
const provider = createHistoryProviderV2(makeSourceAwareClient(captured, { signalk: ["gps.aux", "gps.main"], signalk_str: [] }, [
|
|
640
|
-
["2024-01-01T00:00:00.000Z", 1.5],
|
|
641
|
-
["2024-01-01T00:00:01.000Z", 2.5],
|
|
642
|
-
]), SELF_CONTEXT, true);
|
|
643
|
-
const result = await provider.getValues({
|
|
644
|
-
...RANGE,
|
|
645
|
-
sourcePolicy: "all",
|
|
646
|
-
pathSpecs: [SPEC],
|
|
647
|
-
});
|
|
648
|
-
assert.equal(result.values.length, 2);
|
|
649
|
-
assert.ok(result.data.length > 0, "expected data rows");
|
|
650
|
-
for (const row of result.data) {
|
|
651
|
-
assert.equal(row.length - 1, result.values.length, `row has ${row.length - 1} value slots for ${result.values.length} columns`);
|
|
652
|
-
}
|
|
653
|
-
});
|
|
654
|
-
it("leaves an explicit sourceRef as a filter, not an expansion", async () => {
|
|
655
|
-
const captured = [];
|
|
656
|
-
const provider = createHistoryProviderV2(makeSourceAwareClient(captured, { signalk: ["gps.main", "gps.aux"] }), SELF_CONTEXT, true);
|
|
657
|
-
const result = await provider.getValues({
|
|
658
|
-
...RANGE,
|
|
659
|
-
sourcePolicy: "all",
|
|
660
|
-
pathSpecs: [{ ...SPEC, sourceRef: "gps.main" }],
|
|
661
|
-
});
|
|
662
|
-
assert.equal(result.values.length, 1);
|
|
663
|
-
assert.equal(result.values[0].$source, "gps.main");
|
|
664
|
-
assert.ok(!captured.some((c) => /SELECT DISTINCT source/.test(c.sql)), "an explicit source needs no discovery");
|
|
665
|
-
});
|
|
666
|
-
it("does nothing without the policy, even when enabled", async () => {
|
|
667
|
-
const captured = [];
|
|
668
|
-
const provider = createHistoryProviderV2(makeSourceAwareClient(captured, { signalk: ["gps.main", "gps.aux"] }), SELF_CONTEXT, true);
|
|
669
|
-
const result = await provider.getValues({
|
|
670
|
-
...RANGE,
|
|
671
|
-
pathSpecs: [SPEC],
|
|
672
|
-
});
|
|
673
|
-
assert.equal(result.values.length, 1);
|
|
674
|
-
assert.ok(!("$source" in result.values[0]));
|
|
675
|
-
});
|
|
676
|
-
it("treats an unrecognised policy as absent", async () => {
|
|
677
|
-
const captured = [];
|
|
678
|
-
const provider = createHistoryProviderV2(makeSourceAwareClient(captured, { signalk: ["gps.main", "gps.aux"] }), SELF_CONTEXT, true);
|
|
679
|
-
const result = await provider.getValues({
|
|
680
|
-
...RANGE,
|
|
681
|
-
sourcePolicy: "none",
|
|
682
|
-
pathSpecs: [SPEC],
|
|
683
|
-
});
|
|
684
|
-
assert.equal(result.values.length, 1, "only 'all' expands");
|
|
685
|
-
assert.ok(!("$source" in result.values[0]));
|
|
686
|
-
assert.ok(!captured.some((c) => /SELECT DISTINCT source/.test(c.sql)), "an unknown policy must not probe for sources");
|
|
687
|
-
});
|
|
688
|
-
// The per-path cap alone is not enough: many paths at many sources each is
|
|
689
|
-
// hundreds of queries from one request.
|
|
690
|
-
it("refuses a request whose total expansion is too wide", async () => {
|
|
691
|
-
const captured = [];
|
|
692
|
-
const eight = ["s1", "s2", "s3", "s4", "s5", "s6", "s7", "s8"];
|
|
693
|
-
const provider = createHistoryProviderV2(makeSourceAwareClient(captured, { signalk: eight, signalk_str: [] }), SELF_CONTEXT, true);
|
|
694
|
-
// 9 paths x 8 sources = 72 columns, past the 64 request ceiling.
|
|
695
|
-
const specs = Array.from({ length: 9 }, (_, i) => ({
|
|
696
|
-
path: `nav.p${i}`,
|
|
697
|
-
aggregate: "average",
|
|
698
|
-
parameter: [],
|
|
699
|
-
}));
|
|
700
|
-
await assert.rejects(() => provider.getValues({
|
|
701
|
-
...RANGE,
|
|
702
|
-
sourcePolicy: "all",
|
|
703
|
-
pathSpecs: specs,
|
|
704
|
-
}), /expands these paths into 72 columns/);
|
|
705
|
-
});
|
|
706
|
-
// Discovery is a query per path per table, so a request far past the
|
|
707
|
-
// ceiling must be rejected BEFORE issuing hundreds of DISTINCT probes.
|
|
708
|
-
it("stops probing once the column budget is blown", async () => {
|
|
709
|
-
const captured = [];
|
|
710
|
-
const eight = ["s1", "s2", "s3", "s4", "s5", "s6", "s7", "s8"];
|
|
711
|
-
const provider = createHistoryProviderV2(makeSourceAwareClient(captured, { signalk: eight, signalk_str: [] }), SELF_CONTEXT, true);
|
|
712
|
-
const specs = Array.from({ length: 100 }, (_, i) => ({
|
|
713
|
-
path: `nav.p${i}`,
|
|
714
|
-
aggregate: "average",
|
|
715
|
-
parameter: [],
|
|
716
|
-
}));
|
|
717
|
-
await assert.rejects(() => provider.getValues({
|
|
718
|
-
...RANGE,
|
|
719
|
-
sourcePolicy: "all",
|
|
720
|
-
pathSpecs: specs,
|
|
721
|
-
}), /more than 64 columns/);
|
|
722
|
-
const probes = captured.filter((c) => /SELECT DISTINCT source/.test(c.sql)).length;
|
|
723
|
-
// 100 paths x 2 tables would be 200 if discovery ran to completion.
|
|
724
|
-
assert.ok(probes < 40, `issued ${probes} discovery probes before giving up`);
|
|
725
|
-
});
|
|
726
|
-
// The two value tables migrate independently, so `signalk` can have a
|
|
727
|
-
// `source` column while `signalk_str` does not. Expansion is driven by
|
|
728
|
-
// whichever table answered and then applied to both, so the string fallback
|
|
729
|
-
// carried `AND source = '...'` into a table with no such column and failed
|
|
730
|
-
// the WHOLE request — verified against a live QuestDB.
|
|
731
|
-
it("degrades the string fallback when only signalk_str lacks source", async () => {
|
|
732
|
-
const warnings = [];
|
|
733
|
-
const captured = [];
|
|
734
|
-
const client = {
|
|
735
|
-
exec: async (sql) => {
|
|
736
|
-
captured.push({ sql });
|
|
737
|
-
if (/DISTINCT source FROM signalk_str/.test(sql)) {
|
|
738
|
-
throw new Error("Invalid column: source");
|
|
739
|
-
}
|
|
740
|
-
if (/DISTINCT source FROM signalk/.test(sql)) {
|
|
741
|
-
return {
|
|
742
|
-
columns: [],
|
|
743
|
-
dataset: [["gps.main"]],
|
|
744
|
-
count: 1,
|
|
745
|
-
timestamp: 0,
|
|
746
|
-
};
|
|
747
|
-
}
|
|
748
|
-
// The numeric table holds nothing, forcing the string fallback.
|
|
749
|
-
if (/FROM signalk WHERE/.test(sql)) {
|
|
750
|
-
return { columns: [], dataset: [], count: 0, timestamp: 0 };
|
|
751
|
-
}
|
|
752
|
-
// The string table rejects any query carrying a source predicate.
|
|
753
|
-
if (/FROM signalk_str/.test(sql) && /source/.test(sql)) {
|
|
754
|
-
throw new Error("Invalid column: source");
|
|
755
|
-
}
|
|
756
|
-
return {
|
|
757
|
-
columns: [],
|
|
758
|
-
dataset: [["2024-01-01T00:00:00.000Z", "docked", null]],
|
|
759
|
-
count: 1,
|
|
760
|
-
timestamp: 0,
|
|
761
|
-
};
|
|
762
|
-
},
|
|
763
|
-
};
|
|
764
|
-
const provider = createHistoryProviderV2(client, SELF_CONTEXT, true, (m) => warnings.push(m));
|
|
765
|
-
const result = await provider.getValues({
|
|
766
|
-
...RANGE,
|
|
767
|
-
sourcePolicy: "all",
|
|
768
|
-
pathSpecs: [
|
|
769
|
-
{ path: "navigation.state", aggregate: "last", parameter: [] },
|
|
770
|
-
],
|
|
771
|
-
});
|
|
772
|
-
// The request succeeds rather than failing outright...
|
|
773
|
-
assert.equal(result.values.length, 1);
|
|
774
|
-
assert.deepEqual(result.data, [["2024-01-01T00:00:00.000Z", "docked"]]);
|
|
775
|
-
// ...and the column withdraws its $source claim, because the rows it
|
|
776
|
-
// returned were never filtered by source.
|
|
777
|
-
assert.ok(!("$source" in result.values[0]), "unfiltered rows must not be labelled as one source's");
|
|
778
|
-
assert.ok(warnings.some((w) => /signalk_str has no 'source'/.test(w)), "the degradation must be reported");
|
|
779
|
-
});
|
|
780
|
-
it("gives unattributed rows their own column, ordered last", async () => {
|
|
781
|
-
const captured = [];
|
|
782
|
-
const provider = createHistoryProviderV2(makeSourceAwareClient(captured, {
|
|
783
|
-
signalk: [null, "gps.main"],
|
|
784
|
-
signalk_str: [],
|
|
785
|
-
}), SELF_CONTEXT, true);
|
|
786
|
-
const result = await provider.getValues({
|
|
787
|
-
...RANGE,
|
|
788
|
-
sourcePolicy: "all",
|
|
789
|
-
pathSpecs: [SPEC],
|
|
790
|
-
});
|
|
791
|
-
assert.equal(result.values.length, 2);
|
|
792
|
-
assert.equal(result.values[0].$source, "gps.main");
|
|
793
|
-
assert.ok(!("$source" in result.values[1]), "the unattributed column carries no source claim");
|
|
794
|
-
// The unattributed column must select `source IS NULL`, NOT run unfiltered.
|
|
795
|
-
// An unfiltered query returns EVERY source's rows, so the "no source"
|
|
796
|
-
// column silently duplicates all the others — verified against a live
|
|
797
|
-
// QuestDB, where it returned 60 rows instead of its own 20.
|
|
798
|
-
const dataQueries = captured.filter((c) => !/DISTINCT source/.test(c.sql) && /FROM signalk\b/.test(c.sql));
|
|
799
|
-
assert.ok(dataQueries.some((c) => /AND source IS NULL/.test(c.sql)), "the unattributed column must filter on source IS NULL");
|
|
800
|
-
assert.equal(dataQueries.filter((c) => !/AND source = /.test(c.sql) && !/AND source IS NULL/.test(c.sql)).length, 0, "no expanded column may run without a source predicate");
|
|
801
|
-
assert.ok(!dataQueries.some((c) => /source = 'null'/.test(c.sql)), "a null source must never become the literal string 'null'");
|
|
802
|
-
});
|
|
803
|
-
it("falls back to a single column when nothing was recorded in range", async () => {
|
|
804
|
-
const captured = [];
|
|
805
|
-
const provider = createHistoryProviderV2(makeSourceAwareClient(captured, { signalk: [], signalk_str: [] }), SELF_CONTEXT, true);
|
|
806
|
-
const result = await provider.getValues({
|
|
807
|
-
...RANGE,
|
|
808
|
-
sourcePolicy: "all",
|
|
809
|
-
pathSpecs: [SPEC],
|
|
810
|
-
});
|
|
811
|
-
assert.equal(result.values.length, 1, "the path must not vanish");
|
|
812
|
-
assert.ok(!("$source" in result.values[0]));
|
|
813
|
-
});
|
|
814
|
-
// A legacy table with no `source` column must not make expansion quietly
|
|
815
|
-
// do nothing: the request still succeeds with one merged column, but the
|
|
816
|
-
// operator is told why, naming the column and the table.
|
|
817
|
-
it("warns, rather than silently degrading, on a missing source column", async () => {
|
|
818
|
-
const warnings = [];
|
|
819
|
-
const client = {
|
|
820
|
-
exec: async (sql) => {
|
|
821
|
-
if (/DISTINCT source/.test(sql)) {
|
|
822
|
-
throw new Error("Invalid column: source");
|
|
823
|
-
}
|
|
824
|
-
return { columns: [], dataset: [], count: 0, timestamp: 0 };
|
|
825
|
-
},
|
|
826
|
-
};
|
|
827
|
-
const provider = createHistoryProviderV2(client, SELF_CONTEXT, true, (m) => warnings.push(m));
|
|
828
|
-
const result = await provider.getValues({
|
|
829
|
-
...RANGE,
|
|
830
|
-
sourcePolicy: "all",
|
|
831
|
-
pathSpecs: [SPEC],
|
|
832
|
-
});
|
|
833
|
-
assert.equal(result.values.length, 1, "still answers, unexpanded");
|
|
834
|
-
assert.ok(warnings.length > 0, "the degradation must not be silent");
|
|
835
|
-
assert.match(warnings[0], /source/);
|
|
836
|
-
assert.match(warnings[0], /signalk/, "the warning names the table");
|
|
837
|
-
});
|
|
838
|
-
// A timeout or 5xx is NOT "no sources". Reporting it as an empty result
|
|
839
|
-
// would hand back a plausible single column built on a failure nobody saw.
|
|
840
|
-
it("propagates a real query failure instead of reporting no sources", async () => {
|
|
841
|
-
const client = {
|
|
842
|
-
exec: async (sql) => {
|
|
843
|
-
if (/DISTINCT source/.test(sql)) {
|
|
844
|
-
throw new Error("connection reset by peer");
|
|
845
|
-
}
|
|
846
|
-
return { columns: [], dataset: [], count: 0, timestamp: 0 };
|
|
847
|
-
},
|
|
848
|
-
};
|
|
849
|
-
const provider = createHistoryProviderV2(client, SELF_CONTEXT, true);
|
|
850
|
-
await assert.rejects(() => provider.getValues({
|
|
851
|
-
...RANGE,
|
|
852
|
-
sourcePolicy: "all",
|
|
853
|
-
pathSpecs: [SPEC],
|
|
854
|
-
}), /connection reset/);
|
|
855
|
-
});
|
|
856
|
-
// A table that does not exist yet (no string values recorded) is normal,
|
|
857
|
-
// not a schema fault, and must not produce a warning.
|
|
858
|
-
it("stays quiet when a value table simply does not exist", async () => {
|
|
859
|
-
const warnings = [];
|
|
860
|
-
const client = {
|
|
861
|
-
exec: async (sql) => {
|
|
862
|
-
if (/DISTINCT source FROM signalk_str/.test(sql)) {
|
|
863
|
-
throw new Error("table does not exist [table=signalk_str]");
|
|
864
|
-
}
|
|
865
|
-
if (/DISTINCT source/.test(sql)) {
|
|
866
|
-
return {
|
|
867
|
-
columns: [],
|
|
868
|
-
dataset: [["gps.main"]],
|
|
869
|
-
count: 1,
|
|
870
|
-
timestamp: 0,
|
|
871
|
-
};
|
|
872
|
-
}
|
|
873
|
-
return { columns: [], dataset: [], count: 0, timestamp: 0 };
|
|
874
|
-
},
|
|
875
|
-
};
|
|
876
|
-
const provider = createHistoryProviderV2(client, SELF_CONTEXT, true, (m) => warnings.push(m));
|
|
877
|
-
const result = await provider.getValues({
|
|
878
|
-
...RANGE,
|
|
879
|
-
sourcePolicy: "all",
|
|
880
|
-
pathSpecs: [SPEC],
|
|
881
|
-
});
|
|
882
|
-
assert.equal(result.values[0].$source, "gps.main");
|
|
883
|
-
assert.equal(warnings.length, 0, "an absent table is not a fault");
|
|
884
|
-
});
|
|
885
|
-
// A STORED sourceRef need not satisfy the identifier guard — "tcp://gw:2000"
|
|
886
|
-
// is an ordinary Signal K source containing characters the guard rejects.
|
|
887
|
-
// Letting it reach the query builder throws and takes the WHOLE request
|
|
888
|
-
// down, so expansion would break a query that works today.
|
|
889
|
-
it("skips a source whose name cannot be used as a filter", async () => {
|
|
890
|
-
const warnings = [];
|
|
891
|
-
const captured = [];
|
|
892
|
-
const provider = createHistoryProviderV2(makeSourceAwareClient(captured, {
|
|
893
|
-
signalk: ["gps.main", "tcp://gw:2000"],
|
|
894
|
-
signalk_str: [],
|
|
895
|
-
}), SELF_CONTEXT, true, (m) => warnings.push(m));
|
|
896
|
-
const result = await provider.getValues({
|
|
897
|
-
...RANGE,
|
|
898
|
-
sourcePolicy: "all",
|
|
899
|
-
pathSpecs: [SPEC],
|
|
900
|
-
});
|
|
901
|
-
assert.deepEqual(result.values.map((v) => v.$source), ["gps.main"], "the usable source still expands");
|
|
902
|
-
assert.ok(warnings.some((w) => w.includes("tcp://gw:2000")), "the skipped source must be reported");
|
|
903
|
-
});
|
|
904
|
-
it("keeps the path as one column when every source is unusable", async () => {
|
|
905
|
-
const captured = [];
|
|
906
|
-
const provider = createHistoryProviderV2(makeSourceAwareClient(captured, {
|
|
907
|
-
signalk: ["tcp://a", "tcp://b"],
|
|
908
|
-
signalk_str: [],
|
|
909
|
-
}), SELF_CONTEXT, true);
|
|
910
|
-
const result = await provider.getValues({
|
|
911
|
-
...RANGE,
|
|
912
|
-
sourcePolicy: "all",
|
|
913
|
-
pathSpecs: [SPEC],
|
|
914
|
-
});
|
|
915
|
-
assert.equal(result.values.length, 1, "the path must not vanish");
|
|
916
|
-
assert.ok(!("$source" in result.values[0]));
|
|
917
|
-
});
|
|
918
|
-
// The bucket cap only bites when a resolution was named. An unresolved
|
|
919
|
-
// request runs one raw query per source with nothing else bounding it.
|
|
920
|
-
it("caps how many columns one path may expand into", async () => {
|
|
921
|
-
const warnings = [];
|
|
922
|
-
const captured = [];
|
|
923
|
-
const many = Array.from({ length: 40 }, (_, i) => `src${String(i).padStart(2, "0")}`);
|
|
924
|
-
const provider = createHistoryProviderV2(makeSourceAwareClient(captured, { signalk: many, signalk_str: [] }), SELF_CONTEXT, true, (m) => warnings.push(m));
|
|
925
|
-
const result = await provider.getValues({
|
|
926
|
-
...RANGE,
|
|
927
|
-
sourcePolicy: "all",
|
|
928
|
-
pathSpecs: [SPEC],
|
|
929
|
-
});
|
|
930
|
-
assert.equal(result.values.length, 16, `got ${result.values.length}`);
|
|
931
|
-
assert.ok(warnings.some((w) => /40 sources/.test(w)), "truncation must be reported, not silent");
|
|
932
|
-
// Deterministic: the FIRST 16 sorted, not an arbitrary 16.
|
|
933
|
-
assert.equal(result.values[0].$source, "src00");
|
|
934
|
-
assert.equal(result.values[15].$source, "src15");
|
|
935
|
-
});
|
|
936
|
-
it("survives a database where no value table exists yet", async () => {
|
|
937
|
-
const captured = [];
|
|
938
|
-
// No table answers DISTINCT: the mock throws for both.
|
|
939
|
-
const provider = createHistoryProviderV2(makeSourceAwareClient(captured, {}), SELF_CONTEXT, true);
|
|
940
|
-
const result = await provider.getValues({
|
|
941
|
-
...RANGE,
|
|
942
|
-
sourcePolicy: "all",
|
|
943
|
-
pathSpecs: [SPEC],
|
|
944
|
-
});
|
|
945
|
-
assert.equal(result.values.length, 1);
|
|
946
|
-
assert.ok(!("$source" in result.values[0]));
|
|
947
|
-
});
|
|
948
|
-
it("consults the string table so boolean paths expand too", async () => {
|
|
949
|
-
const captured = [];
|
|
950
|
-
const provider = createHistoryProviderV2(makeSourceAwareClient(captured, {
|
|
951
|
-
signalk: [],
|
|
952
|
-
signalk_str: ["switch.a"],
|
|
953
|
-
}), SELF_CONTEXT, true);
|
|
954
|
-
const result = await provider.getValues({
|
|
955
|
-
...RANGE,
|
|
956
|
-
sourcePolicy: "all",
|
|
957
|
-
pathSpecs: [
|
|
958
|
-
{
|
|
959
|
-
path: "electrical.switches.nav.state",
|
|
960
|
-
aggregate: "last",
|
|
961
|
-
parameter: [],
|
|
962
|
-
},
|
|
963
|
-
],
|
|
964
|
-
});
|
|
965
|
-
assert.equal(result.values[0].$source, "switch.a");
|
|
966
|
-
});
|
|
967
|
-
it("counts EXPANDED columns against the sample-bucket cap", async () => {
|
|
968
|
-
// Four sources over a range that is just under the cap for ONE column.
|
|
969
|
-
// Counting requested paths instead of columns would run at 4x the ceiling.
|
|
970
|
-
const captured = [];
|
|
971
|
-
const provider = createHistoryProviderV2(makeSourceAwareClient(captured, {
|
|
972
|
-
signalk: ["a", "b", "c", "d"],
|
|
973
|
-
signalk_str: [],
|
|
974
|
-
}), SELF_CONTEXT, true);
|
|
975
|
-
await assert.rejects(() => provider.getValues({
|
|
976
|
-
from: { toString: () => "2024-01-01T00:00:00Z" },
|
|
977
|
-
// 400_000s at 1s = 400k buckets per series; 4 sources fall back too,
|
|
978
|
-
// so 8 queries x 400k = 3.2M, past the 1M cap.
|
|
979
|
-
to: { toString: () => "2024-01-05T15:06:40Z" },
|
|
980
|
-
resolution: 1,
|
|
981
|
-
sourcePolicy: "all",
|
|
982
|
-
pathSpecs: [SPEC],
|
|
983
|
-
}), /sample buckets/);
|
|
984
|
-
});
|
|
985
|
-
it("position expands from its own table", async () => {
|
|
986
|
-
const captured = [];
|
|
987
|
-
const provider = createHistoryProviderV2(makeSourceAwareClient(captured, {
|
|
988
|
-
signalk_position: ["gps.main", "gps.aux"],
|
|
989
|
-
}), SELF_CONTEXT, true);
|
|
990
|
-
const result = await provider.getValues({
|
|
991
|
-
...RANGE,
|
|
992
|
-
sourcePolicy: "all",
|
|
993
|
-
pathSpecs: [
|
|
994
|
-
{ path: "navigation.position", aggregate: "first", parameter: [] },
|
|
995
|
-
],
|
|
996
|
-
});
|
|
997
|
-
assert.deepEqual(result.values.map((v) => v.$source), ["gps.aux", "gps.main"]);
|
|
998
|
-
assert.ok(captured.some((c) => /DISTINCT source FROM signalk_position/.test(c.sql)), "position must be probed in its own table, not signalk");
|
|
999
|
-
});
|
|
1000
|
-
});
|
|
1001
|
-
//# sourceMappingURL=history-v2.test.js.map
|