claude
This commit is contained in:
parent
39c5591d36
commit
35877b69da
94 changed files with 3149 additions and 1851 deletions
155
CLAUDE.md
155
CLAUDE.md
|
|
@ -90,150 +90,61 @@ New migrations: `sqlx migrate add --source src/app_state/database/migrations <na
|
|||
|
||||
Read `frontend/sync-client/src/sync-operations/` to follow the sync engine; the rest of `sync-client` is plumbing (filesystem ops, persistence, services, telemetry).
|
||||
|
||||
**`SyncEventQueue`** (`sync-event-queue.ts`) holds two things:
|
||||
The engine is **two independent loops with separate invariants**:
|
||||
|
||||
- `documents: Map<RelativePath, DocumentRecord>` — the local "settled" view of tracked docs.
|
||||
- `events: SyncEvent[]` — pending operations (creates, updates, deletes, remote changes) in FIFO drain order.
|
||||
- **Wire loop** (`syncer.ts`) — drains the single-consumer FIFO queue. HTTP and WS handlers update record fields (`remoteRelativePath`, `parentVersionId`, `remoteHash`) and write content to the file at `record.localPath`. They never move files for path placement.
|
||||
- **Path reconciler** (`reconciler.ts`) — runs after every drained event. Best-effort pass that moves files to make `localPath === remoteRelativePath`. The move graph is topologically sorted; cycles are resolved by reading every file in the cycle into memory and writing each back to its new slot (no tmp files). Records with pending local events are skipped on each pass — the reconciler operates only on settled records. Failures (slot occupied by an untracked file, etc.) are silent skips; the next pass retries.
|
||||
|
||||
The map is keyed by `record.path`; the invariant `documents.get(record.path) === record` is maintained by every mutation point (constructor, `setDocument`, the rename branch in `enqueue`). `setDocument` mutates the same record object in place when relocating, so callers holding a reference to the record see path changes on the next read — this is load-bearing for `Syncer`'s drain handlers, which await across HTTP roundtrips and would otherwise see a captured-string-stale path. Always read `record.path` live; only snapshot it into a local for the explicit "did the path change during my await" comparison (`pathBeforeRoundtrip` in `handleMaybeMergingResponse` / `processRemoteUpdate`).
|
||||
**`SyncEventQueue`** (`sync-event-queue.ts`) holds:
|
||||
|
||||
**`Syncer`** (`syncer.ts`) drains events one at a time. Local creates/updates/deletes round-trip to the server over HTTP; remote changes arrive over the WebSocket and are enqueued as `RemoteChange` events that the same drain processes. `handleMaybeMergingResponse` is the shared response handler for create-and-update flows.
|
||||
- `byDocId: Map<DocumentId, DocumentRecord>` — primary record store.
|
||||
- `byLocalPath: Map<RelativePath, DocumentRecord>` — derived index for path lookups, maintained at every mutation point.
|
||||
- `events: SyncEvent[]` — pending wire ops in FIFO drain order.
|
||||
|
||||
**Conflict-uuid paths.** When a remote create or remote-rename can't claim its server-side path locally (the slot is occupied), the local file lands at `conflict-<uuid>-<original>` and `record.intendedPath` records the path the server has it at. All server-bound requests honor `intendedPath`/`event.originalPath`, so the conflict-uuid path never leaks to the server. There is no automatic unwinding — convergence at conflict points is left to manual user resolution.
|
||||
```ts
|
||||
DocumentRecord = {
|
||||
documentId,
|
||||
parentVersionId,
|
||||
remoteHash?,
|
||||
remoteRelativePath,
|
||||
localPath: RelativePath | undefined
|
||||
}
|
||||
```
|
||||
|
||||
`localPath === undefined` means the doc has no local file yet — typically a remote create whose target slot was occupied at receive time; the reconciler will fetch and place when the slot frees (the bytes wait in `pendingPlacementContent`).
|
||||
|
||||
Local FS events from the watcher update `localPath` synchronously at enqueue time via `setLocalPath` / `upsertRecord`. The wire loop never updates it for path placement; only the reconciler does. A user rename onto a tracked slot enqueues a `LocalDelete` for the displaced doc (the OS rename clobbered its content) and clears that doc's `localPath`.
|
||||
|
||||
**Pending creates** use a `Promise<DocumentId>` chain to serialize dependent ops (`LocalUpdate`, `LocalDelete`) behind the still-in-flight `LocalCreate`. `resolveCreate` resolves the promise once the server returns a docId, and `replacePendingDocumentId` swaps the resolved id across already-queued events. `findLatestCreateForPath` is the lookup the watcher uses to attach dependents; `updatePendingCreatePath` rewrites a pending create's `event.path` in place when the user renames the file before its create has acked.
|
||||
|
||||
**Watermark.** `lastSeenUpdateId` uses a `MinCovered` (a contiguous-prefix tracker over a stream of integers): we only advance the published min when the next consecutive id has been processed, so out-of-order RemoteChange ids don't fool the WebSocket handshake into requesting a too-recent catch-up.
|
||||
|
||||
**Server catch-up.** The server's WS handshake replays events newer than the client's `last_seen_vault_update_id` from the `latest_document_versions` view (one row per doc, the latest). On those replayed rows `is_new_file` means *new to this client* (`creation_vault_update_id > last_seen_vault_update_id`), not "this row is the doc's first version" — necessary because the catch-up only carries the latest version; if a doc was created and updated past the watermark, the client never sees its create otherwise.
|
||||
**Server catch-up.** The server's WS handshake replays events newer than the client's `last_seen_vault_update_id` from the `latest_document_versions` view (one row per doc, the latest). On those replayed rows `is_new_file` means _new to this client_ (`creation_vault_update_id > last_seen_vault_update_id`), not "this row is the doc's first version" — necessary because the catch-up only carries the latest version; if a doc was created and updated past the watermark, the client never sees its create otherwise.
|
||||
|
||||
## Edge-case patterns the sync engine has to survive
|
||||
|
||||
These are non-obvious from reading any single file; they fall out of the
|
||||
interaction between the queue, the watcher, the WebSocket, and the
|
||||
server's commit ordering. Treat the engine as a black box and what
|
||||
follows is the kinds of bugs you should expect to see:
|
||||
The two-loop split defuses most of the old race catalogue (slot-collision stashes, conflict-uuid divergence, `MoveOnConflict.NEW`/`EXISTING` policy choices) by separating wire transport from path placement. What's left:
|
||||
|
||||
**FIFO drain order ≠ user's perceived order.** The queue is single-consumer
|
||||
and FIFO at processing time, but the producers are concurrent and async
|
||||
indirected: user FS actions go through watcher → microtask → enqueue
|
||||
(several microtasks deep), while WS messages go through the onmessage
|
||||
handler. A WS-driven event can land in the queue *between* two user
|
||||
actions even when the user "did them in order". When you read a log,
|
||||
"Decided to ..." timestamps mark the user's intent; they do **not** map
|
||||
to the order of `events.push`.
|
||||
**Pending-create docId is a `Promise`, not a string, until the create acks.** Any `LocalUpdate` / `LocalDelete` queued behind a still-in-flight `LocalCreate` carries the create's `resolvers.promise` as its `documentId`. `replacePendingDocumentId` swaps the resolved id across queued events when the create resolves; `===` comparisons against the resolved string elsewhere will silently fail until that swap runs. Anything that walks `events[]` looking for a docId match must either run after the swap or be tolerant of `Promise`-typed ids.
|
||||
|
||||
**`event.path` is a side channel through disk.** Drain serialises which
|
||||
event runs, but it can't lock disk between events. Between an event's
|
||||
enqueue and its drain, another in-band event can have rewritten the
|
||||
file at that path (a remote-create that landed on the slot, a delete +
|
||||
re-create cycle by the user). Reading at drain time gets *current* disk
|
||||
content — which may be a different doc's bytes — and uploading them as
|
||||
the queued event's content is a duplicate-create / wrong-content bug.
|
||||
**`processCreate` reads `event.path` live, not `event.originalPath`.** The watcher rewrites `event.path` in place via `updatePendingCreatePath` when the user renames a pending-create file. `originalPath` was removed from `LocalCreate` events specifically because reading it would send the stale pre-rename path to the server.
|
||||
|
||||
**Pending-create docId is a `Promise`, not a string, until the create
|
||||
acks.** Any event queued behind a still-in-flight LocalCreate that
|
||||
references the same doc carries the create's `resolvers.promise` as its
|
||||
`documentId`. Two consequences: (a) `===` comparisons against the
|
||||
resolved string in any rewrite loop silently fail; (b) the order of
|
||||
"swap Promise→docId" vs "rewrite paths in events" matters — swap first
|
||||
or the rewrite walks past the events you wanted to retarget. This is
|
||||
load-bearing in any code that touches the queue right after a create
|
||||
resolves.
|
||||
**`record.localPath` mutates in place across awaits.** When the watcher renames a doc while a drain handler is awaiting an HTTP roundtrip, the queue mutates the in-flight event's record so subsequent reads see the new path. Snapshotting `record.localPath` into a local at function entry and using it after an `await` reads/writes a now-vacated slot. Read `record.localPath` live; only snapshot for the deliberate "did it change while I was awaiting" comparison.
|
||||
|
||||
**`record.path` is mutated in place across awaits.** When a user rename
|
||||
runs while a drain handler is awaiting an HTTP roundtrip, the queue
|
||||
mutates the in-flight event's record so subsequent reads see the new
|
||||
path. Snapshotting `record.path` into a local at function entry and
|
||||
using it after an `await` writes/reads from a now-vacated slot.
|
||||
Snapshot only for the *deliberate* "did the path change while I was
|
||||
awaiting" comparison; everywhere else, read `record.path` live.
|
||||
**Reconciler-defer is the wire-loop's contract with the reconciler.** The reconciler skips records where `hasPendingLocalEventsForDocumentId` returns true. Wire-loop handlers can therefore freely write `remoteRelativePath` to whatever the server returned — even if it disagrees with `localPath` — knowing the reconciler won't move the file out from under a queued user rename.
|
||||
|
||||
**Conflict-uuid stashes are local-only divergence.** Whenever a slot
|
||||
collision deflects a doc to `conflict-<uuid>-…`, only the agent that
|
||||
deflected has that file. The cross-agent fuzz assertion ("every path
|
||||
matches across clients") will fire on it. By design these are awaiting
|
||||
manual user resolution — but if your fix silently creates one in a
|
||||
race that *would* converge given more time, the e2e fuzz will show it.
|
||||
**Watermark advancement is load-bearing both ways.** Branches that _skip_ a remote event without advancing `lastSeenUpdateId` create permanent gaps that re-deliver forever. Branches that _advance_ without applying the content lose data: the server has no further event to re-deliver, the catch-up only carries the latest version, and any state in between is gone. Don't advance unless the event was actually applied (or deliberately discarded after weighing both halves).
|
||||
|
||||
**`MoveOnConflict.NEW` vs `EXISTING` is a policy choice, not a default.**
|
||||
NEW preserves the occupant and stashes us at conflict-uuid; EXISTING
|
||||
evicts the occupant and stashes *them*. Picking wrong creates either an
|
||||
orphaned stash on us or an orphaned tracking entry on the occupant.
|
||||
The right choice depends on whether the occupant is tracked, whether
|
||||
they have a pending RemoteChange that will move them, and which side
|
||||
the server has already committed to.
|
||||
**`isNewFile` semantics differ between catch-up and real-time.** On WS handshake replay it means _new to this client_ (`creation_vault_update_id > last_seen_vault_update_id`); on real-time broadcasts it means _this version is the create_ (`creation_vault_update_id == vault_update_id`). A handler that decides based on one interpretation will be wrong on the other channel; reasoning about fetch-and-treat-as-new vs. ignore needs to know which channel delivered the event.
|
||||
|
||||
**Pause / disable-sync mid-flight is a destabiliser.** A request whose
|
||||
HTTP committed server-side but whose response was discarded by an abort
|
||||
leaves the server holding a doc the client has no record of. The next
|
||||
re-enable's offline scan re-derives state from disk vs. the (now
|
||||
incomplete) `documents` map and emits a fresh LocalCreate — a duplicate
|
||||
of a doc already on the server, with a new docId. The catch-up then
|
||||
delivers the orphan as a "new" doc and writes it to disk. Final state:
|
||||
two files, two docIds, same content. Anything that aborts in-flight
|
||||
HTTPs (start-reset, vault change, destroy) needs the queue's documents
|
||||
map to be wiped or rebuilt from the server, not just the events array.
|
||||
**Pause / disable-sync mid-flight** is the one race the new model doesn't structurally fix. An HTTP that committed server-side but whose response was discarded leaves the server holding a doc the client has no record of. Resume → offline scan → server-side dedupe handles it (the server merges the duplicate create into the existing doc), but if the merge produces a deconflict, the client picks up an extra file. Out of scope for the two-loop split.
|
||||
|
||||
**`scheduleSyncForOfflineChanges` clears `events[]` but not `documents`.**
|
||||
Every enable-sync wipes pending local events. The offline scan
|
||||
re-derives them by comparing disk to the documents map (matching by
|
||||
content hash to recognise renames). This is correct *if* the documents
|
||||
map reflects the last server state we committed to. If it lags (an
|
||||
in-flight create whose response we lost; a remote update we haven't
|
||||
applied yet), the scan misclassifies — a real rename becomes a delete
|
||||
+ create with a new docId; a still-tracked doc whose file we deleted
|
||||
becomes a delete the server hasn't seen.
|
||||
|
||||
**Watermark advancement is load-bearing both ways.** Branches that *skip*
|
||||
a remote event without advancing `lastSeenUpdateId` create permanent
|
||||
gaps that re-deliver forever. Branches that *advance* the watermark
|
||||
without applying the content lose data — the server has no further
|
||||
event to re-deliver, the catch-up only carries the latest version, and
|
||||
any state in between is gone. When in doubt: don't advance unless the
|
||||
event was actually applied (or deliberately discarded after weighing
|
||||
both halves).
|
||||
|
||||
**`isNewFile` semantics differ between catch-up and real-time.** On WS
|
||||
handshake replay it means *new to this client* (`creation_vault_update_id
|
||||
> last_seen_vault_update_id`); on real-time broadcasts it means *this
|
||||
version is the create* (`creation_vault_update_id == vault_update_id`).
|
||||
A handler that receives "untracked doc + isNewFile=false" and decides
|
||||
based on one of the two interpretations will be wrong on the other
|
||||
channel. Reasoning about whether to fetch-and-treat-as-new vs. ignore
|
||||
needs to know which channel delivered the event.
|
||||
|
||||
**Race-shape catalogue.** Bugs in this codebase tend to fall into a
|
||||
small set of shapes; recognising the shape from the log gets you most
|
||||
of the way to the cause:
|
||||
|
||||
- *Same-path dedup race*: two clients create at the same path. Server
|
||||
deconflicts the second to `path (1)`. The losing client must
|
||||
relocate locally; mishandling routes the local file to a stash.
|
||||
- *Concurrent rename of same doc*: both clients rename. Server
|
||||
applies in commit order; the loser's local-rename HTTP must rebase
|
||||
against the server's new path or be dropped.
|
||||
- *Local rename + remote rename of same doc*: the local rename's HTTP
|
||||
needs to find the doc at the (now-different) server path; the
|
||||
matching disk file needs to follow without stranding.
|
||||
- *Pending create + remote create at same path*: the agent's pending
|
||||
file is already at the slot the remote wants; the remote's pending
|
||||
bytes will reach the slot the agent is trying to upload from.
|
||||
- *Create + delete + remote create at same path*: the user's local
|
||||
cycle queues two events; a remote create lands in between. The
|
||||
queued LocalCreate (or a re-emitted offline-scan one) reads disk
|
||||
content placed by the remote and uploads it as a third doc.
|
||||
- *Pause-mid-flight*: in-flight HTTP committed server-side, response
|
||||
abandoned client-side. After re-enable the offline scan can't tell
|
||||
the doc was already created and creates a duplicate.
|
||||
|
||||
When triaging a fuzz failure, find the divergent file in `e2e-run.log`'s
|
||||
final dump (it shows each agent's tracked docs), grep the `log_<i>.log`
|
||||
for that path/docId, and match the lifecycle against this catalogue
|
||||
before going deeper.
|
||||
**Cycle reconciliation uses in-memory content swap.** When the move graph contains a cycle, the reconciler reads every file in the cycle into memory and writes each back to its new slot, with no tmp files. A write-ahead marker at `.vaultlink/swap-<uuid>.json` lists each leg; on startup the reconciler reads the marker, hashes each `from` to determine which legs ran, and replays the rest. The `.vaultlink/**` glob is hard-coded as an internal ignore pattern so swap markers don't get sync'd.
|
||||
|
||||
## Two complementary E2E harnesses
|
||||
|
||||
- **`test-client` (fuzz):** random ops across N parallel processes for many minutes. Used by `scripts/e2e.sh`. Catches bugs nobody thought to write a test for, but reproductions are noisy.
|
||||
- **`deterministic-tests`:** scripted scenarios with an in-memory FS pinned to a real server. Used to *capture* a fuzz-discovered bug as a minimal repro before fixing it. See `frontend/deterministic-tests/README.md` for the step grammar (`pause-server`, `pause-websocket`, `barrier`, `assert-consistent`, etc.).
|
||||
- **`deterministic-tests`:** scripted scenarios with an in-memory FS pinned to a real server. Used to _capture_ a fuzz-discovered bug as a minimal repro before fixing it. See `frontend/deterministic-tests/README.md` for the step grammar (`pause-server`, `pause-websocket`, `barrier`, `assert-consistent`, etc.).
|
||||
|
||||
When a fuzz failure surfaces, the workflow is: root-cause from logs → write a deterministic test that fails on the bug → fix → confirm both the deterministic test and `e2e.sh` pass.
|
||||
|
||||
|
|
|
|||
|
|
@ -8,7 +8,9 @@ export function parseConcurrency(): number {
|
|||
i + 1 < args.length
|
||||
) {
|
||||
const n = parseInt(args[i + 1], 10);
|
||||
if (!isNaN(n) && n > 0) {return n;}
|
||||
if (!isNaN(n) && n > 0) {
|
||||
return n;
|
||||
}
|
||||
}
|
||||
}
|
||||
return os.cpus().length;
|
||||
|
|
|
|||
|
|
@ -19,7 +19,9 @@ export class ServerManager {
|
|||
}
|
||||
|
||||
public async stopAll(): Promise<void> {
|
||||
if (this.isShuttingDown) {return;}
|
||||
if (this.isShuttingDown) {
|
||||
return;
|
||||
}
|
||||
this.isShuttingDown = true;
|
||||
|
||||
const servers = Array.from(this.activeServers);
|
||||
|
|
|
|||
|
|
@ -92,14 +92,12 @@ import { displacedFileNotMarkedDeletedTest } from "./tests/displaced-file-not-ma
|
|||
import { remoteUpdateResurrectsDeletedDocTest } from "./tests/remote-update-resurrects-deleted-doc.test";
|
||||
import { localUpdateSurvivesRemoteRenameTest } from "./tests/local-update-survives-remote-rename.test";
|
||||
import { mergingUpdateResponseSurvivesUserRenameTest } from "./tests/merging-update-response-survives-user-rename.test";
|
||||
import { conflictUuidStashClearedAfterRenameDeconflictTest } from "./tests/conflict-uuid-stash-cleared-after-rename-deconflict.test";
|
||||
import { catchupCreateAndUpdateNotSkippedTest } from "./tests/catchup-create-and-update-not-skipped.test";
|
||||
import { localRenameSurvivesRemoteRenameTest } from "./tests/local-rename-survives-remote-rename.test";
|
||||
import { renameChainDuringPendingCreateTest } from "./tests/rename-chain-during-pending-create.test";
|
||||
import { remoteRenameCollidesWithPendingLocalCreateTest } from "./tests/remote-rename-collides-with-pending-local-create.test";
|
||||
import { remoteUpdateSurvivesUserRenameTest } from "./tests/remote-update-survives-user-rename.test";
|
||||
import { orphanStashOnCreateMergeTest } from "./tests/orphan-stash-on-create-merge.test";
|
||||
import { orphanStashOnCreateDedupeMergeTest } from "./tests/orphan-stash-on-create-dedupe-merge.test";
|
||||
import { sameDocIdCollapseOnLocalCreateAfterRemoteCreateTest } from "./tests/same-doc-id-collapse-on-local-create-after-remote-create.test";
|
||||
|
||||
export const TESTS: Partial<Record<string, TestDefinition>> = {
|
||||
"rename-create-conflict": renameCreateConflictTest,
|
||||
|
|
@ -210,25 +208,18 @@ export const TESTS: Partial<Record<string, TestDefinition>> = {
|
|||
"online-create-update-while-other-creates-same-path":
|
||||
onlineCreateUpdateWhileOtherCreatesSamePathTest,
|
||||
"displaced-file-not-marked-deleted": displacedFileNotMarkedDeletedTest,
|
||||
"remote-update-resurrects-deleted-doc": remoteUpdateResurrectsDeletedDocTest,
|
||||
"local-update-survives-remote-rename":
|
||||
localUpdateSurvivesRemoteRenameTest,
|
||||
"remote-update-resurrects-deleted-doc":
|
||||
remoteUpdateResurrectsDeletedDocTest,
|
||||
"local-update-survives-remote-rename": localUpdateSurvivesRemoteRenameTest,
|
||||
"merging-update-response-survives-user-rename":
|
||||
mergingUpdateResponseSurvivesUserRenameTest,
|
||||
"conflict-uuid-stash-cleared-after-rename-deconflict":
|
||||
conflictUuidStashClearedAfterRenameDeconflictTest,
|
||||
"catchup-create-and-update-not-skipped":
|
||||
catchupCreateAndUpdateNotSkippedTest,
|
||||
"local-rename-survives-remote-rename":
|
||||
localRenameSurvivesRemoteRenameTest,
|
||||
"rename-chain-during-pending-create":
|
||||
renameChainDuringPendingCreateTest,
|
||||
"local-rename-survives-remote-rename": localRenameSurvivesRemoteRenameTest,
|
||||
"rename-chain-during-pending-create": renameChainDuringPendingCreateTest,
|
||||
"remote-rename-collides-with-pending-local-create":
|
||||
remoteRenameCollidesWithPendingLocalCreateTest,
|
||||
"remote-update-survives-user-rename":
|
||||
remoteUpdateSurvivesUserRenameTest,
|
||||
"orphan-stash-on-create-merge":
|
||||
orphanStashOnCreateMergeTest,
|
||||
"orphan-stash-on-create-dedupe-merge":
|
||||
orphanStashOnCreateDedupeMergeTest
|
||||
"remote-update-survives-user-rename": remoteUpdateSurvivesUserRenameTest,
|
||||
"same-doc-id-collapse-on-local-create-after-remote-create":
|
||||
sameDocIdCollapseOnLocalCreateAfterRemoteCreateTest
|
||||
};
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ import type { TestDefinition, TestResult, TestStep } from "./test-definition";
|
|||
import { DeterministicAgent } from "./deterministic-agent";
|
||||
import type { ServerControl } from "./server-control";
|
||||
import type { SyncSettings, Logger } from "sync-client";
|
||||
import { CONFLICT_PATH_REGEX } from "sync-client";
|
||||
import { assert } from "./utils/assert";
|
||||
import { AssertableState } from "./utils/assertable-state";
|
||||
import { sleep } from "./utils/sleep";
|
||||
|
|
@ -15,10 +14,6 @@ import {
|
|||
} from "./consts";
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
class ConflictFilesDetectedError extends Error {
|
||||
public override readonly name = "ConflictFilesDetectedError";
|
||||
}
|
||||
|
||||
export class TestRunner {
|
||||
private agents: DeterministicAgent[] = [];
|
||||
private readonly serverControl: ServerControl;
|
||||
|
|
@ -236,9 +231,6 @@ export class TestRunner {
|
|||
this.logger.info("Barrier complete: all clients converged");
|
||||
return;
|
||||
} catch (error) {
|
||||
if (error instanceof ConflictFilesDetectedError) {
|
||||
throw error;
|
||||
}
|
||||
lastError =
|
||||
error instanceof Error ? error : new Error(String(error));
|
||||
this.logger.info("Barrier: not yet converged, retrying...");
|
||||
|
|
@ -304,25 +296,6 @@ export class TestRunner {
|
|||
clientFiles.push(fileMap);
|
||||
}
|
||||
|
||||
const conflictsByClient = clientFiles.map((files) =>
|
||||
Array.from(files.keys()).filter((path) =>
|
||||
CONFLICT_PATH_REGEX.test(path)
|
||||
)
|
||||
);
|
||||
if (conflictsByClient.some((conflicts) => conflicts.length > 0)) {
|
||||
const summary = conflictsByClient
|
||||
.map((conflicts, i) =>
|
||||
conflicts.length > 0
|
||||
? `client ${i}: [${conflicts.join(", ")}]`
|
||||
: null
|
||||
)
|
||||
.filter((s): s is string => s !== null)
|
||||
.join("; ");
|
||||
throw new ConflictFilesDetectedError(
|
||||
`Found local conflict file(s): ${summary}`
|
||||
);
|
||||
}
|
||||
|
||||
const referenceFiles = Array.from(clientFiles[0].keys());
|
||||
|
||||
this.logger.info(
|
||||
|
|
|
|||
|
|
@ -40,13 +40,12 @@ export const concurrentRenameAndCreateAtTargetTest: TestDefinition = {
|
|||
verify: (state: AssertableState): void => {
|
||||
state
|
||||
.assertFileNotExists("X.md")
|
||||
.assertFileExists(
|
||||
"Y.md",
|
||||
)
|
||||
.assertFileExists(
|
||||
"Y (1).md",
|
||||
)
|
||||
.assertAnyFileContains("original file X", "brand new Y content")
|
||||
.assertFileExists("Y.md")
|
||||
.assertFileExists("Y (1).md")
|
||||
.assertAnyFileContains(
|
||||
"original file X",
|
||||
"brand new Y content"
|
||||
);
|
||||
}
|
||||
}
|
||||
]
|
||||
|
|
|
|||
|
|
@ -51,8 +51,10 @@ export const concurrentRenameFirstWinsTest: TestDefinition = {
|
|||
{
|
||||
type: "assert-consistent",
|
||||
verify: (s: AssertableState): void => {
|
||||
s.assertFileNotExists("A.md").
|
||||
assertFileCount(2).assertContent("B.md", "edit from 0\nline 2\nline 3").assertContent("C.md", "line 1\nline 2\nedit from 1");
|
||||
s.assertFileNotExists("A.md")
|
||||
.assertFileCount(2)
|
||||
.assertContent("B.md", "edit from 0\nline 2\nline 3")
|
||||
.assertContent("C.md", "line 1\nline 2\nedit from 1");
|
||||
}
|
||||
}
|
||||
]
|
||||
|
|
|
|||
|
|
@ -1,100 +0,0 @@
|
|||
import type { AssertableState } from "../utils/assertable-state";
|
||||
import type { TestDefinition } from "../test-definition";
|
||||
|
||||
export const conflictUuidStashClearedAfterRenameDeconflictTest: TestDefinition =
|
||||
{
|
||||
description:
|
||||
"A `RemoteChange` for a brand-new doc D2 at `target.md` reaches " +
|
||||
"Client 1's queue *before* Client 1's user-rename of D1 → " +
|
||||
"`target.md`. The rename's `queue.enqueue` mutates " +
|
||||
"`documents` synchronously, so by the time the drain processes " +
|
||||
"the buffered broadcast, `target.md` is already tracked by D1 " +
|
||||
"with a high `parentVersionId`. " +
|
||||
"`processRemoteCreateForNewDocument`'s version comparison " +
|
||||
"(`parentVersionId < remoteVaultUpdateId`) takes the " +
|
||||
"`MoveOnConflict.NEW` branch and stashes D2 at " +
|
||||
"`conflict-<uuid>-target.md`. The rename's `LocalUpdate` then " +
|
||||
"drains, the server deconflicts D1 to `target (1).md`, freeing " +
|
||||
"the `target.md` slot locally — but D2 is left orphaned at the " +
|
||||
"`conflict-<uuid>-` path forever, diverging from Client 0 which " +
|
||||
"has D2 at `target.md`.",
|
||||
clients: 2,
|
||||
steps: [
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
// Both clients have D1 at `original.md`.
|
||||
{
|
||||
type: "create",
|
||||
client: 0,
|
||||
path: "original.md",
|
||||
content: "D1 v1\n"
|
||||
},
|
||||
{ type: "barrier" },
|
||||
|
||||
// Buffer Client 1's WebSocket so D2's broadcast doesn't land
|
||||
// until we're ready to enqueue it ahead of the rename.
|
||||
{ type: "pause-websocket", client: 1 },
|
||||
|
||||
// Client 0 creates D2 at target.md. Server stores it; broadcast
|
||||
// is buffered at Client 1.
|
||||
{
|
||||
type: "create",
|
||||
client: 0,
|
||||
path: "target.md",
|
||||
content: "D2 v1\n"
|
||||
},
|
||||
{ type: "sync", client: 0 },
|
||||
|
||||
// Pause the server. Now Client 1's next HTTP PUT will buffer in
|
||||
// TCP and the drain will sit on `await sendUpdate`.
|
||||
{ type: "pause-server" },
|
||||
|
||||
// Issue an update to D1. The drain pops the LocalUpdate and
|
||||
// suspends on the HTTP PUT (server is SIGSTOPped). The drain is
|
||||
// now busy and won't pop further events until resume-server.
|
||||
{
|
||||
type: "update",
|
||||
client: 1,
|
||||
path: "original.md",
|
||||
content: "D1 v2\n"
|
||||
},
|
||||
|
||||
// Replay the buffered D2 broadcast. It enqueues as a
|
||||
// RemoteChange BEHIND the in-flight LocalUpdate but AHEAD of
|
||||
// the rename event we're about to push.
|
||||
{ type: "resume-websocket", client: 1 },
|
||||
|
||||
// User renames D1 onto target.md. `queue.enqueue` synchronously
|
||||
// updates `documents` so target.md → D1. The rename's
|
||||
// LocalUpdate is pushed to the END of the queue, *after* the
|
||||
// buffered RemoteChange.
|
||||
{
|
||||
type: "rename",
|
||||
client: 1,
|
||||
oldPath: "original.md",
|
||||
newPath: "target.md"
|
||||
},
|
||||
|
||||
// Resume the server. Drain order: (1) finish the v2 update PUT
|
||||
// → D1.parentVersionId bumps above D2's vaultUpdateId. (2)
|
||||
// process the RemoteChange for D2 — sees `documents.get(target.md)
|
||||
// = D1` with parentVersionId > vaultUpdateId → MoveOnConflict.NEW
|
||||
// → stashes D2 at `conflict-<uuid>-target.md`. (3) process the
|
||||
// rename's LocalUpdate — server deconflicts to target (1).md;
|
||||
// local file moves there.
|
||||
{ type: "resume-server" },
|
||||
|
||||
{ type: "barrier" },
|
||||
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (state: AssertableState): void => {
|
||||
state.assertFileCount(2);
|
||||
state.assertFileExists("target.md");
|
||||
state.assertFileExists("target (1).md");
|
||||
state.assertContent("target.md", "D2 v1\n");
|
||||
state.assertContent("target (1).md", "D1 v2\n");
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -62,10 +62,7 @@ export const localUpdateSurvivesRemoteRenameTest: TestDefinition = {
|
|||
verify: (state: AssertableState): void => {
|
||||
state.assertFileCount(1);
|
||||
state.assertFileExists("renamed.md");
|
||||
state.assertContent(
|
||||
"renamed.md",
|
||||
"v1\nclient 0 edit\n"
|
||||
);
|
||||
state.assertContent("renamed.md", "v1\nclient 0 edit\n");
|
||||
}
|
||||
}
|
||||
]
|
||||
|
|
|
|||
|
|
@ -28,7 +28,10 @@ export const moveRemoteUpdateRevertsRenameTest: TestDefinition = {
|
|||
{
|
||||
type: "assert-consistent",
|
||||
verify: (s: AssertableState): void => {
|
||||
s.assertFileCount(1).assertContent("renamed.md", "updated by client 1");
|
||||
s.assertFileCount(1).assertContent(
|
||||
"renamed.md",
|
||||
"updated by client 1"
|
||||
);
|
||||
}
|
||||
}
|
||||
]
|
||||
|
|
|
|||
|
|
@ -29,8 +29,7 @@ export const offlineMoveThenRemoteDeleteTest: TestDefinition = {
|
|||
{
|
||||
type: "assert-consistent",
|
||||
verify: (s: AssertableState): void => {
|
||||
s
|
||||
.assertFileCount(0);
|
||||
s.assertFileCount(0);
|
||||
}
|
||||
}
|
||||
]
|
||||
|
|
|
|||
|
|
@ -1,97 +0,0 @@
|
|||
import type { AssertableState } from "../utils/assertable-state";
|
||||
import type { TestDefinition } from "../test-definition";
|
||||
|
||||
export const orphanStashOnCreateDedupeMergeTest: TestDefinition = {
|
||||
description:
|
||||
"When the server's create endpoint dedupe-merges a client's local " +
|
||||
"create into an existing fresh remote doc that the client has " +
|
||||
"already tracked at a `conflict-<uuid>-` stash (because the " +
|
||||
"remote create's broadcast displaced its content there), " +
|
||||
"`processCreate`'s response handler relocates the doc's record " +
|
||||
"onto the canonical path via `setDocument` but the stash file on " +
|
||||
"disk is left behind — outliving its tracking record and " +
|
||||
"diverging from every other client. Reproducing the merge half " +
|
||||
"of the dedupe is delicate: the server's merge gate requires the " +
|
||||
"POST's `last_seen_vault_update_id` to be *strictly less than* " +
|
||||
"the existing doc's `creation_vault_update_id`. A normal sync " +
|
||||
"advances the watermark contiguously, so on the canonical " +
|
||||
"create-vs-create race the watermark would already include the " +
|
||||
"remote doc's create when the local POST ships, the merge gate " +
|
||||
"fails, and the server deconflicts to `(1)`. This test pokes a " +
|
||||
"permanent gap into the watermark via the catch-up replay's " +
|
||||
"latest-only semantics: a tempdoc created and deleted while " +
|
||||
"Client 0 is offline lives in catch-up only as the delete event, " +
|
||||
"which the client processes (advancing the watermark to the " +
|
||||
"delete) without ever filling the create's update id — so the " +
|
||||
"watermark's contiguous-prefix min stays below the next doc's " +
|
||||
"creation. With that gap in place, Client 0's post-displacement " +
|
||||
"POST satisfies the server's merge gate, the server returns the " +
|
||||
"existing docId, and `processCreate` walks straight into the " +
|
||||
"orphan-stash bug. Pre-fix: `Files from agent-0 missing in " +
|
||||
"agent-1` (the `conflict-<uuid>-` stash). Post-fix: cleaned up.",
|
||||
clients: 2,
|
||||
steps: [
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "barrier" },
|
||||
|
||||
// Client 0 goes offline. Its watermark is saved at v=0.
|
||||
{ type: "disable-sync", client: 0 },
|
||||
|
||||
// Tempdoc that lives only as a delete in Client 0's catch-up
|
||||
// (the create at v=1 is collapsed away by latest-only replay).
|
||||
// The processed delete advances the watermark to v=2 but leaves
|
||||
// v=1 unfilled, parking the contiguous-prefix min at 0.
|
||||
{ type: "create", client: 1, path: "tempdoc.md", content: "x\n" },
|
||||
{ type: "sync", client: 1 },
|
||||
{ type: "delete", client: 1, path: "tempdoc.md" },
|
||||
{ type: "sync", client: 1 },
|
||||
|
||||
// The doc whose dedupe-merge we want to trigger — fresh
|
||||
// (creation == latest), mergeable text. Its creation v=3 is
|
||||
// strictly greater than Client 0's stuck min of 0, so the
|
||||
// server's merge gate will fire.
|
||||
{ type: "create", client: 1, path: "file.md", content: "from-1\n" },
|
||||
{ type: "sync", client: 1 },
|
||||
|
||||
// Re-arm the WS pause for the new socket Client 0 is about to
|
||||
// create on enable-sync, so the catch-up broadcast is buffered
|
||||
// until we explicitly release it. Without sticky pause across
|
||||
// factory `constructorFn` calls this would silently miss the
|
||||
// catch-up.
|
||||
{ type: "pause-websocket", client: 0 },
|
||||
{ type: "enable-sync", client: 0 },
|
||||
|
||||
// Server pause arrives before the buffered catch-up is released
|
||||
// so the resume below parks Client 0's drain on the GET for
|
||||
// file.md's content (the only fetching event in the catch-up;
|
||||
// the tempdoc delete needs no fetch and runs through quickly,
|
||||
// leaving the watermark gap intact).
|
||||
{ type: "pause-server" },
|
||||
{ type: "resume-websocket", client: 0 },
|
||||
|
||||
// Yield so the drain has time to traverse the WS handler →
|
||||
// listener → enqueue → drain → processRemoteCreateForNewDocument
|
||||
// → fetch hops before the local create runs.
|
||||
{ type: "sleep", ms: 100 },
|
||||
|
||||
// Client 0 creates file.md locally while the GET is parked. The
|
||||
// file occupies the canonical slot, so when the GET returns the
|
||||
// remote create displaces D's bytes to `conflict-<uuid>-file.md`
|
||||
// and tracks D there with `intendedPath=file.md`. The
|
||||
// LocalCreate enqueues behind the in-flight RemoteChange.
|
||||
{ type: "create", client: 0, path: "file.md", content: "from-0\n" },
|
||||
|
||||
{ type: "resume-server" },
|
||||
|
||||
{ type: "barrier" },
|
||||
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (s: AssertableState): void => {
|
||||
s.assertFileCount(1);
|
||||
s.assertFileExists("file.md");
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -1,59 +0,0 @@
|
|||
import type { AssertableState } from "../utils/assertable-state";
|
||||
import type { TestDefinition } from "../test-definition";
|
||||
|
||||
export const orphanStashOnCreateMergeTest: TestDefinition = {
|
||||
description:
|
||||
"Client 1 creates file.md (server doc D). Client 0's WebSocket is " +
|
||||
"paused, so the broadcast is buffered. The server is paused, then " +
|
||||
"the WebSocket released — Client 0 enters " +
|
||||
"`processRemoteCreateForNewDocument` and parks on the GET for D's " +
|
||||
"content. While parked, Client 0 creates file.md locally. The GET " +
|
||||
"returns and the remote create displaces to " +
|
||||
"`conflict-<uuid>-file.md` (slot occupied), tracking D there with " +
|
||||
"`intendedPath=file.md`. Client 0's LocalCreate POST then drains " +
|
||||
"and the server deconflicts (because Client 0's lastSeenVaultUpdateId " +
|
||||
"now equals D's creation, so the merge condition fails) — creating " +
|
||||
"a sibling doc D' at `file (1).md`. The convergence path then " +
|
||||
"needs `unwindReadyStashes` to slide D off the conflict-uuid stash " +
|
||||
"back to file.md once Client 0's local file moves to file (1).md, " +
|
||||
"leaving both clients with [file.md, file (1).md]. Documents the " +
|
||||
"displacement-then-deconflict-then-unwind path the fix to " +
|
||||
"`processCreate`'s same-docId orphan cleanup must not regress.",
|
||||
clients: 2,
|
||||
steps: [
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "barrier" },
|
||||
|
||||
{ type: "pause-websocket", client: 0 },
|
||||
|
||||
{ type: "create", client: 1, path: "file.md", content: "from-1\n" },
|
||||
{ type: "sync", client: 1 },
|
||||
|
||||
{ type: "pause-server" },
|
||||
|
||||
{ type: "resume-websocket", client: 0 },
|
||||
|
||||
// Yield long enough for the drain to traverse all the microtask
|
||||
// hops between the WS handler and the GET, so the request is
|
||||
// queued at the (paused) server before the local create runs.
|
||||
{ type: "sleep", ms: 50 },
|
||||
|
||||
{ type: "create", client: 0, path: "file.md", content: "from-0\n" },
|
||||
|
||||
{ type: "resume-server" },
|
||||
|
||||
{ type: "barrier" },
|
||||
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (s: AssertableState): void => {
|
||||
s.assertFileCount(2);
|
||||
s.assertFileExists("file.md");
|
||||
s.assertFileExists("file (1).md");
|
||||
s.assertAnyFileContains("from-0");
|
||||
s.assertAnyFileContains("from-1");
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -2,23 +2,23 @@ import type { AssertableState } from "../utils/assertable-state";
|
|||
import type { TestDefinition } from "../test-definition";
|
||||
|
||||
export const remoteRenameCollidesWithPendingLocalCreateTest: TestDefinition = {
|
||||
// TODO(refactor): the failure mode described below is the
|
||||
// pre-refactor "deflect-to-conflict-uuid" path that no longer
|
||||
// exists. Under the new model the wire loop never moves files for
|
||||
// path placement, so the remote rename can't deflect anywhere; the
|
||||
// reconciler waits for the slot to free. Convergence assertion is
|
||||
// still valid (no conflict-uuid stashes, both files present, the
|
||||
// local create lands at a server-deconflicted sibling).
|
||||
description:
|
||||
"Client 0 has doc D tracked at `original.md`. Client 1 owns doc E " +
|
||||
"and renames it to `target.md` server-side. Before client 0's " +
|
||||
"drain processes the WS broadcast for E, the user creates a new " +
|
||||
"local file `target.md` (a different doc, untracked). When the " +
|
||||
"buffered RemoteChange for E drains, `processRemoteUpdate` " +
|
||||
"tries to move client 0's tracked file from its old slot onto " +
|
||||
"`target.md`. Pre-fix: `MoveOnConflict.NEW` deflects the remote " +
|
||||
"rename to a `conflict-<uuid>-target.md` stash on client 0, " +
|
||||
"leaving a permanent local-only divergence (client 1 has no " +
|
||||
"such stash). Post-fix: when the slot is held by a non-tracked " +
|
||||
"file (typically the agent's own pending LocalCreate), " +
|
||||
"`processRemoteUpdate` uses `MoveOnConflict.EXISTING` to " +
|
||||
"displace it; `updatePendingCreatePath` retargets the displaced " +
|
||||
"create's `event.path`, so its drain reads the file from the " +
|
||||
"new location and the server's deconflict on its create lands " +
|
||||
"the new doc at a clean path.",
|
||||
"buffered RemoteChange for E drains, the engine has to reconcile " +
|
||||
"doc E onto `target.md` even though the slot is held by client " +
|
||||
"0's pending LocalCreate. Convergence requires both clients end " +
|
||||
"up with [target.md = E] and the local create lands at a " +
|
||||
"server-deconflicted sibling (e.g. `target (1).md`).",
|
||||
clients: 2,
|
||||
steps: [
|
||||
{ type: "enable-sync", client: 0 },
|
||||
|
|
@ -34,7 +34,12 @@ export const remoteRenameCollidesWithPendingLocalCreateTest: TestDefinition = {
|
|||
|
||||
// Client 1 renames the doc. Server commits, broadcasts to
|
||||
// client 0 (buffered).
|
||||
{ type: "rename", client: 1, oldPath: "original.md", newPath: "target.md" },
|
||||
{
|
||||
type: "rename",
|
||||
client: 1,
|
||||
oldPath: "original.md",
|
||||
newPath: "target.md"
|
||||
},
|
||||
{ type: "sync", client: 1 },
|
||||
|
||||
// Client 0 still believes the doc is at `original.md`. The user
|
||||
|
|
@ -44,9 +49,8 @@ export const remoteRenameCollidesWithPendingLocalCreateTest: TestDefinition = {
|
|||
{ type: "create", client: 0, path: "target.md", content: "extra\n" },
|
||||
|
||||
// Resume client 0's WS. The buffered RemoteChange drains.
|
||||
// Pre-fix: `MoveOnConflict.NEW` deflects the rename of the
|
||||
// tracked doc into `conflict-<uuid>-target.md`, with
|
||||
// `intendedPath=target.md`.
|
||||
// The reconciler must converge without ever leaving a
|
||||
// conflict-uuid stash on disk.
|
||||
{ type: "resume-websocket", client: 0 },
|
||||
|
||||
{ type: "barrier" },
|
||||
|
|
|
|||
|
|
@ -30,8 +30,18 @@ export const renameChainDuringPendingCreateTest: TestDefinition = {
|
|||
{ type: "pause-server" },
|
||||
|
||||
{ type: "create", client: 0, path: "first.md", content: "v1\n" },
|
||||
{ type: "rename", client: 0, oldPath: "first.md", newPath: "second.md" },
|
||||
{ type: "rename", client: 0, oldPath: "second.md", newPath: "third.md" },
|
||||
{
|
||||
type: "rename",
|
||||
client: 0,
|
||||
oldPath: "first.md",
|
||||
newPath: "second.md"
|
||||
},
|
||||
{
|
||||
type: "rename",
|
||||
client: 0,
|
||||
oldPath: "second.md",
|
||||
newPath: "third.md"
|
||||
},
|
||||
|
||||
// Resume — drain pops LocalCreate (now resolves), then the two
|
||||
// queued LocalUpdates. Pre-fix: only the first rename's
|
||||
|
|
|
|||
|
|
@ -25,7 +25,9 @@ export const renameCreateConflictTest: TestDefinition = {
|
|||
{
|
||||
type: "assert-consistent",
|
||||
verify: (s: AssertableState): void => {
|
||||
s.assertFileCount(2).assertContent("B.md", "hi").assertContent("B (1).md", "hi");
|
||||
s.assertFileCount(2)
|
||||
.assertContent("B.md", "hi")
|
||||
.assertContent("B (1).md", "hi");
|
||||
}
|
||||
}
|
||||
]
|
||||
|
|
|
|||
|
|
@ -0,0 +1,121 @@
|
|||
import type { AssertableState } from "../utils/assertable-state";
|
||||
import type { TestDefinition } from "../test-definition";
|
||||
|
||||
export const sameDocIdCollapseOnLocalCreateAfterRemoteCreateTest: TestDefinition =
|
||||
{
|
||||
description:
|
||||
"Client B creates X with content C2; the server commits and " +
|
||||
"broadcasts. Client A's WS is paused so the RemoteCreate buffers. " +
|
||||
"Server is then paused so A's about-to-POST LocalCreate will " +
|
||||
"hang. A creates X with content C1: file lands on disk, " +
|
||||
"LocalCreate enqueues, drain starts the POST, the POST stalls " +
|
||||
"at the paused server. A's WS is resumed: the buffered " +
|
||||
"RemoteCreate for doc-X is delivered to A and enqueues behind " +
|
||||
"the in-flight LocalCreate. Per the lazy-paths model, when " +
|
||||
"the RemoteCreate is processed it observes that path X is " +
|
||||
"occupied locally by A's pending-create bytes, so it tracks " +
|
||||
"doc-X with `localPath = undefined` / `remoteRelativePath = " +
|
||||
"X` and does NOT fetch content. The server is then resumed: " +
|
||||
"A's LocalCreate POST returns. The server, finding X already " +
|
||||
"taken by doc-X, replies with doc-X's existing documentId " +
|
||||
"(typically a MergingUpdate carrying the merged bytes). A's " +
|
||||
"processCreate handler detects that response.documentId " +
|
||||
"matches the no-localPath record built from the RemoteCreate " +
|
||||
"and collapses the two: it sets localPath = X on that " +
|
||||
"record, writes the merged bytes, and resolves the pending " +
|
||||
"create promise. Final state: exactly one file at X on both " +
|
||||
"clients, both pointing at doc-X's documentId, content " +
|
||||
"carrying both contributions, and no conflict-<uuid>- " +
|
||||
"stash anywhere.",
|
||||
clients: 2,
|
||||
steps: [
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "barrier" },
|
||||
|
||||
// Buffer broadcasts to client 0 (A) so client 1's create
|
||||
// doesn't reach A's WS handler until we say so.
|
||||
{ type: "pause-websocket", client: 0 },
|
||||
|
||||
// Client 1 (B) commits doc-X at path X with content C2.
|
||||
// The server commits, broadcasts (broadcast queued at A's
|
||||
// paused WS).
|
||||
{
|
||||
type: "create",
|
||||
client: 1,
|
||||
path: "X.md",
|
||||
content: "from-client-1 "
|
||||
},
|
||||
{ type: "sync", client: 1 },
|
||||
|
||||
// Pause the server so A's upcoming LocalCreate POST hangs.
|
||||
// This holds A's drain on the in-flight POST while we
|
||||
// release the WS so the RemoteCreate enqueues behind it.
|
||||
{ type: "pause-server" },
|
||||
|
||||
// Client 0 (A) creates X locally with content C1. The
|
||||
// file lands on A's disk; LocalCreate enqueues; drain
|
||||
// starts the POST; POST stalls at the paused server.
|
||||
{
|
||||
type: "create",
|
||||
client: 0,
|
||||
path: "X.md",
|
||||
content: "from-client-0 "
|
||||
},
|
||||
|
||||
// Release A's WS. The buffered RemoteCreate for doc-X is
|
||||
// delivered to A and enqueues behind the in-flight
|
||||
// LocalCreate. Whichever of (RemoteCreate processed first
|
||||
// → no-localPath record, then LocalCreate POST returns
|
||||
// with merging response that collapses) or (LocalCreate
|
||||
// POST returns first with merging response that creates
|
||||
// the canonical record, then RemoteCreate finds the doc
|
||||
// already tracked by id and no-ops) actually plays out
|
||||
// depends on the fine-grained interleaving the runtime
|
||||
// produces, but both paths are required to converge to
|
||||
// the same single-record same-docId state.
|
||||
{ type: "resume-websocket", client: 0 },
|
||||
|
||||
// Resume the server: A's LocalCreate POST completes.
|
||||
// Server returns doc-X's existing documentId (MergingUpdate
|
||||
// with merged content). processCreate runs the collapse
|
||||
// path.
|
||||
{ type: "resume-server" },
|
||||
|
||||
{ type: "barrier" },
|
||||
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (state: AssertableState): void => {
|
||||
state.assertFileCount(1);
|
||||
state.assertFileExists("X.md");
|
||||
// Server-side merge of the two text creates must
|
||||
// carry both contributions through to the
|
||||
// converged file.
|
||||
state.assertContains(
|
||||
"X.md",
|
||||
"from-client-0",
|
||||
"from-client-1"
|
||||
);
|
||||
// The lazy-paths collapse path must not leave a
|
||||
// conflict-<uuid>- stash on either client.
|
||||
for (const path of state.files.keys()) {
|
||||
if (path.startsWith("conflict-")) {
|
||||
throw new Error(
|
||||
`Unexpected conflict-uuid stash on a converged client: ${path}`
|
||||
);
|
||||
}
|
||||
}
|
||||
for (const perClient of state.clientFiles) {
|
||||
for (const path of perClient.keys()) {
|
||||
if (path.startsWith("conflict-")) {
|
||||
throw new Error(
|
||||
`Unexpected conflict-uuid stash on a per-client view: ${path}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -1,4 +1,8 @@
|
|||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { DocumentWithCursors } from "./DocumentWithCursors";
|
||||
|
||||
export type ClientCursors = { userName: string, deviceId: string, documentsWithCursors: Array<DocumentWithCursors>, };
|
||||
export type ClientCursors = {
|
||||
userName: string;
|
||||
deviceId: string;
|
||||
documentsWithCursors: Array<DocumentWithCursors>;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,3 +1,7 @@
|
|||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type CreateDocumentVersion = { relative_path: string, last_seen_vault_update_id: number, content: Array<number>, };
|
||||
export type CreateDocumentVersion = {
|
||||
relative_path: string;
|
||||
last_seen_vault_update_id: number;
|
||||
content: Array<number>;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { DocumentWithCursors } from "./DocumentWithCursors";
|
||||
|
||||
export type CursorPositionFromClient = { documentsWithCursors: Array<DocumentWithCursors>, };
|
||||
export type CursorPositionFromClient = {
|
||||
documentsWithCursors: Array<DocumentWithCursors>;
|
||||
};
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue