@signalk/tracks-plugin 3.2.0 → 3.4.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.
@@ -1,6 +1,6 @@
1
1
  import { TrackQuery } from './timeWindow.js';
2
2
  import { TrackStore } from './store.js';
3
- import { BinEntry, Context, Debug, ImportedTrack, ImportFilter, LatLngTuple, RecordedSpan, RestoreResult, TimedPosition, TimedTrackCollection, TimeWindow, TrackCollection, TrackParams } from './types.js';
3
+ import { BinEntry, Context, Debug, ImportedTrack, StoredImport, ImportFilter, LatLngTuple, RecordedSpan, RestoreResult, TimedPosition, TimedTrackCollection, TimeWindow, TrackCollection, TrackParams } from './types.js';
4
4
  export declare class TransactionStateError extends Error {
5
5
  }
6
6
  export interface SqliteStoreConfig {
@@ -132,6 +132,15 @@ export declare class SqliteTrackStore implements TrackStore {
132
132
  * same id fails rather than replacing the first.
133
133
  */
134
134
  storeImport(track: ImportedTrack): void;
135
+ /**
136
+ * Write a track imported for a vessel into its recording, in place of what
137
+ * was recorded over the same time. The positions within each segment's
138
+ * span go to the recycle bin first, one entry per segment, so a restore
139
+ * brings them back beside the import. Every span is binned before any point
140
+ * is written, so segments that overlap in time cannot bin each other.
141
+ * Resolves to the bin entries made.
142
+ */
143
+ recordTrack(context: Context, segments: TimedPosition[][], deletedAt: number, keepEmpty: boolean): number[];
135
144
  /**
136
145
  * Move a vessel's positions recorded within `[from, to]` into the recycle
137
146
  * bin, resolving to the entry's id and whether the store held the vessel at
@@ -176,9 +185,9 @@ export declare class SqliteTrackStore implements TrackStore {
176
185
  * earlier with it, since they have no track left to be restored into.
177
186
  */
178
187
  private purge;
179
- getImport(id: string): ImportedTrack | undefined;
188
+ getImport(id: string): StoredImport | undefined;
180
189
  /** Imported tracks the filter lets through, in the order they were stored. */
181
- findImports(filter?: ImportFilter): ImportedTrack[];
190
+ findImports(filter?: ImportFilter): StoredImport[];
182
191
  private importFrom;
183
192
  /** Worker startup snapshot; queries and name lookups must not open a second database. */
184
193
  storedNames(): {
package/dist/store.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { BinEntry, Context, Debug, ImportedTrack, ImportFilter, LatLngTuple, RecordedSpan, RestoreResult, TimedPosition, TimedTrackCollection, TimeWindow, TrackCollection, TrackParams } from './types.js';
1
+ import { BinEntry, Context, Debug, ImportedTrack, StoredImport, ImportFilter, LatLngTuple, RecordedSpan, RestoreResult, TimedPosition, TimedTrackCollection, TimeWindow, TrackCollection, TrackParams } from './types.js';
2
2
  import { TrackQuery } from './timeWindow.js';
3
3
  /**
4
4
  * A track store: somewhere positions are accumulated and queried back.
@@ -96,8 +96,8 @@ export interface TrackStore {
96
96
  * back at once.
97
97
  */
98
98
  storeImport?(track: ImportedTrack): void | Promise<void>;
99
- getImport?(id: string): ImportedTrack | undefined | Promise<ImportedTrack | undefined>;
100
- findImports?(filter?: ImportFilter): ImportedTrack[] | Promise<ImportedTrack[]>;
99
+ getImport?(id: string): StoredImport | undefined | Promise<StoredImport | undefined>;
100
+ findImports?(filter?: ImportFilter): StoredImport[] | Promise<StoredImport[]>;
101
101
  /**
102
102
  * The recycle bin. Optional like the import methods: a store without it
103
103
  * cannot delete. A delete moves rows into the bin rather than removing them,
@@ -113,6 +113,11 @@ export interface TrackStore {
113
113
  id?: number;
114
114
  known: boolean;
115
115
  }>;
116
+ /**
117
+ * Write a track imported for a vessel into its recording, binning what was
118
+ * recorded over the same time; resolves to the bin entries made.
119
+ */
120
+ recordTrack?(context: Context, segments: TimedPosition[][], deletedAt: number, keepEmpty: boolean): number[] | Promise<number[]>;
116
121
  binImport?(importId: string, span: {
117
122
  from?: number;
118
123
  to?: number;
@@ -59,6 +59,8 @@ export interface TrackProperties {
59
59
  contextName?: string;
60
60
  /** The track's own name, as an imported GPX `<trk><name>` carries. */
61
61
  name?: string;
62
+ /** When a client stored the track, as an ISO 8601 time; absent on a recorded one. */
63
+ imported?: string;
62
64
  /** Absent, with `to`, for a track whose points carry no times. */
63
65
  from?: string;
64
66
  to?: string;
@@ -1,6 +1,6 @@
1
1
  import { TrackStore } from './store.js';
2
2
  import { TrackApi } from './trackApi.js';
3
- import { TimedPosition, TimeWindow } from './types.js';
3
+ import { ImportedTrack, TimedPosition, TimeWindow } from './types.js';
4
4
  /**
5
5
  * Serves the v2 Track API from this plugin's store.
6
6
  *
@@ -54,5 +54,65 @@ export interface TrackProviderDeps {
54
54
  /** Called after a delete, so whatever hides deleted spans from history reloads them. */
55
55
  deleted: () => void;
56
56
  reconcileWithHistory: (context: string, stored: TimedPosition[], window: TimeWindow | undefined, resolutionMs: number | undefined) => Promise<TimedPosition[]>;
57
+ /**
58
+ * What the store and history hold for a context over a window, rejecting
59
+ * when history could not be read: an import that fills only the gaps
60
+ * cannot tell a gap from an outage.
61
+ */
62
+ recordedOver: (context: string, window: TimeWindow) => Promise<TimedPosition[]>;
63
+ }
64
+ /** The Track API provider, plus the import route's way in with an overlap choice. */
65
+ export type TrackProvider = TrackApi & {
66
+ importTrack(track: ImportedTrack, onOverlap: OnOverlap): Promise<KeptTrack>;
67
+ };
68
+ export declare function createTrackProvider(deps: TrackProviderDeps): TrackProvider;
69
+ /**
70
+ * What an import does where the vessel's recording already holds positions.
71
+ * `keep` leaves them alone and fills only the stretches with nothing; `replace`
72
+ * puts the import in their place, sending them to the recycle bin.
73
+ */
74
+ export type OnOverlap = 'keep' | 'replace';
75
+ /**
76
+ * The shortest stretch without positions that an import kept beside a
77
+ * recording may fill. Filling every pause between recorded points would
78
+ * interleave two sources minute by minute, and any offset between them would
79
+ * draw as a sawtooth; whole stretches keep each part of the line from one.
80
+ */
81
+ export declare const MIN_FILLED_GAP_MS: number;
82
+ export interface KeepTrackDeps {
83
+ store: Required<Pick<TrackStore, 'storeImport' | 'recordTrack'>>;
84
+ selfContext: string;
85
+ historyKnows: (context: string) => Promise<boolean>;
86
+ /** The vessel's positions over a window, from the store and history alike. */
87
+ recorded: (context: string, from: number, to: number) => Promise<TimedPosition[]>;
88
+ deleted: () => void;
89
+ }
90
+ export interface KeptTrack {
91
+ id: string;
92
+ /** Points left out because the vessel already had positions there. */
93
+ overlapping: number;
57
94
  }
58
- export declare function createTrackProvider(deps: TrackProviderDeps): TrackApi;
95
+ /**
96
+ * Keep a client's track, resolving to the id that addresses it.
97
+ *
98
+ * A timed track of the own vessel is the vessel's own passage, from before
99
+ * the plugin recorded it or from another plotter, so it is written into the
100
+ * recording and answers as `recorded:<context>`: with `keep`, only where the
101
+ * recording has nothing (see {@link fillGaps}); with `replace`, in place of
102
+ * what was recorded over the same time. Everything else is kept as an import:
103
+ * another vessel's track is not this boat's recording, and an untimed one
104
+ * cannot be placed in it.
105
+ */
106
+ export declare function keepTrack(track: ImportedTrack, deps: KeepTrackDeps, onOverlap: OnOverlap): Promise<KeptTrack>;
107
+ /**
108
+ * The parts of an import that fall where a recording has nothing for at least
109
+ * `minGap`, as runs to record, and how many points were left out.
110
+ *
111
+ * Each recorded point covers its own instant, or a history bucket's whole
112
+ * span, so a coarse provider read still counts as covering the time between
113
+ * its buckets. A run never crosses a recorded stretch or a segment boundary.
114
+ */
115
+ export declare function fillGaps(segments: TimedPosition[][], recorded: readonly TimedPosition[], minGap: number): {
116
+ runs: TimedPosition[][];
117
+ overlapping: number;
118
+ };
@@ -1,5 +1,5 @@
1
1
  import { SqliteTrackStore } from './sqliteStore.js';
2
- export type StoreMethod = 'newPosition' | 'recordName' | 'initialTrack' | 'prune' | 'get' | 'getTimed' | 'getAllTracks' | 'getFilteredTracks' | 'getFilteredTimedTracks' | 'storeImport' | 'getImport' | 'findImports' | 'binRecorded' | 'binImport' | 'restoreFromBin' | 'purgeFromBin' | 'binEntries' | 'deletedSpans' | 'close';
2
+ export type StoreMethod = 'newPosition' | 'recordName' | 'initialTrack' | 'prune' | 'get' | 'getTimed' | 'getAllTracks' | 'getFilteredTracks' | 'getFilteredTimedTracks' | 'storeImport' | 'getImport' | 'findImports' | 'binRecorded' | 'recordTrack' | 'binImport' | 'restoreFromBin' | 'purgeFromBin' | 'binEntries' | 'deletedSpans' | 'close';
3
3
  export type Operation = {
4
4
  [K in StoreMethod]: {
5
5
  id: number;
@@ -11305,6 +11305,7 @@ var coveringFor = (bounds, maxCells) => {
11305
11305
  });
11306
11306
  });
11307
11307
  };
11308
+ var SELECT_IMPORTS = "SELECT * FROM imported_tracks JOIN import_times USING (id)";
11308
11309
  /** Longitude intervals as `[west, east]`, either of which may cross the antimeridian. */
11309
11310
  var longitudesOverlap = (a, b) => {
11310
11311
  const pieces = ([west, east]) => west <= east ? [[west, east]] : [[west, 180], [-180, east]];
@@ -11398,6 +11399,13 @@ var SqliteTrackStore = class {
11398
11399
  north REAL NOT NULL
11399
11400
  );
11400
11401
  CREATE INDEX IF NOT EXISTS idx_imported_context ON imported_tracks(context);
11402
+ -- When each import was kept. Its own table, like names, so a database
11403
+ -- written before it existed opens unchanged; it stays while the import
11404
+ -- is in the recycle bin, for a restore, and goes when that is purged.
11405
+ CREATE TABLE IF NOT EXISTS import_times (
11406
+ id TEXT PRIMARY KEY,
11407
+ imported_at INTEGER NOT NULL
11408
+ );
11401
11409
  CREATE TABLE IF NOT EXISTS imported_points (
11402
11410
  track_id TEXT NOT NULL,
11403
11411
  seq INTEGER NOT NULL,
@@ -11455,6 +11463,9 @@ var SqliteTrackStore = class {
11455
11463
  );
11456
11464
  CREATE INDEX IF NOT EXISTS idx_binned_imported_points ON binned_imported_points(bin_id);
11457
11465
  `);
11466
+ const now = Date.now();
11467
+ this.db.prepare(`INSERT OR IGNORE INTO import_times (id, imported_at)
11468
+ SELECT id, ? FROM imported_tracks UNION SELECT id, ? FROM binned_imported_tracks`).run(now, now);
11458
11469
  this.insert = this.db.prepare("INSERT INTO positions (context, timestamp, lat, lon, s2cell) VALUES (?, ?, ?, ?, ?)");
11459
11470
  this.insertName = this.db.prepare(`INSERT INTO names (context, name, timestamp) VALUES (?, ?, ?)
11460
11471
  ON CONFLICT(context) DO UPDATE SET name = excluded.name, timestamp = excluded.timestamp
@@ -11692,6 +11703,7 @@ var SqliteTrackStore = class {
11692
11703
  storeImport(track) {
11693
11704
  const points = track.segments.flat();
11694
11705
  if (points.length === 0) throw new Error("An imported track needs at least one point");
11706
+ if (this.db.prepare("SELECT 1 AS found FROM import_times WHERE id = ?").get(track.id)) throw new Error(`An import with id ${track.id} already exists`);
11695
11707
  let south = points[0].position[0];
11696
11708
  let north = south;
11697
11709
  let firstTime;
@@ -11707,11 +11719,32 @@ var SqliteTrackStore = class {
11707
11719
  const [west, east] = longitudeSpan(points.map(({ position }) => position[1]));
11708
11720
  this.db.prepare(`INSERT INTO imported_tracks (id, context, name, metadata, first_time, last_time, west, south, east, north)
11709
11721
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(track.id, track.context ?? null, track.name ?? null, JSON.stringify(track.metadata), firstTime ?? null, lastTime ?? null, west, south, east, north);
11722
+ this.db.prepare("INSERT INTO import_times (id, imported_at) VALUES (?, ?)").run(track.id, Date.now());
11710
11723
  const insert = this.db.prepare("INSERT INTO imported_points (track_id, seq, segment, timestamp, lat, lon) VALUES (?, ?, ?, ?, ?, ?)");
11711
11724
  let seq = 0;
11712
11725
  for (const [segment, segmentPoints] of track.segments.entries()) for (const { position, timestamp } of segmentPoints) insert.run(track.id, seq++, segment, timestamp ?? null, position[0], position[1]);
11713
11726
  }
11714
11727
  /**
11728
+ * Write a track imported for a vessel into its recording, in place of what
11729
+ * was recorded over the same time. The positions within each segment's
11730
+ * span go to the recycle bin first, one entry per segment, so a restore
11731
+ * brings them back beside the import. Every span is binned before any point
11732
+ * is written, so segments that overlap in time cannot bin each other.
11733
+ * Resolves to the bin entries made.
11734
+ */
11735
+ recordTrack(context, segments, deletedAt, keepEmpty) {
11736
+ const binned = [];
11737
+ for (const points of segments) {
11738
+ const first = points[0];
11739
+ const last = points.at(-1);
11740
+ if (first === void 0 || last === void 0) continue;
11741
+ const { id } = this.binRecorded(context, first.timestamp, last.timestamp, deletedAt, keepEmpty);
11742
+ if (id !== void 0) binned.push(id);
11743
+ }
11744
+ for (const points of segments) for (const { position, timestamp } of points) this.store(context, position, timestamp);
11745
+ return binned;
11746
+ }
11747
+ /**
11715
11748
  * Move a vessel's positions recorded within `[from, to]` into the recycle
11716
11749
  * bin, resolving to the entry's id and whether the store held the vessel at
11717
11750
  * all.
@@ -11861,9 +11894,10 @@ var SqliteTrackStore = class {
11861
11894
  if (entry.import_id === null) this.db.prepare("UPDATE recycle_bin SET purged = 1 WHERE id = ?").run(id);
11862
11895
  else this.db.prepare("DELETE FROM recycle_bin WHERE id = ?").run(id);
11863
11896
  }
11897
+ if (entry.whole === 1 && entry.import_id !== null) this.db.prepare("DELETE FROM import_times WHERE id = ?").run(entry.import_id);
11864
11898
  }
11865
11899
  getImport(id) {
11866
- const row = this.db.prepare("SELECT * FROM imported_tracks WHERE id = ?").get(id);
11900
+ const row = this.db.prepare(`${SELECT_IMPORTS} WHERE id = ?`).get(id);
11867
11901
  return row ? this.importFrom(row) : void 0;
11868
11902
  }
11869
11903
  /** Imported tracks the filter lets through, in the order they were stored. */
@@ -11884,7 +11918,7 @@ var SqliteTrackStore = class {
11884
11918
  params.push(filter.bbox.ne[0], filter.bbox.sw[0]);
11885
11919
  }
11886
11920
  const where = clauses.length > 0 ? `WHERE ${clauses.join(" AND ")}` : "";
11887
- let rows = this.db.prepare(`SELECT * FROM imported_tracks ${where} ORDER BY rowid`).all(...params);
11921
+ let rows = this.db.prepare(`${SELECT_IMPORTS} ${where} ORDER BY imported_tracks.rowid`).all(...params);
11888
11922
  const box = filter.bbox;
11889
11923
  if (box) rows = rows.filter((row) => longitudesOverlap([row.west, row.east], [box.sw[1], box.ne[1]]));
11890
11924
  return rows.map((row) => this.importFrom(row));
@@ -11910,7 +11944,8 @@ var SqliteTrackStore = class {
11910
11944
  ...row.context === null ? {} : { context: row.context },
11911
11945
  ...row.name === null ? {} : { name: row.name },
11912
11946
  metadata: JSON.parse(row.metadata),
11913
- segments
11947
+ segments,
11948
+ importedAt: row.imported_at
11914
11949
  };
11915
11950
  }
11916
11951
  /** Worker startup snapshot; queries and name lookups must not open a second database. */
@@ -11999,6 +12034,7 @@ function mutate(operation, target) {
11999
12034
  var ACKNOWLEDGED = /* @__PURE__ */ new Set([
12000
12035
  "storeImport",
12001
12036
  "binRecorded",
12037
+ "recordTrack",
12002
12038
  "binImport",
12003
12039
  "restoreFromBin",
12004
12040
  "purgeFromBin"
@@ -12007,6 +12043,7 @@ function acknowledge(operation, target) {
12007
12043
  switch (operation.method) {
12008
12044
  case "storeImport": return target.storeImport(...operation.args);
12009
12045
  case "binRecorded": return target.binRecorded(...operation.args);
12046
+ case "recordTrack": return target.recordTrack(...operation.args);
12010
12047
  case "binImport": return target.binImport(...operation.args);
12011
12048
  case "restoreFromBin": return target.restoreFromBin(...operation.args);
12012
12049
  case "purgeFromBin": return target.purgeFromBin(...operation.args);