@camstack/addon-post-analysis 1.2.22 → 1.2.24

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-vIJhE1KT.js");
5
+ const require_dist = require("../dist-MQmqxd7v.js");
6
6
  let node_fs = require("node:fs");
7
7
  let node_path = require("node:path");
8
8
  node_path = require_dist.__toESM(node_path);
@@ -10249,6 +10249,108 @@ var TrackStore = class {
10249
10249
  }
10250
10250
  };
10251
10251
  //#endregion
10252
+ //#region src/pipeline-analytics/store/storage-pressure.ts
10253
+ /**
10254
+ * storage-pressure — make a full disk something the system REACTS to, not just
10255
+ * something it logs once per lost file.
10256
+ *
10257
+ * ## What happened
10258
+ *
10259
+ * On 2026-08-02 media writes started failing with
10260
+ * `ENOSPC: no space left on device, mkdir '/recordings/617/events/track/…'`.
10261
+ * For roughly five hours every new track was recorded with positions, a class,
10262
+ * a `bestEventId` and **zero snapshots** — a track that looks complete in the
10263
+ * list and has no picture behind it. The operator found it by noticing the
10264
+ * missing media, not from any alert.
10265
+ *
10266
+ * Two separate defects made that possible:
10267
+ *
10268
+ * 1. **Retention prunes by AGE only.** It has no feedback from the filesystem,
10269
+ * so "the disk is full" is not an input it can respond to. It kept its
10270
+ * configured window while writes failed.
10271
+ * 2. **The failure was logged per KEY.** Every lost frame produced its own
10272
+ * `media put failed` WARN — dozens a minute, each true, none of them saying
10273
+ * "this camera has stopped storing media". A per-item line is a receipt,
10274
+ * not an alarm.
10275
+ *
10276
+ * This module is the missing feedback: it recognises an out-of-space failure,
10277
+ * counts it per device, and hands the caller ONE escalation to act on.
10278
+ *
10279
+ * ## Deliberately not a deleter
10280
+ *
10281
+ * It decides that pressure exists and says so. Freeing space is retention's
10282
+ * job, and giving a write-error handler the power to delete recordings is how
10283
+ * a transient fault becomes data loss. The trigger is separated from the act on
10284
+ * purpose.
10285
+ */
10286
+ /** Errors that mean "the filesystem refused for want of room". */
10287
+ var OUT_OF_SPACE_CODES = ["ENOSPC", "EDQUOT"];
10288
+ /**
10289
+ * Is this the disk being full, rather than any other write failure?
10290
+ *
10291
+ * Matches the CODE, not a message substring: a permission error or a missing
10292
+ * parent directory must not trigger a retention response, and the message text
10293
+ * is not a contract.
10294
+ */
10295
+ function isOutOfSpace(err) {
10296
+ const code = typeof err === "object" && err !== null && "code" in err ? String(err.code) : "";
10297
+ if (OUT_OF_SPACE_CODES.includes(code)) return true;
10298
+ const msg = err instanceof Error ? err.message : String(err);
10299
+ return OUT_OF_SPACE_CODES.some((c) => msg.includes(`${c}:`));
10300
+ }
10301
+ /** How long between escalations while pressure persists. */
10302
+ var PRESSURE_ESCALATION_INTERVAL_MS = 6e4;
10303
+ /**
10304
+ * Counts out-of-space failures and releases an escalation at most once per
10305
+ * interval — plus immediately on the FIRST one, because the first is the line
10306
+ * that should have existed five hours before anyone noticed.
10307
+ *
10308
+ * Clock injected so the interval is testable without waiting.
10309
+ */
10310
+ var StoragePressureTracker = class {
10311
+ failures = 0;
10312
+ devices = /* @__PURE__ */ new Set();
10313
+ lastEscalatedAt = null;
10314
+ now;
10315
+ intervalMs;
10316
+ constructor(intervalMs = PRESSURE_ESCALATION_INTERVAL_MS, now = () => Date.now()) {
10317
+ this.intervalMs = intervalMs;
10318
+ this.now = now;
10319
+ }
10320
+ /**
10321
+ * Record a failed write. Returns an escalation when the caller should act
10322
+ * (alert loudly, ask retention to reclaim), or null to stay quiet.
10323
+ *
10324
+ * A non-space failure returns null and is not counted: this tracker exists
10325
+ * for one condition, and diluting it with unrelated errors would make the
10326
+ * escalation mean nothing.
10327
+ */
10328
+ record(err, deviceId) {
10329
+ if (!isOutOfSpace(err)) return null;
10330
+ this.failures += 1;
10331
+ if (typeof deviceId === "number") this.devices.add(deviceId);
10332
+ const at = this.now();
10333
+ const first = this.lastEscalatedAt === null;
10334
+ if (!first && at - this.lastEscalatedAt < this.intervalMs) return null;
10335
+ const out = {
10336
+ failures: this.failures,
10337
+ deviceIds: [...this.devices],
10338
+ first
10339
+ };
10340
+ this.failures = 0;
10341
+ this.devices.clear();
10342
+ this.lastEscalatedAt = at;
10343
+ return out;
10344
+ }
10345
+ /** Clear the state after a successful write — pressure that has passed must
10346
+ * not make the next isolated failure look like a continuing emergency. */
10347
+ noteSuccess() {
10348
+ if (this.failures === 0 && this.devices.size === 0) return;
10349
+ this.failures = 0;
10350
+ this.devices.clear();
10351
+ }
10352
+ };
10353
+ //#endregion
10252
10354
  //#region src/pipeline-analytics/store/media-store.ts
