@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
@@ -1,5 +1,5 @@
1
- import { $t as hydrateSchema, C as OpsLogEntrySchema, Ct as recordingExportCapability, D as RECORDING_EXPORT_MAX_READ_BYTES, Dt as resolveLocationMode, H as deriveBatteryPresence, I as batteryCapability, Kt as DeviceFeature, Lt as errMsg, Mt as storageOccupancyCapability, O as RecordingConfigSchema, Pt as stripRetiredBandPreBufferSec, Q as evictionPolicyOfLocation, St as recordingCapability, W as deriveRecordingMode, Wt as BaseAddon, Xt as customAction, Y as evaluateSensorEdge, Z as evictionPolicyForMode, Zt as defineCustomActions, _n as record, bn as EventCategory, cn as _enum, ct as mayReadLocation, d as DEFAULT_EVENTS_BAND_BUFFER_SEC, dt as modeMayWrite, et as gbToBytes, g as ExportRecordSchema, gn as preprocess, hn as object, i as BatteryStatusSchema, it as loadContributionCapability, jt as storageEvictableCapability, k as RecordingSignalStatusSchema, kt as resolveRecordingProfiles, ln as array, lt as mayWriteToLocation, mn as number, on as selectAssignedProfileSlots, qt as DeviceType, rn as nodePin, rt as isFirstLevelMacroClass, sn as sleep$1, un as boolean, ut as migrateLegacyDeviceSignalConfig, vn as string, wt as recordingSignalCapability } from "../dist-BS-l_k7M.mjs";
2
- import { a as physicalRootOf, i as NO_COST_CLAIM, t as ChildCostRegistry } from "../node-DjvGLcbe.mjs";
1
+ import { $t as hydrateSchema, C as OpsLogEntrySchema, Ct as recordingExportCapability, D as RECORDING_EXPORT_MAX_READ_BYTES, Dt as resolveLocationMode, H as deriveBatteryPresence, I as batteryCapability, Kt as DeviceFeature, Lt as errMsg, Mt as storageOccupancyCapability, O as RecordingConfigSchema, Pt as stripRetiredBandPreBufferSec, Q as evictionPolicyOfLocation, St as recordingCapability, W as deriveRecordingMode, Wt as BaseAddon, Xt as customAction, Y as evaluateSensorEdge, Z as evictionPolicyForMode, Zt as defineCustomActions, _n as record, bn as EventCategory, cn as _enum, ct as mayReadLocation, d as DEFAULT_EVENTS_BAND_BUFFER_SEC, dt as modeMayWrite, et as gbToBytes, g as ExportRecordSchema, gn as preprocess, hn as object, i as BatteryStatusSchema, it as loadContributionCapability, jt as storageEvictableCapability, k as RecordingSignalStatusSchema, kt as resolveRecordingProfiles, ln as array, lt as mayWriteToLocation, mn as number, on as selectAssignedProfileSlots, qt as DeviceType, rn as nodePin, rt as isFirstLevelMacroClass, sn as sleep$1, un as boolean, ut as migrateLegacyDeviceSignalConfig, vn as string, wt as recordingSignalCapability } from "../dist-xsaGDCOu.mjs";
2
+ import { a as physicalRootOf, i as NO_COST_CLAIM, t as ChildCostRegistry } from "../node-Fv4ByT36.mjs";
3
3
  import { t as resolveHubHostname } from "../hub-hostname-cCknRYKj.mjs";
4
4
  import { c as PREBUFFER_MAX_MS, r as RECORDING_PRE_ROLL_REQUEST_MS, s as withRecordingIntent } from "../restream-intent-D2VT97-U.mjs";
5
5
  import { n as RowMapStore, t as retireRootKeys } from "../retire-root-keys-DZvlso2l.mjs";
@@ -9,7 +9,6 @@ import path from "node:path";
9
9
  import { spawn } from "node:child_process";
10
10
  import { open } from "node:fs/promises";
11
11
  import { contentTypeFor, createFileDataPlaneHandler, parseRangeHeader, startEventLoopStallMonitor } from "@camstack/system/addon-utils";
12
- import { gunzipSync, gzipSync } from "node:zlib";
13
12
  //#region src/recorder/event-capture.ts
14
13
  /**
15
14
  * La sottoscrizione motion del recorder: bus → `onTrigger(deviceId, atMs)`.
@@ -814,14 +813,14 @@ function parseSegmentPath(relPath) {
814
813
  //#region src/recorder/recording-index.ts
815
814
  /** Hour bucket, the granularity the path convention (`.../YYYY/MM/DD/HH/`)
816
815
  * already gives us — so "have I walked this?" costs one Set lookup. */
817
- var HOUR_MS$8 = 36e5;
816
+ var HOUR_MS$9 = 36e5;
818
817
  /** A device walked end-to-end. Cheaper and clearer than inserting every hour of
819
818
  * the retention window into the set. */
820
819
  var ALL_HOURS = Symbol("all-hours");
821
820
  /** The hour buckets a half-open span touches. */
822
821
  function* hourBuckets(fromMs, toMs) {
823
- const first = Math.floor(fromMs / HOUR_MS$8) * HOUR_MS$8;
824
- for (let h = first; h < toMs; h += HOUR_MS$8) yield h;
822
+ const first = Math.floor(fromMs / HOUR_MS$9) * HOUR_MS$9;
823
+ for (let h = first; h < toMs; h += HOUR_MS$9) yield h;
825
824
  }
826
825
  /**
827
826
  * Index of the first element whose `startMs` is GREATER than `startMs` (upper
@@ -1115,7 +1114,38 @@ var RecordingIndex = class {
1115
1114
  changed = true;
1116
1115
  }
1117
1116
  if (changed) this.invalidateSorted(deviceId);
1118
- this.markHydrated(deviceId, hourStartMs, hourStartMs + HOUR_MS$8);
1117
+ this.markHydrated(deviceId, hourStartMs, hourStartMs + HOUR_MS$9);
1118
+ }
1119
+ /**
1120
+ * Make one hour of ONE location exactly `relPaths` — merge what is there and
1121
+ * drop what is not.
1122
+ *
1123
+ * {@link hydrateHour} merges, which is right for a reader: a listing it took
1124
+ * of a live hour is a lower bound, and dropping on it would delete the index
1125
+ * of footage the readdir merely raced. This is for the one caller that has
1126
+ * TRUSTED evidence the hour is over and the volume it listed is the volume it
1127
+ * started on — the operator-armed ledger walk — where the directory is the
1128
+ * whole truth about the hour and an index row naming a file that is not in it
1129
+ * is a ghost (D523, D148's "trusted walk" arm).
1130
+ *
1131
+ * Scoped to the (device, location, hour) triple: a path filed under another
1132
+ * hour, or held for another location, is none of this call's business.
1133
+ * @residency mutation
1134
+ */
1135
+ resetHour(deviceId, locationId, hourStartMs, relPaths) {
1136
+ const m = this.mapFor(deviceId);
1137
+ const keep = new Set(relPaths);
1138
+ const dropped = [];
1139
+ for (const [path, row] of m) {
1140
+ if (row.locationId !== locationId) continue;
1141
+ if (Math.floor(row.startMs / HOUR_MS$9) * HOUR_MS$9 !== hourStartMs) continue;
1142
+ if (keep.has(path)) continue;
1143
+ dropped.push(path);
1144
+ }
1145
+ for (const path of dropped) m.delete(path);
1146
+ if (dropped.length > 0) this.invalidateSorted(deviceId);
1147
+ this.hydrateHour(deviceId, locationId, hourStartMs, relPaths);
1148
+ return dropped;
1119
1149
  }
1120
1150
  /**
1121
1151
  * Insert/replace one finalized segment (idempotent by path).
@@ -1761,45 +1791,11 @@ function hourDirRelPath(deviceId, profile, hourStartMs) {
1761
1791
  const p2 = (n) => String(n).padStart(2, "0");
1762
1792
  return `${deviceId}/${profile}/${d.getUTCFullYear()}/${p2(d.getUTCMonth() + 1)}/${p2(d.getUTCDate())}/${p2(d.getUTCHours())}`;
1763
1793
  }
1764
- var HOUR_MS$7 = 36e5;
1794
+ var HOUR_MS$8 = 36e5;
1765
1795
  async function hydrateWindowFromStorage(index, deviceId, fromMs, toMs, locations, logger, ledger, options) {
1766
1796
  const verify = options?.verifyClosedHours === true;
1767
1797
  if (!verify && index.hydrationOf(deviceId, fromMs, toMs) === "hydrated") return;
1768
- const liveHour = Math.floor(Date.now() / HOUR_MS$7) * HOUR_MS$7;
1769
- /** Per (locationId, profile, hour): what the LEDGER claimed, for the diff. */
1770
- const claimed = /* @__PURE__ */ new Map();
1771
- const claimKey = (locationId, hour) => `${locationId}:${hour}`;
1772
- /**
1773
- * Closed hours the ledger answered for with a row nobody SEALED (D451): what
1774
- * a writer had persisted when it was last heard from, which is a legitimate
1775
- * answer and not a complete one. Each is read from the array once and the
1776
- * row is unioned and sealed, so the second reader pays a point read.
1777
- */
1778
- const unsealed = /* @__PURE__ */ new Set();
1779
- if (ledger !== void 0) {
1780
- const startedMs = Date.now();
1781
- const hours = await ledger.detailForWindow(deviceId, fromMs, toMs);
1782
- let segments = 0;
1783
- let served = 0;
1784
- for (const hour of hours) {
1785
- if (hour.hourStartMs >= liveHour) continue;
1786
- index.hydrateHour(deviceId, hour.locationId, hour.hourStartMs, hour.paths);
1787
- claimed.set(claimKey(hour.locationId, hour.hourStartMs), hour.paths.length);
1788
- if (!hour.sealed) unsealed.add(hour.hourStartMs);
1789
- segments += hour.paths.length;
1790
- served += 1;
1791
- }
1792
- if (served > 0) logger.info("recorder: hydrated a window from the hour ledger", {
1793
- tags: { deviceId },
1794
- meta: {
1795
- hours: served,
1796
- segments,
1797
- unsealedHours: unsealed.size,
1798
- ms: Date.now() - startedMs
1799
- }
1800
- });
1801
- if (!verify && unsealed.size === 0 && index.hydrationOf(deviceId, fromMs, toMs) === "hydrated") return;
1802
- }
1798
+ const liveHour = Math.floor(Date.now() / HOUR_MS$8) * HOUR_MS$8;
1803
1799
  for (const [root, aliases] of aliasesByRoot(locations)) {
1804
1800
  const deviceDir = path.join(root, String(deviceId));
1805
1801
  let profiles;
@@ -1815,15 +1811,15 @@ async function hydrateWindowFromStorage(index, deviceId, fromMs, toMs, locations
1815
1811
  });
1816
1812
  continue;
1817
1813
  }
1818
- const first = Math.floor(fromMs / HOUR_MS$7) * HOUR_MS$7;
1814
+ const first = Math.floor(fromMs / HOUR_MS$8) * HOUR_MS$8;
1819
1815
  const reuseBefore = index.hydrateReuseStats();
1820
1816
  let hoursRead = 0;
1821
1817
  let repaired = 0;
1822
1818
  let forgottenSegments = 0;
1823
1819
  let forgottenHours = 0;
1824
1820
  let sealed = 0;
