simplify 3
This commit is contained in:
parent
82929d7479
commit
96d720eed9
10 changed files with 1318 additions and 995 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
79
frontend/sync-client/src/services/server-config.test.ts
Normal file
79
frontend/sync-client/src/services/server-config.test.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"]);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -131,6 +131,7 @@ export class SyncService {
|
|||
this.history !== undefined
|
||||
) {
|
||||
const previous = this.history.get();
|
||||
if (checkpoint === previous) return;
|
||||
if (
|
||||
previous === undefined ||
|
||||
previous === "" ||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue