signalk-questdb 2.0.1 → 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 (91) hide show
  1. package/.coderabbit.yaml +4 -3
  2. package/README.md +151 -5
  3. package/dist/api-contract.d.ts +62 -0
  4. package/dist/config/schema.d.ts +1 -0
  5. package/dist/config/schema.js +5 -0
  6. package/dist/config/schema.js.map +1 -1
  7. package/dist/configpanel/responses.d.ts +33 -1
  8. package/dist/configpanel/responses.js +89 -0
  9. package/dist/configpanel/responses.js.map +1 -1
  10. package/dist/history-v1.js +12 -9
  11. package/dist/history-v1.js.map +1 -1
  12. package/dist/history-v2.d.ts +18 -6
  13. package/dist/history-v2.js +275 -41
  14. package/dist/history-v2.js.map +1 -1
  15. package/dist/ilp-writer.d.ts +22 -0
  16. package/dist/ilp-writer.js +76 -4
  17. package/dist/ilp-writer.js.map +1 -1
  18. package/dist/index.d.ts +1 -0
  19. package/dist/index.js +326 -58
  20. package/dist/index.js.map +1 -1
  21. package/dist/influx-detect.d.ts +19 -0
  22. package/dist/influx-detect.js +165 -0
  23. package/dist/influx-detect.js.map +1 -0
  24. package/dist/migration.d.ts +147 -0
  25. package/dist/migration.js +819 -0
  26. package/dist/migration.js.map +1 -0
  27. package/dist/query-client.d.ts +9 -0
  28. package/dist/query-client.js +11 -0
  29. package/dist/query-client.js.map +1 -1
  30. package/dist/restore.js +9 -0
  31. package/dist/restore.js.map +1 -1
  32. package/dist/schema-errors.d.ts +15 -0
  33. package/dist/schema-errors.js +38 -0
  34. package/dist/schema-errors.js.map +1 -0
  35. package/package.json +5 -4
  36. package/public/assets/PluginConfigurationPanel-CR4mD9IH.js +9 -0
  37. package/public/assets/{index-Z7eUk0W6.js → index-CyI4zKUm.js} +1 -1
  38. package/public/assets/{virtualExposes-CyrNon4j.js → virtualExposes-BBIkbTtD.js} +1 -1
  39. package/public/remoteEntry.js +1 -1
  40. package/dist/test/config.test.d.ts +0 -1
  41. package/dist/test/config.test.js +0 -161
  42. package/dist/test/config.test.js.map +0 -1
  43. package/dist/test/configpanel-responses.test.d.ts +0 -1
  44. package/dist/test/configpanel-responses.test.js +0 -108
  45. package/dist/test/configpanel-responses.test.js.map +0 -1
  46. package/dist/test/console-proxy.test.d.ts +0 -1
  47. package/dist/test/console-proxy.test.js +0 -664
  48. package/dist/test/console-proxy.test.js.map +0 -1
  49. package/dist/test/container-env.test.d.ts +0 -1
  50. package/dist/test/container-env.test.js +0 -70
  51. package/dist/test/container-env.test.js.map +0 -1
  52. package/dist/test/delta-routing.test.d.ts +0 -1
  53. package/dist/test/delta-routing.test.js +0 -258
  54. package/dist/test/delta-routing.test.js.map +0 -1
  55. package/dist/test/full-export-range.test.d.ts +0 -1
  56. package/dist/test/full-export-range.test.js +0 -78
  57. package/dist/test/full-export-range.test.js.map +0 -1
  58. package/dist/test/history-v1.test.d.ts +0 -1
  59. package/dist/test/history-v1.test.js +0 -676
  60. package/dist/test/history-v1.test.js.map +0 -1
  61. package/dist/test/history-v2.test.d.ts +0 -1
  62. package/dist/test/history-v2.test.js +0 -563
  63. package/dist/test/history-v2.test.js.map +0 -1
  64. package/dist/test/host-limits.test.d.ts +0 -1
  65. package/dist/test/host-limits.test.js +0 -81
  66. package/dist/test/host-limits.test.js.map +0 -1
  67. package/dist/test/ilp-writer.test.d.ts +0 -1
  68. package/dist/test/ilp-writer.test.js +0 -391
  69. package/dist/test/ilp-writer.test.js.map +0 -1
  70. package/dist/test/package-e2e.test.d.ts +0 -1
  71. package/dist/test/package-e2e.test.js +0 -306
  72. package/dist/test/package-e2e.test.js.map +0 -1
  73. package/dist/test/path-matcher.test.d.ts +0 -1
  74. package/dist/test/path-matcher.test.js +0 -224
  75. package/dist/test/path-matcher.test.js.map +0 -1
  76. package/dist/test/query-client.test.d.ts +0 -1
  77. package/dist/test/query-client.test.js +0 -299
  78. package/dist/test/query-client.test.js.map +0 -1
  79. package/dist/test/questdb-endpoint.test.d.ts +0 -1
  80. package/dist/test/questdb-endpoint.test.js +0 -297
  81. package/dist/test/questdb-endpoint.test.js.map +0 -1
  82. package/dist/test/restore.test.d.ts +0 -1
  83. package/dist/test/restore.test.js +0 -496
  84. package/dist/test/restore.test.js.map +0 -1
  85. package/dist/test/time-range.test.d.ts +0 -1
  86. package/dist/test/time-range.test.js +0 -53
  87. package/dist/test/time-range.test.js.map +0 -1
  88. package/dist/test/wal-monitor.test.d.ts +0 -1
  89. package/dist/test/wal-monitor.test.js +0 -405
  90. package/dist/test/wal-monitor.test.js.map +0 -1
  91. package/public/assets/PluginConfigurationPanel-CyS3A7gD.js +0 -9
