@camstack/types 1.2.114 → 1.2.116

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.
@@ -1178,6 +1178,34 @@ export interface AddonExecution {
1178
1178
  * `0` means "no ceiling for this addon" and, being the maximum, lifts the whole runner's cap.
1179
1179
  */
1180
1180
  readonly maxOldSpaceMb?: number;
1181
+ /**
1182
+ * Whole-process RSS budget in MB — the number the memory watchdog alarms on.
1183
+ *
1184
+ * This is NOT a second heap ceiling and it enforces nothing: it is the
1185
+ * footprint this addon's role considers legitimate, and crossing it emits one
1186
+ * WARN carrying the breakdown (`heapUsed` / `external` / native residue) so the
1187
+ * operator learns WHICH KIND of growth it is.
1188
+ *
1189
+ * It exists because every other bound is blind to most of a process.
1190
+ * {@link AddonExecution.maxOldSpaceMb} bounds V8's old space and nothing else,
1191
+ * and the watchdog's `stranded` metric subtracts `external` by construction.
1192
+ * `hub/pipeline-analytics` was OOM-killed at 11.4GB on 2026-08-27 with a
1193
+ * 1024MB old-space ceiling in force the entire time, holding a 536MB heap and
1194
+ * 4264MB of `external` — 96% of the growth was never V8's, and nothing warned.
1195
+ * RSS is what the host's OOM killer reads, so RSS is what needs a declared
1196
+ * ceiling.
1197
+ *
1198
+ * OMITTED (the common case) → this addon declares no budget. If NO co-located
1199
+ * addon declares one, the runner reports itself UNWATCHED once at boot; there
1200
+ * is deliberately no class default, because the legitimate footprint differs by
1201
+ * an order of magnitude by role (a recorder holding a 1.5GB segment index is
1202
+ * behaving correctly; analytics holding 1.5GB is not). Declare it from a
1203
+ * MEASURED peak RSS, never from a heap number.
1204
+ *
1205
+ * A runner's budget is the MAXIMUM declared by any co-located addon —
1206
+ * co-location must never silence the hungriest member (D2/D29).
1207
+ */
1208
+ readonly rssBudgetMb?: number;
1181
1209
  /**
1182
1210
  * `@camstack/system` builtin ONLY: run this builtin in its OWN forked runner
1183
1211
  * instead of in-process on the hub root.
@@ -56,6 +56,21 @@ export interface LogTags {
56
56
  * emitting code can reuse whatever handle the addon already has.
57
57
  */
58
58
  readonly sessionId?: string;
59
+ /**
60
+ * The per-component log CHANNEL this line was emitted on (e.g.
61
+ * `stream-broker.webrtc`). Set by {@link LogChannelGate.log} and by nothing
62
+ * else, so `| json | logChannel="stream-broker.webrtc"` selects exactly the
63
+ * lines an armed channel produced — without matching on message text, which
64
+ * is what makes a channel readable a day later.
65
+ *
66
+ * Deliberately NOT `channel`: that tag is already taken and means something
67
+ * else entirely — the NVR sub-channel index a Reolink hub camera sits on
68
+ * (`accessories/siren.ts`, `accessories/floodlight.ts`), and it is a NUMBER.
69
+ * One tag key meaning both `2` and `stream-broker.rtp` would make every
70
+ * LogQL selector over it ambiguous, which is the same "two things under one
71
+ * name" defect this mechanism exists to remove.
72
+ */
73
+ readonly logChannel?: string;
59
74
  /** Arbitrary extra tags */
60
75
  readonly [key: string]: string | number | undefined;
61
76
  }
@@ -114,6 +114,30 @@ export interface SettingsIsEmptyInput {
114
114
  readonly namespace?: string;
115
115
  readonly collection: SettingsCollection;
116
116
  }
