@camstack/addon-pipeline 1.2.100 → 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
- import { $ as recordingExportCapability, C as RecordingConfigSchema, Ct as nodePin, Et as selectAssignedProfileSlots, Ft as object, It as record, L as deriveRecordingMode, Lt as string, Pt as number, Q as recordingCapability, S as RECORDING_EXPORT_MAX_READ_BYTES, _t as DeviceType, ct as errMsg, f as EVENT_PAD_MS, it as storageEvictableCapability, kt as array, l as DEFAULT_EVENTS_BAND_BUFFER_SEC, m as ExportRecordSchema, mt as BaseAddon, nt as resolveRecordingProfiles, v as OpsLogEntrySchema, yt as hydrateSchema, zt as EventCategory } from "../dist-gXdWP96z.mjs";
1
+ import { $ as recordingExportCapability, C as RecordingConfigSchema, Ct as nodePin, Et as selectAssignedProfileSlots, Ft as object, It as record, L as deriveRecordingMode, Lt as string, Pt as number, Q as recordingCapability, S as RECORDING_EXPORT_MAX_READ_BYTES, _t as DeviceType, ct as errMsg, f as EVENT_PAD_MS, it as storageEvictableCapability, l as DEFAULT_EVENTS_BAND_BUFFER_SEC, m as ExportRecordSchema, mt as BaseAddon, nt as resolveRecordingProfiles, v as OpsLogEntrySchema, yt as hydrateSchema, zt as EventCategory } from "../dist-gXdWP96z.mjs";
2
2
  import { t as resolveHubHostname } from "../hub-hostname-cCknRYKj.mjs";
3
3
  import { n as createFileDataPlaneHandler, s as parseRangeHeader, t as contentTypeFor } from "../addon-utils-A2S9D7pu.mjs";
4
+ import { n as RowMapStore, t as retireRootKeys } from "../retire-root-keys-DMolfhsP.mjs";
4
5
  import { randomUUID } from "node:crypto";
5
6
  import { spawn } from "node:child_process";
6
7
  import path from "node:path";
@@ -2480,70 +2481,75 @@ var CalendarIndex = class {
2480
2481
  //#endregion
2481
2482
  //#region src/recorder/addon/config-store.ts
2482
2483
  /**
2483
- * Per-device recording-config persistence for the recorder addon.
2484
- *
2485
- * Every camera's `RecordingConfig` lives in ONE addon-store blob keyed by
2486
- * (stringified) numeric deviceId enumerable for boot hydrate (so the addon
2487
- * knows which devices have persisted footage to index without scanning every
2488
- * device store). The whole Zod-validated blob round-trips on every read/write
2489
- * via a {@link DurableState} handle, so no per-device field can be silently
2490
- * dropped on persist. A corrupt blob reads back as EMPTY rather than crashing
2491
- * boot.
2492
- *
2493
- * P3b settings-API migration: the functions here consume the durable handle
2494
- * directly — in production the recorder addon builds it via the sanctioned
2495
- * `BaseAddon.state()` (retry budget + validated fallback + parse-warning
2496
- * logging); {@link recordingConfigsState} adapts a raw store slice for test
2497
- * fakes. Same store key, same persisted blob shape as before.
2498
- *
2499
- * `bands` is the ONLY authored recording intent (the legacy
2500
- * mode/schedules/triggers/rules authoring surface was retired 2026-07-30); on
2501
- * save `mode` is stamped as their derived summary for cheap consumers.
2502
- */
2503
- /** The addon-store key holding the deviceId → RecordingConfig map. */
2504
- var RECORDING_CONFIGS_KEY = "recordingConfigs";
2505
- /**
2506
- * Persisted shape: stringified numeric deviceId → full RecordingConfig.
2507
- *
2508
- * READ is forgiving where the wire contract is strict: `RecordingConfigSchema`
2509
- * rejects unknown keys so a stale caller fails loudly, but a blob written by an
2510
- * older build may still carry retired keys — and one unparseable device would
2511
- * take the WHOLE blob down to the empty fallback (= every camera silently
2512
- * unconfigured). `.strip()` drops them instead.
2513
- */
2514
- var RecordingConfigsBlobSchema = record(string(), RecordingConfigSchema.strip());
2484
+ * @durable class=config owner=recorder
2485
+ * write="one row per camera, written by `saveDeviceConfig` (the `recording` cap's
2486
+ * setConfig / band authoring) the only writer"
2487
+ * retention="none by age. A row goes only when the operator deletes the camera's
2488
+ * configuration; an unconfigured camera has no row and reads as DEFAULT_DEVICE_CONFIG"
2489
+ */
2490
+ var RECORDING_CONFIGS_COLLECTION = "recorder:recording-configs";
2491
+ var RECORDING_CONFIGS_SPEC = {
2492
+ collection: RECORDING_CONFIGS_COLLECTION,
2493
+ schema: RecordingConfigSchema.strip(),
2494
+ columns: [
2495
+ {
2496
+ name: "deviceId",
2497
+ type: "INTEGER",
2498
+ notNull: true
2499
+ },
2500
+ {
2501
+ name: "enabled",
2502
+ type: "BOOLEAN",
2503
+ notNull: true
2504
+ },
2505
+ {
2506
+ name: "mode",
2507
+ type: "TEXT"
2508
+ }
2509
+ ],
2510
+ project: (key, value) => ({
2511
+ deviceId: Number(key),
2512
+ enabled: value.enabled,
2513
+ mode: value.mode ?? null
2514
+ }),
2515
+ deviceIdColumn: "deviceId",
2516
+ loadLimit: 2e4
2517
+ };
2515
2518
  /** Cold-start default for a camera that has never been configured: disabled, no bands. */
