signalk-questdb 2.1.0 → 2.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/dist/migration.js +40 -45
  2. package/dist/migration.js.map +1 -1
  3. package/package.json +1 -1
  4. package/dist/test/config.test.d.ts +0 -1
  5. package/dist/test/config.test.js +0 -161
  6. package/dist/test/config.test.js.map +0 -1
  7. package/dist/test/configpanel-responses.test.d.ts +0 -1
  8. package/dist/test/configpanel-responses.test.js +0 -197
  9. package/dist/test/configpanel-responses.test.js.map +0 -1
  10. package/dist/test/console-proxy.test.d.ts +0 -1
  11. package/dist/test/console-proxy.test.js +0 -664
  12. package/dist/test/console-proxy.test.js.map +0 -1
  13. package/dist/test/container-env.test.d.ts +0 -1
  14. package/dist/test/container-env.test.js +0 -70
  15. package/dist/test/container-env.test.js.map +0 -1
  16. package/dist/test/delta-routing.test.d.ts +0 -1
  17. package/dist/test/delta-routing.test.js +0 -258
  18. package/dist/test/delta-routing.test.js.map +0 -1
  19. package/dist/test/full-export-range.test.d.ts +0 -1
  20. package/dist/test/full-export-range.test.js +0 -78
  21. package/dist/test/full-export-range.test.js.map +0 -1
  22. package/dist/test/history-v1.test.d.ts +0 -1
  23. package/dist/test/history-v1.test.js +0 -676
  24. package/dist/test/history-v1.test.js.map +0 -1
  25. package/dist/test/history-v2.test.d.ts +0 -1
  26. package/dist/test/history-v2.test.js +0 -1001
  27. package/dist/test/history-v2.test.js.map +0 -1
  28. package/dist/test/host-limits.test.d.ts +0 -1
  29. package/dist/test/host-limits.test.js +0 -81
  30. package/dist/test/host-limits.test.js.map +0 -1
  31. package/dist/test/ilp-writer.test.d.ts +0 -1
  32. package/dist/test/ilp-writer.test.js +0 -419
  33. package/dist/test/ilp-writer.test.js.map +0 -1
  34. package/dist/test/influx-detect.test.d.ts +0 -1
  35. package/dist/test/influx-detect.test.js +0 -287
  36. package/dist/test/influx-detect.test.js.map +0 -1
  37. package/dist/test/migration.test.d.ts +0 -1
  38. package/dist/test/migration.test.js +0 -851
  39. package/dist/test/migration.test.js.map +0 -1
  40. package/dist/test/package-e2e.test.d.ts +0 -1
  41. package/dist/test/package-e2e.test.js +0 -306
  42. package/dist/test/package-e2e.test.js.map +0 -1
  43. package/dist/test/path-matcher.test.d.ts +0 -1
  44. package/dist/test/path-matcher.test.js +0 -224
  45. package/dist/test/path-matcher.test.js.map +0 -1
  46. package/dist/test/query-client.test.d.ts +0 -1
  47. package/dist/test/query-client.test.js +0 -299
  48. package/dist/test/query-client.test.js.map +0 -1
  49. package/dist/test/questdb-endpoint.test.d.ts +0 -1
  50. package/dist/test/questdb-endpoint.test.js +0 -297
  51. package/dist/test/questdb-endpoint.test.js.map +0 -1
  52. package/dist/test/restore.test.d.ts +0 -1
  53. package/dist/test/restore.test.js +0 -543
  54. package/dist/test/restore.test.js.map +0 -1
  55. package/dist/test/time-range.test.d.ts +0 -1
  56. package/dist/test/time-range.test.js +0 -53
  57. package/dist/test/time-range.test.js.map +0 -1
  58. package/dist/test/wal-monitor.test.d.ts +0 -1
  59. package/dist/test/wal-monitor.test.js +0 -405
  60. package/dist/test/wal-monitor.test.js.map +0 -1
