@camstack/addon-post-analysis 1.2.152 → 1.2.154

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,4 @@
1
- import { $ as audioModeOf, A as NcSnoozeSchema, At as BaseAddon, B as SCENE_DEFAULT_UNCOVERED_POLICY, Bt as discriminatedUnion, C as NcConditionDescriptorSchema, Ct as sceneMonitorCapability, D as NcRuleTargetSchema, Dt as videoclipsCapability, E as NcRuleSchema, Et as vectorDimFromBase64, Ft as nodePin, G as TimelapseRulePatchSchema, Gt as record, H as SceneMonitorSchema, Ht as number, I as RECORDING_EXPORT_MAX_READ_BYTES, It as sleep, J as VISIT_MERGE_GAP_MS, Jt as EventCategory, K as TimelapseRuleSchema, Kt as string, L as RetrainStatusSchema, Lt as _enum, M as NcSystemEventKindSchema, Mt as createEvent, N as NcTaxonomySchema, Nt as hydrateSchema, O as NcScheduleSchema, Ot as zoneAnalyticsCapability, P as OpsLogEntrySchema, Pt as isDeviceScopedCap, Q as audioMetricsCapability, Rt as array, S as NC_TAXONOMY, T as NcRulePatchSchema, Tt as systemEventFilterApplies, U as TIMELAPSE_DENSE_FLOOR_SEC, Ut as object, V as SCENE_DIVERGED, Vt as literal, W as TimelapseRuleInputSchema, Wt as partialRecord, X as alarmPanelCapability, Y as addonWidgetsSourceCapability, Z as assertTimelapseCadences, _ as MACRO_LABELS, _t as pickClusterStepModels, a as COCO_TO_MACRO, b as NC_DEFAULT_SNOOZE_MINUTES, bt as readDeviceStateFrom, ct as faceGalleryCapability, d as DeclaredDevices, dt as isDetectionMacroClass, et as buildEventKindDescriptor, f as EVENT_KIND_BY_CAP, ft as isScheduleActive, g as LabelAttributionSchema, h as FailureCounters, ht as notificationRulesCapability, i as CLUSTER_MODEL_SCOPED_STEPS, it as deriveRecordingMode, j as NcSnoozeSuppressedSchema, jt as DeviceType, k as NcSnoozeInputSchema, kt as errMsg, l as DETECTION_MACRO_CLASSES, lt as failureContributionCapability, m as FULL_IMAGE_BBOX, mt as kebabToCamel, n as AnalyticsGroupRecordSchema, nt as customAction, o as DEFAULT_EVENT_COLOR, ot as encodeVectorBase64, p as EVENT_PAD_MS, pt as isSourceCap, q as TrackSourceSchema, qt as unknown, r as BaseDevice, rt as defineCustomActions, st as evaluateSensorEdge, t as AnalyticsGroupDetailSchema, tt as cosineSimilarity$1, v as NC_ALARM_SYSTEM_EVENT_KINDS, vt as pipelineAnalyticsCapability, w as NcRuleInputSchema, wt as subKindsOf, xt as readTimelapseGeneratedAt, y as NC_CONDITION_CATALOG, yt as plateGalleryCapability, z as SCENE_DEFAULT_ANCHOR_THRESHOLD, zt as boolean } from "../dist-DSg_jDMt.mjs";
1
+ import { $ as audioMetricsCapability, A as NcSnoozeInputSchema, At as errMsg, B as SCENE_DEFAULT_ANCHOR_THRESHOLD, Bt as boolean, C as NC_TAXONOMY, D as NcRuleSchema, Dt as vectorDimFromBase64, E as NcRulePatchSchema, Et as systemEventFilterApplies, F as OpsLogEntrySchema, Ft as isDeviceScopedCap, G as TimelapseRuleInputSchema, Gt as partialRecord, H as SCENE_DIVERGED, Ht as literal, It as nodePin, J as TrackSourceSchema, Jt as unknown, K as TimelapseRulePatchSchema, Kt as record, L as RECORDING_EXPORT_MAX_READ_BYTES, Lt as sleep, M as NcSnoozeSuppressedSchema, Mt as DeviceType, N as NcSystemEventKindSchema, Nt as createEvent, O as NcRuleTargetSchema, Ot as videoclipsCapability, P as NcTaxonomySchema, Pt as hydrateSchema, Q as assertTimelapseCadences, R as RetrainStatusSchema, Rt as _enum, St as readTimelapseGeneratedAt, T as NcRuleInputSchema, Tt as subKindsOf, U as SceneMonitorSchema, Ut as number, V as SCENE_DEFAULT_UNCOVERED_POLICY, Vt as discriminatedUnion, W as TIMELAPSE_DENSE_FLOOR_SEC, Wt as object, X as addonWidgetsSourceCapability, Y as VISIT_MERGE_GAP_MS, Yt as EventCategory, Z as alarmPanelCapability, _ as MACRO_LABELS, a as COCO_TO_MACRO, at as deriveRecordingMode, b as NC_CONDITION_CATALOG, bt as plateGalleryCapability, ct as evaluateSensorEdge, d as DeclaredDevices, et as audioModeOf, f as EVENT_KIND_BY_CAP, ft as isDetectionMacroClass, g as LabelAttributionSchema, gt as notificationRulesCapability, h as FailureCounters, ht as kebabToCamel, i as CLUSTER_MODEL_SCOPED_STEPS, it as defineCustomActions, j as NcSnoozeSchema, jt as BaseAddon, k as NcScheduleSchema, kt as zoneAnalyticsCapability, l as DETECTION_MACRO_CLASSES, lt as faceGalleryCapability, m as FULL_IMAGE_BBOX, mt as isSourceCap, n as AnalyticsGroupRecordSchema, nt as cosineSimilarity$1, o as DEFAULT_EVENT_COLOR, p as EVENT_PAD_MS, pt as isScheduleActive, q as TimelapseRuleSchema, qt as string, r as BaseDevice, rt as customAction, st as encodeVectorBase64, t as AnalyticsGroupDetailSchema, tt as buildEventKindDescriptor, ut as failureContributionCapability, v as MediaFileKindEnum, vt as pickClusterStepModels, w as NcConditionDescriptorSchema, wt as sceneMonitorCapability, x as NC_DEFAULT_SNOOZE_MINUTES, xt as readDeviceStateFrom, y as NC_ALARM_SYSTEM_EVENT_KINDS, yt as pipelineAnalyticsCapability, zt as array } from "../dist-D6iC--Gh.mjs";
2
2
  import { t as __exportAll } from "../embedding-encoder/index.mjs";
3
3
  import * as fs from "node:fs";
4
4
  import { promises } from "node:fs";
@@ -106,6 +106,7 @@ function actionTtlMs(subject) {
106
106
  case "snooze": return SNOOZE_ACTION_TTL_MS;
107
107
  case "alarm": return subject.action === "disarm" ? ALARM_DISARM_TTL_MS : DEFAULT_ACTION_TTL_MS;
108
108
  case "sequence": return DEFAULT_ACTION_TTL_MS;
109
+ case "addon-update": return DEFAULT_ACTION_TTL_MS;
109
110
  }
110
111
  }
111
112
  var NcActionTokenRegistry = class {
@@ -185,6 +186,7 @@ function describeGrant(grant) {
185
186
  case "sequence": return `${grant.ruleName} — ${grant.sequence}`;
186
187
  case "snooze": return grant.classes === void 0 || grant.classes.length === 0 ? `Silenced for ${grant.minutes} min` : `Silenced for ${grant.minutes} min — ${grant.classes.join(", ")}`;
187
188
  case "alarm": return grant.action === "disarm" ? "Alarm disarmed" : `Alarm armed${grant.mode !== void 0 ? ` (${grant.mode})` : ""}`;
189
+ case "addon-update": return `${grant.packages.length} addon update(s) on ${grant.nodeId}`;
188
190
  }
189
191
  }
190
192
  /** The audit line's `meta`, per family. A snooze that logged a `sequence` it
@@ -215,6 +217,11 @@ function grantMeta(grant) {
215
217
  mode: grant.mode ?? null,
216
218
  panelDeviceId: grant.panelDeviceId
217
219
  };
220
+ case "addon-update": return {
221
+ ...common,
222
+ nodeId: grant.nodeId,
223
+ packages: grant.packages
224
+ };
218
225
  }
219
226
  }
220
227
  /** What the human standing in front of the page is told. */
@@ -4724,6 +4731,40 @@ var SceneMonitorProvider = class {
4724
4731
  async listScenes(input) {
4725
4732
  return this.statusFor(input.deviceId);
4726
4733
  }
4734
+ /**
4735
+ * `listScenes` for a SET, in one round trip.
4736
+ *
4737
+ * `/scenes` renders every camera and re-reads them behind the push slice on a
4738
+ * 30 s safety net. Fanned out client-side that was one request per camera to
4739
+ * read `statusFor`, which is an in-memory merge this provider already holds —
4740
+ * all cost, no work. The work here is identical; only the transport
4741
+ * collapses.
4742
+ *
4743
+ * **Every requested id gets a row.** A camera `statusFor` cannot answer for
4744
+ * is `status: null`, which is NOT `monitors: []`: this feature's whole
4745
+ * failure mode is silence reading as "nothing configured", and a batch that
4746
+ * dropped the row would have made an unreachable camera invisible instead of
4747
+ * reported.
4748
+ */
4749
+ async listScenesBatch(input) {
4750
+ return input.deviceIds.map((deviceId) => {
4751
+ try {
4752
+ return {
4753
+ deviceId,
4754
+ status: this.statusFor(deviceId)
4755
+ };
4756
+ } catch (err) {
4757
+ this.deps.logger.warn("scene status could not be read", {
4758
+ tags: { deviceId },
4759
+ meta: { error: String(err) }
4760
+ });
4761
+ return {
4762
+ deviceId,
4763
+ status: null
4764
+ };
4765
+ }
4766
+ });
4767
+ }
4727
4768
  async getStatus(input) {
4728
4769
  return this.statusFor(input.deviceId);
4729
4770
  }
@@ -5406,6 +5447,67 @@ function buildAlarmButtons(input) {
5406
5447
  }];
5407
5448
  }
5408
5449
  //#endregion
5450
+ //#region src/notification-center/addon-update-apply.ts
5451
+ /**
5452
+ * The frozen packages that STILL have an update, in the order the row listed
5453
+ * them.
5454
+ *
5455
+ * Order matters: the notification's body listed them in that order, and an
5456
+ * operator watching addons restart should see them go in the order they read.
5457
+ */
5458
+ function pendingPackages(frozen, updatable) {
5459
+ const available = new Set(updatable);
5460
+ return frozen.filter((name) => available.has(name));
5461
+ }
5462
+ /**
5463
+ * `done` only when every package the button promised was applied and nothing
5464
+ * was skipped or failed — the ONE case where a single number is the whole
5465
+ * truth. Anything else states all three counts, because a sentence that hides
5466
+ * one of them is a sentence that reports work it did not do.
5467
+ */
5468
+ function addonUpdateSentence(report) {
5469
+ if (report.applied.length === 0 && report.failed.length === 0) return "uptodate";
5470
+ if (report.applied.length === report.requested && report.failed.length === 0) return "done";
5471
+ return "partial";
5472
+ }
5473
+ //#endregion
5474
+ //#region src/notification-center/addon-update-buttons.ts
5475
+ /** The id this button travels under — in the callback and in the audit line. */
5476
+ var ADDON_UPDATE_BUTTON_ID = "addon-update-apply";
5477
+ function addonUpdateButtonRefusal(subject, recipient) {
5478
+ if (subject.kind !== "addon-update-available") return "not-an-addon-update";
5479
+ if ((subject.packageNames ?? []).length === 0) return "no-packages";
5480
+ if (subject.nodeId === void 0) return "several-nodes";
5481
+ if (recipient.ownerUserId === void 0) return "unowned-target";
5482
+ return null;
5483
+ }
5484
+ /**
5485
+ * The button, or none.
5486
+ *
5487
+ * Returns an array rather than a nullable, so the caller composes it with the
5488
+ * other families by spreading — and so "which buttons survived" has one shape.
5489
+ */
5490
+ function buildAddonUpdateButtons(input) {
5491
+ const subject = input.subject;
5492
+ if (addonUpdateButtonRefusal(subject, input.recipient) !== null) return [];
5493
+ const packages = subject.packageNames ?? [];
5494
+ const nodeId = subject.nodeId ?? "";
5495
+ return [{
5496
+ id: ADDON_UPDATE_BUTTON_ID,
5497
+ label: input.label({
5498
+ count: packages.length,
5499
+ nodeId
5500
+ }),
5501
+ url: input.mintUrl({
5502
+ actionId: ADDON_UPDATE_BUTTON_ID,
5503
+ packages,
5504
+ nodeId
5505
+ }),
5506
+ destructive: true,
5507
+ mode: "background"
5508
+ }];
5509
+ }
5510
+ //#endregion
5409
5511
  //#region src/notification-center/audio-rule-matcher.ts