2516
2519
  var DEFAULT_DEVICE_CONFIG = {
2517
2520
  enabled: false,
2518
2521
  bands: []
2519
2522
  };
2520
- function serialize$1(map) {
2521
- const out = {};
2522
- for (const [id, config] of map) out[String(id)] = config;
2523
- return out;
2523
+ /** Build the handle. `declare()` MUST run once at boot before any read/write. */
2524
+ function recordingConfigsState(store, logger) {
2525
+ return new RowMapStore({
2526
+ spec: RECORDING_CONFIGS_SPEC,
2527
+ store,
2528
+ logger
2529
+ });
2524
2530
  }
2525
2531
  /** Read every persisted device config, keyed by numeric deviceId. */
2526
2532
  async function readDeviceConfigs(state) {
2527
- const blob = await state.get();
2533
+ const rows = await state.readAll();
2528
2534
  const map = /* @__PURE__ */ new Map();
2529
- for (const [key, config] of Object.entries(blob)) {
2535
+ for (const [key, config] of rows) {
2530
2536
  const id = Number(key);
2531
2537
  if (Number.isInteger(id)) map.set(id, config);
2532
2538
  }
2533
2539
  return map;
2534
2540
  }
2535
2541
  /**
2536
- * The persisted config for a device — `bands` is always materialized (the
2537
- * schema defaults it to `[]`). An unconfigured device returns
2542
+ * The persisted config for a device — ONE keyed row read. `bands` is always
2543
+ * materialized (the schema defaults it to `[]`). An unconfigured device returns
2538
2544
  * {@link DEFAULT_DEVICE_CONFIG}.
2539
2545
  */
2540
2546
  async function loadDeviceConfig(state, deviceId) {
2541
- return (await readDeviceConfigs(state)).get(deviceId) ?? DEFAULT_DEVICE_CONFIG;
2547
+ return await state.read(String(deviceId)) ?? DEFAULT_DEVICE_CONFIG;
2542
2548
  }
2543
2549
  /**
2544
2550
  * The devices whose stored config claims to record (`enabled` + a non-`off`
2545
2551
  * derived `mode`) but has no band left — the one shape this store cannot
2546
- * repair. It can only come from a pre-retirement blob whose legacy authoring
2552
+ * repair. It can only come from a pre-retirement config whose legacy authoring
2547
2553
  * fields were stripped on read: the camera records NOTHING until its bands are
2548
2554
  * re-authored, so the recorder shouts about it once at boot
2549
2555
  * ({@link warnLostBands}) instead of failing silently.
@@ -2560,7 +2566,7 @@ async function warnLostBands(state, logger) {
2560
2566
  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.`);
2561
2567
  }
2562
2568
  /**
2563
- * Persist (insert or replace) a device's config. Validates against
2569
+ * Persist (insert or replace) a device's config — ONE row. Validates against
2564
2570
  * `RecordingConfigSchema` at the boundary (untrusted cap input). Returns the
2565
2571
  * validated config that was persisted.
2566
2572
  */
@@ -2570,9 +2576,7 @@ async function saveDeviceConfig(state, deviceId, config) {
2570
2576
  ...parsed,
2571
2577
  mode: parsed.enabled ? activeModeForConfig(parsed) : "off"
2572
2578
  };
2573
- const map = await readDeviceConfigs(state);
2574
- map.set(deviceId, validated);
2575
- await state.set(serialize$1(map));
2579
+ await state.write(String(deviceId), validated);
2576
2580
  return validated;
2577
2581
  }
2578
2582
  /**
@@ -2939,61 +2943,122 @@ function buildExportArgs(input) {
2939
2943
  /**
2940
2944
  * Recording-export persistence for the recorder addon.
2941
2945
  *
2942
- * Every export job/history row lives in ONE addon-store blob keyed by export
2943
- * id. The whole Zod-validated blob round-trips on every read/write via a
2944
- * {@link DurableState} handle (same pattern as `config-store.ts`), so no field
2945
- * is silently dropped on persist. History rows are never removed by the system
2946
- * (audit) `deleteExport`/janitor flip `state` and clear the file, keeping the
2947
- * row. A corrupt blob reads back as EMPTY rather than crashing boot.
2948
- */
2949
- /**
2950
- * The addon-store key holding the exportId ExportRecord map. Production
2951
- * builds the durable handle via `BaseAddon.state(RECORDING_EXPORTS_KEY,
2952
- * RecordingExportsBlobSchema, {})`; the pure functions below take that handle,
2953
- * so this module never touches the @internal raw addon-store primitive.
2954
- */
2955
- var RECORDING_EXPORTS_KEY = "recordingExports";
2956
- /** Persisted shape: export id full ExportRecord. */
2957
- var RecordingExportsBlobSchema = record(string(), ExportRecordSchema);
2946
+ * ONE ROW PER EXPORT in `recorder:exports`, through {@link RowMapStore}. History
2947
+ * rows are never removed by the system (audit) — `deleteExport` / the janitor
2948
+ * flip `state` and clear the file, keeping the row.
2949
+ *
2950
+ * ## What this replaced, and why it mattered here more than anywhere
2951
+ *
2952
+ * Every job used to live in one key of the recorder's `addon-settings` / `root`
2953
+ * blob — 201 833 bytes on the reference hub, of which the export history was
2954
+ * 50 441. The engine patches `progressPct` roughly once a second for an
2955
+ * in-flight render, so moving one integer read, parsed, re-serialised and
2956
+ * rewrote 201 KB, per second.
2957
+ *
2958
+ * Worse than the cost was the CLOBBER. Both halves of a read-modify-write are
2959
+ * async, so two overlapping cycles read the same snapshot and the second `set`
2960
+ * discarded whatever the first added. On 2026-08-14 a `createExport` for camera
2961
+ * 615 landed between one progress patch's read and its write, the row was
2962
+ * erased, and the poll answered `unknown export "547ea182-…"`; the scheduler
2963
+ * read the vanished row as a failed render and re-queued forever. A per-row
2964
+ * upsert makes that class of loss unrepresentable — a write names ONE row.
2965
+ *
2966
+ * What is still needed is a per-ID serialiser: two patches to the SAME export
2967
+ * still read-modify-write the same row. That is what {@link serialize} bounds,
2968
+ * and it is now per (store, id) rather than per store, so two exports never
2969
+ * queue behind each other.
2970
+ */
2971
+ /**
2972
+ * @durable class=ledger owner=recorder
2973
+ * write="one row per export job, inserted by `createExport` and rewritten by the
2974
+ * render engine's progress/state patches and by the download hook"
2975
+ * retention="the SYSTEM never deletes a row — it is the export AUDIT. `deleteExport`
2976
+ * and the janitor flip `state` to 'deleted'/'expired' and remove the FILE, keeping
2977
+ * the row. `removeExport` exists for an operator-driven purge and nothing calls it
2978
+ * on a schedule."
2979
+ */
2980
+ var RECORDING_EXPORTS_COLLECTION = "recorder:exports";
2981
+ /**
2982
+ * Bound for the whole-history read. Explicit because `settings-store.query`
2983
+ * caps an unbounded read at 2 000 rows SILENTLY, and an export list that
2984
+ * silently loses its oldest half looks exactly like a purge.
2985
+ */
2986
+ var RECORDING_EXPORTS_LIMIT = 2e4;
2987
+ var RECORDING_EXPORTS_SPEC = {
2988
+ collection: RECORDING_EXPORTS_COLLECTION,
2989
+ schema: ExportRecordSchema,
2990
+ columns: [
2991
+ {
2992
+ name: "deviceId",
2993
+ type: "INTEGER",
2994
+ notNull: true
2995
+ },
2996
+ {
2997
+ name: "createdAt",
2998
+ type: "INTEGER",
2999
+ notNull: true
3000
+ },
3001
+ {
3002
+ name: "state",
3003
+ type: "TEXT",
3004
+ notNull: true
3005
+ }
3006
+ ],
3007
+ indexes: [{
3008
+ name: "idx_recorder_exports_device",
3009
+ columns: ["deviceId"]
3010
+ }, {
3011
+ name: "idx_recorder_exports_created",
3012
+ columns: ["createdAt"]
3013
+ }],
3014
+ project: (_key, value) => ({
3015
+ deviceId: value.deviceId,
3016
+ createdAt: value.createdAt,
3017
+ state: value.state
3018
+ }),
3019
+ deviceIdColumn: "deviceId",
3020
+ loadLimit: RECORDING_EXPORTS_LIMIT
3021
+ };
3022
+ /** Build the handle. `declare()` MUST run once at boot before any read/write. */
3023
+ function recordingExportsState(store, logger) {
3024
+ return new RowMapStore({
3025
+ spec: RECORDING_EXPORTS_SPEC,
3026
+ store,
3027
+ logger
3028
+ });
3029
+ }
2958
3030
  /**
2959
- * One in-flight mutation chain per durable handle.
3031
+ * One in-flight mutation chain per (handle, export id).
2960
3032
  *
2961
- * EVERY mutation here is a read-modify-write of the SAME blob, and both halves
2962
- * are async so two overlapping cycles read the same snapshot and the second
2963
- * `set` discards whatever the first one added. Last writer wins, silently.
3033
+ * A row upsert removed the cross-export clobber; it does not order two
3034
+ * read-modify-writes of the SAME row. `patchExport` reads the row, merges and
3035
+ * writes so two overlapping patches on one export still race, and the loser's
3036
+ * fields vanish. Serialising per ID keeps those pairs atomic while leaving
3037
+ * unrelated exports fully concurrent (the old chain was per HANDLE, so a slow
3038
+ * patch queued every other export behind it).
2964
3039
  *
2965
- * That is not a hypothetical. The engine patches `progressPct` roughly once a
2966
- * second for an in-flight render, unawaited; on 2026-08-14 a `createExport` for
2967
- * camera 615 landed between one such patch's read and its write, the row was
2968
- * erased, and five seconds later the poll answered `unknown export
2969
- * "547ea182-…"`. The camera's whole night was lost to it — the scheduler read a
2970
- * vanished row as a failed render and re-queued, forever.
2971
- *
2972
- * Serialising per handle is the smallest fix that makes the loss impossible:
2973
- * the cycles queue instead of interleaving, and the queue is per state object
2974
- * so two recorders in one process never contend. A `WeakMap` keeps a disposed
2975
- * handle collectable. Reads stay OUTSIDE the chain — a blob replacement is
2976
- * atomic, so a reader always sees one consistent generation.
3040
+ * A `WeakMap` keyed by the handle keeps a disposed store collectable; the inner
3041
+ * map is keyed by export id.
2977
3042
  */
2978
3043
  var mutationChains = /* @__PURE__ */ new WeakMap();
2979
- /** Run `work` after every mutation already queued for `state`, never before. */
2980
- async function serialize(state, work) {
2981
- const next = (mutationChains.get(state) ?? Promise.resolve()).then(work, work);
2982
- mutationChains.set(state, next.catch(() => void 0));
3044
+ /** Run `work` after every mutation already queued for `(state, id)`, never before. */
3045
+ async function serialize(state, id, work) {
3046
+ const existing = mutationChains.get(state);
3047
+ const queued = existing ?? /* @__PURE__ */ new Map();
3048
+ if (existing === void 0) mutationChains.set(state, queued);
3049
+ const next = (queued.get(id) ?? Promise.resolve()).then(work, work);
3050
+ const settled = next.catch(() => void 0);
3051
+ queued.set(id, settled);
3052
+ settled.then(() => {
3053
+ if (queued.get(id) === settled) queued.delete(id);
3054
+ });
2983
3055
  return next;
2984
3056
  }
2985
- /** Read every persisted export row, keyed by export id. */
2986
- async function readExports(state) {
2987
- const blob = await state.get();
2988
- return new Map(Object.entries(blob));
2989
- }
2990
3057
  /** Insert or replace one export row. Validates at the boundary. Returns it. */
2991
3058
  async function upsertExport(state, record) {
2992
3059
  const validated = ExportRecordSchema.parse(record);
2993
- return serialize(state, async () => {
2994
- const map = await readExports(state);
2995
- map.set(validated.id, validated);
2996
- await state.set(Object.fromEntries(map));
3060
+ return serialize(state, validated.id, async () => {
3061
+ await state.write(validated.id, validated);
2997
3062
  return validated;
2998
3063
  });
2999
3064
  }
@@ -3002,28 +3067,37 @@ async function upsertExport(state, record) {
3002
3067
  * record, or null when the id is unknown.
3003
3068
  */
3004
3069
  async function patchExport(state, id, patch) {
3005
- return serialize(state, async () => {
3006
- const map = await readExports(state);
3007
- const existing = map.get(id);
3008
- if (!existing) return null;
3070
+ return serialize(state, id, async () => {
3071
+ const existing = await state.read(id);
3072
+ if (existing === null) return null;
3009
3073
  const merged = ExportRecordSchema.parse({
3010
3074
  ...existing,
3011
3075
  ...patch
3012
3076
  });
3013
- map.set(id, merged);
3014
- await state.set(Object.fromEntries(map));
3077
+ await state.write(id, merged);
3015
3078
  return merged;
3016
3079
  });
3017
3080
  }
3018
- /** Read one export row by id, or null. */
3081
+ /** Read one export row by id ONE keyed row read. */
3019
3082
  async function getExportRecord(state, id) {
3020
- return (await readExports(state)).get(id) ?? null;
3083
+ return state.read(id);
3021
3084
  }
3022
3085
  /**
3023
3086
  * List export rows, newest-first, optionally scoped to one device.
3087
+ *
3088
+ * Scoped to a device this is an INDEXED query on the projected `deviceId`
3089
+ * column — not a parse of every export in the installation followed by a filter
3090
+ * in JS.
3024
3091
  */
3025
3092
  async function listExportsFor(state, deviceId) {
3026
- return [...(await readExports(state)).values()].filter((r) => deviceId === void 0 || r.deviceId === deviceId).toSorted((a, b) => b.createdAt - a.createdAt);
3093
+ return (await state.readWhere({
3094
+ ...deviceId === void 0 ? {} : { where: { deviceId } },
3095
+ orderBy: {
3096
+ field: "createdAt",
3097
+ direction: "desc"
3098
+ },
3099
+ limit: RECORDING_EXPORTS_LIMIT
3100
+ })).map((entry) => entry.value);
3027
3101
  }
3028
3102
  //#endregion
3029
3103
  //#region src/recorder/addon/spawn-failure.ts
@@ -4790,39 +4864,117 @@ function runFfmpeg(renderDeps, args, ext) {
4790
4864
  /**
4791
4865
  * Recordings ops-log persistence for the recorder addon.
4792
4866
  *
4793
- * A bounded, append-only audit ring lives in ONE addon-store blob (same
4794
- * DurableState pattern as `export-store.ts` / `config-store.ts`)no SQLite in
4795
- * the recorder. The whole Zod-validated array round-trips on every read/write,
4796
- * so no row is silently dropped on persist. Appends evict the oldest rows once
4797
- * the ring exceeds its cap, so the blob stays bounded regardless of churn. A
4798
- * corrupt blob reads back as EMPTY rather than crashing boot.
4867
+ * ONE ROW PER OPERATION in `recorder:ops-log`, through {@link RowMapStore}. The
4868
+ * ring is still bounded `maxEntries` newest rows survive but the eviction
4869
+ * is now a single `DELETE WHERE at BETWEEN`, and a listing is an indexed
4870
+ * `WHERE deviceId ORDER BY at DESC LIMIT`.
4871
+ *
4872
+ * ## What this replaced
4873
+ *
4874
+ * The ring lived in ONE key of the recorder's `addon-settings` / `root` blob,
4875
+ * as a flat JSON array. On the reference hub it was 147 437 bytes of that row's
4876
+ * 201 833 — so appending one audit line read, parsed, re-serialised and rewrote
4877
+ * ~200 KB, and so did every unrelated reader of that row (`resolveConfig`,
4878
+ * `loadDeviceConfig`, the export progress patch). The ops log is the CHEAPEST
4879
+ * thing in the recorder and it was paying for the most expensive.
4880
+ *
4881
+ * Writes stay BEST-EFFORT: `createRecordingOpsLogSink().append` never throws —
4882
+ * a failed audit write must not fail the operation it records.
4883
+ */
4884
+ /**
4885
+ * @durable class=audit owner=recorder
4886
+ * write="one row per recordings operation (prune, evict, relocate, export, …), appended
4887
+ * best-effort by `createRecordingOpsLogSink().append` — never read back by the system,
4888
+ * only listed by an operator through the `recording.listOpsLog` cap method"
4889
+ * retention="a bounded ring: every append trims to the newest OPS_LOG_RING_DEFAULT_MAX
4890
+ * (500) rows with one `deleteWhere` on `at`. Rows tied on the cutoff millisecond
4891
+ * survive, so the ring may hold a few MORE than the cap and never fewer."
4892
+ */
4893
+ var RECORDING_OPS_LOG_COLLECTION = "recorder:ops-log";
4894
+ var RECORDING_OPS_LOG_SPEC = {
4895
+ collection: RECORDING_OPS_LOG_COLLECTION,
4896
+ schema: OpsLogEntrySchema,
4897
+ columns: [{
4898
+ name: "at",
4899
+ type: "INTEGER",
4900
+ notNull: true
4901
+ }, {
4902
+ name: "deviceId",
4903
+ type: "INTEGER"
4904
+ }],
4905
+ indexes: [{
4906
+ name: "idx_recorder_ops_log_at",
4907
+ columns: ["at"]
4908
+ }, {
4909
+ name: "idx_recorder_ops_log_device",
4910
+ columns: ["deviceId"]
4911
+ }],
4912
+ project: (_key, value) => ({
4913
+ at: value.at,
4914
+ deviceId: value.deviceId
4915
+ }),
4916
+ deviceIdColumn: "deviceId",
4917
+ loadLimit: 2e4
4918
+ };
4919
+ /** Build the handle. `declare()` MUST run once at boot before any read/write. */
4920
+ function recordingOpsLogState(store, logger) {
4921
+ return new RowMapStore({
4922
+ spec: RECORDING_OPS_LOG_SPEC,
4923
+ store,
4924
+ logger
4925
+ });
4926
+ }
4927
+ /**
4928
+ * Trim the ring to the newest `maxEntries` rows. Returns how many went.
4929
+ *
4930
+ * The cursor is the OLDEST row that must SURVIVE — offset `maxEntries - 1`
4931
+ * newest-first — and the delete is strictly older than it (`[0, at - 1]`).
4799
4932
  *
4800
- * Writes are BEST-EFFORT: `createRecordingOpsLogSink().append` never throws a
4801
- * failed audit write must not fail the operation it records.
4933
+ * Strictly, because `whereBetween` is inclusive and the filter surface has no
4934
+ * `<`: including the cursor's own millisecond would take every row tied on it,
4935
+ * and a ring whose rows all shared one millisecond would be deleted whole.
4936
+ * Erring this way keeps a few EXTRA audit rows on a tie and never fewer than
4937
+ * the cap.
4802
4938
  */
4803
- /** The addon-store key holding the bounded ops-log ring. */
4804
- var RECORDING_OPS_LOG_KEY = "recordingOpsLog";
4805
- /** Persisted shape: a flat array of ops-log rows (oldest-first on disk). */
4806
- var RecordingOpsLogBlobSchema = array(OpsLogEntrySchema);
4939
+ async function trimOpsLog(state, maxEntries = 500) {
4940
+ if (await state.count() <= maxEntries) return 0;
4941
+ const oldestKept = (await state.readWhere({
4942
+ orderBy: {
4943
+ field: "at",
4944
+ direction: "desc"
4945
+ },
4946
+ limit: 1,
4947
+ offset: maxEntries - 1
4948
+ }))[0];
4949
+ if (oldestKept === void 0) return 0;
4950
+ return state.deleteWhere({ whereBetween: { at: [0, oldestKept.value.at - 1] } });
4951
+ }
4807
4952
  /**
4808
- * Append one row and persist, evicting the oldest rows so the ring never grows
4809
- * past `maxEntries`. Validates at the boundary. Returns the stored row. Rows are
4810
- * kept oldest-first on disk (the tail is newest); {@link listOpsLog} reverses.
4953
+ * Append one row and persist, then trim the ring. Validates at the boundary.
4954
+ * Returns the stored row.
4811
4955
  */
4812
4956
  async function appendOpsLog(state, entry, maxEntries = 500) {
4813
4957
  const validated = OpsLogEntrySchema.parse(entry);
4814
- const next = [...await state.get(), validated];
4815
- const bounded = next.length > maxEntries ? next.slice(next.length - maxEntries) : next;
4816
- await state.set(bounded);
4958
+ await state.write(validated.id, validated);
4959
+ await trimOpsLog(state, maxEntries);
4817
4960
  return validated;
4818
4961
  }
4819
4962
  /**
4820
4963
  * List ops-log rows newest-first, optionally scoped to one device, capped at
4821
4964
  * `limit` (default {@link OPS_LOG_DEFAULT_LIMIT}).
4965
+ *
4966
+ * Scoped to a device this is an INDEXED query, not a read of the whole ring
4967
+ * followed by a filter and a sort in JS.
4822
4968
  */
4823
4969
  async function listOpsLog(state, query) {
4824
- const rows = await state.get();
4825
- 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);
4970
+ return (await state.readWhere({
4971
+ ...query.deviceId === void 0 ? {} : { where: { deviceId: query.deviceId } },
4972
+ orderBy: {
4973
+ field: "at",
4974
+ direction: "desc"
4975
+ },
4976
+ limit: query.limit ?? 200
4977
+ })).map((entry) => entry.value);
4826
4978
  }
4827
4979
  /**
4828
4980
  * Build the recorder's ops-log sink. `append` stamps `domain:'recording'`,
@@ -8090,6 +8242,25 @@ async function readLocationCapacity(root, statfs) {
8090
8242
  }
8091
8243
  }
8092
8244
  //#endregion
8245
+ //#region src/recorder/addon/retired-root-keys.ts
8246
+ var RECORDER_RETIRED_ROOT_KEYS = [
8247
+ {
8248
+ key: "recordingOpsLog",
8249
+ successor: RECORDING_OPS_LOG_COLLECTION,
8250
+ 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"
8251
+ },
8252
+ {
8253
+ key: "recordingExports",
8254
+ successor: RECORDING_EXPORTS_COLLECTION,
8255
+ 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)"
8256
+ },
8257
+ {
8258
+ key: "recordingConfigs",
8259
+ successor: RECORDING_CONFIGS_COLLECTION,
8260
+ reason: "one row per camera since the flatten; as a blob, asking what camera 617 records parsed every camera in the installation"
8261
+ }
8262
+ ];
8263
+ //#endregion
8093
8264
  //#region src/durable/durable-ledger.ts
8094
8265
  /** Default reseed cap — every current consumer's row set is installation-bounded. */
8095
8266
  var DEFAULT_LOAD_LIMIT = 1e5;
@@ -9653,6 +9824,8 @@ var RecorderV2Addon = class extends BaseAddon {
9653
9824
  } });
9654
9825
  return { providers: [] };
9655
9826
  }
9827
+ await this.declareRowCollections();
9828
+ await this.purgeFlattenedSettingsKeys();
9656
9829
  this.segmentHours = new SegmentHourLedger({
9657
9830
  store: this.ctx.api.settingsStore,
9658
9831
  logger: this.ctx.logger,
@@ -10621,17 +10794,52 @@ var RecorderV2Addon = class extends BaseAddon {
10621
10794
  this.fullWalkInFlight = false;
10622
10795
  }
10623
10796
  }
10797
+ /**
10798
+ * Declare the three row collections that replaced the `recordingConfigs`,
10799
+ * `recordingExports` and `recordingOpsLog` blob keys. Idempotent — a
10800
+ * re-declaration of the same shape is a no-op in the engine.
10801
+ */
10802
+ async declareRowCollections() {
10803
+ await this.configStore().declare();
10804
+ await this.exportsStore().declare();
10805
+ await this.opsLogStore().declare();
10806
+ }
10807
+ /**
10808
+ * Drop the flattened keys from `recorder:addon-settings` / `root`, each gated
10809
+ * on its own successor collection being non-empty.
10810
+ *
10811
+ * `recordingPlacement` is NOT here: it stays a blob key. It is 1.4 KB of
10812
+ * per-(camera, profile) assignments plus a handful of volume ids, written
10813
+ * only when placement changes — and the row has to survive anyway, because it
10814
+ * is where `BaseAddon.resolveConfig` reads the operator's config fields from.
10815
+ * The problem was a 201 KB row, not a blob.
10816
+ *
10817
+ * Best-effort: a purge that throws must not take recording down. The keys are
10818
+ * dead weight, not a fault.
10819
+ */
10820
+ async purgeFlattenedSettingsKeys() {
10821
+ try {
10822
+ await retireRootKeys({
10823
+ store: this.ctx.api.settingsStore,
10824
+ addonId: RECORDER_ADDON_ID,
10825
+ logger: this.ctx.logger,
10826
+ specs: RECORDER_RETIRED_ROOT_KEYS
10827
+ });
10828
+ } catch (err) {
10829
+ this.ctx.logger.warn("recorder: retiring the flattened settings keys failed", { meta: { error: errMsg(err) } });
10830
+ }
10831
+ }
10624
10832
  configStore() {
10625
- return this.state(RECORDING_CONFIGS_KEY, RecordingConfigsBlobSchema, {});
10833
+ return recordingConfigsState(this.ctx.api.settingsStore, this.ctx.logger);
10626
10834
  }
10627
10835
  /** Durable handle over the exportId→ExportRecord blob (same `BaseAddon.state()`
10628
10836
  * wrapper as the config store). */
10629
10837
  exportsStore() {
10630
- return this.state(RECORDING_EXPORTS_KEY, RecordingExportsBlobSchema, {});
10838
+ return recordingExportsState(this.ctx.api.settingsStore, this.ctx.logger);
10631
10839
  }
10632
10840
  /** Durable handle over the bounded recordings ops-log ring. */
10633
10841
  opsLogStore() {
10634
- return this.state(RECORDING_OPS_LOG_KEY, RecordingOpsLogBlobSchema, []);
10842
+ return recordingOpsLogState(this.ctx.api.settingsStore, this.ctx.logger);
10635
10843
  }
10636
10844
  /** Durable handle over the recorder-owned placement blob (assignments + volume ids). */
10637
10845
  placementStore() {