signalk-questdb 2.1.4 → 2.1.5

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 (45) hide show
  1. package/README.md +112 -27
  2. package/dist/api-contract.d.ts +45 -0
  3. package/dist/configpanel/responses.d.ts +28 -1
  4. package/dist/configpanel/responses.js +74 -0
  5. package/dist/configpanel/responses.js.map +1 -1
  6. package/dist/ilp-writer.d.ts +52 -0
  7. package/dist/ilp-writer.js +88 -7
  8. package/dist/ilp-writer.js.map +1 -1
  9. package/dist/index.js +477 -45
  10. package/dist/index.js.map +1 -1
  11. package/dist/migration-checkpoint.d.ts +170 -0
  12. package/dist/migration-checkpoint.js +276 -0
  13. package/dist/migration-checkpoint.js.map +1 -0
  14. package/dist/migration-cleanup.d.ts +48 -0
  15. package/dist/migration-cleanup.js +155 -0
  16. package/dist/migration-cleanup.js.map +1 -0
  17. package/dist/migration.d.ts +134 -17
  18. package/dist/migration.js +694 -158
  19. package/dist/migration.js.map +1 -1
  20. package/dist/questdb-mount.d.ts +106 -0
  21. package/dist/questdb-mount.js +173 -0
  22. package/dist/questdb-mount.js.map +1 -0
  23. package/dist/restore.js +44 -8
  24. package/dist/restore.js.map +1 -1
  25. package/package.json +2 -2
  26. package/public/assets/AppPanel-p27aQlBS.js +1 -0
  27. package/public/assets/PluginConfigurationPanel-BO_4OxBo.js +9 -0
  28. package/public/assets/_virtual_mf-localSharedImportMap___mfe_internal__signalk_questdb__mf_owner__1-BiBqFhVp.js +1 -0
  29. package/public/assets/_virtual_mf___mfe_internal__signalk_questdb__mf_owner__275086164343916__loadShare__react__loadShare__.js-BWO9MGdR.js +1 -0
  30. package/public/assets/_virtual_mf___mfe_internal__signalk_questdb__mf_owner__275086164343916__prebuild__react__prebuild__-D2g10OtU.js +1 -0
  31. package/public/assets/dist-BK97MX24.js +1 -0
  32. package/public/assets/helpers-Bizl8Raj.js +1 -0
  33. package/public/assets/hostInit-BFZIDSUI.js +1 -0
  34. package/public/assets/index-bERRzqQZ.js +2 -0
  35. package/public/assets/pendingShares-BS4kO2ec.js +2 -0
  36. package/public/assets/{dist-BgxWXJ5y.js → utils-jR9fY54i.js} +2 -2
  37. package/public/assets/{vite-preload-helper-HclGiUj8.js → vite-preload-helper-uBIymjUX.js} +1 -1
  38. package/public/remoteEntry.js +2 -2
  39. package/public/assets/AppPanel-CNFtJpeq.js +0 -1
  40. package/public/assets/PluginConfigurationPanel-CR4mD9IH.js +0 -9
  41. package/public/assets/_virtual_mf-localSharedImportMap___mfe_internal__signalk_questdb__mf_owner__1-7tb3Q-Bx.js +0 -1
  42. package/public/assets/_virtual_mf___mfe_internal__signalk_questdb__mf_owner__1__loadShare__react__loadShare__.js-DzhF4wXp.js +0 -1
  43. package/public/assets/hostInit-CyS9_M1c.js +0 -1
  44. package/public/assets/index-Dh-t5VMR.js +0 -1
  45. package/public/assets/virtualExposes-BBIkbTtD.js +0 -1
package/dist/migration.js CHANGED
@@ -8,18 +8,21 @@
8
8
  // dropped: a migration that quietly loses a third of the history looks
9
9
  // identical to one that worked.
10
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.
11
+ // Reads are windowed by time, and each window is streamed: read a batch, write
12
+ // it, release it, read the next. A boat's InfluxDB is routinely larger than the
13
+ // Pi's RAM, and a single dense day of one path can be too — so nothing here
14
+ // holds a whole response, and reading stops while QuestDB is catching up. A
15
+ // window that returns nothing still advances, so a multi-year gap costs one
16
+ // empty query per window rather than a stall.
15
17
  import { routeDeltaValue, flattenObjectValue } from "./delta-routing.js";
18
+ import { CheckpointTracker, migrationIdentity, sameIdentity, } from "./migration-checkpoint.js";
16
19
  /** Nanoseconds per millisecond, the unit gap between `Date` and ILP. */
17
20
  const NANOS_PER_MS = 1000000n;
18
21
  /**
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.
22
+ * How much time one query covers. Large enough that a sparse measurement does
23
+ * not need thousands of round trips, small enough that one query stays cheap
24
+ * for InfluxDB and progress moves in visible steps. It does not bound memory:
25
+ * a window is streamed in batches, however many points it holds.
23
26
  */
24
27
  export const DEFAULT_WINDOW_MS = 24 * 60 * 60 * 1000;
