Fix lints & format

This commit is contained in:
Andras Schmelczer 2026-05-09 15:28:43 +01:00
parent 6d40097bcd
commit 792f57dc7e
36 changed files with 342 additions and 1687 deletions

View file

@ -7,16 +7,15 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
VaultLink is a self-hosted Obsidian file-sync system. Two halves of one repo: VaultLink is a self-hosted Obsidian file-sync system. Two halves of one repo:
- `sync-server/` — Rust (axum + sqlx/SQLite). Source of truth for vault state, broadcasts changes via WebSocket. - `sync-server/` — Rust (axum + sqlx/SQLite). Source of truth for vault state, broadcasts changes via WebSocket.
- `frontend/` — npm workspaces. The sync engine (`sync-client`) is consumed by an Obsidian plugin, a standalone CLI, a fuzz E2E harness, a scripted determinism harness, and a history UI. - `frontend/` — npm workspaces. The sync engine (`sync-client`) is consumed by an Obsidian plugin, a standalone CLI, a fuzz E2E harness, and a scripted determinism harness.
The HTTP/WS API types are generated from Rust (`ts-rs`) and mirrored into the TS workspaces. **Never hand-edit files in `frontend/sync-client/src/services/types/` or `frontend/history-ui/src/lib/types/`** — run `scripts/update-api-types.sh` after changing anything Serde-derived in the server. The HTTP/WS API types are generated from Rust (`ts-rs`) and mirrored into the TS workspaces. **Never hand-edit files in `frontend/sync-client/src/services/types/`** — run `scripts/update-api-types.sh` after changing anything Serde-derived in the server.
### Frontend workspaces ### Frontend workspaces
- `sync-client` — the sync engine; published to consumers via `dist/`. All other TS workspaces depend on it via `file:../sync-client`. - `sync-client` — the sync engine; published to consumers via `dist/`. All other TS workspaces depend on it via `file:../sync-client`.
- `obsidian-plugin` — Obsidian plugin built from `sync-client`. - `obsidian-plugin` — Obsidian plugin built from `sync-client`.
- `local-client-cli` — same engine wrapped as a standalone CLI. - `local-client-cli` — same engine wrapped as a standalone CLI.
- `history-ui` — vault-history web UI.
- `test-client` — fuzz E2E harness (random ops across N processes). - `test-client` — fuzz E2E harness (random ops across N processes).
- `deterministic-tests` — scripted multi-client tests with an in-memory FS, run against a real server. - `deterministic-tests` — scripted multi-client tests with an in-memory FS, run against a real server.
@ -67,7 +66,7 @@ Frontend dev (sync-client + obsidian-plugin watch in parallel):
cd frontend && npm install && npm run dev cd frontend && npm install && npm run dev
``` ```
Regenerate TS bindings from Rust types (touches `frontend/{sync-client,history-ui}/src/.../types/`): Regenerate TS bindings from Rust types (touches `frontend/sync-client/src/services/types/`):
```sh ```sh
scripts/update-api-types.sh scripts/update-api-types.sh

View file

