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