@camstack/addon-pipeline 1.2.144 → 1.2.145

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/detection-pipeline/index.js +5 -5
  4. package/dist/detection-pipeline/index.mjs +4 -4
  5. package/dist/{dist-Dpy1KP1q.js → dist-DML3mpn9.js} +6 -6
  6. package/dist/{dist-uX-YDOrc.mjs → dist-cZopp8Xm.mjs} +6 -6
  7. package/dist/{event-loop-stall-monitor-6YKQeg7_.mjs → event-loop-stall-monitor-DH_vFYWu.mjs} +1 -1
  8. package/dist/{event-loop-stall-monitor-D947d97M.js → event-loop-stall-monitor-DaV4Zdsz.js} +1 -1
  9. package/dist/{lazy-sharp-zDl97I3e.js → lazy-sharp-BzXIjfN8.js} +1 -1
  10. package/dist/motion-wasm/index.js +2 -2
  11. package/dist/motion-wasm/index.mjs +1 -1
  12. package/dist/{node-DuMWMV01.mjs → node-Br6p9KvD.mjs} +1 -1
  13. package/dist/{node-v9ZAGK2b.js → node-OvVqe9eh.js} +1 -1
  14. package/dist/pipeline-runner/index.js +5 -5
  15. package/dist/pipeline-runner/index.mjs +4 -4
  16. package/dist/{process-memory-CNISjywZ.mjs → process-memory-DX25Z60Q.mjs} +1 -1
  17. package/dist/{process-memory-ClAfQ1Nl.js → process-memory-NWKrKfmi.js} +1 -1
  18. package/dist/recorder/index.js +549 -41
  19. package/dist/recorder/index.mjs +549 -41
  20. package/dist/{segment-demux-js-Bpedn-T8.js → segment-demux-js-CerfMroc.js} +1 -1
  21. package/dist/{segment-demux-js-CCMG51zw.mjs → segment-demux-js-DGmFQ1Zj.mjs} +1 -1
  22. package/dist/session-decode/decode-worker-child.js +2 -2
  23. package/dist/session-decode/decode-worker-child.mjs +1 -1
  24. package/dist/stream-broker/{_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-MRLUZgkR.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-DQNgiTjz.mjs} +3 -3
  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-w0ytBmjx.mjs → hostInit-NShcJE25.mjs} +3 -3
  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/dist/{worker-protocol-Cd-G92Qt.mjs → worker-protocol-CUCQ1IPR.mjs} +1 -1
  32. package/dist/{worker-protocol-Cn5x8dbW.js → worker-protocol-Dt3zWCQY.js} +1 -1
  33. package/package.json +1 -1
@@ -1,5 +1,5 @@
1
- import { $ as isFirstLevelMacroClass, $t as record, B as defineCustomActions, Bt as nodePin, E as RecordingConfigSchema, It as hydrateSchema, J as evaluateSensorEdge, Kt as array, L as customAction, N as batteryCapability, Nt as DeviceFeature, Pt as DeviceType, Qt as object, T as RECORDING_EXPORT_MAX_READ_BYTES, U as deriveRecordingMode, Ut as selectAssignedProfileSlots, V as deriveBatteryPresence, Zt as number, bt as storageEvictableCapability, d as DEFAULT_EVENTS_BAND_BUFFER_SEC, en as string, et as loadContributionCapability, g as ExportRecordSchema, i as BatteryStatusSchema, jt as BaseAddon, mt as recordingExportCapability, nn as EventCategory, pt as recordingCapability, qt as boolean, vt as resolveRecordingProfiles, wt as errMsg, x as OpsLogEntrySchema } from "../dist-uX-YDOrc.mjs";
2
- import { i as NO_COST_CLAIM, t as ChildCostRegistry } from "../node-DuMWMV01.mjs";
1
+ import { $ as isFirstLevelMacroClass, $t as record, B as defineCustomActions, Bt as nodePin, E as RecordingConfigSchema, It as hydrateSchema, J as evaluateSensorEdge, Kt as array, L as customAction, N as batteryCapability, Nt as DeviceFeature, Pt as DeviceType, Qt as object, T as RECORDING_EXPORT_MAX_READ_BYTES, U as deriveRecordingMode, Ut as selectAssignedProfileSlots, V as deriveBatteryPresence, Zt as number, bt as storageEvictableCapability, d as DEFAULT_EVENTS_BAND_BUFFER_SEC, en as string, et as loadContributionCapability, g as ExportRecordSchema, i as BatteryStatusSchema, jt as BaseAddon, mt as recordingExportCapability, nn as EventCategory, pt as recordingCapability, qt as boolean, vt as resolveRecordingProfiles, wt as errMsg, x as OpsLogEntrySchema } from "../dist-cZopp8Xm.mjs";
2
+ import { i as NO_COST_CLAIM, t as ChildCostRegistry } from "../node-Br6p9KvD.mjs";
3
3
  import { t as resolveHubHostname } from "../hub-hostname-cCknRYKj.mjs";
4
4
  import { r as withRecordingIntent } from "../restream-intent-B4BXZra7.mjs";
5
5
  import { n as RowMapStore, t as retireRootKeys } from "../retire-root-keys-DMolfhsP.mjs";
@@ -1071,6 +1071,45 @@ var RecordingIndex = class {
1071
1071
  };
1072
1072
  }
1073
1073
  /**
1074
+ * How many materialised sorted views exist and how many row slots they hold.
1075
+ *
1076
+ * The question this answers: **what is in `large_object_space`?** Production
1077
+ * on 2026-08-29 read ~490 MB there, stable across four samples five minutes
1078
+ * apart — retained, not churn — against 110 MB on hub-main with a comparable
1079
+ * total heap. V8 only puts an object there when it does not fit a normal
1080
+ * page (~256 KB and up), and a sorted view is the recorder's obvious
1081
+ * candidate: one `SegmentRow[]` per (device, profile-or-all), materialised
1082
+ * on first read and then kept live. At ~296 k archive rows a single
1083
+ * all-profiles view is a contiguous array of hundreds of thousands of
1084
+ * pointers — megabytes, in one allocation, per device.
1085
+ *
1086
+ * Slots, not bytes: the array's own allocation is `rowSlots × 8 B` on a
1087
+ * 64-bit heap, and the rows it points at are counted once in `byDevice`
1088
+ * regardless. Reporting a byte estimate would be inventing precision this
1089
+ * cannot have; the slot count is the thing that is actually known and it is
1090
+ * what a reader needs to decide whether the views are the 490 MB.
1091
+ */
1092
+ sortedViewCensus() {
1093
+ let views = 0;
1094
+ let rowSlots = 0;
1095
+ let largestRows = 0;
1096
+ let largestDeviceId = 0;
1097
+ for (const [deviceId, byKey] of this.sortedByDevice) for (const rows of byKey.values()) {
1098
+ views += 1;
1099
+ rowSlots += rows.length;
1100
+ if (rows.length > largestRows) {
1101
+ largestRows = rows.length;
1102
+ largestDeviceId = deviceId;
1103
+ }
1104
+ }
1105
+ return {
1106
+ views,
1107
+ rowSlots,
1108
+ largestRows,
1109
+ largestDeviceId
1110
+ };
1111
+ }
1112
+ /**
1074
1113
  * Ordered insert of ONE NEW row into every live view it belongs to (the
1075
1114
  * all-profiles view and its own profile's view). Views not yet materialised
1076
1115
  * stay unmaterialised — their first read pays the one build. PRECONDITION:
@@ -2028,7 +2067,7 @@ var SegmentStore = class SegmentStore {
2028
2067
  return counts;
2029
2068
  };
2030
2069
  const chunkSize = this.deps.evictChunkSize ?? SegmentStore.DEFAULT_EVICT_CHUNK;
2031
- const yieldBetween = this.deps.yieldBetween ?? macrotask$2;
2070
+ const yieldBetween = this.deps.yieldBetween ?? macrotask$3;
2032
2071
  let bucketsRemoved = 0;
2033
2072
  let handled = 0;
2034
2073
  for (const [bucketDir, bucketRows] of byBucket) {
@@ -2166,7 +2205,7 @@ var SegmentStore = class SegmentStore {
2166
2205
  return indexedInBucket === indexedVictims.size;
2167
2206
  }
2168
2207
  };
2169
- var macrotask$2 = () => new Promise((resolve) => {
2208
+ var macrotask$3 = () => new Promise((resolve) => {
2170
2209
  setImmediate(resolve);
2171
2210
  });
2172
2211
  //#endregion
@@ -7193,7 +7232,7 @@ async function resolveRecordingsLocations(api, logger) {
7193
7232
  * (best-effort), so one bad volume never blocks the rest of the hydrate.
7194
7233
  */
7195
7234
  async function hydrateDeviceFromStorage(_api, index, deviceId, locations, logger, options = {}) {
7196
- const yieldBetween = options.yieldBetween ?? macrotask$1;
7235
+ const yieldBetween = options.yieldBetween ?? macrotask$2;
7197
7236
  const chunkSize = options.chunkSize ?? HYDRATE_CHUNK;
7198
7237
  for (const [root, aliases] of aliasesByRoot(locations)) {
7199
7238
  const deviceDir = path.join(root, String(deviceId));
@@ -7261,7 +7300,7 @@ async function hydrateDeviceFromStorage(_api, index, deviceId, locations, logger
7261
7300
  }
7262
7301
  /** Entries classified between yields in {@link hydrateDeviceFromStorage}. */
7263
7302
  var HYDRATE_CHUNK = 2e4;
7264
- var macrotask$1 = () => new Promise((resolve) => {
7303
+ var macrotask$2 = () => new Promise((resolve) => {
7265
7304
  setImmediate(resolve);
7266
7305
  });
7267
7306
  /** `<deviceId>/<profile>/YYYY/MM/DD/HH` for an hour bucket (paths are UTC). */
@@ -7287,9 +7326,56 @@ var HOUR_MS$2 = 36e5;
7287
7326
  *
7288
7327
  * Hours already walked are skipped, so a drag across an hour costs nothing the
7289
7328
  * second time.
7290
- */
7291
- async function hydrateWindowFromStorage(index, deviceId, fromMs, toMs, locations, logger) {
7329
+ *
7330
+ * ## The ledger is the first stop for a CLOSED hour (D287)
7331
+ *
7332
+ * Boot no longer replays the archive into the index, so a ledger-known hour
7333
+ * arrives here `unknown` and pays ONE point read of its hour row instead of a
7334
+ * `readdir` — measured at ~650 ms per hour directory on the live share under
7335
+ * load, against the array the scrub feeder is competing for. Hours the ledger
7336
+ * does not know, and every hour if the read fails, fall through to the walk
7337
+ * exactly as before: the failure direction is toward the disk, never toward an
7338
+ * hour that renders empty because nobody read it.
7339
+ *
7340
+ * **The live hour is never served from the ledger.** It is the one hour whose
7341
+ * durable row is knowably behind the disk: a segment finalized after the last
7342
+ * persist and before a crash is on the array and not in the row. Boot used to
7343
+ * mark every ledger hour `hydrated`, which made that tail invisible to
7344
+ * playback and the timeline FOREVER, not merely under-reported. Excluding the
7345
+ * live hour costs exactly one hour-directory read per process — the walk marks
7346
+ * it, so the second reader pays nothing — and it is what turns a lost tail
7347
+ * back into a recoverable one.
7348
+ *
7349
+ * A CLOSED hour already walked is skipped, which is what makes that exclusion
7350
+ * affordable: without it, every window touching the live hour would re-walk
7351
+ * all of its hours. The past is immutable, so re-reading a closed hour
7352
+ * directory can only ever return what the index already holds; eviction
7353
+ * removes rows through the index directly.
7354
+ */
7355
+ async function hydrateWindowFromStorage(index, deviceId, fromMs, toMs, locations, logger, ledger) {
7292
7356
  if (index.hydrationOf(deviceId, fromMs, toMs) === "hydrated") return;
7357
+ const liveHour = Math.floor(Date.now() / HOUR_MS$2) * HOUR_MS$2;
7358
+ if (ledger !== void 0) {
7359
+ const startedMs = Date.now();
7360
+ const hours = await ledger.detailForWindow(deviceId, fromMs, toMs);
7361
+ let segments = 0;
7362
+ let served = 0;
7363
+ for (const hour of hours) {
7364
+ if (hour.hourStartMs >= liveHour) continue;
7365
+ index.hydrateHour(deviceId, hour.locationId, hour.hourStartMs, hour.paths);
7366
+ segments += hour.paths.length;
7367
+ served += 1;
7368
+ }
7369
+ if (served > 0) logger.info("recorder: hydrated a window from the hour ledger", {
7370
+ tags: { deviceId },
7371
+ meta: {
7372
+ hours: served,
7373
+ segments,
7374
+ ms: Date.now() - startedMs
7375
+ }
7376
+ });
7377
+ if (index.hydrationOf(deviceId, fromMs, toMs) === "hydrated") return;
7378
+ }
7293
7379
  for (const [root, aliases] of aliasesByRoot(locations)) {
7294
7380
  const deviceDir = path.join(root, String(deviceId));
7295
7381
  let profiles;
@@ -7308,16 +7394,26 @@ async function hydrateWindowFromStorage(index, deviceId, fromMs, toMs, locations
7308
7394
  const first = Math.floor(fromMs / HOUR_MS$2) * HOUR_MS$2;
7309
7395
  const reuseBefore = index.hydrateReuseStats();
7310
7396
  let hoursRead = 0;
7311
- for (let hour = first; hour < toMs; hour += HOUR_MS$2) for (const profile of profiles) {
7312
- const rel = hourDirRelPath(deviceId, profile, hour);
7313
- let files;
7314
- try {
7315
- files = await promises.readdir(path.join(root, rel));
7316
- } catch {
7317
- continue;
7397
+ let repaired = 0;
7398
+ for (let hour = first; hour < toMs; hour += HOUR_MS$2) {
7399
+ if (hour < liveHour && index.hydrationOf(deviceId, hour, hour + HOUR_MS$2) === "hydrated") continue;
7400
+ for (const profile of profiles) {
7401
+ const rel = hourDirRelPath(deviceId, profile, hour);
7402
+ let files;
7403
+ try {
7404
+ files = await promises.readdir(path.join(root, rel));
7405
+ } catch {
7406
+ continue;
7407
+ }
7408
+ hoursRead += 1;
7409
+ const locationId = locationForHydratedProfile(aliases, profile).id;
7410
+ const relPaths = files.filter((f) => f.endsWith(".m4s")).map((f) => `${rel}/${f}`);
7411
+ index.hydrateHour(deviceId, locationId, hour, relPaths);
7412
+ if (ledger !== void 0 && relPaths.length > 0 && hour + HOUR_MS$2 <= Date.now() && !ledger.knowsHour(deviceId, profile, locationId, hour)) {
7413
+ repaired += 1;
7414
+ await ledger.adoptRebuiltHour(deviceId, profile, locationId, hour, relPaths);
7415
+ }
7318
7416
  }
7319
- hoursRead += 1;
7320
- index.hydrateHour(deviceId, locationForHydratedProfile(aliases, profile).id, hour, files.filter((f) => f.endsWith(".m4s")).map((f) => `${rel}/${f}`));
7321
7417
  }
7322
7418
  index.markHydrated(deviceId, fromMs, toMs);
7323
7419
  if (hoursRead === 0) continue;
@@ -7329,6 +7425,7 @@ async function hydrateWindowFromStorage(index, deviceId, fromMs, toMs, locations
7329
7425
  meta: {
7330
7426
  root,
7331
7427
  hoursRead,
7428
+ repaired,
7332
7429
  reused,
7333
7430
  allocated
7334
7431
  }
@@ -11610,6 +11707,9 @@ var DurableLedger = class DurableLedger {
11610
11707
  //#endregion
11611
11708
  //#region src/recorder/addon/segment-hour-ledger.ts
11612
11709
  var HOUR_MS$1 = 36e5;
11710
+ var macrotask$1 = () => new Promise((resolve) => {
11711
+ setImmediate(resolve);
11712
+ });
11613
11713
  /**
11614
11714
  * @durable class=mirror owner=recorder
11615
11715
  * write="write-behind on every finalized segment (hour-row upsert); eviction drops paths; the archive walk reconciles disk into the row set"
@@ -11757,6 +11857,7 @@ function hourAggregate(paths) {
11757
11857
  function segmentHourRow(base) {
11758
11858
  return {
11759
11859
  ...base,
11860
+ pathsResident: true,
11760
11861
  ...hourAggregate(base.paths)
11761
11862
  };
11762
11863
  }
@@ -11812,6 +11913,44 @@ var OLDEST_PROBE_COLUMNS = [
11812
11913
  var REPAIR_PROBE_COLUMNS = ["segments"];
11813
11914
  /** Rows repaired per boot. The live archive is ~19 k hour rows. */
11814
11915
  var REPAIR_MAX_ROWS = 5e4;
11916
+ /**
11917
+ * The boot projection (D287): everything the recorder's always-resident tier
11918
+ * needs, and deliberately NOT `paths`.
11919
+ *
11920
+ * `paths` is ~55 B per segment and the archive is the only thing that grows:
11921
+ * ~16 MB of JSON today, a projected ~460 MB at 28 TB of today's bitrate mix
11922
+ * and ~5.9 GB at a low-bitrate mix — one tRPC response, parsed on the main
11923
+ * thread, before the index exists. The summary half is O(writers × days) and
11924
+ * stays ~10–15 MB at any archive size.
11925
+ */
11926
+ var SEGMENT_HOUR_SUMMARY_COLUMNS = [
11927
+ "deviceId",
11928
+ "profile",
11929
+ "locationId",
11930
+ "hourStartMs",
11931
+ "bytes",
11932
+ "segments",
11933
+ "minStartMs",
11934
+ "maxStartMs"
11935
+ ];
11936
+ /** Summary rows folded into the mirror between yields. */
11937
+ var LOAD_CHUNK = 5e3;
11938
+ /** Hour rows materialised per point-read page. One page covers a day of one
11939
+ * camera's three profiles with room to spare. */
11940
+ var HOUR_DETAIL_PAGE = 64;
11941
+ /**
11942
+ * The live tail materialised at boot: the current hour and the one before it.
11943
+ *
11944
+ * `recordSegment` must gate from RAM (D49), and the hour it appends to at boot
11945
+ * is exactly the hour the previous process was writing. Two hours × the active
11946
+ * writers is ~60 rows — one point-read page — and it is what makes the write
11947
+ * path's materialise a case that essentially never happens.
11948
+ */
11949
+ var LIVE_TAIL_HOURS = 2;
11950
+ /** Rows the one-shot aggregate repair may materialise in a single boot. The
11951
+ * backfill is bounded so it cannot re-become the whole-archive read D287
11952
+ * removed; a partial pass finishes on the next boot. */
11953
+ var REPAIR_MAX_MATERIALIZE = 2e3;
11815
11954
  /** The four scalars an accounting answer is made of, asked in one statement. */
11816
11955
  var ACCOUNTING_FIELDS = [
11817
11956
  {
@@ -11904,6 +12043,44 @@ function recordToRow(key, data) {
11904
12043
  paths: raw
11905
12044
  });
11906
12045
  }
12046
+ /** The paths of a summary row: one shared empty array, never a per-row
12047
+ * allocation, and never mistakable for "this hour holds nothing". */
12048
+ var NO_PATHS = Object.freeze([]);
12049
+ function finiteOr0(raw) {
12050
+ const n = Number(raw);
12051
+ return Number.isFinite(n) ? n : 0;
12052
+ }
12053
+ /**
12054
+ * One row of the boot projection ({@link SEGMENT_HOUR_SUMMARY_COLUMNS}).
12055
+ *
12056
+ * The identity half is validated exactly as {@link recordToRow} validates it —
12057
+ * a row that cannot say which camera, profile, location and hour it is about
12058
+ * is skipped, because a summary keyed on garbage would refuse the write path
12059
+ * for an hour that is perfectly fine. The aggregate half is taken as the
12060
+ * database wrote it: a row that predates the aggregate columns reports zeros,
12061
+ * which UNDER-reports its disk (prunes less, never more) until
12062
+ * {@link SegmentHourLedger.repairAggregates} rewrites it.
12063
+ */
12064
+ function summaryToRow(key, data) {
12065
+ const deviceId = Number(data["deviceId"]);
12066
+ const profile = data["profile"];
12067
+ const locationId = data["locationId"];
12068
+ const hour = Number(data["hourStartMs"]);
12069
+ if (!Number.isFinite(deviceId) || deviceId <= 0 || typeof profile !== "string" || profile.length === 0 || typeof locationId !== "string" || locationId.length === 0 || !Number.isFinite(hour)) return null;
12070
+ return {
12071
+ key,
12072
+ deviceId,
12073
+ profile,
12074
+ locationId,
12075
+ hourStartMs: hour,
12076
+ paths: NO_PATHS,
12077
+ pathsResident: false,
12078
+ bytes: finiteOr0(data["bytes"]),
12079
+ segments: finiteOr0(data["segments"]),
12080
+ minStartMs: finiteOr0(data["minStartMs"]),
12081
+ maxStartMs: finiteOr0(data["maxStartMs"])
12082
+ };
12083
+ }
11907
12084
  var SPEC = {
11908
12085
  collection: RECORDING_SEGMENT_HOURS_COLLECTION,
11909
12086
  columns: RECORDING_SEGMENT_HOURS_COLUMNS,
@@ -11924,10 +12101,18 @@ var SegmentHourLedger = class {
11924
12101
  * accounting number is not a gate. */
11925
12102
  store;
11926
12103
  isLocationUsable;
12104
+ now;
12105
+ loadChunk;
12106
+ yieldBetween;
12107
+ rowCap;
11927
12108
  constructor(deps) {
11928
12109
  this.logger = deps.logger;
11929
12110
  this.store = deps.store;
11930
12111
  this.isLocationUsable = deps.isLocationUsable ?? (() => true);
12112
+ this.now = deps.now ?? (() => Date.now());
12113
+ this.loadChunk = deps.loadPacing?.chunkSize ?? LOAD_CHUNK;
12114
+ this.yieldBetween = deps.loadPacing?.yieldBetween ?? macrotask$1;
12115
+ this.rowCap = deps.loadPacing?.rowCap ?? 25e4;
11931
12116
  this.ledger = new DurableLedger({
11932
12117
  spec: SPEC,
11933
12118
  store: deps.store,
@@ -11940,26 +12125,270 @@ var SegmentHourLedger = class {
11940
12125
  declare() {
11941
12126
  return this.ledger.declare();
11942
12127
  }
12128
+ /**
12129
+ * Boot load — **T1 only** (D287).
12130
+ *
12131
+ * One projected query over {@link SEGMENT_HOUR_SUMMARY_COLUMNS}, folded into
12132
+ * the mirror in chunks with a yield between them, then one point-read page
12133
+ * that materialises the live tail so the write path can gate from RAM.
12134
+ *
12135
+ * It replaces `DurableLedger.load`, which fetches whole rows and therefore
12136
+ * the whole archive's path strings, and it keeps that primitive's two rules:
12137
+ * a failed read RETURNS WHAT IS ALREADY MIRRORED rather than clearing it,
12138
+ * and a malformed row is skipped rather than repaired.
12139
+ *
12140
+ * It deliberately does NOT replay the archive into {@link RecordingIndex}.
12141
+ * `hydrated` now means "the rows are resident", so a ledger-known hour is
12142
+ * merely *hydratable from T3*: the first read of it pays one point read
12143
+ * ({@link detailForWindow}), and an hour whose tail the ledger is missing is
12144
+ * no longer permanently invisible to playback.
12145
+ */
11943
12146
  async load() {
11944
- const rows = await this.ledger.load();
12147
+ const startedMs = this.now();
12148
+ let records;
12149
+ try {
12150
+ records = await this.store.query.query({
12151
+ collection: RECORDING_SEGMENT_HOURS_COLLECTION,
12152
+ columns: SEGMENT_HOUR_SUMMARY_COLUMNS,
12153
+ filter: { limit: this.rowCap }
12154
+ });
12155
+ } catch (err) {
12156
+ this.logger.warn("recorder: segment-hour ledger load failed — keeping the hours already held", { meta: {
12157
+ collection: RECORDING_SEGMENT_HOURS_COLLECTION,
12158
+ held: this.ledger.size,
12159
+ error: String(err)
12160
+ } });
12161
+ return this.ledger.snapshot();
12162
+ }
12163
+ const queriedMs = this.now();
12164
+ let skipped = 0;
12165
+ for (let i = 0; i < records.length; i += this.loadChunk) {
12166
+ const end = Math.min(i + this.loadChunk, records.length);
12167
+ for (let j = i; j < end; j += 1) {
12168
+ const record = records[j];
12169
+ const row = summaryToRow(record.id, record.data);
12170
+ if (row === null) {
12171
+ skipped += 1;
12172
+ continue;
12173
+ }
12174
+ this.ledger.stage(row);
12175
+ }
12176
+ if (end < records.length) await this.yieldBetween();
12177
+ }
12178
+ if (skipped > 0) this.logger.warn("recorder: segment-hour summaries skipped as malformed — they gate NOTHING", { meta: {
12179
+ collection: RECORDING_SEGMENT_HOURS_COLLECTION,
12180
+ skipped
12181
+ } });
12182
+ if (records.length >= this.rowCap) this.logger.warn("recorder: segment-hour ledger load hit its row cap — hours past the cap are UNKNOWN to this process and will be walked from disk", { meta: {
12183
+ limit: this.rowCap,
12184
+ loaded: records.length
12185
+ } });
12186
+ const tail = await this.materializeLiveTail();
11945
12187
  this.logger.info("recorder: segment-hour ledger loaded", { meta: {
11946
- hours: rows.length,
12188
+ hours: this.ledger.size,
12189
+ pathsLoaded: false,
12190
+ liveTailHours: tail,
12191
+ skipped,
12192
+ queryMs: queriedMs - startedMs,
12193
+ ms: this.now() - startedMs,
11947
12194
  collection: RECORDING_SEGMENT_HOURS_COLLECTION
11948
12195
  } });
11949
- return rows;
12196
+ return this.ledger.snapshot();
11950
12197
  }
12198
+ /** Every hour the ledger knows — summaries included. A row whose
12199
+ * `pathsResident` is false carries real aggregates and empty `paths`. */
11951
12200
  snapshot() {
11952
12201
  return this.ledger.snapshot();
11953
12202
  }
12203
+ /** Does the ledger hold a row for this hour at all? Pure RAM — the gate the
12204
+ * storage-walk repair consults before it upserts a rebuilt hour. */
12205
+ knowsHour(deviceId, profile, locationId, hour) {
12206
+ return this.ledger.has(segmentHourKey(deviceId, profile, locationId, hour));
12207
+ }
11954
12208
  /**
11955
- * Seed {@link RecordingIndex} from the mirrored hour rows.
12209
+ * The hours of `[fromMs, toMs)` the ledger knows, with their paths — the
12210
+ * cold-hour detail read that replaces the boot replay (D287).
12211
+ *
12212
+ * One bounded point read against the settings store, never the media array:
12213
+ * a `readdir` of one hour directory was measured at ~650 ms under load on
12214
+ * the live share, and the array is the resource the scrub feeder is
12215
+ * competing for. Hours the ledger does NOT know are absent from the result,
12216
+ * and the caller walks those from disk exactly as it always did.
11956
12217
  *
11957
- * Hours present in the ledger become `hydrated` so a seek in those windows
11958
- * does not pay for a disk walk. Hours the ledger never held stay `unknown`
11959
- * — that is the fail-toward-walk direction.
12218
+ * **A read that fails returns what it already had** (D49). The caller sees
12219
+ * fewer hours, the index stays `unknown` for the rest, and the window falls
12220
+ * through to the walk nothing is destroyed and nothing renders as an
12221
+ * empty hour that was merely unread.
11960
12222
  */
11961
- hydrateIndex(index) {
11962
- for (const hour of this.ledger.snapshot()) index.hydrateHour(hour.deviceId, hour.locationId, hour.hourStartMs, hour.paths);
12223
+ async detailForWindow(deviceId, fromMs, toMs) {
12224
+ const first = hourStartMs(fromMs);
12225
+ const out = [];
12226
+ const wanted = [];
12227
+ for (const row of this.ledger.snapshot()) {
12228
+ if (row.deviceId !== deviceId) continue;
12229
+ if (row.hourStartMs < first || row.hourStartMs >= toMs) continue;
12230
+ if (row.pathsResident) {
12231
+ out.push({
12232
+ locationId: row.locationId,
12233
+ hourStartMs: row.hourStartMs,
12234
+ paths: row.paths
12235
+ });
12236
+ continue;
12237
+ }
12238
+ wanted.push(row.key);
12239
+ }
12240
+ for (const row of await this.materialize(wanted, deviceId)) out.push({
12241
+ locationId: row.locationId,
12242
+ hourStartMs: row.hourStartMs,
12243
+ paths: row.paths
12244
+ });
12245
+ return out;
12246
+ }
12247
+ /**
12248
+ * Adopt an hour the storage walk rebuilt and the ledger never held.
12249
+ *
12250
+ * The reader repairs the durable index it just reconstructed, so the next
12251
+ * cold read of this hour is a point read instead of another `readdir`
12252
+ * (`docs/design/2026-08-29-scrypted-nvr-recording-model.md` §4.2). Two
12253
+ * constraints, both enforced by the caller and re-stated here because
12254
+ * getting either wrong turns a READ into durable damage: the hour must be
12255
+ * CLOSED (a live hour's directory races the writer, so a walk of it is a
12256
+ * lower bound, not the truth), and the ledger must hold no row for it (an
12257
+ * existing row is the authority; replacing it with a walk's view is the
12258
+ * D148 prune this ledger refuses to do on its own evidence).
12259
+ */
12260
+ async adoptRebuiltHour(deviceId, profile, locationId, hour, paths) {
12261
+ const key = segmentHourKey(deviceId, profile, locationId, hour);
12262
+ if (paths.length === 0 || this.ledger.has(key)) return;
12263
+ const row = segmentHourRow({
12264
+ key,
12265
+ deviceId,
12266
+ profile,
12267
+ locationId,
12268
+ hourStartMs: hour,
12269
+ paths: uniquePaths(paths)
12270
+ });
12271
+ await this.ledger.put(row);
12272
+ this.logger.info("recorder: adopted a closed hour the disk walk rebuilt and the ledger never held", {
12273
+ tags: { deviceId },
12274
+ meta: {
12275
+ profile,
12276
+ locationId,
12277
+ hourStartMs: hour,
12278
+ segments: row.segments,
12279
+ bytes: row.bytes
12280
+ }
12281
+ });
12282
+ }
12283
+ /**
12284
+ * Fetch whole rows for `keys` and promote them in the mirror.
12285
+ *
12286
+ * Paged and yielding: an export window can name thousands of hours, and one
12287
+ * `whereIn` over all of them would rebuild the very whole-archive response
12288
+ * D287 removed. A page that FAILS ends the materialise and returns what
12289
+ * landed — partial detail is real detail, and the caller's fallback (a disk
12290
+ * walk, or refusing a write) is strictly safer than a guess.
12291
+ *
12292
+ * A row the store returns but this cannot parse is EVICTED from the mirror
12293
+ * rather than left as a summary that can never become resident: it re-seeds
12294
+ * cold on the next write, which is `DurableLedger`'s rule 4 and the only
12295
+ * escape from an hour permanently frozen against its own writer.
12296
+ */
12297
+ async materialize(keys, deviceId) {
12298
+ const out = [];
12299
+ if (keys.length === 0) return out;
12300
+ const tags = deviceId === void 0 ? {} : { tags: { deviceId } };
12301
+ for (let i = 0; i < keys.length; i += HOUR_DETAIL_PAGE) {
12302
+ const page = keys.slice(i, i + HOUR_DETAIL_PAGE);
12303
+ let records;
12304
+ try {
12305
+ records = await this.store.query.query({
12306
+ collection: RECORDING_SEGMENT_HOURS_COLLECTION,
12307
+ filter: {
12308
+ whereIn: { key: page },
12309
+ limit: page.length
12310
+ }
12311
+ });
12312
+ } catch (err) {
12313
+ this.logger.warn("recorder: hour-detail point read failed — these hours stay summaries, and the caller walks the disk or declines the write", {
12314
+ ...tags,
12315
+ meta: {
12316
+ collection: RECORDING_SEGMENT_HOURS_COLLECTION,
12317
+ asked: page.length,
12318
+ materialized: out.length,
12319
+ error: String(err)
12320
+ }
12321
+ });
12322
+ return out;
12323
+ }
12324
+ for (const record of records) {
12325
+ const row = recordToRow(record.id, record.data);
12326
+ if (row === null) {
12327
+ this.ledger.evict(record.id);
12328
+ this.logger.warn("recorder: hour row is unparseable — dropping the summary so the hour re-seeds cold instead of freezing its writer", {
12329
+ ...tags,
12330
+ meta: { key: record.id }
12331
+ });
12332
+ continue;
12333
+ }
12334
+ this.ledger.stage(row);
12335
+ out.push(row);
12336
+ }
12337
+ if (i + HOUR_DETAIL_PAGE < keys.length) await this.yieldBetween();
12338
+ }
12339
+ return out;
12340
+ }
12341
+ /**
12342
+ * Materialise every hour these devices hold, and report how many stayed
12343
+ * summaries.
12344
+ *
12345
+ * The reconcile is the one path that DELETES durable rows, so it may not run
12346
+ * against a mirror that only half knows what it holds. This is a whole-device
12347
+ * read and it is deliberately confined here: `reconcileFromIndex` follows an
12348
+ * operator-armed rescan of that same device — a recursive `readdir` over
12349
+ * >1M files — so a paged point read alongside it is noise, and it is the one
12350
+ * caller whose correctness depends on completeness.
12351
+ */
12352
+ async materializeDevices(deviceIds) {
12353
+ const keys = [];
12354
+ for (const row of this.ledger.snapshot()) if (!row.pathsResident && deviceIds.has(row.deviceId)) keys.push(row.key);
12355
+ if (keys.length === 0) return;
12356
+ await this.materialize(keys);
12357
+ const stillSummary = /* @__PURE__ */ new Map();
12358
+ for (const row of this.ledger.snapshot()) {
12359
+ if (row.pathsResident || !deviceIds.has(row.deviceId)) continue;
12360
+ stillSummary.set(row.deviceId, (stillSummary.get(row.deviceId) ?? 0) + 1);
12361
+ }
12362
+ for (const [deviceId, unreadable] of stillSummary) this.logger.warn("recorder: hours without readable detail take NO part in this reconcile — they are neither pruned nor unioned", {
12363
+ tags: { deviceId },
12364
+ meta: {
12365
+ asked: keys.length,
12366
+ unreadable
12367
+ }
12368
+ });
12369
+ }
12370
+ /** The hour a write path may touch: current + previous. Materialised at boot
12371
+ * so {@link recordSegment}'s dedup gate answers from RAM (D49). */
12372
+ async materializeLiveTail() {
12373
+ const oldest = hourStartMs(this.now()) - (LIVE_TAIL_HOURS - 1) * HOUR_MS$1;
12374
+ const keys = [];
12375
+ for (const row of this.ledger.snapshot()) if (row.hourStartMs >= oldest && !row.pathsResident) keys.push(row.key);
12376
+ return (await this.materialize(keys)).length;
12377
+ }
12378
+ /**
12379
+ * Promote one HELD row to paths-resident, or answer `null`.
12380
+ *
12381
+ * `null` means "this hour is known and its detail could not be had" — the
12382
+ * only honest answer to a failed point read, and every caller turns it into
12383
+ * "do nothing". It is deliberately distinct from `this.ledger.get(key) ===
12384
+ * undefined`, which means "no such hour" and is what licenses a fresh row.
12385
+ */
12386
+ async resident(key, deviceId) {
12387
+ const held = this.ledger.get(key);
12388
+ if (held !== void 0 && held.pathsResident) return held;
12389
+ await this.materialize([key], deviceId);
12390
+ const after = this.ledger.get(key);
12391
+ return after !== void 0 && after.pathsResident ? after : null;
11963
12392
  }
11964
12393
  /**
11965
12394
  * Bytes / count / oldest / newest across a SET of storage locations, as ONE
@@ -12060,6 +12489,7 @@ var SegmentHourLedger = class {
12060
12489
  for (const record of full) {
12061
12490
  const hour = recordToRow(record.id, record.data);
12062
12491
  if (hour === null) continue;
12492
+ this.ledger.stage(hour);
12063
12493
  for (const path of hour.paths) {
12064
12494
  const parsed = parseSegmentPath(path);
12065
12495
  if (parsed === null) continue;
@@ -12089,6 +12519,12 @@ var SegmentHourLedger = class {
12089
12519
  * empties). So `segments = 0` IS the backfill predicate, and after one pass
12090
12520
  * it selects nothing. A partial repair finishes on the next boot; until then
12091
12521
  * the affected disks UNDER-report, which prunes less and never more.
12522
+ *
12523
+ * Since D287 the boot load carries no `paths`, and `paths` is the authority
12524
+ * this repair derives from — so the stale rows are MATERIALISED first, at
12525
+ * most {@link REPAIR_MAX_MATERIALIZE} per boot. That bound is the point: an
12526
+ * unbounded materialise here would be the whole-archive read this stage
12527
+ * removed, wearing a different name.
12092
12528
  */
12093
12529
  async repairAggregates() {
12094
12530
  let repaired = 0;
@@ -12101,9 +12537,15 @@ var SegmentHourLedger = class {
12101
12537
  limit: REPAIR_MAX_ROWS
12102
12538
  }
12103
12539
  });
12540
+ const pending = stale.map((record) => record.id).filter((key) => this.ledger.get(key)?.pathsResident === false);
12541
+ if (pending.length > REPAIR_MAX_MATERIALIZE) this.logger.info("recorder: segment-hour aggregate backfill is bounded this boot — the rest is repaired on the next one", { meta: {
12542
+ pending: pending.length,
12543
+ perBoot: REPAIR_MAX_MATERIALIZE
12544
+ } });
12545
+ await this.materialize(pending.slice(0, REPAIR_MAX_MATERIALIZE));
12104
12546
  for (const record of stale) {
12105
12547
  const held = this.ledger.get(record.id);
12106
- if (held === void 0 || held.segments === 0) continue;
12548
+ if (held === void 0 || !held.pathsResident || held.segments === 0) continue;
12107
12549
  await this.ledger.put(held);
12108
12550
  repaired += 1;
12109
12551
  }
@@ -12131,8 +12573,7 @@ var SegmentHourLedger = class {
12131
12573
  */
12132
12574
  async recordSegment(row) {
12133
12575
  const { key, hourStartMs: hour } = hourOf(row);
12134
- const held = this.ledger.get(key);
12135
- if (held === void 0) {
12576
+ if (!this.ledger.has(key)) {
12136
12577
  await this.ledger.put(segmentHourRow({
12137
12578
  key,
12138
12579
  deviceId: row.deviceId,
@@ -12143,6 +12584,17 @@ var SegmentHourLedger = class {
12143
12584
  }));
12144
12585
  return;
12145
12586
  }
12587
+ const held = await this.resident(key, row.deviceId);
12588
+ if (held === null) {
12589
+ this.logger.warn("recorder: hour detail unavailable — this finalized segment is NOT in the durable hour row yet", {
12590
+ tags: { deviceId: row.deviceId },
12591
+ meta: {
12592
+ key,
12593
+ path: row.path
12594
+ }
12595
+ });
12596
+ return;
12597
+ }
12146
12598
  if (held.paths.includes(row.path)) return;
12147
12599
  await this.ledger.put(appendSegmentPath(held, row.path));
12148
12600
  }
@@ -12154,16 +12606,29 @@ var SegmentHourLedger = class {
12154
12606
  const doomed = /* @__PURE__ */ new Map();
12155
12607
  for (const row of rows) {
12156
12608
  const { key } = hourOf(row);
12157
- let set = doomed.get(key);
12158
- if (!set) {
12159
- set = /* @__PURE__ */ new Set();
12160
- doomed.set(key, set);
12609
+ const entry = doomed.get(key);
12610
+ if (entry === void 0) {
12611
+ doomed.set(key, {
12612
+ deviceId: row.deviceId,
12613
+ paths: new Set([row.path])
12614
+ });
12615
+ continue;
12161
12616
  }
12162
- set.add(row.path);
12617
+ entry.paths.add(row.path);
12163
12618
  }
12164
- for (const [key, paths] of doomed) {
12165
- const held = this.ledger.get(key);
12166
- if (held === void 0) continue;
12619
+ for (const [key, { deviceId, paths }] of doomed) {
12620
+ if (!this.ledger.has(key)) continue;
12621
+ const held = await this.resident(key, deviceId);
12622
+ if (held === null) {
12623
+ this.logger.warn("recorder: hour detail unavailable — these evicted paths stay in the durable hour row until a walk or the next eviction confirms them", {
12624
+ tags: { deviceId },
12625
+ meta: {
12626
+ key,
12627
+ paths: paths.size
12628
+ }
12629
+ });
12630
+ continue;
12631
+ }
12167
12632
  const next = held.paths.filter((p) => !paths.has(p));
12168
12633
  if (next.length === 0) {
12169
12634
  await this.ledger.forget(key);
@@ -12192,10 +12657,17 @@ var SegmentHourLedger = class {
12192
12657
  * And a walk this ledger does not TRUST prunes nothing at all — see
12193
12658
  * {@link refusedPrunes}. What such a walk FOUND is still real, so its paths
12194
12659
  * are unioned in; what it did not find decides nothing (D49).
12660
+ *
12661
+ * Since D287 the walked devices' hours are MATERIALISED first: every
12662
+ * comparison below reads `row.paths`, and a summary's empty `paths` would
12663
+ * read as "the ledger holds nothing here" — which is the union collapsing to
12664
+ * the walk's own view, i.e. the prune the refusal machinery exists to
12665
+ * prevent. An hour whose detail could not be read is left additive-only.
12195
12666
  */
12196
12667
  async reconcileFromIndex(index, deviceIds, nowMs) {
12197
12668
  const walked = new Set(deviceIds);
12198
12669
  const currentHour = hourStartMs(nowMs);
12670
+ await this.materializeDevices(walked);
12199
12671
  const fromIndex = /* @__PURE__ */ new Map();
12200
12672
  for (const deviceId of deviceIds) for (const seg of index.segments(deviceId)) {
12201
12673
  const { key, hourStartMs: hour } = hourOf(seg);
@@ -12213,6 +12685,7 @@ var SegmentHourLedger = class {
12213
12685
  const refused = this.refusedPrunes(walked, fromIndex);
12214
12686
  for (const row of this.ledger.snapshot()) {
12215
12687
  if (!walked.has(row.deviceId)) continue;
12688
+ if (!row.pathsResident) continue;
12216
12689
  const disk = fromIndex.get(row.key);
12217
12690
  const additiveOnly = row.hourStartMs === currentHour || refused.devices.has(row.deviceId) || refused.locations.has(locationKey(row.deviceId, row.locationId));
12218
12691
  if (disk === void 0) {
@@ -13300,7 +13773,7 @@ var RecorderV2Addon = class extends BaseAddon {
13300
13773
  },
13301
13774
  calendar: this.calendar,
13302
13775
  mfraFor: (deviceId, profile, startMs) => this.mfraTables.get(deviceId, profile, startMs),
13303
- hydrateWindow: (deviceId, fromMs, toMs) => hydrateWindowFromStorage(this.index, deviceId, fromMs, toMs, this.resolvedLocations, this.ctx.logger),
13776
+ hydrateWindow: (deviceId, fromMs, toMs) => hydrateWindowFromStorage(this.index, deviceId, fromMs, toMs, this.resolvedLocations, this.ctx.logger, this.segmentHours ?? void 0),
13304
13777
  onConfigChanged: async (deviceId) => {
13305
13778
  await this.refreshSensorTriggerBindings();
13306
13779
  await (this.controller?.onConfigChanged(deviceId) ?? Promise.resolve());
@@ -13399,7 +13872,7 @@ var RecorderV2Addon = class extends BaseAddon {
13399
13872
  state: exportsState,
13400
13873
  engine: this.exportEngine,
13401
13874
  index: { segmentsInWindow: async (deviceId, profile, fromMs, toMs) => {
13402
- if (this.index.hydrationOf(deviceId, fromMs, toMs) !== "hydrated") await hydrateWindowFromStorage(this.index, deviceId, fromMs, toMs, this.resolvedLocations, this.ctx.logger);
13875
+ if (this.index.hydrationOf(deviceId, fromMs, toMs) !== "hydrated") await hydrateWindowFromStorage(this.index, deviceId, fromMs, toMs, this.resolvedLocations, this.ctx.logger, this.segmentHours ?? void 0);
13403
13876
  return this.index.segments(deviceId, profile);
13404
13877
  } },
13405
13878
  now: () => Date.now(),
@@ -13626,7 +14099,6 @@ var RecorderV2Addon = class extends BaseAddon {
13626
14099
  }
13627
14100
  try {
13628
14101
  await this.segmentHours?.load();
13629
- this.segmentHours?.hydrateIndex(this.index);
13630
14102
  this.segmentHours?.repairAggregates();
13631
14103
  } catch (err) {
13632
14104
  this.ctx.logger.warn("recorder: segment-hour ledger load failed — hours stay unknown until a read walks them", { meta: { error: errMsg(err) } });
@@ -13762,6 +14234,7 @@ var RecorderV2Addon = class extends BaseAddon {
13762
14234
  }
13763
14235
  if (this.exportSweepTimer === null) {
13764
14236
  const sweep = () => {
14237
+ this.logIndexCensus();
13765
14238
  sweepExpiredExports(Date.now(), this.exportJanitorDeps()).catch((err) => {
13766
14239
  this.ctx.logger.warn("recorder: export expiry sweep failed", { meta: { error: errMsg(err) } });
13767
14240
  });
@@ -13772,6 +14245,41 @@ var RecorderV2Addon = class extends BaseAddon {
13772
14245
  }
13773
14246
  }
13774
14247
  /**
14248
+ * What the RAM index is actually holding, on the 5-minute cadence the
14249
+ * `[mem]` heartbeat already samples on.
14250
+ *
14251
+ * `RecordingIndex` has exposed `sortedViewStats` and `hydrateReuseStats`
14252
+ * since the churn work and NOTHING has ever read them — so the two numbers
14253
+ * that would have told us where the recorder's heap goes have never appeared
14254
+ * in a log line. Production on 2026-08-29 reads ~490 MB in
14255
+ * `large_object_space`, stable across four samples five minutes apart
14256
+ * (retained, not churn) against 110 MB on hub-main; the sorted views are the
14257
+ * shape that lands there, and `rowSlots × 8 B` is the arithmetic that either
14258
+ * accounts for it or rules it out.
14259
+ *
14260
+ * `tags: { deviceId }` names the camera with the LARGEST view, because
14261
+ * "which camera" is the only form the follow-up question ever takes. Absent
14262
+ * when nothing is materialised — a device id of 0 is not a camera.
14263
+ */
14264
+ logIndexCensus() {
14265
+ const census = this.index.sortedViewCensus();
14266
+ const views = this.index.sortedViewStats();
14267
+ const reuse = this.index.hydrateReuseStats();
14268
+ this.ctx.logger.info("recorder: index census", {
14269
+ ...census.largestDeviceId > 0 ? { tags: { deviceId: census.largestDeviceId } } : {},
14270
+ meta: {
14271
+ sortedViews: census.views,
14272
+ rowSlots: census.rowSlots,
14273
+ largestViewRows: census.largestRows,
14274
+ fullRebuilds: views.fullRebuilds,
14275
+ incrementalInserts: views.incrementalInserts,
14276
+ rowsReused: reuse.rowsReused,
14277
+ rowsAllocated: reuse.rowsAllocated,
14278
+ ledgerHours: this.segmentHours?.snapshot().length ?? 0
14279
+ }
14280
+ });
14281
+ }
14282
+ /**
13775
14283
  * One-time boot cleanup of the retired sprite-sheet scrub-thumbnail dirs
13776
14284
  * (`<locationRoot>/.thumbs`). Scrub previews now use the WebCodecs keyframe
13777
14285
  * packs + the on-demand still route, so the sheets are dead weight on the
@@ -14155,7 +14663,7 @@ var RecorderV2Addon = class extends BaseAddon {
14155
14663
  const started = now;
14156
14664
  const liveHour = Math.floor(now / HOUR_MS) * HOUR_MS;
14157
14665
  for (const deviceId of deviceIds) try {
14158
- await hydrateWindowFromStorage(this.index, deviceId, liveHour, liveHour + HOUR_MS, this.resolvedLocations, this.ctx.logger);
14666
+ await hydrateWindowFromStorage(this.index, deviceId, liveHour, liveHour + HOUR_MS, this.resolvedLocations, this.ctx.logger, this.segmentHours ?? void 0);
14159
14667
  } catch {}
14160
14668
  this.ctx.logger.info("recorder: today warmed", { meta: {
14161
14669
  devices: deviceIds.length,