import microdiff from "microdiff"; import type {AtprotoDraftRecords} from "./record-builder"; export interface RepoRecordEntry { uri: string; cid?: string; value: unknown; } export interface RepoRecordGroups { cards: RepoRecordEntry[]; collections: RepoRecordEntry[]; collectionLinks: RepoRecordEntry[]; notes: RepoRecordEntry[]; } export interface SembleRepoDiff { missingInRepo: string[]; extraInRepo: string[]; changed: Array<{ uri: string; changes: ReturnType; }>; } export interface DiffOptions { ignoredFields?: string[]; } type RecordKind = "card" | "collection" | "collectionLink" | "note"; export function diffSembleRecords( existing: RepoRecordGroups, drafts: AtprotoDraftRecords, options: DiffOptions = {ignoredFields: ["createdAt"]} ): SembleRepoDiff { const ignored = new Set(options.ignoredFields ?? []); const repoCanonical = canonicalizeRecords(existing, ignored); const draftCanonical = canonicalizeRecords(normalizeDraftRecords(drafts), ignored); const repoMap = new Map(repoCanonical.map(entry => [entry.key, entry])); const draftMap = new Map(draftCanonical.map(entry => [entry.key, entry])); const missingInRepo: string[] = []; const extraInRepo: string[] = []; const changed: Array<{uri: string; changes: ReturnType}> = []; for (const [key, draft] of draftMap.entries()) { const existingRecord = repoMap.get(key); if (!existingRecord) { missingInRepo.push(draft.uri); continue; } const left = existingRecord.value as Record | Array; const right = draft.value as Record | Array; const changes = microdiff(left, right); if (changes.length > 0) { changed.push({uri: existingRecord.uri, changes}); } } for (const [key, record] of repoMap.entries()) { if (!draftMap.has(key)) { extraInRepo.push(record.uri); } } return {missingInRepo, extraInRepo, changed}; } interface CanonicalRecord extends RepoRecordEntry { kind: RecordKind; key: string; } function canonicalizeRecords(records: RepoRecordGroups, ignored: Set): CanonicalRecord[] { const result: CanonicalRecord[] = []; const uriToKey = new Map(); for (const entry of records.cards) { const cleaned = scrubIgnoredFields(entry.value, ignored); const url = extractUrl(cleaned); const key = buildKey("card", url ?? entry.uri); result.push({kind: "card", key, uri: entry.uri, value: cleaned}); uriToKey.set(entry.uri, key); } for (const entry of records.collections) { const cleaned = scrubIgnoredFields(entry.value, ignored); const name = extractCollectionName(cleaned); const key = buildKey("collection", name ?? entry.uri); result.push({kind: "collection", key, uri: entry.uri, value: cleaned}); uriToKey.set(entry.uri, key); } for (const entry of records.notes) { const cleaned = scrubIgnoredFields(entry.value, ignored); const parentKey = extractParentKey(cleaned, uriToKey); const keySeed = extractUrl(cleaned) ?? parentKey ?? entry.uri; const key = buildKey("note", keySeed); const normalizedValue = normalizeNoteValue(cleaned, parentKey); result.push({kind: "note", key, uri: entry.uri, value: normalizedValue}); uriToKey.set(entry.uri, key); } for (const entry of records.collectionLinks) { const cleaned = scrubIgnoredFields(entry.value, ignored); const collectionKey = extractLinkedKey(cleaned, "collection", uriToKey); const cardKey = extractLinkedKey(cleaned, "card", uriToKey); const key = buildKey("collectionLink", `${collectionKey ?? "unknown"}->${cardKey ?? "unknown"}`); const normalizedValue = normalizeLinkValue(cleaned, collectionKey, cardKey); result.push({kind: "collectionLink", key, uri: entry.uri, value: normalizedValue}); uriToKey.set(entry.uri, key); } return result; } function buildKey(kind: RecordKind, seed: string): string { return `${kind}|${seed}`; } function scrubIgnoredFields(value: unknown, ignored: Set): unknown { if (value === null || typeof value !== "object") return value; if (Array.isArray(value)) { return value.map(item => scrubIgnoredFields(item, ignored)); } const result: Record = {}; for (const [key, val] of Object.entries(value)) { if (ignored.has(key)) continue; result[key] = scrubIgnoredFields(val, ignored); } return result; } function extractUrl(value: unknown): string | undefined { if (!value || typeof value !== "object") return undefined; const record = value as {url?: unknown; content?: {url?: unknown}}; const direct = typeof record.url === "string" ? record.url : undefined; if (direct) return direct; const nested = record.content; if (nested && typeof nested === "object" && typeof nested.url === "string") { return nested.url; } return undefined; } function extractCollectionName(value: unknown): string | undefined { if (!value || typeof value !== "object") return undefined; const record = value as {name?: unknown}; return typeof record.name === "string" ? record.name : undefined; } function extractParentKey(value: unknown, uriToKey: Map): string | undefined { if (!value || typeof value !== "object") return undefined; const record = value as {parentCard?: {uri?: unknown}}; const parentUri = record.parentCard && typeof record.parentCard === "object" ? (record.parentCard as {uri?: unknown}).uri : undefined; if (typeof parentUri === "string") { return uriToKey.get(parentUri) ?? parentUri; } return undefined; } function extractLinkedKey( value: unknown, field: "collection" | "card", uriToKey: Map ): string | undefined { if (!value || typeof value !== "object") return undefined; const record = value as Record; const ref = record[field]; if (!ref || typeof ref !== "object") return undefined; const uri = (ref as {uri?: unknown}).uri; if (typeof uri === "string") { return uriToKey.get(uri) ?? uri; } return undefined; } function normalizeNoteValue(value: unknown, parentKey?: string): unknown { if (!value || typeof value !== "object") return value; const clone = {...(value as Record)}; if (parentKey) { clone.parentCard = {key: parentKey}; } return clone; } function normalizeLinkValue(value: unknown, collectionKey?: string, cardKey?: string): unknown { if (!value || typeof value !== "object") return value; const clone = {...(value as Record)}; if (collectionKey) { clone.collection = {key: collectionKey}; } if (cardKey) { clone.card = {key: cardKey}; } return clone; } export function normalizeDraftRecords(drafts: AtprotoDraftRecords): RepoRecordGroups { return { cards: drafts.cards.map(envelope => ({ uri: envelope.strongRef.uri, value: envelope.record })), collections: drafts.collections.map(envelope => ({ uri: envelope.strongRef.uri, value: envelope.record })), collectionLinks: drafts.collectionLinks.map(envelope => ({ uri: envelope.strongRef.uri, value: envelope.record })), notes: drafts.notes.map(envelope => ({ uri: envelope.strongRef.uri, value: envelope.record })) }; }