simplify 3
Some checks failed
Check / build (pull_request) Has been cancelled
E2E tests / build (pull_request) Has been cancelled
Publish CLI / publish-docker (pull_request) Has been cancelled
Publish server Docker image / publish-docker (pull_request) Has been cancelled

This commit is contained in:
Andras Schmelczer 2026-09-22 08:00:29 +01:00
commit 96d720eed9
10 changed files with 1318 additions and 995 deletions

View file

@ -9,7 +9,9 @@ resolving, including while offline. Shutdown waits for those saves. Content-upda
notifications only wake reconciliation; scans read the latest bytes, including after
restart. Notifications describe logical external changes: adapters suppress
self-generated create/delete/move notifications, and report atomic editor saves
as content updates. This distinguishes a deliberate delete/recreate from an edit.
as content updates. This distinguishes a deliberate delete/recreate from an edit
when the host supplies reliable logical events. Raw OS watchers instead send
content-update hints for every event and let scans infer the current namespace.
Use `waitUntilFinished()` to await the active attempt; errors leave
pending requests saved and are reported to the caller. Transient failures retry;
authentication and protocol errors wait for an explicit wake or settings change.
@ -82,7 +84,9 @@ Their failures are reported, and remaining observers continue receiving updates.
## Required adapter contract
`FileSystemOperations` and `PersistenceProvider` define the adapter operations.
The Obsidian and CLI adapters still need to adopt the current core interface.
The CLI and desktop Obsidian share a Node filesystem adapter and atomic full-record
metadata persistence. Mobile Obsidian uses its host storage APIs. The E2E runner
builds and tests both integrations, including actual CLI processes.
The sync client does not coordinate separate processes accessing the same vault.
The filesystem adapter must provide complete scans, coherent snapshots, recursive
@ -92,8 +96,10 @@ file removal. Reject links, special files and paths escaping the vault. An unrea
file or directory must fail the scan rather than look like a deletion. Optional
editor cursor metadata is applied with file content.
User-file writes have no power-loss durability requirement. There is no filesystem
journal, hidden staging area, replay, backup archive, or flush protocol. Swaps and
User-file writes have no power-loss durability requirement. The engine has no
filesystem journal, recovery staging area, replay, backup archive, or flush protocol.
Mobile storage needs a disposable scratch file to use its exclusive-copy primitive;
that file is not used for recovery. Swaps and
cycles temporarily move occupied files to visible conflict names; a fresh scan
can discover them if application stops partway through. Partial writes and edits
made before, during or after an interruption are treated as current disk content.

View file

@ -1,5 +1,4 @@
export const TIMEOUT_FOR_MERGING_HISTORY_ENTRIES_IN_SECONDS = 60;
export const DIFF_CACHE_SIZE_MB = 2;
export const MAX_LOG_MESSAGE_COUNT = 100000;
export const MAX_HISTORY_ENTRY_COUNT = 5000;
export const SUPPORTED_API_VERSION = 5;

View file

