Comments & make ping refreshable

This commit is contained in:
Andras Schmelczer 2026-09-30 08:27:24 +01:00
commit b78ab61992
7 changed files with 38 additions and 22 deletions

View file

@ -28,6 +28,11 @@ export class ConflictingPathError extends Error {
export class PermanentSyncError extends Error {} export class PermanentSyncError extends Error {}
/**
* Retry reconciliation against fresh local state. Raised for stale identity plans
* and local files that change during history recovery. Syncer schedules another
* pass without reporting this as a sync failure.
*/
export class LocalChangesDuringReconciliation extends Error {} export class LocalChangesDuringReconciliation extends Error {}
// The server rejected a checkpoint from a discarded/restored history. // The server rejected a checkpoint from a discarded/restored history.

View file

@ -52,7 +52,7 @@ export class FileChangeSession {
return; return;
} }
this.options.checkpoint.assertUnchanged(); this.options.assertUnchanged();
} }
public beforeFileMutation(): void { public beforeFileMutation(): void {
@ -64,7 +64,9 @@ export class FileChangeSession {
const { localChanges, isProtectedPath } = this.options; const { localChanges, isProtectedPath } = this.options;
await localChanges.flush(); await localChanges.flush();
this.abortIfStale(); if (!this.mutationsStarted) {
this.options.assertUnchanged();
}
for (const change of getUnappliedChanges( for (const change of getUnappliedChanges(
this.planned, this.planned,

View file

@ -4,7 +4,6 @@ import { test } from "node:test";
import { MemoryDisk } from "../../../test-support/storage"; import { MemoryDisk } from "../../../test-support/storage";
import { Database, createEmptyDatabase } from "../persistence/database"; import { Database, createEmptyDatabase } from "../persistence/database";
import type { ServerConfig } from "../services/server-config"; import type { ServerConfig } from "../services/server-config";
import { LocalChangeCheckpoint } from "../sync-operations/local-change-checkpoint";
import { hash } from "../utils/hash"; import { hash } from "../utils/hash";
import { FileOperations } from "./file-operations"; import { FileOperations } from "./file-operations";
@ -27,7 +26,6 @@ test("a path swap uses a conflict path only as a temporary destination", async (
async () => undefined async () => undefined
); );
const files = new FileOperations(disk, database, {} as ServerConfig); const files = new FileOperations(disk, database, {} as ServerConfig);
const checkpoint = new LocalChangeCheckpoint({ generation: 0 });
const renames: string[] = []; const renames: string[] = [];
disk.boundary = (label): void => { disk.boundary = (label): void => {
if (label.startsWith("visible:rename:")) { if (label.startsWith("visible:rename:")) {
@ -37,7 +35,7 @@ test("a path swap uses a conflict path only as a temporary destination", async (
const next = structuredClone(database.state); const next = structuredClone(database.state);
next.actualFileManifest = { a: "b.md", b: "a.md" }; next.actualFileManifest = { a: "b.md", b: "a.md" };
await files.applyChanges(next, checkpoint); await files.applyChanges(next, () => undefined);
assert.deepEqual(renames, [ assert.deepEqual(renames, [
"b.md->b (conflict b).md", // Free B without losing its contents. "b.md->b (conflict b).md", // Free B without losing its contents.

View file

@ -12,7 +12,7 @@ export interface ContentHead {
} }
export interface DocumentState { export interface DocumentState {
// A null version retains only the clean hash after a server history reset. // A null version retains an old-history hash until a scan can classify edits.
base?: { vaultUpdateId: VaultUpdateId | null; hash: string }; base?: { vaultUpdateId: VaultUpdateId | null; hash: string };
// Last observed disk hash; an empty string means a notification awaits scanning. // Last observed disk hash; an empty string means a notification awaits scanning.

View file

@ -13,19 +13,23 @@ const config = {
mergeableFileExtensions: ["md"], mergeableFileExtensions: ["md"],
serverVersion: "test" serverVersion: "test"
}; };
const make = (fetch: typeof globalThis.fetch): ServerConfig => const make = (fetch: typeof globalThis.fetch): ServerConfig => {
new ServerConfig( const service = new SyncService(
new SyncService( "device",
"device", new Settings(
new Settings( new Logger(),
new Logger(), { remoteUri: "http://test" },
{ remoteUri: "http://test" }, async () => undefined
async () => undefined ),
), fetch,
fetch, {
{ get: () => undefined, save: async () => undefined } get: (): undefined => undefined,
) save: async (): Promise<void> => undefined
}
); );
service.resume();
return new ServerConfig(service);
};
test("configuration shares one validated request and retries failures", async () => { test("configuration shares one validated request and retries failures", async () => {
let calls = 0; let calls = 0;

View file

@ -53,7 +53,7 @@ export class ServerConfig {
public async getConfig(): Promise<ServerConfigResponse> { public async getConfig(): Promise<ServerConfigResponse> {
if (!this.config) { if (!this.config) {
const pending = this.syncService const pending = this.syncService
.getServerConfig() .getServerConfig(false)
.then((config) => { .then((config) => {
ServerConfig.validateConfig(config); ServerConfig.validateConfig(config);
return config; return config;

View file

@ -46,8 +46,13 @@ export class SyncService {
await this.history.save(undefined); await this.history.save(undefined);
} }
public async getServerConfig(): Promise<ServerConfigResponse> { public async getServerConfig(
return this.request("/config", { ignoreAborts: true }); ignoreAborts = true
): Promise<ServerConfigResponse> {
return this.request("/config", {
ignoreAborts,
recordCheckpoint: false
});
} }
public async getVaultSnapshot(): Promise<VaultSnapshot> { public async getVaultSnapshot(): Promise<VaultSnapshot> {
@ -119,12 +124,14 @@ export class SyncService {
{ {
body, body,
ignoreAborts = false, ignoreAborts = false,
recordCheckpoint = true,
decode = async (response): Promise<T> => decode = async (response): Promise<T> =>
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- Endpoint methods supply the generated protocol response type. // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- Endpoint methods supply the generated protocol response type.
response.json() as Promise<T> response.json() as Promise<T>
}: { }: {
body?: unknown; body?: unknown;
ignoreAborts?: boolean; ignoreAborts?: boolean;
recordCheckpoint?: boolean;
decode?: (response: Response) => Promise<T>; decode?: (response: Response) => Promise<T>;
} = {} } = {}
): Promise<T> { ): Promise<T> {
@ -169,7 +176,7 @@ export class SyncService {
} }
// Finish metadata saves even if the transport is paused meanwhile. // Finish metadata saves even if the transport is paused meanwhile.
if (!ignoreAborts) { if (!ignoreAborts && recordCheckpoint) {
await this.recordHistory(response); await this.recordHistory(response);
} }