@camstack/addon-pipeline 1.2.219 → 1.2.221

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 (40) hide show
  1. package/dist/audio-analyzer/index.js +661 -7
  2. package/dist/audio-analyzer/index.mjs +658 -5
  3. package/dist/chunk-emK7D4bc.js +35 -0
  4. package/dist/{default-detection-model-d7-Q_LMo.js → default-detection-model-B1NT1A38.js} +1 -1
  5. package/dist/{default-detection-model-Dsk1a5pY.mjs → default-detection-model-DkVFgubU.mjs} +1 -1
  6. package/dist/detection-pipeline/index.js +10 -9
  7. package/dist/detection-pipeline/index.mjs +4 -4
  8. package/dist/{dist-wtNhtVVo.js → dist-ATDfcxgL.js} +282 -39
  9. package/dist/{dist-CKK0CBUW.mjs → dist-CEFC3PkP.mjs} +265 -5
  10. package/dist/{lazy-sharp-zoQ07I43.js → lazy-sharp-BuwKlQLR.js} +2 -2
  11. package/dist/motion-wasm/index.js +2 -2
  12. package/dist/motion-wasm/index.mjs +1 -1
  13. package/dist/{node-BduNi21Z.js → node-C5jnTgpv.js} +4 -3
  14. package/dist/{node-CrNBWFbA.mjs → node-DR3fHqUq.mjs} +1 -1
  15. package/dist/pipeline-runner/index.js +222 -157
  16. package/dist/pipeline-runner/index.mjs +218 -154
  17. package/dist/{process-memory-Dwl0MFPH.js → process-memory-DQbCPnMA.js} +3 -2
  18. package/dist/{process-memory-BRQOtDN1.mjs → process-memory-DqYj5IAb.mjs} +1 -1
  19. package/dist/recorder/index.js +344 -127
  20. package/dist/recorder/index.mjs +342 -126
  21. package/dist/{segment-demux-js-D2o9ShNL.mjs → segment-demux-js-CddDwD69.mjs} +1 -1
  22. package/dist/{segment-demux-js-CFOU3biG.js → segment-demux-js-o9KBAz9p.js} +4 -3
  23. package/dist/session-decode/decode-worker-child.js +155 -74
  24. package/dist/session-decode/decode-worker-child.mjs +154 -73
  25. package/dist/stream-broker/_stub.js +2 -2
  26. package/dist/stream-broker/{_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-DN_IoShh.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-D5XnLZWR.mjs} +3 -3
  27. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-CyMYDKfM.mjs +26 -0
  28. package/dist/stream-broker/{_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-DICVQkNi.mjs → _virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-CKUGpDns.mjs} +1 -1
  29. package/dist/stream-broker/demux-worker-child.js +1 -1
  30. package/dist/stream-broker/demux-worker-child.mjs +1 -1
  31. package/dist/stream-broker/{hostInit-n7c9pNlt.mjs → hostInit-C-VdcJS2.mjs} +3 -3
  32. package/dist/stream-broker/index.js +231 -112
  33. package/dist/stream-broker/index.mjs +224 -106
  34. package/dist/stream-broker/remoteEntry.js +1 -1
  35. package/dist/worker-protocol-B2MfQLlu.js +184 -0
  36. package/dist/worker-protocol-C_W-P_g-.mjs +161 -0
  37. package/package.json +1 -1
  38. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-7hqrrd88.mjs +0 -26
  39. package/dist/worker-protocol-Cx3Nqreq.mjs +0 -264
  40. package/dist/worker-protocol-iWTFnnUM.js +0 -299
@@ -1,5 +1,5 @@
1
- import { At as storageEvictableCapability, Ct as recordingSignalCapability, D as RecordingSignalStatusSchema, E as RecordingConfigSchema, Et as resolveLocationMode, Gt as DeviceFeature, H as deriveBatteryPresence, It as errMsg, Jt as hydrateSchema, Kt as DeviceType, Nt as stripRetiredBandPreBufferSec, Ot as resolveRecordingProfiles, P as batteryCapability, Q as evictionPolicyOfLocation, Qt as nodePin, R as customAction, St as recordingExportCapability, T as RECORDING_EXPORT_MAX_READ_BYTES, Ut as BaseAddon, V as defineCustomActions, W as deriveRecordingMode, Y as evaluateSensorEdge, Z as evictionPolicyForMode, an as boolean, ct as mayReadLocation, d as DEFAULT_EVENTS_BAND_BUFFER_SEC, dn as preprocess, dt as modeMayWrite, et as gbToBytes, fn as record, g as ExportRecordSchema, hn as EventCategory, i as BatteryStatusSchema, in as array, it as loadContributionCapability, jt as storageOccupancyCapability, ln as number, lt as mayWriteToLocation, nn as sleep$1, pn as string, rn as _enum, rt as isFirstLevelMacroClass, tn as selectAssignedProfileSlots, un as object, ut as migrateLegacyDeviceSignalConfig, x as OpsLogEntrySchema, xt as recordingCapability } from "../dist-CKK0CBUW.mjs";
2
- import { a as physicalRootOf, i as NO_COST_CLAIM, t as ChildCostRegistry } from "../node-CrNBWFbA.mjs";
1
+ import { $ as evictionPolicyOfLocation, Ct as recordingExportCapability, D as RecordingConfigSchema, Dt as resolveLocationMode, E as RECORDING_EXPORT_MAX_READ_BYTES, F as batteryCapability, G as deriveRecordingMode, H as defineCustomActions, Kt as DeviceFeature, Lt as errMsg, Mt as storageOccupancyCapability, O as RecordingSignalStatusSchema, Pt as stripRetiredBandPreBufferSec, Q as evictionPolicyForMode, S as OpsLogEntrySchema, St as recordingCapability, U as deriveBatteryPresence, Wt as BaseAddon, X as evaluateSensorEdge, Zt as hydrateSchema, an as sleep$1, at as loadContributionCapability, cn as boolean, d as DEFAULT_EVENTS_BAND_BUFFER_SEC, dt as migrateLegacyDeviceSignalConfig, fn as number, ft as modeMayWrite, g as ExportRecordSchema, gn as string, hn as record, i as BatteryStatusSchema, in as selectAssignedProfileSlots, it as isFirstLevelMacroClass, jt as storageEvictableCapability, kt as resolveRecordingProfiles, lt as mayReadLocation, mn as preprocess, on as _enum, pn as object, qt as DeviceType, sn as array, tn as nodePin, tt as gbToBytes, ut as mayWriteToLocation, vn as EventCategory, wt as recordingSignalCapability, z as customAction } from "../dist-CEFC3PkP.mjs";
2
+ import { a as physicalRootOf, i as NO_COST_CLAIM, t as ChildCostRegistry } from "../node-DR3fHqUq.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-DWe2kInY.mjs";
5
5
  import { n as RowMapStore, t as retireRootKeys } from "../retire-root-keys-DZvlso2l.mjs";
