@camstack/addon-post-analysis 1.2.150 → 1.2.151

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.
@@ -8242,6 +8242,21 @@ var RelocateJobSchema = object({
8242
8242
  bytesMoved: number().int(),
8243
8243
  /** Total files discovered up front; null while (or when) unknown. */
8244
8244
  filesTotal: number().int().nullable(),
8245
+ /**
8246
+ * Rows this run CORRECTED while moving them — a durable mutation the move
8247
+ * made that nobody asked for, so it is reported where the operator reads the
8248
+ * job rather than only in a log line.
8249
+ *
8250
+ * A footage segment records its byte count in its own NAME, and the durable
8251
+ * hour row derives its aggregates from those names. A file that does not
8252
+ * match its name therefore makes the ledger's sums — and with them quota and
8253
+ * pressure eviction — wrong by the difference, and only a rename can fix it.
8254
+ * On 2026-08-30 one such row also stalled a 110 749-file drain permanently.
8255
+ *
8256
+ * Absent on lanes where the question has no meaning: a media blob's size is
8257
+ * in its row, not in its name, so `MediaRelocateEngine` never reconciles one.
8258
+ */
8259
+ rowsReconciled: number().int().nonnegative().optional(),
8245
8260
  startedAt: number(),
8246
8261
  finishedAt: number().nullable(),
8247
8262
  error: string().nullable()
@@ -8310,14 +8325,42 @@ var RelocateMediaInputSchema = object({
8310
8325
  /** Omitted = `move`, the pre-existing behaviour. */
8311
8326
  mode: MediaRelocateModeSchema.optional()
8312
8327
  });
8313
- /** How many rows still carry NO `locationId` — the population a repoint would
8314
- * silently re-aim at a disk that does not hold their bytes. Zero is the only
8315
- * value that permits a non-blocking `eventMedia` cutover. */
8316
- var UnstampedEventMediaCountSchema = object({
8317
- media: number().int().nonnegative(),
8318
- retrainFrames: number().int().nonnegative(),
8319
- total: number().int().nonnegative()
8328
+ /**
8329
+ * The unstamped population of ONE collection — split, because the gate and the
8330
+ * operator ask two different questions and only one of them has to be cheap.
8331
+ *
8332
+ * `present` is the GATE: "is there at least one row that would be orphaned by a
8333
+ * repoint". It is a single indexed seek to the first matching row, so it stays
8334
+ * answerable on a saturated disk and answers in O(log n) precisely in the state
8335
+ * that matters — after a seal, when the population is empty.
8336
+ *
8337
+ * `rows` is the NUMBER, for the refusal message and the operator's sense of
8338
+ * scale. It is a second, indexed `COUNT(*)`, and `null` means **not
8339
+ * measurable** — never zero. `{ present: true, rows: null }` is a legitimate
8340
+ * and useful answer: "there are some, and this read could not say how many"
8341
+ * still refuses the cutover, which is the whole job.
8342
+ */
8343
+ var UnstampedRowsSchema = object({
8344
+ present: boolean(),
8345
+ rows: number().int().nonnegative().nullable()
8320
8346
  });
8347
+ /**
8348
+ * How many rows still carry NO `locationId` — the population a repoint would
8349
+ * silently re-aim at a disk that does not hold their bytes.
8350
+ *
8351
+ * **`null` = the count could not be taken**, and it is NOT permission to cut
8352
+ * over. The gate opens on a measured absence and on nothing else; an unread
8353
+ * collection and an empty one are different facts, and this repo has already
8354
+ * paid for conflating them (`RelocateResidueSchema`, D295).
8355
+ */
8356
+ var UnstampedEventMediaCountSchema = object({
8357
+ media: UnstampedRowsSchema,
8358
+ retrainFrames: UnstampedRowsSchema,
8359
+ /** True when EITHER collection holds one. The refusal reads this. */
8360
+ anyPresent: boolean(),
8361
+ /** Sum across both, or `null` when either lane could not be counted. */
8362
+ total: number().int().nonnegative().nullable()
8363
+ }).nullable();
8321
8364
  var StorageMigrationMediaMoveInputSchema = RelocateMediaInputSchema.extend({ leaseId: string().min(1) });
8322
8365
  /** The independently selectable logical storage classes — every class
8323
8366
  * `storage.listLocationDeclarations` reports, so an operator never meets a
@@ -8428,6 +8471,10 @@ var StorageMigrationMoveProgressSchema = object({
8428
8471
  /** The archive census — the **M** of "N of M" (D295). `null` = unknowable. */
8429
8472
  filesTotal: number().int().nonnegative().nullable(),
8430
8473
  bytesMoved: number().int().nonnegative(),
8474
+ /** Rows the mover corrected while moving them — see `RelocateJob`. Absent on
8475
+ * a lane that cannot reconcile. A migration that silently rewrote durable
8476
+ * rows would be the same failure as one that silently skipped them. */
8477
+ rowsReconciled: number().int().nonnegative().optional(),
8431
8478
  /** The MOVER's start, not the migration's: a drain restarted after an addon
8432
8479
  * crash gets a new mover, and a rate computed from the migration's start
8433
8480
  * would silently average in the time nothing was running. */
@@ -20455,6 +20502,17 @@ var pipelineAnalyticsCapability = {
20455
20502
  * happens to stamp it. This count is what the migration planner's
20456
20503
  * non-blocking gate reads; `relocateMedia({ mode: 'seal' })` is what drives
20457
20504
  * it to zero.
20505
+ *
20506
+ * TWO indexed statements per collection, not a walk. It used to page the
20507
+ * whole collection at 200 rows per RPC ordered by an unindexed column, so
20508
+ * on the live hub — 1 254 576 rows — it hit the 60 s RPC deadline every
20509
+ * time it was called, and the migration it gates could never start. The
20510
+ * cheap question (`present`: is there at least one) is asked first and
20511
+ * separately from the expensive one (`rows`), because only the first has
20512
+ * to be answerable for the gate to do its job.
20513
+ *
20514
+ * **`null` is "not measurable", never zero** — at either level. An
20515
+ * unreadable collection must not read as a sealed one.
20458
20516
  */
20459
20517
  countUnstampedEventMedia: method(object({}), UnstampedEventMediaCountSchema, { auth: "admin" }),
20460
20518
  /**
@@ -8273,6 +8273,21 @@ var RelocateJobSchema = object({
8273
8273
  bytesMoved: number().int(),
8274
8274
  /** Total files discovered up front; null while (or when) unknown. */
8275
8275
  filesTotal: number().int().nullable(),
8276
+ /**
8277
+ * Rows this run CORRECTED while moving them — a durable mutation the move
8278
+ * made that nobody asked for, so it is reported where the operator reads the
8279
+ * job rather than only in a log line.
8280
+ *
8281
+ * A footage segment records its byte count in its own NAME, and the durable
8282
+ * hour row derives its aggregates from those names. A file that does not
8283
+ * match its name therefore makes the ledger's sums — and with them quota and
8284
+ * pressure eviction — wrong by the difference, and only a rename can fix it.
8285
+ * On 2026-08-30 one such row also stalled a 110 749-file drain permanently.
8286
+ *
8287
+ * Absent on lanes where the question has no meaning: a media blob's size is
8288
+ * in its row, not in its name, so `MediaRelocateEngine` never reconciles one.
8289
+ */
8290
+ rowsReconciled: number().int().nonnegative().optional(),
8276
8291
  startedAt: number(),
8277
8292
  finishedAt: number().nullable(),
8278
8293
  error: string().nullable()
@@ -8341,14 +8356,42 @@ var RelocateMediaInputSchema = object({
8341
8356
  /** Omitted = `move`, the pre-existing behaviour. */
8342
8357
  mode: MediaRelocateModeSchema.optional()
8343
8358
  });
8344
- /** How many rows still carry NO `locationId` — the population a repoint would
8345
- * silently re-aim at a disk that does not hold their bytes. Zero is the only
8346
- * value that permits a non-blocking `eventMedia` cutover. */
8347
- var UnstampedEventMediaCountSchema = object({
8348
- media: number().int().nonnegative(),
8349
- retrainFrames: number().int().nonnegative(),
8350
- total: number().int().nonnegative()
8359
+ /**
8360
+ * The unstamped population of ONE collection — split, because the gate and the
8361
+ * operator ask two different questions and only one of them has to be cheap.
8362
+ *
8363
+ * `present` is the GATE: "is there at least one row that would be orphaned by a
8364
+ * repoint". It is a single indexed seek to the first matching row, so it stays
8365
+ * answerable on a saturated disk and answers in O(log n) precisely in the state
8366
+ * that matters — after a seal, when the population is empty.
8367
+ *
8368
+ * `rows` is the NUMBER, for the refusal message and the operator's sense of
8369
+ * scale. It is a second, indexed `COUNT(*)`, and `null` means **not
8370
+ * measurable** — never zero. `{ present: true, rows: null }` is a legitimate
8371
+ * and useful answer: "there are some, and this read could not say how many"
8372
+ * still refuses the cutover, which is the whole job.
8373
+ */
8374
+ var UnstampedRowsSchema = object({
8375
+ present: boolean(),
8376
+ rows: number().int().nonnegative().nullable()
8351
8377
  });
8378
+ /**
8379
+ * How many rows still carry NO `locationId` — the population a repoint would
8380
+ * silently re-aim at a disk that does not hold their bytes.
8381
+ *
8382
+ * **`null` = the count could not be taken**, and it is NOT permission to cut
8383
+ * over. The gate opens on a measured absence and on nothing else; an unread
8384
+ * collection and an empty one are different facts, and this repo has already
8385
+ * paid for conflating them (`RelocateResidueSchema`, D295).
8386
+ */
8387
+ var UnstampedEventMediaCountSchema = object({
8388
+ media: UnstampedRowsSchema,
8389
+ retrainFrames: UnstampedRowsSchema,
8390
+ /** True when EITHER collection holds one. The refusal reads this. */
8391
+ anyPresent: boolean(),
8392
+ /** Sum across both, or `null` when either lane could not be counted. */
8393
+ total: number().int().nonnegative().nullable()
8394
+ }).nullable();
8352
8395
  var StorageMigrationMediaMoveInputSchema = RelocateMediaInputSchema.extend({ leaseId: string().min(1) });
8353
8396
  /** The independently selectable logical storage classes — every class
8354
8397
  * `storage.listLocationDeclarations` reports, so an operator never meets a
@@ -8459,6 +8502,10 @@ var StorageMigrationMoveProgressSchema = object({
8459
8502
  /** The archive census — the **M** of "N of M" (D295). `null` = unknowable. */
8460
8503
  filesTotal: number().int().nonnegative().nullable(),
8461
8504
  bytesMoved: number().int().nonnegative(),
8505
+ /** Rows the mover corrected while moving them — see `RelocateJob`. Absent on
8506
+ * a lane that cannot reconcile. A migration that silently rewrote durable
8507
+ * rows would be the same failure as one that silently skipped them. */
8508
+ rowsReconciled: number().int().nonnegative().optional(),
8462
8509
  /** The MOVER's start, not the migration's: a drain restarted after an addon
8463
8510
  * crash gets a new mover, and a rate computed from the migration's start
8464
8511
  * would silently average in the time nothing was running. */
@@ -20486,6 +20533,17 @@ var pipelineAnalyticsCapability = {
20486
20533
  * happens to stamp it. This count is what the migration planner's
20487
20534
  * non-blocking gate reads; `relocateMedia({ mode: 'seal' })` is what drives
20488
20535
  * it to zero.
20536
+ *
20537
+ * TWO indexed statements per collection, not a walk. It used to page the
20538
+ * whole collection at 200 rows per RPC ordered by an unindexed column, so
20539
+ * on the live hub — 1 254 576 rows — it hit the 60 s RPC deadline every
20540
+ * time it was called, and the migration it gates could never start. The
20541
+ * cheap question (`present`: is there at least one) is asked first and
20542
+ * separately from the expensive one (`rows`), because only the first has
20543
+ * to be answerable for the gate to do its job.
20544
+ *
20545
+ * **`null` is "not measurable", never zero** — at either level. An
20546
+ * unreadable collection must not read as a sealed one.
20489
20547
  */
20490
20548
  countUnstampedEventMedia: method(object({}), UnstampedEventMediaCountSchema, { auth: "admin" }),
20491
20549
  /**
@@ -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-BTviHGUC.js");
5
+ const require_dist = require("../dist-DpQno5NK.js");
6
6
  let node_fs = require("node:fs");
7
7
  node_fs = require_dist.__toESM(node_fs);
8
8
  let node_path = require("node:path");
@@ -1,4 +1,4 @@
1
- import { At as BaseAddon, F as PoolMemoryWatchdog, St as resolvePoolMemoryPolicy, at as embeddingEncoderCapability, gt as parseProcStatus, ut as hfModelUrl } from "../dist-C8cA7PX_.mjs";
1
+ import { At as BaseAddon, F as PoolMemoryWatchdog, St as resolvePoolMemoryPolicy, at as embeddingEncoderCapability, gt as parseProcStatus, ut as hfModelUrl } from "../dist-B5K63Zmt.mjs";
2
2
  import { createRequire } from "node:module";
3
3
  import * as fs from "node:fs";
4
4
  import * as path$1 from "node:path";
@@ -3,7 +3,7 @@ import "./dist-CYZr2fwk.mjs";
3
3
  var e = {
4
4
  "@camstack/sdk": {
5
5
  name: "@camstack/sdk",
6
- version: "1.2.48",
6
+ version: "1.2.49",
7
7
  scope: ["default"],
8
8
  loaded: !1,
9
9
  from: "addon_pipeline_analytics_widgets",
@@ -18,7 +18,7 @@ var e = {
18
18
  },
19
19
  "@camstack/types": {
20
20
  name: "@camstack/types",
21
- version: "1.2.126",
21
+ version: "1.2.127",
22
22
  scope: ["default"],
23
23
  loaded: !1,
24
24
  from: "addon_pipeline_analytics_widgets",
@@ -33,7 +33,7 @@ var e = {
33
33
  },
34
34
  "@camstack/ui-library": {
35
35
  name: "@camstack/ui-library",
36
- version: "1.2.89",
36
+ version: "1.2.90",
37
37
  scope: ["default"],
38
38
  loaded: !1,
39
39
  from: "addon_pipeline_analytics_widgets",
@@ -36,7 +36,7 @@ async function r() {
36
36
  }
37
37
  },
38
38
  "@camstack/types": {
39
- version: "1.2.126",
39
+ version: "1.2.127",
40
40
  scope: "default",
41
41
  shareConfig: {
42
42
  singleton: !0,
@@ -45,7 +45,7 @@ async function r() {
45
45
  }
46
46
  },
47
47
  "@camstack/sdk": {
48
- version: "1.2.48",
48
+ version: "1.2.49",
49
49
  scope: "default",
50
50
  shareConfig: {
51
51
  singleton: !0,
@@ -81,7 +81,7 @@ async function r() {
81
81
  }
82
82
  },
83
83
  "@camstack/ui-library": {
84
- version: "1.2.89",
84
+ version: "1.2.90",
85
85
  scope: "default",
86
86
  shareConfig: {
87
87
  singleton: !0,
@@ -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-BTviHGUC.js");
5
+ const require_dist = require("../dist-DpQno5NK.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");
@@ -6270,12 +6270,86 @@ var NcDeviceStateCache = class {
6270
6270
  }
6271
6271
  };
6272
6272
  //#endregion
6273
+ //#region src/notification-center/system-event-tap-url.ts
6274
+ /** The admin UI's own routes, as `packages/addon-admin-ui/src/App.tsx` declares them. */
6275
+ var ADMIN_ADDONS_PATH = "/system/addons";
6276
+ var ADMIN_CLUSTER_PATH = "/system/cluster";
6277
+ var ADMIN_DEVICES_PATH = "/devices";
6278
+ /**
6279
+ * The query parameter naming a node.
6280
+ *
6281
+ * One spelling for both admin pages, mirrored by `NODE_PARAM` in
6282
+ * `packages/addon-admin-ui/src/lib/node-url-selection.ts` and pinned across
6283
+ * the two packages by `scripts/check-notification-tap-targets.ts`.
6284
+ */
6285
+ var NODE_PARAM = "node";
6286
+ /** The device page's top-level tab parameter, read by `DeviceDetail`. */
6287
+ var TAB_PARAM = "tab";
6288
+ /** The Stream Broker tab id — a `WELL_KNOWN_TABS` member, hoisted `top-tab`. */
6289
+ var STREAM_BROKER_TAB = "stream-broker";
6290
+ /** `DeviceType.Camera`. Spelled here rather than imported so this stays a pure
6291
+ * string map with no device-model dependency; the guard pins it. */
6292
+ var CAMERA_DEVICE_TYPE = "camera";
6293
+ /** Kinds whose action is "go and apply an ADDON update". */
6294
+ var ADDON_UPDATE_KINDS = new Set(["addon-update-available"]);
6295
+ /** Kinds whose action is "go and apply a NODE's own (`@camstack/server`) update". */
6296
+ var NODE_UPDATE_KINDS = new Set(["server-update-available"]);
6297
+ /**
6298
+ * Kinds whose action is "go and look at why this camera stopped producing".
6299
+ *
6300
+ * The legacy `camera-offline` is here for the same reason it is still in the
6301
+ * enum: outbox and history rows enqueued before the rename carry it, and an
6302
+ * old row is exactly the one whose action has to keep working.
6303
+ */
6304
+ var DEVICE_DOWN_KINDS = new Set([
6305
+ "device-offline",
6306
+ "stream-offline",
6307
+ "camera-offline"
6308
+ ]);
6309
+ /**
6310
+ * Kinds whose subject is a camera BY CONSTRUCTION, whatever the row says.
6311
+ *
6312
+ * A stream belongs to a camera and nothing else. The legacy `camera-offline`
6313
+ * is one too — that name is precisely the claim it made, and it is why the
6314
+ * kind was split into `device-offline` + a `deviceTypes` condition.
6315
+ */
6316
+ var CAMERA_BY_CONSTRUCTION_KINDS = new Set(["stream-offline", "camera-offline"]);
6317
+ function withParam(path, key, value) {
6318
+ if (value === void 0 || value.length === 0) return path;
6319
+ return `${path}?${key}=${encodeURIComponent(value)}`;
6320
+ }
6321
+ /**
6322
+ * The admin path (with its query) a system event taps through to, or `null`
6323
+ * when the kind has no action.
6324
+ *
6325
+ * Exported for the cross-package guard and for tests that want the path
6326
+ * without a base; the dispatcher goes through {@link systemEventTapUrl}.
6327
+ */
6328
+ function systemEventTapPath(subject) {
6329
+ if (ADDON_UPDATE_KINDS.has(subject.kind)) return withParam(ADMIN_ADDONS_PATH, NODE_PARAM, subject.nodeId);
6330
+ if (NODE_UPDATE_KINDS.has(subject.kind)) return withParam(ADMIN_CLUSTER_PATH, NODE_PARAM, subject.nodeId);
6331
+ if (DEVICE_DOWN_KINDS.has(subject.kind)) {
6332
+ const deviceId = subject.deviceId;
6333
+ if (deviceId === void 0 || !Number.isInteger(deviceId) || deviceId <= 0) return null;
6334
+ const devicePath = `${ADMIN_DEVICES_PATH}/${String(deviceId)}`;
6335
+ return CAMERA_BY_CONSTRUCTION_KINDS.has(subject.kind) || subject.deviceType === CAMERA_DEVICE_TYPE ? withParam(devicePath, TAB_PARAM, STREAM_BROKER_TAB) : devicePath;
6336
+ }
6337
+ return null;
6338
+ }
6339
+ /** Full URL, or `null` when the kind has no action or no base is reachable. */
6340
+ function systemEventTapUrl(subject, baseUrl) {
6341
+ if (baseUrl === null || baseUrl.length === 0) return null;
6342
+ const path = systemEventTapPath(subject);
6343
+ if (path === null) return null;
6344
+ return `${baseUrl.replace(/\/+$/, "")}${path}`;
6345
+ }
6346
+ //#endregion
6273
6347
  //#region src/notification-center/default-tap-url.ts
6274
6348
  /** The viewer's hosted base path under the hub. */
6275
6349
  var VIEWER_BASE_PATH = "/viewer/camstack";
6276
6350
  function defaultTapUrl(input) {
6277
- if (input.systemEvent === true) return null;
6278
6351
  if (input.baseUrl === null || input.baseUrl.length === 0) return null;
6352
+ if (input.systemEvent !== void 0) return systemEventTapUrl(input.systemEvent, input.baseUrl);
6279
6353
  if (!Number.isInteger(input.deviceId) || input.deviceId <= 0) return null;
6280
6354
  return `${input.baseUrl.replace(/\/+$/, "")}${VIEWER_BASE_PATH}/camera/${String(input.deviceId)}?at=${String(input.timestampMs)}`;
6281
6355
  }
@@ -8868,13 +8942,14 @@ function trailAt(trail, timestamp) {
8868
8942
  * cannot be asked (no trail, no polygon) or when nobody qualifies.
8869
8943
  */
8870
8944
  function selectTriggerZoneCandidates(input) {
8871
- const { candidates, trail, polygons } = input;
8945
+ const { candidates, trail, polygons, strict } = input;
8872
8946
  if (candidates.length === 0 || trail.length === 0 || polygons.length === 0) return candidates;
8873
8947
  const qualifying = candidates.filter((candidate) => {
8874
8948
  const point = trailAt(trail, candidate.timestamp);
8875
8949
  return point !== null && zoneOverlap(point, polygons) > 0;
8876
8950
  });
8877
- return qualifying.length > 0 ? qualifying : candidates;
8951
+ if (qualifying.length > 0) return qualifying;
8952
+ return strict === true ? [] : candidates;
8878
8953
  }
8879
8954
  //#endregion
8880
8955
  //#region src/notification-center/dispatcher.ts
@@ -8912,6 +8987,24 @@ var NC_STILL_WAIT_MS = 3e3;
8912
8987
  * is the instant; the ladder then runs among the frames that are about it.
8913
8988
  */
8914
8989
  var NC_TRIGGER_STILL_MAX_AGE_MS = TRAIL_MATCH_MAX_MS;
8990
+ /**
8991
+ * Whether this row's subject can still be photographed AGAIN after its trigger.
8992
+ *
8993
+ * The one thing that separates a row worth waiting for from a row whose only
8994
+ * frame already exists. A live visual track keeps writing media for as long as
8995
+ * it is open — its first frame, its periodic snapshots, its last frame all land
8996
+ * after the instant a zone condition fired. Every other record is photographed
8997
+ * once: an audio window, a device event and a package event are served by the
8998
+ * still shelf, which D178 makes take ONE picture for a whole burst, and a
8999
+ * `track-end` row is by definition past the last of them.
9000
+ *
9001
+ * Waiting on the wrong side of this line has cost both ways in production — 3 s
9002
+ * of latency on a scream when nothing more was coming, and an empty doorstep on
9003
+ * 'Persona su Uscio' when something was.
9004
+ */
9005
+ function canStillGainMedia(entry) {
9006
+ return entry.recordKind === "object-event";
9007
+ }
8915
9008
  var NcDispatcher = class {
8916
9009
  deps;
8917
9010
  targetCache = null;
@@ -9391,7 +9484,12 @@ var NcDispatcher = class {
9391
9484
  deviceId: systemEvent !== void 0 ? 0 : subject.deviceId,
9392
9485
  timestampMs: subject.timestamp,
9393
9486
  baseUrl: await this.deps.viewerUrlBase().catch(() => null),
9394
- systemEvent: systemEvent !== void 0
9487
+ ...systemEvent !== void 0 ? { systemEvent: {
9488
+ kind: systemEvent.kind,
9489
+ ...systemEvent.deviceId !== void 0 ? { deviceId: systemEvent.deviceId } : {},
9490
+ ...systemEvent.deviceType !== void 0 ? { deviceType: systemEvent.deviceType } : {},
9491
+ ...systemEvent.nodeId !== void 0 ? { nodeId: systemEvent.nodeId } : {}
9492
+ } } : {}
9395
9493
  }) : null;
9396
9494
  const actions = await this.mintActions(entry, target);
9397
9495
  return {
@@ -9526,7 +9624,7 @@ var NcDispatcher = class {
9526
9624
  const out = [];
9527
9625
  const zoneIdsWanted = entry.payload.mediaZoneIds;
9528
9626
  const stillOverride = zoneIdsWanted !== void 0 && zoneIdsWanted.length > 0 ? "keyFrame" : void 0;
9529
- const firstLook = await this.resolveAttachment(entry, stillOverride, "any");
9627
+ const firstLook = await this.resolveAttachment(entry, stillOverride, canStillGainMedia(entry) ? "trigger-only" : "any");
9530
9628
  const footagePending = this.renderFootage(entry);
9531
9629
  const still = firstLook !== null || entry.payload.media === "none" ? firstLook : await this.waitForStill(entry, stillOverride);
9532
9630
  const footage = await footagePending;
@@ -9770,20 +9868,34 @@ var NcDispatcher = class {
9770
9868
  * throw. And when nothing qualifies, the full list comes back: a real picture
9771
9869
  * beats no picture (the same rule D212 settled for the best shot).
9772
9870
  */
9773
- async narrowToTriggerZone(entry, ordered) {
9871
+ async narrowToTriggerZone(entry, ordered, freshness) {
9774
9872
  const zoneIds = entry.payload.triggerZoneIds;
9775
9873
  const trackId = entry.payload.subject.trackId;
9776
9874
  const getTrail = this.deps.getSubjectTrail;
9777
9875
  const deviceId = entry.payload.subject.deviceId;
9778
- if (zoneIds === void 0 || zoneIds.length === 0 || trackId === void 0 || getTrail === void 0 || ordered.length < 2) return ordered;
9876
+ const strict = freshness === "trigger-only";
9877
+ if (zoneIds === void 0 || zoneIds.length === 0 || trackId === void 0 || getTrail === void 0 || ordered.length < 2 && !strict) return ordered;
9779
9878
  try {
9780
9879
  const [polygons, trail] = await Promise.all([this.deps.getZonePolygons?.(deviceId, zoneIds) ?? Promise.resolve([]), getTrail(deviceId, trackId)]);
9781
9880
  const kept = selectTriggerZoneCandidates({
9782
9881
  candidates: ordered,
9783
9882
  trail,
9784
9883
  polygons,
9785
- triggerAt: entry.payload.subject.timestamp
9884
+ triggerAt: entry.payload.subject.timestamp,
9885
+ strict
9786
9886
  });
9887
+ if (kept.length === 0) {
9888
+ this.deps.logger.debug("no still from the zone that fired the rule — still waiting", {
9889
+ tags: { deviceId },
9890
+ meta: {
9891
+ ruleId: entry.ruleId,
9892
+ trackId,
9893
+ zoneIds: zoneIds.join(","),
9894
+ rejected: ordered.length
9895
+ }
9896
+ });
9897
+ return kept;
9898
+ }
9787
9899
  if (kept.length === ordered.length) return kept;
9788
9900
  this.deps.logger.info("still narrowed to the zone that fired the rule", {
9789
9901
  tags: { deviceId },
@@ -9863,7 +9975,8 @@ var NcDispatcher = class {
9863
9975
  for (const kind of preference) for (const file of files) if (file.kind === kind) ordered.push(file);
9864
9976
  const inBand = this.narrowToTriggerInstant(entry, ordered, freshness);
9865
9977
  if (inBand.length === 0) continue;
9866
- const shortlist = await this.narrowToTriggerZone(entry, inBand);
9978
+ const shortlist = await this.narrowToTriggerZone(entry, inBand, freshness);
9979
+ if (shortlist.length === 0) continue;
9867
9980
  for (const file of shortlist) {
9868
9981
  const raw = Buffer.from(file.base64, "base64");
9869
9982
  if (raw.byteLength === 0) continue;
@@ -25509,13 +25622,46 @@ var MEDIA_COLUMNS = [
25509
25622
  type: "TEXT"
25510
25623
  })
25511
25624
  ];
25512
- var MEDIA_INDEXES = [{
25513
- name: "idx_media_owner",
25514
- columns: ["ownerKind", "ownerId"]
25515
- }, {
25516
- name: "idx_media_device_ts",
25517
- columns: ["deviceId", "timestamp"]
25518
- }];
25625
+ var MEDIA_INDEXES = [
25626
+ {
25627
+ name: "idx_media_owner",
25628
+ columns: ["ownerKind", "ownerId"]
25629
+ },
25630
+ {
25631
+ name: "idx_media_device_ts",
25632
+ columns: ["deviceId", "timestamp"]
25633
+ },
25634
+ (
25635
+ /**
25636
+ * The `locationId IS NULL` seal gate — see `media-relocate-engine.ts`.
25637
+ *
25638
+ * NULLs sort FIRST in a SQLite index, so `WHERE locationId IS NULL` is a
25639
+ * contiguous range from the head of the b-tree: its cost is proportional to
25640
+ * the ANSWER, not to the table. That is the property the gate needs. The
25641
+ * question it must answer fastest is "are there none left", and with this
25642
+ * index that is one seek that immediately falls off the end of an empty
25643
+ * range — regardless of how many million stamped rows sit behind it.
25644
+ *
25645
+ * **What it costs.** One extra b-tree maintained by every insert into a
25646
+ * table taking ~92 k rows/day, and roughly 25 bytes of index per row (~30 MB
25647
+ * at 1.25 M rows). At ~1 insert/s sustained that maintenance is noise
25648
+ * against the JPEG write the same call performs. The one-off cost is real
25649
+ * and worth naming: the FIRST declare after this ships builds the index over
25650
+ * every existing row, on a recordings volume that is currently saturated —
25651
+ * expect the analytics addon's boot to stall for tens of seconds to a couple
25652
+ * of minutes, once.
25653
+ *
25654
+ * A PARTIAL index (`WHERE locationId IS NULL`) would be smaller still and
25655
+ * would cost nothing at all once sealed. It is not used because
25656
+ * `CollectionIndexSchema` has no predicate and the schema fingerprint does
25657
+ * not hash one — adding both to serve one query is a bigger change than the
25658
+ * query deserves, and the plain index already makes the gate O(log n).
25659
+ */
25660
+ {
25661
+ name: "idx_media_location",
25662
+ columns: ["locationId"]
25663
+ })
25664
+ ];
25519
25665
  /** The storage location of a media row/record: its stamped `locationId`, or
25520
25666
  * the default `eventMedia` location for NULL (pre-multi-location) rows. */
25521
25667
  var DEFAULT_MEDIA_LOCATION = "eventMedia";
@@ -41130,13 +41276,29 @@ var RETRAIN_FRAME_COLUMNS = [
41130
41276
  type: "TEXT"
41131
41277
  })
41132
41278
  ];
41133
- var RETRAIN_FRAME_INDEXES = [{
41134
- name: "idx_retrain_frames_track",
41135
- columns: ["trackId"]
41136
- }, {
41137
- name: "idx_retrain_frames_device",
41138
- columns: ["deviceId", "copiedAt"]
41139
- }];
41279
+ var RETRAIN_FRAME_INDEXES = [
41280
+ {
41281
+ name: "idx_retrain_frames_track",
41282
+ columns: ["trackId"]
41283
+ },
41284
+ {
41285
+ name: "idx_retrain_frames_device",
41286
+ columns: ["deviceId", "copiedAt"]
41287
+ },
41288
+ (
41289
+ /**
41290
+ * The seal gate's other half. This collection is orders of magnitude smaller
41291
+ * than `pipeline-analytics:media` — every row is an operator-staged frame —
41292
+ * so the index buys much less here. It exists anyway because the gate reads
41293
+ * the two lanes with ONE query shape, and a lane that answers by scanning is
41294
+ * a lane that starts timing out the day the dataset grows. See
41295
+ * `MEDIA_INDEXES` for the cost this is a small multiple of.
41296
+ */
41297
+ {
41298
+ name: "idx_retrain_frames_location",
41299
+ columns: ["locationId"]
41300
+ })
41301
+ ];
41140
41302
  /**
41141
41303
  * The copy's id, derived from `(trackId, sourceMediaKey)`.
41142
41304
  *
@@ -44402,14 +44564,27 @@ function snapshot(j) {
44402
44564
  };
44403
44565
  }
44404
44566
  /**
44567
+ * The column both walked collections use for the storage location.
44568
+ *
44569
+ * Named once because it is now a QUERY PREDICATE (`where: { [it]: null }`) as
44570
+ * well as a row field. A predicate naming a column the collection cannot
44571
+ * express is refused by `count` rather than dropped, so a rename that misses
44572
+ * one site fails loudly instead of counting the whole table.
44573
+ */
44574
+ var LOCATION_ID_FIELD = "locationId";
44575
+ /**
44405
44576
  * True when the row names NO location — the population `seal` exists for.
44406
44577
  *
44407
44578
  * Deliberately NOT `mediaRowLocation(data) === DEFAULT_MEDIA_LOCATION`: that
44408
44579
  * helper collapses "unstamped" and "stamped with the bare type ref" into one
44409
44580
  * answer, and only the first is a row whose meaning changes under a repoint.
44581
+ *
44582
+ * The in-JS twin of the `IS NULL` predicate {@link MediaRelocateEngine.countUnstamped}
44583
+ * sends. It stays because the PASS still walks rows and must decide per row;
44584
+ * the two must agree, and `unstamped-count-matches-the-pass` asserts they do.
44410
44585
  */
44411
44586
  function rowIsUnstamped(data) {
44412
- const id = data["locationId"];
44587
+ const id = data[LOCATION_ID_FIELD];
44413
44588
  return !(typeof id === "string" && id.length > 0);
44414
44589
  }
44415
44590
  /** True for the enrolled-gallery rows `move` skips and `gallery` selects. */
@@ -44467,26 +44642,91 @@ var MediaRelocateEngine = class {
44467
44642
  return true;
44468
44643
  }
44469
44644
  /**
44470
- * How many rows still carry no `locationId`, per collection.
44645
+ * Rows that still carry no `locationId`, per collection.
44471
44646
  *
44472
- * Read-only, and the ONLY number the migration planner's non-blocking gate
44473
- * is allowed to trust: it is counted here, against the rows themselves,
44647
+ * Read-only, and the ONLY answer the migration planner's non-blocking gate
44648
+ * is allowed to trust: it is measured here, against the rows themselves,
44474
44649
  * rather than inferred from "a seal job reported done" — a seal that failed
44475
44650
  * some rows still reports a terminal state.
44651
+ *
44652
+ * ── Why this is not the walk the rest of this file uses ──────────────────
44653
+ *
44654
+ * It used to be. It paged both collections through {@link pageRows} — 200
44655
+ * rows per RPC, ordered by `timestamp`, a column that leads no index — and
44656
+ * counted `rowIsUnstamped` in JS. On the live hub that is ~6 300 round trips
44657
+ * over 1 254 576 rows, each paying its own sort, and it hit the 60 s RPC
44658
+ * deadline **every time it was called since it shipped**. The gate it exists
44659
+ * to serve could therefore never open, and 152 GB of event media could not
44660
+ * be migrated.
44661
+ *
44662
+ * The walk is right for the PASS, which must visit every row to act on it.
44663
+ * It was never right for a question the store can answer with a predicate,
44664
+ * and the predicate is now expressible: `where: { locationId: null }`
44665
+ * compiles to `IS NULL` (it used to compile to `= NULL`, which matches
44666
+ * nothing and reports it as an answer — the number the gate treats as "safe
44667
+ * to cut over"). With `idx_media_location` in place both statements read a
44668
+ * contiguous range from the head of a b-tree.
44669
+ *
44670
+ * The cheap question is asked FIRST and on its own. `present` is a single
44671
+ * seek and decides the gate; `rows` is a second statement and only decorates
44672
+ * the refusal. So a count that cannot be taken still leaves a usable answer
44673
+ * — `{ present: true, rows: null }` refuses just as hard — and `null` never
44674
+ * degrades into `0`.
44476
44675
  */
44477
44676
  async countUnstamped() {
44478
- const media = await this.countUnstampedIn(MEDIA_COLLECTION, "timestamp");
44479
- const retrainFrames = await this.countUnstampedIn(RETRAIN_FRAMES_COLLECTION, "copiedAt");
44480
- return {
44481
- media,
44482
- retrainFrames,
44483
- total: media + retrainFrames
44484
- };
44677
+ try {
44678
+ const media = await this.countUnstampedIn(MEDIA_COLLECTION);
44679
+ const retrainFrames = await this.countUnstampedIn(RETRAIN_FRAMES_COLLECTION);
44680
+ return {
44681
+ media,
44682
+ retrainFrames,
44683
+ anyPresent: media.present || retrainFrames.present,
44684
+ total: media.rows === null || retrainFrames.rows === null ? null : media.rows + retrainFrames.rows
44685
+ };
44686
+ } catch (err) {
44687
+ this.deps.logger.warn("event-media seal gate: could not determine whether any row is unstamped — a migration must treat this as unknown, never as sealed", { meta: { error: String(err) } });
44688
+ return null;
44689
+ }
44485
44690
  }
44486
- async countUnstampedIn(collection, orderField) {
44487
- let unstamped = 0;
44488
- for await (const row of this.pageRows(collection, orderField)) if (rowIsUnstamped(row.data)) unstamped++;
44489
- return unstamped;
44691
+ /**
44692
+ * One collection's unstamped population: existence, then size.
44693
+ *
44694
+ * The `limit: 1` probe is deliberately a `query` and not a `count`: a count
44695
+ * of a million matching rows still walks a million index entries, while the
44696
+ * probe stops at the first one. Absence is proved by the same seek finding
44697
+ * nothing, which is where the index earns its keep — after a seal, the range
44698
+ * is empty and the answer is one b-tree descent.
44699
+ */
44700
+ async countUnstampedIn(collection) {
44701
+ if ((await this.deps.store.query.query({
44702
+ collection,
44703
+ filter: {
44704
+ where: { [LOCATION_ID_FIELD]: null },
44705
+ limit: 1
44706
+ },
44707
+ columns: [LOCATION_ID_FIELD]
44708
+ })).length === 0) return {
44709
+ present: false,
44710
+ rows: 0
44711
+ };
44712
+ try {
44713
+ return {
44714
+ present: true,
44715
+ rows: await this.deps.store.count.query({
44716
+ collection,
44717
+ filter: { where: { [LOCATION_ID_FIELD]: null } }
44718
+ })
44719
+ };
44720
+ } catch (err) {
44721
+ this.deps.logger.warn("event-media seal gate: unstamped rows exist but could not be counted — reporting the population as unknown, not as empty", { meta: {
44722
+ collection,
44723
+ error: String(err)
44724
+ } });
44725
+ return {
44726
+ present: true,
44727
+ rows: null
44728
+ };
44729
+ }
44490
44730
  }
44491
44731
  start(input) {
44492
44732
  for (const j of this.jobs.values()) if (j.state === "running") throw new Error(`a media relocation is already running (${j.jobId})`);
@@ -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-C8cA7PX_.mjs";
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-B5K63Zmt.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";
@@ -6263,12 +6263,86 @@ var NcDeviceStateCache = class {
6263
6263
  }
6264
6264
  };
6265
6265
  //#endregion
6266
+ //#region src/notification-center/system-event-tap-url.ts
6267
+ /** The admin UI's own routes, as `packages/addon-admin-ui/src/App.tsx` declares them. */
6268
+ var ADMIN_ADDONS_PATH = "/system/addons";
6269
+ var ADMIN_CLUSTER_PATH = "/system/cluster";
6270
+ var ADMIN_DEVICES_PATH = "/devices";
6271
+ /**
6272
+ * The query parameter naming a node.
6273
+ *
6274
+ * One spelling for both admin pages, mirrored by `NODE_PARAM` in
6275
+ * `packages/addon-admin-ui/src/lib/node-url-selection.ts` and pinned across
6276
+ * the two packages by `scripts/check-notification-tap-targets.ts`.
6277
+ */
6278
+ var NODE_PARAM = "node";
6279
+ /** The device page's top-level tab parameter, read by `DeviceDetail`. */
6280
+ var TAB_PARAM = "tab";
6281
+ /** The Stream Broker tab id — a `WELL_KNOWN_TABS` member, hoisted `top-tab`. */
6282
+ var STREAM_BROKER_TAB = "stream-broker";
6283
+ /** `DeviceType.Camera`. Spelled here rather than imported so this stays a pure
6284
+ * string map with no device-model dependency; the guard pins it. */
6285
+ var CAMERA_DEVICE_TYPE = "camera";
6286
+ /** Kinds whose action is "go and apply an ADDON update". */
6287
+ var ADDON_UPDATE_KINDS = new Set(["addon-update-available"]);
6288
+ /** Kinds whose action is "go and apply a NODE's own (`@camstack/server`) update". */
6289
+ var NODE_UPDATE_KINDS = new Set(["server-update-available"]);
6290
+ /**
6291
+ * Kinds whose action is "go and look at why this camera stopped producing".
6292
+ *
6293
+ * The legacy `camera-offline` is here for the same reason it is still in the
6294
+ * enum: outbox and history rows enqueued before the rename carry it, and an
6295
+ * old row is exactly the one whose action has to keep working.
6296
+ */
6297
+ var DEVICE_DOWN_KINDS = new Set([
6298
+ "device-offline",
6299
+ "stream-offline",
6300
+ "camera-offline"
6301
+ ]);
6302
+ /**
6303
+ * Kinds whose subject is a camera BY CONSTRUCTION, whatever the row says.
6304
+ *
6305
+ * A stream belongs to a camera and nothing else. The legacy `camera-offline`
6306
+ * is one too — that name is precisely the claim it made, and it is why the
6307
+ * kind was split into `device-offline` + a `deviceTypes` condition.
6308
+ */
6309
+ var CAMERA_BY_CONSTRUCTION_KINDS = new Set(["stream-offline", "camera-offline"]);
6310
+ function withParam(path, key, value) {
6311
+ if (value === void 0 || value.length === 0) return path;
6312
+ return `${path}?${key}=${encodeURIComponent(value)}`;
6313
+ }
6314
+ /**
6315
+ * The admin path (with its query) a system event taps through to, or `null`
6316
+ * when the kind has no action.
6317
+ *
6318
+ * Exported for the cross-package guard and for tests that want the path
6319
+ * without a base; the dispatcher goes through {@link systemEventTapUrl}.
6320
+ */
6321
+ function systemEventTapPath(subject) {
6322
+ if (ADDON_UPDATE_KINDS.has(subject.kind)) return withParam(ADMIN_ADDONS_PATH, NODE_PARAM, subject.nodeId);
6323
+ if (NODE_UPDATE_KINDS.has(subject.kind)) return withParam(ADMIN_CLUSTER_PATH, NODE_PARAM, subject.nodeId);
6324
+ if (DEVICE_DOWN_KINDS.has(subject.kind)) {
6325
+ const deviceId = subject.deviceId;
6326
+ if (deviceId === void 0 || !Number.isInteger(deviceId) || deviceId <= 0) return null;
6327
+ const devicePath = `${ADMIN_DEVICES_PATH}/${String(deviceId)}`;
6328
+ return CAMERA_BY_CONSTRUCTION_KINDS.has(subject.kind) || subject.deviceType === CAMERA_DEVICE_TYPE ? withParam(devicePath, TAB_PARAM, STREAM_BROKER_TAB) : devicePath;
6329
+ }
6330
+ return null;
6331
+ }
6332
+ /** Full URL, or `null` when the kind has no action or no base is reachable. */
6333
+ function systemEventTapUrl(subject, baseUrl) {
6334
+ if (baseUrl === null || baseUrl.length === 0) return null;
6335
+ const path = systemEventTapPath(subject);
6336
+ if (path === null) return null;
6337
+ return `${baseUrl.replace(/\/+$/, "")}${path}`;
6338
+ }
6339
+ //#endregion
6266
6340
  //#region src/notification-center/default-tap-url.ts
6267
6341
  /** The viewer's hosted base path under the hub. */
6268
6342
  var VIEWER_BASE_PATH = "/viewer/camstack";
6269
6343
  function defaultTapUrl(input) {
6270
- if (input.systemEvent === true) return null;
6271
6344
  if (input.baseUrl === null || input.baseUrl.length === 0) return null;
6345
+ if (input.systemEvent !== void 0) return systemEventTapUrl(input.systemEvent, input.baseUrl);
6272
6346
  if (!Number.isInteger(input.deviceId) || input.deviceId <= 0) return null;
6273
6347
  return `${input.baseUrl.replace(/\/+$/, "")}${VIEWER_BASE_PATH}/camera/${String(input.deviceId)}?at=${String(input.timestampMs)}`;
6274
6348
  }
@@ -8861,13 +8935,14 @@ function trailAt(trail, timestamp) {
8861
8935
  * cannot be asked (no trail, no polygon) or when nobody qualifies.
8862
8936
  */
8863
8937
  function selectTriggerZoneCandidates(input) {
8864
- const { candidates, trail, polygons } = input;
8938
+ const { candidates, trail, polygons, strict } = input;
8865
8939
  if (candidates.length === 0 || trail.length === 0 || polygons.length === 0) return candidates;
8866
8940
  const qualifying = candidates.filter((candidate) => {
8867
8941
  const point = trailAt(trail, candidate.timestamp);
8868
8942
  return point !== null && zoneOverlap(point, polygons) > 0;
8869
8943
  });
8870
- return qualifying.length > 0 ? qualifying : candidates;
8944
+ if (qualifying.length > 0) return qualifying;
8945
+ return strict === true ? [] : candidates;
8871
8946
  }
8872
8947
  //#endregion
8873
8948
  //#region src/notification-center/dispatcher.ts
@@ -8905,6 +8980,24 @@ var NC_STILL_WAIT_MS = 3e3;
8905
8980
  * is the instant; the ladder then runs among the frames that are about it.
8906
8981
  */
8907
8982
  var NC_TRIGGER_STILL_MAX_AGE_MS = TRAIL_MATCH_MAX_MS;
8983
+ /**
8984
+ * Whether this row's subject can still be photographed AGAIN after its trigger.
8985
+ *
8986
+ * The one thing that separates a row worth waiting for from a row whose only
8987
+ * frame already exists. A live visual track keeps writing media for as long as
8988
+ * it is open — its first frame, its periodic snapshots, its last frame all land
8989
+ * after the instant a zone condition fired. Every other record is photographed
8990
+ * once: an audio window, a device event and a package event are served by the
8991
+ * still shelf, which D178 makes take ONE picture for a whole burst, and a
8992
+ * `track-end` row is by definition past the last of them.
8993
+ *
8994
+ * Waiting on the wrong side of this line has cost both ways in production — 3 s
8995
+ * of latency on a scream when nothing more was coming, and an empty doorstep on
8996
+ * 'Persona su Uscio' when something was.
8997
+ */
8998
+ function canStillGainMedia(entry) {
8999
+ return entry.recordKind === "object-event";
9000
+ }
8908
9001
  var NcDispatcher = class {
8909
9002
  deps;
8910
9003
  targetCache = null;
@@ -9384,7 +9477,12 @@ var NcDispatcher = class {
9384
9477
  deviceId: systemEvent !== void 0 ? 0 : subject.deviceId,
9385
9478
  timestampMs: subject.timestamp,
9386
9479
  baseUrl: await this.deps.viewerUrlBase().catch(() => null),
9387
- systemEvent: systemEvent !== void 0
9480
+ ...systemEvent !== void 0 ? { systemEvent: {
9481
+ kind: systemEvent.kind,
9482
+ ...systemEvent.deviceId !== void 0 ? { deviceId: systemEvent.deviceId } : {},
9483
+ ...systemEvent.deviceType !== void 0 ? { deviceType: systemEvent.deviceType } : {},
9484
+ ...systemEvent.nodeId !== void 0 ? { nodeId: systemEvent.nodeId } : {}
9485
+ } } : {}
9388
9486
  }) : null;
9389
9487
  const actions = await this.mintActions(entry, target);
9390
9488
  return {
@@ -9519,7 +9617,7 @@ var NcDispatcher = class {
9519
9617
  const out = [];
9520
9618
  const zoneIdsWanted = entry.payload.mediaZoneIds;
9521
9619
  const stillOverride = zoneIdsWanted !== void 0 && zoneIdsWanted.length > 0 ? "keyFrame" : void 0;
9522
- const firstLook = await this.resolveAttachment(entry, stillOverride, "any");
9620
+ const firstLook = await this.resolveAttachment(entry, stillOverride, canStillGainMedia(entry) ? "trigger-only" : "any");
9523
9621
  const footagePending = this.renderFootage(entry);
9524
9622
  const still = firstLook !== null || entry.payload.media === "none" ? firstLook : await this.waitForStill(entry, stillOverride);
9525
9623
  const footage = await footagePending;
@@ -9763,20 +9861,34 @@ var NcDispatcher = class {
9763
9861
  * throw. And when nothing qualifies, the full list comes back: a real picture
9764
9862
  * beats no picture (the same rule D212 settled for the best shot).
9765
9863
  */
9766
- async narrowToTriggerZone(entry, ordered) {
9864
+ async narrowToTriggerZone(entry, ordered, freshness) {
9767
9865
  const zoneIds = entry.payload.triggerZoneIds;
9768
9866
  const trackId = entry.payload.subject.trackId;
9769
9867
  const getTrail = this.deps.getSubjectTrail;
9770
9868
  const deviceId = entry.payload.subject.deviceId;
9771
- if (zoneIds === void 0 || zoneIds.length === 0 || trackId === void 0 || getTrail === void 0 || ordered.length < 2) return ordered;
9869
+ const strict = freshness === "trigger-only";
9870
+ if (zoneIds === void 0 || zoneIds.length === 0 || trackId === void 0 || getTrail === void 0 || ordered.length < 2 && !strict) return ordered;
9772
9871
  try {
9773
9872
  const [polygons, trail] = await Promise.all([this.deps.getZonePolygons?.(deviceId, zoneIds) ?? Promise.resolve([]), getTrail(deviceId, trackId)]);
9774
9873
  const kept = selectTriggerZoneCandidates({
9775
9874
  candidates: ordered,
9776
9875
  trail,
9777
9876
  polygons,
9778
- triggerAt: entry.payload.subject.timestamp
9877
+ triggerAt: entry.payload.subject.timestamp,
9878
+ strict
9779
9879
  });
9880
+ if (kept.length === 0) {
9881
+ this.deps.logger.debug("no still from the zone that fired the rule — still waiting", {
9882
+ tags: { deviceId },
9883
+ meta: {
9884
+ ruleId: entry.ruleId,
9885
+ trackId,
9886
+ zoneIds: zoneIds.join(","),
9887
+ rejected: ordered.length
9888
+ }
9889
+ });
9890
+ return kept;
9891
+ }
9780
9892
  if (kept.length === ordered.length) return kept;
9781
9893
  this.deps.logger.info("still narrowed to the zone that fired the rule", {
9782
9894
  tags: { deviceId },
@@ -9856,7 +9968,8 @@ var NcDispatcher = class {
9856
9968
  for (const kind of preference) for (const file of files) if (file.kind === kind) ordered.push(file);
9857
9969
  const inBand = this.narrowToTriggerInstant(entry, ordered, freshness);
9858
9970
  if (inBand.length === 0) continue;
9859
- const shortlist = await this.narrowToTriggerZone(entry, inBand);
9971
+ const shortlist = await this.narrowToTriggerZone(entry, inBand, freshness);
9972
+ if (shortlist.length === 0) continue;
9860
9973
  for (const file of shortlist) {
9861
9974
  const raw = Buffer.from(file.base64, "base64");
9862
9975
  if (raw.byteLength === 0) continue;
@@ -25487,13 +25600,46 @@ var MEDIA_COLUMNS = [
25487
25600
  type: "TEXT"
25488
25601
  })
25489
25602
  ];
25490
- var MEDIA_INDEXES = [{
25491
- name: "idx_media_owner",
25492
- columns: ["ownerKind", "ownerId"]
25493
- }, {
25494
- name: "idx_media_device_ts",
25495
- columns: ["deviceId", "timestamp"]
25496
- }];
25603
+ var MEDIA_INDEXES = [
25604
+ {
25605
+ name: "idx_media_owner",
25606
+ columns: ["ownerKind", "ownerId"]
25607
+ },
25608
+ {
25609
+ name: "idx_media_device_ts",
25610
+ columns: ["deviceId", "timestamp"]
25611
+ },
25612
+ (
25613
+ /**
25614
+ * The `locationId IS NULL` seal gate — see `media-relocate-engine.ts`.
25615
+ *
25616
+ * NULLs sort FIRST in a SQLite index, so `WHERE locationId IS NULL` is a
25617
+ * contiguous range from the head of the b-tree: its cost is proportional to
25618
+ * the ANSWER, not to the table. That is the property the gate needs. The
25619
+ * question it must answer fastest is "are there none left", and with this
25620
+ * index that is one seek that immediately falls off the end of an empty
25621
+ * range — regardless of how many million stamped rows sit behind it.
25622
+ *
25623
+ * **What it costs.** One extra b-tree maintained by every insert into a
25624
+ * table taking ~92 k rows/day, and roughly 25 bytes of index per row (~30 MB
25625
+ * at 1.25 M rows). At ~1 insert/s sustained that maintenance is noise
25626
+ * against the JPEG write the same call performs. The one-off cost is real
25627
+ * and worth naming: the FIRST declare after this ships builds the index over
25628
+ * every existing row, on a recordings volume that is currently saturated —
25629
+ * expect the analytics addon's boot to stall for tens of seconds to a couple
25630
+ * of minutes, once.
25631
+ *
25632
+ * A PARTIAL index (`WHERE locationId IS NULL`) would be smaller still and
25633
+ * would cost nothing at all once sealed. It is not used because
25634
+ * `CollectionIndexSchema` has no predicate and the schema fingerprint does
25635
+ * not hash one — adding both to serve one query is a bigger change than the
25636
+ * query deserves, and the plain index already makes the gate O(log n).
25637
+ */
25638
+ {
25639
+ name: "idx_media_location",
25640
+ columns: ["locationId"]
25641
+ })
25642
+ ];
25497
25643
  /** The storage location of a media row/record: its stamped `locationId`, or
25498
25644
  * the default `eventMedia` location for NULL (pre-multi-location) rows. */
25499
25645
  var DEFAULT_MEDIA_LOCATION = "eventMedia";
@@ -41066,13 +41212,29 @@ var RETRAIN_FRAME_COLUMNS = [
41066
41212
  type: "TEXT"
41067
41213
  })
41068
41214
  ];
41069
- var RETRAIN_FRAME_INDEXES = [{
41070
- name: "idx_retrain_frames_track",
41071
- columns: ["trackId"]
41072
- }, {
41073
- name: "idx_retrain_frames_device",
41074
- columns: ["deviceId", "copiedAt"]
41075
- }];
41215
+ var RETRAIN_FRAME_INDEXES = [
41216
+ {
41217
+ name: "idx_retrain_frames_track",
41218
+ columns: ["trackId"]
41219
+ },
41220
+ {
41221
+ name: "idx_retrain_frames_device",
41222
+ columns: ["deviceId", "copiedAt"]
41223
+ },
41224
+ (
41225
+ /**
41226
+ * The seal gate's other half. This collection is orders of magnitude smaller
41227
+ * than `pipeline-analytics:media` — every row is an operator-staged frame —
41228
+ * so the index buys much less here. It exists anyway because the gate reads
41229
+ * the two lanes with ONE query shape, and a lane that answers by scanning is
41230
+ * a lane that starts timing out the day the dataset grows. See
41231
+ * `MEDIA_INDEXES` for the cost this is a small multiple of.
41232
+ */
41233
+ {
41234
+ name: "idx_retrain_frames_location",
41235
+ columns: ["locationId"]
41236
+ })
41237
+ ];
41076
41238
  /**
41077
41239
  * The copy's id, derived from `(trackId, sourceMediaKey)`.
41078
41240
  *
@@ -44337,14 +44499,27 @@ function snapshot(j) {
44337
44499
  };
44338
44500
  }
44339
44501
  /**
44502
+ * The column both walked collections use for the storage location.
44503
+ *
44504
+ * Named once because it is now a QUERY PREDICATE (`where: { [it]: null }`) as
44505
+ * well as a row field. A predicate naming a column the collection cannot
44506
+ * express is refused by `count` rather than dropped, so a rename that misses
44507
+ * one site fails loudly instead of counting the whole table.
44508
+ */
44509
+ var LOCATION_ID_FIELD = "locationId";
44510
+ /**
44340
44511
  * True when the row names NO location — the population `seal` exists for.
44341
44512
  *
44342
44513
  * Deliberately NOT `mediaRowLocation(data) === DEFAULT_MEDIA_LOCATION`: that
44343
44514
  * helper collapses "unstamped" and "stamped with the bare type ref" into one
44344
44515
  * answer, and only the first is a row whose meaning changes under a repoint.
44516
+ *
44517
+ * The in-JS twin of the `IS NULL` predicate {@link MediaRelocateEngine.countUnstamped}
44518
+ * sends. It stays because the PASS still walks rows and must decide per row;
44519
+ * the two must agree, and `unstamped-count-matches-the-pass` asserts they do.
44345
44520
  */
44346
44521
  function rowIsUnstamped(data) {
44347
- const id = data["locationId"];
44522
+ const id = data[LOCATION_ID_FIELD];
44348
44523
  return !(typeof id === "string" && id.length > 0);
44349
44524
  }
44350
44525
  /** True for the enrolled-gallery rows `move` skips and `gallery` selects. */
@@ -44402,26 +44577,91 @@ var MediaRelocateEngine = class {
44402
44577
  return true;
44403
44578
  }
44404
44579
  /**
44405
- * How many rows still carry no `locationId`, per collection.
44580
+ * Rows that still carry no `locationId`, per collection.
44406
44581
  *
44407
- * Read-only, and the ONLY number the migration planner's non-blocking gate
44408
- * is allowed to trust: it is counted here, against the rows themselves,
44582
+ * Read-only, and the ONLY answer the migration planner's non-blocking gate
44583
+ * is allowed to trust: it is measured here, against the rows themselves,
44409
44584
  * rather than inferred from "a seal job reported done" — a seal that failed
44410
44585
  * some rows still reports a terminal state.
44586
+ *
44587
+ * ── Why this is not the walk the rest of this file uses ──────────────────
44588
+ *
44589
+ * It used to be. It paged both collections through {@link pageRows} — 200
44590
+ * rows per RPC, ordered by `timestamp`, a column that leads no index — and
44591
+ * counted `rowIsUnstamped` in JS. On the live hub that is ~6 300 round trips
44592
+ * over 1 254 576 rows, each paying its own sort, and it hit the 60 s RPC
44593
+ * deadline **every time it was called since it shipped**. The gate it exists
44594
+ * to serve could therefore never open, and 152 GB of event media could not
44595
+ * be migrated.
44596
+ *
44597
+ * The walk is right for the PASS, which must visit every row to act on it.
44598
+ * It was never right for a question the store can answer with a predicate,
44599
+ * and the predicate is now expressible: `where: { locationId: null }`
44600
+ * compiles to `IS NULL` (it used to compile to `= NULL`, which matches
44601
+ * nothing and reports it as an answer — the number the gate treats as "safe
44602
+ * to cut over"). With `idx_media_location` in place both statements read a
44603
+ * contiguous range from the head of a b-tree.
44604
+ *
44605
+ * The cheap question is asked FIRST and on its own. `present` is a single
44606
+ * seek and decides the gate; `rows` is a second statement and only decorates
44607
+ * the refusal. So a count that cannot be taken still leaves a usable answer
44608
+ * — `{ present: true, rows: null }` refuses just as hard — and `null` never
44609
+ * degrades into `0`.
44411
44610
  */
44412
44611
  async countUnstamped() {
44413
- const media = await this.countUnstampedIn(MEDIA_COLLECTION, "timestamp");
44414
- const retrainFrames = await this.countUnstampedIn(RETRAIN_FRAMES_COLLECTION, "copiedAt");
44415
- return {
44416
- media,
44417
- retrainFrames,
44418
- total: media + retrainFrames
44419
- };
44612
+ try {
44613
+ const media = await this.countUnstampedIn(MEDIA_COLLECTION);
44614
+ const retrainFrames = await this.countUnstampedIn(RETRAIN_FRAMES_COLLECTION);
44615
+ return {
44616
+ media,
44617
+ retrainFrames,
44618
+ anyPresent: media.present || retrainFrames.present,
44619
+ total: media.rows === null || retrainFrames.rows === null ? null : media.rows + retrainFrames.rows
44620
+ };
44621
+ } catch (err) {
44622
+ this.deps.logger.warn("event-media seal gate: could not determine whether any row is unstamped — a migration must treat this as unknown, never as sealed", { meta: { error: String(err) } });
44623
+ return null;
44624
+ }
44420
44625
  }
44421
- async countUnstampedIn(collection, orderField) {
44422
- let unstamped = 0;
44423
- for await (const row of this.pageRows(collection, orderField)) if (rowIsUnstamped(row.data)) unstamped++;
44424
- return unstamped;
44626
+ /**
44627
+ * One collection's unstamped population: existence, then size.
44628
+ *
44629
+ * The `limit: 1` probe is deliberately a `query` and not a `count`: a count
44630
+ * of a million matching rows still walks a million index entries, while the
44631
+ * probe stops at the first one. Absence is proved by the same seek finding
44632
+ * nothing, which is where the index earns its keep — after a seal, the range
44633
+ * is empty and the answer is one b-tree descent.
44634
+ */
44635
+ async countUnstampedIn(collection) {
44636
+ if ((await this.deps.store.query.query({
44637
+ collection,
44638
+ filter: {
44639
+ where: { [LOCATION_ID_FIELD]: null },
44640
+ limit: 1
44641
+ },
44642
+ columns: [LOCATION_ID_FIELD]
44643
+ })).length === 0) return {
44644
+ present: false,
44645
+ rows: 0
44646
+ };
44647
+ try {
44648
+ return {
44649
+ present: true,
44650
+ rows: await this.deps.store.count.query({
44651
+ collection,
44652
+ filter: { where: { [LOCATION_ID_FIELD]: null } }
44653
+ })
44654
+ };
44655
+ } catch (err) {
44656
+ this.deps.logger.warn("event-media seal gate: unstamped rows exist but could not be counted — reporting the population as unknown, not as empty", { meta: {
44657
+ collection,
44658
+ error: String(err)
44659
+ } });
44660
+ return {
44661
+ present: true,
44662
+ rows: null
44663
+ };
44664
+ }
44425
44665
  }
44426
44666
  start(input) {
44427
44667
  for (const j of this.jobs.values()) if (j.state === "running") throw new Error(`a media relocation is already running (${j.jobId})`);
@@ -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-DZGvAMuh.mjs")).catch((e) => {
33
+ return l ||= d(() => import("./_virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-bK-ELdFD.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.150",
3
+ "version": "1.2.151",
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",