@signalk/tracks-plugin 3.0.3 → 3.1.1
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/harness.test-utils.d.ts +16 -0
- package/dist/index.js +142 -37
- package/dist/index.js.map +1 -1
- package/dist/reconcile.d.ts +11 -0
- package/dist/sqliteStore.d.ts +18 -0
- package/dist/timeWindow.d.ts +5 -1
- package/dist/trackApi.d.ts +6 -0
- package/dist/trackProvider.d.ts +9 -0
- package/dist/trackStoreWorker.js +99 -13
- package/dist/trackStoreWorker.js.map +1 -1
- package/dist/types.d.ts +17 -0
- package/dist/{utils-DOEyuPRy.js → utils-CGIGp4Xf.js} +62 -5
- package/dist/utils-CGIGp4Xf.js.map +1 -0
- package/dist/utils.d.ts +13 -0
- package/docs/history-and-storage.md +17 -0
- package/package.json +1 -1
- package/dist/utils-DOEyuPRy.js.map +0 -1
|
@@ -112,6 +112,13 @@ export interface HarnessOptions {
|
|
|
112
112
|
};
|
|
113
113
|
/** Fail the windowed read, leaving the provider unable to answer at all. */
|
|
114
114
|
getValuesRejects?: boolean;
|
|
115
|
+
/**
|
|
116
|
+
* Answer `getValues` as a real provider does with `aggregate: 'first'`:
|
|
117
|
+
* only the earliest of `rows` in each bucket of the asked resolution.
|
|
118
|
+
*/
|
|
119
|
+
aggregatesFirst?: boolean;
|
|
120
|
+
/** Called with every `getValues` query, so a test can see what was asked. */
|
|
121
|
+
onValues?: (query: HistoryValuesQuery) => void;
|
|
115
122
|
/**
|
|
116
123
|
* Reject `getHistoryApi` itself, as the server does when no provider is
|
|
117
124
|
* registered — the default install, and not an outage.
|
|
@@ -131,4 +138,13 @@ export declare function deferred(): {
|
|
|
131
138
|
release: () => void;
|
|
132
139
|
wait: Promise<void>;
|
|
133
140
|
};
|
|
141
|
+
export interface HistoryValuesQuery {
|
|
142
|
+
from: {
|
|
143
|
+
toString: () => string;
|
|
144
|
+
};
|
|
145
|
+
to: {
|
|
146
|
+
toString: () => string;
|
|
147
|
+
};
|
|
148
|
+
resolution?: number;
|
|
149
|
+
}
|
|
134
150
|
export declare function createHarness(options?: HarnessOptions): TestHarness;
|
package/dist/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { C as uncoveredBetween, S as thinToBudget, _ as validateParameters, a as contextName, b as segment, c as createMatcher, d as resolveContext, f as rfc8187, g as unwrapString, h as trackLabel, i as clipToBounds, l as gpxFilename, m as toIsoTimes, n as SelfPositionUnavailableError, o as createDistanceTo, r as asciiFilename, s as createInBounds, t as QueryParameterError, u as historyRowPosition, v as TimeWindowError, x as thin, y as parseTrackQuery } from "./utils-CGIGp4Xf.js";
|
|
2
2
|
import { Temporal } from "@js-temporal/polyfill";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { Worker } from "node:worker_threads";
|
|
@@ -241,19 +241,31 @@ function createTrackProvider(deps) {
|
|
|
241
241
|
};
|
|
242
242
|
const window = toTimeWindow(query);
|
|
243
243
|
const resolution = query.resolution ? totalMilliseconds(query.resolution) : void 0;
|
|
244
|
-
const wanted = query.contexts?.length ? new Set(query.contexts.map(resolveSelf(deps.selfContext()))) : void 0;
|
|
244
|
+
const wanted = query.contexts?.length ? [...new Set(query.contexts.map(resolveSelf(deps.selfContext())))] : void 0;
|
|
245
|
+
const bounds = toGeoBounds(query.bbox);
|
|
246
|
+
const clipTo = query.clip === true ? bounds : null;
|
|
245
247
|
const collection = await store.getFilteredTimedTracks({
|
|
246
|
-
bbox:
|
|
248
|
+
bbox: bounds,
|
|
247
249
|
radius: null,
|
|
248
|
-
intersects: true
|
|
250
|
+
intersects: true,
|
|
251
|
+
...wanted ? { contexts: wanted } : {},
|
|
252
|
+
...clipTo ? { clip: true } : {}
|
|
249
253
|
}, void 0, void 0, {
|
|
250
254
|
...window ? { window } : {},
|
|
251
|
-
...resolution === void 0 ? {} : { resolution }
|
|
255
|
+
...resolution === void 0 || bounds ? {} : { resolution }
|
|
252
256
|
});
|
|
257
|
+
const fromHistory = [...await deps.historyContexts(window)].filter((context) => !(context in collection) && (!wanted || wanted.includes(context)));
|
|
258
|
+
const inBounds = bounds ? createInBounds(bounds) : void 0;
|
|
259
|
+
const storedFor = async (context) => {
|
|
260
|
+
const points = await store.getTimed(context, window).catch(() => []);
|
|
261
|
+
return bounds ? points : thin(points, resolution);
|
|
262
|
+
};
|
|
263
|
+
const historyResolution = bounds ? void 0 : resolution;
|
|
253
264
|
const result = /* @__PURE__ */ new Map();
|
|
254
|
-
const
|
|
255
|
-
|
|
256
|
-
|
|
265
|
+
const reconciled = await Promise.all([...Object.entries(collection).map(async ([context, stored]) => [context, await deps.reconcileWithHistory(context, stored, window, historyResolution)]), ...fromHistory.map(async (context) => [context, await deps.reconcileWithHistory(context, await storedFor(context), window, historyResolution)])]);
|
|
266
|
+
for (const [context, reconciledPoints] of reconciled) {
|
|
267
|
+
if (inBounds && !reconciledPoints.some(({ position }) => inBounds(position))) continue;
|
|
268
|
+
const points = clipTo ? thin(clipToBounds(reconciledPoints, clipTo), resolution) : bounds ? thin(reconciledPoints, resolution) : reconciledPoints;
|
|
257
269
|
if (points.length === 0) continue;
|
|
258
270
|
result.set(context, points);
|
|
259
271
|
}
|
|
@@ -798,6 +810,25 @@ function reconcile(history, stored, resolution) {
|
|
|
798
810
|
};
|
|
799
811
|
}
|
|
800
812
|
/**
|
|
813
|
+
* Merge history that is coarser than the store into it, the store first.
|
|
814
|
+
*
|
|
815
|
+
* The rule above holds while history is the finer source. A history read
|
|
816
|
+
* widened to stay within its bucket budget is not: one of its buckets spans
|
|
817
|
+
* several stored fixes, and letting it win would replace them all with one
|
|
818
|
+
* point. So here the store keeps every bucket it has a point in, and history
|
|
819
|
+
* fills only the buckets the store has nothing for — where the provider
|
|
820
|
+
* recorded before this plugin was installed, or while it was off.
|
|
821
|
+
*/
|
|
822
|
+
function fillFromHistory(history, stored, resolution) {
|
|
823
|
+
const covered = new Set(stored.map(({ timestamp }) => bucketOf(timestamp, resolution)));
|
|
824
|
+
const filling = history.filter(({ timestamp }) => !covered.has(bucketOf(timestamp, resolution)));
|
|
825
|
+
return {
|
|
826
|
+
positions: measuredFromHistory([...stored, ...filling].sort((a, b) => a.timestamp - b.timestamp), filling),
|
|
827
|
+
fromHistory: filling.length,
|
|
828
|
+
fromStore: stored.length
|
|
829
|
+
};
|
|
830
|
+
}
|
|
831
|
+
/**
|
|
801
832
|
* A stored point's `pauseBefore` was measured over the stored points thinning
|
|
802
833
|
* dropped before it. Where a history point now comes right before it, history
|
|
803
834
|
* covered part of that time, so the pause is at most the time since that
|
|
@@ -1415,7 +1446,8 @@ var Tracks = class {
|
|
|
1415
1446
|
this.debug(params);
|
|
1416
1447
|
this.debug("Self position", selfPosition);
|
|
1417
1448
|
const matcher = createMatcher(params, selfPosition, debug);
|
|
1418
|
-
const
|
|
1449
|
+
const requested = params.contexts ? new Set(params.contexts) : void 0;
|
|
1450
|
+
const contexts = Object.keys(this.tracks).filter((context) => !requested || requested.has(context));
|
|
1419
1451
|
return (await Promise.all(contexts.map((context) => this.getTimed(context, query?.window).then((points) => ({
|
|
1420
1452
|
context,
|
|
1421
1453
|
points: thin(points, query?.resolution)
|
|
@@ -1548,6 +1580,30 @@ var WINDOWLESS_HISTORY_SPAN_MS = 864e5;
|
|
|
1548
1580
|
*/
|
|
1549
1581
|
var EXISTENCE_PROBE_FROM_MS = 0;
|
|
1550
1582
|
/**
|
|
1583
|
+
* The most buckets one history read may ask a provider for.
|
|
1584
|
+
*
|
|
1585
|
+
* A provider has no spatial filter, so a box query reads every position in
|
|
1586
|
+
* its window, and the cost of that read grows with the window's span over the
|
|
1587
|
+
* resolution, not with what is drawn. Providers fabricate a row for every
|
|
1588
|
+
* bucket in the range — questdb fills empty ones with nulls — and refuse a
|
|
1589
|
+
* range past their own cap, which one-minute buckets over two years already
|
|
1590
|
+
* exceed. A month of one-minute buckets keeps a Pi-class server responsive;
|
|
1591
|
+
* a longer window is read at a wider resolution instead.
|
|
1592
|
+
*/
|
|
1593
|
+
var HISTORY_BUCKET_BUDGET = 43200;
|
|
1594
|
+
/**
|
|
1595
|
+
* How many buckets the read that finds where a provider's data begins asks
|
|
1596
|
+
* for.
|
|
1597
|
+
*
|
|
1598
|
+
* A window with no start would otherwise reach back to the Unix epoch, and
|
|
1599
|
+
* spread over fifty-odd years the bucket budget leaves buckets weeks wide.
|
|
1600
|
+
* One coarse read first finds the earliest bucket holding a position, so the
|
|
1601
|
+
* real read starts there. A thousand buckets over the whole epoch are about
|
|
1602
|
+
* three weeks each, which only sets how much empty time the real read starts
|
|
1603
|
+
* with.
|
|
1604
|
+
*/
|
|
1605
|
+
var START_PROBE_BUCKETS = 1e3;
|
|
1606
|
+
/**
|
|
1551
1607
|
* How long a provider's context list is reused before asking again.
|
|
1552
1608
|
*
|
|
1553
1609
|
* The probe runs only for a vessel about to 404, which is precisely the
|
|
@@ -1644,6 +1700,8 @@ function withTimeout(promise, ms) {
|
|
|
1644
1700
|
});
|
|
1645
1701
|
});
|
|
1646
1702
|
}
|
|
1703
|
+
/** A provider's context list as a set, with the `vessels.self` spelling resolved. */
|
|
1704
|
+
var toContextSet = (listed, selfContext) => new Set((listed ?? []).filter((c) => typeof c === "string").map((c) => c === "vessels.self" ? selfContext : c));
|
|
1647
1705
|
/**
|
|
1648
1706
|
* Whether a history provider holds anything at all for a context.
|
|
1649
1707
|
*
|
|
@@ -1667,7 +1725,7 @@ async function historyKnowsContext(app, context, debug, cache) {
|
|
|
1667
1725
|
const contexts = withTimeout(getHistoryApi(app.config?.settings?.historyApi?.defaultProvider).then((historyApi) => historyApi.getContexts ? historyApi.getContexts({
|
|
1668
1726
|
from: Temporal.Instant.from((/* @__PURE__ */ new Date(EXISTENCE_PROBE_FROM_MS)).toISOString()),
|
|
1669
1727
|
to: Temporal.Instant.from(new Date(now).toISOString())
|
|
1670
|
-
}) : []).then((listed) =>
|
|
1728
|
+
}) : []).then((listed) => toContextSet(listed, app.selfContext)), HISTORY_QUERY_TIMEOUT_MS);
|
|
1671
1729
|
const entry = {
|
|
1672
1730
|
at: now,
|
|
1673
1731
|
contexts
|
|
@@ -1687,6 +1745,38 @@ async function historyKnowsContext(app, context, debug, cache) {
|
|
|
1687
1745
|
}
|
|
1688
1746
|
}
|
|
1689
1747
|
/**
|
|
1748
|
+
* Contexts a history provider holds data for within a window, or none.
|
|
1749
|
+
*
|
|
1750
|
+
* What lets the v2 provider return a vessel the store has never seen, or match
|
|
1751
|
+
* a box on positions only history recorded: the store is often far younger
|
|
1752
|
+
* than the provider. Best-effort like every other provider call: no provider,
|
|
1753
|
+
* a provider without `getContexts`, an error or a hang all answer with no
|
|
1754
|
+
* contexts, and the store answers alone.
|
|
1755
|
+
*
|
|
1756
|
+
* A window with no start is asked from the epoch, for the same reason the
|
|
1757
|
+
* existence probe is; without any window the probe's cached all-time list is
|
|
1758
|
+
* reused rather than asking again per query.
|
|
1759
|
+
*/
|
|
1760
|
+
async function historyContextsIn(app, window, debug, cache) {
|
|
1761
|
+
const getHistoryApi = app.getHistoryApi;
|
|
1762
|
+
if (!getHistoryApi) return /* @__PURE__ */ new Set();
|
|
1763
|
+
try {
|
|
1764
|
+
if (!window) {
|
|
1765
|
+
await historyKnowsContext(app, app.selfContext, debug, cache);
|
|
1766
|
+
return await cache.current?.contexts ?? /* @__PURE__ */ new Set();
|
|
1767
|
+
}
|
|
1768
|
+
const historyApi = await withTimeout(getHistoryApi(app.config?.settings?.historyApi?.defaultProvider), HISTORY_QUERY_TIMEOUT_MS);
|
|
1769
|
+
if (!historyApi.getContexts) return /* @__PURE__ */ new Set();
|
|
1770
|
+
return toContextSet(await withTimeout(historyApi.getContexts({
|
|
1771
|
+
from: Temporal.Instant.from(new Date(Math.max(window.from, EXISTENCE_PROBE_FROM_MS)).toISOString()),
|
|
1772
|
+
to: Temporal.Instant.from(new Date(window.to).toISOString())
|
|
1773
|
+
}), HISTORY_QUERY_TIMEOUT_MS), app.selfContext);
|
|
1774
|
+
} catch (err) {
|
|
1775
|
+
if (debug.enabled) debug(`History contexts unavailable: ${errorDetail(err)}`);
|
|
1776
|
+
return /* @__PURE__ */ new Set();
|
|
1777
|
+
}
|
|
1778
|
+
}
|
|
1779
|
+
/**
|
|
1690
1780
|
* Positions a history provider holds for a window, or none.
|
|
1691
1781
|
*
|
|
1692
1782
|
* Best-effort by design: a provider that is absent, slow, or failing must not
|
|
@@ -1694,65 +1784,80 @@ async function historyKnowsContext(app, context, debug, cache) {
|
|
|
1694
1784
|
* provider is the finer source where it reaches, not a required one.
|
|
1695
1785
|
*/
|
|
1696
1786
|
async function historyPositions(app, context, window, resolutionMs, debug) {
|
|
1697
|
-
const
|
|
1698
|
-
const
|
|
1699
|
-
const getHistoryApi = app.getHistoryApi;
|
|
1700
|
-
if (!getHistoryApi) return {
|
|
1787
|
+
const askedSeconds = Math.max(1, Math.round(resolutionMs / 1e3));
|
|
1788
|
+
const none = (failed) => ({
|
|
1701
1789
|
points: [],
|
|
1702
|
-
resolutionMs:
|
|
1703
|
-
|
|
1704
|
-
|
|
1790
|
+
resolutionMs: askedSeconds * 1e3,
|
|
1791
|
+
coarsened: false,
|
|
1792
|
+
failed
|
|
1793
|
+
});
|
|
1794
|
+
const getHistoryApi = app.getHistoryApi;
|
|
1795
|
+
if (!getHistoryApi) return none(false);
|
|
1705
1796
|
let historyApi;
|
|
1706
1797
|
try {
|
|
1707
1798
|
historyApi = await withTimeout(getHistoryApi(app.config?.settings?.historyApi?.defaultProvider), HISTORY_QUERY_TIMEOUT_MS);
|
|
1708
1799
|
} catch (err) {
|
|
1709
1800
|
const timedOut = err instanceof HistoryTimeoutError;
|
|
1710
1801
|
if (debug.enabled) debug(`${timedOut ? "History provider timed out" : "No history provider"} for ${context}: ${errorDetail(err)}`);
|
|
1711
|
-
return
|
|
1712
|
-
points: [],
|
|
1713
|
-
resolutionMs: applied,
|
|
1714
|
-
failed: timedOut
|
|
1715
|
-
};
|
|
1802
|
+
return none(timedOut);
|
|
1716
1803
|
}
|
|
1717
|
-
|
|
1804
|
+
const read = async (from, seconds, throughEnd = false) => {
|
|
1718
1805
|
const response = await withTimeout(historyApi.getValues({
|
|
1719
1806
|
context,
|
|
1720
|
-
from: Temporal.Instant.from(new Date(
|
|
1807
|
+
from: Temporal.Instant.from(new Date(from).toISOString()),
|
|
1721
1808
|
to: Temporal.Instant.from(new Date(window.to).toISOString()),
|
|
1722
1809
|
pathSpecs: [{
|
|
1723
1810
|
path: "navigation.position",
|
|
1724
1811
|
aggregate: "first"
|
|
1725
1812
|
}],
|
|
1726
|
-
resolution:
|
|
1813
|
+
resolution: seconds
|
|
1727
1814
|
}), HISTORY_QUERY_TIMEOUT_MS);
|
|
1728
1815
|
const points = [];
|
|
1729
1816
|
for (const row of response?.data ?? []) {
|
|
1730
1817
|
const position = historyRowPosition(row);
|
|
1731
1818
|
if (position) {
|
|
1732
1819
|
const timestamp = historyRowTimestamp(row);
|
|
1733
|
-
const withinEnd = window.inclusiveEnd ? timestamp <= window.to : timestamp < window.to;
|
|
1820
|
+
const withinEnd = window.inclusiveEnd || throughEnd ? timestamp <= window.to : timestamp < window.to;
|
|
1734
1821
|
if (timestamp >= window.from && withinEnd) points.push({
|
|
1735
1822
|
position,
|
|
1736
1823
|
timestamp,
|
|
1737
|
-
span:
|
|
1824
|
+
span: seconds * 1e3
|
|
1738
1825
|
});
|
|
1739
1826
|
}
|
|
1740
1827
|
}
|
|
1741
|
-
|
|
1828
|
+
return points;
|
|
1829
|
+
};
|
|
1830
|
+
try {
|
|
1831
|
+
let from = Math.max(window.from, EXISTENCE_PROBE_FROM_MS);
|
|
1832
|
+
if (!Number.isFinite(window.from)) {
|
|
1833
|
+
const probeSeconds = Math.max(askedSeconds, Math.ceil((window.to - from) / 1e3 / START_PROBE_BUCKETS));
|
|
1834
|
+
const probed = await read(from, probeSeconds, true);
|
|
1835
|
+
if (probed.length === 0) return none(false);
|
|
1836
|
+
const earliest = Math.min(...probed.map(({ timestamp }) => timestamp));
|
|
1837
|
+
from = Math.max(from, earliest - probeSeconds * 1e3);
|
|
1838
|
+
}
|
|
1839
|
+
const providerSeconds = Math.max(askedSeconds, Math.ceil((window.to - from) / 1e3 / HISTORY_BUCKET_BUDGET));
|
|
1840
|
+
const points = await read(from, providerSeconds);
|
|
1841
|
+
if (debug.enabled) debug(`History supplied ${points.length} position(s) for ${context} at ${providerSeconds}s`);
|
|
1742
1842
|
return {
|
|
1743
1843
|
points,
|
|
1744
|
-
resolutionMs:
|
|
1844
|
+
resolutionMs: providerSeconds * 1e3,
|
|
1845
|
+
coarsened: providerSeconds > askedSeconds,
|
|
1745
1846
|
failed: false
|
|
1746
1847
|
};
|
|
1747
1848
|
} catch (err) {
|
|
1748
1849
|
if (debug.enabled) debug(`History unavailable for ${context}: ${errorDetail(err)}`);
|
|
1749
|
-
return
|
|
1750
|
-
points: [],
|
|
1751
|
-
resolutionMs: applied,
|
|
1752
|
-
failed: true
|
|
1753
|
-
};
|
|
1850
|
+
return none(true);
|
|
1754
1851
|
}
|
|
1755
1852
|
}
|
|
1853
|
+
/**
|
|
1854
|
+
* A track from the store and what history holds for it.
|
|
1855
|
+
*
|
|
1856
|
+
* History is the finer source where it reaches, so it wins its buckets —
|
|
1857
|
+
* unless the read was widened to stay within its budget, when it is the
|
|
1858
|
+
* coarser one and only fills what the store lacks.
|
|
1859
|
+
*/
|
|
1860
|
+
var withHistory = (history, stored) => history.points.length === 0 ? stored : (history.coarsened ? fillFromHistory : reconcile)(history.points, stored, history.resolutionMs).positions;
|
|
1756
1861
|
/** Epoch milliseconds for a history row, or 0 when the timestamp is unusable. */
|
|
1757
1862
|
var historyRowTimestamp = (row) => {
|
|
1758
1863
|
const raw = Array.isArray(row) ? row[0] : void 0;
|
|
@@ -1885,10 +1990,10 @@ function ThePlugin(app) {
|
|
|
1885
1990
|
selfContext: () => app.selfContext,
|
|
1886
1991
|
segmentGap: () => segmentGap,
|
|
1887
1992
|
contextName: (context) => contextName(context, remembering(() => tracks)),
|
|
1993
|
+
historyContexts: (window) => historyContextsIn(app, window, app.debug, knownContexts),
|
|
1888
1994
|
reconcileWithHistory: async (context, stored, window, resolutionMs) => {
|
|
1889
1995
|
const effective = resolutionMs ?? storeResolution;
|
|
1890
|
-
|
|
1891
|
-
return history.points.length ? reconcile(history.points, stored, history.resolutionMs).positions : stored;
|
|
1996
|
+
return withHistory(await historyPositions(app, context, window ?? windowSpanning(stored, WINDOWLESS_HISTORY_SPAN_MS), effective, app.debug), stored);
|
|
1892
1997
|
}
|
|
1893
1998
|
}));
|
|
1894
1999
|
return starting.ready.catch(async () => {
|
|
@@ -1939,7 +2044,7 @@ function ThePlugin(app) {
|
|
|
1939
2044
|
known: false
|
|
1940
2045
|
}));
|
|
1941
2046
|
const history = await historyPositions(app, context, query.window ?? windowSpanning(stored, WINDOWLESS_HISTORY_SPAN_MS), effectiveResolution, app.debug);
|
|
1942
|
-
const points =
|
|
2047
|
+
const points = withHistory(history, stored);
|
|
1943
2048
|
if (points.length === 0) {
|
|
1944
2049
|
if (!(known || await historyKnowsContext(app, context, app.debug, knownContexts))) return;
|
|
1945
2050
|
if (history.failed) throw new HistoryUnavailableError(`History provider could not be read for ${context}`);
|