This commit is contained in:
Andras Schmelczer 2026-09-27 08:29:17 +01:00
commit 3dab48f58b

View file

@ -1,5 +1,4 @@
import type { PendingRequest, UnconfirmedRequest } from "./requests";
import { upgradeState } from "./upgrade-state";
import type { PendingRequest } from "./requests";
import type { FileManifest } from "../services/types/FileManifest";
import type { FileManifestEntries } from "../types/file-manifest-entries";
@ -8,48 +7,46 @@ export type DocumentId = string;
export type RelativePath = string;
export interface DocumentMetadata {
parentVersionId: number;
parentVersionId: VaultUpdateId;
hash: string;
}
export interface ContentHead {
vaultUpdateId: number;
vaultUpdateId: VaultUpdateId;
contentSize: number;
}
export interface DocumentState {
/** A null version retains only the clean hash after a server history reset. */
base?: { vaultUpdateId: number | null; hash: string };
base?: { vaultUpdateId: VaultUpdateId | null; hash: string };
/** Last observed disk hash; an empty string means a notification awaits scanning. */
observedHash?: string;
/** A remote version still to be incorporated, including excluded downloads. */
remote?: ContentHead;
}
export type { PendingRequest, UnconfirmedRequest } from "./requests";
export type { PendingRequest } from "./requests";
export interface EngineState {
vaultKey: string;
fileManifest: FileManifest;
local: FileManifestEntries;
documents: Record<string, DocumentState>;
documents: Record<DocumentId, DocumentState>;
/** Absent until a vault snapshot is applied; also marks a durable history reset. */
lastSeenUpdateId?: number;
lastSeenUpdateId?: VaultUpdateId;
pending?: PendingRequest;
/** A rejected retry does not prove an earlier attempt failed to commit. */
unconfirmed?: UnconfirmedRequest[];
/** Commits consumption of the saved notification queue with the identity map. */
lastAppliedLocalChangeId?: string;
/** Original server paths while local files are excluded; null means absent. */
excluded?: Record<string, string | null>;
excluded?: Record<DocumentId, RelativePath | null>;
}
export type StoredDatabase = EngineState;
// Read-only view used by cursor tracking and status reporting.
export interface DocumentRecord {
documentId: string;
relativePath: string;
documentId: DocumentId;
relativePath: RelativePath;
metadata?: DocumentMetadata;
}
@ -62,13 +59,11 @@ export function emptyState(vaultKey: string): StoredDatabase {
};
}
function canRebindEmpty(state: Partial<StoredDatabase> | undefined): boolean {
function canRebindEmpty(state: StoredDatabase): boolean {
return (
state !== undefined &&
state.lastSeenUpdateId === undefined &&
!state.pending &&
(state.unconfirmed?.length ?? 0) === 0 &&
Object.keys(state.local ?? {}).length === 0
Object.keys(state.local).length === 0
);
}
@ -76,15 +71,15 @@ export class Database {
public state: StoredDatabase;
private needsReload = false;
public constructor(
initial: Partial<StoredDatabase> | undefined,
initial: StoredDatabase | undefined,
private readonly saveData: (data: StoredDatabase) => Promise<void>,
vaultKey: string,
private readonly loadData: () => Promise<StoredDatabase | undefined>
) {
const state =
initial !== undefined && Object.keys(initial).length > 0
? upgradeState(initial as StoredDatabase)
: emptyState(vaultKey);
initial === undefined
? emptyState(vaultKey)
: structuredClone(initial);
if (state.vaultKey !== vaultKey && !canRebindEmpty(state)) {
throw new Error(
"Incompatible vault identity. Preserve it for recovery and initialize a separate state store."
@ -118,7 +113,7 @@ export class Database {
public async recoverPersistence(): Promise<void> {
if (!this.needsReload) return;
const saved = await this.loadData();
if (saved) this.state = upgradeState(saved);
if (saved) this.state = structuredClone(saved);
this.needsReload = false;
}
@ -130,7 +125,7 @@ export class Database {
return Object.keys(this.state.local).length;
}
public getDocumentByDocumentId(id: string): DocumentRecord | undefined {
public getDocumentByDocumentId(id: DocumentId): DocumentRecord | undefined {
const relativePath = this.state.local[id];
if (relativePath === undefined) return undefined;
const base = this.state.documents[id]?.base;
@ -148,7 +143,7 @@ export class Database {
}
public getLatestDocumentByRelativePath(
path: string
path: RelativePath
): DocumentRecord | undefined {
const id = Object.keys(this.state.local).find(
(id) => this.state.local[id] === path