export type VaultUpdateId = number; export type DocumentId = string; export type RelativePath = string; export interface DocumentMetadata { parentVersionId: VaultUpdateId; documentId: DocumentId; hash: string; isDeleted: boolean; } import type { Logger } from "src/tracing/logger"; export interface StoredDatabase { documents: Record; lastSeenUpdateId: VaultUpdateId | undefined; } export class Database { private documents = new Map< RelativePath, DocumentMetadata | Promise >(); private lastSeenUpdateId: VaultUpdateId | undefined; public constructor( private readonly logger: Logger, initialState: Partial | undefined, private readonly saveData: (data: StoredDatabase) => Promise ) { initialState ??= {}; if (initialState.documents) { for (const [relativePath, metadata] of Object.entries( initialState.documents )) { this.documents.set(relativePath, metadata); } } this.ensureConsistency(); this.logger.debug(`Loaded ${this.documents.size} documents`); this.lastSeenUpdateId = initialState.lastSeenUpdateId; this.logger.debug( `Loaded last seen update id: ${this.lastSeenUpdateId}` ); } public get length(): number { return this.documents.size; } public get resolvedDocuments(): [RelativePath, DocumentMetadata][] { // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion return Array.from(this.documents.entries()).filter( ([_, metadata]) => !(metadata instanceof Promise) ) as [RelativePath, DocumentMetadata][]; } public getLastSeenUpdateId(): VaultUpdateId | undefined { return this.lastSeenUpdateId; } public async setLastSeenUpdateId( value: VaultUpdateId | undefined ): Promise { this.lastSeenUpdateId = value; await this.save(); } public async resetSyncState(): Promise { this.documents = new Map(); this.lastSeenUpdateId = 0; await this.save(); } public getDocumentByDocumentId( documentId: DocumentId ): [RelativePath, DocumentMetadata] | undefined { return this.resolvedDocuments.find( ([_, metadata]) => metadata.documentId === documentId ); } public getDocumentByIdentity( document: | DocumentMetadata | Promise | undefined ): | [ RelativePath, DocumentMetadata | Promise ] | undefined { if (document === undefined) { return undefined; } return Array.from(this.documents.entries()).find( ([_, metadata]) => metadata === document ); } public async setDocument({ documentId, relativePath, parentVersionId, hash, isDeleted }: { documentId: DocumentId; relativePath: RelativePath; parentVersionId: VaultUpdateId; hash: string; isDeleted: boolean; }): Promise { this.documents.set(relativePath, { documentId, parentVersionId, hash, isDeleted }); await this.save(); } public async setDocumentPromise({ relativePath, promise }: { relativePath: RelativePath; promise: Promise; }): Promise { this.documents.set(relativePath, promise); // No need to save as Promises don't get serialized // and a crash would only result in the document being // creatied again. } public getResolvedDocument( relativePath: RelativePath | undefined ): DocumentMetadata | undefined { if (relativePath == undefined) { return undefined; } const metadata = this.documents.get(relativePath); if (metadata instanceof Promise) { return undefined; } return metadata; } public getDocument( relativePath: RelativePath | undefined ): Promise | DocumentMetadata | undefined { if (relativePath == undefined) { return undefined; } return this.documents.get(relativePath); } public async move( oldRelativePath: RelativePath, newRelativePath: RelativePath ): Promise { const document = this.documents.get(oldRelativePath); if (!document) { return; } const resolvedDocument = this.getResolvedDocument(oldRelativePath); if ( this.documents.has(newRelativePath) && resolvedDocument != undefined && resolvedDocument.isDeleted ) { throw new Error( `Cannot update physical path to path that is already in use: ${newRelativePath}` ); } this.documents.delete(oldRelativePath); this.documents.set(newRelativePath, document); await this.save(); } private async save(): Promise { this.ensureConsistency(); await this.saveData({ documents: Object.fromEntries(this.resolvedDocuments), lastSeenUpdateId: this.lastSeenUpdateId }); } private ensureConsistency(): void { const idToPath = new Map(); this.resolvedDocuments.forEach(([name, metadata]) => { idToPath.set(metadata.documentId, [ ...(idToPath.get(metadata.documentId) ?? []), name ]); }); const duplicates = Array.from(idToPath.entries()) .filter(([_, paths]) => paths.length > 1) .map(([id, paths]) => `${id} (${paths.join(", ")})`); if (duplicates.length > 0) { throw new Error( "Document IDs are not unique, found duplicates: " + duplicates.join("; ") ); } } }