signalk-questdb 2.2.2 → 2.3.0

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 (49) hide show
  1. package/.coderabbit.yaml +19 -15
  2. package/.release-please-manifest.json +1 -1
  3. package/CLAUDE.md +4 -3
  4. package/README.md +94 -5
  5. package/dist/api-contract.d.ts +46 -0
  6. package/dist/archive.d.ts +44 -0
  7. package/dist/archive.js +117 -0
  8. package/dist/archive.js.map +1 -0
  9. package/dist/config/schema.d.ts +3 -0
  10. package/dist/config/schema.js +27 -0
  11. package/dist/config/schema.js.map +1 -1
  12. package/dist/container-env.js +11 -0
  13. package/dist/container-env.js.map +1 -1
  14. package/dist/gpx-import-session.d.ts +69 -0
  15. package/dist/gpx-import-session.js +218 -0
  16. package/dist/gpx-import-session.js.map +1 -0
  17. package/dist/gpx-import.d.ts +135 -0
  18. package/dist/gpx-import.js +452 -0
  19. package/dist/gpx-import.js.map +1 -0
  20. package/dist/history-v1.d.ts +2 -1
  21. package/dist/history-v1.js +77 -19
  22. package/dist/history-v1.js.map +1 -1
  23. package/dist/history-v2.d.ts +2 -1
  24. package/dist/history-v2.js +54 -26
  25. package/dist/history-v2.js.map +1 -1
  26. package/dist/ilp-writer.d.ts +1 -1
  27. package/dist/ilp-writer.js +4 -2
  28. package/dist/ilp-writer.js.map +1 -1
  29. package/dist/index.d.ts +3 -0
  30. package/dist/index.js +239 -10
  31. package/dist/index.js.map +1 -1
  32. package/dist/query-client.js +27 -0
  33. package/dist/query-client.js.map +1 -1
  34. package/dist/retention.d.ts +28 -1
  35. package/dist/retention.js +152 -12
  36. package/dist/retention.js.map +1 -1
  37. package/package.json +2 -1
  38. package/public/assets/PluginConfigurationPanel-DR-y5e3d.js +11 -0
  39. package/public/assets/{_virtual_mf-localSharedImportMap___mfe_internal__signalk_questdb__mf_owner__1-D-Le-Wxu.js → _virtual_mf-localSharedImportMap___mfe_internal__signalk_questdb__mf_owner__1-BUtmcsA-.js} +1 -1
  40. package/public/assets/{dist-BI-Ygd_Z.js → dist-C5M3fhBw.js} +1 -1
  41. package/public/assets/helpers-oIxDNnZJ.js +1 -0
  42. package/public/assets/{hostInit-CdqUOHAJ.js → hostInit-B3lIJd9C.js} +1 -1
  43. package/public/assets/{index-mvK71Kw7.js → index-eAjx9ZuX.js} +2 -2
  44. package/public/assets/{pendingShares-BI6kviiM.js → pendingShares-PvyVL1Z8.js} +2 -2
  45. package/public/assets/utils-v9GZlbdk.js +4 -0
  46. package/public/remoteEntry.js +2 -2
  47. package/public/assets/PluginConfigurationPanel-JR1rvS7f.js +0 -9
  48. package/public/assets/helpers-CsBAxMau.js +0 -1
  49. package/public/assets/utils-BRGuweMc.js +0 -4
package/dist/index.js CHANGED
@@ -7,6 +7,9 @@ import { unitsResolver } from "./history-units.js";
7
7
  import { createHistoryProviderV2 } from "./history-v2.js";
8
8
  import { createHistoryProviderV1 } from "./history-v1.js";
9
9
  import { startRetention } from "./retention.js";
