1interface MergeStorage {
2 getItem(key: string): string | null;
3 setItem(key: string, value: string): void;
4}
5
6const PROMPT_STASH_KEY = "t3code:prompt-stash:v2";
7const COMPOSER_DRAFTS_KEY = "t3code:composer-drafts:v1";
8const NOT_IMPORTED_KEYS = new Set([
9 // Identifies one running install to the server; two apps must not share it.
10 "t3.backgroundActivity.clientId",
11 "t3code:snap-shot-setup-resume:v1",
12]);
13/** Matches MAX_STASH_ENTRIES in apps/web/src/promptStashStore.ts. */
14const MAX_STASH_ENTRIES = 20;
15const DRAFT_RECORD_FIELDS = [
16 "draftsByThreadKey",
17 "draftThreadsByThreadKey",
18 "logicalProjectDraftThreadKeyByLogicalProjectKey",
19] as const;
20
21type JsonObject = Record<string, unknown>;
22
23const isObject = (value: unknown): value is JsonObject =>
24 typeof value === "object" && value !== null && !Array.isArray(value);
25
26function parseObject(raw: string): JsonObject | null {
27 try {
28 const parsed: unknown = JSON.parse(raw);
29 return isObject(parsed) ? parsed : null;
30 } catch {
31 return null;
32 }
33}
34
35function mergePromptStash(current: JsonObject, legacy: JsonObject): JsonObject | null {
36 const currentEntries = isObject(current.state) ? current.state.entries : null;
37 const legacyEntries = isObject(legacy.state) ? legacy.state.entries : null;
38 if (!Array.isArray(currentEntries) || !Array.isArray(legacyEntries)) return null;
39 if (current.version !== legacy.version) return null;
40 const ids = new Set(currentEntries.map((entry) => (isObject(entry) ? entry.id : undefined)));
41 const added = legacyEntries.filter((entry) => isObject(entry) && !ids.has(entry.id));
42 const state = isObject(current.state) ? current.state : {};
43 return {
44 ...current,
45 state: { ...state, entries: [...currentEntries, ...added].slice(0, MAX_STASH_ENTRIES) },
46 };
47}
48
49function mergeComposerDrafts(current: JsonObject, legacy: JsonObject): JsonObject | null {
50 if (current.version !== legacy.version) return null;
51 if (!isObject(current.state) || !isObject(legacy.state)) return null;
52 const state: JsonObject = { ...current.state };
53 for (const field of DRAFT_RECORD_FIELDS) {
54 const currentRecord = state[field];
55 const legacyRecord = legacy.state[field];
56 if (!isObject(currentRecord) || !isObject(legacyRecord)) return null;
57 state[field] = { ...legacyRecord, ...currentRecord };
58 }
59 return { ...current, state };
60}
61
62const isMergedKey = (key: string) => key === PROMPT_STASH_KEY || key === COMPOSER_DRAFTS_KEY;
63
64function mergeValue(key: string, current: string, legacy: string): string | null {
65 const merge =
66 key === PROMPT_STASH_KEY
67 ? mergePromptStash
68 : key === COMPOSER_DRAFTS_KEY
69 ? mergeComposerDrafts
70 : null;
71 if (merge === null) return null;
72 const currentObject = parseObject(current);
73 const legacyObject = parseObject(legacy);
74 if (currentObject === null || legacyObject === null) return null;
75 const merged = merge(currentObject, legacyObject);
76 return merged === null ? null : JSON.stringify(merged);
77}
78
79export function mergeLegacyLocalStorage(
80 storage: MergeStorage,
81 legacyItems: Readonly<Record<string, string>>,
82): boolean {
83 let complete = true;
84 // Stash and drafts first, so a full quota costs layout state rather than prompts.
85 const keys = Object.keys(legacyItems).sort(
86 (left, right) => Number(isMergedKey(right)) - Number(isMergedKey(left)),
87 );
88 for (const key of keys) {
89 if (NOT_IMPORTED_KEYS.has(key)) continue;
90 const legacy = legacyItems[key]!;
91 const current = storage.getItem(key);
92 const next = current === null ? legacy : mergeValue(key, current, legacy);
93 if (next === null || next === current) continue;
94 try {
95 storage.setItem(key, next);
96 } catch {
97 complete = false;
98 }
99 }
100 return complete;
101}