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

View file

@ -42,13 +42,6 @@ jobs:
toolchain: "1.92.0"
components: clippy, rustfmt
- name: Setup rust
run: |
which sqlx || cargo install sqlx-cli
cd sync-server
sqlx database create --database-url sqlite://db.sqlite3
sqlx migrate run --source src/app_state/database/migrations --database-url sqlite://db.sqlite3
- name: E2E tests
run: scripts/e2e.sh

View file

@ -19,7 +19,7 @@
- Install [`rustup`](https://rustup.rs): `curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh`
- Install [`wasm-pack`](https://rustwasm.github.io/wasm-pack/installer): `curl https://rustwasm.github.io/wasm-pack/installer/init.sh -sSf | sh`
- `cargo install cargo-insta sqlx-cli`
- `cargo install cargo-insta`
### Install Obsidian on Linux
@ -35,7 +35,6 @@ flatpak run md.obsidian.Obsidian
Start the server:
```sh
cargo install sqlx-cli
cd sync-server
cargo run config-e2e.yml
```

View file

@ -1,6 +1,6 @@
# Sync algorithm
The current API v5 server stores immutable content versions and a separately
The current API v4 server stores immutable content versions and a separately
versioned file manifest. Clients reconcile snapshots with the current local files. The server does not merge competing edits.
## Identity and ordering
@ -32,14 +32,17 @@ head is retained only until it can be applied. The observed hash records local
presence as well as rename evidence; there are no separate bootstrap or
materialization flags. Scans rebuild physical exclusions. Original server paths
remain saved while excluded, so ignoring a file cannot publish a deletion or
forget a deferred rename. Rejected payloads and messages live on the retained
requests that event replay already needs for uncertain acknowledgements.
forget a deferred rename. A permanently rejected upload pauses the whole sync
loop. Its exact pending request and rejection message remain saved across restarts;
an explicit reset or settings change retries it before reading newer remote state.
There is no separate list of rejected requests or late-receipt matching.
For text, the client compares the common base, current local bytes and current
remote bytes using `reconcile-text`. Configured extensions and valid UTF-8 without
NUL bytes determine whether text merging is allowed. Diffs optimize transport;
they do not authorize the server to merge a stale base. Large diff operation sets
are sent as snapshots.
are sent as snapshots. Snapshots remain bytes plus hashes in memory; only outgoing
wire payloads are encoded as base64.
If only one side changed, that side is retained. Concurrent unmergeable changes
keep the server version. First contact with existing local text has no shared ancestor and uses
@ -98,10 +101,13 @@ WebSocket `vaultChanged` hints wake HTTP replay. The server periodically compare
its history checkpoint to detect missed broadcasts; transport liveness checks
recover disconnected clients. Authentication and permanent errors remain visible;
transient errors retry according to client settings. Observer callback failures
are isolated from the background sync loop.
are isolated from the background sync loop. The WebSocket manager sends its own
handshake before announcing a connection. Shutdown retires its callbacks and
drains existing message handlers without waiting for a close handshake.
API v5 clients and servers must be deployed together. V4 databases migrate without
removing history. CLI and desktop Obsidian share the Node filesystem adapter and
API v4 clients and servers must be deployed together. Only the current client
state and server schema are supported; format changes require fresh state stores.
CLI and desktop Obsidian share the Node filesystem adapter and
complete-record metadata persistence. Node file moves use exclusive creation
followed by unlink, so interruption may leave two ordinary files. Metadata alone
is flushed before an atomic rename; there is no user-file durability protocol.

View file

@ -75,21 +75,13 @@ chmod +x sync_server-linux-x86_64
### Build from Source
Requirements: Rust 1.92.0+, SQLite development headers, SQLx CLI
Requirements: Rust 1.92.0+, SQLite development headers
```bash
# Clone the repository
git clone https://github.com/schmelczer/vault-link.git
cd vault-link/sync-server
# Install SQLx CLI
cargo install sqlx-cli
# Set up the database
sqlx database create --database-url sqlite://db.sqlite3
sqlx migrate run --source src/app_state/database/migrations --database-url sqlite://db.sqlite3
cargo sqlx prepare --workspace
# Build in release mode
cargo build --release

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/
);
});
}

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