@camstack/addon-pipeline 1.2.133 → 1.2.135

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 (37) hide show
  1. package/dist/{addon-utils-FV2O25id.js → addon-utils-CKf8kbwh.js} +1 -1
  2. package/dist/audio-analyzer/index.js +3 -3
  3. package/dist/audio-analyzer/index.mjs +2 -2
  4. package/dist/detection-pipeline/index.js +5 -5
  5. package/dist/detection-pipeline/index.mjs +3 -3
  6. package/dist/{dist-DsnZUYTs.js → dist-BzLiQ8Ue.js} +699 -30
  7. package/dist/{dist-7Uc6NdFm.mjs → dist-C1dXLLQq.mjs} +682 -31
  8. package/dist/{event-loop-stall-monitor-XzDMVU9d.mjs → event-loop-stall-monitor-CU-xxX5c.mjs} +1 -1
  9. package/dist/{event-loop-stall-monitor-D86J6d2c.js → event-loop-stall-monitor-DjV4Bow7.js} +1 -1
  10. package/dist/{lazy-sharp-UzEUL79V.js → lazy-sharp-DxWmhTqk.js} +1 -1
  11. package/dist/motion-wasm/index.js +2 -2
  12. package/dist/motion-wasm/index.mjs +1 -1
  13. package/dist/pipeline-runner/index.js +4 -4
  14. package/dist/pipeline-runner/index.mjs +3 -3
  15. package/dist/{process-memory-Co59RQIK.js → process-memory-CZK3D3kM.js} +1 -1
  16. package/dist/{process-memory-Ci90-zF_.mjs → process-memory-CaEwi5Cd.mjs} +1 -1
  17. package/dist/recorder/index.js +357 -327
  18. package/dist/recorder/index.mjs +356 -326
  19. package/dist/{segment-demux-js-DKcc0bxA.js → segment-demux-js-BbpqygHL.js} +1 -1
  20. package/dist/{segment-demux-js-0NZ9TXro.mjs → segment-demux-js-njRO85gd.mjs} +1 -1
  21. package/dist/session-decode/decode-worker-child.js +2 -2
  22. package/dist/session-decode/decode-worker-child.mjs +1 -1
  23. package/dist/stream-broker/_stub.js +2 -2
  24. package/dist/stream-broker/{_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-Dqj2f3H7.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-C14Qqrqn.mjs} +3 -3
  25. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-DqplWP1i.mjs +26 -0
  26. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-DKMXG57X.mjs +26 -0
  27. package/dist/stream-broker/demux-worker-child.js +1 -1
  28. package/dist/stream-broker/demux-worker-child.mjs +1 -1
  29. package/dist/stream-broker/{hostInit-DnwyBL4I.mjs → hostInit-kjsveCjG.mjs} +3 -3
  30. package/dist/stream-broker/index.js +105 -7
  31. package/dist/stream-broker/index.mjs +105 -7
  32. package/dist/stream-broker/remoteEntry.js +1 -1
  33. package/dist/{worker-protocol-DzRUHIcM.js → worker-protocol-DvTZBvh2.js} +1 -1
  34. package/dist/{worker-protocol-CMDUAqvX.mjs → worker-protocol-DxsVx0G7.mjs} +1 -1
  35. package/package.json +4 -1
  36. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-CVBnNONy.mjs +0 -26
  37. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-77rGxv1J.mjs +0 -26
@@ -1,4 +1,4 @@
1
- import { At as hydrateSchema, Bt as array, Dt as DeviceFeature, E as RecordingConfigSchema, Gt as number, I as customAction, Jt as string, K as evaluateSensorEdge, Kt as object, Lt as selectAssignedProfileSlots, N as batteryCapability, Ot as DeviceType, Pt as nodePin, R as defineCustomActions, T as RECORDING_EXPORT_MAX_READ_BYTES, Tt as BaseAddon, V as deriveRecordingMode, Vt as boolean, X as isFirstLevelMacroClass, Xt as EventCategory, _ as ExportRecordSchema, ct as recordingCapability, d as DEFAULT_EVENTS_BAND_BUFFER_SEC, h as EVENT_PAD_MS, ht as storageEvictableCapability, i as BatteryStatusSchema, lt as recordingExportCapability, pt as resolveRecordingProfiles, qt as record, x as OpsLogEntrySchema, yt as errMsg, z as deriveBatteryPresence } from "../dist-7Uc6NdFm.mjs";
1
+ import { $t as EventCategory, At as DeviceFeature, B as defineCustomActions, Bt as selectAssignedProfileSlots, E as RecordingConfigSchema, J as evaluateSensorEdge, Jt as number, L as customAction, Lt as nodePin, N as batteryCapability, Nt as hydrateSchema, Ot as BaseAddon, Q as isFirstLevelMacroClass, St as errMsg, T as RECORDING_EXPORT_MAX_READ_BYTES, U as deriveRecordingMode, Ut as array, V as deriveBatteryPresence, Wt as boolean, Xt as record, Yt as object, Zt as string, _ as ExportRecordSchema, d as DEFAULT_EVENTS_BAND_BUFFER_SEC, dt as recordingCapability, ft as recordingExportCapability, gt as resolveRecordingProfiles, h as EVENT_PAD_MS, i as BatteryStatusSchema, jt as DeviceType, vt as storageEvictableCapability, x as OpsLogEntrySchema } from "../dist-C1dXLLQq.mjs";
2
2
  import { t as resolveHubHostname } from "../hub-hostname-cCknRYKj.mjs";
3
3
  import { r as withRecordingIntent } from "../restream-intent-B4BXZra7.mjs";
4
4
  import { n as createFileDataPlaneHandler, s as parseRangeHeader, t as contentTypeFor } from "../addon-utils-CgBCF-mE.mjs";
@@ -236,81 +236,6 @@ var EventMap = class {
236
236
  }
237
237
  };
238
238
  //#endregion
