Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions architecture/webapp-frontend.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,9 @@ classDiagram
class Browser {
folder listing, file view
per-view routes
+moved: /resolve?path= on a tree miss only
}
note for Browser "A missing path is decided from /tree alone — the file is never fetched — so the X-Bdrive-Canonical-Path header /file answers with would never reach the browser, and a moved FOLDER has no content fetch to hang a header on. The not-found branch asks GET /resolve?path= instead, then replaceState-navigates to the destination and prints one Moved from … line above it (BEA-81)"

class router {
+VIEW_ROUTES dashboard history install settings
Expand Down
31 changes: 30 additions & 1 deletion architecture/webapp-server.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ classDiagram
class volume {
-source Source
-refresh time.Duration
-snap *snapshot
-snap *snapshot (files + moves)
+snapshot(ctx)
+invalidate()
}
Expand Down Expand Up @@ -68,6 +68,30 @@ classDiagram
}
note for sourcedOp "An op's Device field is whatever the writer typed; From is the journal object it actually came out of, which the /store door gates. Attribution reads From — a peer cannot sign someone else's name on a change by editing its own journal"
note for RemoteSource "OpenBlob is the single blob-read door: the sha must match blobRe, and verify re-hashes the bytes whenever the backend is a PutSigner — in direct-upload mode the server never saw the content, so the store is the only thing that could have swapped it. It stops re-hashing only once the object is PROVABLY immutable: both presign doors refuse a key that exists, so every URL for a blob was minted before its first PUT and dies at mint+PresignTTL; past that age the hub is the only writer left. That is what remote.Object.Modified is for"
class MoveSource {
<<interface>>
+FilesWithMoves(ctx) files, moveIndex
}
class moveIndex {
<<map path→[]pathEvent>>
+buildMoveIndex(sorted ops)
+resolveForward(idx, files, p) viewer
+resolveShare(idx, files, p, since) /s/
+chainSegments(idx, p) []segment
+resolveFolder(idx, files, dir) all-or-nothing
}
class pathEvent {
+At the delete that ended it
+To "" = deleted, not moved
+ToAt destination's create
}
class segment {
+Path
+From, To window it WAS the file
}
note for moveIndex "There is no rename op — a move is put(new) + delete(old), same device, same blob, one cycle — so the index is DERIVED inside the replay Files already runs and cached with the snapshot. Pairing needs same device, |Δt| ≤ 30s, B's first-ever put, and one-to-one both ways; anything ambiguous stays a plain deletion. Nothing here writes an op: journal.Less and Replay are untouched"
note for segment "Time-bounded on purpose: a bare set of paths would make history?path=docs/a.md show the ops of the NEW a.md that took the old address"

