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:
parent
d0ba621b42
commit
9a0ff1fc5e
6 changed files with 101 additions and 3 deletions
|
|
@ -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];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 = ?');
|
||||
|
|
|
|||
|
|
@ -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']);
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Reference in a new issue