10
+ import { ARCHIVE_TABLES, ArchiveSpans, waitForApply } from "./archive.js";
11
+ import { GpxImport, GpxImportCancelled, GpxParseError, IMPORT_SOURCE, WriterGate, recordedIntervals, waitForImportedRow, } from "./gpx-import.js";
12
+ import { GpxImportConflict, GpxImportSession, GpxImports, SliceTooLarge, readSlice, } from "./gpx-import-session.js";
10
13
  import { RESTORE_SOURCE, restoreFromHistory } from "./restore.js";
11
14
  import { createConsoleProxy } from "./console-proxy.js";
12
15
  import { buildFullExportWhere } from "./full-export-range.js";
@@ -38,6 +41,8 @@ const FULL_EXPORT_TABLES = [
38
41
  "signalk",
39
42
  "signalk_str",
40
43
  "signalk_position",
44
+ ARCHIVE_TABLES.signalk,
45
+ ARCHIVE_TABLES.signalk_position,
41
46
  ];
42
47
  const FULL_EXPORT_TABLE_SET = new Set(FULL_EXPORT_TABLES);
43
48
  // Healthcheck for the QuestDB container. The `questdb/questdb` image ships no
@@ -126,7 +131,11 @@ export default (app) => {
126
131
  };
127
132
  let migrationCounter = 0;
128
133
  let queryClient = null;
129
- let retentionTimer = null;
134
+ let retention = null;
135
+ // What the archive covers, for the history providers and the GPX import;
136
+ // null while the plugin is stopped.
137
+ let archiveSpans = null;
138
+ const gpxImports = new GpxImports();
130
139
  let schemaHealTimer = null;
131
140
  let currentConfig = null;
132
141
  // True when the `signalk` table exists with the wrong (ILP-auto-created)