@@ -1,676 +0,0 @@
1
- import { describe, it } from "node:test";
2
- import assert from "node:assert/strict";
3
- import { createHistoryProviderV1 } from "../history-v1.js";
4
- // Rows as QuestDB returns them for the unified value query: the reader keys
5
- // off `kind` to turn `valuetext` back into a real delta value.
6
- function mockClient(captured, dataset) {
7
- return {
8
- exec: async (sql) => {
9
- captured.push({ sql });
10
- return { columns: [], dataset, count: dataset.length, timestamp: 0 };
11
- },
12
- toObjects: (result) => result.dataset.map((row) => ({
13
- ts: row[0],
14
- path: row[1],
15
- context: row[2],
16
- valuetext: row[3],
17
- kind: row[4],
18
- })),
19
- };
20
- }
21
- const SELF = "vessels.urn:mrn:imo:mmsi:123456789";
22
- const noop = () => { };
23
- describe("history-v1 hasAnyData", () => {
24
- it("counts every value table, not just the numeric one", async () => {
25
- // A vessel recording only string/boolean channels still has history;
26
- // answering false here disables playback entirely.
27
- const captured = [];
28
- const provider = createHistoryProviderV1(mockClient(captured, [[7]]), SELF, noop);
29
- const result = await new Promise((resolve) => provider.hasAnyData({ startTime: new Date("2024-01-01T00:00:00Z"), playbackRate: 1 }, resolve));
30
- assert.equal(result, true);
31
- for (const table of ["signalk", "signalk_str", "signalk_position"]) {
32
- assert.ok(captured[0].sql.includes(`FROM ${table} `), `expected ${table} in the count, got: ${captured[0].sql}`);
33
- }
34
- });
35
- it("reports no data when every table is empty", async () => {
36
- const provider = createHistoryProviderV1(mockClient([], [[0]]), SELF, noop);
37
- const result = await new Promise((resolve) => provider.hasAnyData({ startTime: new Date("2024-01-01T00:00:00Z"), playbackRate: 1 }, resolve));
38
- assert.equal(result, false);
39
- });
40
- });
41
- describe("history-v1 value decoding", () => {
42
- // One row per stored kind, as the union query returns them.
43
- const rows = [
44
- [
45
- "2024-01-01T00:00:00.000000Z",
46
- "navigation.speedOverGround",
47
- "self",
48
- "4.2",
49
- "number",
50
- ],
51
- [
52
- "2024-01-01T00:00:00.000000Z",
53
- "navigation.state",
54
- "self",
55
- "anchored",
56
- "string",
57
- ],
58
- [
59
- "2024-01-01T00:00:00.000000Z",
60
- "electrical.switches.bilgePump.state",
61
- "self",
62
- "true",
63
- "boolean",
64
- ],
65
- // A path whose text value genuinely is the word "true": untagged, so it
66
- // must stay a string.
67
- ["2024-01-01T00:00:00.000000Z", "some.text.path", "self", "true", null],
68
- [
69
- "2024-01-01T00:00:00.000000Z",
70
- "navigation.position",
71
- "self",
72
- "-17.77,177.38",
73
- "position",
74
- ],
75
- ];
76
- function replay(dataset) {
77
- const provider = createHistoryProviderV1(mockClient([], dataset), SELF, noop);
78
- return new Promise((resolve) => {
79
- provider.getHistory(new Date("2024-01-01T01:00:00Z"), "", (deltas) => {
80
- const values = {};
81
- for (const delta of deltas) {
82
- for (const update of delta.updates) {
83
- for (const { path, value } of update.values)
84
- values[path] = value;
85
- }
86
- }
87
- resolve(values);
88
- });
89
- });
90
- }
91
- it("restores numbers as numbers, not strings", async () => {
92
- const values = await replay(rows);
93
- assert.equal(values["navigation.speedOverGround"], 4.2);
94
- assert.equal(typeof values["navigation.speedOverGround"], "number");
95
- });
96
- it("passes strings through verbatim", async () => {
97
- const values = await replay(rows);
98
- assert.equal(values["navigation.state"], "anchored");
99
- });
100
- it("replays a tagged boolean as a real boolean", async () => {
101
- const values = await replay(rows);
102
- assert.equal(values["electrical.switches.bilgePump.state"], true);
103
- assert.equal(typeof values["electrical.switches.bilgePump.state"], "boolean");
104
- });
105
- it('leaves an untagged "true" as a string', async () => {
106
- // Parsing every "true"/"false" text would corrupt legitimate strings —
107
- // and rows written before value_kind existed carry no tag at all.
108
- const values = await replay(rows);
109
- assert.equal(values["some.text.path"], "true");
110
- assert.equal(typeof values["some.text.path"], "string");
111
- });
112
- it("replays a tagged false", async () => {
113
- const values = await replay([
114
- ["2024-01-01T00:00:00.000000Z", "x.off", "self", "false", "boolean"],
115
- ]);
116
- assert.equal(values["x.off"], false);
117
- });
118
- it("reassembles a position into a lat/lon object", async () => {
119
- const values = await replay(rows);
120
- assert.deepEqual(values["navigation.position"], {
121
- latitude: -17.77,
122
- longitude: 177.38,
123
- });
124
- });
125
- it("replays a stored vessel-name row in the empty-path shape", async () => {
126
- // Names are stored under the synthetic path "name" (kind "identity")
127
- // but were received as empty-path object deltas — the only shape
128
- // consumers (Freeboard) read names from, so replay must reconstruct it.
129
- const values = await replay([
130
- [
131
- "2024-01-01T00:00:00.000000Z",
132
- "name",
133
- "vessels.urn:mrn:imo:mmsi:244813000",
134
- "Sea Breeze",
135
- "identity",
136
- ],
137
- ]);
138
- assert.deepEqual(values[""], { name: "Sea Breeze" });
139
- });
140
- it('leaves a DATA path literally named "name" alone', async () => {
141
- // Only rows tagged kind "identity" are synthetic vessel names; a source
142
- // emitting a real path called "name" must round-trip as the plain
143
- // string it is, not become a vessel identity update.
144
- const values = await replay([
145
- [
146
- "2024-01-01T00:00:00.000000Z",
147
- "name",
148
- "self",
149
- "not an identity",
150
- "string",
151
- ],
152
- ]);
153
- assert.equal(values["name"], "not an identity");
154
- assert.equal(values[""], undefined);
155
- });
156
- it("degrades a malformed value to null rather than emitting NaN", async () => {
157
- const values = await replay([
158
- [
159
- "2024-01-01T00:00:00.000000Z",
160
- "x.broken",
161
- "self",
162
- "not-a-number",
163
- "number",
164
- ],
165
- [
166
- "2024-01-01T00:00:00.000000Z",
167
- "navigation.position",
168
- "self",
169
- "bad",
170
- "position",
171
- ],
172
- ]);
173
- assert.equal(values["x.broken"], null);
174
- assert.equal(values["navigation.position"], null);
175
- });
176
- });
177
- describe("history-v1 unmigrated value_kind column", () => {
178
- // ensureTables() adds value_kind at startup, but a read racing that (or an
179
- // external QuestDB the plugin does not own) must not lose ALL history to
180
- // "Invalid column: value_kind" — verified against a live pre-migration
181
- // database, which returned 0 paths before this fallback.
182
- function failingKindClient(captured) {
183
- return {
184
- exec: async (sql) => {
185
- captured.push({ sql });
186
- if (sql.includes("value_kind")) {
187
- throw new Error("QuestDB query failed (400): Invalid column: value_kind");
188
- }
189
- return {
190
- columns: [],
191
- dataset: [
192
- [
193
- "2024-01-01T00:00:00.000000Z",
194
- "navigation.state",
195
- "self",
196
- "on",
197
- null,
198
- ],
199
- ],
200
- count: 1,
201
- timestamp: 0,
202
- };
203
- },
204
- toObjects: (result) => result.dataset.map((row) => ({
205
- ts: row[0],
206
- path: row[1],
207
- context: row[2],
208
- valuetext: row[3],
209
- kind: row[4],
210
- })),
211
- };
212
- }
213
- it("retries getHistory without the tag and still returns history", async () => {
214
- const captured = [];
215
- const provider = createHistoryProviderV1(failingKindClient(captured), SELF, noop);
216
- const values = await new Promise((resolve) => provider.getHistory(new Date("2024-01-01T01:00:00Z"), "", (deltas) => resolve(deltas.flatMap((d) => d.updates.flatMap((u) => u.values)))));
217
- assert.equal(values.length, 1);
218
- assert.equal(values[0].value, "on");
219
- assert.equal(captured.length, 2, "expected a tagged attempt then a retry");
220
- assert.ok(!captured[1].sql.includes("value_kind"));
221
- });
222
- it("does not swallow unrelated query failures", async () => {
223
- // Only the missing-column case degrades; anything else must surface.
224
- const client = {
225
- exec: async () => {
226
- throw new Error("QuestDB query failed (500): something else");
227
- },
228
- toObjects: () => [],
229
- };
230
- const errors = [];
231
- const provider = createHistoryProviderV1(client, SELF, (m) => errors.push(m));
232
- const deltas = await new Promise((resolve) => provider.getHistory(new Date("2024-01-01T01:00:00Z"), "", resolve));
233
- assert.deepEqual(deltas, []);
234
- assert.ok(errors.some((e) => e.includes("something else")));
235
- });
236
- });
237
- describe("history-v1 getHistory query shape", () => {
238
- it("applies LATEST ON per table instead of over a union", async () => {
239
- // LATEST ON over a materialized union makes QuestDB scan rather than use
240
- // each table's index — that timed out (>30s) on a real install.
241
- const captured = [];
242
- const provider = createHistoryProviderV1(mockClient(captured, []), SELF, noop);
243
- await new Promise((resolve) => provider.getHistory(new Date("2024-01-01T00:00:00Z"), "", () => resolve()));
244
- const sql = captured[0].sql;
245
- assert.equal((sql.match(/LATEST ON/g) || []).length, 3, `expected one LATEST ON per table, got: ${sql}`);
246
- // The track table has no path column, so it partitions by context only.
247
- assert.ok(sql.includes("FROM signalk_position"));
248
- assert.ok(sql.includes("PARTITION BY context)"));
249
- // getHistory builds its own SQL rather than reusing the streaming
250
- // helper, so it must select the stored type tag too — hardcoding a kind
251
- // here silently replayed booleans as strings on this path only.
252
- assert.ok(sql.includes("value_kind"), `getHistory must select value_kind, got: ${sql}`);
253
- assert.ok(!/'string' kind/.test(sql), "getHistory must not hardcode the value kind");
254
- });
255
- });
256
- describe("history-v1 streamHistory chunking", () => {
257
- // A window holding more rows than one read returns must be drained, not
258
- // skipped: advancing past a truncated read silently loses the remainder,
259
- // and querying three tables makes truncation far likelier.
260
- function streamOnce(datasets, playbackRate = 1) {
261
- // `queries` holds WINDOW reads only: the one-shot last-known-names
262
- // lookup (LATEST ON) would otherwise shift the indexes every assertion
263
- // below relies on, and the sequential datasets feed window reads.
264
- const queries = [];
265
- let call = 0;
266
- const client = {
267
- exec: async (sql) => {
268
- if (sql.includes("LATEST ON")) {
269
- return { columns: [], dataset: [], count: 0, timestamp: 0 };
270
- }
271
- queries.push(sql);
272
- const dataset = datasets[Math.min(call++, datasets.length - 1)];
273
- return { columns: [], dataset, count: dataset.length, timestamp: 0 };
274
- },
275
- toObjects: (result) => result.dataset.map((row) => ({
276
- ts: row[0],
277
- path: row[1],
278
- context: row[2],
279
- valuetext: row[3],
280
- kind: row[4],
281
- })),
282
- };
283
- const written = [];
284
- const provider = createHistoryProviderV1(client, SELF, noop);
285
- const stop = provider.streamHistory({ write: (d) => written.push(d), on: () => { } }, { startTime: new Date("2024-01-01T00:00:00Z"), playbackRate }, () => { });
286
- return { queries, written, stop };
287
- }
288
- const row = (ts, path) => [ts, path, "self", "1", "number"];
289
- // Poll rather than sleep a fixed interval: on a loaded runner a fixed wait
290
- // can miss the follow-up read and fail as a TypeError on undefined instead
291
- // of a readable assertion.
292
- async function waitForQueries(queries, n, timeoutMs = 2000) {
293
- const deadline = Date.now() + timeoutMs;
294
- while (queries.length < n && Date.now() < deadline) {
295
- await new Promise((r) => setTimeout(r, 5));
296
- }
297
- assert.ok(queries.length >= n, `expected at least ${n} reads, got ${queries.length}`);
298
- }
299
- it("resumes after the last sent row instead of skipping the remainder", async () => {
300
- // First read returns a full page (10000 rows) ending mid-window; the
301
- // next read must start from that row's timestamp, not the window end.
302
- const full = Array.from({ length: 10000 }, (_, i) => row(i === 9999
303
- ? "2024-01-01T00:00:30.000000Z"
304
- : "2024-01-01T00:00:00.000000Z", `p${i}`));
305
- const { queries, stop } = streamOnce([full, []]);
306
- await waitForQueries(queries, 2);
307
- stop();
308
- assert.ok(queries[1].includes("2024-01-01T00:00:30.000"), `second read should resume AT the last row, got: ${queries[1]}`);
309
- });
310
- it("re-reads the final timestamp so tied rows are not dropped", async () => {
311
- // QuestDB stamps at microsecond precision but JS Date truncates to ms,
312
- // and one instrument update commonly stamps several paths in the same
313
- // millisecond. If the page boundary falls inside such a group, stepping
314
- // past that millisecond would silently lose the siblings.
315
- const tied = Array.from({ length: 10000 }, (_, i) => row("2024-01-01T00:00:10.000000Z", `p${i}`));
316
- // Not every row shares currentTime's own millisecond, so the cursor
317
- // parks on the tie rather than stepping over it.
318
- tied[0] = row("2024-01-01T00:00:05.000000Z", "earlier");
319
- const { queries, stop } = streamOnce([tied, []]);
320
- await waitForQueries(queries, 2);
321
- stop();
322
- assert.ok(queries[1].includes("2024-01-01T00:00:10.000"), `expected a re-read of the tied millisecond, got: ${queries[1]}`);
323
- });
324
- it("still advances when an entire page shares one millisecond", async () => {
325
- // Degenerate case: resuming at the same timestamp would repeat the
326
- // identical read forever, so the cursor must step forward.
327
- const sameMs = Array.from({ length: 10000 }, (_, i) => row("2024-01-01T00:00:00.000000Z", `p${i}`));
328
- const { queries, stop } = streamOnce([sameMs, []]);
329
- await waitForQueries(queries, 2);
330
- stop();
331
- assert.ok(queries[1].includes("2024-01-01T00:00:00.001"), `expected forward progress, got: ${queries[1]}`);
332
- });
333
- it("advances to the next window when a read is not truncated", async () => {
334
- // A non-truncated read waits CHUNK_SECONDS/playbackRate before the next
335
- // one, so drive playback fast enough that the follow-up lands promptly.
336
- const { queries, stop } = streamOnce([[row("2024-01-01T00:00:05.000000Z", "p")], []], 6000);
337
- await waitForQueries(queries, 2);
338
- stop();
339
- assert.ok(queries[1].includes("2024-01-01T00:01:00.000"), `expected the next 60s window, got: ${queries[1]}`);
340
- });
341
- });
342
- describe("history-v1 playback vessel-name injection", () => {
343
- // A playback window almost never contains a name row (names are written
344
- // on change only, typically when the vessel was first seen), so playback
345
- // fetches the last-known name per context once and injects it ahead of a
346
- // context's first delta — otherwise restored targets stay anonymous.
347
- const VESSEL = "vessels.urn:mrn:imo:mmsi:244813000";
348
- function streamWithNames(windowDatasets, names) {
349
- let call = 0;
350
- const nameQueries = [];
351
- const client = {
352
- exec: async (sql) => {
353
- if (sql.includes("LATEST ON")) {
354
- nameQueries.push(sql);
355
- if (names instanceof Error)
356
- throw names;
357
- return {
358
- __names: true,
359
- columns: [],
360
- dataset: names,
361
- count: names.length,
362
- timestamp: 0,
363
- };
364
- }
365
- const dataset = windowDatasets[Math.min(call++, windowDatasets.length - 1)];
366
- return { columns: [], dataset, count: dataset.length, timestamp: 0 };
367
- },
368
- toObjects: (result) => result.__names
369
- ? result.dataset.map((row) => ({
370
- context: row[0],
371
- value_str: row[1],
372
- }))
373
- : result.dataset.map((row) => ({
374
- ts: row[0],
375
- path: row[1],
376
- context: row[2],
377
- valuetext: row[3],
378
- kind: row[4],
379
- })),
380
- };
381
- const written = [];
382
- const provider = createHistoryProviderV1(client, SELF, noop);
383
- const stop = provider.streamHistory({ write: (d) => written.push(d), on: () => { } }, { startTime: new Date("2024-01-01T00:00:00Z"), playbackRate: 1 }, () => { });
384
- return { written, stop, nameQueries };
385
- }
386
- async function waitForWritten(written, n, timeoutMs = 2000) {
387
- const deadline = Date.now() + timeoutMs;
388
- while (written.length < n && Date.now() < deadline) {
389
- await new Promise((r) => setTimeout(r, 5));
390
- }
391
- assert.ok(written.length >= n, `expected at least ${n} written deltas, got ${written.length}`);
392
- }
393
- const dataRow = (ts, context) => [
394
- ts,
395
- "navigation.speedOverGround",
396
- context,
397
- "4.2",
398
- "number",
399
- ];
400
- it("injects the last-known name once, ahead of the first delta", async () => {
401
- const { written, stop, nameQueries } = streamWithNames([
402
- [
403
- dataRow("2024-01-01T00:00:01.000000Z", VESSEL),
404
- dataRow("2024-01-01T00:00:02.000000Z", VESSEL),
405
- ],
406
- [],
407
- ], [[VESSEL, "Sea Breeze"]]);
408
- try {
409
- await waitForWritten(written, 3);
410
- assert.deepEqual(written[0].updates[0].values, [
411
- { path: "", value: { name: "Sea Breeze" } },
412
- ]);
413
- assert.equal(written[0].context, VESSEL);
414
- const nameDeltas = written.filter((d) => d.updates.some((u) => u.values.some((v) => v.path === "")));
415
- assert.equal(nameDeltas.length, 1, "one name delta per context, not per row");
416
- // The lookup is bound to the playback start (a later rename must not
417
- // leak into a historical replay) and to identity-tagged rows (a data
418
- // path literally named "name" is not a vessel name).
419
- assert.equal(nameQueries.length, 1);
420
- assert.ok(nameQueries[0].includes("ts <= '2024-01-01T00:00:00"), `expected a start-time bound, got: ${nameQueries[0]}`);
421
- assert.ok(nameQueries[0].includes("value_kind = 'identity'"), `expected the identity filter, got: ${nameQueries[0]}`);
422
- }
423
- finally {
424
- stop();
425
- }
426
- });
427
- it("resolves the self context on the injected delta", async () => {
428
- const { written, stop } = streamWithNames([[dataRow("2024-01-01T00:00:01.000000Z", "self")], []], [["self", "Vessel Aurora"]]);
429
- try {
430
- await waitForWritten(written, 2);
431
- assert.equal(written[0].context, SELF);
432
- assert.deepEqual(written[0].updates[0].values[0], {
433
- path: "",
434
- value: { name: "Vessel Aurora" },
435
- });
436
- }
437
- finally {
438
- stop();
439
- }
440
- });
441
- it("streams unlabeled when the names lookup fails", async () => {
442
- const { written, stop } = streamWithNames([[dataRow("2024-01-01T00:00:01.000000Z", VESSEL)], []], new Error("QuestDB query failed (500): boom"));
443
- try {
444
- await waitForWritten(written, 1);
445
- assert.equal(written[0].context, VESSEL);
446
- assert.ok(written.every((d) => d.updates.every((u) => u.values.every((v) => v.path !== ""))), "no name delta may be fabricated on lookup failure");
447
- }
448
- finally {
449
- stop();
450
- }
451
- });
452
- });
453
- describe("history-v1 streamHistory does not outlive its process", () => {
454
- // The regression this pins down is not a wrong VALUE, it is a process that
455
- // will not exit. Stopping a playback set a `stopped` flag but left the
456
- // already-scheduled inter-chunk timer armed for CHUNK_SECONDS/playbackRate
457
- // (60s at rate 1), so node stayed alive until it fired. That made this file
458
- // take 60s to run assertions completing in milliseconds, and the plugin
459
- // registry — which runs `npm test` under `timeout 60s` — scored the whole
460
- // suite as failing.
461
- //
462
- // Asserting on timer handles does not work: node does not expose Timeouts
463
- // via process._getActiveHandles(). So assert the observable property
464
- // instead — spawn a real process that starts a playback, and require it to
465
- // exit on its own well inside the 60s the orphaned timer would have cost.
466
- it("lets node exit promptly after a playback is started", async () => {
467
- const { spawn } = await import("node:child_process");
468
- const script = `
469
- const { createHistoryProviderV1 } = require("./dist/history-v1.js");
470
- const dataset = [["2024-01-01T00:00:01.000000Z","navigation.speedOverGround","self","1","number"]];
471
- const client = {
472
- exec: async (sql) => sql.includes("LATEST ON")
473
- ? { columns: [], dataset: [], count: 0, timestamp: 0 }
474
- : { columns: [], dataset, count: dataset.length, timestamp: 0 },
475
- toObjects: (r) => r.dataset.map((row) => ({
476
- ts: row[0], path: row[1], context: row[2], valuetext: row[3], kind: row[4],
477
- })),
478
- };
479
- const provider = createHistoryProviderV1(client, "vessels.self", () => {});
480
- provider.streamHistory(
481
- { write: () => {}, on: () => {} },
482
- { startTime: new Date("2024-01-01T00:00:00Z"), playbackRate: 1 },
483
- () => {},
484
- );
485
- // Deliberately do NOT call stop(): an unref'd timer must not be enough
486
- // to keep the process alive on its own.
487
- `;
488
- const started = Date.now();
489
- const exitCode = await new Promise((resolve) => {
490
- const child = spawn(process.execPath, ["-e", script], {
491
- stdio: "ignore",
492
- });
493
- const kill = setTimeout(() => {
494
- child.kill("SIGKILL");
495
- resolve(null);
496
- }, 15000);
497
- child.on("exit", (code) => {
498
- clearTimeout(kill);
499
- resolve(code);
500
- });
501
- });
502
- const elapsed = Date.now() - started;
503
- assert.equal(exitCode, 0, "the playback process must exit on its own");
504
- assert.ok(elapsed < 10000, `expected a prompt exit, took ${elapsed}ms (an orphaned 60s timer would hang here)`);
505
- });
506
- });
507
- describe("history-v1 source attribution", () => {
508
- // Rows as the unified query returns them since the source migration:
509
- // ts, path, context, source, valuetext, kind.
510
- function sourceMockClient(captured, responder) {
511
- return {
512
- exec: async (sql) => {
513
- captured.push({ sql });
514
- const dataset = responder(sql);
515
- return { columns: [], dataset, count: dataset.length, timestamp: 0 };
516
- },
517
- toObjects: (result) => result.dataset.map((row) => ({
518
- ts: row[0],
519
- path: row[1],
520
- context: row[2],
521
- source: row[3],
522
- valuetext: row[4],
523
- kind: row[5],
524
- })),
525
- };
526
- }
527
- const getHistoryDeltas = (responder, captured = []) => {
528
- const provider = createHistoryProviderV1(sourceMockClient(captured, responder), SELF, noop);
529
- return new Promise((resolve) => provider.getHistory(new Date("2024-01-01T00:00:00Z"), "", resolve));
530
- };
531
- it("replays each source as its own $source-labelled update", async () => {
532
- // Two receivers' rows sharing a timestamp must not merge into one
533
- // update — a single $source would misattribute one receiver's fix to
534
- // the other, exactly the ambiguity the source column exists to remove.
535
- const ts = "2024-01-01T00:00:00.000000Z";
536
- const deltas = await getHistoryDeltas(() => [
537
- [ts, "navigation.position", "self", "gps.main", "60.1,24.9", "position"],
538
- [
539
- ts,
540
- "navigation.position",
541
- "self",
542
- "gps.backup",
543
- "60.2,24.8",
544
- "position",
545
- ],
546
- [ts, "environment.depth.belowKeel", "self", null, "3.2", "number"],
547
- ]);
548
- assert.equal(deltas.length, 3);
549
- const bySource = new Map(deltas.map((d) => [d.updates[0].$source, d.updates[0].values]));
550
- assert.deepEqual(bySource.get("gps.main"), [
551
- {
552
- path: "navigation.position",
553
- value: { latitude: 60.1, longitude: 24.9 },
554
- },
555
- ]);
556
- assert.deepEqual(bySource.get("gps.backup"), [
557
- {
558
- path: "navigation.position",
559
- value: { latitude: 60.2, longitude: 24.8 },
560
- },
561
- ]);
562
- // The pre-migration row (source null) replays without a $source at all.
563
- assert.deepEqual(bySource.get(undefined), [
564
- { path: "environment.depth.belowKeel", value: 3.2 },
565
- ]);
566
- const unlabelled = deltas.find((d) => !d.updates[0].$source);
567
- assert.ok(!("$source" in unlabelled.updates[0]));
568
- });
569
- it("selects the source column from every table", async () => {
570
- const captured = [];
571
- await getHistoryDeltas(() => [], captured);
572
- const branches = captured[0].sql.split("UNION ALL");
573
- assert.equal(branches.length, 3);
574
- for (const branch of branches) {
575
- assert.ok(branch.includes("CAST(source AS STRING) source"), `every branch must carry source, got: ${branch}`);
576
- }
577
- });
578
- it("retries without source when the column is not migrated yet", async () => {
579
- const captured = [];
580
- const deltas = await getHistoryDeltas((sql) => {
581
- if (sql.includes("CAST(source AS STRING)")) {
582
- throw new Error("Invalid column: source");
583
- }
584
- return [
585
- [
586
- "2024-01-01T00:00:00.000000Z",
587
- "environment.depth.belowKeel",
588
- "self",
589
- null,
590
- "3.2",
591
- "number",
592
- ],
593
- ];
594
- }, captured);
595
- assert.equal(captured.length, 2);
596
- assert.ok(captured[1].sql.includes("CAST(NULL AS STRING) source"), `retry must degrade source to NULL, got: ${captured[1].sql}`);
597
- assert.equal(deltas.length, 1);
598
- assert.ok(!("$source" in deltas[0].updates[0]));
599
- });
600
- it("keeps the degraded source flag for the whole playback session", async () => {
601
- // A playback reads once per minute of history; re-probing a missing
602
- // column on every chunk pays a rejected statement per chunk on QuestDB's
603
- // single shared worker. The probe must run once per session, not once
604
- // per chunk — while one-shot reads (getHistory) still re-probe.
605
- const windowReads = [];
606
- const client = {
607
- exec: async (sql) => {
608
- if (sql.includes("LATEST ON")) {
609
- return { columns: [], dataset: [], count: 0, timestamp: 0 };
610
- }
611
- windowReads.push(sql);
612
- if (sql.includes("CAST(source AS STRING)")) {
613
- throw new Error("Invalid column: source");
614
- }
615
- const dataset = [
616
- [
617
- "2024-01-01T00:00:00.000000Z",
618
- "environment.depth.belowKeel",
619
- "self",
620
- null,
621
- "3.2",
622
- "number",
623
- ],
624
- ];
625
- return { columns: [], dataset, count: dataset.length, timestamp: 0 };
626
- },
627
- toObjects: (result) => result.dataset.map((row) => ({
628
- ts: row[0],
629
- path: row[1],
630
- context: row[2],
631
- source: row[3],
632
- valuetext: row[4],
633
- kind: row[5],
634
- })),
635
- };
636
- const provider = createHistoryProviderV1(client, SELF, noop);
637
- const stop = provider.streamHistory({ write: () => { }, on: () => { } }, { startTime: new Date("2024-01-01T00:00:00Z"), playbackRate: 1000 }, () => { });
638
- const deadline = Date.now() + 2000;
639
- while (windowReads.length < 4 && Date.now() < deadline) {
640
- await new Promise((r) => setTimeout(r, 5));
641
- }
642
- stop();
643
- assert.ok(windowReads.length >= 4, `got ${windowReads.length} reads`);
644
- const probes = windowReads.filter((q) => q.includes("CAST(source AS STRING)"));
645
- assert.equal(probes.length, 1, "only the session's first chunk may probe the missing column");
646
- });
647
- it("drops value_kind and source independently when both are missing", async () => {
648
- // An external QuestDB the plugin does not own can predate BOTH
649
- // migrations; each retry must shed only the column actually complained
650
- // about, or the fallback ladder never reaches a query that runs.
651
- const captured = [];
652
- const deltas = await getHistoryDeltas((sql) => {
653
- if (sql.includes("CAST(value_kind AS STRING)")) {
654
- throw new Error("Invalid column: value_kind");
655
- }
656
- if (sql.includes("CAST(source AS STRING)")) {
657
- throw new Error("Invalid column: source");
658
- }
659
- return [
660
- [
661
- "2024-01-01T00:00:00.000000Z",
662
- "environment.depth.belowKeel",
663
- "self",
664
- null,
665
- "3.2",
666
- "number",
667
- ],
668
- ];
669
- }, captured);
670
- assert.equal(captured.length, 3);
671
- assert.ok(captured[2].sql.includes("CAST(NULL AS STRING) source") &&
672
- !captured[2].sql.includes("CAST(value_kind"), `final query must run with both degraded, got: ${captured[2].sql}`);
673
- assert.equal(deltas.length, 1);
674
- });
675
- });
676
- //# sourceMappingURL=history-v1.test.js.map