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" toolchain: "1.92.0"
components: clippy, rustfmt 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 - name: E2E tests
run: scripts/e2e.sh 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 [`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` - 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 ### Install Obsidian on Linux
@ -35,7 +35,6 @@ flatpak run md.obsidian.Obsidian
Start the server: Start the server:
```sh ```sh
cargo install sqlx-cli
cd sync-server cd sync-server
cargo run config-e2e.yml cargo run config-e2e.yml
``` ```

View file

@ -1,6 +1,6 @@
# Sync algorithm # 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. versioned file manifest. Clients reconcile snapshots with the current local files. The server does not merge competing edits.
## Identity and ordering ## 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 presence as well as rename evidence; there are no separate bootstrap or
materialization flags. Scans rebuild physical exclusions. Original server paths materialization flags. Scans rebuild physical exclusions. Original server paths
remain saved while excluded, so ignoring a file cannot publish a deletion or 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 forget a deferred rename. A permanently rejected upload pauses the whole sync
requests that event replay already needs for uncertain acknowledgements. 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 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 remote bytes using `reconcile-text`. Configured extensions and valid UTF-8 without
NUL bytes determine whether text merging is allowed. Diffs optimize transport; 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 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 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 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 its history checkpoint to detect missed broadcasts; transport liveness checks
recover disconnected clients. Authentication and permanent errors remain visible; recover disconnected clients. Authentication and permanent errors remain visible;
transient errors retry according to client settings. Observer callback failures 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 API v4 clients and servers must be deployed together. Only the current client
removing history. CLI and desktop Obsidian share the Node filesystem adapter and 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 complete-record metadata persistence. Node file moves use exclusive creation
followed by unlink, so interruption may leave two ordinary files. Metadata alone 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. 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 ### Build from Source
Requirements: Rust 1.92.0+, SQLite development headers, SQLx CLI Requirements: Rust 1.92.0+, SQLite development headers
```bash ```bash
# Clone the repository # Clone the repository
git clone https://github.com/schmelczer/vault-link.git git clone https://github.com/schmelczer/vault-link.git
cd vault-link/sync-server 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 # Build in release mode
cargo build --release 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 The complete client record (database, settings, queued logical notifications and
server history checkpoint) is saved atomically in `.vaultlink/sync-data.json`. server history checkpoint) is saved atomically in `.vaultlink/sync-data.json`.
Existing database-only files migrate on the next save. Invalid or unreadable Only this complete-record format is supported; database-only files are not
metadata is reported instead of silently starting with empty state. 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 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. 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 { ClientPersistence } from "./client-persistence";
import type { StoredClient } from "sync-client"; 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( const directory = await fs.mkdtemp(
path.join(tmpdir(), "vaultlink-metadata-") path.join(tmpdir(), "vaultlink-metadata-")
); );
@ -18,7 +18,13 @@ test("CLI persistence retains the whole record and migrates database-only saves"
}); });
const record: StoredClient = { const record: StoredClient = {
settings: { token: "old-token", vaultName: "vault" }, settings: { token: "old-token", vaultName: "vault" },
database: { lastSeenUpdateId: 12 }, database: {
vaultKey: "vault",
fileManifest: { fileManifestId: 0, entries: {} },
local: {},
documents: {},
lastSeenUpdateId: 12
},
localChanges: [ localChanges: [
{ type: "delete", path: "removed.md", changeId: "queued" } { type: "delete", path: "removed.md", changeId: "queued" }
], ],
@ -30,12 +36,6 @@ test("CLI persistence retains the whole record and migrates database-only saves"
...record, ...record,
settings: { ...record.settings, token: "current-token" } 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 fs.writeFile(file, "{truncated");
await assert.rejects(store.load(), SyntaxError); await assert.rejects(store.load(), SyntaxError);
await fs.rm(file); await fs.rm(file);

View file

@ -4,7 +4,6 @@ import { randomUUID } from "node:crypto";
import type { import type {
PersistenceProvider, PersistenceProvider,
StoredClient, StoredClient,
StoredDatabase,
SyncSettings SyncSettings
} from "sync-client"; } from "sync-client";
@ -35,11 +34,7 @@ export class ClientPersistence implements PersistenceProvider<StoredClient> {
Array.isArray(parsed) Array.isArray(parsed)
) )
throw new Error("Invalid client metadata"); throw new Error("Invalid client metadata");
// The sync engine validates and migrates the persisted fields. const stored = parsed as StoredClient;
const data = parsed as StoredClient & Partial<StoredDatabase>;
// Earlier CLI versions saved the database alone.
const stored: StoredClient =
"fileManifest" in data ? { database: data } : data;
return { return {
...stored, ...stored,
settings: { ...stored.settings, ...this.settings } 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 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 `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. authentication and protocol errors wait for an explicit wake or settings change.
Pausing or destroying the client aborts its HTTP session, including body reads; 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. 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 ## 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 incorporated cursor; pagination progress is not persisted. Intermediate changes
that the server has already reverted must not overwrite local edits. 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 A permanent upload rejection pauses all sync, including after restart. The exact
failure remains visible in history and `waitUntilFinished()`. Editing it, resetting pending request and rejection message stay in metadata, and the failure remains
the client, or changing settings permits a fresh attempt. Earlier attempts that visible in history and `waitUntilFinished()`. Resetting the client or changing
committed despite a lost reply are incorporated through normal event replay. 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 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 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. - 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 - Text uses three-way `reconcile`. First contact at a matching path adopts the
remote ID and reconciles differing text with an empty parent. 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 - Concurrent binary/unmergeable edits keep the server version. If only one side
changed, keep that side. No local backup archive is created. changed, keep that side. No local backup archive is created.
- Deletion follows the file manifest decision, including when the file has local edits. - 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 document; recovery never persists another copy of its content. Unsent edits with
an old base become separate local documents; path conflicts preserve them under an old base become separate local documents; path conflicts preserve them under
conflict names instead of applying a diff to an unrelated restored version. conflict names instead of applying a diff to an unrelated restored version.
Recovery resumes after interrupted state saves. Existing API v4 client state Recovery resumes after interrupted state saves. Only the current persisted state
without a checkpoint takes this same conservative bootstrap on upgrade. format is supported; old client stores are not converted.
Status observers cannot stop the background engine by throwing or rejecting. Status observers cannot stop the background engine by throwing or rejecting.
Their failures are reported, and remaining observers continue receiving updates. 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 ## 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; replacement. Unexpected eligible occupants receive visible conflict names;
ignored and oversized occupants stay in place. External writers can still change ignored and oversized occupants stay in place. External writers can still change
files between observations, so these checks do not promise transactional file 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_LOG_MESSAGE_COUNT = 100000;
export const MAX_HISTORY_ENTRY_COUNT = 5000; export const MAX_HISTORY_ENTRY_COUNT = 5000;
export const SUPPORTED_API_VERSION = 4; export const SUPPORTED_API_VERSION = 4;
export const WEBSOCKET_DISCONNECT_TIMEOUT_IN_S = 10;
export const CURSOR_HEARTBEAT_INTERVAL_MS = 15_000; export const CURSOR_HEARTBEAT_INTERVAL_MS = 15_000;
export const WEBSOCKET_RECEIVE_TIMEOUT_MS = CURSOR_HEARTBEAT_INTERVAL_MS * 3; 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 { export class SyncResetError extends Error {
public constructor() { public constructor() {
super("SyncClient has been reset, cleaning up"); super("SyncClient has been reset, cleaning up");
@ -22,7 +24,7 @@ export class AuthenticationError extends Error {
export class FileNotFoundError extends Error { export class FileNotFoundError extends Error {
public constructor( public constructor(
message: string, message: string,
public readonly filePath: string public readonly filePath: RelativePath
) { ) {
super(message); super(message);
this.name = "FileNotFoundError"; this.name = "FileNotFoundError";

View file

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

View file

@ -1,22 +1,22 @@
import { v4 as uuid } from "uuid"; import { v4 as uuid } from "uuid";
import type { FileSystemOperations } from "./filesystem-operations"; 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"; import { allocatePortablePath, isInternalPath } from "../utils/portable-path";
/** Resolve occupied rename destinations without discarding another file. */ /** Resolve occupied rename destinations without discarding another file. */
export class FilePaths { export class FilePaths {
public constructor( public constructor(
private readonly fs: FileSystemOperations, private readonly fs: FileSystemOperations,
private readonly isProtectedFile: (path: string, size: number) => boolean private readonly isProtectedFile: (path: RelativePath, size: number) => boolean
) { } ) { }
public async prepareDestination( public async prepareDestination(
next: EngineState, next: EngineState,
current: EngineState, current: EngineState,
id: string, id: DocumentId,
path: string, path: RelativePath,
mutate: (action: () => Promise<void>) => Promise<void> mutate: (action: () => Promise<void>) => Promise<void>
): Promise<string> { ): Promise<RelativePath> {
try { try {
for (; ;) { for (; ;) {
const obstruction = await this.obstruction(path); const obstruction = await this.obstruction(path);
@ -97,8 +97,8 @@ export class FilePaths {
} }
private async obstruction( private async obstruction(
path: string path: RelativePath
): Promise<{ path: string; kind: "file" | "directory" } | undefined> { ): Promise<{ path: RelativePath; kind: "file" | "directory" } | undefined> {
const parts = path.split("/"); const parts = path.split("/");
for (let i = 0; i < parts.length; i++) { for (let i = 0; i < parts.length; i++) {
const prefix = parts.slice(0, i + 1).join("/"); const prefix = parts.slice(0, i + 1).join("/");
@ -110,9 +110,9 @@ export class FilePaths {
private async conflictPath( private async conflictPath(
next: EngineState, next: EngineState,
id: string, id: DocumentId,
path: string path: RelativePath
): Promise<string> { ): Promise<RelativePath> {
const occupied = { ...next.local }; const occupied = { ...next.local };
delete occupied[id]; delete occupied[id];
const files = (await this.fs.listFilesRecursively()).filter( const files = (await this.fs.listFilesRecursively()).filter(
@ -123,12 +123,12 @@ export class FilePaths {
return allocatePortablePath(path, id, occupied); 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("/"); const parent = path.split("/").slice(0, -1).join("/");
if (parent) await this.fs.createDirectory(parent); 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("/"); let parent = path.split("/").slice(0, -1).join("/");
while (parent && !isInternalPath(parent)) { while (parent && !isInternalPath(parent)) {
if ((await this.fs.listFilesRecursively(parent)).length) break; 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. * A successful scan is complete: unreadable directories must reject, not vanish.
*/ */
export interface FileSystemOperations { export interface FileSystemOperations {
listFilesRecursively: (root?: string) => Promise<string[]>; listFilesRecursively: (root?: RelativePath) => Promise<RelativePath[]>;
/** Return undefined on missing file */ /** 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. */ /** Reject symlinks and special files rather than following them and return undefined. */
stat: ( stat: (
path: string path: RelativePath
) => Promise<{ kind: "file" | "directory"; size: number } | undefined>; ) => Promise<{ kind: "file" | "directory"; size: number } | undefined>;
/** Includes directories. */ /** Includes directories. */
exists: (path: string) => Promise<boolean>; exists: (path: RelativePath) => Promise<boolean>;
/** Recursive, idempotent directory creation. */ /** Recursive, idempotent directory creation. */
createDirectory: (path: string) => Promise<void>; createDirectory: (path: RelativePath) => Promise<void>;
/** Create or overwrite a file, applying optional editor cursor metadata. */ /** 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. // Atomically update the content of a text file.
atomicUpdateText: ( atomicUpdateText: (
@ -34,11 +34,11 @@ export interface FileSystemOperations {
) => Promise<string>; ) => Promise<string>;
/** Move a file without replacing an existing destination. */ /** 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. */ /** 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. */ /** 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 { Logger, LogLevel, LogLine } from "./tracing/logger";
export { type SyncSettings, DEFAULT_SETTINGS } from "./persistence/settings"; export { type SyncSettings, DEFAULT_SETTINGS } from "./persistence/settings";
export { rateLimit } from "./utils/rate-limit"; 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 { FileSystemOperations } from "./file-operations/filesystem-operations";
export type { FileSnapshot } from "./snapshot"; export type { FileSnapshot } from "./snapshot";
export type { MetadataPersistenceProvider as PersistenceProvider } from "./persistence/metadata-persistence-provider"; export type { MetadataPersistenceProvider as PersistenceProvider } from "./persistence/metadata-persistence-provider";

View file

@ -1,83 +1,38 @@
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { test } from "node:test"; 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 () => { test("current state keeps absent optional fields absent across save and reload", async () => {
const head = { const initial = emptyState("vault");
documentId: "clean", initial.local = { local: "local.md", remote: "remote.md" };
vaultUpdateId: 1, initial.documents = {
contentSize: 3, local: { observedHash: "local" },
updatedDate: "2026-09-23", remote: { remote: { vaultUpdateId: 1, contentSize: 3 } },
userId: "user", empty: {}
deviceId: "device"
}; };
const legacy = { let saved = structuredClone(initial);
...emptyState("vault"), const restart = (): Database =>
initialized: true, new Database(
lastSeenUpdateId: 2, saved,
protectedPaths: ["ignored.md"],
local: {
clean: "clean.md",
recovering: "recovering.md",
deferred: "deferred.md"
},
documents: {
clean: {
materialized: true,
observedHash: "clean",
base: { ...head, hash: "clean" }
},
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) => { async (next) => {
saves.push(next); saved = structuredClone(next);
}, },
"vault", "vault",
async () => saves.at(-1) async () => structuredClone(saved)
); );
const db = restart();
assert.deepEqual(db.state, initial);
await db.save(); await db.save();
const saved = saves.at(-1); assert.deepEqual(saved, initial);
assert(saved); assert.deepEqual(restart().state, initial);
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"));
}); });
test("old reset intent leaves the cursor absent after an uncertain save reload", async () => { test("current reset state is restored after an uncertain save", async () => {
const legacy = { const saved = emptyState("vault");
...emptyState("vault"), saved.local = { note: "note.md" };
initialized: false, saved.documents.note = {
historyRecovery: true, base: { vaultUpdateId: null, hash: "clean" },
lastSeenUpdateId: 0 observedHash: "clean"
}; };
const db = new Database( const db = new Database(
undefined, undefined,
@ -85,10 +40,9 @@ test("old reset intent leaves the cursor absent after an uncertain save reload",
throw new Error("interrupted"); throw new Error("interrupted");
}, },
"vault", "vault",
async () => legacy async () => structuredClone(saved)
); );
await assert.rejects(db.save(), /interrupted/); await assert.rejects(db.save(), /interrupted/);
await db.recoverPersistence(); await db.recoverPersistence();
assert.equal(db.state.lastSeenUpdateId, undefined); assert.deepEqual(db.state, saved);
assert(!("historyRecovery" in db.state));
}); });

View file

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

View file

@ -1,21 +1,14 @@
import type { PutFileContent } from "../services/types/PutFileContent"; import type { PutFileContent } from "../services/types/PutFileContent";
import type { PushFileManifest } from "../services/types/PushFileManifest"; 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. */ /** Exact wire payload retained until acknowledgement, including after rejection. */
export type PendingRequest = export type PendingRequest = { rejection?: string } & (
| { type: "content"; documentId: string; request: PutFileContent; hash: string } | {
| { type: "fileManifest"; request: PushFileManifest }; type: "content";
documentId: DocumentId;
/** Enough to recognize a late receipt or suppress an unchanged rejected submission. */ request: PutFileContent;
export type UnconfirmedRequest = { requestId: string; rejection?: string } & ( hash: string;
| { type: "content"; documentId: string; hash: string }
| { type: "fileManifest"; entries: FileManifestEntries }
);
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 };
} }
| { type: "fileManifest"; request: PushFileManifest }
);

View file

@ -1,3 +1,4 @@
import type { RelativePath } from "./database";
import type { Logger } from "../tracing/logger"; import type { Logger } from "../tracing/logger";
import { globsToRegexes } from "../utils/globs-to-regexes"; import { globsToRegexes } from "../utils/globs-to-regexes";
import { isInternalPath } from "../utils/portable-path"; import { isInternalPath } from "../utils/portable-path";
@ -68,7 +69,7 @@ export class Settings {
); );
} }
public isIgnored(path: string): boolean { public isIgnored(path: RelativePath): boolean {
return ( return (
isInternalPath(path) || isInternalPath(path) ||
this.ignorePatterns.some((pattern) => pattern.test(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; let requests = 0;
const service = new SyncService("device", settings(), async () => { const service = new SyncService("device", settings(), async () => {
requests++; 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 () => { } }); }, { get: () => undefined, save: async () => { } });
await assert.rejects(service.getEvents(0), SyncResetError); await assert.rejects(service.getEvents(0), SyncResetError);
assert.equal(requests, 0); 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!); signals.push(init!.signal!);
return signals.length === 1 return signals.length === 1
? response.promise ? response.promise
: Response.json({ headEventId: 0, events: [] }); : Response.json(
{ headEventId: 0, endEventId: 0, events: [] },
{ headers: { "X-Vault-Link-History": "0:empty" } }
);
}, },
{ {
get: () => undefined, 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)); await new Promise((resolve) => setImmediate(resolve));
assert.deepEqual(saved, []); assert.deepEqual(saved, ["0:empty"]);
}); });
for (const binary of [false, true]) { 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"]); 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