@camstack/addon-pipeline 1.2.375 → 1.2.601

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 (34) hide show
  1. package/dist/audio-analyzer/index.js +3 -3
  2. package/dist/audio-analyzer/index.mjs +3 -3
  3. package/dist/{custom-model-catalog-DF5qs8u4.mjs → custom-model-catalog-CK9GK3N5.mjs} +2 -2
  4. package/dist/{custom-model-catalog-gbLoUFki.js → custom-model-catalog-DgyafyuW.js} +2 -2
  5. package/dist/detection-pipeline/index.js +4 -4
  6. package/dist/detection-pipeline/index.mjs +4 -4
  7. package/dist/{dist-QyeZXkAO.mjs → dist-BaHsDOWa.mjs} +391 -22
  8. package/dist/{dist-C0Mg8e3I.js → dist-BqI10eLV.js} +396 -21
  9. package/dist/{model-catalogs-CIE4Dz4x.mjs → model-catalogs-DM5sVj1T.mjs} +1 -1
  10. package/dist/{model-catalogs-BTuX-P0k.js → model-catalogs-DhGVt_sH.js} +1 -1
  11. package/dist/motion-wasm/index.js +1 -1
  12. package/dist/motion-wasm/index.mjs +1 -1
  13. package/dist/{node-ndjp9DQO.mjs → node-B0a0dXhg.mjs} +38 -3
  14. package/dist/{node-CifGKjsd.js → node-CgwqjGz0.js} +42 -1
  15. package/dist/pipeline-runner/index.js +4 -4
  16. package/dist/pipeline-runner/index.mjs +4 -4
  17. package/dist/{process-memory-BMfSP5-i.js → process-memory-C0zbWvwD.js} +1 -1
  18. package/dist/{process-memory-CaMI-Ry2.mjs → process-memory-sfdcLVkW.mjs} +1 -1
  19. package/dist/recorder/index.js +343 -516
  20. package/dist/recorder/index.mjs +343 -516
  21. package/dist/{segment-demux-js-LhwVw18z.js → segment-demux-js-B9lL2VCC.js} +1 -1
  22. package/dist/{segment-demux-js-CLXBgUTo.mjs → segment-demux-js-BB4CUAB5.mjs} +1 -1
  23. package/dist/stream-broker/_stub.js +2 -2
  24. package/dist/stream-broker/{_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-B4MD_B4k.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-CbN9DjoK.mjs} +3 -3
  25. package/dist/stream-broker/{_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-BvMrdOCD.mjs → _virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-Boq8JqyD.mjs} +1 -1
  26. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-B25q1OKG.mjs +26 -0
  27. package/dist/stream-broker/demux-worker-child.js +1 -1
  28. package/dist/stream-broker/demux-worker-child.mjs +1 -1
  29. package/dist/stream-broker/{hostInit-DBDp5YHD.mjs → hostInit-BSYHAfPt.mjs} +3 -3
  30. package/dist/stream-broker/index.js +10 -9
  31. package/dist/stream-broker/index.mjs +10 -9
  32. package/dist/stream-broker/remoteEntry.js +1 -1
  33. package/package.json +1 -1
  34. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-JVFs9LgI.mjs +0 -26
@@ -1,5 +1,5 @@
1
- import { $n as userError, $t as recordingArchiveCapability, An as errMsg$1, B as RECORDING_SOURCE_CAMSTACK_ADDON, Bn as defineCustomActions, Bt as memoizePinnedFfmpeg, C as EVENT_PAD_MS, Ct as evictionPolicyForMode, Ft as loadContributionCapability, G as VIDEOCLIPS_MAX_OFFER_BYTES, H as RecordingConfigSchema, Ht as modeMayWrite, K as VISIT_MERGE_GAP_MS, Mn as BaseAddon, Mt as isFirstLevelMacroClass, N as OpsLogEntrySchema, On as DeviceFeature, Ot as gbToBytes, Qn as sleep$1, R as RECORDING_EXPORT_MAX_READ_BYTES, Rt as mayReadLocation, S as EVENT_OWNER_TYPES, T as ExportRecordSchema, Tn as evaluateSensorEdge, Tt as exportSubjectOf, U as RecordingSignalStatusSchema, Un as hydrateSchema, Vt as migrateLegacyDeviceSignalConfig, Yn as nodePin, Z as armRemovalReconcile, Zn as selectAssignedProfileSlots, _n as stripRetiredBandPreBufferSec, at as clipExportFailure, cr as preprocess, en as recordingCapability, er as _enum, fr as EventCategory, hn as storageOccupancyCapability, ht as deriveRecordingMode, in as resolveCreateExportSubject, kn as DeviceType, ln as resolveRecordingProfiles, lr as record, mn as storageEvictableCapability, nn as recordingSignalCapability, nr as boolean, o as BatteryStatusSchema, on as resolveLocationMode, or as number, ot as clipExportFailureOf, pt as deriveBatteryPresence, s as CLIP_BROKER_PACED_RATES, sr as object, tn as recordingExportCapability, tr as array, tt as batteryCapability, ur as string, v as DEFAULT_EVENTS_BAND_BUFFER_SEC, vt as deviceRemovalHookCapability, wt as evictionPolicyOfLocation, yn as videoclipsCapability, z as RECORDING_SOURCE_CAMSTACK, zn as customAction, zt as mayWriteToLocation } from "../dist-QyeZXkAO.mjs";
2
- import { a as physicalRootOf, i as NO_COST_CLAIM, t as ChildCostRegistry } from "../node-ndjp9DQO.mjs";
1
+ import { $n as sleep$1, $t as recordingArchiveCapability, An as errMsg$1, B as RECORDING_SOURCE_CAMSTACK_ADDON, Bn as defineCustomActions, Bt as memoizePinnedFfmpeg, C as EVENT_PAD_MS, Ct as evictionPolicyForMode, Ft as loadContributionCapability, G as VIDEOCLIPS_MAX_OFFER_BYTES, H as RecordingConfigSchema, Ht as modeMayWrite, K as VISIT_MERGE_GAP_MS, Mn as BaseAddon, Mt as isFirstLevelMacroClass, N as OpsLogEntrySchema, On as DeviceFeature, Ot as gbToBytes, Qn as selectAssignedProfileSlots, R as RECORDING_EXPORT_MAX_READ_BYTES, Rt as mayReadLocation, S as EVENT_OWNER_TYPES, T as ExportRecordSchema, Tn as evaluateSensorEdge, Tt as exportSubjectOf, U as RecordingSignalStatusSchema, Vn as defineRecordKinds, Vt as migrateLegacyDeviceSignalConfig, Wn as hydrateSchema, Xn as nodePin, Z as armRemovalReconcile, _n as stripRetiredBandPreBufferSec, at as clipExportFailure, cr as object, dr as string, en as recordingCapability, er as userError, hn as storageOccupancyCapability, ht as deriveRecordingMode, in as resolveCreateExportSubject, kn as DeviceType, ln as resolveRecordingProfiles, lr as preprocess, mn as storageEvictableCapability, nn as recordingSignalCapability, nr as array, o as BatteryStatusSchema, on as resolveLocationMode, ot as clipExportFailureOf, pr as EventCategory, pt as deriveBatteryPresence, rr as boolean, s as CLIP_BROKER_PACED_RATES, sr as number, tn as recordingExportCapability, tr as _enum, tt as batteryCapability, ur as record, v as DEFAULT_EVENTS_BAND_BUFFER_SEC, vt as deviceRemovalHookCapability, wt as evictionPolicyOfLocation, yn as videoclipsCapability, z as RECORDING_SOURCE_CAMSTACK, zn as customAction, zt as mayWriteToLocation } from "../dist-BaHsDOWa.mjs";
2
+ import { i as NO_COST_CLAIM, o as physicalRootOf, t as ChildCostRegistry } from "../node-B0a0dXhg.mjs";
3
3
  import { t as resolveHubHostname } from "../hub-hostname-cCknRYKj.mjs";
