@camstack/addon-pipeline 1.2.304 → 1.2.306

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/dist/audio-analyzer/index.js +2 -2
  2. package/dist/audio-analyzer/index.mjs +2 -2
  3. package/dist/{default-detection-model-vN686W1U.js → default-detection-model-CeYzI_ws.js} +1 -1
  4. package/dist/{default-detection-model-BroB5Jp1.mjs → default-detection-model-CmdkU7Ik.mjs} +1 -1
  5. package/dist/detection-pipeline/index.js +4 -4
  6. package/dist/detection-pipeline/index.mjs +4 -4
  7. package/dist/{dist-CLZmZfHT.mjs → dist-B0mdAGsn.mjs} +3437 -3059
  8. package/dist/{dist-CyWAhtbJ.js → dist-C2GsZSso.js} +3436 -3058
  9. package/dist/motion-wasm/index.js +1 -1
  10. package/dist/motion-wasm/index.mjs +1 -1
  11. package/dist/{node-C5LXX8Qo.mjs → node-C4FxPOmq.mjs} +1 -1
  12. package/dist/{node-BDrkzYiH.js → node-DA9sHeo7.js} +1 -1
  13. package/dist/pipeline-runner/index.js +7 -6
  14. package/dist/pipeline-runner/index.mjs +7 -6
  15. package/dist/{process-memory-DMS-0oRT.js → process-memory-CT7jffcr.js} +1 -1
  16. package/dist/{process-memory-CCx6i9av.mjs → process-memory-DtcP6dwa.mjs} +1 -1
  17. package/dist/recorder/index.js +542 -138
  18. package/dist/recorder/index.mjs +542 -138
  19. package/dist/{segment-demux-js-CwQuMR0h.js → segment-demux-js-C6Z9t7EK.js} +1 -1
  20. package/dist/{segment-demux-js-BUxZFqkb.mjs → segment-demux-js-CDDu7U5-.mjs} +1 -1
  21. package/dist/stream-broker/_stub.js +2 -2
  22. package/dist/stream-broker/{_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-CF7C68rV.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-BRWrphBU.mjs} +2 -2
  23. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-DKNdMa3E.mjs +26 -0
  24. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-CdKbuxqG.mjs +26 -0
  25. package/dist/stream-broker/demux-worker-child.js +1 -1
  26. package/dist/stream-broker/demux-worker-child.mjs +1 -1
  27. package/dist/stream-broker/{hostInit-dac4-ofq.mjs → hostInit-CdU3jJMF.mjs} +2 -2
  28. package/dist/stream-broker/index.js +3 -3
  29. package/dist/stream-broker/index.mjs +3 -3
  30. package/dist/stream-broker/remoteEntry.js +1 -1
  31. package/package.json +1 -1
  32. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-vnc0r4NV.mjs +0 -26
  33. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-CUFWg1jj.mjs +0 -26
@@ -1,5 +1,5 @@
1
- import { A as OpsLogEntrySchema, B as VIDEOCLIPS_MAX_OFFER_BYTES, Bt as resolveCreateExportSubject, C as ExportRecordSchema, Ct as mayWriteToLocation, Dn as array, En as _enum, F as RECORDING_SOURCE_CAMSTACK, Fn as record, Ft as recordingArchiveCapability, Ht as resolveLocationMode, I as RECORDING_SOURCE_CAMSTACK_ADDON, In as string, It as recordingCapability, Jt as storageOccupancyCapability, L as RecordingConfigSchema, Lt as recordingExportCapability, Mn as number, Nn as object, On as boolean, P as RECORDING_EXPORT_MAX_READ_BYTES, Pn as preprocess, Qt as videoclipsCapability, R as RecordingSignalStatusSchema, Rn as EventCategory, Rt as recordingSignalCapability, St as mayReadLocation, Tn as sleep$1, Tt as modeMayWrite, V as VISIT_MERGE_GAP_MS, Wt as resolveRecordingProfiles, X as clipExportFailureOf, Xt as stripRetiredBandPreBufferSec, Y as clipExportFailure, _n as hydrateSchema, _t as isFirstLevelMacroClass, a as BatteryStatusSchema, b as EVENT_OWNER_TYPES, ct as evaluateSensorEdge, dn as DeviceType, dt as evictionPolicyOfLocation, en as errMsg$1, ft as exportSubjectOf, g as DEFAULT_EVENTS_BAND_BUFFER_SEC, hn as defineCustomActions, mn as customAction, mt as gbToBytes, o as CLIP_BROKER_PACED_RATES, q as batteryCapability, qt as storageEvictableCapability, rt as deriveRecordingMode, sn as BaseAddon, tt as deriveBatteryPresence, un as DeviceFeature, ut as evictionPolicyForMode, vt as loadContributionCapability, wn as selectAssignedProfileSlots, wt as migrateLegacyDeviceSignalConfig, x as EVENT_PAD_MS, xn as nodePin } from "../dist-CLZmZfHT.mjs";
2
- import { a as physicalRootOf, i as NO_COST_CLAIM, t as ChildCostRegistry } from "../node-C5LXX8Qo.mjs";
1
+ import { B as VISIT_MERGE_GAP_MS, Bt as resolveLocationMode, Ct as modeMayWrite, Dn as array, En as _enum, F as RECORDING_SOURCE_CAMSTACK_ADDON, Fn as record, Ft as recordingExportCapability, Gt as storageEvictableCapability, Ht as resolveRecordingProfiles, I as RecordingConfigSchema, In as string, It as recordingSignalCapability, J as clipExportFailure, Jt as stripRetiredBandPreBufferSec, K as batteryCapability, Kt as storageOccupancyCapability, L as RecordingSignalStatusSchema, Mn as number, N as RECORDING_EXPORT_MAX_READ_BYTES, Nn as object, Nt as recordingArchiveCapability, On as boolean, P as RECORDING_SOURCE_CAMSTACK, Pn as preprocess, Pt as recordingCapability, Rn as EventCategory, Rt as resolveCreateExportSubject, S as ExportRecordSchema, St as migrateLegacyDeviceSignalConfig, Tn as sleep$1, Xt as videoclipsCapability, Y as clipExportFailureOf, _n as hydrateSchema, a as BatteryStatusSchema, an as DeviceFeature, b as EVENT_PAD_MS, bt as mayReadLocation, ct as evictionPolicyForMode, et as deriveBatteryPresence, ft as gbToBytes, g as DEFAULT_EVENTS_BAND_BUFFER_SEC, gt as loadContributionCapability, hn as defineCustomActions, ht as isFirstLevelMacroClass, k as OpsLogEntrySchema, ln as BaseAddon, lt as evictionPolicyOfLocation, mn as customAction, nn as evaluateSensorEdge, nt as deriveRecordingMode, o as CLIP_BROKER_PACED_RATES, on as DeviceType, sn as errMsg$1, ut as exportSubjectOf, wn as selectAssignedProfileSlots, xn as nodePin, xt as mayWriteToLocation, y as EVENT_OWNER_TYPES, z as VIDEOCLIPS_MAX_OFFER_BYTES } from "../dist-B0mdAGsn.mjs";
2
+ import { a as physicalRootOf, i as NO_COST_CLAIM, t as ChildCostRegistry } from "../node-C4FxPOmq.mjs";
3
3
  import { t as resolveHubHostname } from "../hub-hostname-cCknRYKj.mjs";
4
4
  import { d as PREBUFFER_MAX_MS, l as withRecordingIntent, r as RECORDING_PRE_ROLL_REQUEST_MS } from "../restream-intent-DyGmLAU9.mjs";
5
5
  import { n as RowMapStore, t as retireRootKeys } from "../retire-root-keys-DZvlso2l.mjs";