@@ -0,0 +1,819 @@
1
+ // Importing history out of an existing InfluxDB into QuestDB.
2
+ //
3
+ // The Signal K InfluxDB plugins (signalk-to-influxdb and -influxdb2) store one
4
+ // measurement per Signal K path, with the value in a field. Which field, and
5
+ // what shape, differs between them and across versions, so the reader here
6
+ // classifies each row by the value it actually finds rather than by assuming a
7
+ // schema. Anything it cannot map is counted as skipped, never silently
8
+ // dropped: a migration that quietly loses a third of the history looks
9
+ // identical to one that worked.
10
+ //
11
+ // Reads are windowed by time and drained window by window. A boat's InfluxDB
12
+ // is routinely larger than the Pi's RAM, so neither the query nor the result
13
+ // may be unbounded — and a window that returns nothing still advances, so a
14
+ // multi-year gap costs one empty query per window rather than a stall.
15
+ import { routeDeltaValue, flattenObjectValue } from "./delta-routing.js";
16
+ /** Nanoseconds per millisecond, the unit gap between `Date` and ILP. */
17
+ const NANOS_PER_MS = 1000000n;
18
+ /**
19
+ * How much time one read covers. Small enough that a dense measurement does
20
+ * not return a result set too large to hold, large enough that a sparse one
21
+ * does not need thousands of round trips. A day is the natural unit for boat
22
+ * data: it bounds a busy passage to a few hundred thousand points.
23
+ */
24
+ export const DEFAULT_WINDOW_MS = 24 * 60 * 60 * 1000;
25
+ /**
26
+ * Pause reading once the writer has this many lines queued. The ILP writer
27
+ * flushes on its own timer; without backpressure a fast InfluxDB fills the
28
+ * buffer faster than the socket drains it and the import is capped by
29
+ * MAX_BUFFER_LINES dropping the OLDEST lines — i.e. it would silently discard
30
+ * the very history it was asked to copy.
31
+ */
32
+ const WRITER_HIGH_WATER = 20_000;
33
+ const WRITER_RESUME = 5_000;
34
+ /**
35
+ * Give up waiting for the writer to drain after this long.
36
+ *
37
+ * The drain wait is otherwise unbounded, and `pendingLines` only falls when
38
+ * QuestDB actually accepts data. A QuestDB that is down, wedged or refusing
39
+ * writes therefore parks the import in a `sleep` loop forever: the run sits at
40
+ * "running" with its counters frozen, nothing is logged, and the only way out
41
+ * is cancelling it by hand. Failing with a stated reason after five minutes is
42
+ * far more useful than an import that silently never finishes — and because
43
+ * imported rows upsert on their dedup key, re-running once QuestDB is healthy
44
+ * simply resumes the work.
45
+ */
46
+ const WRITER_DRAIN_TIMEOUT_MS = 5 * 60 * 1000;
47
+ function authHeaders(type, auth) {
48
+ if (!auth)
49
+ return {};
50
+ if (type === "influxdb2") {
51
+ return auth.token ? { Authorization: `Token ${auth.token}` } : {};
52
+ }
53
+ // 1.x accepts HTTP Basic; token auth is meaningless there.
54
+ if (auth.username) {
55
+ const basic = Buffer.from(`${auth.username}:${auth.password ?? ""}`).toString("base64");
56
+ return { Authorization: `Basic ${basic}` };
57
+ }
58
+ return {};
59
+ }
60
+ /** Buckets (2.x) or databases (1.x) available to import from. */
61
+ export async function listBuckets(req, fetchImpl = fetch) {
62
+ const headers = authHeaders(req.type, req.auth);
63
+ if (req.type === "influxdb2") {
64
+ // 100 is the API maximum per page, and a server with more buckets returns
65
+ // a relative `links.next` — verified against a live 2.9.1 holding 106
66
+ // buckets, where a single request silently returned only 98 of them. A
67
+ // bucket the operator cannot see is a bucket they cannot import from, so
68
+ // the pages are followed to the end rather than truncated.
69
+ const collected = [];
70
+ // The FIRST page is built by concatenation, like every other request in
71
+ // this file. Resolving an absolute path against the base instead would
72
+ // drop a configured path prefix — `http://host/influx` would be probed at
73
+ // `http://host/api/v2/buckets` — and a reverse-proxied InfluxDB is a
74
+ // normal setup, which validateInfluxUrl deliberately preserves.
75
+ let url = `${req.url}/api/v2/buckets?limit=100`;
76
+ // Hard stop on the page count: `links.next` comes from the far end, and a
77
+ // server that always returns one would otherwise loop forever. 100 pages
78
+ // is 10k buckets — far past any real deployment.
79
+ for (let page = 0; url && page < 100; page++) {
80
+ const current = url;
81
+ const r = await fetchImpl(current, {
82
+ headers,
83
+ signal: AbortSignal.timeout(10000),
84
+ });
85
+ if (!r.ok)
86
+ throw new Error(await describeHttpError(r, "list buckets"));
87
+ const body = (await r.json());
88
+ collected.push(...(body.buckets ?? []));
89
+ // `links.next` is server-supplied and relative, so it resolves against
90
+ // the page just fetched rather than against the configured base.
91
+ const link = body.links?.next;
92
+ if (typeof link !== "string") {
93
+ url = null;
94
+ }
95
+ else {
96
+ const resolved = new URL(link, current).toString();
97
+ // A `next` pointing at the page just fetched would spin forever.
98
+ url = resolved === current ? null : resolved;
99
+ }
100
+ }
101
+ return (collected
102
+ // The _monitoring/_tasks system buckets hold InfluxDB's own telemetry,
103
+ // never Signal K data; offering them as import sources is noise.
104
+ .filter((b) => b.name && !b.name.startsWith("_"))
105
+ .map((b) => ({
106
+ name: b.name,
107
+ id: b.id,
108
+ retentionSeconds: b.retentionRules?.[0]?.everySeconds ?? 0,
109
+ })));
110
+ }
111
+ const r = await fetchImpl(`${req.url}/query?q=${encodeURIComponent("SHOW DATABASES")}`, { headers, signal: AbortSignal.timeout(10000) });
112
+ if (!r.ok)
113
+ throw new Error(await describeHttpError(r, "list databases"));
114
+ const body = (await r.json());
115
+ // 1.x answers a REJECTED query with HTTP 200 and an `error` member, so
116
+ // without this an auth failure or a bad query reads as "no databases" —
117
+ // the same trap the measurement and window readers already guard against.
118
+ if (body.results?.[0]?.error)
119
+ throw new Error(body.results[0].error);
120
+ const values = body.results?.[0]?.series?.[0]?.values ?? [];
121
+ return values
122
+ .map((row) => String(row[0]))
123
+ .filter((name) => name && name !== "_internal")
124
+ .map((name) => ({ name }));
125
+ }
126
+ /** Measurements in a bucket/database, with the field keys each carries. */
127
+ export async function listMeasurements(req, fetchImpl = fetch) {
128
+ const headers = authHeaders(req.type, req.auth);
129
+ if (req.type === "influxdb2") {
130
+ // `schema.measurements()` reads InfluxDB's SCHEMA, not its points.
131
+ //
132
+ // The obvious-looking alternative — from(bucket) |> range(start: 0) |>
133
+ // keep([_measurement, _field]) |> distinct — returns the same names but
134
+ // gets them by READING EVERY POINT IN THE BUCKET first. `keep` and
135
+ // `distinct` shrink the RESULT, not the scan. Measured on 2.9.1: 0.15s
136
+ // at 1.2M points, 0.50s at 4.8M — linear in point count — while
137
+ // schema.measurements() stayed at ~8ms for both. On a year of boat data
138
+ // (a user reported ~40 GB on a Pi 5) the scan blew the query timeout, so
139
+ // the import failed at discovery having read nothing: "0 written,
140
+ // 0 skipped, 0/0 measurements".
141
+ //
142
+ // Field keys are deliberately NOT fetched. Nothing consumes them: the
143
+ // import maps this to `.name`, and the panel renders only names. Pairing
144
+ // fields to measurements needs either the full scan above or one request
145
+ // per measurement, and neither is worth paying for a value no one reads.
146
+ // `start` is pinned to the epoch rather than left to the default.
147
+ // schema.measurements() takes a time range, and its default is a
148
+ // documented-as-changeable window (historically -30d in the Flux
149
+ // stdlib). A 2.9.1 instance returns everything either way — verified
150
+ // with a measurement 200 days old — but relying on that would mean an
151
+ // import silently skipping a boat's older history on some other
152
+ // version, which is precisely the class of failure this commit fixes.
153
+ const flux = `import "influxdata/influxdb/schema"
154
+ schema.measurements(bucket: ${JSON.stringify(req.bucket)}, start: 1970-01-01T00:00:00Z)`;
155
+ const rows = await runFlux(req, flux, headers, fetchImpl);
156
+ return rows
157
+ .map((r) => r.values["_value"])
158
+ .filter((v) => !!v)
159
+ .map((name) => ({ name, fields: [] }));
160
+ }
161
+ // SHOW FIELD KEYS returns one series PER MEASUREMENT, each named after it,
162
+ // so a single request yields both the names and their field keys. Unlike
163
+ // Flux's schema functions this is metadata, not a point scan — measured at
164
+ // 0.024s over 800k points — so 1.x gets the pairing for free and keeps it.
165
+ // (The 2.x branch above deliberately returns an empty `fields`; see there.)
166
+ const url = `${req.url}/query?db=${encodeURIComponent(req.bucket)}&q=${encodeURIComponent("SHOW FIELD KEYS")}`;
167
+ const r = await fetchImpl(url, {
168
+ headers,
169
+ signal: AbortSignal.timeout(15000),
170
+ });
171
+ if (!r.ok)
172
+ throw new Error(await describeHttpError(r, "list measurements"));
173
+ const body = (await r.json());
174
+ if (body.results?.[0]?.error)
175
+ throw new Error(body.results[0].error);
176
+ const series = body.results?.[0]?.series ?? [];
177
+ const out = series
178
+ .filter((s) => s.name)
179
+ .map((s) => ({
180
+ name: s.name,
181
+ // Each row is [fieldKey, fieldType].
182
+ fields: (s.values ?? []).map((row) => String(row[0])),
183
+ }));
184
+ if (out.length > 0)
185
+ return out;
186
+ // A measurement with no field keys (possible on an odd schema) would be
187
+ // missing above, so fall back to the plain listing rather than returning
188
+ // nothing at all.
189
+ const r2 = await fetchImpl(`${req.url}/query?db=${encodeURIComponent(req.bucket)}&q=${encodeURIComponent("SHOW MEASUREMENTS")}`, { headers, signal: AbortSignal.timeout(15000) });
190
+ if (!r2.ok)
191
+ throw new Error(await describeHttpError(r2, "list measurements"));
192
+ const body2 = (await r2.json());
193
+ const values = body2.results?.[0]?.series?.[0]?.values ?? [];
194
+ return values.map((row) => ({ name: String(row[0]), fields: [] }));
195
+ }
196
+ async function describeHttpError(r, what) {
197
+ const text = await r.text().catch(() => "");
198
+ // 401/403 against InfluxDB almost always means a missing or wrong token,
199
+ // which is worth saying outright — the raw body is usually just {"code":
200
+ // "unauthorized"} and leaves the user guessing.
201
+ if (r.status === 401 || r.status === 403) {
202
+ return `Cannot ${what}: InfluxDB rejected the credentials (HTTP ${r.status}). Check the API token / username and password.`;
203
+ }
204
+ return `Cannot ${what}: HTTP ${r.status}${text ? ` — ${text.slice(0, 200)}` : ""}`;
205
+ }
206
+ /**
207
+ * Run a Flux query and return the annotated-CSV rows as records.
208
+ *
209
+ * Written by hand rather than pulled in as a dependency: the plugin ships with
210
+ * four runtime deps and the official client would add a large tree for what is
211
+ * one POST and a CSV parse.
212
+ */
213
+ async function runFlux(req, flux, headers, fetchImpl, timeoutMs = 120_000) {
214
+ const org = req.auth?.org ?? "";
215
+ const url = `${req.url}/api/v2/query${org ? `?org=${encodeURIComponent(org)}` : ""}`;
216
+ const r = await fetchImpl(url, {
217
+ method: "POST",
218
+ headers: {
219
+ ...headers,
220
+ "Content-Type": "application/vnd.flux",
221
+ Accept: "text/csv",
222
+ },
223
+ body: flux,
224
+ signal: AbortSignal.timeout(timeoutMs),
225
+ });
226
+ if (!r.ok)
227
+ throw new Error(await describeHttpError(r, "run query"));
228
+ return parseAnnotatedCsv(await r.text());
229
+ }
230
+ /**
231
+ * Minimal annotated-CSV reader for Flux responses.
232
+ *
233
+ * Flux emits one or more *tables*, each preceded by annotation lines starting
234
+ * with `#` and then a header row. A response can therefore change its column
235
+ * layout partway through, which is why the header is tracked per block rather
236
+ * than read once at the top.
237
+ */
238
+ export function parseAnnotatedCsv(text) {
239
+ const out = [];
240
+ let header = null;
241
+ // Per-block `#datatype` line. Without it a genuine STRING value of "3.5"
242
+ // is indistinguishable from the number 3.5 in the CSV body — verified
243
+ // against a live 2.9.1 — and would be imported into the numeric table.
244
+ let datatypes = null;
245
+ // A quoted CSV value may contain a newline — verified against a live 2.9.1,
246
+ // which returns `"line1\r\nline2"` for a string value holding one. Splitting
247
+ // on newlines alone would tear that record in half and produce two garbage
248
+ // rows, so physical lines are joined until the quotes balance.
249
+ // \r is stripped per PHYSICAL line before joining: a quoted value spanning
250
+ // two CRLF lines would otherwise keep the \r of the first inside the joined
251
+ // value, so the imported string silently carries a stray carriage return.
252
+ for (const rawLine of joinQuotedLines(text.split("\n").map((l) => l.replace(/\r$/, "")))) {
253
+ const line = rawLine;
254
+ if (line === "") {
255
+ // Blank line separates tables; the next non-# line is a fresh header.
256
+ header = null;
257
+ datatypes = null;
258
+ continue;
259
+ }
260
+ if (line.startsWith("#")) {
261
+ // Annotation (#datatype/#group/#default). A new annotation block means
262
+ // the previous header no longer applies.
263
+ if (line.startsWith("#datatype"))
264
+ datatypes = splitCsvLine(line);
265
+ header = null;
266
+ continue;
267
+ }
268
+ const cells = splitCsvLine(line);
269
+ if (!header) {
270
+ header = cells;
271
+ continue;
272
+ }
273
+ const rec = {};
274
+ const types = {};
275
+ for (let i = 0; i < header.length; i++) {
276
+ const key = header[i];
277
+ // Flux's leading empty column is the annotation gutter, not data.
278
+ if (key === "")
279
+ continue;
280
+ rec[key] = cells[i] ?? "";
281
+ // #datatype is positionally aligned with the header row.
282
+ if (datatypes && datatypes[i])
283
+ types[key] = datatypes[i];
284
+ }
285
+ out.push({ values: rec, types });
286
+ }
287
+ return out;
288
+ }
289
+ /**
290
+ * Re-join physical lines that belong to one CSV record.
291
+ *
292
+ * A record is complete when its double quotes are balanced ("" inside a quoted
293
+ * value is an escaped quote and does not change the balance). Annotation and
294
+ * blank lines can never be mid-record, so they pass straight through and the
295
+ * caller's boundary handling is unaffected.
296
+ */
297
+ function joinQuotedLines(lines) {
298
+ const out = [];
299
+ let pending = null;
300
+ for (const line of lines) {
301
+ const candidate = pending === null ? line : `${pending}\n${line}`;
302
+ if (quotesBalanced(candidate)) {
303
+ out.push(candidate);
304
+ pending = null;
305
+ }
306
+ else {
307
+ pending = candidate;
308
+ }
309
+ }
310
+ // Unterminated quote at EOF: emit what there is rather than dropping it.
311
+ if (pending !== null)
312
+ out.push(pending);
313
+ return out;
314
+ }
315
+ function quotesBalanced(s) {
316
+ let inQuotes = false;
317
+ for (let i = 0; i < s.length; i++) {
318
+ if (s[i] !== '"')
319
+ continue;
320
+ if (inQuotes && s[i + 1] === '"') {
321
+ i++;
322
+ continue;
323
+ }
324
+ inQuotes = !inQuotes;
325
+ }
326
+ return !inQuotes;
327
+ }
328
+ function splitCsvLine(line) {
329
+ const cells = [];
330
+ let cur = "";
331
+ let inQuotes = false;
332
+ for (let i = 0; i < line.length; i++) {
333
+ const c = line[i];
334
+ if (inQuotes) {
335
+ if (c === '"') {
336
+ if (line[i + 1] === '"') {
337
+ cur += '"';
338
+ i++;
339
+ }
340
+ else {
341
+ inQuotes = false;
342
+ }
343
+ }
344
+ else {
345
+ cur += c;
346
+ }
347
+ }
348
+ else if (c === '"') {
349
+ inQuotes = true;
350
+ }
351
+ else if (c === ",") {
352
+ cells.push(cur);
353
+ cur = "";
354
+ }
355
+ else {
356
+ cur += c;
357
+ }
358
+ }
359
+ cells.push(cur);
360
+ return cells;
361
+ }
362
+ /**
363
+ * Parse an RFC3339 instant into nanoseconds since the epoch, preserving digits
364
+ * below the millisecond.
365
+ *
366
+ * `Date.parse` truncates to milliseconds. InfluxDB stores nanoseconds, and the
367
+ * QuestDB tables dedup on (ts, path, context, source) — so two points 200µs
368
+ * apart would collide and one would be lost. The fractional part is therefore
369
+ * read from the string directly.
370
+ */
371
+ export function rfc3339ToNanos(value) {
372
+ const m = /^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})(?:\.(\d+))?(Z|[+-]\d{2}:\d{2})$/.exec(value);
373
+ if (!m) {
374
+ const ms = Date.parse(value);
375
+ return Number.isNaN(ms) ? null : BigInt(ms) * NANOS_PER_MS;
376
+ }
377
+ const [, whole, frac = "", zone] = m;
378
+ const baseMs = Date.parse(`${whole}${zone}`);
379
+ if (Number.isNaN(baseMs))
380
+ return null;
381
+ // Pad/truncate the fraction to exactly 9 digits of nanoseconds.
382
+ const nanosPart = BigInt((frac + "000000000").slice(0, 9));
383
+ return BigInt(baseMs) * NANOS_PER_MS + nanosPart;
384
+ }
385
+ /**
386
+ * The Signal K path a measurement+field pair represents.
387
+ *
388
+ * signalk-to-influxdb writes the path as the measurement name and the value in
389
+ * a field called `value`. Anything else (a measurement with several named
390
+ * fields) is a non-Signal K schema, so the field name is appended to keep the
391
+ * two apart instead of overwriting one with the other.
392
+ */
393
+ export function toSignalKPath(measurement, field) {
394
+ if (field === "value" || field === "" || field === "_value")
395
+ return measurement;
396
+ return `${measurement}.${field}`;
397
+ }
398
+ /** Coerce an InfluxDB field value (always text over CSV) to a JS value. */
399
+ export function coerceValue(raw, dataType) {
400
+ if (raw === "")
401
+ return null;
402
+ // The DECLARED type wins, and is checked first: a STRING column holding
403
+ // "true" is the word, not a boolean, and would otherwise be recorded as one
404
+ // — losing the distinction the value_kind tag exists to preserve.
405
+ if (dataType === "string")
406
+ return raw;
407
+ if (dataType === "boolean" || raw === "true" || raw === "false") {
408
+ if (raw === "true")
409
+ return true;
410
+ if (raw === "false")
411
+ return false;
412
+ }
413
+ const n = Number(raw);
414
+ // `Number("")` is 0 and `Number("12abc")` is NaN — only accept a clean parse,
415
+ // otherwise treat it as the string it is.
416
+ if (raw.trim() !== "" && Number.isFinite(n))
417
+ return n;
418
+ return raw;
419
+ }
420
+ /**
421
+ * A single import run. Holds its own cancellation flag and counters so the
422
+ * HTTP layer can poll it without the importer knowing about Express.
423
+ */
424
+ export class MigrationRun {
425
+ id;
426
+ url;
427
+ bucket;
428
+ state = "running";
429
+ startedAt = new Date().toISOString();
430
+ finishedAt;
431
+ error;
432
+ progress = {
433
+ read: 0,
434
+ written: 0,
435
+ skipped: 0,
436
+ measurementsDone: 0,
437
+ measurementsTotal: 0,
438
+ };
439
+ cancelled = false;
440
+ constructor(id, url, bucket) {
441
+ this.id = id;
442
+ this.url = url;
443
+ this.bucket = bucket;
444
+ }
445
+ cancel() {
446
+ this.cancelled = true;
447
+ }
448
+ get isCancelled() {
449
+ return this.cancelled;
450
+ }
451
+ }
452
+ /**
453
+ * Copy history from InfluxDB into QuestDB.
454
+ *
455
+ * Rows are written through the plugin's own ILP writer, so imported history is
456
+ * subject to exactly the same schema, dedup and retention rules as live data —
457
+ * and re-running an import is idempotent, because a row with the same
458
+ * (ts, path, context, source) upserts rather than duplicating.
459
+ */
460
+ export async function runMigration(req, writer, run, deps = {}) {
461
+ const fetchImpl = deps.fetchImpl ?? fetch;
462
+ const debug = deps.debug ?? (() => { });
463
+ const sleep = deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
464
+ const headers = authHeaders(req.type, req.auth);
465
+ // A non-positive window would make `start += windowMs` never advance and
466
+ // spin forever. Not reachable from the HTTP API (which does not expose the
467
+ // knob), but an infinite loop inside the Signal K process is severe enough
468
+ // to be worth one comparison.
469
+ const requestedWindow = req.windowMs ?? DEFAULT_WINDOW_MS;
470
+ const windowMs = requestedWindow > 0 ? requestedWindow : DEFAULT_WINDOW_MS;
471
+ const context = req.context;
472
+ const source = req.sourceLabel ?? "influxdb-import";
473
+ // The writer's drop counter is monotonic and shared with the live recorder,
474
+ // so only the delta across this run is attributable to it.
475
+ const droppedAtStart = writer.droppedLineCount ?? 0;
476
+ try {
477
+ // Checked BEFORE discovery: listMeasurements is a network round trip, and
478
+ // running it first means a bad range costs a request and — worse — any
479
+ // discovery failure masks the real problem, so the user is told "Cannot
480
+ // list measurements: HTTP 401" when the actual fault is an inverted range.
481
+ const fromMs = Date.parse(req.from);
482
+ const toMs = Date.parse(req.to);
483
+ if (Number.isNaN(fromMs) || Number.isNaN(toMs)) {
484
+ throw new Error("Invalid from/to range");
485
+ }
486
+ if (toMs <= fromMs) {
487
+ throw new Error("`to` must be after `from`");
488
+ }
489
+ let measurements = req.measurements ?? [];
490
+ if (measurements.length === 0) {
491
+ measurements = (await listMeasurements(req, fetchImpl)).map((m) => m.name);
492
+ // Nothing to import is worth SAYING. A run that ends "done, 0 written,
493
+ // 0/0 measurements" is visually identical to the discovery failure this
494
+ // module was just fixed for, so an operator who picked the wrong bucket
495
+ // — or whose token cannot see its contents — would read a silent
496
+ // success and have no idea why nothing arrived.
497
+ if (measurements.length === 0) {
498
+ throw new Error(`No measurements found in "${req.bucket}". The bucket may be empty, ` +
499
+ `or the token may not have read access to it.`);
500
+ }
501
+ }
502
+ run.progress.measurementsTotal = measurements.length;
503
+ // Wait for the writer to fall back to the resume mark.
504
+ //
505
+ // Two marks, not one: resuming at the high-water mark would park the
506
+ // buffer permanently just under the cap, where a single slow flush pushes
507
+ // it over. And the wait is bounded — `pendingLines` only falls when
508
+ // QuestDB actually accepts data, so a QuestDB that is down or wedged would
509
+ // otherwise spin here forever with the run frozen at "running".
510
+ const awaitDrain = async () => {
511
+ if (writer.pendingLines <= WRITER_HIGH_WATER)
512
+ return;
513
+ let waited = 0;
514
+ while (writer.pendingLines > WRITER_RESUME && !run.isCancelled) {
515
+ await sleep(200);
516
+ waited += 200;
517
+ if (waited >= WRITER_DRAIN_TIMEOUT_MS) {
518
+ throw new Error(`QuestDB is not accepting writes: ${writer.pendingLines} lines still queued after ` +
519
+ `${Math.round(WRITER_DRAIN_TIMEOUT_MS / 1000)}s. Import stopped — ` +
520
+ `re-run it once QuestDB is healthy (already-imported rows are not duplicated).`);
521
+ }
522
+ }
523
+ };
524
+ for (const measurement of measurements) {
525
+ if (run.isCancelled)
526
+ break;
527
+ run.progress.currentMeasurement = measurement;
528
+ for (let start = fromMs; start < toMs; start += windowMs) {
529
+ if (run.isCancelled)
530
+ break;
531
+ const end = Math.min(start + windowMs, toMs);
532
+ run.progress.currentWindowStart = new Date(start).toISOString();
533
+ // Backpressure: let the writer drain before reading more.
534
+ await awaitDrain();
535
+ const { rows, dropped } = req.type === "influxdb2"
536
+ ? await readWindowFlux(req, measurement, start, end, headers, fetchImpl)
537
+ : await readWindowInfluxQl(req, measurement, start, end, headers, fetchImpl);
538
+ // Half-pair positions never become rows, so they are counted here or
539
+ // not at all — otherwise read - written - skipped silently disagrees.
540
+ run.progress.read += dropped;
541
+ run.progress.skipped += dropped;
542
+ // Checked inside the row loop too, not only between windows: a single
543
+ // dense window can carry far more rows than the writer's cap on its
544
+ // own, and waiting only at the window boundary would let the buffer
545
+ // sail past MAX_BUFFER_LINES mid-window — dropping its OLDEST lines,
546
+ // which is exactly the silent data loss this guard exists to prevent.
547
+ let sinceDrainCheck = 0;
548
+ for (const row of rows) {
549
+ if (run.isCancelled)
550
+ break;
551
+ if (++sinceDrainCheck >= 1000) {
552
+ sinceDrainCheck = 0;
553
+ await awaitDrain();
554
+ }
555
+ run.progress.read++;
556
+ const written = writeRow(row, measurement, context, source, writer);
557
+ if (written)
558
+ run.progress.written++;
559
+ else
560
+ run.progress.skipped++;
561
+ }
562
+ }
563
+ run.progress.measurementsDone++;
564
+ debug(`migration: ${measurement} done (${run.progress.written} written, ${run.progress.skipped} skipped)`);
565
+ }
566
+ // The writer drops the OLDEST buffered lines when its cap is hit — while
567
+ // disconnected, or when QuestDB cannot keep up. Those rows were counted as
568
+ // written but never reached the database, so a run that ends there is NOT
569
+ // a clean success and must not be reported as one: the whole point of an
570
+ // import is knowing what actually landed.
571
+ const droppedByWriter = (writer.droppedLineCount ?? 0) - droppedAtStart;
572
+ if (droppedByWriter > 0) {
573
+ throw new Error(`QuestDB could not keep up: ${droppedByWriter} buffered rows were dropped before reaching the database. ` +
574
+ `Re-run the import once QuestDB is healthy (already-imported rows are not duplicated).`);
575
+ }
576
+ run.state = run.isCancelled ? "cancelled" : "done";
577
+ }
578
+ catch (err) {
579
+ run.state = "failed";
580
+ run.error = err instanceof Error ? err.message : String(err);
581
+ }
582
+ finally {
583
+ run.finishedAt = new Date().toISOString();
584
+ run.progress.currentMeasurement = undefined;
585
+ run.progress.currentWindowStart = undefined;
586
+ }
587
+ }
588
+ /**
589
+ * Map one source row onto the plugin's tables, reusing the live path's routing
590
+ * so imported data is classified exactly like recorded data.
591
+ *
592
+ * Returns false when the row could not be represented, which the caller counts
593
+ * as skipped.
594
+ */
595
+ function writeRow(row, measurement, context, source, writer) {
596
+ const path = toSignalKPath(measurement, row.field);
597
+ if (!path)
598
+ return false;
599
+ const route = routeDeltaValue(path, row.value);
600
+ switch (route) {
601
+ case "number":
602
+ writer.writeAtNanos(path, context, row.value, row.tsNanos, source);
603
+ return true;
604
+ case "string":
605
+ writer.writeStringAtNanos(path, context, row.value, row.tsNanos, undefined, source);
606
+ return true;
607
+ case "boolean":
608
+ writer.writeStringAtNanos(path, context, String(row.value), row.tsNanos, "boolean", source);
609
+ return true;
610
+ case "position": {
611
+ const v = row.value;
612
+ writer.writePositionAtNanos(context, v, row.tsNanos, source);
613
+ return true;
614
+ }
615
+ case "flatten": {
616
+ // An object value stored in InfluxDB is unusual but not impossible
617
+ // (position written as a single field). Record its scalar leaves, the
618
+ // same as the live path does. A leaf may be a string or boolean, not
619
+ // just a number, so each one is routed on its own type — writing them
620
+ // all as numbers would put "true" into the numeric column.
621
+ const { leaves } = flattenObjectValue(path, row.value);
622
+ let any = false;
623
+ for (const leaf of leaves) {
624
+ if (typeof leaf.value === "number") {
625
+ writer.writeAtNanos(leaf.path, context, leaf.value, row.tsNanos, source);
626
+ }
627
+ else if (typeof leaf.value === "boolean") {
628
+ writer.writeStringAtNanos(leaf.path, context, String(leaf.value), row.tsNanos, "boolean", source);
629
+ }
630
+ else {
631
+ writer.writeStringAtNanos(leaf.path, context, leaf.value, row.tsNanos, undefined, source);
632
+ }
633
+ any = true;
634
+ }
635
+ return any;
636
+ }
637
+ default:
638
+ return false;
639
+ }
640
+ }
641
+ /**
642
+ * Read one time window of a measurement from InfluxDB 2.x.
643
+ *
644
+ * `navigation.position` is stored by the Signal K plugins as two fields on one
645
+ * measurement, so the rows are pivoted back into a single object value —
646
+ * otherwise latitude and longitude would land as two meaningless numeric paths
647
+ * instead of a position.
648
+ */
649
+ async function readWindowFlux(req, measurement, startMs, endMs, headers, fetchImpl) {
650
+ const flux = `from(bucket: ${JSON.stringify(req.bucket)})
651
+ |> range(start: ${new Date(startMs).toISOString()}, stop: ${new Date(endMs).toISOString()})
652
+ |> filter(fn: (r) => r._measurement == ${JSON.stringify(measurement)})
653
+ |> keep(columns: ["_time", "_field", "_value"])`;
654
+ const records = await runFlux(req, flux, headers, fetchImpl);
655
+ const rows = [];
656
+ // A record with no usable timestamp cannot be placed in time, so it is not
657
+ // importable — but it IS reported. The module's contract is that nothing is
658
+ // dropped silently, and a `read` total that quietly excludes these makes an
659
+ // import look smaller than the source rather than showing what was lost.
660
+ let unusable = 0;
661
+ for (const rec of records) {
662
+ const t = rec.values["_time"];
663
+ if (!t) {
664
+ unusable++;
665
+ continue;
666
+ }
667
+ const tsNanos = rfc3339ToNanos(t);
668
+ if (tsNanos === null) {
669
+ unusable++;
670
+ continue;
671
+ }
672
+ rows.push({
673
+ tsNanos,
674
+ field: rec.values["_field"] ?? "value",
675
+ // The declared type decides: a genuine string "3.5" must stay a string
676
+ // rather than being parsed into the numeric table.
677
+ value: coerceValue(rec.values["_value"] ?? "", rec.types["_value"]),
678
+ });
679
+ }
680
+ const merged = mergePositionRows(measurement, rows);
681
+ return { rows: merged.rows, dropped: merged.dropped + unusable };
682
+ }
683
+ /** Read one time window of a measurement from InfluxDB 1.x. */
684
+ async function readWindowInfluxQl(req, measurement, startMs, endMs, headers, fetchImpl) {
685
+ // Measurement names come from SHOW MEASUREMENTS on this same server, but
686
+ // they still land inside a query string — quote them as identifiers.
687
+ //
688
+ // Backslashes are escaped FIRST, then quotes. The other order is a bypass:
689
+ // escaping only quotes leaves a name ending in `\` turning its own
690
+ // backslash into the escape for the closing delimiter, so the identifier
691
+ // ends early and the rest of the name becomes query text.
692
+ const quoted = `"${measurement.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
693
+ // `*::field`, not `*`. A bare `SELECT *` returns TAG columns alongside the
694
+ // fields — verified against 1.8.10 — and the reader treats every non-time
695
+ // column as a field, so a measurement tagged `source=n2k` would import a
696
+ // bogus `measurement.source = "n2k"` string path for every single point.
697
+ // Signal K's own InfluxDB writers tag their points, so this is the common
698
+ // case, not an exotic one.
699
+ const q = `SELECT *::field FROM ${quoted} WHERE time >= ${BigInt(startMs) * NANOS_PER_MS} AND time < ${BigInt(endMs) * NANOS_PER_MS}`;
700
+ const url = `${req.url}/query?db=${encodeURIComponent(req.bucket)}&epoch=ns&q=${encodeURIComponent(q)}`;
701
+ const r = await fetchImpl(url, {
702
+ headers,
703
+ signal: AbortSignal.timeout(120_000),
704
+ });
705
+ if (!r.ok)
706
+ throw new Error(await describeHttpError(r, "read data"));
707
+ const body = (await r.json());
708
+ if (body.results?.[0]?.error)
709
+ throw new Error(body.results[0].error);
710
+ const series = body.results?.[0]?.series ?? [];
711
+ const rows = [];
712
+ // See readWindowFlux: values that cannot be imported are reported, not
713
+ // dropped in silence.
714
+ let unusable = 0;
715
+ for (const s of series) {
716
+ const columns = s.columns ?? [];
717
+ const timeIdx = columns.indexOf("time");
718
+ if (timeIdx < 0)
719
+ continue;
720
+ for (const values of s.values ?? []) {
721
+ // epoch=ns returns the time as a number; going through BigInt(String)
722
+ // avoids the precision loss a float64 would suffer past 2^53 ns.
723
+ const rawTime = values[timeIdx];
724
+ let tsNanos;
725
+ try {
726
+ tsNanos = BigInt(String(rawTime));
727
+ }
728
+ catch {
729
+ // No usable timestamp: reported rather than dropped in silence.
730
+ unusable++;
731
+ continue;
732
+ }
733
+ for (let i = 0; i < columns.length; i++) {
734
+ if (i === timeIdx)
735
+ continue;
736
+ const raw = values[i];
737
+ // A wide row has a column per field; nulls are fields absent from
738
+ // this point, not values.
739
+ if (raw === null || raw === undefined)
740
+ continue;
741
+ // The JSON type IS the declared type here — InfluxQL returns a string
742
+ // field as a JSON string, a float as a JSON number and a boolean as a
743
+ // JSON boolean (verified against 1.8.10). That is unlike the 2.x CSV
744
+ // path, where every cell is text and `#datatype` is the only
745
+ // discriminator, so coerceValue is needed there and actively harmful
746
+ // here: re-parsing put a genuine string "3.5" into the NUMERIC table
747
+ // and recorded a literal "true" as a boolean, in both cases making
748
+ // imported values indistinguishable from real numbers and booleans.
749
+ //
750
+ // An empty string is still mapped to "no reading" rather than being
751
+ // recorded as an empty value, matching coerceValue's own handling —
752
+ // and counted, so the totals account for it.
753
+ if (raw === "") {
754
+ unusable++;
755
+ continue;
756
+ }
757
+ rows.push({
758
+ tsNanos,
759
+ field: columns[i],
760
+ value: raw,
761
+ });
762
+ }
763
+ }
764
+ }
765
+ const merged = mergePositionRows(measurement, rows);
766
+ return { rows: merged.rows, dropped: merged.dropped + unusable };
767
+ }
768
+ /**
769
+ * Recombine latitude/longitude fields of a position measurement into one
770
+ * object value at the same instant.
771
+ *
772
+ * The Signal K InfluxDB plugins store navigation.position as two fields
773
+ * (`latitude`/`longitude`, or `lat`/`lon`). Left as-is they would import as
774
+ * two numeric paths and the position history would be unusable — QuestDB keeps
775
+ * positions in their own table.
776
+ */
777
+ export function mergePositionRows(measurement, rows) {
778
+ if (!measurement.includes("position"))
779
+ return { rows, dropped: 0 };
780
+ const latKeys = new Set(["latitude", "lat"]);
781
+ const lonKeys = new Set(["longitude", "lon", "lng"]);
782
+ const byTime = new Map();
783
+ const passthrough = [];
784
+ for (const row of rows) {
785
+ const key = row.tsNanos.toString();
786
+ if (latKeys.has(row.field) && typeof row.value === "number") {
787
+ if (!byTime.has(key))
788
+ byTime.set(key, {});
789
+ byTime.get(key).lat = row.value;
790
+ }
791
+ else if (lonKeys.has(row.field) && typeof row.value === "number") {
792
+ if (!byTime.has(key))
793
+ byTime.set(key, {});
794
+ byTime.get(key).lon = row.value;
795
+ }
796
+ else {
797
+ passthrough.push(row);
798
+ }
799
+ }
800
+ const merged = [];
801
+ let dropped = 0;
802
+ for (const [key, { lat, lon }] of byTime) {
803
+ // A half-pair (lat with no lon at the same instant) is not a position.
804
+ // Emitting it as a bare number would be worse than skipping it, so it is
805
+ // dropped — and REPORTED, so it lands in the run's skipped total instead
806
+ // of vanishing between the read count and the written count.
807
+ if (lat === undefined || lon === undefined) {
808
+ dropped++;
809
+ continue;
810
+ }
811
+ merged.push({
812
+ tsNanos: BigInt(key),
813
+ field: "value",
814
+ value: { latitude: lat, longitude: lon },
815
+ });
816
+ }
817
+ return { rows: [...passthrough, ...merged], dropped };
818
+ }
819
+ //# sourceMappingURL=migration.js.map