agent-ecosystem/src/renderer/services/composerDraftStorage.ts
2026-05-05 10:42:13 +03:00

331 lines
10 KiB
TypeScript

/**
* Atomic draft storage for MessageComposer snapshots.
*
* Unlike `draftStorage.ts` (text-only with TTL), this stores a unified
* snapshot of text + chips + attachments under a single key — no TTL.
* Drafts persist until explicitly cleared (on send or manual action).
*/
import { createStore, del, get, promisifyRequest, set } from 'idb-keyval';
import type { InlineChip } from '@renderer/types/inlineChip';
import type { AgentActionMode, AttachmentPayload } from '@shared/types';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
/** Current snapshot schema version. Bump when shape changes. */
const SNAPSHOT_VERSION = 1;
export interface ComposerDraftSnapshot {
version: number;
teamName: string;
text: string;
chips: InlineChip[];
attachments: AttachmentPayload[];
actionMode?: AgentActionMode;
pendingSendId?: string;
updatedAt: number;
}
// ---------------------------------------------------------------------------
// Key helpers
// ---------------------------------------------------------------------------
const KEY_PREFIX = 'composer:';
const keyvalStore = createStore('keyval-store', 'keyval');
function storageKey(teamName: string): string {
return `${KEY_PREFIX}${teamName}`;
}
/** Legacy keys used by the old three-key approach. */
function legacyKeys(teamName: string): { text: string; chips: string; attachments: string } {
return {
text: `draft:compose:${teamName}`,
chips: `draft:compose:${teamName}:chips`,
attachments: `draft:compose:${teamName}:attachments`,
} as const;
}
// ---------------------------------------------------------------------------
// Validation
// ---------------------------------------------------------------------------
function isValidSnapshot(data: unknown): data is ComposerDraftSnapshot {
if (typeof data !== 'object' || data === null) return false;
const obj = data as Record<string, unknown>;
return (
typeof obj.version === 'number' &&
typeof obj.teamName === 'string' &&
typeof obj.text === 'string' &&
Array.isArray(obj.chips) &&
Array.isArray(obj.attachments) &&
typeof obj.updatedAt === 'number'
);
}
// ---------------------------------------------------------------------------
// IDB availability tracking (same pattern as draftStorage.ts)
// ---------------------------------------------------------------------------
let idbUnavailable = false;
let idbUnavailableLogged = false;
const fallbackStore = new Map<string, ComposerDraftSnapshot>();
function markIdbUnavailable(): void {
if (!idbUnavailableLogged) {
idbUnavailableLogged = true;
console.warn(
'[composerDraftStorage] IndexedDB unavailable, using in-memory storage for this session.'
);
}
idbUnavailable = true;
}
function toError(error: unknown, fallbackMessage: string): Error {
return error instanceof Error ? error : new Error(fallbackMessage);
}
function deleteMatchingSnapshotInStore(
store: IDBObjectStore,
key: string,
predicate: (snapshot: ComposerDraftSnapshot | null) => boolean
): Promise<void> {
return new Promise<void>((resolve, reject) => {
const request = store.get(key);
request.onerror = () => {
reject(toError(request.error, 'Failed to read composer draft snapshot.'));
};
request.onsuccess = () => {
try {
const raw = request.result as unknown;
const snapshot = raw == null ? null : isValidSnapshot(raw) ? raw : null;
const shouldDelete = (raw != null && snapshot == null) || predicate(snapshot);
if (shouldDelete) {
store.delete(key);
}
void promisifyRequest(store.transaction).then(
() => resolve(),
(error) => reject(toError(error, 'Failed to delete composer draft snapshot.'))
);
} catch (error) {
reject(toError(error, 'Failed to evaluate composer draft snapshot.'));
}
};
});
}
// ---------------------------------------------------------------------------
// Core API
// ---------------------------------------------------------------------------
async function saveSnapshot(teamName: string, snapshot: ComposerDraftSnapshot): Promise<void> {
const key = storageKey(teamName);
if (idbUnavailable) {
fallbackStore.set(key, snapshot);
return;
}
try {
await set(key, snapshot);
} catch {
markIdbUnavailable();
fallbackStore.set(key, snapshot);
}
}
async function loadSnapshot(teamName: string): Promise<ComposerDraftSnapshot | null> {
const key = storageKey(teamName);
if (idbUnavailable) {
return fallbackStore.get(key) ?? null;
}
try {
const data = await get<unknown>(key);
if (data == null) return null;
if (isValidSnapshot(data)) return data;
// Invalid shape — discard silently
void del(key);
return null;
} catch {
markIdbUnavailable();
return fallbackStore.get(key) ?? null;
}
}
async function deleteSnapshot(teamName: string): Promise<void> {
const key = storageKey(teamName);
if (idbUnavailable) {
fallbackStore.delete(key);
return;
}
try {
await del(key);
} catch {
markIdbUnavailable();
fallbackStore.delete(key);
}
}
async function deleteSnapshotIfMatches(
teamName: string,
predicate: (snapshot: ComposerDraftSnapshot | null) => boolean
): Promise<void> {
const key = storageKey(teamName);
if (idbUnavailable) {
const snapshot = fallbackStore.get(key) ?? null;
if (predicate(snapshot)) fallbackStore.delete(key);
return;
}
try {
await keyvalStore('readwrite', (store) => deleteMatchingSnapshotInStore(store, key, predicate));
} catch {
markIdbUnavailable();
const snapshot = fallbackStore.get(key) ?? null;
if (predicate(snapshot)) fallbackStore.delete(key);
}
}
// ---------------------------------------------------------------------------
// Legacy migration
// ---------------------------------------------------------------------------
interface LegacyTextDraft {
value: string;
timestamp: number;
}
function isLegacyTextDraft(d: unknown): d is LegacyTextDraft {
if (typeof d !== 'object' || d === null) return false;
const obj = d as Record<string, unknown>;
return typeof obj.value === 'string' && typeof obj.timestamp === 'number';
}
function isValidChipArray(data: unknown): data is InlineChip[] {
if (!Array.isArray(data)) return false;
return data.every((raw) => {
if (typeof raw !== 'object' || raw === null) return false;
const item = raw as Record<string, unknown>;
return typeof item.id === 'string' && typeof item.filePath === 'string';
});
}
function isValidAttachmentArray(data: unknown): data is AttachmentPayload[] {
if (!Array.isArray(data)) return false;
return data.every((raw) => {
if (typeof raw !== 'object' || raw === null) return false;
const item = raw as Record<string, unknown>;
return (
typeof item.id === 'string' &&
typeof item.filename === 'string' &&
typeof item.data === 'string'
);
});
}
/**
* Attempts to migrate legacy three-key drafts into a unified snapshot.
* Returns the migrated snapshot or null if no legacy data found.
* Deletes legacy keys on success.
*/
async function migrateLegacy(teamName: string): Promise<ComposerDraftSnapshot | null> {
if (idbUnavailable) return null;
const keys = legacyKeys(teamName);
try {
const [rawText, rawChips, rawAttachments] = await Promise.all([
get<unknown>(keys.text),
get<unknown>(keys.chips),
get<unknown>(keys.attachments),
]);
// Nothing to migrate
if (rawText == null && rawChips == null && rawAttachments == null) return null;
let text = '';
if (isLegacyTextDraft(rawText)) {
text = rawText.value;
}
let chips: InlineChip[] = [];
if (rawChips != null) {
const chipsData = typeof rawChips === 'string' ? (JSON.parse(rawChips) as unknown) : rawChips;
// Legacy text draft wraps value in {value, timestamp}
const unwrapped = isLegacyTextDraft(chipsData) ? chipsData.value : chipsData;
const toParse =
typeof unwrapped === 'string' ? (JSON.parse(unwrapped) as unknown) : unwrapped;
if (isValidChipArray(toParse)) chips = toParse;
}
let attachments: AttachmentPayload[] = [];
if (rawAttachments != null) {
const attData =
typeof rawAttachments === 'string'
? (JSON.parse(rawAttachments) as unknown)
: rawAttachments;
const unwrapped = isLegacyTextDraft(attData) ? attData.value : attData;
const toParse =
typeof unwrapped === 'string' ? (JSON.parse(unwrapped) as unknown) : unwrapped;
if (isValidAttachmentArray(toParse)) attachments = toParse;
}
// Only create snapshot if there's actual content
if (text.length === 0 && chips.length === 0 && attachments.length === 0) {
// Clean up empty legacy keys
await Promise.all([del(keys.text), del(keys.chips), del(keys.attachments)]);
return null;
}
const snapshot: ComposerDraftSnapshot = {
version: SNAPSHOT_VERSION,
teamName,
text,
chips,
attachments,
updatedAt: Date.now(),
};
// Save new snapshot and delete legacy keys atomically-ish
await saveSnapshot(teamName, snapshot);
await Promise.all([del(keys.text), del(keys.chips), del(keys.attachments)]);
return snapshot;
} catch {
// Migration is best-effort — don't block the composer
return null;
}
}
// ---------------------------------------------------------------------------
// Factory for empty snapshot
// ---------------------------------------------------------------------------
function emptySnapshot(teamName: string): ComposerDraftSnapshot {
return {
version: SNAPSHOT_VERSION,
teamName,
text: '',
chips: [],
attachments: [],
updatedAt: Date.now(),
};
}
// ---------------------------------------------------------------------------
// Export
// ---------------------------------------------------------------------------
export const composerDraftStorage = {
saveSnapshot,
loadSnapshot,
deleteSnapshot,
deleteSnapshotIfMatches,
migrateLegacy,
emptySnapshot,
};