@ -89,18 +89,19 @@ export const myScenarioTest: TestDefinition = {
The `verify` callback receives an `AssertableState` object with chainable assertion methods: The `verify` callback receives an `AssertableState` object with chainable assertion methods:
```typescript ```typescript
s.assertFileCount(n); // exact file count s.assertFileCount(n); // exact file count
s.assertFileExists("path"); // file must exist s.assertFileExists("path"); // file must exist
s.assertFileNotExists("path"); // file must not exist s.assertFileNotExists("path"); // file must not exist
s.assertContent("path", "expected"); // exact content match s.assertContent("path", "expected"); // exact content match
s.assertContains("path", "a", "b"); // all substrings present in file s.assertContains("path", "a", "b"); // all substrings present in file
s.assertContainsAny("path", "a", "b"); // at least one substring present s.assertContainsAny("path", "a", "b"); // at least one substring present
s.assertAnyFileContains("text"); // substring present in some file s.assertAnyFileContains("text"); // substring present in some file
s.assertNoFileContains("text"); // substring absent from every file s.assertNoFileContains("text"); // substring absent from every file
s.assertSubstringCount("path", "x", 3); // substring appears exactly N times s.assertContentInAtMostOneFile("text"); // no duplicate content
s.assertContentInAtMostOneFile("text"); // no duplicate content s.ifFileExists("path", (s) => {
s.ifFileExists("path", (s) => { /* … */ }); // conditional block /* … */
s.getContent("path"); // raw content (or "" if missing) }); // conditional block
s.getContent("path"); // raw content (or "" if missing)
``` ```
2. Register it in `src/test-registry.ts`: 2. Register it in `src/test-registry.ts`:

View file

@ -42,7 +42,7 @@ function testUsesPauseServer(test: TestDefinition): boolean {
*/ */
function findProjectRoot(): string { function findProjectRoot(): string {
let dir = path.dirname(__filename); let dir = path.dirname(__filename);
const root = path.parse(dir).root; const { root } = path.parse(dir);
while (dir !== root) { while (dir !== root) {
if ( if (
fs.existsSync(path.join(dir, "sync-server")) && fs.existsSync(path.join(dir, "sync-server")) &&

View file

@ -37,15 +37,15 @@ export class DeterministicAgent extends debugging.InMemoryFileSystem {
private readonly wsFactory = new ManagedWebSocketFactory(); private readonly wsFactory = new ManagedWebSocketFactory();
private nextWriteRename: private nextWriteRename:
| { | {
oldPath: RelativePath; oldPath: RelativePath;
newPath: RelativePath; newPath: RelativePath;
} }
| undefined; | undefined;
private nextCreateResponseDrop: private nextCreateResponseDrop:
| { | {
dropped: Promise<void>; dropped: Promise<void>;
resolveDropped: () => void; resolveDropped: () => void;
} }
| undefined; | undefined;
public constructor( public constructor(
@ -138,13 +138,12 @@ export class DeterministicAgent extends debugging.InMemoryFileSystem {
this.nextCreateResponseDrop === undefined, this.nextCreateResponseDrop === undefined,
`Client ${this.clientId} already has a create response drop armed` `Client ${this.clientId} already has a create response drop armed`
); );
let resolveDropped: () => void = () => {}; const resolvers = Promise.withResolvers<undefined>();
const dropped = new Promise<void>((resolve) => {
resolveDropped = resolve;
});
this.nextCreateResponseDrop = { this.nextCreateResponseDrop = {
dropped, dropped: resolvers.promise as Promise<void>,
resolveDropped resolveDropped: (): void => {
resolvers.resolve(undefined);
}
}; };
this.log("Armed next create response drop"); this.log("Armed next create response drop");
} }
@ -175,9 +174,7 @@ export class DeterministicAgent extends debugging.InMemoryFileSystem {
await withTimeout( await withTimeout(
new Promise<void>((resolve) => { new Promise<void>((resolve) => {
const unsubscribe = this.client.onSyncHistoryUpdated.add(() => { const unsubscribe = this.client.onSyncHistoryUpdated.add(() => {
const entry = this.client const entry = this.client.getHistoryEntries().find(matches);
.getHistoryEntries()
.find(matches);
if (entry === undefined) { if (entry === undefined) {
return; return;
} }
@ -324,11 +321,8 @@ export class DeterministicAgent extends debugging.InMemoryFileSystem {
}); });
} }
const nextWriteRename = this.nextWriteRename; const { nextWriteRename } = this;
if ( if (nextWriteRename?.oldPath === path) {
nextWriteRename !== undefined &&
nextWriteRename.oldPath === path
) {
this.nextWriteRename = undefined; this.nextWriteRename = undefined;
await super.rename( await super.rename(
nextWriteRename.oldPath, nextWriteRename.oldPath,
@ -480,5 +474,4 @@ export class DeterministicAgent extends debugging.InMemoryFileSystem {
return response; return response;
}; };
} }
} }

View file

@ -46,7 +46,7 @@ export class ServerControl {
// Retry on bind failure: findFreePort closes its probe before we // Retry on bind failure: findFreePort closes its probe before we
// spawn, so under heavy parallelism another process can grab the // spawn, so under heavy parallelism another process can grab the
// same port. Each attempt picks a fresh port. // same port. Each attempt picks a fresh port.
let lastError: unknown; let lastError: unknown = undefined;
for (let attempt = 1; attempt <= SERVER_START_MAX_ATTEMPTS; attempt++) { for (let attempt = 1; attempt <= SERVER_START_MAX_ATTEMPTS; attempt++) {
try { try {
await this.startOnce(); await this.startOnce();
@ -65,69 +65,6 @@ export class ServerControl {
); );
} }
private async startOnce(): Promise<void> {
const reservation = await findFreePort();
this._port = reservation.port;
const tmpBase = os.tmpdir();
this.tempDir = fs.mkdtempSync(path.join(tmpBase, "vault-link-test-"));
const tempConfigPath = path.join(this.tempDir, "config.yml");
const dbDir = path.join(this.tempDir, "databases");
this.writeConfigFile(tempConfigPath, dbDir);
this.logger.info(
`Starting server: ${this.serverPath} (port ${this._port})`
);
// Release the port reservation right before spawning to minimize
// the TOCTOU window between port discovery and server binding.
reservation.release();
this.process = spawn(this.serverPath, [tempConfigPath], {
stdio: ["ignore", "pipe", "pipe"],
detached: false
});
this.process.stdout?.on("data", (data: Buffer) => {
this.logger.info(`[SERVER] ${data.toString().trim()}`);
});
this.process.stderr?.on("data", (data: Buffer) => {
this.logger.info(`[SERVER] ${data.toString().trim()}`);
});
this.process.on("error", (err) => {
this.logger.error(`[SERVER] Process error: ${err.message}`);
});
const currentProcess = this.process;
currentProcess.on("exit", (code, signal) => {
this.logger.info(
`Server exited with code ${code}, signal ${signal}`
);
// Only clear state if this handler is for the current process.
// A fast stop→start cycle could create a new process before this
// handler fires — clearing state here would corrupt the new one.
if (this.process === currentProcess) {
this.process = null;
this._isPaused = false;
}
});
try {
await this.waitForReady();
} catch (error) {
// Kill the spawned process if it failed to become ready,
// preventing a zombie process from lingering.
try {
await this.stop();
} catch {
// Best-effort cleanup
}
throw error;
}
}
public async waitForReady( public async waitForReady(
maxAttempts: number = SERVER_READY_MAX_ATTEMPTS maxAttempts: number = SERVER_READY_MAX_ATTEMPTS
): Promise<void> { ): Promise<void> {
@ -239,8 +176,7 @@ export class ServerControl {
public isRunning(): boolean { public isRunning(): boolean {
const proc = this.process; const proc = this.process;
return ( return (
proc !== null && proc?.pid !== undefined &&
proc.pid !== undefined &&
proc.exitCode === null && proc.exitCode === null &&
proc.signalCode === null proc.signalCode === null
); );
@ -269,6 +205,69 @@ export class ServerControl {
} }
} }
private async startOnce(): Promise<void> {
const reservation = await findFreePort();
this._port = reservation.port;
const tmpBase = os.tmpdir();
this.tempDir = fs.mkdtempSync(path.join(tmpBase, "vault-link-test-"));
const tempConfigPath = path.join(this.tempDir, "config.yml");
const dbDir = path.join(this.tempDir, "databases");
this.writeConfigFile(tempConfigPath, dbDir);
this.logger.info(
`Starting server: ${this.serverPath} (port ${this._port})`
);
// Release the port reservation right before spawning to minimize
// the TOCTOU window between port discovery and server binding.
reservation.release();
this.process = spawn(this.serverPath, [tempConfigPath], {
stdio: ["ignore", "pipe", "pipe"],
detached: false
});
this.process.stdout?.on("data", (data: Buffer) => {
this.logger.info(`[SERVER] ${data.toString().trim()}`);
});
this.process.stderr?.on("data", (data: Buffer) => {
this.logger.info(`[SERVER] ${data.toString().trim()}`);
});
this.process.on("error", (err) => {
this.logger.error(`[SERVER] Process error: ${err.message}`);
});
const currentProcess = this.process;
currentProcess.on("exit", (code, signal) => {
this.logger.info(
`Server exited with code ${code}, signal ${signal}`
);
// Only clear state if this handler is for the current process.
// A fast stop→start cycle could create a new process before this
// handler fires — clearing state here would corrupt the new one.
if (this.process === currentProcess) {
this.process = null;
this._isPaused = false;
}
});
try {
await this.waitForReady();
} catch (error) {
// Kill the spawned process if it failed to become ready,
// preventing a zombie process from lingering.
try {
await this.stop();
} catch {
// Best-effort cleanup
}
throw error;
}
}
private writeConfigFile(destPath: string, dbDir: string): void { private writeConfigFile(destPath: string, dbDir: string): void {
// Assumes config-e2e.yml has exactly one 2-space-indented `port:` and // Assumes config-e2e.yml has exactly one 2-space-indented `port:` and
// one `databases_directory_path:` (under `server:` and `database:` // one `databases_directory_path:` (under `server:` and `database:`

View file

@ -1,7 +1,7 @@
import type { TestDefinition, TestResult, TestStep } from "./test-definition"; import type { TestDefinition, TestResult, TestStep } from "./test-definition";
import { DeterministicAgent } from "./deterministic-agent"; import { DeterministicAgent } from "./deterministic-agent";
import type { ServerControl } from "./server-control"; import type { ServerControl } from "./server-control";
import type { SyncSettings, Logger } from "sync-client"; import { SyncType, type SyncSettings, type Logger } from "sync-client";
import { assert } from "./utils/assert"; import { assert } from "./utils/assert";
import { AssertableState } from "./utils/assertable-state"; import { AssertableState } from "./utils/assertable-state";
import { sleep } from "./utils/sleep"; import { sleep } from "./utils/sleep";
@ -188,9 +188,11 @@ export class TestRunner {
const agent = this.getAgent(step.client); const agent = this.getAgent(step.client);
const historySeen = agent.waitForHistoryEntry( const historySeen = agent.waitForHistoryEntry(
(entry) => (entry) =>
entry.details.type === step.syncType && entry.details.type === SyncType[step.syncType] &&
entry.details.relativePath === step.path, entry.details.relativePath === step.path,
() => this.serverControl.pause() () => {
this.serverControl.pause();
}
); );
this.serverControl.resume(); this.serverControl.resume();
await historySeen; await historySeen;

View file

@ -1,49 +1,50 @@
import type { AssertableState } from "../utils/assertable-state"; import type { AssertableState } from "../utils/assertable-state";
import type { TestDefinition } from "../test-definition"; import type { TestDefinition } from "../test-definition";
export const concurrentRenameAndCreateAtTargetCreateFirstTest: TestDefinition = { export const concurrentRenameAndCreateAtTargetCreateFirstTest: TestDefinition =
description: {
"One client renames X to Y while another creates a new file at Y, " + description:
"both offline. After syncing, Y should contain merged content from " + "One client renames X to Y while another creates a new file at Y, " +
"both the renamed file and the newly created file.", "both offline. After syncing, Y should contain merged content from " +
clients: 2, "both the renamed file and the newly created file.",
steps: [ clients: 2,
{ steps: [
type: "create", {
client: 0, type: "create",
path: "X.md", client: 0,
content: "original file X" path: "X.md",
}, content: "original file X"
{ type: "enable-sync", client: 0 }, },
{ type: "enable-sync", client: 1 }, { type: "enable-sync", client: 0 },
{ type: "barrier" }, { type: "enable-sync", client: 1 },
{ type: "barrier" },
{ type: "disable-sync", client: 0 }, { type: "disable-sync", client: 0 },
{ type: "disable-sync", client: 1 }, { type: "disable-sync", client: 1 },
{ type: "rename", client: 0, oldPath: "X.md", newPath: "Y.md" }, { type: "rename", client: 0, oldPath: "X.md", newPath: "Y.md" },
{ {
type: "create", type: "create",
client: 1, client: 1,
path: "Y.md", path: "Y.md",
content: "brand new Y content" content: "brand new Y content"
}, },
{ type: "enable-sync", client: 1 }, { type: "enable-sync", client: 1 },
{ type: "sync", client: 1 }, { type: "sync", client: 1 },
{ type: "enable-sync", client: 0 }, { type: "enable-sync", client: 0 },
{ type: "barrier" }, { type: "barrier" },
{ {
type: "assert-consistent", type: "assert-consistent",
verify: (state: AssertableState): void => { verify: (state: AssertableState): void => {
state state
.assertFileCount(2) .assertFileCount(2)
.assertContains("Y (1).md", "original file X") .assertContains("Y (1).md", "original file X")
.assertContains("Y.md", "brand new Y content"); .assertContains("Y.md", "brand new Y content");
}
} }
} ]
] };
};

View file

@ -1,52 +1,53 @@
import type { AssertableState } from "../utils/assertable-state"; import type { AssertableState } from "../utils/assertable-state";
import type { TestDefinition } from "../test-definition"; import type { TestDefinition } from "../test-definition";
export const concurrentRenameAndCreateAtTargetRenameFirstTest: TestDefinition = { export const concurrentRenameAndCreateAtTargetRenameFirstTest: TestDefinition =
description: {
"One client renames X to Y while another creates a new file at Y, " + description:
"both offline. We can't merge the create because it would result in a cycle", "One client renames X to Y while another creates a new file at Y, " +
clients: 2, "both offline. We can't merge the create because it would result in a cycle",
steps: [ clients: 2,
{ steps: [
type: "create", {
client: 0, type: "create",
path: "X.md", client: 0,
content: "original file X" path: "X.md",
}, content: "original file X"
{ type: "enable-sync", client: 0 }, },
{ type: "enable-sync", client: 1 }, { type: "enable-sync", client: 0 },
{ type: "barrier" }, { type: "enable-sync", client: 1 },
{ type: "barrier" },
{ type: "disable-sync", client: 0 }, { type: "disable-sync", client: 0 },
{ type: "disable-sync", client: 1 }, { type: "disable-sync", client: 1 },
{ type: "rename", client: 0, oldPath: "X.md", newPath: "Y.md" }, { type: "rename", client: 0, oldPath: "X.md", newPath: "Y.md" },
{ {
type: "create", type: "create",
client: 1, client: 1,
path: "Y.md", path: "Y.md",
content: "brand new Y content" content: "brand new Y content"
}, },
{ type: "enable-sync", client: 0 }, { type: "enable-sync", client: 0 },
{ type: "sync", client: 0 }, { type: "sync", client: 0 },
{ type: "enable-sync", client: 1 }, { type: "enable-sync", client: 1 },
{ type: "barrier" }, { type: "barrier" },
{ {
type: "assert-consistent", type: "assert-consistent",
verify: (state: AssertableState): void => { verify: (state: AssertableState): void => {
state state
.assertFileNotExists("X.md") .assertFileNotExists("X.md")
.assertFileExists("Y.md") .assertFileExists("Y.md")
.assertFileExists("Y (1).md") .assertFileExists("Y (1).md")
.assertAnyFileContains( .assertAnyFileContains(
"original file X", "original file X",
"brand new Y content" "brand new Y content"
); );
}
} }
} ]
] };
};

View file

@ -106,22 +106,6 @@ export class AssertableState {
return this; return this;
} }
public assertSubstringCount(
path: string,
substring: string,
expected: number
): this {
this.assertFileExists(path);
const content = this.files.get(path) ?? "";
const actual = content.split(substring).length - 1;
if (actual !== expected) {
throw new Error(
`Expected "${substring}" to appear ${expected} time(s) in "${path}", found ${actual}. Content: "${content}"`
);
}
return this;
}
public assertContentInAtMostOneFile(substring: string): this { public assertContentInAtMostOneFile(substring: string): this {
const matches = Array.from(this.files.entries()).filter(([, content]) => const matches = Array.from(this.files.entries()).filter(([, content]) =>
content.includes(substring) content.includes(substring)
@ -143,8 +127,4 @@ export class AssertableState {
} }
return this; return this;
} }
public getContent(path: string): string {
return this.files.get(path) ?? "";
}
} }

View file

@ -169,7 +169,6 @@ test("parseArgs - parse ERROR log level", () => {
assert.equal(args.logLevel, LogLevel.ERROR); assert.equal(args.logLevel, LogLevel.ERROR);
}); });
test("parseArgs - reads required options from environment variables", () => { test("parseArgs - reads required options from environment variables", () => {
process.env.VAULTLINK_LOCAL_PATH = "/env/path"; process.env.VAULTLINK_LOCAL_PATH = "/env/path";
process.env.VAULTLINK_REMOTE_URI = "https://env.example.com"; process.env.VAULTLINK_REMOTE_URI = "https://env.example.com";

View file

@ -1,11 +1,10 @@
import * as path from "path"; import * as path from "path";
import * as fs from "fs/promises"; import * as fs from "fs/promises";
import * as fsSync from "fs"; import * as fsSync from "fs";
import type { NetworkConnectionStatus } from "sync-client"; import type { NetworkConnectionStatus, Logger } from "sync-client";
import { import {
SyncClient, SyncClient,
DEFAULT_SETTINGS, DEFAULT_SETTINGS,
Logger,
LogLevel, LogLevel,
LogLine, LogLine,
type SyncSettings, type SyncSettings,

View file

@ -15,7 +15,7 @@ import { toUnixPath } from "./path-utils";
export const VAULTLINK_DIR = ".vaultlink"; export const VAULTLINK_DIR = ".vaultlink";
export class NodeFileSystemOperations implements FileSystemOperations { export class NodeFileSystemOperations implements FileSystemOperations {
public constructor(private readonly basePath: string) { } public constructor(private readonly basePath: string) {}
public async listFilesRecursively( public async listFilesRecursively(
directory: RelativePath | undefined directory: RelativePath | undefined

View file

@ -139,10 +139,6 @@ export class ObsidianFileSystemOperations implements FileSystemOperations {
return (await this.statFile(path)).size; return (await this.statFile(path)).size;
} }
public async getModificationTime(path: RelativePath): Promise<Date> {
return new Date((await this.statFile(path)).mtime);
}
public async exists(path: RelativePath): Promise<boolean> { public async exists(path: RelativePath): Promise<boolean> {
return this.vault.adapter.exists(normalizePath(path)); return this.vault.adapter.exists(normalizePath(path));
} }

File diff suppressed because it is too large Load diff

View file

@ -13,8 +13,6 @@ import { HttpClientError } from "../errors/http-client-error";
import type { SerializedError } from "./types/SerializedError"; import type { SerializedError } from "./types/SerializedError";
import type { DocumentVersionWithoutContent } from "./types/DocumentVersionWithoutContent"; import type { DocumentVersionWithoutContent } from "./types/DocumentVersionWithoutContent";
import type { DocumentUpdateResponse } from "./types/DocumentUpdateResponse"; import type { DocumentUpdateResponse } from "./types/DocumentUpdateResponse";
import type { DocumentVersion } from "./types/DocumentVersion";
import type { FetchLatestDocumentsResponse } from "./types/FetchLatestDocumentsResponse";
import type { PingResponse } from "./types/PingResponse"; import type { PingResponse } from "./types/PingResponse";
import type { UpdateTextDocumentVersion } from "./types/UpdateTextDocumentVersion"; import type { UpdateTextDocumentVersion } from "./types/UpdateTextDocumentVersion";
import { buildVaultUrl } from "./build-vault-url"; import { buildVaultUrl } from "./build-vault-url";
@ -272,32 +270,6 @@ export class SyncService {
}); });
} }
public async get({
documentId
}: {
documentId: DocumentId;
}): Promise<DocumentVersion> {
return this.retryForever(async () => {
this.logger.debug(`Getting document with id ${documentId}`);
const response = await this.client(
this.getUrl(`/documents/${documentId}`),
{
headers: this.getDefaultHeaders()
}
);
await SyncService.throwIfNotOk(response, "get document");
const result: DocumentVersion =
(await response.json()) as DocumentVersion; // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
this.logger.debug(`Got document ${JSON.stringify(result)}`);
return result;
});
}
public async getDocumentVersionContent({ public async getDocumentVersionContent({
documentId, documentId,
vaultUpdateId vaultUpdateId
@ -332,36 +304,6 @@ export class SyncService {
}); });
} }
public async getAll(
since?: VaultUpdateId
): Promise<FetchLatestDocumentsResponse> {
return this.retryForever(async () => {
this.logger.debug(
"Getting all documents" +
(since != null ? ` since ${since}` : "")
);
const url = new URL(this.getUrl("/documents"));
if (since !== undefined) {
url.searchParams.append("since_update_id", since.toString());
}
const response = await this.client(url.toString(), {
headers: this.getDefaultHeaders()
});
await SyncService.throwIfNotOk(response, "get documents");
const result: FetchLatestDocumentsResponse =
(await response.json()) as FetchLatestDocumentsResponse; // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
this.logger.debug(
`Got ${result.latestDocuments.length} document metadata`
);
return result;
});
}
public async ping(): Promise<PingResponse> { public async ping(): Promise<PingResponse> {
this.logger.debug("Pinging server"); this.logger.debug("Pinging server");
const response = await this.pingClient(this.getUrl("/ping"), { const response = await this.pingClient(this.getUrl("/ping"), {

View file

@ -1,13 +0,0 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { DocumentVersionWithoutContent } from "./DocumentVersionWithoutContent";
/**
* Response to a fetch latest documents request.
*/
export interface FetchLatestDocumentsResponse {
latestDocuments: DocumentVersionWithoutContent[];
/**
* The update ID of the latest document in the response.
*/
lastUpdateId: bigint;
}

View file

@ -56,13 +56,7 @@ export class SyncClient {
private readonly contentCache: FixedSizeDocumentCache, private readonly contentCache: FixedSizeDocumentCache,
private readonly serverConfig: ServerConfig, private readonly serverConfig: ServerConfig,
private readonly syncService: SyncService, private readonly syncService: SyncService,
private readonly expectedFsEvents: ExpectedFsEvents, private readonly expectedFsEvents: ExpectedFsEvents
private readonly persistence: PersistenceProvider<
Partial<{
settings: Partial<SyncSettings>;
database: Partial<StoredSyncState>;
}>
>
) {} ) {}
public get syncedDocumentCount(): number { public get syncedDocumentCount(): number {
@ -172,7 +166,7 @@ export class SyncClient {
// new deviceId, the server-side query would miss, and the // new deviceId, the server-side query would miss, and the
// pending-but-lost create would deconflict instead of // pending-but-lost create would deconflict instead of
// binding to the doc its content was already absorbed into. // binding to the doc its content was already absorbed into.
let deviceId = state.deviceId; let { deviceId } = state;
if (deviceId === undefined) { if (deviceId === undefined) {
deviceId = createClientId(); deviceId = createClientId();
state = { ...state, deviceId }; state = { ...state, deviceId };
@ -269,8 +263,7 @@ export class SyncClient {
contentCache, contentCache,
serverConfig, serverConfig,
syncService, syncService,
expectedFsEvents, expectedFsEvents
persistence
); );
logger.info("SyncClient created successfully"); logger.info("SyncClient created successfully");
@ -322,26 +315,6 @@ export class SyncClient {
} }
} }
/**
* Reload settings from disk overriding current in-memory settings.
* Missing values will be filled in from DEFAULT_SETTINGS rather than
* retaining current in-memory settings.
*/
public async reloadSettings(): Promise<void> {
this.checkIfDestroyed("reloadSettings");
const state = (await this.persistence.load()) ?? {
settings: undefined
};
const settings = {
...DEFAULT_SETTINGS,
...(state.settings ?? {})
};
await this.setSettings(settings);
}
public async checkConnection(): Promise<NetworkConnectionStatus> { public async checkConnection(): Promise<NetworkConnectionStatus> {
this.checkIfDestroyed("checkConnection"); this.checkIfDestroyed("checkConnection");

View file

@ -2,7 +2,10 @@ import { describe, it } from "node:test";
import assert from "node:assert"; import assert from "node:assert";
import { Logger } from "../tracing/logger"; import { Logger } from "../tracing/logger";
import { Settings } from "../persistence/settings"; import { Settings } from "../persistence/settings";
import { STORED_STATE_SCHEMA_VERSION, SyncEventQueue } from "./sync-event-queue"; import {
STORED_STATE_SCHEMA_VERSION,
SyncEventQueue
} from "./sync-event-queue";
import { scheduleOfflineChanges } from "./offline-change-detector"; import { scheduleOfflineChanges } from "./offline-change-detector";
import type { FileOperations } from "../file-operations/file-operations"; import type { FileOperations } from "../file-operations/file-operations";
import type { RelativePath } from "./types"; import type { RelativePath } from "./types";
@ -22,19 +25,20 @@ const makeQueue = async (): Promise<SyncEventQueue> => {
); );
}; };
const makeOperations = ( const makeOperations = (files: Record<string, Uint8Array>): FileOperations => {
files: Record<string, Uint8Array> const map = new Map<RelativePath, Uint8Array>(Object.entries(files));
): FileOperations => { const partial: Partial<FileOperations> = {
return { listFilesRecursively: async () => [...map.keys()],
listFilesRecursively: async () => Object.keys(files),
read: async (path: RelativePath) => { read: async (path: RelativePath) => {
const data = files[path]; const data = map.get(path);
if (data === undefined) { if (data === undefined) {
throw new Error(`File not found: ${path}`); throw new Error(`File not found: ${path}`);
} }
return data; return data;
} }
} as unknown as FileOperations; };
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
return partial as FileOperations;
}; };
describe("scheduleOfflineChanges", () => { describe("scheduleOfflineChanges", () => {
@ -70,7 +74,8 @@ describe("scheduleOfflineChanges", () => {
operations, operations,
queue, queue,
(path) => enqueued.push({ kind: "create", path }), (path) => enqueued.push({ kind: "create", path }),
(args) => enqueued.push({ kind: "update", path: args.relativePath }), (args) =>
enqueued.push({ kind: "update", path: args.relativePath }),
(path) => enqueued.push({ kind: "delete", path }) (path) => enqueued.push({ kind: "delete", path })
); );
@ -109,13 +114,12 @@ describe("scheduleOfflineChanges", () => {
operations, operations,
queue, queue,
(path) => enqueued.push({ kind: "create", path }), (path) => enqueued.push({ kind: "create", path }),
(args) => enqueued.push({ kind: "update", path: args.relativePath }), (args) =>
enqueued.push({ kind: "update", path: args.relativePath }),
(path) => enqueued.push({ kind: "delete", path }) (path) => enqueued.push({ kind: "delete", path })
); );
assert.deepStrictEqual(enqueued, [ assert.deepStrictEqual(enqueued, [{ kind: "update", path: "doc.md" }]);
{ kind: "update", path: "doc.md" }
]);
}); });
it("schedules a delete for a settled record whose local file is missing", async () => { it("schedules a delete for a settled record whose local file is missing", async () => {
@ -136,13 +140,12 @@ describe("scheduleOfflineChanges", () => {
operations, operations,
queue, queue,
(path) => enqueued.push({ kind: "create", path }), (path) => enqueued.push({ kind: "create", path }),
(args) => enqueued.push({ kind: "update", path: args.relativePath }), (args) =>
enqueued.push({ kind: "update", path: args.relativePath }),
(path) => enqueued.push({ kind: "delete", path }) (path) => enqueued.push({ kind: "delete", path })
); );
assert.deepStrictEqual(enqueued, [ assert.deepStrictEqual(enqueued, [{ kind: "delete", path: "gone.md" }]);
{ kind: "delete", path: "gone.md" }
]);
}); });
it("detects an offline rename when an untracked file matches a deleted record's content hash", async () => { it("detects an offline rename when an untracked file matches a deleted record's content hash", async () => {

View file

@ -7,6 +7,24 @@ import type { SyncEventQueue } from "./sync-event-queue";
import { removeFromArray } from "../utils/remove-from-array"; import { removeFromArray } from "../utils/remove-from-array";
import { FileNotFoundError } from "../errors/file-not-found-error"; import { FileNotFoundError } from "../errors/file-not-found-error";
async function readOrUndefined(
operations: FileOperations,
path: RelativePath,
logger: Logger
): Promise<Uint8Array | undefined> {
try {
return await operations.read(path);
} catch (e) {
if (e instanceof FileNotFoundError) {
logger.debug(
`File ${path} disappeared before offline-scan could read it; skipping`
);
return undefined;
}
throw e;
}
}
/** /**
* Scans the local filesystem and the document database to determine * Scans the local filesystem and the document database to determine
* which files were created, updated, moved, or deleted while the * which files were created, updated, moved, or deleted while the
@ -85,18 +103,10 @@ export async function scheduleOfflineChanges(
// the whole scan; nothing to sync for a file that's already gone. // the whole scan; nothing to sync for a file that's already gone.
const disappearedPaths = new Set<RelativePath>(); const disappearedPaths = new Set<RelativePath>();
for (const path of locallyPossibleCreatedFiles) { for (const path of locallyPossibleCreatedFiles) {
let content: Uint8Array; const content = await readOrUndefined(operations, path, logger);
try { if (content === undefined) {
content = await operations.read(path); disappearedPaths.add(path);
} catch (e) { continue;
if (e instanceof FileNotFoundError) {
logger.debug(
`File ${path} disappeared before offline-scan could read it; skipping`
);
disappearedPaths.add(path);
continue;
}
throw e;
} }
const contentHash = await hash(content); const contentHash = await hash(content);
@ -148,8 +158,7 @@ export async function scheduleOfflineChanges(
for (const path of syncedLocalFiles) { for (const path of syncedLocalFiles) {
const record = allDocuments.get(path); const record = allDocuments.get(path);
if ( if (
record !== undefined && record?.localPath !== undefined &&
record.localPath !== undefined &&
record.localPath !== record.remoteRelativePath && record.localPath !== record.remoteRelativePath &&
!allLocalFiles.has(record.remoteRelativePath) && !allLocalFiles.has(record.remoteRelativePath) &&
queue.byLocalPath.get(record.remoteRelativePath) === undefined queue.byLocalPath.get(record.remoteRelativePath) === undefined

View file

@ -2,7 +2,10 @@ import { describe, it } from "node:test";
import assert from "node:assert"; import assert from "node:assert";
import { Logger, LogLevel } from "../tracing/logger"; import { Logger, LogLevel } from "../tracing/logger";
import { Settings } from "../persistence/settings"; import { Settings } from "../persistence/settings";
import { STORED_STATE_SCHEMA_VERSION, SyncEventQueue } from "./sync-event-queue"; import {
STORED_STATE_SCHEMA_VERSION,
SyncEventQueue
} from "./sync-event-queue";
import { Reconciler } from "./reconciler"; import { Reconciler } from "./reconciler";
import { SyncResetError } from "../errors/sync-reset-error"; import { SyncResetError } from "../errors/sync-reset-error";
import type { FileOperations } from "../file-operations/file-operations"; import type { FileOperations } from "../file-operations/file-operations";
@ -32,18 +35,22 @@ describe("Reconciler", () => {
localPath: undefined localPath: undefined
}); });
const operations = { const operationsPartial: Partial<FileOperations> = {
exists: async () => false, exists: async () => false,
create: async () => { create: async () => {
assert.fail("reset-interrupted placement should not write"); assert.fail("reset-interrupted placement should not write");
} }
} as unknown as FileOperations; };
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const operations = operationsPartial as FileOperations;
const syncService = { const syncServicePartial: Partial<SyncService> = {
getDocumentVersionContent: async () => { getDocumentVersionContent: async () => {
throw new SyncResetError(); throw new SyncResetError();
} }
} as unknown as SyncService; };
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const syncService = syncServicePartial as SyncService;
const reconciler = new Reconciler( const reconciler = new Reconciler(
logger, logger,

View file

@ -307,7 +307,10 @@ describe("SyncEventQueue", () => {
queue.byLocalPath.get("renamed.md" as RelativePath), queue.byLocalPath.get("renamed.md" as RelativePath),
undefined undefined
); );
assert.strictEqual(queue.getDocumentByDocumentId("A")?.localPath, "a.md"); assert.strictEqual(
queue.getDocumentByDocumentId("A")?.localPath,
"a.md"
);
// setLocalPath does re-key — it's the explicit path-mutation API. // setLocalPath does re-key — it's the explicit path-mutation API.
await queue.setLocalPath("A", "later.md" as RelativePath); await queue.setLocalPath("A", "later.md" as RelativePath);

View file

@ -220,9 +220,7 @@ export class SyncEventQueue {
* path) still fires when neither side holds a record for the * path) still fires when neither side holds a record for the
* collision target. * collision target.
*/ */
public lastSeenUpdateIdForCreate( public lastSeenUpdateIdForCreate(requestPath: RelativePath): VaultUpdateId {
requestPath: RelativePath
): VaultUpdateId {
let watermark = this._lastSeenUpdateId.min; let watermark = this._lastSeenUpdateId.min;
for (const record of this.byDocId.values()) { for (const record of this.byDocId.values()) {
if ( if (
@ -324,7 +322,7 @@ export class SyncEventQueue {
!pendingCreate.isProcessing !pendingCreate.isProcessing
) { ) {
this.cancelPendingCreate(pendingCreate); this.cancelPendingCreate(pendingCreate);
if (recordIsDeleting && record !== undefined) { if (recordIsDeleting) {
// A stale deleting record was still claiming this path. // A stale deleting record was still claiming this path.
// The not-yet-started create/delete pair collapsed to // The not-yet-started create/delete pair collapsed to
// nothing, and the disk file is gone, so clear the stale // nothing, and the disk file is gone, so clear the stale
@ -343,11 +341,11 @@ export class SyncEventQueue {
path: lookupPath path: lookupPath
}); });
this.notifyPendingUpdateCountChanged(); this.notifyPendingUpdateCountChanged();
if (recordOwnsLookupPath && record !== undefined) { if (recordOwnsLookupPath) {
// The file is gone from disk; clear the doc's localPath so the // The file is gone from disk; clear the doc's localPath so the
// Reconciler doesn't try to operate on a vacated slot. // Reconciler doesn't try to operate on a vacated slot.
await this.setLocalPath(record.documentId, undefined); await this.setLocalPath(record.documentId, undefined);
} else if (recordIsDeleting && record !== undefined) { } else if (recordIsDeleting) {
// A stale deleting record was still claiming this path while a // A stale deleting record was still claiming this path while a
// newer pending create owned the actual disk file. Drop the // newer pending create owned the actual disk file. Drop the
// stale claim now that the file is gone. // stale claim now that the file is gone.
@ -648,14 +646,6 @@ export class SyncEventQueue {
return this.byDocId.get(target); return this.byDocId.get(target);
} }
public getDocumentByDocumentIdOrFail(target: DocumentId): DocumentRecord {
const result = this.getDocumentByDocumentId(target);
if (!result) {
throw new Error(`No document found with id ${target}`);
}
return result;
}
public getRecordByLocalPath( public getRecordByLocalPath(
path: RelativePath path: RelativePath
): DocumentRecord | undefined { ): DocumentRecord | undefined {
@ -814,6 +804,7 @@ export class SyncEventQueue {
event.path === path && event.path === path &&
event.documentId !== promise event.documentId !== promise
) { ) {
// eslint-disable-next-line no-restricted-syntax -- splice-by-index here is a reorder, not an item removal
this.events.splice(i, 1); this.events.splice(i, 1);
this.events.splice(createIndex, 0, event); this.events.splice(createIndex, 0, event);
createIndex++; createIndex++;
@ -866,6 +857,7 @@ export class SyncEventQueue {
typeof event.documentId === "string" && typeof event.documentId === "string" &&
blockingDocIds.has(event.documentId) blockingDocIds.has(event.documentId)
) { ) {
// eslint-disable-next-line no-restricted-syntax -- splice-by-index here is a reorder, not an item removal
this.events.splice(i, 1); this.events.splice(i, 1);
this.events.splice(createIndex, 0, event); this.events.splice(createIndex, 0, event);
createIndex++; createIndex++;
@ -907,8 +899,8 @@ export class SyncEventQueue {
this._byLocalPath.delete(previousLocalPath); this._byLocalPath.delete(previousLocalPath);
} }
record.localPath = newLocalPath; record.localPath = newLocalPath;
let displacedRecord: DocumentRecord | undefined; let displacedRecord: DocumentRecord | undefined = undefined;
let displacedOldPath: RelativePath | undefined; let displacedOldPath: RelativePath | undefined = undefined;
if (newLocalPath !== undefined) { if (newLocalPath !== undefined) {
const displaced = this._byLocalPath.get(newLocalPath); const displaced = this._byLocalPath.get(newLocalPath);
if (displaced !== undefined && displaced !== record) { if (displaced !== undefined && displaced !== record) {

View file

@ -54,11 +54,6 @@ export class Logger {
); );
} }
public reset(): void {
this.messages.length = 0;
this.debug("Logger has been reset");
}
private pushMessage(message: string, level: LogLevel): void { private pushMessage(message: string, level: LogLevel): void {
const logLine = new LogLine(level, message); const logLine = new LogLine(level, message);
this.messages.push(logLine); this.messages.push(logLine);

View file

@ -92,10 +92,6 @@ export class Locks<T> {
this.waiters.clear(); this.waiters.clear();
} }
public isLocked(key: T): boolean {
return this.locked.has(key);
}
/** /**
* Attempts to acquire a lock immediately without waiting. * Attempts to acquire a lock immediately without waiting.
* Must call `unlock()` if successful. * Must call `unlock()` if successful.

View file

@ -58,16 +58,18 @@ export class MockAgent extends MockClient {
// (e.g. `initial-1.md → initial-1 (2).md` after a same-path // (e.g. `initial-1.md → initial-1 (2).md` after a same-path
// collision) lands at a path the touch-list never knew about, // collision) lands at a path the touch-list never knew about,
// and an offline rename against that path strands the file. // and an offline rename against that path strands the file.
this.client.onDocumentPathChanged.add((_documentId, oldPath, newPath) => { this.client.onDocumentPathChanged.add(
if (oldPath !== undefined && newPath !== undefined) { (_documentId, oldPath, newPath) => {
if (this.doNotTouchWhileOffline.includes(oldPath)) { if (oldPath !== undefined && newPath !== undefined) {
this.doNotTouchWhileOffline.push(newPath); if (this.doNotTouchWhileOffline.includes(oldPath)) {
} this.doNotTouchWhileOffline.push(newPath);
if (this.doNotRenameWhileOffline.includes(oldPath)) { }
this.doNotRenameWhileOffline.push(newPath); if (this.doNotRenameWhileOffline.includes(oldPath)) {
this.doNotRenameWhileOffline.push(newPath);
}
} }
} }
}); );
this.client.logger.onLogEmitted.add((logLine: LogLine) => { this.client.logger.onLogEmitted.add((logLine: LogLine) => {
const state = this.client.getSettings().isSyncEnabled const state = this.client.getSettings().isSyncEnabled

View file

@ -1,4 +1,3 @@
disallowed-macros = [ disallowed-macros = [
{ path = "std::eprintln", reason = "use log::info! or log::warn! instead" }, { path = "std::eprintln", reason = "use log::info! or log::warn! instead" },
{ path = "std::println", reason = "use log::info! or log::warn! instead" },
] ]

View file

@ -118,7 +118,7 @@ impl Cursors {
}; };
self.broadcasts.send_document_update( self.broadcasts.send_document_update(
vault_id.clone(), vault_id,
WebSocketServerMessageWithOrigin::new(WebSocketServerMessage::CursorPositions( WebSocketServerMessageWithOrigin::new(WebSocketServerMessage::CursorPositions(
CursorPositionFromServer { CursorPositionFromServer {
clients: client_cursors, clients: client_cursors,

View file

@ -34,6 +34,10 @@ use super::websocket::{
use crate::config::database_config::DatabaseConfig; use crate::config::database_config::DatabaseConfig;
use crate::consts::IDLE_POOL_TIMEOUT; use crate::consts::IDLE_POOL_TIMEOUT;
fn duration_millis_u64(duration: Duration) -> u64 {
u64::try_from(duration.as_millis()).unwrap_or(u64::MAX)
}
/// Holds separate reader and writer pools for a single vault. /// Holds separate reader and writer pools for a single vault.
/// The writer pool has exactly 1 connection so writes never compete /// The writer pool has exactly 1 connection so writes never compete
/// with reads for pool slots. /// with reads for pool slots.
@ -182,7 +186,7 @@ fn rollback_before_acquire(
impl Database { impl Database {
fn now_ms(&self) -> u64 { fn now_ms(&self) -> u64 {
self.epoch.elapsed().as_millis() as u64 duration_millis_u64(self.epoch.elapsed())
} }
pub async fn try_new( pub async fn try_new(
@ -817,8 +821,7 @@ impl Database {
} else { } else {
WebSocketServerMessageWithOrigin::with_origin(version.device_id.clone(), envelope) WebSocketServerMessageWithOrigin::with_origin(version.device_id.clone(), envelope)
}; };
self.broadcasts self.broadcasts.send_document_update(vault_id, with_origin);
.send_document_update(vault_id.clone(), with_origin);
Ok(()) Ok(())
} }
@ -831,7 +834,7 @@ impl Database {
let idle_pools: Vec<(VaultId, Arc<VaultPool>)> = { let idle_pools: Vec<(VaultId, Arc<VaultPool>)> = {
let mut pools = self.connection_pools.lock().await; let mut pools = self.connection_pools.lock().await;
let now_ms = self.now_ms(); let now_ms = self.now_ms();
let idle_threshold_ms = IDLE_POOL_TIMEOUT.as_millis() as u64; let idle_threshold_ms = duration_millis_u64(IDLE_POOL_TIMEOUT);
let vaults_to_remove: Vec<VaultId> = pools let vaults_to_remove: Vec<VaultId> = pools
.iter() .iter()

View file

@ -83,7 +83,6 @@ pub struct DocumentVersion {
pub device_id: DeviceId, pub device_id: DeviceId,
} }
impl From<StoredDocumentVersion> for DocumentVersion { impl From<StoredDocumentVersion> for DocumentVersion {
fn from(value: StoredDocumentVersion) -> Self { fn from(value: StoredDocumentVersion) -> Self {
Self { Self {

View file

@ -10,7 +10,7 @@ use crate::{
}, },
config::user_config::User, config::user_config::User,
errors::{SyncServerError, client_error, server_error, unauthenticated_error}, errors::{SyncServerError, client_error, server_error, unauthenticated_error},
server::auth::auth, server::auth::authenticate_for_vault,
}; };
pub struct AuthenticatedWebSocketHandshake { pub struct AuthenticatedWebSocketHandshake {
@ -30,7 +30,7 @@ pub fn get_authenticated_handshake(
match message { match message {
WebSocketClientMessage::Handshake(handshake) => { WebSocketClientMessage::Handshake(handshake) => {
let user = auth(state, handshake.token.trim(), vault_id)?; let user = authenticate_for_vault(state, handshake.token.trim(), vault_id)?;
Ok(AuthenticatedWebSocketHandshake { handshake, user }) Ok(AuthenticatedWebSocketHandshake { handshake, user })
} }
WebSocketClientMessage::CursorPositions(_) => Err(unauthenticated_error( WebSocketClientMessage::CursorPositions(_) => Err(unauthenticated_error(

View file

@ -79,10 +79,7 @@ impl IntoResponse for SyncServerError {
Self::InitError(_) | Self::ServerError(_) => { Self::InitError(_) | Self::ServerError(_) => {
error!("{serialized}"); error!("{serialized}");
} }
Self::ClientError(_) | Self::NotFound(_) => { Self::ClientError(_) | Self::NotFound(_) | Self::TooManyRequests(_) => {
warn!("{serialized}");
}
Self::TooManyRequests(_) => {
warn!("{serialized}"); warn!("{serialized}");
} }
Self::Unauthenticated(_) | Self::PermissionDeniedError(_) => {} Self::Unauthenticated(_) | Self::PermissionDeniedError(_) => {}

View file

@ -14,7 +14,7 @@ use cli::args::Args;
use config::Config; use config::Config;
use consts::DEFAULT_CONFIG_PATH; use consts::DEFAULT_CONFIG_PATH;
use errors::{SyncServerError, init_error}; use errors::{SyncServerError, init_error};
use log::info; use log::{error, info, warn};
use server::create_server; use server::create_server;
use tracing_appender::non_blocking::WorkerGuard; use tracing_appender::non_blocking::WorkerGuard;
use tracing_subscriber::{EnvFilter, fmt::format, layer::SubscriberExt, util::SubscriberInitExt}; use tracing_subscriber::{EnvFilter, fmt::format, layer::SubscriberExt, util::SubscriberInitExt};
@ -36,30 +36,63 @@ async fn main() -> ExitCode {
.map_err(init_error) .map_err(init_error)
{ {
Ok(config) => config, Ok(config) => config,
Err(e) => { Err(error) => {
eprintln!("{}", e.serialize()); return exit_with_startup_error(&args, &error);
return ExitCode::FAILURE;
} }
}; };
let result = async { if let Err(error) = config.validate().map_err(init_error) {
config.validate().map_err(init_error)?; return exit_with_startup_error(&args, &error);
// Hold the non-blocking writer guards until shutdown so the
// dedicated writer threads stay alive and flush queued log lines.
let _log_guards = set_up_logging(&args, &config.logging)?;
start_server(config).await
} }
.await;
match result { // Hold the non-blocking writer guards until shutdown so the dedicated
// writer threads stay alive and flush queued log lines.
let _log_guards = match set_up_logging(&args, &config.logging) {
Ok(log_guards) => log_guards,
Err(error) => {
return exit_with_startup_error(&args, &error);
}
};
match start_server(config).await {
Ok(()) => ExitCode::SUCCESS, Ok(()) => ExitCode::SUCCESS,
Err(e) => { Err(error) => {
eprintln!("{}", e.serialize()); let serialized = error.serialize();
warn!("{serialized}");
ExitCode::FAILURE ExitCode::FAILURE
} }
} }
} }
fn exit_with_startup_error(args: &Args, err: &SyncServerError) -> ExitCode {
let _ = set_up_stderr_logging(args);
let serialized = err.serialize();
error!("{serialized}");
ExitCode::FAILURE
}
fn set_up_stderr_logging(args: &Args) -> Result<(), SyncServerError> {
let env_filter = EnvFilter::builder()
.with_default_directive(tracing::Level::WARN.into())
.from_env()
.context("Failed to create logging env filter")
.map_err(init_error)?;
let stderr_layer = tracing_subscriber::fmt::layer()
.with_ansi(args.color.use_colors())
.with_writer(std::io::stderr)
.event_format(format().compact());
tracing_subscriber::registry()
.with(env_filter)
.with(stderr_layer)
.try_init()
.context("Failed to initialise fallback tracing")
.map_err(init_error)
}
fn set_up_logging( fn set_up_logging(
args: &Args, args: &Args,
logging_config: &config::logging_config::LoggingConfig, logging_config: &config::logging_config::LoggingConfig,

View file

@ -4,8 +4,6 @@ mod delete_document;
mod device_id_header; mod device_id_header;
mod fetch_document_version; mod fetch_document_version;
mod fetch_document_version_content; mod fetch_document_version_content;
mod fetch_latest_document_version;
mod fetch_latest_documents;
mod index; mod index;
mod ping; mod ping;
mod rate_limit; mod rate_limit;
@ -14,13 +12,14 @@ mod responses;
mod update_document; mod update_document;
mod websocket; mod websocket;
use anyhow::{Context as _, Result}; use anyhow::{Context as _, Result, anyhow};
use auth::auth_middleware; use auth::auth_middleware;
use axum::{ use axum::{
Router, Router,
extract::{DefaultBodyLimit, Request}, extract::{DefaultBodyLimit, Request},
http::{self, HeaderValue, Method}, http::{self, HeaderValue, Method},
middleware, middleware,
response::IntoResponse,
routing::{IntoMakeService, delete, get, post, put}, routing::{IntoMakeService, delete, get, post, put},
}; };
use device_id_header::DEVICE_ID_HEADER_NAME; use device_id_header::DEVICE_ID_HEADER_NAME;
@ -42,6 +41,7 @@ use crate::{
app_state::AppState, app_state::AppState,
config::{Config, server_config::ServerConfig}, config::{Config, server_config::ServerConfig},
consts::GRACEFUL_SHUTDOWN_TIMEOUT, consts::GRACEFUL_SHUTDOWN_TIMEOUT,
errors::not_found_error,
}; };
pub async fn create_server(config: Config) -> Result<()> { pub async fn create_server(config: Config) -> Result<()> {
@ -95,6 +95,7 @@ pub async fn create_server(config: Config) -> Result<()> {
.on_failure(DefaultOnFailure::new().level(Level::ERROR)), .on_failure(DefaultOnFailure::new().level(Level::ERROR)),
) )
.with_state(app_state.clone()) .with_state(app_state.clone())
.fallback(handle_404)
.into_make_service(); .into_make_service();
start_server(app, &server_config, app_state).await start_server(app, &server_config, app_state).await
@ -131,18 +132,10 @@ fn build_cors_layer(server_config: &ServerConfig) -> Result<CorsLayer> {
fn get_authed_routes(app_state: AppState) -> Router<AppState> { fn get_authed_routes(app_state: AppState) -> Router<AppState> {
Router::new() Router::new()
.route(
"/vaults/:vault_id/documents",
get(fetch_latest_documents::fetch_latest_documents),
)
.route( .route(
"/vaults/:vault_id/documents", "/vaults/:vault_id/documents",
post(create_document::create_document), post(create_document::create_document),
) )
.route(
"/vaults/:vault_id/documents/:document_id",
get(fetch_latest_document_version::fetch_latest_document_version),
)
.route( .route(
"/vaults/:vault_id/documents/:document_id/binary", "/vaults/:vault_id/documents/:document_id/binary",
put(update_document::update_binary), put(update_document::update_binary),
@ -233,3 +226,7 @@ async fn shutdown_signal() {
() = terminate => {}, () = terminate => {},
} }
} }
async fn handle_404() -> impl IntoResponse {
not_found_error(anyhow!("Endpoint not found"))
}

View file

@ -34,7 +34,7 @@ pub async fn auth_middleware(
.ok_or_else(|| unauthenticated_error(anyhow::anyhow!("Missing vault_id")))?, .ok_or_else(|| unauthenticated_error(anyhow::anyhow!("Missing vault_id")))?,
); );
let user = auth(&state, token, &vault_id)?; let user = authenticate_for_vault(&state, token, &vault_id)?;
req.extensions_mut().insert(user); req.extensions_mut().insert(user);
@ -50,7 +50,11 @@ pub fn authenticate(state: &AppState, token: &str) -> Result<User, SyncServerErr
.ok_or_else(|| unauthenticated_error(anyhow::anyhow!("Invalid token"))) .ok_or_else(|| unauthenticated_error(anyhow::anyhow!("Invalid token")))
} }
pub fn auth(state: &AppState, token: &str, vault_id: &VaultId) -> Result<User, SyncServerError> { pub fn authenticate_for_vault(
state: &AppState,
token: &str,
vault_id: &VaultId,
) -> Result<User, SyncServerError> {
let user = authenticate(state, token)?; let user = authenticate(state, token)?;
if match user.vault_access { if match user.vault_access {

View file

@ -136,9 +136,7 @@ pub async fn create_document(
{ {
info!( info!(
"Lost-create recovery: binding retry at `{sanitized_relative_path}` to existing doc {} (was at `{}`) in vault `{vault_id}` for device `{}`", "Lost-create recovery: binding retry at `{sanitized_relative_path}` to existing doc {} (was at `{}`) in vault `{vault_id}` for device `{}`",
lost_create.document_id, lost_create.document_id, lost_create.relative_path, device_id.0
lost_create.relative_path,
device_id.0
); );
return update_document::update_document( return update_document::update_document(
&sanitized_relative_path, &sanitized_relative_path,

View file

@ -136,8 +136,7 @@ async fn websocket(
// catch-up and in a contended-then-released broadcast is // catch-up and in a contended-then-released broadcast is
// delivered exactly once (via the catch-up). // delivered exactly once (via the catch-up).
let send_guard = state.broadcasts.acquire_send_lock(&vault_id).await; let send_guard = state.broadcasts.acquire_send_lock(&vault_id).await;
let mut broadcast_receiver = match state.broadcasts.get_receiver(vault_id.clone(), max_clients) let mut broadcast_receiver = match state.broadcasts.get_receiver(&vault_id, max_clients) {
{
Ok(receiver) => receiver, Ok(receiver) => receiver,
Err(err) => { Err(err) => {
drop(send_guard); drop(send_guard);