5410
5512
  /**
5411
5513
  * AudioWatcher — the Notification Center's matcher for audio rules
@@ -8471,6 +8573,22 @@ var en_default = {
8471
8573
  "button.alarm.arm.night": "Arm night",
8472
8574
  "button.alarm.arm.vacation": "Arm vacation",
8473
8575
  "button.alarm.arm.custom_bypass": "Arm custom",
8576
+ "button.addon-update.apply": {
8577
+ "one": "Update {{count}} addon on {{nodeId}}",
8578
+ "other": "Update {{count}} addons on {{nodeId}}"
8579
+ },
8580
+ "action.addon-update.title": "Addon update",
8581
+ "action.addon-update.started": {
8582
+ "one": "Updating {{count}} addon on {{nodeId}}…",
8583
+ "other": "Updating {{count}} addons on {{nodeId}}…"
8584
+ },
8585
+ "action.addon-update.done": {
8586
+ "one": "{{applied}} addon updated on {{nodeId}}",
8587
+ "other": "{{applied}} addons updated on {{nodeId}}"
8588
+ },
8589
+ "action.addon-update.partial": "{{nodeId}}: {{applied}} updated, {{skipped}} already current, {{failed}} failed",
8590
+ "action.addon-update.uptodate": "Already up to date on {{nodeId}} — nothing to do",
8591
+ "action.addon-update.unknown": "Could not read what needs updating on {{nodeId}} — nothing was changed. Open CamStack.",
8474
8592
  "summary.title": "Summary — {{rule}}",
8475
8593
  "summary.body.window": "{{from}}→{{to}}",
8476
8594
  "summary.body.events": {
@@ -8693,6 +8811,22 @@ var it_default = {
8693
8811
  "button.alarm.arm.night": "Arma notte",
8694
8812
  "button.alarm.arm.vacation": "Arma vacanza",
8695
8813
  "button.alarm.arm.custom_bypass": "Arma personalizzato",
8814
+ "button.addon-update.apply": {
8815
+ "one": "Aggiorna {{count}} addon su {{nodeId}}",
8816
+ "other": "Aggiorna {{count}} addon su {{nodeId}}"
8817
+ },
8818
+ "action.addon-update.title": "Aggiornamento addon",
8819
+ "action.addon-update.started": {
8820
+ "one": "Aggiornamento di {{count}} addon su {{nodeId}}…",
8821
+ "other": "Aggiornamento di {{count}} addon su {{nodeId}}…"
8822
+ },
8823
+ "action.addon-update.done": {
8824
+ "one": "{{applied}} addon aggiornato su {{nodeId}}",
8825
+ "other": "{{applied}} addon aggiornati su {{nodeId}}"
8826
+ },
8827
+ "action.addon-update.partial": "{{nodeId}}: {{applied}} aggiornati, {{skipped}} già aggiornati, {{failed}} falliti",
8828
+ "action.addon-update.uptodate": "Già aggiornato su {{nodeId}} — niente da fare",
8829
+ "action.addon-update.unknown": "Non è stato possibile leggere cosa aggiornare su {{nodeId}} — non è stato cambiato nulla. Apri CamStack.",
8696
8830
  "summary.title": "Riepilogo — {{rule}}",
8697
8831
  "summary.body.window": "{{from}}→{{to}}",
8698
8832
  "summary.body.events": {
@@ -9420,7 +9554,7 @@ var NcDispatcher = class {
9420
9554
  targetId: target.id,
9421
9555
  ...target.ownerUserId !== void 0 ? { ownerUserId: target.ownerUserId } : {},
9422
9556
  ...entry.payload.subject.className !== void 0 ? { className: entry.payload.subject.className } : {},
9423
- ...entry.payload.subject.systemEvent !== void 0 ? { systemEventKind: entry.payload.subject.systemEvent.kind } : {}
9557
+ ...entry.payload.subject.systemEvent !== void 0 ? { systemEvent: entry.payload.subject.systemEvent } : {}
9424
9558
  });
9425
9559
  } catch (err) {
9426
9560
  this.deps.logger.warn("notification buttons could not be minted — sending without them", {
@@ -20154,7 +20288,7 @@ var NotificationCenter = class NotificationCenter {
20154
20288
  return id;
20155
20289
  }
20156
20290
  });
20157
- const alarmKind = alarmButtonKind(input.systemEventKind);
20291
+ const alarmKind = alarmButtonKind(input.systemEvent?.kind);
20158
20292
  const panel = this.alarmPanel;
20159
20293
  const alarmButtons = alarmKind === null ? [] : buildAlarmButtons({
20160
20294
  kind: alarmKind,
@@ -20190,6 +20324,49 @@ var NotificationCenter = class NotificationCenter {
20190
20324
  return id;
20191
20325
  }
20192
20326
  });
20327
+ const systemEvent = input.systemEvent;
20328
+ const recipient = input.ownerUserId !== void 0 ? { ownerUserId: input.ownerUserId } : {};
20329
+ const updateRefusal = systemEvent === void 0 ? "not-an-addon-update" : addonUpdateButtonRefusal(systemEvent, recipient);
20330
+ const updateButtons = systemEvent === void 0 ? [] : buildAddonUpdateButtons({
20331
+ subject: systemEvent,
20332
+ recipient,
20333
+ label: ({ count, nodeId }) => this.texts.text({
20334
+ key: "button.addon-update.apply",
20335
+ vars: {
20336
+ count: String(count),
20337
+ nodeId
20338
+ },
20339
+ count
20340
+ }),
20341
+ mintUrl: ({ actionId, packages, nodeId }) => {
20342
+ const id = randomUUID();
20343
+ drafts.push({
20344
+ id,
20345
+ ttlMs: actionTtlMs({ kind: "addon-update" }),
20346
+ grant: (expiresAt) => ({
20347
+ kind: "addon-update",
20348
+ id,
20349
+ packages,
20350
+ nodeId,
20351
+ deviceId: input.deviceId,
20352
+ actionId,
20353
+ targetId: input.targetId,
20354
+ expiresAt
20355
+ })
20356
+ });
20357
+ return id;
20358
+ }
20359
+ });
20360
+ if (updateRefusal === "several-nodes" || updateRefusal === "unowned-target") this.logger.info("addon-update notification carries no apply button", {
20361
+ tags: { deviceId: input.deviceId },
20362
+ meta: {
20363
+ ruleId: input.ruleId,
20364
+ reason: updateRefusal,
20365
+ targetId: input.targetId,
20366
+ nodeIds: systemEvent?.nodeIds ?? [],
20367
+ packages: systemEvent?.packageNames ?? []
20368
+ }
20369
+ });
20193
20370
  if (alarmKind !== null && panel === null) this.logger.info("alarm notification carries no buttons — this node owns no panel", {
20194
20371
  tags: { deviceId: input.deviceId },
20195
20372
  meta: {
@@ -20200,7 +20377,8 @@ var NotificationCenter = class NotificationCenter {
20200
20377
  const out = [
20201
20378
  ...sequenceButtons,
20202
20379
  ...snoozeButtons,
20203
- ...alarmButtons
20380
+ ...alarmButtons,
20381
+ ...updateButtons
20204
20382
  ];
20205
20383
  if (out.length === 0) return [];
20206
20384
  const now = this.now();
@@ -20321,8 +20499,139 @@ var NotificationCenter = class NotificationCenter {
20321
20499
  case "snooze": return this.applyGrantedSnooze(grant);
20322
20500
  case "alarm": return this.applyGrantedAlarm(grant);
20323
20501
  case "sequence": return this.runGrantedSequence(grant);
20502
+ case "addon-update": return this.applyGrantedAddonUpdate(grant);
20324
20503
  }
20325
20504
  }
20505
+ /**
20506
+ * Apply the addon updates one notification was about.
20507
+ *
20508
+ * The whole shape of this method is the answer to "a button that reports
20509
+ * success for work it did not do":
20510
+ *
20511
+ * 1. **The cheap question first.** Ask the registry what is still out of
20512
+ * date on that node. A read that FAILS changes nothing and says so — it
20513
+ * is never read as "everything is current" and never as "apply the frozen
20514
+ * list anyway".
20515
+ * 2. **Nothing to do is an outcome, not a success.** A frozen list the
20516
+ * operator already applied (from the Addons page, from auto-update, or by
20517
+ * tapping the copy of this notification that went to their other phone)
20518
+ * produces one announcement and zero `updatePackage` calls.
20519
+ * 3. **The operator hears twice.** A background POST returns while the
20520
+ * updates run for minutes, so the tap is acknowledged BEFORE the first
20521
+ * apply and the verdict follows it. Tapping into silence is worse than no
20522
+ * button.
20523
+ *
20524
+ * The announcements go back to `grant.targetId` — the delivery this button
20525
+ * was minted for, which is the only addressee a tap can honestly name (a tap
20526
+ * identifies nobody; same reasoning as the snooze grant's frozen `userId`).
20527
+ *
20528
+ * Throws only when NOTHING was applied and something went wrong, so the
20529
+ * plane's line says the run failed rather than reporting a spent token as a
20530
+ * success.
20531
+ */
20532
+ async applyGrantedAddonUpdate(grant) {
20533
+ const apply = this.deps.applyAddonUpdate;
20534
+ const list = this.deps.listAddonUpdates;
20535
+ if (apply === void 0 || list === void 0) throw new Error("no addon updater is wired on this node");
20536
+ let updatable;
20537
+ try {
20538
+ updatable = await list(grant.nodeId);
20539
+ } catch (err) {
20540
+ this.logger.warn("addon update button changed nothing — could not read what needs updating", { meta: {
20541
+ nodeId: grant.nodeId,
20542
+ packages: grant.packages,
20543
+ error: String(err)
20544
+ } });
20545
+ await this.announceAddonUpdate(grant, "unknown", { nodeId: grant.nodeId });
20546
+ throw err instanceof Error ? err : new Error(String(err));
20547
+ }
20548
+ const pending = pendingPackages(grant.packages, updatable);
20549
+ const skipped = grant.packages.filter((name) => !pending.includes(name));
20550
+ if (pending.length === 0) {
20551
+ this.logger.info("addon update button applied nothing — every package is already current", { meta: {
20552
+ nodeId: grant.nodeId,
20553
+ packages: grant.packages
20554
+ } });
20555
+ await this.announceAddonUpdate(grant, "uptodate", { nodeId: grant.nodeId }, {
20556
+ requested: grant.packages.length,
20557
+ applied: [],
20558
+ skipped,
20559
+ failed: []
20560
+ });
20561
+ return;
20562
+ }
20563
+ await this.announceAddonUpdate(grant, "started", {
20564
+ count: String(pending.length),
20565
+ nodeId: grant.nodeId
20566
+ }, void 0, pending.length);
20567
+ const applied = [];
20568
+ const failed = [];
20569
+ for (const packageName of pending) try {
20570
+ const result = await apply({
20571
+ packageName,
20572
+ nodeId: grant.nodeId
20573
+ });
20574
+ if (result.ok) applied.push(packageName);
20575
+ else {
20576
+ failed.push(packageName);
20577
+ this.logger.warn("addon update from a notification button failed", { meta: {
20578
+ nodeId: grant.nodeId,
20579
+ packageName,
20580
+ error: result.error
20581
+ } });
20582
+ }
20583
+ } catch (err) {
20584
+ failed.push(packageName);
20585
+ this.logger.warn("addon update from a notification button threw", { meta: {
20586
+ nodeId: grant.nodeId,
20587
+ packageName,
20588
+ error: String(err)
20589
+ } });
20590
+ }
20591
+ const report = {
20592
+ requested: grant.packages.length,
20593
+ applied,
20594
+ skipped,
20595
+ failed
20596
+ };
20597
+ this.logger.info("addon updates applied from a notification button", { meta: {
20598
+ nodeId: grant.nodeId,
20599
+ requested: report.requested,
20600
+ applied: applied.length,
20601
+ skipped: skipped.length,
20602
+ failed
20603
+ } });
20604
+ await this.announceAddonUpdate(grant, addonUpdateSentence(report), {
20605
+ nodeId: grant.nodeId,
20606
+ applied: String(applied.length),
20607
+ skipped: String(skipped.length),
20608
+ failed: String(failed.length)
20609
+ }, report, applied.length);
20610
+ if (applied.length === 0 && failed.length > 0) throw new Error(`no package updated on ${grant.nodeId} (${failed.join(", ")})`);
20611
+ }
20612
+ /**
20613
+ * One sentence about an update tap, to the target the notification went to.
20614
+ *
20615
+ * Best-effort by construction — `deliverAnnouncement` never throws and never
20616
+ * retries — because an announcement that failed must not turn an update that
20617
+ * HAPPENED into a run the plane reports as failed.
20618
+ */
20619
+ async announceAddonUpdate(grant, sentence, vars, report, count) {
20620
+ const targetId = grant.targetId;
20621
+ if (targetId === void 0) return;
20622
+ const body = this.texts.text({
20623
+ key: `action.addon-update.${sentence}`,
20624
+ vars,
20625
+ ...count !== void 0 ? { count } : {}
20626
+ });
20627
+ const failedNames = report?.failed ?? [];
20628
+ await this.dispatcher.deliverAnnouncement({
20629
+ reason: `addon-update-${sentence}`,
20630
+ targetId,
20631
+ title: this.texts.text({ key: "action.addon-update.title" }),
20632
+ body: failedNames.length > 0 ? `${body}\n${failedNames.join(", ")}` : body
20633
+ });
20634
+ }
20326
20635
  async runGrantedSequence(grant) {
20327
20636
  if (this.actionRunner === null) throw new Error("no actuator is wired on this node");
20328
20637
  const rule = this.rules.get(grant.ruleId);
@@ -25672,6 +25981,67 @@ var MEDIA_INDEXES = [
25672
25981
  {
25673
25982
  name: "idx_media_location",
25674
25983
  columns: ["locationId"]
25984
+ }),
25985
+ (
25986
+ /**
25987
+ * The events-management footprint — `GROUP BY deviceId` and
25988
+ * `SUM(sizeBytes) WHERE deviceId = ?`. See {@link MediaStore.footprintByDevice}.
25989
+ *
25990
+ * **Why not `idx_media_device_ts`.** That index already leads with
25991
+ * `deviceId`, so it can find the range — but it does not carry `sizeBytes`,
25992
+ * so the sum has to fetch the table row behind every index entry it visits.
25993
+ * Over the whole collection that is one scattered read of the entire ~1.25 M
25994
+ * row table (`path` alone is ~80 characters per row) on the volume the
25995
+ * recorder is writing to. With `sizeBytes` in the index the planner never
25996
+ * touches the table: `SCAN … USING COVERING INDEX idx_media_device_size`.
25997
+ *
25998
+ * It has to be a NEW index rather than a third column on the existing one.
25999
+ * `declareCollection` emits `CREATE INDEX IF NOT EXISTS` keyed on the index
26000
+ * NAME, so widening `idx_media_device_ts` in place would be a silent no-op on
26001
+ * every hub that already has it — the declaration would read as done and the
26002
+ * query would still hit the table.
26003
+ *
26004
+ * **What it costs.** ~19 bytes of index per row (two integers plus the
26005
+ * rowid), so ~24 MB at 1.25 M rows, plus one more b-tree maintained by every
26006
+ * insert into a table taking ~92 k rows/day. At ~1 insert/s that maintenance
26007
+ * is noise beside the JPEG write the same call performs. The cost that is not
26008
+ * noise is the one-off: the first `declareCollection` after this ships builds
26009
+ * the index over every existing row. Do not deploy it beside a running drain
26010
+ * or seal — the same caveat `idx_media_location` carries, for the same
26011
+ * reason.
26012
+ */
26013
+ {
26014
+ name: "idx_media_device_size",
26015
+ columns: ["deviceId", "sizeBytes"]
26016
+ }),
26017
+ (
26018
+ /**
26019
+ * The per-KIND footprint — `COUNT(*)`/`SUM(sizeBytes) WHERE kind = ?`, and
26020
+ * the same scoped to one camera. See {@link MediaStore.footprintByKind}.
26021
+ *
26022
+ * `kind` leads no existing index, so without this each of the thirteen
26023
+ * per-kind questions is a full table scan and the breakdown costs thirteen of
26024
+ * them. With it, each kind is a contiguous covering range and the thirteen
26025
+ * together scan the index once — the cost of the whole breakdown is one pass
26026
+ * over ~36 MB of index instead of thirteen passes over the table.
26027
+ *
26028
+ * `deviceId` sits between `kind` and `sizeBytes` so the per-camera variant
26029
+ * (`WHERE kind = ? AND deviceId = ?`) stays covering too, for ~3 bytes a row
26030
+ * more than `(kind, sizeBytes)` would cost. Without it that query finds its
26031
+ * range from the index and then reads the table for every row in it.
26032
+ *
26033
+ * ~29 bytes of index per row, so ~36 MB at 1.25 M rows, and one more b-tree
26034
+ * on every insert. Same one-off caveat as the two indexes above: the first
26035
+ * declare builds it over every existing row, so do not ship it beside a
26036
+ * running drain or seal.
26037
+ */
26038
+ {
26039
+ name: "idx_media_kind_device_size",
26040
+ columns: [
26041
+ "kind",
26042
+ "deviceId",
26043
+ "sizeBytes"
26044
+ ]
25675
26045
  })
25676
26046
  ];
