diff --git a/SYNC_ENGINE_HANDOVER.md b/SYNC_ENGINE_HANDOVER.md index aebacadc..9b5a6372 100644 --- a/SYNC_ENGINE_HANDOVER.md +++ b/SYNC_ENGINE_HANDOVER.md @@ -1,3 +1,8 @@ +> Historical handover: its client journaling, recovery-archive and filesystem +> durability descriptions are superseded by the direct-application model in +> [the sync client README](frontend/sync-client/README.md). The current client +> saves complete metadata and reconciles current disk contents after interruption. + # Sync engine v4 handover Prepared on 2026-09-19. This records the implementation delivered in the preceding diff --git a/docs/architecture/sync-algorithm.md b/docs/architecture/sync-algorithm.md index cfdaa5bc..229e9879 100644 --- a/docs/architecture/sync-algorithm.md +++ b/docs/architecture/sync-algorithm.md @@ -1,8 +1,7 @@ # Sync algorithm The current API v5 server stores immutable content versions and a separately -versioned file manifest. Clients reconcile snapshots and apply them through a -durable filesystem journal. The server does not merge competing edits. +versioned file manifest. Clients reconcile snapshots with the current local files. The server does not merge competing edits. ## Identity and ordering @@ -19,10 +18,11 @@ UUID is rejected. ## Reconciliation -A new client reads a coherent vault snapshot, applies it durably, then replays -subsequent events. Replayed pages are folded to their final document heads and -manifest before changing local files. This avoids applying intermediate remote -edits that were already reverted. +A new client reads a coherent vault snapshot, applies it, then replays +subsequent events over HTTP. Pages are folded in memory to their final document +heads and manifest before changing local files. Interrupted fetching restarts from +the last incorporated cursor without saved pagination state. This avoids applying +intermediate remote edits that were already reverted. For text, the client compares the common base, current local bytes and current remote bytes using `reconcile-text`. Configured extensions and valid UTF-8 without @@ -31,34 +31,36 @@ they do not authorize the server to merge a stale base. Large diff operation set are sent as snapshots. If only one side changed, that side is retained. Concurrent unmergeable changes -keep the server version while the journal archives displaced local bytes for -recovery. First contact with existing local text has no shared ancestor and uses +keep the server version. First contact with existing local text has no shared ancestor and uses an empty parent. Concurrent edits may still require human review. Paths use a separate three-way decision: keep a local change when the server path is unchanged, otherwise prefer the server path if both changed. Portable-name collisions receive deterministic conflict names. A manifest deletion is not -reversed merely because another client edited the document; displaced bytes are -retained locally. +reversed merely because another client edited the document. Local backup archives +are not created. ## Filesystem application -Before moving files, the client persists a journal describing sources, -destinations and replacement snapshots. It stages sources before installing any -destination, so swaps and rename cycles do not overwrite each other. Prepared -content is persisted before installation so a crash does not repeat a merge. +The client applies an in-memory plan directly to the visible namespace, then +saves one metadata snapshot. Swaps and cycles move occupied sources to temporary +conflict names that remain ordinary discoverable files if application stops. +There is no persisted filesystem plan, staged payload, recovery replay or fsync +protocol. An interruption may leave a subset of the operations visible, or partial +file content; the next scan reconciles whatever is present then. -External notifications retain logical document identities. Notifications received -during asynchronous filesystem reads or staging are incorporated before accepting -a source. An editor save to a staged document's old path remains part of that -document. Ignored and oversized physical occupants are protected during conflict -allocation. Adapters report atomic editor saves as content updates and suppress -notifications generated by the engine itself. +Create, delete and move notifications persist logical document identities; content +updates only wake the loop to reread current bytes. Namespace changes detected +before mutations invalidate the plan. Notifications during application are incorporated +between files using their document identities; the next scan rereads content. Content is reread +before replacement. Ignored and oversized occupants stay in place. Adapters +report atomic editor saves as updates and suppress engine-generated notifications. -The filesystem and metadata store have separate durability boundaries. Recovery -replays the journal before scanning, including when network sync is disabled. -Adapters must provide complete scans, coherent snapshots, exclusive vault access, -no-replace renames and durable writes. Recovery artifacts are excluded from sync. +Metadata persistence must replace a complete value atomically, leaving a readable +old/new value after interruption. Metadata and user files need not describe the +same moment: scans repair that disagreement. File creation and rename must reject +occupied destinations; scans must be complete and reads coherent. No guarantee is +made that user bytes or rename identities survive power loss. ## Server restores and failures @@ -68,8 +70,7 @@ The client persists and echoes it; the server rejects a checkpoint from a discar history before executing its request. Each new event gets a fresh token even if its numeric ID or request UUID was used on the discarded branch. -After a mismatch, the client durably records recovery intent, retains pending -content, clears old history-dependent state and bootstraps against the restored +After a mismatch, the client records reset intent, clears old history-dependent state and bootstraps against the restored server. Clean files reconcile against the restored snapshot. Unsent edits with an old base are preserved as separate local documents, with conflict names if needed. Interrupted recovery saves are replayable. Backups must be restored with the server diff --git a/docs/guide/limitations.md b/docs/guide/limitations.md index 87824d11..87c8e712 100644 --- a/docs/guide/limitations.md +++ b/docs/guide/limitations.md @@ -9,8 +9,7 @@ VaultLink works well for most Obsidian vaults, but has some constraints you shou Only **`.md`** and **`.txt`** files get automatic conflict-free merging. For other file types, a one-sided change is retained. Concurrent unmergeable -changes keep the server's version and archive displaced local bytes for recovery. -Review recovery artifacts when both devices edit a binary file. +changes keep the server's version. The client does not archive displaced bytes. ### Binary Detection @@ -60,7 +59,7 @@ Rough estimates (varies by vault size and activity): **Growth**: Version history grows with every change. A 10 MB vault with frequent edits might grow to 100+ MB over months. -Active journals, immutable merge bases and request receipts must not be independently deleted. There is no automatic safe history-pruning protocol. +Immutable merge bases and request receipts must not be independently deleted. There is no automatic safe history-pruning protocol. ### Implications @@ -143,6 +142,14 @@ the restored server; unsent edits are preserved as separate local documents. Clients and server must be upgraded together. This does not replace independent backups or protect against arbitrary physical-media corruption. +## Interrupted sync + +The client has no filesystem journal or power-loss durability protocol. Interrupted +writes may leave missing, partial or moved files. On restart, normal scans reconcile +the current files, including edits made while the client was stopped. Metadata +saves must remain complete and readable, so the client can continue syncing; exact +pre-interruption file contents and identities are not guaranteed. + ## Known Edge Cases ### Simultaneous Deletes and Edits @@ -150,10 +157,10 @@ backups or protect against arbitrary physical-media corruption. ``` User A deletes note.md User B edits note.md -Result: Manifest deletion removes the file; unsent edited bytes are retained for recovery. +Result: Manifest deletion removes the file, including unsent local edits. ``` -Content updates do not restore deleted membership. The client retains displaced bytes in its local recovery directory. +Content updates do not restore deleted membership. There is no local recovery archive. ### Large File Uploads diff --git a/frontend/deterministic-tests/README.md b/frontend/deterministic-tests/README.md index a5afbe4e..c137bc72 100644 --- a/frontend/deterministic-tests/README.md +++ b/frontend/deterministic-tests/README.md @@ -74,7 +74,7 @@ Network retry timers remain enabled. Separate fake-clock tests in A barrier checks byte-for-byte replica equality **and** the server's canonical UUID/path map, file bytes, independently computed base/observed hashes, content and manifest CAS parents, event watermark, -contiguous event log, and absence of pending requests/application journals. +contiguous event log, and absence of pending requests. Internal recovery files are excluded from the user-visible file list, but are present in failure disk images. Scenario assertions separately check intended content, document identity, or preservation markers; equality alone is not an @@ -106,8 +106,8 @@ distinct UUIDs at one path. - `wait-for-response-drop`: explicit checkpoint; every armed fault must fire. Every interrupted request must be retried with an identical request ID, target and body before a different CAS is sent. Fault kinds match exactly. -- `rename-next-write`: inject an editor rename after the engine's durable install - rename. The historical name is retained for fixtures; v4 never writes in place. +- `rename-next-write`: inject an editor rename after the engine's content write, + before its metadata save. - `pause-server`, `resume-server`: SIGSTOP/SIGCONT, not a crash. - `crash-server`, `restart-server`: SIGKILL and restart with the same database/port. Server-control scenarios have dedicated processes. diff --git a/frontend/deterministic-tests/src/deterministic-agent.ts b/frontend/deterministic-tests/src/deterministic-agent.ts index 36a029e5..4ef3a838 100644 --- a/frontend/deterministic-tests/src/deterministic-agent.ts +++ b/frontend/deterministic-tests/src/deterministic-agent.ts @@ -43,12 +43,8 @@ export class DeterministicAgent { }); this.disk.boundary = async (label) => { const rename = this.nextWriteRename; - // v4 installs through an exclusive rename, never write-in-place. - if ( - rename && - label.startsWith("durable:rename:") && - label.endsWith(`->${rename.oldPath}`) - ) { + // Inject after the content write, before the metadata save. + if (rename && label === `durable:write:${rename.oldPath}`) { this.nextWriteRename = undefined; await this.rename(rename.oldPath, rename.newPath); } diff --git a/frontend/deterministic-tests/src/tests/same-doc-id-collapse-after-remote-quick-write-and-pending-rename.test.ts b/frontend/deterministic-tests/src/tests/same-doc-id-collapse-after-remote-quick-write-and-pending-rename.test.ts index 2a3b5de4..e78c2310 100644 --- a/frontend/deterministic-tests/src/tests/same-doc-id-collapse-after-remote-quick-write-and-pending-rename.test.ts +++ b/frontend/deterministic-tests/src/tests/same-doc-id-collapse-after-remote-quick-write-and-pending-rename.test.ts @@ -13,10 +13,8 @@ export const sameDocIdCollapseAfterRemoteQuickWriteAndPendingRenameTest: TestDef steps: [ { type: "enable-sync", client: 0 }, - // Create a deleted latest version before client 1 joins. - // Catch-up will advance MinCovered with a non-contiguous id, - // keeping client 1's create lastSeen low enough to exercise - // the server's same-doc merge path from the e2e failure. + // Seed a deleted document before client 1 bootstraps. Later creates + // at the reused path must still receive separate identities. { type: "create", client: 0, diff --git a/frontend/deterministic-tests/src/tests/self-merge-pending-rename-aliases-second-create.test.ts b/frontend/deterministic-tests/src/tests/self-merge-pending-rename-aliases-second-create.test.ts index ac8ed3ed..e3504098 100644 --- a/frontend/deterministic-tests/src/tests/self-merge-pending-rename-aliases-second-create.test.ts +++ b/frontend/deterministic-tests/src/tests/self-merge-pending-rename-aliases-second-create.test.ts @@ -33,11 +33,8 @@ export const selfMergePendingRenameAliasesSecondCreateTest: TestDefinition = { { type: "enable-sync", client: 1 }, { type: "barrier" }, - // Pause client 0's WS so its MinCovered watermark stays at 0 - // through the whole bug sequence. The merge condition the - // server is going to fire is `creation_vuid > last_seen`; with - // a non-zero gap the same-device second create gets merged - // into the same-device first create. + // Delay socket notifications while client 0 continues local work. + // HTTP catchup must preserve the two generations at the reused path. { type: "pause-websocket", client: 0 }, // Client 1 commits a doc to push the server's vuid above 0. diff --git a/frontend/local-client-cli/src/node-filesystem.ts b/frontend/local-client-cli/src/node-filesystem.ts index 521f76bd..5902db8a 100644 --- a/frontend/local-client-cli/src/node-filesystem.ts +++ b/frontend/local-client-cli/src/node-filesystem.ts @@ -134,7 +134,6 @@ export class NodeFileSystemOperations implements FileSystemOperations { await fs.unlink(fullPath).catch((error: unknown) => { if (!this.isMissing(error)) throw error; }); - await this.syncDirectory(path.dirname(fullPath)); } catch (error) { throw new Error( `Failed to delete file ${fullPath}: ${error instanceof Error ? error.message : String(error)}` @@ -170,34 +169,13 @@ export class NodeFileSystemOperations implements FileSystemOperations { const tmpPath = path.join(tmpDir, `atomic-write-${randomUUID()}.tmp`); try { await fs.writeFile(tmpPath, content, encoding); - const fd = await fs.open(tmpPath, "r"); - try { - await fd.datasync(); - } finally { - await fd.close(); - } await fs.rename(tmpPath, fullPath); - await this.syncDirectory(path.dirname(fullPath)); } catch (error) { await fs.unlink(tmpPath).catch(() => undefined); throw error; } } - // Make the rename durable by fsync'ing the destination's parent directory. - // Skipped on Windows: fsync on a directory handle isn't supported there - private async syncDirectory(dir: string): Promise { - if (process.platform === "win32") { - return; - } - const fd = await fs.open(dir, "r"); - try { - await fd.sync(); - } finally { - await fd.close(); - } - } - private async deleteDirectoryTree(fullPath: string): Promise { const entry = await fs.lstat(fullPath).catch((error: unknown) => { if (this.isMissing(error)) return undefined; @@ -220,7 +198,6 @@ export class NodeFileSystemOperations implements FileSystemOperations { } await fs.rmdir(fullPath); - await this.syncDirectory(path.dirname(fullPath)); } private isMissing(error: unknown): boolean { diff --git a/frontend/package.json b/frontend/package.json index 0bbf94af..9e33d1b0 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -19,8 +19,8 @@ "dev": "concurrently --kill-others \"npm run dev -w sync-client\" \"npm run dev -w obsidian-plugin\"", "test": "npm run test --workspaces", "check:harness": "tsc --noEmit -p test-support/tsconfig.json && tsc --noEmit -p deterministic-tests/tsconfig.json && tsc --noEmit -p test-client/tsconfig.json", - "test:harness": "npm run check:harness && tsx --test 'test-support/harness.test.ts' 'test-support/oracle-regressions.test.ts' 'test-support/recovery.test.ts' 'test-support/journal-interleavings.test.ts' 'test-support/observer.test.ts' 'test-support/event-delivery.test.ts' 'test-support/polling.test.ts' 'test-support/engine-regressions.test.ts' 'test-support/sync-audit.test.ts' 'test-client/src/**/*.test.ts' '../scripts/e2e-runner.test.mjs'", - "test:protocol": "tsx --test 'test-support/protocol.test.ts' 'test-support/journal-protocol.test.ts' 'test-support/content-limits.test.ts' 'test-support/server-restore.test.ts' 'test-support/protocol-audit.test.ts' 'test-support/real-disk.test.ts'", + "test:harness": "npm run check:harness && tsx --test 'test-support/harness.test.ts' 'test-support/oracle-regressions.test.ts' 'test-support/recovery.test.ts' 'test-support/file-application.test.ts' 'test-support/observer.test.ts' 'test-support/event-delivery.test.ts' 'test-support/polling.test.ts' 'test-support/engine-regressions.test.ts' 'test-support/sync-audit.test.ts' 'test-client/src/**/*.test.ts' '../scripts/e2e-runner.test.mjs'", + "test:protocol": "tsx --test 'test-support/protocol.test.ts' 'test-support/restart-reconciliation.test.ts' 'test-support/content-limits.test.ts' 'test-support/server-restore.test.ts' 'test-support/protocol-audit.test.ts' 'test-support/real-disk.test.ts'", "lint": "eslint --fix sync-client obsidian-plugin test-client deterministic-tests local-client-cli && prettier --write \"**/*.ts\"", "update": "ncu -u -ws" }, diff --git a/frontend/sync-client/README.md b/frontend/sync-client/README.md index 06d680ad..0a5bc956 100644 --- a/frontend/sync-client/README.md +++ b/frontend/sync-client/README.md @@ -4,28 +4,30 @@ The engine uses one serialized loop per vault and requires Web Crypto (SHA-256). messages wake it; complete disk scans and the durable server event log are the sources of truth. Periodic reconciliation is opt-in: set `syncIntervalMs` to a positive interval to repair missed notifications. Unset or `0` disables polling. -`syncLocallyCreatedFile`, `syncLocallyUpdatedFile`, and `syncLocallyDeletedFile` -durably record logical file changes before resolving, including while offline. -Shutdown waits for those saves. Notifications describe logical external changes: adapters suppress +Create, delete and move notifications persist logical identity changes before +resolving, including while offline. Shutdown waits for those saves. Content-update +notifications only wake reconciliation; scans read the latest bytes, including after +restart. Notifications describe logical external changes: adapters suppress self-generated create/delete/move notifications, and report atomic editor saves as content updates. This distinguishes a deliberate delete/recreate from an edit. Use `waitUntilFinished()` to await the active attempt; errors leave -pending work durable and are reported to the caller. Transient failures retry; +pending requests saved and are reported to the caller. Transient failures retry; authentication and protocol errors wait for an explicit wake or settings change. ## State and policy The persisted state contains the incorporated server file manifest, the current local identity map, and the exact pending submission. A received server file -manifest is not a merge base until its local application is durable. Successful +manifest becomes the merge base when its application metadata is saved. Successful submissions advance the base to the submitted content snapshot, preserving later local edits. Unknown outcomes are retried unchanged before incorporating newer -remote state. Each contiguous event batch is folded into its final manifest and -content heads before merging. Intermediate changes that the server has already -reverted must not overwrite local edits. +remote state. HTTP is the sole source of remote events; WebSocket messages only +wake the loop. Contiguous event pages are folded in memory into their final manifest +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 locally, with content retained for -recovery. An unchanged rejected payload is skipped so other files can sync; the +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. @@ -44,10 +46,9 @@ 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. -- Concurrent binary/unmergeable edits keep the server version and retain - displaced local bytes in recovery. If only one side changed, keep that side. -- Deletion follows the file manifest decision. Displaced edited bytes are retained - locally, without automatically restoring membership. +- 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. - Notified moves preserve IDs. Offline inference requires a unique nonempty hash match on both sides; uncertain cases become delete/create. - Ignored and oversized files preserve their disk paths and remote membership. @@ -65,9 +66,8 @@ writes. WebSocket events wake HTTP replay, which validates that checkpoint. A restored database cannot silently reuse a version number: a missing or different event incarnation returns a history mismatch before executing the request. -On mismatch the client first recovers its filesystem journal and scans local -work. It durably records a reset before clearing the old checkpoint, archives -pending content requests, and clears old receipts, cursors, bases and diff-cache +On mismatch the client scans local work and records a reset before clearing the +old checkpoint. It clears old requests, receipts, cursors, bases and diff-cache entries. Clean files reconcile against the restored snapshot. 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. @@ -79,59 +79,37 @@ Their failures are reported, and remaining observers continue receiving updates. ## Required adapter contract -`FileSystemOperations` and `PersistenceProvider` define the adapter operations -needed by the recovery model. The Obsidian and CLI adapters have intentionally -not been updated in this rewrite. +`FileSystemOperations` and `PersistenceProvider` define the adapter operations. +The Obsidian and CLI adapters still need to adopt the current core interface. +The sync client does not coordinate separate processes accessing the same vault. -The sync client does not coordinate separate processes accessing the same local -vault. +The filesystem adapter must provide complete scans, coherent snapshots, recursive +directory creation, exclusive file creation, no-replace renames, and deletion of +individual regular files. Directory pruning uses only `rmdir`, never recursive +file removal. Reject links, special files and paths escaping the vault. An unreadable +file or directory must fail the scan rather than look like a deletion. Optional +editor cursor metadata is applied with file content. -The filesystem adapter must provide: +User-file writes have no power-loss durability requirement. There is no filesystem +journal, hidden staging area, replay, backup archive, or flush protocol. Swaps and +cycles temporarily move occupied files to visible conflict names; a fresh scan +can discover them if application stops partway through. Partial writes and edits +made before, during or after an interruption are treated as current disk content. +The next sync reconciles that content; it does not finish a saved filesystem plan. +Power loss may lose bytes or rename identity and may cause a merge to run again. -- Complete scans and coherent content snapshots. Missing and unreadable must - be distinguished; reject read races rather than return a partial snapshot. -- Vault-relative operations that reject symlinks, hardlinks, special files, and - ancestor symlink traversal. Staging and visible files share one filesystem. -- Atomic, complete, exclusive file creation (a crash leaves absent or complete - bytes, never a partial file) and atomic renames that **never replace** destinations. - Writes apply optional cursor metadata. -- `flushPaths` flushes existing files and ancestor directories, including parents - of absent paths. Recovery uses it to complete interrupted rename durability. -- Durable recursive directory creation, and directory pruning using only `rmdir` - operations (never unlink descendant files). All affected data/directory entries - must be flushed before a mutation resolves, including intermediate ancestors. +Metadata is the one atomic boundary: persistence `save` replaces a complete value +and an interrupted/failed save leaves an old or new complete value, never a torn +object. A failed save is reloaded before retrying. No ordering between metadata and +user-file durability is required. Settings and engine saves are serialized. +Keep metadata outside the user-file namespace or in `.vault-link-sync`, which is +excluded from scans. Changing a client's vault once it holds identities, offline +notifications, pending requests or bootstrap state requires a separate state store. -Keep the persistence store outside the user file namespace or inside the reserved -`.vault-link-sync` directory, so writing sync state cannot generate user edits. - -Persistence `save` atomically replaces the complete state and resolves only after -its data and directory entry are durable. A failed save may leave either complete -value; the engine reloads before retrying. Settings saves are assumed to succeed -and do not reload after errors. Settings and engine saves are serialized. -Changing a client's vault once it holds identities, offline notifications, -pending requests, or bootstrap/application state requires a separate state store -and client. Lifecycle operations are serialized. - -Before changing visible files, the engine persists an application journal. It -stages every affected source before installing destinations, so swaps and cycles -work. Recovery runs before scanning, including while the server is unavailable. -Prepared content is persisted before installation, preventing repeated merges -following a crash. Unexpected occupants are preserved and given conflict names. -Notifications received while staging rebind the actual source identity before -content is merged. Editor saves to a temporarily staged path continue the original -document's journal, including saves racing preparation and installation. Ignored -or oversized occupants are reserved before conflict allocation. - -`.vault-link-sync/transactions` contains only active recovery artifacts. Staged -sources and prepared output are removed after every install is durably recorded; -completed journals are not archived. `.vault-link-sync/recovery` retains displaced -original bytes and their document/path metadata before staging files are deleted. -`.vault-link-sync/requests` retains rejected submission snapshots for explicit -recovery. These directories are never scanned -as user files. Pending requests and active recovery journals must not be -independently discarded. - -These guarantees assume the adapters/storage honor their durability contracts. -Arbitrary external writers can change a file between observations; detected races -are preserved/reconciled, but unobserved writes and offline identity cannot be -reconstructed perfectly. There is no automated v2/v3 persisted-state migration. +Filesystem application saves metadata once when it finishes. Namespace changes detected +before mutations invalidate the plan. Changes during application are incorporated +between files using their document identities; the next scan rereads content. Content is reread before +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. diff --git a/frontend/sync-client/src/file-operations/file-operations.recovery.test.ts b/frontend/sync-client/src/file-operations/file-operations.recovery.test.ts deleted file mode 100644 index 22ed4c8e..00000000 --- a/frontend/sync-client/src/file-operations/file-operations.recovery.test.ts +++ /dev/null @@ -1,428 +0,0 @@ -import assert from "node:assert/strict"; -import { describe, it } from "node:test"; -import { - Database, - emptyState, - type StoredDatabase -} from "../persistence/database"; -import type { ServerConfig } from "../services/server-config"; -import { toStoredSnapshot } from "../sync-operations/content"; -import { isInternalPath } from "../utils/portable-path"; -import { Logger } from "../tracing/logger"; -import { FileOperations, type FileWrite } from "./file-operations"; -import type { - FileSnapshot, - FileSystemOperations -} from "./filesystem-operations"; - -const bytes = (text: string) => new TextEncoder().encode(text); -const text = (snapshot: FileSnapshot) => - new TextDecoder().decode(snapshot.content); -const parent = (path: string) => path.split("/").slice(0, -1).join("/"); - -/** Atomic mutations with an injectable interruption before or after durability. */ -class MemoryFiles implements FileSystemOperations { - public readonly files = new Map(); - public readonly directories = new Set([""]); - public boundary: (label: string) => void = () => {}; - - public change(label: string, action: () => void): void { - this.boundary(`before ${label}`); - action(); - this.boundary(`after ${label}`); - } - - public put(path: string, value: string): void { - for ( - let directory = parent(path); - directory; - directory = parent(directory) - ) - this.directories.add(directory); - this.files.set(path, { content: bytes(value) }); - } - - public async listFilesRecursively(root = ""): Promise { - assert(this.directories.has(root), `Cannot list non-directory ${root}`); - return [...this.files.keys()].filter( - (path) => !root || path.startsWith(`${root}/`) - ); - } - - public async readSnapshot(path: string): Promise { - assert(!this.directories.has(path), `Cannot read directory ${path}`); - return structuredClone(this.files.get(path)); - } - - public async stat(path: string) { - for (let ancestor = parent(path); ancestor; ancestor = parent(ancestor)) - assert( - !this.files.has(ancestor), - `Cannot traverse file ${ancestor}` - ); - const snapshot = this.files.get(path); - if (snapshot) - return { kind: "file" as const, size: snapshot.content.length }; - if (this.directories.has(path)) - return { kind: "directory" as const, size: 0 }; - return undefined; - } - - public async exists(path: string): Promise { - return this.files.has(path) || this.directories.has(path); - } - - public async createDirectory(path: string): Promise { - this.change(`mkdir ${path}`, () => { - for ( - let directory = path; - directory; - directory = parent(directory) - ) { - assert( - !this.files.has(directory), - `File blocks directory ${directory}` - ); - this.directories.add(directory); - } - }); - } - - public async write(path: string, snapshot: FileSnapshot): Promise { - this.change(`write ${path}`, () => { - assert( - !this.files.has(path) && !this.directories.has(path), - `Destination exists: ${path}` - ); - assert( - this.directories.has(parent(path)), - `Parent missing: ${path}` - ); - this.files.set(path, structuredClone(snapshot)); - }); - } - - public async rename(from: string, to: string): Promise { - this.change(`rename ${from} -> ${to}`, () => { - assert(this.files.has(from), `Source missing: ${from}`); - assert( - !this.files.has(to) && !this.directories.has(to), - `Destination exists: ${to}` - ); - assert(this.directories.has(parent(to)), `Parent missing: ${to}`); - this.files.set(to, this.files.get(from)!); - this.files.delete(from); - }); - } - - public async flushPaths(paths: readonly string[]): Promise { - this.change(`flush ${paths.join(",")}`, () => {}); - } - - public async delete(path: string): Promise { - this.change(`rmdir ${path}`, () => { - assert(!this.files.has(path)); - assert( - ![...this.files.keys()].some((file) => - file.startsWith(`${path}/`) - ) - ); - for (const directory of this.directories) - if (directory === path || directory.startsWith(`${path}/`)) - this.directories.delete(directory); - }); - } - - public async deleteFile(path: string): Promise { - this.change(`unlink ${path}`, () => { - assert(!this.directories.has(path)); - this.files.delete(path); - }); - } -} - -async function setup( - contents: Record, - local: Record -) { - const fs = new MemoryFiles(); - for (const [path, value] of Object.entries(contents)) fs.put(path, value); - let durable: StoredDatabase = emptyState("test"); - durable.local = local; - for (const [id, path] of Object.entries(local)) - durable.documents[id] = { - materialized: true, - observedHash: (await toStoredSnapshot(fs.files.get(path)!)).hash - }; - const config = { - getConfig: async () => ({ mergeableFileExtensions: ["md"] }) - } as ServerConfig; - const restart = () => { - const database = new Database( - new Logger(), - structuredClone(durable), - async (next) => { - fs.change("save", () => { - durable = structuredClone(next); - }); - }, - "test", - async () => structuredClone(durable) - ); - return { - database, - operations: new FileOperations(fs, database, config) - }; - }; - return { fs, restart, ...restart() }; -} - -async function scenario() { - const fixture = await setup( - { - "a.md": "A", - "b.md": "B", - "c.md": "C", - "d.md": "Unmanaged", - "e.md": "E", - "folder/keep.md": "Keep", - "deleted.md": "Deleted" - }, - { a: "a.md", b: "b.md", c: "c.md", e: "e.md", deleted: "deleted.md" } - ); - const next = structuredClone(fixture.database.state); - next.local = { - a: "b.md", - b: "a.md", - c: "d.md", - e: "folder", - added: "new.md" - }; - next.documents.added = { materialized: false }; - const writes: Record = { - a: { - expected: await fixture.operations.snapshot("a.md"), - replacement: await toStoredSnapshot({ content: bytes("A updated") }) - }, - added: { - replacement: await toStoredSnapshot({ - content: bytes("Downloaded") - }) - } - }; - return { ...fixture, next, writes }; -} - -async function verifyScenario(fs: MemoryFiles, database: Database) { - assert.equal(database.state.application, undefined); - for (const [id, expected] of Object.entries({ - a: "A updated", - b: "B", - c: "C", - e: "E", - added: "Downloaded" - })) { - const snapshot = fs.files.get(database.state.local[id])!; - assert.equal(text(snapshot), expected, id); - assert.equal(database.state.documents[id].materialized, true, id); - assert.equal( - database.state.documents[id].observedHash, - (await toStoredSnapshot(snapshot)).hash, - id - ); - } - assert.equal(database.state.local.a, "b.md"); - assert.equal(database.state.local.b, "a.md"); - assert.equal(database.state.local.c, "d.md"); - assert.notEqual(database.state.local.e, "folder"); - assert.equal(text(fs.files.get("folder/keep.md")!), "Keep"); - assert.equal(fs.files.has("deleted.md"), false); - assert.equal(database.state.local.deleted, undefined); - const visible = [...fs.files].filter(([path]) => !isInternalPath(path)); - assert.deepEqual( - visible.map(([, value]) => text(value)).sort(), - ["A updated", "B", "C", "Downloaded", "E", "Keep", "Unmanaged"].sort() - ); - assert.equal( - [...fs.files.keys()].some((path) => - path.startsWith(".vault-link-sync/transactions/") - ), - false - ); -} - -describe("filesystem journal recovery", () => { - it("stages swaps, preserves unexpected files/directories, and cleans recovery artifacts", async () => { - const { operations, database, fs, next, writes } = await scenario(); - await operations.apply(next, writes); - await verifyScenario(fs, database); - fs.boundary = () => { - throw new Error("Completed recovery must not write"); - }; - await operations.recover(); - }); - - it("resumes after interruption at every filesystem and metadata mutation boundary", async (t) => { - const baseline = await scenario(); - const boundaries: string[] = []; - baseline.fs.boundary = (label) => boundaries.push(label); - await baseline.operations.apply(baseline.next, baseline.writes); - for (const [index, label] of boundaries.entries()) { - await t.test(`${index}: ${label}`, async () => { - const fixture = await scenario(); - const interrupted = new Error("Interrupted"); - let count = 0; - fixture.fs.boundary = () => { - if (count++ === index) throw interrupted; - }; - await assert.rejects( - fixture.operations.apply(fixture.next, fixture.writes), - (error) => error === interrupted - ); - fixture.fs.boundary = () => {}; - const { database, operations } = fixture.restart(); - if ( - !database.state.application && - database.state.local.a === "a.md" - ) - await operations.apply( - structuredClone(fixture.next), - fixture.writes - ); - else await operations.recover(); - await verifyScenario(fixture.fs, database); - }); - } - }); - - it("merges an edit made after planning exactly once across an interrupted install", async () => { - const base = "First paragraph.\n\nLast paragraph.\n"; - const local = "Local first paragraph.\n\nLast paragraph.\n"; - const remote = "First paragraph.\n\nRemote last paragraph.\n"; - const fixture = await setup({ "note.md": base }, { note: "note.md" }); - const expected = await fixture.operations.snapshot("note.md"); - let edited = false; - const interrupted = new Error("Interrupted after install"); - fixture.fs.boundary = (label) => { - if (!edited && label === "after save") { - fixture.fs.put("note.md", local); - edited = true; - } - if ( - label.startsWith("after rename ") && - label.endsWith(" -> note.md") - ) - throw interrupted; - }; - await assert.rejects( - fixture.operations.apply(structuredClone(fixture.database.state), { - note: { - expected, - replacement: await toStoredSnapshot({ - content: bytes(remote) - }) - } - }), - (error) => error === interrupted - ); - fixture.fs.boundary = () => {}; - const { operations, database } = fixture.restart(); - await operations.recover(); - assert.equal( - text(fixture.fs.files.get("note.md")!), - "Local first paragraph.\n\nRemote last paragraph.\n" - ); - assert.equal( - database.state.documents.note.observedHash, - (await operations.snapshot("note.md"))!.hash - ); - assert.equal( - [...fixture.fs.files.keys()].some((path) => - path.startsWith(".vault-link-sync/transactions/") - ), - false - ); - }); - - it("preserves an external deletion between planning and staging", async () => { - const fixture = await setup( - { "note.md": "Local" }, - { note: "note.md" } - ); - const expected = await fixture.operations.snapshot("note.md"); - fixture.fs.boundary = (label) => { - if (label === "after save") fixture.fs.files.delete("note.md"); - }; - await fixture.operations.apply( - structuredClone(fixture.database.state), - { - note: { - expected, - replacement: await toStoredSnapshot({ - content: bytes("Remote") - }) - } - } - ); - assert.equal(fixture.database.state.local.note, undefined); - assert.equal(fixture.fs.files.has("note.md"), false); - }); - - it("preserves a file created after the destination check", async () => { - const fixture = await setup( - { "source.md": "Incoming" }, - { doc: "source.md" } - ); - const next = structuredClone(fixture.database.state); - next.local.doc = "target.md"; - fixture.fs.boundary = (label) => { - if ( - label.startsWith("before rename ") && - label.endsWith(" -> target.md") - ) - fixture.fs.put("target.md", "Concurrent"); - }; - await assert.rejects( - fixture.operations.apply(next), - /Destination exists/ - ); - fixture.fs.boundary = () => {}; - const { operations, database } = fixture.restart(); - await operations.recover(); - assert.equal(text(fixture.fs.files.get("target.md")!), "Incoming"); - const displaced = Object.entries(database.state.local).find( - ([id]) => id !== "doc" - )!; - assert.equal(text(fixture.fs.files.get(displaced[1])!), "Concurrent"); - }); - - it("preserves an unexpected file blocking a destination's ancestor", async () => { - const fixture = await setup( - { "source.md": "Incoming", target: "Occupant" }, - { doc: "source.md" } - ); - const next = structuredClone(fixture.database.state); - next.local.doc = "target/child.md"; - await fixture.operations.apply(next); - assert.equal( - text(fixture.fs.files.get("target/child.md")!), - "Incoming" - ); - const displaced = Object.entries(fixture.database.state.local).find( - ([id]) => id !== "doc" - )!; - assert.equal(text(fixture.fs.files.get(displaced[1])!), "Occupant"); - }); - - it("handles paths becoming directories and directories becoming files", async () => { - const fixture = await setup( - { a: "A", "b/deep/child": "B" }, - { a: "a", b: "b/deep/child" } - ); - const next = structuredClone(fixture.database.state); - next.local = { a: "b", b: "a/deep/child" }; - await fixture.operations.apply(next); - assert.equal(text(fixture.fs.files.get("b")!), "A"); - assert.equal(text(fixture.fs.files.get("a/deep/child")!), "B"); - }); -}); diff --git a/frontend/sync-client/src/file-operations/file-operations.ts b/frontend/sync-client/src/file-operations/file-operations.ts index 81d1a0f6..49c91dc4 100644 --- a/frontend/sync-client/src/file-operations/file-operations.ts +++ b/frontend/sync-client/src/file-operations/file-operations.ts @@ -1,55 +1,48 @@ import { v4 as uuid } from "uuid"; +import type { FileSystemOperations } from "./filesystem-operations"; import type { - FileSnapshot, - FileSystemOperations -} from "./filesystem-operations"; -import type { - ApplicationJournal, Database, EngineState, - FileStep, StoredSnapshot } from "../persistence/database"; -import { - allocatePortablePath, - INTERNAL_DIRECTORY, - isInternalPath -} from "../utils/portable-path"; +import { allocatePortablePath, isInternalPath } from "../utils/portable-path"; import { mergeContent, fromStoredSnapshot, toStoredSnapshot } from "../sync-operations/content"; import type { ServerConfig } from "../services/server-config"; -import { FileNotFoundError } from "../errors/errors"; import { applyLocalChange, unappliedChanges, type LocalChange } from "../sync-operations/local-changes"; -import { resolvePaths } from "../sync-operations/file-manifest"; +import { + FileNotFoundError, + LocalChangesDuringReconciliation +} from "../errors/errors"; export interface FileWrite { expected?: StoredSnapshot; replacement: StoredSnapshot; } -/** The persisted journal bridges the filesystem and the metadata store. Every - * source is staged before any destination is installed. A phase is saved BEFORE - * an ambiguous rename, so recovery never reapplies a content merge to its output. - */ +/** Files and metadata are independent observations, not a filesystem transaction. + * Interrupted applications are reconciled by the next ordinary scan. */ export class FileOperations { + private physical?: EngineState; + public constructor( public readonly fs: FileSystemOperations, private readonly database: Database, private readonly serverConfig: ServerConfig, private readonly filesApplied: ( paths: readonly string[] - ) => void = () => {}, + ) => void = () => undefined, private readonly localChanges: { entries: () => readonly LocalChange[]; flush: () => Promise; - } = { entries: () => [], flush: async () => {} }, + } = { entries: () => [], flush: async () => undefined }, private readonly protectedFile: ( path: string, size: number @@ -70,715 +63,309 @@ export class FileOperations { } public async snapshot(path: string): Promise { - const value = await this.fs.readSnapshot(path); - return value && toStoredSnapshot(value); - } - - /** Project journal phases onto the physical namespace before binding an - * editor notification. Content hashes must never choose a notified identity. */ - private async physicalState( - journal = this.database.state.application - ): Promise { - const state = structuredClone(journal?.next ?? this.database.state); - if (journal) { - for (const step of journal.steps) { - if (step.superseded) continue; - const origin = step.replacement ? step.output : step.staged; - const path = - step.phase === "installed" - ? step.to - : step.phase === "installing" && - !(await this.fs.exists(origin)) - ? step.to - : (await this.fs.exists(step.staged)) - ? step.staged - : step.from; - if (path) state.local[step.documentId] = path; - else delete state.local[step.documentId]; - } - } - return state; + const snapshot = await this.fs.readSnapshot(path); + return snapshot && toStoredSnapshot(snapshot); } public async captureChange( change: LocalChange, previous: readonly LocalChange[] ): Promise { - const state = await this.physicalState(); - // No notifications may bind provisional identities before the initial - // snapshot is available. Replay the whole ordered queue at bootstrap, - // including moves/deletions after creates. + const state = structuredClone(this.physical ?? this.database.state); if (!state.initialized) return; for (const pending of unappliedChanges(state, previous)) applyLocalChange(state, pending, () => false); - if ( - change.type === "update" && - !Object.values(state.local).includes(change.path) - ) { - // An editor may save to the logical path while its original is in - // staging. Namespace creates/deletes still bind to physical paths. - const step = this.database.state.application?.steps.find( - (step) => - step.from === change.path && step.phase !== "installed" - ); - if (step && isInternalPath(state.local[step.documentId] ?? "")) - state.local[step.documentId] = change.path; - } applyLocalChange(state, change, () => false); } - private async incorporateLocalChanges( - journal: ApplicationJournal - ): Promise { - await this.localChanges.flush(); - const changes = unappliedChanges( - journal.next, - this.localChanges.entries() - ); - if (!changes.length) return; - const physical = await this.physicalState(journal); - for (const change of changes) { - if (change.type === "update") { - const info = await this.fs.stat(change.path); - for (const id of Object.keys(change.identities ?? {})) { - // A save immediately before the rename has already moved - // to staging by the time its notification is incorporated. - const stagedPath = physical.local[id]; - const saved = - info ?? - (stagedPath && isInternalPath(stagedPath) - ? await this.fs.stat(stagedPath) - : undefined); - if ( - saved?.kind === "file" && - this.protectPath( - journal, - physical, - id, - change.path, - saved.size - ) - ) - journal.next.local[id] = change.path; - } - await this.incorporateContentSave(journal, physical, change); - journal.next.excluded = physical.excluded; - journal.next.lastAppliedLocalChangeId = change.changeId; - continue; - } - const before = { ...physical.local }; - const path = - change.type === "move" ? change.relativePath : change.path; - const info = await this.fs.stat(path); - if (info?.kind === "file" && this.protectedFile(path, info.size)) - journal.next.protectedPaths = [ - ...new Set([...(journal.next.protectedPaths ?? []), path]) - ]; - applyLocalChange( - physical, - change, - (path) => - isInternalPath(path) || - this.protectedFile(path, 0) || - journal.next.protectedPaths?.includes(path) === true || - Object.keys(journal.next.excluded ?? {}).some( - (id) => before[id] === path - ) - ); - for (const id of new Set([ - ...Object.keys(before), - ...Object.keys(physical.local) - ])) { - if (before[id] === physical.local[id]) continue; - if (physical.local[id] === undefined) - delete journal.next.local[id]; - else { - const path = physical.local[id]; - journal.next.local[id] = path; - const info = await this.fs.stat(path); - this.protectPath( - journal, - physical, - id, - path, - info?.size ?? 0 - ); - } - journal.next.documents[id] ??= physical.documents[id]; - } - journal.next.excluded = physical.excluded; - journal.next.lastAppliedLocalChangeId = change.changeId; - } - journal.next.local = resolvePaths( - journal.next.local, - journal.next.fileManifest.entries, - Object.keys(journal.next.excluded ?? {}), - journal.next.protectedPaths - ); - for (const step of journal.steps) { - if (step.superseded) continue; - // A notified move can exclude an input after this transaction was - // planned. Preserve its bytes as well as its chosen physical path. - if (journal.next.excluded?.[step.documentId] !== undefined) { - const origin = step.replacement ? step.output : step.staged; - if ( - step.phase === "installing" && - !(await this.fs.exists(origin)) - ) - step.phase = "installed"; - if (step.replacement && step.phase !== "installed") { - // contentWrite advanced the proposed merge context before - // applying bytes. Cancelling that application must retain - // the last incorporated base so reenabling the file still - // reconciles the deferred remote head. An installed output, - // including one whose acknowledgement was lost, keeps its - // new base for subsequent local edits. - const previous = - this.database.state.documents[step.documentId]; - Object.assign( - journal.next.documents[step.documentId], - structuredClone({ - base: previous?.base, - bootstrap: previous?.bootstrap, - recoveryBase: previous?.recoveryBase - }) - ); - } - step.replacement = undefined; - } - if (step.phase === "installed") { - step.to = physical.local[step.documentId]; - continue; - } - if (step.phase === "planned") { - const staged = await this.fs.exists(step.staged); - const from = physical.local[step.documentId]; - if (staged && from !== step.staged) { - // A notified move/delete and path reuse raced the source - // rename. Bind the staged object to the new occupant, then - // stage the original identity from its notified location. - const occupant = Object.keys(physical.local).find( - (id) => physical.local[id] === step.from - ); - const originalStaged = step.staged; - step.staged = `${step.staged}-${uuid()}`; - if (occupant) { - journal.steps.push({ - documentId: occupant, - from: step.from, - to: journal.next.local[occupant], - staged: originalStaged, - output: `${originalStaged}.output`, - phase: "staged" - }); - physical.local[occupant] = originalStaged; - } else { - // Retain an ambiguous object without merging it into a - // different document. Cleanup archives this source. - journal.steps.push({ - ...step, - staged: originalStaged, - to: undefined, - replacement: undefined, - phase: "installed", - superseded: true - }); - } - } - if ( - !staged || - (from !== undefined && !isInternalPath(from)) || - from === undefined - ) { - if (step.from && !from) step.replacement = undefined; - step.from = from; - } - } - step.to = journal.next.local[step.documentId]; - } - for (const [id, from] of Object.entries(physical.local)) { - const to = journal.next.local[id]; - if ( - from === to || - isInternalPath(from) || - journal.steps.some( - (step) => - step.documentId === id && step.phase !== "installed" - ) - ) - continue; - const prefix = `${INTERNAL_DIRECTORY}/transactions/${journal.id}/${id}-${uuid()}`; - journal.steps.push({ - documentId: id, - from, - to, - expected: await this.snapshot(from), - staged: `${prefix}.source`, - output: `${prefix}.output`, - phase: "planned" - }); - } - await this.persist(journal); - } - - private protectPath( - journal: ApplicationJournal, - physical: EngineState, - id: string, - path: string, - size: number - ): boolean { - if ( - !this.protectedFile(path, size) && - !journal.next.protectedPaths?.includes(path) - ) - return false; - physical.excluded ??= {}; - physical.excluded[id] ??= - this.database.state.fileManifest.entries[id] ?? null; - journal.next.protectedPaths = [ - ...new Set([...(journal.next.protectedPaths ?? []), path]) - ]; - return true; - } - - private async incorporateContentSave( - journal: ApplicationJournal, - physical: EngineState, - change: LocalChange & { type: "update" } - ): Promise { - for (const id of Object.keys(change.identities ?? {})) { - const step = journal.steps.find( - (step) => - step.documentId === id && - step.phase !== "installed" && - !step.superseded - ); - if ( - !step || - !(await this.fs.exists(step.staged)) || - (await this.fs.stat(change.path))?.kind !== "file" - ) - continue; - const moved = - step.phase === "installing" && - !(await this.fs.exists( - step.replacement ? step.output : step.staged - )); - if (moved && step.to === change.path) continue; - // Stage the editor's save as a continuation of the SAME identity. - // The previous source stays in the journal until durable cleanup. - const prefix = `${INTERNAL_DIRECTORY}/transactions/${journal.id}/${id}-${uuid()}`; - if (moved && step.to) { - // Installation won the race with a save to the old editor path. - // Stage its output before reinstalling this identity's merge. - journal.steps.push({ - documentId: id, - from: step.to, - staged: `${prefix}.previous`, - output: `${prefix}.unused`, - phase: "planned", - superseded: true - }); - } - journal.steps.push({ - documentId: id, - from: change.path, - to: step.to, - expected: step.expected, - replacement: step.replacement, - staged: `${prefix}.source`, - output: `${prefix}.output`, - phase: "planned" - }); - step.to = undefined; - step.replacement = undefined; - step.phase = "installed"; - step.superseded = true; - physical.local[id] = change.path; - } - } - - private directory(path: string): string { - return path.split("/").slice(0, -1).join("/"); - } - - private async writeOnce( - path: string, - snapshot: FileSnapshot - ): Promise { - const existing = await this.fs.readSnapshot(path); - if (existing) { - const actual = await toStoredSnapshot(existing); - const expected = await toStoredSnapshot(snapshot); - if (actual.contentBase64 !== expected.contentBase64) - throw new Error(`Recovery artifact changed: ${path}`); - await this.fs.flushPaths([path]); - return; - } - await this.ensureParents(path); - await this.fs.write(path, snapshot); - } - - private async writeJsonOnce(path: string, value: unknown): Promise { - await this.writeOnce(path, { - content: new TextEncoder().encode(JSON.stringify(value)) - }); - } - - private async persist(journal: ApplicationJournal): Promise { - await this.database.commit({ - ...this.database.state, - application: journal - }); - } - - public async retainSnapshot( - requestId: string, - documentId: string, - path: string | undefined, - snapshot: StoredSnapshot - ): Promise { - const prefix = `${INTERNAL_DIRECTORY}/requests/${requestId}`; - await this.writeOnce(`${prefix}.content`, fromStoredSnapshot(snapshot)); - const metadata = await this.fs.readSnapshot(`${prefix}.json`); - if (metadata) { - const saved: unknown = JSON.parse( - new TextDecoder().decode(metadata.content) - ); - if ( - typeof saved !== "object" || - saved === null || - !("documentId" in saved) || - saved.documentId !== documentId || - !("hash" in saved) || - saved.hash !== snapshot.hash - ) - throw new Error(`Recovery artifact changed: ${prefix}.json`); - await this.fs.flushPaths([`${prefix}.json`]); - return; // originalPath is a hint recorded once, not request identity. - } - await this.writeJsonOnce(`${prefix}.json`, { - documentId, - originalPath: path, - hash: snapshot.hash - }); - } - public async apply( next: EngineState, - writes: Record = {}, - validatePlan: () => void = () => {} + writes: Partial> = {}, + validatePlan: () => void = () => undefined ): Promise { - if (this.database.state.application) - throw new Error( - "Recover the existing filesystem transaction first" - ); - - const id = uuid(); - const steps: FileStep[] = []; - for (const documentId of new Set([ - ...Object.keys(this.database.state.local), - ...Object.keys(next.local) - ])) { - const oldPath = this.database.state.local[documentId]; - const newPath = next.local[documentId]; - const materialized = - this.database.state.documents[documentId]?.materialized; - const from = materialized ? oldPath : undefined; - const write = writes[documentId]; - if (!write && (from === undefined || from === newPath)) continue; - const expected = - write?.expected ?? - (from ? await this.snapshot(from) : undefined); - // An external deletion is a local change, not permission to resurrect - // the file using a stale network response. - if (from && !expected && !write) { - delete next.local[documentId]; - continue; - } - const prefix = `${INTERNAL_DIRECTORY}/transactions/${id}/${documentId}`; - steps.push({ - documentId, - from, - to: newPath, - expected, - replacement: write?.replacement, - staged: `${prefix}.source`, - output: `${prefix}.output`, - phase: "planned" - }); - } - validatePlan(); - if (steps.length === 0) { - const changedPaths = this.changedPaths( - this.database.state.local, - next.local - ); - await this.database.commit(next); - this.filesApplied(changedPaths); - return; - } - - const journal = { - id, - next: structuredClone(next), - steps, - extensions: (await this.serverConfig.getConfig()) - .mergeableFileExtensions + await this.localChanges.flush(); + const changes = this.localChanges.entries().length; + let applying = false; + const guard = (): void => { + // Once mutations begin, incorporate bound notifications between files + // and commit the batch before the next scan reads fresh content. + if (applying) return; + validatePlan(); + if (this.localChanges.entries().length > changes) + throw new LocalChangesDuringReconciliation(); }; - validatePlan(); - await this.persist(journal); - await this.recover(); - } - - public async recover(): Promise { - const saved = this.database.state.application; - if (!saved) return; - - const journal = structuredClone(saved); - await this.fs.createDirectory( - `${INTERNAL_DIRECTORY}/transactions/${journal.id}` - ); - - for (;;) { - await this.incorporateLocalChanges(journal); - // Stage every pending source before installing anything, including - // occupants discovered during an earlier installation attempt. - await this.stageSources(journal); - const step = journal.steps.find( - (step) => step.phase !== "installed" - ); - if (!step) break; - await this.prepareContent(journal, step); - await this.incorporateLocalChanges(journal); - if (step.superseded && step.phase === "installed") continue; - try { - await this.install(journal, step); - } catch (error) { - const code = - typeof error === "object" && - error !== null && - "code" in error && - typeof error.code === "string" - ? error.code + const beginMutation = (): void => { + guard(); + applying = true; + }; + guard(); + const physical = structuredClone(this.database.state); + physical.initialized = next.initialized; + this.physical = physical; + const paths = new Set(); + try { + for (const id of new Set([ + ...Object.keys(physical.local), + ...Object.keys(next.local) + ])) { + await this.incorporateChanges(next, physical, guard); + let from = + Object.hasOwn(physical.documents, id) && + physical.documents[id].materialized + ? physical.local[id] : undefined; - if ( - step.to === undefined || - ![ - "ENAMETOOLONG", - "EINVAL", - "ENOTSUP", - "EOPNOTSUPP", - "EACCES", - "EPERM" - ].includes(code ?? "") - ) - throw error; - const origin = step.replacement ? step.output : step.staged; - // A rename that took effect must be acknowledged at its original - // destination. Never infer failure from a failed durability flush. - if ( - step.phase === "installing" && - !(await this.fs.exists(origin)) - ) - throw error; - if (step.replanned === true) throw error; - const extension = - /\.[^.]*$/u.exec(step.to.split("/").at(-1) ?? "")?.[0] ?? - ""; - const wanted = `Recovered ${step.documentId}${extension}`; - step.to = await this.conflictPath( - journal, - step.documentId, - wanted - ); - step.replanned = true; - journal.next.local[step.documentId] = step.to; - // The payload has already been prepared; persist the new target - // before retrying so crashes cannot reapply its content merge. - step.phase = "prepared"; - await this.persist(journal); - } - } - - // Once every install intent is durable, the journal can be replayed - // without its staged inputs. Remove transient bytes before committing - // the next engine state so an interrupted cleanup is retried. - await this.cleanup(journal); - await this.database.commit(journal.next); - this.filesApplied( - [ - ...new Set( - journal.steps.flatMap((step) => [step.from, step.to]) - ) - ].filter((path): path is string => path !== undefined) - ); - } - - private async stageSources(journal: ApplicationJournal): Promise { - for (;;) { - await this.incorporateLocalChanges(journal); - const step = journal.steps.find((step) => step.phase === "planned"); - if (!step) break; - // An existing staging file proves the move completed before a crash. - if (step.from && !(await this.fs.exists(step.staged))) { - const { from } = step, - { staged } = step; - const present = await this.fs.exists(from); - await this.incorporateLocalChanges(journal); - if ( - step.from !== from || - step.staged !== staged || - step.phase !== "planned" - ) + let to = Object.hasOwn(next.local, id) + ? next.local[id] + : undefined; + const write = writes[id]; + if (write === undefined && (from === undefined || from === to)) continue; - if (present) { - await this.fs.rename(from, staged); - await this.incorporateLocalChanges(journal); - if ( - step.from !== from || - step.staged !== staged || - step.phase !== "planned" - ) - continue; - } else { - // Preserve an external deletion instead of resurrecting it. - delete journal.next.local[step.documentId]; - step.to = undefined; - step.replacement = undefined; - } - } - if (step.from) await this.fs.flushPaths([step.from, step.staged]); - step.phase = "staged"; - await this.persist(journal); - } - // Directory cleanup is also retried if staging completed before a crash. - for (const step of journal.steps) - await this.removeEmptyParents(step.from); - } - - private async prepareContent( - journal: ApplicationJournal, - step: FileStep - ): Promise { - if (step.phase !== "staged") return; - const source = await this.snapshot(step.staged); - if (source) { - if (step.replacement && step.expected) - step.replacement = await mergeContent( - step.to ?? step.from ?? "", - step.expected, - source, - step.replacement, - journal.extensions - ); - // The prepared replacement now includes this source's edits. A - // later save must be merged relative to this source, or superseded - // local text is reintroduced from the already merged replacement. - step.expected = source; - } - // Save the merged result before writing it, so recovery never merges twice. - step.phase = "prepared"; - await this.persist(journal); - } - - private async install( - journal: ApplicationJournal, - step: FileStep - ): Promise { - if (step.to) { - const origin = step.replacement ? step.output : step.staged; - const alreadyMoved = - step.phase === "installing" && !(await this.fs.exists(origin)); - if (alreadyMoved) { - if (!(await this.fs.exists(step.to))) { - // The installed file was externally deleted before - // recovery. Preserve that deletion instead of failing or - // resurrecting bytes from metadata. - delete journal.next.local[step.documentId]; - step.to = undefined; - step.replacement = undefined; - step.phase = "installed"; - await this.persist(journal); - return; - } - await this.fs.flushPaths([origin, step.to]); - } else { - if (step.replacement) - await this.writeOnce( - step.output, - fromStoredSnapshot(step.replacement) + if (from !== undefined) paths.add(from); + const info = + from !== undefined ? await this.fs.stat(from) : undefined; + guard(); + if ( + from !== undefined && + info?.kind === "file" && + this.protectedFile(from, info.size) + ) { + next.local[id] = from; + next.documents[id] = structuredClone( + physical.documents[id] ); - await this.incorporateLocalChanges(journal); - if (step.superseded) return; - if (!(await this.clearDestination(journal, step, step.to))) - return; - await this.ensureParents(step.to); - await this.incorporateLocalChanges(journal); - if (step.superseded) return; - // Persist intent first; the no-replace rename protects against - // another writer taking the destination after we checked it. - step.phase = "installing"; - await this.persist(journal); - await this.fs.rename(origin, step.to); - await this.incorporateLocalChanges(journal); - if (step.superseded) return; - } - const document = journal.next.documents[step.documentId]; - if (document) { - document.materialized = true; - document.observedHash = - step.replacement?.hash ?? - (await this.snapshot(step.to))?.hash; + (next.excluded ??= {})[id] = + physical.fileManifest.entries[id] ?? null; + (next.protectedPaths ??= []).push(from); + continue; + } + if (from !== undefined && info?.kind !== "file") { + Reflect.deleteProperty(next.local, id); + continue; + } + if (to === undefined) { + if (from !== undefined) { + beginMutation(); + await this.fs.deleteFile(from); + Reflect.deleteProperty(physical.local, id); + await this.removeEmptyParents(from); + } + continue; + } + // Move an occupant aside in the visible namespace. This breaks + // cycles without hiding source files in a recovery directory. + if (from !== to) { + try { + to = await this.clearDestination( + next, + physical, + id, + to, + guard, + beginMutation + ); + await this.ensureParents(to); + } catch (error) { + to = await this.fallbackPath(next, id, to, error); + await this.ensureParents(to); + } + next.local[id] = to; + // Clearing the destination may itself have moved this source + // (for example, a file becoming its own parent directory). + from = + Object.hasOwn(physical.documents, id) && + physical.documents[id].materialized + ? physical.local[id] + : undefined; + if (from !== undefined) { + guard(); + // Bind notifications delivered from inside rename to the + // destination identity. A failed rename aborts this plan. + beginMutation(); + physical.local[id] = to; + await this.fs.rename(from, to); + paths.add(to); + await this.removeEmptyParents(from); + from = to; + } + } + if (write !== undefined) { + const current = + from !== undefined + ? await this.snapshot(from) + : undefined; + guard(); + // A detected deletion is a local edit, not an invitation to + // recreate a previously materialized document. + if (from !== undefined && !current) { + Reflect.deleteProperty(next.local, id); + continue; + } + const replacement = + current && write.expected + ? await mergeContent( + to, + write.expected, + current, + write.replacement, + (await this.serverConfig.getConfig()) + .mergeableFileExtensions + ) + : write.replacement; + guard(); + if (current?.hash !== replacement.hash) { + beginMutation(); + if (current) await this.fs.deleteFile(to); + guard(); + physical.local[id] = to; + physical.documents[id] = { + ...next.documents[id], + materialized: true + }; + await this.fs.write( + to, + fromStoredSnapshot(replacement) + ); + } + next.documents[id].observedHash = replacement.hash; + paths.add(to); + } + next.documents[id].materialized = true; + physical.local[id] = to; + physical.documents[id] = structuredClone(next.documents[id]); } + await this.incorporateChanges(next, physical, guard); + guard(); + await this.database.commit(next); + } finally { + this.physical = undefined; + this.filesApplied([...paths]); + } + } + + private async incorporateChanges( + next: EngineState, + physical: EngineState, + guard: () => void + ): Promise { + await this.localChanges.flush(); + guard(); + for (const change of unappliedChanges( + next, + this.localChanges.entries() + )) { + const ignored = (path: string): boolean => + isInternalPath(path) || this.protectedFile(path, 0); + applyLocalChange(physical, change, ignored); + applyLocalChange(next, change, ignored); + physical.lastAppliedLocalChangeId = next.lastAppliedLocalChangeId = + change.changeId; } - // Deletions and completed renames share this path whether their - // acknowledgement was received or lost. Transient bytes are removed - // only after every step reaches this phase. - step.phase = "installed"; - await this.persist(journal); } private async clearDestination( - journal: ApplicationJournal, - step: FileStep, - path: string - ): Promise { - const obstruction = await this.obstruction(path); - if (!obstruction) return true; - if (obstruction.kind === "file") { + next: EngineState, + physical: EngineState, + id: string, + path: string, + guard: () => void, + beginMutation: () => void + ): Promise { + for (;;) { + const obstruction = await this.obstruction(path); + if (!obstruction) return path; + guard(); const info = await this.fs.stat(obstruction.path); if ( - journal.next.protectedPaths?.includes(obstruction.path) === - true || - (info !== undefined && - this.protectedFile(obstruction.path, info.size)) + obstruction.kind === "file" && + info && + next.protectedPaths?.includes(obstruction.path) !== true && + !this.protectedFile(obstruction.path, info.size) ) { - (journal.next.protectedPaths ??= []).push(obstruction.path); - step.to = await this.conflictPath( - journal, - step.documentId, - path + await this.moveAside( + next, + physical, + obstruction.path, + false, + beginMutation ); - journal.next.local[step.documentId] = step.to; - await this.persist(journal); - } else await this.displace(journal, obstruction.path); - } else if ( - (await this.fs.listFilesRecursively(obstruction.path)).length - ) { - // Preserve unmanaged descendants by changing the incoming file's name. - step.to = await this.conflictPath(journal, step.documentId, path); - journal.next.local[step.documentId] = step.to; - await this.persist(journal); - } else { - // The adapter only removes empty directories, including on a race. - await this.fs.delete(obstruction.path); - return true; + } else if (obstruction.kind === "directory") { + const children = await this.fs.listFilesRecursively( + obstruction.path + ); + let movable = true; + for (const child of children) { + const occupant = Object.keys(physical.local).find( + (other) => physical.local[other] === child + ); + const childInfo = await this.fs.stat(child); + if ( + occupant === undefined || + !Object.hasOwn(physical.documents, occupant) || + !physical.documents[occupant].materialized || + next.local[occupant] === child || + next.protectedPaths?.includes(child) === true || + childInfo === undefined || + this.protectedFile(child, childInfo.size) + ) { + movable = false; + break; + } + } + if (movable) { + // Only evacuate tracked sources already scheduled to leave. + // Root-level temporary names also break directory/file cycles. + for (const child of children) + await this.moveAside( + next, + physical, + child, + true, + beginMutation + ); + beginMutation(); + await this.fs.delete(obstruction.path); + } else path = await this.conflictPath(next, id, path); + } else { + path = await this.conflictPath(next, id, path); + } } - // The journal now contains a new source or destination. Retry it through - // the main loop so newly discovered sources are staged before installing. - return false; + } + + private async moveAside( + next: EngineState, + physical: EngineState, + path: string, + toRoot: boolean, + beginMutation: () => void + ): Promise { + const occupant = + Object.keys(physical.local).find( + (other) => + Object.hasOwn(physical.documents, other) && + physical.documents[other].materialized && + physical.local[other] === path + ) ?? uuid(); + const displaced = await this.conflictPath( + next, + occupant, + toRoot ? (path.split("/").at(-1) ?? path) : path + ); + await this.ensureParents(displaced); + beginMutation(); + physical.local[occupant] = displaced; + await this.fs.rename(path, displaced); + if (!Object.hasOwn(physical.documents, occupant)) { + const snapshot = await this.snapshot(displaced); + physical.documents[occupant] = { + materialized: true, + observedHash: snapshot?.hash + }; + next.documents[occupant] = structuredClone( + physical.documents[occupant] + ); + next.local[occupant] = displaced; + } else if (next.local[occupant] === path) + next.local[occupant] = displaced; } private async obstruction( @@ -791,126 +378,68 @@ export class FileOperations { if (info && (info.kind === "file" || i === parts.length - 1)) return { path: prefix, kind: info.kind }; } - return undefined; } private async conflictPath( - journal: ApplicationJournal, - documentId: string, + next: EngineState, + id: string, path: string ): Promise { - const occupied = { ...journal.next.local }; - delete occupied[documentId]; - for (const [index, diskPath] of ( + const occupied = { ...next.local }; + Reflect.deleteProperty(occupied, id); + for (const [i, diskPath] of ( await this.listFilesRecursively() ).entries()) - occupied[`disk-${index}`] = diskPath; - return allocatePortablePath(path, documentId, occupied); + occupied[`disk-${i}`] = diskPath; + return allocatePortablePath(path, id, occupied); } - private async displace( - journal: ApplicationJournal, - path: string - ): Promise { - const unexpected = await this.snapshot(path); - if (!unexpected) - throw new Error(`Destination changed during recovery: ${path}`); - const documentId = uuid(); - const to = await this.conflictPath(journal, documentId, path); - const prefix = `${INTERNAL_DIRECTORY}/transactions/${journal.id}/${documentId}`; - journal.steps.push({ - documentId, - from: path, - to, - expected: unexpected, - staged: `${prefix}.source`, - output: `${prefix}.output`, - phase: "planned" - }); - journal.next.local[documentId] = to; - journal.next.documents[documentId] = { - materialized: true, - observedHash: unexpected.hash - }; - await this.persist(journal); + private async fallbackPath( + next: EngineState, + id: string, + path: string, + error: unknown + ): Promise { + 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] ?? ""; + return this.conflictPath(next, id, `Recovered ${id}${extension}`); } private async ensureParents(path: string): Promise { - const parent = this.directory(path); + const parent = path.split("/").slice(0, -1).join("/"); if (parent) await this.fs.createDirectory(parent); } - private async removeEmptyParents(path?: string): Promise { - if (!path) return; - let parent = this.directory(path); + private async removeEmptyParents(path: string): Promise { + let parent = path.split("/").slice(0, -1).join("/"); while (parent && !isInternalPath(parent)) { - // An earlier installation may have turned an ancestor into a file. const info = await this.obstruction(parent); - if (!info) { - parent = this.directory(parent); - continue; - } - if (info.kind !== "directory") break; - if ((await this.fs.listFilesRecursively(parent)).length) break; - // delete() must reject non-empty directories, including unmanaged entries. + if ( + info?.kind !== "directory" || + (await this.fs.listFilesRecursively(parent)).length + ) + break; try { await this.fs.delete(parent); } catch { break; } - parent = this.directory(parent); + parent = parent.split("/").slice(0, -1).join("/"); } } - - private async cleanup(journal: ApplicationJournal): Promise { - for (const step of journal.steps) { - const source = await this.snapshot(step.staged); - if ( - source && - (!step.to || - (step.replacement && source.hash !== step.replacement.hash)) - ) { - const prefix = `${INTERNAL_DIRECTORY}/recovery/${journal.id}-${step.staged.split("/").at(-1)}`; - await this.writeOnce( - `${prefix}.content`, - fromStoredSnapshot(source) - ); - await this.writeJsonOnce(`${prefix}.json`, { - documentId: step.documentId, - originalPath: step.from, - hash: source.hash - }); - } - for (const path of [ - step.staged, - step.output, - `${step.staged}.recovery`, - `${step.staged}.json` - ]) { - if (await this.fs.exists(path)) await this.fs.deleteFile(path); - } - } - const completed = `${INTERNAL_DIRECTORY}/transactions/${journal.id}/completed.json`; - if (await this.fs.exists(completed)) - await this.fs.deleteFile(completed); - await this.fs.delete( - `${INTERNAL_DIRECTORY}/transactions/${journal.id}` - ); - } - - private changedPaths( - before: Readonly>, - after: Readonly> - ): string[] { - const result = new Set(); - for (const id of new Set([ - ...Object.keys(before), - ...Object.keys(after) - ])) { - if (before[id] === after[id]) continue; - if (before[id] !== undefined) result.add(before[id]); - if (after[id] !== undefined) result.add(after[id]); - } - return [...result]; - } } diff --git a/frontend/sync-client/src/file-operations/filesystem-operations.ts b/frontend/sync-client/src/file-operations/filesystem-operations.ts index f4e79397..d8fcf0ab 100644 --- a/frontend/sync-client/src/file-operations/filesystem-operations.ts +++ b/frontend/sync-client/src/file-operations/filesystem-operations.ts @@ -6,10 +6,9 @@ export interface FileSnapshot { cursors?: CursorPosition[]; } -/** Required adapter contract for API v4; there is deliberately no weak fallback. +/** 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. - * Every mutation must flush file data and affected directories before resolving. * A successful scan is complete: unreadable directories must reject, not vanish. */ export interface FileSystemOperations { @@ -22,24 +21,15 @@ export interface FileSystemOperations { ) => Promise<{ kind: "file" | "directory"; size: number } | undefined>; /** Includes directories. */ exists: (path: string) => Promise; - /** Recursive, idempotent, durable directory creation. */ + /** Recursive, idempotent directory creation. */ createDirectory: (path: string) => Promise; - /** Atomically create a complete file: never replace an existing object. - * A crash leaves the path absent or complete, never a partial file. - * Flush data and directory entries before resolving. */ + /** Create a file exclusively; never overwrite a raced destination. + * Writes apply optional editor cursor metadata. No durability guarantee. */ write: (path: string, snapshot: FileSnapshot) => Promise; - /** Atomic, durable rename on the same filesystem; fail if destination exists. - * Flush the source's data and both affected directories before resolving. */ + /** Move a file without replacing an existing destination. */ rename: (from: string, to: string) => Promise; - /** Flush existing files and all affected ancestor directories, even when a - * named file is absent. Recovery uses this after a mutation became visible - * before its caller learned that it was durable. */ - flushPaths: (paths: readonly string[]) => Promise; - /** Idempotently unlink one regular file and flush its parent directory. - * The sync engine uses this only for its own internal recovery artifacts. */ + /** Idempotently unlink one regular file. */ deleteFile: (path: string) => Promise; - /** Prune an implicit directory tree using rmdir only, durably. Reject - * regular files and never unlink descendant files; reject if files remain. - * Partial removal of empty child directories on failure is harmless. */ + /** Prune directories using rmdir only. Reject files and nonempty directories. */ delete: (path: string) => Promise; } diff --git a/frontend/sync-client/src/persistence/database.ts b/frontend/sync-client/src/persistence/database.ts index 750eb667..ec47430d 100644 --- a/frontend/sync-client/src/persistence/database.ts +++ b/frontend/sync-client/src/persistence/database.ts @@ -1,5 +1,4 @@ import type { CursorPosition } from "reconcile-text"; -import type { Logger } from "../tracing/logger"; import type { DocumentUpdateResponse } from "../services/types/DocumentUpdateResponse"; import type { DocumentVersionWithoutContent } from "../services/types/DocumentVersionWithoutContent"; import type { FileManifest } from "../services/types/FileManifest"; @@ -7,7 +6,6 @@ import type { FileManifestUpdateResponse } from "../services/types/FileManifestU import type { PushFileManifest } from "../services/types/PushFileManifest"; import type { PutFileContent } from "../services/types/PutFileContent"; import type { VaultSnapshot } from "../services/types/VaultSnapshot"; -import type { EventRecord } from "../services/types/EventRecord"; import type { FileManifestEntries } from "../types/file-manifest-entries"; export type VaultUpdateId = number; @@ -23,7 +21,6 @@ export interface StoredSnapshot { export interface DocumentMetadata { parentVersionId: number; hash: string; - remoteRelativePath?: string; } export interface DocumentState { @@ -54,7 +51,7 @@ export type PendingRequest = { rejection?: string } & ( ); export interface EngineState { - /** Durable reset intent; clear the transport checkpoint before bootstrap. */ + /** Persisted reset intent; clear the transport checkpoint before bootstrap. */ historyRecovery?: boolean; vaultKey: string; initialized: boolean; @@ -64,17 +61,10 @@ export interface EngineState { lastSeenUpdateId: number; remoteHeads: Record; bootstrap?: VaultSnapshot; - /** Fold pages durably without applying intermediate remote snapshots. */ - eventReplay?: { - after: number; - manifest: FileManifest; - heads: Record; - receipts: EventRecord[]; - }; pending?: PendingRequest; /** A rejected retry does not prove an earlier attempt failed to commit. */ unconfirmed?: PendingRequest[]; - /** Commits consumption of the durable notification queue with the identity map. */ + /** Commits consumption of the saved notification queue with the identity map. */ lastAppliedLocalChangeId?: string; /** Physical exclusions, including files with no sync identity. */ protectedPaths?: string[]; @@ -83,38 +73,13 @@ export interface EngineState { rejectedManifest?: { entries: FileManifestEntries; message: string }; } -export interface FileStep { - documentId: string; - from?: string; - to?: string; - expected?: StoredSnapshot; - replacement?: StoredSnapshot; - staged: string; - output: string; - /** A permanent destination error has already selected a root fallback. */ - replanned?: boolean; - /** Retained input of a later step for this identity; never projected as live. */ - superseded?: boolean; - phase: "planned" | "staged" | "prepared" | "installing" | "installed"; -} - -export interface ApplicationJournal { - id: string; - extensions: string[]; - next: EngineState; - steps: FileStep[]; -} - -export interface StoredDatabase extends EngineState { - application?: ApplicationJournal; -} +export type StoredDatabase = EngineState; // Read-only view used by cursor tracking and status reporting. export interface DocumentRecord { documentId: string; relativePath: string; metadata?: DocumentMetadata; - isDeleted: boolean; } export function emptyState(vaultKey: string): StoredDatabase { @@ -134,9 +99,7 @@ function canRebindEmpty(state: Partial | undefined): boolean { state !== undefined && !state.initialized && !state.pending && - !state.application && !state.bootstrap && - !state.eventReplay && (state.unconfirmed?.length ?? 0) === 0 && Object.keys(state.local ?? {}).length === 0 ); @@ -146,7 +109,6 @@ export class Database { public state: StoredDatabase; private needsReload = false; public constructor( - private readonly logger: Logger, initial: Partial | undefined, private readonly saveData: (data: StoredDatabase) => Promise, vaultKey: string, @@ -181,7 +143,7 @@ export class Database { const snapshot = structuredClone(next); if (this.needsReload) throw new Error( - "Reload durable state before retrying an uncertain save" + "Reload saved state before retrying an uncertain save" ); this.needsReload = true; await this.saveData(snapshot); @@ -204,10 +166,6 @@ export class Database { return Object.keys(this.state.local).length; } - public getLastSeenUpdateId(): number { - return this.state.lastSeenUpdateId; - } - public getDocumentByDocumentId(id: string): DocumentRecord | undefined { const relativePath = this.state.local[id]; if (relativePath === undefined) return undefined; @@ -215,11 +173,9 @@ export class Database { return { documentId: id, relativePath, - isDeleted: false, metadata: base && { parentVersionId: base.vaultUpdateId, - hash: base.hash, - remoteRelativePath: this.state.fileManifest.entries[id] + hash: base.hash } }; } diff --git a/frontend/sync-client/src/persistence/persistence.ts b/frontend/sync-client/src/persistence/persistence.ts index f879d06b..ac6f724a 100644 --- a/frontend/sync-client/src/persistence/persistence.ts +++ b/frontend/sync-client/src/persistence/persistence.ts @@ -1,6 +1,6 @@ -/** save() atomically replaces the entire value and resolves only after both the - * data and its directory entry are durable across power loss. Failed saves must - * leave either complete value readable; callers retain their recovery journal. +/** Replace the complete metadata value atomically. Interrupted or failed saves + * must leave an old/new complete value (or no value for the first save), never + * partially parsed state. No ordering or durability of user files is required. */ export interface PersistenceProvider { load: () => Promise; diff --git a/frontend/sync-client/src/sync-client.ts b/frontend/sync-client/src/sync-client.ts index 71dabeaa..51b92dfa 100644 --- a/frontend/sync-client/src/sync-client.ts +++ b/frontend/sync-client/src/sync-client.ts @@ -25,12 +25,18 @@ import { isInternalPath } from "./utils/portable-path"; import { FixedSizeDocumentCache } from "./utils/data-structures/fix-sized-cache"; import { globsToRegexes } from "./utils/globs-to-regexes"; import { Lock } from "./utils/data-structures/locks"; -import type { LocalChange } from "./sync-operations/local-changes"; +import { + unappliedChanges, + type LocalChange +} from "./sync-operations/local-changes"; type StoredClient = Partial<{ settings: Partial; database: Partial; - localChanges: LocalChange[]; + localChanges: ( + | LocalChange + | { type: "update"; path: string; changeId?: string } + )[]; localChangesVaultKey: string; historyCheckpoint: { vaultKey: string; checkpoint?: string }; }>; @@ -96,7 +102,7 @@ export class SyncClient { let stored = (await persistence.load()) ?? {}; let saving: Promise = Promise.resolve(); let reloadBeforeSave = false; - // Settings and engine state share a store. Serialize complete durable + // Settings and engine state share a store. Serialize complete metadata // replacements so one cannot overwrite a newer save from the other. const save = async (update: StoredClient): Promise => { const operation = saving.then(async () => { @@ -122,7 +128,6 @@ export class SyncClient { save({ settings: data }) ); const database = new Database( - logger, stored.database, async (data) => save({ database: data }), vaultKey(settings.getSettings()), @@ -132,8 +137,15 @@ export class SyncClient { return stored.database as StoredDatabase | undefined; } ); + // Older stores also queued content updates. Trim the acknowledged prefix + // before discarding them, since its marker may belong to an update. + const localChanges = unappliedChanges( + database.state, + stored.localChanges ?? [] + // eslint-disable-next-line no-restricted-syntax -- Migration needs a narrowed copy, not removal of a specific object. + ).filter((change) => change.type !== "update"); if ( - stored.localChanges?.length && + localChanges.length && stored.localChangesVaultKey !== vaultKey(settings.getSettings()) ) { throw new Error( @@ -168,7 +180,6 @@ export class SyncClient { ); const serverConfig = new ServerConfig(service); const notifier = new FileChangeNotifier(); - const localChanges = stored.localChanges ?? []; let syncer: Syncer; const files = new FileOperations( fs, @@ -283,7 +294,7 @@ export class SyncClient { this.check(); if (this.started) throw new Error("SyncClient has already been started"); - await this.syncer.recoverLocalState(); + await this.syncer.reloadLocalState(); await this.database.bindVault( vaultKey(this.settings.getSettings()) ); @@ -310,7 +321,7 @@ export class SyncClient { await stopped; } - /** Restart transports; never discard pending requests or filesystem journals. */ + /** Restart transports; never discard pending requests. */ public async reset(): Promise { await this.lifecycle.withLock(async () => { this.check(); @@ -335,12 +346,11 @@ export class SyncClient { this.database.state.initialized || this.database.state.bootstrap || this.database.state.pending || - this.database.state.application || this.syncer.hasLocalChanges || Object.keys(this.database.state.local).length) ) throw new Error( - "Changing vaults requires a separate state store and client; pending recovery data must not be reset" + "Changing vaults requires a separate state store and client; existing sync state must not be reset" ); try { diff --git a/frontend/sync-client/src/sync-operations/content.ts b/frontend/sync-client/src/sync-operations/content.ts index a70896bb..8a5aafaf 100644 --- a/frontend/sync-client/src/sync-operations/content.ts +++ b/frontend/sync-client/src/sync-operations/content.ts @@ -47,8 +47,7 @@ export async function mergeContent( remote.contentBase64 ].map(base64ToBytes); - // Concurrent unmergeable edits keep the server's content. The filesystem - // journal retains displaced local bytes for recovery. + // Concurrent unmergeable edits keep the server's content. if (!isFileTypeMergable(path, extensions) || inputs.some(isBinary)) return remote; diff --git a/frontend/sync-client/src/sync-operations/cursor-tracker.test.ts b/frontend/sync-client/src/sync-operations/cursor-tracker.test.ts index 8828d887..9b1c80da 100644 --- a/frontend/sync-client/src/sync-operations/cursor-tracker.test.ts +++ b/frontend/sync-client/src/sync-operations/cursor-tracker.test.ts @@ -18,7 +18,6 @@ it("retains future cursors and publishes them when content catches up", async () getDocumentByDocumentId: () => ({ documentId: "document", relativePath: "note.md", - isDeleted: false, metadata: { parentVersionId: 2, hash: contentHash } }) } as unknown as Database; diff --git a/frontend/sync-client/src/sync-operations/local-changes.ts b/frontend/sync-client/src/sync-operations/local-changes.ts index 6022f992..209c6853 100644 --- a/frontend/sync-client/src/sync-operations/local-changes.ts +++ b/frontend/sync-client/src/sync-operations/local-changes.ts @@ -11,13 +11,12 @@ export type LocalChange = { | { type: "move"; oldPath: string; relativePath: string } | { type: "create"; path: string; documentId?: string } | { type: "delete"; path: string } - | { type: "update"; path: string } ); -export function unappliedChanges( +export function unappliedChanges( state: EngineState, - changes: readonly LocalChange[] -): LocalChange[] { + changes: readonly T[] +): T[] { for (const change of changes) change.changeId ??= uuid(); const applied = changes.findIndex( (change) => change.changeId === state.lastAppliedLocalChangeId @@ -63,7 +62,6 @@ export function applyLocalChange( (path === from || path.startsWith(from + "/")) ) ); - if (change.type === "update") return; for (const [id, path] of Object.entries(change.identities)) { if (change.type === "delete") { if (ignored(path)) { diff --git a/frontend/sync-client/src/sync-operations/outgoing-cursor.test.ts b/frontend/sync-client/src/sync-operations/outgoing-cursor.test.ts index b50d2b8a..5ccfa7a8 100644 --- a/frontend/sync-client/src/sync-operations/outgoing-cursor.test.ts +++ b/frontend/sync-client/src/sync-operations/outgoing-cursor.test.ts @@ -17,7 +17,6 @@ for (const change of ["version", "identity", "path", "delete"] as const) let current: DocumentRecord | undefined = { documentId: "a", relativePath: "a.md", - isDeleted: false, metadata: { parentVersionId: 1, hash: digest } }; const sent: { documentsWithCursors: DocumentWithCursors[] }[] = []; diff --git a/frontend/sync-client/src/sync-operations/scan.ts b/frontend/sync-client/src/sync-operations/scan.ts index 3e743231..90aa9fdb 100644 --- a/frontend/sync-client/src/sync-operations/scan.ts +++ b/frontend/sync-client/src/sync-operations/scan.ts @@ -38,7 +38,7 @@ export async function scanLocalFiles( }: ScanOptions, initial?: VaultSnapshot ): Promise> { - // Leave the prefix queued until its resulting state is durably committed. + // Leave the prefix queued until its resulting state is saved. // A persisted change ID makes replay safe after an uncertain save. const changes = [...queuedChanges]; const unsyncablePaths = new Set(); diff --git a/frontend/sync-client/src/sync-operations/syncer.test.ts b/frontend/sync-client/src/sync-operations/syncer.test.ts index e3c11360..862c56e1 100644 --- a/frontend/sync-client/src/sync-operations/syncer.test.ts +++ b/frontend/sync-client/src/sync-operations/syncer.test.ts @@ -153,8 +153,7 @@ describe("Syncer per-file state and event delivery", () => { path === "note.md" ? { documentId: "document", - relativePath: path, - isDeleted: false + relativePath: path } : undefined } as unknown as Database; @@ -166,10 +165,8 @@ describe("Syncer per-file state and event delivery", () => { (batch: EventBatch) => Promise >() } as unknown as WebSocketManager; - let httpEventsCalls = 0; const service = { events: async () => { - httpEventsCalls++; return { headEventId: 0, events: [] }; } } as unknown as SyncService; @@ -191,8 +188,7 @@ describe("Syncer per-file state and event delivery", () => { return { database, websocket, - syncer, - httpEventsCalls: () => httpEventsCalls + syncer }; } @@ -211,28 +207,6 @@ describe("Syncer per-file state and event delivery", () => { assert.strictEqual(syncer.isDocumentUpToDate("note.md"), false); }); - it("consumes a complete WebSocket event batch without fetching it again", async () => { - const { websocket, syncer, httpEventsCalls } = fixture(); - const batch: EventBatch = { - headEventId: 1, - events: [ - { - eventId: 1, - requestId: "00000000-0000-0000-0000-000000000001", - type: "fileManifest", - fileManifest: { fileManifestId: 1, entries: {} } - } - ] - }; - await websocket.onRemoteVaultUpdateReceived.triggerAsync(batch); - - const selected = await ( - syncer as unknown as { nextEventBatch: () => Promise } - ).nextEventBatch(); - assert.deepEqual(selected, batch); - assert.strictEqual(httpEventsCalls(), 0); - }); - it("projects content responses to metadata before persistence", () => { const { syncer } = fixture(); const projected = ( diff --git a/frontend/sync-client/src/sync-operations/syncer.ts b/frontend/sync-client/src/sync-operations/syncer.ts index 3a9bc83a..9753c7c5 100644 --- a/frontend/sync-client/src/sync-operations/syncer.ts +++ b/frontend/sync-client/src/sync-operations/syncer.ts @@ -20,6 +20,7 @@ import type { WebSocketManager } from "../services/websocket-manager"; import type { DocumentVersion } from "../services/types/DocumentVersion"; import type { DocumentVersionWithoutContent } from "../services/types/DocumentVersionWithoutContent"; import type { EventBatch } from "../services/types/EventBatch"; +import type { EventRecord } from "../services/types/EventRecord"; import type { FileManifest } from "../services/types/FileManifest"; import type { VaultSnapshot } from "../services/types/VaultSnapshot"; import type { PushContent } from "../services/types/PushContent"; @@ -66,7 +67,6 @@ export class Syncer { private notificationSaveFailed = false; private readonly pendingPaths = new Set(); private unsyncablePaths = new Set(); - private eventBatches: EventBatch[] = []; private hasScanned = false; public constructor( @@ -99,10 +99,7 @@ export class Syncer { // including when the optional polling interval is disabled. this.wake(); }); - websocket.onRemoteVaultUpdateReceived.add(async (batch) => { - this.eventBatches.push(batch); - // Notifications are only hints; HTTP replay recovers discarded pages. - if (this.eventBatches.length > 4) this.eventBatches.shift(); + websocket.onRemoteVaultUpdateReceived.add(async () => { this.wake(); }); } @@ -120,7 +117,6 @@ export class Syncer { !!this.running || this.dirty || !!this.database.state.pending || - !!this.database.state.application || !!this.failure || !!this.rejectionMessage ); @@ -169,11 +165,7 @@ export class Syncer { return false; } - return !this.database.state.application?.steps.some( - (step) => - step.documentId === record.documentId && - step.phase !== "installed" - ); + return true; } public start(): void { @@ -273,7 +265,7 @@ export class Syncer { oldPath?: string; relativePath: string; }): Promise { - // Content snapshots are re-read during journal staging. Only namespace + // Content snapshots are re-read before writing. Only namespace // notifications invalidate an identity plan; keystrokes must not starve // a complete scan of unrelated documents. if (oldPath !== undefined && oldPath !== "") this.localGeneration++; @@ -289,23 +281,14 @@ export class Syncer { oldPath, relativePath }); - } else if (!isInternalPath(relativePath)) { - await this.recordLocalChange({ - type: "update", - path: relativePath - }); } this.wake(); } - /** Local recovery must run even when network sync is disabled. Caller stops - * the engine first and holds the client's lifecycle lock. */ - public async recoverLocalState(): Promise { + /** Reload an uncertain metadata save before starting another attempt. */ + public async reloadLocalState(): Promise { await this.flushLocalChanges(); await this.database.recoverPersistence(); - const { application } = this.database.state; - await this.files.recover(); - if (application) this.recordRecoveredApplication(application.steps); } private async recordLocalChange(change: LocalChange): Promise { @@ -339,7 +322,7 @@ export class Syncer { this.onRemainingOperationsCountChanged.trigger(1); this.failure = undefined; try { - await this.recoverLocalState(); + await this.reloadLocalState(); if ( this.database.state.historyRecovery || (this.database.state.initialized && @@ -360,15 +343,13 @@ export class Syncer { await this.finishPending(); } await this.scan(); - const batch = await this.nextEventBatch(); + const batch = await this.service.events( + this.database.state.lastSeenUpdateId + ); await this.incorporateEventBatch(batch); - if (this.database.state.eventReplay) { - this.dirty = true; - continue; - } await this.scan(); // Previously excluded content becomes eligible after a setting - // change. Its durable obligation survives advancing the cursor. + // change. Its saved obligation survives advancing the cursor. if (Object.keys(this.database.state.excluded ?? {}).length) await this.incorporateFileManifest( this.database.state.fileManifest @@ -392,14 +373,6 @@ export class Syncer { await this.finishPending(); this.dirty = true; } - if ( - this.eventBatches.some( - (batch) => - batch.headEventId > - this.database.state.lastSeenUpdateId - ) - ) - this.dirty = true; } while (this.dirty && !this.stopped); if (!this.dirty && !this.database.state.pending) this.pendingPaths.clear(); @@ -428,24 +401,11 @@ export class Syncer { private async recoverServerHistory(): Promise { this.onServerHistoryChanged.trigger(); this.contentCache.reset(); - this.eventBatches = []; this.hasScanned = false; if (!this.database.state.historyRecovery) { await this.scan(); const guard = this.localGuard(); const previous = this.database.state; - for (const pending of [ - previous.pending, - ...(previous.unconfirmed ?? []) - ]) { - if (pending?.type === "content") - await this.files.retainSnapshot( - pending.request.requestId, - pending.documentId, - previous.local[pending.documentId], - pending.snapshot - ); - } const next = emptyState(previous.vaultKey); next.historyRecovery = true; next.lastAppliedLocalChangeId = previous.lastAppliedLocalChangeId; @@ -561,10 +521,7 @@ export class Syncer { } private next(): EngineState { - const { application: _, ...next } = structuredClone( - this.database.state - ); - return next; + return structuredClone(this.database.state); } private async bootstrap(): Promise { @@ -604,56 +561,53 @@ export class Syncer { } private async incorporateEventBatch(batch: EventBatch): Promise { - const replay = this.database.state.eventReplay; - const after = replay?.after ?? this.database.state.lastSeenUpdateId; - let cursor = after; - let manifest = replay?.manifest ?? this.database.state.fileManifest; - const heads = { ...(replay?.heads ?? this.database.state.remoteHeads) }; - const receipts = [...(replay?.receipts ?? [])]; - for (const event of batch.events) { - if (event.eventId <= cursor) continue; - if (event.eventId !== cursor + 1) - throw new PermanentSyncError( - "Non-contiguous event history; refusing to skip changes" - ); - cursor = event.eventId; - if ( - this.database.state.unconfirmed?.some( - (pending) => pending.request.requestId === event.requestId - ) === true - ) - receipts.push(event); - if (event.type === "fileManifest") { - if (event.fileManifest.fileManifestId > manifest.fileManifestId) - manifest = event.fileManifest; - } else if ( - !heads[event.document.documentId] || - heads[event.document.documentId].vaultUpdateId < - event.document.vaultUpdateId - ) { - heads[event.document.documentId] = this.documentHead( - event.document - ); - } - } - const end = batch.endEventId ?? batch.headEventId; - if ( - end !== cursor || - end > batch.headEventId || - (end === after && end < batch.headEventId) - ) - throw new PermanentSyncError("Incomplete event batch"); - if (end < batch.headEventId) { - await this.database.commit({ - ...this.next(), - eventReplay: { - after: end, - manifest, - heads, - receipts + let cursor = this.database.state.lastSeenUpdateId; + let manifest = this.database.state.fileManifest; + const heads = { ...this.database.state.remoteHeads }; + const receipts: EventRecord[] = []; + // Fold every page before touching local files. If fetching is interrupted, + // discard this work and retry from the last committed cursor. + for (;;) { + const after = cursor; + for (const event of batch.events) { + if (event.eventId <= cursor) continue; + if (event.eventId !== cursor + 1) + throw new PermanentSyncError( + "Non-contiguous event history; refusing to skip changes" + ); + cursor = event.eventId; + if ( + this.database.state.unconfirmed?.some( + (pending) => + pending.request.requestId === event.requestId + ) === true + ) + receipts.push(event); + if (event.type === "fileManifest") { + if ( + event.fileManifest.fileManifestId > + manifest.fileManifestId + ) + manifest = event.fileManifest; + } else if ( + !heads[event.document.documentId] || + heads[event.document.documentId].vaultUpdateId < + event.document.vaultUpdateId + ) { + heads[event.document.documentId] = this.documentHead( + event.document + ); } - }); - return; + } + const end = batch.endEventId ?? batch.headEventId; + if ( + end !== cursor || + end > batch.headEventId || + (end === after && end < batch.headEventId) + ) + throw new PermanentSyncError("Incomplete event batch"); + if (end === batch.headEventId) break; + batch = await this.service.events(cursor); } if (cursor === this.database.state.lastSeenUpdateId) return; // Recover our submitted bases before folding later remote edits. A @@ -707,24 +661,6 @@ export class Syncer { ); } - private async nextEventBatch(): Promise { - const after = - this.database.state.eventReplay?.after ?? - this.database.state.lastSeenUpdateId; - if (this.service.verifiesHistory) { - this.eventBatches = []; - return this.service.events(after); - } - this.eventBatches = this.eventBatches.filter( - (batch) => (batch.endEventId ?? batch.headEventId) > after - ); - const queued = this.eventBatches.findIndex((batch) => - batch.events.some((event) => event.eventId === after + 1) - ); - if (queued !== -1) return this.eventBatches.splice(queued, 1)[0]; - return this.service.events(after); - } - private documentHead( version: DocumentVersionWithoutContent ): DocumentVersionWithoutContent { @@ -738,42 +674,6 @@ export class Syncer { }; } - private recordRecoveredApplication( - steps: readonly { - documentId: string; - from?: string; - to?: string; - replacement?: unknown; - }[] - ): void { - for (const step of steps) { - const to = this.database.state.local[step.documentId]; - if (!step.from && !to) continue; - this.history.addHistoryEntry({ - status: SyncStatus.SUCCESS, - message: "Filesystem application recovered", - details: - step.from && to && step.from !== to - ? { - type: SyncType.MOVE, - relativePath: to, - movedFrom: step.from - } - : !step.from && to - ? { type: SyncType.CREATE, relativePath: to } - : step.from && !to - ? { - type: SyncType.DELETE, - relativePath: step.from - } - : { - type: SyncType.UPDATE, - relativePath: to ?? step.from! - } - }); - } - } - private recordManifestApplication( before: Readonly>, writes: Readonly> @@ -948,7 +848,6 @@ export class Syncer { const next = this.next(); if (cursor !== undefined) { next.lastSeenUpdateId = cursor; - delete next.eventReplay; } if (clearPending) { delete next.pending; @@ -1037,7 +936,7 @@ export class Syncer { } private async preparePush(): Promise { - if (this.stopped || this.database.state.eventReplay) { + if (this.stopped) { return false; } @@ -1214,18 +1113,15 @@ export class Syncer { await this.database.recoverPersistence(); const next = structuredClone(this.database.state); let changed = false; - for (const state of [next, next.application?.next]) { - if (!state) continue; - if (state.rejectedManifest) { - delete state.rejectedManifest; + if (next.rejectedManifest) { + delete next.rejectedManifest; + changed = true; + } + for (const doc of Object.values(next.documents)) { + if (doc.rejected) { + delete doc.rejected; changed = true; } - for (const doc of Object.values(state.documents)) { - if (doc.rejected) { - delete doc.rejected; - changed = true; - } - } } if (changed) await this.database.commit(next); } @@ -1260,12 +1156,6 @@ export class Syncer { if (pending.rejection && !pending.response) { const next = this.next(); if (pending.type === "content") { - await this.files.retainSnapshot( - pending.request.requestId, - pending.documentId, - next.local[pending.documentId], - pending.snapshot - ); next.documents[pending.documentId].rejected = { hash: pending.snapshot.hash, message: pending.rejection @@ -1346,12 +1236,6 @@ export class Syncer { } else { const response = pending.response!; if (response.type === "StaleBase") { - await this.files.retainSnapshot( - pending.request.requestId, - pending.documentId, - this.database.state.local[pending.documentId], - pending.snapshot - ); await this.incorporateContent( response, undefined, diff --git a/frontend/sync-client/src/utils/assert-set-contains-exactly.ts b/frontend/sync-client/src/utils/assert-set-contains-exactly.ts deleted file mode 100644 index 5baf965e..00000000 --- a/frontend/sync-client/src/utils/assert-set-contains-exactly.ts +++ /dev/null @@ -1,13 +0,0 @@ -import assert from "node:assert"; - -export function assertSetContainsExactly(set: Set, ...values: T[]): void { - assert.ok( - set.size === values.length && - Array.from(set).every((value) => values.includes(value)), - `Expected set to contain only ${values.map((v) => '"' + v + '"').join(", ")}, but it contained ${Array.from( - set - ) - .map((v) => '"' + v + '"') - .join(", ")}` - ); -} diff --git a/frontend/sync-client/src/utils/data-structures/min-covered.test.ts b/frontend/sync-client/src/utils/data-structures/min-covered.test.ts deleted file mode 100644 index 7b7271d7..00000000 --- a/frontend/sync-client/src/utils/data-structures/min-covered.test.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { describe, it } from "node:test"; -import assert from "node:assert"; -import { CoveredValues } from "./min-covered"; - -describe("CoveredValues", () => { - it("should initialize with the given min value", () => { - const covered = new CoveredValues(5); - assert.strictEqual(covered.min, 5); - }); - - it("should add values greater than min", () => { - const covered = new CoveredValues(0); - covered.add(3); - assert.strictEqual(covered.min, 0); - covered.add(1); - assert.strictEqual(covered.min, 1); - covered.add(4); - assert.strictEqual(covered.min, 1); - covered.add(2); - assert.strictEqual(covered.min, 4); - }); - - it("should ignore duplicate values", () => { - const covered = new CoveredValues(0); - covered.add(3); - covered.add(3); - covered.add(3); - assert.strictEqual(covered.min, 0); - covered.add(1); - covered.add(2); - assert.strictEqual(covered.min, 3); - }); - - it("should handle multiple consecutive values", () => { - const covered = new CoveredValues(132); - for (let i = 250; i > 132; i--) { - assert.strictEqual(covered.min, 132); - covered.add(i); - } - assert.strictEqual(covered.min, 250); - }); - - it("should handle adding values lower than current min", () => { - const covered = new CoveredValues(5); - covered.add(3); - assert.strictEqual(covered.min, 5); - covered.add(6); - assert.strictEqual(covered.min, 6); - }); - - it("should auto-advance when setting min value", () => { - const covered = new CoveredValues(5); - covered.add(7); - covered.add(8); - covered.add(9); - assert.strictEqual(covered.min, 5); - // Setting min to 6 should auto-advance through 7, 8, 9 - covered.min = 6; - assert.strictEqual(covered.min, 9); - covered.add(10); - assert.strictEqual(covered.min, 10); - }); - - it("should handle setting min value with no consecutive values", () => { - const covered = new CoveredValues(5); - covered.add(10); - covered.add(15); - assert.strictEqual(covered.min, 5); - // Setting min to 8 should not auto-advance (no consecutive values) - covered.min = 8; - assert.strictEqual(covered.min, 8); - // Add 9 to trigger auto-advance to 10 - covered.add(9); - assert.strictEqual(covered.min, 10); - }); -}); diff --git a/frontend/sync-client/src/utils/data-structures/min-covered.ts b/frontend/sync-client/src/utils/data-structures/min-covered.ts deleted file mode 100644 index 8b38822f..00000000 --- a/frontend/sync-client/src/utils/data-structures/min-covered.ts +++ /dev/null @@ -1,61 +0,0 @@ -/** - * A class that tracks the minimum covered value in a sequence of numbers. - * It keeps track of a minimum value based on the seen values. - * - * It expects integers slightly out of order and makes sure that the value of `min` is - * always the minimum of the seen values. This is done with bounded memory usage. - * - * @example - * ```typescript - * const covered = new CoveredValues(0); - * covered.add(2); // seenValues = [2], min = 0 - * covered.add(1); // seenValues = [], min = 2 - * covered.min; // returns 2 - * ``` - */ -export class CoveredValues { - private seenValues: number[] = []; - - public constructor(private minValue: number) {} - - public get min(): number { - return this.minValue; - } - - public set min(value: number) { - this.minValue = Math.max(value, this.minValue); - this.seenValues = this.seenValues.filter((v) => v > this.minValue); - this.advanceMinWhilePossible(); - } - - public add(value: number | undefined): void { - if (value === undefined || value < this.minValue) { - return; - } - - let i = 0; - while (i < this.seenValues.length && this.seenValues[i] < value) { - i++; - } - - if (i === this.seenValues.length) { - this.seenValues.push(value); - } else if (this.seenValues[i] === value) { - return; - } else { - this.seenValues.splice(i, 0, value); - } - - this.advanceMinWhilePossible(); - } - - private advanceMinWhilePossible(): void { - while ( - this.seenValues.length > 0 && - this.seenValues[0] === this.minValue + 1 - ) { - this.seenValues.shift(); - this.minValue++; - } - } -} diff --git a/frontend/test-support/README.md b/frontend/test-support/README.md index e3601ba5..824714ab 100644 --- a/frontend/test-support/README.md +++ b/frontend/test-support/README.md @@ -1,163 +1,52 @@ # Shared sync harness support -This is test-only code, shared by `deterministic-tests` and `test-client` rather -than exported as debugging machinery by the production sync client. +Test-only code shared by the deterministic and seeded clients. -The journal suites also cover repeated saves replacing earlier unsynced text, -tracked file/directory moves into ignored paths, and saves crossing the size -limit. They check exact bytes and identities through recovery, including real -server reads. Client and Rust unit suites bound deep-path validation resources. +| Suite | Coverage | +| --- | --- | +| `harness.test.ts`, `oracle-regressions.test.ts` | Independent content/identity oracles, exercised faults, exclusive operations, persistence ownership, cleanup and seeds | +| `file-application.test.ts` | Swaps, file/directory transitions, visible conflict files, live edits, exclusions, and one metadata save per application | +| `recovery.test.ts` | Interrupt every application filesystem/save boundary, reopen metadata, modify disk, and verify stable ordinary scans; manifest policy and notification identity | +| `engine-regressions.test.ts`, `sync-audit.test.ts` | Offline identities, unknown network outcomes, rejection/retry, exclusions, interrupted in-memory pagination, namespace races and lifecycle changes | +| `event-delivery.test.ts`, `polling.test.ts` | WebSocket hints with HTTP-only event reads, missed notifications, opt-in polling, shutdown and retry | +| `protocol.test.ts` | Abrupt worker termination after accepted requests, exact retries, real server SIGKILL, CAS and event ordering | +| `restart-reconciliation.test.ts` | Interrupted file application followed by user edits, two-client/server convergence and a second restart | +| `server-restore.test.ts` | History checkpoints, server backup restores and interrupted metadata resets | +| `content-limits.test.ts`, `protocol-audit.test.ts` | Content validation and API invariants | +| `real-disk.test.ts` | Host filesystem exclusive create/rename, unsafe links, and rename cycles | -| Layer | What it establishes | -| ---------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `harness.test.ts` | Divergence/self-comparison detection, binary-safe comparisons, marker loss/duplication, cleanup failure propagation, deep persistence ownership, exclusive operations, exercised faults, seed reproducibility | -| `oracle-regressions.test.ts` | Negative checks for regenerated retries, wrong fault kinds, duplicate clients, corrupt hashes, BOMs, path byte limits, idempotent unlink, directory durability, and complete identity/content expectations | -| `recovery.test.ts` | Real engine journal recovery at every enumerated apply and replay mutation/save boundary; two interruptions followed by another power loss; identity, retained bytes and merge-policy regressions | -| `engine-regressions.test.ts` | Recovery archives, offline notifications, rejection/retry, exclusions, reverted remote updates, lifecycle races and editor changes during staging/install | -| `event-delivery.test.ts` | Real sync loop with checkpoint-controlled WS bursts before an iteration and during event application; no polling or extra wakeups to hide queued events | -| `polling.test.ts` | Real sync loop with fake clocks: missed local/remote notifications, disabled controls, repeated polls, shutdown and network retries independent of polling | -| `protocol.test.ts` | Terminated worker runtimes after accepted creates, existing-document Snapshot/Diff updates, and manifest requests, exact durable retries after newer remote heads and real server SIGKILL; concurrent CAS and ordered WS catchup | -| `real-disk.test.ts` | Host filesystem exclusive create/rename, symlink/hardlink rejection and a real engine rename-cycle application | -| Scripted suite | Existing multi-client regression scenarios plus v4 binary, identity, path-conflict, retry, and server-restart cases | -| Seeded workloads | Concrete replay traces, offline and rapid online moves/edits, concurrent creates, exact deletion/overwrite exemptions, nested Unicode paths, delayed notifications and before/after network failures | +The filesystem interruption tests assert the supported contract: complete metadata +remains usable, scans observe the current files, and a second scan is stable. +They do not demand the pre-interruption file contents, identities or a replay of +unfinished operations. Normal-operation tests still check exact bytes and identities. +There is no filesystem journal or hidden recovery archive. -`scripts/e2e-runner.test.mjs` also guards clean-install workspace/lockfile -consistency, CI dependency installation and artifact upload wiring, subprocess -exit codes, log failures, spawn failures, and timeouts. +`MemoryDisk` exposes `before:`, `visible:` and `durable:` boundaries. A process +interruption retains visible changes; a simulated power loss rolls unflushed +changes back. This provides different partial-application states for testing; it +is not an APFS/ext4 writeback model or a production durability requirement. +`MemoryPersistence` atomically replaces complete objects and can reject before or +after replacement. Abandoned filesystem sessions are fenced against later writes. -`MemoryDisk` distinguishes visible namespace/data from durable entries. Each -mutation has `before:`, `visible:`, and `durable:` boundaries. `crash(false)` -models process death with dirty kernel state retained; `crash(true)` discards -unflushed state. Operations that return successfully have flushed their affected -entries. Idempotent unlink flushes absent entries too, and durable directory -removal makes all former descendants unreachable. This is a deliberately small atomic-operation model, not an emulation -of APFS/ext4 writeback. A crashed session is fenced against subsequent writes. +`crash-worker.ts` runs a complete client in a worker, which the parent terminates +at accepted-response checkpoints without graceful shutdown. A new runtime receives +the saved disk and metadata. The separate real-disk fixture uses a test-only native +exclusive-rename helper and ordinary writes without flushes. It is not a production +adapter or a sandbox against hostile concurrent changes to ancestor directories. -`MemoryPersistence` atomically saves complete, cloned objects. Failure before a -save leaves the old object; failure after durable replacement leaves the new one. -The journal sweep recreates the production database/file-operations objects after -each fault. From each interrupted state it traces recovery and injects a second -process death or power loss at every replay mutation/save boundary. Final recovery -is replayed again and followed by another power loss. Cases cover swaps with new -output, cross-directory moves with a surviving sibling, concurrent text merges, -binary replacement, deletion and recovery archive cleanup. Exact visible bytes, -UUID/path mappings, retained bytes and absence of transaction files are checked. An intent is resubmitted only if nothing was durably saved; -resubmitting a partially recovered operation would hide journal defects. +Scripted and seeded suites run multiple clients against real servers. They check +visible bytes, UUID mappings, canonical server state, content heads and event cursors. +Polling is disabled except in tests specifically exercising it. Faults must fire; +missed injections fail cleanup rather than silently passing. Workload traces, +per-worker results and logs are saved by `scripts/e2e.sh` for reproduction. -`crash-worker.ts` runs a complete SyncClient in a separate worker. The parent -terminates it at an explicit accepted-response checkpoint without `destroy()`; -no old timers, websocket handlers, or promises survive. Update cases bootstrap an existing UUID and assert the non-null parent and exact -Snapshot/Diff request type before termination. Its saved memory-disk image and persisted database seed a fresh runtime. The separate real-disk fixture -uses a test-only native exclusive-rename primitive. It is **not** a -production adapter or an adversarial `openat` sandbox: concurrent hostile changes -to ancestor directories and actual machine power cuts are outside its coverage. - -## Regression coverage - -The previously exposed WS queue, notified-rename UUID, uncertain-save replay, -and cross-directory durability failures are fixed and remain asserted by the -default harness. Scripted, seeded, event-delivery and engine regression clients -explicitly disable polling so it cannot hide a missing wakeup. Crash workers also -assert that this setting survives restart. Polling is enabled only in separate -tests that intentionally omit notifications and advance a fake clock. Identity -assertions distinguish documents even when bytes -are equal. The engine regressions additionally exercise retained recovery bytes, -durable offline notifications, permanently rejected requests, ignored namespace -conflicts, reverted remote updates, and concurrent lifecycle changes. - -Tests assert intended invariants, including bytes and UUID mappings, rather than -accepting the observed output as an oracle. The memory fault model and real-disk -checks complement one another; neither proves behavior under every real power cut. - -## September 2026 audit regressions - -`scripts/e2e.sh` now runs the Rust and client unit suites as well as the harness, -real-server protocol checks, scripted scenarios, and seeded workloads. A failing -unit suite makes the complete run fail. The audit tests are separate, individually -quoted arguments in the default scripts, with a runner regression guarding that wiring. - -The audit cases were first run with assertions for the required behavior and -observed failing before the corresponding fixes. Coverage is organized by the -original finding numbers: - -| Findings | Permanent coverage and resulting guarantee | -| -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| 1 | Engine and real-server lost-reply/rejected-retry tests preserve later content and rename undos; interrupted receipt saves and paged replay are tested after process replacement. | -| 2, 6 | Ignored/oversized unmanaged occupants keep their paths, including arrivals after scanning; submitted manifests reserve excluded server paths and remain valid. | -| 3, 12 | Oversized remote heads defer uploads; oversized local files are rejected before snapshot reads; scans retain hashes rather than encoded vault contents; content notifications cannot starve namespace scans. | -| 4 | A subprocess deadline detects synchronous conflict-allocation hangs caused by long extensions. | -| 5 | Persistent destination errors select a durable root fallback. The recovery sweep keeps that error active through every first and second crash boundary, checking exact bytes, identities and retained originals. | -| 7–9 | Bootstrap notifications adopt remote identities; settings updates resume enabled engines, disabling sync persists across reset, and initial vault bindings survive restart; disabled startup recovers local journals. | -| 10–11 | Fake-clock silent-socket recovery and real-server connection admission, slot release and handshake expiry. | -| 13 | Rust tests bound event count and aggregate bytes per page; client tests fold remote reversions before application and preserve the replay position across restart. | -| 14–15 | Metadata-only manifest reference validation and active-pool cleanup tests ensure unrelated payload decoding and checked-out transactions cannot block these operations. | -| 16 | Dirty cursor updates invalidate precise positions; disconnect/reset clear presented cursors. | -| 17 | Read-only configuration preservation and concurrent initialization verify that existing credentials are never rewritten or replaced. | - -The follow-up audit added these independently reproduced failures. Each behavior -was asserted before its fix; the default test commands include every regression. - -| Additional failure | Regression coverage | -| ------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| Delayed content receipts crash after local deletion | `sync-audit.test.ts` verifies receipt consumption, preserved deletion, and cursor advancement without a local path. | -| Cursor reads restore presence after disconnect/reset | Cursor unit tests hold an active read and queue another update, retire the connection, then release both. | -| Failed WebSocket upgrades strand clients with polling disabled | `polling.test.ts` requires exact remote content and rename catchup over HTTP after a disconnect signal. | -| WebSocket construction errors escape startup/retry callbacks | Transport unit tests fail the first or second construction and require a successful subsequent connection. | -| Opening a locked vault blocks unrelated vaults | Rust tests hold a real SQLite writer lock during lazy opening; another vault must remain readable. Concurrent openings must share the configured pool, including after cancellation. | -| Failure clearing rejected requests leaves an enabled client stopped | Settings tests fail the second save, before and after commit, and require later notifications to wake synchronization. | -| Caller mutations bypass the checked settings snapshot | A settings test changes the caller's vault name and exclusion array while transports pause; persisted settings must retain the original input. | -| Cursor freshness uses metadata captured before an asynchronous read | A cursor test advances the document while the read is suspended and requires the old cursor to be marked outdated. | -| Throwing/silent socket closes leave live handlers after shutdown | Transport tests require disconnected status and no message delivery after a failed or timed-out close. | -| Handshake send errors escape the socket-open callback | A transport test drops the send during opening and requires safe retirement and retry. | -| Old connection cleanup deletes its replacement's cursors | A real-server overlapping reconnect test preserves the replacement; Rust tests also reject delayed updates from the old session. | - -These cases exercise transitions the earlier tests did not combine: a deletion -between submission and acknowledgement, disconnects during reads, failures after -the settings save, and two connections sharing a device identity. Happy-path -reconnect and single-save fault tests do not establish those guarantees. - -Replay pages contain at most 64 events and approximately 1 MiB of aggregate event -JSON. One atomic manifest can exceed that budget; it is sent alone so replay still -advances. `endEventId` identifies the page boundary while `headEventId` identifies -the vault head. Deploy the updated client and server together: older clients -require each batch to reach the vault head. Local partial replay folds retain only -latest document heads, the latest manifest, and matching outstanding receipts; -local files are reconciled only when catchup reaches the head. - -Permanent destination failures use `Recovered ` in the -vault root, resolving any occupied name. The journal retains the original bytes -when content is replaced. Permission or storage failures that also prevent that -fallback remain explicit recovery failures with the journal intact. - -A rejected retry retains an unconfirmed receipt until its request appears in the -event log. Absence from one catchup is not proof that an earlier in-flight attempt -cannot still commit. Receipt/artifact pruning remains an explicit operational concern; these tests do not claim -formal completeness or simulate arbitrary physical-media corruption. - -Reproduce the complete validation run from the repository root: +Run from the repository root: ```sh -E2E_ITERATIONS=100 E2E_SEED=8201 E2E_WORKERS=2 scripts/e2e.sh +E2E_WORKERS=32 E2E_SEED=12 E2E_TIMEOUT_SECONDS=9000 E2E_ITERATIONS=100 scripts/e2e.sh ``` -## Sync regression coverage added after the September audit - -The normal test commands include these regressions: - -| Failure | Maintained coverage | -| ------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Ignored occupant renamed into scope; incoming download discarded by unrelated notifications | `journal-protocol.test.ts` checks visible files, original IDs and actual server content. | -| Source path reused or moved during staging; editor save becomes a duplicate | `journal-interleavings.test.ts` injects moves, delete/recreate, move/recreate and content saves at every enumerated read, mutation and state-save boundary. Assertions specify bytes and document identities independently. | -| Journal/editor interaction followed by a crash | `journal-protocol.test.ts` crosses three editor operations with failures before/after state persistence, process replacement and simulated power loss, then checks another reset. | -| Restored server history falsely appears current | `server-restore.test.ts` restores actual SQLite backups and restarts Rust, covering equal/lower counters, dirty files, lost acknowledgements, interrupted recovery saves and old client state without checkpoints. | -| Oversized remote fallback and stale responses | `content-limits.test.ts` measures payload downloads, checks metadata-only responses and verifies highly fragmented local edits fall back to snapshots. | -| Adversarial diff work | Rust endpoint tests reject excessive operations without appending events and exercise Unicode reconstruction. | -| Observer exception kills sync | `observer.test.ts` runs throwing/rejecting subscribers in a child process and verifies a second sync; listener unit tests verify continued delivery. | -| Outgoing cursor version race | `outgoing-cursor.test.ts` changes version, identity, path and existence during a read, deliberately including identical bytes. | -| Unpersisted credentials | Rust config tests reject both missing users and missing user lists while preserving existing file bytes; initialization tests verify stable saved defaults. | -| Extensionless filenames enable merging | Extension unit tests include root/nested `md` and `txt` and dots in ancestor directories. | -| Corrupt vault blocks startup | Rust database regression opens a healthy vault alongside an invalid SQLite file and verifies the invalid file remains untouched. | - -The filesystem sweep reports the number of boundaries and injected interleavings. -A passing sweep covers this adapter model and these operations; arbitrary physical -media corruption and every possible editor/OS behavior remain outside its scope. +The command rebuilds peers, runs unit/harness/protocol/scripted suites, and then +runs seeds 12 through 43. `E2E_ARTIFACTS` optionally selects the artifact directory. +The runner tests also verify subprocess exit codes, log errors, spawn failures, +timeouts, workspace dependency consistency and CI artifact wiring. diff --git a/frontend/test-support/canonical.ts b/frontend/test-support/canonical.ts index 0f30ffa4..296413a2 100644 --- a/frontend/test-support/canonical.ts +++ b/frontend/test-support/canonical.ts @@ -26,7 +26,7 @@ export async function getJson(url: string, token: string): Promise { } /** Equality alone can pass when every replica loses the same bytes. Check the - * canonical UUIDs, content heads, receipts, journals and event cursor as well. + * canonical UUIDs, content heads, receipts and event cursor as well. * Scenario-specific content/identity oracles still run separately. */ export async function assertCanonical( diff --git a/frontend/test-support/engine-regressions.test.ts b/frontend/test-support/engine-regressions.test.ts index 517d1360..81995ee6 100644 --- a/frontend/test-support/engine-regressions.test.ts +++ b/frontend/test-support/engine-regressions.test.ts @@ -1,170 +1,144 @@ import assert from "node:assert/strict"; import { PermanentSyncError } from "../sync-client/src/errors/errors"; import { test } from "node:test"; -import { - Database, - emptyState, - type StoredDatabase -} from "../sync-client/src/persistence/database"; -import { Settings } from "../sync-client/src/persistence/settings"; -import { FileOperations } from "../sync-client/src/file-operations/file-operations"; +import { Database, emptyState } from "../sync-client/src/persistence/database"; import { Syncer } from "../sync-client/src/sync-operations/syncer"; import { SyncClient } from "../sync-client/src/sync-client"; import { toStoredSnapshot } from "../sync-client/src/sync-operations/content"; -import { Logger } from "../sync-client/src/tracing/logger"; -import { SyncHistory } from "../sync-client/src/tracing/sync-history"; -import { FixedSizeDocumentCache } from "../sync-client/src/utils/data-structures/fix-sized-cache"; -import { EventListeners } from "../sync-client/src/utils/data-structures/event-listeners"; import { allocatePortablePath } from "../sync-client/src/utils/portable-path"; -import type { ServerConfig } from "../sync-client/src/services/server-config"; -import type { SyncService } from "../sync-client/src/services/sync-service"; -import type { WebSocketManager } from "../sync-client/src/services/websocket-manager"; import { MemoryDisk, MemoryPersistence } from "./storage"; -import type { LocalChange } from "../sync-client/src/sync-operations/local-changes"; -const bytes = (text: string) => new TextEncoder().encode(text); -const head = (id: string, version: number, content: string) => ({ - documentId: id, - vaultUpdateId: version, - contentSize: bytes(content).length, - updatedDate: "2026-09-19T00:00:00Z", - userId: "user", - deviceId: "device" +import { bytes, head, fixture } from "./sync-fixture"; + +test("offline content notifications need no saves and restart reads the current bytes", async () => { + const disk = new MemoryDisk(); + await disk.userWrite("a.md", bytes("original")); + const initial = emptyState(JSON.stringify(["http://offline.test", "test"])); + initial.initialized = true; + initial.local = { a: "a.md" }; + initial.documents.a = { + materialized: true, + observedHash: (await toStoredSnapshot({ content: bytes("original") })) + .hash + }; + const store = new MemoryPersistence({ + database: initial, + settings: { remoteUri: "http://offline.test", vaultName: "test" } + }); + let client = await SyncClient.create({ fs: disk, persistence: store }); + try { + const saved = store.snapshot(); + store.boundary = () => { + throw new Error("content notifications must not save"); + }; + for (let edit = 0; edit < 20; edit++) { + await disk.userWrite("a.md", bytes(`edit ${edit}`)); + await client.syncLocallyUpdatedFile({ relativePath: "a.md" }); + } + await client.destroy(); + assert.deepEqual(store.snapshot(), saved); + store.boundary = () => {}; + // A final edit has no notification at all. + await disk.userWrite("a.md", bytes("latest offline bytes")); + client = await SyncClient.create({ fs: disk, persistence: store }); + const internals = client as unknown as { + database: Database; + syncer: { + stopped: boolean; + scan(): Promise; + preparePush(): Promise; + }; + }; + await internals.syncer.scan(); + internals.syncer.stopped = false; + assert.equal(await internals.syncer.preparePush(), true); + const pending = internals.database.state.pending; + assert(pending?.type === "content"); + assert.equal(pending.documentId, "a"); + assert.equal( + Buffer.from(pending.snapshot.contentBase64, "base64").toString(), + "latest offline bytes" + ); + } finally { + store.boundary = () => {}; + await client.destroy(); + } }); -async function fixture( - contents: Record = { "a.md": "local" }, - service: Partial = {} -) { +test("legacy content notifications preserve the acknowledged namespace prefix on restart", async () => { const disk = new MemoryDisk(); - const initial = emptyState("test"); + await disk.userWrite("d.md", bytes("A")); + await disk.userWrite("b.md", bytes("B")); + const initial = emptyState(JSON.stringify(["http://offline.test", "test"])); initial.initialized = true; - for (const [path, content] of Object.entries(contents)) { - await disk.userWrite(path, bytes(content)); - const id = path.split(".")[0]; - initial.local[id] = path; + initial.local = { a: "c.md", b: "b.md" }; + initial.fileManifest.entries = { ...initial.local }; + for (const [id, content] of [ + ["a", "A"], + ["b", "B"] + ]) initial.documents[id] = { materialized: true, observedHash: (await toStoredSnapshot({ content: bytes(content) })) .hash }; + initial.lastAppliedLocalChangeId = "consumed-update"; + const saved = { + database: initial, + settings: { remoteUri: "http://offline.test", vaultName: "test" }, + localChangesVaultKey: initial.vaultKey, + localChanges: [ + { + type: "move", + oldPath: "a.md", + relativePath: "b.md", + identities: { a: "a.md" }, + changeId: "consumed-move" + }, + { type: "update", path: "c.md", changeId: "consumed-update" }, + { type: "update", path: "b.md", changeId: "new-update" }, + { + type: "move", + oldPath: "c.md", + relativePath: "d.md", + identities: { a: "c.md" }, + changeId: "new-move" + } + ] + }; + const store = new MemoryPersistence(saved); + const client = await SyncClient.create({ fs: disk, persistence: store }); + try { + const internals = client as unknown as { + database: Database; + syncer: { scan(): Promise }; + }; + await internals.syncer.scan(); + assert.deepEqual(internals.database.state.local, { + a: "d.md", + b: "b.md" + }); + assert.equal( + internals.database.state.lastAppliedLocalChangeId, + "new-move" + ); + } finally { + await client.destroy(); } - initial.fileManifest = { fileManifestId: 1, entries: { ...initial.local } }; - const persistence = new MemoryPersistence({ database: initial }); - const logger = new Logger(); - const database = new Database( - logger, - initial, - async (next) => persistence.save({ database: next }), - "test", - async () => (await persistence.load()).database as StoredDatabase - ); - const config = { - initialize: async () => {}, - getConfig: async () => ({ mergeableFileExtensions: ["md"] }), - reset: () => {} - } as unknown as ServerConfig; - const settings = new Settings( - logger, - { syncIntervalMs: 0 }, - async () => {} - ); - const changes: LocalChange[] = []; - const files = new FileOperations( - disk.session(), - database, - config, - undefined, - { entries: () => changes, flush: async () => {} } - ); - const websocket = { - onWebSocketStatusChanged: new EventListeners(), - onRemoteVaultUpdateReceived: new EventListeners() - } as unknown as WebSocketManager; - const syncer = new Syncer( - "device", - logger, - database, - settings, - service as SyncService, - websocket, - files, - config, - new SyncHistory(logger), - new FixedSizeDocumentCache(1024), - { entries: changes, save: async () => {} } - ); - const internals = syncer as unknown as { - finishPending(): Promise; - preparePush(): Promise; - scan(): Promise; - incorporateEventBatch( - batch: import("../sync-client/src/services/types/EventBatch").EventBatch - ): Promise; - stopped: boolean; - incorporateFileManifest(remote: { - fileManifestId: number; - entries: Record; - }): Promise; - incorporateContent(remote: ReturnType): Promise; - }; - return { - disk, - database, - files, - settings, - syncer, - internals, - persistence, - changes - }; -} - -test("remote binary replacement retains displaced bytes outside the visible namespace", async () => { - const f = await fixture({ "a.bin": "private edit" }); - const next = structuredClone(f.database.state); - await f.files.apply(next, { - a: { - expected: await f.files.snapshot("a.bin"), - replacement: await toStoredSnapshot({ content: bytes("remote") }) - } - }); - assert.deepEqual(f.disk.userFiles(), new Map([["a.bin", bytes("remote")]])); - const retained = (await f.disk.listFilesRecursively()).filter((path) => - path.startsWith(".vault-link-sync/recovery/") - ); - assert( - ( - await Promise.all(retained.map((path) => f.disk.readSnapshot(path))) - ).some( - (value) => - value && - Buffer.from(value.content).toString() === "private edit" - ) - ); - await f.files.recover(); - assert.deepEqual( - await f.disk.listFilesRecursively(), - ["a.bin", ...retained].sort() - ); }); -test("remote deletion retains unsent bytes for recovery", async () => { - const f = await fixture(); +test("remote binary replacement and deletion do not create backup artifacts", async () => { + const f = await fixture({ "a.bin": "private edit" }); + await f.files.apply(structuredClone(f.database.state), { + a: { replacement: await toStoredSnapshot({ content: bytes("remote") }) } + }); + assert.deepEqual(await f.disk.listFilesRecursively(), ["a.bin"]); + assert.deepEqual(f.disk.userFiles(), new Map([["a.bin", bytes("remote")]])); await f.internals.incorporateFileManifest({ fileManifestId: 2, entries: {} }); - assert.equal(f.disk.userFiles().size, 0); - const retained = await f.disk.listFilesRecursively(); - assert( - ( - await Promise.all(retained.map((path) => f.disk.readSnapshot(path))) - ).some( - (value) => - value && Buffer.from(value.content).toString() === "local" - ) - ); + assert.deepEqual(await f.disk.listFilesRecursively(), []); }); test("rejected content recovery remains idempotent across a rename and network failure", async () => { @@ -281,45 +255,6 @@ test("unfinished bootstrap cannot be rebound to another vault", async () => { } }); -test("local swap arriving after a remote journal is saved preserves both identities and contents", async () => { - const f = await fixture({ "a.md": "A", "b.md": "B" }); - let fired = false; - f.persistence.boundary = async (label) => { - if ( - fired || - label !== "durable:save" || - !f.persistence.snapshot().database?.application - ) - return; - fired = true; - for (const [oldPath, relativePath] of [ - ["a.md", "tmp.md"], - ["b.md", "a.md"], - ["tmp.md", "b.md"] - ]) { - await f.disk.userRename(oldPath, relativePath); - await f.syncer.syncLocallyUpdatedFile({ oldPath, relativePath }); - } - }; - const next = structuredClone(f.database.state); - next.local = { a: "remote-a.md", b: "remote-b.md" }; - await f.files.apply(next); - assert(fired); - assert.equal( - Buffer.from( - f.disk.userFiles().get(f.database.state.local.a)! - ).toString(), - "A" - ); - assert.equal( - Buffer.from( - f.disk.userFiles().get(f.database.state.local.b)! - ).toString(), - "B" - ); - assert.deepEqual(f.database.state.local, { a: "b.md", b: "a.md" }); -}); - test("a rejected upload is retained locally and does not block another file or a corrected snapshot", async () => { let reject = true; const sent: string[] = []; @@ -375,7 +310,7 @@ test("a transient upload failure retries the exact request and recovers its ackn assert.equal(f.database.state.pending, undefined); }); -test("a persisted upload rejection finishes locally after a recovery write fails", async () => { +test("a persisted upload rejection finishes without filesystem writes", async () => { let uploads = 0; const f = await fixture(undefined, { putFileContent: async () => { @@ -385,29 +320,14 @@ test("a persisted upload rejection finishes locally after a recovery write fails }); f.internals.stopped = false; await f.internals.preparePush(); - const { pending } = f.database.state; - assert(pending); - const { requestId } = pending.request; - const retained = `.vault-link-sync/requests/${requestId}.content`; - const { boundary } = f.disk; - f.disk.boundary = (label): void => { - if (label === `before:write:${retained}`) - throw new Error("recovery write failed"); + f.disk.boundary = (label) => { + if (!label.startsWith("read:")) + throw new Error("Unexpected filesystem mutation"); }; - await assert.rejects(f.internals.finishPending(), /recovery write failed/); - assert.equal( - f.persistence.snapshot().database?.pending?.rejection, - "HTTP 413" - ); - f.disk.boundary = boundary; await f.internals.finishPending(); assert.equal(uploads, 1); assert.equal(f.database.state.pending, undefined); assert.equal(f.database.state.documents.a.rejected?.message, "HTTP 413"); - assert.deepEqual( - (await f.disk.readSnapshot(retained))?.content, - bytes("local") - ); assert.deepEqual(f.disk.userFiles(), new Map([["a.md", bytes("local")]])); }); @@ -749,79 +669,6 @@ test("an incoming ignored document cannot force an existing ignored file to move ); }); -for (const boundary of ["visible", "durable"] as const) { - for (const action of [ - "move-installed", - "recreate-staged-source" - ] as const) { - test(`${action} at a ${boundary} filesystem boundary keeps original identities and all bytes`, async () => { - const f = await fixture({ "a.md": "A", "b.md": "B" }); - let changed = false; - f.disk.boundary = async (label) => { - if (changed || !label.startsWith(`${boundary}:rename:`)) return; - if (action === "move-installed" && label.endsWith("->b.md")) { - changed = true; - await f.disk.userRename("b.md", "later.md"); - await f.syncer.syncLocallyUpdatedFile({ - oldPath: "b.md", - relativePath: "later.md" - }); - } else if ( - action === "recreate-staged-source" && - label.includes("rename:a.md->") - ) { - changed = true; - await f.disk.userWrite("a.md", bytes("new")); - await f.syncer.syncLocallyCreatedFile("a.md"); - } - }; - const next = structuredClone(f.database.state); - next.local = { a: "b.md", b: "a.md" }; - next.fileManifest = { - fileManifestId: 2, - entries: { ...next.local } - }; - await f.files.apply(next); - assert(changed); - assert.equal( - Buffer.from( - (await f.disk.readSnapshot(f.database.state.local.a))! - .content - ).toString(), - "A" - ); - assert.equal( - Buffer.from( - (await f.disk.readSnapshot(f.database.state.local.b))! - .content - ).toString(), - "B" - ); - assert.deepEqual( - [...f.disk.userFiles().values()] - .map((value) => Buffer.from(value).toString()) - .sort(), - action === "move-installed" ? ["A", "B"] : ["A", "B", "new"] - ); - f.disk.crash(true); - assert.equal( - Buffer.from( - (await f.disk.readSnapshot(f.database.state.local.a))! - .content - ).toString(), - "A" - ); - assert.equal( - Buffer.from( - (await f.disk.readSnapshot(f.database.state.local.b))! - .content - ).toString(), - "B" - ); - }); - } -} - test("rejected content is reported to callers and an explicit retry clears the rejection", async () => { const f = await fixture(undefined, { putFileContent: async () => { diff --git a/frontend/test-support/event-delivery.test.ts b/frontend/test-support/event-delivery.test.ts index a31d58e9..012e24fd 100644 --- a/frontend/test-support/event-delivery.test.ts +++ b/frontend/test-support/event-delivery.test.ts @@ -21,11 +21,11 @@ import { MemoryDisk, MemoryPersistence } from "./storage"; for (const [count, delivery] of [ [1, "before the loop"], - [3, "before the loop"], + [9, "before the loop"], [3, "during the first batch"] ] as const) { test( - `${count} WS batches delivered ${delivery} drain without polling or another notification`, + `${count} WS hints delivered ${delivery} catch up through HTTP without polling`, { timeout: 5_000 }, async () => { const logger = new Logger(); @@ -33,7 +33,6 @@ for (const [count, delivery] of [ initial.initialized = true; const persistence = new MemoryPersistence({ database: initial }); const database = new Database( - logger, initial, async (next) => persistence.save({ database: next }), initial.vaultKey, @@ -85,13 +84,20 @@ for (const [count, delivery] of [ fileManifest: { fileManifestId: index + 1, entries: {} } }) ); + let available: number = delivery === "before the loop" ? count : 1; + const fetchedAfter: number[] = []; const service = { - // A correct client may drain the queue or catch up over HTTP. Either - // must reach the same head without the test manually waking it. - events: async (after: number): Promise => ({ - headEventId: count, - events: events.filter((event) => event.eventId > after) - }) + events: async (after: number): Promise => { + fetchedAfter.push(after); + return { + headEventId: available, + events: events.filter( + (event) => + event.eventId > after && + event.eventId <= available + ) + }; + } } as unknown as SyncService; const websocket = { onWebSocketStatusChanged: new EventListeners< @@ -116,7 +122,15 @@ for (const [count, delivery] of [ const deliver = async (event: EventRecord) => { await websocket.onRemoteVaultUpdateReceived.triggerAsync({ headEventId: event.eventId, - events: [event] + // Payloads are deliberately inconsistent with HTTP. The socket + // only wakes the loop; it cannot supply reconciliation state. + events: [ + { + ...event, + type: "fileManifest", + fileManifest: { fileManifestId: 999, entries: {} } + } + ] }); }; try { @@ -129,6 +143,7 @@ for (const [count, delivery] of [ await deliver(events[0]); initialized.resolve(); await firstBatch.promise; + available = count; for (const event of events.slice(1)) await deliver(event); continueBatch.resolve(); } @@ -140,11 +155,14 @@ for (const [count, delivery] of [ ); assert.deepEqual( applied, - events.map((event) => event.eventId), - "Every event must be committed in order" + delivery === "before the loop" ? [count] : [1, count], + "Each HTTP catchup commits its final cursor" + ); + assert.deepEqual( + fetchedAfter, + delivery === "before the loop" ? [0] : [0, 1] ); assert.equal(database.state.fileManifest.fileManifestId, count); - assert.equal(database.state.application, undefined); assert.equal(database.state.pending, undefined); assert.equal(syncer.isBusy, false); } finally { diff --git a/frontend/test-support/file-application.test.ts b/frontend/test-support/file-application.test.ts new file mode 100644 index 00000000..957fe62f --- /dev/null +++ b/frontend/test-support/file-application.test.ts @@ -0,0 +1,219 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { fixture, bytes } from "./sync-fixture"; +import { toStoredSnapshot } from "../sync-client/src/sync-operations/content"; +import { LocalChangesDuringReconciliation } from "../sync-client/src/errors/errors"; + +for (const equal of [false, true]) + test(`swaps preserve identities with ${equal ? "equal" : "distinct"} content and save once`, async () => { + const f = await fixture({ "a.md": "A", "b.md": equal ? "A" : "B" }); + let saves = 0; + f.persistence.boundary = (label) => { + if (label === "before:save") saves++; + }; + const next = structuredClone(f.database.state); + next.local = { a: "b.md", b: "a.md" }; + await f.files.apply(next); + assert.deepEqual(f.database.state.local, next.local); + assert.deepEqual( + f.disk.userFiles(), + new Map([ + ["a.md", bytes(equal ? "A" : "B")], + ["b.md", bytes("A")] + ]) + ); + assert.equal(saves, 1); + assert.deepEqual(await f.disk.listFilesRecursively(), ["a.md", "b.md"]); + }); + +for (const direction of ["file-to-directory", "directory-to-file"]) + test(direction, async () => { + const f = await fixture( + direction === "file-to-directory" + ? { folder: "A", "b.md": "B" } + : { "a.md": "A", "folder/b.md": "B" } + ); + const ids = Object.keys(f.database.state.local); + const next = structuredClone(f.database.state); + next.local = + direction === "file-to-directory" + ? { [ids[0]]: "folder/a.md", [ids[1]]: "b.md" } + : { [ids[0]]: "folder", [ids[1]]: "other.md" }; + const expected = structuredClone(next.local); + await f.files.apply(next); + assert.deepEqual(f.database.state.local, expected); + assert.deepEqual( + new Set(f.disk.userFiles().keys()), + new Set(Object.values(f.database.state.local)) + ); + assert.deepEqual( + [...f.disk.userFiles().values()] + .map((value) => Buffer.from(value).toString()) + .sort(), + ["A", "B"] + ); + }); + +test("an unexpected occupant is preserved under a visible conflict name", async () => { + const f = await fixture(); + await f.disk.userWrite("remote.md", bytes("unexpected")); + const next = structuredClone(f.database.state); + next.local.a = "remote.md"; + await f.files.apply(next); + assert.equal(f.database.state.local.a, "remote.md"); + assert.equal(f.disk.userFiles().size, 2); + assert.deepEqual( + [...f.disk.userFiles().values()] + .map((value) => Buffer.from(value).toString()) + .sort(), + ["local", "unexpected"] + ); + assert.equal(Object.keys(f.database.state.local).length, 2); +}); + +test("content edited after planning is merged with the incoming snapshot", async () => { + const f = await fixture({ "a.md": "base\n" }); + const expected = (await f.files.snapshot("a.md"))!; + await f.disk.userWrite("a.md", bytes("base\nlocal\n")); + await f.files.apply(structuredClone(f.database.state), { + a: { + expected, + replacement: await toStoredSnapshot({ + content: bytes("remote\nbase\n") + }) + } + }); + assert.deepEqual( + f.disk.userFiles().get("a.md"), + bytes("remote\nbase\nlocal\n") + ); +}); + +test("deleting the source after planning does not resurrect it", async () => { + const f = await fixture(); + const next = structuredClone(f.database.state); + next.local.a = "remote.md"; + await f.disk.userDelete("a.md"); + await f.files.apply(next, { + a: { replacement: await toStoredSnapshot({ content: bytes("remote") }) } + }); + assert.deepEqual(f.disk.userFiles(), new Map()); + assert.deepEqual(f.database.state.local, {}); +}); + +for (const protect of ["ignore", "size"] as const) + test(`protect ${protect} at application time`, async () => { + const f = await fixture(); + const next = structuredClone(f.database.state); + next.local.a = "remote.md"; + if (protect === "ignore") + await f.settings.setSetting("ignorePatterns", ["a.md"]); + else await f.settings.setSetting("maxFileSizeMB", 0); + await f.files.apply(next, { + a: { + replacement: await toStoredSnapshot({ + content: bytes("remote") + }) + } + }); + assert.deepEqual( + f.disk.userFiles(), + new Map([["a.md", bytes("local")]]) + ); + assert.equal(f.database.state.local.a, "a.md"); + assert.equal(f.database.state.excluded?.a, "a.md"); + }); + +for (const when of ["planning", "installed"]) + test(`notified move during ${when} is attributed to the right identity on rescan`, async () => { + const f = await fixture({ "a.md": "A", "b.md": "B" }); + let changed = false; + f.disk.boundary = async (label) => { + if ( + changed || + label !== + (when === "planning" + ? "read:stat:a.md" + : "visible:rename:a.md->b.md") + ) + return; + changed = true; + const oldPath = when === "planning" ? "a.md" : "b.md"; + await f.disk.userRename(oldPath, "editor.md"); + await f.syncer.syncLocallyUpdatedFile({ + oldPath, + relativePath: "editor.md" + }); + }; + const next = structuredClone(f.database.state); + next.local = { a: "b.md", b: "a.md" }; + if (when === "planning") + await assert.rejects( + f.files.apply(next), + LocalChangesDuringReconciliation + ); + else await f.files.apply(next); + assert(changed); + await f.internals.scan(); + assert.equal(f.database.state.local.a, "editor.md"); + assert.deepEqual( + f.disk.userFiles().get(f.database.state.local.a), + bytes("A") + ); + assert.deepEqual( + f.disk.userFiles().get(f.database.state.local.b), + bytes("B") + ); + }); + +for (const action of ["move", "delete"] as const) + test(`notified ${action}/recreate between file operations does not repurpose the replacement file`, async () => { + const f = await fixture({ "a.md": "A", "b.md": "B" }); + await f.settings.setSetting("ignorePatterns", ["private.md"]); + let changed = false; + f.disk.boundary = async (label) => { + if (changed || label !== "visible:rename:a.md->remote-a.md") return; + changed = true; + if (action === "move") { + await f.disk.userRename("b.md", "private.md"); + await f.syncer.syncLocallyUpdatedFile({ + oldPath: "b.md", + relativePath: "private.md" + }); + } else { + await f.disk.userDelete("b.md"); + await f.syncer.syncLocallyDeletedFile("b.md"); + } + await f.disk.userWrite("b.md", bytes("replacement")); + await f.syncer.syncLocallyCreatedFile("b.md"); + }; + const next = structuredClone(f.database.state); + next.local = { a: "remote-a.md", b: "remote-b.md" }; + await f.files.apply(next); + assert(changed); + await f.internals.scan(); + assert.deepEqual(f.disk.userFiles().get("b.md"), bytes("replacement")); + assert.equal(f.disk.userFiles().has("remote-b.md"), false); + assert.equal( + f.database.state.local.b, + action === "move" ? "private.md" : undefined + ); + if (action === "move") + assert.deepEqual(f.disk.userFiles().get("private.md"), bytes("B")); + }); + +test("a file and nested directory exchange their exact paths", async () => { + const f = await fixture({ a: "A", "b/deep/child": "B" }); + const [a, b] = Object.keys(f.database.state.local); + const next = structuredClone(f.database.state); + next.local = { [a]: "b", [b]: "a/deep/child" }; + await f.files.apply(next); + assert.deepEqual(f.database.state.local, { [a]: "b", [b]: "a/deep/child" }); + assert.deepEqual( + f.disk.userFiles(), + new Map([ + ["b", bytes("A")], + ["a/deep/child", bytes("B")] + ]) + ); +}); diff --git a/frontend/test-support/journal-interleavings.test.ts b/frontend/test-support/journal-interleavings.test.ts deleted file mode 100644 index 93f0bbb0..00000000 --- a/frontend/test-support/journal-interleavings.test.ts +++ /dev/null @@ -1,407 +0,0 @@ -import assert from "node:assert/strict"; -import { test } from "node:test"; -import { fixture, bytes, head } from "./sync-fixture"; -import { toStoredSnapshot } from "../sync-client/src/sync-operations/content"; - -for (const origin of ["bootstrap", "history recovery"]) - test(`cancelling a replacement retains the ${origin} merge context`, async () => { - const f = await fixture( - { "a.md": "BASE\n" }, - { getDocumentVersionContent: async () => bytes("REMOTE\nBASE\n") } - ); - const recoveryBase = await toStoredSnapshot({ - content: bytes("BASE\n") - }); - if (origin === "bootstrap") - f.database.state.documents.a.bootstrap = true; - else f.database.state.documents.a.recoveryBase = recoveryBase; - await f.database.save(); - await f.settings.setSetting("ignorePatterns", ["private.md"]); - let injected = false; - f.disk.boundary = async (label) => { - if ( - injected || - !label.startsWith("before:mkdir:.vault-link-sync/transactions/") - ) - return; - injected = true; - await f.disk.userRename("a.md", "private.md"); - await f.disk.userWrite("private.md", bytes("BASE\nLOCAL\n")); - await f.syncer.syncLocallyUpdatedFile({ - oldPath: "a.md", - relativePath: "private.md" - }); - }; - await f.internals.incorporateContent(head("a", 2, "REMOTE\nBASE\n")); - assert(injected); - const deferred = f.database.state.documents.a; - assert.equal(deferred.base, undefined); - if (origin === "bootstrap") assert.equal(deferred.bootstrap, true); - else assert.deepEqual(deferred.recoveryBase, recoveryBase); - f.disk.boundary = () => {}; - await f.settings.setSetting("ignorePatterns", []); - await f.internals.incorporateFileManifest( - f.database.state.fileManifest - ); - const content = Buffer.from( - f.disk.userFiles().get("private.md")! - ).toString(); - assert.equal(content.match(/REMOTE/g)?.length, 1); - assert.equal(content.match(/LOCAL/g)?.length, 1); - assert.equal(f.database.state.documents.a.base?.vaultUpdateId, 2); - assert.equal(f.database.state.documents.a.recoveryBase, undefined); - }); - -for (const timing of ["before", "durable"]) - test(`a save that exceeds the size limit ${timing} staging keeps its exact bytes`, async () => { - const f = await fixture({ "a.bin": "PUBLIC" }); - await f.settings.setSetting("maxFileSizeMB", 100 / (1024 * 1024)); - const large = "PRIVATE".repeat(100); - let injected = false; - f.disk.boundary = async (label) => { - if ( - injected || - !label.startsWith( - `${timing}:rename:a.bin->.vault-link-sync/transactions/` - ) - ) - return; - injected = true; - await f.disk.userWrite("a.bin", bytes(large)); - await f.syncer.syncLocallyUpdatedFile({ relativePath: "a.bin" }); - }; - await f.files.apply(structuredClone(f.database.state), { - a: { - expected: await toStoredSnapshot({ content: bytes("PUBLIC") }), - replacement: await toStoredSnapshot({ - content: bytes("REMOTE") - }) - } - }); - assert(injected); - assert.equal( - Buffer.from(f.disk.userFiles().get("a.bin")!).toString(), - large - ); - assert.equal(f.database.state.excluded?.a, "a.bin"); - assert.deepEqual(f.database.state.local, { a: "a.bin" }); - }); - -test("an excluded move after installation survives an uncertain journal acknowledgement", async () => { - const f = await fixture({ "a.md": "PUBLIC" }); - await f.settings.setSetting("ignorePatterns", ["private.md"]); - let moved = false, - crashed = false; - f.disk.boundary = async (label) => { - if ( - moved || - !label.startsWith("durable:rename:") || - !label.endsWith(".output->a.md") - ) - return; - moved = true; - await f.disk.userRename("a.md", "private.md"); - await f.disk.userWrite("private.md", bytes("SECRET")); - await f.syncer.syncLocallyUpdatedFile({ - oldPath: "a.md", - relativePath: "private.md" - }); - }; - f.persistence.boundary = (label) => { - if (!moved || crashed || label !== "durable:save") return; - crashed = true; - throw new Error("lost journal acknowledgement"); - }; - await assert.rejects( - f.files.apply(structuredClone(f.database.state), { - a: { - expected: await toStoredSnapshot({ content: bytes("PUBLIC") }), - replacement: await toStoredSnapshot({ - content: bytes("PUBLIC REMOTE") - }) - } - }), - /lost journal acknowledgement/ - ); - assert(moved && crashed); - f.disk.boundary = () => {}; - f.persistence.boundary = () => {}; - await f.database.recoverPersistence(); - await f.files.recover(); - assert.deepEqual(f.database.state.local, { a: "private.md" }); - assert.equal( - Buffer.from(f.disk.userFiles().get("private.md")!).toString(), - "SECRET" - ); -}); - -test("moving a tracked file into an ignored path during a journal leaves it protected", async () => { - const f = await fixture({ "a.md": "PUBLIC", "b.md": "OTHER" }); - await f.settings.setSetting("ignorePatterns", ["private.md"]); - const next = structuredClone(f.database.state); - next.local.b = "remote-b.md"; - let injected = false; - f.disk.boundary = async (label) => { - if ( - injected || - !label.startsWith("before:mkdir:.vault-link-sync/transactions/") - ) - return; - injected = true; - await f.disk.userRename("a.md", "private.md"); - await f.disk.userWrite("private.md", bytes("SECRET")); - await f.syncer.syncLocallyUpdatedFile({ - oldPath: "a.md", - relativePath: "private.md" - }); - }; - await f.files.apply(next); - assert(injected); - assert.equal( - Buffer.from(f.disk.userFiles().get("private.md") ?? []).toString(), - "SECRET" - ); - assert.equal(f.database.state.local.a, "private.md"); - assert.equal(f.database.state.excluded?.a, "a.md"); -}); - -test("a directory move protects each ignored descendant during a journal", async () => { - const f = await fixture({ "folder/a.md": "PUBLIC", "b.md": "OTHER" }); - await f.settings.setSetting("ignorePatterns", ["private/**"]); - const next = structuredClone(f.database.state); - next.local.b = "remote-b.md"; - let injected = false; - f.disk.boundary = async (label) => { - if ( - injected || - !label.startsWith("before:mkdir:.vault-link-sync/transactions/") - ) - return; - injected = true; - await f.disk.userRename("folder/a.md", "private/a.md"); - await f.disk.userWrite("private/a.md", bytes("SECRET")); - await f.syncer.syncLocallyUpdatedFile({ - oldPath: "folder", - relativePath: "private" - }); - }; - await f.files.apply(next); - assert(injected); - assert.equal( - Buffer.from(f.disk.userFiles().get("private/a.md") ?? []).toString(), - "SECRET" - ); - assert.equal(f.database.state.local["folder/a"], "private/a.md"); - assert.equal(f.database.state.excluded?.["folder/a"], "folder/a.md"); -}); - -test("a later editor save replaces earlier unsynced text after a merge was prepared", async () => { - const f = await fixture({ "a.md": "base" }); - const next = structuredClone(f.database.state); - let saves = 0; - f.disk.boundary = async (label) => { - if (saves === 0 && label.startsWith("before:rename:a.md->")) { - saves++; - await f.disk.userWrite("a.md", bytes("base ONE")); - await f.syncer.syncLocallyUpdatedFile({ relativePath: "a.md" }); - } else if ( - saves === 1 && - label.startsWith("before:write:") && - label.endsWith(".output") - ) { - saves++; - await f.disk.userWrite("a.md", bytes("base TWO")); - await f.syncer.syncLocallyUpdatedFile({ relativePath: "a.md" }); - } - }; - await f.files.apply(next, { - a: { - expected: await toStoredSnapshot({ content: bytes("base") }), - replacement: await toStoredSnapshot({ - content: bytes("base REMOTE") - }) - } - }); - assert.equal(saves, 2); - assert.equal( - Buffer.from(f.disk.userFiles().get("a.md")!).toString(), - "base TWO REMOTE" - ); - assert.deepEqual(f.database.state.local, { a: "a.md" }); -}); - -test("a content save during remote application remains on the same document", async () => { - const f = await fixture({ "a.md": "base" }); - f.database.state.documents.a.base = { - ...head("a", 1, "base"), - hash: f.database.state.documents.a.observedHash! - }; - const next = structuredClone(f.database.state); - next.documents.a.base = { - ...head("a", 2, "base REMOTE"), - hash: (await toStoredSnapshot({ content: bytes("base REMOTE") })).hash - }; - let injected = false; - f.disk.boundary = async (label) => { - if ( - injected || - !label.startsWith( - "durable:rename:a.md->.vault-link-sync/transactions/" - ) - ) - return; - injected = true; - await f.disk.userWrite("a.md", bytes("base LOCAL")); - await f.syncer.syncLocallyUpdatedFile({ relativePath: "a.md" }); - }; - await f.files.apply(next, { - a: { - expected: await toStoredSnapshot({ content: bytes("base") }), - replacement: await toStoredSnapshot({ - content: bytes("base REMOTE") - }) - } - }); - - assert.equal(f.disk.userFiles().size, 1); - const result = Buffer.from( - (await f.disk.readSnapshot("a.md"))!.content - ).toString(); - assert(result.includes("LOCAL") && result.includes("REMOTE")); -}); - -async function setup() { - const f = await fixture({ "a.md": "ORIGINAL", "b.md": "OTHER" }); - f.database.state.documents.a.base = { - ...head("a", 1, "ORIGINAL"), - hash: f.database.state.documents.a.observedHash! - }; - const next = structuredClone(f.database.state); - next.local = { a: "remote.md", b: "remote-b.md" }; - next.fileManifest = { fileManifestId: 3, entries: { ...next.local } }; - const expected = await toStoredSnapshot({ content: bytes("ORIGINAL") }); - const replacement = await toStoredSnapshot({ - content: bytes("ORIGINAL REMOTE") - }); - next.documents.a.base = { - ...head("a", 2, "ORIGINAL REMOTE"), - hash: replacement.hash - }; - return { f, next, writes: { a: { expected, replacement } } }; -} -test("editor changes preserve exact bytes and identities at every filesystem read, mutation and persistence boundary", async (t) => { - const { f, next, writes } = await setup(); - const labels: string[] = []; - const trace = (l: string) => { - labels.push(l); - }; - f.disk.boundary = trace; - f.persistence.boundary = trace; - await f.files.apply(next, writes); - const failures = []; - let exercised = 0; - for (const kind of ["move", "move-recreate", "delete-recreate", "save"]) - for (let target = 0; target < labels.length; target++) { - const { f, next, writes } = await setup(); - let count = 0, - injected = false, - from: string | undefined; - const boundary = async (l: string) => { - if (injected) return; - if (count++ !== target) return; - const files = f.disk.userFiles(); - from = files.has("a.md") - ? "a.md" - : files.has("remote.md") - ? "remote.md" - : undefined; - if (kind === "save" && !from) from = "a.md"; - if (!from) return; - injected = true; - exercised++; - if (kind === "save") { - await f.disk.userWrite( - from, - bytes( - Buffer.from( - files.get(from) ?? bytes("ORIGINAL") - ).toString() + " LOCAL" - ) - ); - await f.syncer.syncLocallyUpdatedFile({ - relativePath: from - }); - } else if (kind === "delete-recreate") { - await f.disk.userDelete(from); - await f.syncer.syncLocallyDeletedFile(from); - } else { - await f.disk.userRename(from, "mine.md"); - await f.syncer.syncLocallyUpdatedFile({ - oldPath: from, - relativePath: "mine.md" - }); - } - if (kind !== "move" && kind !== "save") { - await f.disk.userWrite(from, bytes("REPLACEMENT")); - await f.syncer.syncLocallyCreatedFile(from); - } - }; - f.disk.boundary = boundary; - f.persistence.boundary = boundary; - let error; - try { - await f.files.apply(next, writes); - } catch (e) { - error = String(e); - } - f.disk.boundary = () => {}; - f.persistence.boundary = () => {}; - try { - await f.database.recoverPersistence(); - await f.files.recover(); - await f.internals.scan(); - } catch (e) { - error += ";retry=" + String(e); - } - if (!injected) continue; - const fs = Object.fromEntries( - [...f.disk.userFiles()].map(([p, b]) => [ - p, - Buffer.from(b).toString() - ]) - ); - const bad = - kind === "save" - ? ![ - "ORIGINAL LOCAL REMOTE", - "ORIGINAL REMOTE LOCAL" - ].includes(fs[f.database.state.local.a]) || - Object.keys(f.database.state.local).length !== 2 - : kind === "delete-recreate" - ? f.database.state.local.a !== undefined - : f.database.state.local.a !== "mine.md" || - fs["mine.md"] !== "ORIGINAL REMOTE"; - if ( - bad || - (kind !== "move" && - kind !== "save" && - fs[from!] !== "REPLACEMENT") - ) - failures.push({ - kind, - target, - label: labels[target], - error, - ids: f.database.state.local, - files: fs - }); - } - assert( - exercised > 50, - "The sweep must actually exercise the injected actions" - ); - t.diagnostic( - `${labels.length} boundaries; ${exercised} editor interleavings` - ); - assert.deepEqual(failures, []); -}); diff --git a/frontend/test-support/journal-protocol.test.ts b/frontend/test-support/journal-protocol.test.ts deleted file mode 100644 index 568b9688..00000000 --- a/frontend/test-support/journal-protocol.test.ts +++ /dev/null @@ -1,612 +0,0 @@ -import assert from "node:assert/strict"; -import { test } from "node:test"; -import { fixture, bytes, randomUUID } from "./sync-server-fixture"; - -for (const exclusion of ["size", "ignore"] as const) - for (const phase of [ - "planned", - ...(exclusion === "size" ? ["staged", "prepared"] : []), - "installed" - ]) - for (const recovery of [ - "none", - "restart", - "before:save", - "durable:save" - ] as const) - test(`reenabling ${exclusion} exclusion at ${phase} preserves the content base through ${recovery}`, async () => { - const f = await fixture(); - try { - const installed = phase === "installed"; - const id = randomUUID(); - const base = await f.put( - "/documents/" + id, - f.content("BASE\n") - ); - await f.put("/file-manifest", { - requestId: randomUUID(), - parentFileManifestId: 0, - entries: { [id]: "a.md" } - }); - await f.client.start(); - await f.client.setSetting("isSyncEnabled", false); - await f.client.setSettings({ - maxFileSizeMB: - exclusion === "size" ? 100 / (1024 * 1024) : 1, - ignorePatterns: ["private.md"] - }); - const remote = await f.put( - "/documents/" + id, - f.content("REMOTE\nBASE\n", base.vaultUpdateId) - ); - const local = "BASE\n" + "LOCAL\n".repeat(30); - const path = exclusion === "size" ? "a.md" : "private.md"; - let injected = false, - armed = false, - crashed = false; - f.disk.boundary = async (label) => { - const atBoundary = - phase === "installed" - ? label.startsWith("durable:rename:") && - label.endsWith(".output->a.md") - : phase === "staged" - ? label.startsWith("durable:rename:a.md->") - : phase === "prepared" - ? label.startsWith("before:write:") && - label.endsWith(".output") - : label.startsWith( - "before:mkdir:.vault-link-sync/transactions/" - ); - if (injected || !atBoundary) return; - injected = true; - if (exclusion === "ignore") - await f.disk.userRename("a.md", path); - // When installation already completed, deleting REMOTE - // is a subsequent local edit, which must not be undone. - await f.disk.userWrite(path, bytes(local)); - await f.client.syncLocallyUpdatedFile({ - relativePath: path, - ...(exclusion === "ignore" - ? { oldPath: "a.md" } - : {}) - }); - armed = true; - }; - f.store.boundary = (label) => { - if (!armed || crashed || label !== recovery) return; - crashed = true; - f.disk.crash(true); - throw new Error("interrupted exclusion save"); - }; - if (recovery.endsWith(":save")) { - await assert.rejects( - f.client.setSetting("isSyncEnabled", true), - /interrupted exclusion save/ - ); - assert(crashed); - } else await f.client.setSetting("isSyncEnabled", true); - assert(injected); - f.disk.boundary = f.store.boundary = () => {}; - if (recovery !== "none") { - await f.reopenClient(); - await f.client.start(); - } - - assert.deepEqual( - (await f.disk.readSnapshot(path))?.content, - bytes(local) - ); - const stored = f.store.snapshot().database!; - assert.equal( - stored.documents![id].base?.vaultUpdateId, - installed ? remote.vaultUpdateId : base.vaultUpdateId - ); - assert.equal( - stored.remoteHeads![id].vaultUpdateId, - remote.vaultUpdateId - ); - assert.equal( - (await f.get("/documents/" + id)).contentBase64, - Buffer.from("REMOTE\nBASE\n").toString("base64") - ); - await f.client.setSettings({ - maxFileSizeMB: 1, - ignorePatterns: [] - }); - const expected = installed ? local : "REMOTE\n" + local; - const verify = async () => { - assert.deepEqual( - (await f.get("/file-manifest")).entries, - { [id]: path } - ); - assert.deepEqual( - (await f.disk.readSnapshot(path))?.content, - bytes(expected) - ); - assert.equal( - (await f.get("/documents/" + id)).contentBase64, - Buffer.from(expected).toString("base64") - ); - }; - await verify(); - await f.client.reset(); - await verify(); - } finally { - await f.dispose(); - } - }); - -for (const at of [undefined, "before:save", "durable:save"]) - for (const powerLoss of at ? [false, true] : [false]) - test(`repeated saves retain only the latest local edit through ${at ?? "completion"} and powerLoss=${powerLoss}`, async () => { - const f = await fixture(); - try { - const id = randomUUID(); - const base = await f.put("/documents/" + id, f.content("base")); - await f.put("/file-manifest", { - requestId: randomUUID(), - parentFileManifestId: 0, - entries: { [id]: "a.md" } - }); - await f.client.start(); - await f.client.setSetting("isSyncEnabled", false); - await f.put( - "/documents/" + id, - f.content("base REMOTE", base.vaultUpdateId) - ); - let saves = 0, - armed = false, - crashed = false; - f.disk.boundary = async (label) => { - if ( - saves === 0 && - label.startsWith("before:rename:a.md->") - ) { - saves++; - await f.disk.userWrite("a.md", bytes("base ONE")); - await f.client.syncLocallyUpdatedFile({ - relativePath: "a.md" - }); - } else if ( - saves === 1 && - label.startsWith("before:write:") && - label.endsWith(".output") - ) { - saves++; - await f.disk.userWrite("a.md", bytes("base TWO")); - await f.client.syncLocallyUpdatedFile({ - relativePath: "a.md" - }); - armed = true; - } - }; - f.store.boundary = (label) => { - if (!armed || crashed || label !== at) return; - crashed = true; - f.disk.crash(powerLoss); - throw new Error("injected repeated-save crash"); - }; - if (at) { - await assert.rejects( - f.client.setSetting("isSyncEnabled", true), - /injected repeated-save crash/ - ); - assert(crashed); - f.disk.boundary = () => {}; - f.store.boundary = () => {}; - await f.reopenClient(); - await f.client.start(); - } else await f.client.setSetting("isSyncEnabled", true); - assert.equal(saves, 2); - const verify = async () => { - assert.deepEqual((await f.get("/file-manifest")).entries, { - [id]: "a.md" - }); - assert.deepEqual( - (await f.disk.readSnapshot("a.md"))?.content, - bytes("base TWO REMOTE") - ); - assert.equal( - Buffer.from( - (await f.get("/documents/" + id)).contentBase64, - "base64" - ).toString(), - "base TWO REMOTE" - ); - }; - await verify(); - await f.client.reset(); - await verify(); - } finally { - await f.dispose(); - } - }); - -for (const timing of ["before", "durable"]) - for (const crash of [false, true]) - test(`oversized save ${timing} staging stays local across crash=${crash}`, async () => { - const f = await fixture(); - try { - const id = randomUUID(), - large = "PRIVATE".repeat(100); - const base = await f.put( - "/documents/" + id, - f.content("PUBLIC") - ); - await f.put("/file-manifest", { - requestId: randomUUID(), - parentFileManifestId: 0, - entries: { [id]: "a.bin" } - }); - await f.client.start(); - await f.client.setSetting("isSyncEnabled", false); - await f.client.setSetting("maxFileSizeMB", 100 / (1024 * 1024)); - await f.put( - "/documents/" + id, - f.content("REMOTE", base.vaultUpdateId) - ); - let injected = false, - armed = false, - crashed = false; - f.disk.boundary = async (label) => { - if ( - injected || - !label.startsWith( - `${timing}:rename:a.bin->.vault-link-sync/transactions/` - ) - ) - return; - injected = true; - await f.disk.userWrite("a.bin", bytes(large)); - await f.client.syncLocallyUpdatedFile({ - relativePath: "a.bin" - }); - armed = true; - }; - f.store.boundary = (label) => { - if (!crash || !armed || crashed || label !== "durable:save") - return; - crashed = true; - f.disk.crash(true); - throw new Error("injected oversized-save crash"); - }; - if (crash) { - await assert.rejects( - f.client.setSetting("isSyncEnabled", true), - /injected oversized-save crash/ - ); - assert(crashed); - f.disk.boundary = () => {}; - f.store.boundary = () => {}; - await f.reopenClient(); - await f.client.start(); - } else await f.client.setSetting("isSyncEnabled", true); - assert(injected); - const verify = async () => { - assert.deepEqual( - (await f.disk.readSnapshot("a.bin"))?.content, - bytes(large) - ); - assert.deepEqual((await f.get("/file-manifest")).entries, { - [id]: "a.bin" - }); - assert.equal( - Buffer.from( - (await f.get("/documents/" + id)).contentBase64, - "base64" - ).toString(), - "REMOTE" - ); - }; - await verify(); - await f.client.reset(); - await verify(); - } finally { - await f.dispose(); - } - }); - -test("a tracked file moved into an ignored path during remote application never uploads its new bytes", async () => { - const f = await fixture(); - try { - const a = randomUUID(), - b = randomUUID(); - await f.put("/documents/" + a, f.content("PUBLIC")); - await f.put("/documents/" + b, f.content("OTHER")); - const m = await f.put("/file-manifest", { - requestId: randomUUID(), - parentFileManifestId: 0, - entries: { [a]: "a.md", [b]: "b.md" } - }); - await f.client.setSetting("ignorePatterns", ["private.md"]); - await f.client.start(); - await f.client.setSetting("isSyncEnabled", false); - await f.put("/file-manifest", { - requestId: randomUUID(), - parentFileManifestId: m.fileManifestId, - entries: { [a]: "a.md", [b]: "remote-b.md" } - }); - let injected = false; - f.disk.boundary = async (label) => { - if ( - injected || - !label.startsWith("before:mkdir:.vault-link-sync/transactions/") - ) - return; - injected = true; - await f.disk.userRename("a.md", "private.md"); - await f.disk.userWrite("private.md", bytes("SECRET")); - await f.client.syncLocallyUpdatedFile({ - oldPath: "a.md", - relativePath: "private.md" - }); - }; - await f.client.setSetting("isSyncEnabled", true); - assert(injected); - const manifest = await f.get("/file-manifest"); - const contents = await Promise.all( - Object.keys(manifest.entries).map((id) => f.get("/documents/" + id)) - ); - assert( - !contents.some( - (c) => - Buffer.from(c.contentBase64, "base64").toString() === - "SECRET" - ) - ); - assert.deepEqual( - (await f.disk.readSnapshot("private.md"))?.content, - bytes("SECRET") - ); - assert.equal(manifest.entries[a], "a.md"); - } finally { - await f.dispose(); - } -}); -test("remote creation plus unrelated notification must not delete remote membership on retry", async () => { - const f = await fixture(); - try { - await f.client.start(); - await f.client.setSetting("isSyncEnabled", false); - const id = randomUUID(); - await f.put("/documents/" + id, f.content("REMOTE")); - await f.put("/file-manifest", { - requestId: randomUUID(), - parentFileManifestId: 0, - entries: { [id]: "remote.md" } - }); - let injected = false; - f.disk.boundary = async (label) => { - if ( - injected || - !label.startsWith("before:mkdir:.vault-link-sync/transactions/") - ) - return; - injected = true; - await f.disk.userWrite("local.md", bytes("LOCAL")); - await f.client.syncLocallyCreatedFile("local.md"); - }; - await f.client.setSetting("isSyncEnabled", true); - assert(injected); - f.disk.boundary = () => {}; - await f.client.reset(); - const manifest = await f.get("/file-manifest"); - - assert.equal(manifest.entries[id], "remote.md"); - } finally { - await f.dispose(); - } -}); -test("notified ignored occupant during remote rename must never upload its bytes", async () => { - const f = await fixture(); - try { - const id = randomUUID(); - await f.put("/documents/" + id, f.content("REMOTE")); - const m = await f.put("/file-manifest", { - requestId: randomUUID(), - parentFileManifestId: 0, - entries: { [id]: "a.md" } - }); - await f.client.setSetting("ignorePatterns", ["private"]); - await f.client.start(); - await f.client.setSetting("isSyncEnabled", false); - await f.put("/file-manifest", { - requestId: randomUUID(), - parentFileManifestId: m.fileManifestId, - entries: { [id]: "private/note.md" } - }); - let injected = false; - f.disk.boundary = async (label) => { - if ( - injected || - !label.startsWith("before:mkdir:.vault-link-sync/transactions/") - ) - return; - injected = true; - await f.disk.userWrite("private", bytes("SECRET")); - await f.client.syncLocallyCreatedFile("private"); - }; - await f.client.setSetting("isSyncEnabled", true); - f.disk.boundary = () => {}; - const manifest = await f.get("/file-manifest"); - const contents = await Promise.all( - Object.keys(manifest.entries).map((id) => f.get("/documents/" + id)) - ); - - assert(injected); - assert.deepEqual( - (await f.disk.readSnapshot("private"))?.content, - bytes("SECRET") - ); - assert( - !contents.some( - (c) => - Buffer.from(c.contentBase64, "base64").toString() === - "SECRET" - ) - ); - } finally { - await f.dispose(); - } -}); -test("notified delete/recreate before staging retains replacement file", async () => { - const f = await fixture(); - try { - const id = randomUUID(); - const h = await f.put("/documents/" + id, f.content("ORIGINAL")); - const m = await f.put("/file-manifest", { - requestId: randomUUID(), - parentFileManifestId: 0, - entries: { [id]: "a.md" } - }); - await f.client.start(); - await f.client.setSetting("isSyncEnabled", false); - await f.put( - "/documents/" + id, - f.content("ORIGINAL REMOTE", h.vaultUpdateId) - ); - await f.put("/file-manifest", { - requestId: randomUUID(), - parentFileManifestId: m.fileManifestId, - entries: { [id]: "remote.md" } - }); - let injected = false; - f.disk.boundary = async (label) => { - if ( - injected || - !label.startsWith( - "before:rename:a.md->.vault-link-sync/transactions/" - ) - ) - return; - injected = true; - await f.disk.userDelete("a.md"); - await f.client.syncLocallyDeletedFile("a.md"); - await f.disk.userWrite("a.md", bytes("REPLACEMENT")); - await f.client.syncLocallyCreatedFile("a.md"); - }; - await f.client.setSetting("isSyncEnabled", true); - f.disk.boundary = () => {}; - const manifest = await f.get("/file-manifest"); - - assert.deepEqual( - (await f.disk.readSnapshot("a.md"))?.content, - bytes("REPLACEMENT") - ); - } finally { - await f.dispose(); - } -}); - -for (const mode of ["delete", "move", "save"] as const) - for (const powerLoss of [false, true]) - for (const at of ["before:save", "durable:save"]) - test(`${mode} during staging survives ${at} and ${powerLoss ? "power loss" : "process replacement"}`, async () => { - const f = await fixture(); - try { - const id = randomUUID(); - const h = await f.put( - "/documents/" + id, - f.content("ORIGINAL") - ); - await f.put("/file-manifest", { - requestId: randomUUID(), - parentFileManifestId: 0, - entries: { [id]: "a.md" } - }); - await f.client.start(); - await f.client.setSetting("isSyncEnabled", false); - await f.put( - "/documents/" + id, - f.content("ORIGINAL REMOTE", h.vaultUpdateId) - ); - let injected = false, - armed = false, - crashed = false; - f.disk.boundary = async (label) => { - const point = mode === "save" ? "durable" : "before"; - if ( - injected || - !label.startsWith( - `${point}:rename:a.md->.vault-link-sync/transactions/` - ) - ) - return; - injected = true; - if (mode === "save") { - await f.disk.userWrite( - "a.md", - bytes("ORIGINAL LOCAL") - ); - await f.client.syncLocallyUpdatedFile({ - relativePath: "a.md" - }); - } else { - if (mode === "delete") { - await f.disk.userDelete("a.md"); - await f.client.syncLocallyDeletedFile("a.md"); - } else { - await f.disk.userRename("a.md", "mine.md"); - await f.client.syncLocallyUpdatedFile({ - oldPath: "a.md", - relativePath: "mine.md" - }); - } - await f.disk.userWrite( - "a.md", - bytes("REPLACEMENT") - ); - await f.client.syncLocallyCreatedFile("a.md"); - } - armed = true; - }; - f.store.boundary = (label) => { - if (!armed || crashed || label !== at) return; - crashed = true; - f.disk.crash(powerLoss); - throw new Error("injected journal crash"); - }; - await assert.rejects( - f.client.setSetting("isSyncEnabled", true), - /injected journal crash/ - ); - assert(injected && crashed); - f.disk.boundary = () => {}; - f.store.boundary = () => {}; - await f.reopenClient(); - await f.client.start(); - const manifest = await f.get("/file-manifest"); - if (mode === "save") { - assert.deepEqual(manifest.entries, { [id]: "a.md" }); - const actual = Buffer.from( - (await f.disk.readSnapshot("a.md"))!.content - ).toString(); - assert.equal(actual, "ORIGINAL LOCAL REMOTE"); - } else { - assert.deepEqual( - (await f.disk.readSnapshot("a.md"))?.content, - bytes("REPLACEMENT") - ); - assert.notEqual( - Object.keys(manifest.entries).find( - (key) => manifest.entries[key] === "a.md" - ), - id - ); - if (mode === "move") { - assert.equal(manifest.entries[id], "mine.md"); - assert.deepEqual( - (await f.disk.readSnapshot("mine.md"))?.content, - bytes("ORIGINAL REMOTE") - ); - } else assert.equal(manifest.entries[id], undefined); - } - await f.client.reset(); - assert.deepEqual( - (await f.get("/file-manifest")).entries, - manifest.entries - ); - } finally { - await f.dispose(); - } - }); diff --git a/frontend/test-support/oracles.ts b/frontend/test-support/oracles.ts index f50141ef..bc59b971 100644 --- a/frontend/test-support/oracles.ts +++ b/frontend/test-support/oracles.ts @@ -111,16 +111,6 @@ export function assertQuiescent( undefined, "Pending CAS request at convergence" ); - assert.equal( - state.eventReplay, - undefined, - "Unfinished event replay at convergence" - ); - assert.equal( - state.application, - undefined, - "Unfinished filesystem journal at convergence" - ); assert.deepEqual( state.local, entries, diff --git a/frontend/test-support/polling.test.ts b/frontend/test-support/polling.test.ts index 37627b5e..18187dae 100644 --- a/frontend/test-support/polling.test.ts +++ b/frontend/test-support/polling.test.ts @@ -101,7 +101,6 @@ async function fixture(syncIntervalMs: number | undefined) { const persistence = new MemoryPersistence({ database: initial }); const logger = new Logger(); const database = new Database( - logger, initial, async (next) => persistence.save({ database: next }), initial.vaultKey, diff --git a/frontend/test-support/protocol.test.ts b/frontend/test-support/protocol.test.ts index d0652d79..50b0a38d 100644 --- a/frontend/test-support/protocol.test.ts +++ b/frontend/test-support/protocol.test.ts @@ -248,7 +248,6 @@ for (const [kind, format, originalPath] of [ "Restart changed the persisted request" ); assert.equal(restarted.stored.database!.pending, undefined); - assert.equal(restarted.stored.database!.application, undefined); const recovered = new MemoryDisk(); recovered.restore(restarted.disk); assert.deepEqual( diff --git a/frontend/test-support/real-disk.test.ts b/frontend/test-support/real-disk.test.ts index 3b917c81..1819f632 100644 --- a/frontend/test-support/real-disk.test.ts +++ b/frontend/test-support/real-disk.test.ts @@ -8,12 +8,11 @@ import { promisify } from "node:util"; import { RealDisk } from "./real-disk"; import { Database, emptyState } from "../sync-client/src/persistence/database"; import { FileOperations } from "../sync-client/src/file-operations/file-operations"; -import { Logger } from "../sync-client/src/tracing/logger"; import type { ServerConfig } from "../sync-client/src/services/server-config"; import { toStoredSnapshot } from "../sync-client/src/sync-operations/content"; test( - "real filesystem: exclusive operations, unsafe entries, and journal swaps", + "real filesystem: exclusive operations, unsafe entries, and file swaps", { timeout: 20_000 }, async () => { const directory = await fs.mkdtemp( @@ -53,11 +52,9 @@ test( ); await disk.write("a.md", snapshot("A")); await disk.write("b.md", snapshot("B")); - // Recovery must flush the old source's ancestors even if another - // writer has replaced one of those directories with a file. + // A file in an ancestor position makes descendants absent. assert.equal(await disk.stat("a.md/absent.md"), undefined); assert.equal(await disk.readSnapshot("a.md/absent.md"), undefined); - await disk.flushPaths(["a.md/absent.md"]); await assert.rejects(disk.rename("a.md", "b.md")); await assert.rejects(disk.delete("a.md"), /regular file/); await fs.symlink("a.md", path.join(root, "symlink")); @@ -105,7 +102,6 @@ test( ).hash }; const database = new Database( - new Logger(), initial, async () => {}, "real", @@ -129,7 +125,6 @@ test( ).toString(), "A" ); - assert.equal(database.state.application, undefined); } finally { await fs.rm(directory, { recursive: true }); } diff --git a/frontend/test-support/real-disk.ts b/frontend/test-support/real-disk.ts index d3c42639..ffe05c40 100644 --- a/frontend/test-support/real-disk.ts +++ b/frontend/test-support/real-disk.ts @@ -4,7 +4,6 @@ import * as path from "node:path"; import { constants } from "node:fs"; import { execFile } from "node:child_process"; import { promisify } from "node:util"; -import { randomUUID } from "node:crypto"; import type { FileSystemOperations, FileSnapshot } from "sync-client"; const exec = promisify(execFile); @@ -52,17 +51,6 @@ export class RealDisk implements FileSystemOperations { throw error; } } - private async flush(absolute: string): Promise { - const handle = await fs.open( - absolute, - constants.O_RDONLY | constants.O_NOFOLLOW - ); - try { - await handle.sync(); - } finally { - await handle.close(); - } - } public async stat(relative: string) { const info = await this.info(await this.resolve(relative)); return info @@ -140,8 +128,6 @@ export class RealDisk implements FileSystemOperations { throw error; } assert((await fs.lstat(next)).isDirectory()); - await this.flush(next); - await this.flush(current); current = next; } } @@ -154,44 +140,16 @@ export class RealDisk implements FileSystemOperations { "This disk-only fixture has no editor selections" ); const destination = await this.resolve(relative); - const staging = ".vault-link-sync/adapter-staging"; - await this.createDirectory(staging); - const source = path.join(this.root, staging, randomUUID()); - const handle = await fs.open(source, "wx", 0o600); - try { - await handle.writeFile(snapshot.content); - await handle.sync(); - } finally { - await handle.close(); - } - try { - await exec(this.helper, ["rename", source, destination]); - await this.flush(path.dirname(source)); - await this.flush(path.dirname(destination)); - } finally { - await fs.unlink(source).catch((error: NodeJS.ErrnoException) => { - if (error.code !== "ENOENT") throw error; - }); - } + await fs.writeFile(destination, snapshot.content, { + flag: "wx", + mode: 0o600 + }); } public async rename(from: string, to: string): Promise { const source = await this.resolve(from), destination = await this.resolve(to); assert((await this.info(source))?.isFile()); - await this.flush(source); await exec(this.helper, ["rename", source, destination]); - await this.flush(path.dirname(source)); - await this.flush(path.dirname(destination)); - } - public async flushPaths(paths: readonly string[]): Promise { - for (const relative of paths) { - let absolute = await this.resolve(relative); - for (;;) { - if (await this.info(absolute)) await this.flush(absolute); - if (absolute === this.root) break; - absolute = path.dirname(absolute); - } - } } public async delete(relative: string): Promise { assert(relative, "Cannot prune root"); @@ -202,7 +160,6 @@ export class RealDisk implements FileSystemOperations { for (const child of await fs.readdir(absolute)) await this.delete(`${relative}/${child}`); await fs.rmdir(absolute); - await this.flush(path.dirname(absolute)); } public async deleteFile(relative: string): Promise { const absolute = await this.resolve(relative); @@ -210,6 +167,5 @@ export class RealDisk implements FileSystemOperations { if (!entry) return; assert(entry.isFile() && entry.nlink === 1, "Cannot unlink non-file"); await fs.unlink(absolute); - await this.flush(path.dirname(absolute)); } } diff --git a/frontend/test-support/recovery.test.ts b/frontend/test-support/recovery.test.ts index 8da22c4a..25163213 100644 --- a/frontend/test-support/recovery.test.ts +++ b/frontend/test-support/recovery.test.ts @@ -1,6 +1,5 @@ import assert from "node:assert/strict"; import { test } from "node:test"; -import { isDeepStrictEqual } from "node:util"; import { MemoryDisk, MemoryPersistence } from "./storage"; import { Database, @@ -9,7 +8,6 @@ import { } from "../sync-client/src/persistence/database"; import { FileOperations } from "../sync-client/src/file-operations/file-operations"; import { toStoredSnapshot } from "../sync-client/src/sync-operations/content"; -import { Logger } from "../sync-client/src/tracing/logger"; import type { ServerConfig } from "../sync-client/src/services/server-config"; import { mergeFileManifests } from "../sync-client/src/sync-operations/file-manifest"; import { assertManifest, pathDecision } from "./oracles"; @@ -45,7 +43,6 @@ async function fixture( const persistence = new MemoryPersistence({ database: initial }); const restart = (changes: LocalChange[] = []) => { const database = new Database( - new Logger(), persistence.snapshot().database, async (next) => persistence.save({ database: next }), "recovery-test", @@ -85,7 +82,25 @@ async function swapFixture() { ["b.md", bytes("A")], ["nested/c.md", bytes("C")] ]); - return { ...current, next, writes, expected, retained: [] as string[] }; + return { ...current, next, writes, expected }; +} + +async function directoryCycleFixture() { + const current = await fixture({ + a: { path: "a", text: "A" }, + b: { path: "b/deep/child", text: "B" } + }); + const next = structuredClone(current.initial); + next.local = { a: "b", b: "a/deep/child" }; + return { + ...current, + next, + writes: {}, + expected: new Map([ + ["b", bytes("A")], + ["a/deep/child", bytes("B")] + ]) + }; } async function crossDirectoryFixture() { @@ -101,7 +116,7 @@ async function crossDirectoryFixture() { ["dst/a.md", bytes("A")], ["src/keep.md", bytes("untouched sibling")] ]); - return { ...current, next, writes: {}, expected, retained: [] as string[] }; + return { ...current, next, writes: {}, expected }; } async function mergeFixture() { @@ -123,8 +138,7 @@ async function mergeFixture() { "note.md", bytes("local first paragraph\n\nremote last paragraph\n") ] - ]), - retained: [local] + ]) }; } @@ -145,8 +159,7 @@ async function deletionFixture() { }) } }, - expected: new Map([["a.bin", bytes("remote binary\0")]]), - retained: ["private binary edit\0", "unsent deleted bytes"] + expected: new Map([["a.bin", bytes("remote binary\0")]]) }; } @@ -175,19 +188,19 @@ async function permanentDestinationFixture() { expected: new Map([ ["Recovered a.md", bytes("REMOTE")], ["good.md", bytes("B")] - ]), - retained: ["A"] + ]) }; } for (const [name, create] of [ ["swap and create", swapFixture], - ["cross-directory move with surviving sibling", crossDirectoryFixture], - ["merge during recovery", mergeFixture], - ["binary replacement, deletion and retained bytes", deletionFixture], - ["permanent destination replan", permanentDestinationFixture] + ["file/directory cycle", directoryCycleFixture], + ["cross-directory move", crossDirectoryFixture], + ["content merge", mergeFixture], + ["binary replacement and deletion", deletionFixture], + ["permanent destination fallback", permanentDestinationFixture] ] as const) { - test(`${name}: interrupt every apply and replay boundary, then power-cycle again`, async (t) => { + test(`${name}: interrupted operations leave metadata usable by ordinary scans`, async (t) => { const baseline = await create(); const labels: string[] = []; const trace = (label: string) => { @@ -200,172 +213,90 @@ for (const [name, create] of [ baseline.persistence.boundary = trace; await baseline.restart().files.apply(baseline.next, baseline.writes); assert.deepEqual(baseline.disk.userFiles(), baseline.expected); - assert(labels.some((label) => label.startsWith("visible:rename:"))); - assert(labels.includes("durable:save")); - let replayCrashes = 0; - for (const powerLoss of [false, true]) { - for (let index = 0; index < labels.length; index++) { + assert.deepEqual( + baseline.persistence.snapshot().database!.local, + baseline.expectedLocal ?? baseline.next.local + ); + assert(labels.length > 0); + for (const powerLoss of [false, true]) + for (let cut = 0; cut < labels.length; cut++) { await t.test( - `${powerLoss ? "power" : "process"} crash #${index}: ${labels[index]}`, + `${powerLoss ? "power" : "process"} interruption at ${labels[cut]}`, async () => { const current = await create(); - const clearFaults = () => { - current.disk.boundary = current.permanentFault; - current.persistence.boundary = () => {}; - }; - const interrupt = async ( - action: () => Promise, - at: number, - power: boolean - ) => { - let position = 0, - fired = false; - const fault = (label: string) => { - if (label.startsWith("read:")) return; - if (position++ === at) { - fired = true; - current.disk.crash(power); - throw new Error("Injected interruption"); - } - }; - current.disk.boundary = (label) => { - current.permanentFault(label); - fault(label); - }; - current.persistence.boundary = fault; - try { - await assert.rejects( - action(), - /Injected interruption/ - ); - assert(fired, "Fault index did not occur"); - } finally { - clearFaults(); + let seen = 0; + let fired = false; + const interrupt = (label: string) => { + if (label.startsWith("read:")) return; + if (seen++ === cut) { + fired = true; + current.disk.crash(powerLoss); + throw new Error("interrupted"); } }; - const verify = ( - runtime: ReturnType - ) => { - assert.equal( - runtime.database.state.application, - undefined - ); - assert.deepEqual( - runtime.database.state.local, - current.expectedLocal ?? current.next.local - ); - assert.deepEqual( - current.disk.userFiles(), - current.expected, - "Exact files must survive interrupted replay" - ); - const retained = current.disk - .image() - .visible.filter( - ([path, entry]) => - entry && - path.startsWith( - ".vault-link-sync/recovery/" - ) && - path.endsWith(".content") - ) - .map(([, entry]) => - Buffer.from(entry!.content).toString() - ) - .sort(); - assert.deepEqual( - retained, - [...current.retained].sort(), - "Recovery archives lost or duplicated displaced bytes" - ); - assert( - !current.disk - .image() - .visible.some( - ([path, entry]) => - entry && - path.startsWith( - ".vault-link-sync/transactions/" - ) - ), - "Completed recovery left transaction artifacts" - ); - }; - const finish = async (resubmit: boolean) => { - let runtime = current.restart(); - await runtime.files.recover(); - if (resubmit) - await runtime.files.apply( - current.next, - current.writes - ); - await runtime.files.recover(); - verify(runtime); - current.disk.crash(true); - runtime = current.restart(); - await runtime.files.recover(); - verify(runtime); - }; - await interrupt( - () => - current - .restart() - .files.apply(current.next, current.writes), - index, - powerLoss - ); - const saved = current.persistence.snapshot(); - const diskImage = current.disk.image(); - const intentWasNotSaved = isDeepStrictEqual( - saved.database, - current.initial - ); - // Trace replay from the actual interrupted state, including - // recovery-only branches such as already-visible installs. - const replayLabels: string[] = []; - const traceReplay = (label: string) => { - if (!label.startsWith("read:")) - replayLabels.push(label); - }; current.disk.boundary = (label) => { current.permanentFault(label); - traceReplay(label); + interrupt(label); }; - current.persistence.boundary = traceReplay; - await current.restart().files.recover(); - clearFaults(); - await finish(intentWasNotSaved); - for (const secondPowerLoss of [false, true]) { - for ( - let replay = 0; - replay < replayLabels.length; - replay++ - ) { - current.disk.crash(); - current.disk.restore(diskImage); - await current.persistence.save(saved); - try { - await interrupt( - () => current.restart().files.recover(), - replay, - secondPowerLoss - ); - await finish(false); - replayCrashes++; - } catch (error) { - throw new Error( - `Second ${secondPowerLoss ? "power" : "process"} crash #${replay}: ${replayLabels[replay]}`, - { cause: error } - ); - } - } + current.persistence.boundary = interrupt; + await assert.rejects( + current + .restart() + .files.apply(current.next, current.writes), + /interrupted/ + ); + assert(fired); + current.disk.boundary = () => {}; + current.persistence.boundary = () => {}; + // The user may edit after the interruption, including a partial + // write or a file whose metadata was never saved. + await current.disk.userWrite( + "after-restart.md", + bytes("user edit") + ); + const runtime = current.restart(); + const observed = current.disk.userFiles(); + await scanChanges(runtime, []); + assert.deepEqual( + current.disk.userFiles(), + observed, + "A scan must not replay old filesystem intentions" + ); + assertManifest(runtime.database.state.local); + assert.deepEqual( + new Set( + Object.values(runtime.database.state.local) + ), + new Set(observed.keys()) + ); + for (const [id, path] of Object.entries( + runtime.database.state.local + )) { + assert( + runtime.database.state.documents[id] + .materialized + ); + assert.equal( + runtime.database.state.documents[id] + .observedHash, + (await runtime.files.snapshot(path))!.hash + ); } + assert( + (await runtime.files.listFilesRecursively()).every( + (path) => !path.startsWith(".vault-link-sync/") + ) + ); + const state = structuredClone(runtime.database.state); + await scanChanges(current.restart(), []); + assert.deepEqual( + current.persistence.snapshot().database, + state, + "Second startup must be stable" + ); } ); } - } - assert(replayCrashes > 0, "No interrupted recovery was exercised"); - t.diagnostic(`${replayCrashes} second crashes during journal replay`); }); } @@ -647,49 +578,3 @@ test("normalization-sensitive disk does not lose a distinct decomposed filename" "Distinct Unicode file vanished from the scan" ); }); - -test("notified editor move during a remote swap keeps the moved document's UUID", async () => { - const current = await fixture(); - const changes: LocalChange[] = []; - const runtime = current.restart(changes); - let fired = false; - current.persistence.boundary = async (label) => { - if ( - !fired && - label === "durable:save" && - current.persistence.snapshot().database?.application - ) { - fired = true; - await current.disk.userRename("b.md", "editor.md"); - changes.push({ - type: "move", - oldPath: "b.md", - relativePath: "editor.md" - }); - } - }; - const next = structuredClone(runtime.database.state); - next.local = { a: "b.md", b: "a.md" }; - await runtime.files.apply(next); - assert(fired); - await scanLocalFiles({ - next: structuredClone(runtime.database.state), - changes, - files: runtime.files, - guard: () => {}, - ignored: () => false, - oversized: () => false, - commit: (next) => runtime.database.commit(next) - }); - assert.equal( - runtime.database.state.local.b, - "editor.md", - "Editor move was attributed to the wrong document" - ); - assert.equal( - Buffer.from( - current.disk.userFiles().get(runtime.database.state.local.b)! - ).toString(), - "B" - ); -}); diff --git a/frontend/test-support/restart-reconciliation.test.ts b/frontend/test-support/restart-reconciliation.test.ts new file mode 100644 index 00000000..897b3627 --- /dev/null +++ b/frontend/test-support/restart-reconciliation.test.ts @@ -0,0 +1,231 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { fixture, bytes, randomUUID } from "./sync-server-fixture"; +import { assertCanonical } from "./canonical"; +import { SyncClient } from "../sync-client/src/sync-client"; +import { MemoryDisk, MemoryPersistence } from "./storage"; + +for (const powerLoss of [false, true]) + for (const operation of ["create", "replace", "move", "metadata"] as const) + test( + `${operation}: a ${powerLoss ? "power" : "process"} interruption and later user edits converge without replay`, + { timeout: 20_000 }, + async () => { + const f = await fixture(); + let peer: SyncClient | undefined; + try { + const id = randomUUID(); + const head = await f.put( + `/documents/${id}`, + f.content("BASE\n") + ); + const manifest = await f.put("/file-manifest", { + requestId: randomUUID(), + parentFileManifestId: 0, + entries: { [id]: "note.md" } + }); + if (operation !== "create") { + await f.client.start(); + await f.client.setSetting("isSyncEnabled", false); + if (operation === "move") + await f.put("/file-manifest", { + requestId: randomUUID(), + parentFileManifestId: manifest.fileManifestId, + entries: { [id]: "moved.md" } + }); + else + await f.put( + `/documents/${id}`, + f.content("REMOTE\nBASE\n", head.vaultUpdateId) + ); + } + let fired = false; + let wrote = false; + const interrupt = () => { + fired = true; + f.disk.crash(powerLoss); + throw new Error("injected interruption"); + }; + f.disk.boundary = (label) => { + if (fired) return; + if (label === "durable:write:note.md") wrote = true; + if ( + operation === "move" && + label === "visible:rename:note.md->moved.md" + ) + interrupt(); + if ( + (operation === "create" || + operation === "replace") && + label === "visible:write:note.md" + ) + interrupt(); + }; + f.store.boundary = (label) => { + if ( + !fired && + operation === "metadata" && + wrote && + label === "before:save" + ) + interrupt(); + }; + await assert.rejects( + operation === "create" + ? f.client.start() + : f.client.setSetting("isSyncEnabled", true), + /injected interruption/ + ); + assert( + fired, + "The intended filesystem/save boundary must be reached" + ); + f.disk.boundary = () => {}; + f.store.boundary = () => {}; + // Model torn content and further independent edits. Neither is + // recoverable from an old filesystem intention, nor should it be. + await f.disk.userWrite( + operation === "move" ? "moved.md" : "note.md", + bytes("partial\nUSER EDIT\n") + ); + await f.disk.userWrite( + "after.md", + bytes("AFTER INTERRUPTION\n") + ); + await f.reopenClient(); + await f.client.start(); + const inspectable = { + files: () => f.disk.userFiles(), + database: () => f.store.snapshot().database + }; + const peerDisk = new MemoryDisk(); + const peerStore = new MemoryPersistence({ + settings: f.store.snapshot().settings + }); + peer = await SyncClient.create({ + fs: peerDisk, + persistence: peerStore + }); + await peer.start(); + const clients = [ + inspectable, + { + files: () => peerDisk.userFiles(), + database: () => peerStore.snapshot().database + } + ]; + await assertCanonical( + clients, + f.url, + "test-token-change-me" + ); + assert.deepEqual( + f.disk.userFiles().get("after.md"), + bytes("AFTER INTERRUPTION\n") + ); + assert( + (await f.disk.listFilesRecursively()).every( + (path) => !path.startsWith(".vault-link-sync/") + ) + ); + const files = f.disk.userFiles(); + await f.reopenClient(); + await f.client.start(); + await assertCanonical( + clients, + f.url, + "test-token-change-me" + ); + assert.deepEqual( + f.disk.userFiles(), + files, + "A second restart must not replay old writes" + ); + } finally { + f.disk.boundary = () => {}; + f.store.boundary = () => {}; + await peer?.destroy(); + await f.dispose(); + } + } + ); + +for (const operation of ["create", "replace", "move"] as const) + test( + `${operation}: metadata can survive while completed user-file writes are lost`, + { timeout: 20_000 }, + async () => { + const f = await fixture(); + let peer: SyncClient | undefined; + try { + const id = randomUUID(); + const head = await f.put( + `/documents/${id}`, + f.content("BASE\n") + ); + const manifest = await f.put("/file-manifest", { + requestId: randomUUID(), + parentFileManifestId: 0, + entries: { [id]: "note.md" } + }); + if (operation !== "create") { + await f.client.start(); + await f.client.setSetting("isSyncEnabled", false); + if (operation === "replace") + await f.put( + `/documents/${id}`, + f.content("REMOTE\nBASE\n", head.vaultUpdateId) + ); + else + await f.put("/file-manifest", { + requestId: randomUUID(), + parentFileManifestId: manifest.fileManifestId, + entries: { [id]: "moved.md" } + }); + } + const oldDisk = f.disk.image(); + if (operation === "create") await f.client.start(); + else await f.client.setSetting("isSyncEnabled", true); + await f.client.destroy(); + // Metadata is newer than disk: completed filesystem calls have no + // flush contract. This is not covered by failing a call mid-flight. + f.disk.restore(oldDisk); + await f.disk.userWrite( + "after.md", + bytes("USER AFTER POWER LOSS\n") + ); + await f.reopenClient(); + await f.client.start(); + const peerDisk = new MemoryDisk(); + const peerStore = new MemoryPersistence({ + settings: f.store.snapshot().settings + }); + peer = await SyncClient.create({ + fs: peerDisk, + persistence: peerStore + }); + await peer.start(); + await assertCanonical( + [ + { + files: () => f.disk.userFiles(), + database: () => f.store.snapshot().database + }, + { + files: () => peerDisk.userFiles(), + database: () => peerStore.snapshot().database + } + ], + f.url, + "test-token-change-me" + ); + assert.deepEqual( + f.disk.userFiles().get("after.md"), + bytes("USER AFTER POWER LOSS\n") + ); + } finally { + await peer?.destroy(); + await f.dispose(); + } + } + ); diff --git a/frontend/test-support/storage.ts b/frontend/test-support/storage.ts index 816dc122..09e339a3 100644 --- a/frontend/test-support/storage.ts +++ b/frontend/test-support/storage.ts @@ -216,13 +216,6 @@ export class MemoryDisk implements FileSystemOperations { }); } - public async flushPaths(paths: readonly string[]): Promise { - const affected = new Set(paths); - for (const path of paths) - for (let p = parent(path); p; p = parent(p)) affected.add(p); - await this.change(`flush:${paths.join(",")}`, [...affected], () => {}); - } - public async delete(path: string): Promise { this.check(path); assert(path !== "", "Cannot remove vault root"); diff --git a/frontend/test-support/sync-audit.test.ts b/frontend/test-support/sync-audit.test.ts index ae842a83..d3c49e0c 100644 --- a/frontend/test-support/sync-audit.test.ts +++ b/frontend/test-support/sync-audit.test.ts @@ -1,4 +1,5 @@ import assert from "node:assert/strict"; +import { SUPPORTED_API_VERSION } from "../sync-client/src/consts"; import { PermanentSyncError } from "../sync-client/src/errors/errors"; import { test } from "node:test"; import type { Database } from "../sync-client/src/persistence/database"; @@ -221,52 +222,6 @@ test("local undo of accepted-but-rejected manifest must survive replay", async ( assert.equal(f.database.state.local.a, "a.md"); }); -test("disabled startup should recover an active journal before returning", async () => { - const disk = new MemoryDisk(); - const key = JSON.stringify(["http://offline.test", "test"]); - const state = emptyState(key); - state.initialized = true; - state.local = { a: "a.md" }; - state.documents = { a: { materialized: true } }; - const next = structuredClone(state); - next.local = { a: "b.md" }; - const prefix = ".vault-link-sync/transactions/active/a"; - await disk.userWrite(prefix + ".source", bytes("ONLY COPY")); - state.application = { - id: "active", - extensions: ["md"], - next, - steps: [ - { - documentId: "a", - from: "a.md", - to: "b.md", - staged: prefix + ".source", - output: prefix + ".output", - phase: "staged" - } - ] - }; - const store = new MemoryPersistence({ - settings: { - remoteUri: "http://offline.test", - vaultName: "test", - isSyncEnabled: false - }, - database: state - }); - const client = await SyncClient.create({ fs: disk, persistence: store }); - try { - await client.start(); - assert.deepEqual( - (await disk.readSnapshot("b.md"))?.content, - bytes("ONLY COPY") - ); - } finally { - await client.destroy(); - } -}); - test("a settings update resumes the enabled engine", async () => { const f = await settingsLifecycleFixture(); try { @@ -381,7 +336,7 @@ test("silent open WebSocket must eventually trigger catchup or reconnect", async const path = String(input); if (path.endsWith("/ping")) return Response.json({ - supportedApiVersion: 5, + supportedApiVersion: SUPPORTED_API_VERSION, isAuthenticated: true, mergeableFileExtensions: ["md"], serverVersion: "test" @@ -436,7 +391,6 @@ test("permanent destination failure replans the affected file and finishes the b next.fileManifest = { fileManifestId: 2, entries: { ...next.local } }; await f.files.apply(next); await f.settings.setSettings({ ignorePatterns: ["unsupported/**"] }); - await f.files.recover(); assert.deepEqual( [...f.disk.userFiles().values()] .map((b) => Buffer.from(b).toString()) @@ -469,63 +423,85 @@ test("conflict allocation terminates when a truncated extension ends in a space" assert.equal(result.status, 0, String(result.error ?? result.stderr)); }); -test("paged catchup folds remote edits and reversions before touching local bytes", async () => { - const f = await fixture( - { "a.md": "base LOCAL" }, - { getDocumentVersionContent: async () => bytes("base") } - ); - f.database.state.documents.a.base = { - ...head("a", 1, "base"), - hash: (await toStoredSnapshot({ content: bytes("base") })).hash - }; - f.database.state.remoteHeads.a = head("a", 1, "base"); - f.database.state.lastSeenUpdateId = 1; - const first = { - headEventId: 3, - endEventId: 2, - events: [ - { - eventId: 2, - requestId: "remote-1", - type: "content" as const, - document: head("a", 2, "base REMOTE") +for (const interrupted of [false, true]) { + test(`paged catchup folds edits and reversions${interrupted ? " again after an interrupted fetch" : ""}`, async () => { + const first = { + headEventId: 3, + endEventId: 2, + events: [ + { + eventId: 2, + requestId: "remote-1", + type: "content" as const, + document: head("a", 2, "base REMOTE") + } + ] + }; + const last = { + headEventId: 3, + endEventId: 3, + events: [ + { + eventId: 3, + requestId: "remote-2", + type: "content" as const, + document: head("a", 3, "base") + } + ] + }; + let fail = interrupted; + let f: Awaited>; + const fetchedAfter: number[] = []; + const service: Partial = { + getDocumentVersionContent: async () => bytes("base"), + events: async (after) => { + fetchedAfter.push(after); + if (after === 1) return first; + assert.equal(after, 2); + assert.equal(f.database.state.lastSeenUpdateId, 1); + assert.deepEqual( + f.persistence.snapshot(), + saved, + "partial pages must not save metadata" + ); + assert.deepEqual( + (await f.disk.readSnapshot("a.md"))?.content, + bytes("base LOCAL") + ); + if (fail) { + fail = false; + throw new Error("interrupted page fetch"); + } + return last; } - ] - }; - await f.internals.incorporateEventBatch(first); - assert.equal( - f.database.state.lastSeenUpdateId, - 1, - "partial history must not become an incorporated base" - ); - assert.deepEqual( - (await f.disk.readSnapshot("a.md"))?.content, - bytes("base LOCAL") - ); - assert.equal( - await f.internals.preparePush(), - false, - "do not submit against a partially replayed history" - ); - const last = { - headEventId: 3, - endEventId: 3, - events: [ - { - eventId: 3, - requestId: "remote-2", - type: "content" as const, - document: head("a", 3, "base") - } - ] - }; - await f.internals.incorporateEventBatch(last); - assert.equal(f.database.state.lastSeenUpdateId, 3); - assert.deepEqual( - (await f.disk.readSnapshot("a.md"))?.content, - bytes("base LOCAL") - ); -}); + }; + f = await fixture({ "a.md": "base LOCAL" }, service); + f.database.state.documents.a.base = { + ...head("a", 1, "base"), + hash: (await toStoredSnapshot({ content: bytes("base") })).hash + }; + f.database.state.remoteHeads.a = head("a", 1, "base"); + f.database.state.lastSeenUpdateId = 1; + await f.database.save(); + const saved = f.persistence.snapshot(); + const catchUp = async () => + f.internals.incorporateEventBatch( + await service.events!(f.database.state.lastSeenUpdateId) + ); + if (interrupted) { + await assert.rejects(catchUp(), /interrupted page fetch/); + f.disk.crash(true); + f = await fixture({}, service, f); + } + await catchUp(); + assert.deepEqual(fetchedAfter, interrupted ? [1, 2, 1, 2] : [1, 2]); + assert.equal(f.database.state.lastSeenUpdateId, 3); + assert.deepEqual( + (await f.disk.readSnapshot("a.md"))?.content, + bytes("base LOCAL") + ); + }); +} test("an ignored occupant arriving after the scan keeps its path and privacy", async () => { let f: Awaited>; @@ -580,6 +556,10 @@ test("content notifications during every snapshot do not starve a complete names for (const boundary of ["before:save", "durable:save"]) { test(`receipt incorporation after paged replay survives ${boundary} and process replacement`, async () => { const service: Partial = { + events: async (after) => { + assert.equal(after, 2); + return page; + }, putFileContent: async () => { throw new PermanentSyncError("HTTP 413"); }, @@ -604,18 +584,18 @@ for (const boundary of ["before:save", "durable:save"]) { const { requestId } = f.database.state.pending!.request; await f.internals.finishPending(); await f.disk.userWrite("a.md", bytes("base")); - await f.internals.incorporateEventBatch({ + const first = { headEventId: 3, endEventId: 2, events: [ { eventId: 2, requestId, - type: "content", + type: "content" as const, document: head("a", 2, "base A") } ] - }); + }; const page = { headEventId: 3, endEventId: 3, @@ -632,16 +612,14 @@ for (const boundary of ["before:save", "durable:save"]) { if (label === boundary) throw new Error("injected crash"); }; await assert.rejects( - f.internals.incorporateEventBatch(page), + f.internals.incorporateEventBatch(first), /injected crash/ ); f.persistence.boundary = () => {}; f.disk.crash(true); f = await fixture({}, service, f); - await f.files.recover(); - assert.equal(f.database.state.eventReplay?.after, 2); - await f.internals.incorporateEventBatch(page); - assert.equal(f.database.state.eventReplay, undefined); + assert.equal(f.database.state.lastSeenUpdateId, 1); + await f.internals.incorporateEventBatch(first); assert.equal(f.database.state.lastSeenUpdateId, 3); assert.deepEqual( (await f.disk.readSnapshot("a.md"))?.content, @@ -676,7 +654,7 @@ test("initial vault settings bind the engine and survive restart", async () => { fetch: async (input: RequestInfo | URL) => { if (String(input).endsWith("/ping")) return Response.json({ - supportedApiVersion: 5, + supportedApiVersion: SUPPORTED_API_VERSION, isAuthenticated: true, mergeableFileExtensions: ["md"], serverVersion: "test" @@ -804,7 +782,7 @@ async function settingsLifecycleFixture() { const path = String(input); if (path.endsWith("/ping")) return Response.json({ - supportedApiVersion: 5, + supportedApiVersion: SUPPORTED_API_VERSION, isAuthenticated: true, mergeableFileExtensions: ["md"], serverVersion: "test" diff --git a/frontend/test-support/sync-fixture.ts b/frontend/test-support/sync-fixture.ts index da54142a..30bf6a85 100644 --- a/frontend/test-support/sync-fixture.ts +++ b/frontend/test-support/sync-fixture.ts @@ -59,7 +59,6 @@ export async function fixture( recovered?.persistence ?? new MemoryPersistence({ database: initial }); const logger = new Logger(); const database = new Database( - logger, initial, async (next) => persistence.save({ database: next }), "test",