@camstack/addon-post-analysis 1.2.305 → 1.2.307

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.
@@ -2,7 +2,7 @@ Object.defineProperties(exports, {
2
2
  __esModule: { value: true },
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
- const require_clip_model_registry = require("../clip-model-registry-t2ey3w0B.js");
5
+ const require_clip_model_registry = require("../clip-model-registry-C67UM_q_.js");
6
6
  let node_fs = require("node:fs");
7
7
  node_fs = require_clip_model_registry.__toESM(node_fs, 1);
8
8
  let node_path = require("node:path");
@@ -10652,21 +10652,49 @@ function matchesPlate(label, values, maxDistance) {
10652
10652
  return false;
10653
10653
  }
10654
10654
  /**
10655
+ * Subject kinds whose `classNames` is a real, operator-meaningful detection
10656
+ * class — the ones the `classes` condition can actually scope a rule to.
10657
+ * `device-event` and `system-event` carry NO class live (`classNames: []`,
10658
+ * always — see `subjectFromSensorEvent`); their OUTBOX `className` is a
10659
+ * display placeholder instead (the sensor kind, or the literal `'event'`,
10660
+ * `className: subject.classNames[0] ?? subject.sensorKind ?? 'event'`), which
10661
+ * `cooldownSubjectFromOutbox` would otherwise reconstruct as if it were a real
10662
+ * class. Gating here, not on the reconstructed value, keeps the live path and
10663
+ * the boot reseed addressing the SAME key regardless of what a display label
10664
+ * happens to say (2026-10-01, caught by `system-event-cooldown-direction.spec.ts`
10665
+ * when the default below flipped).
10666
+ */
10667
+ var CLASS_BEARING_SUBJECT_KINDS = new Set([
10668
+ "object-event",
10669
+ "track-end",
10670
+ "package-event",
10671
+ "occupancy-event",
10672
+ "audio-event",
10673
+ "audio-window"
10674
+ ]);
10675
+ /**
10655
10676
  * Should this subject's class be part of the cooldown key?
10656
10677
  *
10657
10678
  * AUDIO is unconditional and predates the setting: a rule opted into several
10658
10679
  * audio classes (dog + scream) must not have a safety-relevant scream
10659
10680
  * swallowed by an unrelated bark's 60 s window.
10660
10681
  *
10661
- * Every other subject gets the same treatment only when the rule ASKS for it
10662
- * (`granularity: 'per-class'`) — cat→dog fires at once, cat→cat still waits.
10663
- * Absent / `shared` reproduces the class-agnostic key byte for byte, because
10664
- * changing how often an operator's existing rules fire is not a side effect
10665
- * anyone asked for.
10682
+ * A `device-event` or `system-event` subject never reads a class — see
10683
+ * {@link CLASS_BEARING_SUBJECT_KINDS} — so the setting has nothing to key on
10684
+ * regardless of `granularity`.
10685
+ *
10686
+ * Every class-bearing subject gets the same treatment unless the rule
10687
+ * explicitly asks for `shared` — cat→dog fires at once, cat→cat still waits.
10688
+ * That is the operator's default since 2026-10-01 (D36's dated note): an
10689
+ * UNSET rule now reads exactly like an explicit `per-class` one, new rules and
10690
+ * existing rules alike. Only a `granularity: 'shared'` actually stored
10691
+ * reproduces the old class-agnostic key byte for byte — the one case where an
10692
+ * operator asked for one window across every class on purpose.
10666
10693
  */
10667
10694
  function keysPerClass(rule, subject) {
10668
10695
  if (subject.kind === "audio-event" || subject.kind === "audio-window") return true;
10669
- return rule.throttle.granularity === "per-class";
10696
+ if (!CLASS_BEARING_SUBJECT_KINDS.has(subject.kind)) return false;
10697
+ return rule.throttle.granularity !== "shared";
10670
10698
  }
10671
10699
  /**
10672
10700
  * The cooldown scope of ONE system event — subject AND direction.
@@ -10751,6 +10779,42 @@ function isCoolingDown(rule, lastFiredAt, now) {
10751
10779
  return now - lastFiredAt < rule.throttle.cooldownSec * 1e3;
10752
10780
  }
10753
10781
  /**
10782
+ * The media policy EVERY sensor rule attaches: one whole-scene photograph,
10783
+ * never a selector. `attach: 'keyFrame'` is the existing "clean full scene
10784
+ * frame" ladder (`attachmentKindPreference`) — the closest existing concept
10785
+ * to a plain snapshot, as opposed to `'best'`/`'best-matching'`, which chase a
10786
+ * SUBJECT crop a sensor subject (a doorbell press, a contact change) has
10787
+ * none of to chase. No zone crop, no gif, no clip, no profile — a sensor
10788
+ * subject has no zone condition to crop to and no reason to animate.
10789
+ */
10790
+ var NC_SENSOR_MEDIA_POLICY = { attach: "keyFrame" };
10791
+ /**
10792
+ * The media policy actually used to build a delivery, for a rule of this
10793
+ * KIND. A sensor rule's STORED `media` is never read at evaluation — the
10794
+ * editor offers no media control for one any more, and `media.attach` /
10795
+ * `.gif` / `.clip` / `.profile` on an existing sensor rule (authored before
10796
+ * this ruling, from the viewer or the API) are IGNORED here, never refused at
10797
+ * load. See {@link mediaPolicyMatchesSensorDefault} for the sampled-log gate.
10798
+ */
10799
+ function effectiveMediaPolicy(rule, kind) {
10800
+ return kind === "sensor" ? NC_SENSOR_MEDIA_POLICY : rule.media;
10801
+ }
10802
+ /**
10803
+ * True when a stored media policy already reads as the sensor default — the
10804
+ * gate the boot/evaluation path uses to decide whether an existing sensor
10805
+ * rule's stored choice is being silently overridden (and so is worth a
10806
+ * sampled log line) or already matches and there is nothing to say.
10807
+ */
10808
+ function mediaPolicyMatchesSensorDefault(media) {
10809
+ return media.attach === NC_SENSOR_MEDIA_POLICY.attach && media.frame === void 0 && media.zoneCrop !== true && media.gif !== true && media.clip !== true && media.profile === void 0;
10810
+ }
10811
+ /** `effectiveMediaPolicy`, resolving the kind itself — the call-site shortcut
10812
+ * every production caller uses; `effectiveMediaPolicy` stays the unit under
10813
+ * test because `ruleKindOf` needs no fixture of its own there. */
10814
+ function effectiveMediaPolicyForRule(rule) {
10815
+ return effectiveMediaPolicy(rule, require_clip_model_registry.ruleKindOf(rule));
10816
+ }
10817
+ /**
10754
10818
  * Ordered media-kind preference for one attachment, per policy and owner.
10755
10819
  * `best` favours the tight subject crop; `keyFrame` the clean scene frame
10756
10820
  * (small variant first — the degrade engine enforces per-backend byte
@@ -14332,11 +14396,11 @@ function logRefusal$3(check, ctx) {
14332
14396
  try {
14333
14397
  check();
14334
14398
  } catch (err) {
14335
- if (err instanceof NcDevicesExcludeRefusal) warnRefusal$2(ctx.logger, err, ctx);
14399
+ if (err instanceof NcDevicesExcludeRefusal) warnRefusal$3(ctx.logger, err, ctx);
14336
14400
  throw err;
14337
14401
  }
14338
14402
  }
14339
- function warnRefusal$2(logger, err, ctx) {
14403
+ function warnRefusal$3(logger, err, ctx) {
14340
14404
  logger.warn("nc rule refused: devicesExclude on a system rule", { meta: {
14341
14405
  ...ctx.ruleId !== void 0 ? { ruleId: ctx.ruleId } : {},
14342
14406
  by: ctx.by,
@@ -14359,6 +14423,184 @@ function guardPatchDevicesExclude(existing, patch, ctx) {
14359
14423
  logRefusal$3(() => assertDevicesExcludeDelivery(merged), ctx);
14360
14424
  }
14361
14425
  //#endregion
14426
+ //#region src/notification-center/device-compat-guard.ts
14427
+ /**
14428
+ * A NEW or UPDATED rule's `conditions.devices` (the "Only these" INCLUDE list)
14429
+ * names only devices that can actually produce this rule's KIND of subject —
14430
+ * operator ruling, 2026-10-01: "the DEVICE picker of a rule must offer only
14431
+ * devices compatible with the rule type … the server should also refuse a
14432
+ * save whose deviceIds include an incompatible device".
14433
+ *
14434
+ * THE authority, same arrangement as `device-scope-guard.ts` beside it: the
14435
+ * cap-provider route (`notificationRules.createRule/updateRule`, index.ts)
14436
+ * and the rule bridge (`nc.createRule/updateRule`, nc-actions.ts) both call
14437
+ * `guardRuleDeviceCompat` / `guardPatchDeviceCompat`. Which devices a kind
14438
+ * may scope is `ruleDeviceCompatCapQueries` (`@camstack/types`) — the SAME
14439
+ * table the admin's Devices tab narrows by, so a save can never refuse a
14440
+ * device its own picker just offered. A `sensor` rule's set includes every
14441
+ * camera: a camera-scoped sensor rule is legitimate (D630 §6).
14442
+ *
14443
+ * ASYNC, unlike its sibling guards: it has to ask `deviceManager` which
14444
+ * devices carry the caps. `listDeviceIdsByCaps` is therefore OPTIONAL on the
14445
+ * context — absent on an older wiring, if the cross-addon call throws, or if
14446
+ * it answers EMPTY (the registry not yet warm at boot: `getAllBindings`
14447
+ * answers `[]` with only a warning, which is not a measurement — D315/D393),
14448
+ * the guard FAILS OPEN (never refuses) rather than blocking every save on a
14449
+ * read it cannot make (D49). It is never SILENT about it: an unjudged scoped
14450
+ * save logs a sampled WARN naming the rule and the reason, so a dropped wiring
14451
+ * is visible in production rather than a guard that quietly stopped existing.
14452
+ *
14453
+ * ON UPDATE only the ids the patch ADDS are judged. A device already stored on
14454
+ * the rule is never re-judged: the answer is not stable (switching detection
14455
+ * off on a camera removes its `detection-pipeline` binding, D62; an agent
14456
+ * camera's native caps are missing during the handshake), and re-judging
14457
+ * would make every rule naming that camera un-editable until the operator
14458
+ * removed it — losing a scope a later switch-on would not give back.
14459
+ *
14460
+ * NEVER called at LOAD. `NcRuleSchema.parse` and the store's own read path are
14461
+ * untouched — a rule saved before this ruling, or saved while the capability
14462
+ * read was unavailable, keeps loading and keeps firing exactly as it does
14463
+ * today; only a NEW save attempt is judged.
14464
+ *
14465
+ * `devicesExclude` is NOT checked: this guard judges what the rule actually
14466
+ * MATCHES ON (the include scope), not what it widens away from, and an
14467
+ * operator excluding an incompatible device from an "all except" rule is not
14468
+ * the mistake this exists to catch.
14469
+ */
14470
+ /** How long one rule's "accepted unchecked" WARN stays quiet after it fired. */
14471
+ var UNCHECKED_WARN_WINDOW_MS = 10 * 6e4;
14472
+ /** Bound on remembered rule keys — cleared whole when exceeded. */
14473
+ var UNCHECKED_WARN_MEMO_MAX = 256;
14474
+ /**
14475
+ * Samples the "accepted unchecked" WARN per rule: an editor that autosaves, or
14476
+ * a script, must not turn one unreachable read into a log flood, but the first
14477
+ * save of every rule in a window is always reported.
14478
+ */
14479
+ var NcDeviceCompatWarnSampler = class {
14480
+ lastWarnedAt = /* @__PURE__ */ new Map();
14481
+ shouldWarn(key, now) {
14482
+ const last = this.lastWarnedAt.get(key);
14483
+ if (last !== void 0 && now - last < UNCHECKED_WARN_WINDOW_MS) return false;
14484
+ if (this.lastWarnedAt.size >= UNCHECKED_WARN_MEMO_MAX) this.lastWarnedAt.clear();
14485
+ this.lastWarnedAt.set(key, now);
14486
+ return true;
14487
+ }
14488
+ };
14489
+ var NcDeviceCompatRefusal = class extends Error {
14490
+ kind;
14491
+ incompatibleIds;
14492
+ constructor(kind, incompatibleIds) {
14493
+ super(`rule refused: device(s) ${incompatibleIds.join(", ")} cannot produce a '${kind}' rule's subject`);
14494
+ this.name = "NcDeviceCompatRefusal";
14495
+ this.kind = kind;
14496
+ this.incompatibleIds = incompatibleIds;
14497
+ }
14498
+ };
14499
+ /** The union of every compat query's answer, or why there is none. */
14500
+ async function readCompatibleIds(queries, listDeviceIdsByCaps) {
14501
+ try {
14502
+ const answers = await Promise.all(queries.map((capNames) => listDeviceIdsByCaps(capNames)));
14503
+ return {
14504
+ ok: true,
14505
+ ids: new Set(answers.flat())
14506
+ };
14507
+ } catch (err) {
14508
+ return {
14509
+ ok: false,
14510
+ error: String(err)
14511
+ };
14512
+ }
14513
+ }
14514
+ /**
14515
+ * The check. `judged` is the ids to judge — the whole include list on a
14516
+ * create, only the ADDED ids on an update (see the file header).
14517
+ *
14518
+ * Resolves `not-judged` when the kind has no capability filter
14519
+ * (`ruleDeviceCompatCapQueries` is `null`) or there is nothing to judge —
14520
+ * "every device" is never refused here. Resolves `unchecked` (fail open) when
14521
+ * the read is not wired, throws, or answers nothing at all. Throws
14522
+ * {@link NcDeviceCompatRefusal} naming every incompatible id, never only the
14523
+ * first — an operator fixing the list needs the whole picture in one pass.
14524
+ */
14525
+ async function assertRuleDeviceCompat(rule, listDeviceIdsByCaps, judged = rule.conditions.devices ?? []) {
14526
+ const kind = require_clip_model_registry.ruleKindOf(rule);
14527
+ const queries = require_clip_model_registry.ruleDeviceCompatCapQueries(kind);
14528
+ if (queries === null || judged.length === 0) return { outcome: "not-judged" };
14529
+ if (listDeviceIdsByCaps === void 0) return {
14530
+ outcome: "unchecked",
14531
+ reason: "unwired",
14532
+ ids: judged
14533
+ };
14534
+ const read = await readCompatibleIds(queries, listDeviceIdsByCaps);
14535
+ if (!read.ok) return {
14536
+ outcome: "unchecked",
14537
+ reason: "read-failed",
14538
+ ids: judged,
14539
+ error: read.error
14540
+ };
14541
+ if (read.ids.size === 0) return {
14542
+ outcome: "unchecked",
14543
+ reason: "answered-empty",
14544
+ ids: judged
14545
+ };
14546
+ const incompatible = judged.filter((id) => !read.ids.has(id));
14547
+ if (incompatible.length > 0) throw new NcDeviceCompatRefusal(kind, incompatible);
14548
+ return { outcome: "compatible" };
14549
+ }
14550
+ /**
14551
+ * Run the check and report what it could not do: a refusal is dropped work, so
14552
+ * it is logged before it is rethrown; an unchecked acceptance is a guard that
14553
+ * did not run, so it is logged (sampled) too.
14554
+ */
14555
+ async function runAndReport(rule, judged, ctx) {
14556
+ let verdict;
14557
+ try {
14558
+ verdict = await assertRuleDeviceCompat(rule, ctx.listDeviceIdsByCaps, judged);
14559
+ } catch (err) {
14560
+ if (err instanceof NcDeviceCompatRefusal) warnRefusal$2(ctx.logger, err, ctx);
14561
+ throw err;
14562
+ }
14563
+ if (verdict.outcome === "unchecked") warnUnchecked(require_clip_model_registry.ruleKindOf(rule), verdict, ctx);
14564
+ }
14565
+ function warnRefusal$2(logger, err, ctx) {
14566
+ logger.warn("nc rule refused: device(s) incompatible with the rule kind", { meta: {
14567
+ ...ctx.ruleId !== void 0 ? { ruleId: ctx.ruleId } : {},
14568
+ by: ctx.by,
14569
+ kind: err.kind,
14570
+ incompatibleIds: [...err.incompatibleIds]
14571
+ } });
14572
+ }
14573
+ function warnUnchecked(kind, verdict, ctx) {
14574
+ const now = (ctx.now ?? Date.now)();
14575
+ const key = `${ctx.ruleId ?? `new:${ctx.by}`}:${verdict.reason}`;
14576
+ if (ctx.sampler !== void 0 && !ctx.sampler.shouldWarn(key, now)) return;
14577
+ ctx.logger.warn("nc rule saved WITHOUT the device-compatibility check — accepted unchecked", { meta: {
14578
+ ...ctx.ruleId !== void 0 ? { ruleId: ctx.ruleId } : {},
14579
+ by: ctx.by,
14580
+ kind,
14581
+ reason: verdict.reason,
14582
+ uncheckedIds: [...verdict.ids],
14583
+ ...verdict.error !== void 0 ? { error: verdict.error } : {}
14584
+ } });
14585
+ }
14586
+ /** The create path: the whole include list is judged. */
14587
+ async function guardRuleDeviceCompat(rule, ctx) {
14588
+ await runAndReport(rule, rule.conditions.devices ?? [], ctx);
14589
+ }
14590
+ /**
14591
+ * The update path: the MERGED rule decides the kind, but only the ids the
14592
+ * patch ADDS to `conditions.devices` are judged — a stored id is never
14593
+ * re-judged (see the file header). A patch that adds nothing never pays for a
14594
+ * capability read.
14595
+ */
14596
+ async function guardPatchDeviceCompat(existing, patch, ctx) {
14597
+ if (existing === null) return;
14598
+ const merged = mergedFamilySubject(existing, patch);
14599
+ if (merged === null) return;
14600
+ const stored = new Set(existing.conditions.devices ?? []);
14601
+ await runAndReport(merged, (merged.conditions.devices ?? []).filter((id) => !stored.has(id)), ctx);
14602
+ }
14603
+ //#endregion
14362
14604
  //#region src/notification-center/rule-summary-guard.ts
14363
14605
  /**
14364
14606
  * The hub's half of D647: `systemEventDelivery: { mode: 'summary' }` belongs to
@@ -17218,6 +17460,8 @@ function requireAdmin(caller) {
17218
17460
  return c;
17219
17461
  }
17220
17462
  function makeNcActionHandlers(deps) {
17463
+ /** One sampler per handler set: an unchecked save warns once per rule per window. */
17464
+ const deviceCompatWarnSampler = new NcDeviceCompatWarnSampler();
17221
17465
  /** The alarm half, or a refusal by name — never a made-up "disarmed". */
17222
17466
  const requireAlarm = (action) => {
17223
17467
  if (deps.alarm === void 0) throw new Error(`${action}: the alarm is not wired on this node`);
@@ -17375,6 +17619,12 @@ function makeNcActionHandlers(deps) {
17375
17619
  logger: deps.logger,
17376
17620
  by: c.userId
17377
17621
  });
17622
+ await guardRuleDeviceCompat(parsed, {
17623
+ logger: deps.logger,
17624
+ by: c.userId,
17625
+ listDeviceIdsByCaps: deps.listDeviceIdsByCaps,
17626
+ sampler: deviceCompatWarnSampler
17627
+ });
17378
17628
  guardRuleSystemEventDelivery(parsed, {
17379
17629
  logger: deps.logger,
17380
17630
  by: c.userId
@@ -17417,6 +17667,13 @@ function makeNcActionHandlers(deps) {
17417
17667
  by: c.userId,
17418
17668
  ruleId: input.ruleId
17419
17669
  });
17670
+ await guardPatchDeviceCompat(existing, patch, {
17671
+ logger: deps.logger,
17672
+ by: c.userId,
17673
+ ruleId: input.ruleId,
17674
+ listDeviceIdsByCaps: deps.listDeviceIdsByCaps,
17675
+ sampler: deviceCompatWarnSampler
17676
+ });
17420
17677
  guardPatchSystemEventDelivery(existing, patch, clear, {
17421
17678
  logger: deps.logger,
17422
17679
  by: c.userId,
@@ -17918,9 +18175,9 @@ function makeNcActionHandlers(deps) {
17918
18175
  * reproduce.
17919
18176
  */
17920
18177
  var NC_CLIP_DISPATCH_MARGIN_SEC = 10;
17921
- /** Does this rule ever cut footage from the broker's clip ring? */
17922
- function wantsFootage(rule) {
17923
- return rule.media.gif === true || rule.media.clip === true;
18178
+ /** Does this media policy ever cut footage from the broker's clip ring? */
18179
+ function wantsFootage(media) {
18180
+ return media.gif === true || media.clip === true;
17924
18181
  }
17925
18182
  /** The ask ONE rule makes, or `null` when it needs no retained footage. */
17926
18183
  function askSecondsFor(rule) {
@@ -17929,9 +18186,10 @@ function askSecondsFor(rule) {
17929
18186
  seconds: require_clip_model_registry.NC_CLIP_RETENTION_MAX_SEC,
17930
18187
  reason: "occupancy"
17931
18188
  };
17932
- if (!wantsFootage(rule)) return null;
17933
- const pre = rule.media.clipPreRollSec ?? require_clip_model_registry.NC_CLIP_PRE_ROLL_DEFAULT_SEC;
17934
- const post = rule.media.clipPostRollSec ?? require_clip_model_registry.NC_CLIP_POST_ROLL_DEFAULT_SEC;
18189
+ const media = effectiveMediaPolicyForRule(rule);
18190
+ if (!wantsFootage(media)) return null;
18191
+ const pre = media.clipPreRollSec ?? require_clip_model_registry.NC_CLIP_PRE_ROLL_DEFAULT_SEC;
18192
+ const post = media.clipPostRollSec ?? require_clip_model_registry.NC_CLIP_POST_ROLL_DEFAULT_SEC;
17935
18193
  return {
17936
18194
  seconds: Math.min(pre + post + NC_CLIP_DISPATCH_MARGIN_SEC, require_clip_model_registry.NC_CLIP_RETENTION_MAX_SEC),
17937
18195
  reason: "window"
@@ -17950,12 +18208,13 @@ function deriveClipRetentionAsks(rules) {
17950
18208
  for (const rule of rules) {
17951
18209
  const ask = askSecondsFor(rule);
17952
18210
  if (ask === null) continue;
18211
+ const profile = effectiveMediaPolicyForRule(rule).profile;
17953
18212
  const base = {
17954
18213
  seconds: ask.seconds,
17955
18214
  reason: ask.reason,
17956
18215
  ruleId: rule.id,
17957
18216
  ruleName: rule.name,
17958
- ...rule.media.profile !== void 0 ? { profile: rule.media.profile } : {}
18217
+ ...profile !== void 0 ? { profile } : {}
17959
18218
  };
17960
18219
  const devices = rule.conditions.devices;
17961
18220
  if (devices === void 0 || devices.length === 0) {
@@ -19812,6 +20071,114 @@ function ncGroupSubjectText(texts, input) {
19812
20071
  return texts.join([...names, ...others]);
19813
20072
  }
19814
20073
  //#endregion
20074
+ //#region src/notification-center/outbox-key.ts
20075
+ /**
20076
+ * The record kinds whose `recordId` is a per-table SQLite rowid (D474), and so
20077
+ * may be reused after a delete. `device-event` is NOT one: sensor events are
20078
+ * TEXT-keyed with a producer-minted id (`sensor-event-store.ts`).
20079
+ */
20080
+ var ROWID_RECORD_KINDS = new Set([
20081
+ "object-event",
20082
+ "package-event",
20083
+ "audio-event"
20084
+ ]);
20085
+ /** Is this kind's record id a reusable rowid? See {@link ROWID_RECORD_KINDS}. */
20086
+ function isRowidRecordKind(kind) {
20087
+ return ROWID_RECORD_KINDS.has(kind);
20088
+ }
20089
+ /** The record identity an outbox key input speaks for. */
20090
+ function outboxRecordIdentity(input) {
20091
+ return {
20092
+ recordId: input.recordId,
20093
+ deviceId: input.deviceId,
20094
+ recordAt: input.payload.subject.timestamp
20095
+ };
20096
+ }
20097
+ /**
20098
+ * The record GENERATION ref — `<recordId>@<deviceId>:<recordAt>`. Exported for
20099
+ * the specs and for a caller that must name a rowid record outside the outbox
20100
+ * (none today); a key is always built through {@link outboxEntryId}.
20101
+ */
20102
+ function recordGenerationRef(record) {
20103
+ return `${record.recordId}@${String(record.deviceId)}:${String(record.recordAt)}`;
20104
+ }
20105
+ /**
20106
+ * The middle segment of the dedup id:
20107
+ *
20108
+ * 1. an explicit `dedupRef` (upgrade / burst revision);
20109
+ * 2. the track id — `maxPerTrack: 1` (a track id is random, never reused);
20110
+ * 3. for a rowid record, its generation ref;
20111
+ * 4. the record id as is (synthetic ids already carry device + instant).
20112
+ */
20113
+ function outboxDedupRef(input) {
20114
+ if (input.dedupRef !== void 0) return input.dedupRef;
20115
+ if (input.trackId !== void 0) return input.trackId;
20116
+ if (isRowidRecordKind(input.recordKind)) return recordGenerationRef(outboxRecordIdentity(input));
20117
+ return input.recordId;
20118
+ }
20119
+ /** The outbox row id — the durable per-track / per-record fire guarantee. */
20120
+ function outboxEntryId(input) {
20121
+ return `${input.ruleId}:${outboxDedupRef(input)}:${input.targetId}`;
20122
+ }
20123
+ /**
20124
+ * The id this input had BEFORE D694 — but only when it differs from today's,
20125
+ * i.e. for a rowid record keyed on its record id. `null` otherwise: there is no
20126
+ * older spelling to look for.
20127
+ */
20128
+ function legacyOutboxEntryId(input) {
20129
+ if (input.dedupRef !== void 0 || input.trackId !== void 0) return null;
20130
+ if (!isRowidRecordKind(input.recordKind)) return null;
20131
+ return `${input.ruleId}:${input.recordId}:${input.targetId}`;
20132
+ }
20133
+ /**
20134
+ * A STORED row's id as it would be built today, when the row was written under
20135
+ * the pre-D694 spelling; `null` when the stored id is already current (or was
20136
+ * built from an explicit dedup ref, which a row does not record and which never
20137
+ * involved the record id).
20138
+ */
20139
+ function translatedLegacyEntryId(storedId, row) {
20140
+ const legacy = legacyOutboxEntryId(row);
20141
+ if (legacy === null || legacy !== storedId) return null;
20142
+ return outboxEntryId(row);
20143
+ }
20144
+ /**
20145
+ * Does a STORED row's payload really carry its record timestamp?
20146
+ *
20147
+ * The type says it always does, but a stored payload is checked only as an
20148
+ * object on the way back from the store (`rowToEntry`), so a malformed or very
20149
+ * old row can lack `subject`. Every read of {@link outboxRecordIdentity} on a
20150
+ * STORED row is preceded by this check. Takes the payload as `unknown` on
20151
+ * purpose: the static type cannot answer the question.
20152
+ */
20153
+ function hasRecordTimestamp(payload) {
20154
+ if (typeof payload !== "object" || payload === null || !("subject" in payload)) return false;
20155
+ const subject = payload.subject;
20156
+ return typeof subject === "object" && subject !== null && "timestamp" in subject && typeof subject.timestamp === "number";
20157
+ }
20158
+ /** Do two rows speak for the same RECORD? Id, camera and record timestamp. */
20159
+ function isSameOutboxRecord(a, b) {
20160
+ return a.recordId === b.recordId && a.deviceId === b.deviceId && a.recordAt === b.recordAt;
20161
+ }
20162
+ /**
20163
+ * The scope of the event a payload's `eventId` names.
20164
+ *
20165
+ * Usually the row's own subject. A COMBINED alarm row is the exception: its
20166
+ * subject is the alarm transition (its own instant), while its `eventId` is the
20167
+ * triggering rule's event — so the generation is the alarm SOURCE's camera and
20168
+ * timestamp (`NcRuleFireRef`), never the transition's.
20169
+ */
20170
+ function eventMediaScopeOf(subject) {
20171
+ const source = subject.systemEvent?.alarmSource;
20172
+ if (source !== void 0) return {
20173
+ deviceId: source.deviceId,
20174
+ recordAt: source.timestamp
20175
+ };
20176
+ return {
20177
+ deviceId: subject.deviceId,
20178
+ recordAt: subject.timestamp
20179
+ };
20180
+ }
20181
+ //#endregion
19815
20182
  //#region src/notification-center/footage-source.ts
19816
20183
  /**
19817
20184
  * Decide where this window must be cut from. Pure and total.
@@ -21777,7 +22144,7 @@ var NcDispatcher = class {
21777
22144
  });
21778
22145
  if (policy === "keyFrame" || frame === "full" || frame === "boxed") owners.reverse();
21779
22146
  for (const owner of owners) try {
21780
- const files = await this.deps.getMediaForOwner(owner.kind, owner.id);
22147
+ const files = owner.kind === "object" ? await this.deps.getMediaForOwner(owner.kind, owner.id, eventMediaScopeOf(subject)) : await this.deps.getMediaForOwner(owner.kind, owner.id);
21781
22148
  if (files.length === 0) continue;
21782
22149
  const preference = frame !== void 0 ? framePreference(frame, owner.kind) : policy === "best-matching" ? bestMatchingKindPreference(signal, owner.kind) : attachmentKindPreference(policy, owner.kind);
21783
22150
  const ordered = [];
@@ -24062,11 +24429,6 @@ var DEFAULT_MAX_ATTEMPTS = 8;
24062
24429
  var DEFAULT_DRAIN_BATCH = 20;
24063
24430
  /** Dedup ids remembered at boot — bounded to recent rows (dedup horizon). */
24064
24431
  var DEDUP_HYDRATE_WINDOW_MS = 168 * 36e5;
24065
- /** Stable dedup id — the durable per-track/per-record fire guarantee. */
24066
- function outboxEntryId(input) {
24067
- const dedupRef = input.dedupRef ?? input.trackId ?? input.recordId;
24068
- return `${input.ruleId}:${dedupRef}:${input.targetId}`;
24069
- }
24070
24432
  var NcOutbox = class {
24071
24433
  pending = /* @__PURE__ */ new Map();
24072
24434
  /** Every id ever enqueued within the dedup horizon (incl. sent/dead). */
@@ -24143,20 +24505,27 @@ var NcOutbox = class {
24143
24505
  }
24144
24506
  });
24145
24507
  let superseded = 0;
24508
+ let translated = 0;
24146
24509
  for (const row of rows) {
24147
- if (this.knownIds.has(row.id)) {
24510
+ const entry = rowToEntry$1(row.id, row.data);
24511
+ const current = entry === null ? null : this.translateLegacy(row.id, entry);
24512
+ if (this.knownIds.has(row.id) || current !== null && this.knownIds.has(current)) {
24148
24513
  superseded += 1;
24149
24514
  continue;
24150
24515
  }
24151
24516
  this.knownIds.add(row.id);
24152
- const entry = rowToEntry$1(row.id, row.data);
24517
+ if (current !== null) {
24518
+ this.knownIds.add(current);
24519
+ translated += 1;
24520
+ }
24153
24521
  if (entry !== null && entry.status === "pending") this.pending.set(entry.id, entry);
24154
24522
  }
24155
24523
  this.horizonLoaded = true;
24156
24524
  this.logger.info("notification outbox loaded", { meta: {
24157
24525
  pending: this.pending.size,
24158
24526
  known: this.knownIds.size,
24159
- superseded
24527
+ superseded,
24528
+ translated
24160
24529
  } });
24161
24530
  } catch (err) {
24162
24531
  this.logger.warn("notification outbox load failed", { meta: { error: String(err) } });
@@ -24180,7 +24549,7 @@ var NcOutbox = class {
24180
24549
  for (const input of inputs) {
24181
24550
  const id = outboxEntryId(input);
24182
24551
  if (this.knownIds.has(id)) continue;
24183
- if (!this.horizonLoaded && await this.rowExists(id)) {
24552
+ if (!this.horizonLoaded && await this.alreadyStored(id, input)) {
24184
24553
  this.knownIds.add(id);
24185
24554
  this.logger.info("outbox enqueue deduped against the store — the horizon is still cold", {
24186
24555
  tags: { deviceId: input.deviceId },
@@ -24397,7 +24766,7 @@ var NcOutbox = class {
24397
24766
  */
24398
24767
  async supersede(input) {
24399
24768
  const wanted = new Set(input.targetIds);
24400
- const victims = [...this.pending.values()].filter((e) => e.ruleId === input.ruleId && e.recordId === input.recordId && wanted.has(e.targetId));
24769
+ const victims = [...this.pending.values()].filter((e) => e.ruleId === input.ruleId && wanted.has(e.targetId) && hasRecordTimestamp(e.payload) && isSameOutboxRecord(outboxRecordIdentity(e), input));
24401
24770
  let dropped = 0;
24402
24771
  for (const entry of victims) try {
24403
24772
  await this.store.delete.mutate({
@@ -24513,6 +24882,81 @@ var NcOutbox = class {
24513
24882
  await this.mutate(retry);
24514
24883
  }
24515
24884
  /**
24885
+ * Cold-horizon dedup: is this intent already a row in the store?
24886
+ *
24887
+ * Today's id first. Then, for a rowid record, the pre-D694 spelling — which
24888
+ * is accepted ONLY when the row found speaks for the SAME record (camera and
24889
+ * record timestamp too). A legacy row of a different record that reused the
24890
+ * rowid is exactly the collision D694 removes, and must not swallow this one.
24891
+ */
24892
+ async alreadyStored(id, input) {
24893
+ if (await this.rowExists(id)) return true;
24894
+ const legacy = legacyOutboxEntryId(input);
24895
+ if (legacy === null) return false;
24896
+ const row = await this.readRow(legacy);
24897
+ if (row === null) return false;
24898
+ if (!hasRecordTimestamp(row.payload)) {
24899
+ this.logger.warn("outbox: a pre-D694 row carries no record timestamp — not used for dedup", {
24900
+ tags: { deviceId: row.deviceId },
24901
+ meta: {
24902
+ id,
24903
+ legacyId: legacy,
24904
+ ruleId: input.ruleId
24905
+ }
24906
+ });
24907
+ return false;
24908
+ }
24909
+ if (isSameOutboxRecord(outboxRecordIdentity(row), outboxRecordIdentity(input))) return true;
24910
+ this.logger.info("outbox: a pre-D694 row shares this rowid but is a different record — not a duplicate", {
24911
+ tags: { deviceId: input.deviceId },
24912
+ meta: {
24913
+ id,
24914
+ legacyId: legacy,
24915
+ legacyDeviceId: row.deviceId,
24916
+ ruleId: input.ruleId
24917
+ }
24918
+ });
24919
+ return false;
24920
+ }
24921
+ /**
24922
+ * The id a stored pre-D694 row would have today, or `null`.
24923
+ *
24924
+ * Guarded per row: a stored payload is validated only as an object
24925
+ * (`rowToEntry`), and a row with no `subject.timestamp` would otherwise throw
24926
+ * inside {@link load} and leave the WHOLE dedup horizon cold. Such a row is
24927
+ * still loaded under its stored id; it just gets no translated twin.
24928
+ */
24929
+ translateLegacy(storedId, entry) {
24930
+ if (legacyOutboxEntryId(entry) !== storedId) return null;
24931
+ if (!hasRecordTimestamp(entry.payload)) {
24932
+ this.logger.warn("outbox: a pre-D694 row carries no record timestamp — loaded without translation", {
24933
+ tags: { deviceId: entry.deviceId },
24934
+ meta: {
24935
+ id: storedId,
24936
+ ruleId: entry.ruleId
24937
+ }
24938
+ });
24939
+ return null;
24940
+ }
24941
+ return translatedLegacyEntryId(storedId, entry);
24942
+ }
24943
+ /** One stored row by id, or `null` (absent, unreadable, or a failed read). */
24944
+ async readRow(id) {
24945
+ try {
24946
+ const row = await this.store.get.query({
24947
+ collection: NC_OUTBOX_COLLECTION,
24948
+ key: id
24949
+ });
24950
+ return isStoredRow(row) ? rowToEntry$1(id, row) : null;
24951
+ } catch (err) {
24952
+ this.logger.debug("outbox cold-horizon dedup read failed", { meta: {
24953
+ id,
24954
+ error: String(err)
24955
+ } });
24956
+ return null;
24957
+ }
24958
+ }
24959
+ /**
24516
24960
  * Does a row already exist under this dedup id?
24517
24961
  *
24518
24962
  * Asked ONLY while {@link horizonLoaded} is false. {@link persist} writes
@@ -24606,6 +25050,10 @@ var OUTBOX_RECORD_KINDS = new Set([
24606
25050
  function isOutboxRecordKind(x) {
24607
25051
  return typeof x === "string" && OUTBOX_RECORD_KINDS.has(x);
24608
25052
  }
25053
+ /** `settingsStore.get` answers `unknown` — a guard, never a cast. */
25054
+ function isStoredRow(x) {
25055
+ return typeof x === "object" && x !== null && !Array.isArray(x);
25056
+ }
24609
25057
  /** A stored row back to an entry — a guard, never a cast. Exported for the
24610
25058
  * specs that read the store a centre wrote to. */
24611
25059
  function rowToEntry$1(id, data) {
@@ -31569,9 +32017,15 @@ var NC_ALARM_ANNOUNCE_RULE_ID = "nc-alarm-announce";
31569
32017
  * A rule's media policy, as the fields a row freezes — ONE derivation, used by
31570
32018
  * every row a rule addresses AND by the alarm's own announcement row when it is
31571
32019
  * the COMBINED siren (the triggering rule's pictures on the alarm's words).
32020
+ *
32021
+ * `effectiveMediaPolicyForRule` is what actually resolves it: a SENSOR rule's
32022
+ * stored `media` is ignored here, in favour of the one-snapshot default
32023
+ * (operator ruling, 2026-10-01) — the rule still LOADS with whatever it has
32024
+ * stored, and `buildEntries` is where the sampled log fires when that stored
32025
+ * choice is about to be overridden.
31572
32026
  */
31573
32027
  function mediaPolicyFields(mediaRule, cropZoneIds) {
31574
- const media = mediaRule.media;
32028
+ const media = effectiveMediaPolicyForRule(mediaRule);
31575
32029
  return {
31576
32030
  media: media.attach,
31577
32031
  ...media.zoneCrop === true && cropZoneIds !== void 0 ? { mediaZoneIds: [...cropZoneIds] } : {},
@@ -31865,13 +32319,14 @@ function cooldownSubjectKind(kind) {
31865
32319
  * the same builder the live path does, so the two can never address different
31866
32320
  * windows. Only the four fields the key is a function of are reconstructed —
31867
32321
  * the class (for `per-class` and audio), the subject kind (which decides
31868
- * whether audio is keyed per class at all), the system event (subject +
32322
+ * whether the class is keyed at all — audio always, occupancy and detections
32323
+ * unless `shared`), the system event (subject +
31869
32324
  * direction) and the device.
31870
32325
  */
31871
32326
  function cooldownSubjectFromOutbox(entry) {
31872
32327
  const subject = entry.payload.subject;
31873
32328
  return {
31874
- kind: cooldownSubjectKind(entry.recordKind),
32329
+ kind: entry.recordKind === "device-event" && subject.occupancy !== void 0 ? "occupancy-event" : cooldownSubjectKind(entry.recordKind),
31875
32330
  recordId: entry.recordId,
31876
32331
  deviceId: entry.deviceId,
31877
32332
  timestamp: subject.timestamp,
@@ -31950,6 +32405,9 @@ function renderAudioScope(devices, excluded) {
31950
32405
  if (devices.length === 0) return excluded.length === 0 ? "@all" : `@all-except:${excluded.join("+")}`;
31951
32406
  return devices.filter((id) => !excluded.includes(id)).join("+");
31952
32407
  }
32408
+ /** {@link NotificationCenter.reportedSensorMediaIgnored} cap, same shape as
32409
+ * the dispatcher's own annotated-fallback memo. */
32410
+ var NC_SENSOR_MEDIA_IGNORED_MEMO_MAX = 5e3;
31953
32411
  var NotificationCenter = class NotificationCenter {
31954
32412
  logger;
31955
32413
  rules;
@@ -31957,6 +32415,11 @@ var NotificationCenter = class NotificationCenter {
31957
32415
  dispatcher;
31958
32416
  deps;
31959
32417
  now;
32418
+ /** Rule ids whose stored (non-default) media policy has already been
32419
+ * reported as ignored at evaluation — ONE line per rule, not per fire. */
32420
+ reportedSensorMediaIgnored = /* @__PURE__ */ new Set();
32421
+ /** Samples the device-compat guard's "accepted unchecked" WARN per rule. */
32422
+ deviceCompatWarnSampler = new NcDeviceCompatWarnSampler();
31960
32423
  /**
31961
32424
  * Every word this centre can print, in the hub's language.
31962
32425
  *
@@ -32957,7 +33420,7 @@ var NotificationCenter = class NotificationCenter {
32957
33420
  ...deps.dispatcher,
32958
33421
  logger: this.logger.child("dispatch"),
32959
33422
  texts: this.texts,
32960
- getMediaForOwner: async (ownerKind, ownerId) => {
33423
+ getMediaForOwner: async (ownerKind, ownerId, eventScope) => {
32961
33424
  const held = isSyntheticId(ownerId) ? this.syntheticMedia.get(ownerId) : void 0;
32962
33425
  if (held !== void 0) return held.files;
32963
33426
  const shelved = isStillShelfId(ownerId) ? this.stills?.get(ownerId) : void 0;
@@ -32966,7 +33429,7 @@ var NotificationCenter = class NotificationCenter {
32966
33429
  if (linked !== void 0) return linked;
32967
33430
  const departed = ownerKind === "track" && this.departureFallbacks.has(ownerId);
32968
33431
  const owner = departed ? await this.resolveDepartedTrack(ownerId) : ownerId;
32969
- const files = await deps.dispatcher.getMediaForOwner(ownerKind, owner);
33432
+ const files = await deps.dispatcher.getMediaForOwner(ownerKind, owner, eventScope);
32970
33433
  if (files.length > 0 || !departed) return files;
32971
33434
  return this.departureFallbackMedia(ownerId) ?? files;
32972
33435
  },
@@ -33737,7 +34200,7 @@ var NotificationCenter = class NotificationCenter {
33737
34200
  const shown = this.unmutedCameras(cameraIds);
33738
34201
  const involved = [sensorId, ...rowShown];
33739
34202
  if (!this.rules.listEnabled("device-event").some((rule) => {
33740
- if (rule.media.attach === "none") return false;
34203
+ if (effectiveMediaPolicyForRule(rule).attach === "none") return false;
33741
34204
  if (!deviceEventRuleMayFireOnKind(rule, sensorKind, sensorRaised)) return false;
33742
34205
  const excluded = rule.conditions.devicesExclude;
33743
34206
  if (excluded !== void 0 && deviceScopeExcludes(excluded, {
@@ -34034,7 +34497,7 @@ var NotificationCenter = class NotificationCenter {
34034
34497
  ruleWantsOccupancyStill(edge) {
34035
34498
  const subject = occupancySubjectFromEdge(edge);
34036
34499
  for (const rule of this.rules.listEnabled("device-event")) {
34037
- if (rule.media.attach === "none") continue;
34500
+ if (effectiveMediaPolicyForRule(rule).attach === "none") continue;
34038
34501
  const occ = rule.conditions.occupancy;
34039
34502
  if (occ === void 0 || !matchesOccupancy(occ, subject)) continue;
34040
34503
  const devices = rule.conditions.devices;
@@ -34301,6 +34764,12 @@ var NotificationCenter = class NotificationCenter {
34301
34764
  logger: this.logger,
34302
34765
  by: caller.userId
34303
34766
  });
34767
+ await guardRuleDeviceCompat(rule, {
34768
+ logger: this.logger,
34769
+ by: caller.userId,
34770
+ listDeviceIdsByCaps: this.deps.listDeviceIdsByCaps,
34771
+ sampler: this.deviceCompatWarnSampler
34772
+ });
34304
34773
  guardRuleSystemEventDelivery(rule, {
34305
34774
  logger: this.logger,
34306
34775
  by: caller.userId
@@ -34338,6 +34807,13 @@ var NotificationCenter = class NotificationCenter {
34338
34807
  by: caller.userId,
34339
34808
  ruleId
34340
34809
  });
34810
+ await guardPatchDeviceCompat(existing, patch, {
34811
+ logger: this.logger,
34812
+ by: caller.userId,
34813
+ ruleId,
34814
+ listDeviceIdsByCaps: this.deps.listDeviceIdsByCaps,
34815
+ sampler: this.deviceCompatWarnSampler
34816
+ });
34341
34817
  guardPatchSystemEventDelivery(existing, patch, clear ?? [], {
34342
34818
  logger: this.logger,
34343
34819
  by: caller.userId,
@@ -35100,6 +35576,8 @@ var NotificationCenter = class NotificationCenter {
35100
35576
  const dropped = await this.outbox.supersede({
35101
35577
  ruleId: source.ruleId,
35102
35578
  recordId: source.recordId,
35579
+ deviceId: source.deviceId,
35580
+ recordAt: source.timestamp,
35103
35581
  targetIds: [...combined]
35104
35582
  });
35105
35583
  this.logger.info("alarm notification combined with the rule that triggered it", {
@@ -35385,7 +35863,7 @@ var NotificationCenter = class NotificationCenter {
35385
35863
  buttons: unresolvable.join(", ")
35386
35864
  }
35387
35865
  });
35388
- if (!noOwnerReported && kind === "device-event" && subject.trackId === void 0 && subject.linkedStills === void 0 && rule.media.attach !== "none") {
35866
+ if (!noOwnerReported && kind === "device-event" && subject.trackId === void 0 && subject.linkedStills === void 0 && effectiveMediaPolicyForRule(rule).attach !== "none") {
35389
35867
  noOwnerReported = true;
35390
35868
  const linked = subject.linkedCameraIds;
35391
35869
  const meta = {
@@ -35763,6 +36241,24 @@ var NotificationCenter = class NotificationCenter {
35763
36241
  }
35764
36242
  });
35765
36243
  }
36244
+ /**
36245
+ * ONE line per rule id, the first time a SENSOR rule's stored media policy
36246
+ * is about to be overridden by {@link effectiveMediaPolicyForRule}'s
36247
+ * one-snapshot default (operator ruling, 2026-10-01). Never refuses the
36248
+ * rule — it still loads and still fires — this only says the stored choice
36249
+ * is not the one being used.
36250
+ */
36251
+ warnSensorMediaIgnoredOnce(rule) {
36252
+ if (mediaPolicyMatchesSensorDefault(rule.media)) return;
36253
+ if (this.reportedSensorMediaIgnored.has(rule.id)) return;
36254
+ if (this.reportedSensorMediaIgnored.size >= NC_SENSOR_MEDIA_IGNORED_MEMO_MAX) this.reportedSensorMediaIgnored.clear();
36255
+ this.reportedSensorMediaIgnored.add(rule.id);
36256
+ this.logger.info("sensor rule carries a media choice — ignored at evaluation, one snapshot is always attached", { meta: {
36257
+ ruleId: rule.id,
36258
+ rule: rule.name,
36259
+ storedMedia: rule.media
36260
+ } });
36261
+ }
35766
36262
  buildEntries(rule, subject, kind, matchedOn, userTargets = [], origin = "pipeline", cooldown, upgrade, group) {
35767
36263
  const hasEventMedia = kind === "object-event" || kind === "package-event";
35768
36264
  const isTrackScoped = kind === "object-event" || kind === "track-end";
@@ -35770,6 +36266,7 @@ var NotificationCenter = class NotificationCenter {
35770
36266
  const alarmSource = subject.systemEvent?.alarmSource;
35771
36267
  const mediaRule = alarmSource !== void 0 ? this.rules.get(alarmSource.ruleId) ?? rule : rule;
35772
36268
  const noMedia = isSystemEvent && alarmSource === void 0;
36269
+ if (require_clip_model_registry.ruleKindOf(mediaRule) === "sensor") this.warnSensorMediaIgnoredOnce(mediaRule);
35773
36270
  const direct = new Set(rule.targets.map((t) => t.targetId));
35774
36271
  const renderedZones = notificationZones(rule, subject);
35775
36272
  const confirm = frozenConfirmPolicy(rule, isSystemEvent);
@@ -95822,6 +96319,30 @@ async function encodeClipQuery(encode, text, modelId, warn) {
95822
96319
  }
95823
96320
  }
95824
96321
  //#endregion
96322
+ //#region src/pipeline-analytics/nc-owner-media-reader.ts
96323
+ /** Keep only the rows written for THIS generation of the event id. */
96324
+ function ofGeneration(files, scope) {
96325
+ return files.filter((f) => f.timestamp === scope.recordAt);
96326
+ }
96327
+ function makeNcOwnerMediaReader(deps) {
96328
+ return async (ownerKind, ownerId, eventScope) => {
96329
+ if (ownerKind === "track") return deps.media.listByOwner("track", await deps.resolveTrackOwnerId(ownerId));
96330
+ if (eventScope === void 0) return deps.media.listByOwner("object", ownerId);
96331
+ const onCamera = await deps.media.listByOwner("object", ownerId, eventScope.deviceId);
96332
+ const kept = ofGeneration(onCamera, eventScope);
96333
+ if (kept.length < onCamera.length) deps.logger.warn("event media of another record on a reused event id — not used", {
96334
+ tags: { deviceId: eventScope.deviceId },
96335
+ meta: {
96336
+ ownerId,
96337
+ recordAt: eventScope.recordAt,
96338
+ refused: onCamera.length - kept.length,
96339
+ kept: kept.length
96340
+ }
96341
+ });
96342
+ return kept;
96343
+ };
96344
+ }
96345
+ //#endregion
95825
96346
  //#region src/pipeline-analytics/store/settings-raw-mirror.ts
95826
96347
  /** How long a mirrored entry is served before a refresh is started behind the
95827
96348
  * next reader. See the module doc for why this matches the per-kind caches'
@@ -98994,6 +99515,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_clip_m
98994
99515
  listCallerTargetIds: async (userId) => {
98995
99516
  return (await api.notificationOutput.listTargets.query({})).filter((t) => t.config["ownerUserId"] === userId).map((t) => t.id);
98996
99517
  },
99518
+ listDeviceIdsByCaps: (capNames) => api.deviceManager.listDeviceIdsByCaps.query({ capNames: [...capNames] }),
98997
99519
  dropRuleMosaics: async (ruleId) => {
98998
99520
  const mediaStore = this.mediaStore;
98999
99521
  if (mediaStore === null) return;
@@ -99910,6 +100432,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_clip_m
99910
100432
  addonId: device.addonId
99911
100433
  };
99912
100434
  },
100435
+ listDeviceIdsByCaps: (capNames) => api.deviceManager.listDeviceIdsByCaps.query({ capNames: [...capNames] }),
99913
100436
  isDeviceDeleted: async (deviceId) => await api.deviceManager.getDevice.query({ deviceId }) === null,
99914
100437
  readNodeDirectory: () => api.nodes.directory.query(),
99915
100438
  getSnapshot: (deviceId) => api.snapshot.getSnapshot.query({ deviceId }),
@@ -100144,7 +100667,11 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_clip_m
100144
100667
  }
100145
100668
  });
100146
100669
  },
100147
- getMediaForOwner: async (ownerKind, ownerId) => stores.mediaStore.listByOwner(ownerKind, await resolveMediaOwnerId(ownerKind, ownerId, (id) => this.trackAliasStore?.resolve(id) ?? Promise.resolve(null), (failure) => this.aliasResolveFailureLogger?.(failure), "getMediaForOwner")),
100670
+ getMediaForOwner: makeNcOwnerMediaReader({
100671
+ media: stores.mediaStore,
100672
+ resolveTrackOwnerId: (trackId) => resolveMediaOwnerId("track", trackId, (id) => this.trackAliasStore?.resolve(id) ?? Promise.resolve(null), (failure) => this.aliasResolveFailureLogger?.(failure), "getMediaForOwner"),
100673
+ logger: this.ctx.logger.child("nc-media")
100674
+ }),
100148
100675
  getSubjectTrail: async (deviceId, trackId) => {
100149
100676
  const dims = this.lastFrameDimsByDevice.get(deviceId);
100150
100677
  if (dims === void 0 || dims.w <= 0 || dims.h <= 0) return [];