239
- //#region src/recorder/redundant-segments.ts
240
- /**
241
- * Return the indices (into `segs`) of segments safe to delete: those fully
242
- * contained within a kept segment `[coverStart, coverEnd]`. Sorting by start
243
- * ascending, then by end DESCENDING, guarantees the containing (longer) segment
244
- * is seen first and kept, and any shorter segment nested inside it is removed.
245
- */
246
- function selectRedundantSegments(segs) {
247
- if (segs.length < 2) return [];
248
- const order = segs.map((s, i) => ({
249
- start: s.startMs,
250
- end: s.startMs + s.durMs,
251
- i
252
- })).toSorted((a, b) => a.start - b.start || b.end - a.end);
253
- const remove = [];
254
- let coverStart = Number.NEGATIVE_INFINITY;
255
- let coverEnd = Number.NEGATIVE_INFINITY;
256
- for (const seg of order) if (seg.start >= coverStart && seg.end <= coverEnd && seg.end > seg.start) remove.push(seg.i);
257
- else {
258
- coverStart = seg.start;
259
- coverEnd = seg.end;
260
- }
261
- return remove;
262
- }
263
- //#endregion
264
- //#region src/recorder/janitor.ts
265
- /** Group an array by a string key. */
266
- function groupBy(items, key) {
267
- const out = /* @__PURE__ */ new Map();
268
- for (const it of items) {
269
- const k = key(it);
270
- const arr = out.get(k);
271
- if (arr) arr.push(it);
272
- else out.set(k, [it]);
273
- }
274
- return out;
275
- }
276
- async function runRedundancyJanitor(deps) {
277
- let files = 0;
278
- let bytes = 0;
279
- for (const deviceId of deps.deviceIds) {
280
- const byProfile = groupBy(deps.segmentsFor(deviceId), (r) => r.profile);
281
- for (const [profile, rows] of byProfile) {
282
- const sorted = [...rows].toSorted((a, b) => a.startMs - b.startMs);
283
- const redundant = selectRedundantSegments(sorted);
284
- if (redundant.length === 0) continue;
285
- const victims = redundant.map((i) => sorted[i]);
286
- for (const [location, vrows] of groupBy(victims, (v) => v.locationId)) try {
287
- const reclaimed = await deps.evict(location, vrows);
288
- files += vrows.length;
289
- bytes += reclaimed;
290
- } catch (err) {
291
- deps.logger.warn("janitor: evict failed (skipping)", {
292
- tags: { deviceId },
293
- meta: {
294
- deviceId,
295
- profile,
296
- location,
297
- count: vrows.length,
298
- error: err instanceof Error ? err.message : String(err)
299
- }
300
- });
301
- }
302
- }
303
- }
304
- if (files > 0) deps.logger.info("janitor: removed redundant recording segments", { meta: {
305
- files,
306
- bytes
307
- } });
308
- return {
309
- files,
310
- bytes
311
- };
312
- }
313
- //#endregion
314
239
  //#region src/recorder/init-info.ts
315
240
  /** Walk sibling boxes in [from, to), yielding [type, start, end]. */
