export const RECENT_PROJECTS_SCHEMA_VERSION = 1; export const RECENT_PROJECTS_MAX_COUNT = 50; export type RecentProjectBackend = "opfs" | "indexeddb" | "unknown"; export interface RecentProjectRecord { schemaVersion: typeof RECENT_PROJECTS_SCHEMA_VERSION; projectId: string; displayName: string; revision: number; bytes: number; sha256: string; updatedAt: string; lastOpenedAt: string; backend: RecentProjectBackend; } export interface RecentProjectIndex { schemaVersion: typeof RECENT_PROJECTS_SCHEMA_VERSION; projects: RecentProjectRecord[]; } export type RecentProjectIssueCode = "MISSING" | "HASH_MISMATCH" | "METADATA_MISMATCH"; export interface RecentProjectIdentity { revision: number; bytes: number; sha256: string; } export interface RecentProjectIssue { project: RecentProjectRecord; code: RecentProjectIssueCode; } export interface RecentProjectParseResult { index: RecentProjectIndex; quarantined: number; } const SHA256_PATTERN = /^[a-f0-9]{64}$/; const PROJECT_ID_PATTERN = /^[A-Za-z0-9_-]{1,64}$/; function canonicalTimestamp(value: unknown): string | undefined { if (typeof value !== "string") return undefined; const milliseconds = Date.parse(value); return Number.isFinite(milliseconds) ? new Date(milliseconds).toISOString() : undefined; } export function createRecentProjectIndex(): RecentProjectIndex { return { schemaVersion: RECENT_PROJECTS_SCHEMA_VERSION, projects: [] }; } export function parseRecentProjectRecord(value: unknown): RecentProjectRecord | undefined { if (!value || typeof value !== "object" || Array.isArray(value)) return undefined; const candidate = value as Record; const updatedAt = canonicalTimestamp(candidate.updatedAt); const lastOpenedAt = canonicalTimestamp(candidate.lastOpenedAt); if (candidate.schemaVersion !== RECENT_PROJECTS_SCHEMA_VERSION || typeof candidate.projectId !== "string" || !PROJECT_ID_PATTERN.test(candidate.projectId) || typeof candidate.displayName !== "string" || candidate.displayName.trim().length === 0 || candidate.displayName.length > 128 || !Number.isInteger(candidate.revision) || Number(candidate.revision) < 0 || !Number.isInteger(candidate.bytes) || Number(candidate.bytes) <= 0 || typeof candidate.sha256 !== "string" || !SHA256_PATTERN.test(candidate.sha256) || !updatedAt || !lastOpenedAt || !["opfs", "indexeddb", "unknown"].includes(candidate.backend as string)) { return undefined; } return { schemaVersion: RECENT_PROJECTS_SCHEMA_VERSION, projectId: candidate.projectId, displayName: candidate.displayName, revision: Number(candidate.revision), bytes: Number(candidate.bytes), sha256: candidate.sha256, updatedAt, lastOpenedAt, backend: candidate.backend as RecentProjectBackend, }; } function compareRecentProjects(left: RecentProjectRecord, right: RecentProjectRecord): number { return right.lastOpenedAt.localeCompare(left.lastOpenedAt) || right.updatedAt.localeCompare(left.updatedAt) || right.revision - left.revision || left.projectId.localeCompare(right.projectId) || left.displayName.localeCompare(right.displayName) || left.backend.localeCompare(right.backend) || left.sha256.localeCompare(right.sha256) || left.bytes - right.bytes; } export function normalizeRecentProjects(records: readonly unknown[], limit = RECENT_PROJECTS_MAX_COUNT): RecentProjectParseResult { const byProjectId = new Map(); let quarantined = 0; for (const value of records) { const parsed = parseRecentProjectRecord(value); if (!parsed) { quarantined += 1; continue; } const previous = byProjectId.get(parsed.projectId); if (!previous || compareRecentProjects(parsed, previous) < 0) byProjectId.set(parsed.projectId, parsed); } const normalizedLimit = Number.isFinite(limit) ? Math.max(0, Math.min(RECENT_PROJECTS_MAX_COUNT, Math.floor(limit))) : RECENT_PROJECTS_MAX_COUNT; const projects = [...byProjectId.values()].sort(compareRecentProjects).slice(0, normalizedLimit); return { index: { schemaVersion: RECENT_PROJECTS_SCHEMA_VERSION, projects }, quarantined }; } export function parseRecentProjectIndex(value: unknown): RecentProjectParseResult { if (!value || typeof value !== "object" || Array.isArray(value)) return { index: createRecentProjectIndex(), quarantined: 1 }; const candidate = value as Record; if (candidate.schemaVersion !== RECENT_PROJECTS_SCHEMA_VERSION || !Array.isArray(candidate.projects)) { return { index: createRecentProjectIndex(), quarantined: 1 }; } return normalizeRecentProjects(candidate.projects); } export function upsertRecentProject(index: RecentProjectIndex, record: RecentProjectRecord): RecentProjectIndex { return normalizeRecentProjects([...index.projects, record]).index; } export function removeRecentProject(index: RecentProjectIndex, projectId: string): RecentProjectIndex { return normalizeRecentProjects(index.projects.filter((project) => project.projectId !== projectId)).index; } export function classifyRecentProjectIdentity(expected: RecentProjectIdentity, actual: RecentProjectIdentity | undefined): RecentProjectIssueCode | undefined { if (!actual) return "MISSING"; if (expected.sha256 !== actual.sha256) return "HASH_MISMATCH"; if (expected.revision !== actual.revision || expected.bytes !== actual.bytes) return "METADATA_MISMATCH"; return undefined; }