Press n or j to go to the next uncovered block, b, p or k for the previous block.
| 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 | 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 10x 10x 10x 10x 10x 10x 10x 10x 1x 1x 33x 33x 33x 31x 31x 31x 33x 5x 26x 33x 33x 33x 33x 33x 33x 33x 10x 10x 1x 6x 6x 6x 6x 1x 5x 5x 5x 5x 5x 5x 5x 1x 3x 3x 3x 3x 3x 3x 3x 2x 3x 2x 2x 2x 2x 2x 2x 1x 1x 1x 1x 1x 1x 7x 7x 7x 7x 7x 7x 7x 7x 7x 7x 3x 3x 7x 7x 8x 8x 2x 2x 2x 6x 4x 4x 7x 2x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 2x 2x 2x 5x 5x 5x 5x 5x 1x 1x 1x 1x 1x | import type { CandidateProfile, SavedJob } from "../types/job";
import { jobStorage, LibraryFullError } from "./jobStorage";
import { getProfile, setProfile } from "./profileStorage";
/**
* One-time migration of pre-002 device-local data into the account-backed
* store (research.md R7, FR-010/FR-011). Legacy keys are read-only inputs:
* they are deleted only after a fully completed migration, never on decline
* or failure — losslessness is the invariant.
*/
export const MIGRATION_MARKER_KEY = "migration:v2";
const LEGACY_PROFILE_KEY = "profile";
const LEGACY_JOB_PREFIX = "job:";
const LEGACY_INDEX_KEY = "job:index";
export interface MigrationMarker {
status: "completed" | "declined";
at: string;
}
export interface LegacyData {
profile: CandidateProfile | null;
jobs: SavedJob[];
}
export type ProfileConflictChoice = "local" | "server";
export interface MigrationOptions {
/**
* Called when the account already has a server-side profile that differs
* from the legacy local one — the user decides which to keep (FR-011).
*/
resolveProfileConflict: (
local: CandidateProfile,
server: CandidateProfile
) => Promise<ProfileConflictChoice>;
}
export interface MigrationResult {
status: "completed" | "cap-blocked" | "failed";
uploadedJobs: number;
/** Jobs already present server-side (server copy wins, nothing overwritten). */
skippedDuplicates: number;
profileOutcome: "uploaded" | "kept-server" | "none";
errorMessage?: string;
}
function isLegacyJob(value: unknown): value is SavedJob {
return (
typeof value === "object" &&
value !== null &&
typeof (value as SavedJob).canonicalUrl === "string" &&
typeof (value as SavedJob).analysis === "object"
);
}
/** Legacy data present and the one-time offer not yet answered → data; else null. */
export async function detectLegacyData(): Promise<LegacyData | null> {
const all = await chrome.storage.local.get(null);
if (all[MIGRATION_MARKER_KEY]) return null;
const rawProfile = all[LEGACY_PROFILE_KEY] as CandidateProfile | undefined;
const profile =
rawProfile && typeof rawProfile.text === "string" && rawProfile.text.length > 0
? rawProfile
: null;
const jobs = Object.entries(all)
.filter(([key]) => key.startsWith(LEGACY_JOB_PREFIX) && key !== LEGACY_INDEX_KEY)
.map(([, value]) => value)
.filter(isLegacyJob);
if (!profile && jobs.length === 0) return null;
return { profile, jobs };
}
async function writeMarker(status: MigrationMarker["status"]): Promise<void> {
const marker: MigrationMarker = { status, at: new Date().toISOString() };
await chrome.storage.local.set({ [MIGRATION_MARKER_KEY]: marker });
}
async function deleteLegacyKeys(): Promise<void> {
const all = await chrome.storage.local.get(null);
const keys = Object.keys(all).filter(
(key) => key === LEGACY_PROFILE_KEY || key.startsWith(LEGACY_JOB_PREFIX)
);
if (keys.length > 0) await chrome.storage.local.remove(keys);
}
async function migrateProfile(
local: CandidateProfile,
options: MigrationOptions
): Promise<"uploaded" | "kept-server"> {
const server = await getProfile();
const differs =
server !== null &&
(server.text !== local.text ||
server.dealbreakers.join("\n") !== local.dealbreakers.join("\n"));
if (differs) {
const choice = await options.resolveProfileConflict(local, server);
if (choice === "server") return "kept-server";
}
await setProfile({ text: local.text, dealbreakers: local.dealbreakers });
return "uploaded";
}
/**
* Accept path. Idempotent per record: existing server entries win and are
* counted as duplicates, so a retry after partial failure converges. The
* marker is written — and legacy keys deleted — only on full success.
*/
export async function runMigration(
data: LegacyData,
options: MigrationOptions
): Promise<MigrationResult> {
let uploadedJobs = 0;
let skippedDuplicates = 0;
let profileOutcome: MigrationResult["profileOutcome"] = "none";
try {
if (data.profile) {
profileOutcome = await migrateProfile(data.profile, options);
}
for (const job of data.jobs) {
const existing = await jobStorage.get(job.canonicalUrl);
if (existing) {
skippedDuplicates++;
continue;
}
await jobStorage.save(job);
uploadedJobs++;
}
} catch (err) {
if (err instanceof LibraryFullError) {
return {
status: "cap-blocked",
uploadedJobs,
skippedDuplicates,
profileOutcome,
errorMessage: err.message,
};
}
return {
status: "failed",
uploadedJobs,
skippedDuplicates,
profileOutcome,
errorMessage:
err instanceof Error ? err.message : "The migration could not finish.",
};
}
await writeMarker("completed");
await deleteLegacyKeys();
return { status: "completed", uploadedJobs, skippedDuplicates, profileOutcome };
}
/** Decline path: remember the answer, leave every legacy byte untouched. */
export async function declineMigration(): Promise<void> {
await writeMarker("declined");
}
|