feat(cloud): cursor pagination for the event list (P4-5)

The event list was hard-capped at the newest 100 with no way to reach
older events. Adds keyset pagination:

- EventRepository::page(limit, severity, beforeTs, beforeId) on both the
  PDO and in-memory implementations, ordered (occurred_at DESC,
  event_id DESC). The cursor is the last row's (occurred_at, event_id),
  so pages stay stable as new events arrive at the head — no overlap, no
  skips. occurred_at is bound as a string in SQL to keep DECIMAL
  precision.
- EventListController reads `limit` (default 50, capped 200) and `cursor`
  ("occurred_at:event_id", malformed input ignored) and returns
  `next_cursor` only on a full page. recent() is retained for existing
  callers; the response gains next_cursor additively.

The live-tail dashboard keeps consuming page 1; a history-browse UI can
build on the same cursor. Test: testListPaginatesWithCursor.

phpunit: 89 passing (4 skipped: PDO-backed).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Xmn8KZWqiQUguEMRtn6rW
This commit is contained in:
유창욱 2026-07-17 10:05:52 +09:00
parent d0ba621b42
commit 9a0ff1fc5e
6 changed files with 101 additions and 3 deletions

View file

@ -8,13 +8,41 @@ use App\Repository\EventRepository;
final class EventListController
{
private const DEFAULT_LIMIT = 50;
private const MAX_LIMIT = 200;
public function __construct(private EventRepository $events, private SessionStore $sess) {}
public function json(Request $req): Response
{
if ($this->sess->get('user_id') === null) return Response::json(['error' => 'unauthorized'], 401);
$severity = $req->query('severity');
$rows = $this->events->recent(100, $severity);
return Response::json(['events' => $rows], 200);
$limit = self::clampLimit($req->query('limit'));
[$beforeTs, $beforeId] = self::parseCursor((string)$req->query('cursor'));
$rows = $this->events->page($limit, $severity, $beforeTs, $beforeId);
// Advertise a next cursor only on a full page — a short page means we hit the end.
// The cursor is "<occurred_at>:<event_id>" of the last (oldest) row on this page.
$next = null;
if (count($rows) === $limit && $rows !== []) {
$last = $rows[count($rows) - 1];
$next = $last['occurred_at'] . ':' . $last['event_id'];
}
return Response::json(['events' => $rows, 'next_cursor' => $next], 200);
}
private static function clampLimit(mixed $raw): int
{
if ($raw === null || $raw === '' || !is_numeric($raw)) return self::DEFAULT_LIMIT;
return max(1, min(self::MAX_LIMIT, (int)$raw));
}
/** @return array{0: ?float, 1: ?int} parsed (occurred_at, event_id), or null/null if absent/malformed. */
private static function parseCursor(string $raw): array
{
if ($raw === '' || !str_contains($raw, ':')) return [null, null];
[$ts, $id] = explode(':', $raw, 2);
if (!is_numeric($ts) || $id === '' || !ctype_digit($id)) return [null, null];
return [(float)$ts, (int)$id];
}
}

View file

@ -6,6 +6,13 @@ interface EventRepository
{
public function insert(array $event): int;
public function recent(int $limit, ?string $severity): array;
/**
* Keyset (cursor) page over the (occurred_at DESC, event_id DESC) sort key.
* Pass null/null for the first page; then the (occurred_at, event_id) of the last
* returned row to fetch strictly older events. Stable under concurrent inserts —
* new events land at the head and never shift a page window.
*/
public function page(int $limit, ?string $severity, ?float $beforeTs, ?int $beforeId): array;
public function find(int $id): ?array;
public function acknowledge(int $id, string $user): bool;
}

View file

