1/**
2 * Merges V1 profile localStorage items into the V2 profile. Runs in the preload
3 * before the app reads storage, and never overwrites what V2 already holds: a
4 * key V2 has written keeps V2's value, except that stashed prompts and unsent
5 * drafts are merged entry by entry so neither side's are lost.
6 */
7
8interface MergeStorage {
9 getItem(key: string): string | null;
10 setItem(key: string, value: string): void;
11}
12
13const PROMPT_STASH_KEY = "t3code:prompt-stash:v2";
14const COMPOSER_DRAFTS_KEY = "t3code:composer-drafts:v1";
15const NOT_IMPORTED_KEYS = new Set([
16 // Identifies one running install to the server; two apps must not share it.
17 "t3.backgroundActivity.clientId",
18 // Resumes a permission setup V1 left mid-flow; V2 would redirect to it on boot.
19 "t3code:snap-shot-setup-resume:v1",
20]);
21/** Matches MAX_STASH_ENTRIES in apps/web/src/promptStashStore.ts. */
22const MAX_STASH_ENTRIES = 20;
23const DRAFT_RECORD_FIELDS = [
24 "draftsByThreadKey",
25 "draftThreadsByThreadKey",
26 "logicalProjectDraftThreadKeyByLogicalProjectKey",
27] as const;
28
29type JsonObject = Record<string, unknown>;
30
31const isObject = (value: unknown): value is JsonObject =>
32 typeof value === "object" && value !== null && !Array.isArray(value);
33
34function parseObject(raw: string): JsonObject | null {
35 try {
36 const parsed: unknown = JSON.parse(raw);
37 return isObject(parsed) ? parsed : null;
38 } catch {
39 return null;
40 }
41}
42
43/** V2 entries first, then V1 entries V2 does not already have, up to the stash cap. */
44function mergePromptStash(current: JsonObject, legacy: JsonObject): JsonObject | null {
45 const currentEntries = isObject(current.state) ? current.state.entries : null;
46 const legacyEntries = isObject(legacy.state) ? legacy.state.entries : null;
47 if (!Array.isArray(currentEntries) || !Array.isArray(legacyEntries)) return null;
48 if (current.version !== legacy.version) return null;
49 const ids = new Set(currentEntries.map((entry) => (isObject(entry) ? entry.id : undefined)));
50 const added = legacyEntries.filter((entry) => isObject(entry) && !ids.has(entry.id));
51 const state = isObject(current.state) ? current.state : {};
52 return {
53 ...current,
54 state: { ...state, entries: [...currentEntries, ...added].slice(0, MAX_STASH_ENTRIES) },
55 };
56}
57
58/** Adds V1 drafts for threads V2 has no draft for. Shapes must share a version. */
59function mergeComposerDrafts(current: JsonObject, legacy: JsonObject): JsonObject | null {
60 if (current.version !== legacy.version) return null;
61 if (!isObject(current.state) || !isObject(legacy.state)) return null;
62 const state: JsonObject = { ...current.state };
63 for (const field of DRAFT_RECORD_FIELDS) {
64 const currentRecord = state[field];
65 const legacyRecord = legacy.state[field];
66 if (!isObject(currentRecord) || !isObject(legacyRecord)) return null;
67 state[field] = { ...legacyRecord, ...currentRecord };
68 }
69 return { ...current, state };
70}
71
72const isMergedKey = (key: string) => key === PROMPT_STASH_KEY || key === COMPOSER_DRAFTS_KEY;
73
74function mergeValue(key: string, current: string, legacy: string): string | null {
75 const merge =
76 key === PROMPT_STASH_KEY
77 ? mergePromptStash
78 : key === COMPOSER_DRAFTS_KEY
79 ? mergeComposerDrafts
80 : null;
81 if (merge === null) return null;
82 const currentObject = parseObject(current);
83 const legacyObject = parseObject(legacy);
84 if (currentObject === null || legacyObject === null) return null;
85 const merged = merge(currentObject, legacyObject);
86 return merged === null ? null : JSON.stringify(merged);
87}
88
89/** Returns false when a write failed, so the caller can retry on a later launch. */
90export function mergeLegacyLocalStorage(
91 storage: MergeStorage,
92 legacyItems: Readonly<Record<string, string>>,
93): boolean {
94 let complete = true;
95 // Stash and drafts first, so a full quota costs layout state rather than prompts.
96 const keys = Object.keys(legacyItems).sort(
97 (left, right) => Number(isMergedKey(right)) - Number(isMergedKey(left)),
98 );
99 for (const key of keys) {
100 if (NOT_IMPORTED_KEYS.has(key)) continue;
101 const legacy = legacyItems[key]!;
102 const current = storage.getItem(key);
103 const next = current === null ? legacy : mergeValue(key, current, legacy);
104 if (next === null || next === current) continue;
105 try {
106 storage.setItem(key, next);
107 } catch {
108 // Over quota: skip this key rather than abandon the rest.
109 complete = false;
110 }
111 }
112 return complete;
113}