signalk-questdb 2.1.10 → 2.2.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,3 +1,3 @@
1
1
  {
2
- ".": "2.1.10"
2
+ ".": "2.2.0"
3
3
  }
package/README.md CHANGED
@@ -38,6 +38,9 @@ InfluxDB history can be imported.
38
38
  ### Requirements
39
39
 
40
40
  - Node.js 22 or newer
41
+ - QuestDB 8.1 or newer (resolution queries use `SAMPLE BY … FROM … TO`). This
42
+ applies to an external QuestDB and to a pinned **QuestDB image version** in
43
+ managed mode; `latest` always qualifies
41
44
  - Signal K server — 2.31 or newer to choose the default history provider in the
42
45
  admin UI
43
46
  - For managed mode: the
@@ -92,32 +95,33 @@ Export** and **Danger zone** sections. The Danger zone removes the container and
92
95
  all of its data — including what Signal K's plugin uninstall cannot reach on
93
96
  rootless Podman.
94
97
 
95
- | Setting | Default | Description |
96
- | ---------------------------- | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
97
- | QuestDB image version | `latest` | Image tag; the dropdown lists the latest release, pre-releases, and the last three stable releases |
98
- | Manage QuestDB container | `true` | Let signalk-container run QuestDB; off = connect to an external QuestDB |
99
- | QuestDB host | `127.0.0.1` | External mode only; in managed mode the address is resolved automatically |
100
- | HTTP port | `9000` | External mode, or the host binding when "Bind to 0.0.0.0" is on; otherwise signalk-container allocates it |
101
- | ILP port | `9009` | External mode, or the host binding when "Bind to 0.0.0.0" is on; otherwise signalk-container allocates it |
102
- | PostgreSQL port | `8812` | Host binding for Grafana/psql when "Bind to 0.0.0.0" is on |
103
- | QuestDB memory limit | `768m` | Hard cgroup cap on the container (empty = unlimited); the JVM sizes its heap to a fraction of it, so heap plus off-heap stays inside |
104
- | QuestDB CPU limit (cores) | `1.5` | Fractional cores (0 = unlimited) |
105
- | Default sampling rate (ms) | `2000` | Minimum ms between writes for any one path (0 = every update) |
106
- | Per-path sampling rates (ms) | _(empty)_ | Glob → ms overrides, e.g. `{ "environment.wind.*": 200, "tanks.*": 10000 }` |
107
- | Write batch interval (ms) | `5000` | How often buffered samples are committed — one WAL transaction per table per commit (see Performance) |
108
- | Record own vessel | `true` | Record the `self` context |
109
- | Record AIS targets | `true` | Record other vessels |
110
- | Restore vessels on startup | `false` | Replay each vessel's last recorded position after a restart (see Startup restore) |
111
- | Restore max age (minutes) | `9` | Only replay values recorded within this window |
112
- | Allow `sourcePolicy=all` | `false` | Let History API callers split a path into one column per recording source (see History API) |
113
- | QuestDB console webapp | `true` | Serve QuestDB's console inside the Signal K admin UI, admin only (see Console webapp) |
114
- | Retention (days) | `0` | Drop daily partitions older than this (0 = keep forever) |
115
- | Filter mode | `exclude` | `exclude` the matching paths, or `include` only the matching paths |
116
- | Path patterns | _(empty)_ | Glob patterns, one per line, e.g. `environment.wind.*`; empty records everything |
117
- | Compression codec | `lz4` | On-disk WAL segments and Parquet exports: `none`, `lz4` (fast) or `zstd` (smaller) |
118
- | Compression level | `3` | ZSTD level 1–22, used only with `zstd` |
119
- | Container network | `sk-network` | Shared Podman/Docker network QuestDB joins in managed mode, so the companion signalk-grafana reaches it by container DNS; host-port publication is separate ("Bind to 0.0.0.0") |
120
- | Bind to 0.0.0.0 | `false` | Publish QuestDB's ports on all interfaces (see Connectivity) |
98
+ | Setting | Default | Description |
99
+ | ----------------------------- | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
100
+ | QuestDB image version | `latest` | Image tag; the dropdown lists the latest release, pre-releases, and the last three stable releases |
101
+ | Manage QuestDB container | `true` | Let signalk-container run QuestDB; off = connect to an external QuestDB |
102
+ | QuestDB host | `127.0.0.1` | External mode only; in managed mode the address is resolved automatically |
103
+ | HTTP port | `9000` | External mode, or the host binding when "Bind to 0.0.0.0" is on; otherwise signalk-container allocates it |
104
+ | ILP port | `9009` | External mode, or the host binding when "Bind to 0.0.0.0" is on; otherwise signalk-container allocates it |
105
+ | PostgreSQL port | `8812` | Host binding for Grafana/psql when "Bind to 0.0.0.0" is on |
106
+ | QuestDB memory limit | `768m` | Hard cgroup cap on the container (empty = unlimited); the JVM sizes its heap to a fraction of it, so heap plus off-heap stays inside |
107
+ | QuestDB CPU limit (cores) | `1.5` | Fractional cores (0 = unlimited) |
108
+ | Default sampling rate (ms) | `2000` | Minimum ms between writes for any one path (0 = every update) |
109
+ | Per-path sampling rates (ms) | _(empty)_ | Glob → ms overrides, e.g. `{ "environment.wind.*": 200, "tanks.*": 10000 }` |
110
+ | Unchanged-value heartbeat (s) | `300` | A value equal to the last one recorded is written again only this often; changes are written at the sampling rate (0 = every sample, see Change-only recording) |
111
+ | Write batch interval (ms) | `5000` | How often buffered samples are committed — one WAL transaction per table per commit (see Performance) |
112
+ | Record own vessel | `true` | Record the `self` context |
113
+ | Record AIS targets | `true` | Record other vessels |
114
+ | Restore vessels on startup | `false` | Replay each vessel's last recorded position after a restart (see Startup restore) |
115
+ | Restore max age (minutes) | `9` | Only replay values recorded within this window |
116
+ | Allow `sourcePolicy=all` | `false` | Let History API callers split a path into one column per recording source (see History API) |
117
+ | QuestDB console webapp | `true` | Serve QuestDB's console inside the Signal K admin UI, admin only (see Console webapp) |
118
+ | Retention (days) | `0` | Drop daily partitions older than this (0 = keep forever) |
119
+ | Filter mode | `exclude` | `exclude` the matching paths, or `include` only the matching paths |
120
+ | Path patterns | _(empty)_ | Glob patterns, one per line, e.g. `environment.wind.*`; empty records everything |
121
+ | Compression codec | `lz4` | On-disk WAL segments and Parquet exports: `none`, `lz4` (fast) or `zstd` (smaller) |
122
+ | Compression level | `3` | ZSTD level 1–22, used only with `zstd` |
123
+ | Container network | `sk-network` | Shared Podman/Docker network QuestDB joins in managed mode, so the companion signalk-grafana reaches it by container DNS; host-port publication is separate ("Bind to 0.0.0.0") |
124
+ | Bind to 0.0.0.0 | `false` | Publish QuestDB's ports on all interfaces (see Connectivity) |
121
125
 