4
4
  import { d as PREBUFFER_MAX_MS, l as withRecordingIntent, r as RECORDING_PRE_ROLL_REQUEST_MS } from "../restream-intent-DyGmLAU9.mjs";
5
5
  import { randomUUID } from "node:crypto";
@@ -5196,29 +5196,6 @@ function activeModeForConfig(config) {
5196
5196
  * pays for ten minutes, not until the next restart.
5197
5197
  */
5198
5198
  var CENSUS_MAX_WINDOW_MS = 10 * 6e4;
5199
- /** Store procedures worth attributing. Reads only: a write is an operator
5200
- * gesture or a pipeline product, and neither is a mystery. */
5201
- var CENSUSED_PROCEDURES = new Set([
5202
- "query",
5203
- "count",
5204
- "aggregate",
5205
- "get",
5206
- "histogram",
5207
- "getAll"
5208
- ]);
5209
- /**
5210
- * Can `new Proxy` wrap this, and is it worth wrapping?
5211
- *
5212
- * Functions count. The generated store client is a **tRPC proxy** and every
5213
- * intermediate node of one is CALLABLE — `typeof client.query` is `'function'`,
5214
- * not `'object'`. A wrapper that tested only for `'object'` handed the whole
5215
- * client back unwrapped, deployed, and reported zero `query` and zero `count`
5216
- * call sites while the engine was recording thousands a minute against the same
5217
- * tables. Silence read as "nobody in this addon does it".
5218
- */
5219
- function isProxyable(value) {
5220
- return typeof value === "object" && value !== null || typeof value === "function";
5221
- }
5222
5199
  function rowsOf(value) {
5223
5200
  if (Array.isArray(value)) return value.length;
5224
5201
  if (typeof value === "number") return value;
@@ -5342,34 +5319,6 @@ var StoreReadCensus = class {
5342
5319
  })).sort((a, b) => b.calls - a.calls);
5343
5320
  }
5344
5321
  };
5345
- /**
5346
- * Wrap a settings-store client so every read is counted.
5347
- *
5348
- * A `Proxy` over the generated tRPC client rather than a hand-written facade:
5349
- * the client has a procedure per cap method and a facade would need updating
5350
- * every time the cap gains one - which is how a diagnostic quietly stops seeing
5351
- * half the traffic. Unrecognised procedures pass straight through.
5352
- */
5353
- function censusStoreClient(client, census) {
5354
- return new Proxy(client, { get(target, prop, receiver) {
5355
- const procedure = Reflect.get(target, prop, receiver);
5356
- if (typeof prop !== "string" || !CENSUSED_PROCEDURES.has(prop)) return procedure;
5357
- if (!isProxyable(procedure)) return procedure;
5358
- const op = prop;
5359
- return new Proxy(procedure, { get(procTarget, procProp, procReceiver) {
5360
- const call = Reflect.get(procTarget, procProp, procReceiver);
5361
- if (procProp !== "query" && procProp !== "mutate") return call;
5362
- if (typeof call !== "function") return call;
5363
- const wrapped = async (...args) => {
5364
- const site = census.siteOf(wrapped);
5365
- const result = await Reflect.apply(call, procTarget, args);
5366
- census.record(op, site, result);
5367
- return result;
5368
- };
5369
- return wrapped;
5370
- } });
5371
- } });
5372
- }
5373
5322
  /** Log the census as one line, including when it collected nothing - silence
5374
5323
  * after an arm is indistinguishable from a census that never armed. */
5375
5324
  function logCensus(census, logger, topN) {
@@ -8361,259 +8310,76 @@ function buildExportArgs(input) {
8361
8310
  outPath
8362
8311
  ];
8363
8312
  }
