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.
- package/.coderabbit.yaml +4 -3
- package/README.md +151 -5
- package/dist/api-contract.d.ts +62 -0
- package/dist/config/schema.d.ts +1 -0
- package/dist/config/schema.js +5 -0
- package/dist/config/schema.js.map +1 -1
- package/dist/configpanel/responses.d.ts +33 -1
- package/dist/configpanel/responses.js +89 -0
- package/dist/configpanel/responses.js.map +1 -1
- package/dist/history-v1.js +12 -9
- package/dist/history-v1.js.map +1 -1
- package/dist/history-v2.d.ts +18 -6
- package/dist/history-v2.js +275 -41
- package/dist/history-v2.js.map +1 -1
- package/dist/ilp-writer.d.ts +22 -0
- package/dist/ilp-writer.js +76 -4
- package/dist/ilp-writer.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +326 -58
- package/dist/index.js.map +1 -1
- package/dist/influx-detect.d.ts +19 -0
- package/dist/influx-detect.js +165 -0
- package/dist/influx-detect.js.map +1 -0
- package/dist/migration.d.ts +147 -0
- package/dist/migration.js +819 -0
- package/dist/migration.js.map +1 -0
- package/dist/query-client.d.ts +9 -0
- package/dist/query-client.js +11 -0
- package/dist/query-client.js.map +1 -1
- package/dist/restore.js +9 -0
- package/dist/restore.js.map +1 -1
- package/dist/schema-errors.d.ts +15 -0
- package/dist/schema-errors.js +38 -0
- package/dist/schema-errors.js.map +1 -0
- package/package.json +5 -4
- package/public/assets/PluginConfigurationPanel-CR4mD9IH.js +9 -0
- package/public/assets/{index-Z7eUk0W6.js → index-CyI4zKUm.js} +1 -1
- package/public/assets/{virtualExposes-CyrNon4j.js → virtualExposes-BBIkbTtD.js} +1 -1
- package/public/remoteEntry.js +1 -1
- 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 -108
- 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 -563
- 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 -391
- package/dist/test/ilp-writer.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 -496
- 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
- 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
|