@camstack/addon-post-analysis 1.2.130 → 1.2.132

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-CDHL-0gR.js");
5
+ const require_dist = require("../dist-BB-Ol201.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");
@@ -5413,6 +5413,12 @@ function buildAlarmButtons(input) {
5413
5413
  }];
5414
5414
  }
5415
5415
  //#endregion
5416
+ //#region src/notification-center/cap-caller.ts
5417
+ function requireCapCaller(method, caller) {
5418
+ if (caller === void 0 || typeof caller.userId !== "string" || caller.userId.length === 0) throw new Error(`notification-rules.${method} declares caller: 'required' and this call carried no caller identity — an addon-to-addon cap call has no authenticated user`);
5419
+ return caller;
5420
+ }
5421
+ //#endregion
5416
5422
  //#region src/notification-center/audio-rule-matcher.ts
5417
5423
  /**
5418
5424
  * AudioWatcher — the Notification Center's matcher for audio rules
@@ -15359,33 +15365,95 @@ function remainingSummaryCandidates(kept, taken, rank) {
15359
15365
  /**
15360
15366
  * The media a tile is drawn from, best first.
15361
15367
  *
15362
- * First-level group crop only (`groupCrop`), then the group's native-frame
15363
- * pointer, then a member's clean full frame. Member `thumbnail` / plate /
15364
- * face crops stay off this ladder: a successful plate read overwrites the
15365
- * vehicle first-level crop under the same `thumbnail` kind, and a digest
15366
- * tile of a targa is the wrong picture. Boxed frames stay absent: a drawn
15367
- * rectangle is a permanent lie in a digest tile. Duplicate driveways from
15368
- * tracker ID churn are collapsed by `frameDedupeKey`, not by hiding the
15369
- * full-frame fallback.
15368
+ * A digest tile is a SUBJECT, so the crops come first: the group's first-level
15369
+ * crop (`groupCrop`), the group's native-frame pointer, then the TRACK's own
15370
+ * subject crop (`thumbnail`, then its `thumbnailSmall` downscale), and only
15371
+ * then the clean whole scene (`keyFrame` → `keyFrameSmall` → `fullFrame`).
15372
+ *
15373
+ * ⚠ The whole-scene rungs are a FALLBACK and stay on the ladder. Measured on
15374
+ * 60 real tracks (615/617, 24 h): 52 carry a crop, 43 carry a scene rung, and
15375
+ * 2 carry a scene rung and NO crop — dropping the fallback would delete those
15376
+ * two tiles for nothing. A track with neither is dropped, never drawn as an
15377
+ * empty cell (see `NcSummaryProducer.loadTiles`).
15378
+ *
15379
+ * ## Why `thumbnail` is back (2026-08-27)
15380
+ *
15381
+ * It was removed on 2026-08-24 (160200681) because a successful plate read
15382
+ * overwrites the vehicle first-level crop under the same `thumbnail` kind.
15383
+ * **That collision belongs to the GROUP entity**: the sealer is what writes
15384
+ * `ownerKind: 'group', kind: 'thumbnail'` (`group/group-sealer.ts`), and the
15385
+ * group process is OFF (`GROUPS_PROCESS_ENABLED`, `group/group-enabled.ts`).
15386
+ * On a TRACK, plates live under `ownerKind: 'plate'` / `kind: 'plateCrop'` and
15387
+ * never touch `thumbnail`. The same commit turned the group process off, so
15388
+ * `groupCrop` and `nativeFrame` became unreachable and every digest tile fell
15389
+ * through to `keyFrame` — a whole frame — for three days.
15390
+ * `scripts/check-summary-ladder-reachable.ts` is the guard that refuses that
15391
+ * shape now.
15392
+ *
15393
+ * Boxed frames stay absent: a drawn rectangle is a permanent lie in a digest
15394
+ * tile. Plate and face crops stay absent: a digest tile of a targa is the
15395
+ * wrong picture. Duplicate driveways from tracker ID churn are collapsed by
15396
+ * `frameDedupeKey`, not by hiding the whole-scene fallback.
15370
15397
  */
15371
15398
  var SUMMARY_MEDIA_LADDER = [
15372
15399
  "groupCrop",
15373
15400
  "nativeFrame",
15401
+ "thumbnail",
15402
+ "thumbnailSmall",
15374
15403
  "keyFrame",
15375
15404
  "keyFrameSmall",
15376
15405
  "fullFrame"
15377
15406
  ];
15378
15407
  /**
15408
+ * The floor below which a `thumbnail` row is NOT a subject crop.
15409
+ *
15410
+ * `copyFaceCropIfTrackHasNoThumbnail` (`pipeline-analytics/copy-face-crop-preview.ts`)
15411
+ * copies the persisted face crop onto a track that has no preview of its own,
15412
+ * and it stores it under `kind: 'thumbnail'` — so an ArcFace **112×112**
15413
+ * alignment template lands on the ladder wearing the same kind as a real
15414
+ * subject crop. Measured: 3 of 52 track thumbnails (5.8%) are 3–10 KB
15415
+ * templates. Delivering one is a face in a digest of passages.
15416
+ *
15417
+ * 20 KiB is chosen as the smallest number that is unambiguous in BOTH
15418
+ * directions: it is 2× the largest template observed (10 KB) and above the
15419
+ * theoretical ceiling of a 112×112 JPEG at any quality, while a native-
15420
+ * resolution subject crop measures in the hundreds of KB. The second reason
15421
+ * the number is safe: a genuine crop small enough to fall under it is a
15422
+ * subject a few dozen pixels across, which is a smear in a mosaic cell — the
15423
+ * whole-scene rung below is the better picture either way.
15424
+ *
15425
+ * The check costs NOTHING: `MediaFileInfo.sizeBytes` is already on the index
15426
+ * row, so no blob is fetched and nothing is decoded to apply it.
15427
+ */
15428
+ var SUMMARY_THUMBNAIL_MIN_BYTES = 20480;
15429
+ /** The rungs {@link SUMMARY_THUMBNAIL_MIN_BYTES} is applied to. */
15430
+ var SUMMARY_SIZE_GATED_RUNGS = new Set(["thumbnail"]);
15431
+ /**
15432
+ * Is this row a picture, or a template wearing a picture's kind?
15433
+ *
15434
+ * Only a size the index REPORTED can demote a row. A `null` size is unknown,
15435
+ * and demoting on unknown would silently delete every group-owned pointer.
15436
+ */
15437
+ function rowClearsSizeFloor(row, rung) {
15438
+ if (!SUMMARY_SIZE_GATED_RUNGS.has(rung)) return true;
15439
+ if (typeof row.sizeBytes !== "number") return true;
15440
+ return row.sizeBytes >= SUMMARY_THUMBNAIL_MIN_BYTES;
15441
+ }
15442
+ /**
15379
15443
  * Pick ONE media row for a track — the cheap question first (index rows only),
15380
15444
  * so exactly one blob is ever read.
15381
15445
  *
15382
15446
  * Within a rung the row CLOSEST to the track's peak is chosen; a member may
15383
15447
  * carry more than one full-frame still, so "the first one" would systematically
15384
15448
  * pick the moment the subject entered rather than the moment it was best seen.
15449
+ *
15450
+ * A `thumbnail` under {@link SUMMARY_THUMBNAIL_MIN_BYTES} DESCENDS to the next
15451
+ * rung rather than being drawn — see that constant for the face template it
15452
+ * exists to catch.
15385
15453
  */
15386
15454
  function pickSummaryMedia(rows, aroundMs) {
15387
15455
  for (const rung of SUMMARY_MEDIA_LADDER) {
15388
- const matches = rows.filter((row) => row.kind === rung);
15456
+ const matches = rows.filter((row) => row.kind === rung && rowClearsSizeFloor(row, rung));
15389
15457
  if (matches.length === 0) continue;
15390
15458
  return matches.reduce((best, row) => Math.abs(row.timestamp - aroundMs) < Math.abs(best.timestamp - aroundMs) ? row : best);
15391
15459
  }
@@ -15697,12 +15765,13 @@ var NcSummaryProducer = class {
15697
15765
  droppedStationary: filtered.droppedStationary
15698
15766
  };
15699
15767
  }
