@camstack/addon-post-analysis 1.2.152 → 1.2.153

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,4 +1,4 @@
1
- import { $ as audioModeOf, A as NcSnoozeSchema, At as BaseAddon, B as SCENE_DEFAULT_UNCOVERED_POLICY, Bt as discriminatedUnion, C as NcConditionDescriptorSchema, Ct as sceneMonitorCapability, D as NcRuleTargetSchema, Dt as videoclipsCapability, E as NcRuleSchema, Et as vectorDimFromBase64, Ft as nodePin, G as TimelapseRulePatchSchema, Gt as record, H as SceneMonitorSchema, Ht as number, I as RECORDING_EXPORT_MAX_READ_BYTES, It as sleep, J as VISIT_MERGE_GAP_MS, Jt as EventCategory, K as TimelapseRuleSchema, Kt as string, L as RetrainStatusSchema, Lt as _enum, M as NcSystemEventKindSchema, Mt as createEvent, N as NcTaxonomySchema, Nt as hydrateSchema, O as NcScheduleSchema, Ot as zoneAnalyticsCapability, P as OpsLogEntrySchema, Pt as isDeviceScopedCap, Q as audioMetricsCapability, Rt as array, S as NC_TAXONOMY, T as NcRulePatchSchema, Tt as systemEventFilterApplies, U as TIMELAPSE_DENSE_FLOOR_SEC, Ut as object, V as SCENE_DIVERGED, Vt as literal, W as TimelapseRuleInputSchema, Wt as partialRecord, X as alarmPanelCapability, Y as addonWidgetsSourceCapability, Z as assertTimelapseCadences, _ as MACRO_LABELS, _t as pickClusterStepModels, a as COCO_TO_MACRO, b as NC_DEFAULT_SNOOZE_MINUTES, bt as readDeviceStateFrom, ct as faceGalleryCapability, d as DeclaredDevices, dt as isDetectionMacroClass, et as buildEventKindDescriptor, f as EVENT_KIND_BY_CAP, ft as isScheduleActive, g as LabelAttributionSchema, h as FailureCounters, ht as notificationRulesCapability, i as CLUSTER_MODEL_SCOPED_STEPS, it as deriveRecordingMode, j as NcSnoozeSuppressedSchema, jt as DeviceType, k as NcSnoozeInputSchema, kt as errMsg, l as DETECTION_MACRO_CLASSES, lt as failureContributionCapability, m as FULL_IMAGE_BBOX, mt as kebabToCamel, n as AnalyticsGroupRecordSchema, nt as customAction, o as DEFAULT_EVENT_COLOR, ot as encodeVectorBase64, p as EVENT_PAD_MS, pt as isSourceCap, q as TrackSourceSchema, qt as unknown, r as BaseDevice, rt as defineCustomActions, st as evaluateSensorEdge, t as AnalyticsGroupDetailSchema, tt as cosineSimilarity$1, v as NC_ALARM_SYSTEM_EVENT_KINDS, vt as pipelineAnalyticsCapability, w as NcRuleInputSchema, wt as subKindsOf, xt as readTimelapseGeneratedAt, y as NC_CONDITION_CATALOG, yt as plateGalleryCapability, z as SCENE_DEFAULT_ANCHOR_THRESHOLD, zt as boolean } from "../dist-DSg_jDMt.mjs";
1
+ import { $ as audioMetricsCapability, A as NcSnoozeInputSchema, At as errMsg, B as SCENE_DEFAULT_ANCHOR_THRESHOLD, Bt as boolean, C as NC_TAXONOMY, D as NcRuleSchema, Dt as vectorDimFromBase64, E as NcRulePatchSchema, Et as systemEventFilterApplies, F as OpsLogEntrySchema, Ft as isDeviceScopedCap, G as TimelapseRuleInputSchema, Gt as partialRecord, H as SCENE_DIVERGED, Ht as literal, It as nodePin, J as TrackSourceSchema, Jt as unknown, K as TimelapseRulePatchSchema, Kt as record, L as RECORDING_EXPORT_MAX_READ_BYTES, Lt as sleep, M as NcSnoozeSuppressedSchema, Mt as DeviceType, N as NcSystemEventKindSchema, Nt as createEvent, O as NcRuleTargetSchema, Ot as videoclipsCapability, P as NcTaxonomySchema, Pt as hydrateSchema, Q as assertTimelapseCadences, R as RetrainStatusSchema, Rt as _enum, St as readTimelapseGeneratedAt, T as NcRuleInputSchema, Tt as subKindsOf, U as SceneMonitorSchema, Ut as number, V as SCENE_DEFAULT_UNCOVERED_POLICY, Vt as discriminatedUnion, W as TIMELAPSE_DENSE_FLOOR_SEC, Wt as object, X as addonWidgetsSourceCapability, Y as VISIT_MERGE_GAP_MS, Yt as EventCategory, Z as alarmPanelCapability, _ as MACRO_LABELS, a as COCO_TO_MACRO, at as deriveRecordingMode, b as NC_CONDITION_CATALOG, bt as plateGalleryCapability, ct as evaluateSensorEdge, d as DeclaredDevices, et as audioModeOf, f as EVENT_KIND_BY_CAP, ft as isDetectionMacroClass, g as LabelAttributionSchema, gt as notificationRulesCapability, h as FailureCounters, ht as kebabToCamel, i as CLUSTER_MODEL_SCOPED_STEPS, it as defineCustomActions, j as NcSnoozeSchema, jt as BaseAddon, k as NcScheduleSchema, kt as zoneAnalyticsCapability, l as DETECTION_MACRO_CLASSES, lt as faceGalleryCapability, m as FULL_IMAGE_BBOX, mt as isSourceCap, n as AnalyticsGroupRecordSchema, nt as cosineSimilarity$1, o as DEFAULT_EVENT_COLOR, p as EVENT_PAD_MS, pt as isScheduleActive, q as TimelapseRuleSchema, qt as string, r as BaseDevice, rt as customAction, st as encodeVectorBase64, t as AnalyticsGroupDetailSchema, tt as buildEventKindDescriptor, ut as failureContributionCapability, v as MediaFileKindEnum, vt as pickClusterStepModels, w as NcConditionDescriptorSchema, wt as sceneMonitorCapability, x as NC_DEFAULT_SNOOZE_MINUTES, xt as readDeviceStateFrom, y as NC_ALARM_SYSTEM_EVENT_KINDS, yt as pipelineAnalyticsCapability, zt as array } from "../dist-DDIu0Q1W.mjs";
2
2
  import { t as __exportAll } from "../embedding-encoder/index.mjs";
