@camstack/addon-pipeline 1.2.304 → 1.2.305

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-BroB5Jp1.mjs → default-detection-model-BduwEjht.mjs} +1 -1
  4. package/dist/{default-detection-model-vN686W1U.js → default-detection-model-BsXfdMCG.js} +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-BdYgTuWJ.mjs} +4011 -3654
  8. package/dist/{dist-CyWAhtbJ.js → dist-D4lbkRUh.js} +4010 -3653
  9. package/dist/motion-wasm/index.js +1 -1
  10. package/dist/motion-wasm/index.mjs +1 -1
  11. package/dist/{node-BDrkzYiH.js → node-BMmRQfH_.js} +1 -1
  12. package/dist/{node-C5LXX8Qo.mjs → node-DTG5A-3e.mjs} +1 -1
  13. package/dist/pipeline-runner/index.js +3 -3
  14. package/dist/pipeline-runner/index.mjs +3 -3
  15. package/dist/{process-memory-DMS-0oRT.js → process-memory-DJrgIn1c.js} +1 -1
  16. package/dist/{process-memory-CCx6i9av.mjs → process-memory-RnWS9Et5.mjs} +1 -1
  17. package/dist/recorder/index.js +542 -136
  18. package/dist/recorder/index.mjs +542 -136
  19. package/dist/{segment-demux-js-CwQuMR0h.js → segment-demux-js-BI9zEgk6.js} +1 -1
  20. package/dist/{segment-demux-js-BUxZFqkb.mjs → segment-demux-js-CsJMEslt.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-Vcuvgik_.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-X_j7TiDp.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-CR9IIPsz.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-C3s1ZFbg.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
@@ -3,8 +3,8 @@ Object.defineProperties(exports, {
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
5
  const require_chunk = require("../chunk-emK7D4bc.js");
6
- const require_dist = require("../dist-CyWAhtbJ.js");
7
- const require_node = require("../node-BDrkzYiH.js");
6
+ const require_dist = require("../dist-D4lbkRUh.js");
7
+ const require_node = require("../node-BMmRQfH_.js");
8
8
  const require_hub_hostname = require("../hub-hostname-DAJXlOgV.js");
9
9
  const require_restream_intent = require("../restream-intent-7uDNqMKs.js");
10
10
  const require_retire_root_keys = require("../retire-root-keys-Csb8z2f3.js");
@@ -840,15 +840,15 @@ function parseSegmentPath(relPath) {
840
840
  //#region src/recorder/recording-index.ts
841
841
  /** Hour bucket, the granularity the path convention (`.../YYYY/MM/DD/HH/`)
842
842
  * already gives us — so "have I walked this?" costs one Set lookup. */
843
- var HOUR_MS$9 = 36e5;
843
+ var HOUR_MS$11 = 36e5;
844
844
  /** The hour buckets a half-open span touches. */
845
845
  function* hourBuckets(fromMs, toMs) {
846
- const first = Math.floor(fromMs / HOUR_MS$9) * HOUR_MS$9;
847
- for (let h = first; h < toMs; h += HOUR_MS$9) yield h;
846
+ const first = Math.floor(fromMs / HOUR_MS$11) * HOUR_MS$11;
847
+ for (let h = first; h < toMs; h += HOUR_MS$11) yield h;
848
848
  }
849
849
  /** The hour bucket a segment belongs to — the same one its path encodes. */
850
850
  function hourOf$1(startMs) {
851
- return Math.floor(startMs / HOUR_MS$9) * HOUR_MS$9;
851
+ return Math.floor(startMs / HOUR_MS$11) * HOUR_MS$11;
852
852
  }
853
853
  /** The production bound. See {@link RecordingIndexBudget} for the measurement. */
854
854
  var DEFAULT_RECORDING_INDEX_BUDGET = {
@@ -1226,8 +1226,8 @@ var RecordingIndex = class {
1226
1226
  const w = this.walked.get(deviceId);
1227
1227
  if (w === void 0 || w.size === 0) return;
1228
1228
  const tick = ++this.useTick;
1229
- if (Math.ceil(Math.max(0, toMs - fromMs) / HOUR_MS$9) > w.size) {
1230
- for (const h of w.keys()) if (h < toMs && h + HOUR_MS$9 > fromMs) w.set(h, tick);
1229
+ if (Math.ceil(Math.max(0, toMs - fromMs) / HOUR_MS$11) > w.size) {
1230
+ for (const h of w.keys()) if (h < toMs && h + HOUR_MS$11 > fromMs) w.set(h, tick);
1231
1231
  return;
1232
1232
  }
1233
1233
  for (const h of hourBuckets(fromMs, toMs)) if (w.has(h)) w.set(h, tick);
@@ -1264,7 +1264,7 @@ var RecordingIndex = class {
1264
1264
  if (rows <= budget.residentRowBudget && hours <= budget.residentHourBudget) return;
1265
1265
  const rowTarget = Math.floor(budget.residentRowBudget * budget.evictToFraction);
1266
1266
  const hourTarget = Math.floor(budget.residentHourBudget * budget.evictToFraction);
1267
- const pinnedFrom = hourOf$1(deps.now()) - Math.max(0, budget.pinnedTailHours - 1) * HOUR_MS$9;
1267
+ const pinnedFrom = hourOf$1(deps.now()) - Math.max(0, budget.pinnedTailHours - 1) * HOUR_MS$11;
1268
1268
  const counts = this.hourHistogram();
1269
1269
  const candidates = [];
1270
1270
  for (const [deviceId, byHour] of counts) {
@@ -1386,7 +1386,7 @@ var RecordingIndex = class {
1386
1386
  changed = true;
1387
1387
  }
1388
1388
  if (changed) this.invalidateSorted(deviceId);
1389
- this.markHydrated(deviceId, hourStartMs, hourStartMs + HOUR_MS$9);
1389
+ this.markHydrated(deviceId, hourStartMs, hourStartMs + HOUR_MS$11);
1390
1390
  this.enforceResidency();
1391
1391
  }
1392
1392
  /**
@@ -1411,7 +1411,7 @@ var RecordingIndex = class {
1411
1411
  const dropped = [];
1412
1412
  for (const [path, row] of m) {
1413
1413
  if (row.locationId !== locationId) continue;
1414
- if (Math.floor(row.startMs / HOUR_MS$9) * HOUR_MS$9 !== hourStartMs) continue;
1414
+ if (Math.floor(row.startMs / HOUR_MS$11) * HOUR_MS$11 !== hourStartMs) continue;
1415
1415
  if (keep.has(path)) continue;
1416
1416
  dropped.push(path);
1417
1417
  }
@@ -2074,11 +2074,11 @@ function hourDirRelPath(deviceId, profile, hourStartMs) {
2074
2074
  const p2 = (n) => String(n).padStart(2, "0");
2075
2075
  return `${deviceId}/${profile}/${d.getUTCFullYear()}/${p2(d.getUTCMonth() + 1)}/${p2(d.getUTCDate())}/${p2(d.getUTCHours())}`;
2076
2076
  }
2077
- var HOUR_MS$8 = 36e5;
2077
+ var HOUR_MS$10 = 36e5;
2078
2078
  async function hydrateWindowFromStorage(index, deviceId, fromMs, toMs, locations, logger, ledger, options) {
2079
2079
  const verify = options?.verifyClosedHours === true;
2080
2080
  if (!verify && index.hydrationOf(deviceId, fromMs, toMs) === "hydrated") return;
2081
- const liveHour = Math.floor(Date.now() / HOUR_MS$8) * HOUR_MS$8;
2081
+ const liveHour = Math.floor(Date.now() / HOUR_MS$10) * HOUR_MS$10;
2082
2082
  for (const [root, aliases] of aliasesByRoot(locations)) {
2083
2083
  const deviceDir = node_path.default.join(root, String(deviceId));
2084
2084
  let profiles;
@@ -2094,15 +2094,15 @@ async function hydrateWindowFromStorage(index, deviceId, fromMs, toMs, locations
2094
2094
  });
2095
2095
  continue;
2096
2096
  }
2097
- const first = Math.floor(fromMs / HOUR_MS$8) * HOUR_MS$8;
2097
+ const first = Math.floor(fromMs / HOUR_MS$10) * HOUR_MS$10;
2098
2098
  const reuseBefore = index.hydrateReuseStats();
2099
2099
  let hoursRead = 0;
2100
2100
  let repaired = 0;
2101
2101
  let forgottenSegments = 0;
2102
2102
  let forgottenHours = 0;
2103
2103
  let sealed = 0;
2104
- for (let hour = first; hour < toMs; hour += HOUR_MS$8) {
2105
- if (!verify && hour < liveHour && index.hydrationOf(deviceId, hour, hour + HOUR_MS$8) === "hydrated") continue;
2104
+ for (let hour = first; hour < toMs; hour += HOUR_MS$10) {
2105
+ if (!verify && hour < liveHour && index.hydrationOf(deviceId, hour, hour + HOUR_MS$10) === "hydrated") continue;
2106
2106
  for (const profile of profiles) {
2107
2107
  const rel = hourDirRelPath(deviceId, profile, hour);
2108
2108
  let files;
@@ -2115,7 +2115,7 @@ async function hydrateWindowFromStorage(index, deviceId, fromMs, toMs, locations
2115
2115
  const locationId = locationForHydratedProfile(aliases, profile).id;
2116
2116
  const relPaths = files.filter((f) => f.endsWith(".m4s")).map((f) => `${rel}/${f}`);
2117
2117
  index.hydrateHour(deviceId, locationId, hour, relPaths);
2118
- if (ledger === void 0 || relPaths.length === 0 || hour + HOUR_MS$8 > Date.now()) continue;
2118
+ if (ledger === void 0 || relPaths.length === 0 || hour + HOUR_MS$10 > Date.now()) continue;
2119
2119
  const outcome = await ledger.adoptWalkedHour(deviceId, profile, locationId, hour, relPaths);
2120
2120
  if (outcome === null) continue;
2121
2121
  if (outcome.created) repaired += 1;
@@ -2197,7 +2197,7 @@ async function hydrateWindowFromStorage(index, deviceId, fromMs, toMs, locations
2197
2197
  */
2198
2198
  var DEFAULT_THROTTLE_MBPS = 40;
2199
2199
  /** The durable index's bucket, and therefore this mover's batch. */
2200
- var HOUR_MS$7 = 36e5;
2200
+ var HOUR_MS$9 = 36e5;
2201
2201
  /** Progress callback cadence (files) — cheap enough to fire often. */
2202
2202
  var PROGRESS_EVERY_FILES = 25;
2203
2203
  /** Free space a destination must keep when it declares no `config.minFreePercent`
@@ -2886,7 +2886,7 @@ function profilesOfClass(footageClass) {
2886
2886
  function groupByHour(rows) {
2887
2887
  const out = /* @__PURE__ */ new Map();
2888
2888
  for (const row of rows) {
2889
- const key = `${row.deviceId}|${row.profile}|${Math.floor(row.startMs / HOUR_MS$7)}`;
2889
+ const key = `${row.deviceId}|${row.profile}|${Math.floor(row.startMs / HOUR_MS$9)}`;
2890
2890
  const found = out.get(key);
2891
2891
  if (found) found.push(row);
2892
2892
  else out.set(key, [row]);
@@ -4110,6 +4110,231 @@ async function profileHasFootage(root, deviceId, profile) {
4110
4110
  return (await listDir(node_path.default.join(root, String(deviceId), profile))).length > 0;
4111
4111
  }
4112
4112
  //#endregion
4113
+ //#region src/recorder/addon/calendar-hour-cache.ts
4114
+ var HOUR_MS$8 = 36e5;
4115
+ var HOURS_PER_DAY = 24;
4116
+ /**
4117
+ * The production bound: the RAM index's own (`DEFAULT_RECORDING_INDEX_BUDGET`,
4118
+ * 250 000 rows / 20 000 walked hours, measured at 698 B/row). The calendar
4119
+ * holds a 3-scalar `HourSegment` per segment where the index holds a full row,
4120
+ * so the same counts are a SMALLER heap here — see
4121
+ * {@link CALENDAR_SEGMENT_APPROX_BYTES}.
4122
+ */
4123
+ var DEFAULT_CALENDAR_HOUR_BUDGET = {
4124
+ hourBudget: DEFAULT_RECORDING_INDEX_BUDGET.residentHourBudget,
4125
+ segmentBudget: DEFAULT_RECORDING_INDEX_BUDGET.residentRowBudget,
4126
+ evictToFraction: DEFAULT_RECORDING_INDEX_BUDGET.evictToFraction
4127
+ };
4128
+ /** Every empty hour shares this one array: a gap costs a slot, not an object. */
4129
+ var EMPTY_HOUR = Object.freeze([]);
4130
+ /** Hours since the epoch — a Smi, where `hourMs` would be a HeapNumber. */
4131
+ function hourIndexOf(epochMs) {
4132
+ return Math.floor(epochMs / HOUR_MS$8);
4133
+ }
4134
+ var CalendarHourCache = class {
4135
+ budget;
4136
+ byDevice = /* @__PURE__ */ new Map();
4137
+ /** Least recently used first — `Set` iteration order is insertion order. */
4138
+ lru = /* @__PURE__ */ new Set();
4139
+ segmentCount = 0;
4140
+ emptyCount = 0;
4141
+ stampedCount = 0;
4142
+ evictedCount = 0;
4143
+ constructor(budget = DEFAULT_CALENDAR_HOUR_BUDGET) {
4144
+ this.budget = budget;
4145
+ }
4146
+ /** The cached segments of one hour, or `undefined` when not cached. */
4147
+ get(deviceId, profile, hourMs) {
4148
+ const entry = this.entryOf(deviceId, profile, hourMs);
4149
+ if (entry === void 0) return void 0;
4150
+ this.lru.delete(entry);
4151
+ this.lru.add(entry);
4152
+ return entry.segments;
4153
+ }
4154
+ /** When this hour was read AS THE CURRENT HOUR, or `undefined`. */
4155
+ readAtOf(deviceId, profile, hourMs) {
4156
+ return this.entryOf(deviceId, profile, hourMs)?.readAtMs ?? void 0;
4157
+ }
4158
+ /**
4159
+ * Store one COMPLETE scan. `currentReadAtMs` is set only when the hour was
4160
+ * the current one at store time; a later store of the closed hour passes
4161
+ * `null` and so drops the stamp with the entry it replaces.
4162
+ */
4163
+ set(deviceId, profile, hourMs, segments, currentReadAtMs) {
4164
+ const hourIndex = hourIndexOf(hourMs);
4165
+ this.remove(deviceId, profile, hourIndex);
4166
+ const entry = {
4167
+ deviceId,
4168
+ profile,
4169
+ hourIndex,
4170
+ segments: segments.length === 0 ? EMPTY_HOUR : segments,
4171
+ readAtMs: currentReadAtMs
4172
+ };
4173
+ this.profileMap(deviceId, profile).set(hourIndex, entry);
4174
+ this.lru.add(entry);
4175
+ if (entry.segments.length === 0) this.emptyCount += 1;
4176
+ else this.segmentCount += entry.segments.length;
4177
+ if (entry.readAtMs !== null) this.stampedCount += 1;
4178
+ this.enforceBudget();
4179
+ }
4180
+ /**
4181
+ * Drop one camera's UTC day, every profile — what eviction changed. The read
4182
+ * stamps go with their hours; there is no second map to sweep.
4183
+ */
4184
+ invalidateDay(deviceId, utcDayMs) {
4185
+ const firstHour = hourIndexOf(utcDayMs);
4186
+ const profiles = this.byDevice.get(deviceId);
4187
+ if (profiles === void 0) return;
4188
+ for (const profile of [...profiles.keys()]) for (let h = 0; h < HOURS_PER_DAY; h++) this.remove(deviceId, profile, firstHour + h);
4189
+ }
4190
+ /** Full reset — a relocation re-pointed rows across locations. */
4191
+ clear() {
4192
+ this.byDevice.clear();
4193
+ this.lru.clear();
4194
+ this.segmentCount = 0;
4195
+ this.emptyCount = 0;
4196
+ this.stampedCount = 0;
4197
+ }
4198
+ census() {
4199
+ const hours = this.lru.size;
4200
+ return {
4201
+ hourKeys: hours - this.emptyCount,
4202
+ emptyHours: this.emptyCount,
4203
+ hourSegments: this.segmentCount,
4204
+ segsReadAtKeys: this.stampedCount,
4205
+ evictedHours: this.evictedCount,
4206
+ approxBytes: hours * 128 + this.segmentCount * 72 + this.stampedCount * 16
4207
+ };
4208
+ }
4209
+ entryOf(deviceId, profile, hourMs) {
4210
+ return this.byDevice.get(deviceId)?.get(profile)?.get(hourIndexOf(hourMs));
4211
+ }
4212
+ profileMap(deviceId, profile) {
4213
+ let profiles = this.byDevice.get(deviceId);
4214
+ if (profiles === void 0) {
4215
+ profiles = /* @__PURE__ */ new Map();
4216
+ this.byDevice.set(deviceId, profiles);
4217
+ }
4218
+ let hours = profiles.get(profile);
4219
+ if (hours === void 0) {
4220
+ hours = /* @__PURE__ */ new Map();
4221
+ profiles.set(profile, hours);
4222
+ }
4223
+ return hours;
4224
+ }
4225
+ /** Remove one hour and its stamp; prune empty parents. */
4226
+ remove(deviceId, profile, hourIndex) {
4227
+ const profiles = this.byDevice.get(deviceId);
4228
+ const hours = profiles?.get(profile);
4229
+ const entry = hours?.get(hourIndex);
4230
+ if (profiles === void 0 || hours === void 0 || entry === void 0) return;
4231
+ hours.delete(hourIndex);
4232
+ if (hours.size === 0) profiles.delete(profile);
4233
+ if (profiles.size === 0) this.byDevice.delete(deviceId);
4234
+ this.lru.delete(entry);
4235
+ if (entry.segments.length === 0) this.emptyCount -= 1;
4236
+ else this.segmentCount -= entry.segments.length;
4237
+ if (entry.readAtMs !== null) this.stampedCount -= 1;
4238
+ }
4239
+ enforceBudget() {
4240
+ const { hourBudget, segmentBudget, evictToFraction } = this.budget;
4241
+ if (this.lru.size <= hourBudget && this.segmentCount <= segmentBudget) return;
4242
+ const hourTarget = Math.floor(hourBudget * evictToFraction);
4243
+ const segmentTarget = Math.floor(segmentBudget * evictToFraction);
4244
+ for (const entry of this.lru) {
4245
+ if (this.lru.size <= hourTarget && this.segmentCount <= segmentTarget) return;
4246
+ this.remove(entry.deviceId, entry.profile, entry.hourIndex);
4247
+ this.evictedCount += 1;
4248
+ }
4249
+ }
4250
+ };
4251
+ //#endregion
4252
+ //#region src/recorder/addon/calendar-window.ts
4253
+ var HOUR_MS$7 = 36e5;
4254
+ /**
4255
+ * The widest walk one call may make AFTER the clamp: a leap year of hours.
4256
+ *
4257
+ * Evidence, not taste. The widest in-repo caller is the viewer's month
4258
+ * calendar (~33 days); the rest ask a day or a clip window (liveness 24 h,
4259
+ * videoclips' since/until, a timelapse window, a timeline day). The one that
4260
+ * asked for more wanted a single number the ledger already holds (D674). The
4261
+ * archives it would still admit are past what this system keeps: the 28 TB
4262
+ * projection is ~47 k hour rows, ~2,000 hours (~82 days) per camera/profile
4263
+ * pair at ~24 pairs, and the live hub held 3.3 days. (The ledger's load CAP,
4264
+ * `SEGMENT_HOUR_SUMMARY_LIMIT` = 250,000 rows, is ~10 k hours per pair — a
4265
+ * bound on memory, not a claim about retention; the clamp only ever narrows a
4266
+ * walk further.) A walk this wide costs ~130 B of garbage per hour warm
4267
+ * (measured: ~65 MB for 497,408 hours), so ~1.1 MB at the cap.
4268
+ */
4269
+ var CALENDAR_MAX_WALK_HOURS = 366 * 24;
4270
+ var UNKNOWN_ARCHIVE_FLOOR = { known: false };
4271
+ /** A walk the calendar will not make. Surfaces as `read: 'unreadable'`. */
4272
+ var CalendarWindowRefusedError = class extends Error {
4273
+ deviceId;
4274
+ hours;
4275
+ maxHours;
4276
+ floorKnown;
4277
+ name = "CalendarWindowRefusedError";
4278
+ constructor(deviceId, hours, maxHours, floorKnown) {
4279
+ 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`);
4280
+ this.deviceId = deviceId;
4281
+ this.hours = hours;
4282
+ this.maxHours = maxHours;
4283
+ this.floorKnown = floorKnown;
4284
+ }
4285
+ };
4286
+ /**
4287
+ * Clamp `[fromMs, toMs)` to the hours that can hold footage and refuse it when
4288
+ * it is still wider than `maxHours`. `null` = nothing left to walk.
4289
+ *
4290
+ * The writer's current hour and the one it just left are never clamped away:
4291
+ * the ledger is write-behind, so a camera's first segments may be on disk (and
4292
+ * in the RAM index) a moment before the ledger holds their hour.
4293
+ */
4294
+ function calendarWalk(deviceId, fromMs, toMs, nowMs, floor, maxHours = CALENDAR_MAX_WALK_HOURS) {
4295
+ const currentHour = Math.floor(nowMs / HOUR_MS$7) * HOUR_MS$7;
4296
+ const liveFloor = currentHour - HOUR_MS$7;
4297
+ const floorHour = floor.known ? Math.min(floor.oldestHourMs, liveFloor) : Number.NEGATIVE_INFINITY;
4298
+ const firstHourMs = Math.max(Math.floor(fromMs / HOUR_MS$7) * HOUR_MS$7, floorHour);
4299
+ const endMs = Math.min(toMs, currentHour + HOUR_MS$7);
4300
+ if (endMs <= firstHourMs || toMs <= fromMs) return null;
4301
+ const hours = Math.ceil((endMs - firstHourMs) / HOUR_MS$7);
4302
+ if (hours > maxHours) throw new CalendarWindowRefusedError(deviceId, hours, maxHours, floor.known);
4303
+ return {
4304
+ firstHourMs,
4305
+ endMs,
4306
+ hours
4307
+ };
4308
+ }
4309
+ /** Per-camera archive floors, one ledger fold per {@link ARCHIVE_FLOOR_TTL_MS}. */
4310
+ var ArchiveFloorReader = class {
4311
+ source;
4312
+ now;
4313
+ folded = null;
4314
+ constructor(source, now = () => Date.now()) {
4315
+ this.source = source;
4316
+ this.now = now;
4317
+ }
4318
+ floorOf(deviceId) {
4319
+ const ledger = this.source();
4320
+ if (ledger === null) return UNKNOWN_ARCHIVE_FLOOR;
4321
+ if (!ledger.isComplete()) {
4322
+ ledger.retryLoadIfUnanswered();
4323
+ return UNKNOWN_ARCHIVE_FLOOR;
4324
+ }
4325
+ const nowMs = this.now();
4326
+ if (this.folded === null || nowMs - this.folded.atMs >= 6e4) this.folded = {
4327
+ atMs: nowMs,
4328
+ floors: ledger.oldestHourByDevice()
4329
+ };
4330
+ const oldestHourMs = this.folded.floors.get(deviceId);
4331
+ return oldestHourMs === void 0 ? UNKNOWN_ARCHIVE_FLOOR : {
4332
+ known: true,
4333
+ oldestHourMs
4334
+ };
4335
+ }
4336
+ };
4337
+ //#endregion
4113
4338
  //#region src/recorder/addon/calendar-index.ts
4114
4339
  var DAY_MS$3 = 864e5;
4115
4340
  var HOUR_MS$6 = 36e5;
@@ -4172,7 +4397,9 @@ function hourSegmentsFromIndex(index, deviceId, profile, hourStartMs) {
4172
4397
  *
4173
4398
  * Segments land every 8–12 s, so three seconds of staleness at the live edge
4174
4399
  * cannot hide a whole one — and the live view owns "now" regardless. Past hours
4175
- * stay cached indefinitely; only this one expires.
4400
+ * stay cached until eviction or the rung's bound (D674) drops them; only this
4401
+ * one expires — and it is read once more after it closes, so its last minutes
4402
+ * are never served from a stamp taken while it was still being written.
4176
4403
  */
4177
4404
  var CURRENT_HOUR_TTL_MS = 3e3;
4178
4405
  /**
@@ -4214,17 +4441,20 @@ async function mapBounded(items, limit, fn) {
4214
4441
  await Promise.all(workers);
4215
4442
  return out;
4216
4443
  }
4444
+ /** Estimated bytes per cached UTC day on the day rung (map slot + a short
4445
+ * array of hour numbers). */
4446
+ var DAY_RUNG_APPROX_BYTES = 96;
4217
4447
  var CalendarIndex = class {
4218
4448
  deps;
4219
4449
  /** `deviceId` → (UTC day ms → hours). Absent = never scanned. */
4220
4450
  byDevice = /* @__PURE__ */ new Map();
4221
4451
  /** Scans in flight, so a burst of queries costs one walk. */
4222
4452
  inFlight = /* @__PURE__ */ new Map();
4223
- /** `deviceId:profile:hourMs` → that hour's segments. The exact rung. */
4224
- hourSegs = /* @__PURE__ */ new Map();
4453
+ /** The exact rung: device → profile → hour → segments, bounded (D674). */
4454
+ hours;
4455
+ /** Hour scans in flight, keyed like the rung — so a burst costs one read. */
4225
4456
  segsInFlight = /* @__PURE__ */ new Map();
4226
- /** When each CURRENT-hour entry was read, for {@link CURRENT_HOUR_TTL_MS}. */
4227
- segsReadAt = /* @__PURE__ */ new Map();
4457
+ segsInFlightCount = 0;
4228
4458
  /** The profile availability reads from, resolved once per device. */
4229
4459
  profileFor = /* @__PURE__ */ new Map();
4230
4460
  profilesFor = /* @__PURE__ */ new Map();
@@ -4239,25 +4469,36 @@ var CalendarIndex = class {
4239
4469
  profileInFlight = /* @__PURE__ */ new Map();
4240
4470
  constructor(deps) {
4241
4471
  this.deps = deps;
4472
+ this.hours = new CalendarHourCache(deps.hourBudget ?? DEFAULT_CALENDAR_HOUR_BUDGET);
4242
4473
  }
4243
4474
  /**
4244
- * Entry counts for the recorder census. `hourSegs` is unbounded (it is
4245
- * invalidated on eviction and relocation, never aged), so its segment total
4246
- * is the number that says whether this cache is where the heap went.
4475
+ * Entry counts and an estimated byte total for the recorder census. The
4476
+ * exact rung is bounded (D674); `evictedHours` says whether the bound bites.
4247
4477
  */
4248
4478
  memoryCensus() {
4249
4479
  let days = 0;
4250
4480
  for (const perDay of this.byDevice.values()) days += perDay.size;
4251
- let hourSegments = 0;
4252
- for (const segments of this.hourSegs.values()) hourSegments += segments.length;
4481
+ const hours = this.hours.census();
4253
4482
  return {
4254
4483
  devices: this.byDevice.size,
4255
4484
  days,
4256
- hourKeys: this.hourSegs.size,
4257
- hourSegments,
4258
- inFlight: this.inFlight.size + this.segsInFlight.size
4485
+ ...hours,
4486
+ approxBytes: hours.approxBytes + days * DAY_RUNG_APPROX_BYTES,
4487
+ inFlight: this.inFlight.size + this.segsInFlightCount
4259
4488
  };
4260
4489
  }
4490
+ nowMs() {
4491
+ return this.deps.now?.() ?? Date.now();
4492
+ }
4493
+ /**
4494
+ * The hours a walk over `[fromMs, toMs)` may visit — clamped to the archive
4495
+ * floor and the live edge, REFUSED above the cap (see `calendar-window.ts`).
4496
+ * Throws {@link CalendarWindowRefusedError}; the provider turns that into
4497
+ * `read: 'unreadable'` and logs it.
4498
+ */
4499
+ walkOf(deviceId, fromMs, toMs) {
4500
+ return calendarWalk(deviceId, fromMs, toMs, this.nowMs(), this.deps.archiveFloor?.(deviceId) ?? UNKNOWN_ARCHIVE_FLOOR, this.deps.maxWalkHours ?? 8784);
4501
+ }
4261
4502
  mapFor(deviceId) {
4262
4503
  let m = this.byDevice.get(deviceId);
4263
4504
  if (!m) {
@@ -4318,8 +4559,10 @@ var CalendarIndex = class {
4318
4559
  * UTC day outside the range (23:30Z on the 28th is the 29th in CEST).
4319
4560
  */
4320
4561
  async localDays(deviceId, fromMs, toMs, tzOffsetMinutes) {
4321
- const first = utcDayOf(fromMs) - DAY_MS$3;
4322
- const last = utcDayOf(toMs) + DAY_MS$3;
4562
+ const walk = this.walkOf(deviceId, utcDayOf(fromMs) - DAY_MS$3, utcDayOf(toMs) + 2 * DAY_MS$3);
4563
+ if (walk === null) return [];
4564
+ const first = utcDayOf(walk.firstHourMs);
4565
+ const last = utcDayOf(walk.endMs - 1);
4323
4566
  const wanted = [];
4324
4567
  for (let day = first; day <= last; day += DAY_MS$3) wanted.push(day);
4325
4568
  const perDay = await mapBounded(wanted, READ_CONCURRENCY, async (day) => ({
@@ -4344,16 +4587,12 @@ var CalendarIndex = class {
4344
4587
  * rebuild lazily on the next question. */
4345
4588
  invalidateAll() {
4346
4589
  this.byDevice.clear();
4347
- this.hourSegs.clear();
4590
+ this.hours.clear();
4348
4591
  }
4349
4592
  invalidate(deviceId, atMs) {
4350
4593
  const day = utcDayOf(atMs);
4351
4594
  this.byDevice.get(deviceId)?.delete(day);
4352
- const prefix = `${deviceId}:`;
4353
- for (const key of this.hourSegs.keys()) {
4354
- if (!key.startsWith(prefix)) continue;
4355
- if (utcDayOf(Number(key.slice(key.lastIndexOf(":") + 1))) === day) this.hourSegs.delete(key);
4356
- }
4595
+ this.hours.invalidateDay(deviceId, day);
4357
4596
  }
4358
4597
  /**
4359
4598
  * Scan a span in the background so the first screen is already answered.
@@ -4411,28 +4650,28 @@ var CalendarIndex = class {
4411
4650
  * retries instead of caching a gap.
4412
4651
  */
4413
4652
  async segmentsOfChecked(deviceId, profile, hourStartMs, background = false) {
4414
- if (!background) this.lastForegroundAt = this.deps.now?.() ?? Date.now();
4653
+ if (!background) this.lastForegroundAt = this.nowMs();
4415
4654
  const hour = Math.floor(hourStartMs / HOUR_MS$6) * HOUR_MS$6;
4416
4655
  const fromIndex = this.deps.indexHour?.(deviceId, profile, hour);
4417
4656
  if (fromIndex !== null && fromIndex !== void 0) return {
4418
4657
  segments: fromIndex,
4419
4658
  complete: true
4420
4659
  };
4421
- const key = `${deviceId}:${profile}:${hour}`;
4422
- const now = this.deps.now?.() ?? Date.now();
4660
+ const now = this.nowMs();
4423
4661
  const isCurrentHour = hour === Math.floor(now / HOUR_MS$6) * HOUR_MS$6;
4424
- const cached = this.hourSegs.get(key);
4662
+ const cached = this.hours.get(deviceId, profile, hour);
4425
4663
  if (cached !== void 0) {
4426
- if (!isCurrentHour) return {
4664
+ const readAt = this.hours.readAtOf(deviceId, profile, hour);
4665
+ if (!isCurrentHour && readAt === void 0) return {
4427
4666
  segments: cached,
4428
4667
  complete: true
4429
4668
  };
4430
- if (now - (this.segsReadAt.get(key) ?? 0) < CURRENT_HOUR_TTL_MS) return {
4669
+ if (isCurrentHour && now - (readAt ?? 0) < CURRENT_HOUR_TTL_MS) return {
4431
4670
  segments: cached,
4432
4671
  complete: true
4433
4672
  };
4434
4673
  }
4435
- const running = this.segsInFlight.get(key);
4674
+ const running = this.inFlightScan(deviceId, profile, hour);
4436
4675
  if (running) return running;
4437
4676
  const scan = (async () => {
4438
4677
  const read = this.deps.scanSegments ?? segmentsOfHour;
@@ -4456,21 +4695,46 @@ var CalendarIndex = class {
4456
4695
  complete
4457
4696
  };
4458
4697
  })();
4459
- this.segsInFlight.set(key, scan);
4698
+ this.trackScan(deviceId, profile, hour, scan);
4460
4699
  try {
4461
4700
  const { segments, complete } = await scan;
4462
- if (complete) {
4463
- this.hourSegs.set(key, segments);
4464
- this.segsReadAt.set(key, this.deps.now?.() ?? Date.now());
4465
- }
4701
+ const storedAt = this.nowMs();
4702
+ const liveHour = Math.floor(storedAt / HOUR_MS$6) * HOUR_MS$6;
4703
+ if (complete && hour <= liveHour) this.hours.set(deviceId, profile, hour, segments, hour === liveHour ? storedAt : null);
4466
4704
  return {
4467
4705
  segments,
4468
4706
  complete
4469
4707
  };
4470
4708
  } finally {
4471
- if (this.segsInFlight.get(key) === scan) this.segsInFlight.delete(key);
4709
+ this.untrackScan(deviceId, profile, hour, scan);
4472
4710
  }
4473
4711
  }
4712
+ inFlightScan(deviceId, profile, hour) {
4713
+ return this.segsInFlight.get(deviceId)?.get(profile)?.get(hour) ?? null;
4714
+ }
4715
+ trackScan(deviceId, profile, hour, scan) {
4716
+ let profiles = this.segsInFlight.get(deviceId);
4717
+ if (profiles === void 0) {
4718
+ profiles = /* @__PURE__ */ new Map();
4719
+ this.segsInFlight.set(deviceId, profiles);
4720
+ }
4721
+ let hours = profiles.get(profile);
4722
+ if (hours === void 0) {
4723
+ hours = /* @__PURE__ */ new Map();
4724
+ profiles.set(profile, hours);
4725
+ }
4726
+ hours.set(hour, scan);
4727
+ this.segsInFlightCount += 1;
4728
+ }
4729
+ untrackScan(deviceId, profile, hour, scan) {
4730
+ const profiles = this.segsInFlight.get(deviceId);
4731
+ const hours = profiles?.get(profile);
4732
+ if (profiles === void 0 || hours === void 0 || hours.get(hour) !== scan) return;
4733
+ hours.delete(hour);
4734
+ this.segsInFlightCount -= 1;
4735
+ if (hours.size === 0) profiles.delete(profile);
4736
+ if (profiles.size === 0) this.segsInFlight.delete(deviceId);
4737
+ }
4474
4738
  /**
4475
4739
  * Exact availability ranges for a UTC day — what the progressive timeline
4476
4740
  * draws.
@@ -4498,9 +4762,10 @@ var CalendarIndex = class {
4498
4762
  */
4499
4763
  async rangesIn(deviceId, profile, fromMs, toMs) {
4500
4764
  if (toMs <= fromMs) return [];
4501
- const first = Math.floor(fromMs / HOUR_MS$6) * HOUR_MS$6;
4765
+ const walk = this.walkOf(deviceId, fromMs, toMs);
4766
+ if (walk === null) return [];
4502
4767
  const hours = [];
4503
- for (let hour = first; hour < toMs; hour += HOUR_MS$6) hours.push(hour);
4768
+ for (let hour = walk.firstHourMs; hour < walk.endMs; hour += HOUR_MS$6) hours.push(hour);
4504
4769
  const perHour = await mapBounded(hours, READ_CONCURRENCY, (hour) => this.segmentsOf(deviceId, profile, hour));
4505
4770
  const ranges = [];
4506
4771
  for (const r of mergeRanges(perHour.flat(), RANGE_MERGE_GAP_MS$1)) {
@@ -4606,6 +4871,115 @@ var CalendarIndex = class {
4606
4871
  }
4607
4872
  };
4608
4873
  //#endregion
4874
+ //#region src/recorder/addon/device-storage-usage.ts
4875
+ /**
4876
+ * Gap between the per-camera sum and the per-volume total that is worth
4877
+ * saying out loud, as a share of the volume total.
4878
+ *
4879
+ * The two are aggregations of the SAME hour rows over the same location set —
4880
+ * one folded per camera, one summed in SQL — so in a healthy recorder they
4881
+ * agree to the byte. A few tenths of a percent is a write landing between the
4882
+ * two reads; 97% (60.5 GB against 1.9 TB) is the defect this file closes,
4883
+ * rebuilt somewhere new.
4884
+ */
4885
+ var USAGE_MISMATCH_SHARE = .02;
4886
+ /** Floor under {@link USAGE_MISMATCH_SHARE} so a nearly empty disk does not
4887
+ * warn on a single in-flight segment. */
4888
+ var USAGE_MISMATCH_FLOOR_BYTES = 1e9;
4889
+ /**
4890
+ * One row per camera the ARCHIVE holds footage for on `locationIds`, biggest
4891
+ * first (deviceId breaks a tie, so two consecutive calls compare).
4892
+ *
4893
+ * A node with no ledger REFUSES (D295 rule 3): empty rows, `read:
4894
+ * 'unavailable'`, and a line saying so. Falling back to the index would answer
4895
+ * the same question with the wrong quantity and look exactly like a correct
4896
+ * answer, which is how this shipped in the first place.
4897
+ */
4898
+ function buildDeviceUsage(input) {
4899
+ if (input.archive === null) {
4900
+ 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(",") } });
4901
+ return {
4902
+ devices: [],
4903
+ totalUsedBytes: 0,
4904
+ read: "unavailable"
4905
+ };
4906
+ }
4907
+ const devices = [];
4908
+ let totalUsedBytes = 0;
4909
+ for (const [deviceId, archived] of input.archive) {
4910
+ const resident = input.resident(deviceId);
4911
+ totalUsedBytes += archived.bytes;
4912
+ devices.push({
4913
+ deviceId,
4914
+ usedBytes: archived.bytes,
4915
+ oldestMs: archived.oldestMs,
4916
+ segments: archived.segments,
4917
+ residentBytes: resident.bytes,
4918
+ residentOldestMs: resident.oldestMs
4919
+ });
4920
+ }
4921
+ return {
4922
+ devices: devices.toSorted((a, b) => b.usedBytes - a.usedBytes || a.deviceId - b.deviceId),
4923
+ totalUsedBytes,
4924
+ read: "archive"
4925
+ };
4926
+ }
4927
+ /**
4928
+ * Say it when the per-camera sum and the per-volume total disagree.
4929
+ *
4930
+ * The "I saw N of M" rule of D295, applied to a REPORT: these two numbers are
4931
+ * the same rows counted two ways, so a gap means one of the two reads saw a
4932
+ * fraction of the archive — and the whole point of D418 is that a page which
4933
+ * shows both without comparing them lets a 97% gap read as a fact about the
4934
+ * disks.
4935
+ */
4936
+ function warnOnUsageMismatch(logger, deviceBytes, volumeBytes, read) {
4937
+ if (read !== "archive") return;
4938
+ if (volumeBytes === 0) return;
4939
+ const gap = Math.abs(volumeBytes - deviceBytes);
4940
+ if (gap <= USAGE_MISMATCH_FLOOR_BYTES) return;
4941
+ if (gap <= volumeBytes * USAGE_MISMATCH_SHARE) return;
4942
+ 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: {
4943
+ deviceBytes,
4944
+ volumeBytes,
4945
+ gap
4946
+ } });
4947
+ }
4948
+ /**
4949
+ * The per-camera archive rows, or `null` — a REFUSAL — when they would not be
4950
+ * the archive (D674, review C1).
4951
+ *
4952
+ * The recorder registers its providers before `onHubReachable` loads the
4953
+ * ledger, and a failed load used to last the runner's life. In both windows the
4954
+ * mirror holds only the rows written since boot, and `usageByDevice` folded
4955
+ * them into a confident `devicesRead: 'archive'` answer — which the retention
4956
+ * floor reads, so every follow-recordings camera was swept on a floor that was
4957
+ * too young. Now:
4958
+ *
4959
+ * - no ledger, or a load that has not answered (including mid-load) ⇒ `null`,
4960
+ * and an unanswered load is retried (bounded, never awaited);
4961
+ * - no resolved location yet ⇒ `null`: an empty id set folds to "no camera
4962
+ * has footage", which is not what an unresolved location means.
4963
+ */
4964
+ function archiveDeviceUsageOf(ledger, locationIds) {
4965
+ if (ledger === null) return null;
4966
+ if (!ledger.hasLoaded()) {
4967
+ ledger.retryLoadIfUnanswered();
4968
+ return null;
4969
+ }
4970
+ if (locationIds.size === 0) return null;
4971
+ return ledger.usageByDevice(locationIds);
4972
+ }
4973
+ /**
4974
+ * Is an `archive` answer WHOLE — may a camera ABSENT from it be read as "no
4975
+ * footage"? Only when the ledger loaded without hitting its row cap and at
4976
+ * least one location resolved. The storage-usage response carries this as
4977
+ * `complete` (D674, review M2).
4978
+ */
4979
+ function archiveUsageComplete(ledger, locationIds) {
4980
+ return ledger !== null && ledger.isComplete() && locationIds.size > 0;
4981
+ }
4982
+ //#endregion
4609
4983
  //#region src/recorder/addon/config-store.ts
4610
4984
  /**
4611
4985
  * Per-device recording-config persistence for the recorder addon.
@@ -7891,81 +8265,6 @@ function buildRecordingDeviceSchema() {
7891
8265
  }]
7892
8266
  }] };
7893
8267
  }
7894
- //#endregion
7895
- //#region src/recorder/addon/device-storage-usage.ts
7896
- /**
7897
- * Gap between the per-camera sum and the per-volume total that is worth
7898
- * saying out loud, as a share of the volume total.
7899
- *
7900
- * The two are aggregations of the SAME hour rows over the same location set —
7901
- * one folded per camera, one summed in SQL — so in a healthy recorder they
7902
- * agree to the byte. A few tenths of a percent is a write landing between the
7903
- * two reads; 97% (60.5 GB against 1.9 TB) is the defect this file closes,
7904
- * rebuilt somewhere new.
7905
- */
7906
- var USAGE_MISMATCH_SHARE = .02;
7907
- /** Floor under {@link USAGE_MISMATCH_SHARE} so a nearly empty disk does not
7908
- * warn on a single in-flight segment. */
7909
- var USAGE_MISMATCH_FLOOR_BYTES = 1e9;
7910
- /**
7911
- * One row per camera the ARCHIVE holds footage for on `locationIds`, biggest
7912
- * first (deviceId breaks a tie, so two consecutive calls compare).
7913
- *
7914
- * A node with no ledger REFUSES (D295 rule 3): empty rows, `read:
7915
- * 'unavailable'`, and a line saying so. Falling back to the index would answer
7916
- * the same question with the wrong quantity and look exactly like a correct
7917
- * answer, which is how this shipped in the first place.
7918
- */
7919
- function buildDeviceUsage(input) {
7920
- if (input.archive === null) {
7921
- 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(",") } });
7922
- return {
7923
- devices: [],
7924
- totalUsedBytes: 0,
7925
- read: "unavailable"
7926
- };
7927
- }
7928
- const devices = [];
7929
- let totalUsedBytes = 0;
7930
- for (const [deviceId, archived] of input.archive) {
7931
- const resident = input.resident(deviceId);
7932
- totalUsedBytes += archived.bytes;
7933
- devices.push({
7934
- deviceId,
7935
- usedBytes: archived.bytes,
7936
- oldestMs: archived.oldestMs,
7937
- segments: archived.segments,
7938
- residentBytes: resident.bytes,
7939
- residentOldestMs: resident.oldestMs
7940
- });
7941
- }
7942
- return {
7943
- devices: devices.toSorted((a, b) => b.usedBytes - a.usedBytes || a.deviceId - b.deviceId),
7944
- totalUsedBytes,
7945
- read: "archive"
7946
- };
7947
- }
7948
- /**
7949
- * Say it when the per-camera sum and the per-volume total disagree.
7950
- *
7951
- * The "I saw N of M" rule of D295, applied to a REPORT: these two numbers are
7952
- * the same rows counted two ways, so a gap means one of the two reads saw a
7953
- * fraction of the archive — and the whole point of D418 is that a page which
7954
- * shows both without comparing them lets a 97% gap read as a fact about the
7955
- * disks.
7956
- */
7957
- function warnOnUsageMismatch(logger, deviceBytes, volumeBytes, read) {
7958
- if (read !== "archive") return;
7959
- if (volumeBytes === 0) return;
7960
- const gap = Math.abs(volumeBytes - deviceBytes);
7961
- if (gap <= USAGE_MISMATCH_FLOOR_BYTES) return;
7962
- if (gap <= volumeBytes * USAGE_MISMATCH_SHARE) return;
7963
- 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: {
7964
- deviceBytes,
7965
- volumeBytes,
7966
- gap
7967
- } });
7968
- }
7969
8268
  /** The placement-store key for one (camera, profile). Mirrors `assignmentKey`
7970
8269
  * in `placement-store.ts`; kept local so this module stays dependency-free. */
7971
8270
  function sourceKey(source) {
@@ -8598,6 +8897,7 @@ function buildRecordingProvider(deps) {
8598
8897
  totalUsedBytes,
8599
8898
  devices: [...devices],
8600
8899
  devicesRead: deviceUsage.read,
8900
+ complete: deviceUsage.read === "archive" && (deps.archiveUsageComplete?.(locationIds) ?? false),
8601
8901
  locations: locationUsages
8602
8902
  };
8603
8903
  },
@@ -9822,6 +10122,10 @@ var CENSUS_FIELDS = [
9822
10122
  ];
9823
10123
  /** Summary rows folded into the mirror between yields. */
9824
10124
  var LOAD_CHUNK = 5e3;
10125
+ /** Minimum spacing between ledger load attempts after a failure (D674). */
10126
+ var LEDGER_LOAD_RETRY_MS = 6e4;
10127
+ /** Spacing of the retries after the fast ones — slower, never stopped. */
10128
+ var LEDGER_LOAD_SLOW_RETRY_MS = 10 * 6e4;
9825
10129
  /** The four scalars an accounting answer is made of, asked in one statement. */
9826
10130
  var ACCOUNTING_FIELDS = [
9827
10131
  {
@@ -10017,6 +10321,22 @@ var SegmentHourLedger = class SegmentHourLedger {
10017
10321
  * the recorder happens to be placing that hour on.
10018
10322
  */
10019
10323
  refusedWriterHours = /* @__PURE__ */ new Set();
10324
+ /**
10325
+ * Has a {@link load} ANSWERED — its query returned AND every row it returned
10326
+ * is staged? Before that the mirror is "not read yet" (or, mid-load, a
10327
+ * fraction), never "the archive is empty". `load` swallows its failure, so a
10328
+ * reader that must tell the two apart (the calendar's window floor, the
10329
+ * storage-usage rows a retention floor is read from — D674) asks here.
10330
+ */
10331
+ loaded = false;
10332
+ /** The last successful load returned {@link rowCap} rows: hours past the cap
10333
+ * are UNKNOWN, so the mirror is an answer but not a WHOLE one. */
10334
+ loadHitRowCap = false;
10335
+ /** Load attempts made, boot included — the retry is bounded (D674). */
10336
+ loadAttempts = 0;
10337
+ lastLoadAttemptMs = null;
10338
+ loadInFlight = null;
10339
+ loadRetriesExhaustedReported = false;
10020
10340
  /** Entry counts of the in-process maps, for the recorder census. */
10021
10341
  memoryCensus() {
10022
10342
  let accountedPaths = 0;
@@ -10065,17 +10385,70 @@ var SegmentHourLedger = class SegmentHourLedger {
10065
10385
  * seed the index (D523).
10066
10386
  */
10067
10387
  async load() {
10388
+ this.loadInFlight ??= this.loadOnce().finally(() => {
10389
+ this.loadInFlight = null;
10390
+ });
10391
+ return this.loadInFlight;
10392
+ }
10393
+ /**
10394
+ * Retry a load that has not answered — rate-limited, logged, never abandoned
10395
+ * (D674).
10396
+ *
10397
+ * A failed boot load used to last the runner's life: every reader then saw
10398
+ * only the rows written since boot, and `getStorageUsage` labelled them the
10399
+ * archive. Readers call this and never wait on it (it is fire-and-forget at
10400
+ * every call site); at most one attempt per {@link LEDGER_LOAD_RETRY_MS} for
10401
+ * the first {@link LEDGER_LOAD_MAX_ATTEMPTS}, then one per
10402
+ * {@link LEDGER_LOAD_SLOW_RETRY_MS}; the slowdown is said once.
10403
+ */
10404
+ retryLoadIfUnanswered() {
10405
+ if (this.loaded || this.loadInFlight !== null) return;
10406
+ const slow = this.loadAttempts >= 10;
10407
+ if (slow && !this.loadRetriesExhaustedReported) {
10408
+ this.loadRetriesExhaustedReported = true;
10409
+ this.logger.warn("recorder: segment-hour ledger still not loaded — retrying at the slow interval; archive-wide answers stay REFUSED until it loads", { meta: {
10410
+ attempts: this.loadAttempts,
10411
+ slowRetryMs: LEDGER_LOAD_SLOW_RETRY_MS
10412
+ } });
10413
+ }
10414
+ const spacing = slow ? LEDGER_LOAD_SLOW_RETRY_MS : LEDGER_LOAD_RETRY_MS;
10415
+ const now = this.now();
10416
+ if (this.lastLoadAttemptMs !== null && now - this.lastLoadAttemptMs < spacing) return;
10417
+ if (!slow) this.logger.info("recorder: retrying the segment-hour ledger load", { meta: {
10418
+ attempt: this.loadAttempts + 1,
10419
+ fastAttempts: 10
10420
+ } });
10421
+ this.load();
10422
+ }
10423
+ /**
10424
+ * Is the mirror a WHOLE answer about the archive: loaded, and not cut at the
10425
+ * row cap? The storage-usage rows and the calendar floor are only facts about
10426
+ * the archive when this is true.
10427
+ */
10428
+ isComplete() {
10429
+ return this.loaded && !this.loadHitRowCap;
10430
+ }
10431
+ async loadOnce() {
10068
10432
  const startedMs = this.now();
10433
+ this.loadAttempts += 1;
10434
+ this.lastLoadAttemptMs = startedMs;
10069
10435
  let records;
10070
10436
  try {
10071
10437
  records = await this.store.query.query({
10072
10438
  collection: RECORDING_SEGMENT_HOURS_COLLECTION,
10073
- filter: { limit: this.rowCap }
10439
+ filter: {
10440
+ limit: this.rowCap,
10441
+ orderBy: {
10442
+ field: "hourStartMs",
10443
+ direction: "desc"
10444
+ }
10445
+ }
10074
10446
  });
10075
10447
  } catch (err) {
10076
10448
  this.logger.warn("recorder: segment-hour ledger load failed — keeping the hours already held", { meta: {
10077
10449
  collection: RECORDING_SEGMENT_HOURS_COLLECTION,
10078
10450
  held: this.ledger.size,
10451
+ attempt: this.loadAttempts,
10079
10452
  error: String(err)
10080
10453
  } });
10081
10454
  return this.ledger.snapshot();
@@ -10091,10 +10464,12 @@ var SegmentHourLedger = class SegmentHourLedger {
10091
10464
  skipped += 1;
10092
10465
  continue;
10093
10466
  }
10094
- this.ledger.stage(row);
10467
+ if (!this.ledger.has(row.key)) this.ledger.stage(row);
10095
10468
  }
10096
10469
  if (end < records.length) await this.yieldBetween();
10097
10470
  }
10471
+ this.loaded = true;
10472
+ this.loadHitRowCap = records.length >= this.rowCap;
10098
10473
  if (skipped > 0) this.logger.warn("recorder: segment-hour rows skipped as malformed — they gate NOTHING", { meta: {
10099
10474
  collection: RECORDING_SEGMENT_HOURS_COLLECTION,
10100
10475
  skipped
@@ -10116,6 +10491,31 @@ var SegmentHourLedger = class SegmentHourLedger {
10116
10491
  snapshot() {
10117
10492
  return this.ledger.snapshot();
10118
10493
  }
10494
+ /** Whether a {@link load} has answered and staged every row it returned —
10495
+ * see {@link loaded}. {@link isComplete} is the stronger question. */
10496
+ hasLoaded() {
10497
+ return this.loaded;
10498
+ }
10499
+ /**
10500
+ * The OLDEST hour the archive holds per camera, across every profile and
10501
+ * every location — the floor below which no hour can hold footage (D674).
10502
+ *
10503
+ * Every row counts, including one whose segments were all dropped and that
10504
+ * the prune has not yet forgotten: an older floor only walks a few more
10505
+ * hours, while a floor that is too YOUNG hides footage. One fold, no I/O —
10506
+ * the same shape as {@link devicesWithFootage}. Meaningful only after
10507
+ * {@link isComplete}; even then a camera ABSENT from the map is unknown to
10508
+ * the calendar (an hour not adopted yet is on disk and absent here), never a
10509
+ * known "no footage" (D674).
10510
+ */
10511
+ oldestHourByDevice() {
10512
+ const out = /* @__PURE__ */ new Map();
10513
+ for (const row of this.ledger.snapshot()) {
10514
+ const held = out.get(row.deviceId);
10515
+ if (held === void 0 || row.hourStartMs < held) out.set(row.deviceId, row.hourStartMs);
10516
+ }
10517
+ return out;
10518
+ }
10119
10519
  /**
10120
10520
  * Which profiles each of these cameras has footage for IN THE ARCHIVE.
10121
10521
  *
@@ -20501,12 +20901,17 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
20501
20901
  /** LAZY: a field initialiser runs before `initialize()` assigns `ctx`, and
20502
20902
  * reading `this.ctx` there throws — the same trap that bit the playback tier. */
20503
20903
  get calendar() {
20504
- this.calendarInstance ??= new CalendarIndex({
20904
+ this.calendarInstance ??= this.buildCalendar();
20905
+ return this.calendarInstance;
20906
+ }
20907
+ buildCalendar() {
20908
+ const floors = new ArchiveFloorReader(() => this.segmentHours);
20909
+ return new CalendarIndex({
20505
20910
  roots: () => this.resolvedLocations.map((l) => l.root),
20506
20911
  logger: this.ctx.logger,
20507
- indexHour: (deviceId, profile, hourStartMs) => hourSegmentsFromIndex(this.index, deviceId, profile, hourStartMs)
20912
+ indexHour: (deviceId, profile, hourStartMs) => hourSegmentsFromIndex(this.index, deviceId, profile, hourStartMs),
20913
+ archiveFloor: (deviceId) => floors.floorOf(deviceId)
20508
20914
  });
20509
- return this.calendarInstance;
20510
20915
  }
20511
20916
  segmentStore = null;
20512
20917
  /** Sync-sample tables captured at finalize, served by the directory so a
@@ -20971,7 +21376,8 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
20971
21376
  refreshLocations: () => this.refreshLocations(),
20972
21377
  capacity: (root) => this.locationCapacity(root),
20973
21378
  archiveAccounting: (locationIds) => this.segmentHours?.accountingForLocations(locationIds) ?? Promise.resolve(EMPTY_ARCHIVE_ACCOUNTING),
20974
- archiveDeviceUsage: (locationIds) => this.segmentHours?.usageByDevice(locationIds) ?? null,
21379
+ archiveDeviceUsage: (locationIds) => archiveDeviceUsageOf(this.segmentHours, locationIds),
21380
+ archiveUsageComplete: (locationIds) => archiveUsageComplete(this.segmentHours, locationIds),
20975
21381
  archive: () => this.segmentHours,
20976
21382
  ledgerWalk: this.ledgerWalkReconciler(),
20977
21383
  hydrateDevice: (deviceId, locations) => hydrateDeviceFromStorage(this.ctx.api, this.index, deviceId, locations, this.ctx.logger),