@camstack/system 1.2.146 → 1.2.148

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.
Files changed (67) hide show
  1. package/dist/addon-runner.js +1 -1
  2. package/dist/addon-runner.mjs +1 -1
  3. package/dist/auth/auth-manager.d.ts +17 -1
  4. package/dist/auth/token-expiry.d.ts +39 -0
  5. package/dist/auth/user-manager.d.ts +2 -1
  6. package/dist/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.js +1 -1
  7. package/dist/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.mjs +1 -1
  8. package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.js +1 -1
  9. package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.mjs +1 -1
  10. package/dist/builtins/alerts/alerts.addon.js +1 -1
  11. package/dist/builtins/alerts/alerts.addon.mjs +1 -1
  12. package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.js +1 -1
  13. package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.mjs +1 -1
  14. package/dist/builtins/console-logging/index.js +1 -1
  15. package/dist/builtins/console-logging/index.mjs +1 -1
  16. package/dist/builtins/core-blocks/core-blocks.addon.js +1 -1
  17. package/dist/builtins/core-blocks/core-blocks.addon.mjs +1 -1
  18. package/dist/builtins/device-manager/device-manager.addon.js +2 -2
  19. package/dist/builtins/device-manager/device-manager.addon.mjs +2 -2
  20. package/dist/builtins/doorbell/virtual-doorbell.addon.js +1 -1
  21. package/dist/builtins/doorbell/virtual-doorbell.addon.mjs +1 -1
  22. package/dist/builtins/hub-forwarder/index.js +1 -1
  23. package/dist/builtins/hub-forwarder/index.mjs +1 -1
  24. package/dist/builtins/liveness-monitor/liveness-monitor.addon.js +1 -1
  25. package/dist/builtins/liveness-monitor/liveness-monitor.addon.mjs +1 -1
  26. package/dist/builtins/local-auth/auth-config-reader.d.ts +20 -0
  27. package/dist/builtins/local-auth/local-auth.addon.js +211 -17
  28. package/dist/builtins/local-auth/local-auth.addon.mjs +211 -17
  29. package/dist/builtins/local-network/local-network.addon.js +1 -1
  30. package/dist/builtins/local-network/local-network.addon.mjs +1 -1
  31. package/dist/builtins/loki-logging/index.js +1 -1
  32. package/dist/builtins/loki-logging/index.mjs +1 -1
  33. package/dist/builtins/native-metrics/load-series-config.d.ts +74 -0
  34. package/dist/builtins/native-metrics/load-series-reader.d.ts +37 -0
  35. package/dist/builtins/native-metrics/load-series-store.d.ts +232 -0
  36. package/dist/builtins/native-metrics/native-metrics.addon.d.ts +118 -35
  37. package/dist/builtins/native-metrics/native-metrics.addon.js +1463 -199
  38. package/dist/builtins/native-metrics/native-metrics.addon.mjs +1463 -199
  39. package/dist/builtins/native-metrics/node-load-ring.d.ts +214 -0
  40. package/dist/builtins/native-metrics/process-classification.d.ts +51 -0
  41. package/dist/builtins/native-metrics/thread-cpu-sampler.d.ts +116 -0
  42. package/dist/builtins/platform-probe/index.js +1 -1
  43. package/dist/builtins/platform-probe/index.mjs +1 -1
  44. package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.js +1 -1
  45. package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.mjs +1 -1
  46. package/dist/builtins/snapshot/index.js +1 -1
  47. package/dist/builtins/snapshot/index.mjs +1 -1
  48. package/dist/builtins/sqlite-storage/filesystem-storage.addon.js +1 -1
  49. package/dist/builtins/sqlite-storage/filesystem-storage.addon.mjs +1 -1
  50. package/dist/builtins/sqlite-storage/sqlite-settings-backend.d.ts +25 -1
  51. package/dist/builtins/sqlite-storage/sqlite-settings.addon.js +0 -0
  52. package/dist/builtins/sqlite-storage/sqlite-settings.addon.mjs +0 -0
  53. package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.js +2 -1
  54. package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.mjs +2 -1
  55. package/dist/builtins/system-config/system-config.addon.js +12 -11
  56. package/dist/builtins/system-config/system-config.addon.mjs +12 -11
  57. package/dist/builtins/winston-logging/index.js +1 -1
  58. package/dist/builtins/winston-logging/index.mjs +1 -1
  59. package/dist/{dist-BVU5JADq.js → dist-B-mBrEz9.js} +497 -36
  60. package/dist/{dist-CG7kwIZp.mjs → dist-BwD83D2f.mjs} +480 -37
  61. package/dist/index.js +2 -2
  62. package/dist/index.mjs +2 -2
  63. package/dist/{manifest-python-deps-CktMcXzS.js → manifest-python-deps-BV_Cy99l.js} +1 -1
  64. package/dist/{manifest-python-deps-CVeckyXx.mjs → manifest-python-deps-DpRX9LtD.mjs} +1 -1
  65. package/dist/{retired-settings-keys-Dm1z4mO4.mjs → retired-settings-keys-CRKpAmSn.mjs} +1 -1
  66. package/dist/{retired-settings-keys-_PLI9w0k.js → retired-settings-keys-Davtjo5p.js} +1 -1
  67. package/package.json +6 -4