10253
10355
  var MEDIA_COLLECTION = "pipeline-analytics:media";
10254
10356
  /** Owner-id prefix for a track's buffered FACE crop. Invariant established by
@@ -10358,6 +10460,14 @@ function buildPath(params) {
10358
10460
  return isSingleInstanceKind(params.kind) ? `${base}/${params.kind}.jpg` : `${base}/${params.kind}-${params.timestamp}.jpg`;
10359
10461
  }
10360
10462
  var MediaStore = class {
10463
+ /** Turns repeated out-of-space write failures into ONE escalation with a
10464
+ * count — see `storage-pressure.ts` for what five hours of silent media loss
10465
+ * cost. */
10466
+ pressure = new StoragePressureTracker();
10467
+ /** Optional hook so the owner can ask retention to reclaim space. Left
10468
+ * undefined by default: deciding there IS pressure and DELETING recordings
10469
+ * are separate powers on purpose. */
10470
+ onStoragePressure;
10361
10471
  storage;
10362
10472
  store;
10363
10473
  logger;
@@ -10406,6 +10516,7 @@ var MediaStore = class {
10406
10516
  data: record
10407
10517
  }
10408
10518
  });
10519
+ this.pressure.noteSuccess();
10409
10520
  return key;
10410
10521
  } catch (err) {
10411
10522
  this.logger.warn("media put failed", {
@@ -10415,6 +10526,19 @@ var MediaStore = class {
10415
10526
  error: String(err)
10416
10527
  }
10417
10528
  });
10529
+ const pressure = this.pressure.record(err, params.deviceId);
10530
+ if (pressure) {
10531
+ this.logger.error("media storage is out of space — media is being LOST", {
10532
+ tags: { deviceId: params.deviceId },
10533
+ meta: {
10534
+ failures: pressure.failures,
10535
+ devices: pressure.deviceIds,
10536
+ first: pressure.first,
10537
+ hint: "retention prunes by age and cannot see a full disk — free space or shorten retention"
10538
+ }
10539
+ });
10540
+ this.onStoragePressure?.(pressure);
10541
+ }
10418
10542
  throw err;
10419
10543
  }
10420
10544
  }
@@ -11024,6 +11148,54 @@ var EventStore = class {
11024
11148
  });
11025
11149
  }
11026
11150
  }