1825
- for (let hour = first; hour < toMs; hour += HOUR_MS$7) {
1826
- if (!verify && hour < liveHour && !unsealed.has(hour) && index.hydrationOf(deviceId, hour, hour + HOUR_MS$7) === "hydrated") continue;
1821
+ for (let hour = first; hour < toMs; hour += HOUR_MS$8) {
1822
+ if (!verify && hour < liveHour && index.hydrationOf(deviceId, hour, hour + HOUR_MS$8) === "hydrated") continue;
1827
1823
  for (const profile of profiles) {
1828
1824
  const rel = hourDirRelPath(deviceId, profile, hour);
1829
1825
  let files;
@@ -1836,12 +1832,11 @@ async function hydrateWindowFromStorage(index, deviceId, fromMs, toMs, locations
1836
1832
  const locationId = locationForHydratedProfile(aliases, profile).id;
1837
1833
  const relPaths = files.filter((f) => f.endsWith(".m4s")).map((f) => `${rel}/${f}`);
1838
1834
  index.hydrateHour(deviceId, locationId, hour, relPaths);
1839
- if (ledger === void 0 || relPaths.length === 0 || hour + HOUR_MS$7 > Date.now()) continue;
1840
- const claimedHere = claimed.get(claimKey(locationId, hour));
1835
+ if (ledger === void 0 || relPaths.length === 0 || hour + HOUR_MS$8 > Date.now()) continue;
1841
1836
  const outcome = await ledger.adoptWalkedHour(deviceId, profile, locationId, hour, relPaths);
1842
1837
  if (outcome === null) continue;
1843
1838
  if (outcome.created) repaired += 1;
1844
- else if (claimedHere !== void 0 && outcome.added > 0) {
1839
+ else if (outcome.added > 0) {
1845
1840
  forgottenSegments += outcome.added;
1846
1841
  forgottenHours += 1;
1847
1842
  }
@@ -1919,7 +1914,7 @@ async function hydrateWindowFromStorage(index, deviceId, fromMs, toMs, locations
1919
1914
  */
1920
1915
  var DEFAULT_THROTTLE_MBPS = 40;
1921
1916
  /** The durable index's bucket, and therefore this mover's batch. */
1922
- var HOUR_MS$6 = 36e5;
1917
+ var HOUR_MS$7 = 36e5;
1923
1918
  /** Progress callback cadence (files) — cheap enough to fire often. */
1924
1919
  var PROGRESS_EVERY_FILES = 25;
1925
1920
  /** Free space a destination must keep when it declares no `config.minFreePercent`
@@ -2478,10 +2473,12 @@ var RelocateEngine = class {
2478
2473
  *
2479
2474
  * The order is the whole point. Between the copy and the delete both
2480
2475
  * locations hold every byte, so the ledger re-point in the middle can only
2481
- * ever name a location that HAS the file — which is what makes a crash
2482
- * recoverable instead of a black window. A ledger that refuses (a failed
2483
- * point read: D49/D287) stops this hour dead with nothing deleted; the
2484
- * copies at the target are adopted by the next run.
2476
+ * ever account for a location that HAS the files — which is what makes a
2477
+ * crash recoverable instead of a black window.
2478
+ *
2479
+ * There is no ledger refusal to handle any more (D523): the durable half is
2480
+ * arithmetic over a row the mirror already holds, so nothing on that path can
2481
+ * fail and stop an hour dead.
2485
2482
  */
2486
2483
  async moveHour(job, rows, from, to, samePhysicalRoot, sleep, bytesPerMs) {
2487
2484
  const movable = [];
@@ -2517,17 +2514,7 @@ var RelocateEngine = class {
2517
2514
  toLocationId: to.id
2518
2515
  }
2519
2516
  });
2520
- else if (!await ledger.repointSegments(movable, to.id)) {
2521
- this.deps.logger.warn("relocate: hour NOT moved — the durable ledger refused the re-point, so the source bytes are kept", {
2522
- tags: { deviceId: movable[0].deviceId },
2523
- meta: {
2524
- jobId: job.jobId,
2525
- files: movable.length,
2526
- toLocationId: to.id
2527
- }
2528
- });
2529
- return;
2530
- }
2517
+ else await ledger.repointSegments(movable, to.id);
2531
2518
  if (!samePhysicalRoot) for (const row of movable) await promises.rm(path.join(from.root, row.fromPath ?? row.path), { force: true });
2532
2519
  for (const row of movable) {
2533
2520
  this.deps.index.removeSegments([row.fromPath ?? row.path]);
@@ -2616,7 +2603,7 @@ function profilesOfClass(footageClass) {
2616
2603
  function groupByHour(rows) {
2617
2604
  const out = /* @__PURE__ */ new Map();
2618
2605
  for (const row of rows) {
2619
- const key = `${row.deviceId}|${row.profile}|${Math.floor(row.startMs / HOUR_MS$6)}`;
2606
+ const key = `${row.deviceId}|${row.profile}|${Math.floor(row.startMs / HOUR_MS$7)}`;
2620
2607
  const found = out.get(key);
2621
2608
  if (found) found.push(row);
2622
2609
  else out.set(key, [row]);
@@ -2920,23 +2907,21 @@ var SegmentStore = class SegmentStore {
2920
2907
  * work now the per-bucket archive scan is gone. */
2921
2908
  static DEFAULT_EVICT_CHUNK = 25;
2922
2909
  /**
2923
- * True when `victims` cover EVERY segment the ARCHIVE says the bucket holds,
2924
- * AND the bucket's hour has fully elapsed.
2910
+ * True when `victims` cover EVERY segment the bucket DIRECTORY holds, AND the
2911
+ * bucket's hour has fully elapsed.
2925
2912
  *
2926
2913
  * The current-hour guard matters because `onFinalized` relocates new segments
2927
2914
  * INTO the current bucket concurrently — an rm racing that rename would
2928
2915
  * orphan a just-indexed segment.
2929
2916
  *
2930
- * The cover test is against `SegmentHourLedger`'s own hour row, never
2931
- * `RecordingIndex`. A bucketDir is `<dev>/<profile>/Y/M/D/H`, so it names
2932
- * exactly one durable hour row: "every path that row lists is a victim" is
2933
- * the same question the `rm -rf` answers, asked of the authority for what
2934
- * EXISTS (D295). Asked of the resident index it read EQUAL whenever the hour
2935
- * was partly resident — both sides short by the same rows — and the directory
2936
- * went with every file the round never named, leaving their durable rows
2937
- * behind as ghosts.
2917
+ * The cover test is a listing of the directory the `rm -rf` is about to take,
2918
+ * never `RecordingIndex` and never a durable row (D523). A bucketDir is
2919
+ * `<dev>/<profile>/Y/M/D/H`, so one listing answers exactly the question the
2920
+ * delete asks. Asked of the resident index it read EQUAL whenever the hour was
2921
+ * partly resident — both sides short by the same rows — and the directory went
2922
+ * with every file the round never named.
2938
2923
  *
2939
- * An unanswerable archive returns `false`: per-file deletes are slower and
2924
+ * An unanswerable listing returns `false`: per-file deletes are slower and
2940
2925
  * always correct, and the alternative is a recursive delete computed from a
2941
2926
  * fraction of the truth.
2942
2927
  */
@@ -2945,7 +2930,7 @@ var SegmentStore = class SegmentStore {
2945
2930
  if (!first) return false;
2946
2931
  const bucketStartMs = Math.floor(first.startMs / SegmentStore.HOUR_MS) * SegmentStore.HOUR_MS;
2947
2932
  if ((this.deps.now?.() ?? Date.now()) < bucketStartMs + SegmentStore.HOUR_MS) return false;
2948
- const ask = this.deps.archiveHourPaths;
2933
+ const ask = this.deps.hourDirectoryPaths;
2949
2934
  if (ask === void 0) return false;
2950
2935
  const held = await ask(first.deviceId, first.profile, location, bucketStartMs);
2951
2936
  if (held === null || held.length === 0) return false;
@@ -3844,7 +3829,7 @@ async function profileHasFootage(root, deviceId, profile) {
3844
3829
  //#endregion
3845
3830
  //#region src/recorder/addon/calendar-index.ts
3846
3831
  var DAY_MS$3 = 864e5;
3847
- var HOUR_MS$5 = 36e5;
3832
+ var HOUR_MS$6 = 36e5;
3848
3833
  /**
3849
3834
  * UTC ms → the UTC epoch of LOCAL midnight of the day it falls in.
3850
3835
  *
@@ -3882,8 +3867,8 @@ var RANGE_MERGE_GAP_MS$1 = 5e3;
3882
3867
  * deploy gap). Unknown hours still fall through to one directory read.
3883
3868
  */
3884
3869
  function hourSegmentsFromIndex(index, deviceId, profile, hourStartMs) {
3885
- const hour = Math.floor(hourStartMs / HOUR_MS$5) * HOUR_MS$5;
3886
- const hourEnd = hour + HOUR_MS$5;
3870
+ const hour = Math.floor(hourStartMs / HOUR_MS$6) * HOUR_MS$6;
3871
+ const hourEnd = hour + HOUR_MS$6;
3887
3872
  if (index.hydrationOf(deviceId, hour, hourEnd) !== "hydrated") return null;
3888
3873
  const rows = [];
3889
3874
  for (const s of index.segmentsStartingIn(deviceId, profile, hour, hourEnd)) rows.push({
@@ -4042,7 +4027,7 @@ var CalendarIndex = class {
4042
4027
  }));
4043
4028
  const days = /* @__PURE__ */ new Set();
4044
4029
  for (const { day, hours } of perDay) for (const h of hours) {
4045
- const at = day + h * HOUR_MS$5;
4030
+ const at = day + h * HOUR_MS$6;
4046
4031
  if (at < fromMs || at >= toMs) continue;
4047
4032
  days.add(localDayStart(at, tzOffsetMinutes));
4048
4033
  }
@@ -4078,8 +4063,9 @@ var CalendarIndex = class {
4078
4063
  *
4079
4064
  * Also used to warm the days ADJACENT to one being viewed — changing day is a
4080
4065
  * single click and the operator expects the summary to be there. That warm
4081
- * belongs in the background: at ~650 ms per cold read, doing it inline would
4082
- * only move the wait.
4066
+ * belongs in the background: a cold read costs whatever the array charges
4067
+ * that day (see the header — the ~650 ms figure is from the retired FUSE
4068
+ * share), and doing it inline would only move the wait onto the click.
4083
4069
  */
4084
4070
  async warm(deviceId, fromMs, toMs) {
4085
4071
  for (let day = utcDayOf(fromMs); day <= utcDayOf(toMs); day += DAY_MS$3) {
@@ -4104,7 +4090,7 @@ var CalendarIndex = class {
4104
4090
  const day = utcDayOf(utcDayMs);
4105
4091
  for (let h = 0; h < 24; h++) {
4106
4092
  await this.awaitQuiet();
4107
- await this.segmentsOf(deviceId, profile, day + h * HOUR_MS$5, true);
4093
+ await this.segmentsOf(deviceId, profile, day + h * HOUR_MS$6, true);
4108
4094
  }
4109
4095
  }
4110
4096
  /**
@@ -4125,7 +4111,7 @@ var CalendarIndex = class {
4125
4111
  */
4126
4112
  async segmentsOfChecked(deviceId, profile, hourStartMs, background = false) {
4127
4113
  if (!background) this.lastForegroundAt = this.deps.now?.() ?? Date.now();
4128
- const hour = Math.floor(hourStartMs / HOUR_MS$5) * HOUR_MS$5;
4114
+ const hour = Math.floor(hourStartMs / HOUR_MS$6) * HOUR_MS$6;
4129
4115
  const fromIndex = this.deps.indexHour?.(deviceId, profile, hour);
4130
4116
  if (fromIndex !== null && fromIndex !== void 0) return {
4131
4117
  segments: fromIndex,
@@ -4133,7 +4119,7 @@ var CalendarIndex = class {
4133
4119
  };
4134
4120
  const key = `${deviceId}:${profile}:${hour}`;
4135
4121
  const now = this.deps.now?.() ?? Date.now();
4136
- const isCurrentHour = hour === Math.floor(now / HOUR_MS$5) * HOUR_MS$5;
4122
+ const isCurrentHour = hour === Math.floor(now / HOUR_MS$6) * HOUR_MS$6;
4137
4123
  const cached = this.hourSegs.get(key);
4138
4124
  if (cached !== void 0) {
4139
4125
  if (!isCurrentHour) return {
@@ -4197,7 +4183,7 @@ var CalendarIndex = class {
4197
4183
  */
4198
4184
  async dayRanges(deviceId, profile, utcDayMs) {
4199
4185
  const day = utcDayOf(utcDayMs);
4200
- 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);
4186
+ 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);
4201
4187
  }
4202
4188
  /**
4203
4189
  * Exact ranges of `[fromMs, toMs)`, scanning ONLY the hours the window
@@ -4211,9 +4197,9 @@ var CalendarIndex = class {
4211
4197
  */
4212
4198
  async rangesIn(deviceId, profile, fromMs, toMs) {
4213
4199
  if (toMs <= fromMs) return [];
4214
- const first = Math.floor(fromMs / HOUR_MS$5) * HOUR_MS$5;
4200
+ const first = Math.floor(fromMs / HOUR_MS$6) * HOUR_MS$6;
4215
4201
  const hours = [];
4216
- for (let hour = first; hour < toMs; hour += HOUR_MS$5) hours.push(hour);
4202
+ for (let hour = first; hour < toMs; hour += HOUR_MS$6) hours.push(hour);
4217
4203
  const perHour = await mapBounded(hours, READ_CONCURRENCY, (hour) => this.segmentsOf(deviceId, profile, hour));
4218
4204
  const ranges = [];
4219
4205
  for (const r of mergeRanges(perHour.flat(), RANGE_MERGE_GAP_MS$1)) {
@@ -4765,7 +4751,7 @@ function handleStoreCensusAction(census, logger, input) {
4765
4751
  }
4766
4752
  //#endregion
4767
4753
  //#region src/recorder/addon/directory-handler.ts
4768
- var HOUR_MS$4 = 36e5;
4754
+ var HOUR_MS$5 = 36e5;
4769
4755
  /** A window wider than this is refused: it is a client bug, and answering it
4770
4756
  * would walk the archive on the read path — the thing this replaces. */
4771
4757
  var MAX_WINDOW_MS = 32 * 864e5;
@@ -4812,9 +4798,9 @@ function withDirectoryRoute(deps, next) {
4812
4798
  return;
4813
4799
  }
4814
4800
  const rows = [];
4815
- const firstHour = Math.floor(fromMs / HOUR_MS$4) * HOUR_MS$4;
4801
+ const firstHour = Math.floor(fromMs / HOUR_MS$5) * HOUR_MS$5;
4816
4802
  let hydrated = true;
4817
- for (let hour = firstHour; hour < toMs; hour += HOUR_MS$4) {
4803
+ for (let hour = firstHour; hour < toMs; hour += HOUR_MS$5) {
4818
4804
  const { segments, complete } = await deps.source.segmentsOfChecked(deviceId, profile, hour);
4819
4805
  if (!complete) hydrated = false;
4820
4806
  for (const s of segments) {
@@ -5200,22 +5186,18 @@ function createFootageDiscardJob(deps) {
5200
5186
  const forgetLedger = async (location) => {
5201
5187
  const ledger = deps.ledger;
5202
5188
  if (ledger === null) return 0;
5203
- let forgotten = 0;
5204
5189
  const profiles = profilesForType(location.type);
5205
- for await (const page of ledger.streamSegments({
5190
+ const scope = {
5206
5191
  locationId: location.id,
5207
5192
  profiles
5208
- })) {
5209
- if (cancelRequested) return forgotten;
5210
- deps.index.removeSegments(page.map((row) => row.path));
5211
- await ledger.dropSegments(page);
5212
- forgotten += page.length;
5213
- report({
5214
- dbRowsDeleted: forgotten,
5215
- detail: `forgot ${forgotten} ledger segments`
5216
- });
5217
- await sleep(0);
5218
- }
5193
+ };
5194
+ const hours = ledger.snapshot().filter((row) => row.locationId === location.id && profiles.includes(row.profile));
5195
+ const forgotten = await ledger.forgetScope(scope);
5196
+ for (const hour of hours) deps.index.resetHour(hour.deviceId, hour.locationId, hour.hourStartMs, []);
5197
+ report({
5198
+ dbRowsDeleted: forgotten,
5199
+ detail: `forgot ${forgotten} ledger segments`
5200
+ });
5219
5201
  return forgotten;
5220
5202
  };
5221
5203
  return {
@@ -8268,63 +8250,6 @@ function runFfmpeg(renderDeps, args, ext) {
8268
8250
  });
8269
8251
  }
8270
8252
  //#endregion
8271
- //#region src/recorder/addon/paths-codec.ts
8272
- /**
8273
- * How an hour row's `paths` becomes text, and back (D471).
8274
- *
8275
- * `recorder:segment-hours` is 66 MB of the settings database and **99.2 % of it
8276
- * is this one column** — 15 643 bytes per row over 4 036 rows. The content is a
8277
- * list of location-relative segment paths that nearly all share the same
8278
- * directory prefix, repeated in full on every entry. Measured per column on a
8279
- * copy of the live database, never the live file (D468).
8280
- *
8281
- * Compressing it is unusually safe here because of D287's two-tier mirror: boot
8282
- * loads the aggregate half of every row and NOT `paths`, so this column is read
8283
- * only on a point materialise, never on the hot path.
8284
- *
8285
- * A local copy of the same idea `pipeline-analytics` uses for `positions` —
8286
- * addons never import each other (CLAUDE.md), so twenty duplicated lines are
8287
- * the correct shape, not a shared dependency across an addon boundary.
8288
- */
8289
- /** Marks a value this module wrote. A plain JSON array always starts `[`. */
8290
- var ENCODED_PREFIX = "z1:";
8291
- function encodePaths(paths) {
8292
- return ENCODED_PREFIX + gzipSync(Buffer.from(JSON.stringify(paths), "utf8"), { level: 6 }).toString("base64");
8293
- }
8294
- /**
8295
- * Decode whichever era wrote the value: the `z1:` string this writes, the plain
8296
- * JSON string every pre-D471 row holds, or an array the store already parsed.
8297
- *
8298
- * `null` means "this row is unreadable", which the caller turns into a DISCARDED
8299
- * row — the same contract the bare `JSON.parse` had. An hour whose paths cannot
8300
- * be read must not silently become an hour with no segments: that would report
8301
- * an empty disk, which is the failure `recordToRow` was already written to
8302
- * avoid.
8303
- */
8304
- function decodePaths(value) {
8305
- if (Array.isArray(value)) return isPathList(value) ? value : null;
8306
- if (typeof value !== "string") return null;
8307
- const json = value.startsWith(ENCODED_PREFIX) ? tryGunzip(value.slice(3)) : value;
8308
- if (json === null) return null;
8309
- try {
8310
- const parsed = JSON.parse(json);
8311
- if (!Array.isArray(parsed)) return null;
8312
- return isPathList(parsed) ? parsed : null;
8313
- } catch {
8314
- return null;
8315
- }
8316
- }
8317
- function isPathList(v) {
8318
- return v.every((p) => typeof p === "string" && p.length > 0);
8319
- }
8320
- function tryGunzip(b64) {
8321
- try {
8322
- return gunzipSync(Buffer.from(b64, "base64")).toString("utf8");
8323
- } catch {
8324
- return null;
8325
- }
8326
- }
8327
- //#endregion
8328
8253
  //#region src/durable/durable-ledger.ts
8329
8254
  /** Default reseed cap — every current consumer's row set is installation-bounded. */
8330
8255
  var DEFAULT_LOAD_LIMIT = 1e5;
@@ -8565,7 +8490,7 @@ var DurableLedger = class DurableLedger {
8565
8490
  };
8566
8491
  //#endregion
8567
8492
  //#region src/recorder/addon/segment-hour-ledger.ts
8568
- var HOUR_MS$3 = 36e5;
8493
+ var HOUR_MS$4 = 36e5;
8569
8494
  /** `Math.min` where a missing left operand means "nothing yet", not `NaN`. */
8570
8495
  function minOf(a, b) {
8571
8496
  return a === null ? b : Math.min(a, b);
@@ -8579,19 +8504,19 @@ var macrotask$1 = () => new Promise((resolve) => {
8579
8504
  });
8580
8505
  /**
8581
8506
  * @durable class=mirror owner=recorder
8582
- * 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"
8507
+ * 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"
8583
8508
  * retention="a row goes when the walk or eviction confirms the hour is empty — never by age, never from the ledger alone (D148)"
8584
8509
  */
8585
8510
  var RECORDING_SEGMENT_HOURS_COLLECTION = "recorder:segment-hours";
8586
8511
  /**
8587
- * The four aggregate columns exist so "how much footage is on this disk" is a
8588
- * `SUM`/`MIN`/`MAX` over ~19 k hour rows instead of a walk over ~7.1 M in-RAM
8589
- * segment rows. They are DERIVED from `paths` — never a second authority — by
8590
- * {@link withSegmentPath} and nothing else: {@link hourAggregate} is that step
8591
- * over every path and {@link appendSegmentPath} is that step over one, so there
8592
- * is no second derivation to drift. A row loaded from a database that predates
8593
- * these columns is corrected in the mirror the moment it is read and repaired
8594
- * on disk by {@link SegmentHourLedger.repairAggregates}.
8512
+ * The four aggregate columns are what this table IS (D523).
8513
+ *
8514
+ * They exist so "how much footage is on this disk" is a `SUM`/`MIN`/`MAX` over
8515
+ * ~19 k hour rows instead of a walk over ~7.1 M in-RAM segment rows, and they
8516
+ * are derived from segment NAMES — by {@link withSegmentPath} and nothing else.
8517
+ * {@link hourAggregate} is that step over a whole listing and
8518
+ * {@link appendSegmentPath} is that step over the one path that just arrived,
8519
+ * so there is no second derivation to drift.
8595
8520
  */
8596
8521
  var RECORDING_SEGMENT_HOURS_COLUMNS = [
8597
8522
  {
@@ -8620,11 +8545,6 @@ var RECORDING_SEGMENT_HOURS_COLUMNS = [
8620
8545
  type: "INTEGER",
8621
8546
  notNull: true
8622
8547
  },
8623
- {
8624
- name: "paths",
8625
- type: "TEXT",
8626
- notNull: true
8627
- },
8628
8548
  {
8629
8549
  name: "bytes",
8630
8550
  type: "INTEGER",
@@ -8683,15 +8603,9 @@ var RECORDING_SEGMENT_HOURS_INDEXES = [
8683
8603
  * `MAX(maxStartMs)` over `locationId IN (…)` — one row back, and with this
8684
8604
  * index not one table row read to produce it (D447).
8685
8605
  *
8686
- * `idx_recorder_segment_hours_loc_hour` finds the same range, but the four
8687
- * aggregates live in the table row, next to `paths` — up to 360 segment
8688
- * paths of ~80 characters per hour — so the eviction index served the
8689
- * accounting read at the price of ~30 KB of table page per hour row. On the
8690
- * live hub that was `durationMs=1001…2902` once a MINUTE on
8691
- * `hub/sqlite-settings` (174 slow-call WARNs in ten hours on 2026-09-10/11),
8692
- * every one of them holding the thread every configuration read in the
8693
- * cluster waits on. The index is ~50 bytes a row over ~19 k rows — nothing —
8694
- * and the first declare after it ships builds it in well under a second.
8606
+ * Since D523 dropped `paths` the table row is ten scalars, so the covering
8607
+ * index no longer saves ~30 KB of page per row; it still saves the row
8608
+ * fetch, and the accounting read is the one this hub runs on a schedule.
8695
8609
  */
8696
8610
  {
8697
8611
  name: "idx_recorder_segment_hours_loc_accounting",
@@ -8704,6 +8618,27 @@ var RECORDING_SEGMENT_HOURS_INDEXES = [
8704
8618
  ]
8705
8619
  })
8706
8620
  ];
8621
+ /**
8622
+ * How far into an hour a writer's first finalize may land and the writer still
8623
+ * be the party that KNOWS what the hour holds (D521).
8624
+ *
8625
+ * A seal is a claim of completeness. A writer that was running across the hour
8626
+ * boundary produces its first in-hour segment within ONE segment length of the
8627
+ * boundary — the segment that straddles it belongs to the previous hour by its
8628
+ * `startMs`. So the grace is one segment length at the longest anyone
8629
+ * configures here (60 s), plus 30 s for a keyframe-cut segment overrunning its
8630
+ * nominal `-segment_time` (ffmpeg cuts on the next GOP, never on the clock).
8631
+ *
8632
+ * It is a guess, and this is the shape of being wrong in each direction.
8633
+ * TIGHTER: a normal rollover stops sealing and every hour of the archive is
8634
+ * listed once by the adoption sweep — at ~1 ms per hour directory on this hub's
8635
+ * SATA SSD, affordable but pointless. LOOSER: a runner respawn inside the
8636
+ * window seals an hour it entered late, which is exactly the 2026-09-17 defect;
8637
+ * the observed hole there was ~29 minutes, so anything under a few minutes
8638
+ * would have caught it and 90 s is the smallest number that does not fire on a
8639
+ * legitimate rollover.
8640
+ */
8641
+ var WRITER_HOUR_ENTRY_GRACE_MS = 9e4;
8707
8642
  /** The identity of the {@link hourAggregate} fold — an hour holding nothing. */
8708
8643
  var EMPTY_HOUR_AGGREGATE = {
8709
8644
  bytes: 0,
@@ -8715,12 +8650,11 @@ var EMPTY_HOUR_AGGREGATE = {
8715
8650
  * ONE path folded into an aggregate — the whole derivation, expressed as its
8716
8651
  * single step.
8717
8652
  *
8718
- * {@link hourAggregate} is this step over every path, and {@link
8653
+ * {@link hourAggregate} is this step over a whole listing, and {@link
8719
8654
  * SegmentHourLedger.recordSegment} is this step over the one path that just
8720
8655
  * arrived. That is not two implementations that happen to agree: the full
8721
8656
  * derivation is *defined* as this function repeated, so an append is the fold
8722
- * resumed from the state the held row already carries — a `SegmentHourRow`'s
8723
- * aggregate half IS the fold state over its `paths`.
8657
+ * resumed from the state the held row already carries.
8724
8658
  *
8725
8659
  * Why it had to become a step: `recordSegment` re-derived the whole hour on
8726
8660
  * every finalize. Σ1..360 ≈ 65 k path parses per camera-profile-hour at 10 s
@@ -8743,49 +8677,57 @@ function withSegmentPath(agg, path) {
8743
8677
  };
8744
8678
  }
8745
8679
  /**
8746
- * Bytes / count / oldest / newest for one hour's paths — the sole FULL
8747
- * derivation, used by rebuilds ({@link recordToRow}, `dropSegments`) and never
8748
- * by the append path.
8680
+ * Bytes / count / oldest / newest for one hour's LISTING — the sole FULL
8681
+ * derivation, and since D523 the only thing that can produce an EXACT row.
8749
8682
  *
8750
8683
  * Every field of a segment path encodes its own facts (`parseSegmentPath`), so
8751
- * this is a derivation, not a measurement: it can never disagree with the paths
8752
- * it was computed from.
8684
+ * this is a derivation, not a measurement: it can never disagree with the
8685
+ * listing it was computed from.
8753
8686
  */
8754
8687
  function hourAggregate(paths) {
8755
8688
  let agg = EMPTY_HOUR_AGGREGATE;
8756
8689
  for (const path of paths) agg = withSegmentPath(agg, path);
8757
8690
  return agg;
8758
8691
  }
8759
- /** Build the persisted row for an hour from its paths — the ONE constructor. */
8692
+ /** Build the persisted row for an hour from a LISTING of it — the ONE
8693
+ * constructor that can produce an exact aggregate. */
8760
8694
  function segmentHourRow(base) {
8695
+ const { paths, sealed, ...identity } = base;
8761
8696
  return {
8762
- ...base,
8763
- sealed: base.sealed ?? false,
8764
- pathsResident: true,
8765
- ...hourAggregate(base.paths)
8697
+ ...identity,
8698
+ sealed: sealed ?? false,
8699
+ ...hourAggregate(paths)
8700
+ };
8701
+ }
8702
+ /**
8703
+ * Build the persisted row for an hour from an aggregate somebody already folded
8704
+ * — a relocation moving one, or a listing that has already been reduced.
8705
+ *
8706
+ * `sealed` is a NAMED field and never a positional boolean, because
8707
+ * `scripts/check-seal-authority.ts` attributes every literal `sealed: true` in
8708
+ * the recorder to the method that wrote it. A seal smuggled in as a third
8709
+ * argument is a seal the guard cannot see.
8710
+ */
8711
+ function hourRowOf(identity, row) {
8712
+ return {
8713
+ ...identity,
8714
+ ...row
8766
8715
  };
8767
8716
  }
8768
8717
  /**
8769
8718
  * The row an hour becomes when ONE more path joins it.
8770
8719
  *
8771
- * Identical to `segmentHourRow({ ...held, paths: [...held.paths, path] })` and
8772
- * strictly cheaper: the aggregate half of `held` is already the fold over
8773
- * `held.paths`, so this resumes that fold instead of restarting it. The
8720
+ * Identical to `segmentHourRow({ …held, paths: [...everything, path] })` and
8721
+ * strictly cheaper: the aggregate half of `held` is already the fold over the
8722
+ * hour's names, so this resumes that fold instead of restarting it. The
8774
8723
  * equality is not an assumption — `segment-hour-append.spec.ts` pins it on
8775
8724
  * generated hours including out-of-order arrival, duplicate paths, unparseable
8776
8725
  * paths and storm-sized hours.
8777
- *
8778
- * The four fields are named rather than spread on purpose. {@link
8779
- * withSegmentPath} hands its argument straight back for a path the parser
8780
- * refuses, and `held` is a whole row — spreading that return would put the
8781
- * PREVIOUS `paths` array back over the appended one. Which is exactly what it
8782
- * did until the generated-hours test caught it.
8783
8726
  */
8784
8727
  function appendSegmentPath(held, path) {
8785
8728
  const { bytes, segments, minStartMs, maxStartMs } = withSegmentPath(held, path);
8786
8729
  return {
8787
8730
  ...held,
8788
- paths: [...held.paths, path],
8789
8731
  bytes,
8790
8732
  segments,
8791
8733
  minStartMs,
@@ -8803,6 +8745,20 @@ function appendSegmentPath(held, path) {
8803
8745
  * `readdir` over an unmounted root deletes all of it.
8804
8746
  */
8805
8747
  var LEDGER_PRUNE_MAX_SHARE = .5;
8748
+ /**
8749
+ * The largest share of ONE hour's accounted segments a single listing may
8750
+ * erase (D523).
8751
+ *
8752
+ * The directory is the authority for WHICH segments exist, so a listing of a
8753
+ * closed hour replaces that hour's aggregate — including downwards, which is
8754
+ * what makes an over-counted row self-correcting. The guard is the same
8755
+ * argument {@link LEDGER_PRUNE_MAX_SHARE} makes one level up: a root that has
8756
+ * gone away, or a bucket half-deleted by a round that died, answers SHORT, and
8757
+ * a short answer that halves an hour is indistinguishable from one that is
8758
+ * simply right. Under the bar the listing wins; over it the row stands, the
8759
+ * hour stays unsealed, and the disagreement is named.
8760
+ */
8761
+ var HOUR_LISTING_MAX_SHRINK_SHARE = .5;
8806
8762
  /** Backstop on the oldest-first probe. A bounded read must stay bounded even if
8807
8763
  * a caller asks for a target no archive can cover. */
8808
8764
  var OLDEST_MAX_PAGES = 16;
@@ -8812,9 +8768,6 @@ var OLDEST_PROBE_COLUMNS = [
8812
8768
  "bytes",
8813
8769
  "locationId"
8814
8770
  ];
8815
- /** Aggregate-repair probe: the primary key always comes back, so this asks for
8816
- * the predicate column alone. */
8817
- var REPAIR_PROBE_COLUMNS = ["segments"];
8818
8771
  /**
8819
8772
  * Backstop on {@link SegmentHourLedger.streamSegments}.
8820
8773
  *
@@ -8853,47 +8806,8 @@ var CENSUS_FIELDS = [
8853
8806
  op: "max"
8854
8807
  }
8855
8808
  ];
8856
- /** Rows repaired per boot. The live archive is ~19 k hour rows. */
8857
- var REPAIR_MAX_ROWS = 5e4;
8858
- /**
8859
- * The boot projection (D287): everything the recorder's always-resident tier
8860
- * needs, and deliberately NOT `paths`.
8861
- *
8862
- * `paths` is ~55 B per segment and the archive is the only thing that grows:
8863
- * ~16 MB of JSON today, a projected ~460 MB at 28 TB of today's bitrate mix
8864
- * and ~5.9 GB at a low-bitrate mix — one tRPC response, parsed on the main
8865
- * thread, before the index exists. The summary half is O(writers × days) and
8866
- * stays ~10–15 MB at any archive size.
8867
- */
8868
- var SEGMENT_HOUR_SUMMARY_COLUMNS = [
8869
- "deviceId",
8870
- "profile",
8871
- "locationId",
8872
- "hourStartMs",
8873
- "bytes",
8874
- "segments",
8875
- "minStartMs",
8876
- "maxStartMs",
8877
- "sealed"
8878
- ];
8879
8809
  /** Summary rows folded into the mirror between yields. */
8880
8810
  var LOAD_CHUNK = 5e3;
8881
- /** Hour rows materialised per point-read page. One page covers a day of one
8882
- * camera's three profiles with room to spare. */
8883
- var HOUR_DETAIL_PAGE = 64;
8884
- /**
8885
- * The live tail materialised at boot: the current hour and the one before it.
8886
- *
8887
- * `recordSegment` must gate from RAM (D49), and the hour it appends to at boot
8888
- * is exactly the hour the previous process was writing. Two hours × the active
8889
- * writers is ~60 rows — one point-read page — and it is what makes the write
8890
- * path's materialise a case that essentially never happens.
8891
- */
8892
- var LIVE_TAIL_HOURS = 2;
8893
- /** Rows the one-shot aggregate repair may materialise in a single boot. The
8894
- * backfill is bounded so it cannot re-become the whole-archive read D287
8895
- * removed; a partial pass finishes on the next boot. */
8896
- var REPAIR_MAX_MATERIALIZE = 2e3;
8897
8811
  /** The four scalars an accounting answer is made of, asked in one statement. */
8898
8812
  var ACCOUNTING_FIELDS = [
8899
8813
  {
@@ -8924,7 +8838,7 @@ var EMPTY_ACCOUNTING = {
8924
8838
  newestMs: null
8925
8839
  };
8926
8840
  function hourStartMs(startMs) {
8927
- return Math.floor(startMs / HOUR_MS$3) * HOUR_MS$3;
8841
+ return Math.floor(startMs / HOUR_MS$4) * HOUR_MS$4;
8928
8842
  }
8929
8843
  function segmentHourKey(deviceId, profile, locationId, hour) {
8930
8844
  return `${deviceId}|${profile}|${locationId}|${hour}`;
@@ -8941,13 +8855,10 @@ function hourOf(row) {
8941
8855
  function writerKey(deviceId, profile) {
8942
8856
  return `${deviceId}|${profile}`;
8943
8857
  }
8944
- function detailOf(row) {
8945
- return {
8946
- locationId: row.locationId,
8947
- hourStartMs: row.hourStartMs,
8948
- paths: row.paths,
8949
- sealed: row.sealed
8950
- };
8858
+ /** One writer's hour, WITHOUT the location — the identity a loss is reported
8859
+ * against by a party that only knows the camera, the profile and the clock. */
8860
+ function writerHourKey(deviceId, profile, hour) {
8861
+ return `${deviceId}|${profile}|${hour}`;
8951
8862
  }
8952
8863
  /**
8953
8864
  * The `hourStartMs` of one probe record.
@@ -8960,21 +8871,31 @@ function hourOfRecord(record) {
8960
8871
  const raw = record.data["hourStartMs"];
8961
8872
  return typeof raw === "number" && Number.isFinite(raw) ? raw : -1;
8962
8873
  }
8963
- function uniquePaths(paths) {
8964
- const seen = /* @__PURE__ */ new Set();
8965
- const out = [];
8966
- for (const p of paths) {
8967
- if (seen.has(p)) continue;
8968
- seen.add(p);
8969
- out.push(p);
8874
+ /** One listing turned into the segment rows a caller can act on — the only
8875
+ * place a `SegmentRow` is built out of a directory read. */
8876
+ function segmentRowsOf(locationId, paths) {
8877
+ const rows = [];
8878
+ let unparseable = 0;
8879
+ for (const path of paths) {
8880
+ const parsed = parseSegmentPath(path);
8881
+ if (parsed === null) {
8882
+ unparseable += 1;
8883
+ continue;
8884
+ }
8885
+ rows.push({
8886
+ deviceId: parsed.deviceId,
8887
+ profile: parsed.profile,
8888
+ startMs: parsed.startMs,
8889
+ durMs: parsed.durMs,
8890
+ bytes: parsed.bytes,
8891
+ path,
8892
+ locationId
8893
+ });
8970
8894
  }
8971
- return out;
8972
- }
8973
- function samePaths(a, b) {
8974
- if (a.length !== b.length) return false;
8975
- const set = new Set(a);
8976
- for (const p of b) if (!set.has(p)) return false;
8977
- return true;
8895
+ return {
8896
+ rows,
8897
+ unparseable
8898
+ };
8978
8899
  }
8979
8900
  function rowToValue(row) {
8980
8901
  return {
@@ -8982,7 +8903,6 @@ function rowToValue(row) {
8982
8903
  profile: row.profile,
8983
8904
  locationId: row.locationId,
8984
8905
  hourStartMs: row.hourStartMs,
8985
- paths: encodePaths(row.paths),
8986
8906
  bytes: row.bytes,
8987
8907
  segments: row.segments,
8988
8908
  minStartMs: row.minStartMs,
@@ -8995,44 +8915,21 @@ function rowToValue(row) {
8995
8915
  function sealedOf(raw) {
8996
8916
  return raw === 1 || raw === true;
8997
8917
  }
8998
- function recordToRow(key, data) {
8999
- const deviceId = Number(data["deviceId"]);
9000
- const profile = data["profile"];
9001
- const locationId = data["locationId"];
9002
- const hour = Number(data["hourStartMs"]);
9003
- const decodedPaths = decodePaths(data["paths"]);
9004
- if (decodedPaths === null) return null;
9005
- const raw = decodedPaths;
9006
- 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;
9007
- return segmentHourRow({
9008
- key,
9009
- deviceId,
9010
- profile,
9011
- locationId,
9012
- hourStartMs: hour,
9013
- paths: raw,
9014
- sealed: sealedOf(data["sealed"])
9015
- });
9016
- }
9017
- /** The paths of a summary row: one shared empty array, never a per-row
9018
- * allocation, and never mistakable for "this hour holds nothing". */
9019
- var NO_PATHS = Object.freeze([]);
9020
8918
  function finiteOr0(raw) {
9021
8919
  const n = Number(raw);
9022
8920
  return Number.isFinite(n) ? n : 0;
9023
8921
  }
9024
8922
  /**
9025
- * One row of the boot projection ({@link SEGMENT_HOUR_SUMMARY_COLUMNS}).
8923
+ * One persisted row.
9026
8924
  *
9027
- * The identity half is validated exactly as {@link recordToRow} validates it —
9028
- * a row that cannot say which camera, profile, location and hour it is about
9029
- * is skipped, because a summary keyed on garbage would refuse the write path
9030
- * for an hour that is perfectly fine. The aggregate half is taken as the
9031
- * database wrote it: a row that predates the aggregate columns reports zeros,
9032
- * which UNDER-reports its disk (prunes less, never more) until
9033
- * {@link SegmentHourLedger.repairAggregates} rewrites it.
8925
+ * The identity half is validated: a row that cannot say which camera, profile,
8926
+ * location and hour it is about is DISCARDED, because a row keyed on garbage
8927
+ * would refuse the write path for an hour that is perfectly fine. The aggregate
8928
+ * half is taken as the database wrote it — a row that predates the aggregate
8929
+ * columns reports zeros, which UNDER-reports its disk (prunes less, never more)
8930
+ * until a listing of that hour re-derives it.
9034
8931
  */
9035
- function summaryToRow(key, data) {
8932
+ function recordToRow(key, data) {
9036
8933
  const deviceId = Number(data["deviceId"]);
9037
8934
  const profile = data["profile"];
9038
8935
  const locationId = data["locationId"];
@@ -9044,8 +8941,6 @@ function summaryToRow(key, data) {
9044
8941
  profile,
9045
8942
  locationId,
9046
8943
  hourStartMs: hour,
9047
- paths: NO_PATHS,
9048
- pathsResident: false,
9049
8944
  sealed: sealedOf(data["sealed"]),
9050
8945
  bytes: finiteOr0(data["bytes"]),
9051
8946
  segments: finiteOr0(data["segments"]),
@@ -9067,11 +8962,12 @@ var SPEC = {
9067
8962
  var SegmentHourLedger = class SegmentHourLedger {
9068
8963
  ledger;
9069
8964
  logger;
9070
- /** Held for the two ARCHIVE reads below, which are queries rather than mirror
8965
+ /** Held for the ARCHIVE reads below, which are queries rather than mirror
9071
8966
  * lookups. Every DECISION path (`recordSegment`, `dropSegments`, the prune
9072
8967
  * refusals) still answers from the mirror alone — D49 is about gates, and an
9073
8968
  * accounting number is not a gate. */
9074
8969
  store;
8970
+ listHour;
9075
8971
  isLocationUsable;
9076
8972
  now;
9077
8973
  loadChunk;
@@ -9085,11 +8981,32 @@ var SegmentHourLedger = class SegmentHourLedger {
9085
8981
  lanes = /* @__PURE__ */ new Map();
9086
8982
  /** Per (device, profile): the hour its writer last appended to. */
9087
8983
  writerHours = /* @__PURE__ */ new Map();
8984
+ /**
8985
+ * Segment paths THIS PROCESS has already accounted for, per hour key.
8986
+ *
8987
+ * The `paths` column used to make the write path idempotent for free: an
8988
+ * append checked `held.paths.includes(path)`. With the column gone the
8989
+ * aggregate cannot say whether it already counted a name, so the dedup lives
8990
+ * here — bounded to the hours this process actually writes (two per writer:
8991
+ * the current hour and the one it just left), dropped when the writer moves
8992
+ * on. A duplicate that survives a RESTART is not caught here and is not
8993
+ * silent either: the row is unsealed, and the first listing of the closed
8994
+ * hour re-derives the aggregate exactly ({@link adoptWalkedHour}).
8995
+ */
8996
+ accounted = /* @__PURE__ */ new Map();
9088
8997
  /** Hour keys a write was REFUSED for in this process — never sealed by it. */
9089
8998
  refused = /* @__PURE__ */ new Set();
8999
+ /**
9000
+ * `${deviceId}|${profile}|${hourStartMs}` a LOSS was reported for, location
9001
+ * left out on purpose: the party that sees the loss (the playlist watcher)
9002
+ * knows the camera and the profile and has no business knowing which disk
9003
+ * the recorder happens to be placing that hour on.
9004
+ */
9005
+ refusedWriterHours = /* @__PURE__ */ new Set();
9090
9006
  constructor(deps) {
9091
9007
  this.logger = deps.logger;
9092
9008
  this.store = deps.store;
9009
+ this.listHour = deps.listHour ?? null;
9093
9010
  this.isLocationUsable = deps.isLocationUsable ?? (() => true);
9094
9011
  this.now = deps.now ?? (() => Date.now());
9095
9012
  this.loadChunk = deps.loadPacing?.chunkSize ?? LOAD_CHUNK;
@@ -9108,22 +9025,17 @@ var SegmentHourLedger = class SegmentHourLedger {
9108
9025
  return this.ledger.declare();
9109
9026
  }
9110
9027
  /**
9111
- * Boot load — **T1 only** (D287).
9028
+ * Boot load — every hour row, in one query.
9112
9029
  *
9113
- * One projected query over {@link SEGMENT_HOUR_SUMMARY_COLUMNS}, folded into
9114
- * the mirror in chunks with a yield between them, then one point-read page
9115
- * that materialises the live tail so the write path can gate from RAM.
9030
+ * Since D523 the whole row is ten scalars, so there is no second tier to
9031
+ * materialise and no projection to choose: the mirror this returns is the
9032
+ * ledger, entire. It keeps `DurableLedger.load`'s two rules — a failed read
9033
+ * RETURNS WHAT IS ALREADY MIRRORED rather than clearing it, and a malformed
9034
+ * row is skipped rather than repaired.
9116
9035
  *
9117
- * It replaces `DurableLedger.load`, which fetches whole rows and therefore
9118
- * the whole archive's path strings, and it keeps that primitive's two rules:
9119
- * a failed read RETURNS WHAT IS ALREADY MIRRORED rather than clearing it,
9120
- * and a malformed row is skipped rather than repaired.
9121
- *
9122
- * It deliberately does NOT replay the archive into {@link RecordingIndex}.
9123
- * `hydrated` now means "the rows are resident", so a ledger-known hour is
9124
- * merely *hydratable from T3*: the first read of it pays one point read
9125
- * ({@link detailForWindow}), and an hour whose tail the ledger is missing is
9126
- * no longer permanently invisible to playback.
9036
+ * It deliberately does NOT replay the archive into {@link RecordingIndex}: a
9037
+ * row says how MUCH an hour holds, never which files, so only a listing can
9038
+ * seed the index (D523).
9127
9039
  */
9128
9040
  async load() {
9129
9041
  const startedMs = this.now();
@@ -9131,7 +9043,6 @@ var SegmentHourLedger = class SegmentHourLedger {
9131
9043
  try {
9132
9044
  records = await this.store.query.query({
9133
9045
  collection: RECORDING_SEGMENT_HOURS_COLLECTION,
9134
- columns: SEGMENT_HOUR_SUMMARY_COLUMNS,
9135
9046
  filter: { limit: this.rowCap }
9136
9047
  });
9137
9048
  } catch (err) {
@@ -9148,7 +9059,7 @@ var SegmentHourLedger = class SegmentHourLedger {
9148
9059
  const end = Math.min(i + this.loadChunk, records.length);
9149
9060
  for (let j = i; j < end; j += 1) {
9150
9061
  const record = records[j];
9151
- const row = summaryToRow(record.id, record.data);
9062
+ const row = recordToRow(record.id, record.data);
9152
9063
  if (row === null) {
9153
9064
  skipped += 1;
9154
9065
  continue;
@@ -9157,7 +9068,7 @@ var SegmentHourLedger = class SegmentHourLedger {
9157
9068
  }
9158
9069
  if (end < records.length) await this.yieldBetween();
9159
9070
  }
9160
- if (skipped > 0) this.logger.warn("recorder: segment-hour summaries skipped as malformed — they gate NOTHING", { meta: {
9071
+ if (skipped > 0) this.logger.warn("recorder: segment-hour rows skipped as malformed — they gate NOTHING", { meta: {
9161
9072
  collection: RECORDING_SEGMENT_HOURS_COLLECTION,
9162
9073
  skipped
9163
9074
  } });
@@ -9165,11 +9076,8 @@ var SegmentHourLedger = class SegmentHourLedger {
9165
9076
  limit: this.rowCap,
9166
9077
  loaded: records.length
9167
9078
  } });
9168
- const tail = await this.materializeLiveTail();
9169
9079
  this.logger.info("recorder: segment-hour ledger loaded", { meta: {
9170
9080
  hours: this.ledger.size,
9171
- pathsLoaded: false,
9172
- liveTailHours: tail,
9173
9081
  skipped,
9174
9082
  queryMs: queriedMs - startedMs,
9175
9083
  ms: this.now() - startedMs,
@@ -9177,8 +9085,7 @@ var SegmentHourLedger = class SegmentHourLedger {
9177
9085
  } });
9178
9086
  return this.ledger.snapshot();
9179
9087
  }
9180
- /** Every hour the ledger knows — summaries included. A row whose
9181
- * `pathsResident` is false carries real aggregates and empty `paths`. */
9088
+ /** Every hour row held. Pure RAM. */
9182
9089
  snapshot() {
9183
9090
  return this.ledger.snapshot();
9184
9091
  }
@@ -9188,10 +9095,10 @@ var SegmentHourLedger = class SegmentHourLedger {
9188
9095
  * The archive's answer to "what should placement plan for", and the reason it
9189
9096
  * is here rather than a `census` loop or an index read:
9190
9097
  *
9191
- * - `RecordingIndex.accountingByProfile` is the wrong authority. Since D287
9192
- * it holds the live tail plus whatever an operator hydrated, so a camera
9193
- * nobody has viewed since boot reports NO profiles — and the placement
9194
- * pass then never plans it (D295 §3a #9, D299).
9098
+ * - `RecordingIndex.accountingByProfile` is the wrong authority. It holds
9099
+ * the live tail plus whatever a reader hydrated, so a camera nobody has
9100
+ * viewed since boot reports NO profiles — and the placement pass then
9101
+ * never plans it (D295 §3a #9, D299).
9195
9102
  * - `census({ deviceId, profiles: [p] })` is the right authority but the
9196
9103
  * wrong shape for a 30 s tick: it is one SQL round trip per (camera,
9197
9104
  * profile), so a 16-camera fleet pays 48 SERIAL trips over the UDS door
@@ -9199,10 +9106,7 @@ var SegmentHourLedger = class SegmentHourLedger {
9199
9106
  * 240003` pass (see `PLACEMENT_STORE_TIMEOUT_MS`), and it grows with the
9200
9107
  * fleet.
9201
9108
  *
9202
- * So it is answered from T1, the hour summary D287 made permanently resident
9203
- * precisely so that O(writers × days) questions cost no I/O: ~10–15 MB and
9204
- * ~47 k rows even at 30 TB, one fold, no query. A hydrated segment row is not
9205
- * needed to know that an hour exists.
9109
+ * So it is answered from the mirror: one fold, no query, no I/O.
9206
9110
  *
9207
9111
  * An EMPTY result for a device is a real answer — "the archive knows no hour
9208
9112
  * for this camera" — and it is also what a ledger whose load failed reports,
@@ -9219,132 +9123,111 @@ var SegmentHourLedger = class SegmentHourLedger {
9219
9123
  }
9220
9124
  return out;
9221
9125
  }
9222
- /** Does the ledger hold a row for this hour at all? Pure RAM — the gate the
9223
- * storage-walk repair consults before it upserts a rebuilt hour. */
9126
+ /** Does the ledger hold a row for this hour at all? Pure RAM. */
9224
9127
  knowsHour(deviceId, profile, locationId, hour) {
9225
9128
  return this.ledger.has(segmentHourKey(deviceId, profile, locationId, hour));
9226
9129
  }
9227
- /**
9228
- * The paths of ONE held hour row, materialised — or `null` when they could
9229
- * not be read.
9230
- *
9231
- * {@link detailForWindow} answers per (device, window) and drops the profile
9232
- * on the way out, which is fine for a reader assembling a playlist and wrong
9233
- * for a caller that must act on one hour ROW: the ghost-location reconcile
9234
- * decides per `(deviceId, profile, locationId, hour)` and hands the resulting
9235
- * paths straight back to {@link dropSegments} / {@link repointSegments},
9236
- * whose bucket key is that same tuple.
9237
- *
9238
- * `null` is "known, and its detail could not be had" — never an empty array.
9239
- * The one caller FORGETS rows on this answer, and a failed point read that
9240
- * came back as `[]` would read as "this hour holds nothing", which is the
9241
- * D148 prune-on-no-evidence this ledger refuses to make.
9242
- */
9243
- async hourPaths(deviceId, profile, locationId, hour) {
9244
- const key = segmentHourKey(deviceId, profile, locationId, hour);
9245
- if (!this.ledger.has(key)) return null;
9246
- const held = await this.resident(key, deviceId);
9247
- return held === null ? null : held.paths;
9248
- }
9249
- /**
9250
- * The hours of `[fromMs, toMs)` the ledger knows, with their paths — the
9251
- * cold-hour detail read that replaces the boot replay (D287).
9252
- *
9253
- * One bounded point read against the settings store, never the media array:
9254
- * a `readdir` of one hour directory was measured at ~650 ms under load on
9255
- * the live share, and the array is the resource the scrub feeder is
9256
- * competing for. Hours the ledger does NOT know are absent from the result,
9257
- * and the caller walks those from disk exactly as it always did.
9258
- *
9259
- * **A read that fails returns what it already had** (D49). The caller sees
9260
- * fewer hours, the index stays `unknown` for the rest, and the window falls
9261
- * through to the walk — nothing is destroyed and nothing renders as an
9262
- * empty hour that was merely unread.
9263
- */
9264
- async detailForWindow(deviceId, fromMs, toMs) {
9265
- const first = hourStartMs(fromMs);
9266
- const out = [];
9267
- const wanted = [];
9268
- for (const row of this.ledger.snapshot()) {
9269
- if (row.deviceId !== deviceId) continue;
9270
- if (row.hourStartMs < first || row.hourStartMs >= toMs) continue;
9271
- if (row.pathsResident) {
9272
- out.push(detailOf(row));
9273
- continue;
9274
- }
9275
- wanted.push(row.key);
9276
- }
9277
- for (const row of await this.materialize(wanted, deviceId)) out.push(detailOf(row));
9278
- return out;
9130
+ /** One held hour row, or `undefined`. Pure RAM — since D523 a row carries
9131
+ * everything it has. */
9132
+ hourRow(deviceId, profile, locationId, hour) {
9133
+ return this.ledger.get(segmentHourKey(deviceId, profile, locationId, hour));
9279
9134
  }
9280
9135
  /**
9281
- * Union a CLOSED hour the storage walk read into its row, and SEAL it.
9136
+ * A LISTING of a CLOSED hour is adopted as that hour's account, and SEALS it.
9282
9137
  *
9283
- * The reader repairs the durable index it just reconstructed, so the next
9284
- * cold read of this hour is a point read instead of another `readdir`
9285
- * (`docs/design/2026-08-29-reference-nvr-recording-model.md` §4.2), and it
9286
- * seals the row because a directory read of a closed hour is the one
9287
- * complete answer there is. The caller guarantees the hour is CLOSED — a
9288
- * live hour's directory races its writer, so a walk of it is a lower bound.
9138
+ * The directory is the authority for which segments exist (D523), so a
9139
+ * listing of an hour that is OVER is the one complete answer there is — it
9140
+ * replaces the row's aggregate rather than merging with it, which is what
9141
+ * makes an hour that was over-counted (a duplicate finalize across a restart)
9142
+ * or under-counted (a writer torn down mid-hour) self-correcting. The caller
9143
+ * guarantees the hour is CLOSED: a live hour's directory races its writer, so
9144
+ * a listing of it is a lower bound.
9289
9145
  *
9290
- * A row the ledger already holds is UNIONED, never replaced: the walk can
9291
- * only add (D148) — a path the row names and the directory lacks stays,
9292
- * because "the reader did not see it" is not evidence it is gone. This is
9293
- * the repair D445 described and did not perform: its predecessor returned
9294
- * on any known key, so a row known INCOMPLETELY — the only kind that needs
9295
- * repairing — was exactly the row it refused to touch, and the export's
9296
- * `forgottenSegments=9758` was counted again the next morning.
9146
+ * A listing that would erase more than {@link HOUR_LISTING_MAX_SHRINK_SHARE}
9147
+ * of what the row accounts for is REFUSED and named. That is the one thing a
9148
+ * listing cannot be trusted about: a root that has gone away and a bucket a
9149
+ * dead round half-deleted both answer short, and neither is distinguishable
9150
+ * from an hour that is simply smaller than the row thought.
9297
9151
  *
9298
- * `null` when the row is known and its detail could not be read: nothing
9299
- * is written from a row whose paths are not resident.
9152
+ * @seal-authority closed-hour-listing — a directory read of an hour that is
9153
+ * OVER is the one complete answer there is; the caller guarantees the hour is
9154
+ * closed (D451, D521, D523).
9300
9155
  */
9301
9156
  async adoptWalkedHour(deviceId, profile, locationId, hour, paths) {
9302
9157
  const key = segmentHourKey(deviceId, profile, locationId, hour);
9158
+ const identity = {
9159
+ key,
9160
+ deviceId,
9161
+ profile,
9162
+ locationId,
9163
+ hourStartMs: hour
9164
+ };
9165
+ const walked = hourAggregate(paths);
9303
9166
  return this.serialized(key, async () => {
9304
- if (!this.ledger.has(key)) {
9305
- if (paths.length === 0) return {
9167
+ const held = this.ledger.get(key);
9168
+ if (held === void 0) {
9169
+ if (walked.segments === 0) return {
9306
9170
  created: false,
9307
9171
  added: 0,
9308
9172
  sealedNow: false
9309
9173
  };
9310
- const row = segmentHourRow({
9311
- key,
9312
- deviceId,
9313
- profile,
9314
- locationId,
9315
- hourStartMs: hour,
9316
- paths: uniquePaths(paths),
9174
+ await this.ledger.put(hourRowOf(identity, {
9175
+ ...walked,
9317
9176
  sealed: true
9318
- });
9319
- await this.ledger.put(row);
9320
- this.logger.info("recorder: adopted a closed hour the disk walk rebuilt and the ledger never held", {
9177
+ }));
9178
+ this.logger.info("recorder: adopted a closed hour the disk listing rebuilt and the ledger never held", {
9321
9179
  tags: { deviceId },
9322
9180
  meta: {
9323
9181
  profile,
9324
9182
  locationId,
9325
9183
  hourStartMs: hour,
9326
- segments: row.segments,
9327
- bytes: row.bytes
9184
+ segments: walked.segments,
9185
+ bytes: walked.bytes
9328
9186
  }
9329
9187
  });
9330
9188
  return {
9331
9189
  created: true,
9332
- added: row.paths.length,
9190
+ added: walked.segments,
9333
9191
  sealedNow: true
9334
9192
  };
9335
9193
  }
9336
- const held = await this.resident(key, deviceId);
9337
- if (held === null) return null;
9338
- const union = uniquePaths([...held.paths, ...paths]);
9339
- const added = union.length - held.paths.length;
9340
- if (added === 0 && held.sealed) return {
9194
+ const shrink = held.segments - walked.segments;
9195
+ if (shrink > 0 && shrink > held.segments * .5) {
9196
+ 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", {
9197
+ tags: { deviceId },
9198
+ meta: {
9199
+ profile,
9200
+ locationId,
9201
+ hourStartMs: hour,
9202
+ held: held.segments,
9203
+ listed: walked.segments,
9204
+ maxShare: HOUR_LISTING_MAX_SHRINK_SHARE
9205
+ }
9206
+ });
9207
+ return {
9208
+ created: false,
9209
+ added: 0,
9210
+ sealedNow: false
9211
+ };
9212
+ }
9213
+ const added = Math.max(0, walked.segments - held.segments);
9214
+ if (added === 0 && shrink === 0 && held.sealed) return {
9341
9215
  created: false,
9342
9216
  added: 0,
9343
9217
  sealedNow: false
9344
9218
  };
9345
- await this.ledger.put(segmentHourRow({
9346
- ...held,
9347
- paths: union,
9219
+ if (shrink > 0) this.logger.info("recorder: hour listing accounts for FEWER segments than the row did — the directory decides", {
9220
+ tags: { deviceId },
9221
+ meta: {
9222
+ profile,
9223
+ locationId,
9224
+ hourStartMs: hour,
9225
+ held: held.segments,
9226
+ listed: walked.segments
9227
+ }
9228
+ });
9229
+ await this.ledger.put(hourRowOf(identity, {
9230
+ ...walked,
9348
9231
  sealed: true
9349
9232
  }));
9350
9233
  return {
@@ -9374,19 +9257,74 @@ var SegmentHourLedger = class SegmentHourLedger {
9374
9257
  }
9375
9258
  /**
9376
9259
  * A finalize this ledger was NEVER handed — the fire-and-forget lane refused
9377
- * it. The hour's row is short by that path and this process is the only
9378
- * party that knows, so the hour must not be sealed by it; a reader walks it
9379
- * once instead, which is exactly what an unsealed row is for.
9260
+ * it. The hour's row is short by that segment and this process is the only
9261
+ * party that knows, so the hour must not be sealed by it; a listing corrects
9262
+ * it instead, which is exactly what an unsealed row is for.
9380
9263
  */
9381
9264
  markRefused(row) {
9382
9265
  this.refused.add(hourOf(row).key);
9383
9266
  }
9267
+ /**
9268
+ * Segments a writer produced that never reached {@link recordSegment} — the
9269
+ * watcher's `rolledOff`, the one moment the system KNOWS work was skipped.
9270
+ *
9271
+ * Every hour the loss spans is refused the seal, whatever else this process
9272
+ * believes about them: the ownership grace answers "was I here", and this
9273
+ * answers "did I get everything while I was". Camera 615's respawned writer
9274
+ * passed the first question for the hour it entered at the top and still held
9275
+ * a row short by 175 segments.
9276
+ *
9277
+ * Returns how many hours were marked, so the caller's line reports what its
9278
+ * report bought rather than that it was made.
9279
+ */
9280
+ markHoursRefused(span) {
9281
+ if (!Number.isFinite(span.fromMs) || !Number.isFinite(span.toMs)) return 0;
9282
+ const first = hourStartMs(Math.min(span.fromMs, span.toMs));
9283
+ const last = hourStartMs(Math.max(span.fromMs, span.toMs));
9284
+ let marked = 0;
9285
+ for (let hour = first; hour <= last && marked < 24; hour += HOUR_MS$4) {
9286
+ this.refusedWriterHours.add(writerHourKey(span.deviceId, span.profile, hour));
9287
+ marked += 1;
9288
+ }
9289
+ if (marked === 0) return 0;
9290
+ this.logger.warn("recorder: segments this writer produced never reached the hour ledger — these hours stay UNSEALED and a listing corrects them", {
9291
+ tags: { deviceId: span.deviceId },
9292
+ meta: {
9293
+ profile: span.profile,
9294
+ fromMs: first,
9295
+ toMs: last,
9296
+ hours: marked,
9297
+ segments: span.count,
9298
+ boundHours: 24
9299
+ }
9300
+ });
9301
+ return marked;
9302
+ }
9384
9303
  /** One write: the row as held, flagged sealed. Serialised on its key so it
9385
- * cannot land over an append that read the row before it. */
9304
+ * cannot land over an append that read the row before it.
9305
+ *
9306
+ * @seal-authority writer-owned-hour — the writer that LEAVES the hour, and
9307
+ * only when it entered within {@link WRITER_HOUR_ENTRY_GRACE_MS} of the
9308
+ * hour's start and no loss was reported against it (D521). */
9386
9309
  async sealHour(target) {
9387
9310
  await this.serialized(target.key, async () => {
9388
- if (this.refused.has(target.key)) {
9389
- 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", {
9311
+ const enteredAfterMs = target.entryMs - target.hourStartMs;
9312
+ if (enteredAfterMs > 9e4) {
9313
+ this.logger.warn("recorder: hour left unsealed — this writer entered it late, so it cannot claim the hour is complete", {
9314
+ tags: { deviceId: target.deviceId },
9315
+ meta: {
9316
+ profile: target.profile,
9317
+ locationId: target.locationId,
9318
+ hourStartMs: target.hourStartMs,
9319
+ enteredAfterMs,
9320
+ graceMs: WRITER_HOUR_ENTRY_GRACE_MS
9321
+ }
9322
+ });
9323
+ this.accounted.delete(target.key);
9324
+ return;
9325
+ }
9326
+ if (this.refused.has(target.key) || this.refusedWriterHours.has(writerHourKey(target.deviceId, target.profile, target.hourStartMs))) {
9327
+ 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", {
9390
9328
  tags: { deviceId: target.deviceId },
9391
9329
  meta: {
9392
9330
  profile: target.profile,
@@ -9394,10 +9332,12 @@ var SegmentHourLedger = class SegmentHourLedger {
9394
9332
  hourStartMs: target.hourStartMs
9395
9333
  }
9396
9334
  });
9335
+ this.accounted.delete(target.key);
9397
9336
  return;
9398
9337
  }
9399
- const held = await this.resident(target.key, target.deviceId);
9400
- if (held === null || held.sealed) return;
9338
+ const held = this.ledger.get(target.key);
9339
+ this.accounted.delete(target.key);
9340
+ if (held === void 0 || held.sealed) return;
9401
9341
  await this.ledger.put({
9402
9342
  ...held,
9403
9343
  sealed: true
@@ -9405,9 +9345,15 @@ var SegmentHourLedger = class SegmentHourLedger {
9405
9345
  });
9406
9346
  }
9407
9347
  /**
9408
- * Remember which hour this writer is on; when it MOVES FORWARD, seal the
9409
- * hour it left. Backwards is a late finalize (a recovered orphan, a replay)
9410
- * into an hour already accounted for, and seals nothing.
9348
+ * Remember which hour this writer is on — and how far into it this process
9349
+ * ARRIVED ({@link WriterHour.entryMs}, the first finalize's own start) —
9350
+ * and when it MOVES FORWARD, seal the hour it left. Backwards is a late
9351
+ * finalize (a recovered orphan, a replay) into an hour already accounted for,
9352
+ * and seals nothing.
9353
+ *
9354
+ * The entry stamp is kept from the FIRST finalize into the hour and never
9355
+ * refreshed: the question the seal has to answer is "was this writer here
9356
+ * when the hour began", and the last segment of the hour cannot answer it.
9411
9357
  */
9412
9358
  noteWriter(next) {
9413
9359
  const id = writerKey(next.deviceId, next.profile);
@@ -9419,11 +9365,25 @@ var SegmentHourLedger = class SegmentHourLedger {
9419
9365
  /**
9420
9366
  * Run `fn` after every mutation already queued on `key`.
9421
9367
  *
9422
- * A row is read, appended to and written back with awaits in between; two
9423
- * such runs on one key interleave, and the second write carries a paths
9424
- * array that never saw the first — the write-behind's own lost update. The
9425
- * lane makes "read, append, write" one step per key. A rejected step must
9426
- * not poison the lane: the next step still runs.
9368
+ * It was the lost-update guard: a row was read from the STORE, folded into
9369
+ * and written back with awaits in between, so two such runs on one key
9370
+ * interleaved and the second write carried a path set that never saw the
9371
+ * first — the sparse rows D451 found. Since D523 there is no store read on
9372
+ * any of these paths (the mirror holds the whole row) and `DurableLedger.put`
9373
+ * sets the mirror synchronously before it awaits the store, so every
9374
+ * get→fold→put here is synchronous up to the mirror and the interleaving
9375
+ * window is gone.
9376
+ *
9377
+ * What the lane still buys is the ORDER of the durable writes per hour key —
9378
+ * a rollover seal fired without an await must not land over an append that
9379
+ * was queued before it — and a handle for {@link flush} to wait on at
9380
+ * shutdown. A rejected step must not poison the lane: the next step still
9381
+ * runs.
9382
+ *
9383
+ * Note for anyone tempted to delete it: `segment-hour-seal.spec.ts` does NOT
9384
+ * catch its removal any more, and says so. Pinning durable write ORDER needs
9385
+ * a store whose latency a test can control, which `fakeSettingsStore` has no
9386
+ * way to express.
9427
9387
  */
9428
9388
  serialized(key, fn) {
9429
9389
  const run = (this.lanes.get(key) ?? Promise.resolve()).then(fn, fn);
@@ -9435,116 +9395,59 @@ var SegmentHourLedger = class SegmentHourLedger {
9435
9395
  return run;
9436
9396
  }
9437
9397
  /**
9438
- * Fetch whole rows for `keys` and promote them in the mirror.
9439
- *
9440
- * Paged and yielding: an export window can name thousands of hours, and one
9441
- * `whereIn` over all of them would rebuild the very whole-archive response
9442
- * D287 removed. A page that FAILS ends the materialise and returns what
9443
- * landed — partial detail is real detail, and the caller's fallback (a disk
9444
- * walk, or refusing a write) is strictly safer than a guess.
9398
+ * List the hour directories of `rows`, paced, and expand them into segment
9399
+ * rows.
9445
9400
  *
9446
- * A row the store returns but this cannot parse is EVICTED from the mirror
9447
- * rather than left as a summary that can never become resident: it re-seeds
9448
- * cold on the next write, which is `DurableLedger`'s rule 4 and the only
9449
- * escape from an hour permanently frozen against its own writer.
9401
+ * The one place the ledger touches the array. An hour whose listing FAILED
9402
+ * takes no part in the caller's pass and is SAID — a branch that drops work
9403
+ * silently reads as "there was nothing there", which for a drain or a prune
9404
+ * is the difference between "moved everything" and "moved what it could see".
9450
9405
  */
9451
- async materialize(keys, deviceId) {
9406
+ async listHours(rows, reason) {
9452
9407
  const out = [];
9453
- if (keys.length === 0) return out;
9454
- const tags = deviceId === void 0 ? {} : { tags: { deviceId } };
9455
- for (let i = 0; i < keys.length; i += HOUR_DETAIL_PAGE) {
9456
- const page = keys.slice(i, i + HOUR_DETAIL_PAGE);
9457
- let records;
9458
- try {
9459
- records = await this.store.query.query({
9460
- collection: RECORDING_SEGMENT_HOURS_COLLECTION,
9461
- filter: {
9462
- whereIn: { key: page },
9463
- limit: page.length
9464
- }
9465
- });
9466
- } catch (err) {
9467
- this.logger.warn("recorder: hour-detail point read failed — these hours stay summaries, and the caller walks the disk or declines the write", {
9468
- ...tags,
9408
+ if (rows.length === 0) return out;
9409
+ const lister = this.listHour;
9410
+ if (lister === null) {
9411
+ this.logger.warn("recorder: no hour lister on this ledger — nothing that needs segment NAMES can run here", { meta: {
9412
+ hours: rows.length,
9413
+ reason
9414
+ } });
9415
+ return out;
9416
+ }
9417
+ let listed = 0;
9418
+ for (const row of rows) {
9419
+ const paths = await lister(row.locationId, row.deviceId, row.profile, row.hourStartMs);
9420
+ if (paths === null) {
9421
+ this.logger.warn("recorder: hour directory could not be listed — this hour takes NO part in this pass", {
9422
+ tags: { deviceId: row.deviceId },
9469
9423
  meta: {
9470
- collection: RECORDING_SEGMENT_HOURS_COLLECTION,
9471
- asked: page.length,
9472
- materialized: out.length,
9473
- error: String(err)
9424
+ profile: row.profile,
9425
+ locationId: row.locationId,
9426
+ hourStartMs: row.hourStartMs,
9427
+ accounted: row.segments,
9428
+ reason
9474
9429
  }
9475
9430
  });
9476
- return out;
9431
+ continue;
9477
9432
  }
9478
- for (const record of records) {
9479
- const row = recordToRow(record.id, record.data);
9480
- if (row === null) {
9481
- this.ledger.evict(record.id);
9482
- this.logger.warn("recorder: hour row is unparseable — dropping the summary so the hour re-seeds cold instead of freezing its writer", {
9483
- ...tags,
9484
- meta: { key: record.id }
9485
- });
9486
- continue;
9433
+ const { rows: segments, unparseable } = segmentRowsOf(row.locationId, paths);
9434
+ if (unparseable > 0) this.logger.warn("recorder: hour directory holds names that are not segments", {
9435
+ tags: { deviceId: row.deviceId },
9436
+ meta: {
9437
+ profile: row.profile,
9438
+ locationId: row.locationId,
9439
+ hourStartMs: row.hourStartMs,
9440
+ unparseable,
9441
+ reason
9487
9442
  }
9488
- this.ledger.stage(row);
9489
- out.push(row);
9490
- }
9491
- if (i + HOUR_DETAIL_PAGE < keys.length) await this.yieldBetween();
9443
+ });
9444
+ out.push(...segments);
9445
+ listed += 1;
9446
+ if (listed % 8 === 0) await this.yieldBetween();
9492
9447
  }
9493
9448
  return out;
9494
9449
  }
9495
9450
  /**
9496
- * Materialise every hour these devices hold, and report how many stayed
9497
- * summaries.
9498
- *
9499
- * The reconcile is the one path that DELETES durable rows, so it may not run
9500
- * against a mirror that only half knows what it holds. This is a whole-device
9501
- * read and it is deliberately confined here: `reconcileFromIndex` follows an
9502
- * operator-armed rescan of that same device — a recursive `readdir` over
9503
- * >1M files — so a paged point read alongside it is noise, and it is the one
9504
- * caller whose correctness depends on completeness.
9505
- */
9506
- async materializeDevices(deviceIds) {
9507
- const keys = [];
9508
- for (const row of this.ledger.snapshot()) if (!row.pathsResident && deviceIds.has(row.deviceId)) keys.push(row.key);
9509
- if (keys.length === 0) return;
9510
- await this.materialize(keys);
9511
- const stillSummary = /* @__PURE__ */ new Map();
9512
- for (const row of this.ledger.snapshot()) {
9513
- if (row.pathsResident || !deviceIds.has(row.deviceId)) continue;
9514
- stillSummary.set(row.deviceId, (stillSummary.get(row.deviceId) ?? 0) + 1);
9515
- }
9516
- 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", {
9517
- tags: { deviceId },
9518
- meta: {
9519
- asked: keys.length,
9520
- unreadable
9521
- }
9522
- });
9523
- }
9524
- /** The hour a write path may touch: current + previous. Materialised at boot
9525
- * so {@link recordSegment}'s dedup gate answers from RAM (D49). */
9526
- async materializeLiveTail() {
9527
- const oldest = hourStartMs(this.now()) - (LIVE_TAIL_HOURS - 1) * HOUR_MS$3;
9528
- const keys = [];
9529
- for (const row of this.ledger.snapshot()) if (row.hourStartMs >= oldest && !row.pathsResident) keys.push(row.key);
9530
- return (await this.materialize(keys)).length;
9531
- }
9532
- /**
9533
- * Promote one HELD row to paths-resident, or answer `null`.
9534
- *
9535
- * `null` means "this hour is known and its detail could not be had" — the
9536
- * only honest answer to a failed point read, and every caller turns it into
9537
- * "do nothing". It is deliberately distinct from `this.ledger.get(key) ===
9538
- * undefined`, which means "no such hour" and is what licenses a fresh row.
9539
- */
9540
- async resident(key, deviceId) {
9541
- const held = this.ledger.get(key);
9542
- if (held !== void 0 && held.pathsResident) return held;
9543
- await this.materialize([key], deviceId);
9544
- const after = this.ledger.get(key);
9545
- return after !== void 0 && after.pathsResident ? after : null;
9546
- }
9547
- /**
9548
9451
  * Bytes / count / oldest / newest across a SET of storage locations, as ONE
9549
9452
  * `SUM`/`COUNT`/`MIN`/`MAX` over the hour rows.
9550
9453
  *
@@ -9585,9 +9488,9 @@ var SegmentHourLedger = class SegmentHourLedger {
9585
9488
  * ## Why two phases
9586
9489
  *
9587
9490
  * Phase one reads only the small columns and walks oldest-first until the
9588
- * accumulated bytes cover the target — normally one page. `paths` is by far
9589
- * the fattest column in the row and phase one never touches it. Phase two
9590
- * fetches paths for exactly the hours phase one chose.
9491
+ * accumulated bytes cover the target — normally one page. Phase two LISTS the
9492
+ * hour directories phase one chose, which is the only thing that can name the
9493
+ * files eviction is about to delete (D523).
9591
9494
  *
9592
9495
  * `exhausted` means the stream ran out before the target was met — the
9593
9496
  * caller's stop condition, and the one thing a bounded read must still be
@@ -9621,11 +9524,10 @@ var SegmentHourLedger = class SegmentHourLedger {
9621
9524
  /**
9622
9525
  * Every camera the ledger holds an hour for on these locations.
9623
9526
  *
9624
- * Answered from T1 — the always-resident hour summary, which carries
9625
- * `deviceId` and `locationId` — exactly like {@link profilesWithFootage},
9626
- * and for the same reason: this is an O(writers × days) question and it must
9627
- * cost no I/O. It runs on the disk-pressure path, where an I/O that fails
9628
- * would have to be turned into a verdict.
9527
+ * Answered from the mirror, exactly like {@link profilesWithFootage}, and for
9528
+ * the same reason: this is an O(writers × days) question and it must cost no
9529
+ * I/O. It runs on the disk-pressure path, where an I/O that fails would have
9530
+ * to be turned into a verdict.
9629
9531
  *
9630
9532
  * An EMPTY answer is "the mirror knows no hour here", which is also what a
9631
9533
  * ledger whose load failed reports — so the caller must treat it as "decide
@@ -9640,7 +9542,7 @@ var SegmentHourLedger = class SegmentHourLedger {
9640
9542
  * Bytes / segments / oldest / newest per CAMERA on these locations — the
9641
9543
  * per-camera half of the storage page, answered by the ARCHIVE (D418).
9642
9544
  *
9643
- * ## Why the T1 fold and not `census({ deviceId })` per camera
9545
+ * ## Why the mirror fold and not `census({ deviceId })` per camera
9644
9546
  *
9645
9547
  * `census` is the right authority for a SCOPE, and it cannot express this
9646
9548
  * scope: {@link SegmentHourLedger.predicate} takes ONE `locationId`, so
@@ -9650,14 +9552,10 @@ var SegmentHourLedger = class SegmentHourLedger {
9650
9552
  * accounts for. Per camera per volume that is `cameras × volumes` SQL
9651
9553
  * aggregates for one operator click, and it grows with the fleet.
9652
9554
  *
9653
- * The T1 hour summary answers all of them in ONE pass with no I/O, exactly
9654
- * as {@link devicesWithFootage} and {@link profilesWithFootage} already do,
9655
- * and it is the same rows `accountingForLocations` sums in SQL — the mirror
9656
- * is write-behind, so it is never BEHIND the store. The cost is one pass
9657
- * over the hour rows the mirror holds — O(writers × profiles × hours), ~19 k
9658
- * on the live hub (see {@link REPAIR_MAX_ROWS}) against 98 445 segments,
9659
- * i.e. it does NOT scale with the segment count — and it is capped for good
9660
- * by {@link SEGMENT_HOUR_SUMMARY_LIMIT}, the mirror's own ceiling.
9555
+ * The mirror answers all of them in ONE pass with no I/O, exactly as
9556
+ * {@link devicesWithFootage} and {@link profilesWithFootage} already do, and
9557
+ * it is the same rows `accountingForLocations` sums in SQL — the mirror is
9558
+ * write-behind, so it is never BEHIND the store.
9661
9559
  *
9662
9560
  * ## What an empty answer means
9663
9561
  *
@@ -9731,44 +9629,28 @@ var SegmentHourLedger = class SegmentHourLedger {
9731
9629
  rows: [],
9732
9630
  exhausted: true
9733
9631
  };
9734
- const full = await this.store.query.query({
9735
- collection: RECORDING_SEGMENT_HOURS_COLLECTION,
9736
- filter: {
9737
- whereIn: { key: chosen },
9738
- limit: chosen.length
9739
- }
9740
- });
9741
- const rows = [];
9742
- for (const record of full) {
9743
- const hour = recordToRow(record.id, record.data);
9744
- if (hour === null) continue;
9745
- this.ledger.stage(hour);
9746
- for (const path of hour.paths) {
9747
- const parsed = parseSegmentPath(path);
9748
- if (parsed === null) continue;
9749
- rows.push({
9750
- deviceId: parsed.deviceId,
9751
- profile: parsed.profile,
9752
- startMs: parsed.startMs,
9753
- durMs: parsed.durMs,
9754
- bytes: parsed.bytes,
9755
- path,
9756
- locationId: hour.locationId
9757
- });
9758
- }
9759
- }
9760
9632
  return {
9761
- rows: rows.toSorted((a, b) => a.startMs - b.startMs),
9633
+ rows: (await this.listHours(this.rowsOfKeys(chosen), "oldest-segments")).toSorted((a, b) => a.startMs - b.startMs),
9762
9634
  exhausted
9763
9635
  };
9764
9636
  }
9637
+ /** Mirror rows for keys a probe named. A key the mirror does not hold is
9638
+ * skipped: the probe and the mirror describe the same table, and an hour the
9639
+ * mirror never loaded is one this process may not act on. */
9640
+ rowsOfKeys(keys) {
9641
+ const out = [];
9642
+ for (const key of keys) {
9643
+ const row = this.ledger.get(key);
9644
+ if (row !== void 0) out.push(row);
9645
+ }
9646
+ return out;
9647
+ }
9765
9648
  /**
9766
9649
  * The predicate half of an {@link ArchiveScope}, as one filter.
9767
9650
  *
9768
9651
  * REFUSES an empty scope rather than compiling to "the whole collection".
9769
- * There is no read here that may be unscoped: at 30 TB the unscoped form is
9770
- * the ~460 MB boot query D287 deleted, and a helper that quietly allows it is
9771
- * how that query comes back under a new name.
9652
+ * There is no read here that may be unscoped: an unscoped stream lists every
9653
+ * hour directory on the hub.
9772
9654
  */
9773
9655
  static predicate(scope) {
9774
9656
  const where = {};
@@ -9781,6 +9663,15 @@ var SegmentHourLedger = class SegmentHourLedger {
9781
9663
  ...profiles.length > 0 ? { whereIn: { profile: [...profiles] } } : {}
9782
9664
  };
9783
9665
  }
9666
+ /** Does this row fall inside `scope`? The mirror-side twin of
9667
+ * {@link SegmentHourLedger.predicate}, for the passes that fold the mirror
9668
+ * instead of querying. */
9669
+ static inScope(row, scope) {
9670
+ if (scope.locationId !== void 0 && row.locationId !== scope.locationId) return false;
9671
+ if (scope.deviceId !== void 0 && row.deviceId !== scope.deviceId) return false;
9672
+ const profiles = scope.profiles ?? [];
9673
+ return profiles.length === 0 || profiles.includes(row.profile);
9674
+ }
9784
9675
  /**
9785
9676
  * What the ARCHIVE holds in this scope — segments and bytes, as ONE
9786
9677
  * `SUM` over the hour rows.
@@ -9824,6 +9715,15 @@ var SegmentHourLedger = class SegmentHourLedger {
9824
9715
  /**
9825
9716
  * Every segment in `scope`, oldest-first, ONE PAGE at a time.
9826
9717
  *
9718
+ * ## Two phases, and which authority answers which
9719
+ *
9720
+ * Phase one asks the TABLE which hours the scope holds, oldest-first — the
9721
+ * ordered aggregate no directory read can answer. Phase two LISTS those hour
9722
+ * directories, which is the only authority for the file names the caller is
9723
+ * about to move or delete (D523). An hour whose listing fails is skipped and
9724
+ * said; the caller's own N-versus-M report is what makes the short walk
9725
+ * visible.
9726
+ *
9827
9727
  * ## Why a keyset cursor and not `offset`
9828
9728
  *
9829
9729
  * Both callers MUTATE the scope while they walk it: the drain re-points each
@@ -9840,10 +9740,9 @@ var SegmentHourLedger = class SegmentHourLedger {
9840
9740
  *
9841
9741
  * ## What a failed read does
9842
9742
  *
9843
- * It ENDS the stream (D49: a fallible read destroys nothing) — and the
9844
- * caller's own N-versus-M report is what makes the short walk visible. A
9845
- * stream that swallowed the error and returned would be indistinguishable
9846
- * from a scope that was genuinely empty.
9743
+ * It ENDS the stream (D49: a fallible read destroys nothing). A stream that
9744
+ * swallowed the error and returned would be indistinguishable from a scope
9745
+ * that was genuinely empty.
9847
9746
  */
9848
9747
  async *streamSegments(scope) {
9849
9748
  const predicate = SegmentHourLedger.predicate(scope);
@@ -9896,8 +9795,8 @@ var SegmentHourLedger = class SegmentHourLedger {
9896
9795
  if (nextCursor !== cursor) emittedAtCursor = /* @__PURE__ */ new Set();
9897
9796
  for (const record of fresh) if (hourOfRecord(record) === nextCursor) emittedAtCursor.add(record.id);
9898
9797
  cursor = nextCursor;
9899
- const rows = await this.rowsForHours(fresh.map((record) => record.id), scope);
9900
- if (rows.length > 0) yield rows;
9798
+ const rows = await this.listHours(this.rowsOfKeys(fresh.map((record) => record.id)), "archive-stream");
9799
+ if (rows.length > 0) yield rows.toSorted((a, b) => a.startMs - b.startMs);
9901
9800
  if (probe.length < 128 && fresh.length === probe.length) return;
9902
9801
  }
9903
9802
  this.logger.warn("recorder: archive stream hit its page backstop — the rest of this scope was NOT walked", {
@@ -9911,110 +9810,50 @@ var SegmentHourLedger = class SegmentHourLedger {
9911
9810
  });
9912
9811
  }
9913
9812
  /**
9914
- * Phase two of {@link streamSegments}: the paths of exactly the hours phase
9915
- * one chose, expanded into segment rows.
9813
+ * Forget every hour row in `scope` — the durable half of an operator's
9814
+ * "discard this location's footage".
9815
+ *
9816
+ * It exists because the files are already GONE by the time it runs: the
9817
+ * discard wipes the class subtrees first, so every hour directory in the
9818
+ * scope answers ENOENT and nothing that needs a listing can name a single
9819
+ * segment. The rows are forgotten by their own identity instead, and the
9820
+ * count returned is the SEGMENTS they accounted for, which is the number the
9821
+ * operator is shown.
9916
9822
  *
9917
- * Staged into the mirror on the way through, exactly as `oldestSegments`
9918
- * does and for the same reason: both consumers immediately call a write path
9919
- * (`repointSegments` / `dropSegments`) which needs the hour paths-resident,
9920
- * and which `forget`s the row as it empties — so the staging is
9921
- * self-cleaning and the alternative is two reads per hour.
9823
+ * Never reachable except from that explicitly-armed action: this is the one
9824
+ * surface here that deletes durable rows with no evidence from the array at
9825
+ * all, because the operator's own instruction is the evidence.
9922
9826
  */
9923
- async rowsForHours(keys, scope) {
9924
- if (keys.length === 0) return [];
9925
- let records;
9926
- try {
9927
- records = await this.store.query.query({
9928
- collection: RECORDING_SEGMENT_HOURS_COLLECTION,
9929
- filter: {
9930
- whereIn: { key: [...keys] },
9931
- limit: keys.length
9932
- }
9933
- });
9934
- } catch (err) {
9935
- this.logger.warn("recorder: archive page detail unavailable — these hours take NO part in this pass", {
9936
- ...scope.deviceId !== void 0 ? { tags: { deviceId: scope.deviceId } } : {},
9937
- meta: {
9938
- collection: RECORDING_SEGMENT_HOURS_COLLECTION,
9939
- hours: keys.length,
9940
- error: String(err)
9941
- }
9942
- });
9943
- return [];
9944
- }
9945
- const rows = [];
9946
- for (const record of records) {
9947
- const hour = recordToRow(record.id, record.data);
9948
- if (hour === null) continue;
9949
- this.ledger.stage(hour);
9950
- for (const path of hour.paths) {
9951
- const parsed = parseSegmentPath(path);
9952
- if (parsed === null) continue;
9953
- rows.push({
9954
- deviceId: parsed.deviceId,
9955
- profile: parsed.profile,
9956
- startMs: parsed.startMs,
9957
- durMs: parsed.durMs,
9958
- bytes: parsed.bytes,
9959
- path,
9960
- locationId: hour.locationId
9961
- });
9962
- }
9963
- }
9964
- return rows.toSorted((a, b) => a.startMs - b.startMs);
9965
- }
9966
9827
  /**
9967
- * Rewrite, once, the rows whose aggregate columns are still the schema
9968
- * default.
9828
+ * Forget ONE hour row outright, and answer the segments it accounted for.
9969
9829
  *
9970
- * `declareCollection` adds a column additively, so every row written before
9971
- * the aggregate columns existed holds `segments = 0` — and an hour row with
9972
- * no segments cannot exist any other way (`dropSegments` forgets an hour that
9973
- * empties). So `segments = 0` IS the backfill predicate, and after one pass
9974
- * it selects nothing. A partial repair finishes on the next boot; until then
9975
- * the affected disks UNDER-report, which prunes less and never more.
9830
+ * The caller must have TRUSTED evidence that the hour holds nothing — a
9831
+ * directory listing under a volume anchor that agreed twice (D49), or a
9832
+ * per-path confirmation against every root there is. This method makes no
9833
+ * such check itself and can make none: it is handed a verdict, not evidence.
9976
9834
  *
9977
- * Since D287 the boot load carries no `paths`, and `paths` is the authority
9978
- * this repair derives from — so the stale rows are MATERIALISED first, at
9979
- * most {@link REPAIR_MAX_MATERIALIZE} per boot. That bound is the point: an
9980
- * unbounded materialise here would be the whole-archive read this stage
9981
- * removed, wearing a different name.
9835
+ * It exists because {@link adoptWalkedHour} deliberately refuses an EMPTY
9836
+ * listing — "the walk found nothing" is what an unmounted root looks like —
9837
+ * so the one repair a listing cannot express is the one that empties an hour.
9982
9838
  */
9983
- async repairAggregates() {
9984
- let repaired = 0;
9985
- try {
9986
- const stale = await this.store.query.query({
9987
- collection: RECORDING_SEGMENT_HOURS_COLLECTION,
9988
- columns: REPAIR_PROBE_COLUMNS,
9989
- filter: {
9990
- where: { segments: 0 },
9991
- limit: REPAIR_MAX_ROWS
9992
- }
9993
- });
9994
- const pending = stale.map((record) => record.id).filter((key) => this.ledger.get(key)?.pathsResident === false);
9995
- 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: {
9996
- pending: pending.length,
9997
- perBoot: REPAIR_MAX_MATERIALIZE
9998
- } });
9999
- await this.materialize(pending.slice(0, REPAIR_MAX_MATERIALIZE));
10000
- for (const record of stale) {
10001
- const held = this.ledger.get(record.id);
10002
- if (held === void 0 || !held.pathsResident || held.segments === 0) continue;
10003
- await this.ledger.put(held);
10004
- repaired += 1;
10005
- }
10006
- } catch (err) {
10007
- this.logger.warn("recorder: segment-hour aggregate repair failed — disks under-report", { meta: {
10008
- collection: RECORDING_SEGMENT_HOURS_COLLECTION,
10009
- error: String(err)
10010
- } });
10011
- return repaired;
9839
+ async forgetHour(deviceId, profile, locationId, hour) {
9840
+ const key = segmentHourKey(deviceId, profile, locationId, hour);
9841
+ return this.serialized(key, async () => {
9842
+ const held = this.ledger.get(key);
9843
+ if (held === void 0) return 0;
9844
+ await this.ledger.forget(key);
9845
+ this.accounted.delete(key);
9846
+ return held.segments;
9847
+ });
9848
+ }
9849
+ async forgetScope(scope) {
9850
+ let forgotten = 0;
9851
+ for (const row of this.ledger.snapshot()) {
9852
+ if (!SegmentHourLedger.inScope(row, scope)) continue;
9853
+ await this.ledger.forget(row.key);
9854
+ forgotten += row.segments;
10012
9855
  }
10013
- if (repaired > 0) this.logger.info("recorder: segment-hour aggregate columns backfilled", { meta: {
10014
- rows: repaired,
10015
- collection: RECORDING_SEGMENT_HOURS_COLLECTION
10016
- } });
10017
- return repaired;
9856
+ return forgotten;
10018
9857
  }
10019
9858
  /**
10020
9859
  * Write-behind: a just-finalized segment joins its hour row.
@@ -10022,8 +9861,8 @@ var SegmentHourLedger = class SegmentHourLedger {
10022
9861
  * THE hot path — every profile-writer lands here every `segmentSeconds`,
10023
9862
  * ~3.4 times a second fleet-wide. The append is {@link appendSegmentPath}:
10024
9863
  * one `parseSegmentPath` and four scalar updates, whatever the hour already
10025
- * holds. It used to re-derive the whole hour, which made one hour
10026
- * O(N²) — 65 k parses at 360 segments, 1.46 M in a restart-storm hour.
9864
+ * holds — and since D523 it needs no read of the store at all, because the
9865
+ * mirror carries the whole row.
10027
9866
  */
10028
9867
  async recordSegment(row) {
10029
9868
  const { key, hourStartMs: hour } = hourOf(row);
@@ -10032,10 +9871,16 @@ var SegmentHourLedger = class SegmentHourLedger {
10032
9871
  deviceId: row.deviceId,
10033
9872
  profile: row.profile,
10034
9873
  locationId: row.locationId,
10035
- hourStartMs: hour
9874
+ hourStartMs: hour,
9875
+ entryMs: row.startMs
10036
9876
  });
10037
9877
  await this.serialized(key, async () => {
10038
- if (!this.ledger.has(key)) {
9878
+ const seen = this.accounted.get(key);
9879
+ if (seen !== void 0 && seen.has(row.path)) return;
9880
+ if (seen === void 0) this.accounted.set(key, new Set([row.path]));
9881
+ else seen.add(row.path);
9882
+ const held = this.ledger.get(key);
9883
+ if (held === void 0) {
10039
9884
  await this.ledger.put(segmentHourRow({
10040
9885
  key,
10041
9886
  deviceId: row.deviceId,
@@ -10046,25 +9891,19 @@ var SegmentHourLedger = class SegmentHourLedger {
10046
9891
  }));
10047
9892
  return;
10048
9893
  }
10049
- const held = await this.resident(key, row.deviceId);
10050
- if (held === null) {
10051
- this.refused.add(key);
10052
- this.logger.warn("recorder: hour detail unavailable — this finalized segment is NOT in the durable hour row yet", {
10053
- tags: { deviceId: row.deviceId },
10054
- meta: {
10055
- key,
10056
- path: row.path
10057
- }
10058
- });
10059
- return;
10060
- }
10061
- if (held.paths.includes(row.path)) return;
10062
9894
  await this.ledger.put(appendSegmentPath(held, row.path));
10063
9895
  });
10064
9896
  }
10065
9897
  /**
10066
- * Eviction confirmed these paths are gone from disk. Drop them; forget the
10067
- * hour when it holds nothing.
9898
+ * Eviction confirmed these segments are gone from disk. Take them out of the
9899
+ * hour's account; forget the hour when nothing is left.
9900
+ *
9901
+ * The aggregate shrinks by exactly what was removed. `minStartMs` /
9902
+ * `maxStartMs` are NOT re-derived — they cannot be, without a listing — so a
9903
+ * partly-evicted hour keeps extremes that may be older or newer than what
9904
+ * survives. That error is bounded by the hour itself (both values always lie
9905
+ * inside `[hourStartMs, hourStartMs + 1h)`), and the row is UNSEALED so the
9906
+ * next listing of it re-derives both exactly.
10068
9907
  */
10069
9908
  async dropSegments(rows) {
10070
9909
  const doomed = /* @__PURE__ */ new Map();
@@ -10074,70 +9913,50 @@ var SegmentHourLedger = class SegmentHourLedger {
10074
9913
  if (entry === void 0) {
10075
9914
  doomed.set(key, {
10076
9915
  deviceId: row.deviceId,
10077
- paths: new Set([row.path])
9916
+ bytes: row.bytes,
9917
+ segments: 1
10078
9918
  });
10079
9919
  continue;
10080
9920
  }
10081
- entry.paths.add(row.path);
10082
- }
10083
- for (const [key, { deviceId, paths }] of doomed) {
10084
- if (!this.ledger.has(key)) continue;
10085
- const held = await this.resident(key, deviceId);
10086
- if (held === null) {
10087
- 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", {
10088
- tags: { deviceId },
10089
- meta: {
10090
- key,
10091
- paths: paths.size
10092
- }
10093
- });
10094
- continue;
10095
- }
10096
- const next = held.paths.filter((p) => !paths.has(p));
10097
- if (next.length === 0) {
9921
+ entry.bytes += row.bytes;
9922
+ entry.segments += 1;
9923
+ }
9924
+ for (const [key, dropped] of doomed) await this.serialized(key, async () => {
9925
+ const held = this.ledger.get(key);
9926
+ if (held === void 0) return;
9927
+ const segments = held.segments - dropped.segments;
9928
+ if (segments <= 0) {
10098
9929
  await this.ledger.forget(key);
10099
- continue;
9930
+ this.accounted.delete(key);
9931
+ return;
10100
9932
  }
10101
- if (next.length === held.paths.length) continue;
10102
- await this.ledger.put(segmentHourRow({
9933
+ await this.ledger.put({
10103
9934
  ...held,
10104
- paths: next
10105
- }));
10106
- }
9935
+ sealed: false,
9936
+ segments,
9937
+ bytes: Math.max(0, held.bytes - dropped.bytes)
9938
+ });
9939
+ });
10107
9940
  }
10108
9941
  /**
10109
- * Move `rows` from the hour rows they are listed in today to the SAME hours
9942
+ * Move `rows` from the hour they are accounted under today to the SAME hour
10110
9943
  * at `toLocationId` — the DURABLE half of a relocation.
10111
9944
  *
10112
- * Without this the mover re-points only `RecordingIndex`, which is RAM. Since
10113
- * D287 a closed hour is hydrated from the ledger FIRST and the disk is not
10114
- * consulted at all when the ledger covered the window, so a relocated hour
10115
- * comes back at its OLD location on the next boot — paths under a root that
10116
- * no longer holds the file. Playback does not stat (that syscall per segment
10117
- * was removed deliberately), so the result is a silent black window; and once
10118
- * the drained location is deleted, `collectRangeSegments` drops the rows
10119
- * outright and the footage is invisible. That is the shape of the 2 131
10120
- * `recordings:high` ghost segments already on this hub.
9945
+ * Without this the mover re-points only `RecordingIndex`, which is RAM, and
9946
+ * the drained location keeps the bytes on every storage page for the life of
9947
+ * the hub. That is the shape of the 2 131 `recordings:high` ghost rows this
9948
+ * hub carried.
10121
9949
  *
10122
9950
  * **The ordering is the invariant, not an implementation detail.** The
10123
9951
  * destination row is written BEFORE the source row is shrunk, and the caller
10124
9952
  * has not yet deleted the source bytes — so the only state a crash can leave
10125
- * is "listed at BOTH locations, both of which hold the file", which resolves
10126
- * correctly whichever row hydrate happens to apply last. The reverse order
10127
- * has a window in which the path is in NO row, and a ledger-known hour marks
10128
- * the index `hydrated`: nothing would ever walk that hour again, and the
10129
- * segment would sit on disk permanently unreachable.
9953
+ * is "accounted at BOTH locations, both of which hold the files", which a
9954
+ * listing of either hour resolves. The reverse order has a window in which
9955
+ * the segments are accounted NOWHERE.
10130
9956
  *
10131
- * **Two path sets, not one.** A path can arrive at the target under a
10132
- * DIFFERENT name than it leaves the source under: a mover that found a file
10133
- * larger than its recorded size reconciles the row, and the byte count is
10134
- * part of the name. So the source row is shrunk by what is LEAVING it and the
10135
- * target row grows by what is ARRIVING — conflating them would leave the
10136
- * source row naming a file the caller is about to delete.
10137
- *
10138
- * Returns `false` when NOTHING was repointed and the caller must delete
10139
- * nothing. An hour whose detail could not be read decides nothing (D49) —
10140
- * the same refusal {@link dropSegments} makes for eviction.
9957
+ * Both rows come out UNSEALED: each carries an aggregate that arithmetic
9958
+ * moved rather than a listing derived, and the next walk of either hour fixes
9959
+ * whatever the arithmetic rounded.
10141
9960
  */
10142
9961
  async repointSegments(rows, toLocationId) {
10143
9962
  const byHour = /* @__PURE__ */ new Map();
@@ -10145,87 +9964,75 @@ var SegmentHourLedger = class SegmentHourLedger {
10145
9964
  if (row.locationId === toLocationId) continue;
10146
9965
  const { key } = hourOf(row);
10147
9966
  const entry = byHour.get(key);
10148
- if (entry === void 0) byHour.set(key, {
10149
- row,
10150
- leaving: new Set([row.fromPath ?? row.path]),
10151
- arriving: new Set([row.path])
10152
- });
10153
- else {
10154
- entry.leaving.add(row.fromPath ?? row.path);
10155
- entry.arriving.add(row.path);
10156
- }
10157
- }
10158
- for (const [sourceKey, { row, leaving, arriving }] of byHour) {
10159
- const held = await this.resident(sourceKey, row.deviceId);
10160
- if (held === null) {
10161
- this.logger.warn("recorder: hour detail unavailable — this relocation is REFUSED and nothing is deleted; the segments stay where they are", {
10162
- tags: { deviceId: row.deviceId },
10163
- meta: {
10164
- key: sourceKey,
10165
- toLocationId,
10166
- paths: leaving.size
10167
- }
10168
- });
10169
- return false;
10170
- }
10171
- const targetKey = segmentHourKey(row.deviceId, row.profile, toLocationId, held.hourStartMs);
10172
- const target = this.ledger.has(targetKey) ? await this.resident(targetKey, row.deviceId) : void 0;
10173
- if (target === null) {
10174
- this.logger.warn("recorder: TARGET hour detail unavailable — this relocation is REFUSED; appending to a summary would rewrite the hour as these paths alone", {
10175
- tags: { deviceId: row.deviceId },
10176
- meta: {
10177
- key: targetKey,
10178
- fromLocationId: row.locationId,
10179
- paths: arriving.size
10180
- }
9967
+ if (entry === void 0) {
9968
+ byHour.set(key, {
9969
+ row,
9970
+ bytes: row.bytes,
9971
+ segments: 1,
9972
+ minStartMs: row.startMs,
9973
+ maxStartMs: row.startMs
10181
9974
  });
10182
- return false;
9975
+ continue;
10183
9976
  }
10184
- const merged = uniquePaths([...target?.paths ?? [], ...arriving]);
10185
- await this.ledger.put(segmentHourRow({
9977
+ entry.bytes += row.bytes;
9978
+ entry.segments += 1;
9979
+ entry.minStartMs = Math.min(entry.minStartMs, row.startMs);
9980
+ entry.maxStartMs = Math.max(entry.maxStartMs, row.startMs);
9981
+ }
9982
+ for (const [sourceKey, moved] of byHour) {
9983
+ const hour = hourStartMs(moved.row.startMs);
9984
+ const targetKey = segmentHourKey(moved.row.deviceId, moved.row.profile, toLocationId, hour);
9985
+ const target = this.ledger.get(targetKey);
9986
+ const targetIdentity = {
10186
9987
  key: targetKey,
10187
- deviceId: row.deviceId,
10188
- profile: row.profile,
9988
+ deviceId: moved.row.deviceId,
9989
+ profile: moved.row.profile,
10189
9990
  locationId: toLocationId,
10190
- hourStartMs: held.hourStartMs,
10191
- paths: merged
9991
+ hourStartMs: hour
9992
+ };
9993
+ const targetEmpty = target === void 0 || target.segments === 0;
9994
+ await this.ledger.put(hourRowOf(targetIdentity, {
9995
+ sealed: false,
9996
+ bytes: (target?.bytes ?? 0) + moved.bytes,
9997
+ segments: (target?.segments ?? 0) + moved.segments,
9998
+ minStartMs: targetEmpty ? moved.minStartMs : Math.min(target.minStartMs, moved.minStartMs),
9999
+ maxStartMs: targetEmpty ? moved.maxStartMs : Math.max(target.maxStartMs, moved.maxStartMs)
10192
10000
  }));
10193
- const next = held.paths.filter((p) => !leaving.has(p));
10194
- if (next.length === 0) await this.ledger.forget(sourceKey);
10195
- else await this.ledger.put(segmentHourRow({
10001
+ const held = this.ledger.get(sourceKey);
10002
+ if (held === void 0) continue;
10003
+ const segments = held.segments - moved.segments;
10004
+ if (segments <= 0) {
10005
+ await this.ledger.forget(sourceKey);
10006
+ continue;
10007
+ }
10008
+ await this.ledger.put({
10196
10009
  ...held,
10197
- paths: next
10198
- }));
10010
+ sealed: false,
10011
+ segments,
10012
+ bytes: Math.max(0, held.bytes - moved.bytes)
10013
+ });
10199
10014
  }
10200
- return true;
10201
10015
  }
10202
10016
  /**
10203
- * After a disk walk of `deviceIds`, make the ledger match the index — with
10204
- * two D148 exceptions:
10017
+ * After a disk walk of `deviceIds`, make the ledger match what the index now
10018
+ * holds — with two D148 exceptions:
10205
10019
  *
10206
10020
  * - an hour the walk did not see is dropped ONLY if it is in the past;
10207
10021
  * the current hour may still hold a live writer whose file the readdir
10208
10022
  * raced;
10209
- * - the current hour UNIONs ledger paths with index paths rather than
10210
- * replacing, for the same race.
10023
+ * - the current hour takes the LARGER of the two accounts rather than the
10024
+ * walk's, for the same race.
10211
10025
  *
10212
10026
  * Devices not in `deviceIds` are left untouched (a partial walk must not
10213
10027
  * prune cameras it did not look at).
10214
10028
  *
10215
10029
  * And a walk this ledger does not TRUST prunes nothing at all — see
10216
- * {@link refusedPrunes}. What such a walk FOUND is still real, so its paths
10217
- * are unioned in; what it did not find decides nothing (D49).
10218
- *
10219
- * Since D287 the walked devices' hours are MATERIALISED first: every
10220
- * comparison below reads `row.paths`, and a summary's empty `paths` would
10221
- * read as "the ledger holds nothing here" — which is the union collapsing to
10222
- * the walk's own view, i.e. the prune the refusal machinery exists to
10223
- * prevent. An hour whose detail could not be read is left additive-only.
10030
+ * {@link refusedPrunes}. What such a walk FOUND is still real, so its larger
10031
+ * account is taken; what it did not find decides nothing (D49).
10224
10032
  */
10225
10033
  async reconcileFromIndex(index, deviceIds, nowMs) {
10226
10034
  const walked = new Set(deviceIds);
10227
10035
  const currentHour = hourStartMs(nowMs);
10228
- await this.materializeDevices(walked);
10229
10036
  const fromIndex = /* @__PURE__ */ new Map();
10230
10037
  for (const deviceId of deviceIds) for (const seg of index.segments(deviceId)) {
10231
10038
  const { key, hourStartMs: hour } = hourOf(seg);
@@ -10244,7 +10051,6 @@ var SegmentHourLedger = class SegmentHourLedger {
10244
10051
  const refused = this.refusedPrunes(walked, fromIndex);
10245
10052
  for (const row of this.ledger.snapshot()) {
10246
10053
  if (!walked.has(row.deviceId)) continue;
10247
- if (!row.pathsResident) continue;
10248
10054
  const disk = fromIndex.get(row.key);
10249
10055
  const additiveOnly = row.hourStartMs === currentHour || refused.devices.has(row.deviceId) || refused.locations.has(locationKey(row.deviceId, row.locationId));
10250
10056
  if (disk === void 0) {
@@ -10253,14 +10059,10 @@ var SegmentHourLedger = class SegmentHourLedger {
10253
10059
  continue;
10254
10060
  }
10255
10061
  if (additiveOnly) {
10256
- const union = uniquePaths([...disk.paths, ...row.paths]);
10257
- if (!samePaths(union, row.paths)) await this.ledger.put(segmentHourRow({
10258
- ...row,
10259
- paths: union
10260
- }));
10062
+ if (disk.segments > row.segments) await this.ledger.put(disk);
10261
10063
  continue;
10262
10064
  }
10263
- if (!samePaths(row.paths, disk.paths)) await this.ledger.put(disk);
10065
+ if (disk.segments !== row.segments || disk.bytes !== row.bytes) await this.ledger.put(disk);
10264
10066
  }
10265
10067
  for (const [key, disk] of fromIndex) {
10266
10068
  if (this.ledger.has(key)) continue;
@@ -10379,46 +10181,47 @@ function locationKey(deviceId, locationId) {
10379
10181
  * skips it forever. Under D148 as written, a ghost row can never be forgotten
10380
10182
  * by any mechanism — which is why the live ones have stood for thirteen days.
10381
10183
  *
10382
- * The resolution is that a walk is not the only trusted confirmation; it is a
10383
- * BULK one. The paths in an hour row are relative to a root
10384
- * (`590/high/2026/08/15/04/…m4s`), so each one can be resolved against every
10385
- * currently-resolvable root and asked about directly. **Per-path
10386
- * `storage.exists`, over every candidate root, for EVERY path of the hour, is
10387
- * the same confirmation a walk gives — done one syscall at a time instead of
10388
- * one `readdir` at a time.** That, and nothing weaker, is what licenses the
10184
+ * The resolution is that the ghost row's root does not resolve but its hour
10185
+ * DIRECTORY is addressable anywhere: the path convention
10186
+ * (`590/high/2026/08/15/04/`) is a function of the row's own fields, so the same
10187
+ * hour can be listed under every currently-resolvable root. **A listing of the
10188
+ * hour directory at every candidate root is exactly the walk D148 sanctions,
10189
+ * done one root at a time.** That, and nothing weaker, is what licenses the
10389
10190
  * forget. Two consequences ride with it:
10390
10191
  *
10391
- * - **Sampling is not evidence.** An hour is forgotten only when every one of
10392
- * its paths came back absent from every root. One unprobed path and the hour
10393
- * stands.
10394
- * - **An error is not an absence** (D49). A probe that THROWS ends the hour
10395
- * with nothing decided — never "not found".
10192
+ * - **Every root must answer.** An hour is forgotten only when every candidate
10193
+ * root listed its directory and every one of them came back EMPTY. One root
10194
+ * that could not answer and the hour stands.
10195
+ * - **An error is not an absence** (D49). A listing that fails, or that reports
10196
+ * "I could not decide", ends the hour with nothing decided — never "not
10197
+ * found". Whether an absent directory means "empty" or "this disk is gone" is
10198
+ * the lister's question, and it answers it by reading the ROOT.
10199
+ *
10200
+ * Until D523 this probed each of the row's `paths` at each root, one
10201
+ * `storage.exists` at a time — up to ~1 000 syscalls to prove one hour absent.
10202
+ * The column is gone and the row names no file, so the unit is the directory,
10203
+ * which is both cheaper and the same evidence.
10396
10204
  *
10397
10205
  * ## Repoint versus forget
10398
10206
  *
10399
10207
  * The asymmetry is the whole safety argument, and it is deliberately cheap in
10400
10208
  * the safe direction:
10401
10209
  *
10402
- * - **one path found anywhere ⇒ the row is MIS-POINTED, not orphaned.** It is
10403
- * repointed to that root's location and the hour is finished — no further
10404
- * probing. A renamed or re-created location produces exactly this shape, and
10405
- * deleting here would destroy the index of footage that EXISTS, which is the
10406
- * failure D291 was written to prevent.
10407
- * - **every path absent everywhere ⇒ orphaned**, and only then forgotten.
10408
- *
10409
- * So proving presence costs one probe and proving absence costs all of them,
10410
- * which is the right way round: the expensive proof is the destructive one.
10210
+ * - **a non-empty listing at any root ⇒ the row is MIS-POINTED, not orphaned.**
10211
+ * It is repointed to that root's location, with the segments that listing
10212
+ * names, and the hour is finished. A renamed or re-created location produces
10213
+ * exactly this shape, and deleting here would destroy the index of footage
10214
+ * that EXISTS, which is the failure D291 was written to prevent.
10215
+ * - **every root lists an EMPTY hour ⇒ orphaned**, and only then forgotten.
10411
10216
  *
10412
10217
  * ## Pacing
10413
10218
  *
10414
- * The recordings array is saturated and slow (a `readdir` of one hour
10415
- * directory measured ~650 ms under load, and a `find` over it hung an SSH
10416
- * session on 2026-08-29). This is a janitor: bounded hours per pass, a hard
10417
- * probe ceiling, and a caller-supplied `pace()` awaited between probe batches.
10418
- * Work it does not finish is left for the next pass and REPORTED, never
10419
- * silently dropped.
10219
+ * The recordings array is the resource every live read competes for. This is a
10220
+ * janitor: bounded hours per pass, a hard probe ceiling, and a caller-supplied
10221
+ * `pace()` awaited between probe batches. Work it does not finish is left for
10222
+ * the next pass and REPORTED, never silently dropped.
10420
10223
  */
10421
- var HOUR_MS$2 = 36e5;
10224
+ var HOUR_MS$3 = 36e5;
10422
10225
  /**
10423
10226
  * Group resolved locations by PHYSICAL root, so two aliases of one disk are
10424
10227
  * probed once and a `true` from each is not mistaken for the file existing in
@@ -10527,7 +10330,7 @@ var GhostLocationSweeper = class {
10527
10330
  refused: null
10528
10331
  };
10529
10332
  }
10530
- const liveTailFrom = this.deps.now() - HOUR_MS$2;
10333
+ const liveTailFrom = this.deps.now() - HOUR_MS$3;
10531
10334
  let examined = 0;
10532
10335
  let probes = 0;
10533
10336
  let repointedHours = 0;
@@ -10565,22 +10368,12 @@ var GhostLocationSweeper = class {
10565
10368
  refuse(row, "live-tail", `hourStartMs=${row.hourStartMs}`);
10566
10369
  continue;
10567
10370
  }
10568
- const paths = await this.deps.ledger.hourPaths(row.deviceId, row.profile, row.locationId, row.hourStartMs);
10569
- if (paths === null) {
10570
- refuse(row, "detail-unavailable", "point read failed");
10571
- continue;
10572
- }
10573
- if (paths.length === 0) {
10574
- refuse(row, "no-paths", "the row materialised empty");
10575
- continue;
10576
- }
10577
- const rows = this.rowsOf(row, paths);
10578
- if (rows === null) {
10579
- refuse(row, "unparseable-paths", "a path does not parse into this hour");
10371
+ if (row.segments === 0) {
10372
+ refuse(row, "no-paths", "the row accounts for no segments");
10580
10373
  continue;
10581
10374
  }
10582
10375
  const before = probes;
10583
- const verdict = await this.probeHour(rows, roots, maxProbes - probes);
10376
+ const verdict = await this.probeHour(row, roots, maxProbes - probes, probes);
10584
10377
  probes += verdict.probes;
10585
10378
  tallyOf(row.deviceId).probes += probes - before;
10586
10379
  if (verdict.outcome.kind === "refused") {
@@ -10589,10 +10382,12 @@ var GhostLocationSweeper = class {
10589
10382
  }
10590
10383
  if (verdict.outcome.kind === "found") {
10591
10384
  const target = locationForHydratedProfile(verdict.outcome.root.aliases, row.profile);
10592
- if (!await this.deps.ledger.repointSegments(rows, target.id)) {
10593
- refuse(row, "durable-refused", `repoint to ${target.id} refused`);
10385
+ const rows = this.rowsOf(row, verdict.outcome.paths);
10386
+ if (rows === null) {
10387
+ refuse(row, "unparseable-paths", "a listed name does not parse into this hour");
10594
10388
  continue;
10595
10389
  }
10390
+ await this.deps.ledger.repointSegments(rows, target.id);
10596
10391
  this.deps.applyToIndex(rows, target.id);
10597
10392
  repointedHours += 1;
10598
10393
  segmentsRepointed += rows.length;
@@ -10602,19 +10397,19 @@ var GhostLocationSweeper = class {
10602
10397
  tally.targets.add(target.id);
10603
10398
  continue;
10604
10399
  }
10605
- await this.deps.ledger.dropSegments(rows);
10400
+ const forgotten = await this.deps.ledger.forgetHour(row.deviceId, row.profile, row.locationId, row.hourStartMs);
10606
10401
  if (this.deps.ledger.knowsHour(row.deviceId, row.profile, row.locationId, row.hourStartMs)) {
10607
- refuse(row, "durable-refused", "the ledger still holds the hour after the drop");
10402
+ refuse(row, "durable-refused", "the ledger still holds the hour after the forget");
10608
10403
  continue;
10609
10404
  }
10610
- this.deps.applyToIndex(rows, null);
10405
+ this.deps.forgetInIndex(row.deviceId, row.locationId, row.hourStartMs);
10611
10406
  forgottenHours += 1;
10612
- segmentsForgotten += rows.length;
10613
- bytesForgotten += rows.reduce((sum, r) => sum + r.bytes, 0);
10407
+ segmentsForgotten += forgotten;
10408
+ bytesForgotten += row.bytes;
10614
10409
  const tally = tallyOf(row.deviceId);
10615
10410
  tally.forgottenHours += 1;
10616
- tally.forgottenSegments += rows.length;
10617
- tally.forgottenBytes += rows.reduce((sum, r) => sum + r.bytes, 0);
10411
+ tally.forgottenSegments += forgotten;
10412
+ tally.forgottenBytes += row.bytes;
10618
10413
  }
10619
10414
  for (const [deviceId, tally] of byDevice) this.deps.logger.info("recorder: ghost-location reconcile — camera summary", {
10620
10415
  tags: { deviceId },
@@ -10696,60 +10491,82 @@ var GhostLocationSweeper = class {
10696
10491
  return out;
10697
10492
  }
10698
10493
  /**
10699
- * Ask every root about the hour's paths until one answers `true`.
10494
+ * List this hour's directory under EVERY candidate root.
10495
+ *
10496
+ * A non-empty listing at one root makes the hour mis-pointed. Two roots
10497
+ * holding it is a refusal, not a choice: repointing would be a guess about
10498
+ * which copy is the footage. Absence is only concluded when every root
10499
+ * answered and every answer was empty — a listing that could not decide
10500
+ * (`null`) or threw ends the hour with nothing decided (D49).
10501
+ *
10502
+ * The whole loop runs even after a hit, because "found at exactly one root"
10503
+ * is a claim about every root and cannot be short-circuited without giving up
10504
+ * the multi-root refusal.
10700
10505
  *
10701
- * Presence short-circuits the hour (one confirmed file makes it mis-pointed);
10702
- * absence does not, and cannot — the hour is orphaned only when EVERY path
10703
- * came back absent from EVERY root. A throw ends the hour with nothing
10704
- * decided.
10506
+ * `spent` is the listings the PASS has already made, not this hour's, and the
10507
+ * brake fires on that running total. Since D523 an hour costs one listing per
10508
+ * physical root — two or three on this hub — so a counter that reset per hour
10509
+ * would never reach {@link GHOST_PROBE_BATCH} and the `pace()` dep would be
10510
+ * dead on every real topology.
10705
10511
  */
10706
- async probeHour(rows, roots, budget) {
10512
+ async probeHour(row, roots, budget, spent) {
10707
10513
  let used = 0;
10708
- for (const row of rows) {
10709
- const found = [];
10710
- for (const root of roots) {
10711
- if (used >= budget) return {
10514
+ const found = [];
10515
+ for (const root of roots) {
10516
+ if (used >= budget) return {
10517
+ outcome: {
10518
+ kind: "refused",
10519
+ reason: "probe-budget",
10520
+ detail: `stopped after ${used} listings with ${roots.length} roots to probe`
10521
+ },
10522
+ probes: used
10523
+ };
10524
+ const alias = locationForHydratedProfile(root.aliases, row.profile);
10525
+ used += 1;
10526
+ if ((spent + used) % 16 === 0) await this.deps.pace();
10527
+ let listing;
10528
+ try {
10529
+ listing = await this.deps.listHour(alias.id, row.deviceId, row.profile, row.hourStartMs);
10530
+ } catch (err) {
10531
+ return {
10712
10532
  outcome: {
10713
10533
  kind: "refused",
10714
- reason: "probe-budget",
10715
- detail: `stopped after ${used} probes with ${rows.length} paths in the hour`
10534
+ reason: "probe-error",
10535
+ detail: `${alias.id}: ${errMsg(err)}`
10716
10536
  },
10717
10537
  probes: used
10718
10538
  };
10719
- const alias = locationForHydratedProfile(root.aliases, row.profile);
10720
- used += 1;
10721
- if (used % 16 === 0) await this.deps.pace();
10722
- try {
10723
- if (await this.deps.exists(alias.id, row.path)) found.push(root);
10724
- } catch (err) {
10725
- return {
10726
- outcome: {
10727
- kind: "refused",
10728
- reason: "probe-error",
10729
- detail: `${alias.id}: ${errMsg(err)}`
10730
- },
10731
- probes: used
10732
- };
10733
- }
10734
10539
  }
10735
- if (found.length > 1) return {
10540
+ if (listing === null) return {
10736
10541
  outcome: {
10737
10542
  kind: "refused",
10738
- reason: "found-at-multiple-roots",
10739
- detail: found.map((root) => root.key).join(", ")
10740
- },
10741
- probes: used
10742
- };
10743
- const only = found[0];
10744
- if (only !== void 0) return {
10745
- outcome: {
10746
- kind: "found",
10747
- root: only,
10748
- path: row.path
10543
+ reason: "detail-unavailable",
10544
+ detail: `${alias.id} could not say what its copy of this hour holds`
10749
10545
  },
10750
10546
  probes: used
10751
10547
  };
10548
+ if (listing.length > 0) found.push({
10549
+ root,
10550
+ paths: listing
10551
+ });
10752
10552
  }
10553
+ if (found.length > 1) return {
10554
+ outcome: {
10555
+ kind: "refused",
10556
+ reason: "found-at-multiple-roots",
10557
+ detail: found.map((hit) => hit.root.key).join(", ")
10558
+ },
10559
+ probes: used
10560
+ };
10561
+ const only = found[0];
10562
+ if (only !== void 0) return {
10563
+ outcome: {
10564
+ kind: "found",
10565
+ root: only.root,
10566
+ paths: only.paths
10567
+ },
10568
+ probes: used
10569
+ };
10753
10570
  return {
10754
10571
  outcome: { kind: "absent" },
10755
10572
  probes: used
@@ -11109,7 +10926,7 @@ function createRecordingConfigMirror(deps) {
11109
10926
  }
11110
10927
  //#endregion
11111
10928
  //#region src/recorder/addon/ledger-walk-reconcile.ts
11112
- var HOUR_MS$1 = 36e5;
10929
+ var HOUR_MS$2 = 36e5;
11113
10930
  /** Every reason at zero, so a reason that never fired reports as zero rather
11114
10931
  * than absent and the summary shape is constant between passes. */
11115
10932
  function zeroReasons() {
@@ -11130,34 +10947,12 @@ function emptyTally() {
11130
10947
  orphanFiles: 0
11131
10948
  };
11132
10949
  }
11133
- /** The basename of a segment path — what a listing returns. */
11134
- function fileName(relPath) {
11135
- const cut = relPath.lastIndexOf("/");
11136
- return cut === -1 ? relPath : relPath.slice(cut + 1);
11137
- }
11138
- /** Group one archive page into whole hours. A page always carries complete hour
11139
- * rows (`streamSegments` expands them whole), so no hour is ever split. */
11140
- function claimsOf(rows) {
11141
- const byHour = /* @__PURE__ */ new Map();
11142
- for (const row of rows) {
11143
- const hour = hourStartMs(row.startMs);
11144
- const key = `${row.deviceId}|${row.profile}|${hour}`;
11145
- const held = byHour.get(key);
11146
- if (held) held.push(row);
11147
- else byHour.set(key, [row]);
11148
- }
11149
- const out = [];
11150
- for (const group of byHour.values()) {
11151
- const first = group[0];
11152
- if (first === void 0) continue;
11153
- out.push({
11154
- deviceId: first.deviceId,
11155
- profile: first.profile,
11156
- hourStartMs: hourStartMs(first.startMs),
11157
- rows: group
11158
- });
11159
- }
11160
- return out.toSorted((a, b) => a.hourStartMs - b.hourStartMs || a.deviceId - b.deviceId);
10950
+ /** Does this hour row fall inside the scope the operator asked for? */
10951
+ function inScope(row, scope) {
10952
+ if (scope.locationId !== void 0 && row.locationId !== scope.locationId) return false;
10953
+ if (scope.deviceId !== void 0 && row.deviceId !== scope.deviceId) return false;
10954
+ const profiles = scope.profiles ?? [];
10955
+ return profiles.length === 0 || profiles.includes(row.profile);
11161
10956
  }
11162
10957
  /**
11163
10958
  * One reconcile pass over one location's claimed hours.
@@ -11205,7 +11000,7 @@ var LedgerWalkReconciler = class {
11205
11000
  ledger,
11206
11001
  apply,
11207
11002
  anchor: anchor.volumeId,
11208
- liveTailFrom: hourStartMs(this.deps.now()) - 1 * HOUR_MS$1,
11003
+ liveTailFrom: hourStartMs(this.deps.now()) - 1 * HOUR_MS$2,
11209
11004
  maxHours: this.deps.maxHours ?? 4096,
11210
11005
  byDevice: /* @__PURE__ */ new Map(),
11211
11006
  skippedByReason: zeroReasons(),
@@ -11255,20 +11050,35 @@ var LedgerWalkReconciler = class {
11255
11050
  this.reportPass(report);
11256
11051
  return report;
11257
11052
  }
11258
- /** Walk the archive page by page, one hour at a time. */
11053
+ /**
11054
+ * Walk the hour ROWS of the scope, oldest first.
11055
+ *
11056
+ * From the MIRROR, not a query. Since D523 a row is ten scalars, so the whole
11057
+ * claim set is already in RAM and costs nothing to fold — and the pass mutates
11058
+ * what it walks, which is exactly the shape that makes `offset` paging skip
11059
+ * half a scope. A snapshot taken once cannot skip.
11060
+ */
11259
11061
  async walk(state, scope) {
11260
- for await (const page of state.ledger.streamSegments(scope)) for (const claim of claimsOf(page)) {
11062
+ const claims = state.ledger.snapshot().filter((row) => inScope(row, scope)).toSorted((a, b) => a.hourStartMs - b.hourStartMs || a.deviceId - b.deviceId);
11063
+ for (const claim of claims) {
11261
11064
  if (state.hoursClaimed >= state.maxHours) {
11262
11065
  state.bounded = true;
11263
11066
  return;
11264
11067
  }
11265
11068
  state.hoursClaimed += 1;
11266
- state.segmentsClaimed += claim.rows.length;
11069
+ state.segmentsClaimed += claim.segments;
11267
11070
  if (!await this.reconcileHour(state, claim)) return;
11268
11071
  }
11269
11072
  }
11270
11073
  /**
11271
- * Diff ONE hour, and forget what it proved absent.
11074
+ * Reconcile ONE hour: what its row ACCOUNTS FOR against what its directory
11075
+ * HOLDS, and repair the account from the directory.
11076
+ *
11077
+ * Since D523 the row names no files, so this is a comparison of counts and
11078
+ * bytes — and it does not need to name them: the repair is "make the account
11079
+ * the listing's", which is exact, and the RESIDENT index is reset to the same
11080
+ * listing so a row it holds for a file the directory does not is dropped with
11081
+ * it.
11272
11082
  *
11273
11083
  * `false` ends the whole pass — the only thing that returns it is an anchor
11274
11084
  * that stopped agreeing, and every listing taken after that point is about a
@@ -11281,10 +11091,6 @@ var LedgerWalkReconciler = class {
11281
11091
  return true;
11282
11092
  }
11283
11093
  const relDir = hourDirRelPath(claim.deviceId, claim.profile, claim.hourStartMs);
11284
- for (const row of claim.rows) if (parseSegmentPath(row.path) === null || row.path !== `${relDir}/${fileName(row.path)}`) {
11285
- this.skip(state, claim, "path-mismatch", row.path);
11286
- return true;
11287
- }
11288
11094
  let listing;
11289
11095
  try {
11290
11096
  listing = await this.deps.listHour(state.location.root, relDir);
@@ -11296,36 +11102,55 @@ var LedgerWalkReconciler = class {
11296
11102
  if (state.listings % 8 === 0) await this.deps.pace();
11297
11103
  state.hoursWalked += 1;
11298
11104
  tally.hoursWalked += 1;
11299
- const diff = this.diff(claim, listing);
11300
- if (diff.missingDirectory) {
11105
+ const missingDirectory = listing === null;
11106
+ if (missingDirectory) {
11301
11107
  state.hoursMissing += 1;
11302
11108
  tally.hoursMissing += 1;
11303
11109
  }
11304
- if (diff.orphans.length > 0) {
11305
- state.orphanFiles += diff.orphans.length;
11306
- tally.orphanFiles += diff.orphans.length;
11307
- for (const orphan of diff.orphans) {
11110
+ const present = [];
11111
+ for (const name of listing ?? []) {
11112
+ if (!name.endsWith(".m4s")) continue;
11113
+ const relPath = `${relDir}/${name}`;
11114
+ const parsed = parseSegmentPath(relPath);
11115
+ if (parsed === null || hourStartMs(parsed.startMs) !== claim.hourStartMs) {
11116
+ this.skip(state, claim, "path-mismatch", relPath);
11117
+ return true;
11118
+ }
11119
+ present.push(relPath);
11120
+ }
11121
+ const listed = hourAggregate(present);
11122
+ const orphans = Math.max(0, listed.segments - claim.segments);
11123
+ if (orphans > 0) {
11124
+ state.orphanFiles += orphans;
11125
+ tally.orphanFiles += orphans;
11126
+ for (const path of present) {
11308
11127
  if (state.orphanSample.length >= 50) break;
11309
- state.orphanSample.push(`${relDir}/${orphan}`);
11128
+ state.orphanSample.push(path);
11310
11129
  }
11311
- this.deps.logger.warn("recorder: files under a claimed hour that the durable ledger does NOT name — reported, never deleted", {
11130
+ this.deps.logger.warn("recorder: an hour directory holds more segments than its durable row accounts for", {
11312
11131
  tags: { deviceId: claim.deviceId },
11313
11132
  meta: {
11314
11133
  locationId: state.location.id,
11315
11134
  profile: claim.profile,
11316
11135
  hourStartMs: claim.hourStartMs,
11317
- orphans: diff.orphans.length,
11318
- sample: diff.orphans.slice(0, 5)
11136
+ accounted: claim.segments,
11137
+ listed: listed.segments,
11138
+ apply: state.apply
11319
11139
  }
11320
11140
  });
11321
11141
  }
11322
- if (diff.ghosts.length === 0) return true;
11323
- const ghostBytes = diff.ghosts.reduce((sum, row) => sum + row.bytes, 0);
11324
- state.ghostSegments += diff.ghosts.length;
11142
+ const ghosts = Math.max(0, claim.segments - listed.segments);
11143
+ if (ghosts === 0) {
11144
+ if (state.apply && listed.segments > 0 && listed.bytes !== claim.bytes) await state.ledger.adoptWalkedHour(claim.deviceId, claim.profile, state.location.id, claim.hourStartMs, present);
11145
+ if (state.apply) this.resetIndex(state, claim, present);
11146
+ return true;
11147
+ }
11148
+ const ghostBytes = Math.max(0, claim.bytes - listed.bytes);
11149
+ state.ghostSegments += ghosts;
11325
11150
  state.ghostBytes += ghostBytes;
11326
- tally.ghostSegments += diff.ghosts.length;
11151
+ tally.ghostSegments += ghosts;
11327
11152
  tally.ghostBytes += ghostBytes;
11328
- if (diff.ghosts.length === claim.rows.length) state.ghostHoursWhole += 1;
11153
+ if (listed.segments === 0) state.ghostHoursWhole += 1;
11329
11154
  const anchor = await this.readAnchor(state.location);
11330
11155
  if (!anchor.ok || anchor.volumeId !== state.anchor) {
11331
11156
  state.refused = anchor.ok ? "anchor-moved" : anchor.refusal;
@@ -11341,56 +11166,52 @@ var LedgerWalkReconciler = class {
11341
11166
  });
11342
11167
  return false;
11343
11168
  }
11344
- this.deps.logger.warn("recorder: durable hour row claims segments the directory does not hold", {
11169
+ this.deps.logger.warn("recorder: durable hour row accounts for segments the directory does not hold", {
11345
11170
  tags: { deviceId: claim.deviceId },
11346
11171
  meta: {
11347
11172
  locationId: state.location.id,
11348
11173
  profile: claim.profile,
11349
11174
  hourStartMs: claim.hourStartMs,
11350
- claimed: claim.rows.length,
11351
- ghosts: diff.ghosts.length,
11175
+ accounted: claim.segments,
11176
+ listed: listed.segments,
11177
+ ghosts,
11352
11178
  ghostBytes,
11353
- wholeHour: diff.ghosts.length === claim.rows.length,
11354
- missingDirectory: diff.missingDirectory,
11179
+ wholeHour: listed.segments === 0,
11180
+ missingDirectory,
11355
11181
  apply: state.apply
11356
11182
  }
11357
11183
  });
11358
11184
  if (!state.apply) return true;
11359
- await state.ledger.dropSegments(diff.ghosts);
11360
- if (!await this.forgetLanded(state, claim, diff.ghosts)) {
11361
- this.skip(state, claim, "durable-refused", "the ledger still lists a dropped path");
11185
+ 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);
11186
+ if (forgotten === null) {
11187
+ this.skip(state, claim, "durable-refused", "the ledger still accounts for the ghosts");
11362
11188
  return true;
11363
11189
  }
11364
- this.deps.applyToIndex(diff.ghosts.map((row) => row.path));
11365
- state.forgottenSegments += diff.ghosts.length;
11190
+ this.resetIndex(state, claim, present);
11191
+ state.forgottenSegments += forgotten;
11366
11192
  state.forgottenBytes += ghostBytes;
11367
- tally.forgottenSegments += diff.ghosts.length;
11193
+ tally.forgottenSegments += forgotten;
11368
11194
  return true;
11369
11195
  }
11370
- /** Did the durable drop actually land? */
11371
- async forgetLanded(state, claim, ghosts) {
11372
- const { deviceId, profile, hourStartMs: hour } = claim;
11373
- if (!state.ledger.knowsHour(deviceId, profile, state.location.id, hour)) return true;
11374
- const held = await state.ledger.hourPaths(deviceId, profile, state.location.id, hour);
11375
- if (held === null) return false;
11376
- const remaining = new Set(held);
11377
- return ghosts.every((row) => !remaining.has(row.path));
11378
- }
11379
11196
  /**
11380
- * What the hour row claims, against what the directory holds — BOTH ways.
11197
+ * Adopt the listing, then READ THE ROW BACK.
11381
11198
  *
11382
- * A missing directory is an empty listing here, which makes every claim a
11383
- * ghost. That is only admissible because the caller re-reads the volume
11384
- * anchor before acting on it.
11199
+ * `adoptWalkedHour` refuses a listing that would erase more than half of what
11200
+ * the row accounts for — the guard against a root that answered short — and it
11201
+ * refuses silently, which a caller that counted it as done would turn into
11202
+ * telling the resident index a row is gone that the archive still holds.
11203
+ * `null` is that refusal.
11385
11204
  */
11386
- diff(claim, listing) {
11387
- const present = new Set((listing ?? []).filter((name) => name.endsWith(".m4s")));
11388
- const claimed = new Set(claim.rows.map((row) => fileName(row.path)));
11389
- return {
11390
- ghosts: claim.rows.filter((row) => !present.has(fileName(row.path))),
11391
- orphans: [...present].filter((name) => !claimed.has(name)).toSorted(),
11392
- missingDirectory: listing === null
11393
- };
11205
+ async adoptLanded(state, claim, present, ghosts) {
11206
+ await state.ledger.adoptWalkedHour(claim.deviceId, claim.profile, state.location.id, claim.hourStartMs, present);
11207
+ const after = state.ledger.hourRow(claim.deviceId, claim.profile, state.location.id, claim.hourStartMs);
11208
+ if (after === void 0) return claim.segments;
11209
+ return after.segments === claim.segments - ghosts ? ghosts : null;
11210
+ }
11211
+ /** Make the resident index's view of the hour the listing's, and say how many
11212
+ * index rows that dropped. */
11213
+ resetIndex(state, claim, present) {
11214
+ return this.deps.applyToIndex(claim.deviceId, state.location.id, claim.hourStartMs, present);
11394
11215
  }
11395
11216
  /** The volume anchor, or the refusal its absence produces. */
11396
11217
  async readAnchor(location) {
@@ -11838,6 +11659,240 @@ function pendingMovesSignature(moves) {
11838
11659
  return moves.map((m) => `${String(m.deviceId)}:${m.profile}>${m.plannedFor}`).join(",");
11839
11660
  }
11840
11661
  //#endregion
11662
+ //#region src/recorder/addon/hour-adoption-sweep.ts
11663
+ var HOUR_MS$1 = 36e5;
11664
+ /** The profile trees a camera directory is allowed to have. Everything else
11665
+ * under it (`events/`, the retired `strips/`) has no hour tree worth probing. */
11666
+ var PROFILES = [
11667
+ "high",
11668
+ "mid",
11669
+ "low"
11670
+ ];
11671
+ function isTwoDigit(name) {
11672
+ return /^\d{2}$/.test(name);
11673
+ }
11674
+ /** One directory level in a stable order. Lexicographic, not numeric: it is the
11675
+ * CURSOR's order, and zero-padded path components (`09`, `17`) already sort
11676
+ * chronologically — device ids do not, and do not need to. */
11677
+ function inCursorOrder(names) {
11678
+ return [...names].toSorted();
11679
+ }
11680
+ /**
11681
+ * One add-only pass over one location's hour directories.
11682
+ *
11683
+ * Never throws: a directory that cannot be read is counted and the pass
11684
+ * continues; a precondition failure is a named refusal and the pass does
11685
+ * nothing at all.
11686
+ */
11687
+ var HourAdoptionSweeper = class {
11688
+ deps;
11689
+ /** Where the last bounded pass stopped (`rel` of the last hour it walked).
11690
+ * In RAM: a restart re-walks from the top, which costs listings and can
11691
+ * never cost footage. */
11692
+ cursor = null;
11693
+ constructor(deps) {
11694
+ this.deps = deps;
11695
+ }
11696
+ async sweep(request) {
11697
+ const location = this.deps.locations().find((candidate) => candidate.id === request.locationId);
11698
+ if (location === void 0) return this.refusedReport(request.locationId, "location-unknown");
11699
+ if (!locationMayBeRead(location)) return this.refusedReport(location.id, "location-unreadable");
11700
+ const ledger = this.deps.ledger();
11701
+ if (ledger === null) {
11702
+ 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 } });
11703
+ return this.refusedReport(location.id, "no-ledger");
11704
+ }
11705
+ const liveTailFrom = hourStartMs(this.deps.now()) - 1 * HOUR_MS$1;
11706
+ const maxHours = this.deps.maxHours ?? 512;
11707
+ const resume = this.cursor;
11708
+ const byDevice = /* @__PURE__ */ new Map();
11709
+ let listings = 0;
11710
+ let hoursAdopted = 0;
11711
+ let hoursRefusedByLedger = 0;
11712
+ let hoursSkippedLive = 0;
11713
+ let hoursSkippedEmpty = 0;
11714
+ let listingErrors = 0;
11715
+ let segmentsAdded = 0;
11716
+ let hoursCreated = 0;
11717
+ let bounded = false;
11718
+ let last = null;
11719
+ for await (const candidate of this.candidates(location, () => {
11720
+ listingErrors += 1;
11721
+ })) {
11722
+ if (resume !== null && candidate.rel <= resume) continue;
11723
+ if (candidate.hourStartMs >= liveTailFrom) {
11724
+ hoursSkippedLive += 1;
11725
+ continue;
11726
+ }
11727
+ if (listings >= maxHours) {
11728
+ bounded = true;
11729
+ break;
11730
+ }
11731
+ let names;
11732
+ try {
11733
+ names = await this.deps.listDir(`${location.root}/${candidate.rel}`);
11734
+ } catch (err) {
11735
+ listingErrors += 1;
11736
+ this.deps.logger.debug("recorder: hour adoption could not list an hour directory", {
11737
+ tags: { deviceId: candidate.deviceId },
11738
+ meta: {
11739
+ locationId: location.id,
11740
+ rel: candidate.rel,
11741
+ error: errMsg(err)
11742
+ }
11743
+ });
11744
+ continue;
11745
+ }
11746
+ listings += 1;
11747
+ last = candidate.rel;
11748
+ if (listings % 8 === 0) await this.deps.pace();
11749
+ const paths = (names ?? []).filter((name) => name.endsWith(".m4s")).map((name) => `${candidate.rel}/${name}`);
11750
+ if (paths.length === 0) {
11751
+ hoursSkippedEmpty += 1;
11752
+ continue;
11753
+ }
11754
+ const outcome = await ledger.adoptWalkedHour(candidate.deviceId, candidate.profile, location.id, candidate.hourStartMs, paths);
11755
+ if (outcome === null) {
11756
+ hoursRefusedByLedger += 1;
11757
+ continue;
11758
+ }
11759
+ hoursAdopted += 1;
11760
+ segmentsAdded += outcome.added;
11761
+ if (outcome.created) hoursCreated += 1;
11762
+ const tally = byDevice.get(candidate.deviceId) ?? {
11763
+ hoursAdopted: 0,
11764
+ segmentsAdded: 0,
11765
+ hoursCreated: 0
11766
+ };
11767
+ tally.hoursAdopted += 1;
11768
+ tally.segmentsAdded += outcome.added;
11769
+ if (outcome.created) tally.hoursCreated += 1;
11770
+ byDevice.set(candidate.deviceId, tally);
11771
+ 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", {
11772
+ tags: { deviceId: candidate.deviceId },
11773
+ meta: {
11774
+ locationId: location.id,
11775
+ profile: candidate.profile,
11776
+ hourStartMs: candidate.hourStartMs,
11777
+ added: outcome.added,
11778
+ created: outcome.created,
11779
+ onDisk: paths.length
11780
+ }
11781
+ });
11782
+ }
11783
+ this.cursor = bounded ? last ?? resume : null;
11784
+ const report = {
11785
+ locationId: location.id,
11786
+ refused: null,
11787
+ listings,
11788
+ hoursAdopted,
11789
+ hoursRefusedByLedger,
11790
+ hoursSkippedLive,
11791
+ hoursSkippedEmpty,
11792
+ listingErrors,
11793
+ segmentsAdded,
11794
+ hoursCreated,
11795
+ bounded,
11796
+ byDevice: [...byDevice.entries()].map(([deviceId, tally]) => ({
11797
+ deviceId,
11798
+ ...tally
11799
+ })).toSorted((a, b) => a.deviceId - b.deviceId)
11800
+ };
11801
+ this.report(report);
11802
+ return report;
11803
+ }
11804
+ /**
11805
+ * Every hour directory under this location, in cursor order.
11806
+ *
11807
+ * Sorted at each level so the cursor is a total order over a tree two passes
11808
+ * see differently: a camera added between passes appears in its place rather
11809
+ * than at the end, and is picked up by the pass after the cursor resets.
11810
+ */
11811
+ async *candidates(location, onError) {
11812
+ const devices = await this.list(location.root, onError);
11813
+ for (const device of inCursorOrder(devices.filter((name) => /^\d+$/.test(name)))) {
11814
+ const deviceId = Number(device);
11815
+ if (!Number.isSafeInteger(deviceId)) continue;
11816
+ const present = new Set(await this.list(`${location.root}/${device}`, onError));
11817
+ for (const profile of PROFILES) {
11818
+ if (!present.has(profile)) continue;
11819
+ const base = `${device}/${profile}`;
11820
+ 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))) {
11821
+ const dayDir = `${base}/${year}/${month}/${day}`;
11822
+ for (const hour of inCursorOrder((await this.list(`${location.root}/${dayDir}`, onError)).filter(isTwoDigit))) {
11823
+ const startMs = Date.UTC(Number(year), Number(month) - 1, Number(day), Number(hour));
11824
+ if (!Number.isFinite(startMs)) continue;
11825
+ yield {
11826
+ deviceId,
11827
+ profile,
11828
+ hourStartMs: startMs,
11829
+ rel: `${dayDir}/${hour}`
11830
+ };
11831
+ }
11832
+ }
11833
+ }
11834
+ }
11835
+ }
11836
+ /** One directory's names; `[]` for a directory that is not there, and `[]`
11837
+ * plus a counted error for one that could not be read. */
11838
+ async list(absDir, onError) {
11839
+ try {
11840
+ return await this.deps.listDir(absDir) ?? [];
11841
+ } catch (err) {
11842
+ onError();
11843
+ this.deps.logger.debug("recorder: hour adoption could not list a directory", { meta: {
11844
+ dir: absDir,
11845
+ error: errMsg(err)
11846
+ } });
11847
+ return [];
11848
+ }
11849
+ }
11850
+ refusedReport(locationId, refused) {
11851
+ return {
11852
+ locationId,
11853
+ refused,
11854
+ listings: 0,
11855
+ hoursAdopted: 0,
11856
+ hoursRefusedByLedger: 0,
11857
+ hoursSkippedLive: 0,
11858
+ hoursSkippedEmpty: 0,
11859
+ listingErrors: 0,
11860
+ segmentsAdded: 0,
11861
+ hoursCreated: 0,
11862
+ bounded: false,
11863
+ byDevice: []
11864
+ };
11865
+ }
11866
+ /** One line per camera that gained something, then one for the pass — a pass
11867
+ * that found nothing must still be distinguishable from one that never ran. */
11868
+ report(report) {
11869
+ for (const device of report.byDevice) {
11870
+ if (device.segmentsAdded === 0 && device.hoursCreated === 0) continue;
11871
+ this.deps.logger.info("recorder: hour adoption — camera summary", {
11872
+ tags: { deviceId: device.deviceId },
11873
+ meta: {
11874
+ locationId: report.locationId,
11875
+ hoursAdopted: device.hoursAdopted,
11876
+ segmentsAdded: device.segmentsAdded,
11877
+ hoursCreated: device.hoursCreated
11878
+ }
11879
+ });
11880
+ }
11881
+ this.deps.logger.info("recorder: hour adoption pass complete", { meta: {
11882
+ locationId: report.locationId,
11883
+ listings: report.listings,
11884
+ hoursAdopted: report.hoursAdopted,
11885
+ hoursRefusedByLedger: report.hoursRefusedByLedger,
11886
+ hoursSkippedLive: report.hoursSkippedLive,
11887
+ hoursSkippedEmpty: report.hoursSkippedEmpty,
11888
+ listingErrors: report.listingErrors,
11889
+ segmentsAdded: report.segmentsAdded,
11890
+ hoursCreated: report.hoursCreated,
11891
+ bounded: report.bounded
11892
+ } });
11893
+ }
11894
+ };
11895
+ //#endregion
11841
11896
  //#region src/recorder/addon/periodic-pass.ts
11842
11897
  function startPeriodicPass(deps) {
11843
11898
  const now = () => deps.now?.() ?? Date.now();
@@ -14197,11 +14252,16 @@ function planFinalizations(body, processed, forceFinal = false) {
14197
14252
  durMs
14198
14253
  });
14199
14254
  }
14255
+ const front = entries.length > 0 ? parseEpochStartMs(entries[0].segPath) : null;
14256
+ const longestDurMs = entries.reduce((longest, entry) => Math.max(longest, Math.round(entry.durSeconds * 1e3)), 0);
14257
+ const spanKnown = rolledOff > 0 && front !== null && longestDurMs > 0;
14200
14258
  return {
14201
14259
  toHandle,
14202
14260
  from: hw,
14203
14261
  processed: Math.max(hw, finalizable),
14204
- rolledOff
14262
+ rolledOff,
14263
+ rolledOffFromMs: spanKnown ? front - rolledOff * longestDurMs : null,
14264
+ rolledOffToMs: spanKnown ? front : null
14205
14265
  };
14206
14266
  }
14207
14267
  /**
@@ -14229,11 +14289,24 @@ function startSegmentWatcher(deps) {
14229
14289
  return;
14230
14290
  }
14231
14291
  const plan = planFinalizations(body, processed, forceFinal);
14232
- if (plan.rolledOff > 0) deps.logger.warn("segment playlist rolled past entries this watcher had not handled", { meta: {
14233
- rolledOff: plan.rolledOff,
14234
- processed,
14235
- from: plan.from
14236
- } });
14292
+ if (plan.rolledOff > 0) {
14293
+ deps.logger.warn("segment playlist rolled past entries this watcher had not handled", {
14294
+ tags: { deviceId: deps.deviceId },
14295
+ meta: {
14296
+ rolledOff: plan.rolledOff,
14297
+ processed,
14298
+ from: plan.from,
14299
+ fromMs: plan.rolledOffFromMs,
14300
+ toMs: plan.rolledOffToMs
14301
+ }
14302
+ });
14303
+ deps.onSegmentsMissed?.({
14304
+ deviceId: deps.deviceId,
14305
+ count: plan.rolledOff,
14306
+ fromMs: plan.rolledOffFromMs,
14307
+ toMs: plan.rolledOffToMs
14308
+ });
14309
+ }
14237
14310
  let index = plan.from;
14238
14311
  for (const { entry, durMs } of plan.toHandle) {
14239
14312
  if (await handleSegmentEntry(deps.outDir, entry, durMs, deps.onFinalized, deps.logger, deps.writerStartedMs) === "pending") {
@@ -14243,11 +14316,14 @@ function startSegmentWatcher(deps) {
14243
14316
  waitedMs,
14244
14317
  boundMs: 3e4
14245
14318
  })) {
14246
- deps.logger.warn("segment file never landed — skipping", { meta: {
14247
- seg: entry.segPath,
14248
- waitedMs,
14249
- boundMs: PENDING_GRACE_MS
14250
- } });
14319
+ deps.logger.warn("segment file never landed — skipping", {
14320
+ tags: { deviceId: deps.deviceId },
14321
+ meta: {
14322
+ seg: entry.segPath,
14323
+ waitedMs,
14324
+ boundMs: PENDING_GRACE_MS
14325
+ }
14326
+ });
14251
14327
  pendingIndex = -1;
14252
14328
  pendingSinceMs = 0;
14253
14329
  index++;
@@ -16085,8 +16161,12 @@ var RecordingController = class {
16085
16161
  writer,
16086
16162
  watcher: startSegmentWatcher({
16087
16163
  outDir,
16164
+ deviceId,
16088
16165
  writerStartedMs,
16089
16166
  now: () => this.now(),
16167
+ onSegmentsMissed: (missed) => {
16168
+ this.deps.onSegmentsMissed?.(missed, profile);
16169
+ },
16090
16170
  intervalMs: this.deps.watchIntervalMs > 0 ? this.deps.watchIntervalMs : DEFAULT_WATCH_INTERVAL_MS,
16091
16171
  onFinalized: async (startMs, durMs, flatAbsPath) => {
16092
16172
  this.lastSegmentAt.set(idleKey(deviceId, profile), this.now());
@@ -17420,14 +17500,33 @@ var GHOST_LOCATION_PROBE_PAUSE_MS = 50;
17420
17500
  /**
17421
17501
  * Awaited every `WALK_HOUR_BATCH` hour listings by the ledger↔disk walk (D319).
17422
17502
  *
17423
- * Twice the ghost sweep's brake, because the syscall between brakes is far
17424
- * heavier: that pass paces `storage.exists` on one path, this one paces a
17425
- * `readdir` of a whole hour directory — measured at ~650 ms on the live share
17426
- * under load. The walk is operator-armed and one-off, so the right trade is to
17427
- * be slower and invisible rather than fast and felt: a `find` over this array
17428
- * hung an SSH session on 2026-08-29.
17503
+ * Twice the ghost sweep's brake. That used to be because the syscall between
17504
+ * brakes was far heavier — the sweep paced one `storage.exists`, this one an
17505
+ * hour `readdir` "measured at ~650 ms on the live share under load". Both
17506
+ * halves of that have changed: the sweep paces a directory listing too since
17507
+ * D523, and the 650 ms belonged to a FUSE mount that has been retired (an hour
17508
+ * of 345–349 files on `/recordings-2` lists in ~1 ms).
17509
+ *
17510
+ * It stays at twice anyway, for the reason that never depended on the number:
17511
+ * the walk is operator-armed and one-off, so the right trade is to be slower
17512
+ * and invisible rather than fast and felt — a `find` over this array hung an
17513
+ * SSH session on 2026-08-29.
17429
17514
  */
17430
17515
  var LEDGER_WALK_PAUSE_MS = 100;
17516
+ /**
17517
+ * How often the ADD-only hour adoption pass runs (D521).
17518
+ *
17519
+ * Daily, for the same reason the ghost sweep is daily: it spends `readdir`
17520
+ * syscalls on the array every live read competes for, and the damage it repairs
17521
+ * is made by a writer teardown — an event measured in days, not minutes. It
17522
+ * does NOT run at boot (`startPeriodicPass` fires on the interval only), which
17523
+ * is when the staging reconcile, the ledger load and the first retention sweep
17524
+ * are already contending for that array.
17525
+ */
17526
+ var HOUR_ADOPTION_INTERVAL_MS = 1440 * 6e4;
17527
+ /** An adoption pass longer than this is reported with its duration. Bounded
17528
+ * work (512 hour listings, paced) — minutes is normal, an hour is not. */
17529
+ var HOUR_ADOPTION_SLOW_AFTER_MS = 10 * 6e4;
17431
17530
  /** What the per-volume usage row reports when the hour ledger is unavailable:
17432
17531
  * nothing measured, rather than a total counted off a partial RAM index. */
17433
17532
  var EMPTY_ARCHIVE_ACCOUNTING = {
@@ -17652,6 +17751,7 @@ var RecorderV2Addon = class extends BaseAddon {
17652
17751
  /** Daily ghost-location reconcile: durable hour rows stamped with a location
17653
17752
  * the storage record no longer has. Armed in `onHubReachable`. */
17654
17753
  ghostLocationPass = null;
17754
+ hourAdoptionPass = null;
17655
17755
  /** Reversible migration pause lease; never reflected in RecordingConfig. */
17656
17756
  storageMigrationLeaseId = null;
17657
17757
  /**
@@ -17707,6 +17807,7 @@ var RecorderV2Addon = class extends BaseAddon {
17707
17807
  this.segmentHours = new SegmentHourLedger({
17708
17808
  store: this.ctx.api.settingsStore,
17709
17809
  logger: this.ctx.logger,
17810
+ listHour: (locationId, deviceId, profile, hourStartMs) => this.listHourDirectory(locationId, deviceId, profile, hourStartMs),
17710
17811
  isLocationUsable: (locationId) => this.placement?.isLocationUsable(locationId) ?? true
17711
17812
  });
17712
17813
  try {
@@ -17740,7 +17841,7 @@ var RecorderV2Addon = class extends BaseAddon {
17740
17841
  await promises.unlink(fromAbs);
17741
17842
  }
17742
17843
  },
17743
- archiveHourPaths: async (deviceId, profile, locationId, hourStartMs) => await this.segmentHours?.hourPaths(deviceId, profile, locationId, hourStartMs) ?? null,
17844
+ hourDirectoryPaths: (deviceId, profile, locationId, hourStartMs) => this.listHourDirectory(locationId, deviceId, profile, hourStartMs),
17744
17845
  removeDir: async (locationId, relDir) => {
17745
17846
  const loc = requireReadableLocation(this.resolvedLocations, locationId, "recorder");
17746
17847
  await promises.rm(path.join(loc.root, relDir), {
@@ -17893,6 +17994,27 @@ var RecorderV2Addon = class extends BaseAddon {
17893
17994
  onWriterClosed: async (deviceId, profile) => {
17894
17995
  await this.segmentHours?.sealWriter(deviceId, profile);
17895
17996
  },
17997
+ onSegmentsMissed: (missed, profile) => {
17998
+ const ledger = this.segmentHours;
17999
+ if (ledger === null) return;
18000
+ if (missed.fromMs === null || missed.toMs === null) {
18001
+ this.ctx.logger.warn("recorder: segments were missed but the playlist named no span — NO hour could be held unsealed for them", {
18002
+ tags: { deviceId: missed.deviceId },
18003
+ meta: {
18004
+ profile,
18005
+ segments: missed.count
18006
+ }
18007
+ });
18008
+ return;
18009
+ }
18010
+ ledger.markHoursRefused({
18011
+ deviceId: missed.deviceId,
18012
+ profile,
18013
+ fromMs: missed.fromMs,
18014
+ toMs: missed.toMs,
18015
+ count: missed.count
18016
+ });
18017
+ },
17896
18018
  spawn,
17897
18019
  costRegistry: this.costRegistry,
17898
18020
  segmentSeconds: this.config.segmentSeconds,
@@ -18204,8 +18326,8 @@ var RecorderV2Addon = class extends BaseAddon {
18204
18326
  localNodeId: this.nodeId,
18205
18327
  index: this.index,
18206
18328
  ledger: this.segmentHours === null ? null : {
18207
- streamSegments: (scope) => this.segmentHours.streamSegments(scope),
18208
- dropSegments: (rows) => this.segmentHours.dropSegments(rows)
18329
+ forgetScope: (scope) => this.segmentHours.forgetScope(scope),
18330
+ snapshot: () => this.segmentHours.snapshot()
18209
18331
  },
18210
18332
  now: () => Date.now()
18211
18333
  });
@@ -18258,6 +18380,8 @@ var RecorderV2Addon = class extends BaseAddon {
18258
18380
  this.redundancySweeper = null;
18259
18381
  this.ghostLocationPass?.stop();
18260
18382
  this.ghostLocationPass = null;
18383
+ this.hourAdoptionPass?.stop();
18384
+ this.hourAdoptionPass = null;
18261
18385
  this.exportEngine = null;
18262
18386
  this.recordingProvider = null;
18263
18387
  }
@@ -18310,9 +18434,7 @@ var RecorderV2Addon = class extends BaseAddon {
18310
18434
  }
18311
18435
  },
18312
18436
  anchorOf: async (root) => (await readLocationMarker(root))?.volumeId ?? null,
18313
- applyToIndex: (paths) => {
18314
- this.index.removeSegments(paths);
18315
- },
18437
+ applyToIndex: (deviceId, locationId, hour, paths) => this.index.resetHour(deviceId, locationId, hour, paths),
18316
18438
  logger: this.ctx.logger,
18317
18439
  now: () => Date.now(),
18318
18440
  pace: () => sleep$1(LEDGER_WALK_PAUSE_MS)
@@ -18440,7 +18562,6 @@ var RecorderV2Addon = class extends BaseAddon {
18440
18562
  });
18441
18563
  try {
18442
18564
  await this.segmentHours?.load();
18443
- this.segmentHours?.repairAggregates();
18444
18565
  } catch (err) {
18445
18566
  this.ctx.logger.warn("recorder: segment-hour ledger load failed — hours stay unknown until a read walks them", { meta: { error: errMsg(err) } });
18446
18567
  }
@@ -18483,18 +18604,15 @@ var RecorderV2Addon = class extends BaseAddon {
18483
18604
  const sweeper = new GhostLocationSweeper({
18484
18605
  ledger,
18485
18606
  locations: () => this.resolvedLocations,
18486
- exists: (location, relativePath) => this.ctx.api.storage.exists.query({
18487
- location,
18488
- relativePath
18489
- }),
18607
+ listHour: (locationId, deviceId, profile, hourStartMs) => this.listHourDirectory(locationId, deviceId, profile, hourStartMs),
18490
18608
  applyToIndex: (rows, toLocationId) => {
18491
18609
  this.index.removeSegments(rows.map((row) => row.path));
18492
- if (toLocationId === null) return;
18493
18610
  for (const row of rows) this.index.addSegment({
18494
18611
  ...row,
18495
18612
  locationId: toLocationId
18496
18613
  });
18497
18614
  },
18615
+ forgetInIndex: (deviceId, locationId, hourStartMs) => this.index.resetHour(deviceId, locationId, hourStartMs, []),
18498
18616
  logger: this.ctx.logger,
18499
18617
  now: () => Date.now(),
18500
18618
  pace: () => sleep$1(GHOST_LOCATION_PROBE_PAUSE_MS)
@@ -18509,6 +18627,32 @@ var RecorderV2Addon = class extends BaseAddon {
18509
18627
  logger: this.ctx.logger
18510
18628
  });
18511
18629
  }
18630
+ if (this.hourAdoptionPass === null) {
18631
+ const adopter = new HourAdoptionSweeper({
18632
+ ledger: () => this.segmentHours,
18633
+ locations: () => this.resolvedLocations,
18634
+ listDir: async (absDir) => {
18635
+ try {
18636
+ return await promises.readdir(absDir);
18637
+ } catch (err) {
18638
+ if (err.code === "ENOENT") return null;
18639
+ throw err;
18640
+ }
18641
+ },
18642
+ logger: this.ctx.logger,
18643
+ now: () => Date.now(),
18644
+ pace: () => sleep$1(LEDGER_WALK_PAUSE_MS)
18645
+ });
18646
+ this.hourAdoptionPass = startPeriodicPass({
18647
+ name: "hour-adoption",
18648
+ intervalMs: HOUR_ADOPTION_INTERVAL_MS,
18649
+ slowAfterMs: HOUR_ADOPTION_SLOW_AFTER_MS,
18650
+ run: async () => {
18651
+ for (const location of this.resolvedLocations) await adopter.sweep({ locationId: location.id });
18652
+ },
18653
+ logger: this.ctx.logger
18654
+ });
18655
+ }
18512
18656
  }
18513
18657
  /**
18514
18658
  * Print one window of detection-trigger health.
@@ -18743,6 +18887,63 @@ var RecorderV2Addon = class extends BaseAddon {
18743
18887
  return this.resolvedLocations;
18744
18888
  }
18745
18889
  /**
18890
+ * ONE hour bucket's segment files, location-relative — the recorder's single
18891
+ * implementation of {@link HourLister} (D523).
18892
+ *
18893
+ * The directory is the authority for WHICH segments exist, and the segment
18894
+ * NAME carries start, duration and bytes, so this is a complete description
18895
+ * of the hour and no file is ever opened.
18896
+ *
18897
+ * Three answers, and the difference between the last two is the whole point:
18898
+ *
18899
+ * - `null` — **undecided.** The location does not resolve, may not be read,
18900
+ * or the read failed for a reason that is not "this directory is not
18901
+ * there". Every caller turns it into "this hour takes no part in this
18902
+ * pass".
18903
+ * - `[]` — **the hour holds nothing**, and the volume confirmed it: an
18904
+ * ENOENT on the hour directory is only reported as empty once the
18905
+ * location's ROOT has answered a read. A root that has gone away answers
18906
+ * ENOENT for every hour on it, and reporting that as "empty" is how a pass
18907
+ * deletes an operator's index of real footage (D148, D393).
18908
+ * - the paths.
18909
+ */
18910
+ async listHourDirectory(locationId, deviceId, profile, hourStartMs) {
18911
+ const location = this.resolvedLocations.find((l) => l.id === locationId);
18912
+ if (location === void 0 || !locationMayBeRead(location)) return null;
18913
+ const rel = hourDirRelPath(deviceId, profile, hourStartMs);
18914
+ try {
18915
+ return (await promises.readdir(path.join(location.root, rel))).filter((f) => f.endsWith(".m4s")).map((f) => `${rel}/${f}`);
18916
+ } catch (err) {
18917
+ if (err.code !== "ENOENT") {
18918
+ this.ctx.logger.warn("recorder: hour directory read failed — this hour decides nothing", {
18919
+ tags: { deviceId },
18920
+ meta: {
18921
+ profile,
18922
+ locationId,
18923
+ hourStartMs,
18924
+ error: errMsg(err)
18925
+ }
18926
+ });
18927
+ return null;
18928
+ }
18929
+ try {
18930
+ await promises.readdir(location.root);
18931
+ } catch {
18932
+ this.ctx.logger.warn("recorder: hour directory is absent AND its location root cannot be read — this hour decides nothing", {
18933
+ tags: { deviceId },
18934
+ meta: {
18935
+ profile,
18936
+ locationId,
18937
+ root: location.root,
18938
+ hourStartMs
18939
+ }
18940
+ });
18941
+ return null;
18942
+ }
18943
+ return [];
18944
+ }
18945
+ }
18946
+ /**
18746
18947
  * Attach-boundary placement for one (device, profile). Falls back to the pure
18747
18948
  * resolver when the service is not built (non-recording node / early boot),
18748
18949
  * which is byte-identical to the pre-placement behaviour.