122
126
  ## Recording
123
127
 
@@ -162,12 +166,55 @@ objects — notifications, resource documents — are not descended into; a valu
162
166
  table can hold is skipped and reported in the status endpoint and the panel
163
167
  rather than dropped silently.
164
168
 
169
+ A number that means "no reading" is skipped and reported the same way: `NaN`
170
+ and `±Infinity`, and the NMEA 2000 "no data" marker on AC real, apparent and
171
+ reactive power (PGNs 65007–65029). Those fields cannot encode anything below
172
+ −2 000 000 000 W, but canboatjs decodes "no data" as −2 000 000 001 W, so real
173
+ and reactive power below that floor are dropped; real negative power, such as
174
+ export, is unaffected. Apparent power reaches the stream multiplied by the power
175
+ factor, which can move the marker anywhere below zero — and apparent power is
176
+ never negative, so any negative apparent power is dropped. Connections using the
177
+ canboat WASM decoder do not produce the marker at all.
178
+
165
179
  **Sampling.** Each path is written at most once per **Default sampling rate**
166
180
  (2000 ms), with per-path overrides by glob for the few paths that need finer
167
181
  resolution and the many that need less. **Record own vessel** and **Record AIS
168
182
  targets** switch the two kinds of context independently, and **Path patterns**
