This commit is contained in:
Andras Schmelczer 2026-09-27 08:29:17 +01:00
commit 3dab48f58b
55 changed files with 1141 additions and 1376 deletions

View file

@ -211,8 +211,9 @@ docker build -f local-client-cli/Dockerfile -t vault-link-cli:test .
The complete client record (database, settings, queued logical notifications and
server history checkpoint) is saved atomically in `.vaultlink/sync-data.json`.
Existing database-only files migrate on the next save. Invalid or unreadable
metadata is reported instead of silently starting with empty state.
Only this complete-record format is supported; database-only files are not
converted. Invalid or unreadable JSON is reported instead of silently starting
with empty state.
File moves use an exclusive copy followed by deletion of the source. An
interruption may leave both files; the next scan reconciles the visible files.

View file

@ -6,7 +6,7 @@ import * as path from "node:path";
import { ClientPersistence } from "./client-persistence";
import type { StoredClient } from "sync-client";
test("CLI persistence retains the whole record and migrates database-only saves", async (t) => {
test("CLI persistence retains the whole record and reports unreadable metadata", async (t) => {
const directory = await fs.mkdtemp(
path.join(tmpdir(), "vaultlink-metadata-")
);
@ -18,7 +18,13 @@ test("CLI persistence retains the whole record and migrates database-only saves"
});
const record: StoredClient = {
settings: { token: "old-token", vaultName: "vault" },
database: { lastSeenUpdateId: 12 },
database: {
vaultKey: "vault",
fileManifest: { fileManifestId: 0, entries: {} },
local: {},
documents: {},
lastSeenUpdateId: 12
},
localChanges: [
{ type: "delete", path: "removed.md", changeId: "queued" }
],
@ -30,12 +36,6 @@ test("CLI persistence retains the whole record and migrates database-only saves"
...record,
settings: { ...record.settings, token: "current-token" }
});
const legacy = {
fileManifest: { fileManifestId: 0, entries: {} },
lastSeenUpdateId: 0
};
await fs.writeFile(file, JSON.stringify(legacy));
assert.deepEqual((await store.load()).database, legacy);
await fs.writeFile(file, "{truncated");
await assert.rejects(store.load(), SyntaxError);
await fs.rm(file);

View file

@ -4,7 +4,6 @@ import { randomUUID } from "node:crypto";
import type {
PersistenceProvider,
StoredClient,
StoredDatabase,
SyncSettings
} from "sync-client";
@ -35,11 +34,7 @@ export class ClientPersistence implements PersistenceProvider<StoredClient> {
Array.isArray(parsed)
)
throw new Error("Invalid client metadata");
// The sync engine validates and migrates the persisted fields.
const data = parsed as StoredClient & Partial<StoredDatabase>;
// Earlier CLI versions saved the database alone.
const stored: StoredClient =
"fileManifest" in data ? { database: data } : data;
const stored = parsed as StoredClient;
return {
...stored,
settings: { ...stored.settings, ...this.settings }

View file

@ -1,4 +1,4 @@
# Sync client (API v5)
# Sync client (API v4)
The engine uses one serialized loop per vault and requires Web Crypto (SHA-256). Local notifications and WebSocket
`vaultChanged` hints wake it; complete disk scans and the durable server event log are the
@ -17,6 +17,8 @@ pending requests saved and are reported to the caller. Transient failures retry;
authentication and protocol errors wait for an explicit wake or settings change.
Pausing or destroying the client aborts its HTTP session, including body reads;
resuming starts a fresh session. Connection checks can run while sync is disabled.
WebSocket shutdown retires callbacks immediately and drains message handlers
already running; it does not wait for the transport's close handshake.
## State and policy
@ -38,10 +40,13 @@ and content heads before merging. An interrupted fetch restarts from the last
incorporated cursor; pagination progress is not persisted. Intermediate changes
that the server has already reverted must not overwrite local edits.
A permanent upload rejection is recorded in metadata. An unchanged rejected payload is skipped so other files can sync; the
failure remains visible in history and `waitUntilFinished()`. Editing it, resetting
the client, or changing settings permits a fresh attempt. Earlier attempts that
committed despite a lost reply are incorporated through normal event replay.
A permanent upload rejection pauses all sync, including after restart. The exact
pending request and rejection message stay in metadata, and the failure remains
visible in history and `waitUntilFinished()`. Resetting the client or changing
settings retries that request before reading newer remote changes. This recovers
earlier attempts that committed despite a lost reply without separate receipt
bookkeeping. Local edits made while paused are uploaded after the pending request
is resolved; editing a file alone does not retry a rejected request.
For each document ID (absence means deletion), use the server path if local is
unchanged, use local if the server is unchanged, and use the server when both
@ -57,6 +62,8 @@ are implicit; empty directories are not synced.
- Content is uploaded before new membership; the client chooses and persists UUIDs.
- Text uses three-way `reconcile`. First contact at a matching path adopts the
remote ID and reconciles differing text with an empty parent.
- In-memory snapshots contain bytes and their hash. Base64 encoding is limited to
wire payloads; pending requests persist the exact payload needed for retries.
- Concurrent binary/unmergeable edits keep the server version. If only one side
changed, keep that side. No local backup archive is created.
- Deletion follows the file manifest decision, including when the file has local edits.
@ -84,11 +91,13 @@ snapshot. A file edited after this reset is also preserved as a separate local
document; recovery never persists another copy of its content. Unsent edits with
an old base become separate local documents; path conflicts preserve them under
conflict names instead of applying a diff to an unrelated restored version.
Recovery resumes after interrupted state saves. Existing API v4 client state
without a checkpoint takes this same conservative bootstrap on upgrade.
Recovery resumes after interrupted state saves. Only the current persisted state
format is supported; old client stores are not converted.
Status observers cannot stop the background engine by throwing or rejecting.
Their failures are reported, and remaining observers continue receiving updates.
Remote cursor tracking retains the latest future update and the last usable
position for each device, so cursors remain visible while file content catches up.
## Required adapter contract
@ -129,4 +138,4 @@ between files using their document identities; the next scan rereads content. Co
replacement. Unexpected eligible occupants receive visible conflict names;
ignored and oversized occupants stay in place. External writers can still change
files between observations, so these checks do not promise transactional file
contents or perfect identity reconstruction. There is no automated v2/v3 state migration.
contents or perfect identity reconstruction. There is no persisted-state migration.

View file

@ -2,6 +2,5 @@ export const TIMEOUT_FOR_MERGING_HISTORY_ENTRIES_IN_SECONDS = 60;
export const MAX_LOG_MESSAGE_COUNT = 100000;
export const MAX_HISTORY_ENTRY_COUNT = 5000;
export const SUPPORTED_API_VERSION = 4;
export const WEBSOCKET_DISCONNECT_TIMEOUT_IN_S = 10;
export const CURSOR_HEARTBEAT_INTERVAL_MS = 15_000;
export const WEBSOCKET_RECEIVE_TIMEOUT_MS = CURSOR_HEARTBEAT_INTERVAL_MS * 3;

View file

@ -1,3 +1,5 @@
import type { RelativePath } from "../persistence/database";
export class SyncResetError extends Error {
public constructor() {
super("SyncClient has been reset, cleaning up");
@ -22,7 +24,7 @@ export class AuthenticationError extends Error {
export class FileNotFoundError extends Error {
public constructor(
message: string,
public readonly filePath: string
public readonly filePath: RelativePath
) {
super(message);
this.name = "FileNotFoundError";

View file

@ -1,11 +1,15 @@
import { reconcile } from "reconcile-text";
import type { FileSystemOperations } from "./filesystem-operations";
import type { Database, EngineState } from "../persistence/database";
import type {
Database,
DocumentId,
EngineState,
RelativePath
} from "../persistence/database";
import {
fromStoredSnapshot,
toStoredSnapshot,
hashSnapshot,
type FileSnapshot,
type StoredSnapshot
type HashedSnapshot
} from "../snapshot";
import { isInternalPath } from "../utils/portable-path";
import { FilePaths } from "./file-paths";
@ -25,8 +29,8 @@ import {
} from "../errors/errors";
export interface FileWrite {
expected?: StoredSnapshot;
replacement: StoredSnapshot;
expected?: HashedSnapshot;
replacement: HashedSnapshot;
}
type Mutation = (action: () => Promise<void>) => Promise<void>;
@ -42,7 +46,7 @@ export class FileOperations {
private readonly serverConfig: ServerConfig,
private readonly filesApplied: (
paths: readonly string[]
paths: readonly RelativePath[]
) => void = () => undefined,
private readonly localChanges: {
@ -51,14 +55,14 @@ export class FileOperations {
} = { entries: () => [], flush: async () => undefined },
private readonly isProtectedFile: (
path: string,
path: RelativePath,
size: number
) => boolean = () => false
) {
this.paths = new FilePaths(fs, isProtectedFile);
}
public async read(path: string): Promise<Uint8Array> {
public async read(path: RelativePath): Promise<Uint8Array> {
const snapshot = await this.fs.read(path);
if (!snapshot)
@ -67,18 +71,19 @@ export class FileOperations {
return snapshot.content;
}
public async snapshot(path: string): Promise<StoredSnapshot | undefined> {
public async snapshot(
path: RelativePath
): Promise<HashedSnapshot | undefined> {
const snapshot = await this.fs.read(path);
return snapshot && toStoredSnapshot(snapshot);
return snapshot && hashSnapshot(snapshot);
}
public async listFilesRecursively(): Promise<string[]> {
public async listFilesRecursively(): Promise<RelativePath[]> {
return (await this.fs.listFilesRecursively()).filter(
(path) => !isInternalPath(path)
);
}
public async captureChange(
change: LocalChange,
previous: readonly LocalChange[]
@ -95,7 +100,7 @@ export class FileOperations {
public async apply(
next: EngineState,
writes: Partial<Record<string, FileWrite>> = {},
writes: Partial<Record<DocumentId, FileWrite>> = {},
validatePlan: () => void = () => undefined
): Promise<void> {
await this.localChanges.flush();
@ -116,7 +121,7 @@ export class FileOperations {
const current = structuredClone(this.database.state);
current.lastSeenUpdateId = next.lastSeenUpdateId;
this.applying = current;
const paths = new Set<string>();
const paths = new Set<RelativePath>();
const observeChanges = async (): Promise<void> => {
await this.localChanges.flush();
guard();
@ -129,7 +134,8 @@ export class FileOperations {
state,
change,
(path) =>
isInternalPath(path) || this.isProtectedFile(path, 0)
isInternalPath(path) ||
this.isProtectedFile(path, 0)
);
state.lastAppliedLocalChangeId = change.changeId;
}
@ -141,9 +147,10 @@ export class FileOperations {
...Object.keys(next.local)
])) {
await observeChanges();
let from = current.documents[id]?.observedHash !== undefined
? current.local[id]
: undefined;
let from =
current.documents[id]?.observedHash !== undefined
? current.local[id]
: undefined;
let to = next.local[id];
const write = writes[id];
if (!write && (from === undefined || from === to)) continue;
@ -182,9 +189,10 @@ export class FileOperations {
mutate
);
next.local[id] = to;
from = current.documents[id]?.observedHash !== undefined
? current.local[id]
: undefined;
from =
current.documents[id]?.observedHash !== undefined
? current.local[id]
: undefined;
if (from !== undefined) {
await mutate(async () => {
current.local[id] = to;
@ -195,9 +203,7 @@ export class FileOperations {
}
if (write) {
const snapshot =
from === undefined
? undefined
: await this.fs.read(to);
from === undefined ? undefined : await this.fs.read(to);
if (from !== undefined && !snapshot) {
delete next.local[id];
continue;
@ -229,13 +235,12 @@ export class FileOperations {
}
private async updateContent(
path: string,
path: RelativePath,
current: FileSnapshot | undefined,
write: FileWrite,
extensions: string[]
): Promise<string> {
const replacement = fromStoredSnapshot(write.replacement);
const base = write.expected && fromStoredSnapshot(write.expected);
const { replacement, expected: base } = write;
if (
current &&
base &&
@ -258,14 +263,17 @@ export class FileOperations {
const result =
current && write.expected
? await mergeContent(
path,
write.expected,
await toStoredSnapshot(current),
write.replacement,
extensions
)
path,
write.expected,
await hashSnapshot(current),
write.replacement,
extensions
)
: write.replacement;
await this.fs.write(path, fromStoredSnapshot(result));
await this.fs.write(path, {
content: result.content,
cursors: result.cursors
});
return result.hash;
}
}

View file

@ -1,22 +1,22 @@
import { v4 as uuid } from "uuid";
import type { FileSystemOperations } from "./filesystem-operations";
import type { EngineState } from "../persistence/database";
import type { DocumentId, EngineState, RelativePath } from "../persistence/database";
import { allocatePortablePath, isInternalPath } from "../utils/portable-path";
/** Resolve occupied rename destinations without discarding another file. */
export class FilePaths {
public constructor(
private readonly fs: FileSystemOperations,
private readonly isProtectedFile: (path: string, size: number) => boolean
private readonly isProtectedFile: (path: RelativePath, size: number) => boolean
) { }
public async prepareDestination(
next: EngineState,
current: EngineState,
id: string,
path: string,
id: DocumentId,
path: RelativePath,
mutate: (action: () => Promise<void>) => Promise<void>
): Promise<string> {
): Promise<RelativePath> {
try {
for (; ;) {
const obstruction = await this.obstruction(path);
@ -97,8 +97,8 @@ export class FilePaths {
}
private async obstruction(
path: string
): Promise<{ path: string; kind: "file" | "directory" } | undefined> {
path: RelativePath
): Promise<{ path: RelativePath; kind: "file" | "directory" } | undefined> {
const parts = path.split("/");
for (let i = 0; i < parts.length; i++) {
const prefix = parts.slice(0, i + 1).join("/");
@ -110,9 +110,9 @@ export class FilePaths {
private async conflictPath(
next: EngineState,
id: string,
path: string
): Promise<string> {
id: DocumentId,
path: RelativePath
): Promise<RelativePath> {
const occupied = { ...next.local };
delete occupied[id];
const files = (await this.fs.listFilesRecursively()).filter(
@ -123,12 +123,12 @@ export class FilePaths {
return allocatePortablePath(path, id, occupied);
}
private async ensureParents(path: string): Promise<void> {
private async ensureParents(path: RelativePath): Promise<void> {
const parent = path.split("/").slice(0, -1).join("/");
if (parent) await this.fs.createDirectory(parent);
}
public async removeEmptyParents(path: string): Promise<void> {
public async removeEmptyParents(path: RelativePath): Promise<void> {
let parent = path.split("/").slice(0, -1).join("/");
while (parent && !isInternalPath(parent)) {
if ((await this.fs.listFilesRecursively(parent)).length) break;

View file

@ -8,24 +8,24 @@ import type { FileSnapshot } from "../snapshot";
* A successful scan is complete: unreadable directories must reject, not vanish.
*/
export interface FileSystemOperations {
listFilesRecursively: (root?: string) => Promise<string[]>;
listFilesRecursively: (root?: RelativePath) => Promise<RelativePath[]>;
/** Return undefined on missing file */
read: (path: string) => Promise<FileSnapshot | undefined>;
read: (path: RelativePath) => Promise<FileSnapshot | undefined>;
/** Reject symlinks and special files rather than following them and return undefined. */
stat: (
path: string
path: RelativePath
) => Promise<{ kind: "file" | "directory"; size: number } | undefined>;
/** Includes directories. */
exists: (path: string) => Promise<boolean>;
exists: (path: RelativePath) => Promise<boolean>;
/** Recursive, idempotent directory creation. */
createDirectory: (path: string) => Promise<void>;
createDirectory: (path: RelativePath) => Promise<void>;
/** Create or overwrite a file, applying optional editor cursor metadata. */
write: (path: string, snapshot: FileSnapshot) => Promise<void>;
write: (path: RelativePath, snapshot: FileSnapshot) => Promise<void>;
// Atomically update the content of a text file.
atomicUpdateText: (
@ -34,11 +34,11 @@ export interface FileSystemOperations {
) => Promise<string>;
/** Move a file without replacing an existing destination. */
rename: (from: string, to: string) => Promise<void>;
rename: (from: RelativePath, to: RelativePath) => Promise<void>;
/** Idempotently unlink one regular file. */
deleteFile: (path: string) => Promise<void>;
deleteFile: (path: RelativePath) => Promise<void>;
/** Prune directories using rmdir only. Reject files and nonempty directories. */
deleteDirectory: (path: string) => Promise<void>;
deleteDirectory: (path: RelativePath) => Promise<void>;
}

View file

@ -21,7 +21,12 @@ export {
export { Logger, LogLevel, LogLine } from "./tracing/logger";
export { type SyncSettings, DEFAULT_SETTINGS } from "./persistence/settings";
export { rateLimit } from "./utils/rate-limit";
export type { RelativePath, StoredDatabase } from "./persistence/database";
export type {
DocumentId,
RelativePath,
StoredDatabase,
VaultUpdateId
} from "./persistence/database";
export type { FileSystemOperations } from "./file-operations/filesystem-operations";
export type { FileSnapshot } from "./snapshot";
export type { MetadataPersistenceProvider as PersistenceProvider } from "./persistence/metadata-persistence-provider";

View file

@ -1,83 +1,38 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { Database, emptyState, type StoredDatabase } from "./database";
import { Database, emptyState } from "./database";
test("old state loads without duplicate flags, full recovery content, or acknowledged heads", async () => {
const head = {
documentId: "clean",
vaultUpdateId: 1,
contentSize: 3,
updatedDate: "2026-09-23",
userId: "user",
deviceId: "device"
test("current state keeps absent optional fields absent across save and reload", async () => {
const initial = emptyState("vault");
initial.local = { local: "local.md", remote: "remote.md" };
initial.documents = {
local: { observedHash: "local" },
remote: { remote: { vaultUpdateId: 1, contentSize: 3 } },
empty: {}
};
const legacy = {
...emptyState("vault"),
initialized: true,
lastSeenUpdateId: 2,
protectedPaths: ["ignored.md"],
local: {
clean: "clean.md",
recovering: "recovering.md",
deferred: "deferred.md"
},
documents: {
clean: {
materialized: true,
observedHash: "clean",
base: { ...head, hash: "clean" }
let saved = structuredClone(initial);
const restart = (): Database =>
new Database(
saved,
async (next) => {
saved = structuredClone(next);
},
recovering: {
materialized: true,
observedHash: "edited",
recoveryBase: { contentBase64: "T0xE", hash: "old" }
},
deferred: { materialized: false, observedHash: undefined }
},
remoteHeads: {
clean: head,
deferred: { ...head, documentId: "deferred" }
}
};
const saves: StoredDatabase[] = [];
const db = new Database(
legacy,
async (next) => {
saves.push(next);
},
"vault",
async () => saves.at(-1)
);
"vault",
async () => structuredClone(saved)
);
const db = restart();
assert.deepEqual(db.state, initial);
await db.save();
const saved = saves.at(-1);
assert(saved);
assert.deepEqual(saved.documents, {
clean: {
base: { vaultUpdateId: 1, hash: "clean" },
observedHash: "clean"
},
recovering: {
base: { vaultUpdateId: null, hash: "old" },
observedHash: "edited"
},
deferred: {
base: undefined,
observedHash: undefined,
remote: { vaultUpdateId: head.vaultUpdateId, contentSize: head.contentSize }
}
});
assert.equal(saved.lastSeenUpdateId, 2);
for (const field of ["initialized", "remoteHeads", "protectedPaths"])
assert(!(field in saved));
assert(!JSON.stringify(saved).includes("contentBase64"));
assert.deepEqual(saved, initial);
assert.deepEqual(restart().state, initial);
});
test("old reset intent leaves the cursor absent after an uncertain save reload", async () => {
const legacy = {
...emptyState("vault"),
initialized: false,
historyRecovery: true,
lastSeenUpdateId: 0
test("current reset state is restored after an uncertain save", async () => {
const saved = emptyState("vault");
saved.local = { note: "note.md" };
saved.documents.note = {
base: { vaultUpdateId: null, hash: "clean" },
observedHash: "clean"
};
const db = new Database(
undefined,
@ -85,10 +40,9 @@ test("old reset intent leaves the cursor absent after an uncertain save reload",
throw new Error("interrupted");
},
"vault",
async () => legacy
async () => structuredClone(saved)
);
await assert.rejects(db.save(), /interrupted/);
await db.recoverPersistence();
assert.equal(db.state.lastSeenUpdateId, undefined);
assert(!("historyRecovery" in db.state));
assert.deepEqual(db.state, saved);
});

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

View file

@ -1,21 +1,14 @@
import type { PutFileContent } from "../services/types/PutFileContent";
import type { PushFileManifest } from "../services/types/PushFileManifest";
import type { FileManifestEntries } from "../types/file-manifest-entries";
import type { DocumentId } from "./database";
/** Exact wire payload and the hash needed to advance the base after acknowledgement. */
export type PendingRequest =
| { type: "content"; documentId: string; request: PutFileContent; hash: string }
| { type: "fileManifest"; request: PushFileManifest };
/** Enough to recognize a late receipt or suppress an unchanged rejected submission. */
export type UnconfirmedRequest = { requestId: string; rejection?: string } & (
| { type: "content"; documentId: string; hash: string }
| { type: "fileManifest"; entries: FileManifestEntries }
/** Exact wire payload retained until acknowledgement, including after rejection. */
export type PendingRequest = { rejection?: string } & (
| {
type: "content";
documentId: DocumentId;
request: PutFileContent;
hash: string;
}
| { type: "fileManifest"; request: PushFileManifest }
);
export function requestReceipt(request: PendingRequest, rejection?: string): UnconfirmedRequest {
const receipt = { requestId: request.request.requestId, rejection };
return request.type === "content"
? { ...receipt, type: "content", documentId: request.documentId, hash: request.hash }
: { ...receipt, type: "fileManifest", entries: request.request.entries };
}

View file

@ -1,3 +1,4 @@
import type { RelativePath } from "./database";
import type { Logger } from "../tracing/logger";
import { globsToRegexes } from "../utils/globs-to-regexes";
import { isInternalPath } from "../utils/portable-path";
@ -68,7 +69,7 @@ export class Settings {
);
}
public isIgnored(path: string): boolean {
public isIgnored(path: RelativePath): boolean {
return (
isInternalPath(path) ||
this.ignorePatterns.some((pattern) => pattern.test(path))

View file

@ -1,72 +0,0 @@
import type { ContentHead, DocumentState, EngineState } from "./database";
import { requestReceipt, type PendingRequest, type UnconfirmedRequest } from "./requests";
import type { StoredSnapshot } from "../snapshot";
type PreviousRequest = PendingRequest & {
snapshot?: StoredSnapshot;
response?: unknown;
rejection?: string;
};
function upgradeRequest(request: PreviousRequest): PendingRequest {
return request.type === "content"
? {
type: "content", documentId: request.documentId,
request: request.request, hash: request.snapshot?.hash ?? request.hash
}
: { type: "fileManifest", request: request.request };
}
/** Normalize earlier stores at the boundary; none of their duplicate data is saved again. */
export function upgradeState(state: EngineState): EngineState {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- Read previous persisted schemas at the migration boundary.
const legacy = state as Omit<EngineState, "pending" | "unconfirmed" | "documents"> & {
initialized?: boolean;
historyRecovery?: boolean;
remoteHeads?: Record<string, ContentHead>;
pending?: PreviousRequest;
unconfirmed?: (UnconfirmedRequest | PreviousRequest)[];
documents: Record<string, DocumentState & {
materialized?: boolean;
bootstrap?: boolean;
recoveryBase?: StoredSnapshot;
}>;
};
const next: EngineState = {
vaultKey: state.vaultKey,
fileManifest: state.fileManifest,
local: state.local,
documents: {},
lastSeenUpdateId: "initialized" in legacy &&
(!legacy.initialized || legacy.historyRecovery === true)
? undefined : state.lastSeenUpdateId,
pending: legacy.pending && upgradeRequest(legacy.pending),
unconfirmed: legacy.unconfirmed?.map((request) =>
"request" in request
? requestReceipt(upgradeRequest(request), request.rejection)
: request
),
lastAppliedLocalChangeId: state.lastAppliedLocalChangeId,
excluded: state.excluded
};
if (legacy.pending?.rejection && !legacy.pending.response) {
(next.unconfirmed ??= []).push(requestReceipt(upgradeRequest(legacy.pending), legacy.pending.rejection));
delete next.pending;
}
for (const id of new Set([...Object.keys(legacy.documents), ...Object.keys(legacy.remoteHeads ?? {})])) {
const doc = legacy.documents[id] ?? {};
const base = doc.recoveryBase
? { vaultUpdateId: null, hash: doc.recoveryBase.hash }
: doc.base && doc.bootstrap !== true
? { vaultUpdateId: doc.base.vaultUpdateId, hash: doc.base.hash }
: undefined;
const observedHash = "materialized" in doc
? doc.materialized === true ? doc.observedHash ?? "" : undefined
: doc.observedHash;
const remote = doc.remote ?? legacy.remoteHeads?.[id];
next.documents[id] = { base, observedHash };
if (remote && (observedHash === undefined || remote.vaultUpdateId > (base?.vaultUpdateId ?? 0)))
next.documents[id].remote = { vaultUpdateId: remote.vaultUpdateId, contentSize: remote.contentSize };
}
return next;
}

View file

@ -20,7 +20,10 @@ test("paused sync sends no requests, while connection checks still work", async
let requests = 0;
const service = new SyncService("device", settings(), async () => {
requests++;
return Response.json({ headEventId: 0, events: [] });
return Response.json(
{ headEventId: 0, endEventId: 0, events: [] },
{ headers: { "X-Vault-Link-History": "0:empty" } }
);
}, { get: () => undefined, save: async () => { } });
await assert.rejects(service.getEvents(0), SyncResetError);
assert.equal(requests, 0);
@ -42,7 +45,10 @@ test("pause aborts requests and fences late responses from an injected fetch", a
signals.push(init!.signal!);
return signals.length === 1
? response.promise
: Response.json({ headEventId: 0, events: [] });
: Response.json(
{ headEventId: 0, endEventId: 0, events: [] },
{ headers: { "X-Vault-Link-History": "0:empty" } }
);
},
{
get: () => undefined,
@ -66,7 +72,7 @@ test("pause aborts requests and fences late responses from an injected fetch", a
)
);
await new Promise((resolve) => setImmediate(resolve));
assert.deepEqual(saved, []);
assert.deepEqual(saved, ["0:empty"]);
});
for (const binary of [false, true]) {
@ -179,3 +185,32 @@ test("history saves only new checkpoints, including a changed token at the same
}
assert.deepEqual(saved, ["1:first", "2:next", "2:replacement"]);
});
for (const checkpoint of [undefined, ""]) {
test(`successful sync responses reject ${checkpoint === undefined ? "missing" : "empty"} history checkpoints`, async () => {
const service = new SyncService(
"device",
settings(),
async () =>
Response.json(
{ headEventId: 0, endEventId: 0, events: [] },
{
headers: checkpoint === undefined
? {}
: { "X-Vault-Link-History": checkpoint }
}
),
{
get: (): undefined => undefined,
save: async (): Promise<void> => {
assert.fail("must not save a missing checkpoint");
}
}
);
service.resume();
await assert.rejects(
service.getEvents(0),
/Missing server history checkpoint/
);
});
}

View file

@ -1,6 +1,6 @@
import type { DocumentId, VaultUpdateId } from "../persistence/database";
import type { Settings } from "../persistence/settings";
import { abortable } from "../utils/abortable";
import { VaultHistoryStatus } from "../types/vault-history-status";
import type { DocumentUpdateResponse } from "./types/DocumentUpdateResponse";
import type { DocumentVersionWithoutContent } from "./types/DocumentVersionWithoutContent";
import type { EventBatch } from "./types/EventBatch";
@ -31,12 +31,6 @@ export class SyncService {
this.pause();
}
public get vaultHistoryStatus(): VaultHistoryStatus {
return this.history.get() !== undefined
? VaultHistoryStatus.STORED
: VaultHistoryStatus.MISSING;
}
public pause(): void {
this.session.abort(new SyncResetError());
}
@ -57,7 +51,7 @@ export class SyncService {
return this.request("/vault-snapshot");
}
public async getEvents(after: number): Promise<EventBatch> {
public async getEvents(after: VaultUpdateId): Promise<EventBatch> {
return this.request(`/events-since?after=${after}`);
}
@ -68,13 +62,13 @@ export class SyncService {
}
public async putFileContent(
id: string,
id: DocumentId,
request: PutFileContent
): Promise<DocumentUpdateResponse> {
return this.request(`/documents/${id}`, request);
}
public async getDocumentMetadata(id: string): Promise<DocumentVersionWithoutContent> {
public async getDocumentMetadata(id: DocumentId): Promise<DocumentVersionWithoutContent> {
return this.request(`/documents/${id}/metadata`);
}
@ -82,8 +76,8 @@ export class SyncService {
documentId,
vaultUpdateId
}: {
documentId: string;
vaultUpdateId: number;
documentId: DocumentId;
vaultUpdateId: VaultUpdateId;
}): Promise<Uint8Array> {
return this.request(
`/documents/${documentId}/versions/${vaultUpdateId}/content`,
@ -95,27 +89,23 @@ export class SyncService {
private getHistoryHeaders(): Record<string, string> {
const checkpoint = this.history.get();
return checkpoint !== undefined && checkpoint !== ""
return checkpoint !== undefined
? { "X-Vault-Link-History": checkpoint }
: {};
}
private async recordHistory(response: Response): Promise<void> {
const checkpoint = response.headers.get("x-vault-link-history");
if (checkpoint === null || checkpoint === "")
throw new PermanentSyncError("Missing server history checkpoint");
const previous = this.history.get();
if (checkpoint === previous) return;
if (
checkpoint !== null &&
checkpoint !== ""
) {
const previous = this.history.get();
if (checkpoint === previous) return;
if (
previous === undefined ||
previous === "" ||
Number(checkpoint.split(":")[0]) >=
previous === undefined ||
Number(checkpoint.split(":")[0]) >=
Number(previous.split(":")[0])
)
await this.history.save(checkpoint);
}
)
await this.history.save(checkpoint);
}
private checkHistory(response: Response): void {

View file

@ -5,4 +5,4 @@ export type EventBatch = { headEventId: number,
/**
* Last event in this page; `head_event_id` is the vault's current head.
*/
endEventId?: number, events: Array<EventRecord>, };
endEventId: number, events: Array<EventRecord>, };

View file

@ -18,7 +18,7 @@ isAuthenticated: boolean,
*/
mergeableFileExtensions: Array<string>,
/**
* API version ensuring backwards & forwards compatibility between the client
* API version that must match exactly between the client
* and server.
*/
supportedApiVersion: number, };

View file

@ -199,18 +199,22 @@ describe("WebSocketManager", () => {
throw new Error("Buffer full");
};
assert.throws(() => {
manager.sendHandshakeMessage({
type: "handshake",
token: "test",
deviceId: "test"
});
});
assert.doesNotThrow(() => mockWs.onopen?.(new Event("open")));
assert.equal(manager.isWebSocketConnected, false);
assert(
(
mockLogger.warn as typeof mockLogger.warn & {
calls: [string][];
}
).calls.some(([message]) =>
message.includes("WebSocket handshake failed")
)
);
await manager.stop();
});
it("completes stop with timeout protection", async () => {
it("completes stop after retiring the socket", async () => {
const manager = new WebSocketManager(
deviceId,
mockLogger,
@ -222,7 +226,7 @@ describe("WebSocketManager", () => {
await new Promise((resolve) => setTimeout(resolve, 50));
await manager.stop();
assert.ok(true);
assert.equal(manager.isWebSocketConnected, false);
});
it("uses a secure URL, preserves the configured prefix, and encodes the vault", async () => {
@ -451,19 +455,65 @@ for (const closeBehavior of ["throws", "stays open"]) {
context.mock.timers.tick(0);
const socket = (manager as unknown as { webSocket: MockWebSocket })
.webSocket;
const stopping = manager.stop();
context.mock.timers.tick(10_000);
await stopping;
const delayedMessage = socket.onmessage;
await manager.stop(); // No close event or timer advance is needed.
assert.equal(manager.isWebSocketConnected, false);
assert.equal(connected, false);
socket.simulateMessage({
type: "vaultChanged"
});
delayedMessage?.(
new MockMessageEvent("message", {
data: JSON.stringify({ type: "vaultChanged" })
})
);
await manager.waitUntilFinished();
assert.equal(delivered, 0);
});
}
it("stop retires the socket immediately but drains messages already being handled", async (context) => {
context.mock.timers.enable({ apis: ["setTimeout"] });
const logger = new Logger();
const manager = new WebSocketManager(
"device",
logger,
new Settings(
logger,
{ remoteUri: "http://test", token: "test-token" },
async () => {}
),
MockWebSocket as unknown as typeof WebSocket
);
const release = Promise.withResolvers<void>();
context.after(async () => {
release.resolve();
await manager.stop();
});
manager.start();
context.mock.timers.tick(0);
const socket = (manager as unknown as { webSocket: MockWebSocket })
.webSocket;
assert.deepEqual(JSON.parse(socket.sentMessages[0]), {
type: "handshake",
token: "test-token",
deviceId: "device"
});
manager.onRemoteVaultUpdateReceived.add(async () => release.promise);
socket.simulateMessage({ type: "vaultChanged" });
let finished = false;
const stopping = manager.stop().then(() => {
finished = true;
});
assert.equal(manager.isWebSocketConnected, false);
assert.equal(socket.onmessage, null);
await Promise.resolve();
assert(!finished);
release.resolve();
await stopping;
assert(finished);
});
it("a handshake send failure retires the connection without escaping the open callback", async (context) => {
context.mock.timers.enable({ apis: ["setTimeout"] });
class BrokenSendSocket extends MockWebSocket {
@ -484,14 +534,6 @@ it("a handshake send failure retires the connection without escaping the open ca
settings,
BrokenSendSocket as unknown as typeof WebSocket
);
manager.onWebSocketStatusChanged.add((connected) => {
if (connected)
manager.sendHandshakeMessage({
type: "handshake",
token: "test",
deviceId: "test"
});
});
const statuses: boolean[] = [];
manager.onWebSocketStatusChanged.add((status) => statuses.push(status));
try {
@ -499,6 +541,10 @@ it("a handshake send failure retires the connection without escaping the open ca
assert.doesNotThrow(() => context.mock.timers.tick(0));
assert.equal(manager.isWebSocketConnected, false);
assert.equal(statuses.at(-1), false);
assert(
!statuses.includes(true),
"a failed handshake must never announce a connection"
);
} finally {
await manager.stop();
}

Some files were not shown because too many files have changed in this diff Show more