sailkick-boat 0.27.0 → 0.29.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.
package/README.md CHANGED
@@ -188,6 +188,21 @@ POST /plugins/sailkick-boat/cache/clear?prefix=tiles/seamap # nuke one ti
188
188
  when clearing — the default keep already does. A hand-typed `find` clear should add
189
189
  `! -name history` alongside `! -name tiles ! -name terrain`.)
190
190
 
191
+ **A pinned file that was cached wrong stays wrong.** Tiles have no expiry, which is the
192
+ whole point — but it means a bug in the *transport* outlives its fix. The pre-0.23.9
193
+ client stored still-gzipped bytes as if they were the payload (core `http` hands back the
194
+ compressed stream where `fetch` decoded it), and Cesium read the gzip header as a vertex
195
+ count: `Invalid typed array length: 11239580910`. Fixing the transport fixed new fetches
196
+ and nothing else: 2,391 terrain tiles and 188 vector tiles kept throwing for weeks after,
197
+ because nothing ever re-examined a stored file.
198
+
199
+ So a cache HIT is now checked — two bytes of a buffer already read. A file that claims to
200
+ be quantized-mesh or protobuf and begins `1f 8b` is dropped and re-fetched (`REPAIRED`);
201
+ if the upstream is unreachable the request FAILS rather than serving the poison again,
202
+ because a renderer handed a gzip header errors hard while a missing tile simply falls back
203
+ to its parent. Content that is legitimately gzip — a `.gz` asset, a gzip content-type — is
204
+ left alone: that is a payload, not an encoding.
205
+
191
206
  ## Offline map coverage — global base seed + region prefetch
192
207
  On-demand caching only holds what you browsed. To make a usable map exist offline
193
208
  *everywhere*, the plugin seeds a worldwide low-zoom base on start and lets you warm a
@@ -712,15 +727,44 @@ cloud, in-memory ring on a DB-less edge*. The boat is a third case — an edge t
712
727
  serves the app's history endpoints from its **own** live data:
713
728
  ```
714
729
  GET /api/history/series?window=3600s&every=30s -> { series: { sog|heading|tws|… : [[tMs,val],…] } }
730
+ GET /api/history/series?…&stats=1&chans=sog,aws -> { series: {…}, bands: { sog: [[t,min,max],…] } }
715
731
  GET /api/history/track?window=3600s&every=10s -> { track: [{ t, lat, lon }, …] }
716
732
  GET /api/history/track?from=<epochMs>&to=<epochMs> (absolute range; ISO also accepted)
717
733
  ```
734
+ **Gusts: `stats=1` adds true min/max bands under the mean.** A mean line hides the thing
735
+ you actually want to see — the app measured an hour of real sailing at 20 s buckets where
736
+ the mean spanned 4.8 kt and the true envelope spanned 8.3 kt, with 1.87 kt of spread
737
+ hidden inside an average bucket. That spread only exists if it is *recorded*: the ring
738
+ polls BoatState every second into a per-channel `{sum, cnt, lo, hi}` accumulator and emits
739
+ one row per sample interval carrying the mean plus the true extremes seen inside it. It
740
+ used to snapshot instead, throwing away 14 of every 15 readings before anything could ask
741
+ a question about them — no later bucketing, on the boat or in the browser, can bring those
742
+ back.
743
+
744
+ A useful side-effect: the auto-coarsening that keeps the ring under `MAX_SAMPLES` is no
745
+ longer lossy. Only the *emit* rate coarsens, never the poll, so a 30-day passage emitting
746
+ every ~52 s still carries the true min/max within each 52 s.
747
+
748
+ **Compass channels never get a band** — `twd`, `twa`, `awa`, `cog`, `heading`, `wptBrg`.
749
+ The mean of 359° and 1° is 180°, the exact opposite of the truth, so those carry a
750
+ last-reading snapshot and no band, ever. It is the one error here that would look entirely
751
+ plausible on screen, so the tests pin it.
752
+
753
+ `chans=sog,aws` narrows the answer to the channels actually plotted; `bands` appears only
754
+ when `stats=1` was asked *and* the provider produced them, so every client degrades to the
755
+ plain line. `series` is unchanged with or without either param.
756
+
718
757
  Both endpoints take **either** a trailing `window`, **or** an absolute `from`/`to` —
719
758
  which is what the app sends whenever the view is scrolled back in time (the historic
720
759
  trail, and a Trends flyout on a past period). The response echoes the `from`/`to` it
