signalk-questdb 2.0.1 → 2.1.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 (54) hide show
  1. package/.coderabbit.yaml +4 -3
  2. package/README.md +151 -5
  3. package/dist/api-contract.d.ts +62 -0
  4. package/dist/config/schema.d.ts +1 -0
  5. package/dist/config/schema.js +5 -0
  6. package/dist/config/schema.js.map +1 -1
  7. package/dist/configpanel/responses.d.ts +33 -1
  8. package/dist/configpanel/responses.js +89 -0
  9. package/dist/configpanel/responses.js.map +1 -1
  10. package/dist/history-v1.js +12 -9
  11. package/dist/history-v1.js.map +1 -1
  12. package/dist/history-v2.d.ts +18 -6
  13. package/dist/history-v2.js +275 -41
  14. package/dist/history-v2.js.map +1 -1
  15. package/dist/ilp-writer.d.ts +22 -0
  16. package/dist/ilp-writer.js +76 -4
  17. package/dist/ilp-writer.js.map +1 -1
  18. package/dist/index.d.ts +1 -0
  19. package/dist/index.js +326 -58
  20. package/dist/index.js.map +1 -1
  21. package/dist/influx-detect.d.ts +19 -0
  22. package/dist/influx-detect.js +165 -0
  23. package/dist/influx-detect.js.map +1 -0
  24. package/dist/migration.d.ts +147 -0
  25. package/dist/migration.js +824 -0
  26. package/dist/migration.js.map +1 -0
  27. package/dist/query-client.d.ts +9 -0
  28. package/dist/query-client.js +11 -0
  29. package/dist/query-client.js.map +1 -1
  30. package/dist/restore.js +9 -0
  31. package/dist/restore.js.map +1 -1
  32. package/dist/schema-errors.d.ts +15 -0
  33. package/dist/schema-errors.js +38 -0
  34. package/dist/schema-errors.js.map +1 -0
  35. package/dist/test/configpanel-responses.test.js +90 -1
  36. package/dist/test/configpanel-responses.test.js.map +1 -1
  37. package/dist/test/history-v2.test.js +440 -2
  38. package/dist/test/history-v2.test.js.map +1 -1
  39. package/dist/test/ilp-writer.test.js +28 -0
  40. package/dist/test/ilp-writer.test.js.map +1 -1
  41. package/dist/test/influx-detect.test.d.ts +1 -0
  42. package/dist/test/influx-detect.test.js +287 -0
  43. package/dist/test/influx-detect.test.js.map +1 -0
  44. package/dist/test/migration.test.d.ts +1 -0
  45. package/dist/test/migration.test.js +851 -0
  46. package/dist/test/migration.test.js.map +1 -0
  47. package/dist/test/restore.test.js +47 -0
  48. package/dist/test/restore.test.js.map +1 -1
  49. package/package.json +5 -4
  50. package/public/assets/PluginConfigurationPanel-CR4mD9IH.js +9 -0
  51. package/public/assets/{index-Z7eUk0W6.js → index-CyI4zKUm.js} +1 -1
  52. package/public/assets/{virtualExposes-CyrNon4j.js → virtualExposes-BBIkbTtD.js} +1 -1
  53. package/public/remoteEntry.js +1 -1
  54. package/public/assets/PluginConfigurationPanel-CyS3A7gD.js +0 -9
package/dist/index.js CHANGED
@@ -9,6 +9,8 @@ import { startRetention } from "./retention.js";
9
9
  import { RESTORE_SOURCE, restoreFromHistory } from "./restore.js";
10
10
  import { createConsoleProxy } from "./console-proxy.js";
11
11
  import { buildFullExportWhere } from "./full-export-range.js";
12
+ import { detectInflux, validateInfluxUrl } from "./influx-detect.js";
13
+ import { listBuckets, listMeasurements, runMigration, MigrationRun, } from "./migration.js";
12
14
  import { WalMonitor, buildPendingSegmentsSQL, computeSkipPlan, extractApplyError, isPartitionOpenFailure, skipPlansEqual, } from "./wal-monitor.js";