@ -36,7 +36,7 @@ export type {
} from "./errors/errors";
export type { MaybeOutdatedClientCursors } from "./types/maybe-outdated-client-cursors";
export { DocumentSyncStatus } from "./types/document-sync-status";
export { SyncClient } from "./sync-client";
export { SyncClient, type StoredClient } from "./sync-client";
export type { TextWithCursors, CursorPosition } from "reconcile-text";
export const debugging = {

View file

@ -0,0 +1,79 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { ServerConfig } from "./server-config";
import { SyncService } from "./sync-service";
import { Settings } from "../persistence/settings";
import { Logger } from "../tracing/logger";
import { SUPPORTED_API_VERSION } from "../consts";
const config = {
supportedApiVersion: SUPPORTED_API_VERSION,
isAuthenticated: true,
mergeableFileExtensions: ["md"],
serverVersion: "test"
};
const make = (fetch: typeof globalThis.fetch) =>
new ServerConfig(
new SyncService(
"device",
new Settings(
new Logger(),
{ remoteUri: "http://test" },
async () => {}
),
fetch
)
);
test("configuration shares one validated request and retries failures", async () => {
let calls = 0;
const server = make(async () => {
if (++calls === 1) throw new Error("offline");
return Response.json(config);
});
await assert.rejects(server.getConfig(), /offline/);
const [a, b] = await Promise.all([server.getConfig(), server.getConfig()]);
assert.equal(a, b);
assert.equal(calls, 2);
await server.getConfig();
assert.equal(calls, 2);
});
test("connection checks are independent and validate authentication and version", async () => {
let response = config;
const server = make(async () => Response.json(response));
const cached = await server.getConfig();
response = { ...config, isAuthenticated: false };
assert.equal((await server.checkConnection()).isSuccessful, false);
response = { ...config, supportedApiVersion: SUPPORTED_API_VERSION + 1 };
assert.equal((await server.checkConnection()).isSuccessful, false);
assert.equal(await server.getConfig(), cached);
server.reset();
await assert.rejects(server.getConfig(), /Unsupported API/);
response = config;
assert.deepEqual(await server.getConfig(), config);
});
for (const fail of [false, true]) {
test(`reset fences a late ${fail ? "failed" : "successful"} configuration request`, async () => {
const first = Promise.withResolvers<Response>();
let calls = 0;
const server = make(async () =>
++calls === 1 ? first.promise : Response.json(config)
);
const old = server.getConfig();
server.reset();
const current = await server.getConfig();
if (fail) {
first.reject(new Error("old request"));
await assert.rejects(old, /old request/);
} else {
first.resolve(
Response.json({ ...config, mergeableFileExtensions: ["txt"] })
);
await old;
}
assert.equal(await server.getConfig(), current);
assert.equal(calls, 2);
});
}

View file

@ -4,7 +4,6 @@ import {
ServerVersionMismatchError
} from "../errors/errors";
import type { SyncService } from "./sync-service";
import type { PingResponse } from "./types/PingResponse";
export interface ServerConfigData {
mergeableFileExtensions: string[];
@ -13,8 +12,7 @@ export interface ServerConfigData {
}
export class ServerConfig {
private response: Promise<PingResponse> | undefined;
private config: ServerConfigData | undefined;
private config: Promise<ServerConfigData> | undefined;
public constructor(private readonly syncService: SyncService) {}
@ -41,29 +39,16 @@ export class ServerConfig {
await this.getConfig();
}
public async checkConnection(forceUpdate = false): Promise<{
public async checkConnection(): Promise<{
isSuccessful: boolean;
message: string;
}> {
try {
let { response } = this;
if (!response || forceUpdate) {
response = this.response = this.syncService.ping();
}
const result: PingResponse = await response; // it must be defined, otherwise we would have thrown above
this.config = result;
if (result.isAuthenticated) {
return {
isSuccessful: true,
message: `Successfully connected to server (version: ${result.serverVersion}) and authenticated`
};
}
const result = await this.syncService.ping();
ServerConfig.validateConfig(result);
return {
isSuccessful: false,
message: `Successfully connected to server (version: ${result.serverVersion}) but failed to authenticate`
isSuccessful: true,
message: `Successfully connected to server (version: ${result.serverVersion}) and authenticated`
};
} catch (e) {
return {
@ -75,22 +60,22 @@ export class ServerConfig {
public async getConfig(): Promise<ServerConfigData> {
if (!this.config) {
this.response ??= this.syncService.ping();
try {
this.config = await this.response;
} catch (error) {
this.response = undefined;
throw error;
}
const pending = this.syncService
.ping()
.then((config) => {
ServerConfig.validateConfig(config);
return config;
})
.catch((error: unknown) => {
if (this.config === pending) this.config = undefined;
throw error;
});
this.config = pending;
}
ServerConfig.validateConfig(this.config);
return this.config;
}
public reset(): void {
this.response = undefined;
this.config = undefined;
}
}

View file

@ -144,3 +144,38 @@ test("pause waits for a checkpoint save already in progress", async () => {
await assert.rejects(request, SyncResetError);
assert.equal(checkpoint, "1:head");
});
test("history saves only new checkpoints, including a changed token at the same event ID", async () => {
let checkpoint: string | undefined;
let responseCheckpoint = "1:first";
const saved: (string | undefined)[] = [];
const service = new SyncService(
"device",
settings(),
async () =>
Response.json(
{},
{ headers: { "X-Vault-Link-History": responseCheckpoint } }
),
{
get: () => checkpoint,
save: async (value) => {
checkpoint = value;
saved.push(value);
}
}
);
service.resume();
for (const value of [
"1:first",
"1:first",
"2:next",
"1:first",
"2:replacement",
"2:replacement"
]) {
responseCheckpoint = value;
await service.events(0);
}
assert.deepEqual(saved, ["1:first", "2:next", "2:replacement"]);
});

View file

@ -131,6 +131,7 @@ export class SyncService {
this.history !== undefined
) {
const previous = this.history.get();
if (checkpoint === previous) return;
if (
previous === undefined ||
previous === "" ||

View file

@ -28,7 +28,7 @@ import {
type LocalChange
} from "./sync-operations/local-changes";
type StoredClient = Partial<{
export type StoredClient = Partial<{
settings: Partial<SyncSettings>;
database: Partial<StoredDatabase>;
localChanges: (
@ -372,7 +372,7 @@ export class SyncClient {
}
public async checkConnection() {
const result = await this.serverConfig.checkConnection(true);
const result = await this.serverConfig.checkConnection();
return {
isSuccessful: result.isSuccessful,
serverMessage: result.message,

View file

@ -340,18 +340,17 @@ export class Syncer {
if (this.database.state.pending) {
await this.finishPending();
}
await this.scan();
const batch = await this.service.events(
this.database.state.lastSeenUpdateId
);
await this.incorporateEventBatch(batch);
await this.scan();
// Previously excluded content becomes eligible after a setting
// change. Its saved obligation survives advancing the cursor.
if (Object.keys(this.database.state.excluded ?? {}).length)
await this.incorporateFileManifest(
this.database.state.fileManifest
);
else await this.scan();
for (const head of Object.values(
this.database.state.remoteHeads
)) {
@ -521,7 +520,6 @@ export class Syncer {
bootstrap: vaultSnapshot
});
}
await this.scan(vaultSnapshot);
await this.incorporateFileManifest(
vaultSnapshot.fileManifest,
vaultSnapshot.headEventId,
@ -531,6 +529,7 @@ export class Syncer {
private async scan(initial?: VaultSnapshot): Promise<void> {
await this.flushLocalChanges();
const hadChanges = this.changes.length > 0;
this.unsyncablePaths = await scanLocalFiles(
{
next: this.next(),
@ -544,7 +543,8 @@ export class Syncer {
initial
);
this.hasScanned = true;
await this.localChangeStorage.save(structuredClone(this.changes));
if (hadChanges)
await this.localChangeStorage.save(structuredClone(this.changes));
}
private async incorporateEventBatch(batch: EventBatch): Promise<void> {

View file

@ -1,66 +1,32 @@
// Implements an in-memory fixed-size cache for document contents,
import type { VaultUpdateId } from "../../persistence/database";
// Doubly-linked list node for O(1) LRU operations
class LRUNode {
public constructor(
public key: VaultUpdateId,
public value: Uint8Array,
public prev: LRUNode | null = null,
public next: LRUNode | null = null
) {}
}
// evicting the least recently used documents when the size limit is exceeded.
/** Map insertion order tracks the least recently used document first. */
export class FixedSizeDocumentCache {
private currentSizeInBytes: number;
private readonly cache: Map<VaultUpdateId, LRUNode>;
private head: LRUNode | null; // Least recently used
private tail: LRUNode | null; // Most recently used
private currentSizeInBytes = 0;
private readonly cache = new Map<VaultUpdateId, Uint8Array>();
public constructor(private maxSizeInBytes: number) {
this.currentSizeInBytes = 0;
this.cache = new Map();
this.head = null;
this.tail = null;
}
public constructor(private maxSizeInBytes: number) {}
public get(updateId: VaultUpdateId): Uint8Array | undefined {
const node = this.cache.get(updateId);
if (node) {
this.moveToTail(node);
return node.value;
const content = this.cache.get(updateId);
if (content !== undefined) {
this.cache.delete(updateId);
this.cache.set(updateId, content);
}
return undefined;
return content;
}
public put(updateId: VaultUpdateId, content: Uint8Array): void {
if (content.byteLength > this.maxSizeInBytes) {
// Document is too large to fit in the cache
return;
}
// If the document is already in the cache, update it
const existingNode = this.cache.get(updateId);
if (existingNode != null) {
this.currentSizeInBytes -= existingNode.value.byteLength;
this.removeNode(existingNode);
this.cache.delete(updateId);
}
const newNode = new LRUNode(updateId, content);
this.cache.set(updateId, newNode);
this.addToTail(newNode);
if (content.byteLength > this.maxSizeInBytes) return;
this.currentSizeInBytes -= this.cache.get(updateId)?.byteLength ?? 0;
this.cache.delete(updateId);
this.cache.set(updateId, content);
this.currentSizeInBytes += content.byteLength;
this.fitBelowMaxSize();
}
public reset(): void {
this.cache.clear();
this.head = null;
this.tail = null;
this.currentSizeInBytes = 0;
}
@ -70,50 +36,10 @@ export class FixedSizeDocumentCache {
}
private fitBelowMaxSize(): void {
// Evict least recently used documents if over size limit
while (this.currentSizeInBytes > this.maxSizeInBytes && this.head) {
const lruNode = this.head;
this.removeNode(lruNode);
this.cache.delete(lruNode.key);
this.currentSizeInBytes -= lruNode.value.byteLength;
for (const [id, content] of this.cache) {
if (this.currentSizeInBytes <= this.maxSizeInBytes) break;
this.cache.delete(id);
this.currentSizeInBytes -= content.byteLength;
}
}
private removeNode(node: LRUNode): void {
if (node.prev) {
node.prev.next = node.next;
} else {
this.head = node.next;
}
if (node.next) {
node.next.prev = node.prev;
} else {
this.tail = node.prev;
}
node.prev = null;
node.next = null;
}
private addToTail(node: LRUNode): void {
node.prev = this.tail;
node.next = null;
if (this.tail) {
this.tail.next = node;
}
this.tail = node;
this.head ??= node;
}
private moveToTail(node: LRUNode): void {
if (node === this.tail) {
return;
}
this.removeNode(node);
this.addToTail(node);
}
}