Small consistency changes
This commit is contained in:
parent
202576a03a
commit
6a19587d7d
15 changed files with 335 additions and 182 deletions
|
|
@ -1,6 +1,8 @@
|
||||||
|
import { FileKind } from "./filesystem-operations";
|
||||||
import { v4 as uuid } from "uuid";
|
import { v4 as uuid } from "uuid";
|
||||||
import type { FileSystemOperations } from "./filesystem-operations";
|
import type { FileSystemOperations } from "./filesystem-operations";
|
||||||
import type { Mutation } from "./mutation";
|
import type { FileApplication } from "./file-application";
|
||||||
|
import { posix } from "path";
|
||||||
import type {
|
import type {
|
||||||
DocumentId,
|
DocumentId,
|
||||||
StoredDatabase,
|
StoredDatabase,
|
||||||
|
|
@ -10,7 +12,7 @@ import { allocatePortablePath, isInternalPath } from "../utils/portable-path";
|
||||||
|
|
||||||
interface PathObstruction {
|
interface PathObstruction {
|
||||||
path: RelativePath;
|
path: RelativePath;
|
||||||
kind: "file" | "directory";
|
kind: FileKind;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Occupant {
|
interface Occupant {
|
||||||
|
|
@ -18,7 +20,7 @@ interface Occupant {
|
||||||
path: RelativePath;
|
path: RelativePath;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Resolve occupied rename destinations without discarding another file. */
|
// Resolve occupied rename destinations without discarding another file.
|
||||||
export class FilePaths {
|
export class FilePaths {
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly fs: FileSystemOperations,
|
private readonly fs: FileSystemOperations,
|
||||||
|
|
@ -29,13 +31,11 @@ export class FilePaths {
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public async prepareDestination(
|
public async prepareDestination(
|
||||||
next: StoredDatabase,
|
application: FileApplication,
|
||||||
current: StoredDatabase,
|
|
||||||
id: DocumentId,
|
id: DocumentId,
|
||||||
path: RelativePath,
|
path: RelativePath
|
||||||
mutate: Mutation
|
|
||||||
): Promise<RelativePath> {
|
): Promise<RelativePath> {
|
||||||
try {
|
const { current, next } = application;
|
||||||
for (;;) {
|
for (;;) {
|
||||||
const obstruction = await this.findObstruction(path);
|
const obstruction = await this.findObstruction(path);
|
||||||
if (!obstruction) {
|
if (!obstruction) {
|
||||||
|
|
@ -53,27 +53,24 @@ export class FilePaths {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.relocateOccupants(occupants, current, next, mutate);
|
await this.relocateOccupants(occupants, application);
|
||||||
|
|
||||||
if (obstruction.kind === "directory") {
|
if (obstruction.kind === FileKind.Directory) {
|
||||||
await mutate(async () =>
|
application.beginFileChanges();
|
||||||
this.fs.deleteDirectory(obstruction.path)
|
await this.fs.deleteDirectory(obstruction.path);
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
application.assertPlanCurrent();
|
||||||
await this.createParentDirectories(path);
|
await this.createParentDirectories(path);
|
||||||
} catch (error) {
|
|
||||||
return this.recoverDestination(next, id, path, error);
|
|
||||||
}
|
|
||||||
|
|
||||||
return path;
|
return path;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async removeEmptyParents(path: RelativePath): Promise<void> {
|
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) {
|
if ((await this.fs.listFilesRecursively(parent)).length) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
@ -84,11 +81,10 @@ export class FilePaths {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
parent = parent.split("/").slice(0, -1).join("/");
|
parent = posix.dirname(parent);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
private async findObstruction(
|
private async findObstruction(
|
||||||
path: RelativePath
|
path: RelativePath
|
||||||
): Promise<PathObstruction | undefined> {
|
): Promise<PathObstruction | undefined> {
|
||||||
|
|
@ -97,20 +93,22 @@ export class FilePaths {
|
||||||
const prefix = parts.slice(0, i + 1).join("/");
|
const prefix = parts.slice(0, i + 1).join("/");
|
||||||
const info = await this.fs.stat(prefix);
|
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 };
|
return { path: prefix, kind: info.kind };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
private async findMovableOccupants(
|
private async findMovableOccupants(
|
||||||
obstruction: PathObstruction,
|
obstruction: PathObstruction,
|
||||||
current: StoredDatabase,
|
current: StoredDatabase,
|
||||||
next: StoredDatabase
|
next: StoredDatabase
|
||||||
): Promise<Occupant[] | undefined> {
|
): Promise<Occupant[] | undefined> {
|
||||||
const children =
|
const children =
|
||||||
obstruction.kind === "file"
|
obstruction.kind === FileKind.File
|
||||||
? [obstruction.path]
|
? [obstruction.path]
|
||||||
: await this.fs.listFilesRecursively(obstruction.path);
|
: await this.fs.listFilesRecursively(obstruction.path);
|
||||||
|
|
||||||
|
|
@ -124,10 +122,9 @@ export class FilePaths {
|
||||||
const info = await this.fs.stat(child);
|
const info = await this.fs.stat(child);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
!info // doesn't exist anymore
|
!info || // doesn't exist anymore
|
||||||
||
|
|
||||||
this.isProtectedFile(child, info.size) ||
|
this.isProtectedFile(child, info.size) ||
|
||||||
(obstruction.kind === "directory" &&
|
(obstruction.kind === FileKind.Directory &&
|
||||||
(occupant === undefined || next.local[occupant] === child))
|
(occupant === undefined || next.local[occupant] === child))
|
||||||
) {
|
) {
|
||||||
return undefined;
|
return undefined;
|
||||||
|
|
@ -157,21 +154,19 @@ export class FilePaths {
|
||||||
|
|
||||||
private async relocateOccupants(
|
private async relocateOccupants(
|
||||||
occupants: Occupant[],
|
occupants: Occupant[],
|
||||||
current: StoredDatabase,
|
application: FileApplication
|
||||||
next: StoredDatabase,
|
|
||||||
mutate: Mutation
|
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
const { current, next } = application;
|
||||||
for (const occupant of occupants) {
|
for (const occupant of occupants) {
|
||||||
const displaced = await this.allocateConflictPath(
|
const displaced = await this.allocateConflictPath(
|
||||||
next,
|
next,
|
||||||
occupant.id,
|
occupant.id,
|
||||||
occupant.path.split("/").at(-1) ?? occupant.path
|
posix.basename(occupant.path)
|
||||||
);
|
);
|
||||||
|
|
||||||
await mutate(async () => {
|
application.beginFileChanges();
|
||||||
current.local[occupant.id] = displaced;
|
current.local[occupant.id] = displaced;
|
||||||
await this.fs.rename(occupant.path, displaced);
|
await this.fs.rename(occupant.path, displaced);
|
||||||
});
|
|
||||||
|
|
||||||
if (!current.documents[occupant.id]) {
|
if (!current.documents[occupant.id]) {
|
||||||
current.documents[occupant.id] = { observedHash: "" };
|
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> {
|
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);
|
await this.fs.createDirectory(parent);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,11 @@ import type { TextWithCursors } from "reconcile-text";
|
||||||
import type { RelativePath } from "../persistence/database";
|
import type { RelativePath } from "../persistence/database";
|
||||||
import type { FileSnapshot } from "../snapshot";
|
import type { FileSnapshot } from "../snapshot";
|
||||||
|
|
||||||
|
export enum FileKind {
|
||||||
|
File = "file",
|
||||||
|
Directory = "directory"
|
||||||
|
}
|
||||||
|
|
||||||
/** Filesystem operations act on the current user-owned namespace.
|
/** Filesystem operations act on the current user-owned namespace.
|
||||||
* Paths are vault-relative. Never follow symlinks (including ancestor symlinks).
|
* Paths are vault-relative. Never follow symlinks (including ancestor symlinks).
|
||||||
* Only independent regular files are supported; reject hardlinks/special files.
|
* Only independent regular files are supported; reject hardlinks/special files.
|
||||||
|
|
@ -9,21 +14,21 @@ import type { FileSnapshot } from "../snapshot";
|
||||||
export interface FileSystemOperations {
|
export interface FileSystemOperations {
|
||||||
listFilesRecursively: (root?: RelativePath) => Promise<RelativePath[]>;
|
listFilesRecursively: (root?: RelativePath) => Promise<RelativePath[]>;
|
||||||
|
|
||||||
/** Return undefined on missing file */
|
// Return undefined on missing file
|
||||||
read: (path: RelativePath) => Promise<FileSnapshot | undefined>;
|
read: (path: RelativePath) => Promise<FileSnapshot | undefined>;
|
||||||
|
|
||||||
/** File size in in bytes. */
|
// File size in bytes.
|
||||||
stat: (
|
stat: (
|
||||||
path: RelativePath
|
path: RelativePath
|
||||||
) => Promise<{ kind: "file" | "directory"; size: number } | undefined>;
|
) => Promise<{ kind: FileKind; size: number } | undefined>;
|
||||||
|
|
||||||
/** Includes directories. */
|
// Includes directories.
|
||||||
exists: (path: RelativePath) => Promise<boolean>;
|
exists: (path: RelativePath) => Promise<boolean>;
|
||||||
|
|
||||||
/** Recursive, idempotent directory creation. */
|
// Recursive, idempotent directory creation.
|
||||||
createDirectory: (path: RelativePath) => Promise<void>;
|
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>;
|
write: (path: RelativePath, snapshot: FileSnapshot) => Promise<void>;
|
||||||
|
|
||||||
// Atomically update the content of a text file.
|
// Atomically update the content of a text file.
|
||||||
|
|
@ -32,12 +37,12 @@ export interface FileSystemOperations {
|
||||||
updater: (current: TextWithCursors) => TextWithCursors
|
updater: (current: TextWithCursors) => TextWithCursors
|
||||||
) => Promise<string>;
|
) => 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>;
|
rename: (from: RelativePath, to: RelativePath) => Promise<void>;
|
||||||
|
|
||||||
/** Idempotently unlink one regular file. */
|
// Idempotently unlink one regular file.
|
||||||
deleteFile: (path: RelativePath) => Promise<void>;
|
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>;
|
deleteDirectory: (path: RelativePath) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
export type Mutation = (action: () => Promise<void>) => Promise<void>;
|
|
||||||
|
|
@ -27,7 +27,7 @@ export type {
|
||||||
StoredDatabase,
|
StoredDatabase,
|
||||||
VaultUpdateId
|
VaultUpdateId
|
||||||
} from "./persistence/database";
|
} 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 { FileSnapshot } from "./snapshot";
|
||||||
export type { MetadataPersistenceProvider as PersistenceProvider } from "./persistence/metadata-persistence-provider";
|
export type { MetadataPersistenceProvider as PersistenceProvider } from "./persistence/metadata-persistence-provider";
|
||||||
export type { CursorSpan } from "./services/types/CursorSpan";
|
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 { PutFileContent } from "./services/types/PutFileContent";
|
||||||
export type { VaultSnapshot } from "./services/types/VaultSnapshot";
|
export type { VaultSnapshot } from "./services/types/VaultSnapshot";
|
||||||
export type { FileManifestEntries } from "./types/file-manifest-entries";
|
export type { FileManifestEntries } from "./types/file-manifest-entries";
|
||||||
|
|
||||||
|
export { LocalChangeType } from "./sync-operations/local-changes";
|
||||||
|
export { SyncEventType } from "./services/protocol-types";
|
||||||
|
|
|
||||||
21
frontend/sync-client/src/services/protocol-types.ts
Normal file
21
frontend/sync-client/src/services/protocol-types.ts
Normal 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"
|
||||||
|
}
|
||||||
|
|
@ -10,7 +10,7 @@ export interface HashedSnapshot extends FileSnapshot {
|
||||||
hash: string;
|
hash: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function hashSnapshot(
|
export async function toHashedSnapshot(
|
||||||
snapshot: FileSnapshot
|
snapshot: FileSnapshot
|
||||||
): Promise<HashedSnapshot> {
|
): Promise<HashedSnapshot> {
|
||||||
// Own the bytes so later adapter mutations cannot invalidate the hash
|
// Own the bytes so later adapter mutations cannot invalidate the hash
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { describe, it } from "node:test";
|
import { describe, it } from "node:test";
|
||||||
import { hashSnapshot, type HashedSnapshot } from "../snapshot";
|
import { toHashedSnapshot, type HashedSnapshot } from "../snapshot";
|
||||||
import { mergeContent } from "./content";
|
import { mergeContent } from "./content";
|
||||||
|
|
||||||
const extensions = ["md"];
|
const extensions = ["md"];
|
||||||
const createSnapshot = async (text: string): Promise<HashedSnapshot> =>
|
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", () => {
|
describe("content reconciliation without file timestamps", () => {
|
||||||
it("hashes snapshots without changing bytes or editor selections", async () => {
|
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]),
|
content: new Uint8Array([0, 255, 13, 10]),
|
||||||
cursors: []
|
cursors: []
|
||||||
};
|
};
|
||||||
const snapshot = await hashSnapshot(original);
|
const snapshot = await toHashedSnapshot(original);
|
||||||
assert.deepEqual(snapshot.content, original.content);
|
assert.deepEqual(snapshot.content, original.content);
|
||||||
assert.deepEqual(snapshot.cursors, original.cursors);
|
assert.deepEqual(snapshot.cursors, original.cursors);
|
||||||
original.content.fill(1);
|
original.content.fill(1);
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { PushContentType } from "../services/protocol-types";
|
||||||
import { ContentSync, selectContentHead } from "./content-sync";
|
import { ContentSync, selectContentHead } from "./content-sync";
|
||||||
/* eslint-disable @typescript-eslint/no-unsafe-type-assertion */
|
/* eslint-disable @typescript-eslint/no-unsafe-type-assertion */
|
||||||
import assert from "node:assert";
|
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 { EventListeners } from "../utils/data-structures/event-listeners";
|
||||||
import { Syncer } from "./syncer";
|
import { Syncer } from "./syncer";
|
||||||
import { FixedSizeDocumentCache } from "../utils/data-structures/fix-sized-cache";
|
import { FixedSizeDocumentCache } from "../utils/data-structures/fix-sized-cache";
|
||||||
import { hashSnapshot } from "../snapshot";
|
import { toHashedSnapshot } from "../snapshot";
|
||||||
import { undiff } from "reconcile-text";
|
import { undiff } from "reconcile-text";
|
||||||
|
|
||||||
describe("Syncer per-file state and event delivery", () => {
|
describe("Syncer per-file state and event delivery", () => {
|
||||||
|
|
@ -148,11 +149,11 @@ describe("Syncer diff uploads", () => {
|
||||||
const payload = await content.createUploadPayload(
|
const payload = await content.createUploadPayload(
|
||||||
"note.md",
|
"note.md",
|
||||||
7,
|
7,
|
||||||
await hashSnapshot({ content: encoder.encode(changed) })
|
await toHashedSnapshot({ content: encoder.encode(changed) })
|
||||||
);
|
);
|
||||||
|
|
||||||
assert.equal(payload.type, "Diff");
|
assert.equal(payload.type, "Diff");
|
||||||
if (payload.type === "Diff") {
|
if (payload.type === PushContentType.Diff) {
|
||||||
assert.equal(
|
assert.equal(
|
||||||
undiff(new TextDecoder().decode(parent), payload.value),
|
undiff(new TextDecoder().decode(parent), payload.value),
|
||||||
changed
|
changed
|
||||||
|
|
@ -169,7 +170,7 @@ describe("Syncer diff uploads", () => {
|
||||||
const payload = await content.createUploadPayload(
|
const payload = await content.createUploadPayload(
|
||||||
"note.md",
|
"note.md",
|
||||||
7,
|
7,
|
||||||
await hashSnapshot({ content: encoder.encode("changed") })
|
await toHashedSnapshot({ content: encoder.encode("changed") })
|
||||||
);
|
);
|
||||||
|
|
||||||
assert.equal(payload.type, "Snapshot");
|
assert.equal(payload.type, "Snapshot");
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ type ResolvedTuple<T extends readonly unknown[]> = {
|
||||||
-readonly [K in keyof T]: Awaited<T[K]>;
|
-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[] | []>(
|
export async function awaitAll<T extends readonly unknown[] | []>(
|
||||||
promises: T
|
promises: T
|
||||||
): Promise<ResolvedTuple<T>> {
|
): Promise<ResolvedTuple<T>> {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import type { VaultUpdateId } from "../../persistence/database";
|
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 {
|
export class FixedSizeDocumentCache {
|
||||||
private currentSizeInBytes = 0;
|
private currentSizeInBytes = 0;
|
||||||
private readonly cache = new Map<VaultUpdateId, Uint8Array>();
|
private readonly cache = new Map<VaultUpdateId, Uint8Array>();
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
declare const __CURRENT_VERSION__: string;
|
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 =
|
export const packageVersion =
|
||||||
typeof __CURRENT_VERSION__ === "undefined"
|
typeof __CURRENT_VERSION__ === "undefined"
|
||||||
? "development"
|
? "development"
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,13 @@ import { spawnSync } from "node:child_process";
|
||||||
import { resolve } from "node:path";
|
import { resolve } from "node:path";
|
||||||
import { pathToFileURL } from "node:url";
|
import { pathToFileURL } from "node:url";
|
||||||
import { test } from "node:test";
|
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", () => {
|
test("a small deeply nested manifest fits within a bounded validation heap", () => {
|
||||||
const source = pathToFileURL(resolve(__dirname, "portable-path.ts")).href;
|
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/);
|
}, /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"
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -2,22 +2,26 @@ import type { DocumentId, RelativePath } from "../persistence/database";
|
||||||
|
|
||||||
export const INTERNAL_DIRECTORY = ".vault-link-sync";
|
export const INTERNAL_DIRECTORY = ".vault-link-sync";
|
||||||
const MAX_COMPONENT_BYTES = 255;
|
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 =
|
const reservedDeviceName =
|
||||||
/^(CON|PRN|AUX|NUL|CONIN\$|CONOUT\$|COM[1-9¹²³]|LPT[1-9¹²³])$/u;
|
/^(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 = (
|
export const arePathAliases = (
|
||||||
left: RelativePath,
|
left: RelativePath,
|
||||||
right: RelativePath
|
right: RelativePath
|
||||||
): boolean => getPathKey(left) === getPathKey(right);
|
): 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(
|
export function findPathWithSameSpelling(
|
||||||
paths: Iterable<RelativePath>,
|
paths: Iterable<RelativePath>,
|
||||||
wanted: RelativePath
|
wanted: RelativePath
|
||||||
|
|
@ -74,7 +78,7 @@ export function validatePortablePaths(paths: Iterable<RelativePath>): void {
|
||||||
previous &&
|
previous &&
|
||||||
(previous.spelling !== spelling || previous.file || file)
|
(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 };
|
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(
|
function findConflictIndex(
|
||||||
left: readonly string[],
|
left: readonly string[],
|
||||||
right: readonly string[]
|
right: readonly string[]
|
||||||
|
|
@ -105,13 +107,67 @@ function findConflictIndex(
|
||||||
return shared - 1;
|
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(
|
export function allocatePortablePath(
|
||||||
wanted: RelativePath,
|
wanted: RelativePath,
|
||||||
id: DocumentId,
|
id: DocumentId,
|
||||||
occupied: readonly RelativePath[]
|
occupied: readonly RelativePath[]
|
||||||
): RelativePath {
|
): RelativePath {
|
||||||
id = sanitizeFileName(truncateToByteLength(id, 64));
|
id = truncateToByteLength(
|
||||||
|
sanitizeFileName(id.replaceAll("/", "_")),
|
||||||
|
MAX_CONFLICT_ID_BYTES
|
||||||
|
);
|
||||||
|
|
||||||
const originals = wanted.split("/");
|
const originals = wanted.split("/");
|
||||||
const parts = originals.map((part, index) => {
|
const parts = originals.map((part, index) => {
|
||||||
|
|
@ -161,55 +217,3 @@ export function allocatePortablePath(
|
||||||
|
|
||||||
throw new Error(`Unable to allocate a portable path: ${wanted}`);
|
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;
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { sleep } from "./sleep";
|
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>(
|
export function rateLimit<Args extends unknown[], R>(
|
||||||
fn: (...args: Args) => Promise<R>,
|
fn: (...args: Args) => Promise<R>,
|
||||||
minIntervalMs: number | (() => number)
|
minIntervalMs: number | (() => number)
|
||||||
|
|
|
||||||
|
|
@ -57,6 +57,7 @@ module.exports = [
|
||||||
},
|
},
|
||||||
resolve: {
|
resolve: {
|
||||||
fallback: {
|
fallback: {
|
||||||
|
path: require.resolve("path-browserify"),
|
||||||
ws: false // Exclude `ws` from the browser bundle
|
ws: false // Exclude `ws` from the browser bundle
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue