@camstack/addon-pipeline 1.2.255 → 1.2.257

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 (32) 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-DRTO2E6G.mjs → default-detection-model-BTzxxm-z.mjs} +1 -1
  4. package/dist/{default-detection-model-CCFY8YLF.js → default-detection-model-C8CHpxQl.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-CqqhxcYH.js → dist-BMARLLlX.js} +64 -1
  8. package/dist/{dist-BS-l_k7M.mjs → dist-xsaGDCOu.mjs} +64 -1
  9. package/dist/motion-wasm/index.js +1 -1
  10. package/dist/motion-wasm/index.mjs +1 -1
  11. package/dist/{node-bdHZWpo1.js → node-Ca-vE0xX.js} +1 -1
  12. package/dist/{node-DjvGLcbe.mjs → node-Fv4ByT36.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-CrDzHWbQ.mjs → process-memory-BgtQADAY.mjs} +1 -1
  16. package/dist/{process-memory-hQeELS5T.js → process-memory-C3Wjmvm0.js} +1 -1
  17. package/dist/recorder/index.js +1235 -1034
  18. package/dist/recorder/index.mjs +1235 -1034
  19. package/dist/{segment-demux-js-BnYubI9B.mjs → segment-demux-js-C7C6c-g2.mjs} +1 -1
  20. package/dist/{segment-demux-js-CaDmuf08.js → segment-demux-js-DHV7EOQf.js} +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-qftSJVIp.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-CJWLoj3Z.mjs} +3 -3
  23. package/dist/stream-broker/{_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-CPFjd94C.mjs → _virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-ReCGmeOG.mjs} +1 -1
  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-BbnIk-9T.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-BbuA-Tci.mjs → hostInit-BzBcvawL.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/package.json +1 -1
  32. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-D8WW_JG-.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-CqqhxcYH.js");
7
- const require_node = require("../node-bdHZWpo1.js");
6
+ const require_dist = require("../dist-BMARLLlX.js");
7
+ const require_node = require("../node-Ca-vE0xX.js");
8
8
  const require_hub_hostname = require("../hub-hostname-DAJXlOgV.js");
9
9
  const require_restream_intent = require("../restream-intent-VVJiUAkh.js");
10
10
  const require_retire_root_keys = require("../retire-root-keys-Csb8z2f3.js");
@@ -15,7 +15,6 @@ node_path = require_chunk.__toESM(node_path);
15
15
  let node_child_process = require("node:child_process");
16
16
  let node_fs_promises = require("node:fs/promises");
17
17
  let _camstack_system_addon_utils = require("@camstack/system/addon-utils");
18
- let node_zlib = require("node:zlib");
19
18
  //#region src/recorder/event-capture.ts
20
19
  /**
21
20
  * La sottoscrizione motion del recorder: bus → `onTrigger(deviceId, atMs)`.
@@ -820,14 +819,14 @@ function parseSegmentPath(relPath) {
820
819
  //#region src/recorder/recording-index.ts
821
820
  /** Hour bucket, the granularity the path convention (`.../YYYY/MM/DD/HH/`)
822
821
  * already gives us — so "have I walked this?" costs one Set lookup. */
823
- var HOUR_MS$8 = 36e5;
822
+ var HOUR_MS$9 = 36e5;
824
823
  /** A device walked end-to-end. Cheaper and clearer than inserting every hour of
825
824
  * the retention window into the set. */
826
825
  var ALL_HOURS = Symbol("all-hours");
827
826
  /** The hour buckets a half-open span touches. */
828
827
  function* hourBuckets(fromMs, toMs) {
829
- const first = Math.floor(fromMs / HOUR_MS$8) * HOUR_MS$8;
830
- for (let h = first; h < toMs; h += HOUR_MS$8) yield h;
828
+ const first = Math.floor(fromMs / HOUR_MS$9) * HOUR_MS$9;
829
+ for (let h = first; h < toMs; h += HOUR_MS$9) yield h;
831
830
  }
832
831
  /**
833
832
  * Index of the first element whose `startMs` is GREATER than `startMs` (upper
@@ -1121,7 +1120,38 @@ var RecordingIndex = class {
1121
1120
  changed = true;
1122
1121
  }
1123
1122
  if (changed) this.invalidateSorted(deviceId);
1124
- this.markHydrated(deviceId, hourStartMs, hourStartMs + HOUR_MS$8);
1123
+ this.markHydrated(deviceId, hourStartMs, hourStartMs + HOUR_MS$9);
1124
+ }
1125
+ /**
1126
+ * Make one hour of ONE location exactly `relPaths` — merge what is there and
1127
+ * drop what is not.
1128
+ *
1129
+ * {@link hydrateHour} merges, which is right for a reader: a listing it took
1130
+ * of a live hour is a lower bound, and dropping on it would delete the index
1131
+ * of footage the readdir merely raced. This is for the one caller that has
1132
+ * TRUSTED evidence the hour is over and the volume it listed is the volume it
1133
+ * started on — the operator-armed ledger walk — where the directory is the
1134
+ * whole truth about the hour and an index row naming a file that is not in it
1135
+ * is a ghost (D523, D148's "trusted walk" arm).
1136
+ *
1137
+ * Scoped to the (device, location, hour) triple: a path filed under another
1138
+ * hour, or held for another location, is none of this call's business.
1139
+ * @residency mutation
1140
+ */
1141
+ resetHour(deviceId, locationId, hourStartMs, relPaths) {
1142
+ const m = this.mapFor(deviceId);
1143
+ const keep = new Set(relPaths);
1144
+ const dropped = [];
1145
+ for (const [path, row] of m) {
1146
+ if (row.locationId !== locationId) continue;
1147
+ if (Math.floor(row.startMs / HOUR_MS$9) * HOUR_MS$9 !== hourStartMs) continue;
1148
+ if (keep.has(path)) continue;
1149
+ dropped.push(path);
1150
+ }
1151
+ for (const path of dropped) m.delete(path);
1152
+ if (dropped.length > 0) this.invalidateSorted(deviceId);
1153
+ this.hydrateHour(deviceId, locationId, hourStartMs, relPaths);
1154
+ return dropped;
1125
1155
  }
1126
1156
  /**
1127
1157
  * Insert/replace one finalized segment (idempotent by path).
@@ -1767,45 +1797,11 @@ function hourDirRelPath(deviceId, profile, hourStartMs) {
1767
1797
  const p2 = (n) => String(n).padStart(2, "0");
1768
1798
  return `${deviceId}/${profile}/${d.getUTCFullYear()}/${p2(d.getUTCMonth() + 1)}/${p2(d.getUTCDate())}/${p2(d.getUTCHours())}`;
1769
1799
  }
1770
- var HOUR_MS$7 = 36e5;
1800
+ var HOUR_MS$8 = 36e5;
1771
1801
  async function hydrateWindowFromStorage(index, deviceId, fromMs, toMs, locations, logger, ledger, options) {
1772
1802
  const verify = options?.verifyClosedHours === true;
1773
1803
  if (!verify && index.hydrationOf(deviceId, fromMs, toMs) === "hydrated") return;
1774
- const liveHour = Math.floor(Date.now() / HOUR_MS$7) * HOUR_MS$7;
1775
- /** Per (locationId, profile, hour): what the LEDGER claimed, for the diff. */
1776
- const claimed = /* @__PURE__ */ new Map();
1777
- const claimKey = (locationId, hour) => `${locationId}:${hour}`;
1778
- /**
1779
- * Closed hours the ledger answered for with a row nobody SEALED (D451): what
1780
- * a writer had persisted when it was last heard from, which is a legitimate
1781
- * answer and not a complete one. Each is read from the array once and the
1782
- * row is unioned and sealed, so the second reader pays a point read.
1783
- */
1784
- const unsealed = /* @__PURE__ */ new Set();
1785
- if (ledger !== void 0) {
1786
- const startedMs = Date.now();
1787
- const hours = await ledger.detailForWindow(deviceId, fromMs, toMs);
1788
- let segments = 0;
1789
- let served = 0;
1790
- for (const hour of hours) {
1791
- if (hour.hourStartMs >= liveHour) continue;
1792
- index.hydrateHour(deviceId, hour.locationId, hour.hourStartMs, hour.paths);
1793
- claimed.set(claimKey(hour.locationId, hour.hourStartMs), hour.paths.length);
1794
- if (!hour.sealed) unsealed.add(hour.hourStartMs);
1795
- segments += hour.paths.length;
1796
- served += 1;
1797
- }
1798
- if (served > 0) logger.info("recorder: hydrated a window from the hour ledger", {
1799
- tags: { deviceId },
1800
- meta: {
1801
- hours: served,
1802
- segments,
1803
- unsealedHours: unsealed.size,
1804
- ms: Date.now() - startedMs
1805
- }
1806
- });
1807
- if (!verify && unsealed.size === 0 && index.hydrationOf(deviceId, fromMs, toMs) === "hydrated") return;
1808
- }
1804
+ const liveHour = Math.floor(Date.now() / HOUR_MS$8) * HOUR_MS$8;
1809
1805
  for (const [root, aliases] of aliasesByRoot(locations)) {
1810
1806
  const deviceDir = node_path.default.join(root, String(deviceId));
1811
1807
  let profiles;
@@ -1821,15 +1817,15 @@ async function hydrateWindowFromStorage(index, deviceId, fromMs, toMs, locations
1821
1817
  });
1822
1818
  continue;
1823
1819
  }
1824
- const first = Math.floor(fromMs / HOUR_MS$7) * HOUR_MS$7;
1820
+ const first = Math.floor(fromMs / HOUR_MS$8) * HOUR_MS$8;
1825
1821
  const reuseBefore = index.hydrateReuseStats();
1826
1822
  let hoursRead = 0;
1827
1823
  let repaired = 0;
1828
1824
  let forgottenSegments = 0;
1829
1825
  let forgottenHours = 0;
1830
1826
  let sealed = 0;