@@ -1,5 +1,5 @@
1
1
  import { i as __require, o as __toESM, t as __commonJSMin } from "../../chunk-CNf5ZN-e.mjs";
2
- import { E as authProviderCapability, Et as BaseAddon, St as userManagementCapability, r as ApiKeyRecordSchema, v as ScopedTokenSchema, x as UserRecordSchema } from "../../dist-CG7kwIZp.mjs";
2
+ import { E as authProviderCapability, Tt as userManagementCapability, kt as BaseAddon, r as ApiKeyRecordSchema, v as ScopedTokenSchema, x as UserRecordSchema } from "../../dist-BwD83D2f.mjs";
3
3
  import * as crypto$2 from "node:crypto";
4
4
  import { createHash, randomUUID, timingSafeEqual } from "node:crypto";
5
5
  import nodeCrypto from "crypto";
@@ -6000,7 +6000,7 @@ var require_sign = /* @__PURE__ */ __commonJSMin(((exports, module) => {
6000
6000
  };
6001
6001
  }));
6002
6002
  //#endregion
6003
- //#region src/auth/auth-manager.ts
6003
+ //#region src/auth/token-expiry.ts
6004
6004
  var import_jsonwebtoken = /* @__PURE__ */ __toESM((/* @__PURE__ */ __commonJSMin(((exports, module) => {
6005
6005
  module.exports = {
6006
6006
  decode: require_decode(),
@@ -6011,6 +6011,156 @@ var import_jsonwebtoken = /* @__PURE__ */ __toESM((/* @__PURE__ */ __commonJSMin
6011
6011
  TokenExpiredError: require_TokenExpiredError()
6012
6012
  };
6013
6013
  })))());
6014
+ /**
6015
+ * Validation for the operator-configured session-JWT lifetime
6016
+ * (`auth.tokenExpiry`, System → Settings → Authentication → Token Expiry).
6017
+ *
6018
+ * WHY THIS EXISTS. The field is a free-text box that reaches `jwt.sign`'s
6019
+ * `expiresIn`. Two things go wrong without a gate in front of it:
6020
+ *
6021
+ * 1. `jsonwebtoken` THROWS on a value it cannot parse. The only caller is
6022
+ * the login/refresh path, so one typo in a settings form turns every
6023
+ * login into a 500 — a self-inflicted outage from a text field.
6024
+ * 2. `jsonwebtoken` hands a bare numeric STRING to `ms()`, which reads it
6025
+ * as MILLISECONDS. An operator who types `3600` meaning "an hour" gets
6026
+ * a token that dies in 3.6 seconds. A bare number is therefore rejected
6027
+ * rather than interpreted — the field's own description, placeholder and
6028
+ * default are all duration strings (`24h`, `7d`, `30d`).
6029
+ *
6030
+ * A rejection never becomes an unbounded lifetime — it resolves to
6031
+ * {@link DEFAULT_TOKEN_EXPIRY}, the single documented default — and it is
6032
+ * never silent: `parseTokenExpiry` reports the refusal in its result and
6033
+ * `logTokenExpiryRejection` renders it. The two are separate so the caller
6034
+ * owns how often it speaks; the signer re-reads on every mint.
6035
+ */
6036
+ /**
6037
+ * The `ms`-compatible duration grammar `jsonwebtoken` accepts, MINUS the bare
6038
+ * number (see the docblock above). A unit is mandatory.
6039
+ */
6040
+ var DURATION_PATTERN = /^(\d+(?:\.\d+)?)\s*(ms|milliseconds?|msecs?|s|secs?|seconds?|m|mins?|minutes?|h|hrs?|hours?|d|days?|w|weeks?|y|yrs?|years?)$/i;
6041
+ /** Multiplier from each accepted unit to milliseconds. */
6042
+ var UNIT_MS = {
6043
+ ms: 1,
6044
+ msec: 1,
6045
+ msecs: 1,
6046
+ millisecond: 1,
6047
+ milliseconds: 1,
6048
+ s: 1e3,
6049
+ sec: 1e3,
6050
+ secs: 1e3,
6051
+ second: 1e3,
6052
+ seconds: 1e3,
6053
+ m: 6e4,
6054
+ min: 6e4,
6055
+ mins: 6e4,
6056
+ minute: 6e4,
6057
+ minutes: 6e4,
6058
+ h: 36e5,
6059
+ hr: 36e5,
6060
+ hrs: 36e5,
6061
+ hour: 36e5,
6062
+ hours: 36e5,
6063
+ d: 864e5,
6064
+ day: 864e5,
6065
+ days: 864e5,
6066
+ w: 6048e5,
6067
+ week: 6048e5,
6068
+ weeks: 6048e5,
6069
+ y: 315576e5,
6070
+ yr: 315576e5,
6071
+ yrs: 315576e5,
6072
+ year: 315576e5,
6073
+ years: 315576e5
6074
+ };
6075
+ /**
6076
+ * Shortest accepted lifetime. Below a minute the token expires inside the
6077
+ * round trip that issued it and the operator sees "logged out immediately",
6078
+ * which reads as a broken server rather than as a bad setting.
6079
+ */
6080
+ var MIN_EXPIRY_MS = 6e4;
6081
+ /**
6082
+ * Longest accepted lifetime. A year is already far past "a device left
6083
+ * unopened for weeks"; anything longer is an infinite-lifetime token wearing
6084
+ * a duration string.
6085
+ */
6086
+ var MAX_EXPIRY_MS = 365 * 864e5;
6087
+ /** Milliseconds for a duration string, or `null` when it is not one. */
6088
+ function durationMs(candidate) {
6089
+ const match = DURATION_PATTERN.exec(candidate);
6090
+ if (match === null) return null;
6091
+ const amount = Number(match[1]);
6092
+ const unitMs = UNIT_MS[match[2].toLowerCase()];
6093
+ if (!Number.isFinite(amount) || unitMs === void 0) return null;
6094
+ return amount * unitMs;
6095
+ }
6096
+ /**
6097
+ * The default, pre-parsed. Computed from {@link DEFAULT_TOKEN_EXPIRY} rather
6098
+ * than written out again — a second spelling of the same default is the defect
6099
+ * this module exists to close.
6100
+ */
6101
+ var DEFAULT_SECONDS = (() => {
6102
+ const ms = durationMs("30d");
6103
+ /* c8 ignore next -- unreachable unless DEFAULT_TOKEN_EXPIRY stops being a duration */
6104
+ if (ms === null) throw new Error(`DEFAULT_TOKEN_EXPIRY is not a duration: 30d`);
6105
+ return ms / 1e3;
6106
+ })();
6107
+ function refuse(reason) {
6108
+ return {
6109
+ ok: false,
6110
+ reason,
6111
+ seconds: DEFAULT_SECONDS,
6112
+ value: "30d"
6113
+ };
6114
+ }
6115
+ /**
6116
+ * Parse a configured `auth.tokenExpiry`. An absent/blank value is not a
6117
+ * rejection — it is "operator never set it" and resolves to the default
6118
+ * without a log line.
6119
+ */
6120
+ function parseTokenExpiry(configured) {
6121
+ if (configured === void 0 || configured === null) return {
6122
+ ok: true,
6123
+ seconds: DEFAULT_SECONDS,
6124
+ value: "30d"
6125
+ };
6126
+ if (typeof configured !== "string") return refuse("not-a-string");
6127
+ const trimmed = configured.trim();
6128
+ if (trimmed === "") return {
6129
+ ok: true,
6130
+ seconds: DEFAULT_SECONDS,
6131
+ value: "30d"
6132
+ };
6133
+ const totalMs = durationMs(trimmed);
6134
+ if (totalMs === null) return refuse("malformed");
6135
+ if (totalMs < MIN_EXPIRY_MS) return refuse("too-short");
6136
+ if (totalMs > MAX_EXPIRY_MS) return refuse("too-long");
6137
+ return {
6138
+ ok: true,
6139
+ seconds: totalMs / 1e3,
6140
+ value: trimmed
6141
+ };
6142
+ }
6143
+ /** Human-readable tail for the rejection log line. */
6144
+ var REJECTION_DETAIL = {
6145
+ "not-a-string": "value is not a string",
6146
+ malformed: "not a duration string (expected e.g. 24h, 7d, 30d — a bare number is NOT seconds)",
6147
+ "too-short": `shorter than the ${MIN_EXPIRY_MS / 1e3}s minimum`,
6148
+ "too-long": `longer than the ${MAX_EXPIRY_MS / 864e5}d maximum`
6149
+ };
6150
+ /**
6151
+ * Report a refused `auth.tokenExpiry`. Separate from {@link parseTokenExpiry}
6152
+ * so the caller owns HOW OFTEN it speaks — the signer re-reads the setting on
6153
+ * every mint and must not turn one bad save into a line per login.
6154
+ */
6155
+ function logTokenExpiryRejection(configured, rejection, logger) {
6156
+ logger.warn(`auth.tokenExpiry REJECTED — ${REJECTION_DETAIL[rejection.reason]}; signing with the default 30d instead`, { meta: {
6157
+ configured,
6158
+ reason: rejection.reason,
6159
+ applied: rejection.value
6160
+ } });
6161
+ }
6162
+ //#endregion
6163
+ //#region src/auth/auth-manager.ts
6014
6164
  /** Minimal no-op logger for default parameter */
6015
6165
  var noopLogger = {
6016
6166
  debug() {},
@@ -6028,6 +6178,16 @@ var AuthManager = class {
6028
6178
  config;
6029
6179
  jwtSecret;
6030
6180
  logger;
6181
+ /**
6182
+ * Offending `auth.tokenExpiry` values already reported by this instance.
6183
+ *
6184
+ * `signToken` re-reads the setting on EVERY mint so an operator's change
6185
+ * takes effect without a restart — which also means a bad value would log
6186
+ * once per login and once per silent refresh from every viewer. The refusal
6187
+ * must be visible, not repeated: the first mint after a bad save logs, the
6188
+ * rest resolve silently to the same default until the value changes.
6189
+ */
6190
+ reportedInvalidExpiries = /* @__PURE__ */ new Set();
6031
6191
  constructor(config, logger = noopLogger) {
6032
6192
  this.config = config;
6033
6193
  this.logger = logger;
@@ -6042,8 +6202,15 @@ var AuthManager = class {
6042
6202
  }
6043
6203
  signToken(payload) {
6044
6204
  const configured = this.config.get("auth.tokenExpiry");
6045
- const expiresIn = typeof configured === "string" && configured.trim() !== "" ? configured.trim() : "30d";
6046
- return import_jsonwebtoken.sign({ ...payload }, this.jwtSecret, { expiresIn });
6205
+ const parsed = parseTokenExpiry(configured);
6206
+ if (!parsed.ok) {
6207
+ const seen = typeof configured === "string" ? configured : JSON.stringify(configured);
6208
+ if (!this.reportedInvalidExpiries.has(seen)) {
6209
+ this.reportedInvalidExpiries.add(seen);
6210
+ logTokenExpiryRejection(configured, parsed, this.logger);
6211
+ }
6212
+ }
6213
+ return import_jsonwebtoken.sign({ ...payload }, this.jwtSecret, { expiresIn: parsed.seconds });
6047
6214
  }
6048
6215
  verifyToken(token) {
6049
6216
  return import_jsonwebtoken.verify(token, this.jwtSecret);
@@ -8814,6 +8981,38 @@ var TotpManager = class {
8814
8981
  }
8815
8982
  };
8816
8983
  //#endregion
8984
+ //#region src/builtins/local-auth/auth-config-reader.ts
8985
+ /**
8986
+ * Every config path this reader can answer. Anything else resolves to
8987
+ * `undefined` by construction — see the module docblock.
8988
+ */
8989
+ function createAuthConfigReader(resolved, writeAuthSection) {
8990
+ return {
8991
+ get(path) {
8992
+ switch (path) {
8993
+ case "auth.jwtSecret": return narrow(resolved.jwtSecret);
8994
+ case "auth.adminUsername": return narrow(resolved.adminUsername);
8995
+ case "auth.adminPassword": return narrow(resolved.adminPassword);
8996
+ case "auth.tokenExpiry": return narrow(resolved.tokenExpiry);
8997
+ default: return;
8998
+ }
8999
+ },
9000
+ update(_section, data) {
9001
+ if (typeof data["jwtSecret"] === "string") writeAuthSection({ jwtSecret: data["jwtSecret"] });
9002
+ }
9003
+ };
9004
+ }
9005
+ /**
9006
+ * The one documented type boundary in this module. `AuthConfigReader.get` is
9007
+ * the legacy `get<T>(path)` shape shared with `ConfigManager.get`, which is
9008
+ * likewise unchecked at runtime — every value returned here is a `string |
9009
+ * undefined` the caller immediately re-validates (`parseTokenExpiry`, the
9010
+ * truthiness gate on `jwtSecret`).
9011
+ */
9012
+ function narrow(value) {
9013
+ return value;
9014
+ }
9015
+ //#endregion
8817
9016
  //#region src/builtins/local-auth/auth-schema.ts
8818
9017
  /**
8819
9018
  * @durable class=config owner=local-auth
@@ -9462,19 +9661,14 @@ var LocalAuthAddon = class extends BaseAddon {
9462
9661
  }
9463
9662
  async onInitialize() {
9464
9663
  const authSection = await this.ctx.settings?.getSection("auth") ?? {};
9465
- const resolvedJwtSecret = typeof authSection["jwtSecret"] === "string" ? authSection["jwtSecret"] : this.config.jwtSecret ?? "";
9466
- const resolvedAdminUser = typeof authSection["adminUsername"] === "string" && authSection["adminUsername"] ? authSection["adminUsername"] : this.config.adminUsername ?? "";
9467
- const resolvedAdminPass = typeof authSection["adminPassword"] === "string" && authSection["adminPassword"] ? authSection["adminPassword"] : this.config.adminPassword ?? "";
9468
- const reader = {
9469
- get(path) {
9470
- if (path === "auth.jwtSecret") return resolvedJwtSecret;
9471
- if (path === "auth.adminUsername") return resolvedAdminUser;
9472
- if (path === "auth.adminPassword") return resolvedAdminPass;
9473
- },
9474
- update: (_section, data) => {
9475
- if (typeof data["jwtSecret"] === "string") this.ctx.settings?.setSection("auth", { jwtSecret: data["jwtSecret"] });
9476
- }
9477
- };
9664
+ const reader = createAuthConfigReader({
9665
+ jwtSecret: typeof authSection["jwtSecret"] === "string" ? authSection["jwtSecret"] : this.config.jwtSecret ?? "",
9666
+ adminUsername: typeof authSection["adminUsername"] === "string" && authSection["adminUsername"] ? authSection["adminUsername"] : this.config.adminUsername ?? "",
9667
+ adminPassword: typeof authSection["adminPassword"] === "string" && authSection["adminPassword"] ? authSection["adminPassword"] : this.config.adminPassword ?? "",
9668
+ tokenExpiry: typeof authSection["tokenExpiry"] === "string" && authSection["tokenExpiry"] ? authSection["tokenExpiry"] : void 0
9669
+ }, (patch) => {
9670
+ this.ctx.settings?.setSection("auth", patch);
9671
+ });
9478
9672
  this.authManager = new AuthManager(reader, this.ctx.logger);
9479
9673
  const store = this.ctx.api?.settingsStore;
9480
9674
  if (store) {
@@ -1,6 +1,6 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
2
  const require_chunk = require("../../chunk-Cek0wNdY.js");
3
- const require_dist = require("../../dist-BVU5JADq.js");
3
+ const require_dist = require("../../dist-B-mBrEz9.js");
4
4
  const require_tls = require("../../tls-u8QCJCFE.js");
5
5
  let node_fs_promises = require("node:fs/promises");
6
6
  let node_path = require("node:path");
@@ -1,4 +1,4 @@
1
- import { Et as BaseAddon, Qt as EventCategory, tt as localNetworkCapability } from "../../dist-CG7kwIZp.mjs";
1
+ import { kt as BaseAddon, nt as localNetworkCapability, tn as EventCategory } from "../../dist-BwD83D2f.mjs";
2
2
  import { S as readLanHttpState, _ as DEFAULT_LAN_HTTP_PORT, a as writeTlsMode, i as writeExtraSans, l as reissueTlsLeaf, n as readTlsAccessStatus, o as validateUploadedTls, r as readTlsMode, s as ensureTlsCert, t as readExtraSans, y as applyLanHttp } from "../../tls-CQhPGSJm.mjs";
3
3
  import { mkdir, writeFile } from "node:fs/promises";
4
4
  import { join } from "node:path";
@@ -3,7 +3,7 @@ Object.defineProperties(exports, {
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
5
  require("../../chunk-Cek0wNdY.js");
6
- const require_dist = require("../../dist-BVU5JADq.js");
6
+ const require_dist = require("../../dist-B-mBrEz9.js");
7
7
  //#region src/builtins/loki-logging/loki-payload.ts
8
8
  /**
9
9
  * Loki rejects a label name that is not a valid Prometheus label
@@ -1,4 +1,4 @@
1
- import { Et as BaseAddon, nt as logDestinationCapability } from "../../dist-CG7kwIZp.mjs";
1
+ import { kt as BaseAddon, rt as logDestinationCapability } from "../../dist-BwD83D2f.mjs";
2
2
  //#region src/builtins/loki-logging/loki-payload.ts
3
3
  /**
4
4
  * Loki rejects a label name that is not a valid Prometheus label
@@ -0,0 +1,74 @@
1
+ /**
2
+ * The two knobs of the durable load series, and the arithmetic that makes them
3
+ * honest.
4
+ *
5
+ * ## Where they live, and why there
6
+ *
7
+ * In `native-metrics`'s OWN global settings document — the one that already
8
+ * exists and already carries `samplingIntervalMs`. Fields on a document, never
9
+ * a method per knob: `system.getLoggingSettings` / `setLoggingSettings` set
10
+ * that precedent and it holds here.
11
+ *
12
+ * They are NOT in the logging document, and that is a decision rather than an
13
+ * omission. That document is about levels and diagnostic windows; a metrics
14
+ * cadence and a table's retention are neither. Two documents that both claim a
15
+ * knob is how this repo has already shipped a switch nobody read.
16
+ *
17
+ * They are cluster-wide, not per-node. Every node emits on the same cadence or
18
+ * the fleet's series cannot be laid over each other, and the table is single
19
+ * and hub-resident, so a per-node retention would be a promise nothing could
20
+ * keep.
21
+ *
22
+ * ## Refused, not clamped
23
+ *
24
+ * A cadence outside 5-60 s is REFUSED. Clamping would silently store 10 when
25
+ * the operator typed 2 and then report 10 back as though it had been accepted
26
+ * — the failure mode where a knob and its readback agree on a value nobody
27
+ * chose. The schema advertises the bounds; this refuses anything else.
28
+ */
29
+ /** The resolved, in-force configuration. */
30
+ export interface LoadSeriesConfig {
31
+ readonly cadenceSec: number;
32
+ readonly retentionHours: number;
33
+ readonly maxRows: number;
34
+ }
35
+ /** Why a value was refused — the message an operator is shown verbatim. */
36
+ export declare class LoadSeriesConfigError extends Error {
37
+ constructor(message: string);
38
+ }
39
+ /**
40
+ * Resolve the configuration, REFUSING anything out of range.
41
+ *
42
+ * An ABSENT field takes its default — that is not a refusal, it is a document
43
+ * that has never been written. A PRESENT field outside its bounds throws.
44
+ */
45
+ export declare function resolveLoadSeriesConfig(raw: {
46
+ readonly loadSeriesCadenceSec?: unknown;
47
+ readonly loadSeriesRetentionHours?: unknown;
48
+ }): LoadSeriesConfig;
49
+ /** What a configuration is projected to cost, from OBSERVED counts. */
50
+ export interface LoadSeriesProjection {
51
+ /** Rows the retention window alone would hold. */
52
+ readonly intendedRows: number;
53
+ /** Rows actually allowed — `min(intendedRows, maxRows)`. */
54
+ readonly boundedRows: number;
55
+ readonly estimatedBytes: number;
56
+ /** Does the row cap bite before the retention window is full? */
57
+ readonly capBites: boolean;
58
+ }
59
+ /**
60
+ * Project the cost of a configuration from the numbers the cluster is actually
61
+ * reporting — nodes and processes OBSERVED, never a constant.
62
+ *
63
+ * This is what makes the knob honest: an operator raising retention to three
64
+ * days sees the row count and the megabytes BEFORE applying it, rather than
65
+ * discovering them afterwards. `MEASURED_BYTES_PER_ROW` is measured, not
66
+ * guessed — see `load-series-store.ts`.
67
+ */
68
+ export declare function projectLoadSeriesCost(input: {
69
+ readonly config: LoadSeriesConfig;
70
+ /** Process rows observed across the fleet in the most recent snapshots. */
71
+ readonly observedProcessRows: number;
72
+ }): LoadSeriesProjection;
73
+ /** Human-readable one-liner for the settings form. */
74
+ export declare function describeLoadSeriesCost(projection: LoadSeriesProjection): string;
@@ -0,0 +1,37 @@
1
+ import { LoadPoint } from '@camstack/types';
2
+ import { RetainedSample } from './load-series-store.js';
3
+ /** One function's assembled series, before it is shaped for the wire. */
4
+ export interface FoldedSeries {
5
+ readonly key: string;
6
+ readonly kind: 'addon' | 'root' | 'unattributed';
7
+ readonly points: readonly LoadPoint[];
8
+ }
9
+ export interface MergeLoadSeriesInput {
10
+ /** Cold samples, oldest-first. May overlap `hot` — that is expected. */
11
+ readonly cold: readonly RetainedSample[];
12
+ /** Hot samples, oldest-first. Wins over a cold sample at the same `atMs`. */
13
+ readonly hot: readonly RetainedSample[];
14
+ readonly cadenceMs: number;
15
+ /** Absent ⇒ no reduction. */
16
+ readonly maxPoints?: number;
17
+ }
18
+ export interface MergedLoadSeries {
19
+ readonly series: readonly FoldedSeries[];
20
+ readonly bucketMs: number;
21
+ readonly retainedSamples: number;
22
+ readonly oldestAtMs: number | null;
23
+ }
24
+ /**
25
+ * Merge the two tiers into one deduped, oldest-first list of samples.
26
+ *
27
+ * Exported because the dedup is the property worth testing on its own: a
28
+ * doubled point is invisible in a total and obvious only as a chart that reads
29
+ * twice as loud as the machine.
30
+ */
31
+ export declare function mergeSamples(cold: readonly RetainedSample[], hot: readonly RetainedSample[]): readonly RetainedSample[];
32
+ /**
33
+ * Merge, fold and reduce. The whole read path in one pure function, so every
34
+ * property that matters — dedup, peak survival, an empty bucket staying empty
35
+ * — is testable without a database or a ring.
36
+ */
37
+ export declare function mergeLoadSeries(input: MergeLoadSeriesInput): MergedLoadSeries;
@@ -0,0 +1,232 @@
1
+ import { IScopedLogger, SettingsStoreClient } from '@camstack/types';
2
+ /**
3
+ * @durable class=audit owner=metrics-native
4
+ * write="one row per process per sample, appended in ONE transaction per
5
+ * sample (`settingsStore.insertMany`) by the HUB's `native-metrics`, off its
6
+ * subscription to `metrics.node-processes-snapshot`. Emission is
7
+ * fixed-cadence, so the write rate is deterministic: nodes x processes /
8
+ * cadence, 7.6 rows/s on the live fleet at the 10 s default. Only a sample
9
+ * the in-memory ring ACCEPTED is written, which is what makes the append
10
+ * idempotent without a unique key."
11
+ * retention="TWO bounds, swept together at most once a minute. By AGE, the
12
+ * operator's `loadSeriesRetentionHours` (default 6, the intention); by ROWS,
13
+ * DEFAULT_MAX_ROWS (the guarantee, which bites regardless of the age knob).
14
+ * Both prune oldest-first through a bounded page — never a full scan — and
15
+ * the row cap logs when it bites. Losing the table loses the cold window
16
+ * only: the ring keeps serving the last hour and the charts keep drawing."
17
+ */
18
+ export declare const LOAD_SERIES_COLLECTION = "metrics:node-load-samples";
19
+ /**
20
+ * Bytes one stored row costs on disk, MEASURED — table plus index, after a
21
+ * truncating checkpoint. Used to project the cost of a configuration for the
22
+ * operator BEFORE they apply it, so the knob is honest.
23
+ */
24
+ export declare const MEASURED_BYTES_PER_ROW = 87;
25
+ /**
26
+ * Sampling cadence bounds, in seconds. Outside this range a cadence is
27
+ * REFUSED — see {@link resolveLoadSeriesConfig}.
28
+ */
29
+ export declare const MIN_CADENCE_SEC = 5;
30
+ export declare const MAX_CADENCE_SEC = 60;
31
+ export declare const DEFAULT_CADENCE_SEC = 10;
32
+ /** Retention the operator asked for, in hours. Six is the shipped default. */
33
+ export declare const DEFAULT_RETENTION_HOURS = 6;
34
+ export declare const MIN_RETENTION_HOURS = 1;
35
+ export declare const MAX_RETENTION_HOURS = 72;
36
+ /**
37
+ * Hard ceiling on the table, in ROWS.
38
+ *
39
+ * The guarantee behind the retention knob. Sized against the shipped default
40
+ * and against the worst configuration the knob can express:
41
+ *
42
+ * | cadence | retention | rows (3 nodes, 76 processes) |
43
+ * | --- | --- | --- |
44
+ * | 10 s | 6 h (default) | 164 160 |
45
+ * | 5 s | 6 h | 328 320 |
46
+ * | 5 s | 72 h | 3 939 840 — capped here |
47
+ *
48
+ * 500 000 rows x the measured 87 B = **41.5 MiB**, roughly 3x what the shipped
49
+ * default needs and far below the 2 M rows a three-day retention would reach.
50
+ * It is deliberately NOT a knob: the point of a guarantee is that it is not
51
+ * subject to the same hand that raised the intention. This is the discipline
52
+ * the in-memory ring already adopted (`DEFAULT_MAX_TOTAL_PROCESS_ROWS`) and
53
+ * the one the RTP pre-roll rings still lack — a maximum per instance and no
54
+ * budget at all for the whole, which reached 898 MB across 47 rings
55
+ * (`docs/architecture/load-ledger.md` #5).
56
+ */
57
+ export declare const DEFAULT_MAX_ROWS = 500000;
58
+ /**
59
+ * Rows a single prune pass may remove.
60
+ *
61
+ * The prune is BOUNDED, never a full scan. It reads at most this many keys
62
+ * oldest-first through `idx_load_samples_node_at`, takes the last one's `atMs`
63
+ * as the effective cutoff and issues ONE `deleteWhere` up to it. A backlog
64
+ * larger than a page is worked off across passes rather than in one stall —
65
+ * which is the defect corrected in the recorder the same week, where
66
+ * `accountingForLocations` visited 6.7 M rows twice a minute.
67
+ */
68
+ export declare const PRUNE_PAGE_ROWS = 20000;
69
+ /** How often a prune pass may run. A bound, not a deadline. */
70
+ export declare const PRUNE_INTERVAL_MS = 60000;
71
+ export declare const LOAD_SERIES_COLUMNS: readonly [{
72
+ readonly name: "id";
73
+ readonly type: "INTEGER";
74
+ readonly primaryKey: true;
75
+ readonly notNull: true;
76
+ }, {
77
+ readonly name: "nodeId";
78
+ readonly type: "TEXT";
79
+ readonly notNull: true;
80
+ }, {
81
+ readonly name: "atMs";
82
+ readonly type: "INTEGER";
83
+ readonly notNull: true;
84
+ }, {
85
+ readonly name: "pid";
86
+ readonly type: "INTEGER";
87
+ readonly notNull: true;
88
+ }, {
89
+ readonly name: "addonId";
90
+ readonly type: "TEXT";
91
+ }, {
92
+ readonly name: "classification";
93
+ readonly type: "TEXT";
94
+ readonly notNull: true;
95
+ }, {
96
+ readonly name: "cpuDeci";
97
+ readonly type: "INTEGER";
98
+ readonly notNull: true;
99
+ }, {
100
+ readonly name: "rssMib";
101
+ readonly type: "INTEGER";
102
+ readonly notNull: true;
103
+ }, {
104
+ readonly name: "cpuMainDeci";
105
+ readonly type: "INTEGER";
106
+ }, {
107
+ readonly name: "cpuGcDeci";
108
+ readonly type: "INTEGER";
109
+ }];
110
+ export declare const LOAD_SERIES_INDEXES: readonly [{
111
+ readonly name: "idx_load_samples_node_at";
112
+ readonly columns: readonly ["nodeId", "atMs"];
113
+ }];
114
+ /** One stored process row, in the units the table stores them in. */
115
+ export interface LoadSeriesRow {
116
+ readonly nodeId: string;
117
+ readonly atMs: number;
118
+ readonly pid: number;
119
+ readonly addonId: string | null;
120
+ readonly classification: string;
121
+ readonly cpuDeci: number;
122
+ readonly rssMib: number;
123
+ readonly cpuMainDeci: number | null;
124
+ readonly cpuGcDeci: number | null;
125
+ }
126
+ /** The process shape both tiers hand back — the series' own row, not `ps`'s. */
127
+ export interface RetainedProcess {
128
+ readonly pid: number;
129
+ readonly addonId: string | null;
130
+ readonly classification: string;
131
+ readonly cpuPercent: number;
132
+ readonly memoryRssBytes: number;
133
+ readonly cpuMainPercent: number | null;
134
+ readonly cpuGcPercent: number | null;
135
+ }
136
+ /** One retained sample: when it was taken, and the processes in it. */
137
+ export interface RetainedSample {
138
+ readonly atMs: number;
139
+ readonly processes: readonly RetainedProcess[];
140
+ }
141
+ /** Storage row → the shape a reader gets. The ONE decode. */
142
+ export declare function rowToProcess(row: LoadSeriesRow): RetainedProcess;
143
+ /** The shape a reader gets → the storage row. The ONE encode. */
144
+ export declare function processToRow(nodeId: string, atMs: number, process: RetainedProcess): LoadSeriesRow;
145
+ /** Group flat rows into samples, oldest-first. A sample is one `atMs`. */
146
+ export declare function rowsToSamples(rows: readonly LoadSeriesRow[]): readonly RetainedSample[];
147
+ /** The two bounds in force. */
148
+ export interface LoadSeriesRetention {
149
+ readonly retentionHours: number;
150
+ readonly maxRows: number;
151
+ }
152
+ export interface LoadSeriesStoreDeps {
153
+ readonly store: SettingsStoreClient;
154
+ readonly logger: IScopedLogger;
155
+ readonly now?: () => number;
156
+ readonly prunePageRows?: number;
157
+ readonly pruneIntervalMs?: number;
158
+ }
159
+ /** What one prune pass did. Returned so a test can assert the bound held. */
160
+ export interface PruneOutcome {
161
+ readonly deletedByAge: number;
162
+ readonly deletedByCap: number;
163
+ /** Rows read to find the cutoffs — the number that must stay bounded. */
164
+ readonly rowsExamined: number;
165
+ readonly capBit: boolean;
166
+ }
167
+ /**
168
+ * The cold tier. Append-only, bounded, and never the authority on anything the
169
+ * hot ring can answer.
170
+ *
171
+ * Every method is best-effort and says so in the log rather than throwing at
172
+ * the emitter: a storage hiccup must cost a gap in a chart, never a dropped
173
+ * snapshot on the bus.
174
+ */
175
+ export declare class LoadSeriesStore {
176
+ private declared;
177
+ private lastPruneAtMs;
178
+ private readonly store;
179
+ private readonly logger;
180
+ private readonly nowFn;
181
+ private readonly prunePageRows;
182
+ private readonly pruneIntervalMs;
183
+ constructor(deps: LoadSeriesStoreDeps);
184
+ /** Idempotently declare the collection. `false` when the store refused. */
185
+ declare(): Promise<boolean>;
186
+ /**
187
+ * Append ONE sample — every process row of it — in ONE transaction.
188
+ *
189
+ * Never a write per row. `insertMany` exists for exactly this: at the 10 s
190
+ * default the fleet produces 7.6 rows/s, and 7.6 separate commits per second
191
+ * on the connection that also serves every cluster-wide configuration read
192
+ * is a constant load nobody asked for.
193
+ */
194
+ append(nodeId: string, atMs: number, processes: readonly RetainedProcess[]): Promise<number>;
195
+ /**
196
+ * Read one node's cold samples, oldest-first.
197
+ *
198
+ * `sinceMs` is EXCLUSIVE, matching the ring, so a caller passing the newest
199
+ * timestamp it holds gets only what it is missing. `limitRows` bounds the
200
+ * read in ROWS (not samples) because rows are what the query costs.
201
+ */
202
+ read(nodeId: string, sinceMs: number, limitRows: number): Promise<readonly RetainedSample[]>;
203
+ /**
204
+ * Enforce BOTH bounds, oldest-first, through a bounded page each.
205
+ *
206
+ * Rate-limited to {@link pruneIntervalMs}: a bound is not a deadline, and the
207
+ * append path must not pay a sweep on every sample.
208
+ */
209
+ prune(nodeIds: readonly string[], retention: LoadSeriesRetention, force?: boolean): Promise<PruneOutcome | null>;
210
+ /** Total rows, or `null` when the store could not answer. */
211
+ count(): Promise<number | null>;
212
+ /**
213
+ * Delete this node's rows older than `cutoff`, at most one page's worth.
214
+ *
215
+ * The page is the whole point. `deleteWhere({ atMs: [0, cutoff] })` on its
216
+ * own is one statement but an UNBOUNDED one — a first pass after a retention
217
+ * change would delete millions of rows inside a single stalling transaction.
218
+ * So the page is read first (keys only, ordered by the index), its last
219
+ * `atMs` becomes the EFFECTIVE cutoff, and the delete is bounded by it.
220
+ */
221
+ private pruneNodeToCutoff;
222
+ /**
223
+ * Drop the oldest rows across the known nodes until `excess` is covered.
224
+ *
225
+ * Same bounded-page technique, walked per node so the one index serves it.
226
+ * The node holding the oldest rows pays first, which is also the node
227
+ * producing the pressure when a runaway process count is the cause.
228
+ */
229
+ private pruneOldestRows;
230
+ }
231
+ /** Structural validation on read. A malformed row is skipped, never charted. */
232
+ export declare function recordToRow(data: Record<string, unknown>): LoadSeriesRow | null;