diff --git a/README.md b/README.md index 37067e27..b6c1b613 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,9 @@ pnpm build pnpm test ``` +## Benchmarks +For benchmark commands, product-facing note/sync scenarios, and the sync target matrix (`direct`, local Postgres sync server, remote sync server), see [docs/BENCHMARKS.md](docs/BENCHMARKS.md). + ## Playground - Live demo (GitHub Pages): https://cybersemics.github.io/treecrdt/ diff --git a/docs/BENCHMARKS.md b/docs/BENCHMARKS.md new file mode 100644 index 00000000..3db3cec1 --- /dev/null +++ b/docs/BENCHMARKS.md @@ -0,0 +1,385 @@ +# Benchmarks + +The benchmark suite is most useful when you treat it as a set of product questions, not just a set of packages. + +## Start Here + +From the repo root: + +```sh +pnpm benchmark +pnpm benchmark:sync:help +``` + +Useful top-level entrypoints: + +```sh +pnpm benchmark +pnpm benchmark:sqlite-node +pnpm benchmark:sqlite-node:ops +pnpm benchmark:sqlite-node:note-paths +pnpm benchmark:sync +pnpm benchmark:sync:direct +pnpm benchmark:sync:local +pnpm benchmark:sync:prime +pnpm benchmark:sync:remote +pnpm benchmark:web +pnpm benchmark:wasm +pnpm benchmark:postgres +``` + +`pnpm benchmark` writes JSON results under `benchmarks/`. + +## Which Benchmark Answers What? + +- First view on a new device, structure only: `benchmark:sync:*` with `sync-balanced-children-cold-start` +- First view on a new device, with payloads: `benchmark:sync:*` with `sync-balanced-children-payloads-cold-start` +- Re-sync the same subtree on a restarted client that already has that scope locally: `benchmark:sync:*` with `sync-balanced-children-resync` or `sync-balanced-children-payloads-resync` +- Single end-to-end time-to-first-visible-page number: `benchmark:sync:*` with the same balanced workloads plus `--first-view` +- Local render cost after the data is already present: `benchmark:sqlite-node:note-paths -- --benches=read-children-payloads` +- Local mutation cost inside a large existing tree: `benchmark:sqlite-node:note-paths -- --benches=insert-into-large-tree` +- Protocol/storage baselines and worst-case stress: `sync-one-missing`, `sync-all`, `sync-children*`, `sync-root-children-fanout10` + +That split is intentional: + +- The sync benches answer "how long until the needed subtree data is in the local store?" +- The note-path benches answer "once the data is local, how quickly can the app render and mutate it?" + +## Recommended Product-Facing Runs + +### First View Sync + +Balanced-tree cold-start sync is the closest current benchmark to "open a node on a fresh device and load the first visible page". + +```sh +pnpm benchmark:sync:direct -- \ + --workloads=sync-balanced-children-cold-start,sync-balanced-children-payloads-cold-start \ + --counts=10000,50000,100000 \ + --fanout=10 +``` + +```sh +pnpm sync-server:postgres:db:start +pnpm benchmark:sync:local -- \ + --workloads=sync-balanced-children-cold-start,sync-balanced-children-payloads-cold-start \ + --counts=10000,50000,100000 \ + --fanout=10 +pnpm sync-server:postgres:db:stop +``` + +```sh +TREECRDT_SYNC_SERVER_URL=ws://host-or-elb/sync \ +pnpm benchmark:sync:remote -- \ + --workloads=sync-balanced-children-cold-start,sync-balanced-children-payloads-cold-start \ + --counts=10000,50000,100000 \ + --fanout=10 +``` + +Use `--fanout=20` when you want to model a broader notebook tree. + +### Re-Sync The Same Subtree + +Balanced-tree re-sync is the closest current benchmark to "restart a client that +already has this subtree locally, then reconcile that same scope again". + +```sh +pnpm sync-server:postgres:db:start +pnpm benchmark:sync:local -- \ + --workloads=sync-balanced-children-resync,sync-balanced-children-payloads-resync \ + --counts=10000,100000 \ + --fanout=10 +pnpm sync-server:postgres:db:stop +``` + +These workloads keep the same balanced immediate-subtree shape as the first-view +benchmarks, but the receiver already has the current scoped result. That means +they measure the normal non-empty scoped reconcile path instead of the +empty-receiver direct-send shortcut. + +### Prime Sync Server Fixtures + +Use this when you want to prebuild sync-server fixtures before running the actual sync benchmarks. + +```sh +pnpm benchmark:sync:prime +``` + +By default this primes the read-only first-view workloads for `10k`, `50k`, and `100k` nodes and forces a rebuild. After that, matching local benchmark runs reuse those fixtures as cache hits instead of reimporting the same large server docs. + +You can still override the forwarded args: + +```sh +pnpm benchmark:sync:prime -- \ + --workloads=sync-balanced-children-payloads-cold-start \ + --counts=50000,100000 \ + --server-fixture-cache=rebuild +``` + +You can also prime the remote target explicitly: + +```sh +TREECRDT_SYNC_SERVER_URL=ws://host-or-elb/sync \ +pnpm benchmark:sync:remote prime -- \ + --workloads=sync-balanced-children-payloads-cold-start \ + --count=10000 \ + --server-fixture-cache=rebuild +``` + +Then benchmark against that already-seeded remote doc without reseeding: + +```sh +TREECRDT_SYNC_SERVER_URL=ws://host-or-elb/sync \ +pnpm benchmark:sync:remote -- \ + --workloads=sync-balanced-children-payloads-cold-start \ + --count=10000 \ + --first-view \ + --server-fixture-cache=reuse +``` + +By default, the local sync target runs the Postgres sync server in a spawned child process so local and remote measurements are closer to each other. When you add `--profile-backend`, the local target intentionally switches to the in-process server so per-backend timings are visible inside the benchmark process. + +Local server benchmarks now seed the Postgres backend directly before the timer starts. That keeps the measured path honest, because the actual sync to the client still goes through the real websocket server, while avoiding huge protocol-seed setup costs that are not part of the benchmark question. + +For read-only local server workloads, the harness now prepares that server fixture once per benchmark case and reuses it across warmup and measured samples. It also reuses the same seeded Postgres fixture across separate benchmark runs by default when the workload definition matches, so repeated `50k/100k` runs do not keep reimporting the same large server doc. + +Use `--server-fixture-cache=rebuild` when you want to force a fresh fixture, or `--server-fixture-cache=off` when you want every run to seed an isolated throwaway fixture. For remote fixtures, `--server-fixture-cache=reuse` assumes the deterministic fixture doc already exists and skips reseeding. + +### Time To First Visible Page + +Add `--first-view` when you want one number that includes: + +- scoped sync into the local store +- the immediate local `childrenPage(...)` read +- payload fetches for the parent and visible children when the workload carries payloads + +```sh +pnpm benchmark:sync:local -- \ + --workloads=sync-balanced-children-payloads-cold-start \ + --counts=10000 \ + --fanout=10 \ + --first-view +``` + +For custom `--count` or `--counts` runs, the sync bench now defaults to multiple measured samples instead of silently falling back to one. Use `--iterations=N` and `--warmup=N` when you want explicit control over stability versus runtime. + +Add `--post-seed-wait-ms=N` when you want to probe whether immediate post-upload backlog is skewing the measured first-view path. This is mainly a debugging aid for remote runs. + +### Upload Benchmarks + +Use prime/upload mode when you want an explicit benchmark for seeding a sync-server doc. + +This measures the full server-fixture creation path and writes a result file under `benchmarks/sqlite-node-sync/server-fixture-*.json` with `durationMs`, `opsPerSec`, and the seeded `fixtureOpCount`. + +```sh +TREECRDT_SYNC_SERVER_URL=ws://host/sync \ +pnpm benchmark:sync:upload:remote -- \ + --workloads=sync-balanced-children-payloads-cold-start \ + --count=10000 \ + --server-fixture-cache=rebuild +``` + +Use this to answer a different question than first-view: + +- `benchmark:sync:remote ... --first-view` answers "how fast can a new device open an existing subtree?" +- `benchmark:sync:upload:remote ...` answers "how long does it take to upload and materialize a large tree on the sync server?" + +### Small-Scope Direct Send + +Add `--direct-send-threshold=N` when you want to experiment with a clean-slate shortcut for small scoped syncs. + +When enabled, if the requesting peer has an empty local result for the requested filter and the responder has at most `N` matching ops, the protocol skips the RIBLT round and sends the scoped ops directly in `opsBatch`. + +This is most relevant for first-view note loading where the client knows the scope root but has not synced its immediate children yet. + +```sh +TREECRDT_POSTGRES_URL=postgres://postgres:postgres@127.0.0.1:55432/postgres \ +pnpm benchmark:sync:local -- \ + --workloads=sync-balanced-children-payloads-cold-start \ + --count=10000 \ + --fanout=10 \ + --first-view \ + --direct-send-threshold=64 +``` + +Add `--max-ops-per-batch=N` when you want to force smaller `opsBatch` messages. This is useful for stress-testing large upload paths and for debugging remote seed behavior where very large inbound batches may monopolize a server task. + +```sh +TREECRDT_SYNC_SERVER_URL=ws://host/sync \ +pnpm benchmark:sync:remote -- \ + --workloads=sync-balanced-children-payloads-cold-start \ + --count=10000 \ + --first-view \ + --direct-send-threshold=64 \ + --max-ops-per-batch=500 +``` + +### Backend Call Profiling + +Add `--profile-backend` when you want per-backend timings for: + +- `listOpRefs` +- `getOpsByOpRefs` +- `applyOps` + +This is especially useful on the local Postgres sync-server target because it shows whether the bottleneck is on the client SQLite side or the server Postgres side. + +```sh +TREECRDT_POSTGRES_URL=postgres://postgres:postgres@127.0.0.1:55432/postgres \ +pnpm benchmark:sync:local -- \ + --workloads=sync-balanced-children-cold-start,sync-balanced-children-payloads-cold-start \ + --count=10000 \ + --fanout=10 \ + --profile-backend +``` + +### Transport Profiling + +Add `--profile-transport` when you want sync message counts, encoded byte counts, and a short event timeline showing where time is spent across the handshake, RIBLT exchange, and ops batches. + +```sh +TREECRDT_POSTGRES_URL=postgres://postgres:postgres@127.0.0.1:55432/postgres \ +pnpm benchmark:sync:local -- \ + --workloads=sync-balanced-children-cold-start,sync-balanced-children-payloads-cold-start \ + --count=10000 \ + --fanout=10 \ + --profile-transport +``` + +### Hello Stage Profiling + +Add `--profile-hello` when you want the responder-side `hello -> helloAck` path broken into internal stages such as: + +- `maxLamport` +- `listOpRefs` +- `filterOutgoingOps` when auth filtering is active +- decoder setup +- `helloAck` send + +This is the right profiler when the coarse transport timeline says `hello -> helloAck` is expensive and you need to know whether that cost is database work, auth filtering, or protocol setup. + +```sh +TREECRDT_POSTGRES_URL=postgres://postgres:postgres@127.0.0.1:55432/postgres \ +pnpm benchmark:sync:local -- \ + --workloads=sync-balanced-children-payloads-cold-start \ + --count=10000 \ + --fanout=10 \ + --first-view \ + --profile-hello +``` + +For `local-postgres-sync-server`, child-process runs capture hello traces by parsing the server process output. For `direct` and in-process debug runs, the benchmark collects the same trace in-process without writing debug noise into the result stream. Remote runs currently only have the coarse transport profile, not internal server hello stages. + +### Local First View Read Path + +This measures the app-shaped local read immediately after sync: fetch the visible children page plus payloads for the parent and those children. + +```sh +pnpm benchmark:sqlite-node:note-paths -- \ + --benches=read-children-payloads \ + --counts=10000,50000,100000 \ + --fanout=10 \ + --page-size=10 \ + --payload-bytes=512 +``` + +### Local Mutation in a Large Tree + +This measures inserting one node with a payload into an already-large balanced tree. + +```sh +pnpm benchmark:sqlite-node:note-paths -- \ + --benches=insert-into-large-tree \ + --counts=10000,50000,100000 \ + --fanout=10 \ + --payload-bytes=512 +``` + +## Sync Targets + +The sync runner supports the same workload definitions across multiple environments: + +- `direct`: in-memory connected peers, no sync server +- `local-postgres-sync-server`: local WebSocket sync server backed by Postgres +- `remote-sync-server`: remote WebSocket sync server + +That keeps the workload constant while you compare transport and backend behavior. + +### Local Postgres Defaults + +```sh +postgres://postgres:postgres@127.0.0.1:5432/postgres +``` + +Override with `TREECRDT_POSTGRES_URL` or `--postgres-url=...`. + +The Docker helper is only a convenience. The local sync benchmark just needs a reachable Postgres URL, so a native local Postgres instance works too: + +```sh +TREECRDT_POSTGRES_URL=postgres://postgres:postgres@127.0.0.1:55432/postgres \ +pnpm benchmark:sync:local -- --workloads=sync-balanced-children-payloads-cold-start --count=10000 +``` + +### Remote Sync Server URL + +The remote URL is intentionally not hardcoded in this repo. Different deployments can have different latency, auth, retention, and scaling settings, so pass it at runtime through `TREECRDT_SYNC_SERVER_URL` or `--sync-server-url=...`. + +For public HTTPS deployments, prefer `wss://.../sync`. Use `ws://.../sync` for local or other plain HTTP deployments. + +## Current Sync Workloads + +Current sync workload definitions live in `packages/treecrdt-benchmark/src/sync.ts`. + +Product-facing defaults: + +- `sync-one-missing`: narrow protocol baseline for a tiny delta +- `sync-balanced-children-cold-start`: new device already knows the scope root and pulls the immediate children of a node from a balanced tree +- `sync-balanced-children-payloads-cold-start`: same balanced-tree cold-start path, plus payloads +- `sync-balanced-children-resync`: same balanced immediate-subtree shape, but the client already has that scoped result locally and re-runs scoped reconcile +- `sync-balanced-children-payloads-resync`: same balanced re-sync path, plus payloads for the scope root and those immediate children + +Specialized or synthetic workloads: + +- `sync-all`: overlapping divergent peers reconcile all ops +- `sync-children`: scoped sync against a synthetic high-fanout parent +- `sync-children-cold-start`: same synthetic high-fanout shape in one-way mode +- `sync-children-payloads`: synthetic high-fanout subtree with payloads +- `sync-children-payloads-cold-start`: one-way version of that same synthetic high-fanout payload case +- `sync-root-children-fanout10`: balanced-tree root-children delta with a move boundary case + +The `sync-children*` workloads are still worth keeping because they act as worst-case or stress-style scoped sync scenarios. They are not the best default proxy for normal note-taking, because they put a very large number of direct children under one parent. + +## Useful Flags + +All sync entrypoints forward arguments to `packages/treecrdt-sqlite-node/scripts/bench-sync.ts`. + +Common sync flags: + +- `--workloads=sync-balanced-children-cold-start,sync-balanced-children-payloads-cold-start` +- `--counts=100,1000,10000` +- `--count=1000` +- `--storages=memory,file` +- `--targets=direct,local-postgres-sync-server` +- `--fanout=10` +- `--first-view` +- `--iterations=5` +- `--warmup=1` +- `--profile-backend` +- `--profile-transport` +- `--profile-hello` +- `--sync-server-url=ws://host/sync` +- `--postgres-url=postgres://...` + +Common note-path flags: + +- `--benches=read-children-payloads,insert-into-large-tree` +- `--counts=10000,50000,100000` +- `--fanout=10` +- `--page-size=10` +- `--payload-bytes=512` + +## What Is Still Missing? + +The remaining gaps are mostly infrastructure-related now: + +- a healthy, repeatable local Postgres bootstrap path that does not depend on a stuck Docker daemon +- a working public websocket deployment path for the remote sync target diff --git a/examples/playground/src/App.tsx b/examples/playground/src/App.tsx index bec96085..6e989600 100644 --- a/examples/playground/src/App.tsx +++ b/examples/playground/src/App.tsx @@ -24,6 +24,7 @@ import { ensureOpfsKey, initialDocId, initialStorage, + makeDefaultDocId, makeNodeId, makeSessionKey, persistDocId, @@ -81,6 +82,13 @@ function initialSyncTransportMode(): SyncTransportMode { return "local"; } +type BulkAddProgress = { + total: number; + completed: number; + phase: "creating" | "applying"; + startedAtMs: number; +}; + export default function App() { const [client, setClient] = useState(null); const clientRef = useRef(null); @@ -104,6 +112,7 @@ export default function App() { overrides: new Set([ROOT_ID]), })); const [busy, setBusy] = useState(false); + const [bulkAddProgress, setBulkAddProgress] = useState(null); const [nodeCount, setNodeCount] = useState(1); const [fanout, setFanout] = useState(10); const [newNodeValue, setNewNodeValue] = useState(""); @@ -155,6 +164,8 @@ export default function App() { const counterRef = useRef(0); const lamportRef = useRef(0); + const initEpochRef = useRef(0); + const disposedRef = useRef(false); const opfsSupport = useMemo(detectOpfsSupport, []); const docPayloadKeyRef = useRef(null); const refreshDocPayloadKey = React.useCallback(async () => { @@ -210,6 +221,7 @@ export default function App() { syncAuth, refreshAuthMaterial, localIdentityChainPromiseRef, + getLocalIdentityChain, authToken, replica, selfPeerId, @@ -585,16 +597,21 @@ export default function App() { online, getMaxLamport, authEnabled, - syncAuth, + authMaterial, authError, joinMode, - authNeedsInvite, authCanSyncAll, viewRootId, + hardRevokedTokenIds, + revocationCutoverEnabled, + revocationCutoverTokenId, + revocationCutoverCounter, treeStateRef, refreshMeta, refreshParents, refreshNodeCount, + getLocalIdentityChain, + onPeerIdentityChain, onAuthGrantMessage, onRemoteOpsApplied, }); @@ -723,13 +740,28 @@ export default function App() { persistDocId(docId); }, [docId]); + const closeClientSafely = React.useCallback(async (closingClient: TreecrdtClient | null) => { + if (!closingClient?.close) return; + try { + await closingClient.close(); + } catch { + // Client teardown is best-effort. HMR/remount/reset can race prior close calls. + } + }, []); + useEffect(() => { + disposedRef.current = false; return () => { - void clientRef.current?.close(); + disposedRef.current = true; + initEpochRef.current += 1; + const closingClient = clientRef.current; + clientRef.current = null; + void closeClientSafely(closingClient); }; - }, []); + }, [closeClientSafely]); - const initClient = async (storageMode: StorageMode, keyOverride?: string) => { + const initClient = async (storageMode: StorageMode, keyOverride?: string, docIdOverride?: string) => { + const initEpoch = ++initEpochRef.current; setStatus("booting"); setError(null); try { @@ -744,8 +776,12 @@ export default function App() { baseUrl, preferWorker: storageMode === "opfs", filename, - docId, + docId: docIdOverride ?? docId, }); + if (disposedRef.current || initEpoch !== initEpochRef.current) { + await closeClientSafely(c); + return; + } clientRef.current = c; setClient(c); setStorage(c.storage); @@ -760,7 +796,8 @@ export default function App() { } }; - const resetAndInit = async (target: StorageMode, opts: { resetKey?: boolean } = {}) => { + const resetAndInit = async (target: StorageMode, opts: { resetKey?: boolean; docId?: string } = {}) => { + setStatus("booting"); const nextKey = target === "opfs" ? opts.resetKey @@ -781,12 +818,15 @@ export default function App() { lamportRef.current = 0; setHeadLamport(0); setTotalNodes(null); - if (clientRef.current?.close) { - await clientRef.current.close(); - } + setParentChoice(ROOT_ID); + setNewNodeValue(""); + setBulkAddProgress(null); + setError(null); + const closingClient = clientRef.current; clientRef.current = null; setClient(null); - await initClient(target, nextKey); + await closeClientSafely(closingClient); + await initClient(target, nextKey, opts.docId); }; const refreshOps = async (nextClient?: TreecrdtClient, opts: { preserveParent?: boolean } = {}) => { @@ -833,7 +873,7 @@ export default function App() { counterRef.current = Math.max(counterRef.current, op.meta.id.counter); setHeadLamport(lamportRef.current); - notifyLocalUpdate(); + notifyLocalUpdate([op]); await refreshPayloadsForNodes(client, nodesAffectedByPayloadOps([op])); ingestOps([op], { assumeSorted: true }); scheduleRefreshParents(parentsAffectedByOps(stateBefore, [op])); @@ -854,7 +894,7 @@ export default function App() { const stateBefore = treeStateRef.current; const placement = after ? { type: "after" as const, after } : { type: "first" as const }; const op = await client.local.move(replica, nodeId, newParent, placement); - notifyLocalUpdate(); + notifyLocalUpdate([op]); ingestOps([op], { assumeSorted: true }); scheduleRefreshParents(parentsAffectedByOps(stateBefore, [op])); scheduleRefreshNodeCount(); @@ -875,6 +915,9 @@ export default function App() { const normalizedCount = Math.max(0, Math.min(MAX_COMPOSER_NODE_COUNT, Math.floor(count))); if (normalizedCount <= 0) return; setBusy(true); + const startedAtMs = Date.now(); + const progressStep = normalizedCount >= 1_000 ? 50 : normalizedCount >= 200 ? 20 : normalizedCount >= 50 ? 5 : 1; + setBulkAddProgress({ total: normalizedCount, completed: 0, phase: "creating", startedAtMs }); try { const stateBefore = treeStateRef.current; const ops: Operation[] = []; @@ -889,6 +932,12 @@ export default function App() { const payload = shouldSetValue ? textEncoder.encode(value) : null; const encryptedPayload = await encryptPayloadBytes(payload); ops.push(await client.local.insert(replica, parentId, nodeId, { type: "last" }, encryptedPayload)); + const completed = i + 1; + if (completed === normalizedCount || completed % progressStep === 0) { + setBulkAddProgress((prev) => + prev ? { ...prev, completed } : prev + ); + } } } else { const expanded = new Set(); @@ -933,16 +982,26 @@ export default function App() { setChildCount(targetParent, childCount + 1); queue.push(nodeId); + const completed = i + 1; + if (completed === normalizedCount || completed % progressStep === 0) { + setBulkAddProgress((prev) => + prev ? { ...prev, completed } : prev + ); + } } } + setBulkAddProgress((prev) => + prev ? { ...prev, completed: normalizedCount, phase: "applying" } : prev + ); + for (const op of ops) { lamportRef.current = Math.max(lamportRef.current, op.meta.lamport); counterRef.current = Math.max(counterRef.current, op.meta.id.counter); } setHeadLamport(lamportRef.current); - notifyLocalUpdate(); + notifyLocalUpdate(ops); await refreshPayloadsForNodes(client, nodesAffectedByPayloadOps(ops)); ingestOps(ops, { assumeSorted: true }); scheduleRefreshParents(parentsAffectedByOps(stateBefore, ops)); @@ -966,6 +1025,7 @@ export default function App() { console.error("Failed to add nodes", err); setError("Failed to add nodes (see console)"); } finally { + setBulkAddProgress(null); setBusy(false); } }; @@ -981,7 +1041,7 @@ export default function App() { const encryptedPayload = await encryptPayloadBytes(payload); const nodeId = makeNodeId(); const op = await client.local.insert(replica, parentId, nodeId, { type: "last" }, encryptedPayload); - notifyLocalUpdate(); + notifyLocalUpdate([op]); await refreshPayloadsForNodes(client, [op.kind.node]); ingestOps([op], { assumeSorted: true }); scheduleRefreshParents(parentsAffectedByOps(stateBefore, [op])); @@ -1053,6 +1113,29 @@ export default function App() { await resetAndInit(storage, { resetKey: true }); }; + const handleNewDoc = async () => { + if (typeof window !== "undefined") { + const url = new URL(window.location.href); + url.searchParams.set("doc", makeDefaultDocId()); + url.searchParams.delete("join"); + url.searchParams.delete("autosync"); + url.hash = ""; + window.history.replaceState({}, "", url); + const nextDocId = url.searchParams.get("doc")!; + setDocId(nextDocId); + setLiveChildrenParents(new Set()); + setLiveAllEnabled(false); + await resetAndInit(storage, { resetKey: true, docId: nextDocId }); + return; + } + + const nextDocId = makeDefaultDocId(); + setDocId(nextDocId); + setLiveChildrenParents(new Set()); + setLiveAllEnabled(false); + await resetAndInit(storage, { resetKey: true, docId: nextDocId }); + }; + const handleStorageToggle = (next: StorageMode) => { if (next === storage) { void handleReset(); @@ -1154,6 +1237,7 @@ export default function App() { ) } onSelectStorage={handleStorageToggle} + onNewDoc={handleNewDoc} onReset={handleReset} onExpandAll={expandAll} onCollapseAll={collapseAll} @@ -1178,6 +1262,7 @@ export default function App() { onAddNodes={handleAddNodes} ready={status === "ready"} busy={busy} + bulkAddProgress={bulkAddProgress} canWritePayload={canWritePayload} canWriteStructure={canWriteStructure} /> diff --git a/examples/playground/src/playground/components/ComposerPanel.tsx b/examples/playground/src/playground/components/ComposerPanel.tsx index 75ffa51c..e8705bed 100644 --- a/examples/playground/src/playground/components/ComposerPanel.tsx +++ b/examples/playground/src/playground/components/ComposerPanel.tsx @@ -3,6 +3,13 @@ import { MdExpandLess, MdExpandMore } from "react-icons/md"; import { ParentPicker } from "./ParentPicker"; +type BulkAddProgress = { + total: number; + completed: number; + phase: "creating" | "applying"; + startedAtMs: number; +}; + export function ComposerPanel({ composerOpen, setComposerOpen, @@ -19,6 +26,7 @@ export function ComposerPanel({ onAddNodes, ready, busy, + bulkAddProgress, canWritePayload, canWriteStructure, }: { @@ -37,11 +45,43 @@ export function ComposerPanel({ onAddNodes: (parentId: string, count: number, opts: { fanout: number }) => void | Promise; ready: boolean; busy: boolean; + bulkAddProgress: BulkAddProgress | null; canWritePayload: boolean; canWriteStructure: boolean; }) { const containerPadding = composerOpen ? "p-5" : "p-3"; const headerMargin = composerOpen ? "mb-3" : "mb-0"; + const [progressNowMs, setProgressNowMs] = React.useState(() => Date.now()); + + React.useEffect(() => { + if (!bulkAddProgress) return; + setProgressNowMs(Date.now()); + const timer = window.setInterval(() => setProgressNowMs(Date.now()), 200); + return () => window.clearInterval(timer); + }, [bulkAddProgress]); + + const elapsedMs = bulkAddProgress ? Math.max(0, progressNowMs - bulkAddProgress.startedAtMs) : 0; + const progressRatio = bulkAddProgress ? Math.min(1, bulkAddProgress.completed / Math.max(1, bulkAddProgress.total)) : 0; + const etaMs = + bulkAddProgress && + bulkAddProgress.phase === "creating" && + bulkAddProgress.completed > 0 && + bulkAddProgress.completed < bulkAddProgress.total + ? Math.round((elapsedMs / bulkAddProgress.completed) * (bulkAddProgress.total - bulkAddProgress.completed)) + : null; + + const formatDuration = (ms: number): string => { + const totalSeconds = Math.max(0, Math.round(ms / 1000)); + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + return minutes > 0 ? `${minutes}m ${String(seconds).padStart(2, "0")}s` : `${seconds}s`; + }; + + const submitLabel = bulkAddProgress + ? bulkAddProgress.phase === "creating" + ? `Loading ${Math.round(progressRatio * 100)}%` + : "Finishing..." + : `Add node${nodeCount > 1 ? "s" : ""}`; return (
{composerOpen ? ( -
{ - e.preventDefault(); - void onAddNodes(parentChoice, nodeCount, { fanout }); - }} - > - - - - - - + + + + + + + {bulkAddProgress ? ( +
+
+ + {bulkAddProgress.phase === "creating" + ? `Creating ${bulkAddProgress.completed} of ${bulkAddProgress.total} nodes` + : `Applying ${bulkAddProgress.total} generated nodes`} + + Elapsed {formatDuration(elapsedMs)} +
+
+
+
+
+ + {bulkAddProgress.phase === "creating" + ? "Preparing inserts locally before the tree view refreshes." + : "Finalizing local tree state and sync updates."} + + + {etaMs !== null ? `ETA ~${formatDuration(etaMs)}` : bulkAddProgress.phase === "applying" ? "ETA settling..." : ""} + +
+
+ ) : null} + ) : null}
); diff --git a/examples/playground/src/playground/components/PeersPanel.tsx b/examples/playground/src/playground/components/PeersPanel.tsx index bd3f58c2..f0251a2b 100644 --- a/examples/playground/src/playground/components/PeersPanel.tsx +++ b/examples/playground/src/playground/components/PeersPanel.tsx @@ -35,7 +35,7 @@ function RemoteStatusIcon({ status }: { status: RemoteSyncStatus }) { return ; } if (status.state === "connecting") { - return ; + return ; } if (status.state === "disabled") { return ; diff --git a/examples/playground/src/playground/components/PlaygroundHeader.tsx b/examples/playground/src/playground/components/PlaygroundHeader.tsx index 18cc01af..ed460fbe 100644 --- a/examples/playground/src/playground/components/PlaygroundHeader.tsx +++ b/examples/playground/src/playground/components/PlaygroundHeader.tsx @@ -13,6 +13,7 @@ export function PlaygroundHeader({ selfPeerIdShort, onCopyPubkey, onSelectStorage, + onNewDoc, onReset, onExpandAll, onCollapseAll, @@ -27,6 +28,7 @@ export function PlaygroundHeader({ selfPeerIdShort: string | null; onCopyPubkey: () => void; onSelectStorage: (next: StorageMode) => void; + onNewDoc: () => void; onReset: () => void; onExpandAll: () => void; onCollapseAll: () => void; @@ -127,6 +129,13 @@ export function PlaygroundHeader({
+