# Sync Feature — Implementation Notes ## DB: better-sqlite3 (migrated from @libsql/client) **Why migrated:** `@libsql/client` caused SIGSEGV (exit code 139) on Azure App Service due to Rust native module incompatibility with seccomp profile. **Key constraints of better-sqlite3:** - All operations are **synchronous** — no `await` on db calls - `db.transaction()` callback **must be synchronous** — `async (tx) => {}` throws `TypeError: Transaction function cannot return a promise` - Drizzle ORM for better-sqlite3: `drizzle-orm/better-sqlite3` + `drizzle-orm/better-sqlite3/migrator` - Result object uses `.changes` (not `.rowsAffected`) → use `(result as any).changes ?? (result as any).rowsAffected ?? 0` **Config:** - `drizzle.config.ts`: `url` without `file:` prefix - Docker base: `node:22-slim` (prebuilt binaries available) - No build tools needed in Dockerfile (prebuilts used on Linux) **Migration:** `migrate(db, { migrationsFolder })` called synchronously at startup in `connection.ts`. ## sync-service.ts `pushChanges` processes entities in FK order: contexts → topics → historyEntries → ratings → imageBlobs → pages → notebooks → pageNotebooks. `upsertEntity` logic: insert if not exists, update if `clientVersion > serverVersion`, else record conflict. **No transaction wrapper** — the old `db.transaction(async ...)` was dead code and was removed. Each entity is upserted individually. ## API Keys (M2M Auth) Added `api_keys` table in schema.ts. Format: `ka_`. Only SHA-256 hash stored in DB. Auth middleware (`auth.ts`) detects `ka_...` Bearer tokens **before** JWT path, looks up by hash, fires `lastUsedAt` update async. Routes: `GET /api/api-keys`, `POST /api/api-keys`, `DELETE /api/api-keys/:id` (soft-delete). Raw key returned **once** on creation — not recoverable after that. ## Browser API Key (client-side) `browserApiKey` store in `authStore.ts` backed by `localStorage['ka-note-browser-key']`. `getAccessToken()` returns browser key first, then falls back to MSAL. `isAuthenticated` derived from `account OR browserApiKey`. UI in Settings → "Browser-Authentifizierung" section. Use case: avoids MSAL silent refresh failures (Azure AD returning HTTP 400 on refresh token). **401 auto-invalidation:** `authFetch` in `apiClient.ts` detects 401 responses when a browser key was used and calls `setBrowserApiKey(null)` — clears `localStorage` and the store. `isAuthenticated` becomes `false`, restoring the MSAL login UI. Prevents the stuck state where a revoked/expired key blocks both sync and manual login. ## Deploy `deploy.ps1` uses normal `docker build` with layer caching. Only changed layers are pushed to ACR. `COPY server/ server/` triggers a cache miss whenever server source changes → tsc reruns automatically. `--no-cache` is not needed and was removed (it caused all layers to be rebuilt and pushed on every deploy, >40 MB). ## Delta Push (client-side filtering) `pushAll(since)` only sends entities with `updatedAt > since.toISOString()`. A read-only client sends 0 entities and skips the HTTP request entirely. `fullSync()` passes `since=null` → pushes everything (used after DB reset or first sync). **Root cause for all-conflicts:** `upsertEntity` accepts only `clientVersion > serverVersion`. After a pull, client has `version == serverVersion` → equal-version entities are never pushed (filtered out by delta push). Without delta push, a read-only client would produce 700+ conflicts on every sync cycle. ## Client identification (`X-Client-Id` header) Each browser instance generates a stable random ID in `localStorage['ka-clientId']`. The header value is `Browser·OS·id` (e.g. `Edge·Win·a3f2`, `Safari·iOS·x9k2`). Server logs `client:` field in `[sync/push]` and `[sync/pull]` entries. ## ECONNRESET on POST /api/sync/push `Error: aborted / ECONNRESET` logged as 500 is a **client-side disconnect**, not a server error. Hono logs 500 because response couldn't be sent. Causes: tab backgrounded on iOS, Azure idle timeout, browser request timeout. Not actionable on server side unless response time is consistently >10s (indicates oversized payload, e.g. first full sync after DB reset).