169
183
  exclude (or include only) paths by glob.
170
184
 
185
+ **Change-only recording.** Most paths on a boat do not change from one sample
186
+ to the next: a switch that flips twice a day, an energy counter at rest, a tank
187
+ nobody is drawing from, a notification's state. Sampling alone would write each
188
+ of them at the sampling rate forever, and those repeats make up most of the
189
+ rows. So a value equal to the last one written for its path, vessel and source
190
+ is skipped until the **Unchanged-value heartbeat** is due, and then
191
+ written once more. Every change is written as it arrives, subject to the
192
+ sampling rate.
193
+
194
+ The heartbeat is what keeps the gaps readable. A path that is still being
195
+ updated has a row at least once per heartbeat, so a longer silence means the
196
+ data really stopped:
197
+
198
+ - **History API (v2).** A resolution bucket with no row takes the last sample
199
+ before it, for up to twice the heartbeat (see History API). A switch that has
200
+ been on for an hour reads as on for the whole hour, not as one point and
201
+ then nothing.
202
+ - **Stale data.** When Signal K marks a path stale it sends `null` for it.
203
+ Nothing is written for the `null`, so the history shows the last value for at
204
+ most twice the heartbeat and then a gap. The next reading after that is
205
+ always written, even if it equals the one before the gap.
206
+ - **Direct SQL and Grafana** see the rows as stored. Add `FILL(PREV)` to a
207
+ `SAMPLE BY` query to carry values across buckets.
208
+ - **Averages** weight rows, not time. A bucket in which a value changes often
209
+ and then holds steady averages mostly over the changes; `min`, `max`, `first`
210
+ and `last` are exact.
211
+ - **Startup restore** replays motion values recorded within **Restore max
212
+ age**. Keep the heartbeat below that, or a vessel lying still may come back
213
+ with its position but without its speed, course and heading.
214
+
215
+ `navigation.position` is never skipped. Set the heartbeat to 0 to record every
216
+ sample.
217
+
171
218
  **Batching.** Samples are buffered and committed every **Write batch interval**
172
219
  (5 s), one WAL transaction per table per commit. The buffer holds 100 000 lines;
173
220
  if QuestDB is unreachable for longer than that covers, the oldest lines are
@@ -205,6 +252,17 @@ in the window: the average there is of what the window still holds, and a value
205
252
  leaves it N buckets after it arrived. `ema` carries its last value across an
206
253
  empty bucket instead.
207
254
 
255
+ Buckets start at the request's `from` and cover the whole range, empty ones
256
+ included. A bucket with no row is not always missing data. Change-only recording (see
257
+ Recording) writes an unchanged value only once per heartbeat, so an empty bucket
258
+ takes the last sample before it — in every aggregate, since every aggregate of a
259
+ steady value is that value — for up to twice the heartbeat after the sample was
260
+ taken, and never into a bucket that has not started yet. At the start of a
261
+ range the sample comes from just before it. Past that window, and with the
262
+ heartbeat set to 0, an empty bucket is `null`. This happens before `sma` and
263
+ `ema`, so a held value counts as a sample. `middle_index` and
264
+ `navigation.position` return recorded rows only.
265
+
208
266
  A path recorded in radians — a heading, a course, a wind angle; `units: rad` in
209
267
  the server's metadata for the path — is averaged as a vector: `average`, `sma`