8364
- var ROW_MAP_VALUE_COLUMN = "value";
8365
- var RowMapStore = class RowMapStore {
8366
- spec;
8367
- store;
8368
- logger;
8369
- constructor(deps) {
8370
- this.spec = deps.spec;
8371
- this.store = deps.store;
8372
- this.logger = deps.logger;
8373
- }
8374
- /** The collection this store owns — for the caller's own log lines. */
8375
- get collection() {
8376
- return this.spec.collection;
8377
- }
8378
- /**
8379
- * Register the collection. MUST run at boot, before any read or write: the
8380
- * SQLite backend answers 412 for an undeclared collection.
8381
- */
8382
- static declare(store, spec) {
8383
- return store.declareCollection.mutate({
8384
- collection: spec.collection,
8385
- columns: [
8386
- {
8387
- name: "id",
8388
- type: "TEXT",
8389
- primaryKey: true,
8390
- notNull: true
8391
- },
8392
- ...spec.columns,
8393
- {
8394
- name: ROW_MAP_VALUE_COLUMN,
8395
- type: "JSON",
8396
- notNull: true
8397
- }
8398
- ],
8399
- ...spec.indexes !== void 0 ? { indexes: [...spec.indexes] } : {}
8400
- });
8401
- }
8402
- declare() {
8403
- return RowMapStore.declare(this.store, this.spec);
8404
- }
8405
- /** One entry, by key. `null` when absent OR unparseable (which is said aloud). */
8406
- async read(key) {
8407
- const raw = await this.store.get.query({
8408
- collection: this.spec.collection,
8409
- key
8410
- });
8411
- if (!isRecord(raw)) return null;
8412
- const row = raw;
8413
- const parsed = this.spec.schema.safeParse(row[ROW_MAP_VALUE_COLUMN]);
8414
- if (!parsed.success) {
8415
- this.logger.warn("row-map row skipped as unparseable", {
8416
- ...this.deviceTags(row),
8417
- meta: {
8418
- collection: this.spec.collection,
8419
- key,
8420
- error: parsed.error.message
8421
- }
8422
- });
8423
- return null;
8424
- }
8425
- return parsed.data;
8426
- }
8427
- /**
8428
- * Every entry, keyed. Unparseable rows are skipped — one bad entry costs that
8429
- * entry, never the map.
8430
- */
8431
- async readAll() {
8432
- const records = await this.store.query.query({
8433
- collection: this.spec.collection,
8434
- filter: { limit: this.spec.loadLimit }
8435
- });
8436
- const out = /* @__PURE__ */ new Map();
8437
- let skipped = 0;
8438
- for (const record of records) {
8439
- const parsed = this.spec.schema.safeParse(record.data[ROW_MAP_VALUE_COLUMN]);
8440
- if (!parsed.success) {
8441
- skipped += 1;
8442
- continue;
8443
- }
8444
- out.set(record.id, parsed.data);
8445
- }
8446
- if (skipped > 0) this.logger.warn("row-map rows skipped as unparseable", { meta: {
8447
- collection: this.spec.collection,
8448
- skipped,
8449
- kept: out.size
8450
- } });
8451
- if (records.length >= this.spec.loadLimit) this.logger.warn("row-map read hit its own bound — the map may be truncated", { meta: {
8452
- collection: this.spec.collection,
8453
- loadLimit: this.spec.loadLimit
8454
- } });
8455
- return out;
8456
- }
8457
- /**
8458
- * Every entry as it was WRITTEN, unvalidated and unparsed — the read a
8459
- * MIGRATION needs.
8460
- *
8461
- * {@link readAll} answers through the CURRENT schema, which is exactly what a
8462
- * migration must not do: a retired key is stripped by a non-strict `z.object`
8463
- * before the migration could see it, so the row it is there to rescue looks
8464
- * like a row that never needed rescuing. Only a migration should call this;
8465
- * everything else wants the validated read.
8466
- */
8467
- async readAllRaw() {
8468
- const records = await this.store.query.query({
8469
- collection: this.spec.collection,
8470
- filter: { limit: this.spec.loadLimit }
8471
- });
8472
- const out = /* @__PURE__ */ new Map();
8473
- for (const record of records) out.set(record.id, record.data[ROW_MAP_VALUE_COLUMN]);
8474
- return out;
8475
- }
8476
- /** Insert or replace ONE entry. Validates at the boundary. */
8477
- async write(key, value) {
8478
- const validated = this.spec.schema.parse(value);
8479
- const columns = this.spec.project(key, validated);
8480
- await this.store.set.mutate({
8481
- collection: this.spec.collection,
8482
- key,
8483
- value: {
8484
- ...columns,
8485
- [ROW_MAP_VALUE_COLUMN]: validated
8486
- }
8487
- });
8488
- }
8489
- /** Drop ONE entry. Idempotent. */
8490
- async remove(key) {
8491
- await this.store.delete.mutate({
8492
- collection: this.spec.collection,
8493
- key
8494
- });
8495
- }
8313
+ var recorderRecordKinds = defineRecordKinds({
8496
8314
  /**
8497
- * Make the collection hold exactly `next` — the write path for an owner whose
8498
- * in-memory map IS the authority (a persister handed the complete set).
8315
+ * One row per export job, inserted by `createExport` and updated as the
8316
+ * render progresses. The system NEVER deletes a row — it is the export AUDIT.
8317
+ * `deleteExport` and the janitor flip `state` and clear the file, keeping
8318
+ * the row. The record's `at` field maps to `createdAt`.
8499
8319
  *
8500
- * Returns how many rows were DELETED, and says so out loud when any were: a
8501
- * replace that silently drops rows is indistinguishable from one that never
8502
- * had them. Only an owner holding an authoritative set may call this — a
8503
- * prune driven by a fallible read is work destroyed (D130, D49).
8504
- */
8505
- async replaceAll(next) {
8506
- const held = await this.store.query.query({
8507
- collection: this.spec.collection,
8508
- filter: { limit: this.spec.loadLimit }
8509
- });
8510
- for (const [key, value] of next) await this.write(key, value);
8511
- const doomed = held.filter((record) => !next.has(record.id));
8512
- for (const record of doomed) await this.remove(record.id);
8513
- if (doomed.length > 0) this.logger.warn("row-map replaceAll dropped rows", { meta: {
8514
- collection: this.spec.collection,
8515
- dropped: doomed.length,
8516
- keys: doomed.slice(0, 20).map((r) => r.id),
8517
- remaining: next.size
8518
- } });
8519
- return doomed.length;
8520
- }
8320
+ * Indexed by `deviceId` to serve the per-device listing efficiently.
8321
+ */
8322
+ exports: {
8323
+ schema: ExportRecordSchema,
8324
+ class: "ledger",
8325
+ writeTrigger: "one row per export job, inserted by createExport; rewritten by the render engine (progress / state patches) and the download hook",
8326
+ device: "none",
8327
+ at: "required",
8328
+ indexes: [{
8329
+ name: "device",
8330
+ fields: ["deviceId"]
8331
+ }],
8332
+ retention: { none: "operator intent — a row is the export AUDIT and survives forever; deleteExport and the janitor flip state and remove the file without removing the row; removeExport is a deliberate operator purge" },
8333
+ adoptsFrom: "recorder:exports"
8334
+ },
8521
8335
  /**
8522
- * The entries matching a projected-column predicate, in the order asked for.
8336
+ * A bounded ring of per-operation audit rows. One row per recordings
8337
+ * operation (prune, evict, relocate, export, …), appended best-effort by
8338
+ * `createRecordingOpsLogSink().append`. The ring is trimmed to the newest
8339
+ * `OPS_LOG_RING_DEFAULT_MAX` (500) rows after every append.
8523
8340
  *
8524
- * This is what projecting columns BUYS: "every export for camera 617, newest
8525
- * first, capped at 50" is an indexed `WHERE` + `ORDER BY` + `LIMIT`, not a
8526
- * parse of the whole map followed by a filter in JS.
8527
- *
8528
- * A `limit` is REQUIRED — `settings-store.query` silently caps an unbounded
8529
- * read at 2 000 rows and answers as if that were everything.
8530
- */
8531
- async readWhere(filter) {
8532
- const records = await this.store.query.query({
8533
- collection: this.spec.collection,
8534
- filter: {
8535
- ...filter.where !== void 0 ? { where: filter.where } : {},
8536
- ...filter.whereIn !== void 0 ? { whereIn: filter.whereIn } : {},
8537
- ...filter.whereBetween !== void 0 ? { whereBetween: filter.whereBetween } : {},
8538
- ...filter.orderBy !== void 0 ? { orderBy: filter.orderBy } : {},
8539
- ...filter.offset !== void 0 ? { offset: filter.offset } : {},
8540
- limit: filter.limit ?? this.spec.loadLimit
8541
- }
8542
- });
8543
- const out = [];
8544
- let skipped = 0;
8545
- for (const record of records) {
8546
- const parsed = this.spec.schema.safeParse(record.data[ROW_MAP_VALUE_COLUMN]);
8547
- if (!parsed.success) {
8548
- skipped += 1;
8549
- continue;
8550
- }
8551
- out.push({
8552
- key: record.id,
8553
- value: parsed.data
8554
- });
8555
- }
8556
- if (skipped > 0) this.logger.warn("row-map rows skipped as unparseable", { meta: {
8557
- collection: this.spec.collection,
8558
- skipped,
8559
- kept: out.length
8560
- } });
8561
- return out;
8562
- }
8563
- /** How many rows match, without materialising one. */
8564
- async count(filter) {
8565
- return this.store.count.query({
8566
- collection: this.spec.collection,
8567
- ...filter !== void 0 ? { filter: {
8568
- ...filter.where !== void 0 ? { where: filter.where } : {},
8569
- ...filter.whereIn !== void 0 ? { whereIn: filter.whereIn } : {},
8570
- ...filter.whereBetween !== void 0 ? { whereBetween: filter.whereBetween } : {}
8571
- } } : {}
8572
- });
8573
- }
8574
- /**
8575
- * Delete every row matching a predicate, in ONE statement. Returns how many
8576
- * went, and says so — a retention trim that silently drops rows is
8577
- * indistinguishable from one that had nothing to do.
8341
+ * Indexed by `deviceId` so a per-device listing is an indexed query, not a
8342
+ * full scan of the ring.
8578
8343
  *
8579
- * The predicate is REQUIRED and the engine REFUSES one that compiles to
8580
- * nothing: deleting a whole collection is a legitimate intent, but it must be
8581
- * asked for by name rather than reached through an empty object.
8582
- */
8583
- async deleteWhere(filter) {
8584
- const { deleted } = await this.store.deleteWhere.mutate({
8585
- collection: this.spec.collection,
8586
- filter
8587
- });
8588
- if (deleted > 0) this.logger.warn("row-map deleteWhere dropped rows", { meta: {
8589
- collection: this.spec.collection,
8590
- deleted,
8591
- filter
8592
- } });
8593
- return deleted;
8594
- }
8595
- /** `tags: { deviceId }` off the PROJECTED column, when the spec names one. */
8596
- deviceTags(row) {
8597
- const column = this.spec.deviceIdColumn;
8598
- if (column === void 0) return {};
8599
- const raw = row[column];
8600
- return typeof raw === "number" && Number.isInteger(raw) ? { tags: { deviceId: raw } } : {};
8344
+ * The record's `at` field maps to `OpsLogEntry.at`.
8345
+ */
8346
+ "ops-log": {
8347
+ schema: OpsLogEntrySchema,
8348
+ class: "ledger",
8349
+ writeTrigger: "one row per recordings operation (prune, evict, relocate, export, …), appended best-effort by createRecordingOpsLogSink().append; the ring is trimmed to the newest 500 rows in application code after every write",
8350
+ device: "none",
8351
+ at: "required",
8352
+ indexes: [{
8353
+ name: "device",
8354
+ fields: ["deviceId"]
8355
+ }],
8356
+ retention: { none: "a bounded ring of the newest 500 rows; older rows are removed in application code (trimOpsLog) after each append — owner-pruned" },
8357
+ adoptsFrom: "recorder:ops-log"
8601
8358
  }
8602
- };
8603
- /** A store payload that is a plain object, or `null`. A type guard, not a cast. */
8604
- function isRecord(value) {
8605
- return value !== null && typeof value === "object" && !Array.isArray(value);
8606
- }
8359
+ });
8607
8360
  //#endregion
8608
8361
  //#region src/recorder/addon/export-store.ts
8609
8362
  /**
8610
8363
  * Recording-export persistence for the recorder addon.
8611
8364
  *
8612
- * ONE ROW PER EXPORT in `recorder:exports`, through {@link RowMapStore}. History
8613
- * rows are never removed by the system (audit) — `deleteExport` / the janitor
8614
- * flip `state` and clear the file, keeping the row.
8365
+ * ONE ROW PER EXPORT in the `recorder` / `exports` record kind, through a
8366
+ * {@link RecordKindHandle}. History rows are never removed by the system
8367
+ * (audit) — `deleteExport` / the janitor flip `state` and clear the file,
8368
+ * keeping the row.
8369
+ *
8370
+ * ## Records migration (phase 2, step 25)
8371
+ *
8372
+ * The store was previously backed by a {@link RowMapStore} over a SettingsStore
8373
+ * collection named `recorder:exports`. It is now backed by a
8374
+ * `RecordKindHandle<ExportRecord>` obtained from `ctx.records`. The record's
8375
+ * `at` field maps to `ExportRecord.createdAt`.
8376
+ *
8377
+ * The per-ID serialiser ({@link serialize}) is preserved: two overlapping
8378
+ * patches on the SAME export row are still read-modify-write, so they must
8379
+ * be ordered. The `RecordKindHandle` (an object) is used as the WeakMap key,
8380
+ * keeping the same compaction semantics.
8615
8381
  *
8616
- * ## What this replaced, and why it mattered here more than anywhere
8382
+ * ## What the RowMapStore replaced, and why it mattered
8617
8383
  *
8618
8384
  * Every job used to live in one key of the recorder's `addon-settings` / `root`
8619
8385
  * blob — 201 833 bytes on the reference hub, of which the export history was
@@ -8628,11 +8394,6 @@ function isRecord(value) {
8628
8394
  * erased, and the poll answered `unknown export "547ea182-…"`; the scheduler
8629
8395
  * read the vanished row as a failed render and re-queued forever. A per-row
8630
8396
  * upsert makes that class of loss unrepresentable — a write names ONE row.
8631
- *
8632
- * What is still needed is a per-ID serialiser: two patches to the SAME export
8633
- * still read-modify-write the same row. That is what {@link serialize} bounds,
8634
- * and it is now per (store, id) rather than per store, so two exports never
8635
- * queue behind each other.
8636
8397
  */
8637
8398
  /**
8638
8399
  * The storage class export MP4s live in: `artifacts`, SHARED with the
@@ -8677,76 +8438,26 @@ function exportOutPathFor(root, deviceId, exportId) {
8677
8438
  return path.join(exportTenantDirFor(root, deviceId), `${exportId}.mp4`);
8678
8439
  }
8679
8440
  /**
8680
- * @durable class=ledger owner=recorder
8681
- * write="one row per export job, inserted by `createExport` and rewritten by the
8682
- * render engine's progress/state patches and by the download hook"
8683
- * retention="the SYSTEM never deletes a row — it is the export AUDIT. `deleteExport`
8684
- * and the janitor flip `state` to 'deleted'/'expired' and remove the FILE, keeping
8685
- * the row. `removeExport` exists for an operator-driven purge and nothing calls it
8686
- * on a schedule."
8441
+ * Bound for the whole-history read. Explicit because a records query without
8442
+ * an explicit limit uses the default cap (100); an export list that silently
8443
+ * loses its oldest entries looks exactly like a purge.
8687
8444
  */
8688
- var RECORDING_EXPORTS_COLLECTION = "recorder:exports";
8445
+ var RECORDING_EXPORTS_LIMIT = 2e4;
8689
8446
  /**
8690
- * Bound for the whole-history read. Explicit because `settings-store.query`
8691
- * caps an unbounded read at 2 000 rows SILENTLY, and an export list that
8692
- * silently loses its oldest half looks exactly like a purge.
8447
+ * Obtain the `recorder` / `exports` record kind handle.
8448
+ * `ctx.records.declare(recorderRecordKinds)` MUST have run before any read or
8449
+ * write (called at boot in `onInitialize`).
8693
8450
  */
8694
- var RECORDING_EXPORTS_LIMIT = 2e4;
8695
- var RECORDING_EXPORTS_SPEC = {
8696
- collection: RECORDING_EXPORTS_COLLECTION,
8697
- schema: ExportRecordSchema,
8698
- columns: [
8699
- {
8700
- name: "deviceId",
8701
- type: "INTEGER",
8702
- notNull: true
8703
- },
8704
- {
8705
- name: "createdAt",
8706
- type: "INTEGER",
8707
- notNull: true
8708
- },
8709
- {
8710
- name: "state",
8711
- type: "TEXT",
8712
- notNull: true
8713
- }
8714
- ],
8715
- indexes: [{
8716
- name: "idx_recorder_exports_device",
8717
- columns: ["deviceId"]
8718
- }, {
8719
- name: "idx_recorder_exports_created",
8720
- columns: ["createdAt"]
8721
- }],
8722
- project: (_key, value) => ({
8723
- deviceId: value.deviceId,
8724
- createdAt: value.createdAt,
8725
- state: value.state
8726
- }),
8727
- deviceIdColumn: "deviceId",
8728
- loadLimit: RECORDING_EXPORTS_LIMIT
8729
- };
8730
- /** Build the handle. `declare()` MUST run once at boot before any read/write. */
8731
- function recordingExportsState(store, logger) {
8732
- return new RowMapStore({
8733
- spec: RECORDING_EXPORTS_SPEC,
8734
- store,
8735
- logger
8736
- });
8451
+ function recordingExportsState(records) {
8452
+ return records.kind(recorderRecordKinds["exports"]);
8737
8453
  }
8738
8454
  /**
8739
8455
  * One in-flight mutation chain per (handle, export id).
8740
8456
  *
8741
- * A row upsert removed the cross-export clobber; it does not order two
8742
- * read-modify-writes of the SAME row. `patchExport` reads the row, merges and
8743
- * writes — so two overlapping patches on one export still race, and the loser's
8744
- * fields vanish. Serialising per ID keeps those pairs atomic while leaving
8745
- * unrelated exports fully concurrent (the old chain was per HANDLE, so a slow
8746
- * patch queued every other export behind it).
8747
- *
8748
- * A `WeakMap` keyed by the handle keeps a disposed store collectable; the inner
8749
- * map is keyed by export id.
8457
+ * Two overlapping patches on the SAME export still read-modify-write one row.
8458
+ * Serialising per id keeps them atomic while leaving unrelated exports fully
8459
+ * concurrent. A `WeakMap` keyed by the handle keeps a disposed handle
8460
+ * collectable; the inner map is keyed by export id.
8750
8461
  */
8751
8462
  var mutationChains = /* @__PURE__ */ new WeakMap();
8752
8463
  /** Run `work` after every mutation already queued for `(state, id)`, never before. */
@@ -8766,7 +8477,11 @@ async function serialize(state, id, work) {
8766
8477
  async function upsertExport(state, record) {
8767
8478
  const validated = ExportRecordSchema.parse(record);
8768
8479
  return serialize(state, validated.id, async () => {
8769
- await state.write(validated.id, validated);
8480
+ await state.put({
8481
+ id: validated.id,
8482
+ at: validated.createdAt,
8483
+ data: validated
8484
+ });
8770
8485
  return validated;
8771
8486
  });
8772
8487
  }
@@ -8776,13 +8491,17 @@ async function upsertExport(state, record) {
8776
8491
  */
8777
8492
  async function patchExport(state, id, patch) {
8778
8493
  return serialize(state, id, async () => {
8779
- const existing = await state.read(id);
8780
- if (existing === null) return null;
8494
+ const row = await state.get(id);
8495
+ if (row === null) return null;
8781
8496
  const merged = ExportRecordSchema.parse({
8782
- ...existing,
8497
+ ...row.data,
8783
8498
  ...patch
8784
8499
  });
8785
- await state.write(id, merged);
8500
+ await state.put({
8501
+ id,
8502
+ at: merged.createdAt,
8503
+ data: merged
8504
+ });
8786
8505
  return merged;
8787
8506
  });
8788
8507
  }
@@ -8793,17 +8512,21 @@ async function patchExport(state, id, patch) {
8793
8512
  */
8794
8513
  async function patchExportUnless(state, id, patch, refuse) {
8795
8514
  return serialize(state, id, async () => {
8796
- const existing = await state.read(id);
8797
- if (existing === null) return { kind: "missing" };
8798
- if (refuse(existing)) return {
8515
+ const row = await state.get(id);
8516
+ if (row === null) return { kind: "missing" };
8517
+ if (refuse(row.data)) return {
8799
8518
  kind: "refused",
8800
- record: existing
8519
+ record: row.data
8801
8520
  };
8802
8521
  const merged = ExportRecordSchema.parse({
8803
- ...existing,
8522
+ ...row.data,
8804
8523
  ...patch
8805
8524
  });
8806
- await state.write(id, merged);
8525
+ await state.put({
8526
+ id,
8527
+ at: merged.createdAt,
8528
+ data: merged
8529
+ });
8807
8530
  return {
8808
8531
  kind: "patched",
8809
8532
  record: merged
@@ -8812,24 +8535,22 @@ async function patchExportUnless(state, id, patch, refuse) {
8812
8535
  }
8813
8536
  /** Read one export row by id — ONE keyed row read. */
8814
8537
  async function getExportRecord(state, id) {
8815
- return state.read(id);
8538
+ const row = await state.get(id);
8539
+ return row === null ? null : row.data;
8816
8540
  }
8817
8541
  /**
8818
- * List export rows, newest-first, optionally scoped to one device.
8542
+ * List export rows, newest-first (by `createdAt` / record `at`), optionally
8543
+ * scoped to one device.
8819
8544
  *
8820
- * Scoped to a device this is an INDEXED query on the projected `deviceId`
8821
- * column — not a parse of every export in the installation followed by a filter
8822
- * in JS.
8545
+ * Scoped to a device this is an INDEXED query on the `device` index — not a
8546
+ * parse of every export in the installation followed by a filter in JS.
8823
8547
  */
8824
8548
  async function listExportsFor(state, deviceId) {
8825
- return (await state.readWhere({
8549
+ return (await state.query({
8826
8550
  ...deviceId === void 0 ? {} : { where: { deviceId } },
8827
- orderBy: {
8828
- field: "createdAt",
8829
- direction: "desc"
8830
- },
8551
+ desc: true,
8831
8552
  limit: RECORDING_EXPORTS_LIMIT
8832
- })).map((entry) => entry.value);
8553
+ })).records.map((r) => r.data);
8833
8554
  }
8834
8555
  //#endregion
8835
8556
  //#region src/recorder/addon/export-timelapse-chunks.ts
@@ -11787,36 +11508,125 @@ function runFfmpeg(renderDeps, ffmpegPath, args, ext) {
11787
11508
  });
11788
11509
  }
11789
11510
  //#endregion
11511
+ //#region src/durable/durable-legacy-kind-handle.ts
11512
+ function notImpl(method) {
11513
+ throw new Error(`DurableLegacyKindHandle.${method}() is not available on the legacy (agent) path`);
11514
+ }
11515
+ function isRecord(value) {
11516
+ return typeof value === "object" && value !== null && !Array.isArray(value);
11517
+ }
11518
+ function toRecordRow(id, data) {
11519
+ return {
11520
+ id,
11521
+ data,
11522
+ deviceId: null,
11523
+ at: null,
11524
+ expiresAt: null,
11525
+ rev: 0,
11526
+ updatedAt: 0
11527
+ };
11528
+ }
11529
+ var DurableLegacyKindHandle = class {
11530
+ name;
11531
+ spec;
11532
+ store;
11533
+ constructor(opts) {
11534
+ this.name = opts.spec.collection;
11535
+ this.spec = opts.spec;
11536
+ this.store = opts.store;
11537
+ }
11538
+ async get(id) {
11539
+ const row = (await this.store.query.query({
11540
+ collection: this.spec.collection,
11541
+ filter: {
11542
+ where: { [this.pkColumn()]: id },
11543
+ limit: 1
11544
+ }
11545
+ }))[0];
11546
+ if (row === void 0 || !isRecord(row.data)) return null;
11547
+ const parsed = this.spec.fromRecord(row.id, row.data);
11548
+ if (parsed === null) return null;
11549
+ return toRecordRow(row.id, this.spec.toValue(parsed));
11550
+ }
11551
+ async query(q) {
11552
+ const rows = await this.store.query.query({
11553
+ collection: this.spec.collection,
11554
+ filter: { limit: q.limit ?? 1e4 }
11555
+ });
11556
+ const records = [];
11557
+ for (const row of rows) {
11558
+ if (!isRecord(row.data)) continue;
11559
+ const parsed = this.spec.fromRecord(row.id, row.data);
11560
+ if (parsed === null) continue;
11561
+ records.push(toRecordRow(row.id, this.spec.toValue(parsed)));
11562
+ }
11563
+ return {
11564
+ records,
11565
+ nextCursor: null
11566
+ };
11567
+ }
11568
+ async put(input) {
11569
+ await this.store.set.mutate({
11570
+ collection: this.spec.collection,
11571
+ key: input.id,
11572
+ value: input.data
11573
+ });
11574
+ return { rev: 0 };
11575
+ }
11576
+ async delete(id) {
11577
+ await this.store.delete.mutate({
11578
+ collection: this.spec.collection,
11579
+ key: id
11580
+ });
11581
+ return true;
11582
+ }
11583
+ getMany = () => notImpl("getMany");
11584
+ getResolved = () => notImpl("getResolved");
11585
+ putMany = () => notImpl("putMany");
11586
+ patch = () => notImpl("patch");
11587
+ deleteMany = () => notImpl("deleteMany");
11588
+ count = () => notImpl("count");
11589
+ histogram = () => notImpl("histogram");
11590
+ aggregate = () => notImpl("aggregate");
11591
+ deleteWhere = () => notImpl("deleteWhere");
11592
+ allocateId = () => notImpl("allocateId");
11593
+ pkColumn() {
11594
+ return this.spec.columns.find((c) => c.primaryKey === true)?.name ?? "id";
11595
+ }
11596
+ };
11597
+ /** Legacy settings-store handle for one {@link DurableLedgerSpec}. */
11598
+ function durableLegacyKind(spec, store) {
11599
+ return new DurableLegacyKindHandle({
11600
+ spec,
11601
+ store
11602
+ });
11603
+ }
11604
+ /** Agent boot: declare the SQLite collection behind a durable ledger spec. */
11605
+ function declareDurableLegacyCollection(store, spec) {
11606
+ return store.declareCollection.mutate({
11607
+ collection: spec.collection,
11608
+ columns: [...spec.columns],
11609
+ ...spec.indexes !== void 0 ? { indexes: [...spec.indexes] } : {}
11610
+ });
11611
+ }
11612
+ //#endregion
11790
11613
  //#region src/durable/durable-ledger.ts
11791
11614
  /** Default reseed cap — every current consumer's row set is installation-bounded. */
11792
11615
  var DEFAULT_LOAD_LIMIT = 1e5;
11793
- var DurableLedger = class DurableLedger {
11616
+ /** Page size for records reseed queries. */
11617
+ var QUERY_PAGE = 1e3;
11618
+ var DurableLedger = class {
11794
11619
  mirror = /* @__PURE__ */ new Map();
11795
11620
  /** Whether ANY load has read the store successfully — see {@link hasAnswered}. */
11796
11621
  answered = false;
11797
11622
  spec;
11798
- store;
11623
+ kind;
11799
11624
  logger;
11800
11625
  constructor(deps) {
11801
11626
  this.spec = deps.spec;
11802
- this.store = deps.store;
11627
+ this.kind = deps.kind;
11803
11628
  this.logger = deps.logger;
11804
11629
  }
11805
- /**
11806
- * Register the collection. MUST run at boot, before any read or write: the
11807
- * SQLite backend answers 412 for an undeclared collection and takes the whole
11808
- * runner down with it (the addon-ai boot-crash lesson).
11809
- */
11810
- static declare(store, spec) {
11811
- return store.declareCollection.mutate({
11812
- collection: spec.collection,
11813
- columns: [...spec.columns],
11814
- ...spec.indexes !== void 0 ? { indexes: [...spec.indexes] } : {}
11815
- });
11816
- }
11817
- declare() {
11818
- return DurableLedger.declare(this.store, this.spec);
11819
- }
11820
11630
  /** The collection this ledger owns — for the caller's own log lines. */
11821
11631
  get collection() {
11822
11632
  return this.spec.collection;
@@ -11833,31 +11643,43 @@ var DurableLedger = class DurableLedger {
11833
11643
  */
11834
11644
  async load() {
11835
11645
  try {
11836
- const records = await this.store.query.query({
11837
- collection: this.spec.collection,
11838
- filter: { limit: this.spec.loadLimit ?? DEFAULT_LOAD_LIMIT }
11839
- });
11840
11646
  const next = /* @__PURE__ */ new Map();
11841
11647
  let skipped = 0;
11842
- for (const record of records) {
11843
- const row = this.spec.fromRecord(record.id, record.data);
11844
- if (row === null) {
11845
- skipped += 1;
11846
- continue;
11648
+ let seen = 0;
11649
+ const cap = this.spec.loadLimit ?? DEFAULT_LOAD_LIMIT;
11650
+ let cursor;
11651
+ for (;;) {
11652
+ const limit = Math.min(QUERY_PAGE, cap - seen);
11653
+ if (limit <= 0) break;
11654
+ const page = await this.kind.query({
11655
+ limit,
11656
+ ...cursor === void 0 ? {} : { cursor }
11657
+ });
11658
+ seen += page.records.length;
11659
+ for (const record of page.records) {
11660
+ const row = this.spec.fromRecord(record.id, record.data);
11661
+ if (row === null) {
11662
+ skipped += 1;
11663
+ continue;
11664
+ }
11665
+ next.set(this.spec.keyOf(row), row);
11847
11666
  }
11848
- next.set(this.spec.keyOf(row), row);
11667
+ if (page.nextCursor === null) break;
11668
+ cursor = page.nextCursor;
11849
11669
  }
11850
11670
  this.mirror.clear();
11851
11671
  for (const [key, row] of next) this.mirror.set(key, row);
11852
11672
  this.answered = true;
11853
11673
  if (skipped > 0) this.logger.warn("durable rows skipped as malformed — they gate NOTHING", { meta: {
11854
11674
  collection: this.spec.collection,
11675
+ kind: this.kind.name,
11855
11676
  skipped
11856
11677
  } });
11857
11678
  return [...this.mirror.values()];
11858
11679
  } catch (err) {
11859
11680
  this.logger.warn("durable load failed — keeping the state already in memory", { meta: {
11860
11681
  collection: this.spec.collection,
11682
+ kind: this.kind.name,
11861
11683
  error: String(err),
11862
11684
  held: this.mirror.size
11863
11685
  } });
@@ -11922,15 +11744,15 @@ var DurableLedger = class DurableLedger {
11922
11744
  */
11923
11745
  async put(row) {
11924
11746
  const key = this.spec.keyOf(row);
11747
+ const body = this.spec.toValue(row);
11925
11748
  if (this.spec.writeMode === "write-behind") {
11926
11749
  this.mirror.set(key, row);
11927
- await this.persist(row);
11750
+ await this.persistRow(key, body);
11928
11751
  return;
11929
11752
  }
11930
- await this.store.set.mutate({
11931
- collection: this.spec.collection,
11932
- key,
11933
- value: this.spec.toValue(row)
11753
+ await this.kind.put({
11754
+ id: key,
11755
+ data: body
11934
11756
  });
11935
11757
  this.mirror.set(key, row);
11936
11758
  }
@@ -11971,13 +11793,11 @@ var DurableLedger = class DurableLedger {
11971
11793
  async forget(key) {
11972
11794
  this.mirror.delete(key);
11973
11795
  try {
11974
- await this.store.delete.mutate({
11975
- collection: this.spec.collection,
11976
- key
11977
- });
11796
+ await this.kind.delete(key);
11978
11797
  } catch (err) {
11979
11798
  this.logger.debug("durable delete failed", { meta: {
11980
11799
  collection: this.spec.collection,
11800
+ kind: this.kind.name,
11981
11801
  key,
11982
11802
  error: String(err)
11983
11803
  } });
@@ -12013,15 +11833,13 @@ var DurableLedger = class DurableLedger {
12013
11833
  for (const key of [...this.mirror.keys()]) {
12014
11834
  if (activeKeys.has(key)) continue;
12015
11835
  try {
12016
- await this.store.delete.mutate({
12017
- collection: this.spec.collection,
12018
- key
12019
- });
11836
+ await this.kind.delete(key);
12020
11837
  this.mirror.delete(key);
12021
11838
  pruned += 1;
12022
11839
  } catch (err) {
12023
11840
  this.logger.debug("durable prune delete failed", { meta: {
12024
11841
  collection: this.spec.collection,
11842
+ kind: this.kind.name,
12025
11843
  key,
12026
11844
  error: String(err)
12027
11845
  } });
@@ -12034,25 +11852,34 @@ var DurableLedger = class DurableLedger {
12034
11852
  * decision path: the mirror already holds the truth for this process, and the
12035
11853
  * worst a lost write can do is one silent re-seed after the next restart.
12036
11854
  */
12037
- async persist(row) {
12038
- const deviceId = this.spec.deviceIdOf?.(row);
11855
+ persist(row) {
11856
+ const key = this.spec.keyOf(row);
11857
+ this.persistRow(key, this.spec.toValue(row));
11858
+ }
11859
+ async persistRow(key, body) {
11860
+ const deviceId = this.deviceIdFromBody(body);
12039
11861
  try {
12040
- await this.store.set.mutate({
12041
- collection: this.spec.collection,
12042
- key: this.spec.keyOf(row),
12043
- value: this.spec.toValue(row)
11862
+ await this.kind.put({
11863
+ id: key,
11864
+ data: body
12044
11865
  });
12045
11866
  } catch (err) {
12046
11867
  this.logger.warn("durable persist failed — this key may re-seed on boot", {
12047
11868
  ...deviceId !== void 0 ? { tags: { deviceId } } : {},
12048
11869
  meta: {
12049
11870
  collection: this.spec.collection,
12050
- key: this.spec.keyOf(row),
11871
+ kind: this.kind.name,
11872
+ key,
12051
11873
  error: String(err)
12052
11874
  }
12053
11875
  });
12054
11876
  }
12055
11877
  }
11878
+ deviceIdFromBody(body) {
11879
+ const raw = body["deviceId"];
11880
+ const n = typeof raw === "number" ? raw : Number(raw);
11881
+ return Number.isFinite(n) ? n : void 0;
11882
+ }
12056
11883
  };
12057
11884
  //#endregion
12058
11885
  //#region src/recorder/addon/segment-hour-ledger.ts
@@ -12613,15 +12440,15 @@ var SegmentHourLedger = class SegmentHourLedger {
12613
12440
  this.rowCap = deps.loadPacing?.rowCap ?? 25e4;
12614
12441
  this.ledger = new DurableLedger({
12615
12442
  spec: SPEC,
12616
- store: deps.store,
12443
+ kind: durableLegacyKind(SPEC, deps.store),
12617
12444
  logger: deps.logger
12618
12445
  });
12619
12446
  }
12620
12447
  static declare(store) {
12621
- return DurableLedger.declare(store, SPEC);
12448
+ return declareDurableLegacyCollection(store, SPEC);
12622
12449
  }
12623
12450
  declare() {
12624
- return this.ledger.declare();
12451
+ return declareDurableLegacyCollection(this.store, SPEC);
12625
12452
  }
12626
12453
  /**
12627
12454
  * Boot load — every hour row, in one query.
@@ -15032,63 +14859,61 @@ var LocationRefresher = class {
15032
14859
  }
15033
14860
  }
15034
14861
  };
15035
- var RECORDING_OPS_LOG_SPEC = {
15036
- collection: "recorder:ops-log",
15037
- schema: OpsLogEntrySchema,
15038
- columns: [{
15039
- name: "at",
15040
- type: "INTEGER",
15041
- notNull: true
15042
- }, {
15043
- name: "deviceId",
15044
- type: "INTEGER"
15045
- }],
15046
- indexes: [{
15047
- name: "idx_recorder_ops_log_at",
15048
- columns: ["at"]
15049
- }, {
15050
- name: "idx_recorder_ops_log_device",
15051
- columns: ["deviceId"]
15052
- }],
15053
- project: (_key, value) => ({
15054
- at: value.at,
15055
- deviceId: value.deviceId
15056
- }),
15057
- deviceIdColumn: "deviceId",
15058
- loadLimit: 2e4
15059
- };
15060
- /** Build the handle. `declare()` MUST run once at boot before any read/write. */
15061
- function recordingOpsLogState(store, logger) {
15062
- return new RowMapStore({
15063
- spec: RECORDING_OPS_LOG_SPEC,
15064
- store,
15065
- logger
15066
- });
14862
+ //#endregion
14863
+ //#region src/recorder/addon/ops-log-store.ts
14864
+ /**
14865
+ * Recordings ops-log persistence for the recorder addon.
14866
+ *
14867
+ * ONE ROW PER OPERATION in the `recorder` / `ops-log` record kind, through a
14868
+ * {@link RecordKindHandle}. The ring is bounded — `maxEntries` newest rows
14869
+ * survive — trimmed with one `deleteWhere({ atBefore })` per append.
14870
+ *
14871
+ * ## Records migration (phase 2, step 26)
14872
+ *
14873
+ * The store was previously backed by a {@link RowMapStore} over a SettingsStore
14874
+ * collection named `recorder:ops-log`. It is now backed by a
14875
+ * `RecordKindHandle<OpsLogEntry>` obtained from `ctx.records`. The record's
14876
+ * `at` field maps to `OpsLogEntry.at`.
14877
+ *
14878
+ * ## What the RowMapStore replaced
14879
+ *
14880
+ * The ring lived in ONE key of the recorder's `addon-settings` / `root` blob,
14881
+ * as a flat JSON array. On the reference hub it was 147 437 bytes of that row's
14882
+ * 201 833 — so appending one audit line read, parsed, re-serialised and rewrote
14883
+ * ~200 KB, and so did every unrelated reader of that row. The ops log is the
14884
+ * CHEAPEST thing in the recorder and it was paying for the most expensive.
14885
+ *
14886
+ * Writes stay BEST-EFFORT: `createRecordingOpsLogSink().append` never throws —
14887
+ * a failed audit write must not fail the operation it records.
14888
+ */
14889
+ /**
14890
+ * Obtain the `recorder` / `ops-log` record kind handle.
14891
+ * `ctx.records.declare(recorderRecordKinds)` MUST have run before any read or
14892
+ * write (called at boot in `onInitialize`).
14893
+ */
14894
+ function recordingOpsLogState(records) {
14895
+ return records.kind(recorderRecordKinds["ops-log"]);
15067
14896
  }
15068
14897
  /**
15069
14898
  * Trim the ring to the newest `maxEntries` rows. Returns how many went.
15070
14899
  *
15071
14900
  * The cursor is the OLDEST row that must SURVIVE — offset `maxEntries - 1`
15072
- * newest-first — and the delete is strictly older than it (`[0, at - 1]`).
14901
+ * newest-first — and the delete removes everything strictly BEFORE its `at`
14902
+ * (`atBefore: cursor.at` deletes rows where `at < cursor.at`).
15073
14903
  *
15074
- * Strictly, because `whereBetween` is inclusive and the filter surface has no
15075
- * `<`: including the cursor's own millisecond would take every row tied on it,
15076
- * and a ring whose rows all shared one millisecond would be deleted whole.
15077
- * Erring this way keeps a few EXTRA audit rows on a tie and never fewer than
15078
- * the cap.
14904
+ * Strictly, to avoid dropping the cursor row itself and any rows tied on its
14905
+ * millisecond. The ring may hold a few MORE than the cap on a millisecond tie
14906
+ * and never FEWER.
15079
14907
  */
15080
14908
  async function trimOpsLog(state, maxEntries = 500) {
15081
- if (await state.count() <= maxEntries) return 0;
15082
- const oldestKept = (await state.readWhere({
15083
- orderBy: {
15084
- field: "at",
15085
- direction: "desc"
15086
- },
15087
- limit: 1,
15088
- offset: maxEntries - 1
15089
- }))[0];
15090
- if (oldestKept === void 0) return 0;
15091
- return state.deleteWhere({ whereBetween: { at: [0, oldestKept.value.at - 1] } });
14909
+ if (await state.count({}) <= maxEntries) return 0;
14910
+ const page = await state.query({
14911
+ desc: true,
14912
+ limit: maxEntries
14913
+ });
14914
+ const oldest = page.records[page.records.length - 1];
14915
+ if (oldest === void 0) return 0;
14916
+ return state.deleteWhere({ atBefore: oldest.at ?? 0 });
15092
14917
  }
15093
14918
  /**
15094
14919
  * Append one row and persist, then trim the ring. Validates at the boundary.
@@ -15096,7 +14921,11 @@ async function trimOpsLog(state, maxEntries = 500) {
15096
14921
  */
15097
14922
  async function appendOpsLog(state, entry, maxEntries = 500) {
15098
14923
  const validated = OpsLogEntrySchema.parse(entry);
15099
- await state.write(validated.id, validated);
14924
+ await state.put({
14925
+ id: validated.id,
14926
+ at: validated.at,
14927
+ data: validated
14928
+ });
15100
14929
  await trimOpsLog(state, maxEntries);
15101
14930
  return validated;
15102
14931
  }
@@ -15104,18 +14933,15 @@ async function appendOpsLog(state, entry, maxEntries = 500) {
15104
14933
  * List ops-log rows newest-first, optionally scoped to one device, capped at
15105
14934
  * `limit` (default {@link OPS_LOG_DEFAULT_LIMIT}).
15106
14935
  *
15107
- * Scoped to a device this is an INDEXED query, not a read of the whole ring
15108
- * followed by a filter and a sort in JS.
14936
+ * Scoped to a device this is an INDEXED query on the `device` index — not a
14937
+ * read of the whole ring followed by a filter and a sort in JS.
15109
14938
  */
15110
14939
  async function listOpsLog(state, query) {
15111
- return (await state.readWhere({
14940
+ return (await state.query({
15112
14941
  ...query.deviceId === void 0 ? {} : { where: { deviceId: query.deviceId } },
15113
- orderBy: {
15114
- field: "at",
15115
- direction: "desc"
15116
- },
14942
+ desc: true,
15117
14943
  limit: query.limit ?? 200
15118
- })).map((entry) => entry.value);
14944
+ })).records.map((r) => r.data);
15119
14945
  }
15120
14946
  /**
15121
14947
  * Build the recorder's ops-log sink. `append` stamps `domain:'recording'`,
@@ -23573,7 +23399,6 @@ var RecorderV2Addon = class extends BaseAddon {
23573
23399
  readCensus = new StoreReadCensus();
23574
23400
  /** Memoised so every `configStore()` / `exportsStore()` / `opsLogStore()`
23575
23401
  * call shares ONE census instead of each wrapping the raw client afresh. */
23576
- censusedSettingsStoreClient = null;
23577
23402
  constructor() {
23578
23403
  super({ ...DEFAULT_CONFIG });
23579
23404
  }
@@ -23610,6 +23435,9 @@ var RecorderV2Addon = class extends BaseAddon {
23610
23435
  } });
23611
23436
  return { providers: [] };
23612
23437
  }
23438
+ const records = this.ctx.records;
23439
+ if (records === void 0) throw new Error("recorder (recording node) needs ctx.records — recording node must be the hub");
23440
+ await records.declare(recorderRecordKinds);
23613
23441
  await this.declareRowCollections();
23614
23442
  this.segmentHours = new SegmentHourLedger({
23615
23443
  store: this.ctx.api.settingsStore,
@@ -25430,26 +25258,16 @@ var RecorderV2Addon = class extends BaseAddon {
25430
25258
  } });
25431
25259
  }
25432
25260
  /**
25433
- * Declare the three row collections that replaced the `recordingConfigs`,
25434
- * `recordingExports` and `recordingOpsLog` blob keys. Idempotent — a
25435
- * re-declaration of the same shape is a no-op in the engine.
25261
+ * Declare the remaining RowMapStore collection that replaced the
25262
+ * `recordingConfigs` blob key. Idempotent — a re-declaration of the same
25263
+ * shape is a no-op in the engine.
25264
+ *
25265
+ * `recorder:exports` and `recorder:ops-log` are now record kinds declared
25266
+ * via `ctx.records.declare(recorderRecordKinds)` at the top of
25267
+ * `onInitialize`.
25436
25268
  */
25437
25269
  async declareRowCollections() {
25438
25270
  await this.configStore().declare();
25439
- await this.exportsStore().declare();
25440
- await this.opsLogStore().declare();
25441
- }
25442
- /**
25443
- * `ctx.api.settingsStore`, wrapped ONCE through {@link censusStoreClient}.
25444
- *
25445
- * LAZY (see `calendar` above): `ctx` is not assigned until `initialize()`
25446
- * runs, so this cannot be a field initialiser. The wrap is permanent and
25447
- * cheap when disarmed (one `get` trap and a comparison) — what is armed and
25448
- * disarmed is `this.readCensus`, through `debug.storeCensus`, not this seam.
25449
- */
25450
- censusedSettingsStore() {
25451
- this.censusedSettingsStoreClient ??= censusStoreClient(this.ctx.api.settingsStore, this.readCensus);
25452
- return this.censusedSettingsStoreClient;
25453
25271
  }
25454
25272
  configsState = null;
25455
25273
  /** A camera's recording config — its per-device settings (D865), migrated on read. */
@@ -25462,14 +25280,23 @@ var RecorderV2Addon = class extends BaseAddon {
25462
25280
  });
25463
25281
  return this.configsState;
25464
25282
  }
25465
- /** Durable handle over the exportId→ExportRecord blob (same `BaseAddon.state()`
25466
- * wrapper as the config store). */
25283
+ /** Record kind handle over the `recorder` / `exports` kind (hub recording node only). */
25467
25284
  exportsStore() {
25468
- return recordingExportsState(this.censusedSettingsStore(), this.ctx.logger);
25285
+ return recordingExportsState(this.requireRecords());
25469
25286
  }
25470
- /** Durable handle over the bounded recordings ops-log ring. */
25287
+ /** Record kind handle over the `recorder` / `ops-log` kind (hub recording node only). */
25471
25288
  opsLogStore() {
25472
- return recordingOpsLogState(this.censusedSettingsStore(), this.ctx.logger);
25289
+ return recordingOpsLogState(this.requireRecords());
25290
+ }
25291
+ /**
25292
+ * Returns `ctx.records`, throwing clearly when it is absent.
25293
+ * Only called on the designated recording node (hub by default); an agent
25294
+ * exits before `onInitialize` reaches any store call.
25295
+ */
25296
+ requireRecords() {
25297
+ const records = this.ctx.records;
25298
+ if (records === void 0) throw new Error("recorder (recording node) needs ctx.records — recording node must be the hub");
25299
+ return records;
25473
25300
  }
25474
25301
  /** Durable handle over the recorder-owned placement blob (assignments + volume ids). */
25475
25302
  placementStore() {