diff --git a/apps/mana/apps/web/src/lib/data/sync-telemetry.ts b/apps/mana/apps/web/src/lib/data/sync-telemetry.ts index 9a796cc84..5834cd63c 100644 --- a/apps/mana/apps/web/src/lib/data/sync-telemetry.ts +++ b/apps/mana/apps/web/src/lib/data/sync-telemetry.ts @@ -35,7 +35,15 @@ export interface SyncTelemetryDetail { /** number of changes touched (pushed / pulled / applied / dropped). */ count?: number; /** Error category for `*:error`. Free-form short string, never raw stacks. */ - errorCategory?: 'network' | 'auth' | 'http-5xx' | 'http-4xx' | 'parse' | 'unknown'; + errorCategory?: + | 'network' + | 'auth' + | 'http-5xx' + | 'http-4xx' + | 'parse' + | 'no-token' + | 'unknown-appid' + | 'unknown'; /** HTTP status code if applicable. */ status?: number; /** Table name for apply-level events. */ diff --git a/apps/mana/apps/web/src/lib/data/sync.ts b/apps/mana/apps/web/src/lib/data/sync.ts index b6509c990..b0fcea3bb 100644 --- a/apps/mana/apps/web/src/lib/data/sync.ts +++ b/apps/mana/apps/web/src/lib/data/sync.ts @@ -631,10 +631,36 @@ export function createUnifiedSync(serverUrl: string, getToken: () => Promise { const channel = channels.get(appId); - if (!channel) return; + if (!channel) { + // Pending changes exist for an appId we never registered. Almost + // always means the module's appId in module-registry.ts drifted + // from the appId stamped on the pending row (e.g. someone renamed + // `bodylog` → `body` and old rows are still tagged `bodylog`). + // Surface it loudly: silent drops here is exactly the SYNC_DEBUG + // failure mode where pending counts only ever go up. + console.warn( + `[mana-sync] push: no channel registered for appId="${appId}". ` + + `Known appIds: ${[...channels.keys()].join(', ')}. ` + + `Pending changes for "${appId}" will accumulate forever until you fix the registry.` + ); + emitSyncTelemetry({ kind: 'push:error', appId, errorCategory: 'unknown-appid' }); + return; + } const token = await getToken(); - if (!token) return; + if (!token) { + // getValidToken returned null. Most likely the local exp check + // failed and the refresh-on-online retry didn't yield a new token. + // Without this surfacing, the user sees pending counts climb with + // no error anywhere — same SYNC_DEBUG symptom as above. + console.warn( + `[mana-sync] push[${appId}]: getToken() returned null — pending changes will not flush until auth recovers.` + ); + channel.lastError = 'no-token'; + setStatus('error'); + emitSyncTelemetry({ kind: 'push:error', appId, errorCategory: 'no-token' }); + return; + } // Get pending changes for this appId const pending: PendingChange[] = await db @@ -1021,6 +1047,31 @@ export function createUnifiedSync(serverUrl: string, getToken: () => Promise channels.get(appId), pushNow: push, pullNow: pull, + /** + * Snapshot of every registered channel's state — meant for the + * SYNC_DEBUG.md runbook (Schritt C). Returns a plain serializable + * object so it survives `JSON.stringify` in DevTools without + * Promise/Map weirdness. + */ + getDebugInfo: () => ({ + status, + online, + clientId, + serverUrl, + channels: Object.fromEntries( + [...channels.entries()].map(([id, c]) => [ + id, + { + appId: c.appId, + tables: c.tables, + lastError: c.lastError, + hasPushTimer: c.pushTimer !== null, + hasPullTimer: c.pullTimer !== null, + }, + ]) + ), + knownAppIds: [...channels.keys()], + }), }; } diff --git a/apps/mana/apps/web/src/routes/(app)/+layout.svelte b/apps/mana/apps/web/src/routes/(app)/+layout.svelte index cf5762ad8..bfc1518b5 100644 --- a/apps/mana/apps/web/src/routes/(app)/+layout.svelte +++ b/apps/mana/apps/web/src/routes/(app)/+layout.svelte @@ -372,6 +372,13 @@ trackReturnVisit(); const getToken = () => authStore.getValidToken(); unifiedSync = createUnifiedSync(SYNC_SERVER_URL, getToken); + // Expose on window for SYNC_DEBUG.md (Schritt C). Not a security + // concern: every method on the returned object is also reachable + // via Dexie + a fresh fetch from the same DevTools console, and + // the production user can't escalate anything by poking at it. + if (typeof window !== 'undefined') { + (window as unknown as { __unifiedSync: typeof unifiedSync }).__unifiedSync = unifiedSync; + } const refreshPendingCount = async () => { try { const count = await db.table('_pendingChanges').count();