markTestSkipped('pdo_sqlite not available on this host'); } $this->pdo = new PDO('sqlite::memory:', null, null, [ PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, ]); $this->pdo->exec(self::sqliteSchema()); } public function testNonceRoundTrip(): void { $repo = new PdoNonceRepository($this->pdo); $this->assertFalse($repo->seen('n1')); $repo->remember('n1', 9999999999); $this->assertTrue($repo->seen('n1')); } public function testEventInsertFindAcknowledge(): void { $repo = new PdoEventRepository($this->pdo); $uuid = str_repeat('a', 32); $id = $repo->insert([ 'event_uuid' => $uuid, 'device_id' => 'dev-1', 'site' => 'factory-a', 'camera_id' => 'cam-1', 'event_type' => 'fall', 'severity' => 'critical', 'track_id' => 7, 'zone_id' => null, 'occurred_at' => 1700000000.5, 'duration_s' => 2.5, 'metrics' => ['confidence' => 0.9], ]); $this->assertGreaterThan(0, $id); $found = $repo->find($id); $this->assertNotNull($found); $this->assertSame('dev-1', $found['device_id']); $this->assertSame('factory-a', $found['site']); $this->assertSame($uuid, $found['event_uuid']); $this->assertSame('critical', $found['severity']); $this->assertSame(['confidence' => 0.9], $found['metrics']); $this->assertNull($found['acknowledged_by']); // Idempotency key lookup (P1-9). $this->assertSame($id, $repo->idByUuid($uuid)); $this->assertNull($repo->idByUuid(str_repeat('z', 32))); // Site-scoped page (P3-2b): matches its own site, excluded from another. $this->assertCount(1, $repo->page(10, null, null, null, 'factory-a')); $this->assertCount(0, $repo->page(10, null, null, null, 'factory-b')); $recent = $repo->recent(10, 'critical'); $this->assertCount(1, $recent); $this->assertSame([], $repo->recent(10, 'info')); $this->assertTrue($repo->acknowledge($id, 'alice')); $this->assertSame('alice', $repo->find($id)['acknowledged_by']); $this->assertFalse($repo->acknowledge(999999, 'bob')); } public function testZoneSaveIsOwnershipScoped(): void { $repo = new PdoZoneRepository($this->pdo); $zoneId = $repo->save([ 'zone_id' => null, 'device_id' => 'dev-1', 'camera_id' => 'cam-1', 'level' => 'warning', 'polygon_json' => '[[0,0],[1,0],[1,1]]', ]); $this->assertNotSame('', $zoneId); $this->assertCount(1, $repo->forCamera('cam-1')); // Legitimate update by the owning device/camera succeeds. $again = $repo->save([ 'zone_id' => $zoneId, 'device_id' => 'dev-1', 'camera_id' => 'cam-1', 'level' => 'restricted', 'polygon_json' => '[[0,0],[2,0],[2,2]]', ]); $this->assertSame($zoneId, $again); // Supplying the same zone_id under a different device/camera must not // silently overwrite it -- this is the cross-tenant guard from the // Task 13 review. $this->expectException(RuntimeException::class); $repo->save([ 'zone_id' => $zoneId, 'device_id' => 'dev-2', 'camera_id' => 'cam-2', 'level' => 'pinch', 'polygon_json' => '[[9,9],[9,8],[8,8]]', ]); } public function testZoneDelete(): void { $repo = new PdoZoneRepository($this->pdo); $zoneId = $repo->save([ 'zone_id' => null, 'device_id' => 'dev-1', 'camera_id' => 'cam-1', 'level' => 'warning', 'polygon_json' => '[[0,0],[1,0],[1,1]]', ]); $this->assertTrue($repo->delete($zoneId)); $this->assertFalse($repo->delete($zoneId)); } /** * Minimal sqlite-flavored translation of the nonces/events/zones tables * in ../schema.sql (see that file's "sqlite compatibility notes" header * for the general MySQL -> sqlite mapping rules applied here). */ /** Insert an event with sensible defaults; only the interesting columns need naming. */ private function seedEvent(PdoEventRepository $repo, array $over = []): int { static $n = 0; $n++; return $repo->insert($over + [ 'event_uuid' => str_repeat((string)($n % 10), 32), 'device_id' => 'dev-1', 'site' => 'factory-a', 'camera_id' => 'cam-1', 'event_type' => 'fall', 'severity' => 'critical', 'track_id' => 1, 'zone_id' => null, 'occurred_at' => 1700000000.0, 'duration_s' => 1.0, 'metrics' => [], ]); } public function testCameraLookupsBackTheActionOwnershipCheck(): void { // These two back ActionsController's unknown_camera 400 and cross-site 404. Cameras have // no registry table, so events ARE the ownership record — verify against real SQL. $repo = new PdoEventRepository($this->pdo); $this->seedEvent($repo, ['camera_id' => 'cam-1', 'site' => 'factory-a']); $this->assertTrue($repo->cameraExists('cam-1')); $this->assertFalse($repo->cameraExists('cam-typo')); $this->assertSame('factory-a', $repo->siteForCamera('cam-1')); $this->assertNull($repo->siteForCamera('cam-typo')); // A re-homed camera belongs to the site it reports to NOW (newest event wins). $this->seedEvent($repo, ['camera_id' => 'cam-1', 'site' => 'factory-b']); $this->assertSame('factory-b', $repo->siteForCamera('cam-1')); } public function testActionRepositoryScopesBySite(): void { $repo = new PdoActionRepository($this->pdo); $repo->insert(['camera_id' => 'cam-a', 'site' => 'factory-a', 'note' => '매트', 'taken_at' => 100, 'created_by' => 'alice']); $repo->insert(['camera_id' => 'cam-b', 'site' => 'factory-b', 'note' => '난간', 'taken_at' => 200, 'created_by' => 'bob']); $this->assertCount(2, $repo->recent(10, null)); // global operator $scoped = $repo->recent(10, 'factory-a'); $this->assertCount(1, $scoped); $this->assertSame('매트', $scoped[0]['note']); $this->assertCount(0, $repo->recent(10, 'factory-c')); // Fail-closed: an empty-string scope is a misconfigured scope, not "everything". $this->assertCount(0, $repo->recent(10, '')); } public function testCountForCameraDrivesTheBeforeAfterEffect(): void { $repo = new PdoEventRepository($this->pdo); $this->seedEvent($repo, ['occurred_at' => 100.0]); $this->seedEvent($repo, ['occurred_at' => 150.0]); $this->seedEvent($repo, ['occurred_at' => 300.0]); $this->seedEvent($repo, ['occurred_at' => 120.0, 'event_type' => 'ergo_report']); $this->assertSame(2, $repo->countForCamera('cam-1', 0.0, 200.0, null)); // ergo excluded $this->assertSame(0, $repo->countForCamera('cam-1', 0.0, 200.0, 'factory-b')); } public function testAggregatesThatBackTheReportPageRunAgainstRealSql(): void { // reportSummary/fpStats/heatmapPoints/occurrenceTimes/exportRows were previously // exercised ONLY against the in-memory double, which reimplements their semantics in // PHP — so a broken query could not fail the suite. Round-trip them for real. $repo = new PdoEventRepository($this->pdo); $this->seedEvent($repo, ['occurred_at' => 1000.0, 'spatial_x' => 0.4, 'spatial_y' => 0.6]); $fp = $this->seedEvent($repo, ['occurred_at' => 1100.0, 'severity' => 'warning']); $repo->markFalsePositive($fp, 'alice'); $this->seedEvent($repo, ['occurred_at' => 1200.0, 'event_type' => 'ergo_report', 'severity' => 'info', 'metrics' => ['bend_s' => 12]]); $summary = $repo->reportSummary(0.0, null, 5); $this->assertNotEmpty($summary['by_type']); $this->assertNotEmpty($summary['by_camera']); $this->assertArrayHasKey('tta', $summary); $this->assertNotEmpty($repo->fpStats(0.0, null)); $this->assertCount(1, $repo->heatmapPoints('cam-1', 0.0, null, 100)); // only the spatial row $this->assertCount(2, $repo->occurrenceTimes(0.0, null, 100)); // ergo excluded $this->assertCount(3, $repo->exportRows(0.0, null, 100)); // Site scoping must reach every one of them. $this->assertSame([], $repo->fpStats(0.0, 'factory-b')); $this->assertSame([], $repo->heatmapPoints('cam-1', 0.0, 'factory-b', 100)); $this->assertSame([], $repo->exportRows(0.0, 'factory-b', 100)); } public function testEventPurgeIsOptInAndBounded(): void { $repo = new PdoEventRepository($this->pdo); for ($i = 0; $i < 5; $i++) $this->seedEvent($repo, ['occurred_at' => 100.0 + $i]); $this->seedEvent($repo, ['occurred_at' => 9000.0]); $this->assertSame(3, $repo->purgeOlderThan(9000.0, 3)); // batch bound respected $this->assertSame(2, $repo->purgeOlderThan(9000.0, 500)); $this->assertSame(0, $repo->purgeOlderThan(9000.0, 500)); // the newer row survives $this->assertCount(1, $repo->page(10, null, null, null, null)); } public function testSnapshotThumbnailLookupAvoidsNPlusOne(): void { $events = new PdoEventRepository($this->pdo); $snaps = new PdoSnapshotRepository($this->pdo, sys_get_temp_dir()); $a = $this->seedEvent($events, ['occurred_at' => 10.0]); $b = $this->seedEvent($events, ['occurred_at' => 20.0]); $snaps->add($a, str_repeat('a', 32), 'h1'); $snaps->add($a, str_repeat('b', 32), 'h2'); // newest wins as the thumbnail $snaps->add($b, str_repeat('c', 32), 'h3'); $thumbs = $snaps->thumbUuidsFor([$a, $b]); $this->assertSame(str_repeat('b', 32), $thumbs[$a]); $this->assertSame(str_repeat('c', 32), $thumbs[$b]); $this->assertCount(2, $snaps->forEvent($a)); } // ================================================================================ // 2026-08-09 확장: 억제 · 통보 · 임계값 편집 · 위험성평가 · 인수인계 · 성능 시계열. // 이 클래스가 납품되는 SQL 이 실제로 실행되는 유일한 자리이므로, 새 저장소도 여기에서 // 검증한다 — 인메모리 더블은 쿼리 의미를 PHP 로 재구현한 것이라 깨진 SQL 을 못 잡는다. // ================================================================================ public function testSuppressionActiveWindowAndRevoke(): void { $repo = new PdoSuppressionRepository($this->pdo); $now = 1_800_000_000; $id = $repo->create(['scope_type' => 'camera', 'scope_id' => 'cam-1', 'site' => 'factory-a', 'reason' => '금형 교체', 'starts_at' => $now - 60, 'ends_at' => $now + 600, 'created_by' => 'op', 'created_at' => $now - 60]); $this->assertCount(1, $repo->activeFor([['camera', 'cam-1']], $now)); $this->assertCount(0, $repo->activeFor([['camera', 'cam-2']], $now), '다른 카메라는 억제되지 않는다'); $this->assertCount(0, $repo->activeFor([['camera', 'cam-1']], $now + 3600), '만료 후에는 되살아나지 않는다'); $this->assertCount(0, $repo->activeFor([['camera', 'cam-1']], $now - 3600), '시작 전에는 적용되지 않는다'); // 스코프 밖 사용자는 해제할 수 없다 — 억제 해제도 접근제어 대상이다. $this->assertFalse($repo->revoke($id, 'other', $now, 'factory-b')); $this->assertTrue($repo->revoke($id, 'op', $now, 'factory-a')); $this->assertCount(0, $repo->activeFor([['camera', 'cam-1']], $now), '해제 즉시 효력이 사라진다'); $this->assertFalse($repo->revoke($id, 'op', $now, 'factory-a'), '두 번 해제되지 않는다'); } public function testAlertClaimIsTheDuplicateSendGuard(): void { $events = new PdoEventRepository($this->pdo); $id = $this->seedEvent($events, []); $repo = new PdoAlertRepository($this->pdo); $now = 1_800_000_000; $this->assertTrue($repo->claim($id, 7, $now)); // 겹쳐 도는 크론 두 개. UNIQUE 키가 승자를 정한다 — 읽고-쓰기였다면 둘 다 보냈다. $this->assertFalse($repo->claim($id, 7, $now)); $repo->markFailed($id, 7, 'relay down', $now); $this->assertSame([['event_id' => $id, 'channel_id' => 7, 'attempts' => 1]], $repo->retryable(5, 10)); $repo->markSent($id, 7, $now + 5); $this->assertSame([], $repo->retryable(5, 10)); $log = $repo->forEvent($id); $this->assertSame('sent', $log[0]['status']); $this->assertNull($log[0]['last_error'], '성공하면 직전 오류를 지운다'); } public function testAlertChannelsFilterBySiteAndOrderByStep(): void { $repo = new PdoAlertRepository($this->pdo); $now = 1_800_000_000; $repo->saveChannel(['site' => null, 'kind' => 'email', 'name' => 'global', 'config_json' => '{}', 'min_severity' => 'high', 'enabled' => 1, 'step' => 1, 'escalate_after_s' => 300, 'created_at' => $now]); $repo->saveChannel(['site' => 'factory-a', 'kind' => 'webhook', 'name' => 'a-immediate', 'config_json' => '{}', 'min_severity' => 'critical', 'enabled' => 1, 'step' => 0, 'escalate_after_s' => 0, 'created_at' => $now]); $repo->saveChannel(['site' => 'factory-b', 'kind' => 'email', 'name' => 'b', 'config_json' => '{}', 'min_severity' => 'high', 'enabled' => 1, 'step' => 0, 'escalate_after_s' => 0, 'created_at' => $now]); $repo->saveChannel(['site' => null, 'kind' => 'email', 'name' => 'off', 'config_json' => '{}', 'min_severity' => 'info', 'enabled' => 0, 'step' => 0, 'escalate_after_s' => 0, 'created_at' => $now]); $names = array_column($repo->channels('factory-a'), 'name'); // 전역 채널 + 자기 현장 채널만, step 오름차순(즉시 -> 에스컬레이션). $this->assertSame(['a-immediate', 'global'], $names); } public function testThresholdEditorRoundTripAndSiteScope(): void { $this->pdo->exec("INSERT INTO move_devices (device_id, name, site, secret) VALUES ('pi-1', 'press', 'factory-a', 's'), ('pi-2', 'wh', 'factory-b', 's')"); $repo = new PdoThresholdRepository($this->pdo); $now = 1_800_000_000; $id = $repo->set('pi-1', null, 'fall_prone_s', '3', 'admin', $now); // 같은 (device, camera, key) 재저장은 새 행이 아니라 갱신이어야 한다. UNIQUE 인덱스가 // NULL 을 서로 다르게 취급하므로 ON DUPLICATE KEY 로는 매번 행이 늘어난다. $again = $repo->set('pi-1', null, 'fall_prone_s', '5', 'admin', $now + 10); $this->assertSame($id, $again); $this->assertSame(['fall_prone_s' => '5'], $repo->forDevice('pi-1')); // 카메라별 재정의는 장치 전체 맵에 섞이지 않는다 — 섞이면 한 카메라 튜닝이 전 카메라에 적용된다. $repo->set('pi-1', 'cam-9', 'fall_prone_s', '9', 'admin', $now); $this->assertSame(['fall_prone_s' => '5'], $repo->forDevice('pi-1')); $this->assertCount(0, $repo->listAll('factory-b'), '타 현장 행은 보이지 않는다'); $scoped = $repo->listAll('factory-a'); $this->assertCount(2, $scoped); $this->assertFalse($repo->remove($id, 'factory-b'), '타 현장은 지울 수 없다'); $this->assertTrue($repo->remove($id, 'factory-a')); } public function testRiskControlUpsertDoesNotDuplicateTheGlobalRow(): void { $repo = new PdoRiskControlRepository($this->pdo); $now = 1_800_000_000; $row = ['site' => null, 'event_type' => 'fall', 'hazard' => '단차 전도', 'control' => '매트', 'severity_rating' => 5, 'likelihood_rating' => 3, 'updated_at' => $now]; $repo->upsert($row); $repo->upsert(['control' => '매트 + 난간'] + $row); $all = $repo->listAll(null); $this->assertCount(1, $all, 'NULL site 는 UNIQUE 에서 서로 다르게 취급되므로 명시적 조회가 필요하다'); $this->assertSame('매트 + 난간', $all[0]['control']); $this->assertSame(15, $all[0]['risk_score']); } public function testHandoverSnapshotIsStoredNotRecomputed(): void { $repo = new PdoHandoverRepository($this->pdo); $now = 1_800_000_000; $repo->create(['site' => 'factory-a', 'from_user' => 'kim', 'to_user' => 'lee', 'period_start' => $now - 43200, 'period_end' => $now, 'unacked_count' => 7, 'severe_count' => 2, 'note' => '프레스 라인 주의', 'created_at' => $now]); $latest = $repo->latest('factory-a'); $this->assertSame(7, $latest['unacked_count']); $this->assertSame('lee', $latest['to_user']); $this->assertNull($repo->latest('factory-b')); } public function testDeviceMetricSeriesAndPrune(): void { $repo = new PdoDeviceMetricRepository($this->pdo); $t = 1_800_000_000; $repo->append('pi-1', ['fps' => 24.5, 'temp' => 58.0, 'throttle' => false, 'queue_pending' => 0], $t - 100); $repo->append('pi-1', ['fps' => 12.1, 'throttle' => true], $t); // temp 미보고 $repo->append('pi-2', ['fps' => 30.0], $t); $series = $repo->series('pi-1', $t - 200, 100); $this->assertCount(2, $series); $this->assertSame($t - 100, $series[0]['ts'], '오름차순'); // 보고되지 않은 값은 0 이 아니라 NULL 이어야 한다 — 0°C 로 앉아 있는 장비처럼 보이면 안 된다. $this->assertNull($series[1]['temp']); $this->assertTrue($series[1]['throttle']); // prune 은 ts < 기준시각만 지운다. 여기서는 pi-1 의 옛 점 하나뿐이고, 기준시각과 // 같은 ts 를 가진 두 점(pi-1, pi-2)은 남는다 — 경계는 열린 구간이어야 방금 들어온 // 하트비트가 같은 초에 지워지지 않는다. $this->assertSame(1, $repo->prune($t)); $this->assertCount(1, $repo->series('pi-1', 0, 100)); $this->assertCount(1, $repo->series('pi-2', 0, 100)); } public function testBumpConfigVersionIsAtomic(): void { $this->pdo->exec("INSERT INTO move_devices (device_id, secret, config_version) VALUES ('pi-1', 's', 4)"); $repo = new PdoDeviceRepository($this->pdo); $this->assertSame(5, $repo->bumpConfigVersion('pi-1')); $this->assertSame(6, $repo->bumpConfigVersion('pi-1')); $this->assertSame(0, $repo->bumpConfigVersion('pi-nope'), '없는 장비는 0'); } public function testEventNotificationWindowIsOldestFirst(): void { $events = new PdoEventRepository($this->pdo); $a = $this->seedEvent($events, ['occurred_at' => 200.0]); $b = $this->seedEvent($events, ['occurred_at' => 100.0]); $ids = array_column($events->forNotification(50.0, 10), 'event_id'); // 밀린 백로그에서는 가장 오래된 미처리 응급부터 통보해야 한다. $this->assertSame([$b, $a], $ids); } public function testWindowForCameraIsBoundedAndScoped(): void { $events = new PdoEventRepository($this->pdo); $in = $this->seedEvent($events, ['occurred_at' => 100.0, 'camera_id' => 'cam-1', 'site' => 'factory-a']); $this->seedEvent($events, ['occurred_at' => 999.0, 'camera_id' => 'cam-1', 'site' => 'factory-a']); $this->seedEvent($events, ['occurred_at' => 100.0, 'camera_id' => 'cam-2', 'site' => 'factory-a']); $rows = $events->windowForCamera('cam-1', 50.0, 200.0, 'factory-a', 100); $this->assertSame([$in], array_column($rows, 'event_id')); $this->assertSame([], $events->windowForCamera('cam-1', 50.0, 200.0, 'factory-b', 100)); } private static function sqliteSchema(): string { return <<<'SQL' CREATE TABLE move_nonces ( nonce TEXT PRIMARY KEY, expires_at INTEGER NOT NULL ); CREATE TABLE move_events ( event_id INTEGER PRIMARY KEY AUTOINCREMENT, event_uuid TEXT UNIQUE, device_id TEXT NOT NULL, site TEXT, camera_id TEXT NOT NULL, event_type TEXT NOT NULL, severity TEXT NOT NULL, track_id INTEGER, zone_id TEXT, occurred_at REAL NOT NULL, duration_s REAL NOT NULL DEFAULT 0, metrics_json TEXT, spatial_x REAL, spatial_y REAL, acknowledged_by TEXT, acknowledged_at INTEGER, false_positive_by TEXT, false_positive_at INTEGER, received_at INTEGER NOT NULL ); CREATE TABLE move_zones ( zone_id TEXT PRIMARY KEY, device_id TEXT NOT NULL, camera_id TEXT NOT NULL, level TEXT NOT NULL, polygon_json TEXT NOT NULL, rules_json TEXT, updated_at INTEGER NOT NULL ); CREATE TABLE move_actions ( action_id INTEGER PRIMARY KEY AUTOINCREMENT, camera_id TEXT NOT NULL, site TEXT, event_id INTEGER, note TEXT NOT NULL, taken_at INTEGER NOT NULL, created_by TEXT NOT NULL, created_at INTEGER NOT NULL ); CREATE TABLE move_devices ( device_id TEXT PRIMARY KEY, name TEXT, site TEXT, secret TEXT NOT NULL, config_version INTEGER NOT NULL DEFAULT 1, last_fps REAL, last_temp REAL, last_throttle INTEGER, reported_config_version INTEGER, last_heartbeat_at INTEGER, created_at INTEGER NOT NULL DEFAULT 0 ); CREATE TABLE move_thresholds ( id INTEGER PRIMARY KEY AUTOINCREMENT, device_id TEXT NOT NULL, camera_id TEXT, zone_id TEXT, `key` TEXT NOT NULL, `value` TEXT NOT NULL, updated_by TEXT, updated_at INTEGER ); CREATE TABLE move_audit_log ( id INTEGER PRIMARY KEY AUTOINCREMENT, actor TEXT NOT NULL, action TEXT NOT NULL, target TEXT NOT NULL, before_json TEXT NOT NULL, after_json TEXT NOT NULL, ts INTEGER NOT NULL, prev_hash TEXT, entry_hash TEXT ); CREATE TABLE move_suppressions ( suppression_id INTEGER PRIMARY KEY AUTOINCREMENT, scope_type TEXT NOT NULL, scope_id TEXT NOT NULL, site TEXT, reason TEXT NOT NULL, starts_at INTEGER NOT NULL, ends_at INTEGER NOT NULL, created_by TEXT NOT NULL, created_at INTEGER NOT NULL, revoked_by TEXT, revoked_at INTEGER ); CREATE TABLE move_alert_channels ( channel_id INTEGER PRIMARY KEY AUTOINCREMENT, site TEXT, kind TEXT NOT NULL, name TEXT NOT NULL, config_json TEXT NOT NULL, min_severity TEXT NOT NULL DEFAULT 'high', enabled INTEGER NOT NULL DEFAULT 1, step INTEGER NOT NULL DEFAULT 0, escalate_after_s INTEGER NOT NULL DEFAULT 0, created_at INTEGER NOT NULL ); CREATE TABLE move_alert_log ( alert_id INTEGER PRIMARY KEY AUTOINCREMENT, event_id INTEGER NOT NULL, channel_id INTEGER NOT NULL, status TEXT NOT NULL, attempts INTEGER NOT NULL DEFAULT 0, last_error TEXT, queued_at INTEGER NOT NULL, sent_at INTEGER, UNIQUE (event_id, channel_id) ); CREATE TABLE move_shift_handovers ( handover_id INTEGER PRIMARY KEY AUTOINCREMENT, site TEXT, from_user TEXT, to_user TEXT NOT NULL, period_start INTEGER NOT NULL, period_end INTEGER NOT NULL, unacked_count INTEGER NOT NULL, severe_count INTEGER NOT NULL, note TEXT, created_at INTEGER NOT NULL ); CREATE TABLE move_risk_controls ( control_id INTEGER PRIMARY KEY AUTOINCREMENT, site TEXT, event_type TEXT NOT NULL, hazard TEXT NOT NULL, control TEXT NOT NULL, severity_rating INTEGER NOT NULL, likelihood_rating INTEGER NOT NULL, reviewed_by TEXT, reviewed_at INTEGER, updated_at INTEGER NOT NULL ); CREATE TABLE move_device_metrics ( metric_id INTEGER PRIMARY KEY AUTOINCREMENT, device_id TEXT NOT NULL, ts INTEGER NOT NULL, fps REAL, temp REAL, throttle INTEGER, queue_pending INTEGER, cameras_total INTEGER, cameras_erroring INTEGER, errors INTEGER ); CREATE TABLE move_event_snapshots ( snapshot_id INTEGER PRIMARY KEY AUTOINCREMENT, event_id INTEGER NOT NULL, file_uuid TEXT NOT NULL, content_hash TEXT, created_at INTEGER NOT NULL ); SQL; } }