/** * Cloud-prefs schema migrations and conflict-merge, isolated from * cloud-prefs-sync.ts so they stay testable without importing the full sync * runtime (which transitively pulls in `import.meta.env.DEV` via * `@/services/clerk` → proxy.ts and fails outside a Vite build). * * Each migration is a pure function from blob → blob. The map is keyed by * the TARGET schema version (so MIGRATIONS[N] runs when going from N-1 → N). */ import { findFullyDisabledCategories, type FeedsByCategory } from '@/services/source-cap'; /** * Apply all migrations from `fromVersion + 1` up through `toVersion` * inclusive. Pure function — no I/O. Caller controls migrations map and * feeds context. Extracted for direct testing without pulling in the * cloud-prefs-sync runtime (which has a Vite-env transitive import). */ export function applyMigrationChain( data: Record, fromVersion: number, toVersion: number, migrations: Record) => Record>, ): Record { let result = data; for (let v = fromVersion + 1; v <= toVersion; v++) { result = migrations[v]?.(result) ?? result; } return result; } /** * Conflict-resolution merge for cloud-prefs sync. * * When a POST to /api/user-prefs hits a 409 (the cloud row advanced under * us), the local edits the user JUST made must not be discarded. The old * behaviour fetched the fresh cloud row and overwrote localStorage with it * wholesale — silently destroying, e.g., a watchlist the user typed seconds * earlier. This merge resolves the conflict without data loss: * * - Start from the fresh cloud blob (so a concurrent change from another * device survives). * - Overlay the keys the user changed locally since the last clean upload * (`dirtyKeys`): a dirty key present in `localBlob` → the local value * wins; a dirty key ABSENT from `localBlob` → the user removed it * locally → drop it from the merge so the removal sticks. * * Pure function — no I/O. `cloudData` is the migrated cloud blob, `localBlob` * is the current localStorage snapshot, `dirtyKeys` is the set of sync keys * mutated locally since the last clean upload. Extracted here (not in * cloud-prefs-sync.ts) so it stays unit-testable without the sync runtime. */ export function mergeCloudWithLocalDirty( cloudData: Record, localBlob: Record, dirtyKeys: Iterable, ): Record { const merged: Record = {}; for (const [key, val] of Object.entries(cloudData)) { if (typeof val === 'string') merged[key] = val; } for (const key of dirtyKeys) { if (Object.prototype.hasOwnProperty.call(localBlob, key)) { merged[key] = localBlob[key]!; } else { delete merged[key]; } } return merged; } /** * After a successful upload, decide which dirty keys are now durably synced * and can be cleared — NOT the whole set. * * A user can mutate another pref *while the POST is in flight*: the setItem * patch marks it dirty, but it was never in `postedBlob`. Blanket-clearing * the dirty set would drop that tracking, so a subsequent 409 would see an * empty dirty set and mergeCloudWithLocalDirty would let the cloud blob * clobber the just-made edit — reintroducing the exact data-loss bug the * dirty set exists to prevent. * * A key is "settled" iff the value the server accepted (`postedBlob`) still * equals the current local value (`localBlob`). Absence counts as null on * both sides, so a synced *removal* settles too. A key changed mid-flight, * or dirtied mid-flight and absent from `postedBlob`, fails the equality * check and is NOT returned — it stays dirty for the next upload. * * Pure function — no I/O. Returns the subset of `dirtyKeys` safe to clear. */ export function settledDirtyKeys( postedBlob: Record, localBlob: Record, dirtyKeys: Iterable, ): string[] { const settled: string[] = []; for (const key of dirtyKeys) { const posted = Object.prototype.hasOwnProperty.call(postedBlob, key) ? postedBlob[key]! : null; const local = Object.prototype.hasOwnProperty.call(localBlob, key) ? localBlob[key]! : null; if (posted === local) settled.push(key); } return settled; } export function parsePersistedDirtyKeys( raw: string | null, allowedKeys: Iterable, expectedUserId: string, ): string[] { if (!raw) return []; let parsed: unknown; try { parsed = JSON.parse(raw); } catch { return []; } if ( !parsed || typeof parsed !== 'object' || (parsed as { userId?: unknown }).userId !== expectedUserId || !Array.isArray((parsed as { keys?: unknown }).keys) ) { return []; } const allowed = new Set(allowedKeys); const seen = new Set(); const keys: string[] = []; for (const key of (parsed as { keys: unknown[] }).keys) { if (typeof key !== 'string' || !allowed.has(key) || seen.has(key)) continue; seen.add(key); keys.push(key); } return keys; } /** * Schema-2 migrations map. Used both inline by cloud-prefs-sync.ts (against * the variant-aware FEEDS) and by tests (against fixture FEEDS). */ export function buildMigrations( feedsByCategory: FeedsByCategory, ): Record) => Record> { return { 2: (data) => migrateDisabledFeedsV2(data, feedsByCategory), }; } /** * Schema-2 migration body, kept separate for direct unit testing. * * Schema 2 (2026-05-01): one-shot recovery for the v1 free-tier source-cap * bug. The pre-PR-3521 alphabetical-slice cap auto-disabled every source * past position 80 alphabetically, leaving entire late-alphabet categories * (Layoffs, Semiconductors, IPO, Funding, Product Hunt, …) with 100% of * their feeds in `disabledFeeds`. PR #3521 added a per-origin localStorage * migration to recover this, but cloud-prefs sync re-poisoned origins * every load by overwriting localStorage with the still-bad cloud blob — * the recovery had to live at the cloud-data layer to be permanent. * * This migration runs ONCE per cloud row (gated by schemaVersion < 2), * detects categories where 100% of sources are in `disabledFeeds`, and * re-enables them. After the migration completes, schemaVersion bumps to * 2 and subsequent sync pulls skip recovery — so a user who explicitly * disables every source in a category POST-migration keeps that * preference forever. The 100%-disabled-category heuristic is targeted * enough that explicit single-source disabling is preserved. * * The recovery uses the variant-aware FEEDS passed in by the caller; the * cloud blob is variant-scoped (per /api/user-prefs?variant=...) so the * caller-supplied FEEDS already matches the row's variant. */ export function migrateDisabledFeedsV2( data: Record, feedsByCategory: FeedsByCategory, ): Record { const raw = data['worldmonitor-disabled-feeds']; if (typeof raw !== 'string') return data; let parsed: unknown; try { parsed = JSON.parse(raw); } catch { return data; } if (!Array.isArray(parsed) || parsed.length === 0) return data; const disabledStrings = parsed.filter((n): n is string => typeof n === 'string'); const recoverable = findFullyDisabledCategories(feedsByCategory, new Set(disabledStrings)); if (recoverable.length === 0) return data; const recoveredSet = new Set(recoverable); const cleaned = parsed.filter( (n) => typeof n !== 'string' || !recoveredSet.has(n), ); console.log( `[cloud-prefs] schema-2 migration: re-enabled ${recoverable.length} source(s) from fully-disabled categories`, ); return { ...data, 'worldmonitor-disabled-feeds': JSON.stringify(cleaned) }; }