VoidBot/packages/rag/src/source-document-archive.ts
2026-05-02 12:49:23 +01:00

423 lines
12 KiB
TypeScript

import { mkdir, readFile, rename, rm } from "node:fs/promises";
import { basename, dirname, extname, join } from "node:path";
import { normalizeText } from "./history-ingester";
import { SerializedFileStore } from "./file-store";
export interface ArchivedSourceDocument {
id: string;
repoName: string;
path: string;
language?: string;
title?: string;
content: string;
lastModifiedAt?: string;
metadata?: Record<string, string>;
}
export interface ArchivedSourceDocumentRecord extends ArchivedSourceDocument {
normalizedContent: string;
indexedAt: string;
}
export interface IndexedSourceRepoSummary {
repoName: string;
documentCount: number;
}
interface LegacySourceDocumentArchiveStore {
version: 1;
documents: ArchivedSourceDocumentRecord[];
}
interface SourceDocumentArchiveManifest {
version: 2;
repos: IndexedSourceRepoSummary[];
}
interface SourceDocumentRepoShardStore {
version: 1;
repoName: string;
documents: ArchivedSourceDocumentRecord[];
}
export interface SourceDocumentSyncResult {
created: number;
updated: number;
unchanged: number;
deleted: number;
previousRepoSourceIds: string[];
changedSourceIds: string[];
changedDocuments: ArchivedSourceDocumentRecord[];
activeDocuments: ArchivedSourceDocumentRecord[];
}
export class FileSourceDocumentArchiveRepository {
private readonly manifestStore: SerializedFileStore<SourceDocumentArchiveManifest>;
private readonly shardRoot: string;
private initialization?: Promise<void>;
public constructor(private readonly filePath: string) {
this.manifestStore = new SerializedFileStore(filePath, () => ({
version: 2,
repos: [],
}));
this.shardRoot = deriveShardRoot(filePath);
}
public async syncRepoDocuments(
repoName: string,
documents: ArchivedSourceDocument[],
): Promise<SourceDocumentSyncResult> {
await this.ensureInitialized();
const shardStore = this.getRepoShardStore(repoName);
const result = await shardStore.mutate((store) => {
const now = new Date().toISOString();
const previousRepoSourceIds = store.documents.map((document) => document.id);
const nextDocuments = documents.map((document) => toArchivedSourceDocumentRecord(document, now));
const existingById = new Map(store.documents.map((document, index) => [document.id, index]));
const nextIds = new Set(nextDocuments.map((document) => document.id));
const changedSourceIds: string[] = [];
const changedDocuments: ArchivedSourceDocumentRecord[] = [];
let created = 0;
let updated = 0;
let unchanged = 0;
let deleted = 0;
for (const document of nextDocuments) {
const position = existingById.get(document.id);
if (position === undefined) {
store.documents.push(document);
existingById.set(document.id, store.documents.length - 1);
changedSourceIds.push(document.id);
changedDocuments.push(document);
created += 1;
continue;
}
const existing = store.documents[position];
if (areEquivalent(existing, document)) {
unchanged += 1;
continue;
}
store.documents[position] = document;
changedSourceIds.push(document.id);
changedDocuments.push(document);
updated += 1;
}
const retainedDocuments = store.documents.filter((document) => {
if (nextIds.has(document.id)) {
return true;
}
changedSourceIds.push(document.id);
deleted += 1;
return false;
});
store.documents = retainedDocuments;
return {
created,
updated,
unchanged,
deleted,
previousRepoSourceIds,
changedSourceIds,
changedDocuments,
activeDocuments: retainedDocuments.slice().sort(compareDocuments),
};
});
await this.syncRepoSummary(repoName, result.activeDocuments.length);
if (result.activeDocuments.length === 0) {
await this.deleteRepoShard(repoName);
}
return result;
}
public async get(sourceId: string): Promise<ArchivedSourceDocumentRecord | undefined> {
await this.ensureInitialized();
const repoName = parseRepoNameFromSourceId(sourceId);
if (!repoName) {
return undefined;
}
const store = await this.getRepoShardStore(repoName).snapshot();
return store.documents.find((document) => document.id === sourceId);
}
public async listAll(): Promise<ArchivedSourceDocumentRecord[]> {
await this.ensureInitialized();
const summaries = await this.listRepoSummaries();
const repoDocuments = await Promise.all(
summaries.map((summary) => this.listByRepo(summary.repoName)),
);
return repoDocuments.flat().sort(compareDocuments);
}
public async listByRepo(repoName: string): Promise<ArchivedSourceDocumentRecord[]> {
await this.ensureInitialized();
const store = await this.getRepoShardStore(repoName).snapshot();
return store.documents.slice().sort(compareDocuments);
}
public async listRepoSummaries(): Promise<IndexedSourceRepoSummary[]> {
await this.ensureInitialized();
const store = await this.manifestStore.snapshot();
return store.repos
.slice()
.sort((left, right) => left.repoName.localeCompare(right.repoName));
}
public async count(): Promise<number> {
const summaries = await this.listRepoSummaries();
return summaries.reduce((total, summary) => total + summary.documentCount, 0);
}
public async normalizeStore(): Promise<void> {
await this.ensureInitialized();
const summaries = await this.listRepoSummaries();
await this.manifestStore.normalize();
for (const summary of summaries) {
await this.getRepoShardStore(summary.repoName).normalize();
}
}
public async removeRepo(repoName: string): Promise<string[]> {
await this.ensureInitialized();
const shardStore = this.getRepoShardStore(repoName);
const store = await shardStore.snapshot();
const deletedSourceIds = store.documents.map((document) => document.id);
await this.deleteRepoShard(repoName);
await this.syncRepoSummary(repoName, 0);
return deletedSourceIds;
}
private async ensureInitialized(): Promise<void> {
if (!this.initialization) {
this.initialization = this.initialize();
}
await this.initialization;
}
private async initialize(): Promise<void> {
await mkdir(dirname(this.filePath), { recursive: true });
await mkdir(this.shardRoot, { recursive: true });
const raw = await readRawFile(this.filePath);
if (!raw) {
return;
}
const parsed = parseArchivePayload(raw);
if (isManifestStore(parsed)) {
return;
}
if (isLegacyStore(parsed)) {
await this.migrateLegacyStore(parsed);
}
}
private async migrateLegacyStore(legacyStore: LegacySourceDocumentArchiveStore): Promise<void> {
const documentsByRepo = new Map<string, ArchivedSourceDocumentRecord[]>();
for (const document of legacyStore.documents) {
const repoDocuments = documentsByRepo.get(document.repoName) ?? [];
repoDocuments.push(document);
documentsByRepo.set(document.repoName, repoDocuments);
}
for (const [repoName, documents] of documentsByRepo.entries()) {
const shardStore = this.getRepoShardStore(repoName);
await shardStore.overwrite({
version: 1,
repoName,
documents: documents.slice().sort(compareDocuments),
});
}
const backupPath = `${this.filePath}.legacy-${Date.now()}.bak`;
await rename(this.filePath, backupPath).catch(async () => {
await rm(backupPath, { force: true }).catch(() => undefined);
await rename(this.filePath, backupPath);
});
await this.manifestStore.overwrite({
version: 2,
repos: [...documentsByRepo.entries()]
.map(([repoName, documents]) => ({
repoName,
documentCount: documents.length,
}))
.sort((left, right) => left.repoName.localeCompare(right.repoName)),
});
}
private getRepoShardStore(repoName: string): SerializedFileStore<SourceDocumentRepoShardStore> {
return new SerializedFileStore(resolveRepoShardPath(this.shardRoot, repoName), () => ({
version: 1,
repoName,
documents: [],
}));
}
private async syncRepoSummary(repoName: string, documentCount: number): Promise<void> {
await this.manifestStore.mutate((manifest) => {
const retainedRepos = manifest.repos.filter((summary) => summary.repoName !== repoName);
if (documentCount > 0) {
retainedRepos.push({
repoName,
documentCount,
});
}
manifest.repos = retainedRepos.sort((left, right) => left.repoName.localeCompare(right.repoName));
});
}
private async deleteRepoShard(repoName: string): Promise<void> {
await rm(resolveRepoShardPath(this.shardRoot, repoName), { force: true }).catch(() => undefined);
}
}
function deriveShardRoot(filePath: string): string {
const extension = extname(filePath);
const baseName = extension.length > 0 ? basename(filePath, extension) : basename(filePath);
return join(dirname(filePath), `${baseName}.repos`);
}
function resolveRepoShardPath(shardRoot: string, repoName: string): string {
const safeRepoName = encodeURIComponent(repoName);
return join(shardRoot, `${safeRepoName}.json`);
}
async function readRawFile(filePath: string): Promise<string | undefined> {
try {
return stripLeadingBom(await readFile(filePath, "utf8"));
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (code === "ENOENT") {
return undefined;
}
throw error;
}
}
function parseArchivePayload(input: string): unknown {
try {
return JSON.parse(input);
} catch (error) {
const lines = input
.split(/\r?\n/)
.map((line) => line.trim())
.filter((line) => line.length > 0);
if (lines.length > 1) {
return JSON.parse(lines[0]);
}
throw error;
}
}
function isLegacyStore(value: unknown): value is LegacySourceDocumentArchiveStore {
return Boolean(
value &&
typeof value === "object" &&
"version" in value &&
(value as { version?: unknown }).version === 1 &&
"documents" in value &&
Array.isArray((value as { documents?: unknown }).documents),
);
}
function isManifestStore(value: unknown): value is SourceDocumentArchiveManifest {
return Boolean(
value &&
typeof value === "object" &&
"version" in value &&
(value as { version?: unknown }).version === 2 &&
"repos" in value &&
Array.isArray((value as { repos?: unknown }).repos),
);
}
function parseRepoNameFromSourceId(sourceId: string): string | undefined {
const separatorIndex = sourceId.indexOf(":");
if (separatorIndex <= 0) {
return undefined;
}
return sourceId.slice(0, separatorIndex);
}
function stripLeadingBom(input: string): string {
return input.charCodeAt(0) === 0xfeff ? input.slice(1) : input;
}
function toArchivedSourceDocumentRecord(
document: ArchivedSourceDocument,
indexedAt: string,
): ArchivedSourceDocumentRecord {
return {
...document,
normalizedContent: normalizeText(document.content),
indexedAt,
};
}
function areEquivalent(
left: ArchivedSourceDocumentRecord,
right: ArchivedSourceDocumentRecord,
): boolean {
return JSON.stringify(comparableFields(left)) === JSON.stringify(comparableFields(right));
}
function comparableFields(document: ArchivedSourceDocumentRecord): Record<string, unknown> {
return {
id: document.id,
repoName: document.repoName,
path: document.path,
language: document.language,
title: document.title,
content: document.content,
lastModifiedAt: document.lastModifiedAt,
metadata: document.metadata ?? {},
};
}
function compareDocuments(
left: ArchivedSourceDocumentRecord,
right: ArchivedSourceDocumentRecord,
): number {
const repoComparison = left.repoName.localeCompare(right.repoName);
if (repoComparison !== 0) {
return repoComparison;
}
return left.path.localeCompare(right.path);
}