@ -58,6 +58,36 @@ final class PdoEventRepository implements EventRepository
return array_map(self::mapRow(...), $st->fetchAll());
}
public function page(int $limit, ?string $severity, ?float $beforeTs, ?int $beforeId): array
{
// Bind list as [value, PDO type] pairs, positional in SQL order.
$where = [];
$binds = [];
if ($severity !== null) {
$where[] = 'severity = ?';
$binds[] = [$severity, PDO::PARAM_STR];
}
// Keyset predicate: strictly "older" than the client's last-seen (occurred_at, event_id)
// on the (occurred_at DESC, event_id DESC) order. occurred_at is bound as a string so the
// DECIMAL comparison keeps full precision (no float round-trip in the driver).
if ($beforeTs !== null && $beforeId !== null) {
$where[] = '(occurred_at < ? OR (occurred_at = ? AND event_id < ?))';
$binds[] = [(string)$beforeTs, PDO::PARAM_STR];
$binds[] = [(string)$beforeTs, PDO::PARAM_STR];
$binds[] = [$beforeId, PDO::PARAM_INT];
}
$sql = 'SELECT ' . self::SELECT_COLUMNS . ' FROM events';
if ($where) $sql .= ' WHERE ' . implode(' AND ', $where);
$sql .= ' ORDER BY occurred_at DESC, event_id DESC LIMIT ?';
$binds[] = [$limit, PDO::PARAM_INT];
$st = $this->pdo->prepare($sql);
$pos = 1;
foreach ($binds as [$val, $type]) $st->bindValue($pos++, $val, $type);
$st->execute();
return array_map(self::mapRow(...), $st->fetchAll());
}
public function find(int $id): ?array
{
$st = $this->pdo->prepare('SELECT ' . self::SELECT_COLUMNS . ' FROM events WHERE event_id = ?');

View file

@ -25,6 +25,23 @@ final class DashboardTest extends TestCase
$this->assertSame(200, $res->status());
$this->assertCount(1, json_decode($res->body(), true)['events']);
}
public function testListPaginatesWithCursor(): void {
$repo = new InMemoryEventRepository();
$repo->insert(['severity' => 'high', 'event_type' => 'fall', 'occurred_at' => 100.0]); // id 1
$repo->insert(['severity' => 'high', 'event_type' => 'fall', 'occurred_at' => 200.0]); // id 2
$repo->insert(['severity' => 'high', 'event_type' => 'fall', 'occurred_at' => 300.0]); // id 3
$c = new EventListController($repo, $this->authed());
// Page 1: the newest two (300, 200); a full page hands back a cursor at the oldest row.
$p1 = json_decode($c->json(new Request('GET', '/api/events', [], '', ['limit' => '2'], []))->body(), true);
$this->assertCount(2, $p1['events']);
$this->assertSame([3, 2], [$p1['events'][0]['event_id'], $p1['events'][1]['event_id']]);
$this->assertSame('200:2', $p1['next_cursor']);
// Page 2: following the cursor yields only the older remaining event; short page => no cursor.
$p2 = json_decode($c->json(new Request('GET', '/api/events', [], '', ['limit' => '2', 'cursor' => $p1['next_cursor']], []))->body(), true);
$this->assertCount(1, $p2['events']);
$this->assertSame(1, $p2['events'][0]['event_id']);
$this->assertNull($p2['next_cursor']);
}
public function testAcknowledgeRequiresPermissionAndSetsUser(): void {
$repo = new InMemoryEventRepository();
$id = $repo->insert(['severity' => 'critical', 'event_type' => 'no_motion']);

View file

@ -29,6 +29,17 @@ final class InMemoryEventRepository implements EventRepository {
if ($sev !== null) $r = array_filter($r, fn($x) => $x['severity'] === $sev);
return array_slice(array_values($r), 0, $limit);
}
public function page(int $limit, ?string $sev, ?float $beforeTs, ?int $beforeId): array {
$r = array_values($this->rows);
if ($sev !== null) $r = array_filter($r, fn($x) => $x['severity'] === $sev);
// (occurred_at DESC, event_id DESC); events inserted without occurred_at sort as 0.
usort($r, fn($a, $b) => [$b['occurred_at'] ?? 0, $b['event_id']] <=> [$a['occurred_at'] ?? 0, $a['event_id']]);
if ($beforeTs !== null && $beforeId !== null) {
$r = array_filter($r, fn($x) => ($x['occurred_at'] ?? 0) < $beforeTs
|| (($x['occurred_at'] ?? 0) == $beforeTs && $x['event_id'] < $beforeId));
}
return array_slice(array_values($r), 0, $limit);
}
public function find(int $id): ?array { return $this->rows[$id] ?? null; }
public function acknowledge(int $id, string $u): bool { if (!isset($this->rows[$id])) return false; $this->rows[$id]['acknowledged_by'] = $u; return true; }
}

View file

@ -159,8 +159,13 @@ misconfig/degradation fail LOUD.
`camera_id` with a space/`+`/Korean saves zones but never loads them. **Fix:** decode
captured params.
DONE — dispatch rawurldecodes captured params; BasePathTest locks space + Korean cases.
- [ ] **P4-5 [M]** `cloud/src/Dashboard/EventListController.php:17` — event list hard-capped at
- [x] **P4-5 [M]** `cloud/src/Dashboard/EventListController.php:17` — event list hard-capped at
newest 100, no pagination/date filter; older events unreachable. **Fix:** cursor pagination.
DONE — added keyset `EventRepository::page(limit, severity, beforeTs, beforeId)` (Pdo + InMemory),
a `(occurred_at DESC, event_id DESC)` cursor stable under concurrent inserts. Controller takes
`limit` (default 50, cap 200) + `cursor` ("occurred_at:event_id"), returns `next_cursor` on full
pages. `recent()` kept for existing callers. The live-tail dashboard consumes page 1; a
history-browse UI can layer on the same cursor later. Test `testListPaginatesWithCursor`.
- [x] **P4-6 [M]** `cloud/src/Repository/Pdo/PdoZoneRepository.php:86` — editing a zone with a
changed device_id/camera_id throws an uncaught RuntimeException → 500. **Fix:** 400 with a
clear error.