@@ -833,15 +833,15 @@ function parseSegmentPath(relPath) {
833
833
  //#region src/recorder/recording-index.ts
834
834
  /** Hour bucket, the granularity the path convention (`.../YYYY/MM/DD/HH/`)
835
835
  * already gives us — so "have I walked this?" costs one Set lookup. */
836
- var HOUR_MS$9 = 36e5;
836
+ var HOUR_MS$11 = 36e5;
837
837
  /** The hour buckets a half-open span touches. */
838
838
  function* hourBuckets(fromMs, toMs) {
839
- const first = Math.floor(fromMs / HOUR_MS$9) * HOUR_MS$9;
840
- for (let h = first; h < toMs; h += HOUR_MS$9) yield h;
839
+ const first = Math.floor(fromMs / HOUR_MS$11) * HOUR_MS$11;
840
+ for (let h = first; h < toMs; h += HOUR_MS$11) yield h;
841
841
  }
842
842
  /** The hour bucket a segment belongs to — the same one its path encodes. */
843
843
  function hourOf$1(startMs) {
844
- return Math.floor(startMs / HOUR_MS$9) * HOUR_MS$9;
844
+ return Math.floor(startMs / HOUR_MS$11) * HOUR_MS$11;
845
845
  }
846
846
  /** The production bound. See {@link RecordingIndexBudget} for the measurement. */
847
847
  var DEFAULT_RECORDING_INDEX_BUDGET = {
@@ -1219,8 +1219,8 @@ var RecordingIndex = class {
1219
1219
  const w = this.walked.get(deviceId);
1220
1220
  if (w === void 0 || w.size === 0) return;
1221
1221
  const tick = ++this.useTick;
1222
- if (Math.ceil(Math.max(0, toMs - fromMs) / HOUR_MS$9) > w.size) {
1223
- for (const h of w.keys()) if (h < toMs && h + HOUR_MS$9 > fromMs) w.set(h, tick);
1222
+ if (Math.ceil(Math.max(0, toMs - fromMs) / HOUR_MS$11) > w.size) {
1223
+ for (const h of w.keys()) if (h < toMs && h + HOUR_MS$11 > fromMs) w.set(h, tick);
1224
1224
  return;
1225
1225
  }
1226
1226
  for (const h of hourBuckets(fromMs, toMs)) if (w.has(h)) w.set(h, tick);
@@ -1257,7 +1257,7 @@ var RecordingIndex = class {
1257
1257
  if (rows <= budget.residentRowBudget && hours <= budget.residentHourBudget) return;
1258
1258
  const rowTarget = Math.floor(budget.residentRowBudget * budget.evictToFraction);
1259
1259
  const hourTarget = Math.floor(budget.residentHourBudget * budget.evictToFraction);
1260
- const pinnedFrom = hourOf$1(deps.now()) - Math.max(0, budget.pinnedTailHours - 1) * HOUR_MS$9;
1260
+ const pinnedFrom = hourOf$1(deps.now()) - Math.max(0, budget.pinnedTailHours - 1) * HOUR_MS$11;
1261
1261
  const counts = this.hourHistogram();
1262
1262
  const candidates = [];
1263
1263
  for (const [deviceId, byHour] of counts) {
@@ -1379,7 +1379,7 @@ var RecordingIndex = class {
1379
1379
  changed = true;
1380
1380
  }
1381
1381
  if (changed) this.invalidateSorted(deviceId);
1382
- this.markHydrated(deviceId, hourStartMs, hourStartMs + HOUR_MS$9);
1382
+ this.markHydrated(deviceId, hourStartMs, hourStartMs + HOUR_MS$11);
1383
1383
  this.enforceResidency();
1384
1384
  }
1385
1385
  /**
@@ -1404,7 +1404,7 @@ var RecordingIndex = class {
1404
1404
  const dropped = [];
1405
1405
  for (const [path, row] of m) {
1406
1406
  if (row.locationId !== locationId) continue;
1407
- if (Math.floor(row.startMs / HOUR_MS$9) * HOUR_MS$9 !== hourStartMs) continue;
1407
+ if (Math.floor(row.startMs / HOUR_MS$11) * HOUR_MS$11 !== hourStartMs) continue;
1408
1408
  if (keep.has(path)) continue;
1409
1409
  dropped.push(path);
1410
1410
  }
@@ -2067,11 +2067,11 @@ function hourDirRelPath(deviceId, profile, hourStartMs) {
2067
2067
  const p2 = (n) => String(n).padStart(2, "0");
2068
2068
  return `${deviceId}/${profile}/${d.getUTCFullYear()}/${p2(d.getUTCMonth() + 1)}/${p2(d.getUTCDate())}/${p2(d.getUTCHours())}`;
2069
2069
  }
2070
- var HOUR_MS$8 = 36e5;
2070
+ var HOUR_MS$10 = 36e5;
2071
2071
  async function hydrateWindowFromStorage(index, deviceId, fromMs, toMs, locations, logger, ledger, options) {
2072
2072
  const verify = options?.verifyClosedHours === true;
2073
2073
  if (!verify && index.hydrationOf(deviceId, fromMs, toMs) === "hydrated") return;
2074
- const liveHour = Math.floor(Date.now() / HOUR_MS$8) * HOUR_MS$8;
2074
+ const liveHour = Math.floor(Date.now() / HOUR_MS$10) * HOUR_MS$10;
2075
2075
  for (const [root, aliases] of aliasesByRoot(locations)) {
2076
2076
  const deviceDir = path.join(root, String(deviceId));
2077
2077
  let profiles;
@@ -2087,15 +2087,15 @@ async function hydrateWindowFromStorage(index, deviceId, fromMs, toMs, locations
2087
2087
  });
2088
2088
  continue;
2089
2089
  }
2090
- const first = Math.floor(fromMs / HOUR_MS$8) * HOUR_MS$8;
2090
+ const first = Math.floor(fromMs / HOUR_MS$10) * HOUR_MS$10;
2091
2091
  const reuseBefore = index.hydrateReuseStats();
2092
2092
  let hoursRead = 0;
2093
2093
  let repaired = 0;
2094
2094
  let forgottenSegments = 0;
2095
2095
  let forgottenHours = 0;
2096
2096
  let sealed = 0;
2097
- for (let hour = first; hour < toMs; hour += HOUR_MS$8) {
2098
- if (!verify && hour < liveHour && index.hydrationOf(deviceId, hour, hour + HOUR_MS$8) === "hydrated") continue;
2097
+ for (let hour = first; hour < toMs; hour += HOUR_MS$10) {
2098
+ if (!verify && hour < liveHour && index.hydrationOf(deviceId, hour, hour + HOUR_MS$10) === "hydrated") continue;
2099
2099
  for (const profile of profiles) {
2100
2100
  const rel = hourDirRelPath(deviceId, profile, hour);
2101
2101
  let files;
@@ -2108,7 +2108,7 @@ async function hydrateWindowFromStorage(index, deviceId, fromMs, toMs, locations
2108
2108
  const locationId = locationForHydratedProfile(aliases, profile).id;
2109
2109
  const relPaths = files.filter((f) => f.endsWith(".m4s")).map((f) => `${rel}/${f}`);
2110
2110
  index.hydrateHour(deviceId, locationId, hour, relPaths);
2111
- if (ledger === void 0 || relPaths.length === 0 || hour + HOUR_MS$8 > Date.now()) continue;
2111
+ if (ledger === void 0 || relPaths.length === 0 || hour + HOUR_MS$10 > Date.now()) continue;
2112
2112
  const outcome = await ledger.adoptWalkedHour(deviceId, profile, locationId, hour, relPaths);
2113
2113
  if (outcome === null) continue;
2114
2114
  if (outcome.created) repaired += 1;
@@ -2190,7 +2190,7 @@ async function hydrateWindowFromStorage(index, deviceId, fromMs, toMs, locations
2190
2190
  */
2191
2191
  var DEFAULT_THROTTLE_MBPS = 40;
2192
2192
  /** The durable index's bucket, and therefore this mover's batch. */
2193
- var HOUR_MS$7 = 36e5;
2193
+ var HOUR_MS$9 = 36e5;
2194
2194
  /** Progress callback cadence (files) — cheap enough to fire often. */
2195
2195
  var PROGRESS_EVERY_FILES = 25;
2196
2196
  /** Free space a destination must keep when it declares no `config.minFreePercent`
@@ -2879,7 +2879,7 @@ function profilesOfClass(footageClass) {
2879
2879
  function groupByHour(rows) {
2880
2880
  const out = /* @__PURE__ */ new Map();
2881
2881
  for (const row of rows) {
2882
- const key = `${row.deviceId}|${row.profile}|${Math.floor(row.startMs / HOUR_MS$7)}`;
2882
+ const key = `${row.deviceId}|${row.profile}|${Math.floor(row.startMs / HOUR_MS$9)}`;
2883
2883
  const found = out.get(key);
2884
2884
  if (found) found.push(row);
2885
2885
  else out.set(key, [row]);
@@ -4103,6 +4103,231 @@ async function profileHasFootage(root, deviceId, profile) {
4103
4103
  return (await listDir(path.join(root, String(deviceId), profile))).length > 0;
4104
4104
  }
4105
4105
  //#endregion
4106
+ //#region src/recorder/addon/calendar-hour-cache.ts
4107
+ var HOUR_MS$8 = 36e5;
4108
+ var HOURS_PER_DAY = 24;
4109
+ /**
4110
+ * The production bound: the RAM index's own (`DEFAULT_RECORDING_INDEX_BUDGET`,
4111
+ * 250 000 rows / 20 000 walked hours, measured at 698 B/row). The calendar
4112
+ * holds a 3-scalar `HourSegment` per segment where the index holds a full row,
4113
+ * so the same counts are a SMALLER heap here — see
4114
+ * {@link CALENDAR_SEGMENT_APPROX_BYTES}.
4115
+ */
4116
+ var DEFAULT_CALENDAR_HOUR_BUDGET = {
4117
+ hourBudget: DEFAULT_RECORDING_INDEX_BUDGET.residentHourBudget,
4118
+ segmentBudget: DEFAULT_RECORDING_INDEX_BUDGET.residentRowBudget,
4119
+ evictToFraction: DEFAULT_RECORDING_INDEX_BUDGET.evictToFraction
4120
+ };
4121
+ /** Every empty hour shares this one array: a gap costs a slot, not an object. */
4122
+ var EMPTY_HOUR = Object.freeze([]);
4123
+ /** Hours since the epoch — a Smi, where `hourMs` would be a HeapNumber. */
4124
+ function hourIndexOf(epochMs) {
4125
+ return Math.floor(epochMs / HOUR_MS$8);
4126
+ }
4127
+ var CalendarHourCache = class {
4128
+ budget;
4129
+ byDevice = /* @__PURE__ */ new Map();
4130
+ /** Least recently used first — `Set` iteration order is insertion order. */
4131
+ lru = /* @__PURE__ */ new Set();
4132
+ segmentCount = 0;
4133
+ emptyCount = 0;
4134
+ stampedCount = 0;
4135
+ evictedCount = 0;
4136
+ constructor(budget = DEFAULT_CALENDAR_HOUR_BUDGET) {
4137
+ this.budget = budget;
4138
+ }
4139
+ /** The cached segments of one hour, or `undefined` when not cached. */
4140
+ get(deviceId, profile, hourMs) {
4141
+ const entry = this.entryOf(deviceId, profile, hourMs);
4142
+ if (entry === void 0) return void 0;
4143
+ this.lru.delete(entry);
4144
+ this.lru.add(entry);
4145
+ return entry.segments;
4146
+ }
4147
+ /** When this hour was read AS THE CURRENT HOUR, or `undefined`. */
4148
+ readAtOf(deviceId, profile, hourMs) {
4149
+ return this.entryOf(deviceId, profile, hourMs)?.readAtMs ?? void 0;
4150
+ }
4151
+ /**
4152
+ * Store one COMPLETE scan. `currentReadAtMs` is set only when the hour was
4153
+ * the current one at store time; a later store of the closed hour passes
4154
+ * `null` and so drops the stamp with the entry it replaces.
4155
+ */
4156
+ set(deviceId, profile, hourMs, segments, currentReadAtMs) {
4157
+ const hourIndex = hourIndexOf(hourMs);
4158
+ this.remove(deviceId, profile, hourIndex);
4159
+ const entry = {
4160
+ deviceId,
4161
+ profile,
4162
+ hourIndex,
4163
+ segments: segments.length === 0 ? EMPTY_HOUR : segments,
4164
+ readAtMs: currentReadAtMs
4165
+ };
4166
+ this.profileMap(deviceId, profile).set(hourIndex, entry);
4167
+ this.lru.add(entry);
4168
+ if (entry.segments.length === 0) this.emptyCount += 1;
4169
+ else this.segmentCount += entry.segments.length;
4170
+ if (entry.readAtMs !== null) this.stampedCount += 1;
4171
+ this.enforceBudget();
4172
+ }
4173
+ /**
4174
+ * Drop one camera's UTC day, every profile — what eviction changed. The read
4175
+ * stamps go with their hours; there is no second map to sweep.
4176
+ */
4177
+ invalidateDay(deviceId, utcDayMs) {
4178
+ const firstHour = hourIndexOf(utcDayMs);
4179
+ const profiles = this.byDevice.get(deviceId);
4180
+ if (profiles === void 0) return;
4181
+ for (const profile of [...profiles.keys()]) for (let h = 0; h < HOURS_PER_DAY; h++) this.remove(deviceId, profile, firstHour + h);
4182
+ }
4183
+ /** Full reset — a relocation re-pointed rows across locations. */
4184
+ clear() {
4185
+ this.byDevice.clear();
4186
+ this.lru.clear();
4187
+ this.segmentCount = 0;
4188
+ this.emptyCount = 0;
4189
+ this.stampedCount = 0;
4190
+ }
4191
+ census() {
4192
+ const hours = this.lru.size;
4193
+ return {
4194
+ hourKeys: hours - this.emptyCount,
4195
+ emptyHours: this.emptyCount,
4196
+ hourSegments: this.segmentCount,
4197
+ segsReadAtKeys: this.stampedCount,
4198
+ evictedHours: this.evictedCount,
4199
+ approxBytes: hours * 128 + this.segmentCount * 72 + this.stampedCount * 16
4200
+ };
4201
+ }
4202
+ entryOf(deviceId, profile, hourMs) {
4203
+ return this.byDevice.get(deviceId)?.get(profile)?.get(hourIndexOf(hourMs));
4204
+ }
4205
+ profileMap(deviceId, profile) {
4206
+ let profiles = this.byDevice.get(deviceId);
4207
+ if (profiles === void 0) {
4208
+ profiles = /* @__PURE__ */ new Map();
4209
+ this.byDevice.set(deviceId, profiles);
4210
+ }
4211
+ let hours = profiles.get(profile);
4212
+ if (hours === void 0) {
4213
+ hours = /* @__PURE__ */ new Map();
4214
+ profiles.set(profile, hours);
4215
+ }
4216
+ return hours;
4217
+ }
4218
+ /** Remove one hour and its stamp; prune empty parents. */
4219
+ remove(deviceId, profile, hourIndex) {
4220
+ const profiles = this.byDevice.get(deviceId);
4221
+ const hours = profiles?.get(profile);
4222
+ const entry = hours?.get(hourIndex);
4223
+ if (profiles === void 0 || hours === void 0 || entry === void 0) return;
4224
+ hours.delete(hourIndex);
4225
+ if (hours.size === 0) profiles.delete(profile);
4226
+ if (profiles.size === 0) this.byDevice.delete(deviceId);
4227
+ this.lru.delete(entry);
4228
+ if (entry.segments.length === 0) this.emptyCount -= 1;
4229
+ else this.segmentCount -= entry.segments.length;
4230
+ if (entry.readAtMs !== null) this.stampedCount -= 1;
4231
+ }
4232
+ enforceBudget() {
4233
+ const { hourBudget, segmentBudget, evictToFraction } = this.budget;
4234
+ if (this.lru.size <= hourBudget && this.segmentCount <= segmentBudget) return;
4235
+ const hourTarget = Math.floor(hourBudget * evictToFraction);
4236
+ const segmentTarget = Math.floor(segmentBudget * evictToFraction);
4237
+ for (const entry of this.lru) {
4238
+ if (this.lru.size <= hourTarget && this.segmentCount <= segmentTarget) return;
4239
+ this.remove(entry.deviceId, entry.profile, entry.hourIndex);
4240
+ this.evictedCount += 1;
4241
+ }
4242
+ }
4243
+ };
4244
+ //#endregion
4245
+ //#region src/recorder/addon/calendar-window.ts
4246
+ var HOUR_MS$7 = 36e5;
4247
+ /**
4248
+ * The widest walk one call may make AFTER the clamp: a leap year of hours.
4249
+ *
4250
+ * Evidence, not taste. The widest in-repo caller is the viewer's month
4251
+ * calendar (~33 days); the rest ask a day or a clip window (liveness 24 h,
4252
+ * videoclips' since/until, a timelapse window, a timeline day). The one that
4253
+ * asked for more wanted a single number the ledger already holds (D674). The
4254
+ * archives it would still admit are past what this system keeps: the 28 TB
4255
+ * projection is ~47 k hour rows, ~2,000 hours (~82 days) per camera/profile
4256
+ * pair at ~24 pairs, and the live hub held 3.3 days. (The ledger's load CAP,
4257
+ * `SEGMENT_HOUR_SUMMARY_LIMIT` = 250,000 rows, is ~10 k hours per pair — a
4258
+ * bound on memory, not a claim about retention; the clamp only ever narrows a
4259
+ * walk further.) A walk this wide costs ~130 B of garbage per hour warm
4260
+ * (measured: ~65 MB for 497,408 hours), so ~1.1 MB at the cap.
4261
+ */
4262
+ var CALENDAR_MAX_WALK_HOURS = 366 * 24;
4263
+ var UNKNOWN_ARCHIVE_FLOOR = { known: false };
4264
+ /** A walk the calendar will not make. Surfaces as `read: 'unreadable'`. */
4265
+ var CalendarWindowRefusedError = class extends Error {
4266
+ deviceId;
4267
+ hours;
4268
+ maxHours;
4269
+ floorKnown;
4270
+ name = "CalendarWindowRefusedError";
4271
+ constructor(deviceId, hours, maxHours, floorKnown) {
4272
+ super(`calendar: refused a ${String(hours)}-hour walk for device ${String(deviceId)} (cap ${String(maxHours)} h, archive floor ${floorKnown ? "known" : "UNKNOWN"}) — ask the archive (recordingArchive.getStorageUsage) for whole-archive facts`);
4273
+ this.deviceId = deviceId;
4274
+ this.hours = hours;
4275
+ this.maxHours = maxHours;
4276
+ this.floorKnown = floorKnown;
4277
+ }
4278
+ };
4279
+ /**
4280
+ * Clamp `[fromMs, toMs)` to the hours that can hold footage and refuse it when
4281
+ * it is still wider than `maxHours`. `null` = nothing left to walk.
4282
+ *
4283
+ * The writer's current hour and the one it just left are never clamped away:
4284
+ * the ledger is write-behind, so a camera's first segments may be on disk (and
4285
+ * in the RAM index) a moment before the ledger holds their hour.
4286
+ */
4287
+ function calendarWalk(deviceId, fromMs, toMs, nowMs, floor, maxHours = CALENDAR_MAX_WALK_HOURS) {
4288
+ const currentHour = Math.floor(nowMs / HOUR_MS$7) * HOUR_MS$7;
4289
+ const liveFloor = currentHour - HOUR_MS$7;
4290
+ const floorHour = floor.known ? Math.min(floor.oldestHourMs, liveFloor) : Number.NEGATIVE_INFINITY;
4291
+ const firstHourMs = Math.max(Math.floor(fromMs / HOUR_MS$7) * HOUR_MS$7, floorHour);
4292
+ const endMs = Math.min(toMs, currentHour + HOUR_MS$7);
4293
+ if (endMs <= firstHourMs || toMs <= fromMs) return null;
4294
+ const hours = Math.ceil((endMs - firstHourMs) / HOUR_MS$7);
4295
+ if (hours > maxHours) throw new CalendarWindowRefusedError(deviceId, hours, maxHours, floor.known);
4296
+ return {
4297
+ firstHourMs,
4298
+ endMs,
4299
+ hours
4300
+ };
4301
+ }
4302
+ /** Per-camera archive floors, one ledger fold per {@link ARCHIVE_FLOOR_TTL_MS}. */
4303
+ var ArchiveFloorReader = class {
4304
+ source;
4305
+ now;
4306
+ folded = null;
4307
+ constructor(source, now = () => Date.now()) {
4308
+ this.source = source;
4309
+ this.now = now;
4310
+ }
4311
+ floorOf(deviceId) {
4312
+ const ledger = this.source();
4313
+ if (ledger === null) return UNKNOWN_ARCHIVE_FLOOR;
4314
+ if (!ledger.isComplete()) {
4315
+ ledger.retryLoadIfUnanswered();
4316
+ return UNKNOWN_ARCHIVE_FLOOR;
4317
+ }
4318
+ const nowMs = this.now();
4319
+ if (this.folded === null || nowMs - this.folded.atMs >= 6e4) this.folded = {
4320
+ atMs: nowMs,
4321
+ floors: ledger.oldestHourByDevice()
4322
+ };
4323
+ const oldestHourMs = this.folded.floors.get(deviceId);
4324
+ return oldestHourMs === void 0 ? UNKNOWN_ARCHIVE_FLOOR : {
4325
+ known: true,
4326
+ oldestHourMs
4327
+ };
4328
+ }
4329
+ };
4330
+ //#endregion
4106
4331
  //#region src/recorder/addon/calendar-index.ts
4107
4332
  var DAY_MS$3 = 864e5;
4108
4333
  var HOUR_MS$6 = 36e5;
@@ -4165,7 +4390,9 @@ function hourSegmentsFromIndex(index, deviceId, profile, hourStartMs) {
4165
4390
  *
4166
4391
  * Segments land every 8–12 s, so three seconds of staleness at the live edge
4167
4392
  * cannot hide a whole one — and the live view owns "now" regardless. Past hours
4168
- * stay cached indefinitely; only this one expires.
4393
+ * stay cached until eviction or the rung's bound (D674) drops them; only this
4394
+ * one expires — and it is read once more after it closes, so its last minutes
4395
+ * are never served from a stamp taken while it was still being written.
4169
4396
  */
4170
4397
  var CURRENT_HOUR_TTL_MS = 3e3;
4171
4398
  /**
@@ -4207,17 +4434,20 @@ async function mapBounded(items, limit, fn) {
4207
4434
  await Promise.all(workers);
4208
4435
  return out;
4209
4436
  }
4437
+ /** Estimated bytes per cached UTC day on the day rung (map slot + a short
4438
+ * array of hour numbers). */
4439
+ var DAY_RUNG_APPROX_BYTES = 96;
4210
4440
  var CalendarIndex = class {
4211
4441
  deps;
4212
4442
  /** `deviceId` → (UTC day ms → hours). Absent = never scanned. */
4213
4443
  byDevice = /* @__PURE__ */ new Map();
4214
4444
  /** Scans in flight, so a burst of queries costs one walk. */
4215
4445
  inFlight = /* @__PURE__ */ new Map();
4216
- /** `deviceId:profile:hourMs` → that hour's segments. The exact rung. */
4217
- hourSegs = /* @__PURE__ */ new Map();
4446
+ /** The exact rung: device → profile → hour → segments, bounded (D674). */
4447
+ hours;
4448
+ /** Hour scans in flight, keyed like the rung — so a burst costs one read. */
4218
4449
  segsInFlight = /* @__PURE__ */ new Map();
4219
- /** When each CURRENT-hour entry was read, for {@link CURRENT_HOUR_TTL_MS}. */
4220
- segsReadAt = /* @__PURE__ */ new Map();
4450
+ segsInFlightCount = 0;
4221
4451
  /** The profile availability reads from, resolved once per device. */
4222
4452
  profileFor = /* @__PURE__ */ new Map();
4223
4453
  profilesFor = /* @__PURE__ */ new Map();
@@ -4232,25 +4462,36 @@ var CalendarIndex = class {
4232
4462
  profileInFlight = /* @__PURE__ */ new Map();
4233
4463
  constructor(deps) {
4234
4464
  this.deps = deps;
4465
+ this.hours = new CalendarHourCache(deps.hourBudget ?? DEFAULT_CALENDAR_HOUR_BUDGET);
4235
4466
  }
4236
4467
  /**
4237
- * Entry counts for the recorder census. `hourSegs` is unbounded (it is
4238
- * invalidated on eviction and relocation, never aged), so its segment total
4239
- * is the number that says whether this cache is where the heap went.
4468
+ * Entry counts and an estimated byte total for the recorder census. The
4469
+ * exact rung is bounded (D674); `evictedHours` says whether the bound bites.
4240
4470
  */
4241
4471
  memoryCensus() {
4242
4472
  let days = 0;
4243
4473
  for (const perDay of this.byDevice.values()) days += perDay.size;
4244
- let hourSegments = 0;
4245
- for (const segments of this.hourSegs.values()) hourSegments += segments.length;
4474
+ const hours = this.hours.census();
4246
4475
  return {
4247
4476
  devices: this.byDevice.size,
4248
4477
  days,
4249
- hourKeys: this.hourSegs.size,
4250
- hourSegments,
4251
- inFlight: this.inFlight.size + this.segsInFlight.size
4478
+ ...hours,
4479
+ approxBytes: hours.approxBytes + days * DAY_RUNG_APPROX_BYTES,
4480
+ inFlight: this.inFlight.size + this.segsInFlightCount
4252
4481
  };
4253
4482
  }
4483
+ nowMs() {
4484
+ return this.deps.now?.() ?? Date.now();
4485
+ }
4486
+ /**
4487
+ * The hours a walk over `[fromMs, toMs)` may visit — clamped to the archive
4488
+ * floor and the live edge, REFUSED above the cap (see `calendar-window.ts`).
4489
+ * Throws {@link CalendarWindowRefusedError}; the provider turns that into
4490
+ * `read: 'unreadable'` and logs it.
4491
+ */
4492
+ walkOf(deviceId, fromMs, toMs) {
4493
+ return calendarWalk(deviceId, fromMs, toMs, this.nowMs(), this.deps.archiveFloor?.(deviceId) ?? UNKNOWN_ARCHIVE_FLOOR, this.deps.maxWalkHours ?? 8784);
4494
+ }
4254
4495
  mapFor(deviceId) {
4255
4496
  let m = this.byDevice.get(deviceId);
4256
4497
  if (!m) {
@@ -4311,8 +4552,10 @@ var CalendarIndex = class {
4311
4552
  * UTC day outside the range (23:30Z on the 28th is the 29th in CEST).
4312
4553
  */
4313
4554
  async localDays(deviceId, fromMs, toMs, tzOffsetMinutes) {
4314
- const first = utcDayOf(fromMs) - DAY_MS$3;
4315
- const last = utcDayOf(toMs) + DAY_MS$3;
4555
+ const walk = this.walkOf(deviceId, utcDayOf(fromMs) - DAY_MS$3, utcDayOf(toMs) + 2 * DAY_MS$3);
4556
+ if (walk === null) return [];
4557
+ const first = utcDayOf(walk.firstHourMs);
4558
+ const last = utcDayOf(walk.endMs - 1);
4316
4559
  const wanted = [];
4317
4560
  for (let day = first; day <= last; day += DAY_MS$3) wanted.push(day);
4318
4561
  const perDay = await mapBounded(wanted, READ_CONCURRENCY, async (day) => ({
@@ -4337,16 +4580,12 @@ var CalendarIndex = class {
4337
4580
  * rebuild lazily on the next question. */
4338
4581
  invalidateAll() {
4339
4582
  this.byDevice.clear();
4340
- this.hourSegs.clear();
4583
+ this.hours.clear();
4341
4584
  }
4342
4585
  invalidate(deviceId, atMs) {
4343
4586
  const day = utcDayOf(atMs);
4344
4587
  this.byDevice.get(deviceId)?.delete(day);
4345
- const prefix = `${deviceId}:`;
4346
- for (const key of this.hourSegs.keys()) {
4347
- if (!key.startsWith(prefix)) continue;
4348
- if (utcDayOf(Number(key.slice(key.lastIndexOf(":") + 1))) === day) this.hourSegs.delete(key);
4349
- }
4588
+ this.hours.invalidateDay(deviceId, day);
4350
4589
  }
4351
4590
  /**
4352
4591
  * Scan a span in the background so the first screen is already answered.
@@ -4404,28 +4643,28 @@ var CalendarIndex = class {
4404
4643
  * retries instead of caching a gap.
4405
4644
  */
4406
4645
  async segmentsOfChecked(deviceId, profile, hourStartMs, background = false) {
4407
- if (!background) this.lastForegroundAt = this.deps.now?.() ?? Date.now();
4646
+ if (!background) this.lastForegroundAt = this.nowMs();
4408
4647
  const hour = Math.floor(hourStartMs / HOUR_MS$6) * HOUR_MS$6;
4409
4648
  const fromIndex = this.deps.indexHour?.(deviceId, profile, hour);
4410
4649
  if (fromIndex !== null && fromIndex !== void 0) return {
4411
4650
  segments: fromIndex,
4412
4651
  complete: true
4413
4652
  };
4414
- const key = `${deviceId}:${profile}:${hour}`;
4415
- const now = this.deps.now?.() ?? Date.now();
4653
+ const now = this.nowMs();
4416
4654
  const isCurrentHour = hour === Math.floor(now / HOUR_MS$6) * HOUR_MS$6;
4417
- const cached = this.hourSegs.get(key);
4655
+ const cached = this.hours.get(deviceId, profile, hour);
4418
4656
  if (cached !== void 0) {
4419
- if (!isCurrentHour) return {
4657
+ const readAt = this.hours.readAtOf(deviceId, profile, hour);
4658
+ if (!isCurrentHour && readAt === void 0) return {
4420
4659
  segments: cached,
4421
4660
  complete: true
4422
4661
  };
4423
- if (now - (this.segsReadAt.get(key) ?? 0) < CURRENT_HOUR_TTL_MS) return {
4662
+ if (isCurrentHour && now - (readAt ?? 0) < CURRENT_HOUR_TTL_MS) return {
4424
4663
  segments: cached,
4425
4664
  complete: true
4426
4665
  };
4427
4666
  }
4428
- const running = this.segsInFlight.get(key);
4667
+ const running = this.inFlightScan(deviceId, profile, hour);
4429
4668
  if (running) return running;
4430
4669
  const scan = (async () => {
4431
4670
  const read = this.deps.scanSegments ?? segmentsOfHour;
@@ -4449,21 +4688,46 @@ var CalendarIndex = class {
4449
4688
  complete
4450
4689
  };
4451
4690
  })();
4452
- this.segsInFlight.set(key, scan);
4691
+ this.trackScan(deviceId, profile, hour, scan);
4453
4692
  try {
4454
4693
  const { segments, complete } = await scan;
4455
- if (complete) {
4456
- this.hourSegs.set(key, segments);
4457
- this.segsReadAt.set(key, this.deps.now?.() ?? Date.now());
4458
- }
4694
+ const storedAt = this.nowMs();
4695
+ const liveHour = Math.floor(storedAt / HOUR_MS$6) * HOUR_MS$6;
4696
+ if (complete && hour <= liveHour) this.hours.set(deviceId, profile, hour, segments, hour === liveHour ? storedAt : null);
4459
4697
  return {
4460
4698
  segments,
4461
4699
  complete
4462
4700
  };
4463
4701
  } finally {
4464
- if (this.segsInFlight.get(key) === scan) this.segsInFlight.delete(key);
4702
+ this.untrackScan(deviceId, profile, hour, scan);
4465
4703
  }
4466
4704
  }
4705
+ inFlightScan(deviceId, profile, hour) {
4706
+ return this.segsInFlight.get(deviceId)?.get(profile)?.get(hour) ?? null;
4707
+ }
4708
+ trackScan(deviceId, profile, hour, scan) {
4709
+ let profiles = this.segsInFlight.get(deviceId);
4710
+ if (profiles === void 0) {
4711
+ profiles = /* @__PURE__ */ new Map();
4712
+ this.segsInFlight.set(deviceId, profiles);
4713
+ }
4714
+ let hours = profiles.get(profile);
4715
+ if (hours === void 0) {
4716
+ hours = /* @__PURE__ */ new Map();
4717
+ profiles.set(profile, hours);
4718
+ }
4719
+ hours.set(hour, scan);
4720
+ this.segsInFlightCount += 1;
4721
+ }
4722
+ untrackScan(deviceId, profile, hour, scan) {
4723
+ const profiles = this.segsInFlight.get(deviceId);
4724
+ const hours = profiles?.get(profile);
4725
+ if (profiles === void 0 || hours === void 0 || hours.get(hour) !== scan) return;
4726
+ hours.delete(hour);
4727
+ this.segsInFlightCount -= 1;
4728
+ if (hours.size === 0) profiles.delete(profile);
4729
+ if (profiles.size === 0) this.segsInFlight.delete(deviceId);
4730
+ }
4467
4731
  /**
4468
4732
  * Exact availability ranges for a UTC day — what the progressive timeline
4469
4733
  * draws.
@@ -4491,9 +4755,10 @@ var CalendarIndex = class {
4491
4755
  */
4492
4756
  async rangesIn(deviceId, profile, fromMs, toMs) {
4493
4757
  if (toMs <= fromMs) return [];
4494
- const first = Math.floor(fromMs / HOUR_MS$6) * HOUR_MS$6;
4758
+ const walk = this.walkOf(deviceId, fromMs, toMs);
4759
+ if (walk === null) return [];
4495
4760
  const hours = [];
4496
- for (let hour = first; hour < toMs; hour += HOUR_MS$6) hours.push(hour);
4761
+ for (let hour = walk.firstHourMs; hour < walk.endMs; hour += HOUR_MS$6) hours.push(hour);
4497
4762
  const perHour = await mapBounded(hours, READ_CONCURRENCY, (hour) => this.segmentsOf(deviceId, profile, hour));
4498
4763
  const ranges = [];
4499
4764
  for (const r of mergeRanges(perHour.flat(), RANGE_MERGE_GAP_MS$1)) {
@@ -4599,6 +4864,115 @@ var CalendarIndex = class {
4599
4864
  }
4600
4865
  };
4601
4866
  //#endregion
4867
+ //#region src/recorder/addon/device-storage-usage.ts
4868
+ /**
4869
+ * Gap between the per-camera sum and the per-volume total that is worth
4870
+ * saying out loud, as a share of the volume total.
4871
+ *
4872
+ * The two are aggregations of the SAME hour rows over the same location set —
4873
+ * one folded per camera, one summed in SQL — so in a healthy recorder they
4874
+ * agree to the byte. A few tenths of a percent is a write landing between the
4875
+ * two reads; 97% (60.5 GB against 1.9 TB) is the defect this file closes,
4876
+ * rebuilt somewhere new.
4877
+ */
4878
+ var USAGE_MISMATCH_SHARE = .02;
4879
+ /** Floor under {@link USAGE_MISMATCH_SHARE} so a nearly empty disk does not
4880
+ * warn on a single in-flight segment. */
4881
+ var USAGE_MISMATCH_FLOOR_BYTES = 1e9;
4882
+ /**
4883
+ * One row per camera the ARCHIVE holds footage for on `locationIds`, biggest
4884
+ * first (deviceId breaks a tie, so two consecutive calls compare).
4885
+ *
4886
+ * A node with no ledger REFUSES (D295 rule 3): empty rows, `read:
4887
+ * 'unavailable'`, and a line saying so. Falling back to the index would answer
4888
+ * the same question with the wrong quantity and look exactly like a correct
4889
+ * answer, which is how this shipped in the first place.
4890
+ */
4891
+ function buildDeviceUsage(input) {
4892
+ if (input.archive === null) {
4893
+ input.logger.warn("recorder: the hour ledger is absent or not loaded on this node — the per-camera storage rows are UNANSWERED, not empty", { meta: { locations: [...input.locationIds].toSorted().join(",") } });
4894
+ return {
4895
+ devices: [],
4896
+ totalUsedBytes: 0,
4897
+ read: "unavailable"
4898
+ };
4899
+ }
4900
+ const devices = [];
4901
+ let totalUsedBytes = 0;
4902
+ for (const [deviceId, archived] of input.archive) {
4903
+ const resident = input.resident(deviceId);
4904
+ totalUsedBytes += archived.bytes;
4905
+ devices.push({
4906
+ deviceId,
4907
+ usedBytes: archived.bytes,
4908
+ oldestMs: archived.oldestMs,
4909
+ segments: archived.segments,
4910
+ residentBytes: resident.bytes,
4911
+ residentOldestMs: resident.oldestMs
4912
+ });
4913
+ }
4914
+ return {
4915
+ devices: devices.toSorted((a, b) => b.usedBytes - a.usedBytes || a.deviceId - b.deviceId),
4916
+ totalUsedBytes,
4917
+ read: "archive"
4918
+ };
4919
+ }
4920
+ /**
4921
+ * Say it when the per-camera sum and the per-volume total disagree.
4922
+ *
4923
+ * The "I saw N of M" rule of D295, applied to a REPORT: these two numbers are
4924
+ * the same rows counted two ways, so a gap means one of the two reads saw a
4925
+ * fraction of the archive — and the whole point of D418 is that a page which
4926
+ * shows both without comparing them lets a 97% gap read as a fact about the
4927
+ * disks.
4928
+ */
4929
+ function warnOnUsageMismatch(logger, deviceBytes, volumeBytes, read) {
4930
+ if (read !== "archive") return;
4931
+ if (volumeBytes === 0) return;
4932
+ const gap = Math.abs(volumeBytes - deviceBytes);
4933
+ if (gap <= USAGE_MISMATCH_FLOOR_BYTES) return;
4934
+ if (gap <= volumeBytes * USAGE_MISMATCH_SHARE) return;
4935
+ logger.warn("recorder: the per-camera storage sum disagrees with the per-volume total — one of the two reads saw a fraction of the archive", { meta: {
4936
+ deviceBytes,
4937
+ volumeBytes,
4938
+ gap
4939
+ } });
4940
+ }
4941
+ /**
4942
+ * The per-camera archive rows, or `null` — a REFUSAL — when they would not be
4943
+ * the archive (D674, review C1).
4944
+ *
4945
+ * The recorder registers its providers before `onHubReachable` loads the
4946
+ * ledger, and a failed load used to last the runner's life. In both windows the
4947
+ * mirror holds only the rows written since boot, and `usageByDevice` folded
4948
+ * them into a confident `devicesRead: 'archive'` answer — which the retention
4949
+ * floor reads, so every follow-recordings camera was swept on a floor that was
4950
+ * too young. Now:
4951
+ *
4952
+ * - no ledger, or a load that has not answered (including mid-load) ⇒ `null`,
4953
+ * and an unanswered load is retried (bounded, never awaited);
4954
+ * - no resolved location yet ⇒ `null`: an empty id set folds to "no camera
4955
+ * has footage", which is not what an unresolved location means.
4956
+ */
4957
+ function archiveDeviceUsageOf(ledger, locationIds) {
4958
+ if (ledger === null) return null;
4959
+ if (!ledger.hasLoaded()) {
4960
+ ledger.retryLoadIfUnanswered();
4961
+ return null;
4962
+ }
4963
+ if (locationIds.size === 0) return null;
4964
+ return ledger.usageByDevice(locationIds);
4965
+ }
4966
+ /**
4967
+ * Is an `archive` answer WHOLE — may a camera ABSENT from it be read as "no
4968
+ * footage"? Only when the ledger loaded without hitting its row cap and at
4969
+ * least one location resolved. The storage-usage response carries this as
4970
+ * `complete` (D674, review M2).
4971
+ */
4972
+ function archiveUsageComplete(ledger, locationIds) {
4973
+ return ledger !== null && ledger.isComplete() && locationIds.size > 0;
4974
+ }
4975
+ //#endregion
4602
4976
  //#region src/recorder/addon/config-store.ts
4603
4977
  /**
4604
4978
  * Per-device recording-config persistence for the recorder addon.
@@ -5644,9 +6018,7 @@ Object.freeze({
5644
6018
  "getMotionEvents": { "limit": 1e3 },
5645
6019
  "getObjectEvents": { "limit": 1e3 },
5646
6020
  "getSensorEvents": { "limit": 1e3 },
5647
- "listRecentSummaries": { "limit": 100 },
5648
6021
  "listRecentTracks": { "limit": 200 },
5649
- "listSummaries": { "limit": 50 },
5650
6022
  "reclaimDebugMedia": { "mode": "report" },
5651
6023
  "searchObjectEvents": { "limit": 50 }
5652
6024
  },
@@ -7883,81 +8255,6 @@ function buildRecordingDeviceSchema() {
7883
8255
  }]
7884
8256
  }] };
7885
8257
  }
7886
- //#endregion
7887
- //#region src/recorder/addon/device-storage-usage.ts
7888
- /**
7889
- * Gap between the per-camera sum and the per-volume total that is worth
7890
- * saying out loud, as a share of the volume total.
7891
- *
7892
- * The two are aggregations of the SAME hour rows over the same location set —
7893
- * one folded per camera, one summed in SQL — so in a healthy recorder they
7894
- * agree to the byte. A few tenths of a percent is a write landing between the
7895
- * two reads; 97% (60.5 GB against 1.9 TB) is the defect this file closes,
7896
- * rebuilt somewhere new.
7897
- */
7898
- var USAGE_MISMATCH_SHARE = .02;
7899
- /** Floor under {@link USAGE_MISMATCH_SHARE} so a nearly empty disk does not
7900
- * warn on a single in-flight segment. */
7901
- var USAGE_MISMATCH_FLOOR_BYTES = 1e9;
7902
- /**
7903
- * One row per camera the ARCHIVE holds footage for on `locationIds`, biggest
7904
- * first (deviceId breaks a tie, so two consecutive calls compare).
7905
- *
7906
- * A node with no ledger REFUSES (D295 rule 3): empty rows, `read:
7907
- * 'unavailable'`, and a line saying so. Falling back to the index would answer
7908
- * the same question with the wrong quantity and look exactly like a correct
7909
- * answer, which is how this shipped in the first place.
7910
- */
7911
- function buildDeviceUsage(input) {
7912
- if (input.archive === null) {
7913
- input.logger.warn("recorder: no hour ledger on this node — the per-camera storage rows are UNANSWERED, not empty", { meta: { locations: [...input.locationIds].toSorted().join(",") } });
7914
- return {
7915
- devices: [],
7916
- totalUsedBytes: 0,
7917
- read: "unavailable"
7918
- };
7919
- }
7920
- const devices = [];
7921
- let totalUsedBytes = 0;
7922
- for (const [deviceId, archived] of input.archive) {
7923
- const resident = input.resident(deviceId);
7924
- totalUsedBytes += archived.bytes;
7925
- devices.push({
7926
- deviceId,
7927
- usedBytes: archived.bytes,
7928
- oldestMs: archived.oldestMs,
7929
- segments: archived.segments,
7930
- residentBytes: resident.bytes,
7931
- residentOldestMs: resident.oldestMs
7932
- });
7933
- }
7934
- return {
7935
- devices: devices.toSorted((a, b) => b.usedBytes - a.usedBytes || a.deviceId - b.deviceId),
7936
- totalUsedBytes,
7937
- read: "archive"
7938
- };
7939
- }
7940
- /**
7941
- * Say it when the per-camera sum and the per-volume total disagree.
7942
- *
7943
- * The "I saw N of M" rule of D295, applied to a REPORT: these two numbers are
7944
- * the same rows counted two ways, so a gap means one of the two reads saw a
7945
- * fraction of the archive — and the whole point of D418 is that a page which
7946
- * shows both without comparing them lets a 97% gap read as a fact about the
7947
- * disks.
7948
- */
7949
- function warnOnUsageMismatch(logger, deviceBytes, volumeBytes, read) {
7950
- if (read !== "archive") return;
7951
- if (volumeBytes === 0) return;
7952
- const gap = Math.abs(volumeBytes - deviceBytes);
7953
- if (gap <= USAGE_MISMATCH_FLOOR_BYTES) return;
7954
- if (gap <= volumeBytes * USAGE_MISMATCH_SHARE) return;
7955
- logger.warn("recorder: the per-camera storage sum disagrees with the per-volume total — one of the two reads saw a fraction of the archive", { meta: {
7956
- deviceBytes,
7957
- volumeBytes,
7958
- gap
7959
- } });
7960
- }
7961
8258
  /** The placement-store key for one (camera, profile). Mirrors `assignmentKey`
7962
8259
  * in `placement-store.ts`; kept local so this module stays dependency-free. */
7963
8260
  function sourceKey(source) {
@@ -8590,6 +8887,7 @@ function buildRecordingProvider(deps) {
8590
8887
  totalUsedBytes,
8591
8888
  devices: [...devices],
8592
8889
  devicesRead: deviceUsage.read,
8890
+ complete: deviceUsage.read === "archive" && (deps.archiveUsageComplete?.(locationIds) ?? false),
8593
8891
  locations: locationUsages
8594
8892
  };
8595
8893
  },
@@ -9814,6 +10112,10 @@ var CENSUS_FIELDS = [
9814
10112
  ];
9815
10113
  /** Summary rows folded into the mirror between yields. */
9816
10114
  var LOAD_CHUNK = 5e3;
10115
+ /** Minimum spacing between ledger load attempts after a failure (D674). */
10116
+ var LEDGER_LOAD_RETRY_MS = 6e4;
10117
+ /** Spacing of the retries after the fast ones — slower, never stopped. */
10118
+ var LEDGER_LOAD_SLOW_RETRY_MS = 10 * 6e4;
9817
10119
  /** The four scalars an accounting answer is made of, asked in one statement. */
9818
10120
  var ACCOUNTING_FIELDS = [
9819
10121
  {
@@ -10009,6 +10311,22 @@ var SegmentHourLedger = class SegmentHourLedger {
10009
10311
  * the recorder happens to be placing that hour on.
10010
10312
  */
10011
10313
  refusedWriterHours = /* @__PURE__ */ new Set();
10314
+ /**
10315
+ * Has a {@link load} ANSWERED — its query returned AND every row it returned
10316
+ * is staged? Before that the mirror is "not read yet" (or, mid-load, a
10317
+ * fraction), never "the archive is empty". `load` swallows its failure, so a
10318
+ * reader that must tell the two apart (the calendar's window floor, the
10319
+ * storage-usage rows a retention floor is read from — D674) asks here.
10320
+ */
10321
+ loaded = false;
10322
+ /** The last successful load returned {@link rowCap} rows: hours past the cap
10323
+ * are UNKNOWN, so the mirror is an answer but not a WHOLE one. */
10324
+ loadHitRowCap = false;
10325
+ /** Load attempts made, boot included — the retry is bounded (D674). */
10326
+ loadAttempts = 0;
10327
+ lastLoadAttemptMs = null;
10328
+ loadInFlight = null;
10329
+ loadRetriesExhaustedReported = false;
10012
10330
  /** Entry counts of the in-process maps, for the recorder census. */
10013
10331
  memoryCensus() {
10014
10332
  let accountedPaths = 0;
@@ -10057,17 +10375,70 @@ var SegmentHourLedger = class SegmentHourLedger {
10057
10375
  * seed the index (D523).
10058
10376
  */
10059
10377
  async load() {
10378
+ this.loadInFlight ??= this.loadOnce().finally(() => {
10379
+ this.loadInFlight = null;
10380
+ });
10381
+ return this.loadInFlight;
10382
+ }
10383
+ /**
10384
+ * Retry a load that has not answered — rate-limited, logged, never abandoned
10385
+ * (D674).
10386
+ *
10387
+ * A failed boot load used to last the runner's life: every reader then saw
10388
+ * only the rows written since boot, and `getStorageUsage` labelled them the
10389
+ * archive. Readers call this and never wait on it (it is fire-and-forget at
10390
+ * every call site); at most one attempt per {@link LEDGER_LOAD_RETRY_MS} for
10391
+ * the first {@link LEDGER_LOAD_MAX_ATTEMPTS}, then one per
10392
+ * {@link LEDGER_LOAD_SLOW_RETRY_MS}; the slowdown is said once.
10393
+ */
10394
+ retryLoadIfUnanswered() {
10395
+ if (this.loaded || this.loadInFlight !== null) return;
10396
+ const slow = this.loadAttempts >= 10;
10397
+ if (slow && !this.loadRetriesExhaustedReported) {
10398
+ this.loadRetriesExhaustedReported = true;
10399
+ this.logger.warn("recorder: segment-hour ledger still not loaded — retrying at the slow interval; archive-wide answers stay REFUSED until it loads", { meta: {
10400
+ attempts: this.loadAttempts,
10401
+ slowRetryMs: LEDGER_LOAD_SLOW_RETRY_MS
10402
+ } });
10403
+ }
10404
+ const spacing = slow ? LEDGER_LOAD_SLOW_RETRY_MS : LEDGER_LOAD_RETRY_MS;
10405
+ const now = this.now();
10406
+ if (this.lastLoadAttemptMs !== null && now - this.lastLoadAttemptMs < spacing) return;
10407
+ if (!slow) this.logger.info("recorder: retrying the segment-hour ledger load", { meta: {
10408
+ attempt: this.loadAttempts + 1,
10409
+ fastAttempts: 10
10410
+ } });
10411
+ this.load();
10412
+ }
10413
+ /**
10414
+ * Is the mirror a WHOLE answer about the archive: loaded, and not cut at the
10415
+ * row cap? The storage-usage rows and the calendar floor are only facts about
10416
+ * the archive when this is true.
10417
+ */
10418
+ isComplete() {
10419
+ return this.loaded && !this.loadHitRowCap;
10420
+ }
10421
+ async loadOnce() {
10060
10422
  const startedMs = this.now();
10423
+ this.loadAttempts += 1;
10424
+ this.lastLoadAttemptMs = startedMs;
10061
10425
  let records;
10062
10426
  try {
10063
10427
  records = await this.store.query.query({
10064
10428
  collection: RECORDING_SEGMENT_HOURS_COLLECTION,
10065
- filter: { limit: this.rowCap }
10429
+ filter: {
10430
+ limit: this.rowCap,
10431
+ orderBy: {
10432
+ field: "hourStartMs",
10433
+ direction: "desc"
10434
+ }
10435
+ }
10066
10436
  });
10067
10437
  } catch (err) {
10068
10438
  this.logger.warn("recorder: segment-hour ledger load failed — keeping the hours already held", { meta: {
10069
10439
  collection: RECORDING_SEGMENT_HOURS_COLLECTION,
10070
10440
  held: this.ledger.size,
10441
+ attempt: this.loadAttempts,
10071
10442
  error: String(err)
10072
10443
  } });
10073
10444
  return this.ledger.snapshot();
@@ -10083,10 +10454,12 @@ var SegmentHourLedger = class SegmentHourLedger {
10083
10454
  skipped += 1;
10084
10455
  continue;
10085
10456
  }
10086
- this.ledger.stage(row);
10457
+ if (!this.ledger.has(row.key)) this.ledger.stage(row);
10087
10458
  }
10088
10459
  if (end < records.length) await this.yieldBetween();
10089
10460
  }
10461
+ this.loaded = true;
10462
+ this.loadHitRowCap = records.length >= this.rowCap;
10090
10463
  if (skipped > 0) this.logger.warn("recorder: segment-hour rows skipped as malformed — they gate NOTHING", { meta: {
10091
10464
  collection: RECORDING_SEGMENT_HOURS_COLLECTION,
10092
10465
  skipped
@@ -10108,6 +10481,31 @@ var SegmentHourLedger = class SegmentHourLedger {
10108
10481
  snapshot() {
10109
10482
  return this.ledger.snapshot();
10110
10483
  }
10484
+ /** Whether a {@link load} has answered and staged every row it returned —
10485
+ * see {@link loaded}. {@link isComplete} is the stronger question. */
10486
+ hasLoaded() {
10487
+ return this.loaded;
10488
+ }
10489
+ /**
10490
+ * The OLDEST hour the archive holds per camera, across every profile and
10491
+ * every location — the floor below which no hour can hold footage (D674).
10492
+ *
10493
+ * Every row counts, including one whose segments were all dropped and that
10494
+ * the prune has not yet forgotten: an older floor only walks a few more
10495
+ * hours, while a floor that is too YOUNG hides footage. One fold, no I/O —
10496
+ * the same shape as {@link devicesWithFootage}. Meaningful only after
10497
+ * {@link isComplete}; even then a camera ABSENT from the map is unknown to
10498
+ * the calendar (an hour not adopted yet is on disk and absent here), never a
10499
+ * known "no footage" (D674).
10500
+ */
10501
+ oldestHourByDevice() {
10502
+ const out = /* @__PURE__ */ new Map();
10503
+ for (const row of this.ledger.snapshot()) {
10504
+ const held = out.get(row.deviceId);
10505
+ if (held === void 0 || row.hourStartMs < held) out.set(row.deviceId, row.hourStartMs);
10506
+ }
10507
+ return out;
10508
+ }
10111
10509
  /**
10112
10510
  * Which profiles each of these cameras has footage for IN THE ARCHIVE.
10113
10511
  *
@@ -20493,12 +20891,17 @@ var RecorderV2Addon = class extends BaseAddon {
20493
20891
  /** LAZY: a field initialiser runs before `initialize()` assigns `ctx`, and
20494
20892
  * reading `this.ctx` there throws — the same trap that bit the playback tier. */
20495
20893
  get calendar() {
20496
- this.calendarInstance ??= new CalendarIndex({
20894
+ this.calendarInstance ??= this.buildCalendar();
20895
+ return this.calendarInstance;
20896
+ }
20897
+ buildCalendar() {
20898
+ const floors = new ArchiveFloorReader(() => this.segmentHours);
20899
+ return new CalendarIndex({
20497
20900
  roots: () => this.resolvedLocations.map((l) => l.root),
20498
20901
  logger: this.ctx.logger,
20499
- indexHour: (deviceId, profile, hourStartMs) => hourSegmentsFromIndex(this.index, deviceId, profile, hourStartMs)
20902
+ indexHour: (deviceId, profile, hourStartMs) => hourSegmentsFromIndex(this.index, deviceId, profile, hourStartMs),
20903
+ archiveFloor: (deviceId) => floors.floorOf(deviceId)
20500
20904
  });
20501
- return this.calendarInstance;
20502
20905
  }
20503
20906
  segmentStore = null;
20504
20907
  /** Sync-sample tables captured at finalize, served by the directory so a
@@ -20963,7 +21366,8 @@ var RecorderV2Addon = class extends BaseAddon {
20963
21366
  refreshLocations: () => this.refreshLocations(),
20964
21367
  capacity: (root) => this.locationCapacity(root),
20965
21368
  archiveAccounting: (locationIds) => this.segmentHours?.accountingForLocations(locationIds) ?? Promise.resolve(EMPTY_ARCHIVE_ACCOUNTING),
20966
- archiveDeviceUsage: (locationIds) => this.segmentHours?.usageByDevice(locationIds) ?? null,
21369
+ archiveDeviceUsage: (locationIds) => archiveDeviceUsageOf(this.segmentHours, locationIds),
21370
+ archiveUsageComplete: (locationIds) => archiveUsageComplete(this.segmentHours, locationIds),
20967
21371
  archive: () => this.segmentHours,
20968
21372
  ledgerWalk: this.ledgerWalkReconciler(),
20969
21373
  hydrateDevice: (deviceId, locations) => hydrateDeviceFromStorage(this.ctx.api, this.index, deviceId, locations, this.ctx.logger),