721
760
  actually served. Before 0.24.0 the boat parsed only `window`, so a request for a past
722
761
  hour came back `200` with the **most recent** hour: the historic trail silently showed
723
- live data. `every` thins a long track and always keeps the first and newest fix.
762
+ live data. `every` thins a long track and always keeps the first and newest fix; on
763
+ `series` it re-buckets (means weighted by the sample count behind each row, extremes as
764
+ min-of-mins), labelling each bucket at its **end** to match the cloud's
765
+ `aggregateWindow(timeSrc: "_stop")` — label them at the start and the two providers plot
766
+ half a bucket apart on the same screen. It is floored so one answer stays under ~3k
767
+ points, as the cloud route does.
724
768
 
725
769
  Same JSON the cloud returns, so the browser can't tell the difference — but it
726
770
  works **offline** with the boat's own data. Only when no telemetry source is
@@ -26,6 +26,10 @@ const wrap180 = (d) => { const x = wrap360(d); return x > 180 ? x - 360 : x }
26
26
 
27
27
  // Parse a duration like "1h" / "30m" / "600s" / "3600" → seconds, clamped.
28
28
  // Ported from the app's server/routes/history.js so limits match exactly.
29
+ // One response has to stay drawable and sendable over a satellite link; the cloud route
30
+ // uses the same ceiling.
31
+ const MAX_POINTS = 3000
32
+
29
33
  function dur (s, def, min, max) {
30
34
  if (s == null) return def
31
35
  const m = String(s).trim().match(/^(\d+)\s*([smh]?)$/)
@@ -127,12 +131,24 @@ function createHistory (app, options) {
127
131
  if (!available()) return sendJson(res, 503, { ok: false, code: 'history-unavailable', message: 'history not available' })
128
132
  const q = query(req.url)
129
133
  const windowSec = dur(q.get('window'), 3600, 60, 86400)
130
- const everySec = dur(q.get('every'), 30, 5, 600)
131
134
  const abs = absRange(q)
135
+ // Floor the bucket so one answer stays drawable. An absolute range can be a month
136
+ // wide, and at the default 30 s that is 86,400 points down a link that may be
137
+ // Starlink on a bad night — the cloud route floors it the same way, so both providers
138
+ // return the same shape for the same request.
139
+ const spanSec = abs ? Math.max(1, Math.round((abs.toMs - abs.fromMs) / 1000)) : windowSec
140
+ const everySec = Math.max(dur(q.get('every'), 30, 5, 600), Math.ceil(spanSec / MAX_POINTS))
141
+ // Both additive and both ignorable, so a client that knows nothing of them is served
142
+ // exactly what it was served before. `stats=1` asks for the true per-bucket min/max
143
+ // under the mean — the gusts, which a mean line hides: on an hour of real sailing the
144
+ // mean spanned 4.8 kt where the true envelope spanned 8.3 kt. `chans=sog,aws` narrows
145
+ // the answer to what is actually plotted.
146
+ const stats = /^(1|true|yes)$/i.test(String(q.get('stats') || ''))
147
+ const chans = String(q.get('chans') || '').split(',').map((c) => c.trim()).filter(Boolean)
132
148
  try {
133
- const r = await provider.getSeries({ windowSec, everySec, ...(abs || {}), signal: abortOnClose(res) })
149
+ const r = await provider.getSeries({ windowSec, everySec, stats, chans, ...(abs || {}), signal: abortOnClose(res) })
134
150
  if (!r.ok) return sendJson(res, r.status || 502, { ok: false, code: 'history-error', message: r.message })
135
- sendJson(res, 200, {
151
+ const body = {
136
152
  ok: true,
137
153
  windowSec,
138
154
  everySec,
@@ -140,7 +156,11 @@ function createHistory (app, options) {
140
156
  from: abs ? abs.fromMs : Date.now() - windowSec * 1000,
141
157
  to: abs ? abs.toMs : Date.now(),
142
158
  series: r.series
143
- })
159
+ }
160
+ // Only when asked AND produced: a client that sent stats=1 must still degrade to
161
+ // the plain line rather than wait for a key that never comes.
162
+ if (stats && r.bands) body.bands = r.bands
163
+ sendJson(res, 200, body)
144
164
  } catch (e) {
145
165
  sendJson(res, 502, { ok: false, code: 'history-error', message: e.message })
146
166
  }
@@ -6,15 +6,28 @@
6
6
  // series/track contract as the InfluxDB provider; TWS/TWD are derived from apparent
7
7
  // wind + boat motion (as the ribbon does), STW is simply absent.
8
8
  //
9
+ // Each emitted row carries, per linear channel, the MEAN over the interval plus the true
10
+ // min/max seen inside it (`lo`/`hi`) and the sample count behind the mean (`n`) — see the
11
+ // two-clocks note in the constructor. Compass channels carry a last-reading snapshot and
12
+ // none of the three, because a bearing has no arithmetic mean.
13
+ //
9
14
  // Persistence (optional, `persistFile`): a JSONL APPEND-LOG so the ring survives
10
- // restarts and can cover a long passage cheaply. Each sample is appended as one
11
- // line (~160 B); the file is compacted (atomic rewrite to the current window) only
12
- // rarely — on start, once appends exceed the ring length, and on destroy — so write
13
- // load is per-sample, decoupled from window size (< ~1 GB over a 30-day passage vs
14
- // ~600 GB for a full-rewrite-every-2min snapshot). To keep RAM/disk bounded at any
15
- // window, the sample rate auto-coarsens so the ring never exceeds ~MAX_SAMPLES rows
16
- // (24 h 15 s, 30 d → ~52 s). All fs is guarded — a missing/corrupt file just means
17
- // an empty start; appends + compactions are serialized so they can't interleave.
15
+ // restarts and can cover a long passage cheaply. Each row is appended as one line; the
16
+ // file is compacted (atomic rewrite to the current window) only rarely — on start, once
17
+ // appends exceed the ring length, and on destroy — so write load is per-sample,
18
+ // decoupled from window size. Measured on a fully-populated row (every channel present):
19
+ // 307 B before the bands, 844 B with them, i.e. 2.7x. A 30-day passage is ~49.8k rows
20
+ // (the emit rate auto-coarsens to ~52 s, see below), so ~40 MB of appends plus ~2x that
21
+ // again in compaction rewrites against ~600 GB for a full-rewrite-every-2min snapshot,
22
+ // and still nothing on a boat SSD.
23
+ //
24
+ // To keep RAM/disk bounded at any window, the EMIT rate auto-coarsens so the ring never
25
+ // exceeds ~MAX_SAMPLES rows (24 h → 15 s, 30 d → ~52 s). That coarsening used to throw
26
+ // readings away; it no longer does. Only the emit rate coarsens, never the poll, so a
27
+ // 30-day passage still carries the true min/max within each ~52 s bucket.
28
+ //
29
+ // All fs is guarded — a missing/corrupt file just means an empty start; appends +
30
+ // compactions are serialized so they can't interleave.
18
31
 
19
32
  const fs = require('fs')
20
33
  const fsp = fs.promises
@@ -43,7 +56,18 @@ const CHANNELS = [
43
56
  // drawn over it.
44
57
  'perf'
45
58
  ]
59
+ // The channels whose values WRAP. A compass bearing cannot be averaged or min/max'd
60
+ // arithmetically — the mean of 359° and 1° is 180°, the exact opposite of the truth — so
61
+ // these keep a plain snapshot of the LAST reading in the interval and never carry a band.
62
+ // Everything else is linear and gets both. Same split as the app's ring provider; a
63
+ // mistake here would look entirely plausible on screen, which is why the test pins it.
64
+ const WRAPPED = new Set(['twd', 'twa', 'awa', 'cog', 'heading', 'wptBrg'])
65
+
46
66
  const wrap360 = (d) => ((d % 360) + 360) % 360
67
+ // Stored values are rounded to 3 decimals. Rows now carry a mean plus two extremes per
68
+ // channel, so full float noise ("6.430000000000001") would inflate every persisted line
69
+ // for precision no instrument has and no chart can draw.
70
+ const round3 = (v) => (Number.isFinite(v) ? Math.round(v * 1000) / 1000 : v)
47
71
  const MAX_SAMPLES = 50000
48
72
 
49
73
  // True wind (TWS kt, TWD ° the wind blows FROM).
@@ -71,10 +95,10 @@ function trueWind (s) {
71
95
  }
72
96
 
73
97
  class RingHistoryProvider {
74
- constructor ({ source, perfSource, sampleSec, windowSec, persistFile } = {}) {
98
+ constructor ({ source, perfSource, sampleSec, pollSec, windowSec, persistFile } = {}) {
75
99
  this._source = source
76
100
  this._perfSource = perfSource || null
77
- this._ring = [] // [{ t, ...CHANNELS, lat, lon }]
101
+ this._ring = [] // [{ t, ...CHANNELS, lo:{}, hi:{}, n:{}, lat, lon }]
78
102
  const win = windowSec || 3600
79
103
  this._windowMs = win * 1000
80
104
  this._persistFile = persistFile || null
@@ -83,45 +107,112 @@ class RingHistoryProvider {
83
107
  // auto-coarsen so the ring is bounded (~MAX_SAMPLES rows) regardless of window
84
108
  const stepSec = Math.max(sampleSec || 15, Math.ceil(win / MAX_SAMPLES))
85
109
  this._stepSec = stepSec
110
+ // TWO CLOCKS. This used to SNAPSHOT BoatState once per stepSec and store point values,
111
+ // which threw away 14 of every 15 readings BEFORE anything could ask a question about
112
+ // them: the gusts were gone before a chart ever saw the data, and no amount of later
113
+ // bucketing could bring them back. Now the state is POLLED at pollSec (~1 s, the rate
114
+ // the boat publishes) into a per-channel sum/count/min/max accumulator, and one row is
115
+ // EMITTED every stepSec carrying the mean plus the true extremes seen inside it.
116
+ //
117
+ // Ring length and emit rate are unchanged, so every existing budget still holds — and
118
+ // MAX_SAMPLES coarsening stops being lossy: a 30-day passage emitting every ~52 s
119
+ // still carries the true min/max within each 52 s, because the poll never coarsened.
120
+ this._pollSec = Math.min(stepSec, Math.max(0.2, pollSec || 1))
121
+ this._resetAcc()
86
122
 
87
123
  this._load() // seed from disk (survives restart)
88
124
  this._compactSync() // rewrite clean + bounded, synchronously (file exists right at start)
89
- this._sample() // immediate first sample
90
- this._timer = setInterval(() => this._sample(), stepSec * 1000)
125
+ this._sample() // immediate first row, so a fresh process is not blank for stepSec
126
+ this._pollTimer = setInterval(() => this._poll(), this._pollSec * 1000)
127
+ this._timer = setInterval(() => this._emit(), stepSec * 1000)
91
128
  if (this._timer.unref) this._timer.unref()
129
+ if (this._pollTimer.unref) this._pollTimer.unref()
92
130
  }
93
131
 
94
132
  // --- sampling ---
95
- _sample () {
96
- const s = this._source && this._source.getState && this._source.getState()
97
- if (!s) return
133
+ _resetAcc () {
134
+ // polls counts readings seen since the last emit. Zero means the telemetry source
135
+ // gave us nothing at all, and we push no row — a gap is the honest record, where a
136
+ // row of nulls would be a claim that we looked and the boat had no data.
137
+ this._acc = { polls: 0, sum: {}, cnt: {}, lo: {}, hi: {}, last: {}, lat: null, lon: null }
138
+ }
139
+
140
+ // BoatState -> the flat channel map this ring records. Kept separate from the
141
+ // accumulator so poll and emit share one definition of where each channel comes from.
142
+ _raw (s) {
98
143
  const tw = trueWind(s)
99
- const num = (v) => (Number.isFinite(v) ? v : null)
100
- const row = {
101
- t: Date.now(),
102
- sog: num(s.sogKt), cog: num(s.cogDeg), heading: num(s.headingDeg),
103
- stw: num(s.stwKt),
104
- aws: num(s.awsKt), awa: num(s.awaDeg), depth: num(s.depthM),
144
+ return {
145
+ sog: s.sogKt, cog: s.cogDeg, heading: s.headingDeg, stw: s.stwKt,
146
+ aws: s.awsKt, awa: s.awaDeg, depth: s.depthM,
105
147
  tws: tw ? tw.tws : null, twd: tw ? tw.twd : null,
106
148
  // Everything below arrived in BoatState with v0.18.6 but was never sampled here,
107
149
  // so these instrument cells had a live value and an empty history flyout.
108
- twa: num(s.twaDeg), vmg: num(s.vmgKt),
109
- seaTemp: num(s.seaTempC), airTemp: num(s.airTempC),
110
- rpmPort: num(s.rpmPort), rpmStbd: num(s.rpmStbd),
111
- // Waypoint channels are legitimately null when no destination is active — num()
112
- // maps that to null and CHANNELS omits empty ones, so the flyout stays blank
113
- // rather than showing a flat line at zero.
114
- wptBrg: num(s.wptBrgDeg), wptDist: num(s.wptDistNm),
115
- wptVmg: num(s.wptVmgKt), wptTtg: num(s.wptTtgSec),
116
- perf: this._perfSource ? num(this._perfSource.getPerf()) : null,
117
- lat: num(s.lat), lon: num(s.lon)
150
+ twa: s.twaDeg, vmg: s.vmgKt,
151
+ seaTemp: s.seaTempC, airTemp: s.airTempC,
152
+ rpmPort: s.rpmPort, rpmStbd: s.rpmStbd,
153
+ // Waypoint channels are legitimately null when no destination is active — the
154
+ // accumulator skips non-finite values and CHANNELS omits empty ones, so the flyout
155
+ // stays blank rather than showing a flat line at zero.
156
+ wptBrg: s.wptBrgDeg, wptDist: s.wptDistNm,
157
+ wptVmg: s.wptVmgKt, wptTtg: s.wptTtgSec,
158
+ perf: this._perfSource ? this._perfSource.getPerf() : null
118
159
  }
160
+ }
161
+
162
+ _poll () {
163
+ const s = this._source && this._source.getState && this._source.getState()
164
+ if (!s) return
165
+ const raw = this._raw(s)
166
+ const a = this._acc
167
+ a.polls++
168
+ for (const c of CHANNELS) {
169
+ const v = raw[c]
170
+ if (!Number.isFinite(v)) continue
171
+ a.last[c] = v
172
+ if (WRAPPED.has(c)) continue // a bearing has no meaningful mean or extreme
173
+ a.sum[c] = (a.sum[c] || 0) + v
174
+ a.cnt[c] = (a.cnt[c] || 0) + 1
175
+ a.lo[c] = Math.min(a.lo[c] == null ? v : a.lo[c], v)
176
+ a.hi[c] = Math.max(a.hi[c] == null ? v : a.hi[c], v)
177
+ }
178
+ if (Number.isFinite(s.lat)) a.lat = s.lat
179
+ if (Number.isFinite(s.lon)) a.lon = s.lon
180
+ }
181
+
182
+ // One row per stepSec: the mean of everything seen since the last emit, plus the true
183
+ // extremes (`lo`/`hi`) and the sample count behind each mean (`n`, needed to weight a
184
+ // later re-bucket). Wrapped channels carry the last reading and appear in none of the
185
+ // three.
186
+ _emit () {
187
+ const a = this._acc
188
+ if (!a.polls) return
189
+ const row = { t: Date.now(), lo: {}, hi: {}, n: {} }
190
+ for (const c of CHANNELS) {
191
+ if (WRAPPED.has(c)) { row[c] = a.last[c] == null ? null : round3(a.last[c]); continue }
192
+ const n = a.cnt[c] || 0
193
+ if (!n) { row[c] = null; continue }
194
+ row[c] = round3(a.sum[c] / n)
195
+ row.lo[c] = round3(a.lo[c])
196
+ row.hi[c] = round3(a.hi[c])
197
+ row.n[c] = n
198
+ }
199
+ row.lat = a.lat == null ? null : a.lat
200
+ row.lon = a.lon == null ? null : a.lon
201
+ this._resetAcc()
202
+
119
203
  this._ring.push(row)
120
204
  const cutoff = Date.now() - this._windowMs
121
205
  while (this._ring.length && this._ring[0].t < cutoff) this._ring.shift()
122
206
  this._append(row)
123
207
  }
124
208
 
209
+ // One poll + one emit, i.e. the old single-shot behaviour. Used for the first row at
210
+ // construction and by the tests, which drive the clocks by hand.
211
+ _sample () {
212
+ this._poll()
213
+ this._emit()
214
+ }
215
+
125
216
  available () { return true }
126
217
 
127
218
  // A trailing window, or an ABSOLUTE range when the caller passes fromMs/toMs — which
@@ -137,14 +228,67 @@ class RingHistoryProvider {
137
228
  return this._ring.filter((r) => r.t >= cutoff)
138
229
  }
139
230
 
140
- getSeries ({ windowSec, fromMs, toMs } = {}) {
231
+ // `series` is the mean line, exactly as before. Two additive extras, both ignorable:
232
+ // stats -> `bands`, the true per-bucket [t, min, max] under the mean;
233
+ // chans -> narrow the answer to the channels actually plotted.
234
+ //
235
+ // `everySec` is now HONOURED. It used to be ignored outright, so the history sheet's
236
+ // 24 h pill got raw sample-rate points whatever the pill said. Rows are re-bucketed
237
+ // into everySec windows: means weighted by the sample count behind each row, extremes
238
+ // as min-of-mins / max-of-maxes, wrapped channels taking the last reading. Buckets are
239
+ // labelled at their END, matching the cloud's aggregateWindow(timeSrc: "_stop") — label
240
+ // them at the start and the two providers plot half a bucket apart on the same screen.
241
+ getSeries ({ windowSec, everySec, fromMs, toMs, stats, chans } = {}) {
141
242
  const rows = this._window(windowSec, fromMs, toMs)
243
+ const want = chans && chans.length ? new Set(chans) : null
244
+ const step = Math.max(0, Math.round(everySec || 0)) * 1000
142
245
  const series = {}
246
+ const bands = {}
143
247
  for (const c of CHANNELS) {
144
- const pts = rows.filter((r) => r[c] != null).map((r) => [r.t, r[c]])
145
- if (pts.length) series[c] = pts
248
+ if (want && !want.has(c)) continue
249
+ const wrapped = WRAPPED.has(c)
250
+ // With no `every`, every row is its own point — which is what this endpoint has
251
+ // always returned, and the contract says `series` is unchanged with or without the
252
+ // new params. (Bucketing by r.t instead would merge two rows sharing a millisecond,
253
+ // which never happens in flight but does when a caller drives the clock by hand.)
254
+ const buckets = new Map()
255
+ let seq = 0
256
+ for (const r of rows) {
257
+ const v = r[c]
258
+ if (v == null) continue
259
+ const key = step ? Math.floor(r.t / step) * step : seq++
260
+ let b = buckets.get(key)
261
+ if (!b) buckets.set(key, (b = { t: step ? key + step : r.t, sum: 0, n: 0, lo: Infinity, hi: -Infinity, last: null }))
262
+ b.last = v
263
+ if (wrapped) continue
264
+ // Rows written before v0.29.0 carry no lo/hi/n. Reading them defensively means an
265
+ // append-log from an older version still loads and simply yields a degenerate
266
+ // band (the point value itself) rather than an empty chart or a crash.
267
+ const n = (r.n && r.n[c]) || 1
268
+ b.sum += v * n
269
+ b.n += n
270
+ const lo = (r.lo && r.lo[c] != null) ? r.lo[c] : v
271
+ const hi = (r.hi && r.hi[c] != null) ? r.hi[c] : v
272
+ if (lo < b.lo) b.lo = lo
273
+ if (hi > b.hi) b.hi = hi
274
+ }
275
+ const out = [...buckets.values()].sort((a, b) => a.t - b.t)
276
+ const pts = wrapped
277
+ ? out.map((b) => [b.t, b.last])
278
+ : out.filter((b) => b.n > 0).map((b) => [b.t, round3(b.sum / b.n)])
279
+ if (!pts.length) continue
280
+ series[c] = pts
281
+ // No `|| wrapped` here: the bucket loop above never accumulates a wrapped channel,
282
+ // so `n` stays 0 and the filter below drops it anyway. Keeping the extra condition
283
+ // would be unreachable code that no test can pin — and an unpinnable guard is the
284
+ // kind that quietly stops matching the guard it duplicates.
285
+ if (!stats) continue
286
+ const band = out
287
+ .filter((b) => b.n > 0 && Number.isFinite(b.lo) && Number.isFinite(b.hi))
288
+ .map((b) => [b.t, b.lo, b.hi])
289
+ if (band.length) bands[c] = band
146
290
  }
147
- return { ok: true, series }
291
+ return stats ? { ok: true, series, bands } : { ok: true, series }
148
292
  }
149
293
 
150
294
  getTrack ({ windowSec, fromMs, toMs, everySec } = {}) {
@@ -224,8 +368,9 @@ class RingHistoryProvider {
224
368
 
225
369
  destroy () {
226
370
  clearInterval(this._timer)
371
+ clearInterval(this._pollTimer)
227
372
  this._compact() // final flush of the current ring
228
373
  }
229
374
  }
230
375
 
231
- module.exports = { RingHistoryProvider, MAX_SAMPLES, CHANNELS }
376
+ module.exports = { RingHistoryProvider, MAX_SAMPLES, CHANNELS, WRAPPED }
@@ -43,6 +43,29 @@ async function readFromDisk (file, meta) {
43
43
  return { buffer, contentType }
44
44
  }
45
45
 
46
+ // Gzip bytes stored as if they were the payload. This is what the pre-0.23.9 transport
47
+ // did: core http hands back the still-compressed stream where fetch() decoded it, and the
48
+ // bytes were cached — and tiles are PINNED, so the corruption outlived the fix by weeks.
49
+ // Cesium read the gzip header as a vertex count: "Invalid typed array length:
50
+ // 11239580910". 2,391 terrain tiles and 188 vector tiles on this boat were still poisoned
51
+ // three days after the transport was fixed, because nothing ever re-examines a pinned
52
+ // file.
53
+ //
54
+ // So a HIT is checked, which costs two bytes of a buffer already in hand. A file that
55
+ // claims to be quantized-mesh or protobuf and begins 1f 8b is not that file; it is
56
+ // dropped and re-fetched. Content that is LEGITIMATELY gzip (a .gz asset, or a
57
+ // gzip content-type) is left alone — that is a payload, not an encoding.
58
+ function looksGzipped (buffer) {
59
+ return buffer && buffer.length >= 2 && buffer[0] === 0x1f && buffer[1] === 0x8b
60
+ }
61
+ function gzipIsThePayload (contentType, reqPath) {
62
+ const ct = String(contentType || '').toLowerCase()
63
+ return ct.includes('gzip') || ct.includes('x-gzip') || /\.gz(\?|$)/i.test(String(reqPath || ''))
64
+ }
65
+ function isCorruptOnDisk (r, reqPath) {
66
+ return looksGzipped(r.buffer) && !gzipIsThePayload(r.contentType, reqPath)
67
+ }
68
+
46
69
  // Fetch from upstream and persist atomically (bytes + content-type sidecar). The
47
70
  // rename stamps the file mtime ~now, which is what freshness compares against.
48
71
  async function fetchAndStore (url, file, meta, timeoutMs) {
@@ -112,7 +135,18 @@ async function getResource (opts) {
112
135
  const stat = await fsp.stat(file).catch(() => null)
113
136
  if (stat) {
114
137
  const stale = invalidatedAt && stat.mtimeMs < invalidatedAt
115
- if (!stale) return serveDisk('HIT')
138
+ if (!stale) {
139
+ const hit = await serveDisk('HIT')
140
+ if (!isCorruptOnDisk(hit, reqPath)) return hit
141
+ // Left over from the transport that cached compressed bytes. Serving it again would
142
+ // reproduce the original error for ever, so drop it and fetch a clean copy. If that
143
+ // fails, FAIL: a renderer given a gzip header errors hard, where a missing tile
144
+ // simply falls back to its parent.
145
+ await fsp.unlink(file).catch(() => {})
146
+ await fsp.unlink(meta).catch(() => {})
147
+ if (downNow) throw offline('cached copy was corrupt (gzip) and the upstream is down')
148
+ try { return await tryFetch('REPAIRED') } catch (e) { noteFail(e); throw e }
149
+ }
116
150
  // A newer bake was announced; the on-disk copy predates it.
117
151
  if (downNow) return serveDisk('STALE')
118
152
  try { return await tryFetch('UPDATED') } catch (e) { noteFail(e); return serveDisk('STALE') }
@@ -149,4 +183,4 @@ async function clearStore ({ storeDir, keep = [], prefix = '' } = {}) {
149
183
  return { removed, mode: 'keep', kept: [...keepSet] }
150
184
  }
151
185
 
152
- module.exports = { getResource, storePaths, clearStore }
186
+ module.exports = { getResource, storePaths, clearStore, isCorruptOnDisk }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sailkick-boat",
3
- "version": "0.27.0",
3
+ "version": "0.29.0",
4
4
  "description": "Run the sailkick app on board with no internet: charts, weather, climatology, trends and AIS all served from the boat itself. With a sailkick account it also syncs your metrics to the cloud in real time. Alpha, invite-only — info@sailkick.io",
5
5
  "main": "index.js",
6
6
  "scripts": {