@camstack/addon-pipeline 1.2.99 → 1.2.101

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.
@@ -1,6 +1,7 @@
1
1
  const require_dist = require("../dist-dm3t4BOt.js");
2
2
  const require_hub_hostname = require("../hub-hostname-DAJXlOgV.js");
3
3
  const require_addon_utils = require("../addon-utils-eUS6n_Zj.js");
4
+ const require_retire_root_keys = require("../retire-root-keys-KE6D6Xh_.js");
4
5
  let node_crypto = require("node:crypto");
5
6
  let node_child_process = require("node:child_process");
6
7
  let node_path = require("node:path");
@@ -1374,6 +1375,17 @@ function encodeDirectory(payload) {
1374
1375
  }
1375
1376
  //#endregion
1376
1377
  //#region src/recorder/segment-store.ts
1378
+ /**
1379
+ * Below this a file cannot be a media segment: one fMP4 `moof` + `mdat`
1380
+ * carrying a single sample already exceeds this by an order of magnitude.
1381
+ * What it actually catches is the structural stub — ffmpeg opens the next
1382
+ * segment the instant it closes the previous one, so a killed writer always
1383
+ * leaves one just-opened file (observed: 28-byte bare `styp` boxes). The boot
1384
+ * recovery plans by NAME and would otherwise index it with a nominal duration,
1385
+ * painting the timeline over footage that decodes to nothing — the same
1386
+ * failure `MAX_GAP_FACTOR` in staging-reconcile exists to prevent via gaps.
1387
+ */
1388
+ var MIN_INDEXABLE_SEGMENT_BYTES = 64;
1377
1389
  var SegmentStore = class SegmentStore {
1378
1390
  deps;
1379
1391
  constructor(deps) {
@@ -1397,7 +1409,16 @@ var SegmentStore = class SegmentStore {
1397
1409
  });
1398
1410
  return null;
1399
1411
  }
1400
- if (bytes <= 0) return null;
1412
+ if (bytes < MIN_INDEXABLE_SEGMENT_BYTES) {
1413
+ this.deps.logger.warn("segment too small to index — left in staging", {
1414
+ tags: { deviceId: input.deviceId },
1415
+ meta: {
1416
+ bytes,
1417
+ path: input.flatAbsPath
1418
+ }
1419
+ });
1420
+ return null;
1421
+ }
1401
1422
  const relPath = segmentRelPath(input.deviceId, input.profile, input.startMs, input.durMs, bytes);
1402
1423
  const toAbs = `${input.locationRoot}/${relPath}`;
