135 lines
5.4 KiB
TypeScript
135 lines
5.4 KiB
TypeScript
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<string, unknown>;
|
|
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<string, RecentProjectRecord>();
|
|
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<string, unknown>;
|
|
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;
|
|
}
|