1831
- for (let hour = first; hour < toMs; hour += HOUR_MS$7) {
1832
- if (!verify && hour < liveHour && !unsealed.has(hour) && index.hydrationOf(deviceId, hour, hour + HOUR_MS$7) === "hydrated") continue;
1827
+ for (let hour = first; hour < toMs; hour += HOUR_MS$8) {
1828
+ if (!verify && hour < liveHour && index.hydrationOf(deviceId, hour, hour + HOUR_MS$8) === "hydrated") continue;
1833
1829
  for (const profile of profiles) {
1834
1830
  const rel = hourDirRelPath(deviceId, profile, hour);
1835
1831
  let files;
@@ -1842,12 +1838,11 @@ async function hydrateWindowFromStorage(index, deviceId, fromMs, toMs, locations
1842
1838
  const locationId = locationForHydratedProfile(aliases, profile).id;
1843
1839
  const relPaths = files.filter((f) => f.endsWith(".m4s")).map((f) => `${rel}/${f}`);
1844
1840
  index.hydrateHour(deviceId, locationId, hour, relPaths);
1845
- if (ledger === void 0 || relPaths.length === 0 || hour + HOUR_MS$7 > Date.now()) continue;
1846
- const claimedHere = claimed.get(claimKey(locationId, hour));
1841
+ if (ledger === void 0 || relPaths.length === 0 || hour + HOUR_MS$8 > Date.now()) continue;
1847
1842
  const outcome = await ledger.adoptWalkedHour(deviceId, profile, locationId, hour, relPaths);
1848
1843
  if (outcome === null) continue;
1849
1844
  if (outcome.created) repaired += 1;
1850
- else if (claimedHere !== void 0 && outcome.added > 0) {
1845
+ else if (outcome.added > 0) {
1851
1846
  forgottenSegments += outcome.added;
1852
1847
  forgottenHours += 1;
1853
1848
  }
@@ -1925,7 +1920,7 @@ async function hydrateWindowFromStorage(index, deviceId, fromMs, toMs, locations
1925
1920
  */
1926
1921
  var DEFAULT_THROTTLE_MBPS = 40;
1927
1922
  /** The durable index's bucket, and therefore this mover's batch. */
1928
- var HOUR_MS$6 = 36e5;
1923
+ var HOUR_MS$7 = 36e5;
1929
1924
  /** Progress callback cadence (files) — cheap enough to fire often. */
1930
1925
  var PROGRESS_EVERY_FILES = 25;
1931
1926
  /** Free space a destination must keep when it declares no `config.minFreePercent`
@@ -2484,10 +2479,12 @@ var RelocateEngine = class {
2484
2479
  *
2485
2480
  * The order is the whole point. Between the copy and the delete both
2486
2481
  * locations hold every byte, so the ledger re-point in the middle can only
2487
- * ever name a location that HAS the file — which is what makes a crash
2488
- * recoverable instead of a black window. A ledger that refuses (a failed
2489
- * point read: D49/D287) stops this hour dead with nothing deleted; the
2490
- * copies at the target are adopted by the next run.
2482
+ * ever account for a location that HAS the files — which is what makes a
2483
+ * crash recoverable instead of a black window.
2484
+ *
2485
+ * There is no ledger refusal to handle any more (D523): the durable half is
2486
+ * arithmetic over a row the mirror already holds, so nothing on that path can
2487
+ * fail and stop an hour dead.
2491
2488
  */
2492
2489
  async moveHour(job, rows, from, to, samePhysicalRoot, sleep, bytesPerMs) {
2493
2490
  const movable = [];
@@ -2523,17 +2520,7 @@ var RelocateEngine = class {
2523
2520
  toLocationId: to.id
2524
2521
  }
2525
2522
  });
2526
- else if (!await ledger.repointSegments(movable, to.id)) {
2527
- this.deps.logger.warn("relocate: hour NOT moved — the durable ledger refused the re-point, so the source bytes are kept", {
2528
- tags: { deviceId: movable[0].deviceId },
2529
- meta: {
2530
- jobId: job.jobId,
2531
- files: movable.length,
2532
- toLocationId: to.id
2533
- }
2534
- });
2535
- return;
2536
- }
2523
+ else await ledger.repointSegments(movable, to.id);
2537
2524
  if (!samePhysicalRoot) for (const row of movable) await node_fs.promises.rm(node_path.default.join(from.root, row.fromPath ?? row.path), { force: true });
2538
2525
  for (const row of movable) {
2539
2526
  this.deps.index.removeSegments([row.fromPath ?? row.path]);
@@ -2622,7 +2609,7 @@ function profilesOfClass(footageClass) {
2622
2609
  function groupByHour(rows) {
2623
2610
  const out = /* @__PURE__ */ new Map();
2624
2611
  for (const row of rows) {
2625
- const key = `${row.deviceId}|${row.profile}|${Math.floor(row.startMs / HOUR_MS$6)}`;
2612
+ const key = `${row.deviceId}|${row.profile}|${Math.floor(row.startMs / HOUR_MS$7)}`;
2626
2613
  const found = out.get(key);
2627
2614
  if (found) found.push(row);
2628
2615
  else out.set(key, [row]);
@@ -2926,23 +2913,21 @@ var SegmentStore = class SegmentStore {
2926
2913
  * work now the per-bucket archive scan is gone. */
2927
2914
  static DEFAULT_EVICT_CHUNK = 25;
2928
2915
  /**
2929
- * True when `victims` cover EVERY segment the ARCHIVE says the bucket holds,
2930
- * AND the bucket's hour has fully elapsed.
2916
+ * True when `victims` cover EVERY segment the bucket DIRECTORY holds, AND the
2917
+ * bucket's hour has fully elapsed.
2931
2918
  *
2932
2919
  * The current-hour guard matters because `onFinalized` relocates new segments
2933
2920
  * INTO the current bucket concurrently — an rm racing that rename would
2934
2921
  * orphan a just-indexed segment.
2935
2922
  *
2936
- * The cover test is against `SegmentHourLedger`'s own hour row, never
2937
- * `RecordingIndex`. A bucketDir is `<dev>/<profile>/Y/M/D/H`, so it names
2938
- * exactly one durable hour row: "every path that row lists is a victim" is
2939
- * the same question the `rm -rf` answers, asked of the authority for what
2940
- * EXISTS (D295). Asked of the resident index it read EQUAL whenever the hour
2941
- * was partly resident — both sides short by the same rows — and the directory
2942
- * went with every file the round never named, leaving their durable rows
2943
- * behind as ghosts.
2923
+ * The cover test is a listing of the directory the `rm -rf` is about to take,
2924
+ * never `RecordingIndex` and never a durable row (D523). A bucketDir is
2925
+ * `<dev>/<profile>/Y/M/D/H`, so one listing answers exactly the question the
2926
+ * delete asks. Asked of the resident index it read EQUAL whenever the hour was
2927
+ * partly resident — both sides short by the same rows — and the directory went
2928
+ * with every file the round never named.
2944
2929
  *
2945
- * An unanswerable archive returns `false`: per-file deletes are slower and
2930
+ * An unanswerable listing returns `false`: per-file deletes are slower and
2946
2931
  * always correct, and the alternative is a recursive delete computed from a
2947
2932
  * fraction of the truth.
2948
2933
  */
@@ -2951,7 +2936,7 @@ var SegmentStore = class SegmentStore {
2951
2936
  if (!first) return false;
2952
2937
  const bucketStartMs = Math.floor(first.startMs / SegmentStore.HOUR_MS) * SegmentStore.HOUR_MS;
2953
2938
  if ((this.deps.now?.() ?? Date.now()) < bucketStartMs + SegmentStore.HOUR_MS) return false;
2954
- const ask = this.deps.archiveHourPaths;
2939
+ const ask = this.deps.hourDirectoryPaths;
2955
2940
  if (ask === void 0) return false;
2956
2941
  const held = await ask(first.deviceId, first.profile, location, bucketStartMs);
2957
2942
  if (held === null || held.length === 0) return false;
@@ -3850,7 +3835,7 @@ async function profileHasFootage(root, deviceId, profile) {
3850
3835
  //#endregion
3851
3836
  //#region src/recorder/addon/calendar-index.ts
3852
3837
  var DAY_MS$3 = 864e5;
3853
- var HOUR_MS$5 = 36e5;
3838
+ var HOUR_MS$6 = 36e5;
3854
3839
  /**
3855
3840
  * UTC ms → the UTC epoch of LOCAL midnight of the day it falls in.
3856
3841
  *
@@ -3888,8 +3873,8 @@ var RANGE_MERGE_GAP_MS$1 = 5e3;
3888
3873
  * deploy gap). Unknown hours still fall through to one directory read.
3889
3874
  */
3890
3875
  function hourSegmentsFromIndex(index, deviceId, profile, hourStartMs) {
3891
- const hour = Math.floor(hourStartMs / HOUR_MS$5) * HOUR_MS$5;
3892
- const hourEnd = hour + HOUR_MS$5;
3876
+ const hour = Math.floor(hourStartMs / HOUR_MS$6) * HOUR_MS$6;
3877
+ const hourEnd = hour + HOUR_MS$6;
3893
3878
  if (index.hydrationOf(deviceId, hour, hourEnd) !== "hydrated") return null;
3894
3879
  const rows = [];
3895
3880
  for (const s of index.segmentsStartingIn(deviceId, profile, hour, hourEnd)) rows.push({
@@ -4048,7 +4033,7 @@ var CalendarIndex = class {
4048
4033
  }));
4049
4034
  const days = /* @__PURE__ */ new Set();
4050
4035
  for (const { day, hours } of perDay) for (const h of hours) {
4051
- const at = day + h * HOUR_MS$5;
4036
+ const at = day + h * HOUR_MS$6;
4052
4037
  if (at < fromMs || at >= toMs) continue;
4053
4038
  days.add(localDayStart(at, tzOffsetMinutes));
4054
4039
  }
@@ -4084,8 +4069,9 @@ var CalendarIndex = class {
4084
4069
  *
4085
4070
  * Also used to warm the days ADJACENT to one being viewed — changing day is a
4086
4071
  * single click and the operator expects the summary to be there. That warm
4087
- * belongs in the background: at ~650 ms per cold read, doing it inline would
4088
- * only move the wait.
4072
+ * belongs in the background: a cold read costs whatever the array charges
4073
+ * that day (see the header — the ~650 ms figure is from the retired FUSE
4074
+ * share), and doing it inline would only move the wait onto the click.
4089
4075
  */
4090
4076
  async warm(deviceId, fromMs, toMs) {
4091
4077
  for (let day = utcDayOf(fromMs); day <= utcDayOf(toMs); day += DAY_MS$3) {
@@ -4110,7 +4096,7 @@ var CalendarIndex = class {
4110
4096
  const day = utcDayOf(utcDayMs);
4111
4097
  for (let h = 0; h < 24; h++) {
4112
4098
  await this.awaitQuiet();
4113
- await this.segmentsOf(deviceId, profile, day + h * HOUR_MS$5, true);
4099
+ await this.segmentsOf(deviceId, profile, day + h * HOUR_MS$6, true);
4114
4100
  }
4115
4101
  }
4116
4102
  /**
@@ -4131,7 +4117,7 @@ var CalendarIndex = class {
4131
4117
  */
4132
4118
  async segmentsOfChecked(deviceId, profile, hourStartMs, background = false) {
4133
4119
  if (!background) this.lastForegroundAt = this.deps.now?.() ?? Date.now();
4134
- const hour = Math.floor(hourStartMs / HOUR_MS$5) * HOUR_MS$5;
4120
+ const hour = Math.floor(hourStartMs / HOUR_MS$6) * HOUR_MS$6;
4135
4121
  const fromIndex = this.deps.indexHour?.(deviceId, profile, hour);
4136
4122
  if (fromIndex !== null && fromIndex !== void 0) return {
4137
4123
  segments: fromIndex,
@@ -4139,7 +4125,7 @@ var CalendarIndex = class {
4139
4125
  };
4140
4126
  const key = `${deviceId}:${profile}:${hour}`;
4141
4127
  const now = this.deps.now?.() ?? Date.now();
4142
- const isCurrentHour = hour === Math.floor(now / HOUR_MS$5) * HOUR_MS$5;
4128
+ const isCurrentHour = hour === Math.floor(now / HOUR_MS$6) * HOUR_MS$6;
4143
4129
  const cached = this.hourSegs.get(key);
4144
4130
  if (cached !== void 0) {
4145
4131
  if (!isCurrentHour) return {
@@ -4203,7 +4189,7 @@ var CalendarIndex = class {
4203
4189
  */
4204
4190
  async dayRanges(deviceId, profile, utcDayMs) {
4205
4191
  const day = utcDayOf(utcDayMs);
4206
- return mergeRanges((await mapBounded(Array.from({ length: 24 }, (_, h) => day + h * HOUR_MS$5), READ_CONCURRENCY, (hour) => this.segmentsOf(deviceId, profile, hour))).flat(), RANGE_MERGE_GAP_MS$1);
4192
+ return mergeRanges((await mapBounded(Array.from({ length: 24 }, (_, h) => day + h * HOUR_MS$6), READ_CONCURRENCY, (hour) => this.segmentsOf(deviceId, profile, hour))).flat(), RANGE_MERGE_GAP_MS$1);
4207
4193
  }
4208
4194
  /**
4209
4195
  * Exact ranges of `[fromMs, toMs)`, scanning ONLY the hours the window
@@ -4217,9 +4203,9 @@ var CalendarIndex = class {
4217
4203
  */
4218
4204
  async rangesIn(deviceId, profile, fromMs, toMs) {
4219
4205
  if (toMs <= fromMs) return [];
4220
- const first = Math.floor(fromMs / HOUR_MS$5) * HOUR_MS$5;
4206
+ const first = Math.floor(fromMs / HOUR_MS$6) * HOUR_MS$6;
4221
4207
  const hours = [];
4222
- for (let hour = first; hour < toMs; hour += HOUR_MS$5) hours.push(hour);
4208
+ for (let hour = first; hour < toMs; hour += HOUR_MS$6) hours.push(hour);
4223
4209
  const perHour = await mapBounded(hours, READ_CONCURRENCY, (hour) => this.segmentsOf(deviceId, profile, hour));
4224
4210
  const ranges = [];
4225
4211
  for (const r of mergeRanges(perHour.flat(), RANGE_MERGE_GAP_MS$1)) {
@@ -4772,7 +4758,7 @@ function handleStoreCensusAction(census, logger, input) {
4772
4758
  }
4773
4759
  //#endregion
4774
4760
  //#region src/recorder/addon/directory-handler.ts
4775
- var HOUR_MS$4 = 36e5;
4761
+ var HOUR_MS$5 = 36e5;
4776
4762
  /** A window wider than this is refused: it is a client bug, and answering it
4777
4763
  * would walk the archive on the read path — the thing this replaces. */
4778
4764
  var MAX_WINDOW_MS = 32 * 864e5;
@@ -4819,9 +4805,9 @@ function withDirectoryRoute(deps, next) {
4819
4805
  return;
4820
4806
  }
4821
4807
  const rows = [];
4822
- const firstHour = Math.floor(fromMs / HOUR_MS$4) * HOUR_MS$4;
4808
+ const firstHour = Math.floor(fromMs / HOUR_MS$5) * HOUR_MS$5;
4823
4809
  let hydrated = true;
4824
- for (let hour = firstHour; hour < toMs; hour += HOUR_MS$4) {
4810
+ for (let hour = firstHour; hour < toMs; hour += HOUR_MS$5) {
4825
4811
  const { segments, complete } = await deps.source.segmentsOfChecked(deviceId, profile, hour);
4826
4812
  if (!complete) hydrated = false;
4827
4813
  for (const s of segments) {
@@ -5207,22 +5193,18 @@ function createFootageDiscardJob(deps) {
5207
5193
  const forgetLedger = async (location) => {
5208
5194
  const ledger = deps.ledger;
5209
5195
  if (ledger === null) return 0;
5210
- let forgotten = 0;
5211
5196
  const profiles = profilesForType(location.type);
5212
- for await (const page of ledger.streamSegments({
5197
+ const scope = {
5213
5198
  locationId: location.id,
5214
5199
  profiles
5215
- })) {
5216
- if (cancelRequested) return forgotten;
5217
- deps.index.removeSegments(page.map((row) => row.path));
5218
- await ledger.dropSegments(page);
5219
- forgotten += page.length;
5220
- report({
5221
- dbRowsDeleted: forgotten,
5222
- detail: `forgot ${forgotten} ledger segments`
5223
- });
5224
- await sleep(0);
5225
- }
5200
+ };
5201
+ const hours = ledger.snapshot().filter((row) => row.locationId === location.id && profiles.includes(row.profile));
5202
+ const forgotten = await ledger.forgetScope(scope);
5203
+ for (const hour of hours) deps.index.resetHour(hour.deviceId, hour.locationId, hour.hourStartMs, []);
5204
+ report({
5205
+ dbRowsDeleted: forgotten,
5206
+ detail: `forgot ${forgotten} ledger segments`
5207
+ });
5226
5208
  return forgotten;
5227
5209
  };
5228
5210
  return {
@@ -8275,63 +8257,6 @@ function runFfmpeg(renderDeps, args, ext) {
8275
8257
  });
8276
8258
  }
8277
8259
  //#endregion
8278
- //#region src/recorder/addon/paths-codec.ts
8279
- /**
8280
- * How an hour row's `paths` becomes text, and back (D471).
8281
- *
8282
- * `recorder:segment-hours` is 66 MB of the settings database and **99.2 % of it
8283
- * is this one column** — 15 643 bytes per row over 4 036 rows. The content is a
8284
- * list of location-relative segment paths that nearly all share the same
8285
- * directory prefix, repeated in full on every entry. Measured per column on a
8286
- * copy of the live database, never the live file (D468).
8287
- *
8288
- * Compressing it is unusually safe here because of D287's two-tier mirror: boot
8289
- * loads the aggregate half of every row and NOT `paths`, so this column is read
8290
- * only on a point materialise, never on the hot path.
8291
- *
8292
- * A local copy of the same idea `pipeline-analytics` uses for `positions` —
8293
- * addons never import each other (CLAUDE.md), so twenty duplicated lines are
8294
- * the correct shape, not a shared dependency across an addon boundary.
8295
- */
8296
- /** Marks a value this module wrote. A plain JSON array always starts `[`. */
8297
- var ENCODED_PREFIX = "z1:";
8298
- function encodePaths(paths) {
8299
- return ENCODED_PREFIX + (0, node_zlib.gzipSync)(Buffer.from(JSON.stringify(paths), "utf8"), { level: 6 }).toString("base64");
8300
- }
8301
- /**
8302
- * Decode whichever era wrote the value: the `z1:` string this writes, the plain
8303
- * JSON string every pre-D471 row holds, or an array the store already parsed.
8304
- *
8305
- * `null` means "this row is unreadable", which the caller turns into a DISCARDED
8306
- * row — the same contract the bare `JSON.parse` had. An hour whose paths cannot
8307
- * be read must not silently become an hour with no segments: that would report
8308
- * an empty disk, which is the failure `recordToRow` was already written to
8309
- * avoid.
8310
- */
8311
- function decodePaths(value) {
8312
- if (Array.isArray(value)) return isPathList(value) ? value : null;
8313
- if (typeof value !== "string") return null;
8314
- const json = value.startsWith(ENCODED_PREFIX) ? tryGunzip(value.slice(3)) : value;
8315
- if (json === null) return null;
8316
- try {
8317
- const parsed = JSON.parse(json);
8318
- if (!Array.isArray(parsed)) return null;
8319
- return isPathList(parsed) ? parsed : null;
8320
- } catch {
8321
- return null;
8322
- }
8323
- }
8324
- function isPathList(v) {
8325
- return v.every((p) => typeof p === "string" && p.length > 0);
8326
- }
8327
- function tryGunzip(b64) {
8328
- try {
8329
- return (0, node_zlib.gunzipSync)(Buffer.from(b64, "base64")).toString("utf8");
8330
- } catch {
8331
- return null;
8332
- }
8333
- }
8334
- //#endregion
8335
8260
  //#region src/durable/durable-ledger.ts
8336
8261
  /** Default reseed cap — every current consumer's row set is installation-bounded. */
8337
8262
  var DEFAULT_LOAD_LIMIT = 1e5;
@@ -8572,7 +8497,7 @@ var DurableLedger = class DurableLedger {
8572
8497
  };
8573
8498
  //#endregion
8574
8499
  //#region src/recorder/addon/segment-hour-ledger.ts
8575
- var HOUR_MS$3 = 36e5;
8500
+ var HOUR_MS$4 = 36e5;
8576
8501
  /** `Math.min` where a missing left operand means "nothing yet", not `NaN`. */
8577
8502
  function minOf(a, b) {
8578
8503
  return a === null ? b : Math.min(a, b);
@@ -8586,19 +8511,19 @@ var macrotask$1 = () => new Promise((resolve) => {
8586
8511
  });
8587
8512
  /**
8588
8513
  * @durable class=mirror owner=recorder
8589
- * write="write-behind on every finalized segment (hour-row upsert, serialised per hour); the writer SEALS the hour it leaves (one write per hour); a disk walk of a closed hour unions what it found and seals; eviction drops paths"
8514
+ * write="write-behind on every finalized segment (hour-row upsert, serialised per hour); the writer SEALS the hour it leaves (one write per hour); a listing of a closed hour re-derives the aggregate and seals; eviction and relocation shrink it"
8590
8515
  * retention="a row goes when the walk or eviction confirms the hour is empty — never by age, never from the ledger alone (D148)"
8591
8516
  */
8592
8517
  var RECORDING_SEGMENT_HOURS_COLLECTION = "recorder:segment-hours";
8593
8518
  /**
8594
- * The four aggregate columns exist so "how much footage is on this disk" is a
8595
- * `SUM`/`MIN`/`MAX` over ~19 k hour rows instead of a walk over ~7.1 M in-RAM
8596
- * segment rows. They are DERIVED from `paths` — never a second authority — by
8597
- * {@link withSegmentPath} and nothing else: {@link hourAggregate} is that step
8598
- * over every path and {@link appendSegmentPath} is that step over one, so there
8599
- * is no second derivation to drift. A row loaded from a database that predates
8600
- * these columns is corrected in the mirror the moment it is read and repaired
8601
- * on disk by {@link SegmentHourLedger.repairAggregates}.
8519
+ * The four aggregate columns are what this table IS (D523).
8520
+ *
8521
+ * They exist so "how much footage is on this disk" is a `SUM`/`MIN`/`MAX` over
8522
+ * ~19 k hour rows instead of a walk over ~7.1 M in-RAM segment rows, and they
8523
+ * are derived from segment NAMES — by {@link withSegmentPath} and nothing else.
8524
+ * {@link hourAggregate} is that step over a whole listing and
8525
+ * {@link appendSegmentPath} is that step over the one path that just arrived,
8526
+ * so there is no second derivation to drift.
8602
8527
  */
8603
8528
  var RECORDING_SEGMENT_HOURS_COLUMNS = [
8604
8529
  {
@@ -8627,11 +8552,6 @@ var RECORDING_SEGMENT_HOURS_COLUMNS = [
8627
8552
  type: "INTEGER",
8628
8553
  notNull: true
8629
8554
  },
8630
- {
8631
- name: "paths",
8632
- type: "TEXT",
8633
- notNull: true
8634
- },
8635
8555
  {
8636
8556
  name: "bytes",
8637
8557
  type: "INTEGER",
@@ -8690,15 +8610,9 @@ var RECORDING_SEGMENT_HOURS_INDEXES = [
8690
8610
  * `MAX(maxStartMs)` over `locationId IN (…)` — one row back, and with this
8691
8611
  * index not one table row read to produce it (D447).
8692
8612
  *
8693
- * `idx_recorder_segment_hours_loc_hour` finds the same range, but the four
8694
- * aggregates live in the table row, next to `paths` — up to 360 segment
8695
- * paths of ~80 characters per hour — so the eviction index served the
8696
- * accounting read at the price of ~30 KB of table page per hour row. On the
8697
- * live hub that was `durationMs=1001…2902` once a MINUTE on
8698
- * `hub/sqlite-settings` (174 slow-call WARNs in ten hours on 2026-09-10/11),
8699
- * every one of them holding the thread every configuration read in the
8700
- * cluster waits on. The index is ~50 bytes a row over ~19 k rows — nothing —
8701
- * and the first declare after it ships builds it in well under a second.
8613
+ * Since D523 dropped `paths` the table row is ten scalars, so the covering
8614
+ * index no longer saves ~30 KB of page per row; it still saves the row
8615
+ * fetch, and the accounting read is the one this hub runs on a schedule.
8702
8616
  */
8703
8617
  {
8704
8618
  name: "idx_recorder_segment_hours_loc_accounting",
@@ -8711,6 +8625,27 @@ var RECORDING_SEGMENT_HOURS_INDEXES = [
8711
8625
  ]
8712
8626
  })
8713
8627
  ];
8628
+ /**
8629
+ * How far into an hour a writer's first finalize may land and the writer still
8630
+ * be the party that KNOWS what the hour holds (D521).
8631
+ *
8632
+ * A seal is a claim of completeness. A writer that was running across the hour
8633
+ * boundary produces its first in-hour segment within ONE segment length of the
8634
+ * boundary — the segment that straddles it belongs to the previous hour by its
8635
+ * `startMs`. So the grace is one segment length at the longest anyone
8636
+ * configures here (60 s), plus 30 s for a keyframe-cut segment overrunning its
8637
+ * nominal `-segment_time` (ffmpeg cuts on the next GOP, never on the clock).
8638
+ *
8639
+ * It is a guess, and this is the shape of being wrong in each direction.
8640
+ * TIGHTER: a normal rollover stops sealing and every hour of the archive is
8641
+ * listed once by the adoption sweep — at ~1 ms per hour directory on this hub's
8642
+ * SATA SSD, affordable but pointless. LOOSER: a runner respawn inside the
8643
+ * window seals an hour it entered late, which is exactly the 2026-09-17 defect;
8644
+ * the observed hole there was ~29 minutes, so anything under a few minutes
8645
+ * would have caught it and 90 s is the smallest number that does not fire on a
8646
+ * legitimate rollover.
8647
+ */
8648
+ var WRITER_HOUR_ENTRY_GRACE_MS = 9e4;
8714
8649
  /** The identity of the {@link hourAggregate} fold — an hour holding nothing. */
8715
8650
  var EMPTY_HOUR_AGGREGATE = {
8716
8651
  bytes: 0,
@@ -8722,12 +8657,11 @@ var EMPTY_HOUR_AGGREGATE = {
8722
8657
  * ONE path folded into an aggregate — the whole derivation, expressed as its
8723
8658
  * single step.
8724
8659
  *
8725
- * {@link hourAggregate} is this step over every path, and {@link
8660
+ * {@link hourAggregate} is this step over a whole listing, and {@link
8726
8661
  * SegmentHourLedger.recordSegment} is this step over the one path that just
8727
8662
  * arrived. That is not two implementations that happen to agree: the full
8728
8663
  * derivation is *defined* as this function repeated, so an append is the fold
8729
- * resumed from the state the held row already carries — a `SegmentHourRow`'s
8730
- * aggregate half IS the fold state over its `paths`.
8664
+ * resumed from the state the held row already carries.
8731
8665
  *
8732
8666
  * Why it had to become a step: `recordSegment` re-derived the whole hour on
8733
8667
  * every finalize. Σ1..360 ≈ 65 k path parses per camera-profile-hour at 10 s
@@ -8750,49 +8684,57 @@ function withSegmentPath(agg, path) {
8750
8684
  };
8751
8685
  }
8752
8686
  /**
8753
- * Bytes / count / oldest / newest for one hour's paths — the sole FULL
8754
- * derivation, used by rebuilds ({@link recordToRow}, `dropSegments`) and never
8755
- * by the append path.
8687
+ * Bytes / count / oldest / newest for one hour's LISTING — the sole FULL
8688
+ * derivation, and since D523 the only thing that can produce an EXACT row.
8756
8689
  *
8757
8690
  * Every field of a segment path encodes its own facts (`parseSegmentPath`), so
8758
- * this is a derivation, not a measurement: it can never disagree with the paths
8759
- * it was computed from.
8691
+ * this is a derivation, not a measurement: it can never disagree with the
8692
+ * listing it was computed from.
8760
8693
  */
8761
8694
  function hourAggregate(paths) {
8762
8695
  let agg = EMPTY_HOUR_AGGREGATE;
8763
8696
  for (const path of paths) agg = withSegmentPath(agg, path);
8764
8697
  return agg;
8765
8698
  }
8766
- /** Build the persisted row for an hour from its paths — the ONE constructor. */
8699
+ /** Build the persisted row for an hour from a LISTING of it — the ONE
8700
+ * constructor that can produce an exact aggregate. */
8767
8701
  function segmentHourRow(base) {
8702
+ const { paths, sealed, ...identity } = base;
8768
8703
  return {
8769
- ...base,
8770
- sealed: base.sealed ?? false,
8771
- pathsResident: true,
8772
- ...hourAggregate(base.paths)
8704
+ ...identity,
8705
+ sealed: sealed ?? false,
8706
+ ...hourAggregate(paths)
8707
+ };
8708
+ }
8709
+ /**
8710
+ * Build the persisted row for an hour from an aggregate somebody already folded
8711
+ * — a relocation moving one, or a listing that has already been reduced.
8712
+ *
8713
+ * `sealed` is a NAMED field and never a positional boolean, because
8714
+ * `scripts/check-seal-authority.ts` attributes every literal `sealed: true` in
8715
+ * the recorder to the method that wrote it. A seal smuggled in as a third
8716
+ * argument is a seal the guard cannot see.
8717
+ */
8718
+ function hourRowOf(identity, row) {
8719
+ return {
8720
+ ...identity,
8721
+ ...row
8773
8722
  };
8774
8723
  }
8775
8724
  /**
8776
8725
  * The row an hour becomes when ONE more path joins it.
8777
8726
  *
8778
- * Identical to `segmentHourRow({ ...held, paths: [...held.paths, path] })` and
8779
- * strictly cheaper: the aggregate half of `held` is already the fold over
8780
- * `held.paths`, so this resumes that fold instead of restarting it. The
8727
+ * Identical to `segmentHourRow({ …held, paths: [...everything, path] })` and
8728
+ * strictly cheaper: the aggregate half of `held` is already the fold over the
8729
+ * hour's names, so this resumes that fold instead of restarting it. The
8781
8730
  * equality is not an assumption — `segment-hour-append.spec.ts` pins it on
8782
8731
  * generated hours including out-of-order arrival, duplicate paths, unparseable
8783
8732
  * paths and storm-sized hours.
8784
- *
8785
- * The four fields are named rather than spread on purpose. {@link
8786
- * withSegmentPath} hands its argument straight back for a path the parser
8787
- * refuses, and `held` is a whole row — spreading that return would put the
8788
- * PREVIOUS `paths` array back over the appended one. Which is exactly what it
8789
- * did until the generated-hours test caught it.
8790
8733
  */
8791
8734
  function appendSegmentPath(held, path) {
8792
8735
  const { bytes, segments, minStartMs, maxStartMs } = withSegmentPath(held, path);
8793
8736
  return {
8794
8737
  ...held,
8795
- paths: [...held.paths, path],
8796
8738
  bytes,
8797
8739
  segments,
8798
8740
  minStartMs,
@@ -8810,6 +8752,20 @@ function appendSegmentPath(held, path) {
8810
8752
  * `readdir` over an unmounted root deletes all of it.
8811
8753
  */
8812
8754
  var LEDGER_PRUNE_MAX_SHARE = .5;
8755
+ /**
8756
+ * The largest share of ONE hour's accounted segments a single listing may
8757
+ * erase (D523).
8758
+ *
8759
+ * The directory is the authority for WHICH segments exist, so a listing of a
8760
+ * closed hour replaces that hour's aggregate — including downwards, which is
8761
+ * what makes an over-counted row self-correcting. The guard is the same
8762
+ * argument {@link LEDGER_PRUNE_MAX_SHARE} makes one level up: a root that has
8763
+ * gone away, or a bucket half-deleted by a round that died, answers SHORT, and
8764
+ * a short answer that halves an hour is indistinguishable from one that is
8765
+ * simply right. Under the bar the listing wins; over it the row stands, the
8766
+ * hour stays unsealed, and the disagreement is named.
8767
+ */
8768
+ var HOUR_LISTING_MAX_SHRINK_SHARE = .5;
8813
8769
  /** Backstop on the oldest-first probe. A bounded read must stay bounded even if
8814
8770
  * a caller asks for a target no archive can cover. */
8815
8771
  var OLDEST_MAX_PAGES = 16;
@@ -8819,9 +8775,6 @@ var OLDEST_PROBE_COLUMNS = [
8819
8775
  "bytes",
8820
8776
  "locationId"
8821
8777
  ];
8822
- /** Aggregate-repair probe: the primary key always comes back, so this asks for
8823
- * the predicate column alone. */
8824
- var REPAIR_PROBE_COLUMNS = ["segments"];
8825
8778
  /**
8826
8779
  * Backstop on {@link SegmentHourLedger.streamSegments}.
8827
8780
  *
@@ -8860,47 +8813,8 @@ var CENSUS_FIELDS = [
8860
8813
  op: "max"
8861
8814
  }
8862
8815
  ];
8863
- /** Rows repaired per boot. The live archive is ~19 k hour rows. */
8864
- var REPAIR_MAX_ROWS = 5e4;
8865
- /**
8866
- * The boot projection (D287): everything the recorder's always-resident tier
8867
- * needs, and deliberately NOT `paths`.
8868
- *
8869
- * `paths` is ~55 B per segment and the archive is the only thing that grows:
8870
- * ~16 MB of JSON today, a projected ~460 MB at 28 TB of today's bitrate mix
8871
- * and ~5.9 GB at a low-bitrate mix — one tRPC response, parsed on the main
8872
- * thread, before the index exists. The summary half is O(writers × days) and
8873
- * stays ~10–15 MB at any archive size.
8874
- */
8875
- var SEGMENT_HOUR_SUMMARY_COLUMNS = [
8876
- "deviceId",
8877
- "profile",
8878
- "locationId",
8879
- "hourStartMs",
8880
- "bytes",
8881
- "segments",
8882
- "minStartMs",
8883
- "maxStartMs",
8884
- "sealed"
8885
- ];
8886
8816
  /** Summary rows folded into the mirror between yields. */
8887
8817
  var LOAD_CHUNK = 5e3;
8888
- /** Hour rows materialised per point-read page. One page covers a day of one
8889
- * camera's three profiles with room to spare. */
8890
- var HOUR_DETAIL_PAGE = 64;
8891
- /**
8892
- * The live tail materialised at boot: the current hour and the one before it.
8893
- *
8894
- * `recordSegment` must gate from RAM (D49), and the hour it appends to at boot
8895
- * is exactly the hour the previous process was writing. Two hours × the active
8896
- * writers is ~60 rows — one point-read page — and it is what makes the write
8897
- * path's materialise a case that essentially never happens.
8898
- */
8899
- var LIVE_TAIL_HOURS = 2;
8900
- /** Rows the one-shot aggregate repair may materialise in a single boot. The
8901
- * backfill is bounded so it cannot re-become the whole-archive read D287
8902
- * removed; a partial pass finishes on the next boot. */
8903
- var REPAIR_MAX_MATERIALIZE = 2e3;
8904
8818
  /** The four scalars an accounting answer is made of, asked in one statement. */
8905
8819
  var ACCOUNTING_FIELDS = [
8906
8820
  {
@@ -8931,7 +8845,7 @@ var EMPTY_ACCOUNTING = {
8931
8845
  newestMs: null
8932
8846
  };
8933
8847
  function hourStartMs(startMs) {
8934
- return Math.floor(startMs / HOUR_MS$3) * HOUR_MS$3;
8848
+ return Math.floor(startMs / HOUR_MS$4) * HOUR_MS$4;
8935
8849
  }
8936
8850
  function segmentHourKey(deviceId, profile, locationId, hour) {
8937
8851
  return `${deviceId}|${profile}|${locationId}|${hour}`;
@@ -8948,13 +8862,10 @@ function hourOf(row) {
8948
8862
  function writerKey(deviceId, profile) {
8949
8863
  return `${deviceId}|${profile}`;
8950
8864
  }
8951
- function detailOf(row) {
8952
- return {
8953
- locationId: row.locationId,
8954
- hourStartMs: row.hourStartMs,
8955
- paths: row.paths,
8956
- sealed: row.sealed
8957
- };
8865
+ /** One writer's hour, WITHOUT the location — the identity a loss is reported
8866
+ * against by a party that only knows the camera, the profile and the clock. */
8867
+ function writerHourKey(deviceId, profile, hour) {
8868
+ return `${deviceId}|${profile}|${hour}`;
8958
8869
  }
8959
8870
  /**
8960
8871
  * The `hourStartMs` of one probe record.
@@ -8967,21 +8878,31 @@ function hourOfRecord(record) {
8967
8878
  const raw = record.data["hourStartMs"];
8968
8879
  return typeof raw === "number" && Number.isFinite(raw) ? raw : -1;
8969
8880
  }
8970
- function uniquePaths(paths) {
8971
- const seen = /* @__PURE__ */ new Set();
8972
- const out = [];
8973
- for (const p of paths) {
8974
- if (seen.has(p)) continue;
8975
- seen.add(p);
8976
- out.push(p);
8881
+ /** One listing turned into the segment rows a caller can act on — the only
8882
+ * place a `SegmentRow` is built out of a directory read. */
8883
+ function segmentRowsOf(locationId, paths) {
8884
+ const rows = [];
8885
+ let unparseable = 0;
8886
+ for (const path of paths) {
8887
+ const parsed = parseSegmentPath(path);
8888
+ if (parsed === null) {
8889
+ unparseable += 1;
8890
+ continue;
8891
+ }
8892
+ rows.push({
8893
+ deviceId: parsed.deviceId,
8894
+ profile: parsed.profile,
8895
+ startMs: parsed.startMs,
8896
+ durMs: parsed.durMs,
8897
+ bytes: parsed.bytes,
8898
+ path,
8899
+ locationId
8900
+ });
8977
8901
  }
8978
- return out;
8979
- }
8980
- function samePaths(a, b) {
8981
- if (a.length !== b.length) return false;
8982
- const set = new Set(a);
8983
- for (const p of b) if (!set.has(p)) return false;
8984
- return true;
8902
+ return {
8903
+ rows,
8904
+ unparseable
8905
+ };
8985
8906
  }
8986
8907
  function rowToValue(row) {
8987
8908
  return {
@@ -8989,7 +8910,6 @@ function rowToValue(row) {
8989
8910
  profile: row.profile,
8990
8911
  locationId: row.locationId,
8991
8912
  hourStartMs: row.hourStartMs,
8992
- paths: encodePaths(row.paths),
8993
8913
  bytes: row.bytes,
8994
8914
  segments: row.segments,
8995
8915
  minStartMs: row.minStartMs,
@@ -9002,44 +8922,21 @@ function rowToValue(row) {
9002
8922
  function sealedOf(raw) {
9003
8923
  return raw === 1 || raw === true;
9004
8924
  }
9005
- function recordToRow(key, data) {
9006
- const deviceId = Number(data["deviceId"]);
9007
- const profile = data["profile"];
9008
- const locationId = data["locationId"];
9009
- const hour = Number(data["hourStartMs"]);
9010
- const decodedPaths = decodePaths(data["paths"]);
9011
- if (decodedPaths === null) return null;
9012
- const raw = decodedPaths;
9013
- 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;
9014
- return segmentHourRow({
9015
- key,
9016
- deviceId,
9017
- profile,
9018
- locationId,
9019
- hourStartMs: hour,
9020
- paths: raw,
9021
- sealed: sealedOf(data["sealed"])
9022
- });
9023
- }
9024
- /** The paths of a summary row: one shared empty array, never a per-row
9025
- * allocation, and never mistakable for "this hour holds nothing". */
9026
- var NO_PATHS = Object.freeze([]);
9027
8925
  function finiteOr0(raw) {
9028
8926
  const n = Number(raw);
9029
8927
  return Number.isFinite(n) ? n : 0;
9030
8928
  }
9031
8929
  /**
9032
- * One row of the boot projection ({@link SEGMENT_HOUR_SUMMARY_COLUMNS}).
8930
+ * One persisted row.
9033
8931
  *
9034
- * The identity half is validated exactly as {@link recordToRow} validates it —
9035
- * a row that cannot say which camera, profile, location and hour it is about
9036
- * is skipped, because a summary keyed on garbage would refuse the write path
9037
- * for an hour that is perfectly fine. The aggregate half is taken as the
9038
- * database wrote it: a row that predates the aggregate columns reports zeros,
9039
- * which UNDER-reports its disk (prunes less, never more) until
9040
- * {@link SegmentHourLedger.repairAggregates} rewrites it.
8932
+ * The identity half is validated: a row that cannot say which camera, profile,
8933
+ * location and hour it is about is DISCARDED, because a row keyed on garbage
8934
+ * would refuse the write path for an hour that is perfectly fine. The aggregate
8935
+ * half is taken as the database wrote it — a row that predates the aggregate
8936
+ * columns reports zeros, which UNDER-reports its disk (prunes less, never more)
8937
+ * until a listing of that hour re-derives it.
9041
8938
  */
9042
- function summaryToRow(key, data) {
8939
+ function recordToRow(key, data) {
9043
8940
  const deviceId = Number(data["deviceId"]);
9044
8941
  const profile = data["profile"];
9045
8942
  const locationId = data["locationId"];
@@ -9051,8 +8948,6 @@ function summaryToRow(key, data) {
9051
8948
  profile,
9052
8949
  locationId,
9053
8950
  hourStartMs: hour,
9054
- paths: NO_PATHS,
9055
- pathsResident: false,
9056
8951
  sealed: sealedOf(data["sealed"]),
9057
8952
  bytes: finiteOr0(data["bytes"]),
9058
8953
  segments: finiteOr0(data["segments"]),
@@ -9074,11 +8969,12 @@ var SPEC = {
9074
8969
  var SegmentHourLedger = class SegmentHourLedger {
9075
8970
  ledger;
9076
8971
  logger;
9077
- /** Held for the two ARCHIVE reads below, which are queries rather than mirror
8972
+ /** Held for the ARCHIVE reads below, which are queries rather than mirror
9078
8973
  * lookups. Every DECISION path (`recordSegment`, `dropSegments`, the prune
9079
8974
  * refusals) still answers from the mirror alone — D49 is about gates, and an
9080
8975
  * accounting number is not a gate. */
9081
8976
  store;
8977
+ listHour;
9082
8978
  isLocationUsable;
9083
8979
  now;
9084
8980
  loadChunk;
@@ -9092,11 +8988,32 @@ var SegmentHourLedger = class SegmentHourLedger {
9092
8988
  lanes = /* @__PURE__ */ new Map();
9093
8989
  /** Per (device, profile): the hour its writer last appended to. */
9094
8990
  writerHours = /* @__PURE__ */ new Map();
8991
+ /**
8992
+ * Segment paths THIS PROCESS has already accounted for, per hour key.
8993
+ *
8994
+ * The `paths` column used to make the write path idempotent for free: an
8995
+ * append checked `held.paths.includes(path)`. With the column gone the
8996
+ * aggregate cannot say whether it already counted a name, so the dedup lives
8997
+ * here — bounded to the hours this process actually writes (two per writer:
8998
+ * the current hour and the one it just left), dropped when the writer moves
8999
+ * on. A duplicate that survives a RESTART is not caught here and is not
9000
+ * silent either: the row is unsealed, and the first listing of the closed
9001
+ * hour re-derives the aggregate exactly ({@link adoptWalkedHour}).
9002
+ */
9003
+ accounted = /* @__PURE__ */ new Map();
9095
9004
  /** Hour keys a write was REFUSED for in this process — never sealed by it. */
9096
9005
  refused = /* @__PURE__ */ new Set();
9006
+ /**
9007
+ * `${deviceId}|${profile}|${hourStartMs}` a LOSS was reported for, location
9008
+ * left out on purpose: the party that sees the loss (the playlist watcher)
9009
+ * knows the camera and the profile and has no business knowing which disk
9010
+ * the recorder happens to be placing that hour on.
9011
+ */
9012
+ refusedWriterHours = /* @__PURE__ */ new Set();
9097
9013
  constructor(deps) {
9098
9014
  this.logger = deps.logger;
9099
9015
  this.store = deps.store;
9016
+ this.listHour = deps.listHour ?? null;
9100
9017
  this.isLocationUsable = deps.isLocationUsable ?? (() => true);
9101
9018
  this.now = deps.now ?? (() => Date.now());
9102
9019
  this.loadChunk = deps.loadPacing?.chunkSize ?? LOAD_CHUNK;
@@ -9115,22 +9032,17 @@ var SegmentHourLedger = class SegmentHourLedger {
9115
9032
  return this.ledger.declare();
9116
9033
  }
9117
9034
  /**
9118
- * Boot load — **T1 only** (D287).
9035
+ * Boot load — every hour row, in one query.
9119
9036
  *
9120
- * One projected query over {@link SEGMENT_HOUR_SUMMARY_COLUMNS}, folded into
9121
- * the mirror in chunks with a yield between them, then one point-read page
9122
- * that materialises the live tail so the write path can gate from RAM.
9037
+ * Since D523 the whole row is ten scalars, so there is no second tier to
9038
+ * materialise and no projection to choose: the mirror this returns is the
9039
+ * ledger, entire. It keeps `DurableLedger.load`'s two rules — a failed read
9040
+ * RETURNS WHAT IS ALREADY MIRRORED rather than clearing it, and a malformed
9041
+ * row is skipped rather than repaired.
9123
9042
  *
9124
- * It replaces `DurableLedger.load`, which fetches whole rows and therefore
9125
- * the whole archive's path strings, and it keeps that primitive's two rules:
9126
- * a failed read RETURNS WHAT IS ALREADY MIRRORED rather than clearing it,
9127
- * and a malformed row is skipped rather than repaired.
9128
- *
9129
- * It deliberately does NOT replay the archive into {@link RecordingIndex}.
9130
- * `hydrated` now means "the rows are resident", so a ledger-known hour is
9131
- * merely *hydratable from T3*: the first read of it pays one point read
9132
- * ({@link detailForWindow}), and an hour whose tail the ledger is missing is
9133
- * no longer permanently invisible to playback.
9043
+ * It deliberately does NOT replay the archive into {@link RecordingIndex}: a
9044
+ * row says how MUCH an hour holds, never which files, so only a listing can
9045
+ * seed the index (D523).
9134
9046
  */
9135
9047
  async load() {
9136
9048
  const startedMs = this.now();
@@ -9138,7 +9050,6 @@ var SegmentHourLedger = class SegmentHourLedger {
9138
9050
  try {
9139
9051
  records = await this.store.query.query({
9140
9052
  collection: RECORDING_SEGMENT_HOURS_COLLECTION,
9141
- columns: SEGMENT_HOUR_SUMMARY_COLUMNS,
9142
9053
  filter: { limit: this.rowCap }
9143
9054
  });
9144
9055
  } catch (err) {
@@ -9155,7 +9066,7 @@ var SegmentHourLedger = class SegmentHourLedger {
9155
9066
  const end = Math.min(i + this.loadChunk, records.length);
9156
9067
  for (let j = i; j < end; j += 1) {
9157
9068
  const record = records[j];
9158
- const row = summaryToRow(record.id, record.data);
9069
+ const row = recordToRow(record.id, record.data);
9159
9070
  if (row === null) {
9160
9071
  skipped += 1;
9161
9072
  continue;
@@ -9164,7 +9075,7 @@ var SegmentHourLedger = class SegmentHourLedger {
9164
9075
  }
9165
9076
  if (end < records.length) await this.yieldBetween();
9166
9077
  }
9167
- if (skipped > 0) this.logger.warn("recorder: segment-hour summaries skipped as malformed — they gate NOTHING", { meta: {
9078
+ if (skipped > 0) this.logger.warn("recorder: segment-hour rows skipped as malformed — they gate NOTHING", { meta: {
9168
9079
  collection: RECORDING_SEGMENT_HOURS_COLLECTION,
9169
9080
  skipped
9170
9081
  } });
@@ -9172,11 +9083,8 @@ var SegmentHourLedger = class SegmentHourLedger {
9172
9083
  limit: this.rowCap,
9173
9084
  loaded: records.length
9174
9085
  } });
9175
- const tail = await this.materializeLiveTail();
9176
9086
  this.logger.info("recorder: segment-hour ledger loaded", { meta: {
9177
9087
  hours: this.ledger.size,
9178
- pathsLoaded: false,
9179
- liveTailHours: tail,
9180
9088
  skipped,
9181
9089
  queryMs: queriedMs - startedMs,
9182
9090
  ms: this.now() - startedMs,
@@ -9184,8 +9092,7 @@ var SegmentHourLedger = class SegmentHourLedger {
9184
9092
  } });
9185
9093
  return this.ledger.snapshot();
9186
9094
  }
9187
- /** Every hour the ledger knows — summaries included. A row whose
9188
- * `pathsResident` is false carries real aggregates and empty `paths`. */
9095
+ /** Every hour row held. Pure RAM. */
9189
9096
  snapshot() {
9190
9097
  return this.ledger.snapshot();
9191
9098
  }
@@ -9195,10 +9102,10 @@ var SegmentHourLedger = class SegmentHourLedger {
9195
9102
  * The archive's answer to "what should placement plan for", and the reason it
9196
9103
  * is here rather than a `census` loop or an index read:
9197
9104
  *
9198
- * - `RecordingIndex.accountingByProfile` is the wrong authority. Since D287
9199
- * it holds the live tail plus whatever an operator hydrated, so a camera
9200
- * nobody has viewed since boot reports NO profiles — and the placement
9201
- * pass then never plans it (D295 §3a #9, D299).
9105
+ * - `RecordingIndex.accountingByProfile` is the wrong authority. It holds
9106
+ * the live tail plus whatever a reader hydrated, so a camera nobody has
9107
+ * viewed since boot reports NO profiles — and the placement pass then
9108
+ * never plans it (D295 §3a #9, D299).
9202
9109
  * - `census({ deviceId, profiles: [p] })` is the right authority but the
9203
9110
  * wrong shape for a 30 s tick: it is one SQL round trip per (camera,
9204
9111
  * profile), so a 16-camera fleet pays 48 SERIAL trips over the UDS door
@@ -9206,10 +9113,7 @@ var SegmentHourLedger = class SegmentHourLedger {
9206
9113
  * 240003` pass (see `PLACEMENT_STORE_TIMEOUT_MS`), and it grows with the
9207
9114
  * fleet.
9208
9115
  *
9209
- * So it is answered from T1, the hour summary D287 made permanently resident
9210
- * precisely so that O(writers × days) questions cost no I/O: ~10–15 MB and
9211
- * ~47 k rows even at 30 TB, one fold, no query. A hydrated segment row is not
9212
- * needed to know that an hour exists.
9116
+ * So it is answered from the mirror: one fold, no query, no I/O.
9213
9117
  *
9214
9118
  * An EMPTY result for a device is a real answer — "the archive knows no hour
9215
9119
  * for this camera" — and it is also what a ledger whose load failed reports,
@@ -9226,132 +9130,111 @@ var SegmentHourLedger = class SegmentHourLedger {
9226
9130
  }
9227
9131
  return out;
9228
9132
  }
9229
- /** Does the ledger hold a row for this hour at all? Pure RAM — the gate the
9230
- * storage-walk repair consults before it upserts a rebuilt hour. */
9133
+ /** Does the ledger hold a row for this hour at all? Pure RAM. */
9231
9134
  knowsHour(deviceId, profile, locationId, hour) {
9232
9135
  return this.ledger.has(segmentHourKey(deviceId, profile, locationId, hour));
9233
9136
  }
9234
- /**
9235
- * The paths of ONE held hour row, materialised — or `null` when they could
9236
- * not be read.
9237
- *
9238
- * {@link detailForWindow} answers per (device, window) and drops the profile
9239
- * on the way out, which is fine for a reader assembling a playlist and wrong
9240
- * for a caller that must act on one hour ROW: the ghost-location reconcile
9241
- * decides per `(deviceId, profile, locationId, hour)` and hands the resulting
9242
- * paths straight back to {@link dropSegments} / {@link repointSegments},
9243
- * whose bucket key is that same tuple.
9244
- *
9245
- * `null` is "known, and its detail could not be had" — never an empty array.
9246
- * The one caller FORGETS rows on this answer, and a failed point read that
9247
- * came back as `[]` would read as "this hour holds nothing", which is the
9248
- * D148 prune-on-no-evidence this ledger refuses to make.
9249
- */
9250
- async hourPaths(deviceId, profile, locationId, hour) {
9251
- const key = segmentHourKey(deviceId, profile, locationId, hour);
9252
- if (!this.ledger.has(key)) return null;
9253
- const held = await this.resident(key, deviceId);
9254
- return held === null ? null : held.paths;
9255
- }
9256
- /**
9257
- * The hours of `[fromMs, toMs)` the ledger knows, with their paths — the
9258
- * cold-hour detail read that replaces the boot replay (D287).
9259
- *
9260
- * One bounded point read against the settings store, never the media array:
9261
- * a `readdir` of one hour directory was measured at ~650 ms under load on
9262
- * the live share, and the array is the resource the scrub feeder is
9263
- * competing for. Hours the ledger does NOT know are absent from the result,
9264
- * and the caller walks those from disk exactly as it always did.
9265
- *
9266
- * **A read that fails returns what it already had** (D49). The caller sees
9267
- * fewer hours, the index stays `unknown` for the rest, and the window falls
9268
- * through to the walk — nothing is destroyed and nothing renders as an
9269
- * empty hour that was merely unread.
9270
- */
9271
- async detailForWindow(deviceId, fromMs, toMs) {
9272
- const first = hourStartMs(fromMs);
9273
- const out = [];
9274
- const wanted = [];
9275
- for (const row of this.ledger.snapshot()) {
9276
- if (row.deviceId !== deviceId) continue;
9277
- if (row.hourStartMs < first || row.hourStartMs >= toMs) continue;
9278
- if (row.pathsResident) {
9279
- out.push(detailOf(row));
9280
- continue;
9281
- }
9282
- wanted.push(row.key);
9283
- }
9284
- for (const row of await this.materialize(wanted, deviceId)) out.push(detailOf(row));
9285
- return out;
9137
+ /** One held hour row, or `undefined`. Pure RAM — since D523 a row carries
9138
+ * everything it has. */
9139
+ hourRow(deviceId, profile, locationId, hour) {
9140
+ return this.ledger.get(segmentHourKey(deviceId, profile, locationId, hour));
9286
9141
  }
9287
9142
  /**
9288
- * Union a CLOSED hour the storage walk read into its row, and SEAL it.
9143
+ * A LISTING of a CLOSED hour is adopted as that hour's account, and SEALS it.
9289
9144
  *
9290
- * The reader repairs the durable index it just reconstructed, so the next
9291
- * cold read of this hour is a point read instead of another `readdir`
9292
- * (`docs/design/2026-08-29-reference-nvr-recording-model.md` §4.2), and it
9293
- * seals the row because a directory read of a closed hour is the one
9294
- * complete answer there is. The caller guarantees the hour is CLOSED — a
9295
- * live hour's directory races its writer, so a walk of it is a lower bound.
9145
+ * The directory is the authority for which segments exist (D523), so a
9146
+ * listing of an hour that is OVER is the one complete answer there is — it
9147
+ * replaces the row's aggregate rather than merging with it, which is what
9148
+ * makes an hour that was over-counted (a duplicate finalize across a restart)
9149
+ * or under-counted (a writer torn down mid-hour) self-correcting. The caller
9150
+ * guarantees the hour is CLOSED: a live hour's directory races its writer, so
9151
+ * a listing of it is a lower bound.
9296
9152
  *
9297
- * A row the ledger already holds is UNIONED, never replaced: the walk can
9298
- * only add (D148) — a path the row names and the directory lacks stays,
9299
- * because "the reader did not see it" is not evidence it is gone. This is
9300
- * the repair D445 described and did not perform: its predecessor returned
9301
- * on any known key, so a row known INCOMPLETELY — the only kind that needs
9302
- * repairing — was exactly the row it refused to touch, and the export's
9303
- * `forgottenSegments=9758` was counted again the next morning.
9153
+ * A listing that would erase more than {@link HOUR_LISTING_MAX_SHRINK_SHARE}
9154
+ * of what the row accounts for is REFUSED and named. That is the one thing a
9155
+ * listing cannot be trusted about: a root that has gone away and a bucket a
9156
+ * dead round half-deleted both answer short, and neither is distinguishable
9157
+ * from an hour that is simply smaller than the row thought.
9304
9158
  *
9305
- * `null` when the row is known and its detail could not be read: nothing
9306
- * is written from a row whose paths are not resident.
9159
+ * @seal-authority closed-hour-listing — a directory read of an hour that is
9160
+ * OVER is the one complete answer there is; the caller guarantees the hour is
9161
+ * closed (D451, D521, D523).
9307
9162
  */
9308
9163
  async adoptWalkedHour(deviceId, profile, locationId, hour, paths) {
9309
9164
  const key = segmentHourKey(deviceId, profile, locationId, hour);
9165
+ const identity = {
9166
+ key,
9167
+ deviceId,
9168
+ profile,
9169
+ locationId,
9170
+ hourStartMs: hour
9171
+ };
9172
+ const walked = hourAggregate(paths);
9310
9173
  return this.serialized(key, async () => {
9311
- if (!this.ledger.has(key)) {
9312
- if (paths.length === 0) return {
9174
+ const held = this.ledger.get(key);
9175
+ if (held === void 0) {
9176
+ if (walked.segments === 0) return {
9313
9177
  created: false,
9314
9178
  added: 0,
9315
9179
  sealedNow: false
9316
9180
  };
9317
- const row = segmentHourRow({
9318
- key,
9319
- deviceId,
9320
- profile,
9321
- locationId,
9322
- hourStartMs: hour,
9323
- paths: uniquePaths(paths),
9181
+ await this.ledger.put(hourRowOf(identity, {
9182
+ ...walked,
9324
9183
  sealed: true
9325
- });
9326
- await this.ledger.put(row);
9327
- this.logger.info("recorder: adopted a closed hour the disk walk rebuilt and the ledger never held", {
9184
+ }));
9185
+ this.logger.info("recorder: adopted a closed hour the disk listing rebuilt and the ledger never held", {
9328
9186
  tags: { deviceId },
9329
9187
  meta: {
9330
9188
  profile,
9331
9189
  locationId,
9332
9190
  hourStartMs: hour,
9333
- segments: row.segments,
9334
- bytes: row.bytes
9191
+ segments: walked.segments,
9192
+ bytes: walked.bytes
9335
9193
  }
9336
9194
  });
9337
9195
  return {
9338
9196
  created: true,
9339
- added: row.paths.length,
9197
+ added: walked.segments,
9340
9198
  sealedNow: true
9341
9199
  };
9342
9200
  }
9343
- const held = await this.resident(key, deviceId);
9344
- if (held === null) return null;
9345
- const union = uniquePaths([...held.paths, ...paths]);
9346
- const added = union.length - held.paths.length;
9347
- if (added === 0 && held.sealed) return {
9201
+ const shrink = held.segments - walked.segments;
9202
+ if (shrink > 0 && shrink > held.segments * .5) {
9203
+ this.logger.warn("recorder: hour listing REFUSED — it accounts for less than half of what this hour row holds, which is what an unmounted root looks like; the row stands and stays unsealed", {
9204
+ tags: { deviceId },
9205
+ meta: {
9206
+ profile,
9207
+ locationId,
9208
+ hourStartMs: hour,
9209
+ held: held.segments,
9210
+ listed: walked.segments,
9211
+ maxShare: HOUR_LISTING_MAX_SHRINK_SHARE
9212
+ }
9213
+ });
9214
+ return {
9215
+ created: false,
9216
+ added: 0,
9217
+ sealedNow: false
9218
+ };
9219
+ }
9220
+ const added = Math.max(0, walked.segments - held.segments);
9221
+ if (added === 0 && shrink === 0 && held.sealed) return {
9348
9222
  created: false,
9349
9223
  added: 0,
9350
9224
  sealedNow: false
9351
9225
  };
9352
- await this.ledger.put(segmentHourRow({
9353
- ...held,
9354
- paths: union,
9226
+ if (shrink > 0) this.logger.info("recorder: hour listing accounts for FEWER segments than the row did — the directory decides", {
9227
+ tags: { deviceId },
9228
+ meta: {
9229
+ profile,
9230
+ locationId,
9231
+ hourStartMs: hour,
9232
+ held: held.segments,
9233
+ listed: walked.segments
9234
+ }
9235
+ });
9236
+ await this.ledger.put(hourRowOf(identity, {
9237
+ ...walked,
9355
9238
  sealed: true
9356
9239
  }));
9357
9240
  return {
@@ -9381,19 +9264,74 @@ var SegmentHourLedger = class SegmentHourLedger {
9381
9264
  }
9382
9265
  /**
9383
9266
  * A finalize this ledger was NEVER handed — the fire-and-forget lane refused
9384
- * it. The hour's row is short by that path and this process is the only
9385
- * party that knows, so the hour must not be sealed by it; a reader walks it
9386
- * once instead, which is exactly what an unsealed row is for.
9267
+ * it. The hour's row is short by that segment and this process is the only
9268
+ * party that knows, so the hour must not be sealed by it; a listing corrects
9269
+ * it instead, which is exactly what an unsealed row is for.
9387
9270
  */
9388
9271
  markRefused(row) {
9389
9272
  this.refused.add(hourOf(row).key);
9390
9273
  }
9274
+ /**
9275
+ * Segments a writer produced that never reached {@link recordSegment} — the
9276
+ * watcher's `rolledOff`, the one moment the system KNOWS work was skipped.
9277
+ *
9278
+ * Every hour the loss spans is refused the seal, whatever else this process
9279
+ * believes about them: the ownership grace answers "was I here", and this
9280
+ * answers "did I get everything while I was". Camera 615's respawned writer
9281
+ * passed the first question for the hour it entered at the top and still held
9282
+ * a row short by 175 segments.
9283
+ *
9284
+ * Returns how many hours were marked, so the caller's line reports what its
9285
+ * report bought rather than that it was made.
9286
+ */
9287
+ markHoursRefused(span) {
9288
+ if (!Number.isFinite(span.fromMs) || !Number.isFinite(span.toMs)) return 0;
9289
+ const first = hourStartMs(Math.min(span.fromMs, span.toMs));
9290
+ const last = hourStartMs(Math.max(span.fromMs, span.toMs));
9291
+ let marked = 0;
9292
+ for (let hour = first; hour <= last && marked < 24; hour += HOUR_MS$4) {
9293
+ this.refusedWriterHours.add(writerHourKey(span.deviceId, span.profile, hour));
9294
+ marked += 1;
9295
+ }
9296
+ if (marked === 0) return 0;
9297
+ this.logger.warn("recorder: segments this writer produced never reached the hour ledger — these hours stay UNSEALED and a listing corrects them", {
9298
+ tags: { deviceId: span.deviceId },
9299
+ meta: {
9300
+ profile: span.profile,
9301
+ fromMs: first,
9302
+ toMs: last,
9303
+ hours: marked,
9304
+ segments: span.count,
9305
+ boundHours: 24
9306
+ }
9307
+ });
9308
+ return marked;
9309
+ }
9391
9310
  /** One write: the row as held, flagged sealed. Serialised on its key so it
9392
- * cannot land over an append that read the row before it. */
9311
+ * cannot land over an append that read the row before it.
9312
+ *
9313
+ * @seal-authority writer-owned-hour — the writer that LEAVES the hour, and
9314
+ * only when it entered within {@link WRITER_HOUR_ENTRY_GRACE_MS} of the
9315
+ * hour's start and no loss was reported against it (D521). */
9393
9316
  async sealHour(target) {
9394
9317
  await this.serialized(target.key, async () => {
9395
- if (this.refused.has(target.key)) {
9396
- this.logger.warn("recorder: hour left unsealed — a ledger write for it was refused, so its row is short and a reader must walk it", {
9318
+ const enteredAfterMs = target.entryMs - target.hourStartMs;
9319
+ if (enteredAfterMs > 9e4) {
9320
+ this.logger.warn("recorder: hour left unsealed — this writer entered it late, so it cannot claim the hour is complete", {
9321
+ tags: { deviceId: target.deviceId },
9322
+ meta: {
9323
+ profile: target.profile,
9324
+ locationId: target.locationId,
9325
+ hourStartMs: target.hourStartMs,
9326
+ enteredAfterMs,
9327
+ graceMs: WRITER_HOUR_ENTRY_GRACE_MS
9328
+ }
9329
+ });
9330
+ this.accounted.delete(target.key);
9331
+ return;
9332
+ }
9333
+ if (this.refused.has(target.key) || this.refusedWriterHours.has(writerHourKey(target.deviceId, target.profile, target.hourStartMs))) {
9334
+ this.logger.warn("recorder: hour left unsealed — a ledger write for it was refused, so its row is short and a listing must correct it", {
9397
9335
  tags: { deviceId: target.deviceId },
9398
9336
  meta: {
9399
9337
  profile: target.profile,
@@ -9401,10 +9339,12 @@ var SegmentHourLedger = class SegmentHourLedger {
9401
9339
  hourStartMs: target.hourStartMs
9402
9340
  }
9403
9341
  });
9342
+ this.accounted.delete(target.key);
9404
9343
  return;
9405
9344
  }
9406
- const held = await this.resident(target.key, target.deviceId);
9407
- if (held === null || held.sealed) return;
9345
+ const held = this.ledger.get(target.key);
9346
+ this.accounted.delete(target.key);
9347
+ if (held === void 0 || held.sealed) return;
9408
9348
  await this.ledger.put({
9409
9349
  ...held,
9410
9350
  sealed: true
@@ -9412,9 +9352,15 @@ var SegmentHourLedger = class SegmentHourLedger {
9412
9352
  });
9413
9353
  }
9414
9354
  /**
9415
- * Remember which hour this writer is on; when it MOVES FORWARD, seal the
9416
- * hour it left. Backwards is a late finalize (a recovered orphan, a replay)
9417
- * into an hour already accounted for, and seals nothing.
9355
+ * Remember which hour this writer is on — and how far into it this process
9356
+ * ARRIVED ({@link WriterHour.entryMs}, the first finalize's own start) —
9357
+ * and when it MOVES FORWARD, seal the hour it left. Backwards is a late
9358
+ * finalize (a recovered orphan, a replay) into an hour already accounted for,
9359
+ * and seals nothing.
9360
+ *
9361
+ * The entry stamp is kept from the FIRST finalize into the hour and never
9362
+ * refreshed: the question the seal has to answer is "was this writer here
9363
+ * when the hour began", and the last segment of the hour cannot answer it.
9418
9364
  */
9419
9365
  noteWriter(next) {
9420
9366
  const id = writerKey(next.deviceId, next.profile);
@@ -9426,11 +9372,25 @@ var SegmentHourLedger = class SegmentHourLedger {
9426
9372
  /**
9427
9373
  * Run `fn` after every mutation already queued on `key`.
9428
9374
  *
9429
- * A row is read, appended to and written back with awaits in between; two
9430
- * such runs on one key interleave, and the second write carries a paths
9431
- * array that never saw the first — the write-behind's own lost update. The
9432
- * lane makes "read, append, write" one step per key. A rejected step must
9433
- * not poison the lane: the next step still runs.
9375
+ * It was the lost-update guard: a row was read from the STORE, folded into
9376
+ * and written back with awaits in between, so two such runs on one key
9377
+ * interleaved and the second write carried a path set that never saw the
9378
+ * first — the sparse rows D451 found. Since D523 there is no store read on
9379
+ * any of these paths (the mirror holds the whole row) and `DurableLedger.put`
9380
+ * sets the mirror synchronously before it awaits the store, so every
9381
+ * get→fold→put here is synchronous up to the mirror and the interleaving
9382
+ * window is gone.
9383
+ *
9384
+ * What the lane still buys is the ORDER of the durable writes per hour key —
9385
+ * a rollover seal fired without an await must not land over an append that
9386
+ * was queued before it — and a handle for {@link flush} to wait on at
9387
+ * shutdown. A rejected step must not poison the lane: the next step still
9388
+ * runs.
9389
+ *
9390
+ * Note for anyone tempted to delete it: `segment-hour-seal.spec.ts` does NOT
9391
+ * catch its removal any more, and says so. Pinning durable write ORDER needs
9392
+ * a store whose latency a test can control, which `fakeSettingsStore` has no
9393
+ * way to express.
9434
9394
  */
9435
9395
  serialized(key, fn) {
9436
9396
  const run = (this.lanes.get(key) ?? Promise.resolve()).then(fn, fn);
@@ -9442,116 +9402,59 @@ var SegmentHourLedger = class SegmentHourLedger {
9442
9402
  return run;
9443
9403
  }
9444
9404
  /**
9445
- * Fetch whole rows for `keys` and promote them in the mirror.
9446
- *
9447
- * Paged and yielding: an export window can name thousands of hours, and one
9448
- * `whereIn` over all of them would rebuild the very whole-archive response
9449
- * D287 removed. A page that FAILS ends the materialise and returns what
9450
- * landed — partial detail is real detail, and the caller's fallback (a disk
9451
- * walk, or refusing a write) is strictly safer than a guess.
9405
+ * List the hour directories of `rows`, paced, and expand them into segment
9406
+ * rows.
9452
9407
  *
9453
- * A row the store returns but this cannot parse is EVICTED from the mirror
9454
- * rather than left as a summary that can never become resident: it re-seeds
9455
- * cold on the next write, which is `DurableLedger`'s rule 4 and the only
9456
- * escape from an hour permanently frozen against its own writer.
9408
+ * The one place the ledger touches the array. An hour whose listing FAILED
9409
+ * takes no part in the caller's pass and is SAID — a branch that drops work
9410
+ * silently reads as "there was nothing there", which for a drain or a prune
9411
+ * is the difference between "moved everything" and "moved what it could see".
9457
9412
  */
9458
- async materialize(keys, deviceId) {
9413
+ async listHours(rows, reason) {
9459
9414
  const out = [];
9460
- if (keys.length === 0) return out;
9461
- const tags = deviceId === void 0 ? {} : { tags: { deviceId } };
9462
- for (let i = 0; i < keys.length; i += HOUR_DETAIL_PAGE) {
9463
- const page = keys.slice(i, i + HOUR_DETAIL_PAGE);
9464
- let records;
9465
- try {
9466
- records = await this.store.query.query({
9467
- collection: RECORDING_SEGMENT_HOURS_COLLECTION,
9468
- filter: {
9469
- whereIn: { key: page },
9470
- limit: page.length
9471
- }
9472
- });
9473
- } catch (err) {
9474
- this.logger.warn("recorder: hour-detail point read failed — these hours stay summaries, and the caller walks the disk or declines the write", {
9475
- ...tags,
9415
+ if (rows.length === 0) return out;
9416
+ const lister = this.listHour;
9417
+ if (lister === null) {
9418
+ this.logger.warn("recorder: no hour lister on this ledger — nothing that needs segment NAMES can run here", { meta: {
9419
+ hours: rows.length,
9420
+ reason
9421
+ } });
9422
+ return out;
9423
+ }
9424
+ let listed = 0;
9425
+ for (const row of rows) {
9426
+ const paths = await lister(row.locationId, row.deviceId, row.profile, row.hourStartMs);
9427
+ if (paths === null) {
9428
+ this.logger.warn("recorder: hour directory could not be listed — this hour takes NO part in this pass", {
9429
+ tags: { deviceId: row.deviceId },
9476
9430
  meta: {
9477
- collection: RECORDING_SEGMENT_HOURS_COLLECTION,
9478
- asked: page.length,
9479
- materialized: out.length,
9480
- error: String(err)
9431
+ profile: row.profile,
9432
+ locationId: row.locationId,
9433
+ hourStartMs: row.hourStartMs,
9434
+ accounted: row.segments,
9435
+ reason
9481
9436
  }
9482
9437
  });
9483
- return out;
9438
+ continue;
9484
9439
  }
9485
- for (const record of records) {
9486
- const row = recordToRow(record.id, record.data);
9487
- if (row === null) {
9488
- this.ledger.evict(record.id);
9489
- this.logger.warn("recorder: hour row is unparseable — dropping the summary so the hour re-seeds cold instead of freezing its writer", {
9490
- ...tags,
9491
- meta: { key: record.id }
9492
- });
9493
- continue;
9440
+ const { rows: segments, unparseable } = segmentRowsOf(row.locationId, paths);
9441
+ if (unparseable > 0) this.logger.warn("recorder: hour directory holds names that are not segments", {
9442
+ tags: { deviceId: row.deviceId },
9443
+ meta: {
9444
+ profile: row.profile,
9445
+ locationId: row.locationId,
9446
+ hourStartMs: row.hourStartMs,
9447
+ unparseable,
9448
+ reason
9494
9449
  }
9495
- this.ledger.stage(row);
9496
- out.push(row);
9497
- }
9498
- if (i + HOUR_DETAIL_PAGE < keys.length) await this.yieldBetween();
9450
+ });
9451
+ out.push(...segments);
9452
+ listed += 1;
9453
+ if (listed % 8 === 0) await this.yieldBetween();
9499
9454
  }
9500
9455
  return out;
9501
9456
  }
9502
9457
  /**
9503
- * Materialise every hour these devices hold, and report how many stayed
9504
- * summaries.
9505
- *
9506
- * The reconcile is the one path that DELETES durable rows, so it may not run
9507
- * against a mirror that only half knows what it holds. This is a whole-device
9508
- * read and it is deliberately confined here: `reconcileFromIndex` follows an
9509
- * operator-armed rescan of that same device — a recursive `readdir` over
9510
- * >1M files — so a paged point read alongside it is noise, and it is the one
9511
- * caller whose correctness depends on completeness.
9512
- */
9513
- async materializeDevices(deviceIds) {
9514
- const keys = [];
9515
- for (const row of this.ledger.snapshot()) if (!row.pathsResident && deviceIds.has(row.deviceId)) keys.push(row.key);
9516
- if (keys.length === 0) return;
9517
- await this.materialize(keys);
9518
- const stillSummary = /* @__PURE__ */ new Map();
9519
- for (const row of this.ledger.snapshot()) {
9520
- if (row.pathsResident || !deviceIds.has(row.deviceId)) continue;
9521
- stillSummary.set(row.deviceId, (stillSummary.get(row.deviceId) ?? 0) + 1);
9522
- }
9523
- 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", {
9524
- tags: { deviceId },
9525
- meta: {
9526
- asked: keys.length,
9527
- unreadable
9528
- }
9529
- });
9530
- }
9531
- /** The hour a write path may touch: current + previous. Materialised at boot
9532
- * so {@link recordSegment}'s dedup gate answers from RAM (D49). */
9533
- async materializeLiveTail() {
9534
- const oldest = hourStartMs(this.now()) - (LIVE_TAIL_HOURS - 1) * HOUR_MS$3;
9535
- const keys = [];
9536
- for (const row of this.ledger.snapshot()) if (row.hourStartMs >= oldest && !row.pathsResident) keys.push(row.key);
9537
- return (await this.materialize(keys)).length;
9538
- }
9539
- /**
9540
- * Promote one HELD row to paths-resident, or answer `null`.
9541
- *
9542
- * `null` means "this hour is known and its detail could not be had" — the
9543
- * only honest answer to a failed point read, and every caller turns it into
9544
- * "do nothing". It is deliberately distinct from `this.ledger.get(key) ===
9545
- * undefined`, which means "no such hour" and is what licenses a fresh row.
9546
- */
9547
- async resident(key, deviceId) {
9548
- const held = this.ledger.get(key);
9549
- if (held !== void 0 && held.pathsResident) return held;
9550
- await this.materialize([key], deviceId);
9551
- const after = this.ledger.get(key);
9552
- return after !== void 0 && after.pathsResident ? after : null;
9553
- }
9554
- /**
9555
9458
  * Bytes / count / oldest / newest across a SET of storage locations, as ONE
9556
9459
  * `SUM`/`COUNT`/`MIN`/`MAX` over the hour rows.
9557
9460
  *
@@ -9592,9 +9495,9 @@ var SegmentHourLedger = class SegmentHourLedger {
9592
9495
  * ## Why two phases
9593
9496
  *
9594
9497
  * Phase one reads only the small columns and walks oldest-first until the
9595
- * accumulated bytes cover the target — normally one page. `paths` is by far
9596
- * the fattest column in the row and phase one never touches it. Phase two
9597
- * fetches paths for exactly the hours phase one chose.
9498
+ * accumulated bytes cover the target — normally one page. Phase two LISTS the
9499
+ * hour directories phase one chose, which is the only thing that can name the
9500
+ * files eviction is about to delete (D523).
9598
9501
  *
9599
9502
  * `exhausted` means the stream ran out before the target was met — the
9600
9503
  * caller's stop condition, and the one thing a bounded read must still be
@@ -9628,11 +9531,10 @@ var SegmentHourLedger = class SegmentHourLedger {
9628
9531
  /**
9629
9532
  * Every camera the ledger holds an hour for on these locations.
9630
9533
  *
9631
- * Answered from T1 — the always-resident hour summary, which carries
9632
- * `deviceId` and `locationId` — exactly like {@link profilesWithFootage},
9633
- * and for the same reason: this is an O(writers × days) question and it must
9634
- * cost no I/O. It runs on the disk-pressure path, where an I/O that fails
9635
- * would have to be turned into a verdict.
9534
+ * Answered from the mirror, exactly like {@link profilesWithFootage}, and for
9535
+ * the same reason: this is an O(writers × days) question and it must cost no
9536
+ * I/O. It runs on the disk-pressure path, where an I/O that fails would have
9537
+ * to be turned into a verdict.
9636
9538
  *
9637
9539
  * An EMPTY answer is "the mirror knows no hour here", which is also what a
9638
9540
  * ledger whose load failed reports — so the caller must treat it as "decide
@@ -9647,7 +9549,7 @@ var SegmentHourLedger = class SegmentHourLedger {
9647
9549
  * Bytes / segments / oldest / newest per CAMERA on these locations — the
9648
9550
  * per-camera half of the storage page, answered by the ARCHIVE (D418).
9649
9551
  *
9650
- * ## Why the T1 fold and not `census({ deviceId })` per camera
9552
+ * ## Why the mirror fold and not `census({ deviceId })` per camera
9651
9553
  *
9652
9554
  * `census` is the right authority for a SCOPE, and it cannot express this
9653
9555
  * scope: {@link SegmentHourLedger.predicate} takes ONE `locationId`, so
@@ -9657,14 +9559,10 @@ var SegmentHourLedger = class SegmentHourLedger {
9657
9559
  * accounts for. Per camera per volume that is `cameras × volumes` SQL
9658
9560
  * aggregates for one operator click, and it grows with the fleet.
9659
9561
  *
9660
- * The T1 hour summary answers all of them in ONE pass with no I/O, exactly
9661
- * as {@link devicesWithFootage} and {@link profilesWithFootage} already do,
9662
- * and it is the same rows `accountingForLocations` sums in SQL — the mirror
9663
- * is write-behind, so it is never BEHIND the store. The cost is one pass
9664
- * over the hour rows the mirror holds — O(writers × profiles × hours), ~19 k
9665
- * on the live hub (see {@link REPAIR_MAX_ROWS}) against 98 445 segments,
9666
- * i.e. it does NOT scale with the segment count — and it is capped for good
9667
- * by {@link SEGMENT_HOUR_SUMMARY_LIMIT}, the mirror's own ceiling.
9562
+ * The mirror answers all of them in ONE pass with no I/O, exactly as
9563
+ * {@link devicesWithFootage} and {@link profilesWithFootage} already do, and
9564
+ * it is the same rows `accountingForLocations` sums in SQL — the mirror is
9565
+ * write-behind, so it is never BEHIND the store.
9668
9566
  *
9669
9567
  * ## What an empty answer means
9670
9568
  *
@@ -9738,44 +9636,28 @@ var SegmentHourLedger = class SegmentHourLedger {
9738
9636
  rows: [],
9739
9637
  exhausted: true
9740
9638
  };
9741
- const full = await this.store.query.query({
9742
- collection: RECORDING_SEGMENT_HOURS_COLLECTION,
9743
- filter: {
9744
- whereIn: { key: chosen },
9745
- limit: chosen.length
9746
- }
9747
- });
9748
- const rows = [];
9749
- for (const record of full) {
9750
- const hour = recordToRow(record.id, record.data);
9751
- if (hour === null) continue;
9752
- this.ledger.stage(hour);
9753
- for (const path of hour.paths) {
9754
- const parsed = parseSegmentPath(path);
9755
- if (parsed === null) continue;
9756
- rows.push({
9757
- deviceId: parsed.deviceId,
9758
- profile: parsed.profile,
9759
- startMs: parsed.startMs,
9760
- durMs: parsed.durMs,
9761
- bytes: parsed.bytes,
9762
- path,
9763
- locationId: hour.locationId
9764
- });
9765
- }
9766
- }
9767
9639
  return {
9768
- rows: rows.toSorted((a, b) => a.startMs - b.startMs),
9640
+ rows: (await this.listHours(this.rowsOfKeys(chosen), "oldest-segments")).toSorted((a, b) => a.startMs - b.startMs),
9769
9641
  exhausted
9770
9642
  };
9771
9643
  }
9644
+ /** Mirror rows for keys a probe named. A key the mirror does not hold is
9645
+ * skipped: the probe and the mirror describe the same table, and an hour the
9646
+ * mirror never loaded is one this process may not act on. */
9647
+ rowsOfKeys(keys) {
9648
+ const out = [];
9649
+ for (const key of keys) {
9650
+ const row = this.ledger.get(key);
9651
+ if (row !== void 0) out.push(row);
9652
+ }
9653
+ return out;
9654
+ }
9772
9655
  /**
9773
9656
  * The predicate half of an {@link ArchiveScope}, as one filter.
9774
9657
  *
9775
9658
  * REFUSES an empty scope rather than compiling to "the whole collection".
9776
- * There is no read here that may be unscoped: at 30 TB the unscoped form is
9777
- * the ~460 MB boot query D287 deleted, and a helper that quietly allows it is
9778
- * how that query comes back under a new name.
9659
+ * There is no read here that may be unscoped: an unscoped stream lists every
9660
+ * hour directory on the hub.
9779
9661
  */
9780
9662
  static predicate(scope) {
9781
9663
  const where = {};
@@ -9788,6 +9670,15 @@ var SegmentHourLedger = class SegmentHourLedger {
9788
9670
  ...profiles.length > 0 ? { whereIn: { profile: [...profiles] } } : {}
9789
9671
  };
9790
9672
  }
9673
+ /** Does this row fall inside `scope`? The mirror-side twin of
9674
+ * {@link SegmentHourLedger.predicate}, for the passes that fold the mirror
9675
+ * instead of querying. */
9676
+ static inScope(row, scope) {
9677
+ if (scope.locationId !== void 0 && row.locationId !== scope.locationId) return false;
9678
+ if (scope.deviceId !== void 0 && row.deviceId !== scope.deviceId) return false;
9679
+ const profiles = scope.profiles ?? [];
9680
+ return profiles.length === 0 || profiles.includes(row.profile);
9681
+ }
9791
9682
  /**
9792
9683
  * What the ARCHIVE holds in this scope — segments and bytes, as ONE
9793
9684
  * `SUM` over the hour rows.
@@ -9831,6 +9722,15 @@ var SegmentHourLedger = class SegmentHourLedger {
9831
9722
  /**
9832
9723
  * Every segment in `scope`, oldest-first, ONE PAGE at a time.
9833
9724
  *
9725
+ * ## Two phases, and which authority answers which
9726
+ *
9727
+ * Phase one asks the TABLE which hours the scope holds, oldest-first — the
9728
+ * ordered aggregate no directory read can answer. Phase two LISTS those hour
9729
+ * directories, which is the only authority for the file names the caller is
9730
+ * about to move or delete (D523). An hour whose listing fails is skipped and
9731
+ * said; the caller's own N-versus-M report is what makes the short walk
9732
+ * visible.
9733
+ *
9834
9734
  * ## Why a keyset cursor and not `offset`
9835
9735
  *
9836
9736
  * Both callers MUTATE the scope while they walk it: the drain re-points each
@@ -9847,10 +9747,9 @@ var SegmentHourLedger = class SegmentHourLedger {
9847
9747
  *
9848
9748
  * ## What a failed read does
9849
9749
  *
9850
- * It ENDS the stream (D49: a fallible read destroys nothing) — and the
9851
- * caller's own N-versus-M report is what makes the short walk visible. A
9852
- * stream that swallowed the error and returned would be indistinguishable
9853
- * from a scope that was genuinely empty.
9750
+ * It ENDS the stream (D49: a fallible read destroys nothing). A stream that
9751
+ * swallowed the error and returned would be indistinguishable from a scope
9752
+ * that was genuinely empty.
9854
9753
  */
9855
9754
  async *streamSegments(scope) {
9856
9755
  const predicate = SegmentHourLedger.predicate(scope);
@@ -9903,8 +9802,8 @@ var SegmentHourLedger = class SegmentHourLedger {
9903
9802
  if (nextCursor !== cursor) emittedAtCursor = /* @__PURE__ */ new Set();
9904
9803
  for (const record of fresh) if (hourOfRecord(record) === nextCursor) emittedAtCursor.add(record.id);
9905
9804
  cursor = nextCursor;
9906
- const rows = await this.rowsForHours(fresh.map((record) => record.id), scope);
9907
- if (rows.length > 0) yield rows;
9805
+ const rows = await this.listHours(this.rowsOfKeys(fresh.map((record) => record.id)), "archive-stream");
9806
+ if (rows.length > 0) yield rows.toSorted((a, b) => a.startMs - b.startMs);
9908
9807
  if (probe.length < 128 && fresh.length === probe.length) return;
9909
9808
  }
9910
9809
  this.logger.warn("recorder: archive stream hit its page backstop — the rest of this scope was NOT walked", {
@@ -9918,110 +9817,50 @@ var SegmentHourLedger = class SegmentHourLedger {
9918
9817
  });
9919
9818
  }
9920
9819
  /**
9921
- * Phase two of {@link streamSegments}: the paths of exactly the hours phase
9922
- * one chose, expanded into segment rows.
9820
+ * Forget every hour row in `scope` — the durable half of an operator's
9821
+ * "discard this location's footage".
9822
+ *
9823
+ * It exists because the files are already GONE by the time it runs: the
9824
+ * discard wipes the class subtrees first, so every hour directory in the
9825
+ * scope answers ENOENT and nothing that needs a listing can name a single
9826
+ * segment. The rows are forgotten by their own identity instead, and the
9827
+ * count returned is the SEGMENTS they accounted for, which is the number the
9828
+ * operator is shown.
9923
9829
  *
9924
- * Staged into the mirror on the way through, exactly as `oldestSegments`
9925
- * does and for the same reason: both consumers immediately call a write path
9926
- * (`repointSegments` / `dropSegments`) which needs the hour paths-resident,
9927
- * and which `forget`s the row as it empties — so the staging is
9928
- * self-cleaning and the alternative is two reads per hour.
9830
+ * Never reachable except from that explicitly-armed action: this is the one
9831
+ * surface here that deletes durable rows with no evidence from the array at
9832
+ * all, because the operator's own instruction is the evidence.
9929
9833
  */
9930
- async rowsForHours(keys, scope) {
9931
- if (keys.length === 0) return [];
9932
- let records;
9933
- try {
9934
- records = await this.store.query.query({
9935
- collection: RECORDING_SEGMENT_HOURS_COLLECTION,
9936
- filter: {
9937
- whereIn: { key: [...keys] },
9938
- limit: keys.length
9939
- }
9940
- });
9941
- } catch (err) {
9942
- this.logger.warn("recorder: archive page detail unavailable — these hours take NO part in this pass", {
9943
- ...scope.deviceId !== void 0 ? { tags: { deviceId: scope.deviceId } } : {},
9944
- meta: {
9945
- collection: RECORDING_SEGMENT_HOURS_COLLECTION,
9946
- hours: keys.length,
9947
- error: String(err)
9948
- }
9949
- });
9950
- return [];
9951
- }
9952
- const rows = [];
9953
- for (const record of records) {
9954
- const hour = recordToRow(record.id, record.data);
9955
- if (hour === null) continue;
9956
- this.ledger.stage(hour);
9957
- for (const path of hour.paths) {
9958
- const parsed = parseSegmentPath(path);
9959
- if (parsed === null) continue;
9960
- rows.push({
9961
- deviceId: parsed.deviceId,
9962
- profile: parsed.profile,
9963
- startMs: parsed.startMs,
9964
- durMs: parsed.durMs,
9965
- bytes: parsed.bytes,
9966
- path,
9967
- locationId: hour.locationId
9968
- });
9969
- }
9970
- }
9971
- return rows.toSorted((a, b) => a.startMs - b.startMs);
9972
- }
9973
9834
  /**
9974
- * Rewrite, once, the rows whose aggregate columns are still the schema
9975
- * default.
9835
+ * Forget ONE hour row outright, and answer the segments it accounted for.
9976
9836
  *
9977
- * `declareCollection` adds a column additively, so every row written before
9978
- * the aggregate columns existed holds `segments = 0` — and an hour row with
9979
- * no segments cannot exist any other way (`dropSegments` forgets an hour that
9980
- * empties). So `segments = 0` IS the backfill predicate, and after one pass
9981
- * it selects nothing. A partial repair finishes on the next boot; until then
9982
- * the affected disks UNDER-report, which prunes less and never more.
9837
+ * The caller must have TRUSTED evidence that the hour holds nothing — a
9838
+ * directory listing under a volume anchor that agreed twice (D49), or a
9839
+ * per-path confirmation against every root there is. This method makes no
9840
+ * such check itself and can make none: it is handed a verdict, not evidence.
9983
9841
  *
9984
- * Since D287 the boot load carries no `paths`, and `paths` is the authority
9985
- * this repair derives from — so the stale rows are MATERIALISED first, at
9986
- * most {@link REPAIR_MAX_MATERIALIZE} per boot. That bound is the point: an
9987
- * unbounded materialise here would be the whole-archive read this stage
9988
- * removed, wearing a different name.
9842
+ * It exists because {@link adoptWalkedHour} deliberately refuses an EMPTY
9843
+ * listing — "the walk found nothing" is what an unmounted root looks like —
9844
+ * so the one repair a listing cannot express is the one that empties an hour.
9989
9845
  */
9990
- async repairAggregates() {
9991
- let repaired = 0;
9992
- try {
9993
- const stale = await this.store.query.query({
9994
- collection: RECORDING_SEGMENT_HOURS_COLLECTION,
9995
- columns: REPAIR_PROBE_COLUMNS,
9996
- filter: {
9997
- where: { segments: 0 },
9998
- limit: REPAIR_MAX_ROWS
9999
- }
10000
- });
10001
- const pending = stale.map((record) => record.id).filter((key) => this.ledger.get(key)?.pathsResident === false);
10002
- 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: {
10003
- pending: pending.length,
10004
- perBoot: REPAIR_MAX_MATERIALIZE
10005
- } });
10006
- await this.materialize(pending.slice(0, REPAIR_MAX_MATERIALIZE));
10007
- for (const record of stale) {
10008
- const held = this.ledger.get(record.id);
10009
- if (held === void 0 || !held.pathsResident || held.segments === 0) continue;
10010
- await this.ledger.put(held);
10011
- repaired += 1;
10012
- }
10013
- } catch (err) {
10014
- this.logger.warn("recorder: segment-hour aggregate repair failed — disks under-report", { meta: {
10015
- collection: RECORDING_SEGMENT_HOURS_COLLECTION,
10016
- error: String(err)
10017
- } });
10018
- return repaired;
9846
+ async forgetHour(deviceId, profile, locationId, hour) {
9847
+ const key = segmentHourKey(deviceId, profile, locationId, hour);
9848
+ return this.serialized(key, async () => {
9849
+ const held = this.ledger.get(key);
9850
+ if (held === void 0) return 0;
9851
+ await this.ledger.forget(key);
9852
+ this.accounted.delete(key);
9853
+ return held.segments;
9854
+ });
9855
+ }
9856
+ async forgetScope(scope) {
9857
+ let forgotten = 0;
9858
+ for (const row of this.ledger.snapshot()) {
9859
+ if (!SegmentHourLedger.inScope(row, scope)) continue;
9860
+ await this.ledger.forget(row.key);
9861
+ forgotten += row.segments;
10019
9862
  }
10020
- if (repaired > 0) this.logger.info("recorder: segment-hour aggregate columns backfilled", { meta: {
10021
- rows: repaired,
10022
- collection: RECORDING_SEGMENT_HOURS_COLLECTION
10023
- } });
10024
- return repaired;
9863
+ return forgotten;
10025
9864
  }
10026
9865
  /**
10027
9866
  * Write-behind: a just-finalized segment joins its hour row.
@@ -10029,8 +9868,8 @@ var SegmentHourLedger = class SegmentHourLedger {
10029
9868
  * THE hot path — every profile-writer lands here every `segmentSeconds`,
10030
9869
  * ~3.4 times a second fleet-wide. The append is {@link appendSegmentPath}:
10031
9870
  * one `parseSegmentPath` and four scalar updates, whatever the hour already
10032
- * holds. It used to re-derive the whole hour, which made one hour
10033
- * O(N²) — 65 k parses at 360 segments, 1.46 M in a restart-storm hour.
9871
+ * holds — and since D523 it needs no read of the store at all, because the
9872
+ * mirror carries the whole row.
10034
9873
  */
10035
9874
  async recordSegment(row) {
10036
9875
  const { key, hourStartMs: hour } = hourOf(row);
@@ -10039,10 +9878,16 @@ var SegmentHourLedger = class SegmentHourLedger {
10039
9878
  deviceId: row.deviceId,
10040
9879
  profile: row.profile,
10041
9880
  locationId: row.locationId,
10042
- hourStartMs: hour
9881
+ hourStartMs: hour,
9882
+ entryMs: row.startMs
10043
9883
  });
10044
9884
  await this.serialized(key, async () => {
10045
- if (!this.ledger.has(key)) {
9885
+ const seen = this.accounted.get(key);
9886
+ if (seen !== void 0 && seen.has(row.path)) return;
9887
+ if (seen === void 0) this.accounted.set(key, new Set([row.path]));
9888
+ else seen.add(row.path);
9889
+ const held = this.ledger.get(key);
9890
+ if (held === void 0) {
10046
9891
  await this.ledger.put(segmentHourRow({
10047
9892
  key,
10048
9893
  deviceId: row.deviceId,
@@ -10053,25 +9898,19 @@ var SegmentHourLedger = class SegmentHourLedger {
10053
9898
  }));
10054
9899
  return;
10055
9900
  }
10056
- const held = await this.resident(key, row.deviceId);
10057
- if (held === null) {
10058
- this.refused.add(key);
10059
- this.logger.warn("recorder: hour detail unavailable — this finalized segment is NOT in the durable hour row yet", {
10060
- tags: { deviceId: row.deviceId },
10061
- meta: {
10062
- key,
10063
- path: row.path
10064
- }
10065
- });
10066
- return;
10067
- }
10068
- if (held.paths.includes(row.path)) return;
10069
9901
  await this.ledger.put(appendSegmentPath(held, row.path));
10070
9902
  });
10071
9903
  }
10072
9904
  /**
10073
- * Eviction confirmed these paths are gone from disk. Drop them; forget the
10074
- * hour when it holds nothing.
9905
+ * Eviction confirmed these segments are gone from disk. Take them out of the
9906
+ * hour's account; forget the hour when nothing is left.
9907
+ *
9908
+ * The aggregate shrinks by exactly what was removed. `minStartMs` /
9909
+ * `maxStartMs` are NOT re-derived — they cannot be, without a listing — so a
9910
+ * partly-evicted hour keeps extremes that may be older or newer than what
9911
+ * survives. That error is bounded by the hour itself (both values always lie
9912
+ * inside `[hourStartMs, hourStartMs + 1h)`), and the row is UNSEALED so the
9913
+ * next listing of it re-derives both exactly.
10075
9914
  */
10076
9915
  async dropSegments(rows) {
10077
9916
  const doomed = /* @__PURE__ */ new Map();
@@ -10081,70 +9920,50 @@ var SegmentHourLedger = class SegmentHourLedger {
10081
9920
  if (entry === void 0) {
10082
9921
  doomed.set(key, {
10083
9922
  deviceId: row.deviceId,
10084
- paths: new Set([row.path])
9923
+ bytes: row.bytes,
9924
+ segments: 1
10085
9925
  });
10086
9926
  continue;
10087
9927
  }
10088
- entry.paths.add(row.path);
10089
- }
10090
- for (const [key, { deviceId, paths }] of doomed) {
10091
- if (!this.ledger.has(key)) continue;
10092
- const held = await this.resident(key, deviceId);
10093
- if (held === null) {
10094
- 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", {
10095
- tags: { deviceId },
10096
- meta: {
10097
- key,
10098
- paths: paths.size
10099
- }
10100
- });
10101
- continue;
10102
- }
10103
- const next = held.paths.filter((p) => !paths.has(p));
10104
- if (next.length === 0) {
9928
+ entry.bytes += row.bytes;
9929
+ entry.segments += 1;
9930
+ }
9931
+ for (const [key, dropped] of doomed) await this.serialized(key, async () => {
9932
+ const held = this.ledger.get(key);
9933
+ if (held === void 0) return;
9934
+ const segments = held.segments - dropped.segments;
9935
+ if (segments <= 0) {
10105
9936
  await this.ledger.forget(key);
10106
- continue;
9937
+ this.accounted.delete(key);
9938
+ return;
10107
9939
  }
10108
- if (next.length === held.paths.length) continue;
10109
- await this.ledger.put(segmentHourRow({
9940
+ await this.ledger.put({
10110
9941
  ...held,
10111
- paths: next
10112
- }));
10113
- }
9942
+ sealed: false,
9943
+ segments,
9944
+ bytes: Math.max(0, held.bytes - dropped.bytes)
9945
+ });
9946
+ });
10114
9947
  }
10115
9948
  /**
10116
- * Move `rows` from the hour rows they are listed in today to the SAME hours
9949
+ * Move `rows` from the hour they are accounted under today to the SAME hour
10117
9950
  * at `toLocationId` — the DURABLE half of a relocation.
10118
9951
  *
10119
- * Without this the mover re-points only `RecordingIndex`, which is RAM. Since
10120
- * D287 a closed hour is hydrated from the ledger FIRST and the disk is not
10121
- * consulted at all when the ledger covered the window, so a relocated hour
10122
- * comes back at its OLD location on the next boot — paths under a root that
10123
- * no longer holds the file. Playback does not stat (that syscall per segment
10124
- * was removed deliberately), so the result is a silent black window; and once
10125
- * the drained location is deleted, `collectRangeSegments` drops the rows
10126
- * outright and the footage is invisible. That is the shape of the 2 131
10127
- * `recordings:high` ghost segments already on this hub.
9952
+ * Without this the mover re-points only `RecordingIndex`, which is RAM, and
9953
+ * the drained location keeps the bytes on every storage page for the life of
9954
+ * the hub. That is the shape of the 2 131 `recordings:high` ghost rows this
9955
+ * hub carried.
10128
9956
  *
10129
9957
  * **The ordering is the invariant, not an implementation detail.** The
10130
9958
  * destination row is written BEFORE the source row is shrunk, and the caller
10131
9959
  * has not yet deleted the source bytes — so the only state a crash can leave
10132
- * is "listed at BOTH locations, both of which hold the file", which resolves
10133
- * correctly whichever row hydrate happens to apply last. The reverse order
10134
- * has a window in which the path is in NO row, and a ledger-known hour marks
10135
- * the index `hydrated`: nothing would ever walk that hour again, and the
10136
- * segment would sit on disk permanently unreachable.
9960
+ * is "accounted at BOTH locations, both of which hold the files", which a
9961
+ * listing of either hour resolves. The reverse order has a window in which
9962
+ * the segments are accounted NOWHERE.
10137
9963
  *
10138
- * **Two path sets, not one.** A path can arrive at the target under a
10139
- * DIFFERENT name than it leaves the source under: a mover that found a file
10140
- * larger than its recorded size reconciles the row, and the byte count is
10141
- * part of the name. So the source row is shrunk by what is LEAVING it and the
10142
- * target row grows by what is ARRIVING — conflating them would leave the
10143
- * source row naming a file the caller is about to delete.
10144
- *
10145
- * Returns `false` when NOTHING was repointed and the caller must delete
10146
- * nothing. An hour whose detail could not be read decides nothing (D49) —
10147
- * the same refusal {@link dropSegments} makes for eviction.
9964
+ * Both rows come out UNSEALED: each carries an aggregate that arithmetic
9965
+ * moved rather than a listing derived, and the next walk of either hour fixes
9966
+ * whatever the arithmetic rounded.
10148
9967
  */
10149
9968
  async repointSegments(rows, toLocationId) {
10150
9969
  const byHour = /* @__PURE__ */ new Map();
@@ -10152,87 +9971,75 @@ var SegmentHourLedger = class SegmentHourLedger {
10152
9971
  if (row.locationId === toLocationId) continue;
10153
9972
  const { key } = hourOf(row);
10154
9973
  const entry = byHour.get(key);
10155
- if (entry === void 0) byHour.set(key, {
10156
- row,
10157
- leaving: new Set([row.fromPath ?? row.path]),
10158
- arriving: new Set([row.path])
10159
- });
10160
- else {
10161
- entry.leaving.add(row.fromPath ?? row.path);
10162
- entry.arriving.add(row.path);
10163
- }
10164
- }
10165
- for (const [sourceKey, { row, leaving, arriving }] of byHour) {
10166
- const held = await this.resident(sourceKey, row.deviceId);
10167
- if (held === null) {
10168
- this.logger.warn("recorder: hour detail unavailable — this relocation is REFUSED and nothing is deleted; the segments stay where they are", {
10169
- tags: { deviceId: row.deviceId },
10170
- meta: {
10171
- key: sourceKey,
10172
- toLocationId,
10173
- paths: leaving.size
10174
- }
10175
- });
10176
- return false;
10177
- }
10178
- const targetKey = segmentHourKey(row.deviceId, row.profile, toLocationId, held.hourStartMs);
10179
- const target = this.ledger.has(targetKey) ? await this.resident(targetKey, row.deviceId) : void 0;
10180
- if (target === null) {
10181
- this.logger.warn("recorder: TARGET hour detail unavailable — this relocation is REFUSED; appending to a summary would rewrite the hour as these paths alone", {
10182
- tags: { deviceId: row.deviceId },
10183
- meta: {
10184
- key: targetKey,
10185
- fromLocationId: row.locationId,
10186
- paths: arriving.size
10187
- }
9974
+ if (entry === void 0) {
9975
+ byHour.set(key, {
9976
+ row,
9977
+ bytes: row.bytes,
9978
+ segments: 1,
9979
+ minStartMs: row.startMs,
9980
+ maxStartMs: row.startMs
10188
9981
  });
10189
- return false;
9982
+ continue;
10190
9983
  }
10191
- const merged = uniquePaths([...target?.paths ?? [], ...arriving]);
10192
- await this.ledger.put(segmentHourRow({
9984
+ entry.bytes += row.bytes;
9985
+ entry.segments += 1;
9986
+ entry.minStartMs = Math.min(entry.minStartMs, row.startMs);
9987
+ entry.maxStartMs = Math.max(entry.maxStartMs, row.startMs);
9988
+ }
9989
+ for (const [sourceKey, moved] of byHour) {
9990
+ const hour = hourStartMs(moved.row.startMs);
9991
+ const targetKey = segmentHourKey(moved.row.deviceId, moved.row.profile, toLocationId, hour);
9992
+ const target = this.ledger.get(targetKey);
9993
+ const targetIdentity = {
10193
9994
  key: targetKey,
10194
- deviceId: row.deviceId,
10195
- profile: row.profile,
9995
+ deviceId: moved.row.deviceId,
9996
+ profile: moved.row.profile,
10196
9997
  locationId: toLocationId,
10197
- hourStartMs: held.hourStartMs,
10198
- paths: merged
9998
+ hourStartMs: hour
9999
+ };
10000
+ const targetEmpty = target === void 0 || target.segments === 0;
10001
+ await this.ledger.put(hourRowOf(targetIdentity, {
10002
+ sealed: false,
10003
+ bytes: (target?.bytes ?? 0) + moved.bytes,
10004
+ segments: (target?.segments ?? 0) + moved.segments,
10005
+ minStartMs: targetEmpty ? moved.minStartMs : Math.min(target.minStartMs, moved.minStartMs),
10006
+ maxStartMs: targetEmpty ? moved.maxStartMs : Math.max(target.maxStartMs, moved.maxStartMs)
10199
10007
  }));
10200
- const next = held.paths.filter((p) => !leaving.has(p));
10201
- if (next.length === 0) await this.ledger.forget(sourceKey);
10202
- else await this.ledger.put(segmentHourRow({
10008
+ const held = this.ledger.get(sourceKey);
10009
+ if (held === void 0) continue;
10010
+ const segments = held.segments - moved.segments;
10011
+ if (segments <= 0) {
10012
+ await this.ledger.forget(sourceKey);
10013
+ continue;
10014
+ }
10015
+ await this.ledger.put({
10203
10016
  ...held,
10204
- paths: next
10205
- }));
10017
+ sealed: false,
10018
+ segments,
10019
+ bytes: Math.max(0, held.bytes - moved.bytes)
10020
+ });
10206
10021
  }
10207
- return true;
10208
10022
  }
10209
10023
  /**
10210
- * After a disk walk of `deviceIds`, make the ledger match the index — with
10211
- * two D148 exceptions:
10024
+ * After a disk walk of `deviceIds`, make the ledger match what the index now
10025
+ * holds — with two D148 exceptions:
10212
10026
  *
10213
10027
  * - an hour the walk did not see is dropped ONLY if it is in the past;
10214
10028
  * the current hour may still hold a live writer whose file the readdir
10215
10029
  * raced;
10216
- * - the current hour UNIONs ledger paths with index paths rather than
10217
- * replacing, for the same race.
10030
+ * - the current hour takes the LARGER of the two accounts rather than the
10031
+ * walk's, for the same race.
10218
10032
  *
10219
10033
  * Devices not in `deviceIds` are left untouched (a partial walk must not
10220
10034
  * prune cameras it did not look at).
10221
10035
  *
10222
10036
  * And a walk this ledger does not TRUST prunes nothing at all — see
10223
- * {@link refusedPrunes}. What such a walk FOUND is still real, so its paths
10224
- * are unioned in; what it did not find decides nothing (D49).
10225
- *
10226
- * Since D287 the walked devices' hours are MATERIALISED first: every
10227
- * comparison below reads `row.paths`, and a summary's empty `paths` would
10228
- * read as "the ledger holds nothing here" — which is the union collapsing to
10229
- * the walk's own view, i.e. the prune the refusal machinery exists to
10230
- * prevent. An hour whose detail could not be read is left additive-only.
10037
+ * {@link refusedPrunes}. What such a walk FOUND is still real, so its larger
10038
+ * account is taken; what it did not find decides nothing (D49).
10231
10039
  */
10232
10040
  async reconcileFromIndex(index, deviceIds, nowMs) {
10233
10041
  const walked = new Set(deviceIds);
10234
10042
  const currentHour = hourStartMs(nowMs);
10235
- await this.materializeDevices(walked);
10236
10043
  const fromIndex = /* @__PURE__ */ new Map();
10237
10044
  for (const deviceId of deviceIds) for (const seg of index.segments(deviceId)) {
10238
10045
  const { key, hourStartMs: hour } = hourOf(seg);
@@ -10251,7 +10058,6 @@ var SegmentHourLedger = class SegmentHourLedger {
10251
10058
  const refused = this.refusedPrunes(walked, fromIndex);
10252
10059
  for (const row of this.ledger.snapshot()) {
10253
10060
  if (!walked.has(row.deviceId)) continue;
10254
- if (!row.pathsResident) continue;
10255
10061
  const disk = fromIndex.get(row.key);
10256
10062
  const additiveOnly = row.hourStartMs === currentHour || refused.devices.has(row.deviceId) || refused.locations.has(locationKey(row.deviceId, row.locationId));
10257
10063
  if (disk === void 0) {
@@ -10260,14 +10066,10 @@ var SegmentHourLedger = class SegmentHourLedger {
10260
10066
  continue;
10261
10067
  }
10262
10068
  if (additiveOnly) {
10263
- const union = uniquePaths([...disk.paths, ...row.paths]);
10264
- if (!samePaths(union, row.paths)) await this.ledger.put(segmentHourRow({
10265
- ...row,
10266
- paths: union
10267
- }));
10069
+ if (disk.segments > row.segments) await this.ledger.put(disk);
10268
10070
  continue;
10269
10071
  }
10270
- if (!samePaths(row.paths, disk.paths)) await this.ledger.put(disk);
10072
+ if (disk.segments !== row.segments || disk.bytes !== row.bytes) await this.ledger.put(disk);
10271
10073
  }
10272
10074
  for (const [key, disk] of fromIndex) {
10273
10075
  if (this.ledger.has(key)) continue;
@@ -10386,46 +10188,47 @@ function locationKey(deviceId, locationId) {
10386
10188
  * skips it forever. Under D148 as written, a ghost row can never be forgotten
10387
10189
  * by any mechanism — which is why the live ones have stood for thirteen days.
10388
10190
  *
10389
- * The resolution is that a walk is not the only trusted confirmation; it is a
10390
- * BULK one. The paths in an hour row are relative to a root
10391
- * (`590/high/2026/08/15/04/…m4s`), so each one can be resolved against every
10392
- * currently-resolvable root and asked about directly. **Per-path
10393
- * `storage.exists`, over every candidate root, for EVERY path of the hour, is
10394
- * the same confirmation a walk gives — done one syscall at a time instead of
10395
- * one `readdir` at a time.** That, and nothing weaker, is what licenses the
10191
+ * The resolution is that the ghost row's root does not resolve but its hour
10192
+ * DIRECTORY is addressable anywhere: the path convention
10193
+ * (`590/high/2026/08/15/04/`) is a function of the row's own fields, so the same
10194
+ * hour can be listed under every currently-resolvable root. **A listing of the
10195
+ * hour directory at every candidate root is exactly the walk D148 sanctions,
10196
+ * done one root at a time.** That, and nothing weaker, is what licenses the
10396
10197
  * forget. Two consequences ride with it:
10397
10198
  *
10398
- * - **Sampling is not evidence.** An hour is forgotten only when every one of
10399
- * its paths came back absent from every root. One unprobed path and the hour
10400
- * stands.
10401
- * - **An error is not an absence** (D49). A probe that THROWS ends the hour
10402
- * with nothing decided — never "not found".
10199
+ * - **Every root must answer.** An hour is forgotten only when every candidate
10200
+ * root listed its directory and every one of them came back EMPTY. One root
10201
+ * that could not answer and the hour stands.
10202
+ * - **An error is not an absence** (D49). A listing that fails, or that reports
10203
+ * "I could not decide", ends the hour with nothing decided — never "not
10204
+ * found". Whether an absent directory means "empty" or "this disk is gone" is
10205
+ * the lister's question, and it answers it by reading the ROOT.
10206
+ *
10207
+ * Until D523 this probed each of the row's `paths` at each root, one
10208
+ * `storage.exists` at a time — up to ~1 000 syscalls to prove one hour absent.
10209
+ * The column is gone and the row names no file, so the unit is the directory,
10210
+ * which is both cheaper and the same evidence.
10403
10211
  *
10404
10212
  * ## Repoint versus forget
10405
10213
  *
10406
10214
  * The asymmetry is the whole safety argument, and it is deliberately cheap in
10407
10215
  * the safe direction:
10408
10216
  *
10409
- * - **one path found anywhere ⇒ the row is MIS-POINTED, not orphaned.** It is
10410
- * repointed to that root's location and the hour is finished — no further
10411
- * probing. A renamed or re-created location produces exactly this shape, and
10412
- * deleting here would destroy the index of footage that EXISTS, which is the
10413
- * failure D291 was written to prevent.
10414
- * - **every path absent everywhere ⇒ orphaned**, and only then forgotten.
10415
- *
10416
- * So proving presence costs one probe and proving absence costs all of them,
10417
- * which is the right way round: the expensive proof is the destructive one.
10217
+ * - **a non-empty listing at any root ⇒ the row is MIS-POINTED, not orphaned.**
10218
+ * It is repointed to that root's location, with the segments that listing
10219
+ * names, and the hour is finished. A renamed or re-created location produces
10220
+ * exactly this shape, and deleting here would destroy the index of footage
10221
+ * that EXISTS, which is the failure D291 was written to prevent.
10222
+ * - **every root lists an EMPTY hour ⇒ orphaned**, and only then forgotten.
10418
10223
  *
10419
10224
  * ## Pacing
10420
10225
  *
10421
- * The recordings array is saturated and slow (a `readdir` of one hour
10422
- * directory measured ~650 ms under load, and a `find` over it hung an SSH
10423
- * session on 2026-08-29). This is a janitor: bounded hours per pass, a hard
10424
- * probe ceiling, and a caller-supplied `pace()` awaited between probe batches.
10425
- * Work it does not finish is left for the next pass and REPORTED, never
10426
- * silently dropped.
10226
+ * The recordings array is the resource every live read competes for. This is a
10227
+ * janitor: bounded hours per pass, a hard probe ceiling, and a caller-supplied
10228
+ * `pace()` awaited between probe batches. Work it does not finish is left for
10229
+ * the next pass and REPORTED, never silently dropped.
10427
10230
  */
10428
- var HOUR_MS$2 = 36e5;
10231
+ var HOUR_MS$3 = 36e5;
10429
10232
  /**
10430
10233
  * Group resolved locations by PHYSICAL root, so two aliases of one disk are
10431
10234
  * probed once and a `true` from each is not mistaken for the file existing in
@@ -10534,7 +10337,7 @@ var GhostLocationSweeper = class {
10534
10337
  refused: null
10535
10338
  };
10536
10339
  }
10537
- const liveTailFrom = this.deps.now() - HOUR_MS$2;
10340
+ const liveTailFrom = this.deps.now() - HOUR_MS$3;
10538
10341
  let examined = 0;
10539
10342
  let probes = 0;
10540
10343
  let repointedHours = 0;
@@ -10572,22 +10375,12 @@ var GhostLocationSweeper = class {
10572
10375
  refuse(row, "live-tail", `hourStartMs=${row.hourStartMs}`);
10573
10376
  continue;
10574
10377
  }
10575
- const paths = await this.deps.ledger.hourPaths(row.deviceId, row.profile, row.locationId, row.hourStartMs);
10576
- if (paths === null) {
10577
- refuse(row, "detail-unavailable", "point read failed");
10578
- continue;
10579
- }
10580
- if (paths.length === 0) {
10581
- refuse(row, "no-paths", "the row materialised empty");
10582
- continue;
10583
- }
10584
- const rows = this.rowsOf(row, paths);
10585
- if (rows === null) {
10586
- refuse(row, "unparseable-paths", "a path does not parse into this hour");
10378
+ if (row.segments === 0) {
10379
+ refuse(row, "no-paths", "the row accounts for no segments");
10587
10380
  continue;
10588
10381
  }
10589
10382
  const before = probes;
10590
- const verdict = await this.probeHour(rows, roots, maxProbes - probes);
10383
+ const verdict = await this.probeHour(row, roots, maxProbes - probes, probes);
10591
10384
  probes += verdict.probes;
10592
10385
  tallyOf(row.deviceId).probes += probes - before;
10593
10386
  if (verdict.outcome.kind === "refused") {
@@ -10596,10 +10389,12 @@ var GhostLocationSweeper = class {
10596
10389
  }
10597
10390
  if (verdict.outcome.kind === "found") {
10598
10391
  const target = locationForHydratedProfile(verdict.outcome.root.aliases, row.profile);
10599
- if (!await this.deps.ledger.repointSegments(rows, target.id)) {
10600
- refuse(row, "durable-refused", `repoint to ${target.id} refused`);
10392
+ const rows = this.rowsOf(row, verdict.outcome.paths);
10393
+ if (rows === null) {
10394
+ refuse(row, "unparseable-paths", "a listed name does not parse into this hour");
10601
10395
  continue;
10602
10396
  }
10397
+ await this.deps.ledger.repointSegments(rows, target.id);
10603
10398
  this.deps.applyToIndex(rows, target.id);
10604
10399
  repointedHours += 1;
10605
10400
  segmentsRepointed += rows.length;
@@ -10609,19 +10404,19 @@ var GhostLocationSweeper = class {
10609
10404
  tally.targets.add(target.id);
10610
10405
  continue;
10611
10406
  }
10612
- await this.deps.ledger.dropSegments(rows);
10407
+ const forgotten = await this.deps.ledger.forgetHour(row.deviceId, row.profile, row.locationId, row.hourStartMs);
10613
10408
  if (this.deps.ledger.knowsHour(row.deviceId, row.profile, row.locationId, row.hourStartMs)) {
10614
- refuse(row, "durable-refused", "the ledger still holds the hour after the drop");
10409
+ refuse(row, "durable-refused", "the ledger still holds the hour after the forget");
10615
10410
  continue;
10616
10411
  }
10617
- this.deps.applyToIndex(rows, null);
10412
+ this.deps.forgetInIndex(row.deviceId, row.locationId, row.hourStartMs);
10618
10413
  forgottenHours += 1;
10619
- segmentsForgotten += rows.length;
10620
- bytesForgotten += rows.reduce((sum, r) => sum + r.bytes, 0);
10414
+ segmentsForgotten += forgotten;
10415
+ bytesForgotten += row.bytes;
10621
10416
  const tally = tallyOf(row.deviceId);
10622
10417
  tally.forgottenHours += 1;
10623
- tally.forgottenSegments += rows.length;
10624
- tally.forgottenBytes += rows.reduce((sum, r) => sum + r.bytes, 0);
10418
+ tally.forgottenSegments += forgotten;
10419
+ tally.forgottenBytes += row.bytes;
10625
10420
  }
10626
10421
  for (const [deviceId, tally] of byDevice) this.deps.logger.info("recorder: ghost-location reconcile — camera summary", {
10627
10422
  tags: { deviceId },
@@ -10703,60 +10498,82 @@ var GhostLocationSweeper = class {
10703
10498
  return out;
10704
10499
  }
10705
10500
  /**
10706
- * Ask every root about the hour's paths until one answers `true`.
10501
+ * List this hour's directory under EVERY candidate root.
10502
+ *
10503
+ * A non-empty listing at one root makes the hour mis-pointed. Two roots
10504
+ * holding it is a refusal, not a choice: repointing would be a guess about
10505
+ * which copy is the footage. Absence is only concluded when every root
10506
+ * answered and every answer was empty — a listing that could not decide
10507
+ * (`null`) or threw ends the hour with nothing decided (D49).
10508
+ *
10509
+ * The whole loop runs even after a hit, because "found at exactly one root"
10510
+ * is a claim about every root and cannot be short-circuited without giving up
10511
+ * the multi-root refusal.
10707
10512
  *
10708
- * Presence short-circuits the hour (one confirmed file makes it mis-pointed);
10709
- * absence does not, and cannot — the hour is orphaned only when EVERY path
10710
- * came back absent from EVERY root. A throw ends the hour with nothing
10711
- * decided.
10513
+ * `spent` is the listings the PASS has already made, not this hour's, and the
10514
+ * brake fires on that running total. Since D523 an hour costs one listing per
10515
+ * physical root — two or three on this hub — so a counter that reset per hour
10516
+ * would never reach {@link GHOST_PROBE_BATCH} and the `pace()` dep would be
10517
+ * dead on every real topology.
10712
10518
  */
10713
- async probeHour(rows, roots, budget) {
10519
+ async probeHour(row, roots, budget, spent) {
10714
10520
  let used = 0;
10715
- for (const row of rows) {
10716
- const found = [];
10717
- for (const root of roots) {
10718
- if (used >= budget) return {
10521
+ const found = [];
10522
+ for (const root of roots) {
10523
+ if (used >= budget) return {
10524
+ outcome: {
10525
+ kind: "refused",
10526
+ reason: "probe-budget",
10527
+ detail: `stopped after ${used} listings with ${roots.length} roots to probe`
10528
+ },
10529
+ probes: used
10530
+ };
10531
+ const alias = locationForHydratedProfile(root.aliases, row.profile);
10532
+ used += 1;
10533
+ if ((spent + used) % 16 === 0) await this.deps.pace();
10534
+ let listing;
10535
+ try {
10536
+ listing = await this.deps.listHour(alias.id, row.deviceId, row.profile, row.hourStartMs);
10537
+ } catch (err) {
10538
+ return {
10719
10539
  outcome: {
10720
10540
  kind: "refused",
10721
- reason: "probe-budget",
10722
- detail: `stopped after ${used} probes with ${rows.length} paths in the hour`
10541
+ reason: "probe-error",
10542
+ detail: `${alias.id}: ${require_dist.errMsg(err)}`
10723
10543
  },
10724
10544
  probes: used
10725
10545
  };
10726
- const alias = locationForHydratedProfile(root.aliases, row.profile);
10727
- used += 1;
10728
- if (used % 16 === 0) await this.deps.pace();
10729
- try {
10730
- if (await this.deps.exists(alias.id, row.path)) found.push(root);
10731
- } catch (err) {
10732
- return {
10733
- outcome: {
10734
- kind: "refused",
10735
- reason: "probe-error",
10736
- detail: `${alias.id}: ${require_dist.errMsg(err)}`
10737
- },
10738
- probes: used
10739
- };
10740
- }
10741
10546
  }
10742
- if (found.length > 1) return {
10547
+ if (listing === null) return {
10743
10548
  outcome: {
10744
10549
  kind: "refused",
10745
- reason: "found-at-multiple-roots",
10746
- detail: found.map((root) => root.key).join(", ")
10747
- },
10748
- probes: used
10749
- };
10750
- const only = found[0];
10751
- if (only !== void 0) return {
10752
- outcome: {
10753
- kind: "found",
10754
- root: only,
10755
- path: row.path
10550
+ reason: "detail-unavailable",
10551
+ detail: `${alias.id} could not say what its copy of this hour holds`
10756
10552
  },
10757
10553
  probes: used
10758
10554
  };
10555
+ if (listing.length > 0) found.push({
10556
+ root,
10557
+ paths: listing
10558
+ });
10759
10559
  }
10560
+ if (found.length > 1) return {
10561
+ outcome: {
10562
+ kind: "refused",
10563
+ reason: "found-at-multiple-roots",
10564
+ detail: found.map((hit) => hit.root.key).join(", ")
10565
+ },
10566
+ probes: used
10567
+ };
10568
+ const only = found[0];
10569
+ if (only !== void 0) return {
10570
+ outcome: {
10571
+ kind: "found",
10572
+ root: only.root,
10573
+ paths: only.paths
10574
+ },
10575
+ probes: used
10576
+ };
10760
10577
  return {
10761
10578
  outcome: { kind: "absent" },
10762
10579
  probes: used
@@ -11116,7 +10933,7 @@ function createRecordingConfigMirror(deps) {
11116
10933
  }
11117
10934
  //#endregion
11118
10935
  //#region src/recorder/addon/ledger-walk-reconcile.ts
11119
- var HOUR_MS$1 = 36e5;
10936
+ var HOUR_MS$2 = 36e5;
11120
10937
  /** Every reason at zero, so a reason that never fired reports as zero rather
11121
10938
  * than absent and the summary shape is constant between passes. */
11122
10939
  function zeroReasons() {
@@ -11137,34 +10954,12 @@ function emptyTally() {
11137
10954
  orphanFiles: 0
11138
10955
  };
11139
10956
  }
11140
- /** The basename of a segment path — what a listing returns. */
11141
- function fileName(relPath) {
11142
- const cut = relPath.lastIndexOf("/");
11143
- return cut === -1 ? relPath : relPath.slice(cut + 1);
11144
- }
11145
- /** Group one archive page into whole hours. A page always carries complete hour
11146
- * rows (`streamSegments` expands them whole), so no hour is ever split. */
11147
- function claimsOf(rows) {
11148
- const byHour = /* @__PURE__ */ new Map();
11149
- for (const row of rows) {
11150
- const hour = hourStartMs(row.startMs);
11151
- const key = `${row.deviceId}|${row.profile}|${hour}`;
11152
- const held = byHour.get(key);
11153
- if (held) held.push(row);
11154
- else byHour.set(key, [row]);
11155
- }
11156
- const out = [];
11157
- for (const group of byHour.values()) {
11158
- const first = group[0];
11159
- if (first === void 0) continue;
11160
- out.push({
11161
- deviceId: first.deviceId,
11162
- profile: first.profile,
11163
- hourStartMs: hourStartMs(first.startMs),
11164
- rows: group
11165
- });
11166
- }
11167
- return out.toSorted((a, b) => a.hourStartMs - b.hourStartMs || a.deviceId - b.deviceId);
10957
+ /** Does this hour row fall inside the scope the operator asked for? */
10958
+ function inScope(row, scope) {
10959
+ if (scope.locationId !== void 0 && row.locationId !== scope.locationId) return false;
10960
+ if (scope.deviceId !== void 0 && row.deviceId !== scope.deviceId) return false;
10961
+ const profiles = scope.profiles ?? [];
10962
+ return profiles.length === 0 || profiles.includes(row.profile);
11168
10963
  }
11169
10964
  /**
11170
10965
  * One reconcile pass over one location's claimed hours.
@@ -11212,7 +11007,7 @@ var LedgerWalkReconciler = class {
11212
11007
  ledger,
11213
11008
  apply,
11214
11009
  anchor: anchor.volumeId,
11215
- liveTailFrom: hourStartMs(this.deps.now()) - 1 * HOUR_MS$1,
11010
+ liveTailFrom: hourStartMs(this.deps.now()) - 1 * HOUR_MS$2,
11216
11011
  maxHours: this.deps.maxHours ?? 4096,
11217
11012
  byDevice: /* @__PURE__ */ new Map(),
11218
11013
  skippedByReason: zeroReasons(),
@@ -11262,20 +11057,35 @@ var LedgerWalkReconciler = class {
11262
11057
  this.reportPass(report);
11263
11058
  return report;
11264
11059
  }
11265
- /** Walk the archive page by page, one hour at a time. */
11060
+ /**
11061
+ * Walk the hour ROWS of the scope, oldest first.
11062
+ *
11063
+ * From the MIRROR, not a query. Since D523 a row is ten scalars, so the whole
11064
+ * claim set is already in RAM and costs nothing to fold — and the pass mutates
11065
+ * what it walks, which is exactly the shape that makes `offset` paging skip
11066
+ * half a scope. A snapshot taken once cannot skip.
11067
+ */
11266
11068
  async walk(state, scope) {
11267
- for await (const page of state.ledger.streamSegments(scope)) for (const claim of claimsOf(page)) {
11069
+ const claims = state.ledger.snapshot().filter((row) => inScope(row, scope)).toSorted((a, b) => a.hourStartMs - b.hourStartMs || a.deviceId - b.deviceId);
11070
+ for (const claim of claims) {
11268
11071
  if (state.hoursClaimed >= state.maxHours) {
11269
11072
  state.bounded = true;
11270
11073
  return;
11271
11074
  }
11272
11075
  state.hoursClaimed += 1;
11273
- state.segmentsClaimed += claim.rows.length;
11076
+ state.segmentsClaimed += claim.segments;
11274
11077
  if (!await this.reconcileHour(state, claim)) return;
11275
11078
  }
11276
11079
  }
11277
11080
  /**
11278
- * Diff ONE hour, and forget what it proved absent.
11081
+ * Reconcile ONE hour: what its row ACCOUNTS FOR against what its directory
11082
+ * HOLDS, and repair the account from the directory.
11083
+ *
11084
+ * Since D523 the row names no files, so this is a comparison of counts and
11085
+ * bytes — and it does not need to name them: the repair is "make the account
11086
+ * the listing's", which is exact, and the RESIDENT index is reset to the same
11087
+ * listing so a row it holds for a file the directory does not is dropped with
11088
+ * it.
11279
11089
  *
11280
11090
  * `false` ends the whole pass — the only thing that returns it is an anchor
11281
11091
  * that stopped agreeing, and every listing taken after that point is about a
@@ -11288,10 +11098,6 @@ var LedgerWalkReconciler = class {
11288
11098
  return true;
11289
11099
  }
11290
11100
  const relDir = hourDirRelPath(claim.deviceId, claim.profile, claim.hourStartMs);
11291
- for (const row of claim.rows) if (parseSegmentPath(row.path) === null || row.path !== `${relDir}/${fileName(row.path)}`) {
11292
- this.skip(state, claim, "path-mismatch", row.path);
11293
- return true;
11294
- }
11295
11101
  let listing;
11296
11102
  try {
11297
11103
  listing = await this.deps.listHour(state.location.root, relDir);
@@ -11303,36 +11109,55 @@ var LedgerWalkReconciler = class {
11303
11109
  if (state.listings % 8 === 0) await this.deps.pace();
11304
11110
  state.hoursWalked += 1;
11305
11111
  tally.hoursWalked += 1;
11306
- const diff = this.diff(claim, listing);
11307
- if (diff.missingDirectory) {
11112
+ const missingDirectory = listing === null;
11113
+ if (missingDirectory) {
11308
11114
  state.hoursMissing += 1;
11309
11115
  tally.hoursMissing += 1;
11310
11116
  }
11311
- if (diff.orphans.length > 0) {
11312
- state.orphanFiles += diff.orphans.length;
11313
- tally.orphanFiles += diff.orphans.length;
11314
- for (const orphan of diff.orphans) {
11117
+ const present = [];
11118
+ for (const name of listing ?? []) {
11119
+ if (!name.endsWith(".m4s")) continue;
11120
+ const relPath = `${relDir}/${name}`;
11121
+ const parsed = parseSegmentPath(relPath);
11122
+ if (parsed === null || hourStartMs(parsed.startMs) !== claim.hourStartMs) {
11123
+ this.skip(state, claim, "path-mismatch", relPath);
11124
+ return true;
11125
+ }
11126
+ present.push(relPath);
11127
+ }
11128
+ const listed = hourAggregate(present);
11129
+ const orphans = Math.max(0, listed.segments - claim.segments);
11130
+ if (orphans > 0) {
11131
+ state.orphanFiles += orphans;
11132
+ tally.orphanFiles += orphans;
11133
+ for (const path of present) {
11315
11134
  if (state.orphanSample.length >= 50) break;
11316
- state.orphanSample.push(`${relDir}/${orphan}`);
11135
+ state.orphanSample.push(path);
11317
11136
  }
11318
- this.deps.logger.warn("recorder: files under a claimed hour that the durable ledger does NOT name — reported, never deleted", {
11137
+ this.deps.logger.warn("recorder: an hour directory holds more segments than its durable row accounts for", {
11319
11138
  tags: { deviceId: claim.deviceId },
11320
11139
  meta: {
11321
11140
  locationId: state.location.id,
11322
11141
  profile: claim.profile,
11323
11142
  hourStartMs: claim.hourStartMs,
11324
- orphans: diff.orphans.length,
11325
- sample: diff.orphans.slice(0, 5)
11143
+ accounted: claim.segments,
11144
+ listed: listed.segments,
11145
+ apply: state.apply
11326
11146
  }
11327
11147
  });
11328
11148
  }
11329
- if (diff.ghosts.length === 0) return true;
11330
- const ghostBytes = diff.ghosts.reduce((sum, row) => sum + row.bytes, 0);
11331
- state.ghostSegments += diff.ghosts.length;
11149
+ const ghosts = Math.max(0, claim.segments - listed.segments);
11150
+ if (ghosts === 0) {
11151
+ if (state.apply && listed.segments > 0 && listed.bytes !== claim.bytes) await state.ledger.adoptWalkedHour(claim.deviceId, claim.profile, state.location.id, claim.hourStartMs, present);
11152
+ if (state.apply) this.resetIndex(state, claim, present);
11153
+ return true;
11154
+ }
11155
+ const ghostBytes = Math.max(0, claim.bytes - listed.bytes);
11156
+ state.ghostSegments += ghosts;
11332
11157
  state.ghostBytes += ghostBytes;
11333
- tally.ghostSegments += diff.ghosts.length;
11158
+ tally.ghostSegments += ghosts;
11334
11159
  tally.ghostBytes += ghostBytes;
11335
- if (diff.ghosts.length === claim.rows.length) state.ghostHoursWhole += 1;
11160
+ if (listed.segments === 0) state.ghostHoursWhole += 1;
11336
11161
  const anchor = await this.readAnchor(state.location);
11337
11162
  if (!anchor.ok || anchor.volumeId !== state.anchor) {
11338
11163
  state.refused = anchor.ok ? "anchor-moved" : anchor.refusal;
@@ -11348,56 +11173,52 @@ var LedgerWalkReconciler = class {
11348
11173
  });
11349
11174
  return false;
11350
11175
  }
11351
- this.deps.logger.warn("recorder: durable hour row claims segments the directory does not hold", {
11176
+ this.deps.logger.warn("recorder: durable hour row accounts for segments the directory does not hold", {
11352
11177
  tags: { deviceId: claim.deviceId },
11353
11178
  meta: {
11354
11179
  locationId: state.location.id,
11355
11180
  profile: claim.profile,
11356
11181
  hourStartMs: claim.hourStartMs,
11357
- claimed: claim.rows.length,
11358
- ghosts: diff.ghosts.length,
11182
+ accounted: claim.segments,
11183
+ listed: listed.segments,
11184
+ ghosts,
11359
11185
  ghostBytes,
11360
- wholeHour: diff.ghosts.length === claim.rows.length,
11361
- missingDirectory: diff.missingDirectory,
11186
+ wholeHour: listed.segments === 0,
11187
+ missingDirectory,
11362
11188
  apply: state.apply
11363
11189
  }
11364
11190
  });
11365
11191
  if (!state.apply) return true;
11366
- await state.ledger.dropSegments(diff.ghosts);
11367
- if (!await this.forgetLanded(state, claim, diff.ghosts)) {
11368
- this.skip(state, claim, "durable-refused", "the ledger still lists a dropped path");
11192
+ const forgotten = listed.segments === 0 ? await state.ledger.forgetHour(claim.deviceId, claim.profile, state.location.id, claim.hourStartMs) : await this.adoptLanded(state, claim, present, ghosts);
11193
+ if (forgotten === null) {
11194
+ this.skip(state, claim, "durable-refused", "the ledger still accounts for the ghosts");
11369
11195
  return true;
11370
11196
  }
11371
- this.deps.applyToIndex(diff.ghosts.map((row) => row.path));
11372
- state.forgottenSegments += diff.ghosts.length;
11197
+ this.resetIndex(state, claim, present);
11198
+ state.forgottenSegments += forgotten;
11373
11199
  state.forgottenBytes += ghostBytes;
11374
- tally.forgottenSegments += diff.ghosts.length;
11200
+ tally.forgottenSegments += forgotten;
11375
11201
  return true;
11376
11202
  }
11377
- /** Did the durable drop actually land? */
11378
- async forgetLanded(state, claim, ghosts) {
11379
- const { deviceId, profile, hourStartMs: hour } = claim;
11380
- if (!state.ledger.knowsHour(deviceId, profile, state.location.id, hour)) return true;
11381
- const held = await state.ledger.hourPaths(deviceId, profile, state.location.id, hour);
11382
- if (held === null) return false;
11383
- const remaining = new Set(held);
11384
- return ghosts.every((row) => !remaining.has(row.path));
11385
- }
11386
11203
  /**
11387
- * What the hour row claims, against what the directory holds — BOTH ways.
11204
+ * Adopt the listing, then READ THE ROW BACK.
11388
11205
  *
11389
- * A missing directory is an empty listing here, which makes every claim a
11390
- * ghost. That is only admissible because the caller re-reads the volume
11391
- * anchor before acting on it.
11206
+ * `adoptWalkedHour` refuses a listing that would erase more than half of what
11207
+ * the row accounts for — the guard against a root that answered short — and it
11208
+ * refuses silently, which a caller that counted it as done would turn into
11209
+ * telling the resident index a row is gone that the archive still holds.
11210
+ * `null` is that refusal.
11392
11211
  */
11393
- diff(claim, listing) {
11394
- const present = new Set((listing ?? []).filter((name) => name.endsWith(".m4s")));
11395
- const claimed = new Set(claim.rows.map((row) => fileName(row.path)));
11396
- return {
11397
- ghosts: claim.rows.filter((row) => !present.has(fileName(row.path))),
11398
- orphans: [...present].filter((name) => !claimed.has(name)).toSorted(),
11399
- missingDirectory: listing === null
11400
- };
11212
+ async adoptLanded(state, claim, present, ghosts) {
11213
+ await state.ledger.adoptWalkedHour(claim.deviceId, claim.profile, state.location.id, claim.hourStartMs, present);
11214
+ const after = state.ledger.hourRow(claim.deviceId, claim.profile, state.location.id, claim.hourStartMs);
11215
+ if (after === void 0) return claim.segments;
11216
+ return after.segments === claim.segments - ghosts ? ghosts : null;
11217
+ }
11218
+ /** Make the resident index's view of the hour the listing's, and say how many
11219
+ * index rows that dropped. */
11220
+ resetIndex(state, claim, present) {
11221
+ return this.deps.applyToIndex(claim.deviceId, state.location.id, claim.hourStartMs, present);
11401
11222
  }
11402
11223
  /** The volume anchor, or the refusal its absence produces. */
11403
11224
  async readAnchor(location) {
@@ -11845,6 +11666,240 @@ function pendingMovesSignature(moves) {
11845
11666
  return moves.map((m) => `${String(m.deviceId)}:${m.profile}>${m.plannedFor}`).join(",");
11846
11667
  }
11847
11668
  //#endregion
11669
+ //#region src/recorder/addon/hour-adoption-sweep.ts
11670
+ var HOUR_MS$1 = 36e5;
11671
+ /** The profile trees a camera directory is allowed to have. Everything else
11672
+ * under it (`events/`, the retired `strips/`) has no hour tree worth probing. */
11673
+ var PROFILES = [
11674
+ "high",
11675
+ "mid",
11676
+ "low"
11677
+ ];
11678
+ function isTwoDigit(name) {
11679
+ return /^\d{2}$/.test(name);
11680
+ }
11681
+ /** One directory level in a stable order. Lexicographic, not numeric: it is the
11682
+ * CURSOR's order, and zero-padded path components (`09`, `17`) already sort
11683
+ * chronologically — device ids do not, and do not need to. */
11684
+ function inCursorOrder(names) {
11685
+ return [...names].toSorted();
11686
+ }
11687
+ /**
11688
+ * One add-only pass over one location's hour directories.
11689
+ *
11690
+ * Never throws: a directory that cannot be read is counted and the pass
11691
+ * continues; a precondition failure is a named refusal and the pass does
11692
+ * nothing at all.
11693
+ */
11694
+ var HourAdoptionSweeper = class {
11695
+ deps;
11696
+ /** Where the last bounded pass stopped (`rel` of the last hour it walked).
11697
+ * In RAM: a restart re-walks from the top, which costs listings and can
11698
+ * never cost footage. */
11699
+ cursor = null;
11700
+ constructor(deps) {
11701
+ this.deps = deps;
11702
+ }
11703
+ async sweep(request) {
11704
+ const location = this.deps.locations().find((candidate) => candidate.id === request.locationId);
11705
+ if (location === void 0) return this.refusedReport(request.locationId, "location-unknown");
11706
+ if (!locationMayBeRead(location)) return this.refusedReport(location.id, "location-unreadable");
11707
+ const ledger = this.deps.ledger();
11708
+ if (ledger === null) {
11709
+ this.deps.logger.warn("recorder: hour adoption REFUSED — this node holds no durable hour ledger, so there is nothing to adopt into", { meta: { locationId: location.id } });
11710
+ return this.refusedReport(location.id, "no-ledger");
11711
+ }
11712
+ const liveTailFrom = hourStartMs(this.deps.now()) - 1 * HOUR_MS$1;
11713
+ const maxHours = this.deps.maxHours ?? 512;
11714
+ const resume = this.cursor;
11715
+ const byDevice = /* @__PURE__ */ new Map();
11716
+ let listings = 0;
11717
+ let hoursAdopted = 0;
11718
+ let hoursRefusedByLedger = 0;
11719
+ let hoursSkippedLive = 0;
11720
+ let hoursSkippedEmpty = 0;
11721
+ let listingErrors = 0;
11722
+ let segmentsAdded = 0;
11723
+ let hoursCreated = 0;
11724
+ let bounded = false;
11725
+ let last = null;
11726
+ for await (const candidate of this.candidates(location, () => {
11727
+ listingErrors += 1;
11728
+ })) {
11729
+ if (resume !== null && candidate.rel <= resume) continue;
11730
+ if (candidate.hourStartMs >= liveTailFrom) {
11731
+ hoursSkippedLive += 1;
11732
+ continue;
11733
+ }
11734
+ if (listings >= maxHours) {
11735
+ bounded = true;
11736
+ break;
11737
+ }
11738
+ let names;
11739
+ try {
11740
+ names = await this.deps.listDir(`${location.root}/${candidate.rel}`);
11741
+ } catch (err) {
11742
+ listingErrors += 1;
11743
+ this.deps.logger.debug("recorder: hour adoption could not list an hour directory", {
11744
+ tags: { deviceId: candidate.deviceId },
11745
+ meta: {
11746
+ locationId: location.id,
11747
+ rel: candidate.rel,
11748
+ error: require_dist.errMsg(err)
11749
+ }
11750
+ });
11751
+ continue;
11752
+ }
11753
+ listings += 1;
11754
+ last = candidate.rel;
11755
+ if (listings % 8 === 0) await this.deps.pace();
11756
+ const paths = (names ?? []).filter((name) => name.endsWith(".m4s")).map((name) => `${candidate.rel}/${name}`);
11757
+ if (paths.length === 0) {
11758
+ hoursSkippedEmpty += 1;
11759
+ continue;
11760
+ }
11761
+ const outcome = await ledger.adoptWalkedHour(candidate.deviceId, candidate.profile, location.id, candidate.hourStartMs, paths);
11762
+ if (outcome === null) {
11763
+ hoursRefusedByLedger += 1;
11764
+ continue;
11765
+ }
11766
+ hoursAdopted += 1;
11767
+ segmentsAdded += outcome.added;
11768
+ if (outcome.created) hoursCreated += 1;
11769
+ const tally = byDevice.get(candidate.deviceId) ?? {
11770
+ hoursAdopted: 0,
11771
+ segmentsAdded: 0,
11772
+ hoursCreated: 0
11773
+ };
11774
+ tally.hoursAdopted += 1;
11775
+ tally.segmentsAdded += outcome.added;
11776
+ if (outcome.created) tally.hoursCreated += 1;
11777
+ byDevice.set(candidate.deviceId, tally);
11778
+ if (outcome.added > 0) this.deps.logger.warn("recorder: adopted segments the durable hour row did not name — this hour was under-reported to every reader", {
11779
+ tags: { deviceId: candidate.deviceId },
11780
+ meta: {
11781
+ locationId: location.id,
11782
+ profile: candidate.profile,
11783
+ hourStartMs: candidate.hourStartMs,
11784
+ added: outcome.added,
11785
+ created: outcome.created,
11786
+ onDisk: paths.length
11787
+ }
11788
+ });
11789
+ }
11790
+ this.cursor = bounded ? last ?? resume : null;
11791
+ const report = {
11792
+ locationId: location.id,
11793
+ refused: null,
11794
+ listings,
11795
+ hoursAdopted,
11796
+ hoursRefusedByLedger,
11797
+ hoursSkippedLive,
11798
+ hoursSkippedEmpty,
11799
+ listingErrors,
11800
+ segmentsAdded,
11801
+ hoursCreated,
11802
+ bounded,
11803
+ byDevice: [...byDevice.entries()].map(([deviceId, tally]) => ({
11804
+ deviceId,
11805
+ ...tally
11806
+ })).toSorted((a, b) => a.deviceId - b.deviceId)
11807
+ };
11808
+ this.report(report);
11809
+ return report;
11810
+ }
11811
+ /**
11812
+ * Every hour directory under this location, in cursor order.
11813
+ *
11814
+ * Sorted at each level so the cursor is a total order over a tree two passes
11815
+ * see differently: a camera added between passes appears in its place rather
11816
+ * than at the end, and is picked up by the pass after the cursor resets.
11817
+ */
11818
+ async *candidates(location, onError) {
11819
+ const devices = await this.list(location.root, onError);
11820
+ for (const device of inCursorOrder(devices.filter((name) => /^\d+$/.test(name)))) {
11821
+ const deviceId = Number(device);
11822
+ if (!Number.isSafeInteger(deviceId)) continue;
11823
+ const present = new Set(await this.list(`${location.root}/${device}`, onError));
11824
+ for (const profile of PROFILES) {
11825
+ if (!present.has(profile)) continue;
11826
+ const base = `${device}/${profile}`;
11827
+ for (const year of inCursorOrder((await this.list(`${location.root}/${base}`, onError)).filter((n) => /^\d{4}$/.test(n)))) for (const month of inCursorOrder((await this.list(`${location.root}/${base}/${year}`, onError)).filter(isTwoDigit))) for (const day of inCursorOrder((await this.list(`${location.root}/${base}/${year}/${month}`, onError)).filter(isTwoDigit))) {
11828
+ const dayDir = `${base}/${year}/${month}/${day}`;
11829
+ for (const hour of inCursorOrder((await this.list(`${location.root}/${dayDir}`, onError)).filter(isTwoDigit))) {
11830
+ const startMs = Date.UTC(Number(year), Number(month) - 1, Number(day), Number(hour));
11831
+ if (!Number.isFinite(startMs)) continue;
11832
+ yield {
11833
+ deviceId,
11834
+ profile,
11835
+ hourStartMs: startMs,
11836
+ rel: `${dayDir}/${hour}`
11837
+ };
11838
+ }
11839
+ }
11840
+ }
11841
+ }
11842
+ }
11843
+ /** One directory's names; `[]` for a directory that is not there, and `[]`
11844
+ * plus a counted error for one that could not be read. */
11845
+ async list(absDir, onError) {
11846
+ try {
11847
+ return await this.deps.listDir(absDir) ?? [];
11848
+ } catch (err) {
11849
+ onError();
11850
+ this.deps.logger.debug("recorder: hour adoption could not list a directory", { meta: {
11851
+ dir: absDir,
11852
+ error: require_dist.errMsg(err)
11853
+ } });
11854
+ return [];
11855
+ }
11856
+ }
11857
+ refusedReport(locationId, refused) {
11858
+ return {
11859
+ locationId,
11860
+ refused,
11861
+ listings: 0,
11862
+ hoursAdopted: 0,
11863
+ hoursRefusedByLedger: 0,
11864
+ hoursSkippedLive: 0,
11865
+ hoursSkippedEmpty: 0,
11866
+ listingErrors: 0,
11867
+ segmentsAdded: 0,
11868
+ hoursCreated: 0,
11869
+ bounded: false,
11870
+ byDevice: []
11871
+ };
11872
+ }
11873
+ /** One line per camera that gained something, then one for the pass — a pass
11874
+ * that found nothing must still be distinguishable from one that never ran. */
11875
+ report(report) {
11876
+ for (const device of report.byDevice) {
11877
+ if (device.segmentsAdded === 0 && device.hoursCreated === 0) continue;
11878
+ this.deps.logger.info("recorder: hour adoption — camera summary", {
11879
+ tags: { deviceId: device.deviceId },
11880
+ meta: {
11881
+ locationId: report.locationId,
11882
+ hoursAdopted: device.hoursAdopted,
11883
+ segmentsAdded: device.segmentsAdded,
11884
+ hoursCreated: device.hoursCreated
11885
+ }
11886
+ });
11887
+ }
11888
+ this.deps.logger.info("recorder: hour adoption pass complete", { meta: {
11889
+ locationId: report.locationId,
11890
+ listings: report.listings,
11891
+ hoursAdopted: report.hoursAdopted,
11892
+ hoursRefusedByLedger: report.hoursRefusedByLedger,
11893
+ hoursSkippedLive: report.hoursSkippedLive,
11894
+ hoursSkippedEmpty: report.hoursSkippedEmpty,
11895
+ listingErrors: report.listingErrors,
11896
+ segmentsAdded: report.segmentsAdded,
11897
+ hoursCreated: report.hoursCreated,
11898
+ bounded: report.bounded
11899
+ } });
11900
+ }
11901
+ };
11902
+ //#endregion
11848
11903
  //#region src/recorder/addon/periodic-pass.ts
11849
11904
  function startPeriodicPass(deps) {
11850
11905
  const now = () => deps.now?.() ?? Date.now();
@@ -14204,11 +14259,16 @@ function planFinalizations(body, processed, forceFinal = false) {
14204
14259
  durMs
14205
14260
  });
14206
14261
  }
14262
+ const front = entries.length > 0 ? parseEpochStartMs(entries[0].segPath) : null;
14263
+ const longestDurMs = entries.reduce((longest, entry) => Math.max(longest, Math.round(entry.durSeconds * 1e3)), 0);
14264
+ const spanKnown = rolledOff > 0 && front !== null && longestDurMs > 0;
14207
14265
  return {
14208
14266
  toHandle,
14209
14267
  from: hw,
14210
14268
  processed: Math.max(hw, finalizable),
14211
- rolledOff
14269
+ rolledOff,
14270
+ rolledOffFromMs: spanKnown ? front - rolledOff * longestDurMs : null,
14271
+ rolledOffToMs: spanKnown ? front : null
14212
14272
  };
14213
14273
  }
14214
14274
  /**
@@ -14236,11 +14296,24 @@ function startSegmentWatcher(deps) {
14236
14296
  return;
14237
14297
  }
14238
14298
  const plan = planFinalizations(body, processed, forceFinal);
14239
- if (plan.rolledOff > 0) deps.logger.warn("segment playlist rolled past entries this watcher had not handled", { meta: {
14240
- rolledOff: plan.rolledOff,
14241
- processed,
14242
- from: plan.from
14243
- } });
14299
+ if (plan.rolledOff > 0) {
14300
+ deps.logger.warn("segment playlist rolled past entries this watcher had not handled", {
14301
+ tags: { deviceId: deps.deviceId },
14302
+ meta: {
14303
+ rolledOff: plan.rolledOff,
14304
+ processed,
14305
+ from: plan.from,
14306
+ fromMs: plan.rolledOffFromMs,
14307
+ toMs: plan.rolledOffToMs
14308
+ }
14309
+ });
14310
+ deps.onSegmentsMissed?.({
14311
+ deviceId: deps.deviceId,
14312
+ count: plan.rolledOff,
14313
+ fromMs: plan.rolledOffFromMs,
14314
+ toMs: plan.rolledOffToMs
14315
+ });
14316
+ }
14244
14317
  let index = plan.from;
14245
14318
  for (const { entry, durMs } of plan.toHandle) {
14246
14319
  if (await handleSegmentEntry(deps.outDir, entry, durMs, deps.onFinalized, deps.logger, deps.writerStartedMs) === "pending") {
@@ -14250,11 +14323,14 @@ function startSegmentWatcher(deps) {
14250
14323
  waitedMs,
14251
14324
  boundMs: 3e4
14252
14325
  })) {
14253
- deps.logger.warn("segment file never landed — skipping", { meta: {
14254
- seg: entry.segPath,
14255
- waitedMs,
14256
- boundMs: PENDING_GRACE_MS
14257
- } });
14326
+ deps.logger.warn("segment file never landed — skipping", {
14327
+ tags: { deviceId: deps.deviceId },
14328
+ meta: {
14329
+ seg: entry.segPath,
14330
+ waitedMs,
14331
+ boundMs: PENDING_GRACE_MS
14332
+ }
14333
+ });
14258
14334
  pendingIndex = -1;
14259
14335
  pendingSinceMs = 0;
14260
14336
  index++;
@@ -16092,8 +16168,12 @@ var RecordingController = class {
16092
16168
  writer,
16093
16169
  watcher: startSegmentWatcher({
16094
16170
  outDir,
16171
+ deviceId,
16095
16172
  writerStartedMs,
16096
16173
  now: () => this.now(),
16174
+ onSegmentsMissed: (missed) => {
16175
+ this.deps.onSegmentsMissed?.(missed, profile);
16176
+ },
16097
16177
  intervalMs: this.deps.watchIntervalMs > 0 ? this.deps.watchIntervalMs : DEFAULT_WATCH_INTERVAL_MS,
16098
16178
  onFinalized: async (startMs, durMs, flatAbsPath) => {
16099
16179
  this.lastSegmentAt.set(idleKey(deviceId, profile), this.now());
@@ -17427,14 +17507,33 @@ var GHOST_LOCATION_PROBE_PAUSE_MS = 50;
17427
17507
  /**
17428
17508
  * Awaited every `WALK_HOUR_BATCH` hour listings by the ledger↔disk walk (D319).
17429
17509
  *
17430
- * Twice the ghost sweep's brake, because the syscall between brakes is far
17431
- * heavier: that pass paces `storage.exists` on one path, this one paces a
17432
- * `readdir` of a whole hour directory — measured at ~650 ms on the live share
17433
- * under load. The walk is operator-armed and one-off, so the right trade is to
17434
- * be slower and invisible rather than fast and felt: a `find` over this array
17435
- * hung an SSH session on 2026-08-29.
17510
+ * Twice the ghost sweep's brake. That used to be because the syscall between
17511
+ * brakes was far heavier — the sweep paced one `storage.exists`, this one an
17512
+ * hour `readdir` "measured at ~650 ms on the live share under load". Both
17513
+ * halves of that have changed: the sweep paces a directory listing too since
17514
+ * D523, and the 650 ms belonged to a FUSE mount that has been retired (an hour
17515
+ * of 345–349 files on `/recordings-2` lists in ~1 ms).
17516
+ *
17517
+ * It stays at twice anyway, for the reason that never depended on the number:
17518
+ * the walk is operator-armed and one-off, so the right trade is to be slower
17519
+ * and invisible rather than fast and felt — a `find` over this array hung an
17520
+ * SSH session on 2026-08-29.
17436
17521
  */
17437
17522
  var LEDGER_WALK_PAUSE_MS = 100;
17523
+ /**
17524
+ * How often the ADD-only hour adoption pass runs (D521).
17525
+ *
17526
+ * Daily, for the same reason the ghost sweep is daily: it spends `readdir`
17527
+ * syscalls on the array every live read competes for, and the damage it repairs
17528
+ * is made by a writer teardown — an event measured in days, not minutes. It
17529
+ * does NOT run at boot (`startPeriodicPass` fires on the interval only), which
17530
+ * is when the staging reconcile, the ledger load and the first retention sweep
17531
+ * are already contending for that array.
17532
+ */
17533
+ var HOUR_ADOPTION_INTERVAL_MS = 1440 * 6e4;
17534
+ /** An adoption pass longer than this is reported with its duration. Bounded
17535
+ * work (512 hour listings, paced) — minutes is normal, an hour is not. */
17536
+ var HOUR_ADOPTION_SLOW_AFTER_MS = 10 * 6e4;
17438
17537
  /** What the per-volume usage row reports when the hour ledger is unavailable:
17439
17538
  * nothing measured, rather than a total counted off a partial RAM index. */
17440
17539
  var EMPTY_ARCHIVE_ACCOUNTING = {
@@ -17659,6 +17758,7 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
17659
17758
  /** Daily ghost-location reconcile: durable hour rows stamped with a location
17660
17759
  * the storage record no longer has. Armed in `onHubReachable`. */
17661
17760
  ghostLocationPass = null;
17761
+ hourAdoptionPass = null;
17662
17762
  /** Reversible migration pause lease; never reflected in RecordingConfig. */
17663
17763
  storageMigrationLeaseId = null;
17664
17764
  /**
@@ -17714,6 +17814,7 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
17714
17814
  this.segmentHours = new SegmentHourLedger({
17715
17815
  store: this.ctx.api.settingsStore,
17716
17816
  logger: this.ctx.logger,
17817
+ listHour: (locationId, deviceId, profile, hourStartMs) => this.listHourDirectory(locationId, deviceId, profile, hourStartMs),
17717
17818
  isLocationUsable: (locationId) => this.placement?.isLocationUsable(locationId) ?? true
17718
17819
  });
17719
17820
  try {
@@ -17747,7 +17848,7 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
17747
17848
  await node_fs.promises.unlink(fromAbs);
17748
17849
  }
17749
17850
  },
17750
- archiveHourPaths: async (deviceId, profile, locationId, hourStartMs) => await this.segmentHours?.hourPaths(deviceId, profile, locationId, hourStartMs) ?? null,
17851
+ hourDirectoryPaths: (deviceId, profile, locationId, hourStartMs) => this.listHourDirectory(locationId, deviceId, profile, hourStartMs),
17751
17852
  removeDir: async (locationId, relDir) => {
17752
17853
  const loc = requireReadableLocation(this.resolvedLocations, locationId, "recorder");
17753
17854
  await node_fs.promises.rm(node_path.default.join(loc.root, relDir), {
@@ -17900,6 +18001,27 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
17900
18001
  onWriterClosed: async (deviceId, profile) => {
17901
18002
  await this.segmentHours?.sealWriter(deviceId, profile);
17902
18003
  },
18004
+ onSegmentsMissed: (missed, profile) => {
18005
+ const ledger = this.segmentHours;
18006
+ if (ledger === null) return;
18007
+ if (missed.fromMs === null || missed.toMs === null) {
18008
+ this.ctx.logger.warn("recorder: segments were missed but the playlist named no span — NO hour could be held unsealed for them", {
18009
+ tags: { deviceId: missed.deviceId },
18010
+ meta: {
18011
+ profile,
18012
+ segments: missed.count
18013
+ }
18014
+ });
18015
+ return;
18016
+ }
18017
+ ledger.markHoursRefused({
18018
+ deviceId: missed.deviceId,
18019
+ profile,
18020
+ fromMs: missed.fromMs,
18021
+ toMs: missed.toMs,
18022
+ count: missed.count
18023
+ });
18024
+ },
17903
18025
  spawn: node_child_process.spawn,
17904
18026
  costRegistry: this.costRegistry,
17905
18027
  segmentSeconds: this.config.segmentSeconds,
@@ -18211,8 +18333,8 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
18211
18333
  localNodeId: this.nodeId,
18212
18334
  index: this.index,
18213
18335
  ledger: this.segmentHours === null ? null : {
18214
- streamSegments: (scope) => this.segmentHours.streamSegments(scope),
18215
- dropSegments: (rows) => this.segmentHours.dropSegments(rows)
18336
+ forgetScope: (scope) => this.segmentHours.forgetScope(scope),
18337
+ snapshot: () => this.segmentHours.snapshot()
18216
18338
  },
18217
18339
  now: () => Date.now()
18218
18340
  });
@@ -18265,6 +18387,8 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
18265
18387
  this.redundancySweeper = null;
18266
18388
  this.ghostLocationPass?.stop();
18267
18389
  this.ghostLocationPass = null;
18390
+ this.hourAdoptionPass?.stop();
18391
+ this.hourAdoptionPass = null;
18268
18392
  this.exportEngine = null;
18269
18393
  this.recordingProvider = null;
18270
18394
  }
@@ -18317,9 +18441,7 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
18317
18441
  }
18318
18442
  },
18319
18443
  anchorOf: async (root) => (await readLocationMarker(root))?.volumeId ?? null,
18320
- applyToIndex: (paths) => {
18321
- this.index.removeSegments(paths);
18322
- },
18444
+ applyToIndex: (deviceId, locationId, hour, paths) => this.index.resetHour(deviceId, locationId, hour, paths),
18323
18445
  logger: this.ctx.logger,
18324
18446
  now: () => Date.now(),
18325
18447
  pace: () => require_dist.sleep(LEDGER_WALK_PAUSE_MS)
@@ -18447,7 +18569,6 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
18447
18569
  });
18448
18570
  try {
18449
18571
  await this.segmentHours?.load();
18450
- this.segmentHours?.repairAggregates();
18451
18572
  } catch (err) {
18452
18573
  this.ctx.logger.warn("recorder: segment-hour ledger load failed — hours stay unknown until a read walks them", { meta: { error: require_dist.errMsg(err) } });
18453
18574
  }
@@ -18490,18 +18611,15 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
18490
18611
  const sweeper = new GhostLocationSweeper({
18491
18612
  ledger,
18492
18613
  locations: () => this.resolvedLocations,
18493
- exists: (location, relativePath) => this.ctx.api.storage.exists.query({
18494
- location,
18495
- relativePath
18496
- }),
18614
+ listHour: (locationId, deviceId, profile, hourStartMs) => this.listHourDirectory(locationId, deviceId, profile, hourStartMs),
18497
18615
  applyToIndex: (rows, toLocationId) => {
18498
18616
  this.index.removeSegments(rows.map((row) => row.path));
18499
- if (toLocationId === null) return;
18500
18617
  for (const row of rows) this.index.addSegment({
18501
18618
  ...row,
18502
18619
  locationId: toLocationId
18503
18620
  });
18504
18621
  },
18622
+ forgetInIndex: (deviceId, locationId, hourStartMs) => this.index.resetHour(deviceId, locationId, hourStartMs, []),
18505
18623
  logger: this.ctx.logger,
18506
18624
  now: () => Date.now(),
18507
18625
  pace: () => require_dist.sleep(GHOST_LOCATION_PROBE_PAUSE_MS)
@@ -18516,6 +18634,32 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
18516
18634
  logger: this.ctx.logger
18517
18635
  });
18518
18636
  }
18637
+ if (this.hourAdoptionPass === null) {
18638
+ const adopter = new HourAdoptionSweeper({
18639
+ ledger: () => this.segmentHours,
18640
+ locations: () => this.resolvedLocations,
18641
+ listDir: async (absDir) => {
18642
+ try {
18643
+ return await node_fs.promises.readdir(absDir);
18644
+ } catch (err) {
18645
+ if (err.code === "ENOENT") return null;
18646
+ throw err;
18647
+ }
18648
+ },
18649
+ logger: this.ctx.logger,
18650
+ now: () => Date.now(),
18651
+ pace: () => require_dist.sleep(LEDGER_WALK_PAUSE_MS)
18652
+ });
18653
+ this.hourAdoptionPass = startPeriodicPass({
18654
+ name: "hour-adoption",
18655
+ intervalMs: HOUR_ADOPTION_INTERVAL_MS,
18656
+ slowAfterMs: HOUR_ADOPTION_SLOW_AFTER_MS,
18657
+ run: async () => {
18658
+ for (const location of this.resolvedLocations) await adopter.sweep({ locationId: location.id });
18659
+ },
18660
+ logger: this.ctx.logger
18661
+ });
18662
+ }
18519
18663
  }
18520
18664
  /**
18521
18665
  * Print one window of detection-trigger health.
@@ -18750,6 +18894,63 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
18750
18894
  return this.resolvedLocations;
18751
18895
  }
18752
18896
  /**
18897
+ * ONE hour bucket's segment files, location-relative — the recorder's single
18898
+ * implementation of {@link HourLister} (D523).
18899
+ *
18900
+ * The directory is the authority for WHICH segments exist, and the segment
18901
+ * NAME carries start, duration and bytes, so this is a complete description
18902
+ * of the hour and no file is ever opened.
18903
+ *
18904
+ * Three answers, and the difference between the last two is the whole point:
18905
+ *
18906
+ * - `null` — **undecided.** The location does not resolve, may not be read,
18907
+ * or the read failed for a reason that is not "this directory is not
18908
+ * there". Every caller turns it into "this hour takes no part in this
18909
+ * pass".
18910
+ * - `[]` — **the hour holds nothing**, and the volume confirmed it: an
18911
+ * ENOENT on the hour directory is only reported as empty once the
18912
+ * location's ROOT has answered a read. A root that has gone away answers
18913
+ * ENOENT for every hour on it, and reporting that as "empty" is how a pass
18914
+ * deletes an operator's index of real footage (D148, D393).
18915
+ * - the paths.
18916
+ */
18917
+ async listHourDirectory(locationId, deviceId, profile, hourStartMs) {
18918
+ const location = this.resolvedLocations.find((l) => l.id === locationId);
18919
+ if (location === void 0 || !locationMayBeRead(location)) return null;
18920
+ const rel = hourDirRelPath(deviceId, profile, hourStartMs);
18921
+ try {
18922
+ return (await node_fs.promises.readdir(node_path.default.join(location.root, rel))).filter((f) => f.endsWith(".m4s")).map((f) => `${rel}/${f}`);
18923
+ } catch (err) {
18924
+ if (err.code !== "ENOENT") {
18925
+ this.ctx.logger.warn("recorder: hour directory read failed — this hour decides nothing", {
18926
+ tags: { deviceId },
18927
+ meta: {
18928
+ profile,
18929
+ locationId,
18930
+ hourStartMs,
18931
+ error: require_dist.errMsg(err)
18932
+ }
18933
+ });
18934
+ return null;
18935
+ }
18936
+ try {
18937
+ await node_fs.promises.readdir(location.root);
18938
+ } catch {
18939
+ this.ctx.logger.warn("recorder: hour directory is absent AND its location root cannot be read — this hour decides nothing", {
18940
+ tags: { deviceId },
18941
+ meta: {
18942
+ profile,
18943
+ locationId,
18944
+ root: location.root,
18945
+ hourStartMs
18946
+ }
18947
+ });
18948
+ return null;
18949
+ }
18950
+ return [];
18951
+ }
18952
+ }
18953
+ /**
18753
18954
  * Attach-boundary placement for one (device, profile). Falls back to the pure
18754
18955
  * resolver when the service is not built (non-recording node / early boot),
18755
18956
  * which is byte-identical to the pre-placement behaviour.