316
241
  function* boxes$1(buf, view, from, to) {
@@ -1290,69 +1215,6 @@ var RecordingIndex = class {
1290
1215
  for (const m of this.byDevice.values()) for (const s of m.values()) if (s.locationId === locationId) out.push(s);
1291
1216
  return out.toSorted((a, b) => a.startMs - b.startMs);
1292
1217
  }
1293
- /**
1294
- * All segments across a SET of storage locations (one eviction DOMAIN — the
1295
- * locations that alias one physical filesystem root), oldest-first across the
1296
- * whole pool. This is the correct unit for byte-eviction: two location ids over
1297
- * the same disk must be reclaimed as ONE oldest-first stream, never partitioned.
1298
- */
1299
- segmentsOnLocations(locationIds) {
1300
- const out = [];
1301
- for (const m of this.byDevice.values()) for (const s of m.values()) if (locationIds.has(s.locationId)) out.push(s);
1302
- return out.toSorted((a, b) => a.startMs - b.startMs);
1303
- }
1304
- /** Aggregate accounting across a SET of storage locations (one eviction domain). */
1305
- accountingForLocations(locationIds) {
1306
- let bytes = 0;
1307
- let count = 0;
1308
- let oldestMs = Infinity;
1309
- let newestMs = -Infinity;
1310
- for (const m of this.byDevice.values()) for (const s of m.values()) {
1311
- if (!locationIds.has(s.locationId)) continue;
1312
- bytes += s.bytes;
1313
- count += 1;
1314
- if (s.startMs < oldestMs) oldestMs = s.startMs;
1315
- if (s.startMs > newestMs) newestMs = s.startMs;
1316
- }
1317
- if (count === 0) return {
1318
- bytes: 0,
1319
- count: 0,
1320
- oldestMs: null,
1321
- newestMs: null
1322
- };
1323
- return {
1324
- bytes,
1325
- count,
1326
- oldestMs,
1327
- newestMs
1328
- };
1329
- }
1330
- /** Aggregate accounting for a storage location across every device. */
1331
- accountingForLocation(locationId) {
1332
- let bytes = 0;
1333
- let count = 0;
1334
- let oldestMs = Infinity;
1335
- let newestMs = -Infinity;
1336
- for (const m of this.byDevice.values()) for (const s of m.values()) {
1337
- if (s.locationId !== locationId) continue;
1338
- bytes += s.bytes;
1339
- count += 1;
1340
- if (s.startMs < oldestMs) oldestMs = s.startMs;
1341
- if (s.startMs > newestMs) newestMs = s.startMs;
1342
- }
1343
- if (count === 0) return {
1344
- bytes: 0,
1345
- count: 0,
1346
- oldestMs: null,
1347
- newestMs: null
1348
- };
1349
- return {
1350
- bytes,
1351
- count,
1352
- oldestMs,
1353
- newestMs
1354
- };
1355
- }
1356
1218
  /** Binary-search a start-sorted slice for the segment containing epochMs. */
1357
1219
  segmentAtIn(segs, epochMs) {
1358
1220
  if (segs.length === 0) return null;
@@ -2255,12 +2117,36 @@ var StorageEvictableProvider = class {
2255
2117
  for (const id of domain) if (!this.deps.isReadOnly(id)) writable.add(id);
2256
2118
  return writable;
2257
2119
  }
2120
+ /**
2121
+ * How many bytes this domain holds — ONE aggregation, no rows returned.
2122
+ *
2123
+ * The storage-pressure sweep asks this per managed location every 60 s, and
2124
+ * two locations aliasing one root make it twice a minute. It used to be a
2125
+ * walk over every indexed segment on the node (7.1 M rows live), which is
2126
+ * also why the whole archive had to stay resident to be walked.
2127
+ */
2258
2128
  async getEvictableUsage(input) {
2259
- return { bytes: this.deps.index.accountingForLocations(this.domainOf(input.locationId)).bytes };
2129
+ const archive = this.deps.archive();
2130
+ if (archive === null) {
2131
+ this.deps.logger.warn("recorder: evictable usage unavailable — no footage archive to measure, reporting 0 evictable bytes", { meta: { locationId: input.locationId } });
2132
+ return { bytes: 0 };
2133
+ }
2134
+ return { bytes: (await archive.accountingForLocations(this.domainOf(input.locationId))).bytes };
2260
2135
  }
2261
2136
  async evict(input) {
2262
- const onDomain = this.deps.index.segmentsOnLocations(this.domainOf(input.locationId));
2263
- const victims = selectEvictionsByBytes(onDomain, input.targetBytes);
2137
+ const archive = this.deps.archive();
2138
+ if (archive === null) {
2139
+ this.deps.logger.warn("recorder: disk-pressure eviction skipped — no footage archive to pick the oldest from", { meta: {
2140
+ locationId: input.locationId,
2141
+ targetBytes: input.targetBytes
2142
+ } });
2143
+ return {
2144
+ reclaimedBytes: 0,
2145
+ exhausted: true
2146
+ };
2147
+ }
2148
+ const oldest = await archive.oldestSegments(this.domainOf(input.locationId), input.targetBytes);
2149
+ const victims = selectEvictionsByBytes(oldest.rows, input.targetBytes);
2264
2150
  const byLocation = /* @__PURE__ */ new Map();
2265
2151
  for (const v of victims) {
2266
2152
  const group = byLocation.get(v.locationId);
@@ -2270,7 +2156,7 @@ var StorageEvictableProvider = class {
2270
2156
  let reclaimedBytes = 0;
2271
2157
  for (const [locationId, rows] of byLocation) reclaimedBytes += await this.deps.store.evict(locationId, rows);
2272
2158
  if (reclaimedBytes > 0 && this.deps.onEvicted) this.deps.onEvicted(summarizeEvictionsByDevice(victims));
2273
- const exhausted = victims.length >= onDomain.length;
2159
+ const exhausted = oldest.exhausted && victims.length >= oldest.rows.length;
2274
2160
  return {
2275
2161
  reclaimedBytes,
2276
2162
  exhausted
@@ -3067,6 +2953,7 @@ var CENSUS_MAX_WINDOW_MS = 10 * 6e4;
3067
2953
  var CENSUSED_PROCEDURES = new Set([
3068
2954
  "query",
3069
2955
  "count",
2956
+ "aggregate",
3070
2957
  "get",
3071
2958
  "histogram",
3072
2959
  "getAll"
@@ -5556,7 +5443,7 @@ function buildRecordingProvider(deps) {
5556
5443
  locationId: first.id,
5557
5444
  locationIds: ids,
5558
5445
  root: first.root,
5559
- usedBytes: deps.index.accountingForLocations(new Set(ids)).bytes,
5446
+ usedBytes: (await deps.archiveAccounting?.(new Set(ids)))?.bytes ?? 0,
5560
5447
  availableBytes: cap.availableBytes,
5561
5448
  totalBytes: cap.totalBytes
5562
5449
  };
@@ -6336,6 +6223,7 @@ Object.freeze({
6336
6223
  "listProfiles",
6337
6224
  "listRuntimeNodes"
6338
6225
  ],
6226
+ "log-channels": ["list"],
6339
6227
  "log-destination": ["query"],
6340
6228
  "login-method": ["getLoginMethods"],
6341
6229
  "mqtt-broker": ["listBrokers"],
@@ -7086,61 +6974,6 @@ async function hydrateDeviceFromStorage(_api, index, deviceId, locations, logger
7086
6974
  }
7087
6975
  /** Entries classified between yields in {@link hydrateDeviceFromStorage}. */
7088
6976
  var HYDRATE_CHUNK = 2e4;
7089
- /**
7090
- * Device walks in flight at once in {@link hydrateDevicesFromStorage}.
7091
- *
7092
- * The walk is `readdir` latency on a network/FUSE share, not CPU: measured on
7093
- * the live hub, 24 cold hour directories cost 31 s one at a time and 16.7 s
7094
- * eight at a time. Four is the conservative point on that curve — enough to
7095
- * hide most of the latency, few enough that the boot walk never becomes the
7096
- * reason a live SegmentWriter waits. libuv's default threadpool is 4, so a
7097
- * higher number here mostly queues.
7098
- */
7099
- var HYDRATE_DEVICE_CONCURRENCY = 4;
7100
- /**
7101
- * Walk the archive for a whole fleet: bounded-parallel, and each camera becomes
7102
- * authoritative the moment ITS OWN walk lands (D167).
7103
- *
7104
- * Two things this fixes, both measured on the live hub on 2026-08-15:
7105
- *
7106
- * - The caller was `for (const id of ids) await hydrateDeviceFromStorage(...)`,
7107
- * so seven cameras' 357 s of `readdir` WAS the 362 s wall clock. Nothing in
7108
- * the walk contends for CPU — it is share latency — so overlapping is free.
7109
- * - `markHydratedAll` ran in a SECOND loop after every device. A camera whose
7110
- * walk finished at minute 1 still reported `hydrationOf → 'unknown'` until
7111
- * minute 6, and every read of it in that window paid for an on-demand walk
7112
- * over rows already in the index, competing with the walk still running for
7113
- * the others.
7114
- *
7115
- * A device whose walk throws is NOT marked: `unknown` is the honest answer, and
7116
- * it is what keeps the read path walking that window on demand.
7117
- */
7118
- async function hydrateDevicesFromStorage(api, index, deviceIds, locations, logger, options = {}) {
7119
- const concurrency = Math.max(1, options.concurrency ?? HYDRATE_DEVICE_CONCURRENCY);
7120
- const queue = [...deviceIds];
7121
- const walkOptions = {
7122
- ...options.yieldBetween ? { yieldBetween: options.yieldBetween } : {},
7123
- ...options.chunkSize !== void 0 ? { chunkSize: options.chunkSize } : {}
7124
- };
7125
- const lane = async () => {
7126
- for (;;) {
7127
- const deviceId = queue.shift();
7128
- if (deviceId === void 0) return;
7129
- try {
7130
- options.onDeviceStarted?.(deviceId);
7131
- await hydrateDeviceFromStorage(api, index, deviceId, locations, logger, walkOptions);
7132
- index.markHydratedAll(deviceId);
7133
- options.onDeviceHydrated?.(deviceId);
7134
- } catch (err) {
7135
- logger.warn("recorder: device hydrate failed — window stays unknown, reads will walk it", {
7136
- tags: { deviceId },
7137
- meta: { error: errMsg(err) }
7138
- });
7139
- }
7140
- }
7141
- };
7142
- await Promise.all(Array.from({ length: Math.min(concurrency, queue.length) }, lane));
7143
- }
7144
6977
  var macrotask$1 = () => new Promise((resolve) => {
7145
6978
  setImmediate(resolve);
7146
6979
  });
@@ -7711,7 +7544,7 @@ var PlacementService = class {
7711
7544
  limits.push(capacity.availableBytes - floor);
7712
7545
  }
7713
7546
  if (location.maxUsedGb !== void 0) {
7714
- const used = this.deps.usedOnLocation?.(location.id) ?? 0;
7547
+ const used = await this.deps.usedOnLocation?.(location.id) ?? 0;
7715
7548
  limits.push(location.maxUsedGb * 1e9 - used);
7716
7549
  }
7717
7550
  out.push({
@@ -11090,6 +10923,14 @@ var HOUR_MS$1 = 36e5;
11090
10923
  * retention="a row goes when the walk or eviction confirms the hour is empty — never by age, never from the ledger alone (D148)"
11091
10924
  */
11092
10925
  var RECORDING_SEGMENT_HOURS_COLLECTION = "recorder:segment-hours";
10926
+ /**
10927
+ * The four aggregate columns exist so "how much footage is on this disk" is a
10928
+ * `SUM`/`MIN`/`MAX` over ~19 k hour rows instead of a walk over ~7.1 M in-RAM
10929
+ * segment rows. They are DERIVED from `paths` — never a second authority — and
10930
+ * {@link hourAggregate} is the only place that derives them, so a row loaded
10931
+ * from a database that predates these columns is corrected in the mirror the
10932
+ * moment it is read and repaired on disk by {@link SegmentHourLedger.repairAggregates}.
10933
+ */
11093
10934
  var RECORDING_SEGMENT_HOURS_COLUMNS = [
11094
10935
  {
11095
10936
  name: "key",
@@ -11121,15 +10962,85 @@ var RECORDING_SEGMENT_HOURS_COLUMNS = [
11121
10962
  name: "paths",
11122
10963
  type: "TEXT",
11123
10964
  notNull: true
10965
+ },
10966
+ {
10967
+ name: "bytes",
10968
+ type: "INTEGER",
10969
+ notNull: true,
10970
+ defaultValue: 0
10971
+ },
10972
+ {
10973
+ name: "segments",
10974
+ type: "INTEGER",
10975
+ notNull: true,
10976
+ defaultValue: 0
10977
+ },
10978
+ {
10979
+ name: "minStartMs",
10980
+ type: "INTEGER",
10981
+ notNull: true,
10982
+ defaultValue: 0
10983
+ },
10984
+ {
10985
+ name: "maxStartMs",
10986
+ type: "INTEGER",
10987
+ notNull: true,
10988
+ defaultValue: 0
11124
10989
  }
11125
10990
  ];
11126
- var RECORDING_SEGMENT_HOURS_INDEXES = [{
11127
- name: "idx_recorder_segment_hours_device",
11128
- columns: ["deviceId"]
11129
- }, {
11130
- name: "idx_recorder_segment_hours_hour",
11131
- columns: ["hourStartMs"]
11132
- }];
10991
+ var RECORDING_SEGMENT_HOURS_INDEXES = [
10992
+ {
10993
+ name: "idx_recorder_segment_hours_device",
10994
+ columns: ["deviceId"]
10995
+ },
10996
+ {
10997
+ name: "idx_recorder_segment_hours_hour",
10998
+ columns: ["hourStartMs"]
10999
+ },
11000
+ (
11001
+ /** The eviction read: one oldest-first stream across a SET of location ids.
11002
+ * Location leads so `locationId IN (…)` is a range scan and `hourStartMs`
11003
+ * supplies the order without a sort. */
11004
+ {
11005
+ name: "idx_recorder_segment_hours_loc_hour",
11006
+ columns: ["locationId", "hourStartMs"]
11007
+ })
11008
+ ];
11009
+ /**
11010
+ * Bytes / count / oldest / newest for one hour's paths.
11011
+ *
11012
+ * Every field of a segment path encodes its own facts (`parseSegmentPath`), so
11013
+ * this is a derivation, not a measurement: it can never disagree with the paths
11014
+ * it was computed from. A path the parser refuses contributes nothing — it is
11015
+ * not a segment, and counting it would inflate a disk report.
11016
+ */
11017
+ function hourAggregate(paths) {
11018
+ let bytes = 0;
11019
+ let segments = 0;
11020
+ let minStartMs = 0;
11021
+ let maxStartMs = 0;
11022
+ for (const path of paths) {
11023
+ const p = parseSegmentPath(path);
11024
+ if (p === null) continue;
11025
+ bytes += p.bytes;
11026
+ if (segments === 0 || p.startMs < minStartMs) minStartMs = p.startMs;
11027
+ if (segments === 0 || p.startMs > maxStartMs) maxStartMs = p.startMs;
11028
+ segments += 1;
11029
+ }
11030
+ return {
11031
+ bytes,
11032
+ segments,
11033
+ minStartMs,
11034
+ maxStartMs
11035
+ };
11036
+ }
11037
+ /** Build the persisted row for an hour from its paths — the ONE constructor. */
11038
+ function segmentHourRow(base) {
11039
+ return {
11040
+ ...base,
11041
+ ...hourAggregate(base.paths)
11042
+ };
11043
+ }
11133
11044
  /**
11134
11045
  * The largest share of a device's held hours a single walk may delete.
11135
11046
  *
@@ -11141,6 +11052,49 @@ var RECORDING_SEGMENT_HOURS_INDEXES = [{
11141
11052
  * `readdir` over an unmounted root deletes all of it.
11142
11053
  */
11143
11054
  var LEDGER_PRUNE_MAX_SHARE = .5;
11055
+ /** Backstop on the oldest-first probe. A bounded read must stay bounded even if
11056
+ * a caller asks for a target no archive can cover. */
11057
+ var OLDEST_MAX_PAGES = 16;
11058
+ /** Phase-one columns: everything the accumulation needs and nothing fat. */
11059
+ var OLDEST_PROBE_COLUMNS = [
11060
+ "hourStartMs",
11061
+ "bytes",
11062
+ "locationId"
11063
+ ];
11064
+ /** Aggregate-repair probe: the primary key always comes back, so this asks for
11065
+ * the predicate column alone. */
11066
+ var REPAIR_PROBE_COLUMNS = ["segments"];
11067
+ /** Rows repaired per boot. The live archive is ~19 k hour rows. */
11068
+ var REPAIR_MAX_ROWS = 5e4;
11069
+ /** The four scalars an accounting answer is made of, asked in one statement. */
11070
+ var ACCOUNTING_FIELDS = [
11071
+ {
11072
+ as: "bytes",
11073
+ field: "bytes",
11074
+ op: "sum"
11075
+ },
11076
+ {
11077
+ as: "segments",
11078
+ field: "segments",
11079
+ op: "sum"
11080
+ },
11081
+ {
11082
+ as: "oldestMs",
11083
+ field: "minStartMs",
11084
+ op: "min"
11085
+ },
11086
+ {
11087
+ as: "newestMs",
11088
+ field: "maxStartMs",
11089
+ op: "max"
11090
+ }
11091
+ ];
11092
+ var EMPTY_ACCOUNTING = {
11093
+ bytes: 0,
11094
+ count: 0,
11095
+ oldestMs: null,
11096
+ newestMs: null
11097
+ };
11144
11098
  function hourStartMs(startMs) {
11145
11099
  return Math.floor(startMs / HOUR_MS$1) * HOUR_MS$1;
11146
11100
  }
@@ -11176,7 +11130,11 @@ function rowToValue(row) {
11176
11130
  profile: row.profile,
11177
11131
  locationId: row.locationId,
11178
11132
  hourStartMs: row.hourStartMs,
11179
- paths: JSON.stringify(row.paths)
11133
+ paths: JSON.stringify(row.paths),
11134
+ bytes: row.bytes,
11135
+ segments: row.segments,
11136
+ minStartMs: row.minStartMs,
11137
+ maxStartMs: row.maxStartMs
11180
11138
  };
11181
11139
  }
11182
11140
  function recordToRow(key, data) {
@@ -11191,14 +11149,14 @@ function recordToRow(key, data) {
11191
11149
  return null;
11192
11150
  }
11193
11151
  if (!Number.isFinite(deviceId) || deviceId <= 0 || typeof profile !== "string" || profile.length === 0 || typeof locationId !== "string" || locationId.length === 0 || !Number.isFinite(hour) || !Array.isArray(raw) || !raw.every((p) => typeof p === "string" && p.length > 0)) return null;
11194
- return {
11152
+ return segmentHourRow({
11195
11153
  key,
11196
11154
  deviceId,
11197
11155
  profile,
11198
11156
  locationId,
11199
11157
  hourStartMs: hour,
11200
11158
  paths: raw
11201
- };
11159
+ });
11202
11160
  }
11203
11161
  var SPEC = {
11204
11162
  collection: RECORDING_SEGMENT_HOURS_COLLECTION,
@@ -11214,9 +11172,15 @@ var SPEC = {
11214
11172
  var SegmentHourLedger = class {
11215
11173
  ledger;
11216
11174
  logger;
11175
+ /** Held for the two ARCHIVE reads below, which are queries rather than mirror
11176
+ * lookups. Every DECISION path (`recordSegment`, `dropSegments`, the prune
11177
+ * refusals) still answers from the mirror alone — D49 is about gates, and an
11178
+ * accounting number is not a gate. */
11179
+ store;
11217
11180
  isLocationUsable;
11218
11181
  constructor(deps) {
11219
11182
  this.logger = deps.logger;
11183
+ this.store = deps.store;
11220
11184
  this.isLocationUsable = deps.isLocationUsable ?? (() => true);
11221
11185
  this.ledger = new DurableLedger({
11222
11186
  spec: SPEC,
@@ -11251,19 +11215,178 @@ var SegmentHourLedger = class {
11251
11215
  hydrateIndex(index) {
11252
11216
  for (const hour of this.ledger.snapshot()) index.hydrateHour(hour.deviceId, hour.locationId, hour.hourStartMs, hour.paths);
11253
11217
  }
11218
+ /**
11219
+ * Bytes / count / oldest / newest across a SET of storage locations, as ONE
11220
+ * `SUM`/`COUNT`/`MIN`/`MAX` over the hour rows.
11221
+ *
11222
+ * The set is the eviction DOMAIN — the location ids that alias one physical
11223
+ * root. It goes into a single statement as `locationId IN (…)`, never a loop
11224
+ * per location: two ids over one disk are one pool, and asking twice is how
11225
+ * an answer about a disk gets partitioned.
11226
+ */
11227
+ async accountingForLocations(locationIds) {
11228
+ const ids = [...locationIds];
11229
+ if (ids.length === 0) return EMPTY_ACCOUNTING;
11230
+ const out = await this.store.aggregate.query({
11231
+ collection: RECORDING_SEGMENT_HOURS_COLLECTION,
11232
+ fields: ACCOUNTING_FIELDS,
11233
+ filter: { whereIn: { locationId: ids } }
11234
+ });
11235
+ const segments = out.values["segments"];
11236
+ if (out.count === 0 || segments === null || segments === void 0) return EMPTY_ACCOUNTING;
11237
+ return {
11238
+ bytes: out.values["bytes"] ?? 0,
11239
+ count: segments,
11240
+ oldestMs: out.values["oldestMs"] ?? null,
11241
+ newestMs: out.values["newestMs"] ?? null
11242
+ };
11243
+ }
11244
+ /**
11245
+ * The OLDEST segments across a set of storage locations — enough of them to
11246
+ * cover `targetBytes`, and no more.
11247
+ *
11248
+ * ## The constraint this read exists to keep
11249
+ *
11250
+ * Locations aliasing one physical root must be reclaimed as ONE oldest-first
11251
+ * stream. That is not a property of the CALLER here, it is a property of the
11252
+ * STATEMENT: every id of the domain rides in a single
11253
+ * `locationId IN (…) ORDER BY hourStartMs ASC`. A query per location plus a
11254
+ * merge would be the age-partition data-loss bug rebuilt in a second place.
11255
+ *
11256
+ * ## Why two phases
11257
+ *
11258
+ * Phase one reads only the small columns and walks oldest-first until the
11259
+ * accumulated bytes cover the target — normally one page. `paths` is by far
11260
+ * the fattest column in the row and phase one never touches it. Phase two
11261
+ * fetches paths for exactly the hours phase one chose.
11262
+ *
11263
+ * `exhausted` means the stream ran out before the target was met — the
11264
+ * caller's stop condition, and the one thing a bounded read must still be
11265
+ * able to say truthfully.
11266
+ */
11267
+ async oldestSegments(locationIds, targetBytes) {
11268
+ const ids = [...locationIds];
11269
+ if (ids.length === 0 || targetBytes <= 0) return {
11270
+ rows: [],
11271
+ exhausted: true
11272
+ };
11273
+ const chosen = [];
11274
+ let covered = 0;
11275
+ let offset = 0;
11276
+ let exhausted = false;
11277
+ for (let page = 0; page < OLDEST_MAX_PAGES && covered < targetBytes; page++) {
11278
+ const records = await this.store.query.query({
11279
+ collection: RECORDING_SEGMENT_HOURS_COLLECTION,
11280
+ columns: OLDEST_PROBE_COLUMNS,
11281
+ filter: {
11282
+ whereIn: { locationId: ids },
11283
+ orderBy: {
11284
+ field: "hourStartMs",
11285
+ direction: "asc"
11286
+ },
11287
+ limit: 32,
11288
+ offset
11289
+ }
11290
+ });
11291
+ for (const record of records) {
11292
+ chosen.push(record.id);
11293
+ const bytes = record.data["bytes"];
11294
+ covered += typeof bytes === "number" ? bytes : 0;
11295
+ }
11296
+ offset += records.length;
11297
+ if (records.length < 32) {
11298
+ exhausted = true;
11299
+ break;
11300
+ }
11301
+ }
11302
+ if (chosen.length === 0) return {
11303
+ rows: [],
11304
+ exhausted: true
11305
+ };
11306
+ const full = await this.store.query.query({
11307
+ collection: RECORDING_SEGMENT_HOURS_COLLECTION,
11308
+ filter: {
11309
+ whereIn: { key: chosen },
11310
+ limit: chosen.length
11311
+ }
11312
+ });
11313
+ const rows = [];
11314
+ for (const record of full) {
11315
+ const hour = recordToRow(record.id, record.data);
11316
+ if (hour === null) continue;
11317
+ for (const path of hour.paths) {
11318
+ const parsed = parseSegmentPath(path);
11319
+ if (parsed === null) continue;
11320
+ rows.push({
11321
+ deviceId: parsed.deviceId,
11322
+ profile: parsed.profile,
11323
+ startMs: parsed.startMs,
11324
+ durMs: parsed.durMs,
11325
+ bytes: parsed.bytes,
11326
+ path,
11327
+ locationId: hour.locationId
11328
+ });
11329
+ }
11330
+ }
11331
+ return {
11332
+ rows: rows.toSorted((a, b) => a.startMs - b.startMs),
11333
+ exhausted
11334
+ };
11335
+ }
11336
+ /**
11337
+ * Rewrite, once, the rows whose aggregate columns are still the schema
11338
+ * default.
11339
+ *
11340
+ * `declareCollection` adds a column additively, so every row written before
11341
+ * the aggregate columns existed holds `segments = 0` — and an hour row with
11342
+ * no segments cannot exist any other way (`dropSegments` forgets an hour that
11343
+ * empties). So `segments = 0` IS the backfill predicate, and after one pass
11344
+ * it selects nothing. A partial repair finishes on the next boot; until then
11345
+ * the affected disks UNDER-report, which prunes less and never more.
11346
+ */
11347
+ async repairAggregates() {
11348
+ let repaired = 0;
11349
+ try {
11350
+ const stale = await this.store.query.query({
11351
+ collection: RECORDING_SEGMENT_HOURS_COLLECTION,
11352
+ columns: REPAIR_PROBE_COLUMNS,
11353
+ filter: {
11354
+ where: { segments: 0 },
11355
+ limit: REPAIR_MAX_ROWS
11356
+ }
11357
+ });
11358
+ for (const record of stale) {
11359
+ const held = this.ledger.get(record.id);
11360
+ if (held === void 0 || held.segments === 0) continue;
11361
+ await this.ledger.put(held);
11362
+ repaired += 1;
11363
+ }
11364
+ } catch (err) {
11365
+ this.logger.warn("recorder: segment-hour aggregate repair failed — disks under-report", { meta: {
11366
+ collection: RECORDING_SEGMENT_HOURS_COLLECTION,
11367
+ error: String(err)
11368
+ } });
11369
+ return repaired;
11370
+ }
11371
+ if (repaired > 0) this.logger.info("recorder: segment-hour aggregate columns backfilled", { meta: {
11372
+ rows: repaired,
11373
+ collection: RECORDING_SEGMENT_HOURS_COLLECTION
11374
+ } });
11375
+ return repaired;
11376
+ }
11254
11377
  /** Write-behind: a just-finalized segment joins its hour row. */
11255
11378
  async recordSegment(row) {
11256
11379
  const { key, hourStartMs: hour } = hourOf(row);
11257
11380
  const held = this.ledger.get(key);
11258
11381
  if (held !== void 0 && held.paths.includes(row.path)) return;
11259
- await this.ledger.put({
11382
+ await this.ledger.put(segmentHourRow({
11260
11383
  key,
11261
11384
  deviceId: row.deviceId,
11262
11385
  profile: row.profile,
11263
11386
  locationId: row.locationId,
11264
11387
  hourStartMs: hour,
11265
11388
  paths: held === void 0 ? [row.path] : [...held.paths, row.path]
11266
- });
11389
+ }));
11267
11390
  }
11268
11391
  /**
11269
11392
  * Eviction confirmed these paths are gone from disk. Drop them; forget the
@@ -11289,10 +11412,10 @@ var SegmentHourLedger = class {
11289
11412
  continue;
11290
11413
  }
11291
11414
  if (next.length === held.paths.length) continue;
11292
- await this.ledger.put({
11415
+ await this.ledger.put(segmentHourRow({
11293
11416
  ...held,
11294
11417
  paths: next
11295
- });
11418
+ }));
11296
11419
  }
11297
11420
  }
11298
11421
  /**
@@ -11319,18 +11442,18 @@ var SegmentHourLedger = class {
11319
11442
  for (const deviceId of deviceIds) for (const seg of index.segments(deviceId)) {
11320
11443
  const { key, hourStartMs: hour } = hourOf(seg);
11321
11444
  const existing = fromIndex.get(key);
11322
- if (existing === void 0) fromIndex.set(key, {
11445
+ if (existing === void 0) fromIndex.set(key, segmentHourRow({
11323
11446
  key,
11324
11447
  deviceId: seg.deviceId,
11325
11448
  profile: seg.profile,
11326
11449
  locationId: seg.locationId,
11327
11450
  hourStartMs: hour,
11328
11451
  paths: [seg.path]
11329
- });
11330
- else fromIndex.set(key, {
11452
+ }));
11453
+ else fromIndex.set(key, segmentHourRow({
11331
11454
  ...existing,
11332
11455
  paths: [...existing.paths, seg.path]
11333
- });
11456
+ }));
11334
11457
  }
11335
11458
  const refused = this.refusedPrunes(walked, fromIndex);
11336
11459
  for (const row of this.ledger.snapshot()) {
@@ -11344,10 +11467,10 @@ var SegmentHourLedger = class {
11344
11467
  }
11345
11468
  if (additiveOnly) {
11346
11469
  const union = uniquePaths([...disk.paths, ...row.paths]);
11347
- if (!samePaths(union, row.paths)) await this.ledger.put({
11470
+ if (!samePaths(union, row.paths)) await this.ledger.put(segmentHourRow({
11348
11471
  ...row,
11349
11472
  paths: union
11350
- });
11473
+ }));
11351
11474
  continue;
11352
11475
  }
11353
11476
  if (!samePaths(row.paths, disk.paths)) await this.ledger.put(disk);
@@ -11968,22 +12091,14 @@ var EXPORT_SWEEP_INTERVAL_MS = 5 * 6e4;
11968
12091
  * hourly cadence keeps the storage.list/evict cost negligible while honouring
11969
12092
  * the recording-spec §6 periodic retention. */
11970
12093
  var RETENTION_SWEEP_INTERVAL_MS = 60 * 6e4;
11971
- /**
11972
- * How long after boot the FULL-archive index walk starts.
11973
- *
11974
- * The walk is a recursive `readdir` per device over the whole recordings
11975
- * subtree — minutes of network-FS latency on a real archive (measured: 240 s
11976
- * for ONE 43 017-entry device, 2026-08-15) and it is the read model for
11977
- * playback/retention/footprint, NOT the write path. Every read self-hydrates
11978
- * the window it is asked about, and the whole-archive consumers
11979
- * (retention sweep, redundancy janitor, disk-pressure eviction) only ever
11980
- * under-report while the index is partial — so the walk waits out the boot
11981
- * storm (writers attaching, staging reconcile relocating, first viewer paint)
11982
- * instead of competing with it. Five minutes because the storm is measured in
11983
- * seconds-to-a-minute and the consumers re-run on their own cadences anyway
11984
- * (retention hourly, eviction on pressure).
11985
- */
11986
- var FULL_WALK_DEFER_MS = 5 * 6e4;
12094
+ /** What the per-volume usage row reports when the hour ledger is unavailable:
12095
+ * nothing measured, rather than a total counted off a partial RAM index. */
12096
+ var EMPTY_ARCHIVE_ACCOUNTING = {
12097
+ bytes: 0,
12098
+ count: 0,
12099
+ oldestMs: null,
12100
+ newestMs: null
12101
+ };
11987
12102
  /** Grace after a completed download before a `deleteAfterDownload` file is removed. */
11988
12103
  var EXPORT_DELETE_GRACE_MS = 6e4;
11989
12104
  /** Only `.mp4` export ids matching this shape are servable (traversal guard). */
@@ -12122,11 +12237,6 @@ var RecorderV2Addon = class extends BaseAddon {
12122
12237
  exportEngine = null;
12123
12238
  /** Periodic export-expiry sweep timer. Cleared on shutdown. */
12124
12239
  exportSweepTimer = null;
12125
- /** Timer arming the deferred full-archive index walk. Null once fired. */
12126
- fullWalkTimer = null;
12127
- /** Single-flight guard for the full-archive walk (onHubReachable can re-enter
12128
- * on a hub reconnect while a deferred walk is still running). */
12129
- fullWalkInFlight = false;
12130
12240
  /** The `recording` cap provider — retained so the periodic retention sweep can
12131
12241
  * call its `pruneFootage`. Null until built in `onInitialize`. */
12132
12242
  recordingProvider = null;
@@ -12293,7 +12403,7 @@ var RecorderV2Addon = class extends BaseAddon {
12293
12403
  opsLog,
12294
12404
  alert: (input) => this.emitPlacementAlert(input),
12295
12405
  capacityFor: (location) => this.locationCapacity(location.root),
12296
- usedOnLocation: (locationId) => this.index.accountingForLocation(locationId).bytes,
12406
+ usedOnLocation: async (locationId) => (await this.segmentHours?.accountingForLocations(new Set([locationId])))?.bytes ?? 0,
12297
12407
  dailyBytesFor: (deviceId, profile) => this.dailyBytesFor(deviceId, profile)
12298
12408
  });
12299
12409
  this.controller = new RecordingController({
@@ -12405,6 +12515,7 @@ var RecorderV2Addon = class extends BaseAddon {
12405
12515
  locations: () => this.resolvedLocations,
12406
12516
  refreshLocations: () => this.refreshLocations(),
12407
12517
  capacity: (root) => this.locationCapacity(root),
12518
+ archiveAccounting: (locationIds) => this.segmentHours?.accountingForLocations(locationIds) ?? Promise.resolve(EMPTY_ARCHIVE_ACCOUNTING),
12408
12519
  hydrateDevice: (deviceId, locations) => hydrateDeviceFromStorage(this.ctx.api, this.index, deviceId, locations, this.ctx.logger),
12409
12520
  reconcileHourLedger: async (deviceId) => {
12410
12521
  await this.segmentHours?.reconcileFromIndex(this.index, [deviceId], Date.now());
@@ -12435,7 +12546,8 @@ var RecorderV2Addon = class extends BaseAddon {
12435
12546
  const recordingProvider = buildRecordingProvider(recordingDeps);
12436
12547
  this.recordingProvider = recordingProvider;
12437
12548
  const evictableProvider = new StorageEvictableProvider({
12438
- index: this.index,
12549
+ archive: () => this.segmentHours,
12550
+ logger: this.ctx.logger,
12439
12551
  store: this.segmentStore,
12440
12552
  resolveDomain: (locationId) => this.locationDomain(locationId),
12441
12553
  isReadOnly: (locationId) => this.resolvedLocations.find((l) => l.id === locationId)?.readOnly === true,
@@ -12619,10 +12731,6 @@ var RecorderV2Addon = class extends BaseAddon {
12619
12731
  }
12620
12732
  this.retentionSweeper?.stop();
12621
12733
  this.retentionSweeper = null;
12622
- if (this.fullWalkTimer !== null) {
12623
- clearTimeout(this.fullWalkTimer);
12624
- this.fullWalkTimer = null;
12625
- }
12626
12734
  this.exportEngine = null;
12627
12735
  this.recordingProvider = null;
12628
12736
  }
@@ -12734,6 +12842,7 @@ var RecorderV2Addon = class extends BaseAddon {
12734
12842
  try {
12735
12843
  await this.segmentHours?.load();
12736
12844
  this.segmentHours?.hydrateIndex(this.index);
12845
+ this.segmentHours?.repairAggregates();
12737
12846
  } catch (err) {
12738
12847
  this.ctx.logger.warn("recorder: segment-hour ledger load failed — hours stay unknown until a read walks them", { meta: { error: errMsg(err) } });
12739
12848
  }
@@ -13222,85 +13331,6 @@ var RecorderV2Addon = class extends BaseAddon {
13222
13331
  } });
13223
13332
  }
13224
13333
  /**
13225
- * Opt-in fleet walk — NOT armed at boot (2026-08-22: starved scrub/play).
13226
- * Kept so an operator path can still request a full reconcile later.
13227
- *
13228
- * ⚠ That operator path DOES NOT EXIST YET. This is the only caller of
13229
- * `runFullArchiveWalk`, so today the whole walk is unreachable — `tsc` was
13230
- * flagging it as unused and it was one of the errors keeping this package
13231
- * red. Made public rather than deleted, because deleting it would have taken
13232
- * the 100-line walk with it; but a comment promising a path nobody built is
13233
- * exactly the leftover that reads as verification. Wire it or drop it.
13234
- */
13235
- scheduleDeferredFullWalk(deviceIds) {
13236
- if (this.fullWalkTimer !== null || this.fullWalkInFlight) return;
13237
- this.fullWalkTimer = setTimeout(() => {
13238
- this.fullWalkTimer = null;
13239
- this.runFullArchiveWalk(deviceIds);
13240
- }, FULL_WALK_DEFER_MS);
13241
- this.fullWalkTimer.unref?.();
13242
- this.ctx.logger.info("recorder: full-archive index walk deferred", { meta: {
13243
- deferMs: FULL_WALK_DEFER_MS,
13244
- deviceCount: deviceIds.length
13245
- } });
13246
- }
13247
- /**
13248
- * The deferred boot walk itself: hydrate every configured device from the
13249
- * recordings roots, then run the two consumers that need the COMPLETE index
13250
- * (the eviction-domain span log and the redundancy janitor). Bounded-parallel
13251
- * per device, and each camera is marked hydrated at ITS OWN completion — not
13252
- * after the fleet (D167). Never overlaps itself.
13253
- */
13254
- async runFullArchiveWalk(deviceIds) {
13255
- if (this.fullWalkInFlight) return;
13256
- this.fullWalkInFlight = true;
13257
- const hydrateStartedMs = Date.now();
13258
- try {
13259
- await hydrateDevicesFromStorage(this.ctx.api, this.index, deviceIds, this.resolvedLocations, this.ctx.logger);
13260
- this.ctx.logger.info("recorder: hydrated index from storage", { meta: {
13261
- deviceCount: deviceIds.length,
13262
- locationCount: this.resolvedLocations.length,
13263
- ms: Date.now() - hydrateStartedMs
13264
- } });
13265
- try {
13266
- await this.segmentHours?.reconcileFromIndex(this.index, deviceIds, Date.now());
13267
- } catch (err) {
13268
- this.ctx.logger.warn("recorder: segment-hour ledger reconcile failed — walk remains the authority", { meta: { error: errMsg(err) } });
13269
- }
13270
- const loggedDomains = /* @__PURE__ */ new Set();
13271
- for (const loc of this.resolvedLocations) {
13272
- const domain = this.locationDomain(loc.id);
13273
- const domainKey = [...domain].toSorted().join("+");
13274
- if (loggedDomains.has(domainKey)) continue;
13275
- loggedDomains.add(domainKey);
13276
- const acct = this.index.accountingForLocations(domain);
13277
- this.ctx.logger.info("recorder: eviction domain footage span at hydrate", { meta: {
13278
- locationIds: [...domain].toSorted(),
13279
- root: loc.root,
13280
- count: acct.count,
13281
- bytes: acct.bytes,
13282
- oldestMs: acct.oldestMs,
13283
- newestMs: acct.newestMs
13284
- } });
13285
- }
13286
- const store = this.segmentStore;
13287
- if (store) try {
13288
- await runRedundancyJanitor({
13289
- deviceIds,
13290
- segmentsFor: (deviceId, profile) => this.index.segments(deviceId, profile),
13291
- evict: (location, rows) => store.evict(location, rows),
13292
- logger: this.ctx.logger
13293
- });
13294
- } catch (err) {
13295
- this.ctx.logger.warn("recorder: redundancy janitor failed", { meta: { error: errMsg(err) } });
13296
- }
13297
- } catch (err) {
13298
- this.ctx.logger.warn("recorder: full-archive walk failed", { meta: { error: errMsg(err) } });
13299
- } finally {
13300
- this.fullWalkInFlight = false;
13301
- }
13302
- }
13303
- /**
13304
13334
  * Declare the three row collections that replaced the `recordingConfigs`,
13305
13335
  * `recordingExports` and `recordingOpsLog` blob keys. Idempotent — a
13306
13336
  * re-declaration of the same shape is a no-op in the engine.