,,
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"
|
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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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 }
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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";
|
||||||
|
|
|
||||||
|
|
@ -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,9 +147,10 @@ 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.local[id]
|
current.documents[id]?.observedHash !== undefined
|
||||||
: undefined;
|
? current.local[id]
|
||||||
|
: undefined;
|
||||||
let to = next.local[id];
|
let to = next.local[id];
|
||||||
const write = writes[id];
|
const write = writes[id];
|
||||||
if (!write && (from === undefined || from === to)) continue;
|
if (!write && (from === undefined || from === to)) continue;
|
||||||
|
|
@ -182,9 +189,10 @@ export class FileOperations {
|
||||||
mutate
|
mutate
|
||||||
);
|
);
|
||||||
next.local[id] = to;
|
next.local[id] = to;
|
||||||
from = current.documents[id]?.observedHash !== undefined
|
from =
|
||||||
? current.local[id]
|
current.documents[id]?.observedHash !== undefined
|
||||||
: undefined;
|
? current.local[id]
|
||||||
|
: undefined;
|
||||||
if (from !== undefined) {
|
if (from !== undefined) {
|
||||||
await mutate(async () => {
|
await mutate(async () => {
|
||||||
current.local[id] = to;
|
current.local[id] = to;
|
||||||
|
|
@ -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 &&
|
||||||
|
|
@ -258,14 +263,17 @@ export class FileOperations {
|
||||||
const result =
|
const result =
|
||||||
current && write.expected
|
current && write.expected
|
||||||
? 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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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>;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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";
|
||||||
|
|
|
||||||
|
|
@ -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"],
|
async (next) => {
|
||||||
local: {
|
saved = structuredClone(next);
|
||||||
clean: "clean.md",
|
|
||||||
recovering: "recovering.md",
|
|
||||||
deferred: "deferred.md"
|
|
||||||
},
|
|
||||||
documents: {
|
|
||||||
clean: {
|
|
||||||
materialized: true,
|
|
||||||
observedHash: "clean",
|
|
||||||
base: { ...head, hash: "clean" }
|
|
||||||
},
|
},
|
||||||
recovering: {
|
"vault",
|
||||||
materialized: true,
|
async () => structuredClone(saved)
|
||||||
observedHash: "edited",
|
);
|
||||||
recoveryBase: { contentBase64: "T0xE", hash: "old" }
|
const db = restart();
|
||||||
},
|
assert.deepEqual(db.state, initial);
|
||||||
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)
|
|
||||||
);
|
|
||||||
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));
|
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 }
|
| { 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 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))
|
||||||
|
|
|
||||||
|
|
@ -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;
|
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
Loading…
Reference in a new issue