117
+ /**
118
+ * One scalar an {@link ISettingsBackend.aggregate} call asks for. `as` names the
119
+ * result slot, so `MIN(startMs)` and `MAX(startMs)` can be asked in one call.
120
+ */
121
+ export interface SettingsAggregateField {
122
+ readonly as: string;
123
+ readonly field: string;
124
+ readonly op: 'sum' | 'min' | 'max';
125
+ }
126
+ export interface SettingsAggregateInput {
127
+ readonly namespace?: string;
128
+ readonly collection: SettingsCollection;
129
+ readonly fields: readonly SettingsAggregateField[];
130
+ readonly filter?: QueryFilter;
131
+ }
132
+ /**
133
+ * `COUNT(*)` plus one value per requested field. A value is `null` when no row
134
+ * matched — never 0, because "nothing is there" and "the sum is zero" are
135
+ * different facts about a disk.
136
+ */
137
+ export interface SettingsAggregateResult {
138
+ readonly count: number;
139
+ readonly values: Readonly<Record<string, number | null>>;
140
+ }
117
141
  export interface SettingsHistogramInput {
118
142
  readonly namespace?: string;
119
143
  readonly collection: SettingsCollection;
@@ -147,6 +171,8 @@ export interface ISettingsBackend {
147
171
  delete(input: SettingsDeleteInput): Promise<void>;
148
172
  /** Count entries in a collection */
149
173
  count(input: SettingsCountInput): Promise<number>;
174
+ /** COUNT(*) plus one SUM/MIN/MAX per field, in one statement. */
175
+ aggregate(input: SettingsAggregateInput): Promise<SettingsAggregateResult>;
150
176
  /** Grouped counts: COUNT(*) per ((field - origin) / bucketSize) bucket. */
151
177
  histogram(input: SettingsHistogramInput): Promise<readonly HistogramBucket[]>;
152
178
  /** Check if a collection is empty (used for first-boot detection) */
@@ -0,0 +1,62 @@
1
+ /**
2
+ * The one-liner an addon registers to join the channel mechanism.
3
+ *
4
+ * ```ts
5
+ * context.registerProvider('log-channels', createLogChannelsProvider(this.ctx.logger))
6
+ * ```
7
+ *
8
+ * Everything it does is bookkeeping over the process-wide
9
+ * {@link LogChannelRegistry}: enumerate the declarations, and push the
10
+ * document's armed set into the mirror. It holds no value of its own, so a
11
+ * second provider in another addon of the same process cannot disagree with
12
+ * this one — they share the registry.
13
+ *
14
+ * ## Why the ticker lives here
15
+ *
16
+ * A window is a deadline, and something has to notice the deadline passing.
17
+ * It must not be the log path: a diagnostic that adds a `Date.now()` to the
18
+ * path it is measuring measures itself. So the provider owns a coarse
19
+ * interval that calls {@link LogChannelRegistry.tick} and writes ONE line per
20
+ * closed channel — because a channel going quiet with no line reads as "that
21
+ * branch was never taken", which is the wrong conclusion and an expensive one.
22
+ */
23
+ import type { IScopedLogger } from '../interfaces/logging.js';
24
+ import type { LogChannelWindow } from './log-channel.js';
25
+ /**
26
+ * How often expiry is noticed. Coarse on purpose: the cost of a channel
27
+ * running a few seconds past its deadline is a few extra lines, and the cost
28
+ * of a tight timer in every addon process is paid forever.
29
+ */
30
+ export declare const LOG_CHANNEL_TICK_MS = 5000;
31
+ export interface LogChannelsProviderHandle {
32
+ readonly list: () => readonly LogChannelDescriptorOut[];
33
+ readonly apply: (input: LogChannelApplyInput) => LogChannelApplyOut;
34
+ /** Stop the expiry ticker. Call from the addon's `shutdown()`. */
35
+ readonly stop: () => void;
36
+ }
37
+ export interface LogChannelApplyInput {
38
+ readonly windows: readonly LogChannelWindow[];
39
+ }
40
+ export interface LogChannelApplyOut {
41
+ readonly armed: number;
42
+ readonly unknown: readonly string[];
43
+ }
44
+ interface LogChannelDescriptorOut {
45
+ readonly name: string;
46
+ readonly description: string;
47
+ readonly defaultLevel: 'info' | 'warn' | 'error';
48
+ readonly perDevice: boolean;
49
+ }
50
+ export interface LogChannelsProviderOptions {
51
+ /** Injected so a spec can drive expiry without waiting. */
52
+ readonly now?: () => number;
53
+ readonly tickMs?: number;
54
+ }
55
+ /**
56
+ * Build the `log-channels` provider for this process.
57
+ *
58
+ * `logger` is used ONLY off the hot path — for the arm/expiry lines — so a
59
+ * channel that is never armed costs this module nothing but a timer.
60
+ */
61
+ export declare function createLogChannelsProvider(logger: IScopedLogger, options?: LogChannelsProviderOptions): LogChannelsProviderHandle;
62
+ export {};
@@ -0,0 +1,194 @@
1
+ /**
2
+ * Per-component log CHANNELS — the gate a hot path consults, and the registry
3
+ * an addon declares its channels in.
4
+ *
5
+ * ## Two axes, deliberately separated
6
+ *
7
+ * - **DECLARATION** — which channels exist. Only the addon knows:
8
+ * `stream-broker` knows webrtc/ICE/RTP, `provider-reolink` knows
9
+ * baichuan/handshake. A hand-wired central list rots at the first addition,
10
+ * and rots silently. So a channel is declared where it is consulted, and the
11
+ * `log-channels` capability enumerates the declarations.
12
+ * - **VALUE** — at which level, for which scope, until when. That stays ONE
13
+ * thing: the logging settings document on the `system` cap. Two authorities
14
+ * over the values is the exact defect
15
+ * `docs/design/plans/2026-08-26-logging-per-componente.md` was written to
16
+ * remove; re-introducing it from the cure side would be grotesque.
17
+ *
18
+ * Nothing in this file reads a clock, an env var or a store. The registry is
19
+ * a MIRROR: it is moved only by {@link LogChannelRegistry.apply}, called off
20
+ * the hot path with a value somebody actually read, and by
21
+ * {@link LogChannelRegistry.tick}, called on a timer. A store read that fails
22
+ * never reaches here, so it can neither disarm an armed channel nor arm a
23
+ * disarmed one (D49).
24
+ *
25
+ * ## The canonical call shape
26
+ *
27
+ * ```ts
28
+ * if (CH_RTP.on && CH_RTP.wants(deviceId)) {
29
+ * CH_RTP.log(logger, 'rtp subscriber added', { tags: { deviceId }, meta: { ssrc } })
30
+ * }
31
+ * ```
32
+ *
33
+ * `on` is a plain boolean FIELD — never a getter — and it is the FIRST thing
34
+ * read. Disarmed, a call site costs one load and one branch, and the `extras`
35
+ * object literal is never constructed because it lives inside the branch. It
36
+ * is the same shape already proven in production at `stream-broker.ts:1650`,
37
+ * and the same discipline `LoggingGate.allowsDestination` uses for the
38
+ * destination floor (measured at 1.93 ns/call when off).
39
+ *
40
+ * ## Why a channel emits at `info`
41
+ *
42
+ * `loki-logging.addon.ts` pins the destination default at `info` and
43
+ * `loki-destination.ts` drops everything below it, so a line emitted at
44
+ * `debug` never reaches Loki and the hub's in-memory ring only holds ~35
45
+ * minutes. A diagnostic that cannot be read an hour later is worse than no
46
+ * diagnostic, because it looks done. {@link LogChannelGate.log} therefore
47
+ * emits at the channel's declared level, whose schema floor is `info`.
48
+ */
49
+ import { z } from 'zod';
50
+ import type { IScopedLogger, LogExtras } from '../interfaces/logging.js';
51
+ /**
52
+ * The level a channel writes at once armed.
53
+ *
54
+ * `debug` is absent ON PURPOSE and not by omission: below `info` the line does
55
+ * not leave the process for Loki, and the whole point of arming a channel is
56
+ * to read it later.
57
+ */
58
+ export declare const LogChannelLevelSchema: z.ZodEnum<{
59
+ error: "error";
60
+ info: "info";
61
+ warn: "warn";
62
+ }>;
63
+ /**
64
+ * What an addon declares about one channel. No value, no state — a
65
+ * declaration is inert.
66
+ */
67
+ export declare const LogChannelDescriptorSchema: z.ZodObject<{
68
+ name: z.ZodString;
69
+ description: z.ZodString;
70
+ defaultLevel: z.ZodEnum<{
71
+ error: "error";
72
+ info: "info";
73
+ warn: "warn";
74
+ }>;
75
+ perDevice: z.ZodBoolean;
76
+ }, z.core.$strip>;
77
+ export type LogChannelDescriptor = z.infer<typeof LogChannelDescriptorSchema>;
78
+ /**
79
+ * An armed window over one channel, as the document hands it to a mirror.
80
+ *
81
+ * A window is a DEADLINE, never a flag (ADR-0244): a channel somebody forgot
82
+ * expires by itself, which is the one failure a boolean cannot avoid.
83
+ */
84
+ export declare const LogChannelWindowSchema: z.ZodObject<{
85
+ channel: z.ZodString;
86
+ armedUntilMs: z.ZodNumber;
87
+ deviceIds: z.ZodNullable<z.ZodReadonly<z.ZodArray<z.ZodNumber>>>;
88
+ }, z.core.$strip>;
89
+ export type LogChannelWindow = z.infer<typeof LogChannelWindowSchema>;
90
+ /**
91
+ * The gate a hot path holds.
92
+ *
93
+ * Obtain it ONCE — at module scope or in a constructor — and keep the
94
+ * reference. Looking a channel up by name per line would put a Map lookup on
95
+ * the path this class exists to keep free.
96
+ */
97
+ export declare class LogChannelGate {
98
+ readonly descriptor: LogChannelDescriptor;
99
+ /**
100
+ * HOT PATH GUARD. A plain data FIELD, and it must stay one.
101
+ *
102
+ * `log-channel.spec.ts` asserts the property descriptor has no getter and
103
+ * booby-traps the device set, so turning this into an accessor — or reading
104
+ * anything before it — fails the spec instead of taxing every line the
105
+ * process emits.
106
+ */
107
+ on: boolean;
108
+ /** `null` while armed for every camera. Never read while `on` is false. */
109
+ private devices;
110
+ private level;
111
+ private closesAtMs;
112
+ constructor(descriptor: LogChannelDescriptor);
113
+ /** Epoch ms this channel disarms itself at. 0 when disarmed. */
114
+ get armedUntilMs(): number;
115
+ /**
116
+ * Does this channel want a line about `deviceId`?
117
+ *
118
+ * Call it only behind `gate.on &&`. On its own it is still correct — the
119
+ * guard is repeated inside — but the point of the prefix is that a disarmed
120
+ * channel must not pay the call at all.
121
+ */
122
+ wants(deviceId: number): boolean;
123
+ /**
124
+ * Emit one line on this channel, at the channel's declared level.
125
+ *
126
+ * The channel name is added as `tags.logChannel` so LogQL can select the
127
+ * channel without matching on the message text, and whatever `tags` the
128
+ * caller passed — `deviceId` above all — is preserved.
129
+ */
130
+ log(logger: IScopedLogger, message: string, extras: LogExtras): void;
131
+ /**
132
+ * Arm (or RE-arm, restarting) this channel. Off the hot path only.
133
+ *
134
+ * An empty `deviceIds` list is treated as "every camera" rather than "no
135
+ * camera": a window that matches nothing is indistinguishable from a
136
+ * disarmed one, and the operator who asked for it would wait for lines that
137
+ * can never come.
138
+ */
139
+ arm(window: LogChannelWindow): void;
140
+ /** Disarm. Off the hot path only. */
141
+ disarm(): void;
142
+ }
143
+ /**
144
+ * Every channel this PROCESS declares, and the mirror of what is armed on it.
145
+ *
146
+ * One per process. A forked runner has its own, and it is refreshed through
147
+ * the `log-channels` capability by the hub that owns the document — the
148
+ * registry never reaches for a value itself.
149
+ */
150
+ export declare class LogChannelRegistry {
151
+ private readonly gates;
152
+ /**
153
+ * Declare a channel and get its gate.
154
+ *
155
+ * A duplicate name throws. Two declarations of one name is a programming
156
+ * error, not a merge: the operator would arm one and the other would stay
157
+ * dark, which is the dead-knob shape (D62) with an extra step.
158
+ */
159
+ declare(descriptor: LogChannelDescriptor): LogChannelGate;
160
+ /** The declarations, sorted by name so a list is stable to read and diff. */
161
+ list(): readonly LogChannelDescriptor[];
162
+ /** The gate for a declared channel, or `undefined`. */
163
+ gate(name: string): LogChannelGate | undefined;
164
+ /**
165
+ * Apply the FULL set of armed windows. Off the hot path.
166
+ *
167
+ * Full, not incremental, and that is the whole design: the document is the
168
+ * authority, so a channel the document does not name is disarmed here. An
169
+ * incremental apply would let a disarm get lost in transit and leave a
170
+ * channel running that nobody can see is running.
171
+ *
172
+ * A window already past its deadline is ignored rather than armed — a
173
+ * restore that re-armed an expired window would make a forgotten diagnostic
174
+ * immortal across restarts.
175
+ *
176
+ * Returns the names it could not place, so the caller can log them: a
177
+ * channel named in the document that this process does not declare is
178
+ * either a typo or an addon that has not booted yet, and both deserve a
179
+ * line rather than silence.
180
+ */
181
+ apply(windows: readonly LogChannelWindow[], nowMs: number): readonly string[];
182
+ /**
183
+ * Disarm whatever has run out. Called on a timer, NEVER from a log path — a
184
+ * diagnostic that adds a `Date.now()` to the path it is measuring measures
185
+ * itself.
186
+ *
187
+ * Returns the names it closed, so the caller can write the one line that
188
+ * says a window ended and stops "it went quiet" from reading as "the branch
189
+ * was not taken".
190
+ */
191
+ tick(nowMs: number): readonly string[];
192
+ /** The channels armed right now, as the document would describe them. */
193
+ armed(): readonly LogChannelWindow[];
194
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Process-wide holder for the {@link LogChannelRegistry}.
3
+ *
4
+ * Three call sites that never meet need the SAME instance: the hot paths that
5
+ * declare a gate at module scope, the `log-channels` provider that enumerates
6
+ * the declarations for the hub, and the same provider applying the windows the
7
+ * document hands down. A registry built inside any one of them would be
8
+ * refreshed and collected — the shape of a knob that never does anything.
9
+ *
10
+ * Same idiom as `logging-gate.singleton.ts` and
11
+ * `http-request-census.singleton.ts`.
12
+ */
13
+ import { LogChannelRegistry, type LogChannelDescriptor, type LogChannelGate } from './log-channel.js';
14
+ /** The process-wide log channel registry. Created empty on first use. */
15
+ export declare function getLogChannelRegistry(): LogChannelRegistry;
16
+ /**
17
+ * Declare a channel on the process-wide registry and get its gate.
18
+ *
19
+ * The one call an addon makes. Keep the returned gate in a module-scope
20
+ * `const`: looking a channel up by name per line would put a Map lookup on
21
+ * exactly the path this mechanism exists to keep free.
22
+ *
23
+ * `scripts/check-log-channel-gated.ts` reads these call sites. It pairs the
24
+ * declared name with the binding it is assigned to and refuses to let a
25
+ * channel ship that no `<binding>.on` anywhere consults — a declared channel
26
+ * nobody reads is a knob the operator turns with nothing happening, forever,
27
+ * and without a line. That is D62, and this repo has now shipped it three
28
+ * times (`audioThresholdDbfs`, the HA entities with no source, the second
29
+ * per-camera switch that wrote a store nobody read).
30
+ */
31
+ export declare function declareLogChannel(descriptor: LogChannelDescriptor): LogChannelGate;
32
+ /** Test-only: drop the instance so a spec starts from an empty registry. */
33
+ export declare function __resetLogChannelRegistryForTests(): void;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/types",
3
- "version": "1.2.114",
3
+ "version": "1.2.116",
4
4
  "description": "Shared types, interfaces, and model catalogs for the CamStack detection ecosystem",
5
5
  "keywords": [
6
6
  "camstack",