Fix lints & format
This commit is contained in:
parent
6d40097bcd
commit
792f57dc7e
36 changed files with 342 additions and 1687 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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`:
|
||||||
|
|
|
||||||
|
|
@ -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")) &&
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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:`
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
]
|
||||||
]
|
};
|
||||||
};
|
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
);
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
]
|
||||||
]
|
};
|
||||||
};
|
|
||||||
|
|
|
||||||
|
|
@ -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) ?? "";
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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";
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
1257
frontend/package-lock.json
generated
1257
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -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"), {
|
||||||
|
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
|
|
@ -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");
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 () => {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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" },
|
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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(_) => {}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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"))
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue