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

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;

View file

@ -1,22 +1,23 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { fromStoredSnapshot, toStoredSnapshot } from "../snapshot";
import { hashSnapshot } from "../snapshot";
import { mergeContent } from "./content";
const extensions = ["md"];
const snapshot = async (text: string) =>
toStoredSnapshot({ content: new TextEncoder().encode(text) });
hashSnapshot({ content: new TextEncoder().encode(text) });
describe("content reconciliation without file timestamps", () => {
it("round-trips content and editor selections without timestamps", async () => {
it("hashes snapshots without changing bytes or editor selections", async () => {
const original = {
content: new Uint8Array([0, 255, 13, 10]),
cursors: []
};
assert.deepEqual(
fromStoredSnapshot(await toStoredSnapshot(original)),
original
);
const snapshot = await hashSnapshot(original);
assert.deepEqual(snapshot.content, original.content);
assert.deepEqual(snapshot.cursors, original.cursors);
original.content.fill(1);
assert.deepEqual(snapshot.content, new Uint8Array([0, 255, 13, 10]));
});
it("keeps local binary edits when the server content is unchanged", async () => {
@ -29,7 +30,7 @@ describe("content reconciliation without file timestamps", () => {
base,
extensions
);
assert.equal(merged.contentBase64, local.contentBase64);
assert.deepEqual(merged.content, local.content);
});
it("incorporates server binary edits when local content is unchanged", async () => {
@ -42,7 +43,7 @@ describe("content reconciliation without file timestamps", () => {
remote,
extensions
);
assert.equal(merged.contentBase64, remote.contentBase64);
assert.deepEqual(merged.content, remote.content);
});
it("keeps the server on concurrent binary or unmergeable text edits", async () => {
@ -61,7 +62,7 @@ describe("content reconciliation without file timestamps", () => {
remote,
extensions
);
assert.equal(merged.contentBase64, remote.contentBase64, path);
assert.deepEqual(merged.content, remote.content, path);
}
});
@ -75,7 +76,7 @@ describe("content reconciliation without file timestamps", () => {
remote,
extensions
);
assert.equal(merged.contentBase64, remote.contentBase64);
assert.deepEqual(merged.content, remote.content);
});
it("still merges independent text edits from both sides", async () => {
@ -87,7 +88,7 @@ describe("content reconciliation without file timestamps", () => {
extensions
);
assert.equal(
new TextDecoder().decode(fromStoredSnapshot(merged).content),
new TextDecoder().decode(merged.content),
"Updated first paragraph.\n\nUpdated last paragraph.\n"
);
});

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