3
3
  import * as fs from "node:fs";
4
4
  import { promises } from "node:fs";
@@ -25672,6 +25672,67 @@ var MEDIA_INDEXES = [
25672
25672
  {
25673
25673
  name: "idx_media_location",
25674
25674
  columns: ["locationId"]
25675
+ }),
25676
+ (
25677
+ /**
25678
+ * The events-management footprint — `GROUP BY deviceId` and
25679
+ * `SUM(sizeBytes) WHERE deviceId = ?`. See {@link MediaStore.footprintByDevice}.
25680
+ *
25681
+ * **Why not `idx_media_device_ts`.** That index already leads with
25682
+ * `deviceId`, so it can find the range — but it does not carry `sizeBytes`,
25683
+ * so the sum has to fetch the table row behind every index entry it visits.
25684
+ * Over the whole collection that is one scattered read of the entire ~1.25 M
25685
+ * row table (`path` alone is ~80 characters per row) on the volume the
25686
+ * recorder is writing to. With `sizeBytes` in the index the planner never
25687
+ * touches the table: `SCAN … USING COVERING INDEX idx_media_device_size`.
25688
+ *
25689
+ * It has to be a NEW index rather than a third column on the existing one.
25690
+ * `declareCollection` emits `CREATE INDEX IF NOT EXISTS` keyed on the index
25691
+ * NAME, so widening `idx_media_device_ts` in place would be a silent no-op on
25692
+ * every hub that already has it — the declaration would read as done and the
25693
+ * query would still hit the table.
25694
+ *
25695
+ * **What it costs.** ~19 bytes of index per row (two integers plus the
25696
+ * rowid), so ~24 MB at 1.25 M rows, plus one more b-tree maintained by every
25697
+ * insert into a table taking ~92 k rows/day. At ~1 insert/s that maintenance
25698
+ * is noise beside the JPEG write the same call performs. The cost that is not
25699
+ * noise is the one-off: the first `declareCollection` after this ships builds
25700
+ * the index over every existing row. Do not deploy it beside a running drain
25701
+ * or seal — the same caveat `idx_media_location` carries, for the same
25702
+ * reason.
25703
+ */
25704
+ {
25705
+ name: "idx_media_device_size",
25706
+ columns: ["deviceId", "sizeBytes"]
25707
+ }),
25708
+ (
25709
+ /**
25710
+ * The per-KIND footprint — `COUNT(*)`/`SUM(sizeBytes) WHERE kind = ?`, and
25711
+ * the same scoped to one camera. See {@link MediaStore.footprintByKind}.
25712
+ *
25713
+ * `kind` leads no existing index, so without this each of the thirteen
25714
+ * per-kind questions is a full table scan and the breakdown costs thirteen of
25715
+ * them. With it, each kind is a contiguous covering range and the thirteen
25716
+ * together scan the index once — the cost of the whole breakdown is one pass
25717
+ * over ~36 MB of index instead of thirteen passes over the table.
25718
+ *
25719
+ * `deviceId` sits between `kind` and `sizeBytes` so the per-camera variant
25720
+ * (`WHERE kind = ? AND deviceId = ?`) stays covering too, for ~3 bytes a row
25721
+ * more than `(kind, sizeBytes)` would cost. Without it that query finds its
25722
+ * range from the index and then reads the table for every row in it.
25723
+ *
25724
+ * ~29 bytes of index per row, so ~36 MB at 1.25 M rows, and one more b-tree
25725
+ * on every insert. Same one-off caveat as the two indexes above: the first
25726
+ * declare builds it over every existing row, so do not ship it beside a
25727
+ * running drain or seal.
25728
+ */
25729
+ {
25730
+ name: "idx_media_kind_device_size",
25731
+ columns: [
25732
+ "kind",
25733
+ "deviceId",
25734
+ "sizeBytes"
25735
+ ]
25675
25736
  })
