cctv-stack 0.3.0__py3-none-any.whl

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.
cctv/materialise.py ADDED
@@ -0,0 +1,488 @@
1
+ """Turning a review item into a canonical clip, on disk and in the database.
2
+
3
+ This is the composition the rest of Phase 3 was built for. Everything subtle already lives
4
+ somewhere tested — the fetch range, the coverage decision, the atomic write, the probe
5
+ parsing, the trigger verdict — so this module is mostly *ordering*. The ordering is the
6
+ part that matters, though, and three of its rules are load-bearing:
7
+
8
+ 1. **Pin before downloading, unpin after.** [INV-9]. A failed pin is fatal for that review,
9
+ because downloading without a pin risks Frigate expiring the footage underneath us, and
10
+ a missing clip is worse than a late one. Unpinning is best-effort: a stuck pin wastes
11
+ Frigate's disk, it does not lose data, and the reaper collects it later.
12
+ 2. **A row exists only once the file does.** The database is the record of what is on disk,
13
+ so the insert happens after the rename. The reverse order would let a crash leave a row
14
+ pointing at nothing, and every consumer of that table would believe it.
15
+ 3. **A short clip is kept, not discarded.** [INV-14]. Attempts are ranked and the best one
16
+ is promoted; retrying improves the outcome and discarding cannot. Each attempt lands on
17
+ its own scratch path for exactly that reason — writing retries straight onto the
18
+ canonical path would let a worse second attempt overwrite a better first one, which is
19
+ the opposite of keeping the best.
20
+
21
+ **No network or subprocess work happens inside a database transaction.** The claim in
22
+ [`cctv.db`][cctv.db] commits before returning and the insert here opens its own short one,
23
+ so a slow fetch cannot hold the write lock.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import hashlib
29
+ import json
30
+ import sqlite3
31
+ import time
32
+ from collections.abc import Callable, Sequence
33
+ from contextlib import suppress
34
+ from dataclasses import dataclass
35
+ from pathlib import Path, PurePosixPath
36
+ from typing import Protocol
37
+ from zoneinfo import ZoneInfo
38
+
39
+ from . import atomic, db
40
+ from .clip import (
41
+ DEFAULT_COVERAGE_POLICY,
42
+ DEFAULT_LONG_CLIP_WARN_S,
43
+ DEFAULT_SEGMENT_FLUSH_GRACE_S,
44
+ CoveragePolicy,
45
+ FetchPlan,
46
+ Verdict,
47
+ judge_coverage,
48
+ pick_best,
49
+ plan_fetch,
50
+ )
51
+ from .ffprobe import ClipFacts, ProbeError, run_probe
52
+ from .frigate import FrigateClient, FrigateError, FrigateUnavailable, NoRecordingsYet
53
+ from .paths import candidate_relpaths, clip_cloud_key, sidecar_relpath
54
+ from .reviews import ReviewCandidate
55
+ from .timeutil import Instant
56
+ from .timeutil import now as _now
57
+
58
+ __all__ = [
59
+ "CREATED",
60
+ "DEFERRED",
61
+ "FAILED",
62
+ "SKIPPED",
63
+ "ClipMaterialiser",
64
+ "ClipSource",
65
+ "FrigateSource",
66
+ "MaterialiseOutcome",
67
+ "Prober",
68
+ ]
69
+
70
+ CREATED = "created"
71
+ SKIPPED = "skipped"
72
+ #: Retryable — the caller should requeue this review rather than treat it as lost.
73
+ DEFERRED = "deferred"
74
+ FAILED = "failed"
75
+
76
+
77
+ class ClipSource(Protocol):
78
+ """Where clips come from. Narrow on purpose, so a fake is three methods."""
79
+
80
+ def retain(self, event_id: str) -> None: ...
81
+
82
+ def unretain(self, event_id: str) -> None: ...
83
+
84
+ def download(
85
+ self,
86
+ camera: str,
87
+ start: Instant,
88
+ end: Instant,
89
+ sink: object,
90
+ *,
91
+ timeout_s: float,
92
+ ) -> int: ...
93
+
94
+
95
+ class Prober(Protocol):
96
+ def __call__(self, path: Path) -> ClipFacts: ...
97
+
98
+
99
+ @dataclass(frozen=True, slots=True)
100
+ class FrigateSource:
101
+ """Adapts [`cctv.frigate.FrigateClient`][cctv.frigate.FrigateClient] to `ClipSource`.
102
+
103
+ The indirection exists so the materialiser can be tested without a network, and so
104
+ nothing in this module needs to know the client's exception hierarchy beyond
105
+ `FrigateError`.
106
+ """
107
+
108
+ client: FrigateClient
109
+
110
+ def retain(self, event_id: str) -> None:
111
+ self.client.retain_event(event_id)
112
+
113
+ def unretain(self, event_id: str) -> None:
114
+ self.client.unretain_event(event_id)
115
+
116
+ def download(
117
+ self,
118
+ camera: str,
119
+ start: Instant,
120
+ end: Instant,
121
+ sink: object,
122
+ *,
123
+ timeout_s: float,
124
+ ) -> int:
125
+ return self.client.download_range(camera, start, end, sink, timeout_s=timeout_s)
126
+
127
+
128
+ @dataclass(frozen=True, slots=True)
129
+ class MaterialiseOutcome:
130
+ """What happened, and why.
131
+
132
+ `status` distinguishes four things that a boolean would conflate: a clip was made; it
133
+ was deliberately not made; it could not be made and should be retried; it could not be
134
+ made and will not be.
135
+ """
136
+
137
+ review_id: str
138
+ status: str
139
+ reason: str
140
+ clip_id: int | None = None
141
+ path: str | None = None
142
+ coverage_ratio: float | None = None
143
+ degraded: bool = False
144
+ long_clip: bool = False
145
+ fetch_attempts: int = 0
146
+
147
+ @property
148
+ def materialised(self) -> bool:
149
+ return self.status == CREATED
150
+
151
+
152
+ @dataclass(frozen=True, slots=True)
153
+ class _Ranked:
154
+ """A finished attempt, shaped so `pick_best` can rank it.
155
+
156
+ Exists so the ranking rule lives in exactly one place. Re-deriving "best" here would
157
+ mean two implementations of INV-14 that could disagree.
158
+ """
159
+
160
+ coverage_ratio: float | None
161
+ duration_s: float | None
162
+ scratch: Path
163
+ facts: ClipFacts
164
+
165
+
166
+ class ClipMaterialiser:
167
+ """Materialises review items into canonical clips."""
168
+
169
+ def __init__(
170
+ self,
171
+ *,
172
+ conn: sqlite3.Connection,
173
+ source: ClipSource,
174
+ data_dir: Path,
175
+ location: str,
176
+ zone: ZoneInfo,
177
+ prober: Prober = run_probe,
178
+ sleep: Callable[[float], None] = time.sleep,
179
+ clock: Callable[[], Instant] = _now,
180
+ policy: CoveragePolicy = DEFAULT_COVERAGE_POLICY,
181
+ flush_grace_s: float = DEFAULT_SEGMENT_FLUSH_GRACE_S,
182
+ long_clip_warn_s: float = DEFAULT_LONG_CLIP_WARN_S,
183
+ backoff_s: Sequence[float] = (5.0, 15.0, 45.0),
184
+ clips_dirname: str = "clips",
185
+ ) -> None:
186
+ self.conn = conn
187
+ self.source = source
188
+ self.data_dir = Path(data_dir)
189
+ self.location = location
190
+ self.zone = zone
191
+ self.prober = prober
192
+ self._sleep = sleep
193
+ self._clock = clock
194
+ self.policy = policy
195
+ self.flush_grace_s = flush_grace_s
196
+ self.long_clip_warn_s = long_clip_warn_s
197
+ self.backoff_s = tuple(backoff_s)
198
+ self.clips_dirname = clips_dirname
199
+
200
+ # -----------------------------------------------------------------------------------
201
+
202
+ def materialise(
203
+ self,
204
+ candidate: ReviewCandidate,
205
+ *,
206
+ pre_roll_s: float = 5.0,
207
+ post_roll_s: float = 10.0,
208
+ ) -> MaterialiseOutcome:
209
+ """Fetch, verify, and store one review as a canonical clip."""
210
+ if candidate.is_open:
211
+ return MaterialiseOutcome(candidate.review_id, SKIPPED, "review is still open")
212
+
213
+ plan = plan_fetch(
214
+ candidate.review_id,
215
+ start_time=candidate.start_time,
216
+ end_time=candidate.end_time,
217
+ pre_roll_s=pre_roll_s,
218
+ post_roll_s=post_roll_s,
219
+ segment_flush_grace_s=self.flush_grace_s,
220
+ )
221
+
222
+ delay = plan.seconds_until_ready(self._clock())
223
+ if delay > 0:
224
+ self._sleep(delay)
225
+
226
+ pinned: list[str] = []
227
+ try:
228
+ for event_id in candidate.event_ids:
229
+ try:
230
+ self.source.retain(event_id)
231
+ except FrigateError as exc:
232
+ # Fatal for this review. Downloading without a pin risks Frigate
233
+ # expiring the footage first, and a missing clip beats a late one only
234
+ # if it is deliberate — which this is.
235
+ return MaterialiseOutcome(
236
+ candidate.review_id,
237
+ FAILED,
238
+ f"could not pin event {event_id}: {exc}",
239
+ )
240
+ pinned.append(event_id)
241
+
242
+ return self._fetch_and_store(candidate, plan, post_roll_s=post_roll_s)
243
+ finally:
244
+ for event_id in pinned:
245
+ with suppress(FrigateError):
246
+ # Non-fatal by design: a stuck pin wastes disk, it does not lose data.
247
+ self.source.unretain(event_id)
248
+
249
+ # -----------------------------------------------------------------------------------
250
+
251
+ def _fetch_and_store(
252
+ self, candidate: ReviewCandidate, plan: FetchPlan, *, post_roll_s: float
253
+ ) -> MaterialiseOutcome:
254
+ target = self._choose_target(candidate)
255
+ ranked: list[_Ranked] = []
256
+ last_error: str | None = None
257
+
258
+ for attempt in range(1, self.policy.max_attempts + 1):
259
+ scratch = target.with_name(f"{target.name}.tmp-attempt{attempt}")
260
+ try:
261
+ with atomic.atomic_writer(scratch) as handle:
262
+ self.source.download(
263
+ candidate.camera,
264
+ plan.requested_start,
265
+ plan.requested_end,
266
+ handle,
267
+ timeout_s=plan.timeout_s,
268
+ )
269
+ facts = self.prober(scratch)
270
+ except (NoRecordingsYet, FrigateUnavailable) as exc:
271
+ # The flush race and transient outages. Both mean "ask again later".
272
+ atomic.discard(scratch)
273
+ last_error = str(exc)
274
+ if attempt < self.policy.max_attempts:
275
+ self._sleep(self._backoff(attempt))
276
+ continue
277
+ except ProbeError as exc:
278
+ atomic.discard(scratch)
279
+ last_error = str(exc)
280
+ if attempt < self.policy.max_attempts:
281
+ self._sleep(self._backoff(attempt))
282
+ continue
283
+ except FrigateError as exc:
284
+ atomic.discard(scratch)
285
+ return MaterialiseOutcome(candidate.review_id, FAILED, f"fetch failed: {exc}")
286
+
287
+ coverage = facts.coverage_against(plan.requested_s)
288
+ ranked.append(
289
+ _Ranked(
290
+ coverage_ratio=coverage,
291
+ duration_s=facts.duration_s,
292
+ scratch=scratch,
293
+ facts=facts,
294
+ )
295
+ )
296
+
297
+ verdict = judge_coverage(coverage, attempts_used=attempt, policy=self.policy)
298
+ if verdict is Verdict.ACCEPT:
299
+ break
300
+ if verdict is Verdict.ACCEPT_DEGRADED:
301
+ # Attempts exhausted and it is still short. Keep it, flag it, move on.
302
+ break
303
+ if attempt < self.policy.max_attempts:
304
+ self._sleep(self._backoff(attempt))
305
+
306
+ if not ranked:
307
+ return MaterialiseOutcome(
308
+ candidate.review_id,
309
+ DEFERRED,
310
+ last_error or "no attempt produced a clip",
311
+ )
312
+
313
+ chosen = pick_best(ranked)
314
+ assert isinstance(chosen, _Ranked) # pick_best returns from the input sequence
315
+ for item in ranked:
316
+ if item.scratch != chosen.scratch:
317
+ atomic.discard(item.scratch)
318
+
319
+ atomic.promote(chosen.scratch, target)
320
+ return self._record(
321
+ candidate,
322
+ plan,
323
+ target,
324
+ chosen,
325
+ post_roll_s=post_roll_s,
326
+ fetch_attempts=len(ranked),
327
+ )
328
+
329
+ # -----------------------------------------------------------------------------------
330
+
331
+ def _record(
332
+ self,
333
+ candidate: ReviewCandidate,
334
+ plan: FetchPlan,
335
+ target: Path,
336
+ chosen: _Ranked,
337
+ *,
338
+ post_roll_s: float,
339
+ fetch_attempts: int,
340
+ ) -> MaterialiseOutcome:
341
+ # Two related-but-different paths. `relative` is relative to DATA_DIR, which is
342
+ # what the database stores. The cloud key is built from the path relative to the
343
+ # *clips* directory, because `clip_cloud_key` adds the `clips` segment itself —
344
+ # passing the DATA_DIR-relative form doubled it.
345
+ relative = PurePosixPath(self.clips_dirname) / self._relative(target)
346
+ relative_str = relative.as_posix()
347
+ below_clips = self._relative(target)
348
+
349
+ size_bytes = target.stat().st_size
350
+ digest = _sha256(target)
351
+ coverage = chosen.coverage_ratio
352
+ degraded = coverage is None or coverage < self.policy.ok
353
+ long_clip = (chosen.duration_s or 0.0) > self.long_clip_warn_s
354
+ cloud_key = clip_cloud_key(self.location, below_clips)
355
+
356
+ sidecar_payload = {
357
+ "review_id": candidate.review_id,
358
+ "camera": candidate.camera,
359
+ "severity": candidate.severity,
360
+ "review_start_time": candidate.start_time,
361
+ "review_end_time": candidate.end_time,
362
+ "requested_start_ts": plan.requested_start,
363
+ "requested_end_ts": plan.requested_end,
364
+ "requested_s": plan.requested_s,
365
+ "duration_s": chosen.facts.duration_s,
366
+ "coverage_ratio": coverage,
367
+ "max_gap_s": chosen.facts.max_packet_gap_s,
368
+ "has_gaps": False, # see the note on ClipRecord below — the detector is unusable
369
+ "video_codec": chosen.facts.video_codec,
370
+ "audio_codec": chosen.facts.audio_codec,
371
+ "has_audio": chosen.facts.has_audio,
372
+ "event_ids": list(candidate.event_ids),
373
+ "objects": list(candidate.objects),
374
+ "zones": list(candidate.zones),
375
+ "sub_labels": list(candidate.sub_labels),
376
+ "post_roll_s": post_roll_s,
377
+ "degraded": degraded,
378
+ "long_clip": long_clip,
379
+ "path": relative_str,
380
+ "cloud_key": cloud_key,
381
+ "size_bytes": size_bytes,
382
+ "sha256": digest,
383
+ }
384
+
385
+ sidecar = sidecar_relpath(target)
386
+ atomic.write_atomic(
387
+ sidecar,
388
+ json.dumps(sidecar_payload, indent=2, sort_keys=True).encode("utf-8"),
389
+ )
390
+ sidecar_relative = (PurePosixPath(self.clips_dirname) / self._relative(sidecar)).as_posix()
391
+
392
+ clip_id, _created = db.insert_clip(
393
+ self.conn,
394
+ db.ClipRecord(
395
+ review_id=candidate.review_id,
396
+ camera=candidate.camera,
397
+ severity=candidate.severity,
398
+ start_time=candidate.start_time,
399
+ end_time=candidate.end_time or candidate.start_time,
400
+ path=relative_str,
401
+ event_ids=candidate.event_ids,
402
+ zones=candidate.zones,
403
+ label=candidate.objects[0] if candidate.objects else None,
404
+ duration_s=chosen.facts.duration_s,
405
+ requested_start_ts=plan.requested_start,
406
+ requested_end_ts=plan.requested_end,
407
+ pre_roll_s=candidate.start_time - plan.requested_start,
408
+ post_roll_s=post_roll_s,
409
+ # Recorded because the data is real and a future detector may want it.
410
+ # `has_gaps` stays false: packet-gap detection flags healthy clips on this
411
+ # stack (a measured 3.55 s gap in a 30 s clip with a 2.55 s keyframe
412
+ # interval), so setting it here would mark almost every clip as damaged and
413
+ # make the flag meaningless. ADR-0015.
414
+ max_gap_s=chosen.facts.max_packet_gap_s,
415
+ has_gaps=False,
416
+ has_audio=chosen.facts.has_audio,
417
+ video_codec=chosen.facts.video_codec,
418
+ audio_codec=chosen.facts.audio_codec,
419
+ fetch_attempts=fetch_attempts,
420
+ degraded=degraded,
421
+ long_clip=long_clip,
422
+ size_bytes=size_bytes,
423
+ sha256=digest,
424
+ sidecar_path=sidecar_relative,
425
+ materialised_at=_now(),
426
+ ),
427
+ now=_now(),
428
+ )
429
+
430
+ return MaterialiseOutcome(
431
+ review_id=candidate.review_id,
432
+ status=CREATED,
433
+ reason="ok" if not degraded else f"kept a short clip (coverage {coverage})",
434
+ clip_id=clip_id,
435
+ path=relative_str,
436
+ coverage_ratio=coverage,
437
+ degraded=degraded,
438
+ long_clip=long_clip,
439
+ fetch_attempts=fetch_attempts,
440
+ )
441
+
442
+ # -----------------------------------------------------------------------------------
443
+
444
+ def _relative(self, target: Path) -> PurePosixPath:
445
+ """`<camera>/YYYY/MM/DD/HH-MM-SS.mp4` — the part below the clips directory."""
446
+ return PurePosixPath(target.relative_to(self.data_dir / self.clips_dirname).as_posix())
447
+
448
+ def _choose_target(self, candidate: ReviewCandidate) -> Path:
449
+ """The path to write, resolving the collision case.
450
+
451
+ Two reviews on one camera can start in the same second, and a DST fallback makes it
452
+ certain rather than rare. The colliding form is used when the primary path belongs
453
+ to a *different* review, or when a file is there that no row accounts for — an
454
+ orphan from a crash. Overwriting either would destroy a clip to make room, which is
455
+ the one thing the design never does.
456
+ """
457
+ primary, colliding = candidate_relpaths(
458
+ candidate.camera, candidate.start_time, self.zone, candidate.review_id
459
+ )
460
+ chosen = primary
461
+
462
+ row = self.conn.execute(
463
+ "SELECT review_id FROM clips WHERE path = ?",
464
+ ((PurePosixPath(self.clips_dirname) / primary).as_posix(),),
465
+ ).fetchone()
466
+ if row is not None:
467
+ if row["review_id"] != candidate.review_id:
468
+ chosen = colliding
469
+ elif (self.data_dir / self.clips_dirname / primary).exists():
470
+ chosen = colliding
471
+
472
+ return self.data_dir / self.clips_dirname / chosen
473
+
474
+ def _backoff(self, attempt: int) -> float:
475
+ if not self.backoff_s:
476
+ return 0.0
477
+ return self.backoff_s[min(attempt - 1, len(self.backoff_s) - 1)]
478
+
479
+
480
+ def _sha256(path: Path, *, chunk: int = 1024 * 1024) -> str:
481
+ digest = hashlib.sha256()
482
+ with open(path, "rb") as handle:
483
+ while True:
484
+ block = handle.read(chunk)
485
+ if not block:
486
+ break
487
+ digest.update(block)
488
+ return digest.hexdigest()
@@ -0,0 +1,199 @@
1
+ -- Migration 0001 — the initial schema.
2
+ --
3
+ -- Forward-only. There are no down migrations: a bad migration is corrected by a new one
4
+ -- (INV-8, docs/architecture/lld/03-state-store.md §4.1).
5
+ --
6
+ -- Two properties are enforced here rather than in application code, because a constraint
7
+ -- cannot be forgotten:
8
+ --
9
+ -- * `UNIQUE(review_id)` is the idempotency mechanism. Materialising the same review
10
+ -- twice cannot produce two rows, which is what makes INV-7 structural.
11
+ -- * The `upload_state` CHECK is closed. A typo in a state name fails at write time
12
+ -- instead of creating a row that no query will ever find again.
13
+ --
14
+ -- Timestamps are `REAL` epoch seconds — an instant, which carries no timezone (INV-12,
15
+ -- ADR-0017). No column stores a rendered local time.
16
+
17
+ CREATE TABLE cameras (
18
+ name TEXT PRIMARY KEY,
19
+ display_name TEXT NOT NULL,
20
+ enabled INTEGER NOT NULL DEFAULT 1 CHECK (enabled IN (0, 1)),
21
+ first_seen_at REAL NOT NULL,
22
+ retired_at REAL,
23
+ config_hash TEXT,
24
+ updated_at REAL NOT NULL
25
+ );
26
+
27
+ CREATE TABLE clips (
28
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
29
+
30
+ -- Identity. The review id is Frigate's review item id and the dedup key
31
+ -- (ADR-0016). event_ids preserves event-level searchability for a per-visit file.
32
+ review_id TEXT NOT NULL UNIQUE,
33
+ event_ids TEXT NOT NULL DEFAULT '[]',
34
+ severity TEXT NOT NULL CHECK (severity IN ('alert', 'detection')),
35
+ camera TEXT NOT NULL REFERENCES cameras (name) ON UPDATE CASCADE,
36
+ label TEXT,
37
+ zones TEXT NOT NULL DEFAULT '[]',
38
+
39
+ -- What Frigate called the activity.
40
+ start_time REAL NOT NULL,
41
+ end_time REAL NOT NULL,
42
+
43
+ -- What we actually got. These are the columns that make INV-14 auditable: a
44
+ -- truncated clip, a clip with a hole, and a perfect clip are all valid MP4s that
45
+ -- play fine and upload fine, and are otherwise indistinguishable.
46
+ duration_s REAL,
47
+ requested_start_ts REAL,
48
+ requested_end_ts REAL,
49
+ requested_s REAL,
50
+ coverage_ratio REAL,
51
+ pre_roll_s REAL,
52
+ post_roll_s REAL,
53
+ max_gap_s REAL,
54
+ has_gaps INTEGER NOT NULL DEFAULT 0 CHECK (has_gaps IN (0, 1)),
55
+ has_audio INTEGER NOT NULL DEFAULT 0 CHECK (has_audio IN (0, 1)),
56
+ video_codec TEXT,
57
+ audio_codec TEXT,
58
+ fetch_attempts INTEGER NOT NULL DEFAULT 0,
59
+ degraded INTEGER NOT NULL DEFAULT 0 CHECK (degraded IN (0, 1)),
60
+ long_clip INTEGER NOT NULL DEFAULT 0 CHECK (long_clip IN (0, 1)),
61
+
62
+ -- The artefact. `path` is relative to DATA_DIR, never absolute, so a restore to a
63
+ -- different mount point still works.
64
+ path TEXT NOT NULL,
65
+ sidecar_path TEXT,
66
+ size_bytes INTEGER,
67
+ sha256 TEXT,
68
+
69
+ -- Upload state. See the contract §3 for the machine, and INV-1 for the one rule
70
+ -- that matters most: PENDING, UPLOADING, RETRY_WAIT and FAILED_PERMANENT all hold
71
+ -- the local file.
72
+ upload_state TEXT NOT NULL DEFAULT 'PENDING'
73
+ CHECK (upload_state IN (
74
+ 'PENDING', 'UPLOADING', 'RETRY_WAIT', 'UPLOADED',
75
+ 'FAILED_PERMANENT', 'SKIPPED_BY_POLICY',
76
+ 'DELETED_LOCAL', 'EXPIRED_CLOUD', 'FORCE_DELETED'
77
+ )),
78
+ attempts INTEGER NOT NULL DEFAULT 0,
79
+ next_attempt_at REAL,
80
+ lease_owner TEXT,
81
+ lease_expires_at REAL,
82
+ last_error_class TEXT CHECK (last_error_class IS NULL OR last_error_class IN (
83
+ 'transient', 'auth', 'not_found', 'quota', 'checksum',
84
+ 'unknown'
85
+ )),
86
+ -- Truncated to 2000 characters on write. Never contains credentials (INV-2).
87
+ last_error TEXT,
88
+
89
+ cloud_key TEXT,
90
+ cloud_etag TEXT,
91
+ cloud_size_bytes INTEGER,
92
+ uploaded_at REAL,
93
+ local_deleted_at REAL,
94
+ cloud_deleted_at REAL,
95
+
96
+ -- A pin protects Frigate's source footage until our copy is durable (INV-9).
97
+ pinned INTEGER NOT NULL DEFAULT 0 CHECK (pinned IN (0, 1)),
98
+ pinned_at REAL,
99
+
100
+ materialised_at REAL,
101
+ created_at REAL NOT NULL,
102
+ updated_at REAL NOT NULL,
103
+
104
+ CHECK (last_error IS NULL OR length(last_error) <= 2000)
105
+ );
106
+
107
+ -- Each index serves one named query. No speculative indexes: every one costs write
108
+ -- amplification on a table that is written on every clip.
109
+ CREATE INDEX clips_claim_query ON clips (upload_state, next_attempt_at, id);
110
+ CREATE INDEX clips_local_retention ON clips (upload_state, start_time);
111
+ CREATE INDEX clips_cloud_retention ON clips (upload_state, uploaded_at);
112
+ CREATE INDEX clips_camera_start ON clips (camera, start_time DESC);
113
+ CREATE INDEX clips_pin_reaper ON clips (pinned, pinned_at);
114
+ CREATE INDEX clips_by_checksum ON clips (sha256);
115
+
116
+ CREATE TABLE upload_attempts (
117
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
118
+ clip_id INTEGER NOT NULL REFERENCES clips (id) ON DELETE CASCADE,
119
+ attempt_no INTEGER NOT NULL,
120
+ started_at REAL NOT NULL,
121
+ finished_at REAL,
122
+ outcome TEXT CHECK (outcome IS NULL OR outcome IN (
123
+ 'success', 'transient_error', 'permanent_error', 'checksum_mismatch'
124
+ )),
125
+ http_status INTEGER,
126
+ bytes_sent INTEGER,
127
+ error_class TEXT,
128
+ error_message TEXT,
129
+ duration_ms INTEGER
130
+ );
131
+
132
+ CREATE TABLE sweep_state (
133
+ name TEXT PRIMARY KEY,
134
+ watermark REAL,
135
+ last_run_at REAL,
136
+ last_success_at REAL,
137
+ consecutive_failures INTEGER NOT NULL DEFAULT 0,
138
+ notes TEXT
139
+ );
140
+
141
+ CREATE TABLE pinned_events (
142
+ event_id TEXT PRIMARY KEY,
143
+ camera TEXT NOT NULL,
144
+ pinned_at REAL NOT NULL,
145
+ unpinned_at REAL,
146
+ reason TEXT NOT NULL CHECK (reason IN ('materialisation', 'manual')),
147
+ state TEXT NOT NULL CHECK (state IN ('held', 'released', 'expired', 'failed'))
148
+ );
149
+
150
+ CREATE INDEX pinned_events_open ON pinned_events (state, pinned_at);
151
+
152
+ CREATE TABLE component_health (
153
+ component TEXT PRIMARY KEY,
154
+ state TEXT NOT NULL CHECK (state IN (
155
+ 'HEALTHY', 'DEGRADED', 'UNHEALTHY', 'UNKNOWN', 'DISABLED'
156
+ )),
157
+ detail TEXT,
158
+ version TEXT,
159
+ observed_at REAL NOT NULL
160
+ );
161
+
162
+ CREATE TABLE health_history (
163
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
164
+ subject TEXT NOT NULL,
165
+ state TEXT NOT NULL,
166
+ observed_at REAL NOT NULL
167
+ );
168
+
169
+ CREATE INDEX health_history_subject ON health_history (subject, observed_at DESC);
170
+
171
+ CREATE TABLE reconciliation_findings (
172
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
173
+ run_at REAL NOT NULL,
174
+ kind TEXT NOT NULL CHECK (kind IN (
175
+ 'orphan_object', 'dangling_row', 'checksum_mismatch',
176
+ 'unexpected_local_file'
177
+ )),
178
+ subject TEXT NOT NULL,
179
+ detail TEXT,
180
+ resolved_at REAL
181
+ );
182
+
183
+ CREATE INDEX reconciliation_open ON reconciliation_findings (resolved_at, run_at);
184
+
185
+ CREATE TABLE system_events (
186
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
187
+ at REAL NOT NULL,
188
+ severity TEXT NOT NULL CHECK (severity IN ('info', 'warning', 'error', 'critical')),
189
+ component TEXT NOT NULL,
190
+ -- A stable machine API: alerting rules reference these, so changing one is a
191
+ -- breaking change.
192
+ code TEXT NOT NULL,
193
+ message TEXT,
194
+ detail TEXT,
195
+ dedupe_key TEXT
196
+ );
197
+
198
+ CREATE INDEX system_events_recent ON system_events (at DESC);
199
+ CREATE INDEX system_events_dedupe ON system_events (dedupe_key, at DESC);