25677
26047
  /** The storage location of a media row/record: its stamped `locationId`, or
@@ -26326,47 +26696,136 @@ var MediaStore = class {
26326
26696
  };
26327
26697
  }
26328
26698
  /**
26329
- * On-demand footprint aggregation for the events-management UI: sum
26330
- * `sizeBytes` and count rows per device across the whole media collection.
26331
- * Pages by `id` (offset-based) so a large collection never loads at once.
26332
- * Metadata rows are small (no blob) — the image bytes live on the storage
26333
- * provider and are counted via the persisted `sizeBytes` column.
26699
+ * On-demand footprint aggregation for the events-management UI: rows and
26700
+ * summed `sizeBytes` per camera, across the whole media collection.
26701
+ *
26702
+ * ## Why this is a histogram plus one aggregate per camera
26703
+ *
26704
+ * It used to page the ENTIRE collection — `ORDER BY id LIMIT 1000 OFFSET n`,
26705
+ * no predicate — and add `sizeBytes` up in JS. SQLite honours an OFFSET by
26706
+ * stepping over `n` entries, so the walk is quadratic in the table: at the
26707
+ * live hub's 1.25 M rows that is ~1 250 round trips and ~780 M index steps,
26708
+ * with every row serialised across an RPC boundary and rebuilt in V8 to be
26709
+ * added to a number. It timed out at 60 s on every call it ever made, and it
26710
+ * is the only thing that can size the surplus-media deletion it gates.
26711
+ *
26712
+ * The answer is at most a few dozen `(deviceId, rows, bytes)` triples.
26713
+ *
26714
+ * - The DEVICE SET comes from a histogram on `deviceId` with `bucketSize: 1`
26715
+ * — `GROUP BY deviceId`, served from `idx_media_device_size` as a covering
26716
+ * scan, the same trick `TrackStore.listDeviceIds` already uses (D56).
26717
+ * `bucket` IS the deviceId: the column is INTEGER and the bucket
26718
+ * expression is `CAST((deviceId - 0) / 1 AS INTEGER)`.
26719
+ * - The NUMBERS come from one `aggregate` per camera — `COUNT(*)` and
26720
+ * `SUM(sizeBytes)` in a single statement over an indexed `deviceId` range.
26721
+ *
26722
+ * Both figures for a camera come from the SAME statement deliberately. Taking
26723
+ * `rows` from the histogram and `bytes` from the aggregate would let a write
26724
+ * landing between the two produce a footprint whose row count and byte count
26725
+ * describe different populations — a disagreement an operator sizing a
26726
+ * deletion has no way to detect.
26727
+ *
26728
+ * A read that fails THROWS. It must never degrade to a zero: "this camera
26729
+ * holds nothing" is precisely the claim a failed measurement must not be
26730
+ * allowed to make to something about to delete (D295, D312).
26334
26731
  */
