@signalk/tracks-plugin 3.1.1 → 3.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.
@@ -0,0 +1,20 @@
1
+ import { DeletedSpan, TimedPosition } from './types.js';
2
+ /**
3
+ * Prefix of a recorded track's id. The rest is the vessel's context: a vessel
4
+ * has one recording, so its context is all an id needs, and a provider may
5
+ * derive the id from it, as the server's Track API foresees.
6
+ */
7
+ export declare const RECORDED_PREFIX = "recorded:";
8
+ export declare const recordedId: (context: string) => string;
9
+ /** The context a recorded track's id names, or undefined for any other id. */
10
+ export declare const recordedContext: (id: string) => string | undefined;
11
+ /**
12
+ * The points outside every deleted span.
13
+ *
14
+ * Applied to what a history provider returns, which this plugin cannot delete
15
+ * from: without it the next query would fill a deleted span back in. A
16
+ * history point stands for a bucket starting at its timestamp, and the fix it
17
+ * carries can lie anywhere in that bucket, so a bucket that overlaps a span at
18
+ * all is dropped; the store's own points answer for the rest of it.
19
+ */
20
+ export declare function outsideSpans(points: TimedPosition[], spans: readonly DeletedSpan[]): TimedPosition[];
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1 @@
1
+ export {};
@@ -1,6 +1,6 @@
1
1
  import { TrackQuery } from './timeWindow.js';
2
2
  import { TrackStore } from './store.js';