25
28
  /**
@@ -193,6 +196,49 @@ schema.measurements(bucket: ${JSON.stringify(req.bucket)}, start: 1970-01-01T00:
193
196
  const values = body2.results?.[0]?.series?.[0]?.values ?? [];
194
197
  return values.map((row) => ({ name: String(row[0]), fields: [] }));
195
198
  }
199
+ /**
200
+ * The `context` tag values in a bucket/database: the vessels the source holds.
201
+ * Both Signal K InfluxDB writers tag every point with the delta's context, so
202
+ * this is how the source's own vessel is told from the AIS targets it recorded.
203
+ * A source with no such tag — a non-Signal K schema — returns none.
204
+ */
205
+ export async function listContexts(req, fetchImpl = fetch) {
206
+ const headers = authHeaders(req.type, req.auth);
207
+ if (req.type === "influxdb2") {
208
+ const flux = `import "influxdata/influxdb/schema"
209
+ schema.tagValues(bucket: ${JSON.stringify(req.bucket)}, tag: "context", start: 1970-01-01T00:00:00Z)`;
210
+ const rows = await runFlux(req, flux, headers, fetchImpl);
211
+ return [
212
+ ...new Set(rows.map((r) => r.values["_value"]).filter((v) => !!v)),
213
+ ].sort();
214
+ }
215
+ // Answered per measurement, each series a list of [key, value] rows.
216
+ const url = `${req.url}/query?db=${encodeURIComponent(req.bucket)}&q=${encodeURIComponent('SHOW TAG VALUES WITH KEY = "context"')}`;
217
+ const r = await fetchImpl(url, {
218
+ headers,
219
+ signal: AbortSignal.timeout(15000),
220
+ });
221
+ if (!r.ok)
222
+ throw new Error(await describeHttpError(r, "list contexts"));
223
+ const body = (await r.json());
224
+ if (body.results?.[0]?.error)
225
+ throw new Error(body.results[0].error);
226
+ const values = new Set();
227
+ for (const s of body.results?.[0]?.series ?? []) {
228
+ for (const row of s.values ?? []) {
229
+ if (typeof row[1] === "string" && row[1])
230
+ values.add(row[1]);
231
+ }
232
+ }
233
+ return [...values].sort();
234
+ }
235
+ /**
236
+ * Whether a string is a Signal K context this plugin will file history under:
237
+ * a vessel, aton, aircraft or SAR target with a well-formed identifier.
238
+ */
239
+ export function isSignalKContext(context) {
240
+ return /^(vessels|atons|aircraft|sar)\.[A-Za-z0-9:._-]+$/.test(context);
241
+ }
196
242
  async function describeHttpError(r, what) {
197
243
  const text = await r.text().catch(() => "");
198
244
  // 401/403 against InfluxDB almost always means a missing or wrong token,
@@ -211,108 +257,234 @@ async function describeHttpError(r, what) {
211
257
  * one POST and a CSV parse.
212
258
  */
213
259
  async function runFlux(req, flux, headers, fetchImpl, timeoutMs = 120_000) {
260
+ const r = await fetchImpl(fluxQueryUrl(req), {
261
+ ...fluxQueryInit(flux, headers),
262
+ signal: AbortSignal.timeout(timeoutMs),
263
+ });
264
+ if (!r.ok)
265
+ throw new Error(await describeHttpError(r, "run query"));
266
+ return parseAnnotatedCsv(await r.text());
267
+ }
268
+ function fluxQueryUrl(req) {
214
269
  const org = req.auth?.org ?? "";
215
- const url = `${req.url}/api/v2/query${org ? `?org=${encodeURIComponent(org)}` : ""}`;
216
- const r = await fetchImpl(url, {
270
+ return `${req.url}/api/v2/query${org ? `?org=${encodeURIComponent(org)}` : ""}`;
271
+ }
272
+ /**
273
+ * A Flux query as a request.
274
+ *
275
+ * Sent as JSON, not as a raw `application/vnd.flux` body, because only the JSON
276
+ * form can carry a dialect — and without one InfluxDB answers with bare CSV, no
277
+ * annotation lines at all (verified against a live 2.7.12). The reader needs
278
+ * two of them: `#datatype`, without which a string field holding "3.5" is
279
+ * indistinguishable from the number and lands in the numeric table, and
280
+ * `#group`, which is how a pivoted record's tags are told from its fields.
281
+ */
282
+ function fluxQueryInit(flux, headers) {
283
+ return {
217
284
  method: "POST",
218
285
  headers: {
219
286
  ...headers,
220
- "Content-Type": "application/vnd.flux",
287
+ "Content-Type": "application/json",
221
288
  Accept: "text/csv",
222
289
  },
223
- body: flux,
224
- signal: AbortSignal.timeout(timeoutMs),
290
+ body: JSON.stringify({
291
+ query: flux,
292
+ type: "flux",
293
+ dialect: { header: true, annotations: ["datatype", "group", "default"] },
294
+ }),
295
+ };
296
+ }
297
+ /**
298
+ * How long InfluxDB may stay silent while a read is outstanding.
299
+ *
300
+ * Not a limit on the whole request: a streamed window is read only as fast as
301
+ * QuestDB accepts it, so a dense one legitimately stays open for many minutes
302
+ * while the writer drains. The clock runs only while this side is waiting for
303
+ * bytes it has asked for.
304
+ */
305
+ const READ_IDLE_TIMEOUT_MS = 120_000;
306
+ /**
307
+ * Run a request and yield its response body one physical line at a time.
308
+ *
309
+ * The body is never held whole — a dense day of one measurement runs to
310
+ * hundreds of megabytes of CSV, and a heap exhausted by it cannot be caught:
311
+ * it ends the Signal K process. Reading is demand-driven, so a consumer that
312
+ * pauses (the import, waiting for the writer to drain) stops the reads, and
313
+ * TCP flow control carries that back to InfluxDB.
314
+ *
315
+ * Stopping early — cancelling a run — aborts the request rather than leaving
316
+ * InfluxDB streaming into a socket nobody reads.
317
+ */
318
+ async function* streamLines(fetchImpl, url, init, what, idleTimeoutMs) {
319
+ const controller = new AbortController();
320
+ // Rejects on its own clock rather than waiting for the abort to surface
321
+ // through the pending step: the abort is what frees the connection, but the
322
+ // run must fail on time even if the step never notices it.
323
+ const guarded = (step) => new Promise((resolve, reject) => {
324
+ const timer = setTimeout(() => {
325
+ controller.abort();
326
+ reject(new Error(`Cannot ${what}: InfluxDB sent no data for ${Math.round(idleTimeoutMs / 1000)}s`));
327
+ }, idleTimeoutMs);
328
+ step().then((value) => {
329
+ clearTimeout(timer);
330
+ resolve(value);
331
+ }, (err) => {
332
+ clearTimeout(timer);
333
+ reject(err instanceof Error ? err : new Error(String(err)));
334
+ });
225
335
  });
336
+ const r = await guarded(() => fetchImpl(url, { ...init, signal: controller.signal }));
226
337
  if (!r.ok)
227
- throw new Error(await describeHttpError(r, "run query"));
228
- return parseAnnotatedCsv(await r.text());
338
+ throw new Error(await guarded(() => describeHttpError(r, what)));
339
+ if (!r.body)
340
+ return;
341
+ const reader = r.body.getReader();
342
+ const decoder = new TextDecoder();
343
+ let carry = "";
344
+ try {
345
+ for (;;) {
346
+ const { done, value } = await guarded(() => reader.read());
347
+ if (done)
348
+ break;
349
+ carry += decoder.decode(value, { stream: true });
350
+ let from = 0;
351
+ for (let nl = carry.indexOf("\n"); nl >= 0; nl = carry.indexOf("\n", from)) {
352
+ yield carry.slice(from, nl);
353
+ from = nl + 1;
354
+ }
355
+ carry = carry.slice(from);
356
+ }
357
+ carry += decoder.decode();
358
+ if (carry !== "")
359
+ yield carry;
360
+ }
361
+ finally {
362
+ controller.abort();
363
+ await reader.cancel().catch(() => { });
364
+ }
229
365
  }
230
366
  /**
231
- * Minimal annotated-CSV reader for Flux responses.
367
+ * Incremental annotated-CSV reader for Flux responses.
368
+ *
369
+ * Fed one physical line at a time, so a response is never held whole: the
370
+ * caller streams the body through it and sees each record as it completes.
232
371
  *
233
372
  * Flux emits one or more *tables*, each preceded by annotation lines starting
234
373
  * with `#` and then a header row. A response can therefore change its column
235
374
  * layout partway through, which is why the header is tracked per block rather
236
375
  * than read once at the top.
237
376
  */
238
- export function parseAnnotatedCsv(text) {
239
- const out = [];
240
- let header = null;
377
+ export class AnnotatedCsvReader {
378
+ header = null;
241
379
  // Per-block `#datatype` line. Without it a genuine STRING value of "3.5"
242
380
  // is indistinguishable from the number 3.5 in the CSV body — verified
243
381
  // 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;
382
+ datatypes = null;
383
+ groups = null;
384
+ // Built once per table and shared by its records: a table can run to
385
+ // millions of rows, and a copy per record was most of what a window cost.
386
+ types = {};
387
+ grouped = new Set();
388
+ pending = null;
389
+ /**
390
+ * Feed one physical line, without its `\n`. Returns the record it
391
+ * completes, or null for annotations, headers, blanks and partial records.
392
+ *
393
+ * A quoted CSV value may contain a newline — verified against a live 2.9.1,
394
+ * which returns `"line1\r\nline2"` for a string value holding one. Splitting
395
+ * on newlines alone would tear that record in half and produce two garbage
396
+ * rows, so physical lines are joined until the quotes balance ("" inside a
397
+ * quoted value is an escaped quote and does not change the balance).
398
+ * \r is stripped per PHYSICAL line before joining: a quoted value spanning
399
+ * two CRLF lines would otherwise keep the \r of the first inside the joined
400
+ * value, so the imported string silently carries a stray carriage return.
401
+ */
402
+ push(physicalLine) {
403
+ const stripped = physicalLine.endsWith("\r")
404
+ ? physicalLine.slice(0, -1)
405
+ : physicalLine;
406
+ const line = this.pending === null ? stripped : `${this.pending}\n${stripped}`;
407
+ if (!quotesBalanced(line)) {
408
+ this.pending = line;
409
+ return null;
410
+ }
411
+ this.pending = null;
412
+ return this.logicalLine(line);
413
+ }
414
+ /** Call once after the last line. */
415
+ end() {
416
+ if (this.pending === null)
417
+ return null;
418
+ // Unterminated quote at EOF: emit what there is rather than dropping it.
419
+ const line = this.pending;
420
+ this.pending = null;
421
+ return this.logicalLine(line);
422
+ }
423
+ logicalLine(line) {
254
424
  if (line === "") {
255
425
  // Blank line separates tables; the next non-# line is a fresh header.
256
- header = null;
257
- datatypes = null;
258
- continue;
426
+ this.header = null;
427
+ this.datatypes = null;
428
+ this.groups = null;
429
+ return null;
259
430
  }
260
431
  if (line.startsWith("#")) {
261
432
  // Annotation (#datatype/#group/#default). A new annotation block means
262
433
  // the previous header no longer applies.
263
434
  if (line.startsWith("#datatype"))
264
- datatypes = splitCsvLine(line);
265
- header = null;
266
- continue;
435
+ this.datatypes = splitCsvLine(line);
436
+ else if (line.startsWith("#group"))
437
+ this.groups = splitCsvLine(line);
438
+ this.header = null;
439
+ return null;
267
440
  }
268
441
  const cells = splitCsvLine(line);
269
- if (!header) {
270
- header = cells;
271
- continue;
442
+ if (!this.header) {
443
+ this.header = cells;
444
+ const types = {};
445
+ const grouped = new Set();
446
+ for (let i = 0; i < cells.length; i++) {
447
+ const key = cells[i];
448
+ if (key === "")
449
+ continue;
450
+ // Annotations are positionally aligned with the header row.
451
+ if (this.datatypes?.[i])
452
+ types[key] = this.datatypes[i];
453
+ if (this.groups?.[i] === "true")
454
+ grouped.add(key);
455
+ }
456
+ this.types = types;
457
+ this.grouped = grouped;
458
+ return null;
272
459
  }
273
- const rec = {};
274
- const types = {};
275
- for (let i = 0; i < header.length; i++) {
276
- const key = header[i];
460
+ const values = {};
461
+ for (let i = 0; i < this.header.length; i++) {
462
+ const key = this.header[i];
277
463
  // Flux's leading empty column is the annotation gutter, not data.
278
464
  if (key === "")
279
465
  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];
466
+ values[key] = cells[i] ?? "";
284
467
  }
285
- out.push({ values: rec, types });
468
+ return { values, types: this.types, grouped: this.grouped };
286
469
  }
287
- return out;
288
470
  }
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) {
471
+ /** Parse a whole annotated-CSV response. For small results only. */
472
+ export function parseAnnotatedCsv(text) {
473
+ const reader = new AnnotatedCsvReader();
298
474
  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
- }
475
+ for (const line of text.split("\n")) {
476
+ const rec = reader.push(line);
477
+ if (rec)
478
+ out.push(rec);
309
479
  }
310
- // Unterminated quote at EOF: emit what there is rather than dropping it.
311
- if (pending !== null)
312
- out.push(pending);
480
+ const last = reader.end();
481
+ if (last)
482
+ out.push(last);
313
483
  return out;
314
484
  }
315
485
  function quotesBalanced(s) {
486
+ if (!s.includes('"'))
487
+ return true;
316
488
  let inQuotes = false;
317
489
  for (let i = 0; i < s.length; i++) {
318
490
  if (s[i] !== '"')
@@ -326,6 +498,8 @@ function quotesBalanced(s) {
326
498
  return !inQuotes;
327
499
  }
328
500
  function splitCsvLine(line) {
501
+ if (!line.includes('"'))
502
+ return line.split(",");
329
503
  const cells = [];
330
504
  let cur = "";
331
505
  let inQuotes = false;
@@ -382,19 +556,72 @@ export function rfc3339ToNanos(value) {
382
556
  const nanosPart = BigInt((frac + "000000000").slice(0, 9));
383
557
  return BigInt(baseMs) * NANOS_PER_MS + nanosPart;
384
558
  }
559
+ /**
560
+ * The field signalk-to-influxdb 1.x uses for a value that is neither a number,
561
+ * a string nor a boolean: the value itself, JSON-encoded. `navigation.position`
562
+ * is always stored this way; `lat`/`lon` fields exist only when that plugin's
563
+ * `separateLatLon` option is on, which it is not by default.
564
+ */
565
+ const JSON_VALUE_FIELD = "jsonValue";
566
+ /**
567
+ * Field names that hold the value of the measurement's own path.
568
+ *
569
+ * signalk-to-influxdb2 writes every value to `value`. signalk-to-influxdb 1.x
570
+ * picks the field by the value's type — `value` for numbers, then
571
+ * `stringValue`, `boolValue` and `jsonValue` — because an InfluxDB 1.x field
572
+ * cannot change type once it has been written.
573
+ */
574
+ const PATH_VALUE_FIELDS = new Set([
575
+ "value",
576
+ "",
577
+ "_value",
578
+ "stringValue",
579
+ "boolValue",
580
+ JSON_VALUE_FIELD,
581
+ ]);
385
582
  /**
386
583
  * The Signal K path a measurement+field pair represents.
387
584
  *
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.
585
+ * The Signal K InfluxDB plugins write the path as the measurement name and the
586
+ * value in one of PATH_VALUE_FIELDS. Anything else (a measurement with several
587
+ * named fields) is a non-Signal K schema, so the field name is appended to keep
588
+ * the two apart instead of overwriting one with the other.
392
589
  */
393
590
  export function toSignalKPath(measurement, field) {
394
- if (field === "value" || field === "" || field === "_value")
591
+ if (PATH_VALUE_FIELDS.has(field))
395
592
  return measurement;
396
593
  return `${measurement}.${field}`;
397
594
  }
595
+ /**
596
+ * Decode `jsonValue` fields back into the values they encode.
597
+ *
598
+ * Left as the string it arrives as, a position would be recorded in
599
+ * `signalk_str` as text and `signalk_position` would stay empty. Decoded, it
600
+ * routes exactly like the live value did: a position to the position table,
601
+ * any other object to its scalar leaves.
602
+ *
603
+ * A value that does not parse cannot be represented, so it is dropped — and
604
+ * reported, so it lands in the run's skipped total.
605
+ */
606
+ export function decodeJsonValueRows(rows) {
607
+ if (!rows.some((row) => row.field === JSON_VALUE_FIELD))
608
+ return { rows, dropped: 0 };
609
+ let dropped = 0;
610
+ const decoded = [];
611
+ for (const row of rows) {
612
+ if (row.field !== JSON_VALUE_FIELD || typeof row.value !== "string") {
613
+ decoded.push(row);
614
+ continue;
615
+ }
616
+ try {
617
+ decoded.push({ ...row, value: JSON.parse(row.value) });
618
+ }
619
+ catch {
620
+ dropped++;
621
+ }
622
+ }
623
+ return { rows: decoded, dropped };
624
+ }
398
625
  /** Coerce an InfluxDB field value (always text over CSV) to a JS value. */
399
626
  export function coerceValue(raw, dataType) {
400
627
  if (raw === "")
@@ -429,6 +656,7 @@ export class MigrationRun {
429
656
  startedAt = new Date().toISOString();
430
657
  finishedAt;
431
658
  error;
659
+ resumedFrom;
432
660
  progress = {
433
661
  read: 0,
434
662
  written: 0,
@@ -462,6 +690,7 @@ export async function runMigration(req, writer, run, deps = {}) {
462
690
  const debug = deps.debug ?? (() => { });
463
691
  const sleep = deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
464
692
  const headers = authHeaders(req.type, req.auth);
693
+ const readIdleTimeoutMs = deps.readIdleTimeoutMs ?? READ_IDLE_TIMEOUT_MS;
465
694
  // A non-positive window would make `start += windowMs` never advance and
466
695
  // spin forever. Not reachable from the HTTP API (which does not expose the
467
696
  // knob), but an infinite loop inside the Signal K process is severe enough
@@ -469,10 +698,39 @@ export async function runMigration(req, writer, run, deps = {}) {
469
698
  const requestedWindow = req.windowMs ?? DEFAULT_WINDOW_MS;
470
699
  const windowMs = requestedWindow > 0 ? requestedWindow : DEFAULT_WINDOW_MS;
471
700
  const context = req.context;
701
+ const sourceSelf = req.sourceSelfContext;
702
+ const others = req.others ?? "keep";
472
703
  const source = req.sourceLabel ?? "influxdb-import";
473
704
  // The writer's drop counter is monotonic and shared with the live recorder,
474
705
  // so only the delta across this run is attributable to it.
475
706
  const droppedAtStart = writer.droppedLineCount ?? 0;
707
+ // The writer drops the OLDEST buffered lines when its cap is hit — while
708
+ // disconnected, or when QuestDB cannot keep up. Those rows were counted as
709
+ // written but never reached the database, so the run must not go on as if
710
+ // they had, and must not end as a success: the whole point of an import is
711
+ // knowing what actually landed.
712
+ //
713
+ // Checked before every row is written, not only at the end, and that is
714
+ // load-bearing for resume. A run that stops at its first drop never writes
715
+ // anything after a gap, so a window's last row, wherever it came from, is
716
+ // only ever in QuestDB with the whole window before it — which is what lets
717
+ // a saved position be confirmed by reading that row back, even one an
718
+ // earlier run wrote. Before the write rather than after: a drop registered
719
+ // while the run waited for the writer would otherwise be followed by one
720
+ // more row.
721
+ const failOnDrops = () => {
722
+ const droppedByWriter = (writer.droppedLineCount ?? 0) - droppedAtStart;
723
+ if (droppedByWriter > 0) {
724
+ throw new Error(`QuestDB could not keep up: ${droppedByWriter} buffered rows were dropped before reaching the database. ` +
725
+ `Re-run the import once QuestDB is healthy (already-imported rows are not duplicated).`);
726
+ }
727
+ // A write into a full buffer would itself cause the drop — and be the
728
+ // row behind the gap. Refused instead, before it is enqueued.
729
+ if (writer.atCapacity) {
730
+ throw new Error(`QuestDB could not keep up: the writer's buffer is full. ` +
731
+ `Re-run the import once QuestDB is healthy (already-imported rows are not duplicated).`);
732
+ }
733
+ };
476
734
  try {
477
735
  // Checked BEFORE discovery: listMeasurements is a network round trip, and
478
736
  // running it first means a bad range costs a request and — worse — any
@@ -486,6 +744,24 @@ export async function runMigration(req, writer, run, deps = {}) {
486
744
  if (toMs <= fromMs) {
487
745
  throw new Error("`to` must be after `from`");
488
746
  }
747
+ // Before the first await, so a caller that started this run without
748
+ // awaiting it — the HTTP handler — already sees it as resumed, with the
749
+ // earlier totals, in the response it sends straight away.
750
+ const now = deps.now ?? Date.now;
751
+ const identity = migrationIdentity(req, windowMs);
752
+ const resume = deps.resumeFrom && sameIdentity(deps.resumeFrom.identity, identity)
753
+ ? deps.resumeFrom
754
+ : undefined;
755
+ const done = new Set(resume?.done ?? []);
756
+ if (resume) {
757
+ run.progress.read = resume.progress.read;
758
+ run.progress.written = resume.progress.written;
759
+ run.progress.skipped = resume.progress.skipped;
760
+ run.resumedFrom = {
761
+ measurement: resume.current?.measurement,
762
+ windowStart: resume.current && new Date(resume.current.windowStart).toISOString(),
763
+ };
764
+ }
489
765
  let measurements = req.measurements ?? [];
490
766
  if (measurements.length === 0) {
491
767
  measurements = (await listMeasurements(req, fetchImpl)).map((m) => m.name);
@@ -500,6 +776,33 @@ export async function runMigration(req, writer, run, deps = {}) {
500
776
  }
501
777
  }
502
778
  run.progress.measurementsTotal = measurements.length;
779
+ const tracker = deps.checkpoints
780
+ ? new CheckpointTracker(deps.checkpoints, writer, deps.checkpointLagMs, now, deps.confirmStored)
781
+ : undefined;
782
+ // The newest row written to each table, kept up to date by writeRow: what
783
+ // a position offered after it is confirmed against.
784
+ const tail = { source };
785
+ // A checkpoint that cannot be written costs a later resume, nothing more.
786
+ // It must not cost the import that is running.
787
+ const offerCheckpoint = async (current) => {
788
+ try {
789
+ await tracker?.offer(() => ({
790
+ version: 1,
791
+ identity,
792
+ done: [...done],
793
+ current,
794
+ progress: {
795
+ read: run.progress.read,
796
+ written: run.progress.written,
797
+ skipped: run.progress.skipped,
798
+ },
799
+ updatedAt: new Date(now()).toISOString(),
800
+ }), tail);
801
+ }
802
+ catch (err) {
803
+ debug(`migration: checkpoint not written: ${err instanceof Error ? err.message : String(err)}`);
804
+ }
805
+ };
503
806
  // Wait for the writer to fall back to the resume mark.
504
807
  //
505
808
  // Two marks, not one: resuming at the high-water mark would park the
@@ -524,56 +827,126 @@ export async function runMigration(req, writer, run, deps = {}) {
524
827
  for (const measurement of measurements) {
525
828
  if (run.isCancelled)
526
829
  break;
830
+ if (done.has(measurement)) {
831
+ run.progress.measurementsDone++;
832
+ continue;
833
+ }
527
834
  run.progress.currentMeasurement = measurement;
528
- for (let start = fromMs; start < toMs; start += windowMs) {
835
+ // Only a start this run would itself arrive at: anything else in the
836
+ // file is not a position of this import, and the measurement is read
837
+ // from its beginning instead.
838
+ let firstStart = fromMs;
839
+ const resumeAt = resume?.current?.measurement === measurement
840
+ ? resume.current.windowStart
841
+ : undefined;
842
+ if (resumeAt !== undefined &&
843
+ resumeAt > fromMs &&
844
+ resumeAt < toMs &&
845
+ (resumeAt - fromMs) % windowMs === 0) {
846
+ firstStart = resumeAt;
847
+ }
848
+ for (let start = firstStart; start < toMs; start += windowMs) {
529
849
  if (run.isCancelled)
530
850
  break;
531
851
  const end = Math.min(start + windowMs, toMs);
532
852
  run.progress.currentWindowStart = new Date(start).toISOString();
533
853
  // Backpressure: let the writer drain before reading more.
534
854
  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.
855
+ const batches = req.type === "influxdb2"
856
+ ? readWindowFlux(req, measurement, start, end, headers, fetchImpl, readIdleTimeoutMs)
857
+ : readWindowInfluxQl(req, measurement, start, end, headers, fetchImpl, readIdleTimeoutMs);
858
+ // The window is read a batch at a time and each batch is written
859
+ // before the next is asked for, so what is held never grows with the
860
+ // window. Leaving the loop early — a cancel — ends the read as well.
547
861
  let sinceDrainCheck = 0;
548
- for (const row of rows) {
862
+ for await (const { rows, dropped } of batches) {
549
863
  if (run.isCancelled)
550
864
  break;
551
- if (++sinceDrainCheck >= 1000) {
552
- sinceDrainCheck = 0;
553
- await awaitDrain();
865
+ // Half-pair positions never become rows, so they are counted here or
866
+ // not at all — otherwise read - written - skipped silently disagrees.
867
+ run.progress.read += dropped;
868
+ run.progress.skipped += dropped;
869
+ // Checked inside the row loop too, not only between batches: waiting
870
+ // only at a boundary would let the buffer sail past
871
+ // MAX_BUFFER_LINES mid-batch — dropping its OLDEST lines, which is
872
+ // exactly the silent data loss this guard exists to prevent.
873
+ for (const row of rows) {
874
+ if (run.isCancelled)
875
+ break;
876
+ if (++sinceDrainCheck >= 1000) {
877
+ sinceDrainCheck = 0;
878
+ await awaitDrain();
879
+ }
880
+ failOnDrops();
881
+ run.progress.read++;
882
+ const target = targetContext(row.context, sourceSelf, context, others);
883
+ if (target === null) {
884
+ run.progress.skipped++;
885
+ continue;
886
+ }
887
+ const written = writeRow(row, measurement, target, source, writer, tail);
888
+ if (written)
889
+ run.progress.written++;
890
+ else
891
+ run.progress.skipped++;
554
892
  }
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++;
893
+ // Again after the batch, so a drop on its last row ends the run
894
+ // here rather than after the next window has been requested.
895
+ failOnDrops();
896
+ }
897
+ // A window left early is not an imported window.
898
+ if (run.isCancelled)
899
+ break;
900
+ if (start + windowMs < toMs) {
901
+ await offerCheckpoint({
902
+ measurement,
903
+ windowStart: start + windowMs,
904
+ });
561
905
  }
562
906
  }
907
+ if (run.isCancelled)
908
+ break;
909
+ done.add(measurement);
563
910
  run.progress.measurementsDone++;
911
+ await offerCheckpoint();
564
912
  debug(`migration: ${measurement} done (${run.progress.written} written, ${run.progress.skipped} skipped)`);
565
913
  }
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).`);
914
+ failOnDrops();
915
+ // "Done" is a claim about QuestDB, not about the writer. The last rows
916
+ // are still on their way when the loop ends, so wait for them to leave
917
+ // the writer and, where QuestDB can be asked, for it to read the newest
918
+ // of them back — the same standard a saved position is held to. A run
919
+ // that cannot get that within the drain timeout fails and keeps its
920
+ // checkpoint, so the last windows are done again rather than assumed.
921
+ if (!run.isCancelled) {
922
+ const mark = writer.enqueuedLineCount;
923
+ let waited = 0;
924
+ while (!run.isCancelled) {
925
+ failOnDrops();
926
+ const settled = mark === undefined ||
927
+ writer.settledLineCount === undefined ||
928
+ writer.settledLineCount + (writer.droppedLineCount ?? 0) >= mark;
929
+ const confirmed = settled &&
930
+ (!deps.confirmStored ||
931
+ (await deps.confirmStored(tail).catch(() => false)));
932
+ if (confirmed)
933
+ break;
934
+ if (waited >= WRITER_DRAIN_TIMEOUT_MS) {
935
+ throw new Error(`QuestDB has not confirmed the last rows after ${Math.round(WRITER_DRAIN_TIMEOUT_MS / 1000)}s. ` +
936
+ `Re-run the import once QuestDB is healthy (already-imported rows are not duplicated).`);
937
+ }
938
+ await sleep(200);
939
+ waited += 200;
940
+ }
575
941
  }
576
942
  run.state = run.isCancelled ? "cancelled" : "done";
943
+ // Nothing left to resume. Kept on a cancel or a failure, which is exactly
944
+ // when a later run wants it.
945
+ if (run.state === "done") {
946
+ await deps.checkpoints?.clear().catch((err) => {
947
+ debug(`migration: finished checkpoint not removed: ${err instanceof Error ? err.message : String(err)}`);
948
+ });
949
+ }
577
950
  }
578
951
  catch (err) {
579
952
  run.state = "failed";
@@ -585,6 +958,27 @@ export async function runMigration(req, writer, run, deps = {}) {
585
958
  run.progress.currentWindowStart = undefined;
586
959
  }
587
960
  }
961
+ /**
962
+ * The Signal K context a row is written under, or null for a row that is left
963
+ * out — counted as skipped by the caller.
964
+ *
965
+ * The source's own vessel becomes `self`, whether the point was tagged with
966
+ * the source's own context, with the literal `vessels.self`, or not at all.
967
+ * Any other tag is another vessel: kept under its own context if it is a
968
+ * well-formed Signal K one, since that is what the history API is queried by,
969
+ * or left out when other vessels are not wanted. With no `sourceSelf` given,
970
+ * every row is the own vessel — what a single-vessel source amounts to.
971
+ */
972
+ export function targetContext(rowContext, sourceSelf, self, others) {
973
+ if (rowContext === undefined ||
974
+ rowContext === "vessels.self" ||
975
+ rowContext === sourceSelf ||
976
+ sourceSelf === undefined)
977
+ return self;
978
+ if (others === "skip")
979
+ return null;
980
+ return isSignalKContext(rowContext) ? rowContext : null;
981
+ }
588
982
  /**
589
983
  * Map one source row onto the plugin's tables, reusing the live path's routing
590
984
  * so imported data is classified exactly like recorded data.
@@ -592,7 +986,7 @@ export async function runMigration(req, writer, run, deps = {}) {
592
986
  * Returns false when the row could not be represented, which the caller counts
593
987
  * as skipped.
594
988
  */
595
- function writeRow(row, measurement, context, source, writer) {
989
+ function writeRow(row, measurement, context, source, writer, tail) {
596
990
  const path = toSignalKPath(measurement, row.field);
597
991
  if (!path)
598
992
  return false;
@@ -600,16 +994,20 @@ function writeRow(row, measurement, context, source, writer) {
600
994
  switch (route) {
601
995
  case "number":
602
996
  writer.writeAtNanos(path, context, row.value, row.tsNanos, source);
997
+ tail.numeric = { path, context, tsNanos: row.tsNanos };
603
998
  return true;
604
999
  case "string":
605
1000
  writer.writeStringAtNanos(path, context, row.value, row.tsNanos, undefined, source);
1001
+ tail.string = { path, context, tsNanos: row.tsNanos };
606
1002
  return true;
607
1003
  case "boolean":
608
1004
  writer.writeStringAtNanos(path, context, String(row.value), row.tsNanos, "boolean", source);
1005
+ tail.string = { path, context, tsNanos: row.tsNanos };
609
1006
  return true;
610
1007
  case "position": {
611
1008
  const v = row.value;
612
1009
  writer.writePositionAtNanos(context, v, row.tsNanos, source);
1010
+ tail.position = { context, tsNanos: row.tsNanos };
613
1011
  return true;
614
1012
  }
615
1013
  case "flatten": {
@@ -623,12 +1021,15 @@ function writeRow(row, measurement, context, source, writer) {
623
1021
  for (const leaf of leaves) {
624
1022
  if (typeof leaf.value === "number") {
625
1023
  writer.writeAtNanos(leaf.path, context, leaf.value, row.tsNanos, source);
1024
+ tail.numeric = { path: leaf.path, context, tsNanos: row.tsNanos };
626
1025
  }
627
1026
  else if (typeof leaf.value === "boolean") {
628
1027
  writer.writeStringAtNanos(leaf.path, context, String(leaf.value), row.tsNanos, "boolean", source);
1028
+ tail.string = { path: leaf.path, context, tsNanos: row.tsNanos };
629
1029
  }
630
1030
  else {
631
1031
  writer.writeStringAtNanos(leaf.path, context, leaf.value, row.tsNanos, undefined, source);
1032
+ tail.string = { path: leaf.path, context, tsNanos: row.tsNanos };
632
1033
  }
633
1034
  any = true;
634
1035
  }
@@ -639,35 +1040,74 @@ function writeRow(row, measurement, context, source, writer) {
639
1040
  }
640
1041
  }
641
1042
  /**
642
- * Read one time window of a measurement from InfluxDB 2.x.
1043
+ * How many Flux records go into one batch. Bounds what a window holds at once:
1044
+ * a batch is parsed, written and released before the next is read.
1045
+ */
1046
+ const FLUX_BATCH_RECORDS = 5_000;
1047
+ /**
1048
+ * Whether a measurement may hold a position split across latitude/longitude
1049
+ * fields — the measurements mergePositionRows reassembles.
1050
+ */
1051
+ function isPositionMeasurement(measurement) {
1052
+ return measurement.includes("position");
1053
+ }
1054
+ /**
1055
+ * Turn the rows gathered so far into a batch.
643
1056
  *
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.
1057
+ * Decoding and position reassembly work within the batch, so a batch must
1058
+ * hold WHOLE points: every field of an instant together. Both readers
1059
+ * guarantee that — see each for how.
648
1060
  */
649
- async function readWindowFlux(req, measurement, startMs, endMs, headers, fetchImpl) {
650
- const flux = `from(bucket: ${JSON.stringify(req.bucket)})
1061
+ function finishBatch(measurement, rows, unusable) {
1062
+ const decoded = decodeJsonValueRows(rows);
1063
+ const merged = mergePositionRows(measurement, decoded.rows);
1064
+ return {
1065
+ rows: merged.rows,
1066
+ dropped: merged.dropped + decoded.dropped + unusable,
1067
+ };
1068
+ }
1069
+ /**
1070
+ * Read one time window of a measurement from InfluxDB 2.x, in batches.
1071
+ *
1072
+ * Flux returns one table per field, each running the length of the window. For
1073
+ * most measurements that is fine — every record is a complete value. A
1074
+ * position is not: its latitude and longitude arrive as two separate tables,
1075
+ * the second starting only after the first has ended, so pairing them from the
1076
+ * stream would mean holding a whole table back. Position measurements are
1077
+ * therefore pivoted by InfluxDB, which puts every field of an instant on one
1078
+ * record and lets a position be assembled record by record.
1079
+ */
1080
+ async function* readWindowFlux(req, measurement, startMs, endMs, headers, fetchImpl, idleTimeoutMs) {
1081
+ const pivoted = isPositionMeasurement(measurement);
1082
+ const source = `from(bucket: ${JSON.stringify(req.bucket)})
651
1083
  |> 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 = [];
1084
+ |> filter(fn: (r) => r._measurement == ${JSON.stringify(measurement)})`;
1085
+ const flux = pivoted
1086
+ ? `${source}
1087
+ |> pivot(rowKey: ["_time"], columnKey: ["_field"], valueColumn: "_value")
1088
+ |> drop(columns: ["_start", "_stop", "_measurement"])`
1089
+ : `${source}
1090
+ |> keep(columns: ["_time", "_field", "_value", "context"])`;
1091
+ const csv = new AnnotatedCsvReader();
1092
+ let rows = [];
1093
+ let records = 0;
656
1094
  // A record with no usable timestamp cannot be placed in time, so it is not
657
1095
  // importable — but it IS reported. The module's contract is that nothing is
658
1096
  // dropped silently, and a `read` total that quietly excludes these makes an
659
1097
  // import look smaller than the source rather than showing what was lost.
660
1098
  let unusable = 0;
661
- for (const rec of records) {
1099
+ const take = (rec) => {
1100
+ records++;
662
1101
  const t = rec.values["_time"];
663
- if (!t) {
664
- unusable++;
665
- continue;
666
- }
667
- const tsNanos = rfc3339ToNanos(t);
1102
+ const tsNanos = t ? rfc3339ToNanos(t) : null;
668
1103
  if (tsNanos === null) {
669
1104
  unusable++;
670
- continue;
1105
+ return;
1106
+ }
1107
+ const context = rec.values["context"] || undefined;
1108
+ if (pivoted) {
1109
+ pushPivotedFields(rec, tsNanos, context, rows);
1110
+ return;
671
1111
  }
672
1112
  rows.push({
673
1113
  tsNanos,
@@ -675,13 +1115,72 @@ async function readWindowFlux(req, measurement, startMs, endMs, headers, fetchIm
675
1115
  // The declared type decides: a genuine string "3.5" must stay a string
676
1116
  // rather than being parsed into the numeric table.
677
1117
  value: coerceValue(rec.values["_value"] ?? "", rec.types["_value"]),
1118
+ context,
678
1119
  });
1120
+ };
1121
+ const lines = streamLines(fetchImpl, fluxQueryUrl(req), fluxQueryInit(flux, headers), "read data", idleTimeoutMs);
1122
+ for await (const line of lines) {
1123
+ const rec = csv.push(line);
1124
+ if (!rec)
1125
+ continue;
1126
+ take(rec);
1127
+ if (records >= FLUX_BATCH_RECORDS) {
1128
+ yield finishBatch(measurement, rows, unusable);
1129
+ rows = [];
1130
+ records = 0;
1131
+ unusable = 0;
1132
+ }
679
1133
  }
680
- const merged = mergePositionRows(measurement, rows);
681
- return { rows: merged.rows, dropped: merged.dropped + unusable };
1134
+ const last = csv.end();
1135
+ if (last)
1136
+ take(last);
1137
+ if (records > 0)
1138
+ yield finishBatch(measurement, rows, unusable);
682
1139
  }
683
- /** Read one time window of a measurement from InfluxDB 1.x. */
684
- async function readWindowInfluxQl(req, measurement, startMs, endMs, headers, fetchImpl) {
1140
+ /** Columns of a pivoted record that are bookkeeping rather than fields. */
1141
+ const PIVOT_NON_FIELD_COLUMNS = new Set([
1142
+ "result",
1143
+ "table",
1144
+ "_time",
1145
+ ]);
1146
+ /**
1147
+ * The fields of one pivoted record, as rows at its instant.
1148
+ *
1149
+ * A pivoted record carries the series' tags as columns too. They are what the
1150
+ * table is grouped by, which is how they are told from fields: reading them as
1151
+ * fields would import a bogus `measurement.source` path for every point.
1152
+ *
1153
+ * An empty cell is a field this point does not have, not a value.
1154
+ */
1155
+ function pushPivotedFields(rec, tsNanos, context, out) {
1156
+ for (const column of Object.keys(rec.values)) {
1157
+ if (PIVOT_NON_FIELD_COLUMNS.has(column) || rec.grouped.has(column))
1158
+ continue;
1159
+ const raw = rec.values[column];
1160
+ if (raw === "")
1161
+ continue;
1162
+ out.push({
1163
+ tsNanos,
1164
+ field: column,
1165
+ value: coerceValue(raw, rec.types[column]),
1166
+ context,
1167
+ });
1168
+ }
1169
+ }
1170
+ /**
1171
+ * How many points InfluxDB 1.x puts in one chunk of a chunked response. Each
1172
+ * chunk becomes a batch, so this bounds what a window holds at once.
1173
+ */
1174
+ const INFLUXQL_CHUNK_POINTS = 10_000;
1175
+ /**
1176
+ * Read one time window of a measurement from InfluxDB 1.x, in batches.
1177
+ *
1178
+ * `chunked=true` makes InfluxDB answer with a series of complete JSON
1179
+ * documents, one per line, instead of one document for the whole window. A
1180
+ * 1.x row already carries every field of its point, so each chunk holds whole
1181
+ * points and can be finished on its own.
1182
+ */
1183
+ async function* readWindowInfluxQl(req, measurement, startMs, endMs, headers, fetchImpl, idleTimeoutMs) {
685
1184
  // Measurement names come from SHOW MEASUREMENTS on this same server, but
686
1185
  // they still land inside a query string — quote them as identifiers.
687
1186
  //
@@ -696,28 +1195,46 @@ async function readWindowInfluxQl(req, measurement, startMs, endMs, headers, fet
696
1195
  // bogus `measurement.source = "n2k"` string path for every single point.
697
1196
  // Signal K's own InfluxDB writers tag their points, so this is the common
698
1197
  // 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.
1198
+ // The context tag rides along as one more column, named after the tag. A
1199
+ // measurement without it simply has no such column.
1200
+ const q = `SELECT *::field, "context"::tag FROM ${quoted} WHERE time >= ${BigInt(startMs) * NANOS_PER_MS} AND time < ${BigInt(endMs) * NANOS_PER_MS}`;
1201
+ const url = `${req.url}/query?db=${encodeURIComponent(req.bucket)}&epoch=ns&chunked=true&chunk_size=${INFLUXQL_CHUNK_POINTS}&q=${encodeURIComponent(q)}`;
1202
+ for await (const line of streamLines(fetchImpl, url, { headers }, "read data", idleTimeoutMs)) {
1203
+ if (line.trim() === "")
1204
+ continue;
1205
+ let body;
1206
+ try {
1207
+ body = JSON.parse(line);
1208
+ }
1209
+ catch {
1210
+ throw new Error("Cannot read data: InfluxDB returned a malformed response");
1211
+ }
1212
+ if (body.error)
1213
+ throw new Error(body.error);
1214
+ if (body.results?.[0]?.error)
1215
+ throw new Error(body.results[0].error);
1216
+ const rows = [];
1217
+ const unusable = pushInfluxQlSeries(body.results?.[0]?.series ?? [], rows);
1218
+ if (rows.length > 0 || unusable > 0)
1219
+ yield finishBatch(measurement, rows, unusable);
1220
+ }
1221
+ }
1222
+ /**
1223
+ * The points of one InfluxQL result, as rows. Returns how many values could
1224
+ * not be imported — reported, like readWindowFlux's, not dropped in silence.
1225
+ */
1226
+ function pushInfluxQlSeries(series, rows) {
714
1227
  let unusable = 0;
715
1228
  for (const s of series) {
716
1229
  const columns = s.columns ?? [];
717
1230
  const timeIdx = columns.indexOf("time");
718
1231
  if (timeIdx < 0)
719
1232
  continue;
1233
+ const contextIdx = columns.indexOf("context");
720
1234
  for (const values of s.values ?? []) {
1235
+ const context = contextIdx >= 0 && typeof values[contextIdx] === "string"
1236
+ ? values[contextIdx] || undefined
1237
+ : undefined;
721
1238
  // epoch=ns returns the time as a number; going through BigInt(String)
722
1239
  // avoids the precision loss a float64 would suffer past 2^53 ns.
723
1240
  const rawTime = values[timeIdx];
@@ -731,7 +1248,7 @@ async function readWindowInfluxQl(req, measurement, startMs, endMs, headers, fet
731
1248
  continue;
732
1249
  }
733
1250
  for (let i = 0; i < columns.length; i++) {
734
- if (i === timeIdx)
1251
+ if (i === timeIdx || i === contextIdx)
735
1252
  continue;
736
1253
  const raw = values[i];
737
1254
  // A wide row has a column per field; nulls are fields absent from
@@ -758,39 +1275,55 @@ async function readWindowInfluxQl(req, measurement, startMs, endMs, headers, fet
758
1275
  tsNanos,
759
1276
  field: columns[i],
760
1277
  value: raw,
1278
+ context,
761
1279
  });
762
1280
  }
763
1281
  }
764
1282
  }
765
- const merged = mergePositionRows(measurement, rows);
766
- return { rows: merged.rows, dropped: merged.dropped + unusable };
1283
+ return unusable;
767
1284
  }
768
1285
  /**
769
1286
  * Recombine latitude/longitude fields of a position measurement into one
770
1287
  * object value at the same instant.
771
1288
  *
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.
1289
+ * signalk-to-influxdb2 stores navigation.position as two fields (`lat`/`lon`),
1290
+ * as does signalk-to-influxdb 1.x with `separateLatLon` on. Left as-is they
1291
+ * would import as two numeric paths and the position history would be unusable
1292
+ * — QuestDB keeps positions in their own table. The 1.x default encoding, a
1293
+ * single `jsonValue`, is already whole by the time it gets here (see
1294
+ * decodeJsonValueRows).
776
1295
  */
777
1296
  export function mergePositionRows(measurement, rows) {
778
1297
  if (!measurement.includes("position"))
779
1298
  return { rows, dropped: 0 };
780
1299
  const latKeys = new Set(["latitude", "lat"]);
781
1300
  const lonKeys = new Set(["longitude", "lon", "lng"]);
1301
+ // Keyed by vessel AND instant: two vessels' fixes at the same instant are
1302
+ // two positions, and a latitude of one must never pair with a longitude of
1303
+ // the other.
782
1304
  const byTime = new Map();
783
1305
  const passthrough = [];
1306
+ // Instants that already carry a whole position. signalk-to-influxdb 1.x with
1307
+ // `separateLatLon` on writes `jsonValue` AND `lat`/`lon` for the same fix, so
1308
+ // the pair is a second copy of a row that is already going to be written.
1309
+ const wholePositionTimes = new Set();
784
1310
  for (const row of rows) {
785
- const key = row.tsNanos.toString();
786
- if (latKeys.has(row.field) && typeof row.value === "number") {
1311
+ const key = `${row.context ?? ""}|${row.tsNanos}`;
1312
+ // Routed on the mapped path, as writeRow does, so a row is only counted as
1313
+ // a whole position here if it is going to be written as one.
1314
+ if (routeDeltaValue(toSignalKPath(measurement, row.field), row.value) ===
1315
+ "position") {
1316
+ wholePositionTimes.add(key);
1317
+ passthrough.push(row);
1318
+ }
1319
+ else if (latKeys.has(row.field) && typeof row.value === "number") {
787
1320
  if (!byTime.has(key))
788
- byTime.set(key, {});
1321
+ byTime.set(key, { context: row.context, tsNanos: row.tsNanos });
789
1322
  byTime.get(key).lat = row.value;
790
1323
  }
791
1324
  else if (lonKeys.has(row.field) && typeof row.value === "number") {
792
1325
  if (!byTime.has(key))
793
- byTime.set(key, {});
1326
+ byTime.set(key, { context: row.context, tsNanos: row.tsNanos });
794
1327
  byTime.get(key).lon = row.value;
795
1328
  }
796
1329
  else {
@@ -799,7 +1332,9 @@ export function mergePositionRows(measurement, rows) {
799
1332
  }
800
1333
  const merged = [];
801
1334
  let dropped = 0;
802
- for (const [key, { lat, lon }] of byTime) {
1335
+ for (const [key, { context, tsNanos, lat, lon }] of byTime) {
1336
+ if (wholePositionTimes.has(key))
1337
+ continue;
803
1338
  // A half-pair (lat with no lon at the same instant) is not a position.
804
1339
  // Emitting it as a bare number would be worse than skipping it, so it is
805
1340
  // dropped — and REPORTED, so it lands in the run's skipped total instead
@@ -809,9 +1344,10 @@ export function mergePositionRows(measurement, rows) {
809
1344
  continue;
810
1345
  }
811
1346
  merged.push({
812
- tsNanos: BigInt(key),
1347
+ tsNanos,
813
1348
  field: "value",
814
1349
  value: { latitude: lat, longitude: lon },
1350
+ ...(context === undefined ? {} : { context }),
815
1351
  });
816
1352
  }
817
1353
  return { rows: [...passthrough, ...merged], dropped };