signalk-questdb 1.8.4 → 1.9.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
@@ -34,7 +34,7 @@ The plugin embeds a React config panel in the Signal K Admin UI showing:
34
34
  - **Image Version** -- dropdown with latest, pre-releases, and last 3 stable releases
35
35
  - **Connection** -- managed container toggle, host/ports, PostgreSQL port for Grafana
36
36
  - **Recording** -- record self, record AIS targets, startup restore, console webapp, retention days
37
- - **Path filtering** (collapsible) -- exclude or include-only paths with glob patterns (e.g. exclude `notifications.*`); empty by default, so everything is recorded
37
+ - **Path filtering** (collapsible) -- exclude or include-only paths with glob patterns
38
38
  - **Compression** (collapsible) -- LZ4/ZSTD codec selection for on-disk storage
39
39
  - **InfluxDB Migration** (collapsible) -- auto-detect with manual URL for remote instances
40
40
  - **Data Export** (collapsible) -- date range picker, Parquet/CSV format, download button
@@ -1,3 +1,4 @@
1
+ import type { UlimitClamp } from "signalk-container-helper";
1
2
  import type { MaxMapCountStatus } from "./host-limits.js";
2
3
  import type { SkipPlan, SuspendedTable } from "./wal-monitor.js";
3
4
  /**
@@ -11,16 +12,15 @@ export interface ApiError {
11
12
  message?: string;
12
13
  }
13
14
  /**
14
- * Mirror of signalk-container's `UlimitClamp` event, redeclared (not
15
- * imported) for the same reason index.ts redeclares it: signalk-container is
16
- * an optional peer reached through globalThis, so there is no type to import.
15
+ * signalk-container's `UlimitClamp` event, re-exported under the name the
16
+ * API responses use.
17
+ *
18
+ * `import type` is load-bearing: this file is shared with the browser panel,
19
+ * and the helper's main entry carries ~19KB of Node-side runtime code. A
20
+ * value import would drag all of it into the federated bundle. The type-only
21
+ * form is erased at compile time, so nothing ships.
17
22
  */
