// Time-of-check / time-of-use stale read across a LINK.
//
// cellB = { isAdmin: true }
// cellA = { isAdmin: cellB.isAdmin> }
//
// Two Runtime clients share ONE in-process MemoryV2Server (harness recipe from
// commit-conflict-reconcile.test.ts). Client 1 reads isAdmin=true; Client 2
// flips cellB.isAdmin=false on the server; Client 1 — in the window before its
// replica syncs — re-reads the linked value through cellA inside a tx and
// writes a grant based on it. That commit is rejected as a ConflictError.
//
// What makes this DISTINCT from commit-conflict-reconcile.test.ts (which shares
// this harness): the stale read is reached THROUGH A LINK, so the conflict is
// on the link *target* (cellB) — a different doc than the one the tx actually
// writes (cellC). This pins that a stale read enrolled via a link, not just a
// direct read of the written doc, is enough to reject the commit.
import { afterEach, beforeEach, describe, it } from "@std/testing/bdd";
import { expect } from "@std/expect";
import { Identity } from "@commonfabric/identity";
import * as MemoryV2Server from "@commonfabric/memory/v2/server";
import { EmulatedStorageManager } from "../src/storage/v2-emulate.ts";
import type { Options } from "../src/storage/v2.ts";
import { Runtime } from "../src/runtime.ts";
const signer = await Identity.fromPassphrase("linked-stale-read-strand");
const space = signer.did();
// Two StorageManagers sharing ONE real server, each with its own replicas.
class SharedServerStorageManager extends EmulatedStorageManager {
static connectTo(
server: MemoryV2Server.Server,
options: Omit,
): SharedServerStorageManager {
const manager = new SharedServerStorageManager(
{ ...options, memoryHost: new URL("memory://") },
() => server,
);
manager.sharedServer = server;
return manager;
}
private sharedServer!: MemoryV2Server.Server;
protected override server(): MemoryV2Server.Server {
return this.sharedServer;
}
}
const newSharedServer = () =>
new MemoryV2Server.Server({
authorizeSessionOpen(message) {
const principal = (message.authorization as { principal?: unknown })
?.principal;
return typeof principal === "string" ? principal : undefined;
},
});
describe("stale linked read across two clients", () => {
let server: MemoryV2Server.Server;
let storageA: SharedServerStorageManager;
let storageB: SharedServerStorageManager;
let rtA: Runtime; // Client 1
let rtB: Runtime; // Client 2
beforeEach(() => {
server = newSharedServer();
storageA = SharedServerStorageManager.connectTo(server, { as: signer });
storageB = SharedServerStorageManager.connectTo(server, { as: signer });
rtA = new Runtime({
apiUrl: new URL(import.meta.url),
storageManager: storageA,
});
rtB = new Runtime({
apiUrl: new URL(import.meta.url),
storageManager: storageB,
});
});
afterEach(async () => {
await rtB.dispose();
await rtA.dispose();
await storageB.close();
await storageA.close();
await server.close();
});
it("rejects Client 1's grant when its tx read a stale linked isAdmin", async () => {
const A = "cellA";
const B = "cellB";
const C = "cellC";
// --- Client 1 seeds cellB = { isAdmin: true } and links cellA.isAdmin to it.
const cellB1 = rtA.getCell<{ isAdmin: boolean }>(space, B, undefined);
const cellA1 = rtA.getCell<{ isAdmin: boolean }>(space, A, undefined);
{
const tx = rtA.edit();
cellB1.withTx(tx).set({ isAdmin: true });
cellA1.withTx(tx).key("isAdmin").setRawUntyped(
cellB1.key("isAdmin").getAsLink(),
);
rtA.prepareTxForCommit(tx);
const res = await tx.commit();
expect(res.error, `seed: ${JSON.stringify(res.error)}`).toBeUndefined();
await storageA.synced();
}
// Client 1 reads the linked value -> { isAdmin: true }
expect(cellA1.get()).toEqual({ isAdmin: true });
// --- Client 2 converges, then flips cellB.isAdmin = false and publishes it.
const cellB2 = rtB.getCell<{ isAdmin: boolean }>(space, B, undefined);
await cellB2.sync();
await cellB2.pull();
expect(cellB2.get()).toEqual({ isAdmin: true });
{
const tx = rtB.edit();
cellB2.withTx(tx).key("isAdmin").set(false);
rtB.prepareTxForCommit(tx);
const res = await tx.commit();
expect(res.error, `flip: ${JSON.stringify(res.error)}`).toBeUndefined();
await storageB.synced();
}
// --- Client 1, NOT synced, opens ONE transaction that both READS isAdmin
// through the link in cellA and, on the strength of that read, writes the
// grant to cellC. Because the read happens via `withTx(tx)`, the stale
// `isAdmin` (the link target cellB) enters this tx's read-set.
const cellC1 = rtA.getCell(space, C, undefined);
const tx = rtA.edit();
const observedByClient1 = cellA1.withTx(tx).key("isAdmin").get();
// Client 1 still read the stale `true` in the race window ...
expect(observedByClient1, "Client 1's in-tx linked read").toBe(true);
// Client 1 sets the field in C based on the information in A.
cellC1.withTx(tx).set("User is allowed, because isAdmin = true");
rtA.prepareTxForCommit(tx);
const res = await tx.commit();
// ... but now that read is part of the tx's read-set, so committing the
// grant is REJECTED: the server's head for cellB.isAdmin has advanced past
// the seq Client 1 read it at.
expect(res.error, "stale-read commit should be rejected").toBeDefined();
expect(
(res.error as { name?: string })?.name,
"stale read across the link is a ConflictError",
).toBe("ConflictError");
// The grant write never lands.
expect(cellC1.get(), "rejected grant must not persist").toBeUndefined();
});
});