@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.
- package/dist/recorder/index.js +331 -123
- package/dist/recorder/index.mjs +332 -124
- package/dist/retire-root-keys-DMolfhsP.mjs +308 -0
- package/dist/retire-root-keys-KE6D6Xh_.js +319 -0
- package/dist/stream-broker/{_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-B7ERJocD.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-C4fRkYcj.mjs} +1 -1
- package/dist/stream-broker/{hostInit-Cm2rl8-e.mjs → hostInit-CS6xpCdb.mjs} +1 -1
- package/dist/stream-broker/index.js +284 -40
- package/dist/stream-broker/index.mjs +284 -40
- package/dist/stream-broker/remoteEntry.js +1 -1
- package/package.json +1 -1
package/dist/recorder/index.js
CHANGED
|
@@ -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");
|
|
@@ -2481,70 +2482,75 @@ var CalendarIndex = class {
|
|
|
2481
2482
|
//#endregion
|
|
2482
2483
|
//#region src/recorder/addon/config-store.ts
|
|
2483
2484
|
/**
|
|
2484
|
-
*
|
|
2485
|
-
*
|
|
2486
|
-
*
|
|
2487
|
-
*
|
|
2488
|
-
*
|
|
2489
|
-
|
|
2490
|
-
|
|
2491
|
-
|
|
2492
|
-
|
|
2493
|
-
|
|
2494
|
-
|
|
2495
|
-
|
|
2496
|
-
|
|
2497
|
-
|
|
2498
|
-
|
|
2499
|
-
|
|
2500
|
-
|
|
2501
|
-
|
|
2502
|
-
|
|
2503
|
-
|
|
2504
|
-
|
|
2505
|
-
|
|
2506
|
-
|
|
2507
|
-
|
|
2508
|
-
|
|
2509
|
-
|
|
2510
|
-
|
|
2511
|
-
|
|
2512
|
-
|
|
2513
|
-
|
|
2514
|
-
|
|
2515
|
-
|
|
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
|
+
};
|
|
2516
2519
|
/** Cold-start default for a camera that has never been configured: disabled, no bands. */
|
|
2517
2520
|
var DEFAULT_DEVICE_CONFIG = {
|
|
2518
2521
|
enabled: false,
|
|
2519
2522
|
bands: []
|
|
2520
2523
|
};
|
|
2521
|
-
|
|
2522
|
-
|
|
2523
|
-
|
|
2524
|
-
|
|
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
|
+
});
|
|
2525
2531
|
}
|
|
2526
2532
|
/** Read every persisted device config, keyed by numeric deviceId. */
|
|
2527
2533
|
async function readDeviceConfigs(state) {
|
|
2528
|
-
const
|
|
2534
|
+
const rows = await state.readAll();
|
|
2529
2535
|
const map = /* @__PURE__ */ new Map();
|
|
2530
|
-
for (const [key, config] of
|
|
2536
|
+
for (const [key, config] of rows) {
|
|
2531
2537
|
const id = Number(key);
|
|
2532
2538
|
if (Number.isInteger(id)) map.set(id, config);
|
|
2533
2539
|
}
|
|
2534
2540
|
return map;
|
|
2535
2541
|
}
|
|
2536
2542
|
/**
|
|
2537
|
-
* The persisted config for a device — `bands` is always
|
|
2538
|
-
* 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
|
|
2539
2545
|
* {@link DEFAULT_DEVICE_CONFIG}.
|
|
2540
2546
|
*/
|
|
2541
2547
|
async function loadDeviceConfig(state, deviceId) {
|
|
2542
|
-
return
|
|
2548
|
+
return await state.read(String(deviceId)) ?? DEFAULT_DEVICE_CONFIG;
|
|
2543
2549
|
}
|
|
2544
2550
|
/**
|
|
2545
2551
|
* The devices whose stored config claims to record (`enabled` + a non-`off`
|
|
2546
2552
|
* derived `mode`) but has no band left — the one shape this store cannot
|
|
2547
|
-
* repair. It can only come from a pre-retirement
|
|
2553
|
+
* repair. It can only come from a pre-retirement config whose legacy authoring
|
|
2548
2554
|
* fields were stripped on read: the camera records NOTHING until its bands are
|
|
2549
2555
|
* re-authored, so the recorder shouts about it once at boot
|
|
2550
2556
|
* ({@link warnLostBands}) instead of failing silently.
|
|
@@ -2561,7 +2567,7 @@ async function warnLostBands(state, logger) {
|
|
|
2561
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.`);
|
|
2562
2568
|
}
|
|
2563
2569
|
/**
|
|
2564
|
-
* Persist (insert or replace) a device's config. Validates against
|
|
2570
|
+
* Persist (insert or replace) a device's config — ONE row. Validates against
|
|
2565
2571
|
* `RecordingConfigSchema` at the boundary (untrusted cap input). Returns the
|
|
2566
2572
|
* validated config that was persisted.
|
|
2567
2573
|
*/
|
|
@@ -2571,9 +2577,7 @@ async function saveDeviceConfig(state, deviceId, config) {
|
|
|
2571
2577
|
...parsed,
|
|
2572
2578
|
mode: parsed.enabled ? activeModeForConfig(parsed) : "off"
|
|
2573
2579
|
};
|
|
2574
|
-
|
|
2575
|
-
map.set(deviceId, validated);
|
|
2576
|
-
await state.set(serialize$1(map));
|
|
2580
|
+
await state.write(String(deviceId), validated);
|
|
2577
2581
|
return validated;
|
|
2578
2582
|
}
|
|
2579
2583
|
/**
|
|
@@ -2940,61 +2944,122 @@ function buildExportArgs(input) {
|
|
|
2940
2944
|
/**
|
|
2941
2945
|
* Recording-export persistence for the recorder addon.
|
|
2942
2946
|
*
|
|
2943
|
-
*
|
|
2944
|
-
*
|
|
2945
|
-
*
|
|
2946
|
-
*
|
|
2947
|
-
*
|
|
2948
|
-
*
|
|
2949
|
-
|
|
2950
|
-
|
|
2951
|
-
* The
|
|
2952
|
-
*
|
|
2953
|
-
*
|
|
2954
|
-
*
|
|
2955
|
-
|
|
2956
|
-
|
|
2957
|
-
|
|
2958
|
-
|
|
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
|
+
}
|
|
2959
3031
|
/**
|
|
2960
|
-
* One in-flight mutation chain per
|
|
3032
|
+
* One in-flight mutation chain per (handle, export id).
|
|
2961
3033
|
*
|
|
2962
|
-
*
|
|
2963
|
-
*
|
|
2964
|
-
*
|
|
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).
|
|
2965
3040
|
*
|
|
2966
|
-
*
|
|
2967
|
-
*
|
|
2968
|
-
* camera 615 landed between one such patch's read and its write, the row was
|
|
2969
|
-
* erased, and five seconds later the poll answered `unknown export
|
|
2970
|
-
* "547ea182-…"`. The camera's whole night was lost to it — the scheduler read a
|
|
2971
|
-
* vanished row as a failed render and re-queued, forever.
|
|
2972
|
-
*
|
|
2973
|
-
* Serialising per handle is the smallest fix that makes the loss impossible:
|
|
2974
|
-
* the cycles queue instead of interleaving, and the queue is per state object
|
|
2975
|
-
* so two recorders in one process never contend. A `WeakMap` keeps a disposed
|
|
2976
|
-
* handle collectable. Reads stay OUTSIDE the chain — a blob replacement is
|
|
2977
|
-
* 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.
|
|
2978
3043
|
*/
|
|
2979
3044
|
var mutationChains = /* @__PURE__ */ new WeakMap();
|
|
2980
|
-
/** Run `work` after every mutation already queued for `state`, never before. */
|
|
2981
|
-
async function serialize(state, work) {
|
|
2982
|
-
const
|
|
2983
|
-
|
|
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
|
+
});
|
|
2984
3056
|
return next;
|
|
2985
3057
|
}
|
|
2986
|
-
/** Read every persisted export row, keyed by export id. */
|
|
2987
|
-
async function readExports(state) {
|
|
2988
|
-
const blob = await state.get();
|
|
2989
|
-
return new Map(Object.entries(blob));
|
|
2990
|
-
}
|
|
2991
3058
|
/** Insert or replace one export row. Validates at the boundary. Returns it. */
|
|
2992
3059
|
async function upsertExport(state, record) {
|
|
2993
3060
|
const validated = require_dist.ExportRecordSchema.parse(record);
|
|
2994
|
-
return serialize(state, async () => {
|
|
2995
|
-
|
|
2996
|
-
map.set(validated.id, validated);
|
|
2997
|
-
await state.set(Object.fromEntries(map));
|
|
3061
|
+
return serialize(state, validated.id, async () => {
|
|
3062
|
+
await state.write(validated.id, validated);
|
|
2998
3063
|
return validated;
|
|
2999
3064
|
});
|
|
3000
3065
|
}
|
|
@@ -3003,28 +3068,37 @@ async function upsertExport(state, record) {
|
|
|
3003
3068
|
* record, or null when the id is unknown.
|
|
3004
3069
|
*/
|
|
3005
3070
|
async function patchExport(state, id, patch) {
|
|
3006
|
-
return serialize(state, async () => {
|
|
3007
|
-
const
|
|
3008
|
-
|
|
3009
|
-
if (!existing) return null;
|
|
3071
|
+
return serialize(state, id, async () => {
|
|
3072
|
+
const existing = await state.read(id);
|
|
3073
|
+
if (existing === null) return null;
|
|
3010
3074
|
const merged = require_dist.ExportRecordSchema.parse({
|
|
3011
3075
|
...existing,
|
|
3012
3076
|
...patch
|
|
3013
3077
|
});
|
|
3014
|
-
|
|
3015
|
-
await state.set(Object.fromEntries(map));
|
|
3078
|
+
await state.write(id, merged);
|
|
3016
3079
|
return merged;
|
|
3017
3080
|
});
|
|
3018
3081
|
}
|
|
3019
|
-
/** Read one export row by id
|
|
3082
|
+
/** Read one export row by id — ONE keyed row read. */
|
|
3020
3083
|
async function getExportRecord(state, id) {
|
|
3021
|
-
return
|
|
3084
|
+
return state.read(id);
|
|
3022
3085
|
}
|
|
3023
3086
|
/**
|
|
3024
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.
|
|
3025
3092
|
*/
|
|
3026
3093
|
async function listExportsFor(state, deviceId) {
|
|
3027
|
-
return
|
|
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);
|
|
3028
3102
|
}
|
|
3029
3103
|
//#endregion
|
|
3030
3104
|
//#region src/recorder/addon/spawn-failure.ts
|
|
@@ -4791,39 +4865,117 @@ function runFfmpeg(renderDeps, args, ext) {
|
|
|
4791
4865
|
/**
|
|
4792
4866
|
* Recordings ops-log persistence for the recorder addon.
|
|
4793
4867
|
*
|
|
4794
|
-
*
|
|
4795
|
-
*
|
|
4796
|
-
*
|
|
4797
|
-
*
|
|
4798
|
-
*
|
|
4799
|
-
*
|
|
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]`).
|
|
4800
4933
|
*
|
|
4801
|
-
*
|
|
4802
|
-
*
|
|
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.
|
|
4803
4939
|
*/
|
|
4804
|
-
|
|
4805
|
-
|
|
4806
|
-
|
|
4807
|
-
|
|
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
|
+
}
|
|
4808
4953
|
/**
|
|
4809
|
-
* Append one row and persist,
|
|
4810
|
-
*
|
|
4811
|
-
* 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.
|
|
4812
4956
|
*/
|
|
4813
4957
|
async function appendOpsLog(state, entry, maxEntries = 500) {
|
|
4814
4958
|
const validated = require_dist.OpsLogEntrySchema.parse(entry);
|
|
4815
|
-
|
|
4816
|
-
|
|
4817
|
-
await state.set(bounded);
|
|
4959
|
+
await state.write(validated.id, validated);
|
|
4960
|
+
await trimOpsLog(state, maxEntries);
|
|
4818
4961
|
return validated;
|
|
4819
4962
|
}
|
|
4820
4963
|
/**
|
|
4821
4964
|
* List ops-log rows newest-first, optionally scoped to one device, capped at
|
|
4822
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.
|
|
4823
4969
|
*/
|
|
4824
4970
|
async function listOpsLog(state, query) {
|
|
4825
|
-
|
|
4826
|
-
|
|
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);
|
|
4827
4979
|
}
|
|
4828
4980
|
/**
|
|
4829
4981
|
* Build the recorder's ops-log sink. `append` stamps `domain:'recording'`,
|
|
@@ -8091,6 +8243,25 @@ async function readLocationCapacity(root, statfs) {
|
|
|
8091
8243
|
}
|
|
8092
8244
|
}
|
|
8093
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
|
|
8094
8265
|
//#region src/durable/durable-ledger.ts
|
|
8095
8266
|
/** Default reseed cap — every current consumer's row set is installation-bounded. */
|
|
8096
8267
|
var DEFAULT_LOAD_LIMIT = 1e5;
|
|
@@ -9654,6 +9825,8 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
|
|
|
9654
9825
|
} });
|
|
9655
9826
|
return { providers: [] };
|
|
9656
9827
|
}
|
|
9828
|
+
await this.declareRowCollections();
|
|
9829
|
+
await this.purgeFlattenedSettingsKeys();
|
|
9657
9830
|
this.segmentHours = new SegmentHourLedger({
|
|
9658
9831
|
store: this.ctx.api.settingsStore,
|
|
9659
9832
|
logger: this.ctx.logger,
|
|
@@ -10622,17 +10795,52 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
|
|
|
10622
10795
|
this.fullWalkInFlight = false;
|
|
10623
10796
|
}
|
|
10624
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
|
+
}
|
|
10625
10833
|
configStore() {
|
|
10626
|
-
return this.
|
|
10834
|
+
return recordingConfigsState(this.ctx.api.settingsStore, this.ctx.logger);
|
|
10627
10835
|
}
|
|
10628
10836
|
/** Durable handle over the exportId→ExportRecord blob (same `BaseAddon.state()`
|
|
10629
10837
|
* wrapper as the config store). */
|
|
10630
10838
|
exportsStore() {
|
|
10631
|
-
return this.
|
|
10839
|
+
return recordingExportsState(this.ctx.api.settingsStore, this.ctx.logger);
|
|
10632
10840
|
}
|
|
10633
10841
|
/** Durable handle over the bounded recordings ops-log ring. */
|
|
10634
10842
|
opsLogStore() {
|
|
10635
|
-
return this.
|
|
10843
|
+
return recordingOpsLogState(this.ctx.api.settingsStore, this.ctx.logger);
|
|
10636
10844
|
}
|
|
10637
10845
|
/** Durable handle over the recorder-owned placement blob (assignments + volume ids). */
|
|
10638
10846
|
placementStore() {
|