codex
This commit is contained in:
parent
35877b69da
commit
8aeb0d6027
20 changed files with 1198 additions and 88 deletions
|
|
@ -1,10 +1,17 @@
|
|||
import type {
|
||||
HistoryEntry,
|
||||
StoredDatabase,
|
||||
SyncSettings,
|
||||
RelativePath,
|
||||
TextWithCursors
|
||||
} from "sync-client";
|
||||
import { SyncClient, debugging, LogLevel, utils } from "sync-client";
|
||||
import {
|
||||
SyncClient,
|
||||
SyncResetError,
|
||||
debugging,
|
||||
LogLevel,
|
||||
utils
|
||||
} from "sync-client";
|
||||
import { assert } from "./utils/assert";
|
||||
import { sleep } from "./utils/sleep";
|
||||
import { withTimeout } from "./utils/with-timeout";
|
||||
|
|
@ -28,6 +35,18 @@ export class DeterministicAgent extends debugging.InMemoryFileSystem {
|
|||
private readonly syncErrors: Error[] = [];
|
||||
private readonly pendingSyncOperations = new Set<Promise<void>>();
|
||||
private readonly wsFactory = new ManagedWebSocketFactory();
|
||||
private nextWriteRename:
|
||||
| {
|
||||
oldPath: RelativePath;
|
||||
newPath: RelativePath;
|
||||
}
|
||||
| undefined;
|
||||
private nextCreateResponseDrop:
|
||||
| {
|
||||
dropped: Promise<void>;
|
||||
resolveDropped: () => void;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
public constructor(
|
||||
clientId: number,
|
||||
|
|
@ -49,7 +68,7 @@ export class DeterministicAgent extends debugging.InMemoryFileSystem {
|
|||
load: async () => this.data,
|
||||
save: async (data) => void (this.data = data)
|
||||
},
|
||||
fetch: fetchImplementation,
|
||||
fetch: this.wrapFetch(fetchImplementation),
|
||||
webSocket: this.wsFactory.constructorFn
|
||||
});
|
||||
|
||||
|
|
@ -94,6 +113,65 @@ export class DeterministicAgent extends debugging.InMemoryFileSystem {
|
|||
this.wsFactory.resume();
|
||||
}
|
||||
|
||||
public dropNextCreateResponse(): void {
|
||||
assert(
|
||||
this.nextCreateResponseDrop === undefined,
|
||||
`Client ${this.clientId} already has a create response drop armed`
|
||||
);
|
||||
let resolveDropped!: () => void;
|
||||
const dropped = new Promise<void>((resolve) => {
|
||||
resolveDropped = resolve;
|
||||
});
|
||||
this.nextCreateResponseDrop = {
|
||||
dropped,
|
||||
resolveDropped
|
||||
};
|
||||
this.log("Armed next create response drop");
|
||||
}
|
||||
|
||||
public async waitForDroppedCreateResponse(): Promise<void> {
|
||||
assert(
|
||||
this.nextCreateResponseDrop !== undefined,
|
||||
`Client ${this.clientId} has no create response drop armed`
|
||||
);
|
||||
await withTimeout(
|
||||
this.nextCreateResponseDrop.dropped,
|
||||
WAIT_TIMEOUT_MS,
|
||||
`Client ${this.clientId} timed out waiting for create response drop`
|
||||
);
|
||||
this.log("Create response was dropped after server commit");
|
||||
}
|
||||
|
||||
public async waitForHistoryEntry(
|
||||
matches: (entry: HistoryEntry) => boolean,
|
||||
onMatch?: (entry: HistoryEntry) => void
|
||||
): Promise<void> {
|
||||
const existing = this.client.getHistoryEntries().find(matches);
|
||||
if (existing !== undefined) {
|
||||
onMatch?.(existing);
|
||||
return;
|
||||
}
|
||||
|
||||
await withTimeout(
|
||||
new Promise<void>((resolve) => {
|
||||
const unsubscribe = this.client.onSyncHistoryUpdated.add(() => {
|
||||
const entry = this.client
|
||||
.getHistoryEntries()
|
||||
.find(matches);
|
||||
if (entry === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
unsubscribe();
|
||||
onMatch?.(entry);
|
||||
resolve();
|
||||
});
|
||||
}),
|
||||
WAIT_TIMEOUT_MS,
|
||||
`Client ${this.clientId} timed out waiting for history entry`
|
||||
);
|
||||
}
|
||||
|
||||
public async waitForSync(): Promise<void> {
|
||||
this.log("Waiting for sync to complete...");
|
||||
// Drain agent-level sync operations first. These are the fire-and-forget
|
||||
|
|
@ -160,6 +238,15 @@ export class DeterministicAgent extends debugging.InMemoryFileSystem {
|
|||
return new TextDecoder().decode(bytes);
|
||||
}
|
||||
|
||||
public renameNextWrite(oldPath: RelativePath, newPath: RelativePath): void {
|
||||
assert(
|
||||
this.nextWriteRename === undefined,
|
||||
`Client ${this.clientId} already has a next-write rename armed`
|
||||
);
|
||||
this.nextWriteRename = { oldPath, newPath };
|
||||
this.log(`Armed next write rename: ${oldPath} -> ${newPath}`);
|
||||
}
|
||||
|
||||
public async cleanup(): Promise<void> {
|
||||
this.log("Cleaning up...");
|
||||
// Guard against uninitialized client (init() failed partway).
|
||||
|
|
@ -201,15 +288,37 @@ export class DeterministicAgent extends debugging.InMemoryFileSystem {
|
|||
const isNew = !this.files.has(path);
|
||||
await super.write(path, content);
|
||||
|
||||
if (this.isSyncEnabled && isNew) {
|
||||
this.enqueueSync(async () => {
|
||||
this.client.syncLocallyCreatedFile(path);
|
||||
});
|
||||
}
|
||||
|
||||
const nextWriteRename = this.nextWriteRename;
|
||||
if (
|
||||
nextWriteRename !== undefined &&
|
||||
nextWriteRename.oldPath === path
|
||||
) {
|
||||
this.nextWriteRename = undefined;
|
||||
await super.rename(
|
||||
nextWriteRename.oldPath,
|
||||
nextWriteRename.newPath
|
||||
);
|
||||
if (this.isSyncEnabled) {
|
||||
this.enqueueSync(async () => {
|
||||
this.client.syncLocallyUpdatedFile({
|
||||
oldPath: nextWriteRename.oldPath,
|
||||
relativePath: nextWriteRename.newPath
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!this.isSyncEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isNew) {
|
||||
this.enqueueSync(async () => {
|
||||
this.client.syncLocallyCreatedFile(path);
|
||||
});
|
||||
} else {
|
||||
if (!isNew) {
|
||||
this.enqueueSync(async () => {
|
||||
this.client.syncLocallyUpdatedFile({ relativePath: path });
|
||||
});
|
||||
|
|
@ -314,4 +423,42 @@ export class DeterministicAgent extends debugging.InMemoryFileSystem {
|
|||
private log(message: string): void {
|
||||
this.logger(`[Client ${this.clientId}] ${message}`);
|
||||
}
|
||||
|
||||
private wrapFetch(
|
||||
fetchImplementation: typeof globalThis.fetch
|
||||
): typeof globalThis.fetch {
|
||||
return async (input, init) => {
|
||||
const response = await fetchImplementation(input, init);
|
||||
const drop = this.nextCreateResponseDrop;
|
||||
if (
|
||||
drop !== undefined &&
|
||||
DeterministicAgent.isCreateDocumentRequest(input, init)
|
||||
) {
|
||||
this.nextCreateResponseDrop = undefined;
|
||||
drop.resolveDropped();
|
||||
throw new SyncResetError();
|
||||
}
|
||||
return response;
|
||||
};
|
||||
}
|
||||
|
||||
private static isCreateDocumentRequest(
|
||||
input: RequestInfo | URL,
|
||||
init: RequestInit | undefined
|
||||
): boolean {
|
||||
const method =
|
||||
init?.method ??
|
||||
(typeof Request !== "undefined" && input instanceof Request
|
||||
? input.method
|
||||
: "GET");
|
||||
if (method.toUpperCase() !== "POST") {
|
||||
return false;
|
||||
}
|
||||
|
||||
const url =
|
||||
input instanceof URL
|
||||
? input
|
||||
: new URL(typeof input === "string" ? input : input.url);
|
||||
return /\/documents\/?$/.test(url.pathname);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,16 +9,30 @@ export type TestStep =
|
|||
| { type: "create"; client: number; path: string; content: string }
|
||||
| { type: "update"; client: number; path: string; content: string }
|
||||
| { type: "rename"; client: number; oldPath: string; newPath: string }
|
||||
| {
|
||||
type: "rename-next-write";
|
||||
client: number;
|
||||
oldPath: string;
|
||||
newPath: string;
|
||||
}
|
||||
| { type: "delete"; client: number; path: string }
|
||||
| { type: "sync"; client?: number }
|
||||
| { type: "disable-sync"; client: number }
|
||||
| { type: "enable-sync"; client: number }
|
||||
| { type: "pause-server" }
|
||||
| { type: "resume-server" }
|
||||
| {
|
||||
type: "resume-server-until-history-then-pause";
|
||||
client: number;
|
||||
syncType: "CREATE" | "UPDATE" | "DELETE";
|
||||
path: string;
|
||||
}
|
||||
| { type: "barrier" }
|
||||
| { type: "assert-consistent"; verify?: (state: AssertableState) => void }
|
||||
| { type: "pause-websocket"; client: number }
|
||||
| { type: "resume-websocket"; client: number }
|
||||
| { type: "drop-next-create-response"; client: number }
|
||||
| { type: "wait-for-dropped-create-response"; client: number }
|
||||
| { type: "sleep"; ms: number }
|
||||
| { type: "reset"; client: number };
|
||||
|
||||
|
|
|
|||
|
|
@ -98,6 +98,13 @@ import { renameChainDuringPendingCreateTest } from "./tests/rename-chain-during-
|
|||
import { remoteRenameCollidesWithPendingLocalCreateTest } from "./tests/remote-rename-collides-with-pending-local-create.test";
|
||||
import { remoteUpdateSurvivesUserRenameTest } from "./tests/remote-update-survives-user-rename.test";
|
||||
import { sameDocIdCollapseOnLocalCreateAfterRemoteCreateTest } from "./tests/same-doc-id-collapse-on-local-create-after-remote-create.test";
|
||||
import { sameDocIdCollapseAfterRemoteQuickWriteAndPendingRenameTest } from "./tests/same-doc-id-collapse-after-remote-quick-write-and-pending-rename.test";
|
||||
import { renameOverwritesPendingCreateThenDeleteTest } from "./tests/rename-overwrites-pending-create-then-delete.test";
|
||||
import { deleteRecreatedPendingCreateWithStaleDeletingRecordTest } from "./tests/delete-recreated-pending-create-with-stale-deleting-record.test";
|
||||
import { queuedCreateDeleteDoesNotHijackReusedPathTest } from "./tests/queued-create-delete-does-not-hijack-reused-path.test";
|
||||
import { renamedPendingCreateReusedPathThenDeleteTest } from "./tests/renamed-pending-create-reused-path-then-delete.test";
|
||||
import { renamePendingCreateOntoPendingDeletePathTest } from "./tests/rename-pending-create-onto-pending-delete-path.test";
|
||||
import { remoteQuickWriteRenameBeforeRecordTest } from "./tests/remote-quick-write-rename-before-record.test";
|
||||
|
||||
export const TESTS: Partial<Record<string, TestDefinition>> = {
|
||||
"rename-create-conflict": renameCreateConflictTest,
|
||||
|
|
@ -221,5 +228,19 @@ export const TESTS: Partial<Record<string, TestDefinition>> = {
|
|||
remoteRenameCollidesWithPendingLocalCreateTest,
|
||||
"remote-update-survives-user-rename": remoteUpdateSurvivesUserRenameTest,
|
||||
"same-doc-id-collapse-on-local-create-after-remote-create":
|
||||
sameDocIdCollapseOnLocalCreateAfterRemoteCreateTest
|
||||
sameDocIdCollapseOnLocalCreateAfterRemoteCreateTest,
|
||||
"renamed-pending-create-reused-path-then-delete":
|
||||
renamedPendingCreateReusedPathThenDeleteTest,
|
||||
"rename-pending-create-onto-pending-delete-path":
|
||||
renamePendingCreateOntoPendingDeletePathTest,
|
||||
"rename-overwrites-pending-create-then-delete":
|
||||
renameOverwritesPendingCreateThenDeleteTest,
|
||||
"same-doc-id-collapse-after-remote-quick-write-and-pending-rename":
|
||||
sameDocIdCollapseAfterRemoteQuickWriteAndPendingRenameTest,
|
||||
"delete-recreated-pending-create-with-stale-deleting-record":
|
||||
deleteRecreatedPendingCreateWithStaleDeletingRecordTest,
|
||||
"queued-create-delete-does-not-hijack-reused-path":
|
||||
queuedCreateDeleteDoesNotHijackReusedPathTest,
|
||||
"remote-quick-write-rename-before-record":
|
||||
remoteQuickWriteRenameBeforeRecordTest
|
||||
};
|
||||
|
|
|
|||
|
|
@ -144,6 +144,13 @@ export class TestRunner {
|
|||
);
|
||||
break;
|
||||
|
||||
case "rename-next-write":
|
||||
this.getAgent(step.client).renameNextWrite(
|
||||
step.oldPath,
|
||||
step.newPath
|
||||
);
|
||||
break;
|
||||
|
||||
case "delete":
|
||||
await this.getAgent(step.client).delete(step.path);
|
||||
break;
|
||||
|
|
@ -177,6 +184,19 @@ export class TestRunner {
|
|||
await this.serverControl.waitForReady();
|
||||
break;
|
||||
|
||||
case "resume-server-until-history-then-pause": {
|
||||
const agent = this.getAgent(step.client);
|
||||
const historySeen = agent.waitForHistoryEntry(
|
||||
(entry) =>
|
||||
entry.details.type === step.syncType &&
|
||||
entry.details.relativePath === step.path,
|
||||
() => this.serverControl.pause()
|
||||
);
|
||||
this.serverControl.resume();
|
||||
await historySeen;
|
||||
break;
|
||||
}
|
||||
|
||||
case "barrier":
|
||||
await this.waitForConvergence();
|
||||
break;
|
||||
|
|
@ -193,6 +213,14 @@ export class TestRunner {
|
|||
this.getAgent(step.client).resumeWebSocket();
|
||||
break;
|
||||
|
||||
case "drop-next-create-response":
|
||||
this.getAgent(step.client).dropNextCreateResponse();
|
||||
break;
|
||||
|
||||
case "wait-for-dropped-create-response":
|
||||
await this.getAgent(step.client).waitForDroppedCreateResponse();
|
||||
break;
|
||||
|
||||
case "sleep":
|
||||
await sleep(step.ms);
|
||||
break;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,52 @@
|
|||
import type { AssertableState } from "../utils/assertable-state";
|
||||
import type { TestDefinition } from "../test-definition";
|
||||
|
||||
export const deleteRecreatedPendingCreateWithStaleDeletingRecordTest: TestDefinition =
|
||||
{
|
||||
description:
|
||||
"A local delete for a recreated pending create must target the " +
|
||||
"new pending create, not an older same-path record whose server " +
|
||||
"delete has been acked but whose WebSocket delete receipt is " +
|
||||
"still paused.",
|
||||
clients: 2,
|
||||
steps: [
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "barrier" },
|
||||
|
||||
{ type: "pause-websocket", client: 0 },
|
||||
{ type: "pause-server" },
|
||||
{
|
||||
type: "create",
|
||||
client: 0,
|
||||
path: "binary-14.bin",
|
||||
content: "BINARY:first"
|
||||
},
|
||||
{ type: "sleep", ms: 100 },
|
||||
{ type: "delete", client: 0, path: "binary-14.bin" },
|
||||
{ type: "resume-server" },
|
||||
{ type: "sync", client: 0 },
|
||||
|
||||
{ type: "pause-server" },
|
||||
{
|
||||
type: "create",
|
||||
client: 0,
|
||||
path: "binary-14.bin",
|
||||
content: "BINARY:second"
|
||||
},
|
||||
{ type: "sleep", ms: 100 },
|
||||
{ type: "delete", client: 0, path: "binary-14.bin" },
|
||||
{ type: "resume-server" },
|
||||
{ type: "sync", client: 0 },
|
||||
|
||||
{ type: "resume-websocket", client: 0 },
|
||||
{ type: "barrier" },
|
||||
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (state: AssertableState): void => {
|
||||
state.assertFileCount(0);
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
import type { AssertableState } from "../utils/assertable-state";
|
||||
import type { TestDefinition } from "../test-definition";
|
||||
|
||||
export const queuedCreateDeleteDoesNotHijackReusedPathTest: TestDefinition = {
|
||||
description:
|
||||
"A create/delete pair that is still queued behind another request " +
|
||||
"must collapse locally. It must not later read a different file " +
|
||||
"that reused the same path before the queued create drained.",
|
||||
clients: 2,
|
||||
steps: [
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "barrier" },
|
||||
|
||||
{ type: "pause-server" },
|
||||
{
|
||||
type: "create",
|
||||
client: 1,
|
||||
path: "blocker.bin",
|
||||
content: "BINARY:blocker"
|
||||
},
|
||||
{ type: "sleep", ms: 100 },
|
||||
{
|
||||
type: "create",
|
||||
client: 1,
|
||||
path: "target.bin",
|
||||
content: "BINARY:old"
|
||||
},
|
||||
{ type: "delete", client: 1, path: "target.bin" },
|
||||
{
|
||||
type: "create",
|
||||
client: 1,
|
||||
path: "source.bin",
|
||||
content: "BINARY:new"
|
||||
},
|
||||
{
|
||||
type: "rename",
|
||||
client: 1,
|
||||
oldPath: "source.bin",
|
||||
newPath: "target.bin"
|
||||
},
|
||||
{ type: "resume-server" },
|
||||
{ type: "barrier" },
|
||||
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (state: AssertableState): void => {
|
||||
state
|
||||
.assertFileCount(2)
|
||||
.assertContent("blocker.bin", "BINARY:blocker")
|
||||
.assertContent("target.bin", "BINARY:new")
|
||||
.assertFileNotExists("source.bin");
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
import type { AssertableState } from "../utils/assertable-state";
|
||||
import type { TestDefinition } from "../test-definition";
|
||||
|
||||
export const remoteQuickWriteRenameBeforeRecordTest: TestDefinition = {
|
||||
description:
|
||||
"Client 0 receives a remote create and the user renames the new " +
|
||||
"file immediately after the syncer writes it. The watcher event " +
|
||||
"must bind to the new document instead of being dropped before " +
|
||||
"the remote-create handler persists the record.",
|
||||
clients: 2,
|
||||
steps: [
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
|
||||
{
|
||||
type: "rename-next-write",
|
||||
client: 0,
|
||||
oldPath: "doc.md",
|
||||
newPath: "renamed.md"
|
||||
},
|
||||
|
||||
{ type: "create", client: 1, path: "doc.md", content: "v1\n" },
|
||||
{ type: "sync", client: 1 },
|
||||
{ type: "barrier" },
|
||||
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (s: AssertableState): void => {
|
||||
s.assertFileCount(1);
|
||||
s.assertFileExists("renamed.md");
|
||||
s.assertFileNotExists("doc.md");
|
||||
s.assertContent("renamed.md", "v1\n");
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
import type { AssertableState } from "../utils/assertable-state";
|
||||
import type { TestDefinition } from "../test-definition";
|
||||
|
||||
export const renameOverwritesPendingCreateThenDeleteTest: TestDefinition = {
|
||||
description:
|
||||
"A pending local create at a path must not mask a synced document renamed onto that path; later rename/delete events still belong to the synced document.",
|
||||
clients: 2,
|
||||
steps: [
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{
|
||||
type: "create",
|
||||
client: 0,
|
||||
path: "tracked.bin",
|
||||
content: "BINARY:tracked"
|
||||
},
|
||||
{ type: "barrier" },
|
||||
|
||||
{ type: "pause-server" },
|
||||
|
||||
{
|
||||
type: "create",
|
||||
client: 0,
|
||||
path: "pending.bin",
|
||||
content: "BINARY:pending"
|
||||
},
|
||||
{
|
||||
type: "rename",
|
||||
client: 0,
|
||||
oldPath: "tracked.bin",
|
||||
newPath: "pending.bin"
|
||||
},
|
||||
{
|
||||
type: "rename",
|
||||
client: 0,
|
||||
oldPath: "pending.bin",
|
||||
newPath: "final.bin"
|
||||
},
|
||||
{ type: "delete", client: 0, path: "final.bin" },
|
||||
|
||||
{ type: "resume-server" },
|
||||
{ type: "barrier" },
|
||||
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (state: AssertableState): void => {
|
||||
state.assertFileCount(0);
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
import type { AssertableState } from "../utils/assertable-state";
|
||||
import type { TestDefinition } from "../test-definition";
|
||||
|
||||
export const renamePendingCreateOntoPendingDeletePathTest: TestDefinition = {
|
||||
description:
|
||||
"A pending create is renamed onto a path whose old server document " +
|
||||
"has a queued delete. The delete must reach the server before the " +
|
||||
"new create so the new generation is not merged into the soon-to-be " +
|
||||
"deleted document.",
|
||||
clients: 2,
|
||||
steps: [
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "barrier" },
|
||||
|
||||
{
|
||||
type: "create",
|
||||
client: 1,
|
||||
path: "file-17.md",
|
||||
content: "old\n"
|
||||
},
|
||||
{ type: "barrier" },
|
||||
|
||||
{ type: "pause-server" },
|
||||
{
|
||||
type: "create",
|
||||
client: 1,
|
||||
path: "blocker.md",
|
||||
content: "blocker\n"
|
||||
},
|
||||
{ type: "sleep", ms: 100 },
|
||||
{
|
||||
type: "create",
|
||||
client: 1,
|
||||
path: "file-23.md",
|
||||
content: "new\n"
|
||||
},
|
||||
{ type: "delete", client: 1, path: "file-17.md" },
|
||||
{
|
||||
type: "rename",
|
||||
client: 1,
|
||||
oldPath: "file-23.md",
|
||||
newPath: "file-17.md"
|
||||
},
|
||||
{ type: "resume-server" },
|
||||
{ type: "barrier" },
|
||||
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (state: AssertableState): void => {
|
||||
state
|
||||
.assertFileCount(2)
|
||||
.assertContent("blocker.md", "blocker\n")
|
||||
.assertContent("file-17.md", "new\n")
|
||||
.assertFileNotExists("file-23.md");
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
import type { AssertableState } from "../utils/assertable-state";
|
||||
import type { TestDefinition } from "../test-definition";
|
||||
|
||||
export const renamedPendingCreateReusedPathThenDeleteTest: TestDefinition = {
|
||||
description:
|
||||
"A queued create is renamed away from file-59.md, a newer local " +
|
||||
"file reuses file-59.md before the queued create drains, and the " +
|
||||
"renamed-away generation is deleted. The delete must not erase or " +
|
||||
"orphan the newer file-59.md generation.",
|
||||
clients: 2,
|
||||
steps: [
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "barrier" },
|
||||
|
||||
{ type: "pause-server" },
|
||||
{
|
||||
type: "create",
|
||||
client: 1,
|
||||
path: "blocker.md",
|
||||
content: "blocker\n"
|
||||
},
|
||||
{ type: "sleep", ms: 100 },
|
||||
|
||||
{
|
||||
type: "create",
|
||||
client: 1,
|
||||
path: "file-59.md",
|
||||
content: "old\n"
|
||||
},
|
||||
{
|
||||
type: "rename",
|
||||
client: 1,
|
||||
oldPath: "file-59.md",
|
||||
newPath: "file-33.md"
|
||||
},
|
||||
{
|
||||
type: "create",
|
||||
client: 1,
|
||||
path: "file-59.md",
|
||||
content: "new\n"
|
||||
},
|
||||
|
||||
{
|
||||
type: "resume-server-until-history-then-pause",
|
||||
client: 1,
|
||||
syncType: "CREATE",
|
||||
path: "file-33.md"
|
||||
},
|
||||
{ type: "delete", client: 1, path: "file-33.md" },
|
||||
{ type: "resume-server" },
|
||||
{ type: "barrier" },
|
||||
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (state: AssertableState): void => {
|
||||
state
|
||||
.assertFileCount(2)
|
||||
.assertContent("blocker.md", "blocker\n")
|
||||
.assertContent("file-59.md", "new\n")
|
||||
.assertFileNotExists("file-33.md");
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -0,0 +1,82 @@
|
|||
import type { AssertableState } from "../utils/assertable-state";
|
||||
import type { TestDefinition } from "../test-definition";
|
||||
|
||||
export const sameDocIdCollapseAfterRemoteQuickWriteAndPendingRenameTest: TestDefinition =
|
||||
{
|
||||
description:
|
||||
"A remote create starts quick-writing at doc.md while a local " +
|
||||
"create for the same path is queued and renamed to renamed.md. " +
|
||||
"Because the local create was renamed before it reached the " +
|
||||
"server, the two generations should remain separate tracked " +
|
||||
"documents.",
|
||||
clients: 2,
|
||||
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.
|
||||
{
|
||||
type: "create",
|
||||
client: 0,
|
||||
path: "history.md",
|
||||
content: "history-v1"
|
||||
},
|
||||
{ type: "sync", client: 0 },
|
||||
{
|
||||
type: "update",
|
||||
client: 0,
|
||||
path: "history.md",
|
||||
content: "history-v2"
|
||||
},
|
||||
{ type: "sync", client: 0 },
|
||||
{ type: "delete", client: 0, path: "history.md" },
|
||||
{ type: "sync", client: 0 },
|
||||
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "sync", client: 1 },
|
||||
|
||||
{ type: "pause-websocket", client: 1 },
|
||||
|
||||
{
|
||||
type: "create",
|
||||
client: 0,
|
||||
path: "doc.md",
|
||||
content: "remote\n"
|
||||
},
|
||||
{ type: "sync", client: 0 },
|
||||
|
||||
// Let client 1's buffered RemoteCreate enter the quick-write
|
||||
// path, but hold the content fetch until the local create has
|
||||
// appeared and moved away from doc.md.
|
||||
{ type: "pause-server" },
|
||||
{ type: "resume-websocket", client: 1 },
|
||||
{ type: "sleep", ms: 100 },
|
||||
|
||||
{
|
||||
type: "create",
|
||||
client: 1,
|
||||
path: "doc.md",
|
||||
content: "local\n"
|
||||
},
|
||||
{
|
||||
type: "rename",
|
||||
client: 1,
|
||||
oldPath: "doc.md",
|
||||
newPath: "renamed.md"
|
||||
},
|
||||
|
||||
{ type: "resume-server" },
|
||||
{ type: "barrier" },
|
||||
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (state: AssertableState): void => {
|
||||
state.assertFileCount(2);
|
||||
state.assertContent("doc.md", "remote\n");
|
||||
state.assertContent("renamed.md", "local\n");
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue