Ka-Note/docs/feature-sync.md

3.8 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 await on db calls
  • db.transaction() callback must be synchronousasync (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_<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).

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).