210
268
  and `ema` take the angle of the mean sine and cosine, so 359° and 1° average to
@@ -657,6 +715,10 @@ shown on the status card as **WAL suspended**. Two causes need two remedies:
657
715
  seconds, keeping write volume modest on busy NMEA 2000 buses; per-path
658
716
  overrides allow faster rates for critical paths
659
717
  (`{ "environment.wind.*": 200 }`) while slow-changing ones stay throttled.
718
+ - **Change-only recording** skips a value that has not changed since it was last
719
+ written until the heartbeat is due. Paths that hold steady — which is
720
+ most of them — cost one row per heartbeat instead of one per sampling
721
+ interval, which cuts both disk use and WAL apply work (see Recording).
660
722
  - **Resource caps** of 768 MB RAM and 1.5 CPU cores (cgroup limits via
661
723
  signalk-container) keep QuestDB from squeezing co-resident containers like
662
724
  Grafana, mayara, or signalk-backup. The JVM sizes its heap to a fraction of
@@ -0,0 +1,26 @@
1
+ export declare class ChangeGate {
2
+ private readonly heartbeatMs;
3
+ private readonly maxEntries;
4
+ private readonly byContext;
5
+ private count;
6
+ constructor(heartbeatMs: number, maxEntries?: number);
7
+ get size(): number;
8
+ /**
9
+ * True when `value` is what this path, context and source last wrote and
10
+ * the heartbeat is not yet due — the value adds nothing a reader cannot
11
+ * carry forward. A non-positive heartbeat turns the gate off.
12
+ */
13
+ isRepeat(path: string, context: string, source: string | undefined, value: unknown, now: number): boolean;
14
+ /** Records that `value` was written, so later equal values are repeats. */
15
+ wrote(path: string, context: string, source: string | undefined, value: unknown, now: number): void;
16
+ /**
17
+ * Forgets a path in a context, for every source, along with any leaves it
18
+ * was flattened into. Signal K reports a path going stale as a null value;
19
+ * without this, the reading that comes back after the gap would be skipped
20
+ * whenever it equals the one before it, and the gap would read as a value
21
+ * held straight through it.
22
+ */
23
+ forget(path: string, context: string): void;
24
+ clear(): void;
25
+ private evict;
26
+ }
@@ -0,0 +1,118 @@
1
+ // Change-only recording gate.
2
+ //
3
+ // Most of a boat's paths do not change from one sample to the next: a switch
4
+ // that flips twice a day, an energy counter at rest, a notification's state,
5
+ // a tank that is not being used. The throttle alone writes every one of them
6
+ // at the sampling rate forever — measured on a live install, 45% of numeric
7
+ // rows and 76% of string rows were a path that held one value all day.
8
+ //
9
+ // So a value equal to the last one WRITTEN for its path, context and source
10
+ // is skipped until the heartbeat is due. Every change is written as it
11
+ // arrives (subject to the throttle), and the heartbeat bounds how old the
12
+ // newest row of a live path can be. That bound is what lets the history API
13
+ // carry a held value across the empty buckets in between without also
14
+ // carrying a dead sensor's last value forever — see holdMs in history-v2.
15
+ //
16
+ // Compared against the last value written, not the last one seen: a change
17
+ // the throttle drops leaves the gate on the old value, so the next delta
18
+ // carrying the new one is still a change and gets written.
19
+ export class ChangeGate {
20
+ heartbeatMs;
21
+ maxEntries;
22
+ // Keyed by context first, so forget() — run for every null on the stream,
23
+ // and some sources send nulls routinely — only walks one vessel's paths.
24
+ byContext = new Map();
25
+ count = 0;
26
+ // Same pressure valve as Throttle: transient AIS contexts churn for months,
27
+ // and every (path, context, source) would otherwise stay until shutdown.
28
+ // An entry older than the heartbeat gates nothing — the next value is due
29
+ // anyway — so those are swept first; a full clear only costs one extra
30
+ // row per path.
31
+ constructor(heartbeatMs, maxEntries = 20_000) {
32
+ this.heartbeatMs = heartbeatMs;
33
+ this.maxEntries = maxEntries;
34
+ }
35
+ get size() {
36
+ return this.count;
37
+ }
38
+ /**
39
+ * True when `value` is what this path, context and source last wrote and
40
+ * the heartbeat is not yet due — the value adds nothing a reader cannot
41
+ * carry forward. A non-positive heartbeat turns the gate off.
42
+ */
43
+ isRepeat(path, context, source, value, now) {
44
+ if (this.heartbeatMs <= 0)
45
+ return false;
46
+ const entry = this.byContext.get(context)?.get(key(path, source));
47
+ return (entry !== undefined &&
48
+ entry.value === value &&
49
+ now - entry.at < this.heartbeatMs);
50
+ }
51
+ /** Records that `value` was written, so later equal values are repeats. */
52
+ wrote(path, context, source, value, now) {
53
+ if (this.heartbeatMs <= 0)
54
+ return;
55
+ const k = key(path, source);
56
+ let paths = this.byContext.get(context);
57
+ if (!paths?.has(k)) {
58
+ if (this.count >= this.maxEntries) {
59
+ this.evict(now);
60
+ paths = this.byContext.get(context);
61
+ }
62
+ if (!paths) {
63
+ paths = new Map();
64
+ this.byContext.set(context, paths);
65
+ }
66
+ this.count += 1;
67
+ }
68
+ paths.set(k, { path, value, at: now });
69
+ }
70
+ /**
71
+ * Forgets a path in a context, for every source, along with any leaves it
72
+ * was flattened into. Signal K reports a path going stale as a null value;
73
+ * without this, the reading that comes back after the gap would be skipped
74
+ * whenever it equals the one before it, and the gap would read as a value
75
+ * held straight through it.
76
+ */
77
+ forget(path, context) {
78
+ const paths = this.byContext.get(context);
79
+ if (!paths)
80
+ return;
81
+ const leafPrefix = `${path}.`;
82
+ for (const [k, entry] of paths) {
83
+ if (entry.path === path || entry.path.startsWith(leafPrefix)) {
84
+ paths.delete(k);
85
+ this.count -= 1;
86
+ }
87
+ }
88
+ if (paths.size === 0)
89
+ this.byContext.delete(context);
90
+ }
91
+ clear() {
92
+ this.byContext.clear();
93
+ this.count = 0;
94
+ }
95
+ evict(now) {
96
+ for (const [context, paths] of this.byContext) {
97
+ for (const [k, entry] of paths) {
98
+ if (now - entry.at >= this.heartbeatMs) {
99
+ paths.delete(k);
100
+ this.count -= 1;
101
+ }
102
+ }
103
+ if (paths.size === 0)
104
+ this.byContext.delete(context);
105
+ }
106
+ // Cleared unless the sweep got well below the cap: a sweep that frees a
107
+ // handful would run again after that many new keys, walking every entry
108
+ // on nearly every new key of a busy AIS stream.
109
+ if (this.count >= this.maxEntries * 0.75)
110
+ this.clear();
111
+ }
112
+ }
113
+ // A Signal K path cannot contain `|`, so two (path, source) pairs never share
114
+ // a key; contexts are kept apart one level up, in byContext.
115
+ function key(path, source) {
116
+ return `${path}|${source ?? ""}`;
117
+ }
118
+ //# sourceMappingURL=change-gate.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"change-gate.js","sourceRoot":"","sources":["../src/change-gate.ts"],"names":[],"mappings":"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"}
@@ -15,6 +15,7 @@ export declare const ConfigSchema: Type.TObject<{
15
15
  defaultSamplingRate: Type.TNumber;
16
16
  ilpFlushIntervalMs: Type.TNumber;
17
17
  samplingRates: Type.TRecord<"^.*$", Type.TNumber>;
18
+ unchangedHeartbeatSeconds: Type.TNumber;
18
19
  recordSelf: Type.TBoolean;
19
20
  recordOthers: Type.TBoolean;
20
21
  restoreOnStart: Type.TBoolean;
@@ -67,6 +67,12 @@ export const ConfigSchema = Type.Object({
67
67
  title: "Per-path sampling rates (ms)",
68
68
  description: 'Override default rate for specific paths. e.g. { "environment.wind.*": 200, "tanks.*": 10000 }',
69
69
  }),
70
+ unchangedHeartbeatSeconds: Type.Number({
71
+ default: 300,
72
+ minimum: 0,
73
+ title: "Unchanged-value heartbeat (s)",
74
+ description: "A value equal to the last one recorded for its path is written again only once per this many seconds instead of at every sampling interval; a change is written as it arrives, subject to the sampling rate like any value. History queries carry the last value across the buckets in between, for up to twice this long, so a path that stops updating shows as a gap after that. 0 = record every sample.",
75
+ }),
70
76
  recordSelf: Type.Boolean({
71
77
  default: true,
72
78
  title: "Record own vessel",
@@ -139,6 +145,12 @@ export function normalizeConfig(config) {
139
145
  paths: config.pathFilter?.paths ?? [],
140
146
  },
141
147
  samplingRates: config.samplingRates ?? {},
148
+ // A config saved before the option existed gets the schema default, so
149
+ // an update turns change-only recording on. An explicit 0 turns it off.
150
+ unchangedHeartbeatSeconds: typeof config.unchangedHeartbeatSeconds === "number" &&
151
+ config.unchangedHeartbeatSeconds >= 0
152
+ ? config.unchangedHeartbeatSeconds
153
+ : 300,
142
154
  // Missing recording toggles get the schema defaults (both ON). The
143
155
  // runtime guards read these directly, so without this a hand-edited or
144
156
  // pre-panel config silently disabled the recording the schema promises.
@@ -1 +1 @@
1
- {"version":3,"file":"schema.js","sourceRoot":"","sources":["../../src/config/schema.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"schema.js","sourceRoot":"","sources":["../../src/config/schema.ts"],"names":[],"mappings":"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"}
@@ -33,6 +33,11 @@ export declare class UnstorableTracker {
33
33
  clear(): void;
34
34
  }
35
35
  export declare function extractVesselName(path: string, value: unknown): string | null;
36
+ /**
37
+ * True for a numeric value that is an NMEA 2000 "no data" marker leaked
38
+ * through the decoder rather than a reading. See N2K_AC_POWER_FLOOR.
39
+ */
40
+ export declare function isNoDataSentinel(path: string, value: number): boolean;
36
41
  export declare function routeDeltaValue(path: string, value: unknown): DeltaRoute;
37
42
  /**
38
43
  * Scalar leaves of an object value, as dotted paths.
@@ -58,6 +58,38 @@ export function extractVesselName(path, value) {
58
58
  const name = value.name;
59
59
  return typeof name === "string" && name.trim() !== "" ? name : null;
60
60
  }
61
+ // The AC power fields of NMEA 2000 PGNs 65007–65029 (utility and generator
62
+ // real, apparent and reactive power, total and per phase) carry an offset of
63
+ // -2 000 000 000 W, so the lowest value they can encode is exactly that. The
64
+ // canboat definition also marks them signed, and canboatjs reads them that
65
+ // way: a device reporting "no data" (0xFFFFFFFF) decodes as -1, misses the
66
+ // signed no-data check (0x7FFFFFFF), and has the offset added — arriving as
67
+ // -2000000001 W ("error" and the other reserved values land just below). The
68
+ // canboat WASM decoder gets this right; canboatjs connections do not. A
69
+ // handful of these on device start-up dominate every min and average over the
70
+ // path they land in.
71
+ //
72
+ // Real and reactive power: anything below the floor cannot have been
73
+ // encoded, so it is an artefact by construction, and a real negative
74
+ // (exported, capacitive) power passes untouched.
75
+ //
76
+ // Apparent power needs a different test, because n2k-signalk multiplies it by
77
+ // the power factor before it reaches the stream: the marker times a factor
78
+ // below 1 lands ABOVE the floor (-3725220001.86 was observed in the field with
79
+ // a factor above 1). But apparent power is a magnitude, V·I, and never
80
+ // negative — so any negative value is the marker, whatever it was scaled by.
81
+ const N2K_AC_POWER_FLOOR = -2_000_000_000;
82
+ const SIGNED_AC_POWER_LEAF = /\.(realPower|reactivePower)$/;
83
+ const APPARENT_POWER_LEAF = /\.apparentPower$/;
84
+ /**
85
+ * True for a numeric value that is an NMEA 2000 "no data" marker leaked
86
+ * through the decoder rather than a reading. See N2K_AC_POWER_FLOOR.
87
+ */
88
+ export function isNoDataSentinel(path, value) {
89
+ if (APPARENT_POWER_LEAF.test(path))
90
+ return value < 0;
91
+ return value < N2K_AC_POWER_FLOOR && SIGNED_AC_POWER_LEAF.test(path);
92
+ }
61
93
  export function routeDeltaValue(path, value) {
62
94
  // NaN and ±Infinity have no ILP representation. QuestDB does NOT reject
63
95
  // them — verified against a live instance, `value=NaN` is accepted and
@@ -65,9 +97,12 @@ export function routeDeltaValue(path, value) {
65
97
  // column, and every aggregate over that path afterwards. A source
66
98
  // reporting a non-finite number is reporting "no reading", which is what
67
99
  // recording nothing means. flattenObjectValue already applied this to
68
- // leaves; the top-level path has to agree.
100
+ // leaves; the top-level path has to agree. A decoder's "no data" marker
101
+ // is the same "no reading", in disguise.
69
102
  if (typeof value === "number")
70
- return Number.isFinite(value) ? "number" : null;
103
+ return Number.isFinite(value) && !isNoDataSentinel(path, value)
104
+ ? "number"
105
+ : null;
71
106
  if (typeof value === "string")
72
107
  return "string";
73
108
  // Booleans are everywhere in Signal K — switch and relay states, pump and
@@ -129,8 +164,9 @@ export function flattenObjectValue(path, value) {
129
164
  const leafPath = `${path}.${key}`;
130
165
  if (typeof leaf === "number") {
131
166
  // NaN and ±Infinity have no ILP representation and would poison the
132
- // column; a sensor reporting them is reporting "no reading".
133
- if (Number.isFinite(leaf))
167
+ // column; a sensor reporting them is reporting "no reading", and so is
168
+ // a leaked "no data" marker.
169
+ if (Number.isFinite(leaf) && !isNoDataSentinel(leafPath, leaf))
134
170
  leaves.push({ path: leafPath, value: leaf });
135
171
  else
136
172
  skipped.push(leafPath);
@@ -1 +1 @@
1
- {"version":3,"file":"delta-routing.js","sourceRoot":"","sources":["../src/delta-routing.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"delta-routing.js","sourceRoot":"","sources":["../src/delta-routing.ts"],"names":[],"mappings":"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"}
@@ -1,3 +1,3 @@
1
1
  import { QueryClient } from "./query-client.js";
2
2
  import type { history as HistoryApi } from "@signalk/server-api";
3
- export declare function createHistoryProviderV2(queryClient: QueryClient, selfContext: string, sourcePolicyAllEnabled?: boolean, debug?: (msg: string) => void, unitsOf?: (path: string, context: string) => string | undefined): HistoryApi.HistoryProvider;
3
+ export declare function createHistoryProviderV2(queryClient: QueryClient, selfContext: string, sourcePolicyAllEnabled?: boolean, debug?: (msg: string) => void, unitsOf?: (path: string, context: string) => string | undefined, holdMs?: number): HistoryApi.HistoryProvider;