18
- export interface UlimitClampStatus {
19
- ulimit: string;
20
- requested: number;
21
- granted: number;
22
- reason: string;
23
- }
23
+ export type UlimitClampStatus = UlimitClamp;
24
24
  export type SuspendedTableStatus = SuspendedTable & {
25
25
  /** WAL monitor's verdict for the current stall point: "pending" | "failed". */
26
26
  autoResume: string | null;
@@ -51,6 +51,26 @@ export interface DbStatus extends ApiError {
51
51
  * display name gets an empty statusMessage and the count would be invisible.
52
52
  */
53
53
  restore?: RestoreStatus | null;
54
+ /**
55
+ * Paths seen carrying a value no table can hold — arrays, or objects nested
56
+ * deeper than the one level that gets flattened. Absent when nothing has
57
+ * been dropped. Surfaced because the previous behaviour was to drop them
58
+ * silently, which is how attitude went unrecorded unnoticed (issue #128).
59
+ */
60
+ unstorable?: UnstorableStatus | null;
61
+ }
62
+ /** Values encountered that cannot be represented in any table. */
63
+ export interface UnstorableStatus {
64
+ /**
65
+ * Distinct paths dropped since the plugin started. A LOWER BOUND when
66
+ * `truncated` is set: tracking is capped, because Signal K paths embed
67
+ * instance and vessel identifiers and so are not a bounded vocabulary.
68
+ */
69
+ paths: number;
70
+ /** True once the cap was hit and further distinct paths stopped being kept. */
71
+ truncated: boolean;
72
+ /** A sample of those paths, for identifying them without reading logs. */
73
+ examples: string[];
54
74
  }
55
75
  /** Startup-restore outcome, shown on the config panel. */
56
76
  export interface RestoreStatus {
@@ -1,14 +1,4 @@
1
- import type { MigrationSource, QuestdbVersion } from "../api-contract.js";
2
- /**
3
- * GET /api/versions returns an array of releases. Anything else becomes an
4
- * empty list, and malformed members are dropped: the version dropdown is
5
- * then short or empty rather than the panel being gone.
6
- *
7
- * Members are validated, not just the array — `versions.filter(v =>
8
- * !v.prerelease)` runs during render, so a single null element is as fatal
9
- * as a non-array body.
10
- */
11
- export declare function toVersionList(body: unknown): QuestdbVersion[];
1
+ import type { MigrationSource } from "../api-contract.js";
12
2
  /**
13
3
  * GET /api/migration/detect returns `{ sources: [...] }`. A body without it
14
4
  * means "nothing detected", which is also what an empty array means — so the
@@ -9,25 +9,6 @@
9
9
  //
10
10
  // These helpers keep the "trust but verify" boundary in one testable place.
11
11
  const isRecord = (v) => typeof v === "object" && v !== null;
12
- /**
13
- * GET /api/versions returns an array of releases. Anything else becomes an
14
- * empty list, and malformed members are dropped: the version dropdown is
15
- * then short or empty rather than the panel being gone.
16
- *
17
- * Members are validated, not just the array — `versions.filter(v =>
18
- * !v.prerelease)` runs during render, so a single null element is as fatal
19
- * as a non-array body.
20
- */
21
- export function toVersionList(body) {
22
- if (!Array.isArray(body))
23
- return [];
24
- return body.filter((v) => isRecord(v) &&
25
- typeof v.tag === "string" &&
26
- // Not merely cosmetic: the two filters below partition on this, so a
27
- // missing or non-boolean value would list a pre-release as stable —
28
- // and the stable list is what the version dropdown defaults to.
29
- typeof v.prerelease === "boolean");
30
- }
31
12
  /**
32
13
  * GET /api/migration/detect returns `{ sources: [...] }`. A body without it
33
14
  * means "nothing detected", which is also what an empty array means — so the
@@ -1 +1 @@
1
- {"version":3,"file":"responses.js","sourceRoot":"","sources":["../../src/configpanel/responses.ts"],"names":[],"mappings":"AAAA,4EAA4E;AAC5E,EAAE;AACF,4EAA4E;AAC5E,6EAA6E;AAC7E,6EAA6E;AAC7E,wEAAwE;AACxE,4EAA4E;AAC5E,uEAAuE;AACvE,EAAE;AACF,4EAA4E;AAI5E,MAAM,QAAQ,GAAG,CAAC,CAAU,EAAgC,EAAE,CAC5D,OAAO,CAAC,KAAK,QAAQ,IAAI,CAAC,KAAK,IAAI,CAAC;AAEtC;;;;;;;;GAQG;AACH,MAAM,UAAU,aAAa,CAAC,IAAa;IACzC,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,IAAI,CAAC;QAAE,OAAO,EAAE,CAAC;IACpC,OAAO,IAAI,CAAC,MAAM,CAChB,CAAC,CAAC,EAAuB,EAAE,CACzB,QAAQ,CAAC,CAAC,CAAC;QACX,OAAO,CAAC,CAAC,GAAG,KAAK,QAAQ;QACzB,qEAAqE;QACrE,oEAAoE;QACpE,gEAAgE;QAChE,OAAO,CAAC,CAAC,UAAU,KAAK,SAAS,CACpC,CAAC;AACJ,CAAC;AAED;;;;;GAKG;AACH,MAAM,UAAU,kBAAkB,CAAC,IAAa;IAC9C,MAAM,OAAO,GAAG,QAAQ,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC,CAAC,SAAS,CAAC;IAC1D,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,OAAO,CAAC;QAAE,OAAO,EAAE,CAAC;IACvC,OAAO,OAAO,CAAC,MAAM,CACnB,CAAC,CAAC,EAAwB,EAAE,CAC1B,QAAQ,CAAC,CAAC,CAAC;QACX,OAAO,CAAC,CAAC,IAAI,KAAK,QAAQ;QAC1B,OAAO,CAAC,CAAC,GAAG,KAAK,QAAQ;QACzB,iEAAiE;QACjE,kEAAkE;QAClE,iEAAiE;QACjE,sDAAsD;QACtD,OAAO,CAAC,CAAC,MAAM,KAAK,QAAQ;QAC5B,CAAC,CAAC,CAAC,OAAO,KAAK,SAAS,IAAI,OAAO,CAAC,CAAC,OAAO,KAAK,QAAQ,CAAC,CAC7D,CAAC;AACJ,CAAC"}
1
+ {"version":3,"file":"responses.js","sourceRoot":"","sources":["../../src/configpanel/responses.ts"],"names":[],"mappings":"AAAA,4EAA4E;AAC5E,EAAE;AACF,4EAA4E;AAC5E,6EAA6E;AAC7E,6EAA6E;AAC7E,wEAAwE;AACxE,4EAA4E;AAC5E,uEAAuE;AACvE,EAAE;AACF,4EAA4E;AAI5E,MAAM,QAAQ,GAAG,CAAC,CAAU,EAAgC,EAAE,CAC5D,OAAO,CAAC,KAAK,QAAQ,IAAI,CAAC,KAAK,IAAI,CAAC;AAEtC;;;;;GAKG;AACH,MAAM,UAAU,kBAAkB,CAAC,IAAa;IAC9C,MAAM,OAAO,GAAG,QAAQ,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC,CAAC,SAAS,CAAC;IAC1D,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,OAAO,CAAC;QAAE,OAAO,EAAE,CAAC;IACvC,OAAO,OAAO,CAAC,MAAM,CACnB,CAAC,CAAC,EAAwB,EAAE,CAC1B,QAAQ,CAAC,CAAC,CAAC;QACX,OAAO,CAAC,CAAC,IAAI,KAAK,QAAQ;QAC1B,OAAO,CAAC,CAAC,GAAG,KAAK,QAAQ;QACzB,iEAAiE;QACjE,kEAAkE;QAClE,iEAAiE;QACjE,sDAAsD;QACtD,OAAO,CAAC,CAAC,MAAM,KAAK,QAAQ;QAC5B,CAAC,CAAC,CAAC,OAAO,KAAK,SAAS,IAAI,OAAO,CAAC,CAAC,OAAO,KAAK,QAAQ,CAAC,CAC7D,CAAC;AACJ,CAAC"}
@@ -1,3 +1,57 @@
1
- export type DeltaRoute = "number" | "string" | "boolean" | "position" | null;
1
+ export type DeltaRoute = "number" | "string" | "boolean" | "position" | "flatten" | null;
2
+ /** One scalar leaf pulled out of an object value, ready to record. */
3
+ export interface FlattenedLeaf {
4
+ /** Parent path plus the key, e.g. `navigation.attitude.roll`. */
5
+ path: string;
6
+ value: number | string | boolean;
7
+ }
8
+ /**
9
+ * Remembers which paths carried a value no table can hold, so the drop can be
10
+ * reported instead of happening in silence — the underlying bug behind #128.
11
+ *
12
+ * Capped, because the path vocabulary is NOT bounded: paths embed instance
13
+ * identifiers (`watermaker.0.*`) and notifications embed per-vessel ones, so
14
+ * an unstorable shape on a busy AIS stream would grow an uncapped set for the
15
+ * lifetime of the process. Past the cap the count still rises but no new path
16
+ * is retained, and `truncated` marks the count as a lower bound rather than
17
+ * letting it read as exact.
18
+ *
19
+ * Deliberately does no logging: `note()` runs per delta on a stream carrying
20
+ * 100+ values/sec, and building a message string there costs whether or not
21
+ * debug is enabled.
22
+ */
23
+ export declare class UnstorableTracker {
24
+ private readonly cap;
25
+ private readonly paths;
26
+ private capped;
27
+ constructor(cap?: number);
28
+ /** Records a path. Cheap and idempotent; safe to call per delta. */
29
+ note(path: string): void;
30
+ get size(): number;
31
+ get truncated(): boolean;
32
+ examples(limit: number): string[];
33
+ clear(): void;
34
+ }
2
35
  export declare function extractVesselName(path: string, value: unknown): string | null;
3
36
  export declare function routeDeltaValue(path: string, value: unknown): DeltaRoute;
37
+ /**
38
+ * Scalar leaves of an object value, as dotted paths.
39
+ *
40
+ * navigation.attitude {roll: 0.02, yaw: 1.57}
41
+ * -> navigation.attitude.roll 0.02
42
+ * -> navigation.attitude.yaw 1.57
43
+ *
44
+ * **One level deep, deliberately.** That covers attitude and effectively every
45
+ * real Signal K object, while a recursive walk would happily write out whole
46
+ * nested payloads (notifications, resource documents) that nobody asked to
47
+ * record. A nested object is therefore skipped, not descended into — and
48
+ * reported, so it is not another silent drop.
49
+ *
50
+ * The leaves are ordinary scalar paths, which is what makes this cheap: no
51
+ * schema change, and dedup, retention, sampling, path filtering and both
52
+ * history APIs apply to them unchanged.
53
+ */
54
+ export declare function flattenObjectValue(path: string, value: unknown): {
55
+ leaves: FlattenedLeaf[];
56
+ skipped: string[];
57
+ };
@@ -1,3 +1,49 @@
1
+ /**
2
+ * Remembers which paths carried a value no table can hold, so the drop can be
3
+ * reported instead of happening in silence — the underlying bug behind #128.
4
+ *
5
+ * Capped, because the path vocabulary is NOT bounded: paths embed instance
6
+ * identifiers (`watermaker.0.*`) and notifications embed per-vessel ones, so
7
+ * an unstorable shape on a busy AIS stream would grow an uncapped set for the
8
+ * lifetime of the process. Past the cap the count still rises but no new path
9
+ * is retained, and `truncated` marks the count as a lower bound rather than
10
+ * letting it read as exact.
11
+ *
12
+ * Deliberately does no logging: `note()` runs per delta on a stream carrying
13
+ * 100+ values/sec, and building a message string there costs whether or not
14
+ * debug is enabled.
15
+ */
16
+ export class UnstorableTracker {
17
+ cap;
18
+ paths = new Set();
19
+ capped = false;
20
+ constructor(cap = 50) {
21
+ this.cap = cap;
22
+ }
23
+ /** Records a path. Cheap and idempotent; safe to call per delta. */
24
+ note(path) {
25
+ if (this.paths.has(path))
26
+ return;
27
+ if (this.paths.size >= this.cap) {
28
+ this.capped = true;
29
+ return;
30
+ }
31
+ this.paths.add(path);
32
+ }
33
+ get size() {
34
+ return this.paths.size;
35
+ }
36
+ get truncated() {
37
+ return this.capped;
38
+ }
39
+ examples(limit) {
40
+ return [...this.paths].slice(0, limit);
41
+ }
42
+ clear() {
43
+ this.paths.clear();
44
+ this.capped = false;
45
+ }
46
+ }
1
47
  // Static vessel identity arrives as EMPTY-path object deltas —
2
48
  // `{path: "", value: {name: "..."}}` is how AIS static reports reach the
3
49
  // server, and the exact shape Freeboard reads names from. These never make
@@ -13,8 +59,15 @@ export function extractVesselName(path, value) {
13
59
  return typeof name === "string" && name.trim() !== "" ? name : null;
14
60
  }
15
61
  export function routeDeltaValue(path, value) {
62
+ // NaN and ±Infinity have no ILP representation. QuestDB does NOT reject
63
+ // them — verified against a live instance, `value=NaN` is accepted and
64
+ // stored — so an unguarded sensor fault silently poisons the numeric
65
+ // column, and every aggregate over that path afterwards. A source
66
+ // reporting a non-finite number is reporting "no reading", which is what
67
+ // recording nothing means. flattenObjectValue already applied this to
68
+ // leaves; the top-level path has to agree.
16
69
  if (typeof value === "number")
17
- return "number";
70
+ return Number.isFinite(value) ? "number" : null;
18
71
  if (typeof value === "string")
19
72
  return "string";
20
73
  // Booleans are everywhere in Signal K — switch and relay states, pump and
@@ -34,6 +87,62 @@ export function routeDeltaValue(path, value) {
34
87
  Number.isFinite(value.latitude) &&
35
88
  Number.isFinite(value.longitude))
36
89
  return "position";
90
+ // Any other non-null, non-array object: record its scalar leaves
91
+ // individually. Arrays are excluded deliberately — their indices are not
92
+ // stable identities, so `foo.0` would silently mean a different thing from
93
+ // one delta to the next.
94
+ if (value !== null && typeof value === "object" && !Array.isArray(value))
95
+ return "flatten";
37
96
  return null;
38
97
  }
98
+ /**
99
+ * Scalar leaves of an object value, as dotted paths.
100
+ *
101
+ * navigation.attitude {roll: 0.02, yaw: 1.57}
102
+ * -> navigation.attitude.roll 0.02
103
+ * -> navigation.attitude.yaw 1.57
104
+ *
105
+ * **One level deep, deliberately.** That covers attitude and effectively every
106
+ * real Signal K object, while a recursive walk would happily write out whole
107
+ * nested payloads (notifications, resource documents) that nobody asked to
108
+ * record. A nested object is therefore skipped, not descended into — and
109
+ * reported, so it is not another silent drop.
110
+ *
111
+ * The leaves are ordinary scalar paths, which is what makes this cheap: no
112
+ * schema change, and dedup, retention, sampling, path filtering and both
113
+ * history APIs apply to them unchanged.
114
+ */
115
+ export function flattenObjectValue(path, value) {
116
+ const leaves = [];
117
+ const skipped = [];
118
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
119
+ return { leaves, skipped };
120
+ }
121
+ // An empty parent would build ".name" — a leading-dot path that matches no
122
+ // Signal K path and no filter pattern. The recorder already returns before
123
+ // reaching here for path "" (that shape is the AIS static report, handled
124
+ // by extractVesselName), so this is belt-and-braces: the function must not
125
+ // depend on a caller's guard to avoid emitting a malformed path.
126
+ if (path === "")
127
+ return { leaves, skipped };
128
+ for (const [key, leaf] of Object.entries(value)) {
129
+ const leafPath = `${path}.${key}`;
130
+ if (typeof leaf === "number") {
131
+ // 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))
134
+ leaves.push({ path: leafPath, value: leaf });
135
+ else
136
+ skipped.push(leafPath);
137
+ }
138
+ else if (typeof leaf === "string" || typeof leaf === "boolean") {
139
+ leaves.push({ path: leafPath, value: leaf });
140
+ }
141
+ else {
142
+ // Nested object, array, null or undefined.
143
+ skipped.push(leafPath);
144
+ }
145
+ }
146
+ return { leaves, skipped };
147
+ }
39
148
  //# sourceMappingURL=delta-routing.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"delta-routing.js","sourceRoot":"","sources":["../src/delta-routing.ts"],"names":[],"mappings":"AAUA,+DAA+D;AAC/D,yEAAyE;AACzE,2EAA2E;AAC3E,sEAAsE;AACtE,wEAAwE;AACxE,uBAAuB;AACvB,MAAM,UAAU,iBAAiB,CAAC,IAAY,EAAE,KAAc;IAC5D,IAAI,IAAI,KAAK,EAAE;QAAE,OAAO,IAAI,CAAC;IAC7B,IAAI,KAAK,KAAK,IAAI,IAAI,OAAO,KAAK,KAAK,QAAQ;QAAE,OAAO,IAAI,CAAC;IAC7D,MAAM,IAAI,GAAI,KAA4B,CAAC,IAAI,CAAC;IAChD,OAAO,OAAO,IAAI,KAAK,QAAQ,IAAI,IAAI,CAAC,IAAI,EAAE,KAAK,EAAE,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC;AACtE,CAAC;AAED,MAAM,UAAU,eAAe,CAAC,IAAY,EAAE,KAAc;IAC1D,IAAI,OAAO,KAAK,KAAK,QAAQ;QAAE,OAAO,QAAQ,CAAC;IAC/C,IAAI,OAAO,KAAK,KAAK,QAAQ;QAAE,OAAO,QAAQ,CAAC;IAC/C,0EAA0E;IAC1E,yEAAyE;IACzE,iEAAiE;IACjE,0EAA0E;IAC1E,2EAA2E;IAC3E,yEAAyE;IACzE,qDAAqD;IACrD,IAAI,OAAO,KAAK,KAAK,SAAS;QAAE,OAAO,SAAS,CAAC;IACjD,IACE,IAAI,KAAK,qBAAqB;QAC9B,KAAK,KAAK,IAAI;QACd,OAAO,KAAK,KAAK,QAAQ;QACzB,UAAU,IAAI,KAAK;QACnB,WAAW,IAAI,KAAK;QACpB,MAAM,CAAC,QAAQ,CAAE,KAA+B,CAAC,QAAQ,CAAC;QAC1D,MAAM,CAAC,QAAQ,CAAE,KAAgC,CAAC,SAAS,CAAC;QAE5D,OAAO,UAAU,CAAC;IACpB,OAAO,IAAI,CAAC;AACd,CAAC"}
1
+ {"version":3,"file":"delta-routing.js","sourceRoot":"","sources":["../src/delta-routing.ts"],"names":[],"mappings":"AAsBA;;;;;;;;;;;;;;GAcG;AACH,MAAM,OAAO,iBAAiB;IAIC;IAHZ,KAAK,GAAG,IAAI,GAAG,EAAU,CAAC;IACnC,MAAM,GAAG,KAAK,CAAC;IAEvB,YAA6B,MAAM,EAAE;QAAR,QAAG,GAAH,GAAG,CAAK;IAAG,CAAC;IAEzC,oEAAoE;IACpE,IAAI,CAAC,IAAY;QACf,IAAI,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,IAAI,CAAC;YAAE,OAAO;QACjC,IAAI,IAAI,CAAC,KAAK,CAAC,IAAI,IAAI,IAAI,CAAC,GAAG,EAAE,CAAC;YAChC,IAAI,CAAC,MAAM,GAAG,IAAI,CAAC;YACnB,OAAO;QACT,CAAC;QACD,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC;IACvB,CAAC;IAED,IAAI,IAAI;QACN,OAAO,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC;IACzB,CAAC;IAED,IAAI,SAAS;QACX,OAAO,IAAI,CAAC,MAAM,CAAC;IACrB,CAAC;IAED,QAAQ,CAAC,KAAa;QACpB,OAAO,CAAC,GAAG,IAAI,CAAC,KAAK,CAAC,CAAC,KAAK,CAAC,CAAC,EAAE,KAAK,CAAC,CAAC;IACzC,CAAC;IAED,KAAK;QACH,IAAI,CAAC,KAAK,CAAC,KAAK,EAAE,CAAC;QACnB,IAAI,CAAC,MAAM,GAAG,KAAK,CAAC;IACtB,CAAC;CACF;AAED,+DAA+D;AAC/D,yEAAyE;AACzE,2EAA2E;AAC3E,sEAAsE;AACtE,wEAAwE;AACxE,uBAAuB;AACvB,MAAM,UAAU,iBAAiB,CAAC,IAAY,EAAE,KAAc;IAC5D,IAAI,IAAI,KAAK,EAAE;QAAE,OAAO,IAAI,CAAC;IAC7B,IAAI,KAAK,KAAK,IAAI,IAAI,OAAO,KAAK,KAAK,QAAQ;QAAE,OAAO,IAAI,CAAC;IAC7D,MAAM,IAAI,GAAI,KAA4B,CAAC,IAAI,CAAC;IAChD,OAAO,OAAO,IAAI,KAAK,QAAQ,IAAI,IAAI,CAAC,IAAI,EAAE,KAAK,EAAE,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC;AACtE,CAAC;AAED,MAAM,UAAU,eAAe,CAAC,IAAY,EAAE,KAAc;IAC1D,wEAAwE;IACxE,uEAAuE;IACvE,qEAAqE;IACrE,kEAAkE;IAClE,yEAAyE;IACzE,sEAAsE;IACtE,2CAA2C;IAC3C,IAAI,OAAO,KAAK,KAAK,QAAQ;QAC3B,OAAO,MAAM,CAAC,QAAQ,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,QAAQ,CAAC,CAAC,CAAC,IAAI,CAAC;IAClD,IAAI,OAAO,KAAK,KAAK,QAAQ;QAAE,OAAO,QAAQ,CAAC;IAC/C,0EAA0E;IAC1E,yEAAyE;IACzE,iEAAiE;IACjE,0EAA0E;IAC1E,2EAA2E;IAC3E,yEAAyE;IACzE,qDAAqD;IACrD,IAAI,OAAO,KAAK,KAAK,SAAS;QAAE,OAAO,SAAS,CAAC;IACjD,IACE,IAAI,KAAK,qBAAqB;QAC9B,KAAK,KAAK,IAAI;QACd,OAAO,KAAK,KAAK,QAAQ;QACzB,UAAU,IAAI,KAAK;QACnB,WAAW,IAAI,KAAK;QACpB,MAAM,CAAC,QAAQ,CAAE,KAA+B,CAAC,QAAQ,CAAC;QAC1D,MAAM,CAAC,QAAQ,CAAE,KAAgC,CAAC,SAAS,CAAC;QAE5D,OAAO,UAAU,CAAC;IACpB,iEAAiE;IACjE,yEAAyE;IACzE,2EAA2E;IAC3E,yBAAyB;IACzB,IAAI,KAAK,KAAK,IAAI,IAAI,OAAO,KAAK,KAAK,QAAQ,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,KAAK,CAAC;QACtE,OAAO,SAAS,CAAC;IACnB,OAAO,IAAI,CAAC;AACd,CAAC;AAED;;;;;;;;;;;;;;;;GAgBG;AACH,MAAM,UAAU,kBAAkB,CAChC,IAAY,EACZ,KAAc;IAEd,MAAM,MAAM,GAAoB,EAAE,CAAC;IACnC,MAAM,OAAO,GAAa,EAAE,CAAC;IAC7B,IAAI,KAAK,KAAK,IAAI,IAAI,OAAO,KAAK,KAAK,QAAQ,IAAI,KAAK,CAAC,OAAO,CAAC,KAAK,CAAC,EAAE,CAAC;QACxE,OAAO,EAAE,MAAM,EAAE,OAAO,EAAE,CAAC;IAC7B,CAAC;IACD,2EAA2E;IAC3E,2EAA2E;IAC3E,0EAA0E;IAC1E,2EAA2E;IAC3E,iEAAiE;IACjE,IAAI,IAAI,KAAK,EAAE;QAAE,OAAO,EAAE,MAAM,EAAE,OAAO,EAAE,CAAC;IAC5C,KAAK,MAAM,CAAC,GAAG,EAAE,IAAI,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC,KAAgC,CAAC,EAAE,CAAC;QAC3E,MAAM,QAAQ,GAAG,GAAG,IAAI,IAAI,GAAG,EAAE,CAAC;QAClC,IAAI,OAAO,IAAI,KAAK,QAAQ,EAAE,CAAC;YAC7B,oEAAoE;YACpE,6DAA6D;YAC7D,IAAI,MAAM,CAAC,QAAQ,CAAC,IAAI,CAAC;gBAAE,MAAM,CAAC,IAAI,CAAC,EAAE,IAAI,EAAE,QAAQ,EAAE,KAAK,EAAE,IAAI,EAAE,CAAC,CAAC;;gBACnE,OAAO,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC;QAC9B,CAAC;aAAM,IAAI,OAAO,IAAI,KAAK,QAAQ,IAAI,OAAO,IAAI,KAAK,SAAS,EAAE,CAAC;YACjE,MAAM,CAAC,IAAI,CAAC,EAAE,IAAI,EAAE,QAAQ,EAAE,KAAK,EAAE,IAAI,EAAE,CAAC,CAAC;QAC/C,CAAC;aAAM,CAAC;YACN,2CAA2C;YAC3C,OAAO,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC;QACzB,CAAC;IACH,CAAC;IACD,OAAO,EAAE,MAAM,EAAE,OAAO,EAAE,CAAC;AAC7B,CAAC"}
package/dist/index.js CHANGED
@@ -1,7 +1,8 @@
1
+ import { waitForContainerManager } from "signalk-container-helper";
1
2
  import { ILPWriter } from "./ilp-writer.js";
2
3
  import { QueryClient, isReadOnlySQL } from "./query-client.js";
3
4
  import { ConfigSchema, normalizeConfig } from "./config/schema.js";
4
- import { extractVesselName, routeDeltaValue } from "./delta-routing.js";
5
+ import { extractVesselName, flattenObjectValue, routeDeltaValue, UnstorableTracker, } from "./delta-routing.js";
5
6
  import { createHistoryProviderV2 } from "./history-v2.js";
6
7
  import { createHistoryProviderV1 } from "./history-v1.js";
7
8
  import { startRetention } from "./retention.js";
@@ -13,6 +14,12 @@ import { QUESTDB_INTERNAL_HTTP_PORT, QUESTDB_INTERNAL_ILP_PORT, QUESTDB_INTERNAL
13
14
  import { nofileClampSatisfied, readMaxMapCount } from "./host-limits.js";
14
15
  import { buildContainerEnv } from "./container-env.js";
15
16
  import { PathMatcher, RateMatcher, Throttle } from "./path-matcher.js";
17
+ // signalk-container's API, imported rather than mirrored. The 156-line
18
+ // hand-written copy that used to live here drifted by construction: it was
19
+ // only ever updated when this plugin happened to need a new member, so
20
+ // getContainerNofile sat unmirrored from 1.25.3 until a probe needed it.
21
+ // signalk-container-helper carries the same types and pins them against
22
+ // signalk-container/types in CI, so a drift fails there instead of here.
16
23
  // The managed QuestDB container's name. signalk-container prefixes it with
17
24
  // `sk-` for the actual container and its DNS name (e.g. `sk-signalk-questdb`).
18
25
  const QUESTDB_CONTAINER_NAME = "signalk-questdb";
@@ -184,6 +191,13 @@ export default (app) => {
184
191
  // without creating resources, so an aborted start can't resurrect a
185
192
  // just-purged container.
186
193
  let startAbort = null;
194
+ // How long a start waits for signalk-container to publish its API and for
195
+ // runtime detection to settle. Generous because both are one-off startup
196
+ // costs on a cold boot — signalk-container may still be pulling its own
197
+ // image — and the alternative to waiting is a spurious "plugin required"
198
+ // error on a system that has it. A purge aborts the wait, so a wedged start
199
+ // does not hold the lock for this long.
200
+ const CONTAINER_MANAGER_TIMEOUT_MS = 120000;
187
201
  // How long purge waits to acquire the lifecycle lock before proceeding
188
202
  // anyway. After this it forces its teardown through even if a hung start
189
203
  // never released the lock (see PURGE_LOCK_TIMEOUT_MS use below).
@@ -346,8 +360,22 @@ export default (app) => {
346
360
  [`${QUESTDB_INTERNAL_ILP_PORT}/tcp`]: `${bind}:${ilpPort}`,
347
361
  [`${QUESTDB_INTERNAL_PG_PORT}/tcp`]: `${bind}:${pgPort}`,
348
362
  };
363
+ // ensureNetwork is feature-detected: the helper types every
364
+ // version-gated member as optional so a plugin degrades on an older
365
+ // signalk-container rather than throwing. The previous hand-written
366
+ // mirror declared it required, which hid the need for this guard —
367
+ // and the connectToNetwork call further down was already guarding, so
368
+ // the two disagreed about the same API.
369
+ //
370
+ // networkMode is set REGARDLESS. Creating the network is the optional
371
+ // step; joining it is what the companion Grafana depends on, and on a
372
+ // signalk-container without ensureNetwork the network the operator
373
+ // named almost certainly exists already. Gating the assignment on the
374
+ // probe would silently drop the container off that network.
349
375
  if (config.networkName) {
350
- await containers.ensureNetwork(config.networkName);
376
+ if (typeof containers.ensureNetwork === "function") {
377
+ await containers.ensureNetwork(config.networkName);
378
+ }
351
379
  containerConfig.networkMode = config.networkName;
352
380
  }
353
381
  // A 0.0.0.0-published port is reached on 127.0.0.1 or via the host
@@ -374,10 +402,17 @@ export default (app) => {
374
402
  // endpoint from whatever address signalk-container reports.
375
403
  containerConfig.signalkAccessiblePorts = QUESTDB_ACCESSIBLE_PORTS;
376
404
  return async () => {
405
+ // The two are feature-detected SEPARATELY. Requiring both would skip
406
+ // attachment entirely on a signalk-container that has connectToNetwork
407
+ // but not ensureNetwork -- and there the network almost certainly
408
+ // already exists, so attaching still works. Creating it is the
409
+ // optional step; joining it is the one Grafana depends on.
377
410
  if (config.networkName &&
378
411
  typeof containers.connectToNetwork === "function") {
379
412
  try {
380
- await containers.ensureNetwork(config.networkName);
413
+ if (typeof containers.ensureNetwork === "function") {
414
+ await containers.ensureNetwork(config.networkName);
415
+ }
381
416
  await containers.connectToNetwork(name, config.networkName);
382
417
  }
383
418
  catch (err) {
@@ -394,6 +429,18 @@ export default (app) => {
394
429
  // 100% CPU with an 89-pattern filter list.
395
430
  let pathFilterMatcher = null;
396
431
  let rateMatcher = null;
432
+ // Paths carrying a value with no representation in any table: arrays, and
433
+ // objects nested below the one level `flattenObjectValue` descends. These
434
+ // used to vanish without any trace at all — the whole reason
435
+ // navigation.attitude went unnoticed until a user asked (issue #128) — so
436
+ // record them and surface the count on /api/status.
437
+ //
438
+ // See UnstorableTracker: capped, and deliberately silent on the per-delta
439
+ // path. The diagnostic surfaces on /api/status, which is where a user will
440
+ // actually see it — this whole class of loss went unnoticed precisely
441
+ // because it was only ever visible in a log nobody had enabled (issue #128).
442
+ const unstorable = new UnstorableTracker();
443
+ const noteUnstorable = (path) => unstorable.note(path);
397
444
  // Takes only the mode: the patterns themselves live in pathFilterMatcher,
398
445
  // compiled from this same config at start. Accepting a `paths` array here
399
446
  // would imply it is consulted when it is not.
@@ -600,19 +647,34 @@ export default (app) => {
600
647
  },
601
648
  };
602
649
  if (config.managedContainer !== false) {
603
- const containers = globalThis.__signalk_containerManager;
650
+ // Two phases in one call: poll for the manager global, then wait for
651
+ // runtime detection to settle. The previous code read the global ONCE,
652
+ // synchronously — which worked only because plugins start in
653
+ // alphabetical order and "signalk-questdb" sorts after
654
+ // "signalk-container". Any rename, or a slower container start, and the
655
+ // plugin reported "signalk-container required" on a system that has it.
656
+ //
657
+ // The signal is threaded through so a purge preempting a start stops
658
+ // the poll immediately rather than waiting out the full budget.
659
+ app.setPluginStatus("Waiting for signalk-container plugin...");
660
+ const { manager: containers, runtime } = await waitForContainerManager({
661
+ timeoutMs: CONTAINER_MANAGER_TIMEOUT_MS,
662
+ signal,
663
+ onWaiting: (phase) => {
664
+ if (phase === "runtime")
665
+ app.setPluginStatus("Waiting for container runtime detection...");
666
+ },
667
+ });
668
+ // Purge may have preempted us while the wait was pending; bail before
669
+ // touching the runtime so we don't recreate what purge is removing.
670
+ if (signal.aborted)
671
+ return;
604
672
  if (!containers) {
605
673
  app.debug("containerManager not found");
606
674
  publishError("signalk-container plugin required for managed mode. Install it or set managedContainer=false.");
607
675
  return;
608
676
  }
609
- app.setPluginStatus("Waiting for container runtime detection...");
610
- await containers.whenReady();
611
- // Purge may have preempted us while whenReady() was pending; bail before
612
- // touching the runtime so we don't recreate what purge is removing.
613
- if (signal.aborted)
614
- return;
615
- if (!containers.getRuntime()) {
677
+ if (!runtime) {
616
678
  app.debug("container runtime not detected");
617
679
  publishError("No container runtime detected. Check signalk-container plugin.");
618
680
  return;
@@ -812,13 +874,32 @@ export default (app) => {
812
874
  return;
813
875
  if (!isSelf && !config.recordOthers)
814
876
  return;
815
- if (!shouldRecord(path, config.pathFilter.mode))
816
- return;
817
- // Throttled per path AND context: the sampling rate bounds each
818
- // vessel's stream, not the fleet's (issue #93). The stored context
819
- // ("self" vs raw) is the key, matching what the rows carry.
820
- if (isThrottled(path, isSelf ? "self" : context, config.defaultSamplingRate ?? 2000))
877
+ // Routed once, before the gates, because object values are gated on
878
+ // their leaf paths instead — see the "flatten" branch below. Applying
879
+ // the parent's gates to those would be wrong twice over: an
880
+ // include-filter listing only `navigation.attitude.roll` would drop the
881
+ // parent before any leaf was seen, and the parent would consume a
882
+ // throttle slot the leaves then have to wait out again, halving the
883
+ // effective sampling rate.
884
+ const route = routeDeltaValue(path, value);
885
+ // Recorded BEFORE the gates. A value nothing can store is worth
886
+ // reporting whether or not the user also filters or throttles that
887
+ // path — and gating it first would hide exactly the case this exists to
888
+ // surface, since a throttled array would return here and never be
889
+ // counted.
890
+ if (route === null) {
891
+ noteUnstorable(path);
821
892
  return;
893
+ }
894
+ if (route !== "flatten") {
895
+ if (!shouldRecord(path, config.pathFilter.mode))
896
+ return;
897
+ // Throttled per path AND context: the sampling rate bounds each
898
+ // vessel's stream, not the fleet's (issue #93). The stored context
899
+ // ("self" vs raw) is the key, matching what the rows carry.
900
+ if (isThrottled(path, isSelf ? "self" : context, config.defaultSamplingRate ?? 2000))
901
+ return;
902
+ }
822
903
  // Rows are stamped with the server receive time, deliberately NOT the
823
904
  // delta's own timestamp: a boat is a set of independent clocks (GPS
824
905
  // time, RTC-less devices, gateway latencies), and storing per-source
@@ -832,7 +913,6 @@ export default (app) => {
832
913
  // even gets *more* accurate history. Re-sent ILP batches keep their
833
914
  // original stamps (baked at write() time), so replay-idempotency holds.
834
915
  const ctx = isSelf ? "self" : context;
835
- const route = routeDeltaValue(path, value);
836
916
  if (route === "number") {
837
917
  writer.write(path, ctx, value);
838
918
  }
@@ -848,6 +928,40 @@ export default (app) => {
848
928
  else if (route === "position") {
849
929
  writer.writePosition(ctx, value);
850
930
  }
931
+ else if (route === "flatten") {
932
+ // Object values are recorded as their scalar leaves (issue #128).
933
+ const { leaves, skipped } = flattenObjectValue(path, value);
934
+ // Only the leaves are reported, never the parent as well. Each
935
+ // skipped leaf is already named here, so also noting the parent
936
+ // double-counts one problem and — worse — burns a second slot against
937
+ // the cap, hitting it sooner and hiding genuinely distinct paths.
938
+ //
939
+ // An object with NO storable leaf still gets reported: `skipped` then
940
+ // holds every key, so the drop is visible through them. The only
941
+ // silent case left is an empty object, which carries no data to lose.
942
+ for (const leafPath of skipped)
943
+ noteUnstorable(leafPath);
944
+ for (const leaf of leaves) {
945
+ // Filtered and throttled on the LEAF path, not the parent: the
946
+ // leaves are what the history API exposes, so excluding
947
+ // `navigation.attitude.yaw` has to actually exclude it. Throttling
948
+ // per leaf also keeps one object's sampling budget from being
949
+ // consumed by whichever key happened to be written first.
950
+ if (!shouldRecord(leaf.path, config.pathFilter.mode))
951
+ continue;
952
+ if (isThrottled(leaf.path, ctx, config.defaultSamplingRate ?? 2000))
953
+ continue;
954
+ if (typeof leaf.value === "number") {
955
+ writer.write(leaf.path, ctx, leaf.value);
956
+ }
957
+ else if (typeof leaf.value === "boolean") {
958
+ writer.writeString(leaf.path, ctx, leaf.value ? "true" : "false", undefined, "boolean");
959
+ }
960
+ else {
961
+ writer.writeString(leaf.path, ctx, leaf.value);
962
+ }
963
+ }
964
+ }
851
965
  });
852
966
  unsubscribes.push(unsub);
853
967
  if (config.retentionDays && config.retentionDays > 0) {
@@ -1044,6 +1158,9 @@ export default (app) => {
1044
1158
  // restore count from the previous run.
1045
1159
  restoreSummary = null;
1046
1160
  restoreStatus = null;
1161
+ // Same reason: a path that is unstorable under this config may not be
1162
+ // under the next, so the count must not carry across a restart.
1163
+ unstorable.clear();
1047
1164
  pluginErrorActive = false;
1048
1165
  for (const unsub of unsubscribes) {
1049
1166
  try {
@@ -1245,6 +1362,17 @@ export default (app) => {
1245
1362
  ? `${questdbEndpoints.http.host}:${questdbEndpoints.http.port}`
1246
1363
  : null,
1247
1364
  restore: restoreStatus,
1365
+ // Omitted entirely when nothing was dropped, so the panel can
1366
+ // treat presence as "there is something to report".
1367
+ unstorable: unstorable.size > 0
1368
+ ? {
1369
+ paths: unstorable.size,
1370
+ truncated: unstorable.truncated,
1371
+ // A handful is enough to identify the shape; the tracked
1372
+ // set is capped but still larger than a status body wants.
1373
+ examples: unstorable.examples(5),
1374
+ }
1375
+ : null,
1248
1376
  });
1249
1377
  }
1250
1378
  catch (err) {