@signalk/tracks-plugin 3.0.0-beta.1 → 3.0.0-beta.2

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/dist/index.js CHANGED
@@ -1,10 +1,10 @@
1
1
  import { Temporal } from "@js-temporal/polyfill";
2
2
  import { join } from "node:path";
3
- import { BehaviorSubject, ReplaySubject, Subject, combineLatest, connectable, firstValueFrom } from "rxjs";
4
- import { map, scan, startWith, throttleTime } from "rxjs/operators";
5
3
  import { Type } from "typebox";
6
4
  import { Value } from "typebox/value";
7
5
  import { DatabaseSync } from "node:sqlite";
6
+ import { BehaviorSubject, ReplaySubject, Subject, combineLatest, connectable, firstValueFrom } from "rxjs";
7
+ import { map, scan, startWith, throttleTime } from "rxjs/operators";
8
8
  //#region \0rolldown/runtime.js
9
9
  var __commonJSMin = (cb, mod) => () => (mod || (cb((mod = { exports: {} }).exports, mod), cb = null), mod.exports);
10
10
  //#endregion
@@ -358,142 +358,26 @@ function createMatcher(params, selfPosition, debug) {
358
358
  }
359
359
  return () => true;
360
360
  }
361
- //#endregion
362
- //#region src/tracks.ts
363
361
  /**
364
- * The in-memory store: a bounded sliding window of positions per context,
365
- * held in RxJS accumulators and lost on restart. `bootstrapSelfTrack()` in
366
- * index.ts is what re-hydrates it from a History provider at startup.
362
+ * A history row is `[timestamp, position]`. Both encodings are legal: the
363
+ * History API types a value as `unknown` and its own comment names "objects
364
+ * (like navigation.position)" alongside primitives, so signalk-questdb returns
365
+ * `{latitude, longitude}` and signalk-to-influxdb2 returns `[lon, lat]`.
366
+ * Accept both — neither is an error, so rejecting one silently yields an empty
367
+ * track rather than a failure anybody can see.
367
368
  */