11151
+ /**
11152
+ * Collect the ids matching `filter`, then delete every matching row in ONE
11153
+ * statement. The shared body of every retention sweep here.
11154
+ *
11155
+ * The ids are still read because the callers delete each event's media in
11156
+ * lockstep and a bulk delete returns only a count. What is gone is the
11157
+ * per-row delete loop: ~7,200 round trips to prune a day of motion events,
11158
+ * each carrying every column of the row it deleted.
11159
+ *
11160
+ * Three properties, each of which cost a red test to learn:
11161
+ *
11162
+ * - **the filter is used for BOTH halves**, so what is reported and what is
11163
+ * removed cannot drift apart;
11164
+ * - **paging carries an explicit offset over ROWS READ.** Deleting each page
11165
+ * used to advance the cursor implicitly; reading without deleting re-reads
11166
+ * page one forever. And a row with a non-string id yields no id but still
11167
+ * occupies a place, so ids collected is the wrong cursor;
11168
+ * - **only an EMPTY page ends the scan.** The store caps pages on its own
11169
+ * (~500), so "fewer rows than requested" is normal, not the end — treating
11170
+ * it as the end silently deletes just the first page while reporting a
11171
+ * complete sweep;
11172
+ * - **a delete that removed nothing reports NO ids.** Callers remove media
11173
+ * against this list; claiming a deletion that did not happen would orphan
11174
+ * media in the opposite direction.
11175
+ */
11176
+ async drainMatching(collection, filter) {
11177
+ const ids = [];
11178
+ let offset = 0;
11179
+ for (;;) {
11180
+ const rows = await this.store.query.query({
11181
+ collection,
11182
+ filter: {
11183
+ ...filter,
11184
+ limit: 500,
11185
+ offset
11186
+ }
11187
+ });
11188
+ if (rows.length === 0) break;
11189
+ offset += rows.length;
11190
+ for (const row of rows) if (typeof row.id === "string") ids.push(row.id);
11191
+ }
11192
+ if (ids.length === 0) return ids;
11193
+ const { deleted } = await this.store.deleteWhere.mutate({
11194
+ collection,
11195
+ filter
11196
+ });
11197
+ return deleted === 0 ? [] : ids;
11198
+ }
11027
11199
  buildFilter(q) {
11028
11200
  const filter = {
11029
11201
  where: { deviceId: q.deviceId },
@@ -11344,35 +11516,10 @@ var EventStore = class {
11344
11516
  * page at a time (PRUNE_PAGE_SIZE) until no stale rows remain.
11345
11517
  */
11346
11518
  async evictBefore(params) {
11347
- const batch = async (collection, cutoffMs) => {
11348
- const ids = [];
11349
- for (;;) {
11350
- const rows = await this.store.query.query({
11351
- collection,
11352
- filter: {
11353
- ...params.deviceId !== void 0 ? { where: { deviceId: params.deviceId } } : {},
11354
- whereBetween: { timestamp: [0, cutoffMs] },
11355
- limit: 500
11356
- }
11357
- });
11358
- if (rows.length === 0) break;
11359
- let deletedInPage = 0;
11360
- for (const row of rows) {
11361
- const id = row.id;
11362
- if (typeof id !== "string") continue;
11363
- try {
11364
- await this.store.delete.mutate({
11365
- collection,
11366
- key: id
11367
- });
11368
- ids.push(id);
11369
- deletedInPage++;
11370
- } catch {}
11371
- }
11372
- if (deletedInPage === 0) break;
11373
- }
11374
- return ids;
11375
- };
11519
+ const batch = async (collection, cutoffMs) => this.drainMatching(collection, {
11520
+ ...params.deviceId !== void 0 ? { where: { deviceId: params.deviceId } } : {},
11521
+ whereBetween: { timestamp: [0, cutoffMs] }
11522
+ });
11376
11523
  return {
11377
11524
  motion: await batch(MOTION_EVENTS_COLLECTION, params.motionCutoffMs),
11378
11525
  object: await batch(OBJECT_EVENTS_COLLECTION, params.objectCutoffMs),
@@ -11395,35 +11542,16 @@ var EventStore = class {
11395
11542
  * so ALL matching rows are deleted regardless of total count.
11396
11543
  */
11397
11544
  async pruneBefore(input) {
11398
- const allForDevice = async (collection) => {
11399
- const ids = [];
11400
- for (;;) {
11401
- const rows = await this.store.query.query({
11402
- collection,
11403
- filter: {
11404
- where: { deviceId: input.deviceId },
11405
- whereBetween: { timestamp: [0, input.cutoffMs - 1] },
11406
- limit: 500
11407
- }
11408
- });
11409
- if (rows.length === 0) break;
11410
- let deletedInPage = 0;
11411
- for (const row of rows) {
11412
- const id = row.id;
11413
- if (typeof id !== "string") continue;
11414
- try {
11415
- await this.store.delete.mutate({
11416
- collection,
11417
- key: id
11418
- });
11419
- ids.push(id);
11420
- deletedInPage++;
11421
- } catch {}
11422
- }
11423
- if (deletedInPage === 0) break;
11424
- }
11425
- return ids;
11545
+ /**
11546
+ * The filter is written ONCE and used for both halves below, so the rows
11547
+ * reported and the rows deleted cannot drift apart. Inclusive `[0, cutoff-1]`
11548
+ * → selects timestamps strictly < `cutoffMs`.
11549
+ */
11550
+ const staleForDevice = {
11551
+ where: { deviceId: input.deviceId },
11552
+ whereBetween: { timestamp: [0, input.cutoffMs - 1] }
11426
11553
  };
11554
+ const allForDevice = async (collection) => this.drainMatching(collection, staleForDevice);
11427
11555
  const [motionIds, objectIds, audioIds] = await Promise.all([
11428
11556
  allForDevice(MOTION_EVENTS_COLLECTION),
11429
11557
  allForDevice(OBJECT_EVENTS_COLLECTION),
@@ -17546,6 +17674,22 @@ var OBJECT_EMBEDDING_COLUMNS = [
17546
17674
  type: "TEXT"
17547
17675
  }
17548
17676
  ];
17677
+ /**
17678
+ * Ceiling on the CANDIDATE pool a semantic search may load.
17679
+ *
17680
+ * `query()` was called with no limit at all, so a default search loaded the
17681
+ * entire collection — 86.5 MB across 7,375 rows on a live hub, every time,
17682
+ * before ranking anything.
17683
+ *
17684
+ * It cannot simply be the caller's result limit: search RANKS the pool and then
17685
+ * takes the top N, so limiting to N up front would return N arbitrary rows
17686
+ * instead of the N best. This bounds the pool well above any result count, and
17687
+ * `query()` orders by `timestamp` DESC whenever a limit applies so "the pool"
17688
+ * means the most recent rows rather than whatever the page order happened to
17689
+ * be. The caller logs when the cap is reached — a silently truncated search
17690
+ * reads as "nothing older matched".
17691
+ */
17692
+ var EMBEDDING_SEARCH_CANDIDATE_CAP = 5e3;
17549
17693
  var ObjectEmbeddingStore = class {
17550
17694
  store;
17551
17695
  logger;
@@ -17620,7 +17764,13 @@ var ObjectEmbeddingStore = class {
17620
17764
  if (input.modelId !== void 0) where["modelId"] = input.modelId;
17621
17765
  const filter = { where };
17622
17766
  if (input.since !== void 0 || input.until !== void 0) filter["whereBetween"] = { timestamp: [input.since ?? 0, input.until ?? Date.now()] };
17623
- if (input.limit !== void 0) filter["limit"] = input.limit;
17767
+ if (input.limit !== void 0) {
17768
+ filter["limit"] = input.limit;
17769
+ filter["orderBy"] = {
17770
+ field: "timestamp",
17771
+ direction: "desc"
17772
+ };
17773
+ }
17624
17774
  return (await this.store.query.query({
17625
17775
  collection: OBJECT_EMBEDDINGS_COLLECTION,
17626
17776
  filter
@@ -20735,7 +20885,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
20735
20885
  let storage = this.ctx.kernel.storage;
20736
20886
  const mediaRoot = process.env.CAMSTACK_MEDIA_ROOT?.trim();
20737
20887
  if (mediaRoot) {
20738
- const { FilesystemStorageProvider } = await Promise.resolve().then(() => require("../node-C1I1svLc.js"));
20888
+ const { FilesystemStorageProvider } = await Promise.resolve().then(() => require("../node-DrRsUzaw.js"));
20739
20889
  storage = new FilesystemStorageProvider(mediaRoot, { eventMedia: mediaRoot });
20740
20890
  logger.info("pipeline-analytics: event media rooted at CAMSTACK_MEDIA_ROOT", { meta: { mediaRoot } });
20741
20891
  }
@@ -23670,7 +23820,17 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
23670
23820
  deviceId: input.deviceId,
23671
23821
  since: input.since,
23672
23822
  until: input.until,
23673
- className: input.classFilter
23823
+ className: input.classFilter,
23824
+ limit: EMBEDDING_SEARCH_CANDIDATE_CAP
23825
+ });
23826
+ if (embeddingRows.length >= 5e3) this.ctx.logger.warn("searchObjectEvents: candidate pool truncated", {
23827
+ meta: {
23828
+ cap: EMBEDDING_SEARCH_CANDIDATE_CAP,
23829
+ since: input.since,
23830
+ until: input.until,
23831
+ className: input.classFilter
23832
+ },
23833
+ tags: input.deviceId !== void 0 ? { deviceId: input.deviceId } : void 0
23674
23834
  });
23675
23835
  if (embeddingRows.length === 0) return [];
23676
23836
  const embeddingMediaKeyByTrackId = /* @__PURE__ */ new Map();