4.1 KiB
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
awaiton db calls db.transaction()callback must be synchronous —async (tx) => {}throwsTypeError: 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:urlwithoutfile: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_<base64url-32bytes>. 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).