3
- import { Context, Debug, LatLngTuple, TimedPosition, TimedTrackCollection, TimeWindow, TrackCollection, TrackParams } from './types.js';
3
+ import { BinEntry, Context, Debug, ImportedTrack, 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 {
@@ -17,6 +17,8 @@ export interface SqliteStoreConfig {
17
17
  resolution?: number;
18
18
  /** Drop positions older than this many ms. 0 keeps everything. */
19
19
  retention?: number;
20
+ /** Purge a recycle bin entry this many ms after its delete. 0 never purges. */
21
+ binRetention?: number;
20
22
  /** Cells per bounding box covering. */
21
23
  maxCells?: number;
22
24
  /** A gap longer than this ms starts a new track segment. */
@@ -38,9 +40,11 @@ export declare class SqliteTrackStore implements TrackStore {
38
40
  private readonly insert;
39
41
  private readonly insertName;
40
42
  private readonly selectName;
43
+ private readonly selectImportedPoints;
41
44
  private readonly maxCells;
42
45
  private readonly segmentGap;
43
46
  private readonly retention;
47
+ private readonly binRetention;
44
48
  private readonly resolution;
45
49
  /** See CLIP_STRETCH_GAP; widened so a coarse write resolution does not split every fix. */
46
50
  private readonly stretchGap;
@@ -123,6 +127,68 @@ export declare class SqliteTrackStore implements TrackStore {
123
127
  recordName(context: Context, name: string, timestamp?: number): void;
124
128
  nameFor(context: Context): string | undefined;
125
129
  prune(maxAge: number, keep?: Context, now?: number): void;
130
+ /**
131
+ * Keep an imported track. The caller mints the id; a second track under the
132
+ * same id fails rather than replacing the first.
133
+ */
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[];
144
+ /**
145
+ * Move a vessel's positions recorded within `[from, to]` into the recycle
146
+ * bin, resolving to the entry's id and whether the store held the vessel at
147
+ * all.
148
+ *
149
+ * An entry is made even when the store holds nothing there if `keepEmpty`
150
+ * says another source does: the span still has to hide what a history
151
+ * provider holds. Without one, an empty delete leaves no entry behind.
152
+ */
153
+ binRecorded(context: Context, from: number | undefined, to: number, deletedAt: number, keepEmpty: boolean): {
154
+ id?: number;
155
+ known: boolean;
156
+ };
157
+ /**
158
+ * Move an imported track into the recycle bin, or with a span only its
159
+ * points within it, resolving to the entry's id; undefined when there is no
160
+ * such import, or the span holds none of its points.
161
+ */
162
+ binImport(importId: string, span: {
163
+ from?: number;
164
+ to?: number;
165
+ } | undefined, deletedAt: number): number | undefined;
166
+ private newBinEntry;
167
+ /**
168
+ * Put a recycle bin entry's rows back where they were deleted from.
169
+ *
170
+ * A span of an import whose whole track has since gone to the bin as well
171
+ * is a conflict: its points have no track to return to until that is
172
+ * restored first.
173
+ */
174
+ restoreFromBin(id: number): RestoreResult;
175
+ /** Purge one recycle bin entry now, resolving whether it was in the bin. */
176
+ purgeFromBin(id: number): boolean;
177
+ /** Entries still in the recycle bin, newest delete first. */
178
+ binEntries(): BinEntry[];
179
+ /** Every deleted span of a recorded track, purged or not: what history must not show. */
180
+ deletedSpans(): RecordedSpan[];
181
+ private liveEntry;
182
+ /**
183
+ * Remove an entry's rows for good. A recorded span stays as a tombstone, so
184
+ * history cannot refill it; a whole import takes the spans deleted from it
185
+ * earlier with it, since they have no track left to be restored into.
186
+ */
187
+ private purge;
188
+ getImport(id: string): ImportedTrack | undefined;
189
+ /** Imported tracks the filter lets through, in the order they were stored. */
190
+ findImports(filter?: ImportFilter): ImportedTrack[];
191
+ private importFrom;
126
192
  /** Worker startup snapshot; queries and name lookups must not open a second database. */
127
193
  storedNames(): {
128
194
  context: string;
package/dist/store.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { Context, Debug, LatLngTuple, TimedPosition, TimedTrackCollection, TimeWindow, TrackCollection, TrackParams } from './types.js';
1
+ import { BinEntry, Context, Debug, ImportedTrack, 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.
@@ -86,6 +86,48 @@ export interface TrackStore {
86
86
  * keeps it out of a real configuration.
87
87
  */
88
88
  prune(maxAge: number, keep?: Context): void;
89
+ /**
90
+ * Keep a track a client imported. Optional: a store that only records what
91
+ * it observes leaves the four import methods out, and the Track API then
92
+ * answers that storing tracks is not supported.
93
+ *
94
+ * Resolves once the track is durably stored, unlike the recording writes
95
+ * above: the caller hands its id to a client, which must be able to read it
96
+ * back at once.
97
+ */
98
+ storeImport?(track: ImportedTrack): void | Promise<void>;
99
+ getImport?(id: string): ImportedTrack | undefined | Promise<ImportedTrack | undefined>;
100
+ findImports?(filter?: ImportFilter): ImportedTrack[] | Promise<ImportedTrack[]>;
101
+ /**
102
+ * The recycle bin. Optional like the import methods: a store without it
103
+ * cannot delete. A delete moves rows into the bin rather than removing them,
104
+ * and each of these resolves once committed, like `storeImport`.
105
+ *
106
+ * `binRecorded` moves a vessel's positions recorded within `[from, to]`;
107
+ * `binImport` moves an imported track, or only its points within a span.
108
+ */
109
+ binRecorded?(context: Context, from: number | undefined, to: number, deletedAt: number, keepEmpty: boolean): {
110
+ id?: number;
111
+ known: boolean;
112
+ } | Promise<{
113
+ id?: number;
114
+ known: boolean;
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[]>;
121
+ binImport?(importId: string, span: {
122
+ from?: number;
123
+ to?: number;
124
+ } | undefined, deletedAt: number): number | undefined | Promise<number | undefined>;
125
+ restoreFromBin?(id: number): RestoreResult | Promise<RestoreResult>;
126
+ /** Purge an entry now, resolving whether it was in the bin. */
127
+ purgeFromBin?(id: number): boolean | Promise<boolean>;
128
+ binEntries?(): BinEntry[] | Promise<BinEntry[]>;
129
+ /** Every deleted span of a recorded track, purged or not: what history must not show. */
130
+ deletedSpans?(): RecordedSpan[] | Promise<RecordedSpan[]>;
89
131
  /**
90
132
  * Release any resources held by the store.
91
133
  *
package/dist/style.css CHANGED
@@ -47,3 +47,81 @@ tbody tr:hover {
47
47
  .self {
48
48
  font-weight: 600;
49
49
  }
50
+
51
+ .sr-only {
52
+ position: absolute;
53
+ width: 1px;
54
+ height: 1px;
55
+ overflow: hidden;
56
+ clip-path: inset(50%);
57
+ white-space: nowrap;
58
+ }
59
+
60
+ h2 {
61
+ font-size: 1.1rem;
62
+ margin: 2rem 0 0.25rem;
63
+ }
64
+
65
+ #import-form {
66
+ display: flex;
67
+ flex-wrap: wrap;
68
+ align-items: center;
69
+ gap: 0.5rem 1rem;
70
+ }
71
+
72
+ #bin-status,
73
+ #import-status {
74
+ color: GrayText;
75
+ margin: 0 0 1rem;
76
+ }
77
+
78
+ .error,
79
+ #bin-status[data-state='error'],
80
+ #import-status[data-state='error'] {
81
+ color: crimson;
82
+ }
83
+
84
+ .imported {
85
+ color: GrayText;
86
+ }
87
+
88
+ .actions {
89
+ display: flex;
90
+ flex-wrap: wrap;
91
+ gap: 0.5rem;
92
+ }
93
+
94
+ .danger {
95
+ color: crimson;
96
+ }
97
+
98
+ dialog {
99
+ max-width: min(32rem, calc(100vw - 2rem));
100
+ }
101
+
102
+ dialog h2 {
103
+ margin-top: 0;
104
+ }
105
+
106
+ dialog fieldset {
107
+ display: grid;
108
+ gap: 0.5rem;
109
+ border: none;
110
+ padding: 0;
111
+ margin: 0 0 1rem;
112
+ }
113
+
114
+ dialog .span {
115
+ display: flex;
116
+ flex-wrap: wrap;
117
+ gap: 0.5rem 1rem;
118
+ margin-left: 1.5rem;
119
+ }
120
+
121
+ dialog menu {
122
+ display: flex;
123
+ justify-content: flex-end;
124
+ gap: 0.5rem;
125
+ padding: 0;
126
+ margin: 0;
127
+ }
@@ -8,7 +8,10 @@ import { Temporal } from '@js-temporal/polyfill';
8
8
  * imports when it is.
9
9
  *
10
10
  * Specified in https://github.com/SignalK/signalk-server/issues/2504 and
11
- * implemented in https://github.com/SignalK/signalk-server/pull/2995.
11
+ * implemented in https://github.com/SignalK/signalk-server/pull/2995; storing,
12
+ * fetching and deleting a track by id in
13
+ * https://github.com/SignalK/signalk-server/pull/3038, and deleting part of
14
+ * one in the server's `deleteTrackSpan`.
12
15
  */
13
16
  /** `[west, south, east, north]` — GeoJSON coordinate order. */
14
17
  export type TrackBoundingBox = [number, number, number, number];
@@ -47,11 +50,20 @@ export interface TracksRequest {
47
50
  geometry?: boolean;
48
51
  }
49
52
  export interface TrackProperties {
50
- context: string;
51
- isSelf: boolean;
53
+ /** Identifies a stored track within this provider; recorded tracks carry none. */
54
+ id?: string;
55
+ /** Absent for an imported track that names no vessel. */
56
+ context?: string;
57
+ /** Absent along with `context`. */
58
+ isSelf?: boolean;
52
59
  contextName?: string;
53
- from: string;
54
- to: string;
60
+ /** The track's own name, as an imported GPX `<trk><name>` carries. */
61
+ name?: string;
62
+ /** Set on a track a client stored, absent on a recorded one. */
63
+ imported?: true;
64
+ /** Absent, with `to`, for a track whose points carry no times. */
65
+ from?: string;
66
+ to?: string;
55
67
  bbox?: TrackBoundingBox;
56
68
  pointCount: number;
57
69
  resolution?: string;
@@ -59,6 +71,8 @@ export interface TrackProperties {
59
71
  coordTimes?: string[][];
60
72
  appliedProperties?: string[];
61
73
  values?: Record<string, (number | string | null)[][]>;
74
+ /** Whatever else an imported track was posted with, returned as posted. */
75
+ [key: string]: unknown;
62
76
  }
63
77
  export interface TrackFeature {
64
78
  type: 'Feature';
@@ -76,6 +90,50 @@ export interface TracksResponse {
76
90
  export interface TrackApi {
77
91
  getTracks(query: TracksRequest): Promise<TracksResponse>;
78
92
  getTrackContexts(query: TracksRequest): Promise<string[]>;
93
+ /** One stored track by id, or undefined when there is none. */
94
+ getTrack?(id: string): Promise<TrackFeature | undefined>;
95
+ /** Keep a track a client supplied, resolving to the id it was given. */
96
+ storeTrack?(track: TrackImport): Promise<string>;
97
+ /** Delete a stored track by id, resolving false when there is none. */
98
+ deleteTrack?(id: string): Promise<boolean>;
99
+ /** Delete the part of a stored track recorded within `span`, resolving false when there is no such track. */
100
+ deleteTrackSpan?(id: string, span: TrackSpan): Promise<boolean>;
101
+ }
102
+ /** Every point recorded at or after `from` and at or before `to`; an absent bound is open. */
103
+ export interface TrackSpan {
104
+ from?: Temporal.Instant;
105
+ to?: Temporal.Instant;
106
+ }
107
+ /**
108
+ * A track offered for storage, in the canonical form the server reduces every
109
+ * import to: a MultiLineString, `coordTimes` nested to match it and in UTC, a
110
+ * qualified context, and none of the properties a provider derives itself.
111
+ */
112
+ export interface TrackImport {
113
+ type: 'Feature';
114
+ geometry: {
115
+ type: 'MultiLineString';
116
+ coordinates: [number, number][][];
117
+ };
118
+ properties: TrackImportProperties;
119
+ }
120
+ export interface TrackImportProperties {
121
+ coordTimes?: string[][];
122
+ name?: string;
123
+ context?: string;
124
+ /** The client's own metadata, stored as posted. */
125
+ [key: string]: unknown;
126
+ }
127
+ /**
128
+ * Thrown by `storeTrack` when this provider will not keep the track, which
129
+ * the server answers with a 400 rather than a 500.
130
+ *
131
+ * The server recognises it by `isTrackRejected` rather than by class, since
132
+ * this is a copy of its own.
133
+ */
134
+ export declare class TrackRejectedError extends Error {
135
+ readonly isTrackRejected: true;
136
+ constructor(message?: string);
79
137
  }
80
138
  /** Present on servers that carry the Track API; absent on older ones. */
81
139
  export interface WithTrackApi {
@@ -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
  *
@@ -49,6 +49,27 @@ export interface TrackProviderDeps {
49
49
  * only before this plugin was installed is known to history alone.
50
50
  */
51
51
  historyContexts: (window: TimeWindow | undefined) => Promise<ReadonlySet<string>>;
52
+ /** Whether a history provider holds anything at all for a context. */
53
+ historyKnows: (context: string) => Promise<boolean>;
54
+ /** Called after a delete, so whatever hides deleted spans from history reloads them. */
55
+ deleted: () => void;
52
56
  reconcileWithHistory: (context: string, stored: TimedPosition[], window: TimeWindow | undefined, resolutionMs: number | undefined) => Promise<TimedPosition[]>;
53
57
  }
54
58
  export declare function createTrackProvider(deps: TrackProviderDeps): TrackApi;
59
+ export interface KeepTrackDeps {
60
+ store: Required<Pick<TrackStore, 'storeImport' | 'recordTrack'>>;
61
+ selfContext: string;
62
+ historyKnows: (context: string) => Promise<boolean>;
63
+ deleted: () => void;
64
+ }
65
+ /**
66
+ * Keep a client's track, resolving to the id that addresses it.
67
+ *
68
+ * A timed track of the own vessel is the vessel's own passage, from before
69
+ * the plugin recorded it or from another plotter, so it is written into the
70
+ * recording in place of what was recorded over the same time and answers
71
+ * as `recorded:<context>`. Everything else is kept as an import: another
72
+ * vessel's track is not this boat's recording, and an untimed one cannot be
73
+ * placed in it.
74
+ */
75
+ export declare function keepTrack(track: ImportedTrack, deps: KeepTrackDeps): Promise<string>;
@@ -1,5 +1,5 @@
1
1
  import { SqliteTrackStore } from './sqliteStore.js';
2
- export type StoreMethod = 'newPosition' | 'recordName' | 'initialTrack' | 'prune' | 'get' | 'getTimed' | 'getAllTracks' | 'getFilteredTracks' | 'getFilteredTimedTracks' | '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;