,,
This commit is contained in:
parent
0c75d3b4c2
commit
3dab48f58b
55 changed files with 1141 additions and 1376 deletions
|
|
@ -211,8 +211,9 @@ docker build -f local-client-cli/Dockerfile -t vault-link-cli:test .
|
|||
|
||||
The complete client record (database, settings, queued logical notifications and
|
||||
server history checkpoint) is saved atomically in `.vaultlink/sync-data.json`.
|
||||
Existing database-only files migrate on the next save. Invalid or unreadable
|
||||
metadata is reported instead of silently starting with empty state.
|
||||
Only this complete-record format is supported; database-only files are not
|
||||
converted. Invalid or unreadable JSON is reported instead of silently starting
|
||||
with empty state.
|
||||
|
||||
File moves use an exclusive copy followed by deletion of the source. An
|
||||
interruption may leave both files; the next scan reconciles the visible files.
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import * as path from "node:path";
|
|||
import { ClientPersistence } from "./client-persistence";
|
||||
import type { StoredClient } from "sync-client";
|
||||
|
||||
test("CLI persistence retains the whole record and migrates database-only saves", async (t) => {
|
||||
test("CLI persistence retains the whole record and reports unreadable metadata", async (t) => {
|
||||
const directory = await fs.mkdtemp(
|
||||
path.join(tmpdir(), "vaultlink-metadata-")
|
||||
);
|
||||
|
|
@ -18,7 +18,13 @@ test("CLI persistence retains the whole record and migrates database-only saves"
|
|||
});
|
||||
const record: StoredClient = {
|
||||
settings: { token: "old-token", vaultName: "vault" },
|
||||
database: { lastSeenUpdateId: 12 },
|
||||
database: {
|
||||
vaultKey: "vault",
|
||||
fileManifest: { fileManifestId: 0, entries: {} },
|
||||
local: {},
|
||||
documents: {},
|
||||
lastSeenUpdateId: 12
|
||||
},
|
||||
localChanges: [
|
||||
{ type: "delete", path: "removed.md", changeId: "queued" }
|
||||
],
|
||||
|
|
@ -30,12 +36,6 @@ test("CLI persistence retains the whole record and migrates database-only saves"
|
|||
...record,
|
||||
settings: { ...record.settings, token: "current-token" }
|
||||
});
|
||||
const legacy = {
|
||||
fileManifest: { fileManifestId: 0, entries: {} },
|
||||
lastSeenUpdateId: 0
|
||||
};
|
||||
await fs.writeFile(file, JSON.stringify(legacy));
|
||||
assert.deepEqual((await store.load()).database, legacy);
|
||||
await fs.writeFile(file, "{truncated");
|
||||
await assert.rejects(store.load(), SyntaxError);
|
||||
await fs.rm(file);
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ import { randomUUID } from "node:crypto";
|
|||
import type {
|
||||
PersistenceProvider,
|
||||
StoredClient,
|
||||
StoredDatabase,
|
||||
SyncSettings
|
||||
} from "sync-client";
|
||||
|
||||
|
|
@ -35,11 +34,7 @@ export class ClientPersistence implements PersistenceProvider<StoredClient> {
|
|||
Array.isArray(parsed)
|
||||
)
|
||||
throw new Error("Invalid client metadata");
|
||||
// The sync engine validates and migrates the persisted fields.
|
||||
const data = parsed as StoredClient & Partial<StoredDatabase>;
|
||||
// Earlier CLI versions saved the database alone.
|
||||
const stored: StoredClient =
|
||||
"fileManifest" in data ? { database: data } : data;
|
||||
const stored = parsed as StoredClient;
|
||||
return {
|
||||
...stored,
|
||||
settings: { ...stored.settings, ...this.settings }
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
# Sync client (API v5)
|
||||
# Sync client (API v4)
|
||||
|
||||
The engine uses one serialized loop per vault and requires Web Crypto (SHA-256). Local notifications and WebSocket
|
||||
`vaultChanged` hints wake it; complete disk scans and the durable server event log are the
|
||||
|
|
@ -17,6 +17,8 @@ pending requests saved and are reported to the caller. Transient failures retry;
|
|||
authentication and protocol errors wait for an explicit wake or settings change.
|
||||
Pausing or destroying the client aborts its HTTP session, including body reads;
|
||||
resuming starts a fresh session. Connection checks can run while sync is disabled.
|
||||
WebSocket shutdown retires callbacks immediately and drains message handlers
|
||||
already running; it does not wait for the transport's close handshake.
|
||||
|
||||
## State and policy
|
||||
|
||||
|
|
@ -38,10 +40,13 @@ and content heads before merging. An interrupted fetch restarts from the last
|
|||
incorporated cursor; pagination progress is not persisted. Intermediate changes
|
||||
that the server has already reverted must not overwrite local edits.
|
||||
|
||||
A permanent upload rejection is recorded in metadata. An unchanged rejected payload is skipped so other files can sync; the
|
||||
failure remains visible in history and `waitUntilFinished()`. Editing it, resetting
|
||||
the client, or changing settings permits a fresh attempt. Earlier attempts that
|
||||
committed despite a lost reply are incorporated through normal event replay.
|
||||
A permanent upload rejection pauses all sync, including after restart. The exact
|
||||
pending request and rejection message stay in metadata, and the failure remains
|
||||
visible in history and `waitUntilFinished()`. Resetting the client or changing
|
||||
settings retries that request before reading newer remote changes. This recovers
|
||||
earlier attempts that committed despite a lost reply without separate receipt
|
||||
bookkeeping. Local edits made while paused are uploaded after the pending request
|
||||
is resolved; editing a file alone does not retry a rejected request.
|
||||
|
||||
For each document ID (absence means deletion), use the server path if local is
|
||||
unchanged, use local if the server is unchanged, and use the server when both
|
||||
|
|
@ -57,6 +62,8 @@ are implicit; empty directories are not synced.
|
|||
- Content is uploaded before new membership; the client chooses and persists UUIDs.
|
||||
- Text uses three-way `reconcile`. First contact at a matching path adopts the
|
||||
remote ID and reconciles differing text with an empty parent.
|
||||
- In-memory snapshots contain bytes and their hash. Base64 encoding is limited to
|
||||
wire payloads; pending requests persist the exact payload needed for retries.
|
||||
- Concurrent binary/unmergeable edits keep the server version. If only one side
|
||||
changed, keep that side. No local backup archive is created.
|
||||
- Deletion follows the file manifest decision, including when the file has local edits.
|
||||
|
|
@ -84,11 +91,13 @@ snapshot. A file edited after this reset is also preserved as a separate local
|
|||
document; recovery never persists another copy of its content. Unsent edits with
|
||||
an old base become separate local documents; path conflicts preserve them under
|
||||
conflict names instead of applying a diff to an unrelated restored version.
|
||||
Recovery resumes after interrupted state saves. Existing API v4 client state
|
||||
without a checkpoint takes this same conservative bootstrap on upgrade.
|
||||
Recovery resumes after interrupted state saves. Only the current persisted state
|
||||
format is supported; old client stores are not converted.
|
||||
|
||||
Status observers cannot stop the background engine by throwing or rejecting.
|
||||
Their failures are reported, and remaining observers continue receiving updates.
|
||||
Remote cursor tracking retains the latest future update and the last usable
|
||||
position for each device, so cursors remain visible while file content catches up.
|
||||
|
||||
## Required adapter contract
|
||||
|
||||
|
|
@ -129,4 +138,4 @@ between files using their document identities; the next scan rereads content. Co
|
|||
replacement. Unexpected eligible occupants receive visible conflict names;
|
||||
ignored and oversized occupants stay in place. External writers can still change
|
||||
files between observations, so these checks do not promise transactional file
|
||||
contents or perfect identity reconstruction. There is no automated v2/v3 state migration.
|
||||
contents or perfect identity reconstruction. There is no persisted-state migration.
|
||||
|
|
|
|||
|
|
@ -2,6 +2,5 @@ export const TIMEOUT_FOR_MERGING_HISTORY_ENTRIES_IN_SECONDS = 60;
|
|||
export const MAX_LOG_MESSAGE_COUNT = 100000;
|
||||
export const MAX_HISTORY_ENTRY_COUNT = 5000;
|
||||
export const SUPPORTED_API_VERSION = 4;
|
||||
export const WEBSOCKET_DISCONNECT_TIMEOUT_IN_S = 10;
|
||||
export const CURSOR_HEARTBEAT_INTERVAL_MS = 15_000;
|
||||
export const WEBSOCKET_RECEIVE_TIMEOUT_MS = CURSOR_HEARTBEAT_INTERVAL_MS * 3;
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import type { RelativePath } from "../persistence/database";
|
||||
|
||||
export class SyncResetError extends Error {
|
||||
public constructor() {
|
||||
super("SyncClient has been reset, cleaning up");
|
||||
|
|
@ -22,7 +24,7 @@ export class AuthenticationError extends Error {
|
|||
export class FileNotFoundError extends Error {
|
||||
public constructor(
|
||||
message: string,
|
||||
public readonly filePath: string
|
||||
public readonly filePath: RelativePath
|
||||
) {
|
||||
super(message);
|
||||
this.name = "FileNotFoundError";
|
||||
|
|
|
|||
|
|
@ -1,11 +1,15 @@
|
|||
import { reconcile } from "reconcile-text";
|
||||
import type { FileSystemOperations } from "./filesystem-operations";
|
||||
import type { Database, EngineState } from "../persistence/database";
|
||||
import type {
|
||||
Database,
|
||||
DocumentId,
|
||||
EngineState,
|
||||
RelativePath
|
||||
} from "../persistence/database";
|
||||
import {
|
||||
fromStoredSnapshot,
|
||||
toStoredSnapshot,
|
||||
hashSnapshot,
|
||||
type FileSnapshot,
|
||||
type StoredSnapshot
|
||||
type HashedSnapshot
|
||||
} from "../snapshot";
|
||||
import { isInternalPath } from "../utils/portable-path";
|
||||
import { FilePaths } from "./file-paths";
|
||||
|
|
@ -25,8 +29,8 @@ import {
|
|||
} from "../errors/errors";
|
||||
|
||||
export interface FileWrite {
|
||||
expected?: StoredSnapshot;
|
||||
replacement: StoredSnapshot;
|
||||
expected?: HashedSnapshot;
|
||||
replacement: HashedSnapshot;
|
||||
}
|
||||
|
||||
type Mutation = (action: () => Promise<void>) => Promise<void>;
|
||||
|
|
@ -42,7 +46,7 @@ export class FileOperations {
|
|||
private readonly serverConfig: ServerConfig,
|
||||
|
||||
private readonly filesApplied: (
|
||||
paths: readonly string[]
|
||||
paths: readonly RelativePath[]
|
||||
) => void = () => undefined,
|
||||
|
||||
private readonly localChanges: {
|
||||
|
|
@ -51,14 +55,14 @@ export class FileOperations {
|
|||
} = { entries: () => [], flush: async () => undefined },
|
||||
|
||||
private readonly isProtectedFile: (
|
||||
path: string,
|
||||
path: RelativePath,
|
||||
size: number
|
||||
) => boolean = () => false
|
||||
) {
|
||||
this.paths = new FilePaths(fs, isProtectedFile);
|
||||
}
|
||||
|
||||
public async read(path: string): Promise<Uint8Array> {
|
||||
public async read(path: RelativePath): Promise<Uint8Array> {
|
||||
const snapshot = await this.fs.read(path);
|
||||
|
||||
if (!snapshot)
|
||||
|
|
@ -67,18 +71,19 @@ export class FileOperations {
|
|||
return snapshot.content;
|
||||
}
|
||||
|
||||
public async snapshot(path: string): Promise<StoredSnapshot | undefined> {
|
||||
public async snapshot(
|
||||
path: RelativePath
|
||||
): Promise<HashedSnapshot | undefined> {
|
||||
const snapshot = await this.fs.read(path);
|
||||
return snapshot && toStoredSnapshot(snapshot);
|
||||
return snapshot && hashSnapshot(snapshot);
|
||||
}
|
||||
|
||||
public async listFilesRecursively(): Promise<string[]> {
|
||||
public async listFilesRecursively(): Promise<RelativePath[]> {
|
||||
return (await this.fs.listFilesRecursively()).filter(
|
||||
(path) => !isInternalPath(path)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
public async captureChange(
|
||||
change: LocalChange,
|
||||
previous: readonly LocalChange[]
|
||||
|
|
@ -95,7 +100,7 @@ export class FileOperations {
|
|||
|
||||
public async apply(
|
||||
next: EngineState,
|
||||
writes: Partial<Record<string, FileWrite>> = {},
|
||||
writes: Partial<Record<DocumentId, FileWrite>> = {},
|
||||
validatePlan: () => void = () => undefined
|
||||
): Promise<void> {
|
||||
await this.localChanges.flush();
|
||||
|
|
@ -116,7 +121,7 @@ export class FileOperations {
|
|||
const current = structuredClone(this.database.state);
|
||||
current.lastSeenUpdateId = next.lastSeenUpdateId;
|
||||
this.applying = current;
|
||||
const paths = new Set<string>();
|
||||
const paths = new Set<RelativePath>();
|
||||
const observeChanges = async (): Promise<void> => {
|
||||
await this.localChanges.flush();
|
||||
guard();
|
||||
|
|
@ -129,7 +134,8 @@ export class FileOperations {
|
|||
state,
|
||||
change,
|
||||
(path) =>
|
||||
isInternalPath(path) || this.isProtectedFile(path, 0)
|
||||
isInternalPath(path) ||
|
||||
this.isProtectedFile(path, 0)
|
||||
);
|
||||
state.lastAppliedLocalChangeId = change.changeId;
|
||||
}
|
||||
|
|
@ -141,9 +147,10 @@ export class FileOperations {
|
|||
...Object.keys(next.local)
|
||||
])) {
|
||||
await observeChanges();
|
||||
let from = current.documents[id]?.observedHash !== undefined
|
||||
? current.local[id]
|
||||
: undefined;
|
||||
let from =
|
||||
current.documents[id]?.observedHash !== undefined
|
||||
? current.local[id]
|
||||
: undefined;
|
||||
let to = next.local[id];
|
||||
const write = writes[id];
|
||||
if (!write && (from === undefined || from === to)) continue;
|
||||
|
|
@ -182,9 +189,10 @@ export class FileOperations {
|
|||
mutate
|
||||
);
|
||||
next.local[id] = to;
|
||||
from = current.documents[id]?.observedHash !== undefined
|
||||
? current.local[id]
|
||||
: undefined;
|
||||
from =
|
||||
current.documents[id]?.observedHash !== undefined
|
||||
? current.local[id]
|
||||
: undefined;
|
||||
if (from !== undefined) {
|
||||
await mutate(async () => {
|
||||
current.local[id] = to;
|
||||
|
|
@ -195,9 +203,7 @@ export class FileOperations {
|
|||
}
|
||||
if (write) {
|
||||
const snapshot =
|
||||
from === undefined
|
||||
? undefined
|
||||
: await this.fs.read(to);
|
||||
from === undefined ? undefined : await this.fs.read(to);
|
||||
if (from !== undefined && !snapshot) {
|
||||
delete next.local[id];
|
||||
continue;
|
||||
|
|
@ -229,13 +235,12 @@ export class FileOperations {
|
|||
}
|
||||
|
||||
private async updateContent(
|
||||
path: string,
|
||||
path: RelativePath,
|
||||
current: FileSnapshot | undefined,
|
||||
write: FileWrite,
|
||||
extensions: string[]
|
||||
): Promise<string> {
|
||||
const replacement = fromStoredSnapshot(write.replacement);
|
||||
const base = write.expected && fromStoredSnapshot(write.expected);
|
||||
const { replacement, expected: base } = write;
|
||||
if (
|
||||
current &&
|
||||
base &&
|
||||
|
|
@ -258,14 +263,17 @@ export class FileOperations {
|
|||
const result =
|
||||
current && write.expected
|
||||
? await mergeContent(
|
||||
path,
|
||||
write.expected,
|
||||
await toStoredSnapshot(current),
|
||||
write.replacement,
|
||||
extensions
|
||||
)
|
||||
path,
|
||||
write.expected,
|
||||
await hashSnapshot(current),
|
||||
write.replacement,
|
||||
extensions
|
||||
)
|
||||
: write.replacement;
|
||||
await this.fs.write(path, fromStoredSnapshot(result));
|
||||
await this.fs.write(path, {
|
||||
content: result.content,
|
||||
cursors: result.cursors
|
||||
});
|
||||
return result.hash;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,22 +1,22 @@
|
|||
import { v4 as uuid } from "uuid";
|
||||
import type { FileSystemOperations } from "./filesystem-operations";
|
||||
import type { EngineState } from "../persistence/database";
|
||||
import type { DocumentId, EngineState, RelativePath } from "../persistence/database";
|
||||
import { allocatePortablePath, isInternalPath } from "../utils/portable-path";
|
||||
|
||||
/** Resolve occupied rename destinations without discarding another file. */
|
||||
export class FilePaths {
|
||||
public constructor(
|
||||
private readonly fs: FileSystemOperations,
|
||||
private readonly isProtectedFile: (path: string, size: number) => boolean
|
||||
private readonly isProtectedFile: (path: RelativePath, size: number) => boolean
|
||||
) { }
|
||||
|
||||
public async prepareDestination(
|
||||
next: EngineState,
|
||||
current: EngineState,
|
||||
id: string,
|
||||
path: string,
|
||||
id: DocumentId,
|
||||
path: RelativePath,
|
||||
mutate: (action: () => Promise<void>) => Promise<void>
|
||||
): Promise<string> {
|
||||
): Promise<RelativePath> {
|
||||
try {
|
||||
for (; ;) {
|
||||
const obstruction = await this.obstruction(path);
|
||||
|
|
@ -97,8 +97,8 @@ export class FilePaths {
|
|||
}
|
||||
|
||||
private async obstruction(
|
||||
path: string
|
||||
): Promise<{ path: string; kind: "file" | "directory" } | undefined> {
|
||||
path: RelativePath
|
||||
): Promise<{ path: RelativePath; kind: "file" | "directory" } | undefined> {
|
||||
const parts = path.split("/");
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
const prefix = parts.slice(0, i + 1).join("/");
|
||||
|
|
@ -110,9 +110,9 @@ export class FilePaths {
|
|||
|
||||
private async conflictPath(
|
||||
next: EngineState,
|
||||
id: string,
|
||||
path: string
|
||||
): Promise<string> {
|
||||
id: DocumentId,
|
||||
path: RelativePath
|
||||
): Promise<RelativePath> {
|
||||
const occupied = { ...next.local };
|
||||
delete occupied[id];
|
||||
const files = (await this.fs.listFilesRecursively()).filter(
|
||||
|
|
@ -123,12 +123,12 @@ export class FilePaths {
|
|||
return allocatePortablePath(path, id, occupied);
|
||||
}
|
||||
|
||||
private async ensureParents(path: string): Promise<void> {
|
||||
private async ensureParents(path: RelativePath): Promise<void> {
|
||||
const parent = path.split("/").slice(0, -1).join("/");
|
||||
if (parent) await this.fs.createDirectory(parent);
|
||||
}
|
||||
|
||||
public async removeEmptyParents(path: string): Promise<void> {
|
||||
public async removeEmptyParents(path: RelativePath): Promise<void> {
|
||||
let parent = path.split("/").slice(0, -1).join("/");
|
||||
while (parent && !isInternalPath(parent)) {
|
||||
if ((await this.fs.listFilesRecursively(parent)).length) break;
|
||||
|
|
|
|||
|
|
@ -8,24 +8,24 @@ import type { FileSnapshot } from "../snapshot";
|
|||
* A successful scan is complete: unreadable directories must reject, not vanish.
|
||||
*/
|
||||
export interface FileSystemOperations {
|
||||
listFilesRecursively: (root?: string) => Promise<string[]>;
|
||||
listFilesRecursively: (root?: RelativePath) => Promise<RelativePath[]>;
|
||||
|
||||
/** Return undefined on missing file */
|
||||
read: (path: string) => Promise<FileSnapshot | undefined>;
|
||||
read: (path: RelativePath) => Promise<FileSnapshot | undefined>;
|
||||
|
||||
/** Reject symlinks and special files rather than following them and return undefined. */
|
||||
stat: (
|
||||
path: string
|
||||
path: RelativePath
|
||||
) => Promise<{ kind: "file" | "directory"; size: number } | undefined>;
|
||||
|
||||
/** Includes directories. */
|
||||
exists: (path: string) => Promise<boolean>;
|
||||
exists: (path: RelativePath) => Promise<boolean>;
|
||||
|
||||
/** Recursive, idempotent directory creation. */
|
||||
createDirectory: (path: string) => Promise<void>;
|
||||
createDirectory: (path: RelativePath) => Promise<void>;
|
||||
|
||||
/** Create or overwrite a file, applying optional editor cursor metadata. */
|
||||
write: (path: string, snapshot: FileSnapshot) => Promise<void>;
|
||||
write: (path: RelativePath, snapshot: FileSnapshot) => Promise<void>;
|
||||
|
||||
// Atomically update the content of a text file.
|
||||
atomicUpdateText: (
|
||||
|
|
@ -34,11 +34,11 @@ export interface FileSystemOperations {
|
|||
) => Promise<string>;
|
||||
|
||||
/** Move a file without replacing an existing destination. */
|
||||
rename: (from: string, to: string) => Promise<void>;
|
||||
rename: (from: RelativePath, to: RelativePath) => Promise<void>;
|
||||
|
||||
/** Idempotently unlink one regular file. */
|
||||
deleteFile: (path: string) => Promise<void>;
|
||||
deleteFile: (path: RelativePath) => Promise<void>;
|
||||
|
||||
/** Prune directories using rmdir only. Reject files and nonempty directories. */
|
||||
deleteDirectory: (path: string) => Promise<void>;
|
||||
deleteDirectory: (path: RelativePath) => Promise<void>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,7 +21,12 @@ export {
|
|||
export { Logger, LogLevel, LogLine } from "./tracing/logger";
|
||||
export { type SyncSettings, DEFAULT_SETTINGS } from "./persistence/settings";
|
||||
export { rateLimit } from "./utils/rate-limit";
|
||||
export type { RelativePath, StoredDatabase } from "./persistence/database";
|
||||
export type {
|
||||
DocumentId,
|
||||
RelativePath,
|
||||
StoredDatabase,
|
||||
VaultUpdateId
|
||||
} from "./persistence/database";
|
||||
export type { FileSystemOperations } from "./file-operations/filesystem-operations";
|
||||
export type { FileSnapshot } from "./snapshot";
|
||||
export type { MetadataPersistenceProvider as PersistenceProvider } from "./persistence/metadata-persistence-provider";
|
||||
|
|
|
|||
|
|
@ -1,83 +1,38 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import { Database, emptyState, type StoredDatabase } from "./database";
|
||||
import { Database, emptyState } from "./database";
|
||||
|
||||
test("old state loads without duplicate flags, full recovery content, or acknowledged heads", async () => {
|
||||
const head = {
|
||||
documentId: "clean",
|
||||
vaultUpdateId: 1,
|
||||
contentSize: 3,
|
||||
updatedDate: "2026-09-23",
|
||||
userId: "user",
|
||||
deviceId: "device"
|
||||
test("current state keeps absent optional fields absent across save and reload", async () => {
|
||||
const initial = emptyState("vault");
|
||||
initial.local = { local: "local.md", remote: "remote.md" };
|
||||
initial.documents = {
|
||||
local: { observedHash: "local" },
|
||||
remote: { remote: { vaultUpdateId: 1, contentSize: 3 } },
|
||||
empty: {}
|
||||
};
|
||||
const legacy = {
|
||||
...emptyState("vault"),
|
||||
initialized: true,
|
||||
lastSeenUpdateId: 2,
|
||||
protectedPaths: ["ignored.md"],
|
||||
local: {
|
||||
clean: "clean.md",
|
||||
recovering: "recovering.md",
|
||||
deferred: "deferred.md"
|
||||
},
|
||||
documents: {
|
||||
clean: {
|
||||
materialized: true,
|
||||
observedHash: "clean",
|
||||
base: { ...head, hash: "clean" }
|
||||
let saved = structuredClone(initial);
|
||||
const restart = (): Database =>
|
||||
new Database(
|
||||
saved,
|
||||
async (next) => {
|
||||
saved = structuredClone(next);
|
||||
},
|
||||
recovering: {
|
||||
materialized: true,
|
||||
observedHash: "edited",
|
||||
recoveryBase: { contentBase64: "T0xE", hash: "old" }
|
||||
},
|
||||
deferred: { materialized: false, observedHash: undefined }
|
||||
},
|
||||
remoteHeads: {
|
||||
clean: head,
|
||||
deferred: { ...head, documentId: "deferred" }
|
||||
}
|
||||
};
|
||||
const saves: StoredDatabase[] = [];
|
||||
const db = new Database(
|
||||
legacy,
|
||||
async (next) => {
|
||||
saves.push(next);
|
||||
},
|
||||
"vault",
|
||||
async () => saves.at(-1)
|
||||
);
|
||||
"vault",
|
||||
async () => structuredClone(saved)
|
||||
);
|
||||
const db = restart();
|
||||
assert.deepEqual(db.state, initial);
|
||||
await db.save();
|
||||
const saved = saves.at(-1);
|
||||
assert(saved);
|
||||
assert.deepEqual(saved.documents, {
|
||||
clean: {
|
||||
base: { vaultUpdateId: 1, hash: "clean" },
|
||||
observedHash: "clean"
|
||||
},
|
||||
recovering: {
|
||||
base: { vaultUpdateId: null, hash: "old" },
|
||||
observedHash: "edited"
|
||||
},
|
||||
deferred: {
|
||||
base: undefined,
|
||||
observedHash: undefined,
|
||||
remote: { vaultUpdateId: head.vaultUpdateId, contentSize: head.contentSize }
|
||||
}
|
||||
});
|
||||
assert.equal(saved.lastSeenUpdateId, 2);
|
||||
for (const field of ["initialized", "remoteHeads", "protectedPaths"])
|
||||
assert(!(field in saved));
|
||||
assert(!JSON.stringify(saved).includes("contentBase64"));
|
||||
assert.deepEqual(saved, initial);
|
||||
assert.deepEqual(restart().state, initial);
|
||||
});
|
||||
|
||||
test("old reset intent leaves the cursor absent after an uncertain save reload", async () => {
|
||||
const legacy = {
|
||||
...emptyState("vault"),
|
||||
initialized: false,
|
||||
historyRecovery: true,
|
||||
lastSeenUpdateId: 0
|
||||
test("current reset state is restored after an uncertain save", async () => {
|
||||
const saved = emptyState("vault");
|
||||
saved.local = { note: "note.md" };
|
||||
saved.documents.note = {
|
||||
base: { vaultUpdateId: null, hash: "clean" },
|
||||
observedHash: "clean"
|
||||
};
|
||||
const db = new Database(
|
||||
undefined,
|
||||
|
|
@ -85,10 +40,9 @@ test("old reset intent leaves the cursor absent after an uncertain save reload",
|
|||
throw new Error("interrupted");
|
||||
},
|
||||
"vault",
|
||||
async () => legacy
|
||||
async () => structuredClone(saved)
|
||||
);
|
||||
await assert.rejects(db.save(), /interrupted/);
|
||||
await db.recoverPersistence();
|
||||
assert.equal(db.state.lastSeenUpdateId, undefined);
|
||||
assert(!("historyRecovery" in db.state));
|
||||
assert.deepEqual(db.state, saved);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import type { PendingRequest, UnconfirmedRequest } from "./requests";
|
||||
import { upgradeState } from "./upgrade-state";
|
||||
import type { PendingRequest } from "./requests";
|
||||
import type { FileManifest } from "../services/types/FileManifest";
|
||||
import type { FileManifestEntries } from "../types/file-manifest-entries";
|
||||
|
||||
|
|
@ -8,48 +7,46 @@ export type DocumentId = string;
|
|||
export type RelativePath = string;
|
||||
|
||||
export interface DocumentMetadata {
|
||||
parentVersionId: number;
|
||||
parentVersionId: VaultUpdateId;
|
||||
hash: string;
|
||||
}
|
||||
|
||||
export interface ContentHead {
|
||||
vaultUpdateId: number;
|
||||
vaultUpdateId: VaultUpdateId;
|
||||
contentSize: number;
|
||||
}
|
||||
|
||||
export interface DocumentState {
|
||||
/** A null version retains only the clean hash after a server history reset. */
|
||||
base?: { vaultUpdateId: number | null; hash: string };
|
||||
base?: { vaultUpdateId: VaultUpdateId | null; hash: string };
|
||||
/** Last observed disk hash; an empty string means a notification awaits scanning. */
|
||||
observedHash?: string;
|
||||
/** A remote version still to be incorporated, including excluded downloads. */
|
||||
remote?: ContentHead;
|
||||
}
|
||||
|
||||
export type { PendingRequest, UnconfirmedRequest } from "./requests";
|
||||
export type { PendingRequest } from "./requests";
|
||||
|
||||
export interface EngineState {
|
||||
vaultKey: string;
|
||||
fileManifest: FileManifest;
|
||||
local: FileManifestEntries;
|
||||
documents: Record<string, DocumentState>;
|
||||
documents: Record<DocumentId, DocumentState>;
|
||||
/** Absent until a vault snapshot is applied; also marks a durable history reset. */
|
||||
lastSeenUpdateId?: number;
|
||||
lastSeenUpdateId?: VaultUpdateId;
|
||||
pending?: PendingRequest;
|
||||
/** A rejected retry does not prove an earlier attempt failed to commit. */
|
||||
unconfirmed?: UnconfirmedRequest[];
|
||||
/** Commits consumption of the saved notification queue with the identity map. */
|
||||
lastAppliedLocalChangeId?: string;
|
||||
/** Original server paths while local files are excluded; null means absent. */
|
||||
excluded?: Record<string, string | null>;
|
||||
excluded?: Record<DocumentId, RelativePath | null>;
|
||||
}
|
||||
|
||||
export type StoredDatabase = EngineState;
|
||||
|
||||
// Read-only view used by cursor tracking and status reporting.
|
||||
export interface DocumentRecord {
|
||||
documentId: string;
|
||||
relativePath: string;
|
||||
documentId: DocumentId;
|
||||
relativePath: RelativePath;
|
||||
metadata?: DocumentMetadata;
|
||||
}
|
||||
|
||||
|
|
@ -62,13 +59,11 @@ export function emptyState(vaultKey: string): StoredDatabase {
|
|||
};
|
||||
}
|
||||
|
||||
function canRebindEmpty(state: Partial<StoredDatabase> | undefined): boolean {
|
||||
function canRebindEmpty(state: StoredDatabase): boolean {
|
||||
return (
|
||||
state !== undefined &&
|
||||
state.lastSeenUpdateId === undefined &&
|
||||
!state.pending &&
|
||||
(state.unconfirmed?.length ?? 0) === 0 &&
|
||||
Object.keys(state.local ?? {}).length === 0
|
||||
Object.keys(state.local).length === 0
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -76,15 +71,15 @@ export class Database {
|
|||
public state: StoredDatabase;
|
||||
private needsReload = false;
|
||||
public constructor(
|
||||
initial: Partial<StoredDatabase> | undefined,
|
||||
initial: StoredDatabase | undefined,
|
||||
private readonly saveData: (data: StoredDatabase) => Promise<void>,
|
||||
vaultKey: string,
|
||||
private readonly loadData: () => Promise<StoredDatabase | undefined>
|
||||
) {
|
||||
const state =
|
||||
initial !== undefined && Object.keys(initial).length > 0
|
||||
? upgradeState(initial as StoredDatabase)
|
||||
: emptyState(vaultKey);
|
||||
initial === undefined
|
||||
? emptyState(vaultKey)
|
||||
: structuredClone(initial);
|
||||
if (state.vaultKey !== vaultKey && !canRebindEmpty(state)) {
|
||||
throw new Error(
|
||||
"Incompatible vault identity. Preserve it for recovery and initialize a separate state store."
|
||||
|
|
@ -118,7 +113,7 @@ export class Database {
|
|||
public async recoverPersistence(): Promise<void> {
|
||||
if (!this.needsReload) return;
|
||||
const saved = await this.loadData();
|
||||
if (saved) this.state = upgradeState(saved);
|
||||
if (saved) this.state = structuredClone(saved);
|
||||
this.needsReload = false;
|
||||
}
|
||||
|
||||
|
|
@ -130,7 +125,7 @@ export class Database {
|
|||
return Object.keys(this.state.local).length;
|
||||
}
|
||||
|
||||
public getDocumentByDocumentId(id: string): DocumentRecord | undefined {
|
||||
public getDocumentByDocumentId(id: DocumentId): DocumentRecord | undefined {
|
||||
const relativePath = this.state.local[id];
|
||||
if (relativePath === undefined) return undefined;
|
||||
const base = this.state.documents[id]?.base;
|
||||
|
|
@ -148,7 +143,7 @@ export class Database {
|
|||
}
|
||||
|
||||
public getLatestDocumentByRelativePath(
|
||||
path: string
|
||||
path: RelativePath
|
||||
): DocumentRecord | undefined {
|
||||
const id = Object.keys(this.state.local).find(
|
||||
(id) => this.state.local[id] === path
|
||||
|
|
|
|||
|
|
@ -1,21 +1,14 @@
|
|||
import type { PutFileContent } from "../services/types/PutFileContent";
|
||||
import type { PushFileManifest } from "../services/types/PushFileManifest";
|
||||
import type { FileManifestEntries } from "../types/file-manifest-entries";
|
||||
import type { DocumentId } from "./database";
|
||||
|
||||
/** Exact wire payload and the hash needed to advance the base after acknowledgement. */
|
||||
export type PendingRequest =
|
||||
| { type: "content"; documentId: string; request: PutFileContent; hash: string }
|
||||
| { type: "fileManifest"; request: PushFileManifest };
|
||||
|
||||
/** Enough to recognize a late receipt or suppress an unchanged rejected submission. */
|
||||
export type UnconfirmedRequest = { requestId: string; rejection?: string } & (
|
||||
| { type: "content"; documentId: string; hash: string }
|
||||
| { type: "fileManifest"; entries: FileManifestEntries }
|
||||
/** Exact wire payload retained until acknowledgement, including after rejection. */
|
||||
export type PendingRequest = { rejection?: string } & (
|
||||
| {
|
||||
type: "content";
|
||||
documentId: DocumentId;
|
||||
request: PutFileContent;
|
||||
hash: string;
|
||||
}
|
||||
| { type: "fileManifest"; request: PushFileManifest }
|
||||
);
|
||||
|
||||
export function requestReceipt(request: PendingRequest, rejection?: string): UnconfirmedRequest {
|
||||
const receipt = { requestId: request.request.requestId, rejection };
|
||||
return request.type === "content"
|
||||
? { ...receipt, type: "content", documentId: request.documentId, hash: request.hash }
|
||||
: { ...receipt, type: "fileManifest", entries: request.request.entries };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import type { RelativePath } from "./database";
|
||||
import type { Logger } from "../tracing/logger";
|
||||
import { globsToRegexes } from "../utils/globs-to-regexes";
|
||||
import { isInternalPath } from "../utils/portable-path";
|
||||
|
|
@ -68,7 +69,7 @@ export class Settings {
|
|||
);
|
||||
}
|
||||
|
||||
public isIgnored(path: string): boolean {
|
||||
public isIgnored(path: RelativePath): boolean {
|
||||
return (
|
||||
isInternalPath(path) ||
|
||||
this.ignorePatterns.some((pattern) => pattern.test(path))
|
||||
|
|
|
|||
|
|
@ -1,72 +0,0 @@
|
|||
import type { ContentHead, DocumentState, EngineState } from "./database";
|
||||
import { requestReceipt, type PendingRequest, type UnconfirmedRequest } from "./requests";
|
||||
import type { StoredSnapshot } from "../snapshot";
|
||||
|
||||
type PreviousRequest = PendingRequest & {
|
||||
snapshot?: StoredSnapshot;
|
||||
response?: unknown;
|
||||
rejection?: string;
|
||||
};
|
||||
|
||||
function upgradeRequest(request: PreviousRequest): PendingRequest {
|
||||
return request.type === "content"
|
||||
? {
|
||||
type: "content", documentId: request.documentId,
|
||||
request: request.request, hash: request.snapshot?.hash ?? request.hash
|
||||
}
|
||||
: { type: "fileManifest", request: request.request };
|
||||
}
|
||||
|
||||
/** Normalize earlier stores at the boundary; none of their duplicate data is saved again. */
|
||||
export function upgradeState(state: EngineState): EngineState {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- Read previous persisted schemas at the migration boundary.
|
||||
const legacy = state as Omit<EngineState, "pending" | "unconfirmed" | "documents"> & {
|
||||
initialized?: boolean;
|
||||
historyRecovery?: boolean;
|
||||
remoteHeads?: Record<string, ContentHead>;
|
||||
pending?: PreviousRequest;
|
||||
unconfirmed?: (UnconfirmedRequest | PreviousRequest)[];
|
||||
documents: Record<string, DocumentState & {
|
||||
materialized?: boolean;
|
||||
bootstrap?: boolean;
|
||||
recoveryBase?: StoredSnapshot;
|
||||
}>;
|
||||
};
|
||||
const next: EngineState = {
|
||||
vaultKey: state.vaultKey,
|
||||
fileManifest: state.fileManifest,
|
||||
local: state.local,
|
||||
documents: {},
|
||||
lastSeenUpdateId: "initialized" in legacy &&
|
||||
(!legacy.initialized || legacy.historyRecovery === true)
|
||||
? undefined : state.lastSeenUpdateId,
|
||||
pending: legacy.pending && upgradeRequest(legacy.pending),
|
||||
unconfirmed: legacy.unconfirmed?.map((request) =>
|
||||
"request" in request
|
||||
? requestReceipt(upgradeRequest(request), request.rejection)
|
||||
: request
|
||||
),
|
||||
lastAppliedLocalChangeId: state.lastAppliedLocalChangeId,
|
||||
excluded: state.excluded
|
||||
};
|
||||
if (legacy.pending?.rejection && !legacy.pending.response) {
|
||||
(next.unconfirmed ??= []).push(requestReceipt(upgradeRequest(legacy.pending), legacy.pending.rejection));
|
||||
delete next.pending;
|
||||
}
|
||||
for (const id of new Set([...Object.keys(legacy.documents), ...Object.keys(legacy.remoteHeads ?? {})])) {
|
||||
const doc = legacy.documents[id] ?? {};
|
||||
const base = doc.recoveryBase
|
||||
? { vaultUpdateId: null, hash: doc.recoveryBase.hash }
|
||||
: doc.base && doc.bootstrap !== true
|
||||
? { vaultUpdateId: doc.base.vaultUpdateId, hash: doc.base.hash }
|
||||
: undefined;
|
||||
const observedHash = "materialized" in doc
|
||||
? doc.materialized === true ? doc.observedHash ?? "" : undefined
|
||||
: doc.observedHash;
|
||||
const remote = doc.remote ?? legacy.remoteHeads?.[id];
|
||||
next.documents[id] = { base, observedHash };
|
||||
if (remote && (observedHash === undefined || remote.vaultUpdateId > (base?.vaultUpdateId ?? 0)))
|
||||
next.documents[id].remote = { vaultUpdateId: remote.vaultUpdateId, contentSize: remote.contentSize };
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
|
@ -20,7 +20,10 @@ test("paused sync sends no requests, while connection checks still work", async
|
|||
let requests = 0;
|
||||
const service = new SyncService("device", settings(), async () => {
|
||||
requests++;
|
||||
return Response.json({ headEventId: 0, events: [] });
|
||||
return Response.json(
|
||||
{ headEventId: 0, endEventId: 0, events: [] },
|
||||
{ headers: { "X-Vault-Link-History": "0:empty" } }
|
||||
);
|
||||
}, { get: () => undefined, save: async () => { } });
|
||||
await assert.rejects(service.getEvents(0), SyncResetError);
|
||||
assert.equal(requests, 0);
|
||||
|
|
@ -42,7 +45,10 @@ test("pause aborts requests and fences late responses from an injected fetch", a
|
|||
signals.push(init!.signal!);
|
||||
return signals.length === 1
|
||||
? response.promise
|
||||
: Response.json({ headEventId: 0, events: [] });
|
||||
: Response.json(
|
||||
{ headEventId: 0, endEventId: 0, events: [] },
|
||||
{ headers: { "X-Vault-Link-History": "0:empty" } }
|
||||
);
|
||||
},
|
||||
{
|
||||
get: () => undefined,
|
||||
|
|
@ -66,7 +72,7 @@ test("pause aborts requests and fences late responses from an injected fetch", a
|
|||
)
|
||||
);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.deepEqual(saved, []);
|
||||
assert.deepEqual(saved, ["0:empty"]);
|
||||
});
|
||||
|
||||
for (const binary of [false, true]) {
|
||||
|
|
@ -179,3 +185,32 @@ test("history saves only new checkpoints, including a changed token at the same
|
|||
}
|
||||
assert.deepEqual(saved, ["1:first", "2:next", "2:replacement"]);
|
||||
});
|
||||
|
||||
for (const checkpoint of [undefined, ""]) {
|
||||
test(`successful sync responses reject ${checkpoint === undefined ? "missing" : "empty"} history checkpoints`, async () => {
|
||||
const service = new SyncService(
|
||||
"device",
|
||||
settings(),
|
||||
async () =>
|
||||
Response.json(
|
||||
{ headEventId: 0, endEventId: 0, events: [] },
|
||||
{
|
||||
headers: checkpoint === undefined
|
||||
? {}
|
||||
: { "X-Vault-Link-History": checkpoint }
|
||||
}
|
||||
),
|
||||
{
|
||||
get: (): undefined => undefined,
|
||||
save: async (): Promise<void> => {
|
||||
assert.fail("must not save a missing checkpoint");
|
||||
}
|
||||
}
|
||||
);
|
||||
service.resume();
|
||||
await assert.rejects(
|
||||
service.getEvents(0),
|
||||
/Missing server history checkpoint/
|
||||
);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import type { DocumentId, VaultUpdateId } from "../persistence/database";
|
||||
import type { Settings } from "../persistence/settings";
|
||||
import { abortable } from "../utils/abortable";
|
||||
import { VaultHistoryStatus } from "../types/vault-history-status";
|
||||
import type { DocumentUpdateResponse } from "./types/DocumentUpdateResponse";
|
||||
import type { DocumentVersionWithoutContent } from "./types/DocumentVersionWithoutContent";
|
||||
import type { EventBatch } from "./types/EventBatch";
|
||||
|
|
@ -31,12 +31,6 @@ export class SyncService {
|
|||
this.pause();
|
||||
}
|
||||
|
||||
public get vaultHistoryStatus(): VaultHistoryStatus {
|
||||
return this.history.get() !== undefined
|
||||
? VaultHistoryStatus.STORED
|
||||
: VaultHistoryStatus.MISSING;
|
||||
}
|
||||
|
||||
public pause(): void {
|
||||
this.session.abort(new SyncResetError());
|
||||
}
|
||||
|
|
@ -57,7 +51,7 @@ export class SyncService {
|
|||
return this.request("/vault-snapshot");
|
||||
}
|
||||
|
||||
public async getEvents(after: number): Promise<EventBatch> {
|
||||
public async getEvents(after: VaultUpdateId): Promise<EventBatch> {
|
||||
return this.request(`/events-since?after=${after}`);
|
||||
}
|
||||
|
||||
|
|
@ -68,13 +62,13 @@ export class SyncService {
|
|||
}
|
||||
|
||||
public async putFileContent(
|
||||
id: string,
|
||||
id: DocumentId,
|
||||
request: PutFileContent
|
||||
): Promise<DocumentUpdateResponse> {
|
||||
return this.request(`/documents/${id}`, request);
|
||||
}
|
||||
|
||||
public async getDocumentMetadata(id: string): Promise<DocumentVersionWithoutContent> {
|
||||
public async getDocumentMetadata(id: DocumentId): Promise<DocumentVersionWithoutContent> {
|
||||
return this.request(`/documents/${id}/metadata`);
|
||||
}
|
||||
|
||||
|
|
@ -82,8 +76,8 @@ export class SyncService {
|
|||
documentId,
|
||||
vaultUpdateId
|
||||
}: {
|
||||
documentId: string;
|
||||
vaultUpdateId: number;
|
||||
documentId: DocumentId;
|
||||
vaultUpdateId: VaultUpdateId;
|
||||
}): Promise<Uint8Array> {
|
||||
return this.request(
|
||||
`/documents/${documentId}/versions/${vaultUpdateId}/content`,
|
||||
|
|
@ -95,27 +89,23 @@ export class SyncService {
|
|||
|
||||
private getHistoryHeaders(): Record<string, string> {
|
||||
const checkpoint = this.history.get();
|
||||
return checkpoint !== undefined && checkpoint !== ""
|
||||
return checkpoint !== undefined
|
||||
? { "X-Vault-Link-History": checkpoint }
|
||||
: {};
|
||||
}
|
||||
|
||||
private async recordHistory(response: Response): Promise<void> {
|
||||
const checkpoint = response.headers.get("x-vault-link-history");
|
||||
if (checkpoint === null || checkpoint === "")
|
||||
throw new PermanentSyncError("Missing server history checkpoint");
|
||||
const previous = this.history.get();
|
||||
if (checkpoint === previous) return;
|
||||
if (
|
||||
checkpoint !== null &&
|
||||
checkpoint !== ""
|
||||
) {
|
||||
const previous = this.history.get();
|
||||
if (checkpoint === previous) return;
|
||||
if (
|
||||
previous === undefined ||
|
||||
previous === "" ||
|
||||
Number(checkpoint.split(":")[0]) >=
|
||||
previous === undefined ||
|
||||
Number(checkpoint.split(":")[0]) >=
|
||||
Number(previous.split(":")[0])
|
||||
)
|
||||
await this.history.save(checkpoint);
|
||||
}
|
||||
)
|
||||
await this.history.save(checkpoint);
|
||||
}
|
||||
|
||||
private checkHistory(response: Response): void {
|
||||
|
|
|
|||
|
|
@ -5,4 +5,4 @@ export type EventBatch = { headEventId: number,
|
|||
/**
|
||||
* Last event in this page; `head_event_id` is the vault's current head.
|
||||
*/
|
||||
endEventId?: number, events: Array<EventRecord>, };
|
||||
endEventId: number, events: Array<EventRecord>, };
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ isAuthenticated: boolean,
|
|||
*/
|
||||
mergeableFileExtensions: Array<string>,
|
||||
/**
|
||||
* API version ensuring backwards & forwards compatibility between the client
|
||||
* API version that must match exactly between the client
|
||||
* and server.
|
||||
*/
|
||||
supportedApiVersion: number, };
|
||||
|
|
|
|||
|
|
@ -199,18 +199,22 @@ describe("WebSocketManager", () => {
|
|||
throw new Error("Buffer full");
|
||||
};
|
||||
|
||||
assert.throws(() => {
|
||||
manager.sendHandshakeMessage({
|
||||
type: "handshake",
|
||||
token: "test",
|
||||
deviceId: "test"
|
||||
});
|
||||
});
|
||||
assert.doesNotThrow(() => mockWs.onopen?.(new Event("open")));
|
||||
assert.equal(manager.isWebSocketConnected, false);
|
||||
assert(
|
||||
(
|
||||
mockLogger.warn as typeof mockLogger.warn & {
|
||||
calls: [string][];
|
||||
}
|
||||
).calls.some(([message]) =>
|
||||
message.includes("WebSocket handshake failed")
|
||||
)
|
||||
);
|
||||
|
||||
await manager.stop();
|
||||
});
|
||||
|
||||
it("completes stop with timeout protection", async () => {
|
||||
it("completes stop after retiring the socket", async () => {
|
||||
const manager = new WebSocketManager(
|
||||
deviceId,
|
||||
mockLogger,
|
||||
|
|
@ -222,7 +226,7 @@ describe("WebSocketManager", () => {
|
|||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
|
||||
await manager.stop();
|
||||
assert.ok(true);
|
||||
assert.equal(manager.isWebSocketConnected, false);
|
||||
});
|
||||
|
||||
it("uses a secure URL, preserves the configured prefix, and encodes the vault", async () => {
|
||||
|
|
@ -451,19 +455,65 @@ for (const closeBehavior of ["throws", "stays open"]) {
|
|||
context.mock.timers.tick(0);
|
||||
const socket = (manager as unknown as { webSocket: MockWebSocket })
|
||||
.webSocket;
|
||||
const stopping = manager.stop();
|
||||
context.mock.timers.tick(10_000);
|
||||
await stopping;
|
||||
const delayedMessage = socket.onmessage;
|
||||
await manager.stop(); // No close event or timer advance is needed.
|
||||
assert.equal(manager.isWebSocketConnected, false);
|
||||
assert.equal(connected, false);
|
||||
socket.simulateMessage({
|
||||
type: "vaultChanged"
|
||||
});
|
||||
delayedMessage?.(
|
||||
new MockMessageEvent("message", {
|
||||
data: JSON.stringify({ type: "vaultChanged" })
|
||||
})
|
||||
);
|
||||
await manager.waitUntilFinished();
|
||||
assert.equal(delivered, 0);
|
||||
});
|
||||
}
|
||||
|
||||
it("stop retires the socket immediately but drains messages already being handled", async (context) => {
|
||||
context.mock.timers.enable({ apis: ["setTimeout"] });
|
||||
const logger = new Logger();
|
||||
const manager = new WebSocketManager(
|
||||
"device",
|
||||
logger,
|
||||
new Settings(
|
||||
logger,
|
||||
{ remoteUri: "http://test", token: "test-token" },
|
||||
async () => {}
|
||||
),
|
||||
MockWebSocket as unknown as typeof WebSocket
|
||||
);
|
||||
const release = Promise.withResolvers<void>();
|
||||
context.after(async () => {
|
||||
release.resolve();
|
||||
await manager.stop();
|
||||
});
|
||||
manager.start();
|
||||
context.mock.timers.tick(0);
|
||||
const socket = (manager as unknown as { webSocket: MockWebSocket })
|
||||
.webSocket;
|
||||
assert.deepEqual(JSON.parse(socket.sentMessages[0]), {
|
||||
type: "handshake",
|
||||
token: "test-token",
|
||||
deviceId: "device"
|
||||
});
|
||||
manager.onRemoteVaultUpdateReceived.add(async () => release.promise);
|
||||
socket.simulateMessage({ type: "vaultChanged" });
|
||||
let finished = false;
|
||||
const stopping = manager.stop().then(() => {
|
||||
finished = true;
|
||||
});
|
||||
assert.equal(manager.isWebSocketConnected, false);
|
||||
assert.equal(socket.onmessage, null);
|
||||
await Promise.resolve();
|
||||
assert(!finished);
|
||||
release.resolve();
|
||||
await stopping;
|
||||
assert(finished);
|
||||
});
|
||||
|
||||
it("a handshake send failure retires the connection without escaping the open callback", async (context) => {
|
||||
context.mock.timers.enable({ apis: ["setTimeout"] });
|
||||
class BrokenSendSocket extends MockWebSocket {
|
||||
|
|
@ -484,14 +534,6 @@ it("a handshake send failure retires the connection without escaping the open ca
|
|||
settings,
|
||||
BrokenSendSocket as unknown as typeof WebSocket
|
||||
);
|
||||
manager.onWebSocketStatusChanged.add((connected) => {
|
||||
if (connected)
|
||||
manager.sendHandshakeMessage({
|
||||
type: "handshake",
|
||||
token: "test",
|
||||
deviceId: "test"
|
||||
});
|
||||
});
|
||||
const statuses: boolean[] = [];
|
||||
manager.onWebSocketStatusChanged.add((status) => statuses.push(status));
|
||||
try {
|
||||
|
|
@ -499,6 +541,10 @@ it("a handshake send failure retires the connection without escaping the open ca
|
|||
assert.doesNotThrow(() => context.mock.timers.tick(0));
|
||||
assert.equal(manager.isWebSocketConnected, false);
|
||||
assert.equal(statuses.at(-1), false);
|
||||
assert(
|
||||
!statuses.includes(true),
|
||||
"a failed handshake must never announce a connection"
|
||||
);
|
||||
} finally {
|
||||
await manager.stop();
|
||||
}
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue