File size: 7,836 Bytes
dbb1bf9 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 | /**
* 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<string, unknown>,
fromVersion: number,
toVersion: number,
migrations: Record<number, (data: Record<string, unknown>) => Record<string, unknown>>,
): Record<string, unknown> {
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<string, unknown>,
localBlob: Record<string, string>,
dirtyKeys: Iterable<string>,
): Record<string, string> {
const merged: Record<string, string> = {};
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<string, string>,
localBlob: Record<string, string>,
dirtyKeys: Iterable<string>,
): 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<string>,
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<string>();
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<number, (data: Record<string, unknown>) => Record<string, unknown>> {
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<string, unknown>,
feedsByCategory: FeedsByCategory,
): Record<string, unknown> {
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) };
}
|