368
- var Tracks = class {
369
- tracks = {};
370
- debug;
371
- config;
372
- constructor(config, debug) {
373
- if (debug.enabled) debug(JSON.stringify(config));
374
- this.config = config;
375
- this.debug = debug;
376
- }
377
- newPosition(context, position, timestamp) {
378
- this.getAccumulator(context)?.nextLatLngTuple(position, timestamp);
379
- }
380
- initialTrack(context, track, timestamps) {
381
- this.getAccumulator(context)?.setInitialTrack(track, timestamps);
382
- }
383
- getAccumulator(context, createIfMissing = true) {
384
- if (context.indexOf("vessels.") === -1 && context.indexOf("aircraft.") === -1) return;
385
- let result = this.tracks[context];
386
- if (!result && createIfMissing) {
387
- const accParams = this.config.fetchInitialTrack ? {
388
- ...this.config,
389
- fetchTrackFor: context
390
- } : { ...this.config };
391
- result = this.tracks[context] = new TrackAccumulator(accParams);
392
- }
393
- return result;
394
- }
395
- get(context, window) {
396
- return this.getTimed(context, window).then((points) => points.map(({ position }) => position));
397
- }
398
- /**
399
- * Track points with their timestamps, optionally narrowed to a time window.
400
- *
401
- * Rejects with EmptyError if the stream completes without emitting; the route
402
- * handlers turn that into the same 404 as an unknown context.
403
- */
404
- getTimed(context, window) {
405
- const accumulator = this.getAccumulator(context, false);
406
- if (!accumulator) return Promise.reject(/* @__PURE__ */ new Error(`No track accumulator for ${context}`));
407
- return firstValueFrom(accumulator.timedTrack).then((points) => window ? points.filter(({ timestamp }) => timestamp >= window.from && (window.inclusiveEnd ? timestamp <= window.to : timestamp < window.to)) : points);
408
- }
409
- getAllTracks(query) {
410
- return Promise.all(Object.keys(this.tracks).map((context) => this.getTimed(context, query?.window).then((points) => ({
411
- context,
412
- track: thin(points, query?.resolution).map(({ position }) => position)
413
- }))));
414
- }
415
- async getFilteredTracks(params, selfPosition, debug, query) {
416
- const timed = await this.getFilteredTimedTracks(params, selfPosition, debug, query);
417
- return Object.fromEntries(Object.entries(timed).map(([context, points]) => [context, points.map(({ position }) => position)]));
418
- }
419
- /**
420
- * The same filtering as `getFilteredTracks`, keeping each point's timestamp.
421
- *
422
- * The untimed form is derived from this one so the two cannot disagree about
423
- * which tracks match — the filter runs once, here.
424
- */
425
- async getFilteredTimedTracks(params, selfPosition, debug, query) {
426
- this.debug(params);
427
- this.debug("Self position", selfPosition);
428
- const matcher = createMatcher(params, selfPosition, debug);
429
- const contexts = Object.keys(this.tracks);
430
- return (await Promise.all(contexts.map((context) => this.getTimed(context, query?.window).then((points) => ({
431
- context,
432
- points: thin(points, query?.resolution)
433
- }))))).reduce((acc, { context, points }) => {
434
- if (matcher(points.map(({ position }) => position))) acc[context] = points;
435
- return acc;
436
- }, {});
437
- }
438
- prune(maxAge) {
439
- const cutoff = Date.now() - maxAge;
440
- const deleted = [];
441
- Object.entries(this.tracks).forEach(([key, value]) => {
442
- if (value.latestLatLngTuple < cutoff) {
443
- delete this.tracks[key];
444
- deleted.push(key);
445
- }
446
- });
447
- if (this.debug.enabled) this.debug(`deleted tracks for ${deleted.join(", ")}`);
448
- }
449
- };
450
- var TrackAccumulator = class {
451
- initialTrack = new BehaviorSubject([]);
452
- input = new Subject();
453
- latestLatLngTuple = 0;
454
- accumulatedTrack;
455
- /** Timestamped points, used to answer time-window queries. */
456
- timedTrack;
457
- /** Positions only — the long-standing public shape. */
458
- track;
459
- constructor({ resolution, pointsToKeep, fetchTrackFor }) {
460
- this.accumulatedTrack = connectable(this.input.pipe(throttleTime(resolution), scan((acc, point) => {
461
- acc.push(point);
462
- return acc.slice(Math.max(0, acc.length - pointsToKeep));
463
- }, []), startWith([])), {
464
- connector: () => new ReplaySubject(1),
465
- resetOnDisconnect: false
466
- });
467
- this.timedTrack = combineLatest([this.initialTrack, this.accumulatedTrack]).pipe(map(([initialTrack, accumulatedTrack]) => [...initialTrack, ...accumulatedTrack]));
468
- this.track = this.timedTrack.pipe(map((points) => points.map(({ position }) => position)));
469
- this.accumulatedTrack.connect();
470
- if (fetchTrackFor) fetchTrack(fetchTrackFor).then((trackGEOJson) => {
471
- const coordinates = trackGEOJson?.coordinates?.[0];
472
- if (coordinates) this.initialTrack.next(coordinates.map((position) => ({
473
- position,
474
- timestamp: 0
475
- })));
476
- });
477
- }
478
- nextLatLngTuple(position, timestamp = Date.now()) {
479
- this.input.next({
480
- position,
481
- timestamp
482
- });
483
- this.latestLatLngTuple = timestamp;
369
+ var historyRowPosition = (row) => {
370
+ if (!Array.isArray(row) || row.length < 2) return;
371
+ const value = row[1];
372
+ if (Array.isArray(value) && value.length === 2 && Number.isFinite(value[0]) && Number.isFinite(value[1])) {
373
+ const [longitude, latitude] = value;
374
+ return [latitude, longitude];
484
375
  }
485
- setInitialTrack(track, timestamps) {
486
- this.initialTrack.next(track.map((position, i) => ({
487
- position,
488
- timestamp: timestamps?.[i] ?? 0
489
- })));
376
+ if (value && typeof value === "object" && "latitude" in value && "longitude" in value) {
377
+ const { latitude, longitude } = value;
378
+ if (Number.isFinite(latitude) && Number.isFinite(longitude)) return [latitude, longitude];
490
379
  }
491
380
  };
492
- var fetchTrack = (context) => {
493
- const contextParts = context.split(".");
494
- if (contextParts[0] !== "vessels") return Promise.resolve({});
495
- return fetch(`/signalk/v1/api/vessels/${contextParts[1]}/track`, { credentials: "include" }).then((r) => r.status === 200 ? r.json() : Promise.resolve({}));
496
- };
497
381
  //#endregion
498
382
  //#region src/trackProvider.ts
499
383
  /** v2 sends `[west, south, east, north]`; a bounds here is `[lat, lng]` corners. */
@@ -614,7 +498,10 @@ var longitudeSpan = (longitudes) => {
614
498
  function createTrackProvider(deps) {
615
499
  const matching = async (query) => {
616
500
  const store = deps.store();
617
- if (!store) return /* @__PURE__ */ new Map();
501
+ if (!store) return {
502
+ tracks: /* @__PURE__ */ new Map(),
503
+ resolution: void 0
504
+ };
618
505
  const window = toTimeWindow(query);
619
506
  const resolution = query.resolution ? totalMilliseconds(query.resolution) : void 0;
620
507
  const wanted = query.contexts?.length ? new Set(query.contexts.map(resolveSelf(deps.selfContext()))) : void 0;
@@ -627,19 +514,22 @@ function createTrackProvider(deps) {
627
514
  ...resolution === void 0 ? {} : { resolution }
628
515
  });
629
516
  const result = /* @__PURE__ */ new Map();
630
- for (const [context, points] of Object.entries(collection)) {
631
- if (wanted && !wanted.has(context)) continue;
517
+ const contexts = Object.entries(collection).filter(([context]) => !wanted || wanted.has(context));
518
+ const reconciled = await Promise.all(contexts.map(async ([context, stored]) => [context, await deps.reconcileWithHistory(context, stored, window, resolution)]));
519
+ for (const [context, points] of reconciled) {
632
520
  if (points.length === 0) continue;
633
521
  result.set(context, points);
634
522
  }
635
- return result;
523
+ return {
524
+ tracks: result,
525
+ resolution
526
+ };
636
527
  };
637
528
  return {
638
529
  async getTracks(query) {
639
- const matched = await matching(query);
530
+ const { tracks: matched, resolution: requested } = await matching(query);
640
531
  const gap = deps.segmentGap();
641
532
  const selfContext = deps.selfContext();
642
- const requested = query.resolution ? totalMilliseconds(query.resolution) : void 0;
643
533
  const features = [];
644
534
  for (const [context, all] of matched) {
645
535
  const { points, resolution: appliedMs } = query.maxPoints === void 0 ? {
@@ -673,7 +563,7 @@ function createTrackProvider(deps) {
673
563
  };
674
564
  },
675
565
  async getTrackContexts(query) {
676
- return [...(await matching(query)).keys()];
566
+ return [...(await matching(query)).tracks.keys()];
677
567
  }
678
568
  };
679
569
  }
@@ -12058,16 +11948,20 @@ var SqliteTrackStore = class {
12058
11948
  }))));
12059
11949
  }
12060
11950
  /**
12061
- * Tracks whose last position matches the spatial predicate.
11951
+ * Tracks matching the spatial predicate, which depends on who is asking.
11952
+ *
11953
+ * `intersects: false` — the v1 routes — matches a track's *last* position:
11954
+ * "which vessels are near me now". `intersects: true` — the v2 Track API —
11955
+ * matches a track that passed through the box at any point in the window,
11956
+ * including one that has since left.
12062
11957
  *
12063
11958
  * Two filters run, and they are not redundant despite both testing bounds:
12064
11959
  *
12065
11960
  * - `contextsInBounds` asks the cell index which contexts have *any* position
12066
11961
  * near the box. It is a cheap prefilter that keeps the query off every row
12067
11962
  * in the table, and it decides nothing on its own.
12068
- * - `matcher` — the same predicate the in-memory store uses — then tests each
12069
- * track's *last* position, which is what the endpoint actually means by
12070
- * "in this box". That test is authoritative.
11963
+ * - `matcher` — the same predicate the in-memory store uses — then applies
11964
+ * whichever rule the caller asked for. That test is authoritative.
12071
11965
  *
12072
11966
  * Because `matcher` has the final say, dropping the exact-bounds check inside
12073
11967
  * `contextsInBounds` does not change any result; it only widens the candidate
@@ -12131,10 +12025,16 @@ var SqliteTrackStore = class {
12131
12025
  if (current.length > 0) result.push(current);
12132
12026
  return result;
12133
12027
  }
12134
- prune(maxAge) {
12028
+ prune(maxAge, keep) {
12135
12029
  const cutoff = Date.now() - maxAge;
12136
- this.db.prepare("DELETE FROM positions WHERE context IN (SELECT context FROM positions GROUP BY context HAVING MAX(timestamp) < ?)").run(cutoff);
12137
- if (this.retention > 0) this.db.prepare("DELETE FROM positions WHERE timestamp < ?").run(Date.now() - this.retention);
12030
+ const dropped = this.db.prepare("SELECT context FROM positions GROUP BY context HAVING MAX(timestamp) < ? AND context IS NOT ?").all(cutoff, keep ?? null);
12031
+ this.db.prepare("DELETE FROM positions WHERE context IN (SELECT context FROM positions GROUP BY context HAVING MAX(timestamp) < ?) AND context IS NOT ?").run(cutoff, keep ?? null);
12032
+ for (const { context } of dropped) this.lastStored.delete(context);
12033
+ if (this.retention > 0) {
12034
+ const oldest = Date.now() - this.retention;
12035
+ if (keep === void 0) this.db.prepare("DELETE FROM positions WHERE timestamp < ?").run(oldest);
12036
+ else this.db.prepare("DELETE FROM positions WHERE timestamp < ? AND context IS ?").run(oldest, keep);
12037
+ }
12138
12038
  }
12139
12039
  close() {
12140
12040
  this.db.close();
@@ -12368,11 +12268,11 @@ var SourceWatch = class {
12368
12268
  };
12369
12269
  //#endregion
12370
12270
  //#region src/stateGate.ts
12371
- /**
12372
- * Off unless a user opts in, per the request and because the states worth
12373
- * pausing on depend on how a boat is used.
12374
- */
12375
- var DEFAULT_PAUSE_STATES = [];
12271
+ var DEFAULT_PAUSE_STATES = [
12272
+ "moored",
12273
+ "not-under-way",
12274
+ "aground"
12275
+ ];
12376
12276
  /**
12377
12277
  * The states a boat is plausibly parked in, offered in the plugin UI.
12378
12278
  *
@@ -12437,25 +12337,150 @@ var StateGate = class {
12437
12337
  }
12438
12338
  };
12439
12339
  //#endregion
12440
- //#region src/index.ts
12340
+ //#region src/tracks.ts
12441
12341
  /**
12442
- * Resolve the effective source, honouring the setting this replaced.
12342
+ * The in-memory store: a bounded sliding window of positions per context, held
12343
+ * in RxJS accumulators and lost on restart.
12443
12344
  *
12444
- * `bootstrapFromHistory` shipped as a boolean defaulting to true, so an
12445
- * installed plugin has it saved in its config either way. Reading `source`
12446
- * first and only then falling back keeps those installs working: an explicit
12447
- * `false` still means "do not touch the History API", and everything else
12448
- * lands on the old default of `history`.
12345
+ * No longer used by the plugin, which always records to SQLite. It remains as
12346
+ * the client-side `TrackAccumulator` this package exports, where a browser
12347
+ * holding one vessel's recent track in memory is exactly the right shape.
12449
12348
  */
12450
- var resolveSource = (config) => {
12451
- if (config.source) return config.source;
12452
- return config.bootstrapFromHistory === false ? "memory" : "history";
12349
+ var Tracks = class {
12350
+ tracks = {};
12351
+ debug;
12352
+ config;
12353
+ constructor(config, debug) {
12354
+ if (debug.enabled) debug(JSON.stringify(config));
12355
+ this.config = config;
12356
+ this.debug = debug;
12357
+ }
12358
+ newPosition(context, position, timestamp) {
12359
+ this.getAccumulator(context)?.nextLatLngTuple(position, timestamp);
12360
+ }
12361
+ initialTrack(context, track, timestamps) {
12362
+ this.getAccumulator(context)?.setInitialTrack(track, timestamps);
12363
+ }
12364
+ getAccumulator(context, createIfMissing = true) {
12365
+ if (context.indexOf("vessels.") === -1 && context.indexOf("aircraft.") === -1) return;
12366
+ let result = this.tracks[context];
12367
+ if (!result && createIfMissing) {
12368
+ const accParams = this.config.fetchInitialTrack ? {
12369
+ ...this.config,
12370
+ fetchTrackFor: context
12371
+ } : { ...this.config };
12372
+ result = this.tracks[context] = new TrackAccumulator(accParams);
12373
+ }
12374
+ return result;
12375
+ }
12376
+ get(context, window) {
12377
+ return this.getTimed(context, window).then((points) => points.map(({ position }) => position));
12378
+ }
12379
+ /**
12380
+ * Track points with their timestamps, optionally narrowed to a time window.
12381
+ *
12382
+ * Rejects with EmptyError if the stream completes without emitting; the route
12383
+ * handlers turn that into the same 404 as an unknown context.
12384
+ */
12385
+ getTimed(context, window) {
12386
+ const accumulator = this.getAccumulator(context, false);
12387
+ if (!accumulator) return Promise.reject(/* @__PURE__ */ new Error(`No track accumulator for ${context}`));
12388
+ return firstValueFrom(accumulator.timedTrack).then((points) => window ? points.filter(({ timestamp }) => timestamp >= window.from && (window.inclusiveEnd ? timestamp <= window.to : timestamp < window.to)) : points);
12389
+ }
12390
+ getAllTracks(query) {
12391
+ return Promise.all(Object.keys(this.tracks).map((context) => this.getTimed(context, query?.window).then((points) => ({
12392
+ context,
12393
+ track: thin(points, query?.resolution).map(({ position }) => position)
12394
+ }))));
12395
+ }
12396
+ async getFilteredTracks(params, selfPosition, debug, query) {
12397
+ const timed = await this.getFilteredTimedTracks(params, selfPosition, debug, query);
12398
+ return Object.fromEntries(Object.entries(timed).map(([context, points]) => [context, points.map(({ position }) => position)]));
12399
+ }
12400
+ /**
12401
+ * The same filtering as `getFilteredTracks`, keeping each point's timestamp.
12402
+ *
12403
+ * The untimed form is derived from this one so the two cannot disagree about
12404
+ * which tracks match — the filter runs once, here.
12405
+ */
12406
+ async getFilteredTimedTracks(params, selfPosition, debug, query) {
12407
+ this.debug(params);
12408
+ this.debug("Self position", selfPosition);
12409
+ const matcher = createMatcher(params, selfPosition, debug);
12410
+ const contexts = Object.keys(this.tracks);
12411
+ return (await Promise.all(contexts.map((context) => this.getTimed(context, query?.window).then((points) => ({
12412
+ context,
12413
+ points: thin(points, query?.resolution)
12414
+ }))))).reduce((acc, { context, points }) => {
12415
+ if (matcher(points.map(({ position }) => position))) acc[context] = points;
12416
+ return acc;
12417
+ }, {});
12418
+ }
12419
+ prune(maxAge, keep) {
12420
+ const cutoff = Date.now() - maxAge;
12421
+ const deleted = [];
12422
+ Object.entries(this.tracks).forEach(([key, value]) => {
12423
+ if (key !== keep && value.latestLatLngTuple < cutoff) {
12424
+ delete this.tracks[key];
12425
+ deleted.push(key);
12426
+ }
12427
+ });
12428
+ if (this.debug.enabled) this.debug(`deleted tracks for ${deleted.join(", ")}`);
12429
+ }
12453
12430
  };
12431
+ var TrackAccumulator = class {
12432
+ initialTrack = new BehaviorSubject([]);
12433
+ input = new Subject();
12434
+ latestLatLngTuple = 0;
12435
+ accumulatedTrack;
12436
+ /** Timestamped points, used to answer time-window queries. */
12437
+ timedTrack;
12438
+ /** Positions only — the long-standing public shape. */
12439
+ track;
12440
+ constructor({ resolution, pointsToKeep, fetchTrackFor }) {
12441
+ this.accumulatedTrack = connectable(this.input.pipe(throttleTime(resolution), scan((acc, point) => {
12442
+ acc.push(point);
12443
+ return acc.slice(Math.max(0, acc.length - pointsToKeep));
12444
+ }, []), startWith([])), {
12445
+ connector: () => new ReplaySubject(1),
12446
+ resetOnDisconnect: false
12447
+ });
12448
+ this.timedTrack = combineLatest([this.initialTrack, this.accumulatedTrack]).pipe(map(([initialTrack, accumulatedTrack]) => [...initialTrack, ...accumulatedTrack]));
12449
+ this.track = this.timedTrack.pipe(map((points) => points.map(({ position }) => position)));
12450
+ this.accumulatedTrack.connect();
12451
+ if (fetchTrackFor) fetchTrack(fetchTrackFor).then((trackGEOJson) => {
12452
+ const coordinates = trackGEOJson?.coordinates?.[0];
12453
+ if (coordinates) this.initialTrack.next(coordinates.map((position) => ({
12454
+ position,
12455
+ timestamp: 0
12456
+ })));
12457
+ });
12458
+ }
12459
+ nextLatLngTuple(position, timestamp = Date.now()) {
12460
+ this.input.next({
12461
+ position,
12462
+ timestamp
12463
+ });
12464
+ this.latestLatLngTuple = timestamp;
12465
+ }
12466
+ setInitialTrack(track, timestamps) {
12467
+ this.initialTrack.next(track.map((position, i) => ({
12468
+ position,
12469
+ timestamp: timestamps?.[i] ?? 0
12470
+ })));
12471
+ }
12472
+ };
12473
+ var fetchTrack = (context) => {
12474
+ const contextParts = context.split(".");
12475
+ if (contextParts[0] !== "vessels") return Promise.resolve({});
12476
+ return fetch(`/signalk/v1/api/vessels/${contextParts[1]}/track`, { credentials: "include" }).then((r) => r.status === 200 ? r.json() : Promise.resolve({}));
12477
+ };
12478
+ //#endregion
12479
+ //#region src/index.ts
12454
12480
  var toLngLat = ([lat, lng]) => [lng, lat];
12455
12481
  var DEFAULT_RESOLUTION = 6e4;
12456
- var DEFAULT_POINTS_TO_KEEP = 120;
12457
- var DEFAULT_MAX_AGE = 600;
12458
- var DEFAULT_MAX_RADIUS = 5e4;
12482
+ var DEFAULT_AIS_RETENTION_DAYS = 30;
12483
+ var PRUNE_INTERVAL_MS = 36e5;
12459
12484
  var DEFAULT_SEGMENT_GAP_MINUTES = 0;
12460
12485
  var SOURCE_STATUS_INTERVAL_MS = 3e4;
12461
12486
  /**
@@ -12477,10 +12502,6 @@ var HISTORY_QUERY_TIMEOUT_MS = 5e3;
12477
12502
  * `duration` or `timespan` reaches further.
12478
12503
  */
12479
12504
  var WINDOWLESS_HISTORY_SPAN_MS = 864e5;
12480
- var BOOTSTRAP_INITIAL_DELAY = 5e3;
12481
- var BOOTSTRAP_RETRY_DELAY = 15e3;
12482
- var BOOTSTRAP_MAX_ATTEMPTS = 18;
12483
- var BOOTSTRAP_MAX_NO_PROVIDER = 3;
12484
12505
  /**
12485
12506
  * Config values arrive from the plugin UI as numbers, but a hand-edited
12486
12507
  * settings file can supply strings. Accept both, reject anything non-finite so
@@ -12497,27 +12518,7 @@ var notAvailable = (res) => {
12497
12518
  res.status(404);
12498
12519
  res.json({ message: "Tracks API not available because tracks plugin is not enabled" });
12499
12520
  };
12500
- var sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
12501
12521
  var errorDetail = (err) => err instanceof Error && err.stack ? err.stack : String(err);
12502
- var isNoProviderError = (err) => {
12503
- const text = String(err);
12504
- return text.includes("No history") && text.includes("provider");
12505
- };
12506
- /**
12507
- * A history row is `[timestamp, position]`. Providers disagree on how the
12508
- * position is encoded: signalk-questdb returns `{latitude, longitude}`, while
12509
- * a `[lon, lat]` pair is the shape the History API's own docs describe. Accept
12510
- * both — rejecting either silently bootstraps an empty track.
12511
- */
12512
- var historyRowPosition = (row) => {
12513
- if (!Array.isArray(row) || row.length < 2) return;
12514
- const value = row[1];
12515
- if (Array.isArray(value) && value.length === 2 && typeof value[0] === "number" && typeof value[1] === "number") return [value[1], value[0]];
12516
- if (value && typeof value === "object" && "latitude" in value && "longitude" in value) {
12517
- const { latitude, longitude } = value;
12518
- if (typeof latitude === "number" && typeof longitude === "number") return [latitude, longitude];
12519
- }
12520
- };
12521
12522
  /**
12522
12523
  * A window covering everything the store holds, up to now.
12523
12524
  *
@@ -12617,92 +12618,6 @@ var historyRowTimestamp = (row) => {
12617
12618
  const parsed = Date.parse(raw);
12618
12619
  return Number.isNaN(parsed) ? 0 : parsed;
12619
12620
  };
12620
- async function bootstrapSelfTrack(app, tracks, config, glitchFilter) {
12621
- const { debug } = app;
12622
- const getHistoryApi = app.getHistoryApi;
12623
- if (!getHistoryApi) {
12624
- debug("getHistoryApi not available on server, skipping track bootstrap");
12625
- return;
12626
- }
12627
- if (!app.selfContext) {
12628
- debug("selfContext not available, skipping track bootstrap");
12629
- return;
12630
- }
12631
- const configuredProvider = app.config?.settings?.historyApi?.defaultProvider;
12632
- const resolution = toNumber(config.resolution) ?? DEFAULT_RESOLUTION;
12633
- const timespanMs = resolution * (toNumber(config.pointsToKeep) ?? DEFAULT_POINTS_TO_KEEP);
12634
- const resolutionSecs = Math.max(1, Math.round(resolution / 1e3));
12635
- const timespanMinutes = Math.round(timespanMs / 1e3 / 60);
12636
- debug(`Track bootstrap: requesting ${timespanMinutes} minutes of history at ${resolutionSecs}s resolution (max ${BOOTSTRAP_MAX_ATTEMPTS} attempts)`);
12637
- let noProviderCount = 0;
12638
- for (let attempt = 1; attempt <= BOOTSTRAP_MAX_ATTEMPTS; attempt++) {
12639
- const delay = attempt === 1 ? BOOTSTRAP_INITIAL_DELAY : BOOTSTRAP_RETRY_DELAY;
12640
- debug(`Track bootstrap attempt ${attempt}/${BOOTSTRAP_MAX_ATTEMPTS}, waiting ${delay / 1e3}s...`);
12641
- await sleep(delay);
12642
- try {
12643
- const historyApi = await getHistoryApi(configuredProvider);
12644
- noProviderCount = 0;
12645
- const to = /* @__PURE__ */ new Date();
12646
- const from = new Date(to.getTime() - timespanMs);
12647
- const response = await historyApi.getValues({
12648
- context: app.selfContext,
12649
- from: Temporal.Instant.from(from.toISOString()),
12650
- to: Temporal.Instant.from(to.toISOString()),
12651
- pathSpecs: [{
12652
- path: "navigation.position",
12653
- aggregate: "first"
12654
- }],
12655
- resolution: resolutionSecs
12656
- });
12657
- if (response?.data && response.data.length > 0) {
12658
- const positions = [];
12659
- const timestamps = [];
12660
- let dropped = 0;
12661
- for (const row of response.data) {
12662
- const position = historyRowPosition(row);
12663
- if (position) {
12664
- const timestamp = historyRowTimestamp(row);
12665
- if (timestamp === 0) {
12666
- dropped++;
12667
- continue;
12668
- }
12669
- if (!glitchFilter.accept(app.selfContext, position, timestamp)) {
12670
- dropped++;
12671
- continue;
12672
- }
12673
- positions.push(position);
12674
- timestamps.push(timestamp);
12675
- }
12676
- }
12677
- if (dropped > 0) debug(`Track bootstrap discarded ${dropped} position(s) as glitches`);
12678
- if (positions.length > 0) {
12679
- tracks.initialTrack(app.selfContext, positions, timestamps);
12680
- debug(`Track bootstrap complete: loaded ${positions.length} positions for self (${timespanMinutes} min window) on attempt ${attempt}`);
12681
- return;
12682
- }
12683
- }
12684
- debug(`History API returned no position data on attempt ${attempt}/${BOOTSTRAP_MAX_ATTEMPTS}; the configured provider may not have registered yet`);
12685
- if (attempt === BOOTSTRAP_MAX_ATTEMPTS) {
12686
- debug("History API returned no position data for bootstrap");
12687
- return;
12688
- }
12689
- continue;
12690
- } catch (err) {
12691
- if (isNoProviderError(err)) {
12692
- noProviderCount++;
12693
- debug(`Track bootstrap attempt ${attempt}/${BOOTSTRAP_MAX_ATTEMPTS}: no history provider registered yet (${noProviderCount}/${BOOTSTRAP_MAX_NO_PROVIDER})`);
12694
- if (noProviderCount >= BOOTSTRAP_MAX_NO_PROVIDER) {
12695
- debug(`No history provider registered after ${BOOTSTRAP_MAX_NO_PROVIDER} consecutive checks — no provider plugin appears to be installed. Giving up.`);
12696
- return;
12697
- }
12698
- } else {
12699
- noProviderCount = 0;
12700
- debug(`Track bootstrap attempt ${attempt}/${BOOTSTRAP_MAX_ATTEMPTS} failed: ${errorDetail(err)}`);
12701
- }
12702
- if (attempt === BOOTSTRAP_MAX_ATTEMPTS) app.error(`Track bootstrap from History API failed after ${BOOTSTRAP_MAX_ATTEMPTS} attempts. Tracks will start empty and accumulate from live data.`);
12703
- }
12704
- }
12705
- }
12706
12621
  function ThePlugin(app) {
12707
12622
  let onStop = [];
12708
12623
  let tracks = void 0;
@@ -12715,7 +12630,6 @@ function ThePlugin(app) {
12715
12630
  * own.
12716
12631
  */
12717
12632
  let storeResolution = DEFAULT_RESOLUTION;
12718
- let defaultMaxRadius = void 0;
12719
12633
  const sourceWatch = new SourceWatch();
12720
12634
  let glitchFilter = new GlitchFilter({ maxSpeedKnots: 100 });
12721
12635
  let stateGate = new StateGate(app.selfContext, DEFAULT_PAUSE_STATES);
@@ -12736,25 +12650,28 @@ function ThePlugin(app) {
12736
12650
  }
12737
12651
  return {
12738
12652
  start: function(config) {
12739
- const { resolution, pointsToKeep, maxAge, maxRadius } = config;
12740
- defaultMaxRadius = toNumber(maxRadius);
12741
- const source = resolveSource(config);
12653
+ const { resolution } = config;
12742
12654
  storeResolution = toNumber(config.resolution) ?? DEFAULT_RESOLUTION;
12743
12655
  const segmentGapMinutes = toNumber(config.segmentGapMinutes) ?? DEFAULT_SEGMENT_GAP_MINUTES;
12744
12656
  segmentGap = segmentGapMinutes > 0 ? segmentGapMinutes * 60 * 1e3 : 0;
12745
12657
  glitchFilter = new GlitchFilter({ maxSpeedKnots: toNumber(config.maxSpeedKnots) ?? 100 });
12746
12658
  stateGate = new StateGate(app.selfContext, Array.isArray(config.pauseWhenState) ? config.pauseWhenState : DEFAULT_PAUSE_STATES);
12747
- const dataDir = source === "sqlite" ? app.getDataDirPath?.() : void 0;
12748
- if (source === "sqlite" && !dataDir) app.error("source is sqlite but this server provides no plugin data directory; using memory instead");
12749
- tracks = dataDir ? new SqliteTrackStore({
12750
- file: join(dataDir, "tracks.db"),
12751
- resolution: toNumber(resolution) ?? DEFAULT_RESOLUTION,
12752
- retention: (toNumber(config.retentionDays) ?? 0) * 24 * 60 * 60 * 1e3,
12753
- segmentGap
12754
- }, app.debug) : new Tracks({
12755
- resolution: toNumber(resolution) ?? DEFAULT_RESOLUTION,
12756
- pointsToKeep: toNumber(pointsToKeep) ?? DEFAULT_POINTS_TO_KEEP
12757
- }, app.debug);
12659
+ const dataDir = app.getDataDirPath?.();
12660
+ if (!dataDir) {
12661
+ app.error("This server provides no plugin data directory, so tracks cannot be recorded.");
12662
+ return;
12663
+ }
12664
+ try {
12665
+ tracks = new SqliteTrackStore({
12666
+ file: join(dataDir, "tracks.db"),
12667
+ resolution: toNumber(resolution) ?? DEFAULT_RESOLUTION,
12668
+ retention: (toNumber(config.retentionDays) ?? 0) * 24 * 60 * 60 * 1e3,
12669
+ segmentGap
12670
+ }, app.debug);
12671
+ } catch (err) {
12672
+ app.error(`Could not open the track database in ${dataDir}: ${errorDetail(err)}`);
12673
+ return;
12674
+ }
12758
12675
  onStop.push(app.streambundle.getBus("navigation.position").onValue((update) => {
12759
12676
  if (!update.value || update.value.latitude == null || update.value.longitude == null) return;
12760
12677
  sourceWatch.add(update.context, update.$source);
@@ -12765,8 +12682,10 @@ function ThePlugin(app) {
12765
12682
  if (!glitchFilter.accept(update.context, position, timestamp ?? Date.now())) return;
12766
12683
  tracks?.newPosition(update.context, position, timestamp);
12767
12684
  }));
12768
- const theMaxAge = toNumber(maxAge) ?? DEFAULT_MAX_AGE;
12769
- const pruneInterval = setInterval(() => tracks?.prune(theMaxAge * 1e3), theMaxAge * 1e3 / 2);
12685
+ const aisRetentionMs = (toNumber(config.aisRetentionDays) ?? DEFAULT_AIS_RETENTION_DAYS) * 24 * 60 * 60 * 1e3;
12686
+ const pruneInterval = setInterval(() => {
12687
+ tracks?.prune(aisRetentionMs > 0 ? aisRetentionMs : Infinity, app.selfContext);
12688
+ }, PRUNE_INTERVAL_MS);
12770
12689
  onStop.push(() => {
12771
12690
  clearInterval(pruneInterval);
12772
12691
  });
@@ -12784,11 +12703,13 @@ function ThePlugin(app) {
12784
12703
  app.registerTrackApiProvider?.(createTrackProvider({
12785
12704
  store: () => tracks,
12786
12705
  selfContext: () => app.selfContext,
12787
- segmentGap: () => segmentGap
12706
+ segmentGap: () => segmentGap,
12707
+ reconcileWithHistory: async (context, stored, window, resolutionMs) => {
12708
+ const effective = resolutionMs ?? storeResolution;
12709
+ const history = await historyPositions(app, context, window ?? windowSpanning(stored, WINDOWLESS_HISTORY_SPAN_MS), effective, app.debug);
12710
+ return history.points.length ? reconcile(history.points, stored, history.resolutionMs).positions : stored;
12711
+ }
12788
12712
  }));
12789
- if (source === "history") bootstrapSelfTrack(app, tracks, config, glitchFilter).catch((err) => {
12790
- app.error(`Unexpected error in track bootstrap: ${errorDetail(err)}`);
12791
- });
12792
12713
  },
12793
12714
  stop: function() {
12794
12715
  onStop.forEach((f) => {
@@ -12806,6 +12727,8 @@ function ThePlugin(app) {
12806
12727
  tracks?.close?.();
12807
12728
  } catch (err) {
12808
12729
  app.error(err);
12730
+ } finally {
12731
+ tracks = void 0;
12809
12732
  }
12810
12733
  },
12811
12734
  signalKApiRoutes: function(router) {
@@ -12869,7 +12792,7 @@ function ThePlugin(app) {
12869
12792
  res.json({ message: err instanceof TimeWindowError ? err.message : "Invalid query parameters" });
12870
12793
  return;
12871
12794
  }
12872
- const params = validateParameters(req.query, defaultMaxRadius);
12795
+ const params = validateParameters(req.query, void 0);
12873
12796
  const selfPosition = getVesselPosition();
12874
12797
  (query.times ? tracks.getFilteredTimedTracks(params, selfPosition, app.debug, query).then((tc) => Object.entries(tc).reduce((acc, [context, points]) => {
12875
12798
  const segments = segment(points, segmentGap);
@@ -12901,55 +12824,31 @@ function ThePlugin(app) {
12901
12824
  getTracks: () => tracks,
12902
12825
  id: "tracks",
12903
12826
  name: "Tracks",
12904
- description: "Accumulate tracks in memory for the track API implementation",
12827
+ enabledByDefault: true,
12828
+ description: "Record vessel tracks to SQLite and serve them through the track API",
12905
12829
  schema: {
12906
12830
  type: "object",
12907
12831
  properties: {
12908
12832
  resolution: {
12909
12833
  type: "integer",
12834
+ minimum: 0,
12910
12835
  title: "Track resolution (milliseconds)",
12911
12836
  default: DEFAULT_RESOLUTION
12912
12837
  },
12913
- pointsToKeep: {
12914
- type: "integer",
12915
- title: "Points to keep",
12916
- description: "How many trackpoints to keep for each track",
12917
- default: DEFAULT_POINTS_TO_KEEP
12918
- },
12919
- maxAge: {
12920
- type: "integer",
12921
- title: "Maximum idle time (seconds)",
12922
- description: "Tracks with no updates longer than this are removed",
12923
- default: DEFAULT_MAX_AGE
12924
- },
12925
- maxRadius: {
12926
- type: "integer",
12927
- title: "Maximum Radius (meters) ",
12928
- description: "Include only vessels with position within this range. 0= all vessels",
12929
- default: DEFAULT_MAX_RADIUS
12930
- },
12931
- source: {
12932
- type: "string",
12933
- title: "Where tracks come from after a restart",
12934
- description: "In memory only: start empty and accumulate from live positions. History API: refill on startup from a history provider such as signalk-to-influxdb2 or signalk-questdb. SQLite: record positions to a database file so they survive a restart with no other plugin required.",
12935
- enum: [
12936
- "memory",
12937
- "history",
12938
- "sqlite"
12939
- ],
12940
- enumNames: [
12941
- "In memory only",
12942
- "Load from the History API on startup",
12943
- "Record to a SQLite database"
12944
- ],
12945
- default: "history"
12946
- },
12947
12838
  retentionDays: {
12948
12839
  type: "integer",
12949
- title: "Days of track history to keep (SQLite only)",
12950
- description: "Positions older than this are deleted. 0 keeps everything.",
12840
+ minimum: 0,
12841
+ title: "Days of the own vessel's track to keep",
12842
+ description: "Positions older than this are deleted. 0, the default, keeps everything — a track is worth more the longer it goes back, and a year of one vessel at the default resolution is a few megabytes.",
12951
12843
  default: 0
12952
12844
  },
12845
+ aisRetentionDays: {
12846
+ type: "integer",
12847
+ minimum: 0,
12848
+ title: "Days to keep another vessel after its last fix",
12849
+ description: "The own vessel is never removed. Other vessels are: a busy harbour puts hundreds of AIS targets past the receiver in a day, and keeping all of them forever is rarely what anyone wants. 0 keeps every vessel indefinitely.",
12850
+ default: DEFAULT_AIS_RETENTION_DAYS
12851
+ },
12953
12852
  segmentGapMinutes: {
12954
12853
  type: "integer",
12955
12854
  title: "Split a track after this many minutes without a fix",
@@ -12978,6 +12877,6 @@ function ThePlugin(app) {
12978
12877
  };
12979
12878
  }
12980
12879
  //#endregion
12981
- export { TrackAccumulator, Tracks, ThePlugin as default, resolveSource };
12880
+ export { TrackAccumulator, Tracks, ThePlugin as default };
12982
12881
 
12983
12882
  //# sourceMappingURL=index.js.map