@@ -846,6 +855,18 @@ export default (app) => {
846
855
  // the wrong (`timestamp`, not `ts`) schema; heal it before the writer can
847
856
  // ingest into the broken shape.
848
857
  await healSchemaTables();
858
+ // Reads add the archive only where it covers the asked range, so its span
859
+ // must be known before a provider answers anything. If it cannot be read
860
+ // now, reads leave the archive out until the heal heartbeat below reads
861
+ // it.
862
+ const archive = new ArchiveSpans(queryClient);
863
+ archiveSpans = archive;
864
+ try {
865
+ await archive.refresh();
866
+ }
867
+ catch (err) {
868
+ app.debug(`Archive span unknown: ${err instanceof Error ? err.message : String(err)}`);
869
+ }
849
870
  writer = new ILPWriter(ilpHost, ilpPort, (msg) => app.debug(msg), {
850
871
  // Surface a flapping ILP connection instead of leaving the status line
851
872
  // stuck on a cheerful "Recording" while every sample is dropped.
@@ -876,9 +897,9 @@ export default (app) => {
876
897
  const v2Provider = createHistoryProviderV2(queryClient, app.selfContext, config.historySourcePolicyAll ?? false, (msg) => app.debug(msg), unitsResolver(app),
877
898
  // Twice the heartbeat: a path that updates at least once per heartbeat
878
899
  // gets a row within heartbeat + its own interval of the previous one.
879
- config.unchangedHeartbeatSeconds * 2000);
900
+ config.unchangedHeartbeatSeconds * 2000, archive);
880
901
  app.registerHistoryApiProvider(v2Provider);
881
- const v1Provider = createHistoryProviderV1(queryClient, app.selfContext, (msg) => app.debug(msg));
902
+ const v1Provider = createHistoryProviderV1(queryClient, app.selfContext, (msg) => app.debug(msg), archive);
882
903
  app.registerHistoryProvider(v1Provider);
883
904
  // Arm live-context tracking before the first delta can arrive, so the
884
905
  // restore sees every vessel that transmitted during startup.
@@ -1073,13 +1094,23 @@ export default (app) => {
1073
1094
  });
1074
1095
  unsubscribes.push(unsub);
1075
1096
  if (config.retentionDays && config.retentionDays > 0) {
1076
- retentionTimer = startRetention(queryClient, config.retentionDays, (msg) => app.debug(msg));
1097
+ retention = startRetention(queryClient, {
1098
+ retentionDays: config.retentionDays,
1099
+ archiveOwnTrack: config.archiveOwnTrack,
1100
+ archivePaths: new PathMatcher(config.archivePaths),
1101
+ archiveResolutionSeconds: config.archiveResolutionSeconds,
1102
+ }, (msg) => app.debug(msg), () => archive.refresh());
1077
1103
  }
1078
1104
  // Heartbeat: catch and repair a table that gets dropped (e.g. a manual
1079
1105
  // WAL recovery) and re-auto-created by ILP with the wrong schema, so
1080
1106
  // recording silently breaking heals itself instead of needing a restart.
1081
1107
  schemaHealTimer = setInterval(() => {
1082
1108
  void healSchemaTables();
1109
+ if (!archive.known) {
1110
+ archive.refresh().catch((err) => {
1111
+ app.debug(`Archive span still unknown: ${err instanceof Error ? err.message : String(err)}`);
1112
+ });
1113
+ }
1083
1114
  }, SCHEMA_HEAL_INTERVAL_MS);
1084
1115
  // Watch for WAL suspensions. Detection alone is not enough — a suspended
1085
1116
  // table keeps accepting ILP writes while committing nothing, and the only
@@ -1286,10 +1317,12 @@ export default (app) => {
1286
1317
  }
1287
1318
  }
1288
1319
  unsubscribes.length = 0;
1289
- if (retentionTimer) {
1290
- clearInterval(retentionTimer);
1291
- retentionTimer = null;
1320
+ if (retention) {
1321
+ retention.stop();
1322
+ retention = null;
1292
1323
  }
1324
+ gpxImports.abortAll();
1325
+ archiveSpans = null;
1293
1326
  if (schemaHealTimer) {
1294
1327
  clearInterval(schemaHealTimer);
1295
1328
  schemaHealTimer = null;
@@ -2154,10 +2187,12 @@ export default (app) => {
2154
2187
  walMonitor = null;
2155
2188
  }
2156
2189
  clearWalAlertOnTeardown();
2157
- if (retentionTimer) {
2158
- clearInterval(retentionTimer);
2159
- retentionTimer = null;
2190
+ if (retention) {
2191
+ retention.stop();
2192
+ retention = null;
2160
2193
  }
2194
+ gpxImports.abortAll();
2195
+ archiveSpans = null;
2161
2196
  if (writer) {
2162
2197
  try {
2163
2198
  await writer.disconnect();
@@ -2453,6 +2488,181 @@ export default (app) => {
2453
2488
  });
2454
2489
  }
2455
2490
  });
2491
+ // GPX import into the position archive (see gpx-import.ts). The file
2492
+ // arrives as slices over several requests: POST announces it, each PUT
2493
+ // carries the slice starting at `offset`, and POST …/finish ends it once
2494
+ // every byte is in. Admin-only, like every route not registered through
2495
+ // router.access(): an import writes history.
2496
+ const importReply = (res, code, body) => {
2497
+ res.status(code).json(body);
2498
+ };
2499
+ const importFailure = (res, err) => {
2500
+ if (err instanceof GpxImportConflict) {
2501
+ importReply(res, 409, { error: err.message, import: err.status });
2502
+ }
2503
+ else if (err instanceof SliceTooLarge) {
2504
+ importReply(res, 413, { error: err.message });
2505
+ }
2506
+ else if (err instanceof GpxParseError) {
2507
+ importReply(res, 422, {
2508
+ error: err.message,
2509
+ import: gpxImports.latest(),
2510
+ });
2511
+ }
2512
+ else if (err instanceof GpxImportCancelled) {
2513
+ importReply(res, 409, {
2514
+ error: err.message,
2515
+ import: gpxImports.latest(),
2516
+ });
2517
+ }
2518
+ else {
2519
+ importReply(res, 500, {
2520
+ error: err instanceof Error ? err.message : String(err),
2521
+ import: gpxImports.latest(),
2522
+ });
2523
+ }
2524
+ };
2525
+ router.get("/api/gpx-import", (_req, res) => {
2526
+ importReply(res, 200, { import: gpxImports.latest() });
2527
+ });
2528
+ router.post("/api/gpx-import", async (req, res) => {
2529
+ const body = (req.body ?? {});
2530
+ const filename = typeof body.filename === "string" ? body.filename.slice(0, 255) : "";
2531
+ const size = body.size;
2532
+ if (typeof size !== "number" ||
2533
+ !Number.isSafeInteger(size) ||
2534
+ size <= 0) {
2535
+ importReply(res, 400, {
2536
+ error: "size must be the file's length in bytes",
2537
+ });
2538
+ return;
2539
+ }
2540
+ // Which tracks are the vessel's is the caller's call (see
2541
+ // GpxImportOptions.tracks), so there is no default.
2542
+ const tracks = body.tracks;
2543
+ if (!Array.isArray(tracks) ||
2544
+ tracks.length === 0 ||
2545
+ tracks.length > 1000 ||
2546
+ !tracks.every((t) => typeof t === "string" && t.length <= 1024)) {
2547
+ importReply(res, 400, {
2548
+ error: 'tracks must list the names of the tracks to import ("" for one without a name)',
2549
+ });
2550
+ return;
2551
+ }
2552
+ const w = writer;
2553
+ const client = queryClient;
2554
+ const spans = archiveSpans;
2555
+ const config = currentConfig;
2556
+ if (!w || !client || !spans || !config) {
2557
+ importReply(res, 503, { error: "QuestDB writer not connected" });
2558
+ return;
2559
+ }
2560
+ // ILP would auto-create a missing archive with the wrong designated
2561
+ // timestamp, and every read of it would come back empty.
2562
+ let designated;
2563
+ try {
2564
+ designated = await client.designatedTimestamp(ARCHIVE_TABLES.signalk_position);
2565
+ }
2566
+ catch (err) {
2567
+ importFailure(res, err);
2568
+ return;
2569
+ }
2570
+ if (designated !== "ts") {
2571
+ importReply(res, 503, {
2572
+ error: `${ARCHIVE_TABLES.signalk_position} is missing; restart the plugin to create it`,
2573
+ });
2574
+ return;
2575
+ }
2576
+ // From here to gpxImports.start() nothing awaits, so a migration
2577
+ // cannot start between this check and the import taking the writer.
2578
+ if (activeMigration && activeMigration.state === "running") {
2579
+ importReply(res, 409, {
2580
+ error: "An InfluxDB migration is running; import after it ends",
2581
+ });
2582
+ return;
2583
+ }
2584
+ // The rebuild carries over everything written during its copy, from
2585
+ // the oldest line's time on: a GPX line from years back would make
2586
+ // that years of rows.
2587
+ if (legacyCleanupRunning) {
2588
+ importReply(res, 409, {
2589
+ error: "The string table is being rebuilt; import after it ends",
2590
+ });
2591
+ return;
2592
+ }
2593
+ try {
2594
+ const session = gpxImports.start(() => {
2595
+ const gate = new WriterGate(w);
2596
+ const importer = new GpxImport({
2597
+ tracks: new Set(tracks.map((t) => t.trim())),
2598
+ resolutionSeconds: config.archiveResolutionSeconds,
2599
+ write: (ms, latitude, longitude) => w.writePositionAtNanos("self", { latitude, longitude }, BigInt(ms) * 1000000n, IMPORT_SOURCE, ARCHIVE_TABLES.signalk_position),
2600
+ backpressure: () => gate.room(),
2601
+ ...(body.fillGapsOnly === false
2602
+ ? {}
2603
+ : { recorded: recordedIntervals(client, spans) }),
2604
+ });
2605
+ return new GpxImportSession(filename, size, importer, async () => {
2606
+ await gate.settled();
2607
+ await waitForApply(client, ARCHIVE_TABLES.signalk_position, 120_000);
2608
+ // QuestDB commits ILP on its own schedule, so a batch the
2609
+ // socket took may not be in the WAL yet; the last row written
2610
+ // reading back is what says the import is stored.
2611
+ const last = importer.lastWritten;
2612
+ if (last !== null)
2613
+ await waitForImportedRow(client, last, 120_000);
2614
+ await spans.refresh();
2615
+ });
2616
+ });
2617
+ importReply(res, 201, { import: session.status });
2618
+ }
2619
+ catch (err) {
2620
+ importFailure(res, err);
2621
+ }
2622
+ });
2623
+ router.put("/api/gpx-import/:id", async (req, res) => {
2624
+ const session = gpxImports.get(String(req.params.id));
2625
+ if (!session) {
2626
+ importReply(res, 404, { error: "No such import" });
2627
+ return;
2628
+ }
2629
+ const offset = Number(req.query.offset);
2630
+ if (!Number.isSafeInteger(offset) || offset < 0) {
2631
+ importReply(res, 400, { error: "offset must be a byte offset" });
2632
+ return;
2633
+ }
2634
+ try {
2635
+ const slice = await readSlice(req);
2636
+ importReply(res, 200, {
2637
+ import: await session.append(offset, slice),
2638
+ });
2639
+ }
2640
+ catch (err) {
2641
+ importFailure(res, err);
2642
+ }
2643
+ });
2644
+ router.post("/api/gpx-import/:id/finish", async (req, res) => {
2645
+ const session = gpxImports.get(String(req.params.id));
2646
+ if (!session) {
2647
+ importReply(res, 404, { error: "No such import" });
2648
+ return;
2649
+ }
2650
+ try {
2651
+ importReply(res, 200, { import: await session.finish() });
2652
+ }
2653
+ catch (err) {
2654
+ importFailure(res, err);
2655
+ }
2656
+ });
2657
+ router.delete("/api/gpx-import/:id", (req, res) => {
2658
+ const session = gpxImports.get(String(req.params.id));
2659
+ if (!session) {
2660
+ importReply(res, 404, { error: "No such import" });
2661
+ return;
2662
+ }
2663
+ session.abort();
2664
+ importReply(res, 200, { import: session.status });
2665
+ });
2456
2666
  router.post("/api/migration/start", async (req, res) => {
2457
2667
  // One import at a time. Two concurrent runs would interleave on the
2458
2668
  // single ILP writer and race each other for its buffer, and there is
@@ -2464,6 +2674,12 @@ export default (app) => {
2464
2674
  });
2465
2675
  return;
2466
2676
  }
2677
+ if (gpxImports.active) {
2678
+ res.status(409).json({
2679
+ error: "A GPX import is running; migrate after it ends",
2680
+ });
2681
+ return;
2682
+ }
2467
2683
  if (!writer) {
2468
2684
  res.status(503).json({
2469
2685
  error: "QuestDB writer not connected",
@@ -2698,6 +2914,12 @@ export default (app) => {
2698
2914
  });
2699
2915
  return;
2700
2916
  }
2917
+ if (gpxImports.active) {
2918
+ res.status(409).json({
2919
+ error: "A GPX import is running; migrate after it ends",
2920
+ });
2921
+ return;
2922
+ }
2701
2923
  if (!writer) {
2702
2924
  res.status(503).json({
2703
2925
  error: "QuestDB writer not connected",
@@ -2798,6 +3020,13 @@ export default (app) => {
2798
3020
  });
2799
3021
  return;
2800
3022
  }
3023
+ if (gpxImports.active) {
3024
+ res.status(409).json({
3025
+ error: "A GPX import is running; wait for it or abort it first",
3026
+ rows: 0,
3027
+ });
3028
+ return;
3029
+ }
2801
3030
  if (legacyCleanupRunning) {
2802
3031
  res.status(409).json({
2803
3032
  error: "The rows are already being removed",