13
15
  import { QUESTDB_INTERNAL_HTTP_PORT, QUESTDB_INTERNAL_ILP_PORT, QUESTDB_INTERNAL_PG_PORT, QUESTDB_ACCESSIBLE_PORTS, resolveManagedEndpoints, resolveLanExposureHost, lanExposureEndpoints, } from "./questdb-endpoint.js";
14
16
  import { nofileClampSatisfied, readMaxMapCount } from "./host-limits.js";
@@ -94,6 +96,13 @@ function buildResourceLimits(config) {
94
96
  }
95
97
  export default (app) => {
96
98
  let writer = null;
99
+ // The most recent (or currently running) InfluxDB import. Deliberately
100
+ // in-memory and single-slot: an import is an operator-driven one-off, and
101
+ // surviving a plugin restart would mean persisting a half-finished run with
102
+ // no writer to resume it onto. A restart therefore forgets it, and re-running
103
+ // is safe because imported rows upsert on (ts, path, context, source).
104
+ let activeMigration = null;
105
+ let migrationCounter = 0;
97
106
  let queryClient = null;
98
107
  let retentionTimer = null;
99
108
  let schemaHealTimer = null;
@@ -107,6 +116,12 @@ export default (app) => {
107
116
  // capped QuestDB's requested value. Null until a clamp happens (or on a
108
117
  // signalk-container older than 1.18.0, which doesn't emit the event).
109
118
  let ulimitClamp = null;
119
+ // Set by onContainerWedged: the runtime couldn't stop/remove the container to
120
+ // apply a change, so signalk-container deferred the recreate (it does NOT
121
+ // throw). Sticky within a start/update cycle — the container is running the
122
+ // OLD config/version, so we must not report success or persist a new version
123
+ // as if the change applied. Cleared at the top of each ensureRunning attempt.
124
+ let containerWedged = null;
110
125
  // Whether this QuestDB's wal_tables() exposes the errorTag/errorMessage
111
126
  // columns (present on current builds, absent on an older pinned version).
112
127
  // Probed once via the richer status query and cached so an old build does
@@ -211,6 +226,18 @@ export default (app) => {
211
226
  ulimitClamp = event;
212
227
  app.debug(event.reason);
213
228
  };
229
+ // A wedged container (the runtime can't stop/remove it — usually an orphaned
230
+ // rootless-Podman user namespace after a Podman service restart) cannot be
231
+ // recovered from the plugin; signalk-container defers the recreate and fires
232
+ // this once. Surface the remedy as a plugin error rather than letting the
233
+ // operator see only a silent drift loop in the server log. Requires
234
+ // signalk-container >= 1.29.0; on older versions the callback never fires.
235
+ const onContainerWedged = (event) => {
236
+ containerWedged = event.reason;
237
+ // publishError → setPluginError already lands in the server log at error
238
+ // level and drives the admin-UI status; matches every other error site.
239
+ publishError(event.reason);
240
+ };
214
241
  // Tables the plugin owns; each is rebuilt if ILP auto-created it with the
215
242
  // wrong designated-timestamp schema. (signalk_position uses `ts` too.)
216
243
  const OWNED_TABLES = ["signalk", "signalk_str", "signalk_position"];
@@ -714,8 +741,10 @@ export default (app) => {
714
741
  // limit was raised) doesn't leave a stale warning; onUlimitClamped
715
742
  // re-sets it if this run clamps again.
716
743
  ulimitClamp = null;
744
+ containerWedged = null;
717
745
  await containers.ensureRunning(QUESTDB_CONTAINER_NAME, containerConfig, {
718
746
  onUlimitClamped,
747
+ onContainerWedged,
719
748
  });
720
749
  // ensureRunning() just (re)created the container. If purge preempted us
721
750
  // while it ran, return now so we don't proceed to resolve endpoints and
@@ -795,7 +824,7 @@ export default (app) => {
795
824
  queryClient = null;
796
825
  return;
797
826
  }
798
- const v2Provider = createHistoryProviderV2(queryClient, app.selfContext);
827
+ const v2Provider = createHistoryProviderV2(queryClient, app.selfContext, config.historySourcePolicyAll ?? false, (msg) => app.debug(msg));
799
828
  app.registerHistoryApiProvider(v2Provider);
800
829
  const v1Provider = createHistoryProviderV1(queryClient, app.selfContext, (msg) => app.debug(msg));
801
830
  app.registerHistoryProvider(v1Provider);
@@ -1034,7 +1063,14 @@ export default (app) => {
1034
1063
  `/etc/sysctl.d/99-questdb.conf && sudo sysctl --system`);
1035
1064
  }
1036
1065
  }
1037
- app.setPluginStatus(recordingStatus(ilpHost, ilpPort));
1066
+ // If the container wedged, ensureRunning deferred rather than threw, and the
1067
+ // "Waiting…/Creating tables…" progress lines above have since overwritten
1068
+ // the error onContainerWedged set. Re-assert it as the final status so the
1069
+ // operator sees the remedy, not a false "Recording". publishRecordingStatus
1070
+ // is guarded on pluginErrorActive, so it is a no-op while the error stands.
1071
+ if (containerWedged)
1072
+ publishError(containerWedged);
1073
+ publishRecordingStatus(ilpHost, ilpPort);
1038
1074
  // Everything is registered — only now does the plugin count as running.
1039
1075
  // Any earlier return/throw leaves the flag false, so a half-started
1040
1076
  // plugin rejects lifecycle-dependent requests like /api/update/apply.
@@ -1189,6 +1225,14 @@ export default (app) => {
1189
1225
  // Clear the clamp warning so it doesn't survive into the next start
1190
1226
  // (e.g. a switch to external/unmanaged mode that never calls ensureRunning).
1191
1227
  ulimitClamp = null;
1228
+ // Stop any import before the writer goes away: runMigration holds a
1229
+ // direct reference to it, so a run left going would keep enqueueing
1230
+ // lines onto a disconnected writer for as long as its read loop lasts.
1231
+ // The flag is checked between windows and between rows, so this takes
1232
+ // effect within one window rather than at the end of the whole import.
1233
+ if (activeMigration && activeMigration.state === "running") {
1234
+ activeMigration.cancel();
1235
+ }
1192
1236
  if (writer) {
1193
1237
  await writer.disconnect();
1194
1238
  writer = null;
@@ -1552,17 +1596,11 @@ export default (app) => {
1552
1596
  app.setPluginStatus(`Pulling QuestDB ${newTag}...`);
1553
1597
  await containers.pullImage(`questdb/questdb:${newTag}`);
1554
1598
  assertNotStopped();
1555
- if (currentConfig) {
1556
- currentConfig.questdbVersion = newTag;
1557
- await new Promise((resolve, reject) => {
1558
- app.savePluginOptions({ ...currentConfig }, (err) => {
1559
- if (err)
1560
- reject(err);
1561
- else
1562
- resolve();
1563
- });
1564
- });
1565
- }
1599
+ // NOTE: questdbVersion is persisted only AFTER a confirmed
1600
+ // recreate (below), not here — a wedged container defers the
1601
+ // recreate without throwing, and persisting newTag now would make
1602
+ // the plugin claim a version the still-running old container is not
1603
+ // actually on, surviving restarts.
1566
1604
  const updateVolumeSource = await resolveQuestdbVolumeSource(containers);
1567
1605
  app.setPluginStatus(`Starting QuestDB ${newTag}...`);
1568
1606
  const updateConfig = {
@@ -1586,9 +1624,11 @@ export default (app) => {
1586
1624
  // generation one more time.
1587
1625
  assertNotStopped();
1588
1626
  ulimitClamp = null;
1627
+ containerWedged = null;
1589
1628
  containerEpoch++;
1590
1629
  await containers.ensureRunning(QUESTDB_CONTAINER_NAME, updateConfig, {
1591
1630
  onUlimitClamped,
1631
+ onContainerWedged,
1592
1632
  });
1593
1633
  assertNotStopped();
1594
1634
  // The QuestDB version just changed, so the cached wal_tables()
@@ -1647,8 +1687,30 @@ export default (app) => {
1647
1687
  assertNotStopped();
1648
1688
  }
1649
1689
  }
1690
+ // Persist the new version only now that the recreate is confirmed
1691
+ // (a wedge deferred it — the old container is still running).
1692
+ if (!containerWedged && currentConfig) {
1693
+ currentConfig.questdbVersion = newTag;
1694
+ await new Promise((resolve, reject) => {
1695
+ app.savePluginOptions({ ...currentConfig }, (err) => {
1696
+ if (err)
1697
+ reject(err);
1698
+ else
1699
+ resolve();
1700
+ });
1701
+ });
1702
+ }
1650
1703
  return { ilpHost, ilpPort };
1651
1704
  });
1705
+ if (containerWedged) {
1706
+ // The recreate was deferred: the container is still on the OLD
1707
+ // version. Do not claim success — report the wedge and its remedy.
1708
+ res.status(409).json({
1709
+ error: "container wedged",
1710
+ message: containerWedged,
1711
+ });
1712
+ return;
1713
+ }
1652
1714
  app.setPluginStatus(`Recording to QuestDB ${newTag} at ${ilp.ilpHost}:${ilp.ilpPort}`);
1653
1715
  res.json({
1654
1716
  status: "updated",
@@ -2067,64 +2129,270 @@ export default (app) => {
2067
2129
  });
2068
2130
  }
2069
2131
  });
2132
+ // Strip the class down to the wire shape. `MigrationRun` carries a
2133
+ // `cancel()` method and a private flag that must not be serialised.
2134
+ const migrationRunView = (run) => ({
2135
+ id: run.id,
2136
+ state: run.state,
2137
+ url: run.url,
2138
+ bucket: run.bucket,
2139
+ startedAt: run.startedAt,
2140
+ finishedAt: run.finishedAt,
2141
+ progress: { ...run.progress },
2142
+ error: run.error,
2143
+ });
2144
+ // Auth travels in the request BODY rather than plugin config: the token
2145
+ // is only needed for the duration of an import and storing it in the
2146
+ // plugin's settings file would persist a credential the user never
2147
+ // asked us to keep. Never the query string — Signal K logs full request
2148
+ // URLs (morgan "combined"), so a token there reaches the access log.
2149
+ const readAuth = (src) => {
2150
+ const token = typeof src.token === "string" ? src.token : undefined;
2151
+ const username = typeof src.username === "string" ? src.username : undefined;
2152
+ const password = typeof src.password === "string" ? src.password : undefined;
2153
+ const org = typeof src.org === "string" ? src.org : undefined;
2154
+ if (!token && !username && !org)
2155
+ return undefined;
2156
+ return { token, username, password, org };
2157
+ };
2070
2158
  router.get("/api/migration/detect", async (req, res) => {
2071
- const baseUrl = req.query.url || "http://localhost:8086";
2159
+ // `?url=a&url=b` arrives as an ARRAY, not a string — a cast here
2160
+ // would hand a non-string to validateInfluxUrl and throw inside the
2161
+ // handler rather than answering 400.
2162
+ const requestedUrl = req.query.url;
2163
+ if (requestedUrl !== undefined && typeof requestedUrl !== "string") {
2164
+ res.status(400).json({
2165
+ error: "url must be a single value",
2166
+ sources: [],
2167
+ });
2168
+ return;
2169
+ }
2170
+ const baseUrl = validateInfluxUrl(requestedUrl || "http://localhost:8086");
2171
+ if (!baseUrl) {
2172
+ res.status(400).json({
2173
+ error: "Only localhost and private network http(s) URLs allowed",
2174
+ sources: [],
2175
+ });
2176
+ return;
2177
+ }
2072
2178
  try {
2073
- const urlObj = new URL(baseUrl);
2074
- const host = urlObj.hostname;
2075
- const isLocal = host === "localhost" || host === "127.0.0.1" || host === "::1";
2076
- const isPrivate = /^(10\.|172\.(1[6-9]|2\d|3[01])\.|192\.168\.)/.test(host);
2077
- if (!isLocal && !isPrivate) {
2078
- res.status(400).json({
2079
- error: "Only localhost and private network URLs allowed",
2080
- });
2081
- return;
2082
- }
2179
+ const sources = await detectInflux(baseUrl);
2180
+ res.json({ sources });
2083
2181
  }
2084
- catch {
2085
- res.status(400).json({ error: "Invalid URL" });
2182
+ catch (err) {
2183
+ res.status(500).json({
2184
+ error: err instanceof Error ? err.message : "Unknown error",
2185
+ sources: [],
2186
+ });
2187
+ }
2188
+ });
2189
+ // POST, not GET, purely because of the credentials. Signal K mounts
2190
+ // morgan("combined"), which writes the full request URL — query string
2191
+ // included — to the access log, and browsers keep it in history. An
2192
+ // InfluxDB API token in a query parameter therefore ends up in both.
2193
+ // Discovery is a read, but the token has to travel in the body.
2194
+ router.post("/api/migration/buckets", async (req, res) => {
2195
+ const body = (req.body ?? {});
2196
+ const baseUrl = validateInfluxUrl(typeof body.url === "string" ? body.url : "http://localhost:8086");
2197
+ if (!baseUrl) {
2198
+ res.status(400).json({
2199
+ error: "Invalid URL",
2200
+ buckets: [],
2201
+ });
2086
2202
  return;
2087
2203
  }
2088
- const sources = [];
2089
- // Detect InfluxDB 1.x
2090
2204
  try {
2091
- const r = await fetch(`${baseUrl}/ping`, {
2092
- method: "HEAD",
2093
- signal: AbortSignal.timeout(3000),
2205
+ const buckets = await listBuckets({
2206
+ url: baseUrl,
2207
+ type: typeof body.type === "string" ? body.type : "influxdb2",
2208
+ auth: readAuth(body),
2094
2209
  });
2095
- if (r.status === 204) {
2096
- sources.push({
2097
- type: "influxdb1",
2098
- url: baseUrl,
2099
- status: "found",
2100
- version: r.headers.get("X-Influxdb-Version") || "unknown",
2101
- });
2102
- }
2210
+ res.json({ buckets });
2103
2211
  }
2104
- catch {
2105
- // not running
2212
+ catch (err) {
2213
+ res.status(502).json({
2214
+ error: err instanceof Error ? err.message : "Unknown error",
2215
+ buckets: [],
2216
+ });
2217
+ }
2218
+ });
2219
+ // POST for the same reason as /buckets: the token must not reach the
2220
+ // access log or the browser's history via the query string.
2221
+ router.post("/api/migration/measurements", async (req, res) => {
2222
+ const body = (req.body ?? {});
2223
+ const baseUrl = validateInfluxUrl(typeof body.url === "string" ? body.url : "http://localhost:8086");
2224
+ const bucket = typeof body.bucket === "string" ? body.bucket : "";
2225
+ if (!baseUrl || !bucket) {
2226
+ res.status(400).json({
2227
+ error: "Missing or invalid url/bucket",
2228
+ measurements: [],
2229
+ });
2230
+ return;
2106
2231
  }
2107
- // Detect InfluxDB 2.x
2108
2232
  try {
2109
- const r = await fetch(`${baseUrl}/health`, {
2110
- signal: AbortSignal.timeout(3000),
2233
+ const measurements = await listMeasurements({
2234
+ url: baseUrl,
2235
+ type: typeof body.type === "string" ? body.type : "influxdb2",
2236
+ bucket,
2237
+ auth: readAuth(body),
2111
2238
  });
2112
- if (r.ok) {
2113
- const data = (await r.json());
2114
- if (data.status === "pass") {
2115
- sources.push({
2116
- type: "influxdb2",
2117
- url: baseUrl,
2118
- status: "found",
2119
- version: data.version || "unknown",
2120
- });
2121
- }
2122
- }
2239
+ res.json({ measurements });
2123
2240
  }
2124
- catch {
2125
- // not running
2241
+ catch (err) {
2242
+ res.status(502).json({
2243
+ error: err instanceof Error ? err.message : "Unknown error",
2244
+ measurements: [],
2245
+ });
2246
+ }
2247
+ });
2248
+ router.post("/api/migration/start", async (req, res) => {
2249
+ // One import at a time. Two concurrent runs would interleave on the
2250
+ // single ILP writer and race each other for its buffer, and there is
2251
+ // no sane way to show two progress bars against one writer.
2252
+ if (activeMigration && activeMigration.state === "running") {
2253
+ res.status(409).json({
2254
+ error: "A migration is already running",
2255
+ run: migrationRunView(activeMigration),
2256
+ });
2257
+ return;
2258
+ }
2259
+ if (!writer) {
2260
+ res.status(503).json({
2261
+ error: "QuestDB writer not connected",
2262
+ });
2263
+ return;
2264
+ }
2265
+ const body = (req.body ?? {});
2266
+ const baseUrl = validateInfluxUrl(typeof body.url === "string" ? body.url : "http://localhost:8086");
2267
+ const bucket = typeof body.bucket === "string" ? body.bucket : "";
2268
+ if (!baseUrl || !bucket) {
2269
+ res.status(400).json({
2270
+ error: "Missing or invalid url/bucket",
2271
+ });
2272
+ return;
2126
2273
  }
2127
- res.json({ sources });
2274
+ const from = typeof body.from === "string" ? body.from : "";
2275
+ const to = typeof body.to === "string" ? body.to : "";
2276
+ if (!from ||
2277
+ !to ||
2278
+ Number.isNaN(Date.parse(from)) ||
2279
+ Number.isNaN(Date.parse(to))) {
2280
+ res.status(400).json({
2281
+ error: "Missing or invalid from/to",
2282
+ });
2283
+ return;
2284
+ }
2285
+ // An inverted range is rejected here rather than left to fail inside
2286
+ // the run: the caller gets a 400 it can act on, instead of a 200
2287
+ // followed by a run that reports "failed" a moment later.
2288
+ if (Date.parse(to) <= Date.parse(from)) {
2289
+ res.status(400).json({
2290
+ error: "`to` must be after `from`",
2291
+ });
2292
+ return;
2293
+ }
2294
+ // "self" and "vessels.self" both mean this vessel; anything else must
2295
+ // be a fully-qualified Signal K context (vessels.urn:mrn:..., or
2296
+ // atons./aircraft./sar. for other targets).
2297
+ //
2298
+ // The own vessel is stored as the LITERAL string "self", not as
2299
+ // `vessels.<uuid>` — that is what the live recorder writes (see the
2300
+ // `isSelf ? "self" : context` in the delta handler) and what both
2301
+ // history providers normalise incoming contexts to. Importing under
2302
+ // the expanded uuid would file the history in a context no query ever
2303
+ // looks at: the rows would be there and the API would return nothing.
2304
+ const rawContext = typeof body.context === "string" ? body.context.trim() : "";
2305
+ let importContext;
2306
+ if (!rawContext ||
2307
+ rawContext === "self" ||
2308
+ rawContext === "vessels.self" ||
2309
+ rawContext === `vessels.${app.selfId}`) {
2310
+ importContext = "self";
2311
+ }
2312
+ else if (/^(vessels|atons|aircraft|sar)\.[A-Za-z0-9:._-]+$/.test(rawContext)) {
2313
+ importContext = rawContext;
2314
+ }
2315
+ else {
2316
+ res.status(400).json({
2317
+ error: "Invalid context: expected `self` or a Signal K context such as `vessels.urn:mrn:imo:mmsi:123456789`",
2318
+ });
2319
+ return;
2320
+ }
2321
+ // Kept deliberately narrow: a label is a short identifier, not free
2322
+ // text. Anything else falls back to the default.
2323
+ const rawLabel = typeof body.sourceLabel === "string" ? body.sourceLabel.trim() : "";
2324
+ const migrationSourceLabel = rawLabel && /^[A-Za-z0-9._:-]{1,64}$/.test(rawLabel)
2325
+ ? rawLabel
2326
+ : "influxdb-import";
2327
+ const request = {
2328
+ url: baseUrl,
2329
+ type: typeof body.type === "string" ? body.type : "influxdb2",
2330
+ bucket,
2331
+ auth: readAuth(body),
2332
+ from,
2333
+ to,
2334
+ // Default to the server's own vessel: that is what a boat migrating
2335
+ // its own history means by "my data". A supplied context is checked
2336
+ // against the Signal K shape rather than passed through — it becomes
2337
+ // a SYMBOL value on every imported row and is what the history API
2338
+ // queries by, so a malformed one writes history nothing can read.
2339
+ context: importContext,
2340
+ measurements: Array.isArray(body.measurements)
2341
+ ? body.measurements.filter((m) => typeof m === "string")
2342
+ : undefined,
2343
+ // The label is written as the `source` tag on every imported row,
2344
+ // which is both a dedup key component and how an operator tells
2345
+ // imported history from live recording. A blank or whitespace-only
2346
+ // value would leave the tag off entirely (making imported rows
2347
+ // indistinguishable from recorded ones, and changing the dedup
2348
+ // key so a re-run duplicates instead of upserting), so it falls
2349
+ // back to the default rather than being trusted as given.
2350
+ sourceLabel: migrationSourceLabel,
2351
+ };
2352
+ // The id only has to be unique within this process, where the counter
2353
+ // already guarantees it; the timestamp just makes logs readable.
2354
+ const run = new MigrationRun(`mig-${Date.now()}-${++migrationCounter}`, baseUrl, bucket);
2355
+ activeMigration = run;
2356
+ const startedWriter = writer;
2357
+ // Deliberately not awaited: an import runs for minutes to hours, so
2358
+ // the HTTP call returns immediately and the panel polls /status.
2359
+ void runMigration(request, startedWriter, run, {
2360
+ debug: (msg) => app.debug(msg),
2361
+ })
2362
+ .catch((err) => {
2363
+ // runMigration traps its own failures, but a few statements run
2364
+ // BEFORE its try block (auth header construction). A rejection
2365
+ // from there would be an unhandled promise rejection on a
2366
+ // deliberately un-awaited call — fatal to the Signal K process
2367
+ // under Node's default policy — and would leave the run stuck
2368
+ // reading "running" forever.
2369
+ run.state = "failed";
2370
+ run.error = err instanceof Error ? err.message : String(err);
2371
+ run.finishedAt = new Date().toISOString();
2372
+ })
2373
+ .finally(() => {
2374
+ app.debug(`migration ${run.id} ${run.state}: ${run.progress.written} written, ${run.progress.skipped} skipped`);
2375
+ });
2376
+ res.json({
2377
+ run: migrationRunView(run),
2378
+ });
2379
+ });
2380
+ router.get("/api/migration/status", (_req, res) => {
2381
+ res.json({
2382
+ run: activeMigration ? migrationRunView(activeMigration) : undefined,
2383
+ });
2384
+ });
2385
+ router.post("/api/migration/cancel", (_req, res) => {
2386
+ if (!activeMigration || activeMigration.state !== "running") {
2387
+ res.status(409).json({
2388
+ error: "No migration running",
2389
+ });
2390
+ return;
2391
+ }
2392
+ activeMigration.cancel();
2393
+ res.json({
2394
+ run: migrationRunView(activeMigration),
2395
+ });
2128
2396
  });
2129
2397
  router.get("/api/export", async (req, res) => {
2130
2398
  try {