@@ -1768,51 +1768,20 @@ function hourDirRelPath(deviceId, profile, hourStartMs) {
1768
1768
  return `${deviceId}/${profile}/${d.getUTCFullYear()}/${p2(d.getUTCMonth() + 1)}/${p2(d.getUTCDate())}/${p2(d.getUTCHours())}`;
1769
1769
  }
1770
1770
  var HOUR_MS$7 = 36e5;
1771
- /**
1772
- * Hydrate ONLY the hours a window spans, instead of walking the archive.
1773
- *
1774
- * `hydrateDeviceFromStorage` is a recursive `readdir` over the whole device
1775
- * subtree — ">1M files and takes minutes" per its own comment. Until it lands,
1776
- * every read of past footage sees an empty index, which the server reports as
1777
- * "no footage": measured after a deploy, a camera-day held 7 segments and grew
1778
- * only at the rate of NEW writes for ~9 minutes.
1779
- *
1780
- * The path convention makes the window addressable directly: one SHALLOW
1781
- * `readdir` per (profile, hour), ~450 entries each. A day is ~72 of them. The
1782
- * background full walk still runs, for the consumers that need the whole
1783
- * archive (footprint, eviction domains, retention).
1784
- *
1785
- * Hours already walked are skipped, so a drag across an hour costs nothing the
1786
- * second time.
1787
- *
1788
- * ## The ledger is the first stop for a CLOSED hour (D287)
1789
- *
1790
- * Boot no longer replays the archive into the index, so a ledger-known hour
1791
- * arrives here `unknown` and pays ONE point read of its hour row instead of a
1792
- * `readdir` — measured at ~650 ms per hour directory on the live share under
1793
- * load, against the array the scrub feeder is competing for. Hours the ledger
1794
- * does not know, and every hour if the read fails, fall through to the walk
1795
- * exactly as before: the failure direction is toward the disk, never toward an
1796
- * hour that renders empty because nobody read it.
1797
- *
1798
- * **The live hour is never served from the ledger.** It is the one hour whose
1799
- * durable row is knowably behind the disk: a segment finalized after the last
1800
- * persist and before a crash is on the array and not in the row. Boot used to
1801
- * mark every ledger hour `hydrated`, which made that tail invisible to
1802
- * playback and the timeline FOREVER, not merely under-reported. Excluding the
1803
- * live hour costs exactly one hour-directory read per process — the walk marks
1804
- * it, so the second reader pays nothing — and it is what turns a lost tail
1805
- * back into a recoverable one.
1806
- *
1807
- * A CLOSED hour already walked is skipped, which is what makes that exclusion
1808
- * affordable: without it, every window touching the live hour would re-walk
1809
- * all of its hours. The past is immutable, so re-reading a closed hour
1810
- * directory can only ever return what the index already holds; eviction
1811
- * removes rows through the index directly.
1812
- */
1813
- async function hydrateWindowFromStorage(index, deviceId, fromMs, toMs, locations, logger, ledger) {
1814
- if (index.hydrationOf(deviceId, fromMs, toMs) === "hydrated") return;
1771
+ async function hydrateWindowFromStorage(index, deviceId, fromMs, toMs, locations, logger, ledger, options) {
1772
+ const verify = options?.verifyClosedHours === true;
1773
+ if (!verify && index.hydrationOf(deviceId, fromMs, toMs) === "hydrated") return;
1815
1774
  const liveHour = Math.floor(Date.now() / HOUR_MS$7) * HOUR_MS$7;
1775
+ /** Per (locationId, profile, hour): what the LEDGER claimed, for the diff. */
1776
+ const claimed = /* @__PURE__ */ new Map();
1777
+ const claimKey = (locationId, hour) => `${locationId}:${hour}`;
1778
+ /**
1779
+ * Closed hours the ledger answered for with a row nobody SEALED (D451): what
1780
+ * a writer had persisted when it was last heard from, which is a legitimate
1781
+ * answer and not a complete one. Each is read from the array once and the
1782
+ * row is unioned and sealed, so the second reader pays a point read.
1783
+ */
1784
+ const unsealed = /* @__PURE__ */ new Set();
1816
1785
  if (ledger !== void 0) {
1817
1786
  const startedMs = Date.now();
1818
1787
  const hours = await ledger.detailForWindow(deviceId, fromMs, toMs);
@@ -1821,6 +1790,8 @@ async function hydrateWindowFromStorage(index, deviceId, fromMs, toMs, locations
1821
1790
  for (const hour of hours) {
1822
1791
  if (hour.hourStartMs >= liveHour) continue;
1823
1792
  index.hydrateHour(deviceId, hour.locationId, hour.hourStartMs, hour.paths);
1793
+ claimed.set(claimKey(hour.locationId, hour.hourStartMs), hour.paths.length);
1794
+ if (!hour.sealed) unsealed.add(hour.hourStartMs);
1824
1795
  segments += hour.paths.length;
1825
1796
  served += 1;
1826
1797
  }
@@ -1829,10 +1800,11 @@ async function hydrateWindowFromStorage(index, deviceId, fromMs, toMs, locations
1829
1800
  meta: {
1830
1801
  hours: served,
1831
1802
  segments,
1803
+ unsealedHours: unsealed.size,
1832
1804
  ms: Date.now() - startedMs
1833
1805
  }
1834
1806
  });
1835
- if (index.hydrationOf(deviceId, fromMs, toMs) === "hydrated") return;
1807
+ if (!verify && unsealed.size === 0 && index.hydrationOf(deviceId, fromMs, toMs) === "hydrated") return;
1836
1808
  }
1837
1809
  for (const [root, aliases] of aliasesByRoot(locations)) {
1838
1810
  const deviceDir = path.join(root, String(deviceId));
@@ -1853,8 +1825,11 @@ async function hydrateWindowFromStorage(index, deviceId, fromMs, toMs, locations
1853
1825
  const reuseBefore = index.hydrateReuseStats();
1854
1826
  let hoursRead = 0;
1855
1827
  let repaired = 0;
1828
+ let forgottenSegments = 0;
1829
+ let forgottenHours = 0;
1830
+ let sealed = 0;
1856
1831
  for (let hour = first; hour < toMs; hour += HOUR_MS$7) {
1857
- if (hour < liveHour && index.hydrationOf(deviceId, hour, hour + HOUR_MS$7) === "hydrated") continue;
1832
+ if (!verify && hour < liveHour && !unsealed.has(hour) && index.hydrationOf(deviceId, hour, hour + HOUR_MS$7) === "hydrated") continue;
1858
1833
  for (const profile of profiles) {
1859
1834
  const rel = hourDirRelPath(deviceId, profile, hour);
1860
1835
  let files;
@@ -1867,13 +1842,35 @@ async function hydrateWindowFromStorage(index, deviceId, fromMs, toMs, locations
1867
1842
  const locationId = locationForHydratedProfile(aliases, profile).id;
1868
1843
  const relPaths = files.filter((f) => f.endsWith(".m4s")).map((f) => `${rel}/${f}`);
1869
1844
  index.hydrateHour(deviceId, locationId, hour, relPaths);
1870
- if (ledger !== void 0 && relPaths.length > 0 && hour + HOUR_MS$7 <= Date.now() && !ledger.knowsHour(deviceId, profile, locationId, hour)) {
1871
- repaired += 1;
1872
- await ledger.adoptRebuiltHour(deviceId, profile, locationId, hour, relPaths);
1845
+ if (ledger === void 0 || relPaths.length === 0 || hour + HOUR_MS$7 > Date.now()) continue;
1846
+ const claimedHere = claimed.get(claimKey(locationId, hour));
1847
+ const outcome = await ledger.adoptWalkedHour(deviceId, profile, locationId, hour, relPaths);
1848
+ if (outcome === null) continue;
1849
+ if (outcome.created) repaired += 1;
1850
+ else if (claimedHere !== void 0 && outcome.added > 0) {
1851
+ forgottenSegments += outcome.added;
1852
+ forgottenHours += 1;
1873
1853
  }
1854
+ if (outcome.sealedNow) sealed += 1;
1874
1855
  }
1875
1856
  }
1876
1857
  index.markHydrated(deviceId, fromMs, toMs);
1858
+ if (verify || forgottenSegments > 0) {
1859
+ const message = "recorder: verified the hour ledger against the array";
1860
+ const entry = {
1861
+ tags: { deviceId },
1862
+ meta: {
1863
+ root,
1864
+ hoursRead,
1865
+ forgottenSegments,
1866
+ forgottenHours,
1867
+ sealed,
1868
+ verify
1869
+ }
1870
+ };
1871
+ if (forgottenSegments > 0) logger.warn(message, entry);
1872
+ else logger.info(message, entry);
1873
+ }
1877
1874
  if (hoursRead === 0) continue;
1878
1875
  const reuseAfter = index.hydrateReuseStats();
1879
1876
  const reused = reuseAfter.rowsReused - reuseBefore.rowsReused;
@@ -1884,6 +1881,7 @@ async function hydrateWindowFromStorage(index, deviceId, fromMs, toMs, locations
1884
1881
  root,
1885
1882
  hoursRead,
1886
1883
  repaired,
1884
+ sealed,
1887
1885
  reused,
1888
1886
  allocated
1889
1887
  }
@@ -7148,7 +7146,8 @@ async function probeVariantCodec(readPrefix, cache, deviceId, profile, firstPres
7148
7146
  * checks every one, playback does not. See the comment at the branch.
7149
7147
  */
7150
7148
  async function collectRangeSegments(deps, deviceId, profile, fromMs, toMs, uriMode) {
7151
- if (deps.index.hydrationOf(deviceId, fromMs, toMs) !== "hydrated") await deps.hydrateWindow?.(deviceId, fromMs, toMs);
7149
+ const verifyClosedHours = uriMode === "absolute";
7150
+ if (verifyClosedHours || deps.index.hydrationOf(deviceId, fromMs, toMs) !== "hydrated") await deps.hydrateWindow?.(deviceId, fromMs, toMs, verifyClosedHours);
7152
7151
  const hydration = deps.index.hydrationOf(deviceId, fromMs, toMs);
7153
7152
  const rootById = new Map(deps.locations().map((l) => [l.id, l.root]));
7154
7153
  const candidates = deps.index.segmentsOverlapping(deviceId, profile, fromMs, toMs).flatMap((s) => {
@@ -8528,7 +8527,7 @@ var macrotask$1 = () => new Promise((resolve) => {
8528
8527
  });
8529
8528
  /**
8530
8529
  * @durable class=mirror owner=recorder
8531
- * write="write-behind on every finalized segment (hour-row upsert); eviction drops paths; the archive walk reconciles disk into the row set"
8530
+ * 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"
8532
8531
  * retention="a row goes when the walk or eviction confirms the hour is empty — never by age, never from the ledger alone (D148)"
8533
8532
  */
8534
8533
  var RECORDING_SEGMENT_HOURS_COLLECTION = "recorder:segment-hours";
@@ -8597,7 +8596,17 @@ var RECORDING_SEGMENT_HOURS_COLUMNS = [
8597
8596
  type: "INTEGER",
8598
8597
  notNull: true,
8599
8598
  defaultValue: 0
8600
- }
8599
+ },
8600
+ (
8601
+ /** {@link SegmentHourRow.sealed}, as 0/1. A row written before the column
8602
+ * existed reads 0 — unsealed — which is the direction that costs one
8603
+ * directory read, never a missing hour. */
8604
+ {
8605
+ name: "sealed",
8606
+ type: "INTEGER",
8607
+ notNull: true,
8608
+ defaultValue: 0
8609
+ })
8601
8610
  ];
8602
8611
  var RECORDING_SEGMENT_HOURS_INDEXES = [
8603
8612
  {
@@ -8615,6 +8624,32 @@ var RECORDING_SEGMENT_HOURS_INDEXES = [
8615
8624
  {
8616
8625
  name: "idx_recorder_segment_hours_loc_hour",
8617
8626
  columns: ["locationId", "hourStartMs"]
8627
+ }),
8628
+ (
8629
+ /**
8630
+ * The accounting read: `SUM(bytes)`, `SUM(segments)`, `MIN(minStartMs)`,
8631
+ * `MAX(maxStartMs)` over `locationId IN (…)` — one row back, and with this
8632
+ * index not one table row read to produce it (D447).
8633
+ *
8634
+ * `idx_recorder_segment_hours_loc_hour` finds the same range, but the four
8635
+ * aggregates live in the table row, next to `paths` — up to 360 segment
8636
+ * paths of ~80 characters per hour — so the eviction index served the
8637
+ * accounting read at the price of ~30 KB of table page per hour row. On the
8638
+ * live hub that was `durationMs=1001…2902` once a MINUTE on
8639
+ * `hub/sqlite-settings` (174 slow-call WARNs in ten hours on 2026-09-10/11),
8640
+ * every one of them holding the thread every configuration read in the
8641
+ * cluster waits on. The index is ~50 bytes a row over ~19 k rows — nothing —
8642
+ * and the first declare after it ships builds it in well under a second.
8643
+ */
8644
+ {
8645
+ name: "idx_recorder_segment_hours_loc_accounting",
8646
+ columns: [
8647
+ "locationId",
8648
+ "bytes",
8649
+ "segments",
8650
+ "minStartMs",
8651
+ "maxStartMs"
8652
+ ]
8618
8653
  })
8619
8654
  ];
8620
8655
  /** The identity of the {@link hourAggregate} fold — an hour holding nothing. */
@@ -8673,6 +8708,7 @@ function hourAggregate(paths) {
8673
8708
  function segmentHourRow(base) {
8674
8709
  return {
8675
8710
  ...base,
8711
+ sealed: base.sealed ?? false,
8676
8712
  pathsResident: true,
8677
8713
  ...hourAggregate(base.paths)
8678
8714
  };
@@ -8785,7 +8821,8 @@ var SEGMENT_HOUR_SUMMARY_COLUMNS = [
8785
8821
  "bytes",
8786
8822
  "segments",
8787
8823
  "minStartMs",
8788
- "maxStartMs"
8824
+ "maxStartMs",
8825
+ "sealed"
8789
8826
  ];
8790
8827
  /** Summary rows folded into the mirror between yields. */
8791
8828
  var LOAD_CHUNK = 5e3;
@@ -8847,6 +8884,19 @@ function hourOf(row) {
8847
8884
  key: segmentHourKey(row.deviceId, row.profile, row.locationId, hour)
8848
8885
  };
8849
8886
  }
8887
+ /** The identity of one writer: a camera's profile. Location is not part of it —
8888
+ * a relocated writer is the same writer on a new root. */
8889
+ function writerKey(deviceId, profile) {
8890
+ return `${deviceId}|${profile}`;
8891
+ }
8892
+ function detailOf(row) {
8893
+ return {
8894
+ locationId: row.locationId,
8895
+ hourStartMs: row.hourStartMs,
8896
+ paths: row.paths,
8897
+ sealed: row.sealed
8898
+ };
8899
+ }
8850
8900
  /**
8851
8901
  * The `hourStartMs` of one probe record.
8852
8902
  *
@@ -8884,9 +8934,15 @@ function rowToValue(row) {
8884
8934
  bytes: row.bytes,
8885
8935
  segments: row.segments,
8886
8936
  minStartMs: row.minStartMs,
8887
- maxStartMs: row.maxStartMs
8937
+ maxStartMs: row.maxStartMs,
8938
+ sealed: row.sealed ? 1 : 0
8888
8939
  };
8889
8940
  }
8941
+ /** The stored 0/1 (or a boolean from a fake) as the flag. Anything else —
8942
+ * including the absence a pre-D451 row shows — is unsealed. */
8943
+ function sealedOf(raw) {
8944
+ return raw === 1 || raw === true;
8945
+ }
8890
8946
  function recordToRow(key, data) {
8891
8947
  const deviceId = Number(data["deviceId"]);
8892
8948
  const profile = data["profile"];
@@ -8905,7 +8961,8 @@ function recordToRow(key, data) {
8905
8961
  profile,
8906
8962
  locationId,
8907
8963
  hourStartMs: hour,
8908
- paths: raw
8964
+ paths: raw,
8965
+ sealed: sealedOf(data["sealed"])
8909
8966
  });
8910
8967
  }
8911
8968
  /** The paths of a summary row: one shared empty array, never a per-row
@@ -8940,6 +8997,7 @@ function summaryToRow(key, data) {
8940
8997
  hourStartMs: hour,
8941
8998
  paths: NO_PATHS,
8942
8999
  pathsResident: false,
9000
+ sealed: sealedOf(data["sealed"]),
8943
9001
  bytes: finiteOr0(data["bytes"]),
8944
9002
  segments: finiteOr0(data["segments"]),
8945
9003
  minStartMs: finiteOr0(data["minStartMs"]),
@@ -8970,6 +9028,16 @@ var SegmentHourLedger = class SegmentHourLedger {
8970
9028
  loadChunk;
8971
9029
  yieldBetween;
8972
9030
  rowCap;
9031
+ /**
9032
+ * One lane per hour key: every mutation of a row runs after the previous one
9033
+ * on the same key has finished. Two appends that both read the row and both
9034
+ * wrote it back used to keep only the second — the sparse rows D451 found.
9035
+ */
9036
+ lanes = /* @__PURE__ */ new Map();
9037
+ /** Per (device, profile): the hour its writer last appended to. */
9038
+ writerHours = /* @__PURE__ */ new Map();
9039
+ /** Hour keys a write was REFUSED for in this process — never sealed by it. */
9040
+ refused = /* @__PURE__ */ new Set();
8973
9041
  constructor(deps) {
8974
9042
  this.logger = deps.logger;
8975
9043
  this.store = deps.store;
@@ -9152,57 +9220,170 @@ var SegmentHourLedger = class SegmentHourLedger {
9152
9220
  if (row.deviceId !== deviceId) continue;
9153
9221
  if (row.hourStartMs < first || row.hourStartMs >= toMs) continue;
9154
9222
  if (row.pathsResident) {
9155
- out.push({
9156
- locationId: row.locationId,
9157
- hourStartMs: row.hourStartMs,
9158
- paths: row.paths
9159
- });
9223
+ out.push(detailOf(row));
9160
9224
  continue;
9161
9225
  }
9162
9226
  wanted.push(row.key);
9163
9227
  }
9164
- for (const row of await this.materialize(wanted, deviceId)) out.push({
9165
- locationId: row.locationId,
9166
- hourStartMs: row.hourStartMs,
9167
- paths: row.paths
9168
- });
9228
+ for (const row of await this.materialize(wanted, deviceId)) out.push(detailOf(row));
9169
9229
  return out;
9170
9230
  }
9171
9231
  /**
9172
- * Adopt an hour the storage walk rebuilt and the ledger never held.
9232
+ * Union a CLOSED hour the storage walk read into its row, and SEAL it.
9173
9233
  *
9174
9234
  * The reader repairs the durable index it just reconstructed, so the next
9175
9235
  * cold read of this hour is a point read instead of another `readdir`
9176
- * (`docs/design/2026-08-29-reference-nvr-recording-model.md` §4.2). Two
9177
- * constraints, both enforced by the caller and re-stated here because
9178
- * getting either wrong turns a READ into durable damage: the hour must be
9179
- * CLOSED (a live hour's directory races the writer, so a walk of it is a
9180
- * lower bound, not the truth), and the ledger must hold no row for it (an
9181
- * existing row is the authority; replacing it with a walk's view is the
9182
- * D148 prune this ledger refuses to do on its own evidence).
9236
+ * (`docs/design/2026-08-29-reference-nvr-recording-model.md` §4.2), and it
9237
+ * seals the row because a directory read of a closed hour is the one
9238
+ * complete answer there is. The caller guarantees the hour is CLOSED — a
9239
+ * live hour's directory races its writer, so a walk of it is a lower bound.
9240
+ *
9241
+ * A row the ledger already holds is UNIONED, never replaced: the walk can
9242
+ * only add (D148) — a path the row names and the directory lacks stays,
9243
+ * because "the reader did not see it" is not evidence it is gone. This is
9244
+ * the repair D445 described and did not perform: its predecessor returned
9245
+ * on any known key, so a row known INCOMPLETELY — the only kind that needs
9246
+ * repairing — was exactly the row it refused to touch, and the export's
9247
+ * `forgottenSegments=9758` was counted again the next morning.
9248
+ *
9249
+ * `null` when the row is known and its detail could not be read: nothing
9250
+ * is written from a row whose paths are not resident.
9183
9251
  */
9184
- async adoptRebuiltHour(deviceId, profile, locationId, hour, paths) {
9252
+ async adoptWalkedHour(deviceId, profile, locationId, hour, paths) {
9185
9253
  const key = segmentHourKey(deviceId, profile, locationId, hour);
9186
- if (paths.length === 0 || this.ledger.has(key)) return;
9187
- const row = segmentHourRow({
9188
- key,
9189
- deviceId,
9190
- profile,
9191
- locationId,
9192
- hourStartMs: hour,
9193
- paths: uniquePaths(paths)
9254
+ return this.serialized(key, async () => {
9255
+ if (!this.ledger.has(key)) {
9256
+ if (paths.length === 0) return {
9257
+ created: false,
9258
+ added: 0,
9259
+ sealedNow: false
9260
+ };
9261
+ const row = segmentHourRow({
9262
+ key,
9263
+ deviceId,
9264
+ profile,
9265
+ locationId,
9266
+ hourStartMs: hour,
9267
+ paths: uniquePaths(paths),
9268
+ sealed: true
9269
+ });
9270
+ await this.ledger.put(row);
9271
+ this.logger.info("recorder: adopted a closed hour the disk walk rebuilt and the ledger never held", {
9272
+ tags: { deviceId },
9273
+ meta: {
9274
+ profile,
9275
+ locationId,
9276
+ hourStartMs: hour,
9277
+ segments: row.segments,
9278
+ bytes: row.bytes
9279
+ }
9280
+ });
9281
+ return {
9282
+ created: true,
9283
+ added: row.paths.length,
9284
+ sealedNow: true
9285
+ };
9286
+ }
9287
+ const held = await this.resident(key, deviceId);
9288
+ if (held === null) return null;
9289
+ const union = uniquePaths([...held.paths, ...paths]);
9290
+ const added = union.length - held.paths.length;
9291
+ if (added === 0 && held.sealed) return {
9292
+ created: false,
9293
+ added: 0,
9294
+ sealedNow: false
9295
+ };
9296
+ await this.ledger.put(segmentHourRow({
9297
+ ...held,
9298
+ paths: union,
9299
+ sealed: true
9300
+ }));
9301
+ return {
9302
+ created: false,
9303
+ added,
9304
+ sealedNow: !held.sealed
9305
+ };
9194
9306
  });
9195
- await this.ledger.put(row);
9196
- this.logger.info("recorder: adopted a closed hour the disk walk rebuilt and the ledger never held", {
9197
- tags: { deviceId },
9198
- meta: {
9199
- profile,
9200
- locationId,
9201
- hourStartMs: hour,
9202
- segments: row.segments,
9203
- bytes: row.bytes
9307
+ }
9308
+ /**
9309
+ * The writer of `(deviceId, profile)` stopped gracefully: seal the hour it
9310
+ * was appending to. One write, and only if the row has every finalize this
9311
+ * ledger was handed for it.
9312
+ */
9313
+ async sealWriter(deviceId, profile) {
9314
+ const last = this.writerHours.get(writerKey(deviceId, profile));
9315
+ if (last === void 0) return;
9316
+ await this.sealHour(last);
9317
+ }
9318
+ /**
9319
+ * Every mutation queued so far has landed — including the seals a rollover
9320
+ * fires without waiting. Awaited at shutdown so the hour a writer just
9321
+ * closed is sealed before the process is gone.
9322
+ */
9323
+ async flush() {
9324
+ while (this.lanes.size > 0) await Promise.all([...this.lanes.values()]);
9325
+ }
9326
+ /**
9327
+ * A finalize this ledger was NEVER handed — the fire-and-forget lane refused
9328
+ * it. The hour's row is short by that path and this process is the only
9329
+ * party that knows, so the hour must not be sealed by it; a reader walks it
9330
+ * once instead, which is exactly what an unsealed row is for.
9331
+ */
9332
+ markRefused(row) {
9333
+ this.refused.add(hourOf(row).key);
9334
+ }
9335
+ /** One write: the row as held, flagged sealed. Serialised on its key so it
9336
+ * cannot land over an append that read the row before it. */
9337
+ async sealHour(target) {
9338
+ await this.serialized(target.key, async () => {
9339
+ if (this.refused.has(target.key)) {
9340
+ 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", {
9341
+ tags: { deviceId: target.deviceId },
9342
+ meta: {
9343
+ profile: target.profile,
9344
+ locationId: target.locationId,
9345
+ hourStartMs: target.hourStartMs
9346
+ }
9347
+ });
9348
+ return;
9204
9349
  }
9350
+ const held = await this.resident(target.key, target.deviceId);
9351
+ if (held === null || held.sealed) return;
9352
+ await this.ledger.put({
9353
+ ...held,
9354
+ sealed: true
9355
+ });
9356
+ });
9357
+ }
9358
+ /**
9359
+ * Remember which hour this writer is on; when it MOVES FORWARD, seal the
9360
+ * hour it left. Backwards is a late finalize (a recovered orphan, a replay)
9361
+ * into an hour already accounted for, and seals nothing.
9362
+ */
9363
+ noteWriter(next) {
9364
+ const id = writerKey(next.deviceId, next.profile);
9365
+ const last = this.writerHours.get(id);
9366
+ if (last !== void 0 && last.key === next.key) return;
9367
+ if (last === void 0 || next.hourStartMs > last.hourStartMs) this.writerHours.set(id, next);
9368
+ if (last !== void 0 && next.hourStartMs > last.hourStartMs) this.sealHour(last);
9369
+ }
9370
+ /**
9371
+ * Run `fn` after every mutation already queued on `key`.
9372
+ *
9373
+ * A row is read, appended to and written back with awaits in between; two
9374
+ * such runs on one key interleave, and the second write carries a paths
9375
+ * array that never saw the first — the write-behind's own lost update. The
9376
+ * lane makes "read, append, write" one step per key. A rejected step must
9377
+ * not poison the lane: the next step still runs.
9378
+ */
9379
+ serialized(key, fn) {
9380
+ const run = (this.lanes.get(key) ?? Promise.resolve()).then(fn, fn);
9381
+ const settled = run.then(() => void 0, () => void 0);
9382
+ this.lanes.set(key, settled);
9383
+ settled.then(() => {
9384
+ if (this.lanes.get(key) === settled) this.lanes.delete(key);
9205
9385
  });
9386
+ return run;
9206
9387
  }
9207
9388
  /**
9208
9389
  * Fetch whole rows for `keys` and promote them in the mirror.
@@ -9797,30 +9978,40 @@ var SegmentHourLedger = class SegmentHourLedger {
9797
9978
  */
9798
9979
  async recordSegment(row) {
9799
9980
  const { key, hourStartMs: hour } = hourOf(row);
9800
- if (!this.ledger.has(key)) {
9801
- await this.ledger.put(segmentHourRow({
9802
- key,
9803
- deviceId: row.deviceId,
9804
- profile: row.profile,
9805
- locationId: row.locationId,
9806
- hourStartMs: hour,
9807
- paths: [row.path]
9808
- }));
9809
- return;
9810
- }
9811
- const held = await this.resident(key, row.deviceId);
9812
- if (held === null) {
9813
- this.logger.warn("recorder: hour detail unavailable — this finalized segment is NOT in the durable hour row yet", {
9814
- tags: { deviceId: row.deviceId },
9815
- meta: {
9981
+ this.noteWriter({
9982
+ key,
9983
+ deviceId: row.deviceId,
9984
+ profile: row.profile,
9985
+ locationId: row.locationId,
9986
+ hourStartMs: hour
9987
+ });
9988
+ await this.serialized(key, async () => {
9989
+ if (!this.ledger.has(key)) {
9990
+ await this.ledger.put(segmentHourRow({
9816
9991
  key,
9817
- path: row.path
9818
- }
9819
- });
9820
- return;
9821
- }
9822
- if (held.paths.includes(row.path)) return;
9823
- await this.ledger.put(appendSegmentPath(held, row.path));
9992
+ deviceId: row.deviceId,
9993
+ profile: row.profile,
9994
+ locationId: row.locationId,
9995
+ hourStartMs: hour,
9996
+ paths: [row.path]
9997
+ }));
9998
+ return;
9999
+ }
10000
+ const held = await this.resident(key, row.deviceId);
10001
+ if (held === null) {
10002
+ this.refused.add(key);
10003
+ this.logger.warn("recorder: hour detail unavailable — this finalized segment is NOT in the durable hour row yet", {
10004
+ tags: { deviceId: row.deviceId },
10005
+ meta: {
10006
+ key,
10007
+ path: row.path
10008
+ }
10009
+ });
10010
+ return;
10011
+ }
10012
+ if (held.paths.includes(row.path)) return;
10013
+ await this.ledger.put(appendSegmentPath(held, row.path));
10014
+ });
9824
10015
  }
9825
10016
  /**
9826
10017
  * Eviction confirmed these paths are gone from disk. Drop them; forget the
@@ -9996,7 +10187,8 @@ var SegmentHourLedger = class SegmentHourLedger {
9996
10187
  profile: seg.profile,
9997
10188
  locationId: seg.locationId,
9998
10189
  hourStartMs: hour,
9999
- paths: [seg.path]
10190
+ paths: [seg.path],
10191
+ sealed: hour !== currentHour
10000
10192
  }));
10001
10193
  else fromIndex.set(key, appendSegmentPath(existing, seg.path));
10002
10194
  }
@@ -10517,10 +10709,16 @@ var GhostLocationSweeper = class {
10517
10709
  };
10518
10710
  Object.freeze({
10519
10711
  "addons": { "getLogs": { "limit": 100 } },
10520
- "audio-analyzer": { "analyseChunk": { "settings": { "__nested__": {
10521
- "minConfidence": .3,
10522
- "allowedClasses": []
10523
- } } } },
10712
+ "audio-analyzer": {
10713
+ "analyseChunk": { "settings": { "__nested__": {
10714
+ "minConfidence": .3,
10715
+ "allowedClasses": []
10716
+ } } },
10717
+ "attachDevice": { "settings": { "__nested__": {
10718
+ "minConfidence": .3,
10719
+ "allowedClasses": []
10720
+ } } }
10721
+ },
10524
10722
  "audio-codec": {
10525
10723
  "pullEncoded": { "maxCount": 8 },
10526
10724
  "pullPcm": { "maxCount": 8 }
@@ -15921,6 +16119,17 @@ var RecordingController = class {
15921
16119
  flushMs: Date.now() - teardownStartMs - writerStopMs
15922
16120
  }
15923
16121
  });
16122
+ try {
16123
+ await this.deps.onWriterClosed?.(deviceId, r.profile);
16124
+ } catch (err) {
16125
+ this.deps.logger.warn("recorder: sealing the closed writer’s hour failed — it stays unsealed", {
16126
+ tags: { deviceId },
16127
+ meta: {
16128
+ profile: r.profile,
16129
+ error: errMsg(err)
16130
+ }
16131
+ });
16132
+ }
15924
16133
  try {
15925
16134
  r.watcher.stop();
15926
16135
  } catch {}
@@ -17437,10 +17646,13 @@ var RecorderV2Addon = class extends BaseAddon {
17437
17646
  },
17438
17647
  onIndexed: (row, absPath) => {
17439
17648
  const ledger = this.segmentHours;
17440
- if (ledger && !this.ledgerWriteGate.run(() => ledger.recordSegment(row))) this.ctx.logger.debug("recorder: hour-ledger write dropped — lane saturated; the archive walk reconciles this hour", {
17441
- tags: { deviceId: row.deviceId },
17442
- meta: { path: row.path }
17443
- });
17649
+ if (ledger && !this.ledgerWriteGate.run(() => ledger.recordSegment(row))) {
17650
+ ledger.markRefused(row);
17651
+ this.ctx.logger.debug("recorder: hour-ledger write dropped — lane saturated; the hour stays unsealed and a reader walks it", {
17652
+ tags: { deviceId: row.deviceId },
17653
+ meta: { path: row.path }
17654
+ });
17655
+ }
17444
17656
  if (!isMfraTableServable(row.startMs, Date.now())) return;
17445
17657
  if (!this.mfraCaptureGate.run(async () => {
17446
17658
  try {
@@ -17568,6 +17780,9 @@ var RecorderV2Addon = class extends BaseAddon {
17568
17780
  locations: () => this.resolvedLocations,
17569
17781
  placeProfile: (deviceId, profile) => this.placeProfile(deviceId, profile),
17570
17782
  onPlacementTick: (deviceIds) => this.recomputePlacement(deviceIds),
17783
+ onWriterClosed: async (deviceId, profile) => {
17784
+ await this.segmentHours?.sealWriter(deviceId, profile);
17785
+ },
17571
17786
  spawn,
17572
17787
  costRegistry: this.costRegistry,
17573
17788
  segmentSeconds: this.config.segmentSeconds,
@@ -17637,7 +17852,7 @@ var RecorderV2Addon = class extends BaseAddon {
17637
17852
  },
17638
17853
  calendar: this.calendar,
17639
17854
  mfraFor: (deviceId, profile, startMs) => this.mfraTables.get(deviceId, profile, startMs),
17640
- hydrateWindow: (deviceId, fromMs, toMs) => hydrateWindowFromStorage(this.index, deviceId, fromMs, toMs, this.resolvedLocations, this.ctx.logger, this.segmentHours ?? void 0),
17855
+ hydrateWindow: (deviceId, fromMs, toMs, verifyClosedHours) => hydrateWindowFromStorage(this.index, deviceId, fromMs, toMs, this.resolvedLocations, this.ctx.logger, this.segmentHours ?? void 0, { verifyClosedHours: verifyClosedHours === true }),
17641
17856
  onConfigChanged: async (deviceId) => {
17642
17857
  await this.refreshSensorTriggerBindings();
17643
17858
  await (this.controller?.onConfigChanged(deviceId) ?? Promise.resolve());
@@ -17917,6 +18132,7 @@ var RecorderV2Addon = class extends BaseAddon {
17917
18132
  this.locationRefresher?.dispose();
17918
18133
  this.locationRefresher = null;
17919
18134
  await this.controller?.stop();
18135
+ await this.segmentHours?.flush();
17920
18136
  this.controller = null;
17921
18137
  this.segmentStore = null;
17922
18138
  if (this.exportSweepTimer !== null) {