,,
This commit is contained in:
parent
0c75d3b4c2
commit
3dab48f58b
72 changed files with 1141 additions and 1376 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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
Loading…
Reference in a new issue