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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -4,8 +4,7 @@ import type { WebSocketServerMessage } from "./types/WebSocketServerMessage";
import type { WebSocketClientMessage } from "./types/WebSocketClientMessage";
import type { CursorPositionFromClient } from "./types/CursorPositionFromClient";
import type { ClientCursors } from "./types/ClientCursors";
import { createPromise } from "../utils/create-promise";
import { WEBSOCKET_DISCONNECT_TIMEOUT_IN_S, WEBSOCKET_RECEIVE_TIMEOUT_MS } from "../consts";
import { WEBSOCKET_RECEIVE_TIMEOUT_MS } from "../consts";
import { removeFromArray } from "../utils/remove-from-array";
import { EventListeners } from "../utils/data-structures/event-listeners";
import { awaitAll } from "../utils/await-all";
@ -24,7 +23,6 @@ export class WebSocketManager {
>();
private isStopped = true;
private resolveDisconnectingPromise: null | (() => unknown) = null;
private receiveTimeoutId: ReturnType<typeof setTimeout> | undefined;
private reconnectTimeoutId: ReturnType<typeof setTimeout> | undefined;
@ -69,58 +67,10 @@ export class WebSocketManager {
}
public async stop(): Promise<void> {
const [promise, resolve] = createPromise();
this.resolveDisconnectingPromise = resolve;
this.isStopped = true;
clearTimeout(this.receiveTimeoutId);
if (this.reconnectTimeoutId !== undefined) {
clearTimeout(this.reconnectTimeoutId);
this.reconnectTimeoutId = undefined;
}
const socket = this.webSocket;
try {
socket?.close(1000, "WebSocketManager has been stopped");
} catch (error) {
this.logger.warn(`Failed to close WebSocket: ${String(error)}`);
if (socket) this.reconnect(socket);
}
// eslint-disable-next-line @typescript-eslint/init-declarations
let timeoutId: ReturnType<typeof setTimeout> | undefined;
const timeoutPromise = new Promise<void>((_, reject) => {
timeoutId = setTimeout(() => {
reject(
new Error(
`Timeout waiting for WebSocket to close after ${WEBSOCKET_DISCONNECT_TIMEOUT_IN_S} seconds`
)
);
}, WEBSOCKET_DISCONNECT_TIMEOUT_IN_S * 1000);
});
try {
while (this.isWebSocketConnected) {
await Promise.race([promise, timeoutPromise]);
}
} catch (error) {
this.logger.error(
`Error while waiting for WebSocket to close: ${String(error)}`
);
// Force cleanup even if close didn't work
this.resolveDisconnectingPromise();
this.resolveDisconnectingPromise = null;
} finally {
// Clear timeout to prevent unhandled rejection
if (timeoutId !== undefined) {
clearTimeout(timeoutId);
}
// A throwing, silent or timed-out close must still fence callbacks
// before a stopped client can release its state or restart.
if (socket) this.reconnect(socket);
}
clearTimeout(this.reconnectTimeoutId);
this.reconnectTimeoutId = undefined;
this.retireSocket();
await this.waitUntilFinished();
}
@ -128,27 +78,6 @@ export class WebSocketManager {
await awaitAll(this.outstandingPromises);
}
public sendHandshakeMessage(
message: WebSocketClientMessage & { type: "handshake" }
): void {
const { webSocket } = this;
if (!webSocket) {
throw new Error(
"WebSocket is not connected, cannot send handshake message"
);
}
try {
webSocket.send(JSON.stringify(message));
} catch (error) {
this.logger.error(
`Failed to send handshake message: ${String(error)}`
);
this.reconnect(webSocket);
throw error;
}
}
public updateLocalCursors(cursorPositions: CursorPositionFromClient): void {
if (!this.isWebSocketConnected || !this.webSocket) {
// A missing cursor update is fine, we can just skip it if needed
@ -190,30 +119,31 @@ export class WebSocketManager {
this.receiveTimeoutId.unref?.();
}
private reconnect(socket: WebSocket): void {
if (this.webSocket !== socket) return;
private retireSocket(): void {
clearTimeout(this.receiveTimeoutId);
const socket = this.webSocket;
if (!socket) return;
this.webSocket = undefined;
socket.onopen = null;
socket.onclose = null;
socket.onmessage = null;
socket.onerror = (): void => {
/* Retired transports may still emit errors. */
};
this.webSocket = undefined;
this.onWebSocketStatusChanged.trigger(false);
try {
socket.close();
} catch {
/* The transport may already be gone. */
}
if (this.isStopped) {
this.resolveDisconnectingPromise?.();
this.resolveDisconnectingPromise = null;
} else {
this.scheduleReconnect();
socket.close(1000, "WebSocket retired");
} catch (error) {
this.logger.warn(`Failed to close WebSocket: ${String(error)}`);
}
}
private reconnect(socket: WebSocket): void {
if (this.webSocket !== socket) return;
this.retireSocket();
this.scheduleReconnect();
}
private scheduleReconnect(): void {
if (this.isStopped) return;
clearTimeout(this.reconnectTimeoutId);
@ -237,23 +167,7 @@ export class WebSocketManager {
}
private openWebSocket(): void {
// Clean up old WebSocket handlers to prevent race conditions
if (this.webSocket) {
try {
// Remove handlers to prevent them from firing after new connection
this.webSocket.onopen = null;
this.webSocket.onclose = null;
this.webSocket.onmessage = null;
this.webSocket.onerror = (): void => {
/* Retired transports may still emit errors. */
};
this.webSocket.close();
} catch (e) {
this.logger.error(
`Failed to close previous WebSocket connection: ${e}`
);
}
}
this.retireSocket();
const { remoteUri, vaultName } = this.settings.getSettings();
const wsUri = new URL(remoteUri);
@ -274,19 +188,17 @@ export class WebSocketManager {
};
this.webSocket.onopen = (): void => {
// Check if we've been stopped while connecting
if (this.isStopped) {
this.webSocket?.close(
1000,
"WebSocketManager was stopped during connection"
);
return;
}
this.logger.info("WebSocket connection opened");
if (this.webSocket !== socket || this.isStopped) return;
try {
const handshake: WebSocketClientMessage = {
type: "handshake",
token: this.settings.getSettings().token,
deviceId: this.deviceId
};
socket.send(JSON.stringify(handshake));
this.logger.info("WebSocket connection opened");
this.onWebSocketStatusChanged.trigger(true);
// A handshake failure can retire this socket from inside an
// observer. Finish delivery with its actual connection state.
// An observer may stop or replace the connection during delivery.
if (this.webSocket !== socket)
this.onWebSocketStatusChanged.trigger(false);
} catch (error) {
@ -298,6 +210,7 @@ export class WebSocketManager {
};
this.webSocket.onmessage = (event): void => {
if (this.webSocket !== socket || this.isStopped) return;
try {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const message = JSON.parse(
@ -322,7 +235,7 @@ export class WebSocketManager {
);
});
void this.outstandingPromises.push(messageHandlingPromise); // ignore the returned promise
this.outstandingPromises.push(messageHandlingPromise);
} catch (error) {
this.logger.error(
`Error parsing WebSocket message: ${String(error)}`

View file

@ -1,4 +1,3 @@
import { base64ToBytes, bytesToBase64 } from "byte-base64";
import type { CursorPosition } from "reconcile-text";
import { hash } from "./utils/hash";
@ -8,25 +7,14 @@ export interface FileSnapshot {
cursors?: CursorPosition[];
}
export interface StoredSnapshot {
contentBase64: string;
export interface HashedSnapshot extends FileSnapshot {
hash: string;
cursors?: CursorPosition[];
}
export async function toStoredSnapshot(
/** Own the bytes so later adapter mutations cannot invalidate the hash. */
export async function hashSnapshot(
snapshot: FileSnapshot
): Promise<StoredSnapshot> {
return {
contentBase64: bytesToBase64(snapshot.content),
cursors: snapshot.cursors,
hash: await hash(snapshot.content)
};
}
export function fromStoredSnapshot(snapshot: StoredSnapshot): FileSnapshot {
return {
content: base64ToBytes(snapshot.contentBase64),
cursors: snapshot.cursors
};
): Promise<HashedSnapshot> {
const content = new Uint8Array(snapshot.content);
return { content, cursors: snapshot.cursors, hash: await hash(content) };
}

View file

@ -1,7 +1,11 @@
import * as Sentry from "@sentry/browser";
import { setUpTelemetry } from "./utils/set-up-telemetry";
import type { MetadataPersistenceProvider } from "./persistence/metadata-persistence-provider";
import { Database, type StoredDatabase } from "./persistence/database";
import {
Database,
type RelativePath,
type StoredDatabase
} from "./persistence/database";
import {
Settings,
DEFAULT_SETTINGS,
@ -16,7 +20,7 @@ import { ServerConfig } from "./services/server-config";
import { WebSocketManager } from "./services/websocket-manager";
import { Syncer } from "./sync-operations/syncer";
import { CursorTracker } from "./sync-operations/cursor-tracker";
import { FileChangeNotifier } from "./sync-operations/file-change-notifier";
import { EventListeners } from "./utils/data-structures/event-listeners";
import { createClientId } from "./utils/create-client-id";
import { DocumentSyncStatus } from "./types/document-sync-status";
import type { CursorSpan } from "./services/types/CursorSpan";
@ -30,11 +34,8 @@ import {
export type StoredClient = Partial<{
settings: Partial<SyncSettings>;
database: Partial<StoredDatabase>;
localChanges: (
| LocalChange
| { type: "update"; path: string; changeId?: string }
)[];
database: StoredDatabase;
localChanges: LocalChange[];
localChangesVaultKey: string;
historyCheckpoint: { vaultKey: string; checkpoint?: string };
}>;
@ -62,7 +63,9 @@ export class SyncClient {
private readonly service: SyncService,
private readonly serverConfig: ServerConfig,
private readonly cursorTracker: CursorTracker,
private readonly fileChangeNotifier: FileChangeNotifier,
private readonly fileChanges: EventListeners<
(path: RelativePath) => unknown
>,
private readonly contentCache: FixedSizeDocumentCache,
private readonly persistence: MetadataPersistenceProvider<StoredClient>
) {
@ -96,12 +99,12 @@ export class SyncClient {
}): Promise<SyncClient> {
const logger = new Logger();
let stored = (await persistence.load()) ?? {};
let saving: Promise<void> = Promise.resolve();
const persistenceLock = new Lock();
let reloadBeforeSave = false;
// Settings and engine state share a store. Serialize complete metadata
// replacements so one cannot overwrite a newer save from the other.
const save = async (update: StoredClient): Promise<void> => {
const operation = saving.then(async () => {
const save = async (update: StoredClient): Promise<void> =>
persistenceLock.withLock(async () => {
if (reloadBeforeSave) {
stored = (await persistence.load()) ?? {};
reloadBeforeSave = false;
@ -117,9 +120,6 @@ export class SyncClient {
throw error;
}
});
saving = operation.catch(() => { });
return operation;
};
const settings = new Settings(logger, stored.settings, async (data) =>
save({ settings: data })
);
@ -127,19 +127,17 @@ export class SyncClient {
stored.database,
async (data) => save({ database: data }),
vaultKey(settings.getSettings()),
async () => {
await saving;
stored = (await persistence.load()) ?? {};
return stored.database as StoredDatabase | undefined;
}
async () =>
persistenceLock.withLock(async () => {
stored = (await persistence.load()) ?? {};
reloadBeforeSave = false;
return stored.database;
})
);
// Older stores also queued content updates. Trim the acknowledged prefix
// before discarding them, since its marker may belong to an update.
const localChanges = unappliedChanges(
database.state,
stored.localChanges ?? []
// eslint-disable-next-line no-restricted-syntax -- Migration needs a narrowed copy, not removal of a specific object.
).filter((change) => change.type !== "update");
);
if (
localChanges.length &&
stored.localChangesVaultKey !== vaultKey(settings.getSettings())
@ -152,7 +150,7 @@ export class SyncClient {
const service = new SyncService(deviceId, settings, fetch, {
get: () =>
stored.historyCheckpoint?.vaultKey ===
vaultKey(settings.getSettings())
vaultKey(settings.getSettings())
? stored.historyCheckpoint.checkpoint
: undefined,
save: async (checkpoint) =>
@ -164,18 +162,17 @@ export class SyncClient {
})
});
const serverConfig = new ServerConfig(service);
const notifier = new FileChangeNotifier();
let syncer: Syncer;
const notifier = new EventListeners<(path: RelativePath) => unknown>();
const files = new FileOperations(
fs,
database,
serverConfig,
(paths) => {
for (const path of paths) notifier.notifyOfFileChange(path);
for (const path of paths) notifier.trigger(path);
},
{
entries: () => localChanges,
flush: async () => syncer?.flushLocalChanges()
flush: async () => syncer.flushLocalChanges()
},
(path, size) =>
settings.isIgnored(path) || settings.isOversized(size)
@ -190,8 +187,7 @@ export class SyncClient {
const contentCache = new FixedSizeDocumentCache(
settings.getSettings().diffCacheSizeMB * 1024 * 1024
);
syncer = new Syncer(
deviceId,
const syncer: Syncer = new Syncer(
logger,
database,
settings,
@ -380,23 +376,23 @@ export class SyncClient {
}
/** Notifications enqueue work. Use waitUntilFinished() to await convergence. */
public async syncLocallyCreatedFile(path: string): Promise<void> {
public async syncLocallyCreatedFile(path: RelativePath): Promise<void> {
this.check();
if (isInternalPath(path)) return;
this.fileChangeNotifier.notifyOfFileChange(path);
this.fileChanges.trigger(path);
await this.syncer.syncLocallyCreatedFile(path);
}
public async syncLocallyDeletedFile(path: string): Promise<void> {
public async syncLocallyDeletedFile(path: RelativePath): Promise<void> {
this.check();
if (isInternalPath(path)) return;
this.fileChangeNotifier.notifyOfFileChange(path);
this.fileChanges.trigger(path);
await this.syncer.syncLocallyDeletedFile(path);
}
public async syncLocallyUpdatedFile(change: {
oldPath?: string;
relativePath: string;
oldPath?: RelativePath;
relativePath: RelativePath;
}): Promise<void> {
this.check();
if (
@ -404,18 +400,18 @@ export class SyncClient {
(change.oldPath && isInternalPath(change.oldPath))
)
return;
this.fileChangeNotifier.notifyOfFileChange(change.relativePath);
this.fileChanges.trigger(change.relativePath);
await this.syncer.syncLocallyUpdatedFile(change);
}
public async updateLocalCursors(
cursors: Record<string, CursorSpan[]>
cursors: Record<RelativePath, CursorSpan[]>
): Promise<void> {
this.check();
await this.cursorTracker.sendLocalCursorsToServer(cursors);
}
public getDocumentSyncingStatus(path: string): DocumentSyncStatus {
public getDocumentSyncingStatus(path: RelativePath): DocumentSyncStatus {
this.check();
if (!this.settings.getSettings().isSyncEnabled)
return DocumentSyncStatus.SYNCING_IS_DISABLED;

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