diff --git a/config.php b/config.php index 03cb64e..36dd2ef 100644 --- a/config.php +++ b/config.php @@ -1,7 +1,7 @@ hyprcrdRelayEncrypt($body, $relayKey), 'hero_filename' => $heroFilename, ]); + // Aggressive timeouts: read-later is opt-in + best-effort. If the + // relay is slow or unreachable we must NOT block the user's save — + // this function still runs inline from the PATCH handler (we'll + // also flush the response before invoking it), so the shorter + // timeout caps the worst-case backlog even if flushing fails. $ch = curl_init(rtrim($relayUrl, '/') . '/api/read'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_POSTFIELDS => $payload, CURLOPT_HTTPHEADER => ['Content-Type: application/json', 'X-Relay-User: ' . $relayUser, 'X-Relay-Key: ' . $relayKey], - CURLOPT_TIMEOUT => 10, + CURLOPT_CONNECTTIMEOUT => 2, + CURLOPT_TIMEOUT => 5, ]); hyprcrdApplySafeCurlOpts($ch); $resp = curl_exec($ch); @@ -348,7 +354,8 @@ if (str_starts_with($route, '/api/')) { CURLOPT_POST => true, CURLOPT_POSTFIELDS => $attPayload, CURLOPT_HTTPHEADER => ['Content-Type: application/json', 'X-Relay-User: ' . $relayUser, 'X-Relay-Key: ' . $relayKey], - CURLOPT_TIMEOUT => 30, + CURLOPT_CONNECTTIMEOUT => 2, + CURLOPT_TIMEOUT => 10, ]); hyprcrdApplySafeCurlOpts($ach); curl_exec($ach); @@ -367,7 +374,8 @@ if (str_starts_with($route, '/api/')) { CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'DELETE', CURLOPT_HTTPHEADER => ['X-Relay-User: ' . $relayUser, 'X-Relay-Key: ' . $relayKey], - CURLOPT_TIMEOUT => 10, + CURLOPT_CONNECTTIMEOUT => 2, + CURLOPT_TIMEOUT => 5, ]); hyprcrdApplySafeCurlOpts($ch); curl_exec($ch); @@ -397,13 +405,21 @@ if (str_starts_with($route, '/api/')) { 'read_push' => $readPush, ]); $saved = $db->query('SELECT c.*, b.name as bucket_name FROM cards c LEFT JOIN buckets b ON c.bucket_id = b.id WHERE c.id = ' . $db->lastInsertId())->fetch(); - if ($readPush && $saved) { - pushCardToRelayRead($saved, $db); - // relay_uid may have been generated inside the push helper; - // re-read so the client sees it on the response. - $saved = $db->query('SELECT c.*, b.name as bucket_name FROM cards c LEFT JOIN buckets b ON c.bucket_id = b.id WHERE c.id = ' . (int)$saved['id'])->fetch(); + // When the card opts into read-later, generate the relay_uid up + // front so the response the client receives already carries it. + // Actually pushing to the relay happens AFTER the response is + // flushed — the relay hop is best-effort and shouldn't stretch + // the user-facing save latency. + if ($readPush && $saved && empty($saved['relay_uid'])) { + $uid = bin2hex(random_bytes(16)); + $db->prepare('UPDATE cards SET relay_uid = ? WHERE id = ?')->execute([$uid, $saved['id']]); + $saved['relay_uid'] = $uid; } echo json_encode($saved); + if ($readPush && $saved) { + hyprcrdFinishRequest(); + pushCardToRelayRead($saved, $db); + } exit; } @@ -436,11 +452,15 @@ if (str_starts_with($route, '/api/')) { if ($cardRow && !empty($cardRow['caldav_uid'])) { $db->prepare('INSERT OR REPLACE INTO caldav_tombstones (caldav_uid, card_id) VALUES (?, ?)')->execute([$cardRow['caldav_uid'], $m[1]]); } - if ($cardRow && (int)($cardRow['read_push'] ?? 0) === 1 && !empty($cardRow['relay_uid'])) { - deleteCardFromRelayRead((string)$cardRow['relay_uid'], $db); - } + $wipeFromRelay = ($cardRow && (int)($cardRow['read_push'] ?? 0) === 1 && !empty($cardRow['relay_uid'])) + ? (string)$cardRow['relay_uid'] : null; $db->prepare('DELETE FROM cards WHERE id = :id')->execute(['id' => $m[1]]); - echo json_encode(['ok' => true]); exit; + echo json_encode(['ok' => true]); + if ($wipeFromRelay) { + hyprcrdFinishRequest(); + deleteCardFromRelayRead($wipeFromRelay, $db); + } + exit; } if (preg_match('#^/api/cards/(\d+)/history$#', $route, $m) && $method === 'GET') { @@ -491,21 +511,34 @@ if (str_starts_with($route, '/api/')) { if ($fields) { $fields[] = 'updated_at = datetime("now")'; $db->prepare('UPDATE cards SET ' . implode(', ', $fields) . ' WHERE id = :id')->execute($params); } $card = $db->prepare('SELECT c.*, b.name as bucket_name FROM cards c LEFT JOIN buckets b ON c.bucket_id = b.id WHERE c.id = :id'); $card->execute(['id' => $m[1]]); $savedCard = $card->fetch(); - // Relay sync: - // - if card currently wants to be in the read list, push (idempotent - // upsert keeps the remote copy in sync on every edit). - // - if the user just toggled it off, clean up remotely. + // Decide the relay-sync action BEFORE echoing the response, then + // flush + run the actual cURL hop afterwards. The user doesn't + // wait for the relay round-trip; a dead/slow relay no longer + // stretches the card save into "eine Ewigkeit". + $pushAction = null; // 'push' | 'delete' | null if ($savedCard) { $nowRead = (int)($savedCard['read_push'] ?? 0); if ($nowRead === 1) { - pushCardToRelayRead($savedCard, $db); - // Re-read to surface any newly-generated relay_uid on the wire. - $card->execute(['id' => $m[1]]); $savedCard = $card->fetch(); + // Mint relay_uid up front so the response carries it. + if (empty($savedCard['relay_uid'])) { + $uid = bin2hex(random_bytes(16)); + $db->prepare('UPDATE cards SET relay_uid = ? WHERE id = ?')->execute([$uid, $savedCard['id']]); + $savedCard['relay_uid'] = $uid; + } + $pushAction = 'push'; } elseif ($prevRead === 1 && $prevRelayUid) { - deleteCardFromRelayRead($prevRelayUid, $db); + $pushAction = 'delete'; } } - echo json_encode($savedCard); exit; + echo json_encode($savedCard); + if ($pushAction === 'push') { + hyprcrdFinishRequest(); + pushCardToRelayRead($savedCard, $db); + } elseif ($pushAction === 'delete') { + hyprcrdFinishRequest(); + deleteCardFromRelayRead((string)$prevRelayUid, $db); + } + exit; } // Plugins diff --git a/public/js/app.js b/public/js/app.js index 40304cc..6560dd8 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -108,11 +108,14 @@ async function loadCards() { updateSortUI(); renderBoard(); - // Phase 2: Load full cards in batches → replace spinners with content - const batchSize = 25; - for (let i = 0; i < meta.length; i += batchSize) { - const batch = meta.slice(i, i + batchSize); - const ids = batch.map(c => c.id).join(','); + // Phase 2: Load full card bodies. One fetch up to the ID cap — with + // the /api/cards(?ids=) endpoint now indexed on cards.position + + // created_at a single big batch is faster than many small ones, and + // shorter perceived spinner time matters more than memory. Fall back + // to smaller batches only if the URL would blow past ~2000 chars. + const IDS_MAX_PER_FETCH = 500; + for (let i = 0; i < meta.length; i += IDS_MAX_PER_FETCH) { + const ids = meta.slice(i, i + IDS_MAX_PER_FETCH).map(c => c.id).join(','); const full = await api('api/cards?ids=' + ids); if (!full) continue; for (const card of full) { diff --git a/public/js/modules/board.js b/public/js/modules/board.js index a73846c..cc380e6 100644 --- a/public/js/modules/board.js +++ b/public/js/modules/board.js @@ -493,8 +493,30 @@ export function renderCardHtml(c, inGroup = false, recent = []) { } // ── Render ── +// +// Public renderBoard() is coalesced via requestAnimationFrame: multiple +// callers in the same tick (cache paint → meta paint → body paint → +// plugin-hook re-render → ...) collapse into a single actual render on +// the next frame. Dropped the "three innerHTML swaps per reload" cost +// that showed up as the "Gekringel" during boot. Callers that need the +// DOM in a specific state before doing something else can call +// renderBoardSync() explicitly — but in practice every existing caller +// just wants the board to reflect the latest state. +let rafScheduled = 0; export function renderBoard() { + if (rafScheduled) return; + rafScheduled = requestAnimationFrame(() => { + rafScheduled = 0; + renderBoardSync(); + }); +} +export function renderBoardSync() { + if (rafScheduled) { cancelAnimationFrame(rafScheduled); rafScheduled = 0; } + renderBoardImpl(); +} + +function renderBoardImpl() { // Diff state.cards against the last seen ids so we can flag arrivals // as "unseen". The very first renderBoard runs with state.cards = [] // (initial empty state, before the API has returned), so we wait diff --git a/src/Database.php b/src/Database.php index 8d9f856..963d0ea 100644 --- a/src/Database.php +++ b/src/Database.php @@ -19,6 +19,14 @@ class Database self::$pdo->exec('PRAGMA journal_mode = WAL'); self::$pdo->exec('PRAGMA foreign_keys = ON'); + // Safe speedups under WAL. synchronous=NORMAL trades a tiny + // durability window on power-loss (WAL checkpoint hasn't + // flushed) for noticeably quicker write-heavy flows — fine for + // a single-user local-first app. temp_store=MEMORY keeps + // transient sort / group scratch in RAM instead of touching + // disk for every ORDER BY. + self::$pdo->exec('PRAGMA synchronous = NORMAL'); + self::$pdo->exec('PRAGMA temp_store = MEMORY'); self::migrate(); } @@ -188,5 +196,17 @@ class Database ) '); self::$pdo->exec('CREATE INDEX IF NOT EXISTS idx_login_attempts_ip_ep ON login_attempts(ip, endpoint, attempted_at)'); + + // Hot-path indices for the board. /api/cards is called on every + // reload and runs `SELECT … FROM cards LEFT JOIN buckets ORDER BY + // position, created_at`. Without an index on position this is a + // full-table sort on every call — fine at 10 cards, noticeable at + // 300+. + self::$pdo->exec('CREATE INDEX IF NOT EXISTS idx_cards_position ON cards(position, created_at)'); + self::$pdo->exec('CREATE INDEX IF NOT EXISTS idx_cards_bucket_id ON cards(bucket_id)'); + self::$pdo->exec('CREATE INDEX IF NOT EXISTS idx_cards_parent_id ON cards(parent_id)'); + self::$pdo->exec('CREATE INDEX IF NOT EXISTS idx_cards_updated_at ON cards(updated_at DESC)'); + self::$pdo->exec('CREATE INDEX IF NOT EXISTS idx_cards_caldav_uid ON cards(caldav_uid) WHERE caldav_uid IS NOT NULL'); + self::$pdo->exec('CREATE INDEX IF NOT EXISTS idx_cards_relay_uid ON cards(relay_uid) WHERE relay_uid IS NOT NULL'); } } diff --git a/src/bootstrap.php b/src/bootstrap.php index 813c201..ea6faa0 100644 --- a/src/bootstrap.php +++ b/src/bootstrap.php @@ -89,6 +89,24 @@ function hyprcrdRelayDecrypt(string $envelope, string $secret): string { return $envelope; } +/** + * Finalise the HTTP response (headers + body already echoed) and let + * PHP continue working in the background without blocking the client. + * Under php-fpm this uses fastcgi_finish_request. Under mod_php / the + * built-in dev server we fall back to ignore_user_abort + flush — the + * client may still see the connection hold open, but at least it + * doesn't error on disconnect and the response is already buffered. + */ +function hyprcrdFinishRequest(): void { + if (function_exists('fastcgi_finish_request')) { + fastcgi_finish_request(); + return; + } + @ignore_user_abort(true); + if (ob_get_level() > 0) { @ob_end_flush(); } + @flush(); +} + function hyprcrdUrlHostIsPublic(string $url): bool { $host = parse_url($url, PHP_URL_HOST); if (!$host) return false;