class Uploader {
<<interface>>
+Upload(ctx, path, r, size, who, note)
Expand Down Expand Up @@ -346,6 +370,11 @@ classDiagram

Source <|.. DirSource
Source <|.. RemoteSource
MoveSource <|.. RemoteSource : optional, like Uploader — DirSource has no journals, so no moves
MoveSource ..> moveIndex
volume o-- moveIndex : cached with the snapshot
moveIndex *-- pathEvent
moveIndex ..> segment : chainSegments
Uploader <|-- DirectUploader
DirectUploader <|.. RemoteSource
RemoteSource o-- Backend : Prefixed(Root, projectID)
Expand Down
65 changes: 65 additions & 0 deletions internal/syncer/syncer_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -765,3 +765,68 @@ func sha256hex(s string) string {
sum := sha256.Sum256([]byte(s))
return hex.EncodeToString(sum[:])
}

// A rename is not an op — the scanner emits a put at the new path and a
// delete at the old, in one cycle, carrying the same blob. The hub infers
// moves from exactly that shape (internal/webapp/moves.go), which only stays
// true while sync keeps producing it. Nothing in the move index touches
// journal.Less or Replay; this is what pins that.
func TestRenameConvergesAsPutPlusDelete(t *testing.T) {
be := sharedRemote(t)
a := newDevice(t, "deva", be)
b := newDevice(t, "devb", be)

write(t, a.Folder, "plan.md", "the plan")
cycle(t, a)
cycle(t, b)
if got := read(t, b.Folder, "plan.md"); got != "the plan" {
t.Fatalf("b before the rename = %q", got)
}

// The rename, exactly as a person or an editor does it.
if err := os.MkdirAll(filepath.Join(a.Folder, "notes"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.Rename(filepath.Join(a.Folder, "plan.md"), filepath.Join(a.Folder, "notes", "plan.md")); err != nil {
t.Fatal(err)
}
res := cycle(t, a)
if res.LocalOps != 2 {
t.Fatalf("LocalOps = %d, want 2 (the put and the delete)", res.LocalOps)
}
cycle(t, b)

if got := read(t, b.Folder, "notes/plan.md"); got != "the plan" {
t.Fatalf("b after the rename = %q, want the plan", got)
}
if _, err := os.Stat(filepath.Join(b.Folder, "plan.md")); !os.IsNotExist(err) {
t.Fatalf("the old path survived on b: %v", err)
}

// The two halves the hub pairs on: one device, same blob, same cycle.
ops, err := journal.ReadFile(a.Store.JournalPath(a.Device.ID))
if err != nil {
t.Fatal(err)
}
var put, del *journal.Op
for i := range ops {
switch {
case ops[i].Kind == journal.KindPut && ops[i].Path == "notes/plan.md":
put = &ops[i]
case ops[i].Kind == journal.KindDelete && ops[i].Path == "plan.md":
del = &ops[i]
}
}
if put == nil || del == nil {
t.Fatalf("rename did not journal a put+delete pair: %+v", ops)
}
if put.Device != del.Device {
t.Fatalf("halves on different devices: %q vs %q", put.Device, del.Device)
}
if want := ops[0].Blob; put.Blob != want {
t.Fatalf("the moved file's blob changed: %q, want %q", put.Blob, want)
}
if d := del.Time.Sub(put.Time); d > 30*time.Second || d < -30*time.Second {
t.Fatalf("the halves landed %v apart — wider than the hub's pairing window", d)
}
}
13 changes: 13 additions & 0 deletions internal/webapp/e2e_serve_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,19 @@ func seedE2E(t *testing.T, state, prefix, projectID string) {
// A second version of the same binary, so the history diff has a
// predecessor to refuse to diff (the "binary — no diff" path).
put("assets/logo.png", png+"\x00trailing", 3*time.Hour)
// A file that MOVED: the same blob put at the new path and the old path
// deleted, one device, one cycle — the shape the scanner emits for a
// rename. The old URL has to keep working (BEA-81).
put("old-guide.md", "# Old guide\n\nThis file has been moved.\n", 30*time.Hour)
put("archive/moved-guide.md", "# Old guide\n\nThis file has been moved.\n", 5*time.Hour)
lam++
seq++
ops = append(ops, journal.Op{
Seq: seq, Lamport: lam, Time: now.Add(-5 * time.Hour).Add(time.Second),
Device: "seed", DeviceName: "seed-agent", Author: "alice@x.io",
User: "alice@x.io", UserName: "Alice",
Kind: journal.KindDelete, Path: "old-guide.md",
})
// One removed file, so the history feed has a delete row: deletes have no
// content, so their rows stay unclickable while every other row is now an
// address for its own version.
Expand Down
21 changes: 21 additions & 0 deletions internal/webapp/frontend/e2e/browse.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -808,6 +808,27 @@ test("an old version of an extensionless file previews the same way", async ({ p
await expect(page.locator("#content .empty")).toContainText("That version isn't available.");
});

// BEA-81: an old URL for a file that has since been renamed or dragged into
// a folder still lands on the file, rewrites itself, and says what happened.
test("a moved file's old URL redirects and says so", async ({ page }) => {
await login(page);
const pid = await wikiId(page);
await page.goto(`/${pid}/old-guide.md`);
await page.waitForURL(`/${pid}/archive/moved-guide.md`);
await expect(page.locator("#content")).toContainText("This file has been moved");
await expect(page.locator(".vbanner")).toContainText("Moved from old-guide.md");
// replace, not push: Back must not bounce off the dead URL forever.
await expect(page.locator(".notfound")).toHaveCount(0);
});

test("a path that never existed still gets the not-found card", async ({ page }) => {
await login(page);
const pid = await wikiId(page);
await page.goto(`/${pid}/nothing-here.md`);
await expect(page.locator(".notfound")).toBeVisible();
await expect(page.locator(".vbanner")).toHaveCount(0);
});

// BEA-74: .csv/.tsv render as a table, and anything the parser can't make a
// table of stays the plain-text view it is today.

Expand Down
47 changes: 45 additions & 2 deletions internal/webapp/frontend/src/apps/Browser.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,10 @@ import {
useState,
type ReactNode,
} from "react";
import { useQueryClient } from "@tanstack/react-query";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Button } from "@/components/ui/button";
import { atLeast } from "../api/types";
import { postJSON } from "../api/http";
import { getJSON, postJSON } from "../api/http";
import type { Project, ServerConfig } from "../api/types";
import { useHeat, useTree } from "../hooks/useBrowse";
import { useShares } from "../hooks/useHub";
Expand Down Expand Up @@ -78,6 +78,29 @@ export default function Browser(props: {
const isMissing = !!path && loaded && !isDir && !isFile;
const listingShowing = isDir && !route.view;

/* ---- an address whose file moved ----
Files get renamed and dragged into folders, and the old URL is already
in someone's notes. The server can pair the delete with the put that
carried the same blob, so ask it — but only once the tree says the path
is gone, so the happy path costs nothing. It is a separate call rather
than the X-Bdrive-Canonical-Path header /file answers with, because we
never fetch a missing file at all, and a moved FOLDER has no content
fetch to hang a header on. */
const { data: moved } = useQuery({
queryKey: ["resolve", apiBase, path],
queryFn: () =>
getJSON<{ to: string; kind: string }>(apiBase + "resolve?path=" + encodeURIComponent(path)),
enabled: isMissing,
retry: false, // a 404 here is the normal answer, not a flake
staleTime: 60_000,
});
const [movedFrom, setMovedFrom] = useState<{ from: string; to: string } | null>(null);
useEffect(() => {
if (!isMissing || !moved?.to) return;
setMovedFrom({ from: path, to: moved.to });
navigate(urlForPath(moved.to, project?.id), { replace: true });
}, [isMissing, moved, path, project?.id]);

/* ---- tree expansion ---- */
const [expanded, setExpanded] = useState<Set<string>>(() => new Set());
const firstLoad = useRef(true);
Expand Down Expand Up @@ -464,6 +487,26 @@ export default function Browser(props: {
view = <div className="empty">Select a file to read it.</div>;
}

// Arriving here by redirect: say so, or the URL silently changed under a
// reader who typed the other one. Above whatever the destination renders,
// so a moved folder gets it too.
if (movedFrom && movedFrom.to === path) {
view = (
<>
<div className="vbanner" role="status">
<span className="vb-icon">
<Icon name="link" />
</span>
<div className="vb-text">
<b>Moved from {movedFrom.from}</b>
<span>The URL has been updated.</span>
</div>
</div>
{view}
</>
);
}

const crumb = panel ? (
panel.crumb
) : path ? (
Expand Down
15 changes: 14 additions & 1 deletion internal/webapp/history.go
Original file line number Diff line number Diff line change
Expand Up @@ -216,11 +216,24 @@ func (s *Server) handleHistory(v *volume, w http.ResponseWriter, r *http.Request
op journal.Op
}
visible := s.deviceVisibleIn(projectID(r))
// A file that moved keeps its past — under its old path. ?path= resolves
// through the move chain so the feed for docs/a.md includes the versions
// written while it was a.md. Each hop is time-bounded (see segment), so
// an unrelated NEW a.md created after the move does not leak in. `all`
// is already sorted by journal.Less, so this costs no extra I/O.
var chain []segment
if path != "" {
ops := make([]journal.Op, len(all))
for i, sop := range all {
ops[i] = sop.Op
}
chain = chainSegments(buildMoveIndex(ops), path)
}
matched := make([]timed, 0, len(all))
for i, sop := range all {
op := sop.Op
switch {
case path != "" && op.Path != path:
case path != "" && !inSegments(chain, op.Path, op.Time):
continue
case path == "" && prefix != "" && !strings.HasPrefix(op.Path, strings.TrimSuffix(prefix, "/")+"/"):
continue
Expand Down
Loading
Loading