15700
- const loaded = await this.loadTiles({
15768
+ const tileLoad = await this.loadTiles({
15701
15769
  rule,
15702
15770
  preferred: selection.tiles,
15703
15771
  refill: remainingSummaryCandidates(filtered.kept, selection.tiles, rule.rank),
15704
15772
  maxTiles: rule.maxTiles
15705
15773
  });
15774
+ const loaded = tileLoad.tiles;
15706
15775
  const detections = countDetections({ tracks: selection.tiles.map((t) => ({
15707
15776
  firstSeen: t.firstSeen,
15708
15777
  lastSeen: t.lastSeen,
@@ -15760,7 +15829,8 @@ var NcSummaryProducer = class {
15760
15829
  tiles: loaded.length,
15761
15830
  cameras: new Set(loaded.map((t) => t.candidate.deviceId)).size,
15762
15831
  camerasDropped: selection.camerasDropped,
15763
- droppedNoMedia: Math.max(0, rule.maxTiles - loaded.length),
15832
+ droppedNoMedia: tileLoad.droppedNoMedia,
15833
+ droppedDuplicateFrame: tileLoad.droppedDuplicateFrame,
15764
15834
  mosaicBytes: mosaic.bytes,
15765
15835
  mosaicPublished: mosaic.ref !== null,
15766
15836
  enqueued,
@@ -15826,14 +15896,20 @@ var NcSummaryProducer = class {
15826
15896
  }
15827
15897
  /** One blob per tile — the cheap question (index rows) always first.
15828
15898
  * Duplicate camera-seconds are skipped so tracker ID churn cannot paint
15829
- * the same driveway eleven times; leftover ranked candidates refill. */
15899
+ * the same driveway eleven times; leftover ranked candidates refill.
15900
+ *
15901
+ * It COUNTS what it drops, per reason, because only this loop knows: the
15902
+ * caller sees tiles and has no way to tell a track that had no picture from
15903
+ * a rule whose quota was simply larger than the night. */
15830
15904
  async loadTiles(input) {
15831
- const out = [];
15905
+ const tiles = [];
15832
15906
  const seenFrames = /* @__PURE__ */ new Set();
15833
15907
  const seenIds = /* @__PURE__ */ new Set();
15908
+ let droppedNoMedia = 0;
15909
+ let droppedDuplicateFrame = 0;
15834
15910
  const queue = [...input.preferred, ...input.refill];
15835
15911
  for (const candidate of queue) {
15836
- if (out.length >= input.maxTiles) break;
15912
+ if (tiles.length >= input.maxTiles) break;
15837
15913
  if (seenIds.has(candidate.trackId)) continue;
15838
15914
  seenIds.add(candidate.trackId);
15839
15915
  const tags = { deviceId: candidate.deviceId };
@@ -15841,19 +15917,22 @@ var NcSummaryProducer = class {
15841
15917
  const rows = await this.deps.listMediaInfo(candidate.trackId);
15842
15918
  const picked = pickSummaryMedia(rows, (candidate.firstSeen + candidate.lastSeen) / 2);
15843
15919
  if (picked === null) {
15844
- this.deps.logger.debug("summary tile dropped: the track carries no clean media", {
15920
+ droppedNoMedia += 1;
15921
+ this.deps.logger.info("summary tile dropped: the track carries no clean media", {
15845
15922
  tags,
15846
15923
  meta: {
15847
15924
  ruleId: input.rule.id,
15848
15925
  trackId: candidate.trackId,
15849
- mediaRows: rows.length
15926
+ mediaRows: rows.length,
15927
+ mediaKinds: [...new Set(rows.map((row) => row.kind))].sort()
15850
15928
  }
15851
15929
  });
15852
15930
  continue;
15853
15931
  }
15854
15932
  const frameKey = frameDedupeKey(candidate.deviceId, picked.timestamp);
15855
15933
  if (seenFrames.has(frameKey)) {
15856
- this.deps.logger.debug("summary tile dropped: same frame as an earlier tile", {
15934
+ droppedDuplicateFrame += 1;
15935
+ this.deps.logger.info("summary tile dropped: same frame as an earlier tile", {
15857
15936
  tags,
15858
15937
  meta: {
15859
15938
  ruleId: input.rule.id,
@@ -15865,22 +15944,25 @@ var NcSummaryProducer = class {
15865
15944
  }
15866
15945
  const jpeg = await this.deps.readMedia(picked.key);
15867
15946
  if (jpeg === null || jpeg.byteLength === 0) {
15868
- this.deps.logger.debug("summary tile dropped: the media blob could not be read", {
15947
+ droppedNoMedia += 1;
15948
+ this.deps.logger.info("summary tile dropped: the media blob could not be read", {
15869
15949
  tags,
15870
15950
  meta: {
15871
15951
  ruleId: input.rule.id,
15872
15952
  trackId: candidate.trackId,
15873
- mediaKey: picked.key
15953
+ mediaKey: picked.key,
15954
+ mediaKind: picked.kind
15874
15955
  }
15875
15956
  });
15876
15957
  continue;
15877
15958
  }
15878
15959
  seenFrames.add(frameKey);
15879
- out.push({
15960
+ tiles.push({
15880
15961
  candidate,
15881
15962
  jpeg
15882
15963
  });
15883
15964
  } catch (err) {
15965
+ droppedNoMedia += 1;
15884
15966
  this.deps.logger.warn("summary tile dropped: reading its media threw", {
15885
15967
  tags,
15886
15968
  meta: {
@@ -15891,7 +15973,11 @@ var NcSummaryProducer = class {
15891
15973
  });
15892
15974
  }
15893
15975
  }
15894
- return out;
15976
+ return {
15977
+ tiles,
15978
+ droppedNoMedia,
15979
+ droppedDuplicateFrame
15980
+ };
15895
15981
  }
15896
15982
  /**
15897
15983
  * Render the mosaic, file it where event media lives, and mint the public
@@ -21208,10 +21294,13 @@ var NotificationCenter = class NotificationCenter {
21208
21294
  });
21209
21295
  return { success: true };
21210
21296
  },
21211
- listSnoozes: async ({ caller }) => ({ snoozes: [...this.snoozes.listVisibleTo(caller.userId, caller.isAdmin)] }),
21212
- createSnooze: async ({ snooze, caller }) => ({ snooze: await this.createSnooze(snooze, caller) }),
21297
+ listSnoozes: async ({ caller }) => {
21298
+ const who = requireCapCaller("listSnoozes", caller);
21299
+ return { snoozes: [...this.snoozes.listVisibleTo(who.userId, who.isAdmin)] };
21300
+ },
21301
+ createSnooze: async ({ snooze, caller }) => ({ snooze: await this.createSnooze(snooze, requireCapCaller("createSnooze", caller)) }),
21213
21302
  cancelSnooze: async ({ snoozeId, caller }) => {
21214
- await this.cancelSnooze(snoozeId, caller);
21303
+ await this.cancelSnooze(snoozeId, requireCapCaller("cancelSnooze", caller));
21215
21304
  return { success: true };
21216
21305
  },
21217
21306
  getAlarmConfig: async () => this.alarmConfig(),
@@ -26836,6 +26925,639 @@ function updateTrackAggregate(prev, match, opts) {
26836
26925
  };
26837
26926
  }
26838
26927
  //#endregion
26928
+ //#region src/pipeline-analytics/store/face-sort.ts
26929
+ var DEFAULT_FACE_SORT = {
26930
+ by: "timestamp",
26931
+ direction: "desc"
26932
+ };
26933
+ /** Resolve a caller's partial request into a complete order. */
26934
+ function resolveFaceSort(input) {
26935
+ return {
26936
+ by: input?.sortBy ?? DEFAULT_FACE_SORT.by,
26937
+ direction: input?.sortDirection ?? DEFAULT_FACE_SORT.direction
26938
+ };
26939
+ }
26940
+ /**
26941
+ * True when the store's `timestamp`-indexed paging already emits rows in the
26942
+ * requested order, so a paged read may STOP as soon as `limit` rows have passed
26943
+ * the filter.
26944
+ *
26945
+ * False for `suggestionConfidence`: the most certain suggestion may be the
26946
+ * oldest row in the window, so an early stop would silently answer with the
26947
+ * newest N ranked — the exact defect this order was added to remove.
26948
+ */
26949
+ function sortIsServedByTimestampPaging(order) {
26950
+ return order.by === "timestamp";
26951
+ }
26952
+ /** Certainty, or `null` when the row has none. Null and undefined are the same
26953
+ * answer — the relational backend surfaces an unset REAL column as SQL NULL. */
26954
+ function certaintyOf(face) {
26955
+ return face.suggestedMatchScore ?? null;
26956
+ }
26957
+ /** Newest first, then `id` ascending. The tail of every comparison. */
26958
+ function compareTiebreak(a, b) {
26959
+ if (a.timestamp !== b.timestamp) return b.timestamp - a.timestamp;
26960
+ if (a.id === b.id) return 0;
26961
+ return a.id < b.id ? -1 : 1;
26962
+ }
26963
+ function compareFaces(a, b, order) {
26964
+ if (order.by === "timestamp") {
26965
+ if (a.timestamp !== b.timestamp) return order.direction === "desc" ? b.timestamp - a.timestamp : a.timestamp - b.timestamp;
26966
+ return compareTiebreak(a, b);
26967
+ }
26968
+ const aScore = certaintyOf(a);
26969
+ const bScore = certaintyOf(b);
26970
+ if (aScore === null && bScore !== null) return 1;
26971
+ if (aScore !== null && bScore === null) return -1;
26972
+ if (aScore !== null && bScore !== null && aScore !== bScore) return order.direction === "desc" ? bScore - aScore : aScore - bScore;
26973
+ return compareTiebreak(a, b);
26974
+ }
26975
+ /** Immutable sort — the input array is never reordered in place. */
26976
+ function sortFaces(faces, order) {
26977
+ return faces.toSorted((a, b) => compareFaces(a, b, order));
26978
+ }
26979
+ /**
26980
+ * Rank `faces` and keep at most `limit` of them.
26981
+ *
26982
+ * This is what lets the confidence order stay bounded: a paged sweep folds each
26983
+ * page in through here, so the retained set is the caller's `limit`, never the
26984
+ * corpus — even though the sweep itself has to walk the whole window.
26985
+ */
26986
+ function rankAndCap(faces, order, limit) {
26987
+ const sorted = sortFaces(faces, order);
26988
+ return limit === void 0 ? sorted : sorted.slice(0, limit);
26989
+ }
26990
+ //#endregion
26991
+ //#region src/pipeline-analytics/store/face-store.ts
26992
+ /**
26993
+ * @durable class=ledger owner=pipeline-analytics
26994
+ * write="one UPSERT per track that produced a face detail — the id is
26995
+ * 'face-<trackId>', so a track has at most one row and the recognizer
26996
+ * overwrites it as a better read lands (embedding, match cosine, crop keys).
26997
+ * The row is rewritten again when the operator enrols it, which flips
26998
+ * `assigned` and stamps the identity."
26999
+ * retention="TRACK-OWNED and clockless (D61): unassigned rows leave with their
27000
+ * track through FaceStore.deleteByTracks, and enrolled (`assigned`) rows are
27001
+ * exempt from that and from every sweep — an assignment is a curation act.
27002
+ * The only other bound is capacity: pruneCapOverflow holds each camera's
27003
+ * UNASSIGNED buffer to bufferMaxPerDevice newest rows (default 50). At 10.8 KB
27004
+ * of JSON embedding per row, that cap is what keeps the table small."
27005
+ */
27006
+ var FACES_COLLECTION = "pipeline-analytics:faces";
27007
+ /**
27008
+ * Rows per page for the two whole-collection sweeps below.
27009
+ *
27010
+ * A face row is one of the two WIDEST row shapes in the hub's database: it
27011
+ * carries a JSON `embedding` on every single row, measured at **10.8 KB
27012
+ * average** where a motion event is 78 bytes. Both sweeps used to read the
27013
+ * collection in ONE statement with no `limit`.
27014
+ *
27015
+ * To be exact about what that did and did not cause: this table holds **372
27016
+ * rows / 4.0 MB** on the live hub, so it was *not* the read that killed
27017
+ * hub-main on 2026-08-07 (that was the multi-camera track feed — see
27018
+ * `RECENT_FANOUT_ROW_BUDGET` in `track-store.ts`). It is a full scan whose cost
27019
+ * grows with a table nothing else bounds, which is the same defect one incident
27020
+ * earlier. `pruneCapOverflow` is what keeps the row count small — and it was
27021
+ * itself one of the two unbounded readers, so the bound and the thing enforcing
27022
+ * it were the same loop.
27023
+ *
27024
+ * The engine now refuses an unbounded read (`query-bounds.ts`), so paging here
27025
+ * is not belt-and-braces: without it these two methods would silently see only
27026
+ * the newest N rows and the buffer would grow forever.
27027
+ *
27028
+ * 1 000 keeps peak transfer around 11 MB while staying under the engine's own
27029
+ * cap, so the page is always the caller's bound and never the engine's.
27030
+ */
27031
+ var FACE_SWEEP_PAGE = 1e3;
27032
+ /**
27033
+ * Refuses to loop forever if a page ever fails to advance. 1 000 pages is a
27034
+ * million faces — far past anything real, and reached only by a bug.
27035
+ */
27036
+ var FACE_SWEEP_MAX_PAGES = 1e3;
27037
+ var FACE_COLUMNS = [
27038
+ {
27039
+ name: "id",
27040
+ type: "TEXT",
27041
+ primaryKey: true,
27042
+ notNull: true
27043
+ },
27044
+ {
27045
+ name: "deviceId",
27046
+ type: "INTEGER",
27047
+ notNull: true
27048
+ },
27049
+ {
27050
+ name: "trackId",
27051
+ type: "TEXT",
27052
+ notNull: true
27053
+ },
27054
+ {
27055
+ name: "timestamp",
27056
+ type: "INTEGER",
27057
+ notNull: true
27058
+ },
27059
+ {
27060
+ name: "embedding",
27061
+ type: "JSON",
27062
+ notNull: true
27063
+ },
27064
+ {
27065
+ name: "embeddingModelId",
27066
+ type: "TEXT"
27067
+ },
27068
+ {
27069
+ name: "mediaKey",
27070
+ type: "TEXT"
27071
+ },
27072
+ {
27073
+ name: "recognizedIdentityId",
27074
+ type: "TEXT"
27075
+ },
27076
+ {
27077
+ name: "assigned",
27078
+ type: "BOOLEAN",
27079
+ notNull: true
27080
+ },
27081
+ {
27082
+ name: "assignedSampleId",
27083
+ type: "TEXT"
27084
+ },
27085
+ {
27086
+ name: "keyFrameMediaKey",
27087
+ type: "TEXT"
27088
+ },
27089
+ {
27090
+ name: "faceBbox",
27091
+ type: "JSON"
27092
+ },
27093
+ {
27094
+ name: "bestMatchScore",
27095
+ type: "REAL"
27096
+ },
27097
+ {
27098
+ name: "nativeFaceShortSidePx",
27099
+ type: "REAL"
27100
+ },
27101
+ {
27102
+ name: "suggestedIdentityId",
27103
+ type: "TEXT"
27104
+ },
27105
+ {
27106
+ name: "suggestedMatchScore",
27107
+ type: "REAL"
27108
+ }
27109
+ ];
27110
+ var FACE_INDEXES = [{
27111
+ name: "idx_faces_device_ts",
27112
+ columns: ["deviceId", "timestamp"]
27113
+ }, {
27114
+ name: "idx_faces_track",
27115
+ columns: ["trackId"]
27116
+ }];
27117
+ /**
27118
+ * `since` / `until` as a store predicate.
27119
+ *
27120
+ * The filter surface has exactly one range form, `whereBetween`, and it is
27121
+ * inclusive on both ends — so a one-sided window is expressed by pinning the
27122
+ * other end to the edge of the safe integer range rather than by omitting it.
27123
+ * Returns `{}` when the caller named no window, so the read stays exactly the
27124
+ * unwindowed one.
27125
+ */
27126
+ function buildTimestampWindow(input) {
27127
+ if (input.since === void 0 && input.until === void 0) return {};
27128
+ return { whereBetween: { timestamp: [input.since ?? Number.MIN_SAFE_INTEGER, input.until ?? Number.MAX_SAFE_INTEGER] } };
27129
+ }
27130
+ /**
27131
+ * One stored row → one {@link Face}.
27132
+ *
27133
+ * The single documented type boundary of this store: `SettingsRecord.data` is
27134
+ * `Record<string, unknown>` on the wire and the column set is declared right
27135
+ * here in {@link FACE_COLUMNS}, so this is the one place allowed to name the
27136
+ * shape it wrote.
27137
+ *
27138
+ * It also owns the NULL normalisation, and that is not cosmetic: the relational
27139
+ * backend surfaces an unset optional column as SQL NULL, and `FaceInfo`'s
27140
+ * optional strings REJECT null — an un-normalised row fails
27141
+ * `faceGallery.listRecentFaces` output validation and the People page renders
27142
+ * empty. This used to be copied out three times, which is three chances to
27143
+ * forget the next additive column.
27144
+ */
27145
+ function rowToFace(id, raw) {
27146
+ const data = raw;
27147
+ return {
27148
+ ...data,
27149
+ id,
27150
+ assigned: Boolean(data.assigned),
27151
+ mediaKey: data.mediaKey ?? void 0,
27152
+ recognizedIdentityId: data.recognizedIdentityId ?? void 0,
27153
+ assignedSampleId: data.assignedSampleId ?? void 0,
27154
+ keyFrameMediaKey: data.keyFrameMediaKey ?? void 0,
27155
+ faceBbox: data.faceBbox ?? void 0,
27156
+ bestMatchScore: data.bestMatchScore ?? void 0,
27157
+ nativeFaceShortSidePx: data.nativeFaceShortSidePx ?? void 0,
27158
+ suggestedIdentityId: data.suggestedIdentityId ?? void 0,
27159
+ suggestedMatchScore: data.suggestedMatchScore ?? void 0
27160
+ };
27161
+ }
27162
+ var FaceStore = class {
27163
+ store;
27164
+ logger;
27165
+ constructor(deps) {
27166
+ this.store = deps.store;
27167
+ this.logger = deps.logger;
27168
+ }
27169
+ static async declare(store) {
27170
+ await store.declareCollection.mutate({
27171
+ collection: FACES_COLLECTION,
27172
+ columns: [...FACE_COLUMNS],
27173
+ indexes: [...FACE_INDEXES]
27174
+ });
27175
+ }
27176
+ /**
27177
+ * Upsert one buffered face. Keyed by `face.id` (the worker uses one stable
27178
+ * id per track → one entry per track). `set` replaces an existing row.
27179
+ */
27180
+ async insert(face) {
27181
+ const { id, ...rest } = face;
27182
+ try {
27183
+ await this.store.set.mutate({
27184
+ collection: FACES_COLLECTION,
27185
+ key: id,
27186
+ value: rest
27187
+ });
27188
+ } catch (err) {
27189
+ this.logger.warn("FaceStore.insert failed", {
27190
+ tags: { deviceId: face.deviceId },
27191
+ meta: {
27192
+ faceId: id,
27193
+ error: String(err)
27194
+ }
27195
+ });
27196
+ }
27197
+ }
27198
+ /**
27199
+ * Recent UNASSIGNED faces (the enrollment buffer), newest first.
27200
+ *
27201
+ * NOTE: `assigned` is normalized via `Boolean()` on read so the production
27202
+ * SQLite backend (which returns INTEGER 0/1) is handled correctly.
27203
+ */
27204
+ async listRecentFaces(input) {
27205
+ const where = {};
27206
+ if (input.deviceId !== void 0) where["deviceId"] = input.deviceId;
27207
+ return (await this.store.query.query({
27208
+ collection: FACES_COLLECTION,
27209
+ filter: {
27210
+ where,
27211
+ orderBy: {
27212
+ field: "timestamp",
27213
+ direction: "desc"
27214
+ },
27215
+ limit: input.limit ?? 100
27216
+ }
27217
+ })).map((r) => rowToFace(r.id, r.data)).filter((f) => !f.assigned);
27218
+ }
27219
+ /**
27220
+ * The per-device `prune(deviceId, cutoffMs, maxPerDevice)` that used to sit
27221
+ * here is GONE (D61). It had no production caller — only tests — and it was
27222
+ * an age-keyed deletion of a track-OWNED entity, i.e. the exact thing the
27223
+ * retention model now forbids. A leftover that describes the wrong design
27224
+ * still reads as permission to use it.
27225
+ */
27226
+ /**
27227
+ * Hold the unassigned face buffer to `maxPerDevice` newest rows per camera.
27228
+ *
27229
+ * **This is a CAPACITY bound, not an age one.** It used to be both: a face
27230
+ * was deleted if it was older than `bufferRetentionDays` OR beyond the cap.
27231
+ * The age half was a fifth clock over a track-OWNED entity — a face row
27232
+ * carries a `trackId`, so it belongs to its track and cascades with it — and
27233
+ * it aged crops out from under tracks that were still well inside their own
27234
+ * retention window. It is gone (D61). What remains is the ring-buffer
27235
+ * property the recognizer actually depends on: the gallery of recent
27236
+ * unassigned faces stays small.
27237
+ *
27238
+ * ENROLLED (`assigned`) rows are skipped entirely and do not consume cap
27239
+ * slots. Returns all deleted ids (the caller removes their crops).
27240
+ */
27241
+ async pruneCapOverflow(input) {
27242
+ /**
27243
+ * Paged, not one statement. Only three scalars per row survive the page —
27244
+ * the `embedding` string that dominates the row is dropped as soon as the
27245
+ * page goes out of scope, so peak heap is one page rather than the whole
27246
+ * face table. The prune still considers EVERY row, which is what makes it a
27247
+ * capacity bound rather than a bound on the newest page.
27248
+ */
27249
+ const byDevice = /* @__PURE__ */ new Map();
27250
+ for (let page = 0; page < FACE_SWEEP_MAX_PAGES; page++) {
27251
+ const rows = await this.store.query.query({
27252
+ collection: FACES_COLLECTION,
27253
+ filter: {
27254
+ orderBy: {
27255
+ field: "timestamp",
27256
+ direction: "desc"
27257
+ },
27258
+ limit: FACE_SWEEP_PAGE,
27259
+ offset: page * FACE_SWEEP_PAGE
27260
+ }
27261
+ });
27262
+ if (rows.length === 0) break;
27263
+ for (const r of rows) {
27264
+ const data = r.data;
27265
+ if (data.assigned) continue;
27266
+ const deviceId = Number(data.deviceId ?? -1);
27267
+ const list = byDevice.get(deviceId) ?? [];
27268
+ list.push({
27269
+ id: r.id,
27270
+ timestamp: Number(data.timestamp ?? 0)
27271
+ });
27272
+ byDevice.set(deviceId, list);
27273
+ }
27274
+ if (rows.length < 1e3) break;
27275
+ }
27276
+ const toDelete = /* @__PURE__ */ new Set();
27277
+ for (const list of byDevice.values()) {
27278
+ list.sort((a, b) => b.timestamp - a.timestamp);
27279
+ list.forEach((row, idx) => {
27280
+ if (idx >= input.maxPerDevice) toDelete.add(row.id);
27281
+ });
27282
+ }
27283
+ const deleted = [];
27284
+ for (const id of toDelete) try {
27285
+ await this.store.delete.mutate({
27286
+ collection: FACES_COLLECTION,
27287
+ key: id
27288
+ });
27289
+ deleted.push(id);
27290
+ } catch (err) {
27291
+ this.logger.warn("FaceStore.pruneCapOverflow delete failed", { meta: {
27292
+ faceId: id,
27293
+ error: String(err)
27294
+ } });
27295
+ }
27296
+ return deleted;
27297
+ }
27298
+ /** Flip `assigned` to true and record which identity was matched. Optionally persist the sample id. */
27299
+ async markAssigned(faceId, identityId, sampleId) {
27300
+ const data = {
27301
+ assigned: true,
27302
+ recognizedIdentityId: identityId
27303
+ };
27304
+ if (sampleId !== void 0) data["assignedSampleId"] = sampleId;
27305
+ try {
27306
+ await this.store.update.mutate({
27307
+ collection: FACES_COLLECTION,
27308
+ id: faceId,
27309
+ data
27310
+ });
27311
+ } catch (err) {
27312
+ this.logger.warn("FaceStore.markAssigned failed", { meta: {
27313
+ faceId,
27314
+ error: String(err)
27315
+ } });
27316
+ }
27317
+ }
27318
+ /** Clear the assignment: set assigned=false, clear recognizedIdentityId and assignedSampleId. */
27319
+ async clearAssignment(faceId) {
27320
+ try {
27321
+ await this.store.update.mutate({
27322
+ collection: FACES_COLLECTION,
27323
+ id: faceId,
27324
+ data: {
27325
+ assigned: false,
27326
+ recognizedIdentityId: null,
27327
+ assignedSampleId: null
27328
+ }
27329
+ });
27330
+ } catch (err) {
27331
+ this.logger.warn("FaceStore.clearAssignment failed", { meta: {
27332
+ faceId,
27333
+ error: String(err)
27334
+ } });
27335
+ }
27336
+ }
27337
+ /**
27338
+ * Persist a `face.rescoreTrack` result onto the SUGGESTED tier — the ONE
27339
+ * write that explicitly-requested action makes. `suggestion: null` means
27340
+ * the recompute found no candidate worth offering and CLEARS both columns
27341
+ * (a genuine "the gallery has nothing for this face right now" result, not
27342
+ * a no-op left at whatever they held before). Never touches `assigned` /
27343
+ * `recognizedIdentityId` — a suggestion is never an assignment. The caller
27344
+ * is responsible for never calling this with a non-comparable (`model-
27345
+ * mismatch`) report; this method persists whatever it is given.
27346
+ */
27347
+ async setSuggestion(faceId, suggestion) {
27348
+ const data = suggestion === null ? {
27349
+ suggestedIdentityId: null,
27350
+ suggestedMatchScore: null
27351
+ } : {
27352
+ suggestedIdentityId: suggestion.identityId,
27353
+ suggestedMatchScore: suggestion.score
27354
+ };
27355
+ try {
27356
+ await this.store.update.mutate({
27357
+ collection: FACES_COLLECTION,
27358
+ id: faceId,
27359
+ data
27360
+ });
27361
+ } catch (err) {
27362
+ this.logger.warn("FaceStore.setSuggestion failed", { meta: {
27363
+ faceId,
27364
+ error: String(err)
27365
+ } });
27366
+ }
27367
+ }
27368
+ /**
27369
+ * `TrackScopedStore` contract for the retention cascade: delete the buffered
27370
+ * face rows of the given tracks that are NOT assigned. Reuses the EXACT
27371
+ * `assigned` exemption that `prune`/`pruneAll` apply (see the
27372
+ * `!(r.data.assigned)` predicate above) so ENROLLED faces SURVIVE retention.
27373
+ * Per-track ISOLATED + best-effort per row — one bad track or row never
27374
+ * aborts the batch. (Crop media of these rows is removed by `MediaStore`.)
27375
+ */
27376
+ async deleteByTracks(trackIds) {
27377
+ let removed = 0;
27378
+ for (const trackId of trackIds) try {
27379
+ const eligible = (await this.store.query.query({
27380
+ collection: FACES_COLLECTION,
27381
+ filter: { where: { trackId } }
27382
+ })).filter((r) => !r.data.assigned);
27383
+ for (const row of eligible) try {
27384
+ await this.store.delete.mutate({
27385
+ collection: FACES_COLLECTION,
27386
+ key: row.id
27387
+ });
27388
+ removed++;
27389
+ } catch (err) {
27390
+ this.logger.warn("FaceStore.deleteByTracks delete failed", { meta: {
27391
+ faceId: row.id,
27392
+ error: String(err)
27393
+ } });
27394
+ }
27395
+ } catch (err) {
27396
+ this.logger.warn("FaceStore.deleteByTracks query failed", { meta: {
27397
+ trackId,
27398
+ error: String(err)
27399
+ } });
27400
+ }
27401
+ return [{
27402
+ family: "faces",
27403
+ rows: removed,
27404
+ bytes: 0
27405
+ }];
27406
+ }
27407
+ /** Delete a single buffered face row by id (its crop media is removed by the
27408
+ * caller — the FaceStore owns rows, not blobs). Best-effort. */
27409
+ async delete(faceId) {
27410
+ try {
27411
+ await this.store.delete.mutate({
27412
+ collection: FACES_COLLECTION,
27413
+ key: faceId
27414
+ });
27415
+ } catch (err) {
27416
+ this.logger.warn("FaceStore.delete failed", { meta: {
27417
+ faceId,
27418
+ error: String(err)
27419
+ } });
27420
+ }
27421
+ }
27422
+ /**
27423
+ * Retrieve a single face by id. Returns null if not found.
27424
+ */
27425
+ async get(faceId) {
27426
+ const raw = await this.store.get.query({
27427
+ collection: FACES_COLLECTION,
27428
+ key: faceId
27429
+ });
27430
+ if (!raw) return null;
27431
+ return rowToFace(faceId, raw);
27432
+ }
27433
+ /**
27434
+ * Update internal fields of a face (used for partial mutation, e.g. setting recognizedIdentityId).
27435
+ * Not exposed publicly — intended for internal use and tests only.
27436
+ */
27437
+ async update(faceId, data) {
27438
+ try {
27439
+ await this.store.update.mutate({
27440
+ collection: FACES_COLLECTION,
27441
+ id: faceId,
27442
+ data
27443
+ });
27444
+ } catch (err) {
27445
+ this.logger.warn("FaceStore.update failed", { meta: {
27446
+ faceId,
27447
+ error: String(err)
27448
+ } });
27449
+ }
27450
+ }
27451
+ /**
27452
+ * List recent faces across the cluster, or across a named set of cameras.
27453
+ *
27454
+ * filter:
27455
+ * - 'all' (default) → every row
27456
+ * - 'unassigned' → !assigned
27457
+ * - 'recognized' → !assigned && recognizedIdentityId != null
27458
+ * - 'identified' → recognizedIdentityId != null, including manual assignments
27459
+ *
27460
+ * `deviceIds`, `since` and `until` are pushed INTO the query — they narrow
27461
+ * what is read, not what is returned. `filter` and the ordering cannot be
27462
+ * (the first is a computed predicate, the second is a contract this backend
27463
+ * does not implement — see `face-sort.ts`), so they are applied here.
27464
+ *
27465
+ * **`limit` counts rows that PASSED the filter, never rows that were
27466
+ * discarded.** That is the whole reason this pages instead of asking the
27467
+ * store for `limit` rows: with `filter:'unassigned'` the newest N rows may be
27468
+ * entirely assigned while matches wait just beyond them.
27469
+ *
27470
+ * The previous answer was to fetch the WHOLE collection and slice after
27471
+ * filtering. That is the read that killed hub-main (`query-bounds.ts`) —
27472
+ * every face row carries a JSON `embedding`, so "all rows" is the widest
27473
+ * table in the database materialised at once, to render a gallery of at most
27474
+ * a few hundred tiles.
27475
+ *
27476
+ * So: PAGE. Under the default `timestamp` order the store's paging already
27477
+ * emits rows in the answer's order, so the sweep stops the moment enough
27478
+ * MATCHES exist and the common case costs one page. Under
27479
+ * `suggestionConfidence` it cannot — the most certain suggestion may be the
27480
+ * oldest row — so the sweep walks the window and folds each page through
27481
+ * `rankAndCap`, which holds the RETAINED set to `limit` even though the read
27482
+ * is wider. Narrow the read with `since` / `until`.
27483
+ *
27484
+ * An **empty `deviceIds` reads nothing at all**: `[]` is a request for no
27485
+ * cameras, not an omission (the `deviceManager.listFleet` contract).
27486
+ */
27487
+ async listAllRecentFaces(input) {
27488
+ if (input.deviceIds !== void 0 && input.deviceIds.length === 0) return [];
27489
+ const order = resolveFaceSort(input);
27490
+ const pagingServesTheOrder = sortIsServedByTimestampPaging(order);
27491
+ const filterMode = input.filter ?? "all";
27492
+ const matches = (f) => {
27493
+ if (filterMode === "unassigned") return !f.assigned;
27494
+ if (filterMode === "recognized") return !f.assigned && f.recognizedIdentityId != null;
27495
+ if (filterMode === "identified") return f.recognizedIdentityId != null;
27496
+ return true;
27497
+ };
27498
+ const storeFilter = {
27499
+ ...input.deviceIds !== void 0 ? { whereIn: { deviceId: [...new Set(input.deviceIds)] } } : {},
27500
+ ...buildTimestampWindow(input),
27501
+ orderBy: {
27502
+ field: "timestamp",
27503
+ direction: pagingServesTheOrder ? order.direction : "desc"
27504
+ }
27505
+ };
27506
+ let result = [];
27507
+ for (let page = 0; page < FACE_SWEEP_MAX_PAGES; page++) {
27508
+ const rows = await this.store.query.query({
27509
+ collection: FACES_COLLECTION,
27510
+ filter: {
27511
+ ...storeFilter,
27512
+ limit: FACE_SWEEP_PAGE,
27513
+ offset: page * FACE_SWEEP_PAGE
27514
+ }
27515
+ });
27516
+ if (rows.length === 0) break;
27517
+ const matched = [];
27518
+ for (const r of rows) {
27519
+ const face = rowToFace(r.id, r.data);
27520
+ if (matches(face)) matched.push(face);
27521
+ }
27522
+ if (pagingServesTheOrder) {
27523
+ const merged = [...result, ...matched];
27524
+ if (input.limit !== void 0 && merged.length >= input.limit) return merged.slice(0, input.limit);
27525
+ result = merged;
27526
+ } else result = rankAndCap([...result, ...matched], order, input.limit);
27527
+ if (rows.length < 1e3) break;
27528
+ }
27529
+ return pagingServesTheOrder ? result : rankAndCap(result, order, input.limit);
27530
+ }
27531
+ /** One real face row per camera currently attributed to an identity. */
27532
+ async listCameraRefsForIdentity(identityId) {
27533
+ const byDevice = /* @__PURE__ */ new Map();
27534
+ for (let page = 0; page < FACE_SWEEP_MAX_PAGES; page++) {
27535
+ const rows = await this.store.query.query({
27536
+ collection: FACES_COLLECTION,
27537
+ filter: {
27538
+ where: { recognizedIdentityId: identityId },
27539
+ orderBy: {
27540
+ field: "timestamp",
27541
+ direction: "desc"
27542
+ },
27543
+ limit: FACE_SWEEP_PAGE,
27544
+ offset: page * FACE_SWEEP_PAGE
27545
+ }
27546
+ });
27547
+ if (rows.length === 0) break;
27548
+ for (const row of rows) {
27549
+ const deviceId = Number(row.data["deviceId"]);
27550
+ if (Number.isInteger(deviceId) && !byDevice.has(deviceId)) byDevice.set(deviceId, row.id);
27551
+ }
27552
+ if (rows.length < 1e3) break;
27553
+ }
27554
+ return [...byDevice].map(([deviceId, faceId]) => ({
27555
+ deviceId,
27556
+ faceId
27557
+ }));
27558
+ }
27559
+ };
27560
+ //#endregion
26839
27561
  //#region src/pipeline-analytics/face-gallery-provider.ts
26840
27562
  /** Attribution of a face identity NAMED BY THE OPERATOR in the gallery. */
26841
27563
  var OPERATOR_FACE_STEP_ID = "operator:face-gallery";
@@ -26849,6 +27571,36 @@ var OPERATOR_FACE_STEP_ID = "operator:face-gallery";
26849
27571
  * stalled pipeline.
26850
27572
  */
26851
27573
  var FACE_CROP_FETCH_CONCURRENCY = 16;
27574
+ /**
27575
+ * How many unassigned faces `suggestFaceClusters` reads when the caller names
27576
+ * no {@link SuggestFaceClustersInput.maxFacesScanned}.
27577
+ *
27578
+ * Clustering used to read EVERY unassigned face on the hub, unconditionally:
27579
+ * its only bound (`limit`) cut the finished clusters, so it shortened the
27580
+ * ANSWER and never the work. A UI showing a window of 100 paid for a scan of
27581
+ * the whole corpus — on an addon whose disk is under contention — and no caller
27582
+ * had any way to ask for less.
27583
+ *
27584
+ * `FACE_SWEEP_PAGE` is chosen on purpose rather than a round number: it is
27585
+ * exactly ONE store page, so on the live corpus (372 face rows) the default
27586
+ * reads what it read before, one page, and nothing about today's answers
27587
+ * changes. What changes is the ceiling — the unbounded scan cannot come back as
27588
+ * the table grows.
27589
+ */
27590
+ var SUGGEST_CLUSTERS_DEFAULT_FACE_POOL = FACE_SWEEP_PAGE;
27591
+ /**
27592
+ * The camera set a face listing is scoped to, as ONE value.
27593
+ *
27594
+ * `deviceIds` is the authority whenever it is present — including when it is
27595
+ * EMPTY, which reads nothing (a request for no cameras is a request, not an
27596
+ * omission). `deviceId` is the legacy single-camera form and only applies when
27597
+ * no set was named, so the two can never disagree about the answer.
27598
+ */
27599
+ function resolveFaceDeviceScope(input) {
27600
+ if (input?.deviceIds !== void 0) return { deviceIds: input.deviceIds };
27601
+ if (input?.deviceId !== void 0) return { deviceIds: [input.deviceId] };
27602
+ return {};
27603
+ }
26852
27604
  var FaceGalleryProvider = class {
26853
27605
  identityStore;
26854
27606
  faceStore;
@@ -26984,9 +27736,13 @@ var FaceGalleryProvider = class {
26984
27736
  }
26985
27737
  async listRecentFaces(input) {
26986
27738
  const faces = await this.faceStore.listAllRecentFaces({
26987
- deviceId: input?.deviceId,
27739
+ ...resolveFaceDeviceScope(input),
26988
27740
  limit: input?.limit,
26989
- filter: input?.filter
27741
+ filter: input?.filter,
27742
+ since: input?.since,
27743
+ until: input?.until,
27744
+ sortBy: input?.sortBy,
27745
+ sortDirection: input?.sortDirection
26990
27746
  });
26991
27747
  const identities = await this.identityStore.listIdentities();
26992
27748
  const nameMap = new Map(identities.map((i) => [i.id, i.name]));
@@ -27242,16 +27998,34 @@ var FaceGalleryProvider = class {
27242
27998
  * Suggest clusters of look-alike UNASSIGNED faces (greedy cosine on their
27243
27999
  * embeddings) so the operator can batch-assign a whole group. Embeddings stay
27244
28000
  * server-side; only faceId groupings are returned.
28001
+ *
28002
+ * TWO bounds, and they cut different things — which is the whole point of the
28003
+ * rename:
28004
+ * - `maxFacesScanned` bounds the POOL, i.e. the work. Newest unassigned
28005
+ * faces first, default {@link SUGGEST_CLUSTERS_DEFAULT_FACE_POOL}.
28006
+ * - `maxClusters` (formerly the ambiguous `limit`) bounds the ANSWER.
28007
+ * It has never made this method read one row less.
27245
28008
  */
27246
28009
  async suggestFaceClusters(input) {
27247
28010
  const threshold = input?.threshold ?? .5;
27248
28011
  const minClusterSize = input?.minClusterSize ?? 2;
27249
- let clusters = clusterByEmbedding((await this.faceStore.listAllRecentFaces({ filter: "unassigned" })).filter((f) => Array.isArray(f.embedding) && f.embedding.length > 0).map((f) => ({
28012
+ const maxFacesScanned = input?.maxFacesScanned ?? SUGGEST_CLUSTERS_DEFAULT_FACE_POOL;
28013
+ const maxClusters = input?.maxClusters ?? input?.limit;
28014
+ const faces = await this.faceStore.listAllRecentFaces({
28015
+ filter: "unassigned",
28016
+ limit: maxFacesScanned
28017
+ });
28018
+ if (faces.length >= maxFacesScanned) this.logger.info("suggestFaceClusters pool saturated — older faces not considered", { meta: {
28019
+ maxFacesScanned,
28020
+ scanned: faces.length,
28021
+ threshold,
28022
+ minClusterSize
28023
+ } });
28024
+ const clusters = clusterByEmbedding(faces.filter((f) => Array.isArray(f.embedding) && f.embedding.length > 0).map((f) => ({
27250
28025
  id: f.id,
27251
28026
  embedding: f.embedding
27252
28027
  })), threshold).filter((c) => c.size >= minClusterSize);
27253
- if (input?.limit !== void 0) clusters = clusters.slice(0, input.limit);
27254
- return clusters;
28028
+ return maxClusters === void 0 ? clusters : clusters.slice(0, maxClusters);
27255
28029
  }
27256
28030
  };
27257
28031
  //#endregion
@@ -40606,12 +41380,14 @@ function digestMediaRows(input) {
40606
41380
  if (input.mediaKey !== null && input.mediaKey.length > 0) owned.push({
40607
41381
  key: input.mediaKey,
40608
41382
  kind: "groupCrop",
40609
- timestamp: input.timestamp
41383
+ timestamp: input.timestamp,
41384
+ sizeBytes: null
40610
41385
  });
40611
41386
  if (input.nativeKey !== null && input.nativeKey.length > 0) owned.push({
40612
41387
  key: input.nativeKey,
40613
41388
  kind: "nativeFrame",
40614
- timestamp: input.timestamp
41389
+ timestamp: input.timestamp,
41390
+ sizeBytes: null
40615
41391
  });
40616
41392
  if (owned.length > 0) return owned;
40617
41393
  return input.memberRows.filter((row) => DIGEST_MEMBER_FALLBACK_KINDS.has(row.kind));
@@ -47496,553 +48272,6 @@ function stripNulls$1(data) {
47496
48272
  return out;
47497
48273
  }
47498
48274
  //#endregion
47499
- //#region src/pipeline-analytics/store/face-store.ts
47500
- /**
47501
- * @durable class=ledger owner=pipeline-analytics
47502
- * write="one UPSERT per track that produced a face detail — the id is
47503
- * 'face-<trackId>', so a track has at most one row and the recognizer
47504
- * overwrites it as a better read lands (embedding, match cosine, crop keys).
47505
- * The row is rewritten again when the operator enrols it, which flips
47506
- * `assigned` and stamps the identity."
47507
- * retention="TRACK-OWNED and clockless (D61): unassigned rows leave with their
47508
- * track through FaceStore.deleteByTracks, and enrolled (`assigned`) rows are
47509
- * exempt from that and from every sweep — an assignment is a curation act.
47510
- * The only other bound is capacity: pruneCapOverflow holds each camera's
47511
- * UNASSIGNED buffer to bufferMaxPerDevice newest rows (default 50). At 10.8 KB
47512
- * of JSON embedding per row, that cap is what keeps the table small."
47513
- */
47514
- var FACES_COLLECTION = "pipeline-analytics:faces";
47515
- /**
47516
- * Rows per page for the two whole-collection sweeps below.
47517
- *
47518
- * A face row is one of the two WIDEST row shapes in the hub's database: it
47519
- * carries a JSON `embedding` on every single row, measured at **10.8 KB
47520
- * average** where a motion event is 78 bytes. Both sweeps used to read the
47521
- * collection in ONE statement with no `limit`.
47522
- *
47523
- * To be exact about what that did and did not cause: this table holds **372
47524
- * rows / 4.0 MB** on the live hub, so it was *not* the read that killed
47525
- * hub-main on 2026-08-07 (that was the multi-camera track feed — see
47526
- * `RECENT_FANOUT_ROW_BUDGET` in `track-store.ts`). It is a full scan whose cost
47527
- * grows with a table nothing else bounds, which is the same defect one incident
47528
- * earlier. `pruneCapOverflow` is what keeps the row count small — and it was
47529
- * itself one of the two unbounded readers, so the bound and the thing enforcing
47530
- * it were the same loop.
47531
- *
47532
- * The engine now refuses an unbounded read (`query-bounds.ts`), so paging here
47533
- * is not belt-and-braces: without it these two methods would silently see only
47534
- * the newest N rows and the buffer would grow forever.
47535
- *
47536
- * 1 000 keeps peak transfer around 11 MB while staying under the engine's own
47537
- * cap, so the page is always the caller's bound and never the engine's.
47538
- */
47539
- var FACE_SWEEP_PAGE = 1e3;
47540
- /**
47541
- * Refuses to loop forever if a page ever fails to advance. 1 000 pages is a
47542
- * million faces — far past anything real, and reached only by a bug.
47543
- */
47544
- var FACE_SWEEP_MAX_PAGES = 1e3;
47545
- var FACE_COLUMNS = [
47546
- {
47547
- name: "id",
47548
- type: "TEXT",
47549
- primaryKey: true,
47550
- notNull: true
47551
- },
47552
- {
47553
- name: "deviceId",
47554
- type: "INTEGER",
47555
- notNull: true
47556
- },
47557
- {
47558
- name: "trackId",
47559
- type: "TEXT",
47560
- notNull: true
47561
- },
47562
- {
47563
- name: "timestamp",
47564
- type: "INTEGER",
47565
- notNull: true
47566
- },
47567
- {
47568
- name: "embedding",
47569
- type: "JSON",
47570
- notNull: true
47571
- },
47572
- {
47573
- name: "embeddingModelId",
47574
- type: "TEXT"
47575
- },
47576
- {
47577
- name: "mediaKey",
47578
- type: "TEXT"
47579
- },
47580
- {
47581
- name: "recognizedIdentityId",
47582
- type: "TEXT"
47583
- },
47584
- {
47585
- name: "assigned",
47586
- type: "BOOLEAN",
47587
- notNull: true
47588
- },
47589
- {
47590
- name: "assignedSampleId",
47591
- type: "TEXT"
47592
- },
47593
- {
47594
- name: "keyFrameMediaKey",
47595
- type: "TEXT"
47596
- },
47597
- {
47598
- name: "faceBbox",
47599
- type: "JSON"
47600
- },
47601
- {
47602
- name: "bestMatchScore",
47603
- type: "REAL"
47604
- },
47605
- {
47606
- name: "nativeFaceShortSidePx",
47607
- type: "REAL"
47608
- },
47609
- {
47610
- name: "suggestedIdentityId",
47611
- type: "TEXT"
47612
- },
47613
- {
47614
- name: "suggestedMatchScore",
47615
- type: "REAL"
47616
- }
47617
- ];
47618
- var FACE_INDEXES = [{
47619
- name: "idx_faces_device_ts",
47620
- columns: ["deviceId", "timestamp"]
47621
- }, {
47622
- name: "idx_faces_track",
47623
- columns: ["trackId"]
47624
- }];
47625
- var FaceStore = class {
47626
- store;
47627
- logger;
47628
- constructor(deps) {
47629
- this.store = deps.store;
47630
- this.logger = deps.logger;
47631
- }
47632
- static async declare(store) {
47633
- await store.declareCollection.mutate({
47634
- collection: FACES_COLLECTION,
47635
- columns: [...FACE_COLUMNS],
47636
- indexes: [...FACE_INDEXES]
47637
- });
47638
- }
47639
- /**
47640
- * Upsert one buffered face. Keyed by `face.id` (the worker uses one stable
47641
- * id per track → one entry per track). `set` replaces an existing row.
47642
- */
47643
- async insert(face) {
47644
- const { id, ...rest } = face;
47645
- try {
47646
- await this.store.set.mutate({
47647
- collection: FACES_COLLECTION,
47648
- key: id,
47649
- value: rest
47650
- });
47651
- } catch (err) {
47652
- this.logger.warn("FaceStore.insert failed", {
47653
- tags: { deviceId: face.deviceId },
47654
- meta: {
47655
- faceId: id,
47656
- error: String(err)
47657
- }
47658
- });
47659
- }
47660
- }
47661
- /**
47662
- * Recent UNASSIGNED faces (the enrollment buffer), newest first.
47663
- *
47664
- * NOTE: `assigned` is normalized via `Boolean()` on read so the production
47665
- * SQLite backend (which returns INTEGER 0/1) is handled correctly.
47666
- */
47667
- async listRecentFaces(input) {
47668
- const where = {};
47669
- if (input.deviceId !== void 0) where["deviceId"] = input.deviceId;
47670
- return (await this.store.query.query({
47671
- collection: FACES_COLLECTION,
47672
- filter: {
47673
- where,
47674
- orderBy: {
47675
- field: "timestamp",
47676
- direction: "desc"
47677
- },
47678
- limit: input.limit ?? 100
47679
- }
47680
- })).map((r) => {
47681
- const data = r.data;
47682
- return {
47683
- id: r.id,
47684
- ...data,
47685
- assigned: Boolean(r.data.assigned),
47686
- mediaKey: data.mediaKey ?? void 0,
47687
- recognizedIdentityId: data.recognizedIdentityId ?? void 0,
47688
- assignedSampleId: data.assignedSampleId ?? void 0,
47689
- keyFrameMediaKey: data.keyFrameMediaKey ?? void 0,
47690
- faceBbox: data.faceBbox ?? void 0,
47691
- bestMatchScore: data.bestMatchScore ?? void 0,
47692
- nativeFaceShortSidePx: data.nativeFaceShortSidePx ?? void 0,
47693
- suggestedIdentityId: data.suggestedIdentityId ?? void 0,
47694
- suggestedMatchScore: data.suggestedMatchScore ?? void 0
47695
- };
47696
- }).filter((f) => !f.assigned);
47697
- }
47698
- /**
47699
- * The per-device `prune(deviceId, cutoffMs, maxPerDevice)` that used to sit
47700
- * here is GONE (D61). It had no production caller — only tests — and it was
47701
- * an age-keyed deletion of a track-OWNED entity, i.e. the exact thing the
47702
- * retention model now forbids. A leftover that describes the wrong design
47703
- * still reads as permission to use it.
47704
- */
47705
- /**
47706
- * Hold the unassigned face buffer to `maxPerDevice` newest rows per camera.
47707
- *
47708
- * **This is a CAPACITY bound, not an age one.** It used to be both: a face
47709
- * was deleted if it was older than `bufferRetentionDays` OR beyond the cap.
47710
- * The age half was a fifth clock over a track-OWNED entity — a face row
47711
- * carries a `trackId`, so it belongs to its track and cascades with it — and
47712
- * it aged crops out from under tracks that were still well inside their own
47713
- * retention window. It is gone (D61). What remains is the ring-buffer
47714
- * property the recognizer actually depends on: the gallery of recent
47715
- * unassigned faces stays small.
47716
- *
47717
- * ENROLLED (`assigned`) rows are skipped entirely and do not consume cap
47718
- * slots. Returns all deleted ids (the caller removes their crops).
47719
- */
47720
- async pruneCapOverflow(input) {
47721
- /**
47722
- * Paged, not one statement. Only three scalars per row survive the page —
47723
- * the `embedding` string that dominates the row is dropped as soon as the
47724
- * page goes out of scope, so peak heap is one page rather than the whole
47725
- * face table. The prune still considers EVERY row, which is what makes it a
47726
- * capacity bound rather than a bound on the newest page.
47727
- */
47728
- const byDevice = /* @__PURE__ */ new Map();
47729
- for (let page = 0; page < FACE_SWEEP_MAX_PAGES; page++) {
47730
- const rows = await this.store.query.query({
47731
- collection: FACES_COLLECTION,
47732
- filter: {
47733
- orderBy: {
47734
- field: "timestamp",
47735
- direction: "desc"
47736
- },
47737
- limit: FACE_SWEEP_PAGE,
47738
- offset: page * FACE_SWEEP_PAGE
47739
- }
47740
- });
47741
- if (rows.length === 0) break;
47742
- for (const r of rows) {
47743
- const data = r.data;
47744
- if (data.assigned) continue;
47745
- const deviceId = Number(data.deviceId ?? -1);
47746
- const list = byDevice.get(deviceId) ?? [];
47747
- list.push({
47748
- id: r.id,
47749
- timestamp: Number(data.timestamp ?? 0)
47750
- });
47751
- byDevice.set(deviceId, list);
47752
- }
47753
- if (rows.length < 1e3) break;
47754
- }
47755
- const toDelete = /* @__PURE__ */ new Set();
47756
- for (const list of byDevice.values()) {
47757
- list.sort((a, b) => b.timestamp - a.timestamp);
47758
- list.forEach((row, idx) => {
47759
- if (idx >= input.maxPerDevice) toDelete.add(row.id);
47760
- });
47761
- }
47762
- const deleted = [];
47763
- for (const id of toDelete) try {
47764
- await this.store.delete.mutate({
47765
- collection: FACES_COLLECTION,
47766
- key: id
47767
- });
47768
- deleted.push(id);
47769
- } catch (err) {
47770
- this.logger.warn("FaceStore.pruneCapOverflow delete failed", { meta: {
47771
- faceId: id,
47772
- error: String(err)
47773
- } });
47774
- }
47775
- return deleted;
47776
- }
47777
- /** Flip `assigned` to true and record which identity was matched. Optionally persist the sample id. */
47778
- async markAssigned(faceId, identityId, sampleId) {
47779
- const data = {
47780
- assigned: true,
47781
- recognizedIdentityId: identityId
47782
- };
47783
- if (sampleId !== void 0) data["assignedSampleId"] = sampleId;
47784
- try {
47785
- await this.store.update.mutate({
47786
- collection: FACES_COLLECTION,
47787
- id: faceId,
47788
- data
47789
- });
47790
- } catch (err) {
47791
- this.logger.warn("FaceStore.markAssigned failed", { meta: {
47792
- faceId,
47793
- error: String(err)
47794
- } });
47795
- }
47796
- }
47797
- /** Clear the assignment: set assigned=false, clear recognizedIdentityId and assignedSampleId. */
47798
- async clearAssignment(faceId) {
47799
- try {
47800
- await this.store.update.mutate({
47801
- collection: FACES_COLLECTION,
47802
- id: faceId,
47803
- data: {
47804
- assigned: false,
47805
- recognizedIdentityId: null,
47806
- assignedSampleId: null
47807
- }
47808
- });
47809
- } catch (err) {
47810
- this.logger.warn("FaceStore.clearAssignment failed", { meta: {
47811
- faceId,
47812
- error: String(err)
47813
- } });
47814
- }
47815
- }
47816
- /**
47817
- * Persist a `face.rescoreTrack` result onto the SUGGESTED tier — the ONE
47818
- * write that explicitly-requested action makes. `suggestion: null` means
47819
- * the recompute found no candidate worth offering and CLEARS both columns
47820
- * (a genuine "the gallery has nothing for this face right now" result, not
47821
- * a no-op left at whatever they held before). Never touches `assigned` /
47822
- * `recognizedIdentityId` — a suggestion is never an assignment. The caller
47823
- * is responsible for never calling this with a non-comparable (`model-
47824
- * mismatch`) report; this method persists whatever it is given.
47825
- */
47826
- async setSuggestion(faceId, suggestion) {
47827
- const data = suggestion === null ? {
47828
- suggestedIdentityId: null,
47829
- suggestedMatchScore: null
47830
- } : {
47831
- suggestedIdentityId: suggestion.identityId,
47832
- suggestedMatchScore: suggestion.score
47833
- };
47834
- try {
47835
- await this.store.update.mutate({
47836
- collection: FACES_COLLECTION,
47837
- id: faceId,
47838
- data
47839
- });
47840
- } catch (err) {
47841
- this.logger.warn("FaceStore.setSuggestion failed", { meta: {
47842
- faceId,
47843
- error: String(err)
47844
- } });
47845
- }
47846
- }
47847
- /**
47848
- * `TrackScopedStore` contract for the retention cascade: delete the buffered
47849
- * face rows of the given tracks that are NOT assigned. Reuses the EXACT
47850
- * `assigned` exemption that `prune`/`pruneAll` apply (see the
47851
- * `!(r.data.assigned)` predicate above) so ENROLLED faces SURVIVE retention.
47852
- * Per-track ISOLATED + best-effort per row — one bad track or row never
47853
- * aborts the batch. (Crop media of these rows is removed by `MediaStore`.)
47854
- */
47855
- async deleteByTracks(trackIds) {
47856
- let removed = 0;
47857
- for (const trackId of trackIds) try {
47858
- const eligible = (await this.store.query.query({
47859
- collection: FACES_COLLECTION,
47860
- filter: { where: { trackId } }
47861
- })).filter((r) => !r.data.assigned);
47862
- for (const row of eligible) try {
47863
- await this.store.delete.mutate({
47864
- collection: FACES_COLLECTION,
47865
- key: row.id
47866
- });
47867
- removed++;
47868
- } catch (err) {
47869
- this.logger.warn("FaceStore.deleteByTracks delete failed", { meta: {
47870
- faceId: row.id,
47871
- error: String(err)
47872
- } });
47873
- }
47874
- } catch (err) {
47875
- this.logger.warn("FaceStore.deleteByTracks query failed", { meta: {
47876
- trackId,
47877
- error: String(err)
47878
- } });
47879
- }
47880
- return [{
47881
- family: "faces",
47882
- rows: removed,
47883
- bytes: 0
47884
- }];
47885
- }
47886
- /** Delete a single buffered face row by id (its crop media is removed by the
47887
- * caller — the FaceStore owns rows, not blobs). Best-effort. */
47888
- async delete(faceId) {
47889
- try {
47890
- await this.store.delete.mutate({
47891
- collection: FACES_COLLECTION,
47892
- key: faceId
47893
- });
47894
- } catch (err) {
47895
- this.logger.warn("FaceStore.delete failed", { meta: {
47896
- faceId,
47897
- error: String(err)
47898
- } });
47899
- }
47900
- }
47901
- /**
47902
- * Retrieve a single face by id. Returns null if not found.
47903
- */
47904
- async get(faceId) {
47905
- const raw = await this.store.get.query({
47906
- collection: FACES_COLLECTION,
47907
- key: faceId
47908
- });
47909
- if (!raw) return null;
47910
- const data = raw;
47911
- return {
47912
- id: faceId,
47913
- ...data,
47914
- assigned: Boolean(raw.assigned),
47915
- mediaKey: data.mediaKey ?? void 0,
47916
- recognizedIdentityId: data.recognizedIdentityId ?? void 0,
47917
- assignedSampleId: data.assignedSampleId ?? void 0,
47918
- keyFrameMediaKey: data.keyFrameMediaKey ?? void 0,
47919
- faceBbox: data.faceBbox ?? void 0,
47920
- bestMatchScore: data.bestMatchScore ?? void 0,
47921
- nativeFaceShortSidePx: data.nativeFaceShortSidePx ?? void 0,
47922
- suggestedIdentityId: data.suggestedIdentityId ?? void 0,
47923
- suggestedMatchScore: data.suggestedMatchScore ?? void 0
47924
- };
47925
- }
47926
- /**
47927
- * Update internal fields of a face (used for partial mutation, e.g. setting recognizedIdentityId).
47928
- * Not exposed publicly — intended for internal use and tests only.
47929
- */
47930
- async update(faceId, data) {
47931
- try {
47932
- await this.store.update.mutate({
47933
- collection: FACES_COLLECTION,
47934
- id: faceId,
47935
- data
47936
- });
47937
- } catch (err) {
47938
- this.logger.warn("FaceStore.update failed", { meta: {
47939
- faceId,
47940
- error: String(err)
47941
- } });
47942
- }
47943
- }
47944
- /**
47945
- * List ALL recent faces across all devices, newest-first.
47946
- *
47947
- * filter:
47948
- * - 'all' (default) → every row
47949
- * - 'unassigned' → !assigned
47950
- * - 'recognized' → !assigned && recognizedIdentityId != null
47951
- * - 'identified' → recognizedIdentityId != null, including manual assignments
47952
- */
47953
- async listAllRecentFaces(input) {
47954
- /**
47955
- * The filter is applied in memory, so a DB `limit` of N cannot be the
47956
- * caller's N: with `filter:'unassigned'` the newest N rows may be entirely
47957
- * assigned while matches wait just beyond them.
47958
- *
47959
- * The previous answer was to fetch the WHOLE collection and slice after
47960
- * filtering. That is the read that killed hub-main (`query-bounds.ts`) —
47961
- * every face row carries a JSON `embedding`, so "all rows" is the widest
47962
- * table in the database materialised at once, to render a gallery of at
47963
- * most a few hundred tiles.
47964
- *
47965
- * PAGE instead, and stop as soon as enough MATCHES exist. The pathological
47966
- * case (a filter that matches nothing) walks the whole collection exactly
47967
- * as before but one bounded page at a time; the common case stops after the
47968
- * first page. Semantics are unchanged: the limit is still applied after the
47969
- * filter, never before it.
47970
- */
47971
- const filterMode = input.filter ?? "all";
47972
- const matches = (f) => {
47973
- if (filterMode === "unassigned") return !f.assigned;
47974
- if (filterMode === "recognized") return !f.assigned && f.recognizedIdentityId != null;
47975
- if (filterMode === "identified") return f.recognizedIdentityId != null;
47976
- return true;
47977
- };
47978
- const result = [];
47979
- for (let page = 0; page < FACE_SWEEP_MAX_PAGES; page++) {
47980
- const rows = await this.store.query.query({
47981
- collection: FACES_COLLECTION,
47982
- filter: {
47983
- ...input.deviceId !== void 0 ? { where: { deviceId: input.deviceId } } : {},
47984
- orderBy: {
47985
- field: "timestamp",
47986
- direction: "desc"
47987
- },
47988
- limit: FACE_SWEEP_PAGE,
47989
- offset: page * FACE_SWEEP_PAGE
47990
- }
47991
- });
47992
- if (rows.length === 0) break;
47993
- for (const r of rows) {
47994
- const data = r.data;
47995
- const face = {
47996
- id: r.id,
47997
- ...data,
47998
- assigned: Boolean(r.data.assigned),
47999
- mediaKey: data.mediaKey ?? void 0,
48000
- recognizedIdentityId: data.recognizedIdentityId ?? void 0,
48001
- assignedSampleId: data.assignedSampleId ?? void 0,
48002
- keyFrameMediaKey: data.keyFrameMediaKey ?? void 0,
48003
- faceBbox: data.faceBbox ?? void 0,
48004
- bestMatchScore: data.bestMatchScore ?? void 0,
48005
- nativeFaceShortSidePx: data.nativeFaceShortSidePx ?? void 0,
48006
- suggestedIdentityId: data.suggestedIdentityId ?? void 0,
48007
- suggestedMatchScore: data.suggestedMatchScore ?? void 0
48008
- };
48009
- if (matches(face)) result.push(face);
48010
- if (input.limit !== void 0 && result.length >= input.limit) return result;
48011
- }
48012
- if (rows.length < 1e3) break;
48013
- }
48014
- return result;
48015
- }
48016
- /** One real face row per camera currently attributed to an identity. */
48017
- async listCameraRefsForIdentity(identityId) {
48018
- const byDevice = /* @__PURE__ */ new Map();
48019
- for (let page = 0; page < FACE_SWEEP_MAX_PAGES; page++) {
48020
- const rows = await this.store.query.query({
48021
- collection: FACES_COLLECTION,
48022
- filter: {
48023
- where: { recognizedIdentityId: identityId },
48024
- orderBy: {
48025
- field: "timestamp",
48026
- direction: "desc"
48027
- },
48028
- limit: FACE_SWEEP_PAGE,
48029
- offset: page * FACE_SWEEP_PAGE
48030
- }
48031
- });
48032
- if (rows.length === 0) break;
48033
- for (const row of rows) {
48034
- const deviceId = Number(row.data["deviceId"]);
48035
- if (Number.isInteger(deviceId) && !byDevice.has(deviceId)) byDevice.set(deviceId, row.id);
48036
- }
48037
- if (rows.length < 1e3) break;
48038
- }
48039
- return [...byDevice].map(([deviceId, faceId]) => ({
48040
- deviceId,
48041
- faceId
48042
- }));
48043
- }
48044
- };
48045
- //#endregion
48046
48275
  //#region src/pipeline-analytics/store/identity-store.ts
48047
48276
  /**
48048
48277
  * IdentityStore — per-person identity registry for face recognition.
@@ -60421,7 +60650,8 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
60421
60650
  const memberRows = (await Promise.all(members.map((m) => stores.mediaStore.listInfoByOwner("track", m.trackId)))).flatMap((rows) => rows.map((r) => ({
60422
60651
  key: r.key,
60423
60652
  kind: r.kind,
60424
- timestamp: r.timestamp
60653
+ timestamp: r.timestamp,
60654
+ sizeBytes: r.sizeBytes
60425
60655
  })));
60426
60656
  return digestMediaRows({
60427
60657
  mediaKey: group.mediaKey,
@@ -60433,7 +60663,8 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
60433
60663
  return (await stores.mediaStore.listInfoByOwner("track", id)).map((r) => ({
60434
60664
  key: r.key,
60435
60665
  kind: r.kind,
60436
- timestamp: r.timestamp
60666
+ timestamp: r.timestamp,
60667
+ sizeBytes: r.sizeBytes
60437
60668
  }));
60438
60669
  },
60439
60670
  readMedia: async (key) => {