25676
25737
  ];
25677
25738
  /** The storage location of a media row/record: its stamped `locationId`, or
@@ -26326,47 +26387,136 @@ var MediaStore = class {
26326
26387
  };
26327
26388
  }
26328
26389
  /**
26329
- * On-demand footprint aggregation for the events-management UI: sum
26330
- * `sizeBytes` and count rows per device across the whole media collection.
26331
- * Pages by `id` (offset-based) so a large collection never loads at once.
26332
- * Metadata rows are small (no blob) — the image bytes live on the storage
26333
- * provider and are counted via the persisted `sizeBytes` column.
26390
+ * On-demand footprint aggregation for the events-management UI: rows and
26391
+ * summed `sizeBytes` per camera, across the whole media collection.
26392
+ *
26393
+ * ## Why this is a histogram plus one aggregate per camera
26394
+ *
26395
+ * It used to page the ENTIRE collection — `ORDER BY id LIMIT 1000 OFFSET n`,
26396
+ * no predicate — and add `sizeBytes` up in JS. SQLite honours an OFFSET by
26397
+ * stepping over `n` entries, so the walk is quadratic in the table: at the
26398
+ * live hub's 1.25 M rows that is ~1 250 round trips and ~780 M index steps,
26399
+ * with every row serialised across an RPC boundary and rebuilt in V8 to be
26400
+ * added to a number. It timed out at 60 s on every call it ever made, and it
26401
+ * is the only thing that can size the surplus-media deletion it gates.
26402
+ *
26403
+ * The answer is at most a few dozen `(deviceId, rows, bytes)` triples.
26404
+ *
26405
+ * - The DEVICE SET comes from a histogram on `deviceId` with `bucketSize: 1`
26406
+ * — `GROUP BY deviceId`, served from `idx_media_device_size` as a covering
26407
+ * scan, the same trick `TrackStore.listDeviceIds` already uses (D56).
26408
+ * `bucket` IS the deviceId: the column is INTEGER and the bucket
26409
+ * expression is `CAST((deviceId - 0) / 1 AS INTEGER)`.
26410
+ * - The NUMBERS come from one `aggregate` per camera — `COUNT(*)` and
26411
+ * `SUM(sizeBytes)` in a single statement over an indexed `deviceId` range.
26412
+ *
26413
+ * Both figures for a camera come from the SAME statement deliberately. Taking
26414
+ * `rows` from the histogram and `bytes` from the aggregate would let a write
26415
+ * landing between the two produce a footprint whose row count and byte count
26416
+ * describe different populations — a disagreement an operator sizing a
26417
+ * deletion has no way to detect.
26418
+ *
26419
+ * A read that fails THROWS. It must never degrade to a zero: "this camera
26420
+ * holds nothing" is precisely the claim a failed measurement must not be
26421
+ * allowed to make to something about to delete (D295, D312).
26334
26422
  */