1403
1424
  try {
@@ -2461,70 +2482,75 @@ var CalendarIndex = class {
2461
2482
  //#endregion
2462
2483
  //#region src/recorder/addon/config-store.ts
2463
2484
  /**
2464
- * Per-device recording-config persistence for the recorder addon.
2465
- *
2466
- * Every camera's `RecordingConfig` lives in ONE addon-store blob keyed by
2467
- * (stringified) numeric deviceId enumerable for boot hydrate (so the addon
2468
- * knows which devices have persisted footage to index without scanning every
2469
- * device store). The whole Zod-validated blob round-trips on every read/write
2470
- * via a {@link DurableState} handle, so no per-device field can be silently
2471
- * dropped on persist. A corrupt blob reads back as EMPTY rather than crashing
2472
- * boot.
2473
- *
2474
- * P3b settings-API migration: the functions here consume the durable handle
2475
- * directly — in production the recorder addon builds it via the sanctioned
2476
- * `BaseAddon.state()` (retry budget + validated fallback + parse-warning
2477
- * logging); {@link recordingConfigsState} adapts a raw store slice for test
2478
- * fakes. Same store key, same persisted blob shape as before.
2479
- *
2480
- * `bands` is the ONLY authored recording intent (the legacy
2481
- * mode/schedules/triggers/rules authoring surface was retired 2026-07-30); on
2482
- * save `mode` is stamped as their derived summary for cheap consumers.
2483
- */
2484
- /** The addon-store key holding the deviceId → RecordingConfig map. */
2485
- var RECORDING_CONFIGS_KEY = "recordingConfigs";
2486
- /**
2487
- * Persisted shape: stringified numeric deviceId → full RecordingConfig.
2488
- *
2489
- * READ is forgiving where the wire contract is strict: `RecordingConfigSchema`
2490
- * rejects unknown keys so a stale caller fails loudly, but a blob written by an
2491
- * older build may still carry retired keys — and one unparseable device would
2492
- * take the WHOLE blob down to the empty fallback (= every camera silently
2493
- * unconfigured). `.strip()` drops them instead.
2494
- */
2495
- var RecordingConfigsBlobSchema = require_dist.record(require_dist.string(), require_dist.RecordingConfigSchema.strip());
2485
+ * @durable class=config owner=recorder
2486
+ * write="one row per camera, written by `saveDeviceConfig` (the `recording` cap's
2487
+ * setConfig / band authoring) the only writer"
2488
+ * retention="none by age. A row goes only when the operator deletes the camera's
2489
+ * configuration; an unconfigured camera has no row and reads as DEFAULT_DEVICE_CONFIG"
2490
+ */
2491
+ var RECORDING_CONFIGS_COLLECTION = "recorder:recording-configs";
2492
+ var RECORDING_CONFIGS_SPEC = {
2493
+ collection: RECORDING_CONFIGS_COLLECTION,
2494
+ schema: require_dist.RecordingConfigSchema.strip(),
2495
+ columns: [
2496
+ {
2497
+ name: "deviceId",
2498
+ type: "INTEGER",
2499
+ notNull: true
2500
+ },
2501
+ {
2502
+ name: "enabled",
2503
+ type: "BOOLEAN",
2504
+ notNull: true
2505
+ },
2506
+ {
2507
+ name: "mode",
2508
+ type: "TEXT"
2509
+ }
2510
+ ],
2511
+ project: (key, value) => ({
2512
+ deviceId: Number(key),
2513
+ enabled: value.enabled,
2514
+ mode: value.mode ?? null
2515
+ }),
2516
+ deviceIdColumn: "deviceId",
2517
+ loadLimit: 2e4
2518
+ };
2496
2519
  /** Cold-start default for a camera that has never been configured: disabled, no bands. */
2497
2520
  var DEFAULT_DEVICE_CONFIG = {
2498
2521
  enabled: false,
2499
2522
  bands: []
2500
2523
  };
2501
- function serialize$1(map) {
2502
- const out = {};
2503
- for (const [id, config] of map) out[String(id)] = config;
2504
- return out;
2524
+ /** Build the handle. `declare()` MUST run once at boot before any read/write. */
2525
+ function recordingConfigsState(store, logger) {
2526
+ return new require_retire_root_keys.RowMapStore({
2527
+ spec: RECORDING_CONFIGS_SPEC,
2528
+ store,
2529
+ logger
2530
+ });
2505
2531
  }
2506
2532
  /** Read every persisted device config, keyed by numeric deviceId. */
2507
2533
  async function readDeviceConfigs(state) {
2508
- const blob = await state.get();
2534
+ const rows = await state.readAll();
2509
2535
  const map = /* @__PURE__ */ new Map();
2510
- for (const [key, config] of Object.entries(blob)) {
2536
+ for (const [key, config] of rows) {
2511
2537
  const id = Number(key);
2512
2538
  if (Number.isInteger(id)) map.set(id, config);
2513
2539
  }
2514
2540
  return map;
2515
2541
  }
2516
2542
  /**
2517
- * The persisted config for a device — `bands` is always materialized (the
2518
- * schema defaults it to `[]`). An unconfigured device returns
2543
+ * The persisted config for a device — ONE keyed row read. `bands` is always
2544
+ * materialized (the schema defaults it to `[]`). An unconfigured device returns
2519
2545
  * {@link DEFAULT_DEVICE_CONFIG}.
2520
2546
  */
2521
2547
  async function loadDeviceConfig(state, deviceId) {
2522
- return (await readDeviceConfigs(state)).get(deviceId) ?? DEFAULT_DEVICE_CONFIG;
2548
+ return await state.read(String(deviceId)) ?? DEFAULT_DEVICE_CONFIG;
2523
2549
  }
2524
2550
  /**
2525
2551
  * The devices whose stored config claims to record (`enabled` + a non-`off`
2526
2552
  * derived `mode`) but has no band left — the one shape this store cannot
2527
- * repair. It can only come from a pre-retirement blob whose legacy authoring
2553
+ * repair. It can only come from a pre-retirement config whose legacy authoring
2528
2554
  * fields were stripped on read: the camera records NOTHING until its bands are
2529
2555
  * re-authored, so the recorder shouts about it once at boot
2530
2556
  * ({@link warnLostBands}) instead of failing silently.
@@ -2541,7 +2567,7 @@ async function warnLostBands(state, logger) {
2541
2567
  logger.warn(`recording config: device(s) ${lost.join(", ")} claim a recording mode but have NO bands — a pre-retirement config that lost its legacy authoring fields. Nothing is recorded for them until their bands are re-authored.`);
2542
2568
  }
2543
2569
  /**
2544
- * Persist (insert or replace) a device's config. Validates against
2570
+ * Persist (insert or replace) a device's config — ONE row. Validates against
2545
2571
  * `RecordingConfigSchema` at the boundary (untrusted cap input). Returns the
2546
2572
  * validated config that was persisted.
2547
2573
  */
@@ -2551,9 +2577,7 @@ async function saveDeviceConfig(state, deviceId, config) {
2551
2577
  ...parsed,
2552
2578
  mode: parsed.enabled ? activeModeForConfig(parsed) : "off"
2553
2579
  };
2554
- const map = await readDeviceConfigs(state);
2555
- map.set(deviceId, validated);
2556
- await state.set(serialize$1(map));
2580
+ await state.write(String(deviceId), validated);
2557
2581
  return validated;
2558
2582
  }
2559
2583
  /**
@@ -2920,61 +2944,122 @@ function buildExportArgs(input) {
2920
2944
  /**
2921
2945
  * Recording-export persistence for the recorder addon.
2922
2946
  *
2923
- * Every export job/history row lives in ONE addon-store blob keyed by export
2924
- * id. The whole Zod-validated blob round-trips on every read/write via a
2925
- * {@link DurableState} handle (same pattern as `config-store.ts`), so no field
2926
- * is silently dropped on persist. History rows are never removed by the system
2927
- * (audit) `deleteExport`/janitor flip `state` and clear the file, keeping the
2928
- * row. A corrupt blob reads back as EMPTY rather than crashing boot.
2929
- */
2930
- /**
2931
- * The addon-store key holding the exportId ExportRecord map. Production
2932
- * builds the durable handle via `BaseAddon.state(RECORDING_EXPORTS_KEY,
2933
- * RecordingExportsBlobSchema, {})`; the pure functions below take that handle,
2934
- * so this module never touches the @internal raw addon-store primitive.
2935
- */
2936
- var RECORDING_EXPORTS_KEY = "recordingExports";
2937
- /** Persisted shape: export id full ExportRecord. */
2938
- var RecordingExportsBlobSchema = require_dist.record(require_dist.string(), require_dist.ExportRecordSchema);
2947
+ * ONE ROW PER EXPORT in `recorder:exports`, through {@link RowMapStore}. History
2948
+ * rows are never removed by the system (audit) — `deleteExport` / the janitor
2949
+ * flip `state` and clear the file, keeping the row.
2950
+ *
2951
+ * ## What this replaced, and why it mattered here more than anywhere
2952
+ *
2953
+ * Every job used to live in one key of the recorder's `addon-settings` / `root`
2954
+ * blob — 201 833 bytes on the reference hub, of which the export history was
2955
+ * 50 441. The engine patches `progressPct` roughly once a second for an
2956
+ * in-flight render, so moving one integer read, parsed, re-serialised and
2957
+ * rewrote 201 KB, per second.
2958
+ *
2959
+ * Worse than the cost was the CLOBBER. Both halves of a read-modify-write are
2960
+ * async, so two overlapping cycles read the same snapshot and the second `set`
2961
+ * discarded whatever the first added. On 2026-08-14 a `createExport` for camera
2962
+ * 615 landed between one progress patch's read and its write, the row was
2963
+ * erased, and the poll answered `unknown export "547ea182-…"`; the scheduler
2964
+ * read the vanished row as a failed render and re-queued forever. A per-row
2965
+ * upsert makes that class of loss unrepresentable — a write names ONE row.
2966
+ *
2967
+ * What is still needed is a per-ID serialiser: two patches to the SAME export
2968
+ * still read-modify-write the same row. That is what {@link serialize} bounds,
2969
+ * and it is now per (store, id) rather than per store, so two exports never
2970
+ * queue behind each other.
2971
+ */
2972
+ /**
2973
+ * @durable class=ledger owner=recorder
2974
+ * write="one row per export job, inserted by `createExport` and rewritten by the
2975
+ * render engine's progress/state patches and by the download hook"
2976
+ * retention="the SYSTEM never deletes a row — it is the export AUDIT. `deleteExport`
2977
+ * and the janitor flip `state` to 'deleted'/'expired' and remove the FILE, keeping
2978
+ * the row. `removeExport` exists for an operator-driven purge and nothing calls it
2979
+ * on a schedule."
2980
+ */
2981
+ var RECORDING_EXPORTS_COLLECTION = "recorder:exports";
2982
+ /**
2983
+ * Bound for the whole-history read. Explicit because `settings-store.query`
2984
+ * caps an unbounded read at 2 000 rows SILENTLY, and an export list that
2985
+ * silently loses its oldest half looks exactly like a purge.
2986
+ */
2987
+ var RECORDING_EXPORTS_LIMIT = 2e4;
2988
+ var RECORDING_EXPORTS_SPEC = {
2989
+ collection: RECORDING_EXPORTS_COLLECTION,
2990
+ schema: require_dist.ExportRecordSchema,
2991
+ columns: [
2992
+ {
2993
+ name: "deviceId",
2994
+ type: "INTEGER",
2995
+ notNull: true
2996
+ },
2997
+ {
2998
+ name: "createdAt",
2999
+ type: "INTEGER",
3000
+ notNull: true
3001
+ },
3002
+ {
3003
+ name: "state",
3004
+ type: "TEXT",
3005
+ notNull: true
3006
+ }
3007
+ ],
3008
+ indexes: [{
3009
+ name: "idx_recorder_exports_device",
3010
+ columns: ["deviceId"]
3011
+ }, {
3012
+ name: "idx_recorder_exports_created",
3013
+ columns: ["createdAt"]
3014
+ }],
3015
+ project: (_key, value) => ({
3016
+ deviceId: value.deviceId,
3017
+ createdAt: value.createdAt,
3018
+ state: value.state
3019
+ }),
3020
+ deviceIdColumn: "deviceId",
3021
+ loadLimit: RECORDING_EXPORTS_LIMIT
3022
+ };
3023
+ /** Build the handle. `declare()` MUST run once at boot before any read/write. */
3024
+ function recordingExportsState(store, logger) {
3025
+ return new require_retire_root_keys.RowMapStore({
3026
+ spec: RECORDING_EXPORTS_SPEC,
3027
+ store,
3028
+ logger
3029
+ });
3030
+ }
2939
3031
  /**
2940
- * One in-flight mutation chain per durable handle.
2941
- *
2942
- * EVERY mutation here is a read-modify-write of the SAME blob, and both halves
2943
- * are async — so two overlapping cycles read the same snapshot and the second
2944
- * `set` discards whatever the first one added. Last writer wins, silently.
3032
+ * One in-flight mutation chain per (handle, export id).
2945
3033
  *
2946
- * That is not a hypothetical. The engine patches `progressPct` roughly once a
2947
- * second for an in-flight render, unawaited; on 2026-08-14 a `createExport` for
2948
- * camera 615 landed between one such patch's read and its write, the row was
2949
- * erased, and five seconds later the poll answered `unknown export
2950
- * "547ea182-…"`. The camera's whole night was lost to it the scheduler read a
2951
- * vanished row as a failed render and re-queued, forever.
3034
+ * A row upsert removed the cross-export clobber; it does not order two
3035
+ * read-modify-writes of the SAME row. `patchExport` reads the row, merges and
3036
+ * writes so two overlapping patches on one export still race, and the loser's
3037
+ * fields vanish. Serialising per ID keeps those pairs atomic while leaving
3038
+ * unrelated exports fully concurrent (the old chain was per HANDLE, so a slow
3039
+ * patch queued every other export behind it).
2952
3040
  *
2953
- * Serialising per handle is the smallest fix that makes the loss impossible:
2954
- * the cycles queue instead of interleaving, and the queue is per state object
2955
- * so two recorders in one process never contend. A `WeakMap` keeps a disposed
2956
- * handle collectable. Reads stay OUTSIDE the chain — a blob replacement is
2957
- * atomic, so a reader always sees one consistent generation.
3041
+ * A `WeakMap` keyed by the handle keeps a disposed store collectable; the inner
3042
+ * map is keyed by export id.
2958
3043
  */
2959
3044
  var mutationChains = /* @__PURE__ */ new WeakMap();
2960
- /** Run `work` after every mutation already queued for `state`, never before. */
2961
- async function serialize(state, work) {
2962
- const next = (mutationChains.get(state) ?? Promise.resolve()).then(work, work);
2963
- mutationChains.set(state, next.catch(() => void 0));
3045
+ /** Run `work` after every mutation already queued for `(state, id)`, never before. */
3046
+ async function serialize(state, id, work) {
3047
+ const existing = mutationChains.get(state);
3048
+ const queued = existing ?? /* @__PURE__ */ new Map();
3049
+ if (existing === void 0) mutationChains.set(state, queued);
3050
+ const next = (queued.get(id) ?? Promise.resolve()).then(work, work);
3051
+ const settled = next.catch(() => void 0);
3052
+ queued.set(id, settled);
3053
+ settled.then(() => {
3054
+ if (queued.get(id) === settled) queued.delete(id);
3055
+ });
2964
3056
  return next;
2965
3057
  }
2966
- /** Read every persisted export row, keyed by export id. */
2967
- async function readExports(state) {
2968
- const blob = await state.get();
2969
- return new Map(Object.entries(blob));
2970
- }
2971
3058
  /** Insert or replace one export row. Validates at the boundary. Returns it. */
2972
3059
  async function upsertExport(state, record) {
2973
3060
  const validated = require_dist.ExportRecordSchema.parse(record);
2974
- return serialize(state, async () => {
2975
- const map = await readExports(state);
2976
- map.set(validated.id, validated);
2977
- await state.set(Object.fromEntries(map));
3061
+ return serialize(state, validated.id, async () => {
3062
+ await state.write(validated.id, validated);
2978
3063
  return validated;
2979
3064
  });
2980
3065
  }
@@ -2983,28 +3068,37 @@ async function upsertExport(state, record) {
2983
3068
  * record, or null when the id is unknown.
2984
3069
  */
2985
3070
  async function patchExport(state, id, patch) {
2986
- return serialize(state, async () => {
2987
- const map = await readExports(state);
2988
- const existing = map.get(id);
2989
- if (!existing) return null;
3071
+ return serialize(state, id, async () => {
3072
+ const existing = await state.read(id);
3073
+ if (existing === null) return null;
2990
3074
  const merged = require_dist.ExportRecordSchema.parse({
2991
3075
  ...existing,
2992
3076
  ...patch
2993
3077
  });
2994
- map.set(id, merged);
2995
- await state.set(Object.fromEntries(map));
3078
+ await state.write(id, merged);
2996
3079
  return merged;
2997
3080
  });
2998
3081
  }
2999
- /** Read one export row by id, or null. */
3082
+ /** Read one export row by id ONE keyed row read. */
3000
3083
  async function getExportRecord(state, id) {
3001
- return (await readExports(state)).get(id) ?? null;
3084
+ return state.read(id);
3002
3085
  }
3003
3086
  /**
3004
3087
  * List export rows, newest-first, optionally scoped to one device.
3088
+ *
3089
+ * Scoped to a device this is an INDEXED query on the projected `deviceId`
3090
+ * column — not a parse of every export in the installation followed by a filter
3091
+ * in JS.
3005
3092
  */
3006
3093
  async function listExportsFor(state, deviceId) {
3007
- return [...(await readExports(state)).values()].filter((r) => deviceId === void 0 || r.deviceId === deviceId).toSorted((a, b) => b.createdAt - a.createdAt);
3094
+ return (await state.readWhere({
3095
+ ...deviceId === void 0 ? {} : { where: { deviceId } },
3096
+ orderBy: {
3097
+ field: "createdAt",
3098
+ direction: "desc"
3099
+ },
3100
+ limit: RECORDING_EXPORTS_LIMIT
3101
+ })).map((entry) => entry.value);
3008
3102
  }
3009
3103
  //#endregion
3010
3104
  //#region src/recorder/addon/spawn-failure.ts
@@ -4771,39 +4865,117 @@ function runFfmpeg(renderDeps, args, ext) {
4771
4865
  /**
4772
4866
  * Recordings ops-log persistence for the recorder addon.
4773
4867
  *
4774
- * A bounded, append-only audit ring lives in ONE addon-store blob (same
4775
- * DurableState pattern as `export-store.ts` / `config-store.ts`)no SQLite in
4776
- * the recorder. The whole Zod-validated array round-trips on every read/write,
4777
- * so no row is silently dropped on persist. Appends evict the oldest rows once
4778
- * the ring exceeds its cap, so the blob stays bounded regardless of churn. A
4779
- * corrupt blob reads back as EMPTY rather than crashing boot.
4868
+ * ONE ROW PER OPERATION in `recorder:ops-log`, through {@link RowMapStore}. The
4869
+ * ring is still bounded `maxEntries` newest rows survive but the eviction
4870
+ * is now a single `DELETE WHERE at BETWEEN`, and a listing is an indexed
4871
+ * `WHERE deviceId ORDER BY at DESC LIMIT`.
4872
+ *
4873
+ * ## What this replaced
4874
+ *
4875
+ * The ring lived in ONE key of the recorder's `addon-settings` / `root` blob,
4876
+ * as a flat JSON array. On the reference hub it was 147 437 bytes of that row's
4877
+ * 201 833 — so appending one audit line read, parsed, re-serialised and rewrote
4878
+ * ~200 KB, and so did every unrelated reader of that row (`resolveConfig`,
4879
+ * `loadDeviceConfig`, the export progress patch). The ops log is the CHEAPEST
4880
+ * thing in the recorder and it was paying for the most expensive.
4881
+ *
4882
+ * Writes stay BEST-EFFORT: `createRecordingOpsLogSink().append` never throws —
4883
+ * a failed audit write must not fail the operation it records.
4884
+ */
4885
+ /**
4886
+ * @durable class=audit owner=recorder
4887
+ * write="one row per recordings operation (prune, evict, relocate, export, …), appended
4888
+ * best-effort by `createRecordingOpsLogSink().append` — never read back by the system,
4889
+ * only listed by an operator through the `recording.listOpsLog` cap method"
4890
+ * retention="a bounded ring: every append trims to the newest OPS_LOG_RING_DEFAULT_MAX
4891
+ * (500) rows with one `deleteWhere` on `at`. Rows tied on the cutoff millisecond
4892
+ * survive, so the ring may hold a few MORE than the cap and never fewer."
4893
+ */
4894
+ var RECORDING_OPS_LOG_COLLECTION = "recorder:ops-log";
4895
+ var RECORDING_OPS_LOG_SPEC = {
4896
+ collection: RECORDING_OPS_LOG_COLLECTION,
4897
+ schema: require_dist.OpsLogEntrySchema,
4898
+ columns: [{
4899
+ name: "at",
4900
+ type: "INTEGER",
4901
+ notNull: true
4902
+ }, {
4903
+ name: "deviceId",
4904
+ type: "INTEGER"
4905
+ }],
4906
+ indexes: [{
4907
+ name: "idx_recorder_ops_log_at",
4908
+ columns: ["at"]
4909
+ }, {
4910
+ name: "idx_recorder_ops_log_device",
4911
+ columns: ["deviceId"]
4912
+ }],
4913
+ project: (_key, value) => ({
4914
+ at: value.at,
4915
+ deviceId: value.deviceId
4916
+ }),
4917
+ deviceIdColumn: "deviceId",
4918
+ loadLimit: 2e4
4919
+ };
4920
+ /** Build the handle. `declare()` MUST run once at boot before any read/write. */
4921
+ function recordingOpsLogState(store, logger) {
4922
+ return new require_retire_root_keys.RowMapStore({
4923
+ spec: RECORDING_OPS_LOG_SPEC,
4924
+ store,
4925
+ logger
4926
+ });
4927
+ }
4928
+ /**
4929
+ * Trim the ring to the newest `maxEntries` rows. Returns how many went.
4930
+ *
4931
+ * The cursor is the OLDEST row that must SURVIVE — offset `maxEntries - 1`
4932
+ * newest-first — and the delete is strictly older than it (`[0, at - 1]`).
4780
4933
  *
4781
- * Writes are BEST-EFFORT: `createRecordingOpsLogSink().append` never throws a
4782
- * failed audit write must not fail the operation it records.
4934
+ * Strictly, because `whereBetween` is inclusive and the filter surface has no
4935
+ * `<`: including the cursor's own millisecond would take every row tied on it,
4936
+ * and a ring whose rows all shared one millisecond would be deleted whole.
4937
+ * Erring this way keeps a few EXTRA audit rows on a tie and never fewer than
4938
+ * the cap.
4783
4939
  */
4784
- /** The addon-store key holding the bounded ops-log ring. */
4785
- var RECORDING_OPS_LOG_KEY = "recordingOpsLog";
4786
- /** Persisted shape: a flat array of ops-log rows (oldest-first on disk). */
4787
- var RecordingOpsLogBlobSchema = require_dist.array(require_dist.OpsLogEntrySchema);
4940
+ async function trimOpsLog(state, maxEntries = 500) {
4941
+ if (await state.count() <= maxEntries) return 0;
4942
+ const oldestKept = (await state.readWhere({
4943
+ orderBy: {
4944
+ field: "at",
4945
+ direction: "desc"
4946
+ },
4947
+ limit: 1,
4948
+ offset: maxEntries - 1
4949
+ }))[0];
4950
+ if (oldestKept === void 0) return 0;
4951
+ return state.deleteWhere({ whereBetween: { at: [0, oldestKept.value.at - 1] } });
4952
+ }
4788
4953
  /**
4789
- * Append one row and persist, evicting the oldest rows so the ring never grows
4790
- * past `maxEntries`. Validates at the boundary. Returns the stored row. Rows are
4791
- * kept oldest-first on disk (the tail is newest); {@link listOpsLog} reverses.
4954
+ * Append one row and persist, then trim the ring. Validates at the boundary.
4955
+ * Returns the stored row.
4792
4956
  */
4793
4957
  async function appendOpsLog(state, entry, maxEntries = 500) {
4794
4958
  const validated = require_dist.OpsLogEntrySchema.parse(entry);
4795
- const next = [...await state.get(), validated];
4796
- const bounded = next.length > maxEntries ? next.slice(next.length - maxEntries) : next;
4797
- await state.set(bounded);
4959
+ await state.write(validated.id, validated);
4960
+ await trimOpsLog(state, maxEntries);
4798
4961
  return validated;
4799
4962
  }
4800
4963
  /**
4801
4964
  * List ops-log rows newest-first, optionally scoped to one device, capped at
4802
4965
  * `limit` (default {@link OPS_LOG_DEFAULT_LIMIT}).
4966
+ *
4967
+ * Scoped to a device this is an INDEXED query, not a read of the whole ring
4968
+ * followed by a filter and a sort in JS.
4803
4969
  */
4804
4970
  async function listOpsLog(state, query) {
4805
- const rows = await state.get();
4806
- return (query.deviceId === void 0 ? rows : rows.filter((r) => r.deviceId === query.deviceId)).toSorted((a, b) => b.at - a.at).slice(0, query.limit ?? 200);
4971
+ return (await state.readWhere({
4972
+ ...query.deviceId === void 0 ? {} : { where: { deviceId: query.deviceId } },
4973
+ orderBy: {
4974
+ field: "at",
4975
+ direction: "desc"
4976
+ },
4977
+ limit: query.limit ?? 200
4978
+ })).map((entry) => entry.value);
4807
4979
  }
4808
4980
  /**
4809
4981
  * Build the recorder's ops-log sink. `append` stamps `domain:'recording'`,
@@ -7946,12 +8118,22 @@ var RecordingController = class {
7946
8118
  }
7947
8119
  /** Stop one profile's writer + watcher and release its broker lease. */
7948
8120
  async teardownProfile(deviceId, r) {
8121
+ const teardownStartMs = Date.now();
7949
8122
  try {
7950
8123
  await r.writer.stopAndWait();
7951
8124
  } catch {}
8125
+ const writerStopMs = Date.now() - teardownStartMs;
7952
8126
  try {
7953
8127
  await r.watcher.flush?.();
7954
8128
  } catch {}
8129
+ this.deps.logger.info("recorder: profile teardown drained", {
8130
+ tags: { deviceId },
8131
+ meta: {
8132
+ profile: r.profile,
8133
+ writerStopMs,
8134
+ flushMs: Date.now() - teardownStartMs - writerStopMs
8135
+ }
8136
+ });
7955
8137
  try {
7956
8138
  r.watcher.stop();
7957
8139
  } catch {}
@@ -8061,6 +8243,25 @@ async function readLocationCapacity(root, statfs) {
8061
8243
  }
8062
8244
  }
8063
8245
  //#endregion
8246
+ //#region src/recorder/addon/retired-root-keys.ts
8247
+ var RECORDER_RETIRED_ROOT_KEYS = [
8248
+ {
8249
+ key: "recordingOpsLog",
8250
+ successor: RECORDING_OPS_LOG_COLLECTION,
8251
+ reason: "the audit ring is one row per operation since the flatten; as a blob it was 147 KB of the 201 KB root row, re-parsed by every unrelated reader of that row"
8252
+ },
8253
+ {
8254
+ key: "recordingExports",
8255
+ successor: RECORDING_EXPORTS_COLLECTION,
8256
+ reason: "one row per export job since the flatten; as a blob a once-a-second progress patch rewrote 201 KB, and an overlapping createExport erased the row (2026-08-14)"
8257
+ },
8258
+ {
8259
+ key: "recordingConfigs",
8260
+ successor: RECORDING_CONFIGS_COLLECTION,
8261
+ reason: "one row per camera since the flatten; as a blob, asking what camera 617 records parsed every camera in the installation"
8262
+ }
8263
+ ];
8264
+ //#endregion
8064
8265
  //#region src/durable/durable-ledger.ts
8065
8266
  /** Default reseed cap — every current consumer's row set is installation-bounded. */
8066
8267
  var DEFAULT_LOAD_LIMIT = 1e5;
@@ -9306,6 +9507,7 @@ async function recoverAllStagedOrphans(deps) {
9306
9507
  return entries;
9307
9508
  };
9308
9509
  const walkStartedMs = Date.now();
9510
+ const segmentSecondsMap = await deps.segmentSecondsByDevice();
9309
9511
  const work = [];
9310
9512
  for (const location of locations) {
9311
9513
  const stagingRoot = `${location.root}/${STAGING_DIR_NAME}`;
@@ -9316,7 +9518,7 @@ async function recoverAllStagedOrphans(deps) {
9316
9518
  if (!Number.isInteger(deviceId) || deviceId <= 0) continue;
9317
9519
  const profileDirs = await listPaced(`${stagingRoot}/${deviceDir}`);
9318
9520
  if (profileDirs === null) continue;
9319
- const segmentSeconds = await deps.segmentSecondsFor(deviceId);
9521
+ const segmentSeconds = segmentSecondsMap.get(deviceId) ?? deps.defaultSegmentSeconds;
9320
9522
  for (const profile of profileDirs) {
9321
9523
  if (!KNOWN_PROFILES.has(profile)) continue;
9322
9524
  work.push({
@@ -9623,6 +9825,8 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
9623
9825
  } });
9624
9826
  return { providers: [] };
9625
9827
  }
9828
+ await this.declareRowCollections();
9829
+ await this.purgeFlattenedSettingsKeys();
9626
9830
  this.segmentHours = new SegmentHourLedger({
9627
9831
  store: this.ctx.api.settingsStore,
9628
9832
  logger: this.ctx.logger,
@@ -10359,13 +10563,14 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
10359
10563
  },
10360
10564
  logger: this.ctx.logger,
10361
10565
  bootMs,
10362
- segmentSecondsFor: async (deviceId) => {
10566
+ segmentSecondsByDevice: async () => {
10567
+ const out = /* @__PURE__ */ new Map();
10363
10568
  try {
10364
- return (await loadDeviceConfig(this.configStore(), deviceId)).segmentSeconds ?? this.config.segmentSeconds;
10365
- } catch {
10366
- return this.config.segmentSeconds;
10367
- }
10569
+ for (const [id, config] of await readDeviceConfigs(this.configStore())) if (config.segmentSeconds !== void 0) out.set(id, config.segmentSeconds);
10570
+ } catch {}
10571
+ return out;
10368
10572
  },
10573
+ defaultSegmentSeconds: this.config.segmentSeconds,
10369
10574
  shouldStop: () => this.segmentStore === null
10370
10575
  });
10371
10576
  } catch (err) {
@@ -10590,17 +10795,52 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
10590
10795
  this.fullWalkInFlight = false;
10591
10796
  }
10592
10797
  }
10798
+ /**
10799
+ * Declare the three row collections that replaced the `recordingConfigs`,
10800
+ * `recordingExports` and `recordingOpsLog` blob keys. Idempotent — a
10801
+ * re-declaration of the same shape is a no-op in the engine.
10802
+ */
10803
+ async declareRowCollections() {
10804
+ await this.configStore().declare();
10805
+ await this.exportsStore().declare();
10806
+ await this.opsLogStore().declare();
10807
+ }
10808
+ /**
10809
+ * Drop the flattened keys from `recorder:addon-settings` / `root`, each gated
10810
+ * on its own successor collection being non-empty.
10811
+ *
10812
+ * `recordingPlacement` is NOT here: it stays a blob key. It is 1.4 KB of
10813
+ * per-(camera, profile) assignments plus a handful of volume ids, written
10814
+ * only when placement changes — and the row has to survive anyway, because it
10815
+ * is where `BaseAddon.resolveConfig` reads the operator's config fields from.
10816
+ * The problem was a 201 KB row, not a blob.
10817
+ *
10818
+ * Best-effort: a purge that throws must not take recording down. The keys are
10819
+ * dead weight, not a fault.
10820
+ */
10821
+ async purgeFlattenedSettingsKeys() {
10822
+ try {
10823
+ await require_retire_root_keys.retireRootKeys({
10824
+ store: this.ctx.api.settingsStore,
10825
+ addonId: RECORDER_ADDON_ID,
10826
+ logger: this.ctx.logger,
10827
+ specs: RECORDER_RETIRED_ROOT_KEYS
10828
+ });
10829
+ } catch (err) {
10830
+ this.ctx.logger.warn("recorder: retiring the flattened settings keys failed", { meta: { error: require_dist.errMsg(err) } });
10831
+ }
10832
+ }
10593
10833
  configStore() {
10594
- return this.state(RECORDING_CONFIGS_KEY, RecordingConfigsBlobSchema, {});
10834
+ return recordingConfigsState(this.ctx.api.settingsStore, this.ctx.logger);
10595
10835
  }
10596
10836
  /** Durable handle over the exportId→ExportRecord blob (same `BaseAddon.state()`
10597
10837
  * wrapper as the config store). */
10598
10838
  exportsStore() {
10599
- return this.state(RECORDING_EXPORTS_KEY, RecordingExportsBlobSchema, {});
10839
+ return recordingExportsState(this.ctx.api.settingsStore, this.ctx.logger);
10600
10840
  }
10601
10841
  /** Durable handle over the bounded recordings ops-log ring. */
10602
10842
  opsLogStore() {
10603
- return this.state(RECORDING_OPS_LOG_KEY, RecordingOpsLogBlobSchema, []);
10843
+ return recordingOpsLogState(this.ctx.api.settingsStore, this.ctx.logger);
10604
10844
  }
10605
10845
  /** Durable handle over the recorder-owned placement blob (assignments + volume ids). */
10606
10846
  placementStore() {