26335
26732
  async footprintByDevice() {
26336
- const PAGE = 1e3;
26733
+ const buckets = await this.store.histogram.query({
26734
+ collection: MEDIA_COLLECTION,
26735
+ field: "deviceId",
26736
+ bucketSize: 1,
26737
+ origin: 0
26738
+ });
26337
26739
  const out = /* @__PURE__ */ new Map();
26338
- let offset = 0;
26339
- for (;;) {
26340
- const rows = await this.store.query.query({
26740
+ for (const bucket of buckets) {
26741
+ if (bucket.count === 0 || !Number.isFinite(bucket.bucket)) continue;
26742
+ const deviceId = bucket.bucket;
26743
+ const agg = await this.store.aggregate.query({
26341
26744
  collection: MEDIA_COLLECTION,
26342
- filter: {
26343
- orderBy: {
26344
- field: "id",
26345
- direction: "asc"
26346
- },
26347
- limit: PAGE,
26348
- offset
26349
- }
26745
+ fields: [{
26746
+ as: "bytes",
26747
+ field: "sizeBytes",
26748
+ op: "sum"
26749
+ }],
26750
+ filter: { where: { deviceId } }
26751
+ });
26752
+ if (agg.count === 0) continue;
26753
+ const bytes = agg.values["bytes"];
26754
+ out.set(deviceId, {
26755
+ bytes: bytes ?? 0,
26756
+ rows: agg.count
26350
26757
  });
26351
- if (rows.length === 0) break;
26352
- for (const row of rows) {
26353
- const data = row.data;
26354
- const deviceId = Number(data["deviceId"]);
26355
- const sizeBytes = Number(data["sizeBytes"]);
26356
- if (!Number.isFinite(deviceId)) continue;
26357
- const acc = out.get(deviceId) ?? {
26358
- bytes: 0,
26359
- rows: 0
26360
- };
26361
- acc.bytes += Number.isFinite(sizeBytes) ? sizeBytes : 0;
26362
- acc.rows += 1;
26363
- out.set(deviceId, acc);
26364
- }
26365
- if (rows.length < PAGE) break;
26366
- offset += PAGE;
26367
26758
  }
26368
26759
  return out;
26369
26760
  }
26761
+ /**
26762
+ * The media footprint broken down by KIND — fleet-wide, or for one camera.
26763
+ *
26764
+ * `footprintByDevice` answers "how much is where"; this answers "how much is
26765
+ * WHAT", which is the axis a deletion decision turns on. The deletable set
26766
+ * (the periodic `snapshot` filmstrip, the surplus per-edge motion stills) and
26767
+ * the keep set (`firstFrame`, rolling `lastFrame`, `thumbnail` /
26768
+ * `thumbnailSmall`, `keyFrame` / `keyFrameSmall`, the face and plate buffers,
26769
+ * gallery media, the CLIP `crop`) are told apart by `kind` and by nothing
26770
+ * else. A byte total cannot size a deletion; this can.
26771
+ *
26772
+ * One `aggregate` per kind, each an indexed covering range on
26773
+ * `idx_media_kind_device_size`, plus ONE unfiltered aggregate for the totals.
26774
+ *
26775
+ * That last statement is the point of the design and not a spare round trip.
26776
+ * `MediaFileKindEnum.options` is the closed set the writers use, but the
26777
+ * TABLE is not closed: a row written by a retired path, or by a version that
26778
+ * knew a kind this one does not, matches no per-kind query. Adding the kinds
26779
+ * up to produce the total would make such a row invisible and the denominator
26780
+ * quietly too small — the failure mode that matters most here, because the
26781
+ * caller is about to delete against it. So the total is measured
26782
+ * independently and the difference is REPORTED as `unaccounted*` rather than
26783
+ * being absorbed.
26784
+ */
26785
+ async footprintByKind(deviceId) {
26786
+ const scope = deviceId === void 0 ? void 0 : { where: { deviceId } };
26787
+ const fields = [{
26788
+ as: "bytes",
26789
+ field: "sizeBytes",
26790
+ op: "sum"
26791
+ }];
26792
+ const totals = await this.store.aggregate.query({
26793
+ collection: MEDIA_COLLECTION,
26794
+ fields: [...fields],
26795
+ ...scope === void 0 ? {} : { filter: scope }
26796
+ });
26797
+ const kinds = [];
26798
+ let countedRows = 0;
26799
+ let countedBytes = 0;
26800
+ for (const kind of MediaFileKindEnum.options) {
26801
+ const agg = await this.store.aggregate.query({
26802
+ collection: MEDIA_COLLECTION,
26803
+ fields: [...fields],
26804
+ filter: { where: {
26805
+ kind,
26806
+ ...deviceId === void 0 ? {} : { deviceId }
26807
+ } }
26808
+ });
26809
+ if (agg.count === 0) continue;
26810
+ const bytes = agg.values["bytes"] ?? 0;
26811
+ countedRows += agg.count;
26812
+ countedBytes += bytes;
26813
+ kinds.push({
26814
+ kind,
26815
+ rows: agg.count,
26816
+ bytes
26817
+ });
26818
+ }
26819
+ kinds.sort((a, b) => b.bytes - a.bytes);
26820
+ const totalBytes = totals.values["bytes"] ?? 0;
26821
+ return {
26822
+ totalRows: totals.count,
26823
+ totalBytes,
26824
+ kinds,
26825
+ unaccountedRows: totals.count - countedRows,
26826
+ unaccountedBytes: totalBytes - countedBytes
26827
+ };
26828
+ }
26370
26829
  /** Move a single media entry from one owner to another.
26371
26830
  * The new copy is written before the old one is removed.
26372
26831
  * The old-entry deletion is best-effort: on failure a warning is logged
@@ -28887,30 +29346,57 @@ var LEGACY_FACE_MODEL_ID = "arcface-r100";
28887
29346
  */
28888
29347
  var FACES_COLLECTION = "pipeline-analytics:faces";
28889
29348
  /**
28890
- * Rows per page for the two whole-collection sweeps below.
28891
- *
28892
- * A face row is one of the two WIDEST row shapes in the hub's database: it
28893
- * carries a JSON `embedding` on every single row, measured at **10.8 KB
28894
- * average** where a motion event is 78 bytes. Both sweeps used to read the
28895
- * collection in ONE statement with no `limit`.
29349
+ * Rows per page for `pruneCapOverflow`, the ONE remaining walk of this
29350
+ * collection.
28896
29351
  *
28897
- * To be exact about what that did and did not cause: this table holds **372
28898
- * rows / 4.0 MB** on the live hub, so it was *not* the read that killed
28899
- * hub-main on 2026-08-07 (that was the multi-camera track feed — see
28900
- * `RECENT_FANOUT_ROW_BUDGET` in `track-store.ts`). It is a full scan whose cost
28901
- * grows with a table nothing else bounds, which is the same defect one incident
28902
- * earlier. `pruneCapOverflow` is what keeps the row count small — and it was
28903
- * itself one of the two unbounded readers, so the bound and the thing enforcing
28904
- * it were the same loop.
29352
+ * It is a walk that DOES WORK: the capacity bound has to consider every row to
29353
+ * decide which ones leave, and it runs on the maintenance pass, not on a
29354
+ * request. That is the shape D320 permits. What it must not do is drag the
29355
+ * whole row across for four scalars — see the projection there.
28905
29356
  *
28906
- * The engine now refuses an unbounded read (`query-bounds.ts`), so paging here
28907
- * is not belt-and-braces: without it these two methods would silently see only
28908
- * the newest N rows and the buffer would grow forever.
29357
+ * The two READS that used to page here do not any more. They asked a question
29358
+ * ("the newest N faces that are still unassigned"), the question is a
29359
+ * predicate, and a predicate answers it in one statement whose cost is the
29360
+ * ANSWER rather than the table.
28909
29361
  *
28910
- * 1 000 keeps peak transfer around 11 MB while staying under the engine's own
28911
- * cap, so the page is always the caller's bound and never the engine's.
29362
+ * The engine refuses an unbounded read (`query-bounds.ts`) and truncates a
29363
+ * limitless one at `SETTINGS_QUERY_DEFAULT_ROW_CAP` with a WARN nobody can act
29364
+ * on, so every read below carries an explicit `limit`.
28912
29365
  */
28913
29366
  var FACE_SWEEP_PAGE = 1e3;
29367
+ /**
29368
+ * The widest window a single gallery read may materialise before it REFUSES.
29369
+ *
29370
+ * It bounds only the reads whose size the CALLER did not name: an unbounded
29371
+ * `listAllRecentFaces`, and the `suggestionConfidence` order, which cannot stop
29372
+ * early because the most certain suggestion may be the oldest row (`face-sort.ts`).
29373
+ * A caller's own `limit` is honoured verbatim at any table size — asking for
29374
+ * 500 and getting 500 is being served, not being truncated.
29375
+ *
29376
+ * 10 000 because that is above what `pruneCapOverflow` actually permits to
29377
+ * exist: 29 cameras × (50 ordinary + 200 suggested) = 7 250 unassigned rows,
29378
+ * plus whatever the operator has enrolled. It sits under the engine's
29379
+ * `SETTINGS_QUERY_HARD_ROW_CAP` (20 000) so the engine never truncates first —
29380
+ * this bound is the one that speaks, and it speaks by throwing.
29381
+ *
29382
+ * The alternative was to answer with the rows it managed to read. A short list
29383
+ * and a complete one are the same array, and a gallery that quietly stops
29384
+ * showing a face is exactly the failure this whole change exists to avoid.
29385
+ */
29386
+ var FACE_WINDOW_MAX_ROWS = 1e4;
29387
+ /**
29388
+ * A gallery read whose window is wider than {@link FACE_WINDOW_MAX_ROWS}.
29389
+ *
29390
+ * Distinct type so a router can map it to a 400 and an operator can be told
29391
+ * *"the buffer is too large to rank in one pass — narrow the window"* instead
29392
+ * of being shown a page that silently lost its tail.
29393
+ */
29394
+ var FaceWindowTooWideError = class extends Error {
29395
+ constructor(rows) {
29396
+ super(`face window exceeds ${String(FACE_WINDOW_MAX_ROWS)} rows (read ${String(rows)}) — refusing to answer with the part that fit; narrow with deviceIds / since / until, or pass an explicit limit`);
29397
+ this.name = "FaceWindowTooWideError";
29398
+ }
29399
+ };
28914
29400
  /** How many evicted face ids one per-camera eviction line names before it
28915
29401
  * stops — enough to find the rows, not enough to turn a log line into a page. */
28916
29402
  var SUGGESTED_EVICTION_IDS_LOGGED = 10;
@@ -29012,13 +29498,128 @@ var FACE_COLUMNS = [
29012
29498
  type: "REAL"
29013
29499
  }
29014
29500
  ];
29015
- var FACE_INDEXES = [{
29016
- name: "idx_faces_device_ts",
29017
- columns: ["deviceId", "timestamp"]
29018
- }, {
29019
- name: "idx_faces_track",
29020
- columns: ["trackId"]
29021
- }];
29501
+ /**
29502
+ * What every gallery query orders by, and what the three of them filter on.
29503
+ *
29504
+ * Before these existed, EVERY read of this collection was
29505
+ * `SCAN … | USE TEMP B-TREE FOR ORDER BY` — a full table scan plus a sort of
29506
+ * the whole table, to hand back the newest 500 rows. Measured with
29507
+ * `EXPLAIN QUERY PLAN` over a 20 000-row copy of this exact schema:
29508
+ *
29509
+ * | query | before | after |
29510
+ * | --- | --- | --- |
29511
+ * | newest N | `SCAN + TEMP B-TREE` | `SCAN USING idx_faces_ts` |
29512
+ * | newest N unassigned | `SCAN + TEMP B-TREE` | `SEARCH USING idx_faces_assigned_ts (assigned=?)` |
29513
+ * | newest N recognized | `SCAN + TEMP B-TREE` | `SEARCH USING idx_faces_assigned_ts (assigned=?)` |
29514
+ * | faces of one identity | `SCAN + TEMP B-TREE` | `SEARCH USING idx_faces_identity_ts (recognizedIdentityId=?)` |
29515
+ *
29516
+ * The sort disappearing is the point: with an ordered index the statement stops
29517
+ * at the `LIMIT`, so the cost is the ANSWER. Without it the cost is the table,
29518
+ * and this table is bounded only by `pruneCapOverflow` — it went from 372 rows
29519
+ * (2026-08-25) to 1 612 (2026-08-31) while a UI polled it every 30 seconds.
29520
+ *
29521
+ * `idx_faces_ts` is NOT redundant beside `idx_faces_assigned_ts`: a composite
29522
+ * leading on `assigned` cannot serve an unfiltered `ORDER BY timestamp`.
29523
+ * Three b-trees over a few thousand narrow rows is tens of KB, against one
29524
+ * UPSERT per track that produces a face — the write rate this pays at.
29525
+ */
29526
+ var FACE_INDEXES = [
29527
+ {
29528
+ name: "idx_faces_device_ts",
29529
+ columns: ["deviceId", "timestamp"]
29530
+ },
29531
+ {
29532
+ name: "idx_faces_track",
29533
+ columns: ["trackId"]
29534
+ },
29535
+ {
29536
+ name: "idx_faces_ts",
29537
+ columns: ["timestamp"]
29538
+ },
29539
+ {
29540
+ name: "idx_faces_assigned_ts",
29541
+ columns: ["assigned", "timestamp"]
29542
+ },
29543
+ {
29544
+ name: "idx_faces_identity_ts",
29545
+ columns: ["recognizedIdentityId", "timestamp"]
29546
+ }
29547
+ ];
29548
+ /**
29549
+ * The columns a {@link Face} is built from — every declared column EXCEPT the
29550
+ * drained legacy `embedding`, derived mechanically so a column added tomorrow
29551
+ * arrives without anyone remembering this list.
29552
+ *
29553
+ * Mechanical on purpose. A hand-written projection is how a field silently
29554
+ * stops arriving: the row comes back missing the key, `rowToFace` reads
29555
+ * `undefined`, and "the database says nothing" is indistinguishable from "I did
29556
+ * not ask". The engine REFUSES a column it cannot serve
29557
+ * (`resolveProjection`) — the projection is the one part of a query that is not
29558
+ * allowed to be forgiving — so a stale name here is a throw, not a wrong value.
29559
+ *
29560
+ * `embedding` is the only exclusion, and it is the whole reason to project:
29561
+ * it is a `notNull` JSON column on the widest row shape in the database, it is
29562
+ * dropped by `rowToFace` on arrival, and {@link Face} has no field for it. A
29563
+ * caller that wants floats asks by name ({@link FaceStore.getWithEmbedding},
29564
+ * {@link FaceStore.loadEmbeddings}). Since D304 drained it to `[]` the saving
29565
+ * is small — 23 B/row, measured on the live hub — but the column is still
29566
+ * declared, still `notNull`, and an unconverted row still carries ~10.8 KB of
29567
+ * it. Projecting is what keeps that a fact about ONE row rather than about
29568
+ * every gallery poll.
29569
+ */
29570
+ var FACE_LIST_COLUMNS = FACE_COLUMNS.map((c) => c.name).filter((name) => name !== "embedding" && name !== "id");
29571
+ /**
29572
+ * The four scalars `pruneCapOverflow` decides on.
29573
+ *
29574
+ * Its docblock has always said *"only four scalars per row survive the page"*.
29575
+ * They survived the PAGE; the query still dragged every column — the embedding
29576
+ * included — across the RPC boundary and through V8 first. A leftover that
29577
+ * describes the right design reads as verification, so the projection now makes
29578
+ * the sentence true.
29579
+ */
29580
+ var FACE_CAP_COLUMNS = [
29581
+ "deviceId",
29582
+ "timestamp",
29583
+ "assigned",
29584
+ "suggestedIdentityId"
29585
+ ];
29586
+ /** One camera reference per identity — see {@link FaceStore.listCameraRefsForIdentity}. */
29587
+ var FACE_REF_COLUMNS = ["deviceId"];
29588
+ /**
29589
+ * A gallery view as a STORE PREDICATE.
29590
+ *
29591
+ * Every one of the four is expressible, and that is the whole finding behind
29592
+ * this change. They were applied in JS, over pages the store had already
29593
+ * chosen, which is why `limit` counted rows that PASSED and the read had to
29594
+ * sweep until enough had: with `filter: 'unassigned'` the newest N rows may be
29595
+ * entirely assigned while matches wait just beyond them. Pushed down, `limit`
29596
+ * IS the store's limit, and the sweep has nothing left to do.
29597
+ *
29598
+ * **Two of them name null, and null is the trap.** `recognizedIdentityId` is a
29599
+ * nullable TEXT column, so "has an identity" is `IS NOT NULL` — which
29600
+ * `whereNot: { field: null }` compiles to since D312, and which before D312
29601
+ * compiled to `(expr IS NULL OR expr != NULL)`: TRUE for a NULL row and NULL
29602
+ * for every other, i.e. precisely the rows it was asked to exclude. A predicate
29603
+ * that silently matched nothing would report "no recognized faces" forever and
29604
+ * every assertion about the rows returned would agree with it. Verified against
29605
+ * the live hub before shipping: 1 612 rows, `where {assigned:false}` → 1 444,
29606
+ * `whereNot {recognizedIdentityId:null}` → 247.
29607
+ *
29608
+ * `assigned` is `BOOLEAN NOT NULL`; the backend serialises `false` to `0` at
29609
+ * the bind (`serializeColumnValue`) and decodes `0` back to `false` on read, so
29610
+ * plain equality is correct and no null semantics are involved.
29611
+ */
29612
+ function faceFilterPredicate(filter) {
29613
+ switch (filter) {
29614
+ case "unassigned": return { where: { assigned: false } };
29615
+ case "recognized": return {
29616
+ where: { assigned: false },
29617
+ whereNot: { recognizedIdentityId: null }
29618
+ };
29619
+ case "identified": return { whereNot: { recognizedIdentityId: null } };
29620
+ case "all": return {};
29621
+ }
29622
+ }
29022
29623
  /**
29023
29624
  * `since` / `until` as a store predicate.
29024
29625
  *
@@ -29053,6 +29654,7 @@ function rowToFace(id, raw) {
29053
29654
  return {
29054
29655
  ...rest,
29055
29656
  id,
29657
+ embeddingModelId: data.embeddingModelId ?? void 0,
29056
29658
  assigned: Boolean(data.assigned),
29057
29659
  mediaKey: data.mediaKey ?? void 0,
29058
29660
  recognizedIdentityId: data.recognizedIdentityId ?? void 0,
@@ -29281,16 +29883,21 @@ var FaceStore = class {
29281
29883
  */
29282
29884
  async pruneCapOverflow(input) {
29283
29885
  /**
29284
- * Paged, not one statement. Only four scalars per row survive the page —
29285
- * the `embedding` string that dominates the row is dropped as soon as the
29286
- * page goes out of scope, so peak heap is one page rather than the whole
29287
- * face table. The prune still considers EVERY row, which is what makes it a
29288
- * capacity bound rather than a bound on the newest page.
29886
+ * Paged, not one statement, and PROJECTED to the four scalars the decision
29887
+ * needs — this sentence used to be true only of what survived the page,
29888
+ * while the query still dragged every column, `embedding` included, across
29889
+ * the RPC boundary and through V8 first.
29890
+ *
29891
+ * This is the one walk of the collection that remains, and D320 permits it:
29892
+ * it must touch every row to ACT on it, and it runs on the maintenance
29893
+ * pass, not on a request. The prune still considers EVERY row, which is
29894
+ * what makes it a capacity bound rather than a bound on the newest page.
29289
29895
  */
29290
29896
  const byDevice = /* @__PURE__ */ new Map();
29291
29897
  for (let page = 0; page < FACE_SWEEP_MAX_PAGES; page++) {
29292
29898
  const rows = await this.store.query.query({
29293
29899
  collection: FACES_COLLECTION,
29900
+ columns: FACE_CAP_COLUMNS,
29294
29901
  filter: {
29295
29902
  orderBy: {
29296
29903
  field: "timestamp",
@@ -29584,29 +30191,38 @@ var FaceStore = class {
29584
30191
  * - 'recognized' → !assigned && recognizedIdentityId != null
29585
30192
  * - 'identified' → recognizedIdentityId != null, including manual assignments
29586
30193
  *
29587
- * `deviceIds`, `since` and `until` are pushed INTO the query — they narrow
29588
- * what is read, not what is returned. `filter` and the ordering cannot be
29589
- * (the first is a computed predicate, the second is a contract this backend
29590
- * does not implement — see `face-sort.ts`), so they are applied here.
29591
- *
29592
- * **`limit` counts rows that PASSED the filter, never rows that were
29593
- * discarded.** That is the whole reason this pages instead of asking the
29594
- * store for `limit` rows: with `filter:'unassigned'` the newest N rows may be
29595
- * entirely assigned while matches wait just beyond them.
29596
- *
29597
- * The previous answer was to fetch the WHOLE collection and slice after
29598
- * filtering. That is the read that killed hub-main (`query-bounds.ts`) —
29599
- * every face row carries a JSON `embedding`, so "all rows" is the widest
29600
- * table in the database materialised at once, to render a gallery of at most
29601
- * a few hundred tiles.
29602
- *
29603
- * So: PAGE. Under the default `timestamp` order the store's paging already
29604
- * emits rows in the answer's order, so the sweep stops the moment enough
29605
- * MATCHES exist and the common case costs one page. Under
29606
- * `suggestionConfidence` it cannot — the most certain suggestion may be the
29607
- * oldest row — so the sweep walks the window and folds each page through
29608
- * `rankAndCap`, which holds the RETAINED set to `limit` even though the read
29609
- * is wider. Narrow the read with `since` / `until`.
30194
+ * **Every part of the question is now a store predicate** — `deviceIds`,
30195
+ * `since` / `until` AND `filter` ({@link faceFilterPredicate}) — so `limit`
30196
+ * is the STORE's limit and one statement is the whole answer.
30197
+ *
30198
+ * That is the change. This used to page: `filter` was a JS predicate applied
30199
+ * to rows the store had already chosen, so `limit` counted rows that PASSED
30200
+ * and the read had to sweep until enough had, up to `FACE_SWEEP_MAX_PAGES`
30201
+ * (1 000) × `FACE_SWEEP_PAGE` (1 000) — a million rows, `offset`-paged,
30202
+ * behind a UI polling every 30 seconds. On the live hub that meant reading
30203
+ * all 1 612 rows over 2 round trips to produce a 60-row `recognized` page,
30204
+ * every 30 s, per open tab, and growing with a table only `pruneCapOverflow`
30205
+ * bounds. It is the same family as `MediaRelocateEngine.pageRows`, which put
30206
+ * a ~16 s standing queue in front of every settings-store call in the cluster
30207
+ * (D320) — smaller, on a longer timer, and the same shape.
30208
+ *
30209
+ * `offset` is gone with it, not merely unnecessary: the recognizer UPSERTs
30210
+ * into this collection continuously, so page two at `offset: N` starts N rows
30211
+ * past a set that may have gained or lost its first N (D295 rule 1, D320 R3).
30212
+ *
30213
+ * ## What still cannot be pushed, and what that costs
30214
+ *
30215
+ * The `suggestionConfidence` ORDER stays here. `ORDER BY suggestedMatchScore`
30216
+ * would let the backend decide where a scoreless row goes — SQLite collates
30217
+ * NULL first on ASC and last on DESC — and the contract is that it sorts LAST
30218
+ * in BOTH directions (`face-sort.ts`). So that order reads the whole FILTERED
30219
+ * window and ranks it. Filtered, which is the improvement: the window is the
30220
+ * unassigned buffer, not the table.
30221
+ *
30222
+ * A window it cannot read whole REFUSES ({@link FaceWindowTooWideError}).
30223
+ * Ranking the part that fit is exactly "most certain among the newest N",
30224
+ * which is the defect that order was added to remove, and it is invisible in
30225
+ * the rows returned.
29610
30226
  *
29611
30227
  * An **empty `deviceIds` reads nothing at all**: `[]` is a request for no
29612
30228
  * cameras, not an omission (the `deviceManager.listFleet` contract).
@@ -29615,74 +30231,86 @@ var FaceStore = class {
29615
30231
  if (input.deviceIds !== void 0 && input.deviceIds.length === 0) return [];
29616
30232
  const order = resolveFaceSort(input);
29617
30233
  const pagingServesTheOrder = sortIsServedByTimestampPaging(order);
29618
- const filterMode = input.filter ?? "all";
29619
- const matches = (f) => {
29620
- if (filterMode === "unassigned") return !f.assigned;
29621
- if (filterMode === "recognized") return !f.assigned && f.recognizedIdentityId != null;
29622
- if (filterMode === "identified") return f.recognizedIdentityId != null;
29623
- return true;
29624
- };
29625
- const storeFilter = {
29626
- ...input.deviceIds !== void 0 ? { whereIn: { deviceId: [...new Set(input.deviceIds)] } } : {},
29627
- ...buildTimestampWindow(input),
29628
- orderBy: {
29629
- field: "timestamp",
29630
- direction: pagingServesTheOrder ? order.direction : "desc"
30234
+ /**
30235
+ * The caller's `limit` bounds the READ only when the store's ordering is
30236
+ * already the answer's. Under `suggestionConfidence` the most certain
30237
+ * suggestion may be the oldest row, so the read is the window and `limit`
30238
+ * bounds only what is RETAINED (`rankAndCap`).
30239
+ */
30240
+ const callerBounded = pagingServesTheOrder && input.limit !== void 0;
30241
+ const readLimit = callerBounded ? input.limit ?? 0 : 10001;
30242
+ const rows = await this.store.query.query({
30243
+ collection: FACES_COLLECTION,
30244
+ columns: FACE_LIST_COLUMNS,
30245
+ filter: {
30246
+ ...input.deviceIds !== void 0 ? { whereIn: { deviceId: [...new Set(input.deviceIds)] } } : {},
30247
+ ...buildTimestampWindow(input),
30248
+ ...faceFilterPredicate(input.filter ?? "all"),
30249
+ orderBy: {
30250
+ field: "timestamp",
30251
+ direction: pagingServesTheOrder ? order.direction : "desc"
30252
+ },
30253
+ limit: readLimit
29631
30254
  }
29632
- };
29633
- let result = [];
29634
- for (let page = 0; page < FACE_SWEEP_MAX_PAGES; page++) {
29635
- const rows = await this.store.query.query({
29636
- collection: FACES_COLLECTION,
29637
- filter: {
29638
- ...storeFilter,
29639
- limit: FACE_SWEEP_PAGE,
29640
- offset: page * FACE_SWEEP_PAGE
29641
- }
29642
- });
29643
- if (rows.length === 0) break;
29644
- const matched = [];
29645
- for (const r of rows) {
29646
- const face = rowToFace(r.id, r.data);
29647
- if (matches(face)) matched.push(face);
29648
- }
29649
- if (pagingServesTheOrder) {
29650
- const merged = [...result, ...matched];
29651
- if (input.limit !== void 0 && merged.length >= input.limit) return merged.slice(0, input.limit);
29652
- result = merged;
29653
- } else result = rankAndCap([...result, ...matched], order, input.limit);
29654
- if (rows.length < 1e3) break;
29655
- }
29656
- return pagingServesTheOrder ? result : rankAndCap(result, order, input.limit);
30255
+ });
30256
+ if (!callerBounded && rows.length > 1e4) throw new FaceWindowTooWideError(rows.length);
30257
+ const faces = rows.map((r) => rowToFace(r.id, r.data));
30258
+ return pagingServesTheOrder ? faces : rankAndCap(faces, order, input.limit);
29657
30259
  }
29658
- /** One real face row per camera currently attributed to an identity. */
30260
+ /**
30261
+ * One real face row per camera currently attributed to an identity.
30262
+ *
30263
+ * `recognizedIdentityId` is indexed (`idx_faces_identity_ts`), so this is a
30264
+ * bounded index walk over the identity's own rows rather than the
30265
+ * `offset`-paged sweep of the whole collection it used to be. It projects
30266
+ * `deviceId` alone — the answer is a device id and a row id, and the row id
30267
+ * is the record key.
30268
+ */
29659
30269
  async listCameraRefsForIdentity(identityId) {
30270
+ const rows = await this.identityRows(identityId, FACE_REF_COLUMNS);
29660
30271
  const byDevice = /* @__PURE__ */ new Map();
29661
- for (let page = 0; page < FACE_SWEEP_MAX_PAGES; page++) {
29662
- const rows = await this.store.query.query({
29663
- collection: FACES_COLLECTION,
29664
- filter: {
29665
- where: { recognizedIdentityId: identityId },
29666
- orderBy: {
29667
- field: "timestamp",
29668
- direction: "desc"
29669
- },
29670
- limit: FACE_SWEEP_PAGE,
29671
- offset: page * FACE_SWEEP_PAGE
29672
- }
29673
- });
29674
- if (rows.length === 0) break;
29675
- for (const row of rows) {
29676
- const deviceId = Number(row.data["deviceId"]);
29677
- if (Number.isInteger(deviceId) && !byDevice.has(deviceId)) byDevice.set(deviceId, row.id);
29678
- }
29679
- if (rows.length < 1e3) break;
30272
+ for (const row of rows) {
30273
+ const deviceId = Number(row.data["deviceId"]);
30274
+ if (Number.isInteger(deviceId) && !byDevice.has(deviceId)) byDevice.set(deviceId, row.id);
29680
30275
  }
29681
30276
  return [...byDevice].map(([deviceId, faceId]) => ({
29682
30277
  deviceId,
29683
30278
  faceId
29684
30279
  }));
29685
30280
  }
30281
+ /**
30282
+ * Every face row currently attributed to an identity, id only.
30283
+ *
30284
+ * `deleteIdentity` detaches these before the identity goes. It used to get
30285
+ * them by listing the WHOLE collection with `filter: 'all'` and no limit and
30286
+ * comparing in JS — which past `SETTINGS_QUERY_DEFAULT_ROW_CAP` would have
30287
+ * detached the rows that fit and left the rest pointing at a deleted
30288
+ * identity, silently, with a WARN in the engine that no caller can see.
30289
+ */
30290
+ async listFaceIdsForIdentity(identityId) {
30291
+ return (await this.identityRows(identityId, FACE_REF_COLUMNS)).map((r) => r.id);
30292
+ }
30293
+ /**
30294
+ * The rows of one identity, newest first, bounded and REFUSING rather than
30295
+ * narrowing. Shared by the two callers above so they cannot disagree about
30296
+ * what "this identity's faces" means.
30297
+ */
30298
+ async identityRows(identityId, columns) {
30299
+ const rows = await this.store.query.query({
30300
+ collection: FACES_COLLECTION,
30301
+ columns,
30302
+ filter: {
30303
+ where: { recognizedIdentityId: identityId },
30304
+ orderBy: {
30305
+ field: "timestamp",
30306
+ direction: "desc"
30307
+ },
30308
+ limit: 10001
30309
+ }
30310
+ });
30311
+ if (rows.length > 1e4) throw new FaceWindowTooWideError(rows.length);
30312
+ return rows;
30313
+ }
29686
30314
  };
29687
30315
  //#endregion
29688
30316
  //#region src/pipeline-analytics/face-gallery-provider.ts
@@ -29799,8 +30427,7 @@ var FaceGalleryProvider = class {
29799
30427
  });
29800
30428
  }
29801
30429
  async deleteIdentity(input) {
29802
- const faces = await this.faceStore.listAllRecentFaces({ filter: "all" });
29803
- for (const f of faces) if (f.recognizedIdentityId === input.id) await this.unassignFace({ faceId: f.id });
30430
+ for (const faceId of await this.faceStore.listFaceIdsForIdentity(input.id)) await this.unassignFace({ faceId });
29804
30431
  await this.mediaStore.deleteForOwner("identity", [input.id]);
29805
30432
  await this.identityStore.deleteIdentity(input.id);
29806
30433
  this.refreshGallery();
@@ -29873,8 +30500,7 @@ var FaceGalleryProvider = class {
29873
30500
  sortBy: input?.sortBy,
29874
30501
  sortDirection: input?.sortDirection
29875
30502
  });
29876
- const identities = await this.identityStore.listIdentities();
29877
- const nameMap = new Map(identities.map((i) => [i.id, i.name]));
30503
+ const nameMap = await this.identityStore.namesFor(faces.flatMap((f) => f.recognizedIdentityId != null ? [f.recognizedIdentityId] : []));
29878
30504
  const includeCrops = resolveIncludeCrops(input);
29879
30505
  return mapWithConcurrency(faces, FACE_CROP_FETCH_CONCURRENCY, async (face) => {
29880
30506
  const base64 = includeCrops ? await this.faceCropBase64(face) : void 0;
@@ -29910,8 +30536,7 @@ var FaceGalleryProvider = class {
29910
30536
  const face = await this.faceStore.get("face-" + input.trackId);
29911
30537
  if (!face) return null;
29912
30538
  if (face.deviceId !== input.deviceId) return null;
29913
- const identities = await this.identityStore.listIdentities();
29914
- const nameMap = new Map(identities.map((i) => [i.id, i.name]));
30539
+ const nameMap = await this.identityStore.namesFor(face.recognizedIdentityId != null ? [face.recognizedIdentityId] : []);
29915
30540
  const identityName = face.recognizedIdentityId ? nameMap.get(face.recognizedIdentityId) : void 0;
29916
30541
  const cropUrl = this.cropUrlFor(face);
29917
30542
  return {
@@ -34728,9 +35353,14 @@ var PlateGalleryProvider = class {
34728
35353
  ...cropUrl !== void 0 ? { cropUrl } : {}
34729
35354
  };
34730
35355
  }
34731
- async vehicleNameMap() {
34732
- const vehicles = await this.vehicleStore.listVehicles();
34733
- return new Map(vehicles.map((v) => [v.id, v.name]));
35356
+ /**
35357
+ * Names for the vehicles THESE PLATES reference — not the collection.
35358
+ *
35359
+ * A page of unassigned reads references none, and then this costs no round
35360
+ * trip at all. See `VehicleStore.namesFor`.
35361
+ */
35362
+ async vehicleNameMap(plates) {
35363
+ return this.vehicleStore.namesFor(plates.flatMap((p) => p.recognizedVehicleId != null ? [p.recognizedVehicleId] : []));
34734
35364
  }
34735
35365
  /**
34736
35366
  * `includeCrops` defaults to `true` — the pre-existing behaviour, kept so no
@@ -34745,14 +35375,14 @@ var PlateGalleryProvider = class {
34745
35375
  ...input?.deviceId !== void 0 ? { deviceId: input.deviceId } : {},
34746
35376
  ...input?.limit !== void 0 ? { limit: input.limit } : {}
34747
35377
  });
34748
- const nameMap = await this.vehicleNameMap();
35378
+ const nameMap = await this.vehicleNameMap(rows);
34749
35379
  const includeCrops = resolveIncludeCrops(input);
34750
35380
  return Promise.all(rows.map((p) => this.toPlateInfo(p, nameMap, { includeCrops })));
34751
35381
  }
34752
35382
  async getPlateByTrack(input) {
34753
35383
  const plate = await this.plateStore.get(`plate-${input.trackId}`);
34754
35384
  if (!plate || plate.deviceId !== input.deviceId) return null;
34755
- const nameMap = await this.vehicleNameMap();
35385
+ const nameMap = await this.vehicleNameMap([plate]);
34756
35386
  return this.toPlateInfo(plate, nameMap, { includeCrops: false });
34757
35387
  }
34758
35388
  async getPlateMedia(input) {
@@ -34765,7 +35395,7 @@ var PlateGalleryProvider = class {
34765
35395
  dist: plateDistance(input.text, p.text)
34766
35396
  })).filter((x) => x.dist <= maxDistance).toSorted((a, b) => a.dist - b.dist || b.p.timestamp - a.p.timestamp);
34767
35397
  const limited = input.limit !== void 0 ? scored.slice(0, input.limit) : scored;
34768
- const nameMap = await this.vehicleNameMap();
35398
+ const nameMap = await this.vehicleNameMap(limited.map((x) => x.p));
34769
35399
  const includeCrops = input.includeCrops ?? true;
34770
35400
  return Promise.all(limited.map((x) => this.toPlateInfo(x.p, nameMap, { includeCrops })));
34771
35401
  }
@@ -34840,8 +35470,7 @@ var PlateGalleryProvider = class {
34840
35470
  await this.vehicleStore.renameVehicle(input.id, input.name);
34841
35471
  }
34842
35472
  async deleteVehicle(input) {
34843
- const plates = await this.plateStore.listAllRecentPlates({});
34844
- for (const p of plates) if (p.recognizedVehicleId === input.id) await this.unassignPlate({ plateId: p.id });
35473
+ for (const plateId of await this.plateStore.listPlateIdsForVehicle(input.id)) await this.unassignPlate({ plateId });
34845
35474
  await this.mediaStore.deleteForOwner("vehicle", [input.id]);
34846
35475
  await this.vehicleStore.deleteVehicle(input.id);
34847
35476
  this.refreshGallery();
@@ -36978,10 +37607,34 @@ var AnalyticsQueryFacade = class {
36978
37607
  }
36979
37608
  /**
36980
37609
  * Durable event-store footprint for the events-management UI: event ROWS
36981
- * (motion + object + audio) counted per camera (indexed `count`) + total, and
36982
- * event-owned media BYTES on disk per camera (paged `sizeBytes` sum) + total.
36983
- * The device set is the union of cameras that have media OR persisted tracks;
36984
- * cameras with neither rows nor bytes are omitted.
37610
+ * (motion + object + audio) per camera + total, and event-owned media BYTES
37611
+ * on disk per camera + total. Cameras with neither rows nor bytes are
37612
+ * omitted; the rest come back ordered by bytes, descending, which is the
37613
+ * order the operator's table renders.
37614
+ *
37615
+ * ## The device set is a union of THREE sources
37616
+ *
37617
+ * It used to be `media ∪ tracks`, and a camera holding events but no media
37618
+ * row and no persisted track was therefore invisible — not listed, and its
37619
+ * events missing from `totalRows`. That combination is not exotic: media
37620
+ * retention, a media deletion and `pruneTracksBefore` all run on clocks of
37621
+ * their own, and every one of them can outlive the event rows. The number
37622
+ * this method exists to produce is the denominator for a deletion, so an
37623
+ * undercount is the one failure mode that matters, and it was silent.
37624
+ *
37625
+ * Events now contribute their own device set, so a camera is present if ANY
37626
+ * of the three sources has ever heard of it.
37627
+ *
37628
+ * ## The cost is the answer, not the table
37629
+ *
37630
+ * Three grouped reads for the event rows (one per event collection, never
37631
+ * three per camera), one grouped read plus one aggregate per camera for the
37632
+ * media bytes, one for the track device set. The retired shape paid ~1 250
37633
+ * round trips for the media half alone and timed out at 60 s on every call.
37634
+ * See {@link MediaStore.footprintByDevice} and {@link EventStore.countByDevice}.
37635
+ *
37636
+ * A store that cannot answer THROWS rather than returning zeroes — see the
37637
+ * same note on both.
36985
37638
  */
36986
37639
  async getEventStoreFootprint() {
36987
37640
  const eventStore = this.deps.eventStore();
@@ -36991,15 +37644,16 @@ var AnalyticsQueryFacade = class {
36991
37644
  totalBytes: 0,
36992
37645
  devices: []
36993
37646
  };
36994
- const mediaByDevice = await mediaStore.footprintByDevice();
37647
+ const [mediaByDevice, eventRowsByDevice] = await Promise.all([mediaStore.footprintByDevice(), eventStore.countByDevice()]);
36995
37648
  const deviceIds = new Set(mediaByDevice.keys());
37649
+ for (const id of eventRowsByDevice.keys()) deviceIds.add(id);
36996
37650
  const trackStore = this.deps.trackStore();
36997
37651
  if (trackStore) for (const id of await trackStore.listDeviceIds()) deviceIds.add(id);
36998
37652
  const devices = [];
36999
37653
  let totalRows = 0;
37000
37654
  let totalBytes = 0;
37001
37655
  for (const deviceId of deviceIds) {
37002
- const rows = await eventStore.countForDevice(deviceId);
37656
+ const rows = eventRowsByDevice.get(deviceId) ?? 0;
37003
37657
  const bytes = mediaByDevice.get(deviceId)?.bytes ?? 0;
37004
37658
  if (rows === 0 && bytes === 0) continue;
37005
37659
  totalRows += rows;
@@ -37018,6 +37672,26 @@ var AnalyticsQueryFacade = class {
37018
37672
  };
37019
37673
  }
37020
37674
  /**
37675
+ * The media footprint by KIND — the axis that says what is safe to delete.
37676
+ * See {@link MediaStore.footprintByKind}.
37677
+ *
37678
+ * An absent media store is an empty breakdown, not a refusal: it is the same
37679
+ * "the addon has not finished booting" branch every other method here takes,
37680
+ * and the zeroes are accompanied by an empty `kinds` list, which no reader
37681
+ * can mistake for a measured "there is nothing".
37682
+ */
37683
+ async getEventMediaFootprintByKind(input) {
37684
+ const mediaStore = this.deps.mediaStore();
37685
+ if (!mediaStore) return {
37686
+ totalRows: 0,
37687
+ totalBytes: 0,
37688
+ kinds: [],
37689
+ unaccountedRows: 0,
37690
+ unaccountedBytes: 0
37691
+ };
37692
+ return mediaStore.footprintByKind(input.deviceId);
37693
+ }
37694
+ /**
37021
37695
  * Cluster-wide prune of events with `timestamp < olderThanMs` across every
37022
37696
  * camera (via the device-agnostic `evictBefore`), deleting each pruned event's
37023
37697
  * media in lockstep. Logs one global (`deviceId:null`) events ops-log row.
@@ -44650,6 +45324,45 @@ function pickWritableMediaLocation(locations, currentDefaultId) {
44650
45324
  }
44651
45325
  //#endregion
44652
45326
  //#region src/pipeline-analytics/media-relocate-engine.ts
45327
+ /**
45328
+ * Event-media relocation engine (entity-routing spec, Phase 4) — the
45329
+ * analytics sibling of the recorder's footage mover. Each addon moves ONLY
45330
+ * what it owns (addons never touch each other's files).
45331
+ *
45332
+ * Per row: read blob from its CURRENT location → write to the target →
45333
+ * stamp `row.locationId` → delete the old blob. The row update lands between
45334
+ * the two blob operations, so a crash leaves the row pointing at a location
45335
+ * that still holds valid bytes (the old blob is only deleted after the row
45336
+ * points away from it). Rows already at the target are skipped — a re-run
45337
+ * RESUMES.
45338
+ *
45339
+ * Paging: timestamp-cursor over the media collection, with a seen-id set
45340
+ * bridging equal-timestamp boundaries. The PASS pages because it must visit
45341
+ * every row to act on it; that is not true of every question asked about this
45342
+ * collection, and `countUnstamped` no longer pages — see its docblock, and the
45343
+ * 60 s timeout that shape cost.
45344
+ *
45345
+ * (This header used to claim `WHERE locationId IS NULL` was inexpressible
45346
+ * through the store's filter. It was expressible, and that was the problem:
45347
+ * `where: { locationId: null }` compiled to `= NULL`, which matches nothing
45348
+ * and reports it as `0`. The filter layer now compiles it to `IS NULL`.)
45349
+ *
45350
+ * ── Three passes, one engine (see `MediaRelocateModeSchema`) ──────────────
45351
+ *
45352
+ * `move` is the historical pass. `seal` and `gallery` are the SAME walk with a
45353
+ * different row predicate and a different per-row action, deliberately not a
45354
+ * second mover: the walk, the cursor, the tie-bridging and the failure
45355
+ * accounting are the hard parts and they are already right here.
45356
+ *
45357
+ * - `seal` exists because a row with `locationId: NULL` means "wherever
45358
+ * `eventMedia` points NOW" (`media-store.ts:396`). A repoint moves that
45359
+ * pointer and every NULL row starts reading from the new disk while its
45360
+ * bytes are on the old one. `seal` is a ROW-ONLY pass — no blob is read,
45361
+ * written or deleted — that nails those rows to the location they are
45362
+ * actually on before anything repoints.
45363
+ * - `gallery` is the inverse selection of `move`: only the retention-exempt
45364
+ * enrolled-gallery rows, which `move` skips on purpose (D197).
45365
+ */
44653
45366
  var PAGE_SIZE = 200;
44654
45367
  var DEFAULT_THROTTLE_MBPS = 40;
44655
45368
  /** What a pass touches, reported on the job so an ops-log row says which of the
@@ -44685,6 +45398,68 @@ function snapshot(j) {
44685
45398
  */
44686
45399
  var LOCATION_ID_FIELD = "locationId";
44687
45400
  /**
45401
+ * Unstamped rows claimed per bulk seal statement.
45402
+ *
45403
+ * Bounds two things at once: the number of bound parameters in the `IN` list,
45404
+ * and how long one `UPDATE` holds the write lock on the database every process
45405
+ * in the cluster reads configuration through. Deliberately well under SQLite's
45406
+ * variable ceiling, and small enough that a cancel lands within one statement.
45407
+ */
45408
+ var SEAL_BATCH_SIZE = 500;
45409
+ /**
45410
+ * Rows one CENSUS may visit before it answers `null`.
45411
+ *
45412
+ * A census is the read-only question behind `storageMigration.residue` — "how
45413
+ * much would a pass still act on". It is NOT the pass: a pass walks the whole
45414
+ * collection because it has to touch every row, and pays for that walk once,
45415
+ * under an operator's explicit start.
45416
+ *
45417
+ * 20 000 rows is 100 round trips at {@link PAGE_SIZE} — ~26 ms of engine time
45418
+ * on a healthy store, and a ceiling that cannot become the cluster's queue. A
45419
+ * collection larger than this is one a WALK cannot answer questions about; the
45420
+ * honest reply is "I could not answer" (D295), and the structural fix is an
45421
+ * indexed `COUNT(*)`, which needs `locationId` sealed first and is deliberately
45422
+ * not smuggled in behind a bound.
45423
+ */
45424
+ var MEDIA_CENSUS_ROW_BUDGET = 2e4;
45425
+ /**
45426
+ * Wall clock one CENSUS may spend before it answers `null`.
45427
+ *
45428
+ * The row budget bounds a healthy store; this one bounds a sick store, where
45429
+ * 100 round trips at a 16 s queue depth is 27 minutes. Read through
45430
+ * `deps.now`, so it is deterministic under test and does not need a timer.
45431
+ */
45432
+ var MEDIA_CENSUS_DEADLINE_MS = 2e4;
45433
+ /**
45434
+ * What one census may spend, and what it has spent.
45435
+ *
45436
+ * Explicit rather than folded into the `stop` callback: `stop` means "someone
45437
+ * asked me to stop" and the budget means "I am not allowed to continue", and a
45438
+ * pass passes `null` here because a pass is allowed to walk the whole
45439
+ * collection. Two concepts, two parameters — the walk that ran unbounded for
45440
+ * a day did so because ONE `stop: () => false` stood for both.
45441
+ */
45442
+ var CensusBudget = class {
45443
+ now;
45444
+ maxRows;
45445
+ rows = 0;
45446
+ deadlineAt;
45447
+ constructor(now, maxRows, maxMs) {
45448
+ this.now = now;
45449
+ this.maxRows = maxRows;
45450
+ this.deadlineAt = now() + maxMs;
45451
+ }
45452
+ get rowsVisited() {
45453
+ return this.rows;
45454
+ }
45455
+ /** Visit one row. `true` means the budget is spent and the census must stop. */
45456
+ spend() {
45457
+ this.rows += 1;
45458
+ if (this.rows > this.maxRows) return true;
45459
+ return this.rows % PAGE_SIZE === 0 && this.now() >= this.deadlineAt;
45460
+ }
45461
+ };
45462
+ /**
44688
45463
  * True when the row names NO location — the population `seal` exists for.
44689
45464
  *
44690
45465
  * Deliberately NOT `mediaRowLocation(data) === DEFAULT_MEDIA_LOCATION`: that
@@ -44741,9 +45516,26 @@ function selectsRow(mode, toLocationId, data, isRetrain) {
44741
45516
  var MediaRelocateEngine = class {
44742
45517
  deps;
44743
45518
  jobs = /* @__PURE__ */ new Map();
45519
+ /** Censuses in flight, keyed by the QUESTION — see {@link countSelectable}. */
45520
+ censuses = /* @__PURE__ */ new Map();
44744
45521
  constructor(deps) {
44745
45522
  this.deps = deps;
44746
45523
  }
45524
+ /**
45525
+ * Retire every census in flight. They answer `null`, and stop reading.
45526
+ *
45527
+ * @returns how many were retired.
45528
+ */
45529
+ cancelCensuses(reason) {
45530
+ const retired = this.censuses.size;
45531
+ if (retired === 0) return 0;
45532
+ for (const run of this.censuses.values()) run.abort();
45533
+ this.deps.logger.info("media relocate: standing censuses retired", { meta: {
45534
+ retired,
45535
+ reason
45536
+ } });
45537
+ return retired;
45538
+ }
44747
45539
  list() {
44748
45540
  return [...this.jobs.values()].sort((a, b) => b.startedAt - a.startedAt).map(snapshot);
44749
45541
  }
@@ -44842,6 +45634,7 @@ var MediaRelocateEngine = class {
44842
45634
  }
44843
45635
  start(input) {
44844
45636
  for (const j of this.jobs.values()) if (j.state === "running") throw new Error(`a media relocation is already running (${j.jobId})`);
45637
+ this.cancelCensuses("a relocate pass started");
44845
45638
  const job = {
44846
45639
  jobId: this.deps.newId(),
44847
45640
  state: "running",
@@ -44912,15 +45705,66 @@ var MediaRelocateEngine = class {
44912
45705
  * must not be allowed to make (D295).
44913
45706
  */
44914
45707
  async countSelectable(input) {
44915
- return this.countSelected(input.mode ?? "move", input.toLocationId, () => false);
45708
+ const mode = input.mode ?? "move";
45709
+ const key = `${mode}|${input.toLocationId}`;
45710
+ const running = this.censuses.get(key);
45711
+ if (running !== void 0) return running.promise;
45712
+ let aborted = false;
45713
+ const budget = new CensusBudget(this.deps.now, MEDIA_CENSUS_ROW_BUDGET, MEDIA_CENSUS_DEADLINE_MS);
45714
+ const promise = this.countSelected(mode, input.toLocationId, () => aborted, budget).finally(() => {
45715
+ this.censuses.delete(key);
45716
+ });
45717
+ this.censuses.set(key, {
45718
+ promise,
45719
+ abort: () => {
45720
+ aborted = true;
45721
+ }
45722
+ });
45723
+ return promise;
44916
45724
  }
44917
- async countSelected(mode, toLocationId, stop) {
45725
+ /**
45726
+ * @param stop someone asked this walk to stop (an operator cancel, or
45727
+ * {@link cancelCensuses}). Answers `null`.
45728
+ * @param budget what this walk is allowed to spend, or `null` for a PASS —
45729
+ * a pass walks the whole collection because it must touch every row, and
45730
+ * an operator started it explicitly.
45731
+ */
45732
+ async countSelected(mode, toLocationId, stop, budget) {
44918
45733
  try {
45734
+ if (mode === "seal") {
45735
+ let sealable = 0;
45736
+ for (const collection of collectionsFor(mode)) {
45737
+ if (stop()) return null;
45738
+ sealable += await this.deps.store.count.query({
45739
+ collection: collection.name,
45740
+ filter: { where: { [LOCATION_ID_FIELD]: null } }
45741
+ });
45742
+ }
45743
+ return { rows: sealable };
45744
+ }
44919
45745
  let rows = 0;
44920
45746
  for (const collection of collectionsFor(mode)) {
44921
45747
  const isRetrain = collection.name === RETRAIN_FRAMES_COLLECTION;
44922
45748
  for await (const row of this.pageRows(collection.name, collection.order)) {
44923
- if (stop()) return null;
45749
+ if (stop()) {
45750
+ this.deps.logger.warn("media relocate: census stopped before it finished — reporting the residue as unknown, not as empty", { meta: {
45751
+ mode,
45752
+ toLocationId,
45753
+ collection: collection.name
45754
+ } });
45755
+ return null;
45756
+ }
45757
+ if (budget !== null && budget.spend()) {
45758
+ this.deps.logger.warn("media relocate: census exceeded its budget — this collection is larger than a walk can answer", { meta: {
45759
+ mode,
45760
+ toLocationId,
45761
+ collection: collection.name,
45762
+ rowsVisited: budget.rowsVisited,
45763
+ rowBudget: MEDIA_CENSUS_ROW_BUDGET,
45764
+ deadlineMs: MEDIA_CENSUS_DEADLINE_MS
45765
+ } });
45766
+ return null;
45767
+ }
44924
45768
  if (selectsRow(mode, toLocationId, row.data, isRetrain)) rows++;
44925
45769
  }
44926
45770
  }
@@ -44946,7 +45790,15 @@ var MediaRelocateEngine = class {
44946
45790
  * rows in silence reads as "there were none", which is how a migration
44947
45791
  * gets believed. */
44948
45792
  let pathlessRows = 0;
44949
- job.filesTotal = (await this.countSelected(job.mode, job.toLocationId, () => job.cancelRequested))?.rows ?? null;
45793
+ job.filesTotal = (await this.countSelected(job.mode, job.toLocationId, () => job.cancelRequested, null))?.rows ?? null;
45794
+ if (job.mode === "seal") {
45795
+ for (const collection of collectionsFor(job.mode)) {
45796
+ if (job.cancelRequested) break;
45797
+ await this.sealCollection(collection.name, job);
45798
+ }
45799
+ job.state = job.cancelRequested ? "cancelled" : "done";
45800
+ return;
45801
+ }
44950
45802
  for (const collection of collectionsFor(job.mode)) {
44951
45803
  if (job.cancelRequested) break;
44952
45804
  const isRetrain = collection.name === RETRAIN_FRAMES_COLLECTION;
@@ -44954,20 +45806,6 @@ var MediaRelocateEngine = class {
44954
45806
  if (job.cancelRequested) break;
44955
45807
  const data = row.data;
44956
45808
  if (!selectsRow(job.mode, job.toLocationId, data, isRetrain)) continue;
44957
- if (job.mode === "seal") {
44958
- try {
44959
- await this.stamp(collection.name, row.id, data, job.toLocationId);
44960
- job.filesMoved++;
44961
- } catch (err) {
44962
- failedRows++;
44963
- this.deps.logger.debug("event-media seal row failed", { meta: {
44964
- collection: collection.name,
44965
- key: row.id,
44966
- error: String(err)
44967
- } });
44968
- }
44969
- continue;
44970
- }
44971
45809
  const from = mediaRowLocation(data);
44972
45810
  const relativePath = String(data["path"] ?? "");
44973
45811
  if (relativePath.length === 0) {
@@ -45030,6 +45868,73 @@ var MediaRelocateEngine = class {
45030
45868
  this.deps.onFinished?.(snapshot(job));
45031
45869
  }
45032
45870
  }
45871
+ /**
45872
+ * Stamp every unstamped row of one collection with the job's target, in
45873
+ * bounded bulk statements.
45874
+ *
45875
+ * ## What this replaced
45876
+ *
45877
+ * A per-row loop: page the whole collection through {@link pageRows}, then
45878
+ * one `store.set.mutate` per selected row — a full-row UPSERT rewriting every
45879
+ * column to change one field, one RPC each, plus a second full walk before it
45880
+ * to produce the denominator. Measured on the live hub against the 464 902
45881
+ * unstamped rows: **2.3 rows/s, falling to 0.7 rows/s under a competing
45882
+ * drain**, i.e. over two and a half days for a metadata stamp that reads no
45883
+ * file and moves no byte. It was cancelled. This is the write-side twin of
45884
+ * the read-side walk {@link countUnstamped} retired, and the fourth instance
45885
+ * of the same pattern found in one day — work looped over the door instead of
45886
+ * pushed into the statement.
45887
+ *
45888
+ * ## The shape
45889
+ *
45890
+ * Probe the indexed `IS NULL` range for a batch of ids, then ONE
45891
+ * `updateWhere` over those ids. Two round trips per {@link SEAL_BATCH_SIZE}
45892
+ * rows instead of one write per row.
45893
+ *
45894
+ * The probe carries no cursor and needs none: the predicate CONSUMES itself.
45895
+ * A stamped row leaves the range, so the next probe returns the next batch,
45896
+ * and a run that is interrupted anywhere simply resumes on what is left. That
45897
+ * is also why the seal is idempotent (a second run probes an empty range and
45898
+ * stamps nothing) and why it cannot repoint a row that already names a
45899
+ * different location — `IS NULL` never selects one.
45900
+ *
45901
+ * A single unbounded `UPDATE … WHERE locationId IS NULL` would be one
45902
+ * statement instead of ~1 860, and is not used: half a million row rewrites,
45903
+ * each maintaining four indexes, is an unbounded statement holding the write
45904
+ * lock on a database the whole cluster reads configuration through, under an
45905
+ * RPC deadline that must not be raised. Batching bounds the statement, keeps
45906
+ * `cancel` responsive between batches, and keeps `filesMoved` honest.
45907
+ *
45908
+ * ## The termination guard
45909
+ *
45910
+ * If the probe finds rows and the update reports **0**, this THROWS. That is
45911
+ * not defensive noise: it is precisely what a backend compiling
45912
+ * `locationId = NULL` does — matches nothing, reports success (D312) — and
45913
+ * against it a self-consuming loop never terminates and a non-looping one
45914
+ * reports a completed seal having stamped nothing. Zero unstamped rows is the
45915
+ * single value the migration gate reads as "safe to cut over".
45916
+ */
45917
+ async sealCollection(collection, job) {
45918
+ for (;;) {
45919
+ if (job.cancelRequested) return;
45920
+ const batch = await this.deps.store.query.query({
45921
+ collection,
45922
+ filter: {
45923
+ where: { [LOCATION_ID_FIELD]: null },
45924
+ limit: SEAL_BATCH_SIZE
45925
+ },
45926
+ columns: [LOCATION_ID_FIELD]
45927
+ });
45928
+ if (batch.length === 0) return;
45929
+ const { updated } = await this.deps.store.updateWhere.mutate({
45930
+ collection,
45931
+ filter: { whereIn: { id: batch.map((row) => row.id) } },
45932
+ data: { [LOCATION_ID_FIELD]: job.toLocationId }
45933
+ });
45934
+ if (updated === 0) throw new Error(`event-media seal stamped 0 of ${batch.length} unstamped row(s) in "${collection}" — the bulk predicate matched nothing, so the population cannot be sealed`);
45935
+ job.filesMoved += updated;
45936
+ }
45937
+ }
45033
45938
  async stamp(collection, key, data, locationId) {
45034
45939
  await this.deps.store.set.mutate({
45035
45940
  collection,
@@ -51761,6 +52666,43 @@ var EventStore = class {
51761
52666
  return motion + object + audio;
51762
52667
  }
51763
52668
  /**
52669
+ * Persisted event rows (motion + object + audio) for EVERY device that has
52670
+ * any — three round trips for the whole fleet.
52671
+ *
52672
+ * {@link countForDevice} answers one camera in three `COUNT(*)`s, which is
52673
+ * right for one camera and wrong in a loop: the events-management footprint
52674
+ * called it once per device, so the cost of "how many events does each camera
52675
+ * hold" grew with the camera count for a result that is a few dozen integers.
52676
+ *
52677
+ * A histogram on `deviceId` with `bucketSize: 1` is `GROUP BY deviceId`, and
52678
+ * every event collection already carries `idx_<kind>_device_ts`, which leads
52679
+ * with `deviceId` — so the planner serves each of these from a covering index
52680
+ * with no table access at all. `bucket` IS the deviceId (the column is
52681
+ * INTEGER, the bucket expression is `CAST((deviceId - 0) / 1 AS INTEGER)`).
52682
+ *
52683
+ * Not best-effort: a failure propagates. The one caller is sizing a deletion,
52684
+ * and a camera silently reported as holding zero events is the answer that
52685
+ * must never come from a read that did not happen.
52686
+ */
52687
+ async countByDevice() {
52688
+ const perCollection = await Promise.all([
52689
+ MOTION_EVENTS_COLLECTION,
52690
+ OBJECT_EVENTS_COLLECTION,
52691
+ AUDIO_EVENTS_COLLECTION
52692
+ ].map((collection) => this.store.histogram.query({
52693
+ collection,
52694
+ field: "deviceId",
52695
+ bucketSize: 1,
52696
+ origin: 0
52697
+ })));
52698
+ const out = /* @__PURE__ */ new Map();
52699
+ for (const buckets of perCollection) for (const bucket of buckets) {
52700
+ if (bucket.count === 0 || !Number.isFinite(bucket.bucket)) continue;
52701
+ out.set(bucket.bucket, (out.get(bucket.bucket) ?? 0) + bucket.count);
52702
+ }
52703
+ return out;
52704
+ }
52705
+ /**
51764
52706
  * Return per-kind event counts in equal-width time buckets.
51765
52707
  *
51766
52708
  * Each bucket maps to a `bucketStart = since + i * bucketMs`.
@@ -52377,6 +53319,50 @@ var IdentityStore = class {
52377
53319
  key: id
52378
53320
  });
52379
53321
  }
53322
+ /**
53323
+ * `identityId → name` for exactly the identities asked about.
53324
+ *
53325
+ * The decorating half of a face or plate page needs a NAME per referenced
53326
+ * identity, and it used to get it by reading the whole `identities`
53327
+ * collection — unbounded, unprojected, on every 30 s poll of
53328
+ * `faceGallery.listRecentFaces`, and again on every `getFaceByTrack`. The
53329
+ * table is small (4 rows on the live hub on 2026-08-31) so the bytes were
53330
+ * never the problem; the SHAPE was. It is a second question whose cost is
53331
+ * proportional to a collection, asked on a timer, to answer a question about
53332
+ * a page — and it carried no `limit`, so past
53333
+ * `SETTINGS_QUERY_DEFAULT_ROW_CAP` it would have handed back a name map
53334
+ * missing entries, shape-identical to a complete one (D320).
53335
+ *
53336
+ * **An empty request costs no round trip at all.** With
53337
+ * `filter: 'unassigned'` — the view the operator lives in — a page
53338
+ * references no identity, and the honest number of queries for that is zero.
53339
+ * `whereIn` with an empty list matches nothing, so asking anyway would be an
53340
+ * RPC to be told what the caller already knew.
53341
+ *
53342
+ * A cache belongs in the owner of the fact, and this store IS the owner —
53343
+ * but a name here changes only when the operator renames, through this same
53344
+ * store, and the read is now one indexed `IN` over at most a page's worth of
53345
+ * ids. There is nothing left to cache that would not be a second authority
53346
+ * over a fact one round trip away (D224).
53347
+ */
53348
+ async namesFor(identityIds) {
53349
+ const ids = [...new Set(identityIds)];
53350
+ if (ids.length === 0) return /* @__PURE__ */ new Map();
53351
+ const rows = await this.store.query.query({
53352
+ collection: IDENTITIES_COLLECTION,
53353
+ columns: ["name"],
53354
+ filter: {
53355
+ whereIn: { id: ids },
53356
+ limit: ids.length
53357
+ }
53358
+ });
53359
+ const names = /* @__PURE__ */ new Map();
53360
+ for (const r of rows) {
53361
+ const name = r.data["name"];
53362
+ if (typeof name === "string") names.set(r.id, name);
53363
+ }
53364
+ return names;
53365
+ }
52380
53366
  async listIdentities() {
52381
53367
  return (await this.store.query.query({
52382
53368
  collection: IDENTITIES_COLLECTION,
@@ -52918,13 +53904,55 @@ var PLATE_COLUMNS = [
52918
53904
  type: "JSON"
52919
53905
  }
52920
53906
  ];
52921
- var PLATE_INDEXES = [{
52922
- name: "idx_plates_device_ts",
52923
- columns: ["deviceId", "timestamp"]
52924
- }, {
52925
- name: "idx_plates_track",
52926
- columns: ["trackId"]
52927
- }];
53907
+ /**
53908
+ * What the gallery orders by, and what the vehicle-scoped read filters on.
53909
+ *
53910
+ * The face twin's `EXPLAIN QUERY PLAN` measurement applies verbatim: without an
53911
+ * index on `timestamp`, `ORDER BY timestamp DESC LIMIT 500` is
53912
+ * `SCAN + USE TEMP B-TREE FOR ORDER BY` — the read was already BOUNDED at 200,
53913
+ * and still sorted the whole table to produce the newest N. A bound caps what
53914
+ * comes back; only an ordered index caps what is read.
53915
+ */
53916
+ var PLATE_INDEXES = [
53917
+ {
53918
+ name: "idx_plates_device_ts",
53919
+ columns: ["deviceId", "timestamp"]
53920
+ },
53921
+ {
53922
+ name: "idx_plates_track",
53923
+ columns: ["trackId"]
53924
+ },
53925
+ {
53926
+ name: "idx_plates_ts",
53927
+ columns: ["timestamp"]
53928
+ },
53929
+ {
53930
+ name: "idx_plates_vehicle_ts",
53931
+ columns: ["recognizedVehicleId", "timestamp"]
53932
+ }
53933
+ ];
53934
+ /**
53935
+ * The widest plate window a single read may materialise before it REFUSES.
53936
+ *
53937
+ * The corpus reads (`searchPlates`, `suggestPlateClusters`) are genuinely
53938
+ * corpus-shaped — fuzzy text distance is not a predicate — so they cannot be
53939
+ * narrowed the way the gallery list was. What they CAN do is stop lying: they
53940
+ * carried no `limit` at all, so the engine imposed
53941
+ * `SETTINGS_QUERY_DEFAULT_ROW_CAP` and handed back the first 2 000 rows with a
53942
+ * WARN no caller can see. A search over the newest 2 000 of 50 000 plates and a
53943
+ * search over all of them are the same array (D320 R3).
53944
+ *
53945
+ * 10 000 mirrors the faces ceiling and stays under the engine's hard cap so the
53946
+ * engine never truncates first.
53947
+ */
53948
+ var PLATE_WINDOW_MAX_ROWS = 1e4;
53949
+ /** A corpus read whose window is wider than {@link PLATE_WINDOW_MAX_ROWS}. */
53950
+ var PlateWindowTooWideError = class extends Error {
53951
+ constructor(rows) {
53952
+ super(`plate window exceeds ${String(PLATE_WINDOW_MAX_ROWS)} rows (read ${String(rows)}) — refusing to answer with the part that fit`);
53953
+ this.name = "PlateWindowTooWideError";
53954
+ }
53955
+ };
52928
53956
  var PlateStore = class {
52929
53957
  store;
52930
53958
  logger;
@@ -52989,18 +54017,53 @@ var PlateStore = class {
52989
54017
  }
52990
54018
  })).map((r) => this.normalizeRow(r));
52991
54019
  }
52992
- /** List ALL recent plate reads across devices, newest first. */
54020
+ /**
54021
+ * List ALL recent plate reads across devices, newest first.
54022
+ *
54023
+ * The corpus read, for the two passes whose question is not a predicate
54024
+ * (fuzzy text search, text clustering). It carries an explicit `limit` and
54025
+ * REFUSES past it rather than handing back the part that fit: without one the
54026
+ * engine imposed its own cap and truncated silently, and a search over the
54027
+ * newest 2 000 plates looks exactly like a search over all of them.
54028
+ */
52993
54029
  async listAllRecentPlates(input) {
52994
- return (await this.store.query.query({
54030
+ const bounded = input.limit !== void 0;
54031
+ const rows = await this.store.query.query({
52995
54032
  collection: PLATES_COLLECTION,
52996
54033
  filter: {
52997
54034
  orderBy: {
52998
54035
  field: "timestamp",
52999
54036
  direction: "desc"
53000
54037
  },
53001
- ...input.limit !== void 0 ? { limit: input.limit } : {}
54038
+ limit: bounded ? input.limit ?? 0 : 10001
53002
54039
  }
53003
- })).map((r) => this.normalizeRow(r));
54040
+ });
54041
+ if (!bounded && rows.length > 1e4) throw new PlateWindowTooWideError(rows.length);
54042
+ return rows.map((r) => this.normalizeRow(r));
54043
+ }
54044
+ /**
54045
+ * Every plate row currently attributed to a vehicle, id only.
54046
+ *
54047
+ * `deleteVehicle` detaches these before the vehicle goes. It used to list the
54048
+ * WHOLE buffer and compare in JS — the `deleteIdentity` defect verbatim, and
54049
+ * past the engine's row cap it would detach the plates that fit and leave the
54050
+ * rest pointing at a vehicle that no longer exists.
54051
+ */
54052
+ async listPlateIdsForVehicle(vehicleId) {
54053
+ const rows = await this.store.query.query({
54054
+ collection: PLATES_COLLECTION,
54055
+ columns: ["deviceId"],
54056
+ filter: {
54057
+ where: { recognizedVehicleId: vehicleId },
54058
+ orderBy: {
54059
+ field: "timestamp",
54060
+ direction: "desc"
54061
+ },
54062
+ limit: 10001
54063
+ }
54064
+ });
54065
+ if (rows.length > 1e4) throw new PlateWindowTooWideError(rows.length);
54066
+ return rows.map((r) => r.id);
53004
54067
  }
53005
54068
  async get(plateId) {
53006
54069
  const raw = await this.store.get.query({
@@ -55660,6 +56723,38 @@ var VehicleStore = class {
55660
56723
  key: id
55661
56724
  });
55662
56725
  }
56726
+ /**
56727
+ * `vehicleId → name` for exactly the vehicles asked about — the plate twin of
56728
+ * `IdentityStore.namesFor`, and it exists for the same reason.
56729
+ *
56730
+ * The decorating half of a plate page needs a NAME per referenced vehicle,
56731
+ * and it got it by reading the whole `vehicles` collection: unbounded,
56732
+ * unprojected, on every 30 s poll of `plateGallery.listPlates` and again on
56733
+ * every `getPlateByTrack` and `searchPlates`. The live table holds **0 rows**
56734
+ * — the bytes were never the point. The shape was: a question proportional to
56735
+ * a collection, on a timer, to decorate a page.
56736
+ *
56737
+ * An empty request costs no round trip: `whereIn` over an empty list matches
56738
+ * nothing, so asking would be an RPC to be told what the caller knew.
56739
+ */
56740
+ async namesFor(vehicleIds) {
56741
+ const ids = [...new Set(vehicleIds)];
56742
+ if (ids.length === 0) return /* @__PURE__ */ new Map();
56743
+ const rows = await this.store.query.query({
56744
+ collection: VEHICLES_COLLECTION,
56745
+ columns: ["name"],
56746
+ filter: {
56747
+ whereIn: { id: ids },
56748
+ limit: ids.length
56749
+ }
56750
+ });
56751
+ const names = /* @__PURE__ */ new Map();
56752
+ for (const r of rows) {
56753
+ const name = r.data["name"];
56754
+ if (typeof name === "string") names.set(r.id, name);
56755
+ }
56756
+ return names;
56757
+ }
55663
56758
  async listVehicles() {
55664
56759
  return (await this.store.query.query({
55665
56760
  collection: VEHICLES_COLLECTION,
@@ -62631,6 +63726,50 @@ var ZoneAnalyticsProvider = class {
62631
63726
  return this.snapshots.get(deviceId) ?? await this.hydrateFromRegistry(deviceId);
62632
63727
  }
62633
63728
  /**
63729
+ * The same snapshot, for a SET of cameras, in one round trip.
63730
+ *
63731
+ * The Events page's Stationary section polls this every 15 s for every
63732
+ * selected camera. Fanned out client-side that is one browser → hub →
63733
+ * post-analysis round trip per camera to read a `Map.get`; the answer costs
63734
+ * nothing and the transport costs everything. The per-device work here is
63735
+ * unchanged — the same live snapshot, the same parked-registry hydration when
63736
+ * there is no live one — and it runs concurrently inside the owner.
63737
+ *
63738
+ * **Three outcomes stay three.** A camera with a reading, a camera with none
63739
+ * yet (`snapshot: null`, `read: 'read'`), and a camera the owner could not
63740
+ * answer for (`read: 'unreadable'`). The hydration reaches a durable store
63741
+ * and can throw; letting that throw become `snapshot: null` would render an
63742
+ * unread camera as a camera with nothing parked, which is a definite claim
63743
+ * nobody made.
63744
+ */
63745
+ async getCurrentSnapshotBatch({ deviceIds }) {
63746
+ return Promise.all(deviceIds.map(async (deviceId) => {
63747
+ const live = this.snapshots.get(deviceId);
63748
+ if (live !== void 0) return {
63749
+ deviceId,
63750
+ read: "read",
63751
+ snapshot: live
63752
+ };
63753
+ try {
63754
+ return {
63755
+ deviceId,
63756
+ read: "read",
63757
+ snapshot: await this.hydrateFromRegistry(deviceId)
63758
+ };
63759
+ } catch (err) {
63760
+ this.ctx.logger.warn("occupancy snapshot could not be read", {
63761
+ tags: { deviceId },
63762
+ meta: { error: String(err) }
63763
+ });
63764
+ return {
63765
+ deviceId,
63766
+ read: "unreadable",
63767
+ snapshot: null
63768
+ };
63769
+ }
63770
+ }));
63771
+ }
63772
+ /**
62634
63773
  * The cameras a LIVE snapshot exists for.
62635
63774
  *
62636
63775
  * The Notification Center's occupancy dry-run ("as of now, would this rule
@@ -65189,6 +66328,23 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
65189
66328
  },
65190
66329
  resolveArtifactUrl: async (artifactId) => await this.ncArtifactPlane?.mintUrl(artifactId) ?? null,
65191
66330
  mintActionUrl: (input) => this.ncActionMintUrl?.(input) ?? Promise.resolve(null),
66331
+ listAddonUpdates: async (nodeId) => {
66332
+ return (await this.ctx.api.addons.listUpdates.query({ nodeId })).filter((update) => !update.isSystem).map((update) => update.name);
66333
+ },
66334
+ applyAddonUpdate: async ({ packageName, nodeId }) => {
66335
+ try {
66336
+ await this.ctx.api.addons.updatePackage.mutate({
66337
+ name: packageName,
66338
+ nodeId
66339
+ });
66340
+ return { ok: true };
66341
+ } catch (err) {
66342
+ return {
66343
+ ok: false,
66344
+ error: errMsg(err)
66345
+ };
66346
+ }
66347
+ },
65192
66348
  readDeviceStates: async (ids) => {
65193
66349
  const out = /* @__PURE__ */ new Map();
65194
66350
  for (const id of ids) try {
@@ -70148,6 +71304,36 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
70148
71304
  return [];
70149
71305
  }
70150
71306
  }
71307
+ /**
71308
+ * `getKeyEvents` for a SET of cameras, in one round trip.
71309
+ *
71310
+ * The Detection Intelligence events feed asked this of every selected camera
71311
+ * on a 30 s timer — N browser → hub → post-analysis round trips for N
71312
+ * independent, already-indexed store queries. The queries are unchanged and
71313
+ * still run per camera (each is `where deviceId` + a `firstSeen` range
71314
+ * against its own index, and `limit` stays PER CAMERA so a busy camera cannot
71315
+ * starve a quiet one of its rows); what collapses is the transport.
71316
+ *
71317
+ * Every requested id gets a row. `getKeyEvents` already degrades to `[]`
71318
+ * rather than throwing, so a per-camera failure is reported exactly as it was
71319
+ * before this method existed — this does not make it worse, and the row
71320
+ * carries the `deviceId` the single method's output never did.
71321
+ */
71322
+ async getKeyEventsBatch(input) {
71323
+ return Promise.all(input.deviceIds.map(async (deviceId) => {
71324
+ return {
71325
+ deviceId,
71326
+ events: await this.getKeyEvents({
71327
+ deviceId,
71328
+ since: input.since,
71329
+ until: input.until,
71330
+ ...input.limit !== void 0 ? { limit: input.limit } : {},
71331
+ ...input.minImportance !== void 0 ? { minImportance: input.minImportance } : {},
71332
+ ...input.classFilter !== void 0 ? { classFilter: input.classFilter } : {}
71333
+ })
71334
+ };
71335
+ }));
71336
+ }
70151
71337
  async getAudioEvents(input) {
70152
71338
  return this.queryFacade.getAudioEvents(input);
70153
71339
  }
@@ -71484,6 +72670,9 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
71484
72670
  async getEventStoreFootprint() {
71485
72671
  return this.queryFacade.getEventStoreFootprint();
71486
72672
  }
72673
+ async getEventMediaFootprintByKind(input) {
72674
+ return this.queryFacade.getEventMediaFootprintByKind(input);
72675
+ }
71487
72676
  async pruneEvents(input) {
71488
72677
  return this.queryFacade.pruneEvents(input);
71489
72678
  }