26335
26423
  async footprintByDevice() {
26336
- const PAGE = 1e3;
26424
+ const buckets = await this.store.histogram.query({
26425
+ collection: MEDIA_COLLECTION,
26426
+ field: "deviceId",
26427
+ bucketSize: 1,
26428
+ origin: 0
26429
+ });
26337
26430
  const out = /* @__PURE__ */ new Map();
26338
- let offset = 0;
26339
- for (;;) {
26340
- const rows = await this.store.query.query({
26431
+ for (const bucket of buckets) {
26432
+ if (bucket.count === 0 || !Number.isFinite(bucket.bucket)) continue;
26433
+ const deviceId = bucket.bucket;
26434
+ const agg = await this.store.aggregate.query({
26341
26435
  collection: MEDIA_COLLECTION,
26342
- filter: {
26343
- orderBy: {
26344
- field: "id",
26345
- direction: "asc"
26346
- },
26347
- limit: PAGE,
26348
- offset
26349
- }
26436
+ fields: [{
26437
+ as: "bytes",
26438
+ field: "sizeBytes",
26439
+ op: "sum"
26440
+ }],
26441
+ filter: { where: { deviceId } }
26442
+ });
26443
+ if (agg.count === 0) continue;
26444
+ const bytes = agg.values["bytes"];
26445
+ out.set(deviceId, {
26446
+ bytes: bytes ?? 0,
26447
+ rows: agg.count
26350
26448
  });
26351
- if (rows.length === 0) break;
26352
- for (const row of rows) {
26353
- const data = row.data;
26354
- const deviceId = Number(data["deviceId"]);
26355
- const sizeBytes = Number(data["sizeBytes"]);
26356
- if (!Number.isFinite(deviceId)) continue;
26357
- const acc = out.get(deviceId) ?? {
26358
- bytes: 0,
26359
- rows: 0
26360
- };
26361
- acc.bytes += Number.isFinite(sizeBytes) ? sizeBytes : 0;
26362
- acc.rows += 1;
26363
- out.set(deviceId, acc);
26364
- }
26365
- if (rows.length < PAGE) break;
26366
- offset += PAGE;
26367
26449
  }
26368
26450
  return out;
26369
26451
  }
26452
+ /**
26453
+ * The media footprint broken down by KIND — fleet-wide, or for one camera.
26454
+ *
26455
+ * `footprintByDevice` answers "how much is where"; this answers "how much is
26456
+ * WHAT", which is the axis a deletion decision turns on. The deletable set
26457
+ * (the periodic `snapshot` filmstrip, the surplus per-edge motion stills) and
26458
+ * the keep set (`firstFrame`, rolling `lastFrame`, `thumbnail` /
26459
+ * `thumbnailSmall`, `keyFrame` / `keyFrameSmall`, the face and plate buffers,
26460
+ * gallery media, the CLIP `crop`) are told apart by `kind` and by nothing
26461
+ * else. A byte total cannot size a deletion; this can.
26462
+ *
26463
+ * One `aggregate` per kind, each an indexed covering range on
26464
+ * `idx_media_kind_device_size`, plus ONE unfiltered aggregate for the totals.
26465
+ *
26466
+ * That last statement is the point of the design and not a spare round trip.
26467
+ * `MediaFileKindEnum.options` is the closed set the writers use, but the
26468
+ * TABLE is not closed: a row written by a retired path, or by a version that
26469
+ * knew a kind this one does not, matches no per-kind query. Adding the kinds
26470
+ * up to produce the total would make such a row invisible and the denominator
26471
+ * quietly too small — the failure mode that matters most here, because the
26472
+ * caller is about to delete against it. So the total is measured
26473
+ * independently and the difference is REPORTED as `unaccounted*` rather than
26474
+ * being absorbed.
26475
+ */
26476
+ async footprintByKind(deviceId) {
26477
+ const scope = deviceId === void 0 ? void 0 : { where: { deviceId } };
26478
+ const fields = [{
26479
+ as: "bytes",
26480
+ field: "sizeBytes",
26481
+ op: "sum"
26482
+ }];
26483
+ const totals = await this.store.aggregate.query({
26484
+ collection: MEDIA_COLLECTION,
26485
+ fields: [...fields],
26486
+ ...scope === void 0 ? {} : { filter: scope }
26487
+ });
26488
+ const kinds = [];
26489
+ let countedRows = 0;
26490
+ let countedBytes = 0;
26491
+ for (const kind of MediaFileKindEnum.options) {
26492
+ const agg = await this.store.aggregate.query({
26493
+ collection: MEDIA_COLLECTION,
26494
+ fields: [...fields],
26495
+ filter: { where: {
26496
+ kind,
26497
+ ...deviceId === void 0 ? {} : { deviceId }
26498
+ } }
26499
+ });
26500
+ if (agg.count === 0) continue;
26501
+ const bytes = agg.values["bytes"] ?? 0;
26502
+ countedRows += agg.count;
26503
+ countedBytes += bytes;
26504
+ kinds.push({
26505
+ kind,
26506
+ rows: agg.count,
26507
+ bytes
26508
+ });
26509
+ }
26510
+ kinds.sort((a, b) => b.bytes - a.bytes);
26511
+ const totalBytes = totals.values["bytes"] ?? 0;
26512
+ return {
26513
+ totalRows: totals.count,
26514
+ totalBytes,
26515
+ kinds,
26516
+ unaccountedRows: totals.count - countedRows,
26517
+ unaccountedBytes: totalBytes - countedBytes
26518
+ };
26519
+ }
26370
26520
  /** Move a single media entry from one owner to another.
26371
26521
  * The new copy is written before the old one is removed.
26372
26522
  * The old-entry deletion is best-effort: on failure a warning is logged
@@ -36978,10 +37128,34 @@ var AnalyticsQueryFacade = class {
36978
37128
  }
36979
37129
  /**
36980
37130
  * Durable event-store footprint for the events-management UI: event ROWS
36981
- * (motion + object + audio) counted per camera (indexed `count`) + total, and
36982
- * event-owned media BYTES on disk per camera (paged `sizeBytes` sum) + total.
36983
- * The device set is the union of cameras that have media OR persisted tracks;
36984
- * cameras with neither rows nor bytes are omitted.
37131
+ * (motion + object + audio) per camera + total, and event-owned media BYTES
37132
+ * on disk per camera + total. Cameras with neither rows nor bytes are
37133
+ * omitted; the rest come back ordered by bytes, descending, which is the
37134
+ * order the operator's table renders.
37135
+ *
37136
+ * ## The device set is a union of THREE sources
37137
+ *
37138
+ * It used to be `media ∪ tracks`, and a camera holding events but no media
37139
+ * row and no persisted track was therefore invisible — not listed, and its
37140
+ * events missing from `totalRows`. That combination is not exotic: media
37141
+ * retention, a media deletion and `pruneTracksBefore` all run on clocks of
37142
+ * their own, and every one of them can outlive the event rows. The number
37143
+ * this method exists to produce is the denominator for a deletion, so an
37144
+ * undercount is the one failure mode that matters, and it was silent.
37145
+ *
37146
+ * Events now contribute their own device set, so a camera is present if ANY
37147
+ * of the three sources has ever heard of it.
37148
+ *
37149
+ * ## The cost is the answer, not the table
37150
+ *
37151
+ * Three grouped reads for the event rows (one per event collection, never
37152
+ * three per camera), one grouped read plus one aggregate per camera for the
37153
+ * media bytes, one for the track device set. The retired shape paid ~1 250
37154
+ * round trips for the media half alone and timed out at 60 s on every call.
37155
+ * See {@link MediaStore.footprintByDevice} and {@link EventStore.countByDevice}.
37156
+ *
37157
+ * A store that cannot answer THROWS rather than returning zeroes — see the
37158
+ * same note on both.
36985
37159
  */
36986
37160
  async getEventStoreFootprint() {
36987
37161
  const eventStore = this.deps.eventStore();
@@ -36991,15 +37165,16 @@ var AnalyticsQueryFacade = class {
36991
37165
  totalBytes: 0,
36992
37166
  devices: []
36993
37167
  };
36994
- const mediaByDevice = await mediaStore.footprintByDevice();
37168
+ const [mediaByDevice, eventRowsByDevice] = await Promise.all([mediaStore.footprintByDevice(), eventStore.countByDevice()]);
36995
37169
  const deviceIds = new Set(mediaByDevice.keys());
37170
+ for (const id of eventRowsByDevice.keys()) deviceIds.add(id);
36996
37171
  const trackStore = this.deps.trackStore();
36997
37172
  if (trackStore) for (const id of await trackStore.listDeviceIds()) deviceIds.add(id);
36998
37173
  const devices = [];
36999
37174
  let totalRows = 0;
37000
37175
  let totalBytes = 0;
37001
37176
  for (const deviceId of deviceIds) {
37002
- const rows = await eventStore.countForDevice(deviceId);
37177
+ const rows = eventRowsByDevice.get(deviceId) ?? 0;
37003
37178
  const bytes = mediaByDevice.get(deviceId)?.bytes ?? 0;
37004
37179
  if (rows === 0 && bytes === 0) continue;
37005
37180
  totalRows += rows;
@@ -37018,6 +37193,26 @@ var AnalyticsQueryFacade = class {
37018
37193
  };
37019
37194
  }
37020
37195
  /**
37196
+ * The media footprint by KIND — the axis that says what is safe to delete.
37197
+ * See {@link MediaStore.footprintByKind}.
37198
+ *
37199
+ * An absent media store is an empty breakdown, not a refusal: it is the same
37200
+ * "the addon has not finished booting" branch every other method here takes,
37201
+ * and the zeroes are accompanied by an empty `kinds` list, which no reader
37202
+ * can mistake for a measured "there is nothing".
37203
+ */
37204
+ async getEventMediaFootprintByKind(input) {
37205
+ const mediaStore = this.deps.mediaStore();
37206
+ if (!mediaStore) return {
37207
+ totalRows: 0,
37208
+ totalBytes: 0,
37209
+ kinds: [],
37210
+ unaccountedRows: 0,
37211
+ unaccountedBytes: 0
37212
+ };
37213
+ return mediaStore.footprintByKind(input.deviceId);
37214
+ }
37215
+ /**
37021
37216
  * Cluster-wide prune of events with `timestamp < olderThanMs` across every
37022
37217
  * camera (via the device-agnostic `evictBefore`), deleting each pruned event's
37023
37218
  * media in lockstep. Logs one global (`deviceId:null`) events ops-log row.
@@ -44685,6 +44880,15 @@ function snapshot(j) {
44685
44880
  */
44686
44881
  var LOCATION_ID_FIELD = "locationId";
44687
44882
  /**
44883
+ * Unstamped rows claimed per bulk seal statement.
44884
+ *
44885
+ * Bounds two things at once: the number of bound parameters in the `IN` list,
44886
+ * and how long one `UPDATE` holds the write lock on the database every process
44887
+ * in the cluster reads configuration through. Deliberately well under SQLite's
44888
+ * variable ceiling, and small enough that a cancel lands within one statement.
44889
+ */
44890
+ var SEAL_BATCH_SIZE = 500;
44891
+ /**
44688
44892
  * True when the row names NO location — the population `seal` exists for.
44689
44893
  *
44690
44894
  * Deliberately NOT `mediaRowLocation(data) === DEFAULT_MEDIA_LOCATION`: that
@@ -44916,6 +45120,17 @@ var MediaRelocateEngine = class {
44916
45120
  }
44917
45121
  async countSelected(mode, toLocationId, stop) {
44918
45122
  try {
45123
+ if (mode === "seal") {
45124
+ let sealable = 0;
45125
+ for (const collection of collectionsFor(mode)) {
45126
+ if (stop()) return null;
45127
+ sealable += await this.deps.store.count.query({
45128
+ collection: collection.name,
45129
+ filter: { where: { [LOCATION_ID_FIELD]: null } }
45130
+ });
45131
+ }
45132
+ return { rows: sealable };
45133
+ }
44919
45134
  let rows = 0;
44920
45135
  for (const collection of collectionsFor(mode)) {
44921
45136
  const isRetrain = collection.name === RETRAIN_FRAMES_COLLECTION;
@@ -44947,6 +45162,14 @@ var MediaRelocateEngine = class {
44947
45162
  * gets believed. */
44948
45163
  let pathlessRows = 0;
44949
45164
  job.filesTotal = (await this.countSelected(job.mode, job.toLocationId, () => job.cancelRequested))?.rows ?? null;
45165
+ if (job.mode === "seal") {
45166
+ for (const collection of collectionsFor(job.mode)) {
45167
+ if (job.cancelRequested) break;
45168
+ await this.sealCollection(collection.name, job);
45169
+ }
45170
+ job.state = job.cancelRequested ? "cancelled" : "done";
45171
+ return;
45172
+ }
44950
45173
  for (const collection of collectionsFor(job.mode)) {
44951
45174
  if (job.cancelRequested) break;
44952
45175
  const isRetrain = collection.name === RETRAIN_FRAMES_COLLECTION;
@@ -44954,20 +45177,6 @@ var MediaRelocateEngine = class {
44954
45177
  if (job.cancelRequested) break;
44955
45178
  const data = row.data;
44956
45179
  if (!selectsRow(job.mode, job.toLocationId, data, isRetrain)) continue;
44957
- if (job.mode === "seal") {
44958
- try {
44959
- await this.stamp(collection.name, row.id, data, job.toLocationId);
44960
- job.filesMoved++;
44961
- } catch (err) {
44962
- failedRows++;
44963
- this.deps.logger.debug("event-media seal row failed", { meta: {
44964
- collection: collection.name,
44965
- key: row.id,
44966
- error: String(err)
44967
- } });
44968
- }
44969
- continue;
44970
- }
44971
45180
  const from = mediaRowLocation(data);
44972
45181
  const relativePath = String(data["path"] ?? "");
44973
45182
  if (relativePath.length === 0) {
@@ -45030,6 +45239,73 @@ var MediaRelocateEngine = class {
45030
45239
  this.deps.onFinished?.(snapshot(job));
45031
45240
  }
45032
45241
  }
45242
+ /**
45243
+ * Stamp every unstamped row of one collection with the job's target, in
45244
+ * bounded bulk statements.
45245
+ *
45246
+ * ## What this replaced
45247
+ *
45248
+ * A per-row loop: page the whole collection through {@link pageRows}, then
45249
+ * one `store.set.mutate` per selected row — a full-row UPSERT rewriting every
45250
+ * column to change one field, one RPC each, plus a second full walk before it
45251
+ * to produce the denominator. Measured on the live hub against the 464 902
45252
+ * unstamped rows: **2.3 rows/s, falling to 0.7 rows/s under a competing
45253
+ * drain**, i.e. over two and a half days for a metadata stamp that reads no
45254
+ * file and moves no byte. It was cancelled. This is the write-side twin of
45255
+ * the read-side walk {@link countUnstamped} retired, and the fourth instance
45256
+ * of the same pattern found in one day — work looped over the door instead of
45257
+ * pushed into the statement.
45258
+ *
45259
+ * ## The shape
45260
+ *
45261
+ * Probe the indexed `IS NULL` range for a batch of ids, then ONE
45262
+ * `updateWhere` over those ids. Two round trips per {@link SEAL_BATCH_SIZE}
45263
+ * rows instead of one write per row.
45264
+ *
45265
+ * The probe carries no cursor and needs none: the predicate CONSUMES itself.
45266
+ * A stamped row leaves the range, so the next probe returns the next batch,
45267
+ * and a run that is interrupted anywhere simply resumes on what is left. That
45268
+ * is also why the seal is idempotent (a second run probes an empty range and
45269
+ * stamps nothing) and why it cannot repoint a row that already names a
45270
+ * different location — `IS NULL` never selects one.
45271
+ *
45272
+ * A single unbounded `UPDATE … WHERE locationId IS NULL` would be one
45273
+ * statement instead of ~1 860, and is not used: half a million row rewrites,
45274
+ * each maintaining four indexes, is an unbounded statement holding the write
45275
+ * lock on a database the whole cluster reads configuration through, under an
45276
+ * RPC deadline that must not be raised. Batching bounds the statement, keeps
45277
+ * `cancel` responsive between batches, and keeps `filesMoved` honest.
45278
+ *
45279
+ * ## The termination guard
45280
+ *
45281
+ * If the probe finds rows and the update reports **0**, this THROWS. That is
45282
+ * not defensive noise: it is precisely what a backend compiling
45283
+ * `locationId = NULL` does — matches nothing, reports success (D312) — and
45284
+ * against it a self-consuming loop never terminates and a non-looping one
45285
+ * reports a completed seal having stamped nothing. Zero unstamped rows is the
45286
+ * single value the migration gate reads as "safe to cut over".
45287
+ */
45288
+ async sealCollection(collection, job) {
45289
+ for (;;) {
45290
+ if (job.cancelRequested) return;
45291
+ const batch = await this.deps.store.query.query({
45292
+ collection,
45293
+ filter: {
45294
+ where: { [LOCATION_ID_FIELD]: null },
45295
+ limit: SEAL_BATCH_SIZE
45296
+ },
45297
+ columns: [LOCATION_ID_FIELD]
45298
+ });
45299
+ if (batch.length === 0) return;
45300
+ const { updated } = await this.deps.store.updateWhere.mutate({
45301
+ collection,
45302
+ filter: { whereIn: { id: batch.map((row) => row.id) } },
45303
+ data: { [LOCATION_ID_FIELD]: job.toLocationId }
45304
+ });
45305
+ if (updated === 0) throw new Error(`event-media seal stamped 0 of ${batch.length} unstamped row(s) in "${collection}" — the bulk predicate matched nothing, so the population cannot be sealed`);
45306
+ job.filesMoved += updated;
45307
+ }
45308
+ }
45033
45309
  async stamp(collection, key, data, locationId) {
45034
45310
  await this.deps.store.set.mutate({
45035
45311
  collection,
@@ -51761,6 +52037,43 @@ var EventStore = class {
51761
52037
  return motion + object + audio;
51762
52038
  }
51763
52039
  /**
52040
+ * Persisted event rows (motion + object + audio) for EVERY device that has
52041
+ * any — three round trips for the whole fleet.
52042
+ *
52043
+ * {@link countForDevice} answers one camera in three `COUNT(*)`s, which is
52044
+ * right for one camera and wrong in a loop: the events-management footprint
52045
+ * called it once per device, so the cost of "how many events does each camera
52046
+ * hold" grew with the camera count for a result that is a few dozen integers.
52047
+ *
52048
+ * A histogram on `deviceId` with `bucketSize: 1` is `GROUP BY deviceId`, and
52049
+ * every event collection already carries `idx_<kind>_device_ts`, which leads
52050
+ * with `deviceId` — so the planner serves each of these from a covering index
52051
+ * with no table access at all. `bucket` IS the deviceId (the column is
52052
+ * INTEGER, the bucket expression is `CAST((deviceId - 0) / 1 AS INTEGER)`).
52053
+ *
52054
+ * Not best-effort: a failure propagates. The one caller is sizing a deletion,
52055
+ * and a camera silently reported as holding zero events is the answer that
52056
+ * must never come from a read that did not happen.
52057
+ */
52058
+ async countByDevice() {
52059
+ const perCollection = await Promise.all([
52060
+ MOTION_EVENTS_COLLECTION,
52061
+ OBJECT_EVENTS_COLLECTION,
52062
+ AUDIO_EVENTS_COLLECTION
52063
+ ].map((collection) => this.store.histogram.query({
52064
+ collection,
52065
+ field: "deviceId",
52066
+ bucketSize: 1,
52067
+ origin: 0
52068
+ })));
52069
+ const out = /* @__PURE__ */ new Map();
52070
+ for (const buckets of perCollection) for (const bucket of buckets) {
52071
+ if (bucket.count === 0 || !Number.isFinite(bucket.bucket)) continue;
52072
+ out.set(bucket.bucket, (out.get(bucket.bucket) ?? 0) + bucket.count);
52073
+ }
52074
+ return out;
52075
+ }
52076
+ /**
51764
52077
  * Return per-kind event counts in equal-width time buckets.
51765
52078
  *
51766
52079
  * Each bucket maps to a `bucketStart = since + i * bucketMs`.
@@ -71484,6 +71797,9 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
71484
71797
  async getEventStoreFootprint() {
71485
71798
  return this.queryFacade.getEventStoreFootprint();
71486
71799
  }
71800
+ async getEventMediaFootprintByKind(input) {
71801
+ return this.queryFacade.getEventMediaFootprintByKind(input);
71802
+ }
71487
71803
  async pruneEvents(input) {
71488
71804
  return this.queryFacade.pruneEvents(input);
71489
71805
  }
@@ -30,7 +30,7 @@ async function d(e) {
30
30
  }
31
31
  }
32
32
  async function f() {
33
- return l ||= d(() => import("./_virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-DTO1HvN7.mjs")).catch((e) => {
33
+ return l ||= d(() => import("./_virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-Cil5ur25.mjs")).catch((e) => {
34
34
  throw l = void 0, e;
35
35
  }), l;
36
36
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/addon-post-analysis",
3
- "version": "1.2.152",
3
+ "version": "1.2.153",
4
4
  "description": "Post-Analysis bundle — enrichment, embedding-encoder, pipeline-analytics. Multi-entry npm package shipping addons that consume pipeline output.",
5
5
  "keywords": [
6
6
  "camstack",