Small consistency changes

This commit is contained in:
Andras Schmelczer 2026-09-27 21:48:55 +01:00
commit 6a19587d7d
15 changed files with 335 additions and 182 deletions

View file

@ -1,6 +1,8 @@
import { FileKind } from "./filesystem-operations";
import { v4 as uuid } from "uuid";
import type { FileSystemOperations } from "./filesystem-operations";
import type { Mutation } from "./mutation";
import type { FileApplication } from "./file-application";
import { posix } from "path";
import type {
DocumentId,
StoredDatabase,
@ -10,7 +12,7 @@ import { allocatePortablePath, isInternalPath } from "../utils/portable-path";
interface PathObstruction {
path: RelativePath;
kind: "file" | "directory";
kind: FileKind;
}
interface Occupant {
@ -18,7 +20,7 @@ interface Occupant {
path: RelativePath;
}
/** Resolve occupied rename destinations without discarding another file. */
// Resolve occupied rename destinations without discarding another file.
export class FilePaths {
public constructor(
private readonly fs: FileSystemOperations,
@ -26,17 +28,15 @@ export class FilePaths {
path: RelativePath,
size: number
) => boolean
) { }
) {}
public async prepareDestination(
next: StoredDatabase,
current: StoredDatabase,
application: FileApplication,
id: DocumentId,
path: RelativePath,
mutate: Mutation
path: RelativePath
): Promise<RelativePath> {
try {
for (; ;) {
const { current, next } = application;
for (;;) {
const obstruction = await this.findObstruction(path);
if (!obstruction) {
break;
@ -53,27 +53,24 @@ export class FilePaths {
continue;
}
await this.relocateOccupants(occupants, current, next, mutate);
await this.relocateOccupants(occupants, application);
if (obstruction.kind === "directory") {
await mutate(async () =>
this.fs.deleteDirectory(obstruction.path)
);
if (obstruction.kind === FileKind.Directory) {
application.beginFileChanges();
await this.fs.deleteDirectory(obstruction.path);
}
}
application.assertPlanCurrent();
await this.createParentDirectories(path);
} catch (error) {
return this.recoverDestination(next, id, path, error);
}
return path;
}
public async removeEmptyParents(path: RelativePath): Promise<void> {
let parent = path.split("/").slice(0, -1).join("/");
let parent = posix.dirname(path);
while (parent && !isInternalPath(parent)) {
while (parent !== "." && !isInternalPath(parent)) {
if ((await this.fs.listFilesRecursively(parent)).length) {
break;
}
@ -84,11 +81,10 @@ export class FilePaths {
break;
}
parent = parent.split("/").slice(0, -1).join("/");
parent = posix.dirname(parent);
}
}
private async findObstruction(
path: RelativePath
): Promise<PathObstruction | undefined> {
@ -97,20 +93,22 @@ export class FilePaths {
const prefix = parts.slice(0, i + 1).join("/");
const info = await this.fs.stat(prefix);
if (info && (info.kind === "file" || i === parts.length - 1)) {
if (
info &&
(info.kind === FileKind.File || i === parts.length - 1)
) {
return { path: prefix, kind: info.kind };
}
}
}
private async findMovableOccupants(
obstruction: PathObstruction,
current: StoredDatabase,
next: StoredDatabase
): Promise<Occupant[] | undefined> {
const children =
obstruction.kind === "file"
obstruction.kind === FileKind.File
? [obstruction.path]
: await this.fs.listFilesRecursively(obstruction.path);
@ -124,10 +122,9 @@ export class FilePaths {
const info = await this.fs.stat(child);
if (
!info // doesn't exist anymore
||
!info || // doesn't exist anymore
this.isProtectedFile(child, info.size) ||
(obstruction.kind === "directory" &&
(obstruction.kind === FileKind.Directory &&
(occupant === undefined || next.local[occupant] === child))
) {
return undefined;
@ -157,21 +154,19 @@ export class FilePaths {
private async relocateOccupants(
occupants: Occupant[],
current: StoredDatabase,
next: StoredDatabase,
mutate: Mutation
application: FileApplication
): Promise<void> {
const { current, next } = application;
for (const occupant of occupants) {
const displaced = await this.allocateConflictPath(
next,
occupant.id,
occupant.path.split("/").at(-1) ?? occupant.path
posix.basename(occupant.path)
);
await mutate(async () => {
application.beginFileChanges();
current.local[occupant.id] = displaced;
await this.fs.rename(occupant.path, displaced);
});
if (!current.documents[occupant.id]) {
current.documents[occupant.id] = { observedHash: "" };
@ -183,49 +178,10 @@ export class FilePaths {
}
}
private async recoverDestination(
next: StoredDatabase,
id: DocumentId,
path: RelativePath,
error: unknown
): Promise<RelativePath> {
const code =
typeof error === "object" && error !== null && "code" in error
? error.code
: undefined;
if (
![
"ENAMETOOLONG",
"EINVAL",
"ENOTSUP",
"EOPNOTSUPP",
"EACCES",
"EPERM"
].includes(String(code))
) {
throw error;
}
const extension =
/\.[^.]*$/u.exec(path.split("/").at(-1) ?? "")?.[0] ?? "";
path = await this.allocateConflictPath(
next,
id,
`Recovered ${id}${extension}`
);
await this.createParentDirectories(path);
return path;
}
private async createParentDirectories(path: RelativePath): Promise<void> {
const parent = path.split("/").slice(0, -1).join("/");
const parent = posix.dirname(path);
if (parent) {
if (parent !== ".") {
await this.fs.createDirectory(parent);
}
}

View file

@ -2,6 +2,11 @@ import type { TextWithCursors } from "reconcile-text";
import type { RelativePath } from "../persistence/database";
import type { FileSnapshot } from "../snapshot";
export enum FileKind {
File = "file",
Directory = "directory"
}
/** Filesystem operations act on the current user-owned namespace.
* Paths are vault-relative. Never follow symlinks (including ancestor symlinks).
* Only independent regular files are supported; reject hardlinks/special files.
@ -9,21 +14,21 @@ import type { FileSnapshot } from "../snapshot";
export interface FileSystemOperations {
listFilesRecursively: (root?: RelativePath) => Promise<RelativePath[]>;
/** Return undefined on missing file */
// Return undefined on missing file
read: (path: RelativePath) => Promise<FileSnapshot | undefined>;
/** File size in in bytes. */
// File size in bytes.
stat: (
path: RelativePath
) => Promise<{ kind: "file" | "directory"; size: number } | undefined>;
) => Promise<{ kind: FileKind; size: number } | undefined>;
/** Includes directories. */
// Includes directories.
exists: (path: RelativePath) => Promise<boolean>;
/** Recursive, idempotent directory creation. */
// Recursive, idempotent directory creation.
createDirectory: (path: RelativePath) => Promise<void>;
/** Create or overwrite a file, applying optional editor cursor metadata. */
// Create or overwrite a file, applying optional editor cursor metadata.
write: (path: RelativePath, snapshot: FileSnapshot) => Promise<void>;
// Atomically update the content of a text file.
@ -32,12 +37,12 @@ export interface FileSystemOperations {
updater: (current: TextWithCursors) => TextWithCursors
) => Promise<string>;
/** Move a file without replacing an existing destination. */
// Move a file without replacing an existing destination.
rename: (from: RelativePath, to: RelativePath) => Promise<void>;
/** Idempotently unlink one regular file. */
// Idempotently unlink one regular file.
deleteFile: (path: RelativePath) => Promise<void>;
/** Prune directories using rmdir only. Reject files and nonempty directories. */
// Prune directories using rmdir only. Reject files and nonempty directories.
deleteDirectory: (path: RelativePath) => Promise<void>;
}

View file

@ -1 +0,0 @@
export type Mutation = (action: () => Promise<void>) => Promise<void>;

View file

@ -27,7 +27,7 @@ export type {
StoredDatabase,
VaultUpdateId
} from "./persistence/database";
export type { FileSystemOperations } from "./file-operations/filesystem-operations";
export { FileKind, type FileSystemOperations } from "./file-operations/filesystem-operations";
export type { FileSnapshot } from "./snapshot";
export type { MetadataPersistenceProvider as PersistenceProvider } from "./persistence/metadata-persistence-provider";
export type { CursorSpan } from "./services/types/CursorSpan";
@ -63,3 +63,6 @@ export type { PushFileManifest } from "./services/types/PushFileManifest";
export type { PutFileContent } from "./services/types/PutFileContent";
export type { VaultSnapshot } from "./services/types/VaultSnapshot";
export type { FileManifestEntries } from "./types/file-manifest-entries";
export { LocalChangeType } from "./sync-operations/local-changes";
export { SyncEventType } from "./services/protocol-types";

View file

@ -0,0 +1,21 @@
// Values match the discriminants in the server-generated wire types.
export enum SyncEventType {
Content = "content",
FileManifest = "fileManifest"
}
export enum PushContentType {
Snapshot = "Snapshot",
Diff = "Diff"
}
export enum UpdateResponseType {
Accepted = "Accepted",
StaleBase = "StaleBase"
}
export enum WebSocketMessageType {
Handshake = "handshake",
CursorPositions = "cursorPositions",
VaultChanged = "vaultChanged"
}

View file

@ -10,7 +10,7 @@ export interface HashedSnapshot extends FileSnapshot {
hash: string;
}
export async function hashSnapshot(
export async function toHashedSnapshot(
snapshot: FileSnapshot
): Promise<HashedSnapshot> {
// Own the bytes so later adapter mutations cannot invalidate the hash

View file

@ -1,11 +1,11 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { hashSnapshot, type HashedSnapshot } from "../snapshot";
import { toHashedSnapshot, type HashedSnapshot } from "../snapshot";
import { mergeContent } from "./content";
const extensions = ["md"];
const createSnapshot = async (text: string): Promise<HashedSnapshot> =>
hashSnapshot({ content: new TextEncoder().encode(text) });
toHashedSnapshot({ content: new TextEncoder().encode(text) });
describe("content reconciliation without file timestamps", () => {
it("hashes snapshots without changing bytes or editor selections", async () => {
@ -13,7 +13,7 @@ describe("content reconciliation without file timestamps", () => {
content: new Uint8Array([0, 255, 13, 10]),
cursors: []
};
const snapshot = await hashSnapshot(original);
const snapshot = await toHashedSnapshot(original);
assert.deepEqual(snapshot.content, original.content);
assert.deepEqual(snapshot.cursors, original.cursors);
original.content.fill(1);

View file

@ -1,3 +1,4 @@
import { PushContentType } from "../services/protocol-types";
import { ContentSync, selectContentHead } from "./content-sync";
/* eslint-disable @typescript-eslint/no-unsafe-type-assertion */
import assert from "node:assert";
@ -13,7 +14,7 @@ import type { SyncHistory } from "../tracing/sync-history";
import { EventListeners } from "../utils/data-structures/event-listeners";
import { Syncer } from "./syncer";
import { FixedSizeDocumentCache } from "../utils/data-structures/fix-sized-cache";
import { hashSnapshot } from "../snapshot";
import { toHashedSnapshot } from "../snapshot";
import { undiff } from "reconcile-text";
describe("Syncer per-file state and event delivery", () => {
@ -148,11 +149,11 @@ describe("Syncer diff uploads", () => {
const payload = await content.createUploadPayload(
"note.md",
7,
await hashSnapshot({ content: encoder.encode(changed) })
await toHashedSnapshot({ content: encoder.encode(changed) })
);
assert.equal(payload.type, "Diff");
if (payload.type === "Diff") {
if (payload.type === PushContentType.Diff) {
assert.equal(
undiff(new TextDecoder().decode(parent), payload.value),
changed
@ -169,7 +170,7 @@ describe("Syncer diff uploads", () => {
const payload = await content.createUploadPayload(
"note.md",
7,
await hashSnapshot({ content: encoder.encode("changed") })
await toHashedSnapshot({ content: encoder.encode("changed") })
);
assert.equal(payload.type, "Snapshot");

View file

@ -2,7 +2,7 @@ type ResolvedTuple<T extends readonly unknown[]> = {
-readonly [K in keyof T]: Awaited<T[K]>;
};
/** Await every operation before returning results or throwing the first error. */
// Await every operation before returning results or throwing the first error.
export async function awaitAll<T extends readonly unknown[] | []>(
promises: T
): Promise<ResolvedTuple<T>> {

View file

@ -1,6 +1,6 @@
import type { VaultUpdateId } from "../../persistence/database";
/** Map insertion order tracks the least recently used document first. */
// Map insertion order tracks the least recently used document first.
export class FixedSizeDocumentCache {
private currentSizeInBytes = 0;
private readonly cache = new Map<VaultUpdateId, Uint8Array>();

View file

@ -1,6 +1,6 @@
declare const __CURRENT_VERSION__: string;
/** Webpack replaces this identifier in bundles; source imports use a stable fallback. */
// Webpack replaces this identifier in bundles; source imports use a stable fallback.
export const packageVersion =
typeof __CURRENT_VERSION__ === "undefined"
? "development"

View file

@ -3,7 +3,13 @@ import { spawnSync } from "node:child_process";
import { resolve } from "node:path";
import { pathToFileURL } from "node:url";
import { test } from "node:test";
import { allocatePortablePath, validatePortablePaths } from "./portable-path";
import {
allocatePortablePath,
validatePortablePaths,
arePathAliases,
findPathWithSameSpelling,
isInternalPath
} from "./portable-path";
test("a small deeply nested manifest fits within a bounded validation heap", () => {
const source = pathToFileURL(resolve(__dirname, "portable-path.ts")).href;
@ -84,3 +90,160 @@ test("shared directories remain distinct from aliases and file ancestors", () =>
}, /Conflicting path/);
}
});
for (const [left, right, aliases] of [
["café.md", "cafe\u0301.md", true],
["Notes/File.md", "notes/file.MD", true],
["Straße.md", "STRASSE.md", true],
["σ.md", "ς.md", true],
["İ.md", "i.md", false],
["a.md", "b.md", false]
] as const) {
test(`portable alias comparison: ${left} / ${right}`, () => {
assert.equal(arePathAliases(left, right), aliases);
assert.equal(arePathAliases(right, left), aliases);
});
}
test("same spelling preserves Unicode aliases without hiding case-only renames", () => {
assert.equal(
findPathWithSameSpelling(["café.md"], "cafe\u0301.md"),
"café.md"
);
assert.equal(findPathWithSameSpelling(["note.md"], "Note.md"), undefined);
assert.equal(
findPathWithSameSpelling(["Straße.md"], "STRASSE.md"),
undefined
);
assert.equal(findPathWithSameSpelling(["note.md"], "note.md"), undefined);
});
test("only the top-level internal directory is reserved, including aliases", () => {
for (const path of [".vault-link-sync", ".VAULT-LINK-SYNC/state.json"]) {
assert.equal(isInternalPath(path), true);
assert.throws(() => {
validatePortablePaths([path]);
}, /Invalid portable path/u);
const allocated = allocatePortablePath(path, "id", []);
assert.equal(allocated, "_" + path);
validatePortablePaths([allocated]);
}
for (const path of [
"notes/.vault-link-sync/a.md",
".vault-link-sync-other/a.md"
]) {
assert.equal(isInternalPath(path), false);
validatePortablePaths([path]);
}
});
test("invalid components and device names are sanitized in every directory", () => {
const cases = [
["", "_"],
[".", "_"],
["..", "_"],
["a//b.md", "a/_/b.md"],
["/a.md", "_/a.md"],
["a/../b.md", "a/_/b.md"],
["cafe\u0301.md", "café.md"],
['bad<>:"\\|?*\u0000.md', "bad_________.md"],
["trailing. ", "trailing_"],
["CON.txt", "_CON.txt"],
["aux .md", "_aux .md"],
["LPT¹.txt", "_LPT¹.txt"],
["com²/PRN.md", "_com²/_PRN.md"],
["CONIN$", "_CONIN$"],
["conout$.txt", "_conout$.txt"]
];
for (const [wanted, expected] of cases) {
assert.ok(wanted !== undefined && expected !== undefined);
assert.throws(() => {
validatePortablePaths([wanted]);
}, /Invalid portable path/u);
assert.equal(allocatePortablePath(wanted, "id", []), expected);
validatePortablePaths([expected]);
}
validatePortablePaths(["COM0.txt", "LPT10.md", "console.md", ".hidden"]);
});
test("long UTF-8 components preserve the entire conflict suffix and extension", () => {
for (const character of ["a", "é", "界", "🦊"]) {
const wanted = `${character.repeat(260)}.md`;
const allocated = allocatePortablePath(wanted, "identity", []);
assert.ok(allocated.endsWith(" (conflict identity).md"));
assert.ok(new TextEncoder().encode(allocated).length <= 255);
assert.ok(allocated.isWellFormed());
assert.equal(allocated, allocated.normalize("NFC"));
validatePortablePaths([allocated]);
assert.equal(
allocatePortablePath(allocated, "identity", []),
allocated
);
}
});
test("byte limits apply to IDs, extensions and directories independently", () => {
const allocated = allocatePortablePath(
`${"d".repeat(256)}/${"🦊".repeat(80)}.${"é".repeat(80)}`,
"界".repeat(100),
[]
);
const [directory, file] = allocated.split("/");
assert.equal(directory, `${"d".repeat(180)} (conflict ${"界".repeat(21)})`);
assert.ok(file !== undefined);
assert.ok(
file.endsWith(` (conflict ${"界".repeat(21)}).${"é".repeat(31)}`)
);
for (const part of allocated.split("/")) {
assert.ok(new TextEncoder().encode(part).length <= 255);
assert.ok(part.isWellFormed());
}
validatePortablePaths([allocated]);
});
test("repeated long-name collisions keep a unique suffix without losing the extension", () => {
const wanted = `${"🦊".repeat(70)}.md`;
const occupied: string[] = [];
for (let i = 0; i < 12; i++) {
const path = allocatePortablePath(wanted, "same-id", occupied);
assert.equal(occupied.includes(path), false);
assert.equal(
allocatePortablePath(wanted, "same-id", occupied.toReversed()),
path
);
assert.ok(path.endsWith(".md"));
assert.ok(new TextEncoder().encode(path).length <= 255);
occupied.push(path);
validatePortablePaths(occupied);
}
});
test("allocation resolves both ancestor and leaf collisions without changing siblings", () => {
const occupied = ["notes/other.md", "Notes (conflict id)/FILE.md"];
const path = allocatePortablePath("Notes/file.md", "id", occupied);
assert.equal(path, "Notes (conflict id)/file (conflict id) (1).md");
validatePortablePaths([...occupied, path]);
assert.equal(
allocatePortablePath("notebook.md", "id", ["note"]),
"notebook.md"
);
assert.equal(
allocatePortablePath(".hidden", "id", [".hidden"]),
".hidden (conflict id)"
);
});
test("manifest conflicts throw TypeError", () => {
assert.throws(
() => {
validatePortablePaths(["a.md", "A.md"]);
},
{
name: "TypeError",
message: "Conflicting path: A.md"
}
);
});

View file

@ -2,22 +2,26 @@ import type { DocumentId, RelativePath } from "../persistence/database";
export const INTERNAL_DIRECTORY = ".vault-link-sync";
const MAX_COMPONENT_BYTES = 255;
const MAX_CONFLICT_ID_BYTES = 64;
const MAX_EXTENSION_BYTES = 64;
// Windows reserved device names, including extensions and superscript digits:
// https://learn.microsoft.com/en-us/windows/win32/fileio/naming-a-file#naming-conventions
const reservedDeviceName =
/^(CON|PRN|AUX|NUL|CONIN\$|CONOUT\$|COM[1-9¹²³]|LPT[1-9¹²³])$/u;
const encoder = new TextEncoder();
const getByteLength = (value: string): number => encoder.encode(value).length;
// NFC -> uppercase -> NFC matches the server's locale-independent alias rules.
const getPathKey = (path: RelativePath): string =>
path.normalize("NFC").toUpperCase().normalize("NFC");
export const arePathAliases = (
left: RelativePath,
right: RelativePath
): boolean => getPathKey(left) === getPathKey(right);
// NFC -> uppercase -> NFC matches the server's locale-independent alias rules.
const getPathKey = (path: RelativePath): string =>
path.normalize("NFC").toUpperCase().normalize("NFC");
export function findPathWithSameSpelling(
paths: Iterable<RelativePath>,
wanted: RelativePath
@ -74,7 +78,7 @@ export function validatePortablePaths(paths: Iterable<RelativePath>): void {
previous &&
(previous.spelling !== spelling || previous.file || file)
) {
throw new Error(`Conflicting path: ${path}`);
throw new TypeError(`Conflicting path: ${path}`);
}
const node = previous ?? { id: nodes.size + 1, spelling, file };
@ -84,9 +88,7 @@ export function validatePortablePaths(paths: Iterable<RelativePath>): void {
}
}
/** First aliased component, or the file occupying a shared path prefix. */
// First aliased component, or the file occupying a shared path prefix.
function findConflictIndex(
left: readonly string[],
right: readonly string[]
@ -105,13 +107,67 @@ function findConflictIndex(
return shared - 1;
}
/** Deterministic allocation also handles a file blocking an ancestor directory. */
function sanitizeFileName(part: string): string {
let result = part
.normalize("NFC")
.replace(/[<>:"\\|?*\p{Cc}]/gu, "_")
.replace(/[. ]+$/u, "_");
if (!result || result === "." || result === "..") {
result = "_";
}
if (
reservedDeviceName.test(
(result.split(".")[0] ?? "").replace(/ +$/u, "").toUpperCase()
)
) {
result = "_" + result;
}
return result;
}
function truncateToByteLength(value: string, limit: number): string {
let result = "";
let bytes = 0;
for (const character of value) {
const characterBytes = getByteLength(character);
if (bytes + characterBytes > limit) {
break;
}
result += character;
bytes += characterBytes;
}
return result;
}
function fitName(name: string, suffix: string, file: boolean): string {
const dot = file ? name.lastIndexOf(".") : -1;
const extension =
dot > 0
? truncateToByteLength(name.slice(dot), MAX_EXTENSION_BYTES)
: "";
const stem = dot > 0 ? name.slice(0, dot) : name;
// Reserve the suffix and extension first; only the original stem is shortened.
const stemBudget = MAX_COMPONENT_BYTES - getByteLength(suffix + extension);
return sanitizeFileName(
truncateToByteLength(stem, stemBudget) + suffix + extension
);
}
// Deterministic allocation also handles a file blocking an ancestor directory.
export function allocatePortablePath(
wanted: RelativePath,
id: DocumentId,
occupied: readonly RelativePath[]
): RelativePath {
id = sanitizeFileName(truncateToByteLength(id, 64));
id = truncateToByteLength(
sanitizeFileName(id.replaceAll("/", "_")),
MAX_CONFLICT_ID_BYTES
);
const originals = wanted.split("/");
const parts = originals.map((part, index) => {
@ -161,55 +217,3 @@ export function allocatePortablePath(
throw new Error(`Unable to allocate a portable path: ${wanted}`);
}
function sanitizeFileName(part: string): string {
let result = part
.normalize("NFC")
.replace(/[<>:"\\|?*\p{Cc}]/gu, "_")
.replace(/[. ]+$/u, "_");
if (!result || result === "." || result === "..") {
result = "_";
}
if (
reservedDeviceName.test(
(result.split(".")[0] ?? "").replace(/ +$/u, "").toUpperCase()
)
) {
result = "_" + result;
}
return result;
}
function fitName(name: string, suffix: string, file: boolean): string {
const dot = file ? name.lastIndexOf(".") : -1;
const extension = dot > 0 ? truncateToByteLength(name.slice(dot), 64) : "";
const stem = dot > 0 ? name.slice(0, dot) : name;
return sanitizeFileName(
truncateToByteLength(
stem,
MAX_COMPONENT_BYTES - getByteLength(suffix + extension)
) +
suffix +
extension
);
}
function truncateToByteLength(value: string, limit: number): string {
let result = "";
for (const character of value) {
if (getByteLength(result + character) > limit) {
break;
}
result += character;
}
return result;
}
const getByteLength = (value: string): number =>
new TextEncoder().encode(value).length;

View file

@ -1,6 +1,6 @@
import { sleep } from "./sleep";
/** Coalesce calls during each interval; the first waiter runs the latest arguments. */
// Coalesce calls during each interval; the first waiter runs the latest arguments.
export function rateLimit<Args extends unknown[], R>(
fn: (...args: Args) => Promise<R>,
minIntervalMs: number | (() => number)

View file

@ -57,6 +57,7 @@ module.exports = [
},
resolve: {
fallback: {
path: require.resolve("path-browserify"),
ws: false // Exclude `ws` from the browser bundle
}
}