@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.
@@ -2,7 +2,7 @@ Object.defineProperties(exports, {
2
2
  __esModule: { value: true },
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
- const require_dist = require("../dist-Bw6cidiJ.js");
5
+ const require_dist = require("../dist-DeMgxNFC.js");
6
6
  let node_fs = require("node:fs");
7
7
  node_fs = require_dist.__toESM(node_fs, 1);
8
8
  let node_path = require("node:path");
@@ -25694,6 +25694,67 @@ var MEDIA_INDEXES = [
25694
25694
  {
25695
25695
  name: "idx_media_location",
25696
25696
  columns: ["locationId"]
25697
+ }),
25698
+ (
25699
+ /**
25700
+ * The events-management footprint — `GROUP BY deviceId` and
25701
+ * `SUM(sizeBytes) WHERE deviceId = ?`. See {@link MediaStore.footprintByDevice}.
25702
+ *
25703
+ * **Why not `idx_media_device_ts`.** That index already leads with
25704
+ * `deviceId`, so it can find the range — but it does not carry `sizeBytes`,
25705
+ * so the sum has to fetch the table row behind every index entry it visits.
25706
+ * Over the whole collection that is one scattered read of the entire ~1.25 M
25707
+ * row table (`path` alone is ~80 characters per row) on the volume the
25708
+ * recorder is writing to. With `sizeBytes` in the index the planner never
25709
+ * touches the table: `SCAN … USING COVERING INDEX idx_media_device_size`.
25710
+ *
25711
+ * It has to be a NEW index rather than a third column on the existing one.
25712
+ * `declareCollection` emits `CREATE INDEX IF NOT EXISTS` keyed on the index
25713
+ * NAME, so widening `idx_media_device_ts` in place would be a silent no-op on
25714
+ * every hub that already has it — the declaration would read as done and the
25715
+ * query would still hit the table.
25716
+ *
25717
+ * **What it costs.** ~19 bytes of index per row (two integers plus the
25718
+ * rowid), so ~24 MB at 1.25 M rows, plus one more b-tree maintained by every
25719
+ * insert into a table taking ~92 k rows/day. At ~1 insert/s that maintenance
25720
+ * is noise beside the JPEG write the same call performs. The cost that is not
25721
+ * noise is the one-off: the first `declareCollection` after this ships builds
25722
+ * the index over every existing row. Do not deploy it beside a running drain
25723
+ * or seal — the same caveat `idx_media_location` carries, for the same
25724
+ * reason.
25725
+ */
25726
+ {
25727
+ name: "idx_media_device_size",
25728
+ columns: ["deviceId", "sizeBytes"]
25729
+ }),
25730
+ (
25731
+ /**
25732
+ * The per-KIND footprint — `COUNT(*)`/`SUM(sizeBytes) WHERE kind = ?`, and
25733
+ * the same scoped to one camera. See {@link MediaStore.footprintByKind}.
25734
+ *
25735
+ * `kind` leads no existing index, so without this each of the thirteen
25736
+ * per-kind questions is a full table scan and the breakdown costs thirteen of
25737
+ * them. With it, each kind is a contiguous covering range and the thirteen
25738
+ * together scan the index once — the cost of the whole breakdown is one pass
25739
+ * over ~36 MB of index instead of thirteen passes over the table.
25740
+ *
25741
+ * `deviceId` sits between `kind` and `sizeBytes` so the per-camera variant
25742
+ * (`WHERE kind = ? AND deviceId = ?`) stays covering too, for ~3 bytes a row
25743
+ * more than `(kind, sizeBytes)` would cost. Without it that query finds its
25744
+ * range from the index and then reads the table for every row in it.
25745
+ *
25746
+ * ~29 bytes of index per row, so ~36 MB at 1.25 M rows, and one more b-tree
25747
+ * on every insert. Same one-off caveat as the two indexes above: the first
25748
+ * declare builds it over every existing row, so do not ship it beside a
25749
+ * running drain or seal.
25750
+ */
25751
+ {
25752
+ name: "idx_media_kind_device_size",
25753
+ columns: [
25754
+ "kind",
25755
+ "deviceId",
25756
+ "sizeBytes"
25757
+ ]
25697
25758
  })
25698
25759
  ];
25699
25760
  /** The storage location of a media row/record: its stamped `locationId`, or
@@ -26348,47 +26409,136 @@ var MediaStore = class {
26348
26409
  };
26349
26410
  }
26350
26411
  /**
26351
- * On-demand footprint aggregation for the events-management UI: sum
26352
- * `sizeBytes` and count rows per device across the whole media collection.
26353
- * Pages by `id` (offset-based) so a large collection never loads at once.
26354
- * Metadata rows are small (no blob) — the image bytes live on the storage
26355
- * provider and are counted via the persisted `sizeBytes` column.
26412
+ * On-demand footprint aggregation for the events-management UI: rows and
26413
+ * summed `sizeBytes` per camera, across the whole media collection.
26414
+ *
26415
+ * ## Why this is a histogram plus one aggregate per camera
26416
+ *
26417
+ * It used to page the ENTIRE collection — `ORDER BY id LIMIT 1000 OFFSET n`,
26418
+ * no predicate — and add `sizeBytes` up in JS. SQLite honours an OFFSET by
26419
+ * stepping over `n` entries, so the walk is quadratic in the table: at the
26420
+ * live hub's 1.25 M rows that is ~1 250 round trips and ~780 M index steps,
26421
+ * with every row serialised across an RPC boundary and rebuilt in V8 to be
26422
+ * added to a number. It timed out at 60 s on every call it ever made, and it
26423
+ * is the only thing that can size the surplus-media deletion it gates.
26424
+ *
26425
+ * The answer is at most a few dozen `(deviceId, rows, bytes)` triples.
26426
+ *
26427
+ * - The DEVICE SET comes from a histogram on `deviceId` with `bucketSize: 1`
26428
+ * — `GROUP BY deviceId`, served from `idx_media_device_size` as a covering
26429
+ * scan, the same trick `TrackStore.listDeviceIds` already uses (D56).
26430
+ * `bucket` IS the deviceId: the column is INTEGER and the bucket
26431
+ * expression is `CAST((deviceId - 0) / 1 AS INTEGER)`.
26432
+ * - The NUMBERS come from one `aggregate` per camera — `COUNT(*)` and
26433
+ * `SUM(sizeBytes)` in a single statement over an indexed `deviceId` range.
26434
+ *
26435
+ * Both figures for a camera come from the SAME statement deliberately. Taking
26436
+ * `rows` from the histogram and `bytes` from the aggregate would let a write
26437
+ * landing between the two produce a footprint whose row count and byte count
26438
+ * describe different populations — a disagreement an operator sizing a
26439
+ * deletion has no way to detect.
26440
+ *
26441
+ * A read that fails THROWS. It must never degrade to a zero: "this camera
26442
+ * holds nothing" is precisely the claim a failed measurement must not be
26443
+ * allowed to make to something about to delete (D295, D312).
26356
26444
  */
26357
26445
  async footprintByDevice() {
26358
- const PAGE = 1e3;
26446
+ const buckets = await this.store.histogram.query({
26447
+ collection: MEDIA_COLLECTION,
26448
+ field: "deviceId",
26449
+ bucketSize: 1,
26450
+ origin: 0
26451
+ });
26359
26452
  const out = /* @__PURE__ */ new Map();
26360
- let offset = 0;
26361
- for (;;) {
26362
- const rows = await this.store.query.query({
26453
+ for (const bucket of buckets) {
26454
+ if (bucket.count === 0 || !Number.isFinite(bucket.bucket)) continue;
26455
+ const deviceId = bucket.bucket;
26456
+ const agg = await this.store.aggregate.query({
26363
26457
  collection: MEDIA_COLLECTION,
26364
- filter: {
26365
- orderBy: {
26366
- field: "id",
26367
- direction: "asc"
26368
- },
26369
- limit: PAGE,
26370
- offset
26371
- }
26458
+ fields: [{
26459
+ as: "bytes",
26460
+ field: "sizeBytes",
26461
+ op: "sum"
26462
+ }],
26463
+ filter: { where: { deviceId } }
26464
+ });
26465
+ if (agg.count === 0) continue;
26466
+ const bytes = agg.values["bytes"];
26467
+ out.set(deviceId, {
26468
+ bytes: bytes ?? 0,
26469
+ rows: agg.count
26372
26470
  });
26373
- if (rows.length === 0) break;
26374
- for (const row of rows) {
26375
- const data = row.data;
26376
- const deviceId = Number(data["deviceId"]);
26377
- const sizeBytes = Number(data["sizeBytes"]);
26378
- if (!Number.isFinite(deviceId)) continue;
26379
- const acc = out.get(deviceId) ?? {
26380
- bytes: 0,
26381
- rows: 0
26382
- };
26383
- acc.bytes += Number.isFinite(sizeBytes) ? sizeBytes : 0;
26384
- acc.rows += 1;
26385
- out.set(deviceId, acc);
26386
- }
26387
- if (rows.length < PAGE) break;
26388
- offset += PAGE;
26389
26471
  }
26390
26472
  return out;
26391
26473
  }
26474
+ /**
26475
+ * The media footprint broken down by KIND — fleet-wide, or for one camera.
26476
+ *
26477
+ * `footprintByDevice` answers "how much is where"; this answers "how much is
26478
+ * WHAT", which is the axis a deletion decision turns on. The deletable set
26479
+ * (the periodic `snapshot` filmstrip, the surplus per-edge motion stills) and
26480
+ * the keep set (`firstFrame`, rolling `lastFrame`, `thumbnail` /
26481
+ * `thumbnailSmall`, `keyFrame` / `keyFrameSmall`, the face and plate buffers,
26482
+ * gallery media, the CLIP `crop`) are told apart by `kind` and by nothing
26483
+ * else. A byte total cannot size a deletion; this can.
26484
+ *
26485
+ * One `aggregate` per kind, each an indexed covering range on
26486
+ * `idx_media_kind_device_size`, plus ONE unfiltered aggregate for the totals.
26487
+ *
26488
+ * That last statement is the point of the design and not a spare round trip.
26489
+ * `MediaFileKindEnum.options` is the closed set the writers use, but the
26490
+ * TABLE is not closed: a row written by a retired path, or by a version that
26491
+ * knew a kind this one does not, matches no per-kind query. Adding the kinds
26492
+ * up to produce the total would make such a row invisible and the denominator
26493
+ * quietly too small — the failure mode that matters most here, because the
26494
+ * caller is about to delete against it. So the total is measured
26495
+ * independently and the difference is REPORTED as `unaccounted*` rather than
26496
+ * being absorbed.
26497
+ */
26498
+ async footprintByKind(deviceId) {
26499
+ const scope = deviceId === void 0 ? void 0 : { where: { deviceId } };
26500
+ const fields = [{
26501
+ as: "bytes",
26502
+ field: "sizeBytes",
26503
+ op: "sum"
26504
+ }];
26505
+ const totals = await this.store.aggregate.query({
26506
+ collection: MEDIA_COLLECTION,
26507
+ fields: [...fields],
26508
+ ...scope === void 0 ? {} : { filter: scope }
26509
+ });
26510
+ const kinds = [];
26511
+ let countedRows = 0;
26512
+ let countedBytes = 0;
26513
+ for (const kind of require_dist.MediaFileKindEnum.options) {
26514
+ const agg = await this.store.aggregate.query({
26515
+ collection: MEDIA_COLLECTION,
26516
+ fields: [...fields],
26517
+ filter: { where: {
26518
+ kind,
26519
+ ...deviceId === void 0 ? {} : { deviceId }
26520
+ } }
26521
+ });
26522
+ if (agg.count === 0) continue;
26523
+ const bytes = agg.values["bytes"] ?? 0;
26524
+ countedRows += agg.count;
26525
+ countedBytes += bytes;
26526
+ kinds.push({
26527
+ kind,
26528
+ rows: agg.count,
26529
+ bytes
26530
+ });
26531
+ }
26532
+ kinds.sort((a, b) => b.bytes - a.bytes);
26533
+ const totalBytes = totals.values["bytes"] ?? 0;
26534
+ return {
26535
+ totalRows: totals.count,
26536
+ totalBytes,
26537
+ kinds,
26538
+ unaccountedRows: totals.count - countedRows,
26539
+ unaccountedBytes: totalBytes - countedBytes
26540
+ };
26541
+ }
26392
26542
  /** Move a single media entry from one owner to another.
26393
26543
  * The new copy is written before the old one is removed.
26394
26544
  * The old-entry deletion is best-effort: on failure a warning is logged
@@ -37042,10 +37192,34 @@ var AnalyticsQueryFacade = class {
37042
37192
  }
37043
37193
  /**
37044
37194
  * Durable event-store footprint for the events-management UI: event ROWS
37045
- * (motion + object + audio) counted per camera (indexed `count`) + total, and
37046
- * event-owned media BYTES on disk per camera (paged `sizeBytes` sum) + total.
37047
- * The device set is the union of cameras that have media OR persisted tracks;
37048
- * cameras with neither rows nor bytes are omitted.
37195
+ * (motion + object + audio) per camera + total, and event-owned media BYTES
37196
+ * on disk per camera + total. Cameras with neither rows nor bytes are
37197
+ * omitted; the rest come back ordered by bytes, descending, which is the
37198
+ * order the operator's table renders.
37199
+ *
37200
+ * ## The device set is a union of THREE sources
37201
+ *
37202
+ * It used to be `media ∪ tracks`, and a camera holding events but no media
37203
+ * row and no persisted track was therefore invisible — not listed, and its
37204
+ * events missing from `totalRows`. That combination is not exotic: media
37205
+ * retention, a media deletion and `pruneTracksBefore` all run on clocks of
37206
+ * their own, and every one of them can outlive the event rows. The number
37207
+ * this method exists to produce is the denominator for a deletion, so an
37208
+ * undercount is the one failure mode that matters, and it was silent.
37209
+ *
37210
+ * Events now contribute their own device set, so a camera is present if ANY
37211
+ * of the three sources has ever heard of it.
37212
+ *
37213
+ * ## The cost is the answer, not the table
37214
+ *
37215
+ * Three grouped reads for the event rows (one per event collection, never
37216
+ * three per camera), one grouped read plus one aggregate per camera for the
37217
+ * media bytes, one for the track device set. The retired shape paid ~1 250
37218
+ * round trips for the media half alone and timed out at 60 s on every call.
37219
+ * See {@link MediaStore.footprintByDevice} and {@link EventStore.countByDevice}.
37220
+ *
37221
+ * A store that cannot answer THROWS rather than returning zeroes — see the
37222
+ * same note on both.
37049
37223
  */
37050
37224
  async getEventStoreFootprint() {
37051
37225
  const eventStore = this.deps.eventStore();
@@ -37055,15 +37229,16 @@ var AnalyticsQueryFacade = class {
37055
37229
  totalBytes: 0,
37056
37230
  devices: []
37057
37231
  };
37058
- const mediaByDevice = await mediaStore.footprintByDevice();
37232
+ const [mediaByDevice, eventRowsByDevice] = await Promise.all([mediaStore.footprintByDevice(), eventStore.countByDevice()]);
37059
37233
  const deviceIds = new Set(mediaByDevice.keys());
37234
+ for (const id of eventRowsByDevice.keys()) deviceIds.add(id);
37060
37235
  const trackStore = this.deps.trackStore();
37061
37236
  if (trackStore) for (const id of await trackStore.listDeviceIds()) deviceIds.add(id);
37062
37237
  const devices = [];
37063
37238
  let totalRows = 0;
37064
37239
  let totalBytes = 0;
37065
37240
  for (const deviceId of deviceIds) {
37066
- const rows = await eventStore.countForDevice(deviceId);
37241
+ const rows = eventRowsByDevice.get(deviceId) ?? 0;
37067
37242
  const bytes = mediaByDevice.get(deviceId)?.bytes ?? 0;
37068
37243
  if (rows === 0 && bytes === 0) continue;
37069
37244
  totalRows += rows;
@@ -37082,6 +37257,26 @@ var AnalyticsQueryFacade = class {
37082
37257
  };
37083
37258
  }
37084
37259
  /**
37260
+ * The media footprint by KIND — the axis that says what is safe to delete.
37261
+ * See {@link MediaStore.footprintByKind}.
37262
+ *
37263
+ * An absent media store is an empty breakdown, not a refusal: it is the same
37264
+ * "the addon has not finished booting" branch every other method here takes,
37265
+ * and the zeroes are accompanied by an empty `kinds` list, which no reader
37266
+ * can mistake for a measured "there is nothing".
37267
+ */
37268
+ async getEventMediaFootprintByKind(input) {
37269
+ const mediaStore = this.deps.mediaStore();
37270
+ if (!mediaStore) return {
37271
+ totalRows: 0,
37272
+ totalBytes: 0,
37273
+ kinds: [],
37274
+ unaccountedRows: 0,
37275
+ unaccountedBytes: 0
37276
+ };
37277
+ return mediaStore.footprintByKind(input.deviceId);
37278
+ }
37279
+ /**
37085
37280
  * Cluster-wide prune of events with `timestamp < olderThanMs` across every
37086
37281
  * camera (via the device-agnostic `evictBefore`), deleting each pruned event's
37087
37282
  * media in lockstep. Logs one global (`deviceId:null`) events ops-log row.
@@ -44750,6 +44945,15 @@ function snapshot(j) {
44750
44945
  */
44751
44946
  var LOCATION_ID_FIELD = "locationId";
44752
44947
  /**
44948
+ * Unstamped rows claimed per bulk seal statement.
44949
+ *
44950
+ * Bounds two things at once: the number of bound parameters in the `IN` list,
44951
+ * and how long one `UPDATE` holds the write lock on the database every process
44952
+ * in the cluster reads configuration through. Deliberately well under SQLite's
44953
+ * variable ceiling, and small enough that a cancel lands within one statement.
44954
+ */
44955
+ var SEAL_BATCH_SIZE = 500;
44956
+ /**
44753
44957
  * True when the row names NO location — the population `seal` exists for.
44754
44958
  *
44755
44959
  * Deliberately NOT `mediaRowLocation(data) === DEFAULT_MEDIA_LOCATION`: that
@@ -44981,6 +45185,17 @@ var MediaRelocateEngine = class {
44981
45185
  }
44982
45186
  async countSelected(mode, toLocationId, stop) {
44983
45187
  try {
45188
+ if (mode === "seal") {
45189
+ let sealable = 0;
45190
+ for (const collection of collectionsFor(mode)) {
45191
+ if (stop()) return null;
45192
+ sealable += await this.deps.store.count.query({
45193
+ collection: collection.name,
45194
+ filter: { where: { [LOCATION_ID_FIELD]: null } }
45195
+ });
45196
+ }
45197
+ return { rows: sealable };
45198
+ }
44984
45199
  let rows = 0;
44985
45200
  for (const collection of collectionsFor(mode)) {
44986
45201
  const isRetrain = collection.name === RETRAIN_FRAMES_COLLECTION;
@@ -45012,6 +45227,14 @@ var MediaRelocateEngine = class {
45012
45227
  * gets believed. */
45013
45228
  let pathlessRows = 0;
45014
45229
  job.filesTotal = (await this.countSelected(job.mode, job.toLocationId, () => job.cancelRequested))?.rows ?? null;
45230
+ if (job.mode === "seal") {
45231
+ for (const collection of collectionsFor(job.mode)) {
45232
+ if (job.cancelRequested) break;
45233
+ await this.sealCollection(collection.name, job);
45234
+ }
45235
+ job.state = job.cancelRequested ? "cancelled" : "done";
45236
+ return;
45237
+ }
45015
45238
  for (const collection of collectionsFor(job.mode)) {
45016
45239
  if (job.cancelRequested) break;
45017
45240
  const isRetrain = collection.name === RETRAIN_FRAMES_COLLECTION;
@@ -45019,20 +45242,6 @@ var MediaRelocateEngine = class {
45019
45242
  if (job.cancelRequested) break;
45020
45243
  const data = row.data;
45021
45244
  if (!selectsRow(job.mode, job.toLocationId, data, isRetrain)) continue;
45022
- if (job.mode === "seal") {
45023
- try {
45024
- await this.stamp(collection.name, row.id, data, job.toLocationId);
45025
- job.filesMoved++;
45026
- } catch (err) {
45027
- failedRows++;
45028
- this.deps.logger.debug("event-media seal row failed", { meta: {
45029
- collection: collection.name,
45030
- key: row.id,
45031
- error: String(err)
45032
- } });
45033
- }
45034
- continue;
45035
- }
45036
45245
  const from = mediaRowLocation(data);
45037
45246
  const relativePath = String(data["path"] ?? "");
45038
45247
  if (relativePath.length === 0) {
@@ -45095,6 +45304,73 @@ var MediaRelocateEngine = class {
45095
45304
  this.deps.onFinished?.(snapshot(job));
45096
45305
  }
45097
45306
  }
45307
+ /**
45308
+ * Stamp every unstamped row of one collection with the job's target, in
45309
+ * bounded bulk statements.
45310
+ *
45311
+ * ## What this replaced
45312
+ *
45313
+ * A per-row loop: page the whole collection through {@link pageRows}, then
45314
+ * one `store.set.mutate` per selected row — a full-row UPSERT rewriting every
45315
+ * column to change one field, one RPC each, plus a second full walk before it
45316
+ * to produce the denominator. Measured on the live hub against the 464 902
45317
+ * unstamped rows: **2.3 rows/s, falling to 0.7 rows/s under a competing
45318
+ * drain**, i.e. over two and a half days for a metadata stamp that reads no
45319
+ * file and moves no byte. It was cancelled. This is the write-side twin of
45320
+ * the read-side walk {@link countUnstamped} retired, and the fourth instance
45321
+ * of the same pattern found in one day — work looped over the door instead of
45322
+ * pushed into the statement.
45323
+ *
45324
+ * ## The shape
45325
+ *
45326
+ * Probe the indexed `IS NULL` range for a batch of ids, then ONE
45327
+ * `updateWhere` over those ids. Two round trips per {@link SEAL_BATCH_SIZE}
45328
+ * rows instead of one write per row.
45329
+ *
45330
+ * The probe carries no cursor and needs none: the predicate CONSUMES itself.
45331
+ * A stamped row leaves the range, so the next probe returns the next batch,
45332
+ * and a run that is interrupted anywhere simply resumes on what is left. That
45333
+ * is also why the seal is idempotent (a second run probes an empty range and
45334
+ * stamps nothing) and why it cannot repoint a row that already names a
45335
+ * different location — `IS NULL` never selects one.
45336
+ *
45337
+ * A single unbounded `UPDATE … WHERE locationId IS NULL` would be one
45338
+ * statement instead of ~1 860, and is not used: half a million row rewrites,
45339
+ * each maintaining four indexes, is an unbounded statement holding the write
45340
+ * lock on a database the whole cluster reads configuration through, under an
45341
+ * RPC deadline that must not be raised. Batching bounds the statement, keeps
45342
+ * `cancel` responsive between batches, and keeps `filesMoved` honest.
45343
+ *
45344
+ * ## The termination guard
45345
+ *
45346
+ * If the probe finds rows and the update reports **0**, this THROWS. That is
45347
+ * not defensive noise: it is precisely what a backend compiling
45348
+ * `locationId = NULL` does — matches nothing, reports success (D312) — and
45349
+ * against it a self-consuming loop never terminates and a non-looping one
45350
+ * reports a completed seal having stamped nothing. Zero unstamped rows is the
45351
+ * single value the migration gate reads as "safe to cut over".
45352
+ */
45353
+ async sealCollection(collection, job) {
45354
+ for (;;) {
45355
+ if (job.cancelRequested) return;
45356
+ const batch = await this.deps.store.query.query({
45357
+ collection,
45358
+ filter: {
45359
+ where: { [LOCATION_ID_FIELD]: null },
45360
+ limit: SEAL_BATCH_SIZE
45361
+ },
45362
+ columns: [LOCATION_ID_FIELD]
45363
+ });
45364
+ if (batch.length === 0) return;
45365
+ const { updated } = await this.deps.store.updateWhere.mutate({
45366
+ collection,
45367
+ filter: { whereIn: { id: batch.map((row) => row.id) } },
45368
+ data: { [LOCATION_ID_FIELD]: job.toLocationId }
45369
+ });
45370
+ 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`);
45371
+ job.filesMoved += updated;
45372
+ }
45373
+ }
45098
45374
  async stamp(collection, key, data, locationId) {
45099
45375
  await this.deps.store.set.mutate({
45100
45376
  collection,
@@ -51826,6 +52102,43 @@ var EventStore = class {
51826
52102
  return motion + object + audio;
51827
52103
  }
51828
52104
  /**
52105
+ * Persisted event rows (motion + object + audio) for EVERY device that has
52106
+ * any — three round trips for the whole fleet.
52107
+ *
52108
+ * {@link countForDevice} answers one camera in three `COUNT(*)`s, which is
52109
+ * right for one camera and wrong in a loop: the events-management footprint
52110
+ * called it once per device, so the cost of "how many events does each camera
52111
+ * hold" grew with the camera count for a result that is a few dozen integers.
52112
+ *
52113
+ * A histogram on `deviceId` with `bucketSize: 1` is `GROUP BY deviceId`, and
52114
+ * every event collection already carries `idx_<kind>_device_ts`, which leads
52115
+ * with `deviceId` — so the planner serves each of these from a covering index
52116
+ * with no table access at all. `bucket` IS the deviceId (the column is
52117
+ * INTEGER, the bucket expression is `CAST((deviceId - 0) / 1 AS INTEGER)`).
52118
+ *
52119
+ * Not best-effort: a failure propagates. The one caller is sizing a deletion,
52120
+ * and a camera silently reported as holding zero events is the answer that
52121
+ * must never come from a read that did not happen.
52122
+ */
52123
+ async countByDevice() {
52124
+ const perCollection = await Promise.all([
52125
+ MOTION_EVENTS_COLLECTION,
52126
+ OBJECT_EVENTS_COLLECTION,
52127
+ AUDIO_EVENTS_COLLECTION
52128
+ ].map((collection) => this.store.histogram.query({
52129
+ collection,
52130
+ field: "deviceId",
52131
+ bucketSize: 1,
52132
+ origin: 0
52133
+ })));
52134
+ const out = /* @__PURE__ */ new Map();
52135
+ for (const buckets of perCollection) for (const bucket of buckets) {
52136
+ if (bucket.count === 0 || !Number.isFinite(bucket.bucket)) continue;
52137
+ out.set(bucket.bucket, (out.get(bucket.bucket) ?? 0) + bucket.count);
52138
+ }
52139
+ return out;
52140
+ }
52141
+ /**
51829
52142
  * Return per-kind event counts in equal-width time buckets.
51830
52143
  *
51831
52144
  * Each bucket maps to a `bucketStart = since + i * bucketMs`.
@@ -71549,6 +71862,9 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
71549
71862
  async getEventStoreFootprint() {
71550
71863
  return this.queryFacade.getEventStoreFootprint();
71551
71864
  }
71865
+ async getEventMediaFootprintByKind(input) {
71866
+ return this.queryFacade.getEventMediaFootprintByKind(input);
71867
+ }
71552
71868
  async pruneEvents(input) {
71553
71869
  return this.queryFacade.pruneEvents(input);
71554
71870
  }