,,
This commit is contained in:
parent
0c75d3b4c2
commit
3dab48f58b
38 changed files with 1141 additions and 1376 deletions
|
|
@ -1,4 +1,4 @@
|
|||
# Sync client (API v5)
|
||||
# Sync client (API v4)
|
||||
|
||||
The engine uses one serialized loop per vault and requires Web Crypto (SHA-256). Local notifications and WebSocket
|
||||
`vaultChanged` hints wake it; complete disk scans and the durable server event log are the
|
||||
|
|
@ -17,6 +17,8 @@ pending requests saved and are reported to the caller. Transient failures retry;
|
|||
authentication and protocol errors wait for an explicit wake or settings change.
|
||||
Pausing or destroying the client aborts its HTTP session, including body reads;
|
||||
resuming starts a fresh session. Connection checks can run while sync is disabled.
|
||||
WebSocket shutdown retires callbacks immediately and drains message handlers
|
||||
already running; it does not wait for the transport's close handshake.
|
||||
|
||||
## State and policy
|
||||
|
||||
|
|
@ -38,10 +40,13 @@ and content heads before merging. An interrupted fetch restarts from the last
|
|||
incorporated cursor; pagination progress is not persisted. Intermediate changes
|
||||
that the server has already reverted must not overwrite local edits.
|
||||
|
||||
A permanent upload rejection is recorded in metadata. An unchanged rejected payload is skipped so other files can sync; the
|
||||
failure remains visible in history and `waitUntilFinished()`. Editing it, resetting
|
||||
the client, or changing settings permits a fresh attempt. Earlier attempts that
|
||||
committed despite a lost reply are incorporated through normal event replay.
|
||||
A permanent upload rejection pauses all sync, including after restart. The exact
|
||||
pending request and rejection message stay in metadata, and the failure remains
|
||||
visible in history and `waitUntilFinished()`. Resetting the client or changing
|
||||
settings retries that request before reading newer remote changes. This recovers
|
||||
earlier attempts that committed despite a lost reply without separate receipt
|
||||
bookkeeping. Local edits made while paused are uploaded after the pending request
|
||||
is resolved; editing a file alone does not retry a rejected request.
|
||||
|
||||
For each document ID (absence means deletion), use the server path if local is
|
||||
unchanged, use local if the server is unchanged, and use the server when both
|
||||
|
|
@ -57,6 +62,8 @@ are implicit; empty directories are not synced.
|
|||
- Content is uploaded before new membership; the client chooses and persists UUIDs.
|
||||
- Text uses three-way `reconcile`. First contact at a matching path adopts the
|
||||
remote ID and reconciles differing text with an empty parent.
|
||||
- In-memory snapshots contain bytes and their hash. Base64 encoding is limited to
|
||||
wire payloads; pending requests persist the exact payload needed for retries.
|
||||
- Concurrent binary/unmergeable edits keep the server version. If only one side
|
||||
changed, keep that side. No local backup archive is created.
|
||||
- Deletion follows the file manifest decision, including when the file has local edits.
|
||||
|
|
@ -84,11 +91,13 @@ snapshot. A file edited after this reset is also preserved as a separate local
|
|||
document; recovery never persists another copy of its content. Unsent edits with
|
||||
an old base become separate local documents; path conflicts preserve them under
|
||||
conflict names instead of applying a diff to an unrelated restored version.
|
||||
Recovery resumes after interrupted state saves. Existing API v4 client state
|
||||
without a checkpoint takes this same conservative bootstrap on upgrade.
|
||||
Recovery resumes after interrupted state saves. Only the current persisted state
|
||||
format is supported; old client stores are not converted.
|
||||
|
||||
Status observers cannot stop the background engine by throwing or rejecting.
|
||||
Their failures are reported, and remaining observers continue receiving updates.
|
||||
Remote cursor tracking retains the latest future update and the last usable
|
||||
position for each device, so cursors remain visible while file content catches up.
|
||||
|
||||
## Required adapter contract
|
||||
|
||||
|
|
@ -129,4 +138,4 @@ between files using their document identities; the next scan rereads content. Co
|
|||
replacement. Unexpected eligible occupants receive visible conflict names;
|
||||
ignored and oversized occupants stay in place. External writers can still change
|
||||
files between observations, so these checks do not promise transactional file
|
||||
contents or perfect identity reconstruction. There is no automated v2/v3 state migration.
|
||||
contents or perfect identity reconstruction. There is no persisted-state migration.
|
||||
|
|
|
|||
|
|
@ -2,6 +2,5 @@ export const TIMEOUT_FOR_MERGING_HISTORY_ENTRIES_IN_SECONDS = 60;
|
|||
export const MAX_LOG_MESSAGE_COUNT = 100000;
|
||||
export const MAX_HISTORY_ENTRY_COUNT = 5000;
|
||||
export const SUPPORTED_API_VERSION = 4;
|
||||
export const WEBSOCKET_DISCONNECT_TIMEOUT_IN_S = 10;
|
||||
export const CURSOR_HEARTBEAT_INTERVAL_MS = 15_000;
|
||||
export const WEBSOCKET_RECEIVE_TIMEOUT_MS = CURSOR_HEARTBEAT_INTERVAL_MS * 3;
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import type { RelativePath } from "../persistence/database";
|
||||
|
||||
export class SyncResetError extends Error {
|
||||
public constructor() {
|
||||
super("SyncClient has been reset, cleaning up");
|
||||
|
|
@ -22,7 +24,7 @@ export class AuthenticationError extends Error {
|
|||
export class FileNotFoundError extends Error {
|
||||
public constructor(
|
||||
message: string,
|
||||
public readonly filePath: string
|
||||
public readonly filePath: RelativePath
|
||||
) {
|
||||
super(message);
|
||||
this.name = "FileNotFoundError";
|
||||
|
|
|
|||
|
|
@ -1,11 +1,15 @@
|
|||
import { reconcile } from "reconcile-text";
|
||||
import type { FileSystemOperations } from "./filesystem-operations";
|
||||
import type { Database, EngineState } from "../persistence/database";
|
||||
import type {
|
||||
Database,
|
||||
DocumentId,
|
||||
EngineState,
|
||||
RelativePath
|
||||
} from "../persistence/database";
|
||||
import {
|
||||
fromStoredSnapshot,
|
||||
toStoredSnapshot,
|
||||
hashSnapshot,
|
||||
type FileSnapshot,
|
||||
type StoredSnapshot
|
||||
type HashedSnapshot
|
||||
} from "../snapshot";
|
||||
import { isInternalPath } from "../utils/portable-path";
|
||||
import { FilePaths } from "./file-paths";
|
||||
|
|
@ -25,8 +29,8 @@ import {
|
|||
} from "../errors/errors";
|
||||
|
||||
export interface FileWrite {
|
||||
expected?: StoredSnapshot;
|
||||
replacement: StoredSnapshot;
|
||||
expected?: HashedSnapshot;
|
||||
replacement: HashedSnapshot;
|
||||
}
|
||||
|
||||
type Mutation = (action: () => Promise<void>) => Promise<void>;
|
||||
|
|
@ -42,7 +46,7 @@ export class FileOperations {
|
|||
private readonly serverConfig: ServerConfig,
|
||||
|
||||
private readonly filesApplied: (
|
||||
paths: readonly string[]
|
||||
paths: readonly RelativePath[]
|
||||
) => void = () => undefined,
|
||||
|
||||
private readonly localChanges: {
|
||||
|
|
@ -51,14 +55,14 @@ export class FileOperations {
|
|||
} = { entries: () => [], flush: async () => undefined },
|
||||
|
||||
private readonly isProtectedFile: (
|
||||
path: string,
|
||||
path: RelativePath,
|
||||
size: number
|
||||
) => boolean = () => false
|
||||
) {
|
||||
this.paths = new FilePaths(fs, isProtectedFile);
|
||||
}
|
||||
|
||||
public async read(path: string): Promise<Uint8Array> {
|
||||
public async read(path: RelativePath): Promise<Uint8Array> {
|
||||
const snapshot = await this.fs.read(path);
|
||||
|
||||
if (!snapshot)
|
||||
|
|
@ -67,18 +71,19 @@ export class FileOperations {
|
|||
return snapshot.content;
|
||||
}
|
||||
|
||||
public async snapshot(path: string): Promise<StoredSnapshot | undefined> {
|
||||
public async snapshot(
|
||||
path: RelativePath
|
||||
): Promise<HashedSnapshot | undefined> {
|
||||
const snapshot = await this.fs.read(path);
|
||||
return snapshot && toStoredSnapshot(snapshot);
|
||||
return snapshot && hashSnapshot(snapshot);
|
||||
}
|
||||
|
||||
public async listFilesRecursively(): Promise<string[]> {
|
||||
public async listFilesRecursively(): Promise<RelativePath[]> {
|
||||
return (await this.fs.listFilesRecursively()).filter(
|
||||
(path) => !isInternalPath(path)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
public async captureChange(
|
||||
change: LocalChange,
|
||||
previous: readonly LocalChange[]
|
||||
|
|
@ -95,7 +100,7 @@ export class FileOperations {
|
|||
|
||||
public async apply(
|
||||
next: EngineState,
|
||||
writes: Partial<Record<string, FileWrite>> = {},
|
||||
writes: Partial<Record<DocumentId, FileWrite>> = {},
|
||||
validatePlan: () => void = () => undefined
|
||||
): Promise<void> {
|
||||
await this.localChanges.flush();
|
||||
|
|
@ -116,7 +121,7 @@ export class FileOperations {
|
|||
const current = structuredClone(this.database.state);
|
||||
current.lastSeenUpdateId = next.lastSeenUpdateId;
|
||||
this.applying = current;
|
||||
const paths = new Set<string>();
|
||||
const paths = new Set<RelativePath>();
|
||||
const observeChanges = async (): Promise<void> => {
|
||||
await this.localChanges.flush();
|
||||
guard();
|
||||
|
|
@ -129,7 +134,8 @@ export class FileOperations {
|
|||
state,
|
||||
change,
|
||||
(path) =>
|
||||
isInternalPath(path) || this.isProtectedFile(path, 0)
|
||||
isInternalPath(path) ||
|
||||
this.isProtectedFile(path, 0)
|
||||
);
|
||||
state.lastAppliedLocalChangeId = change.changeId;
|
||||
}
|
||||
|
|
@ -141,9 +147,10 @@ export class FileOperations {
|
|||
...Object.keys(next.local)
|
||||
])) {
|
||||
await observeChanges();
|
||||
let from = current.documents[id]?.observedHash !== undefined
|
||||
? current.local[id]
|
||||
: undefined;
|
||||
let from =
|
||||
current.documents[id]?.observedHash !== undefined
|
||||
? current.local[id]
|
||||
: undefined;
|
||||
let to = next.local[id];
|
||||
const write = writes[id];
|
||||
if (!write && (from === undefined || from === to)) continue;
|
||||
|
|
@ -182,9 +189,10 @@ export class FileOperations {
|
|||
mutate
|
||||
);
|
||||
next.local[id] = to;
|
||||
from = current.documents[id]?.observedHash !== undefined
|
||||
? current.local[id]
|
||||
: undefined;
|
||||
from =
|
||||
current.documents[id]?.observedHash !== undefined
|
||||
? current.local[id]
|
||||
: undefined;
|
||||
if (from !== undefined) {
|
||||
await mutate(async () => {
|
||||
current.local[id] = to;
|
||||
|
|
@ -195,9 +203,7 @@ export class FileOperations {
|
|||
}
|
||||
if (write) {
|
||||
const snapshot =
|
||||
from === undefined
|
||||
? undefined
|
||||
: await this.fs.read(to);
|
||||
from === undefined ? undefined : await this.fs.read(to);
|
||||
if (from !== undefined && !snapshot) {
|
||||
delete next.local[id];
|
||||
continue;
|
||||
|
|
@ -229,13 +235,12 @@ export class FileOperations {
|
|||
}
|
||||
|
||||
private async updateContent(
|
||||
path: string,
|
||||
path: RelativePath,
|
||||
current: FileSnapshot | undefined,
|
||||
write: FileWrite,
|
||||
extensions: string[]
|
||||
): Promise<string> {
|
||||
const replacement = fromStoredSnapshot(write.replacement);
|
||||
const base = write.expected && fromStoredSnapshot(write.expected);
|
||||
const { replacement, expected: base } = write;
|
||||
if (
|
||||
current &&
|
||||
base &&
|
||||
|
|
@ -258,14 +263,17 @@ export class FileOperations {
|
|||
const result =
|
||||
current && write.expected
|
||||
? await mergeContent(
|
||||
path,
|
||||
write.expected,
|
||||
await toStoredSnapshot(current),
|
||||
write.replacement,
|
||||
extensions
|
||||
)
|
||||
path,
|
||||
write.expected,
|
||||
await hashSnapshot(current),
|
||||
write.replacement,
|
||||
extensions
|
||||
)
|
||||
: write.replacement;
|
||||
await this.fs.write(path, fromStoredSnapshot(result));
|
||||
await this.fs.write(path, {
|
||||
content: result.content,
|
||||
cursors: result.cursors
|
||||
});
|
||||
return result.hash;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,22 +1,22 @@
|
|||
import { v4 as uuid } from "uuid";
|
||||
import type { FileSystemOperations } from "./filesystem-operations";
|
||||
import type { EngineState } from "../persistence/database";
|
||||
import type { DocumentId, EngineState, RelativePath } from "../persistence/database";
|
||||
import { allocatePortablePath, isInternalPath } from "../utils/portable-path";
|
||||
|
||||
/** Resolve occupied rename destinations without discarding another file. */
|
||||
export class FilePaths {
|
||||
public constructor(
|
||||
private readonly fs: FileSystemOperations,
|
||||
private readonly isProtectedFile: (path: string, size: number) => boolean
|
||||
private readonly isProtectedFile: (path: RelativePath, size: number) => boolean
|
||||
) { }
|
||||
|
||||
public async prepareDestination(
|
||||
next: EngineState,
|
||||
current: EngineState,
|
||||
id: string,
|
||||
path: string,
|
||||
id: DocumentId,
|
||||
path: RelativePath,
|
||||
mutate: (action: () => Promise<void>) => Promise<void>
|
||||
): Promise<string> {
|
||||
): Promise<RelativePath> {
|
||||
try {
|
||||
for (; ;) {
|
||||
const obstruction = await this.obstruction(path);
|
||||
|
|
@ -97,8 +97,8 @@ export class FilePaths {
|
|||
}
|
||||
|
||||
private async obstruction(
|
||||
path: string
|
||||
): Promise<{ path: string; kind: "file" | "directory" } | undefined> {
|
||||
path: RelativePath
|
||||
): Promise<{ path: RelativePath; kind: "file" | "directory" } | undefined> {
|
||||
const parts = path.split("/");
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
const prefix = parts.slice(0, i + 1).join("/");
|
||||
|
|
@ -110,9 +110,9 @@ export class FilePaths {
|
|||
|
||||
private async conflictPath(
|
||||
next: EngineState,
|
||||
id: string,
|
||||
path: string
|
||||
): Promise<string> {
|
||||
id: DocumentId,
|
||||
path: RelativePath
|
||||
): Promise<RelativePath> {
|
||||
const occupied = { ...next.local };
|
||||
delete occupied[id];
|
||||
const files = (await this.fs.listFilesRecursively()).filter(
|
||||
|
|
@ -123,12 +123,12 @@ export class FilePaths {
|
|||
return allocatePortablePath(path, id, occupied);
|
||||
}
|
||||
|
||||
private async ensureParents(path: string): Promise<void> {
|
||||
private async ensureParents(path: RelativePath): Promise<void> {
|
||||
const parent = path.split("/").slice(0, -1).join("/");
|
||||
if (parent) await this.fs.createDirectory(parent);
|
||||
}
|
||||
|
||||
public async removeEmptyParents(path: string): Promise<void> {
|
||||
public async removeEmptyParents(path: RelativePath): Promise<void> {
|
||||
let parent = path.split("/").slice(0, -1).join("/");
|
||||
while (parent && !isInternalPath(parent)) {
|
||||
if ((await this.fs.listFilesRecursively(parent)).length) break;
|
||||
|
|
|
|||
|
|
@ -8,24 +8,24 @@ import type { FileSnapshot } from "../snapshot";
|
|||
* A successful scan is complete: unreadable directories must reject, not vanish.
|
||||
*/
|
||||
export interface FileSystemOperations {
|
||||
listFilesRecursively: (root?: string) => Promise<string[]>;
|
||||
listFilesRecursively: (root?: RelativePath) => Promise<RelativePath[]>;
|
||||
|
||||
/** Return undefined on missing file */
|
||||
read: (path: string) => Promise<FileSnapshot | undefined>;
|
||||
read: (path: RelativePath) => Promise<FileSnapshot | undefined>;
|
||||
|
||||
/** Reject symlinks and special files rather than following them and return undefined. */
|
||||
stat: (
|
||||
path: string
|
||||
path: RelativePath
|
||||
) => Promise<{ kind: "file" | "directory"; size: number } | undefined>;
|
||||
|
||||
/** Includes directories. */
|
||||
exists: (path: string) => Promise<boolean>;
|
||||
exists: (path: RelativePath) => Promise<boolean>;
|
||||
|
||||
/** Recursive, idempotent directory creation. */
|
||||
createDirectory: (path: string) => Promise<void>;
|
||||
createDirectory: (path: RelativePath) => Promise<void>;
|
||||
|
||||
/** Create or overwrite a file, applying optional editor cursor metadata. */
|
||||
write: (path: string, snapshot: FileSnapshot) => Promise<void>;
|
||||
write: (path: RelativePath, snapshot: FileSnapshot) => Promise<void>;
|
||||
|
||||
// Atomically update the content of a text file.
|
||||
atomicUpdateText: (
|
||||
|
|
@ -34,11 +34,11 @@ export interface FileSystemOperations {
|
|||
) => Promise<string>;
|
||||
|
||||
/** Move a file without replacing an existing destination. */
|
||||
rename: (from: string, to: string) => Promise<void>;
|
||||
rename: (from: RelativePath, to: RelativePath) => Promise<void>;
|
||||
|
||||
/** Idempotently unlink one regular file. */
|
||||
deleteFile: (path: string) => Promise<void>;
|
||||
deleteFile: (path: RelativePath) => Promise<void>;
|
||||
|
||||
/** Prune directories using rmdir only. Reject files and nonempty directories. */
|
||||
deleteDirectory: (path: string) => Promise<void>;
|
||||
deleteDirectory: (path: RelativePath) => Promise<void>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,7 +21,12 @@ export {
|
|||
export { Logger, LogLevel, LogLine } from "./tracing/logger";
|
||||
export { type SyncSettings, DEFAULT_SETTINGS } from "./persistence/settings";
|
||||
export { rateLimit } from "./utils/rate-limit";
|
||||
export type { RelativePath, StoredDatabase } from "./persistence/database";
|
||||
export type {
|
||||
DocumentId,
|
||||
RelativePath,
|
||||
StoredDatabase,
|
||||
VaultUpdateId
|
||||
} from "./persistence/database";
|
||||
export type { FileSystemOperations } from "./file-operations/filesystem-operations";
|
||||
export type { FileSnapshot } from "./snapshot";
|
||||
export type { MetadataPersistenceProvider as PersistenceProvider } from "./persistence/metadata-persistence-provider";
|
||||
|
|
|
|||
|
|
@ -1,83 +1,38 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import { Database, emptyState, type StoredDatabase } from "./database";
|
||||
import { Database, emptyState } from "./database";
|
||||
|
||||
test("old state loads without duplicate flags, full recovery content, or acknowledged heads", async () => {
|
||||
const head = {
|
||||
documentId: "clean",
|
||||
vaultUpdateId: 1,
|
||||
contentSize: 3,
|
||||
updatedDate: "2026-09-23",
|
||||
userId: "user",
|
||||
deviceId: "device"
|
||||
test("current state keeps absent optional fields absent across save and reload", async () => {
|
||||
const initial = emptyState("vault");
|
||||
initial.local = { local: "local.md", remote: "remote.md" };
|
||||
initial.documents = {
|
||||
local: { observedHash: "local" },
|
||||
remote: { remote: { vaultUpdateId: 1, contentSize: 3 } },
|
||||
empty: {}
|
||||
};
|
||||
const legacy = {
|
||||
...emptyState("vault"),
|
||||
initialized: true,
|
||||
lastSeenUpdateId: 2,
|
||||
protectedPaths: ["ignored.md"],
|
||||
local: {
|
||||
clean: "clean.md",
|
||||
recovering: "recovering.md",
|
||||
deferred: "deferred.md"
|
||||
},
|
||||
documents: {
|
||||
clean: {
|
||||
materialized: true,
|
||||
observedHash: "clean",
|
||||
base: { ...head, hash: "clean" }
|
||||
let saved = structuredClone(initial);
|
||||
const restart = (): Database =>
|
||||
new Database(
|
||||
saved,
|
||||
async (next) => {
|
||||
saved = structuredClone(next);
|
||||
},
|
||||
recovering: {
|
||||
materialized: true,
|
||||
observedHash: "edited",
|
||||
recoveryBase: { contentBase64: "T0xE", hash: "old" }
|
||||
},
|
||||
deferred: { materialized: false, observedHash: undefined }
|
||||
},
|
||||
remoteHeads: {
|
||||
clean: head,
|
||||
deferred: { ...head, documentId: "deferred" }
|
||||
}
|
||||
};
|
||||
const saves: StoredDatabase[] = [];
|
||||
const db = new Database(
|
||||
legacy,
|
||||
async (next) => {
|
||||
saves.push(next);
|
||||
},
|
||||
"vault",
|
||||
async () => saves.at(-1)
|
||||
);
|
||||
"vault",
|
||||
async () => structuredClone(saved)
|
||||
);
|
||||
const db = restart();
|
||||
assert.deepEqual(db.state, initial);
|
||||
await db.save();
|
||||
const saved = saves.at(-1);
|
||||
assert(saved);
|
||||
assert.deepEqual(saved.documents, {
|
||||
clean: {
|
||||
base: { vaultUpdateId: 1, hash: "clean" },
|
||||
observedHash: "clean"
|
||||
},
|
||||
recovering: {
|
||||
base: { vaultUpdateId: null, hash: "old" },
|
||||
observedHash: "edited"
|
||||
},
|
||||
deferred: {
|
||||
base: undefined,
|
||||
observedHash: undefined,
|
||||
remote: { vaultUpdateId: head.vaultUpdateId, contentSize: head.contentSize }
|
||||
}
|
||||
});
|
||||
assert.equal(saved.lastSeenUpdateId, 2);
|
||||
for (const field of ["initialized", "remoteHeads", "protectedPaths"])
|
||||
assert(!(field in saved));
|
||||
assert(!JSON.stringify(saved).includes("contentBase64"));
|
||||
assert.deepEqual(saved, initial);
|
||||
assert.deepEqual(restart().state, initial);
|
||||
});
|
||||
|
||||
test("old reset intent leaves the cursor absent after an uncertain save reload", async () => {
|
||||
const legacy = {
|
||||
...emptyState("vault"),
|
||||
initialized: false,
|
||||
historyRecovery: true,
|
||||
lastSeenUpdateId: 0
|
||||
test("current reset state is restored after an uncertain save", async () => {
|
||||
const saved = emptyState("vault");
|
||||
saved.local = { note: "note.md" };
|
||||
saved.documents.note = {
|
||||
base: { vaultUpdateId: null, hash: "clean" },
|
||||
observedHash: "clean"
|
||||
};
|
||||
const db = new Database(
|
||||
undefined,
|
||||
|
|
@ -85,10 +40,9 @@ test("old reset intent leaves the cursor absent after an uncertain save reload",
|
|||
throw new Error("interrupted");
|
||||
},
|
||||
"vault",
|
||||
async () => legacy
|
||||
async () => structuredClone(saved)
|
||||
);
|
||||
await assert.rejects(db.save(), /interrupted/);
|
||||
await db.recoverPersistence();
|
||||
assert.equal(db.state.lastSeenUpdateId, undefined);
|
||||
assert(!("historyRecovery" in db.state));
|
||||
assert.deepEqual(db.state, saved);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import type { PendingRequest, UnconfirmedRequest } from "./requests";
|
||||
import { upgradeState } from "./upgrade-state";
|
||||
import type { PendingRequest } from "./requests";
|
||||
import type { FileManifest } from "../services/types/FileManifest";
|
||||
import type { FileManifestEntries } from "../types/file-manifest-entries";
|
||||
|
||||
|
|
@ -8,48 +7,46 @@ export type DocumentId = string;
|
|||
export type RelativePath = string;
|
||||
|
||||
export interface DocumentMetadata {
|
||||
parentVersionId: number;
|
||||
parentVersionId: VaultUpdateId;
|
||||
hash: string;
|
||||
}
|
||||
|
||||
export interface ContentHead {
|
||||
vaultUpdateId: number;
|
||||
vaultUpdateId: VaultUpdateId;
|
||||
contentSize: number;
|
||||
}
|
||||
|
||||
export interface DocumentState {
|
||||
/** A null version retains only the clean hash after a server history reset. */
|
||||
base?: { vaultUpdateId: number | null; hash: string };
|
||||
base?: { vaultUpdateId: VaultUpdateId | null; hash: string };
|
||||
/** Last observed disk hash; an empty string means a notification awaits scanning. */
|
||||
observedHash?: string;
|
||||
/** A remote version still to be incorporated, including excluded downloads. */
|
||||
remote?: ContentHead;
|
||||
}
|
||||
|
||||
export type { PendingRequest, UnconfirmedRequest } from "./requests";
|
||||
export type { PendingRequest } from "./requests";
|
||||
|
||||
export interface EngineState {
|
||||
vaultKey: string;
|
||||
fileManifest: FileManifest;
|
||||
local: FileManifestEntries;
|
||||
documents: Record<string, DocumentState>;
|
||||
documents: Record<DocumentId, DocumentState>;
|
||||
/** Absent until a vault snapshot is applied; also marks a durable history reset. */
|
||||
lastSeenUpdateId?: number;
|
||||
lastSeenUpdateId?: VaultUpdateId;
|
||||
pending?: PendingRequest;
|
||||
/** A rejected retry does not prove an earlier attempt failed to commit. */
|
||||
unconfirmed?: UnconfirmedRequest[];
|
||||
/** Commits consumption of the saved notification queue with the identity map. */
|
||||
lastAppliedLocalChangeId?: string;
|
||||
/** Original server paths while local files are excluded; null means absent. */
|
||||
excluded?: Record<string, string | null>;
|
||||
excluded?: Record<DocumentId, RelativePath | null>;
|
||||
}
|
||||
|
||||
export type StoredDatabase = EngineState;
|
||||
|
||||
// Read-only view used by cursor tracking and status reporting.
|
||||
export interface DocumentRecord {
|
||||
documentId: string;
|
||||
relativePath: string;
|
||||
documentId: DocumentId;
|
||||
relativePath: RelativePath;
|
||||
metadata?: DocumentMetadata;
|
||||
}
|
||||
|
||||
|
|
@ -62,13 +59,11 @@ export function emptyState(vaultKey: string): StoredDatabase {
|
|||
};
|
||||
}
|
||||
|
||||
function canRebindEmpty(state: Partial<StoredDatabase> | undefined): boolean {
|
||||
function canRebindEmpty(state: StoredDatabase): boolean {
|
||||
return (
|
||||
state !== undefined &&
|
||||
state.lastSeenUpdateId === undefined &&
|
||||
!state.pending &&
|
||||
(state.unconfirmed?.length ?? 0) === 0 &&
|
||||
Object.keys(state.local ?? {}).length === 0
|
||||
Object.keys(state.local).length === 0
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -76,15 +71,15 @@ export class Database {
|
|||
public state: StoredDatabase;
|
||||
private needsReload = false;
|
||||
public constructor(
|
||||
initial: Partial<StoredDatabase> | undefined,
|
||||
initial: StoredDatabase | undefined,
|
||||
private readonly saveData: (data: StoredDatabase) => Promise<void>,
|
||||
vaultKey: string,
|
||||
private readonly loadData: () => Promise<StoredDatabase | undefined>
|
||||
) {
|
||||
const state =
|
||||
initial !== undefined && Object.keys(initial).length > 0
|
||||
? upgradeState(initial as StoredDatabase)
|
||||
: emptyState(vaultKey);
|
||||
initial === undefined
|
||||
? emptyState(vaultKey)
|
||||
: structuredClone(initial);
|
||||
if (state.vaultKey !== vaultKey && !canRebindEmpty(state)) {
|
||||
throw new Error(
|
||||
"Incompatible vault identity. Preserve it for recovery and initialize a separate state store."
|
||||
|
|
@ -118,7 +113,7 @@ export class Database {
|
|||
public async recoverPersistence(): Promise<void> {
|
||||
if (!this.needsReload) return;
|
||||
const saved = await this.loadData();
|
||||
if (saved) this.state = upgradeState(saved);
|
||||
if (saved) this.state = structuredClone(saved);
|
||||
this.needsReload = false;
|
||||
}
|
||||
|
||||
|
|
@ -130,7 +125,7 @@ export class Database {
|
|||
return Object.keys(this.state.local).length;
|
||||
}
|
||||
|
||||
public getDocumentByDocumentId(id: string): DocumentRecord | undefined {
|
||||
public getDocumentByDocumentId(id: DocumentId): DocumentRecord | undefined {
|
||||
const relativePath = this.state.local[id];
|
||||
if (relativePath === undefined) return undefined;
|
||||
const base = this.state.documents[id]?.base;
|
||||
|
|
@ -148,7 +143,7 @@ export class Database {
|
|||
}
|
||||
|
||||
public getLatestDocumentByRelativePath(
|
||||
path: string
|
||||
path: RelativePath
|
||||
): DocumentRecord | undefined {
|
||||
const id = Object.keys(this.state.local).find(
|
||||
(id) => this.state.local[id] === path
|
||||
|
|
|
|||
|
|
@ -1,21 +1,14 @@
|
|||
import type { PutFileContent } from "../services/types/PutFileContent";
|
||||
import type { PushFileManifest } from "../services/types/PushFileManifest";
|
||||
import type { FileManifestEntries } from "../types/file-manifest-entries";
|
||||
import type { DocumentId } from "./database";
|
||||
|
||||
/** Exact wire payload and the hash needed to advance the base after acknowledgement. */
|
||||
export type PendingRequest =
|
||||
| { type: "content"; documentId: string; request: PutFileContent; hash: string }
|
||||
| { type: "fileManifest"; request: PushFileManifest };
|
||||
|
||||
/** Enough to recognize a late receipt or suppress an unchanged rejected submission. */
|
||||
export type UnconfirmedRequest = { requestId: string; rejection?: string } & (
|
||||
| { type: "content"; documentId: string; hash: string }
|
||||
| { type: "fileManifest"; entries: FileManifestEntries }
|
||||
/** Exact wire payload retained until acknowledgement, including after rejection. */
|
||||
export type PendingRequest = { rejection?: string } & (
|
||||
| {
|
||||
type: "content";
|
||||
documentId: DocumentId;
|
||||
request: PutFileContent;
|
||||
hash: string;
|
||||
}
|
||||
| { type: "fileManifest"; request: PushFileManifest }
|
||||
);
|
||||
|
||||
export function requestReceipt(request: PendingRequest, rejection?: string): UnconfirmedRequest {
|
||||
const receipt = { requestId: request.request.requestId, rejection };
|
||||
return request.type === "content"
|
||||
? { ...receipt, type: "content", documentId: request.documentId, hash: request.hash }
|
||||
: { ...receipt, type: "fileManifest", entries: request.request.entries };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import type { RelativePath } from "./database";
|
||||
import type { Logger } from "../tracing/logger";
|
||||
import { globsToRegexes } from "../utils/globs-to-regexes";
|
||||
import { isInternalPath } from "../utils/portable-path";
|
||||
|
|
@ -68,7 +69,7 @@ export class Settings {
|
|||
);
|
||||
}
|
||||
|
||||
public isIgnored(path: string): boolean {
|
||||
public isIgnored(path: RelativePath): boolean {
|
||||
return (
|
||||
isInternalPath(path) ||
|
||||
this.ignorePatterns.some((pattern) => pattern.test(path))
|
||||
|
|
|
|||
|
|
@ -1,72 +0,0 @@
|
|||
import type { ContentHead, DocumentState, EngineState } from "./database";
|
||||
import { requestReceipt, type PendingRequest, type UnconfirmedRequest } from "./requests";
|
||||
import type { StoredSnapshot } from "../snapshot";
|
||||
|
||||
type PreviousRequest = PendingRequest & {
|
||||
snapshot?: StoredSnapshot;
|
||||
response?: unknown;
|
||||
rejection?: string;
|
||||
};
|
||||
|
||||
function upgradeRequest(request: PreviousRequest): PendingRequest {
|
||||
return request.type === "content"
|
||||
? {
|
||||
type: "content", documentId: request.documentId,
|
||||
request: request.request, hash: request.snapshot?.hash ?? request.hash
|
||||
}
|
||||
: { type: "fileManifest", request: request.request };
|
||||
}
|
||||
|
||||
/** Normalize earlier stores at the boundary; none of their duplicate data is saved again. */
|
||||
export function upgradeState(state: EngineState): EngineState {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- Read previous persisted schemas at the migration boundary.
|
||||
const legacy = state as Omit<EngineState, "pending" | "unconfirmed" | "documents"> & {
|
||||
initialized?: boolean;
|
||||
historyRecovery?: boolean;
|
||||
remoteHeads?: Record<string, ContentHead>;
|
||||
pending?: PreviousRequest;
|
||||
unconfirmed?: (UnconfirmedRequest | PreviousRequest)[];
|
||||
documents: Record<string, DocumentState & {
|
||||
materialized?: boolean;
|
||||
bootstrap?: boolean;
|
||||
recoveryBase?: StoredSnapshot;
|
||||
}>;
|
||||
};
|
||||
const next: EngineState = {
|
||||
vaultKey: state.vaultKey,
|
||||
fileManifest: state.fileManifest,
|
||||
local: state.local,
|
||||
documents: {},
|
||||
lastSeenUpdateId: "initialized" in legacy &&
|
||||
(!legacy.initialized || legacy.historyRecovery === true)
|
||||
? undefined : state.lastSeenUpdateId,
|
||||
pending: legacy.pending && upgradeRequest(legacy.pending),
|
||||
unconfirmed: legacy.unconfirmed?.map((request) =>
|
||||
"request" in request
|
||||
? requestReceipt(upgradeRequest(request), request.rejection)
|
||||
: request
|
||||
),
|
||||
lastAppliedLocalChangeId: state.lastAppliedLocalChangeId,
|
||||
excluded: state.excluded
|
||||
};
|
||||
if (legacy.pending?.rejection && !legacy.pending.response) {
|
||||
(next.unconfirmed ??= []).push(requestReceipt(upgradeRequest(legacy.pending), legacy.pending.rejection));
|
||||
delete next.pending;
|
||||
}
|
||||
for (const id of new Set([...Object.keys(legacy.documents), ...Object.keys(legacy.remoteHeads ?? {})])) {
|
||||
const doc = legacy.documents[id] ?? {};
|
||||
const base = doc.recoveryBase
|
||||
? { vaultUpdateId: null, hash: doc.recoveryBase.hash }
|
||||
: doc.base && doc.bootstrap !== true
|
||||
? { vaultUpdateId: doc.base.vaultUpdateId, hash: doc.base.hash }
|
||||
: undefined;
|
||||
const observedHash = "materialized" in doc
|
||||
? doc.materialized === true ? doc.observedHash ?? "" : undefined
|
||||
: doc.observedHash;
|
||||
const remote = doc.remote ?? legacy.remoteHeads?.[id];
|
||||
next.documents[id] = { base, observedHash };
|
||||
if (remote && (observedHash === undefined || remote.vaultUpdateId > (base?.vaultUpdateId ?? 0)))
|
||||
next.documents[id].remote = { vaultUpdateId: remote.vaultUpdateId, contentSize: remote.contentSize };
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
|
@ -20,7 +20,10 @@ test("paused sync sends no requests, while connection checks still work", async
|
|||
let requests = 0;
|
||||
const service = new SyncService("device", settings(), async () => {
|
||||
requests++;
|
||||
return Response.json({ headEventId: 0, events: [] });
|
||||
return Response.json(
|
||||
{ headEventId: 0, endEventId: 0, events: [] },
|
||||
{ headers: { "X-Vault-Link-History": "0:empty" } }
|
||||
);
|
||||
}, { get: () => undefined, save: async () => { } });
|
||||
await assert.rejects(service.getEvents(0), SyncResetError);
|
||||
assert.equal(requests, 0);
|
||||
|
|
@ -42,7 +45,10 @@ test("pause aborts requests and fences late responses from an injected fetch", a
|
|||
signals.push(init!.signal!);
|
||||
return signals.length === 1
|
||||
? response.promise
|
||||
: Response.json({ headEventId: 0, events: [] });
|
||||
: Response.json(
|
||||
{ headEventId: 0, endEventId: 0, events: [] },
|
||||
{ headers: { "X-Vault-Link-History": "0:empty" } }
|
||||
);
|
||||
},
|
||||
{
|
||||
get: () => undefined,
|
||||
|
|
@ -66,7 +72,7 @@ test("pause aborts requests and fences late responses from an injected fetch", a
|
|||
)
|
||||
);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.deepEqual(saved, []);
|
||||
assert.deepEqual(saved, ["0:empty"]);
|
||||
});
|
||||
|
||||
for (const binary of [false, true]) {
|
||||
|
|
@ -179,3 +185,32 @@ test("history saves only new checkpoints, including a changed token at the same
|
|||
}
|
||||
assert.deepEqual(saved, ["1:first", "2:next", "2:replacement"]);
|
||||
});
|
||||
|
||||
for (const checkpoint of [undefined, ""]) {
|
||||
test(`successful sync responses reject ${checkpoint === undefined ? "missing" : "empty"} history checkpoints`, async () => {
|
||||
const service = new SyncService(
|
||||
"device",
|
||||
settings(),
|
||||
async () =>
|
||||
Response.json(
|
||||
{ headEventId: 0, endEventId: 0, events: [] },
|
||||
{
|
||||
headers: checkpoint === undefined
|
||||
? {}
|
||||
: { "X-Vault-Link-History": checkpoint }
|
||||
}
|
||||
),
|
||||
{
|
||||
get: (): undefined => undefined,
|
||||
save: async (): Promise<void> => {
|
||||
assert.fail("must not save a missing checkpoint");
|
||||
}
|
||||
}
|
||||
);
|
||||
service.resume();
|
||||
await assert.rejects(
|
||||
service.getEvents(0),
|
||||
/Missing server history checkpoint/
|
||||
);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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>, };
|
||||
|
|
|
|||
|
|
@ -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, };
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)}`
|
||||
|
|
|
|||
|
|
@ -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) };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Reference in a new issue