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.
- package/.coderabbit.yaml +19 -15
- package/.release-please-manifest.json +1 -1
- package/CLAUDE.md +4 -3
- package/README.md +94 -5
- package/dist/api-contract.d.ts +46 -0
- package/dist/archive.d.ts +44 -0
- package/dist/archive.js +117 -0
- package/dist/archive.js.map +1 -0
- package/dist/config/schema.d.ts +3 -0
- package/dist/config/schema.js +27 -0
- package/dist/config/schema.js.map +1 -1
- package/dist/container-env.js +11 -0
- package/dist/container-env.js.map +1 -1
- package/dist/gpx-import-session.d.ts +69 -0
- package/dist/gpx-import-session.js +218 -0
- package/dist/gpx-import-session.js.map +1 -0
- package/dist/gpx-import.d.ts +135 -0
- package/dist/gpx-import.js +452 -0
- package/dist/gpx-import.js.map +1 -0
- package/dist/history-v1.d.ts +2 -1
- package/dist/history-v1.js +77 -19
- package/dist/history-v1.js.map +1 -1
- package/dist/history-v2.d.ts +2 -1
- package/dist/history-v2.js +54 -26
- package/dist/history-v2.js.map +1 -1
- package/dist/ilp-writer.d.ts +1 -1
- package/dist/ilp-writer.js +4 -2
- package/dist/ilp-writer.js.map +1 -1
- package/dist/index.d.ts +3 -0
- package/dist/index.js +239 -10
- package/dist/index.js.map +1 -1
- package/dist/query-client.js +27 -0
- package/dist/query-client.js.map +1 -1
- package/dist/retention.d.ts +28 -1
- package/dist/retention.js +152 -12
- package/dist/retention.js.map +1 -1
- package/package.json +2 -1
- package/public/assets/PluginConfigurationPanel-DR-y5e3d.js +11 -0
- 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
- package/public/assets/{dist-BI-Ygd_Z.js → dist-C5M3fhBw.js} +1 -1
- package/public/assets/helpers-oIxDNnZJ.js +1 -0
- package/public/assets/{hostInit-CdqUOHAJ.js → hostInit-B3lIJd9C.js} +1 -1
- package/public/assets/{index-mvK71Kw7.js → index-eAjx9ZuX.js} +2 -2
- package/public/assets/{pendingShares-BI6kviiM.js → pendingShares-PvyVL1Z8.js} +2 -2
- package/public/assets/utils-v9GZlbdk.js +4 -0
- package/public/remoteEntry.js +2 -2
- package/public/assets/PluginConfigurationPanel-JR1rvS7f.js +0 -9
- package/public/assets/helpers-CsBAxMau.js +0 -1
- 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
|
|
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
|
-
|
|
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 (
|
|
1290
|
-
|
|
1291
|
-
|
|
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 (
|
|
2158
|
-
|
|
2159
|
-
|
|
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",
|