From f63fa549aee61fd65d92b5733930180ae88b99b3 Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Mon, 17 Aug 2026 15:31:30 +0200 Subject: [PATCH] feat(yhub): migrate versioning client to upstream yhub API Update the YHub versioning endpoints to match the upstream yjs/yhub API (v0.3.0+), which introduced versioned URL paths and new response shapes. --- .../13-versioning-yjs14/src/App.tsx | 6 +- .../13-versioning-yjs14/src/seed.ts | 4 +- .../src/y/versioning/__test__/yhub.test.ts | 151 +++++++++++------- packages/core/src/y/versioning/yhub.ts | 64 ++++---- 4 files changed, 132 insertions(+), 93 deletions(-) diff --git a/examples/07-collaboration/13-versioning-yjs14/src/App.tsx b/examples/07-collaboration/13-versioning-yjs14/src/App.tsx index d6e4d45230..0f2ae9ea48 100644 --- a/examples/07-collaboration/13-versioning-yjs14/src/App.tsx +++ b/examples/07-collaboration/13-versioning-yjs14/src/App.tsx @@ -39,7 +39,7 @@ const DAY_MS = 24 * 60 * 60 * 1000; // fields fresh on each call, so writing new values here + re-running `list()` // reshapes the history sidebar against the same document, no editor recreation. const versioningOptions = { - baseUrl: `https://${yhubHost}`, + baseUrl: `https://${yhubHost}/api`, org, docId, // The seeded history has a few hundred edits; a high limit lets the sidebar @@ -108,7 +108,7 @@ const formatMs = (ms: number) => { const doc = new Y.Doc(); const provider = new WebsocketProvider( - `wss://${yhubHost}/ws`, + `wss://${yhubHost}/api/ws/v1`, `${org}/${docId}`, doc, { @@ -128,7 +128,7 @@ const preparePromise: Promise = (async () => { if (!(doc.get("bn").length > 0)) { provider.disconnect(); await seedSampleVersions({ - baseUrl: `https://${yhubHost}`, + baseUrl: `https://${yhubHost}/api`, org, docId, fragment: "bn", diff --git a/examples/07-collaboration/13-versioning-yjs14/src/seed.ts b/examples/07-collaboration/13-versioning-yjs14/src/seed.ts index a4504a3cec..52e1779274 100644 --- a/examples/07-collaboration/13-versioning-yjs14/src/seed.ts +++ b/examples/07-collaboration/13-versioning-yjs14/src/seed.ts @@ -63,7 +63,7 @@ function makeVersionMarkerUpdate(): Uint8Array { * Pre-populate a YHub document with content **and** version history from a * {@link buildEditHistory} result, without a live editor / sync connection. * - * Each step's captured transactions are PATCHed to `/ydoc/{org}/{docId}` as a + * Each step's captured transactions are PATCHed to `/api/ydoc/v1/{org}/{docId}` as a * single ordered `patches` bulk request: one content patch per captured * transaction (attributed via `by`, **no** version marker), followed by one * marker patch carrying a `type:version` custom attribution — the same marker @@ -98,7 +98,7 @@ export async function seedYHubDocument( build: SeedableBuild, ): Promise { const { baseUrl, org, docId, headers = {} } = options; - const url = `${baseUrl}/ydoc/${org}/${docId}`; + const url = `${baseUrl}/ydoc/v1/${org}/${docId}`; const send = async (body: Record) => { const res = await fetch(url, { diff --git a/packages/core/src/y/versioning/__test__/yhub.test.ts b/packages/core/src/y/versioning/__test__/yhub.test.ts index 8535885754..f7c6e55460 100644 --- a/packages/core/src/y/versioning/__test__/yhub.test.ts +++ b/packages/core/src/y/versioning/__test__/yhub.test.ts @@ -66,15 +66,12 @@ const SNAPSHOT_2: VersionSnapshot = { const PATCH_RESPONSE = { success: true, message: "Document updated" }; -function makeChangeset( - opts: { nextDoc?: boolean; attributions?: boolean } = {}, -) { +function makeChangeset(opts: { ydoc?: boolean; attributions?: boolean } = {}) { const doc = new Y.Doc(); const frag = doc.get("default", "XmlFragment"); frag.insert(0, ["hello"]); return { - prevDoc: Y.encodeStateAsUpdate(new Y.Doc()), - ...(opts.nextDoc !== false ? { nextDoc: Y.encodeStateAsUpdate(doc) } : {}), + ...(opts.ydoc !== false ? { ydoc: Y.encodeStateAsUpdate(doc) } : {}), ...(opts.attributions ? { attributions: new Uint8Array([0]) } : {}), }; } @@ -83,7 +80,7 @@ function makeChangeset( // Helpers // --------------------------------------------------------------------------- -const BASE_URL = "https://yhub.test"; +const BASE_URL = "https://yhub.test/api"; const ORG = "test-org"; const DOC_ID = "test-doc"; @@ -162,13 +159,17 @@ describe("createYHubVersioningEndpoints", () => { describe("list", () => { it("returns version-tagged entries using the id attribution as snapshot id", async () => { fetchSpy.mockResolvedValueOnce( - mockFetchResponse([VERSION_ENTRY_2, VERSION_ENTRY_1]), + mockFetchResponse({ activity: [VERSION_ENTRY_2, VERSION_ENTRY_1] }), ); // Current-version probe: latest edit of any kind, then latest version // marker. Both are VERSION_ENTRY_2, so latest edit == latest marker → no // synthetic "current version" entry. - fetchSpy.mockResolvedValueOnce(mockFetchResponse([VERSION_ENTRY_2])); - fetchSpy.mockResolvedValueOnce(mockFetchResponse([VERSION_ENTRY_2])); + fetchSpy.mockResolvedValueOnce( + mockFetchResponse({ activity: [VERSION_ENTRY_2] }), + ); + fetchSpy.mockResolvedValueOnce( + mockFetchResponse({ activity: [VERSION_ENTRY_2] }), + ); const endpoints = makeEndpoints(); const snapshots = await endpoints.list(); @@ -188,16 +189,16 @@ describe("createYHubVersioningEndpoints", () => { it("fetches the full activity timeline (no type:version filter) with grouping defaults", async () => { // 1: full activity timeline. 2: latest edit of any kind. 3: latest // version marker (the current-version probe makes both 2 & 3). - fetchSpy.mockResolvedValueOnce(mockFetchResponse([])); - fetchSpy.mockResolvedValueOnce(mockFetchResponse([])); - fetchSpy.mockResolvedValueOnce(mockFetchResponse([])); + fetchSpy.mockResolvedValueOnce(mockFetchResponse({ activity: [] })); + fetchSpy.mockResolvedValueOnce(mockFetchResponse({ activity: [] })); + fetchSpy.mockResolvedValueOnce(mockFetchResponse({ activity: [] })); const endpoints = makeEndpoints(); await endpoints.list(); expect(fetchSpy).toHaveBeenCalledTimes(3); const versionUrl = new URL(fetchSpy.mock.calls[0][0] as string); - expect(versionUrl.pathname).toBe(`/activity/${ORG}/${DOC_ID}`); + expect(versionUrl.pathname).toBe(`/api/activity/v1/${ORG}/${DOC_ID}`); // The `type:version` overlay filter is dropped so history entries are // returned too. expect(versionUrl.searchParams.get("withCustomAttributions")).toBe(null); @@ -207,14 +208,14 @@ describe("createYHubVersioningEndpoints", () => { // Probe A: the latest entry of *any* type (no marker filter). const latestUrl = new URL(fetchSpy.mock.calls[1][0] as string); - expect(latestUrl.pathname).toBe(`/activity/${ORG}/${DOC_ID}`); + expect(latestUrl.pathname).toBe(`/api/activity/v1/${ORG}/${DOC_ID}`); expect(latestUrl.searchParams.get("limit")).toBe("1"); expect(latestUrl.searchParams.has("withCustomAttributions")).toBe(false); // Probe B: the latest *version marker*, server-filtered to `type:version` // so grouping/mergeUsers can't conflate it with a later edit. const markerUrl = new URL(fetchSpy.mock.calls[2][0] as string); - expect(markerUrl.pathname).toBe(`/activity/${ORG}/${DOC_ID}`); + expect(markerUrl.pathname).toBe(`/api/activity/v1/${ORG}/${DOC_ID}`); expect(markerUrl.searchParams.get("limit")).toBe("1"); expect(markerUrl.searchParams.get("withCustomAttributions")).toBe( "type:version", @@ -222,9 +223,9 @@ describe("createYHubVersioningEndpoints", () => { }); it("forwards group + groupMaxDuration params when configured", async () => { - fetchSpy.mockResolvedValueOnce(mockFetchResponse([])); - fetchSpy.mockResolvedValueOnce(mockFetchResponse([])); - fetchSpy.mockResolvedValueOnce(mockFetchResponse([])); + fetchSpy.mockResolvedValueOnce(mockFetchResponse({ activity: [] })); + fetchSpy.mockResolvedValueOnce(mockFetchResponse({ activity: [] })); + fetchSpy.mockResolvedValueOnce(mockFetchResponse({ activity: [] })); const endpoints = createYHubVersioningEndpoints({ baseUrl: BASE_URL, @@ -242,9 +243,9 @@ describe("createYHubVersioningEndpoints", () => { }); it("omits group params by default while keeping the groupMaxGap default", async () => { - fetchSpy.mockResolvedValueOnce(mockFetchResponse([])); - fetchSpy.mockResolvedValueOnce(mockFetchResponse([])); - fetchSpy.mockResolvedValueOnce(mockFetchResponse([])); + fetchSpy.mockResolvedValueOnce(mockFetchResponse({ activity: [] })); + fetchSpy.mockResolvedValueOnce(mockFetchResponse({ activity: [] })); + fetchSpy.mockResolvedValueOnce(mockFetchResponse({ activity: [] })); // `makeEndpoints` builds the factory with no group/groupMaxDuration opts. const endpoints = makeEndpoints(); @@ -275,12 +276,16 @@ describe("createYHubVersioningEndpoints", () => { by: "user-2", }; fetchSpy.mockResolvedValueOnce( - mockFetchResponse([namedEntry, historyEntry]), + mockFetchResponse({ activity: [namedEntry, historyEntry] }), ); // Current-version probe: latest edit == latest marker == `namedEntry`, so // no synthetic current row. - fetchSpy.mockResolvedValueOnce(mockFetchResponse([namedEntry])); - fetchSpy.mockResolvedValueOnce(mockFetchResponse([namedEntry])); + fetchSpy.mockResolvedValueOnce( + mockFetchResponse({ activity: [namedEntry] }), + ); + fetchSpy.mockResolvedValueOnce( + mockFetchResponse({ activity: [namedEntry] }), + ); const endpoints = makeEndpoints(); const snapshots = await endpoints.list(); @@ -297,9 +302,9 @@ describe("createYHubVersioningEndpoints", () => { }); it("returns empty array when no versions exist", async () => { - fetchSpy.mockResolvedValueOnce(mockFetchResponse([])); - fetchSpy.mockResolvedValueOnce(mockFetchResponse([])); - fetchSpy.mockResolvedValueOnce(mockFetchResponse([])); + fetchSpy.mockResolvedValueOnce(mockFetchResponse({ activity: [] })); + fetchSpy.mockResolvedValueOnce(mockFetchResponse({ activity: [] })); + fetchSpy.mockResolvedValueOnce(mockFetchResponse({ activity: [] })); const endpoints = makeEndpoints(); const snapshots = await endpoints.list(); @@ -309,10 +314,14 @@ describe("createYHubVersioningEndpoints", () => { it("sorts snapshots newest-first", async () => { fetchSpy.mockResolvedValueOnce( - mockFetchResponse([VERSION_ENTRY_1, VERSION_ENTRY_2]), + mockFetchResponse({ activity: [VERSION_ENTRY_1, VERSION_ENTRY_2] }), + ); + fetchSpy.mockResolvedValueOnce( + mockFetchResponse({ activity: [VERSION_ENTRY_2] }), + ); + fetchSpy.mockResolvedValueOnce( + mockFetchResponse({ activity: [VERSION_ENTRY_2] }), ); - fetchSpy.mockResolvedValueOnce(mockFetchResponse([VERSION_ENTRY_2])); - fetchSpy.mockResolvedValueOnce(mockFetchResponse([VERSION_ENTRY_2])); const endpoints = makeEndpoints(); const snapshots = await endpoints.list(); @@ -328,10 +337,14 @@ describe("createYHubVersioningEndpoints", () => { customAttributions: [{ k: "type", v: "version" }], }; fetchSpy.mockResolvedValueOnce( - mockFetchResponse([VERSION_ENTRY_1, noIdEntry]), + mockFetchResponse({ activity: [VERSION_ENTRY_1, noIdEntry] }), + ); + fetchSpy.mockResolvedValueOnce( + mockFetchResponse({ activity: [VERSION_ENTRY_1] }), + ); + fetchSpy.mockResolvedValueOnce( + mockFetchResponse({ activity: [VERSION_ENTRY_1] }), ); - fetchSpy.mockResolvedValueOnce(mockFetchResponse([VERSION_ENTRY_1])); - fetchSpy.mockResolvedValueOnce(mockFetchResponse([VERSION_ENTRY_1])); const endpoints = makeEndpoints(); const snapshots = await endpoints.list(); @@ -361,9 +374,15 @@ describe("createYHubVersioningEndpoints", () => { }; // 1: activity fetch. 2 & 3: current-version probe (latest edit, latest // marker) — same entry both times, so no newer edit and no current row. - fetchSpy.mockResolvedValueOnce(mockFetchResponse([versionEntry])); - fetchSpy.mockResolvedValueOnce(mockFetchResponse([versionEntry])); - fetchSpy.mockResolvedValueOnce(mockFetchResponse([versionEntry])); + fetchSpy.mockResolvedValueOnce( + mockFetchResponse({ activity: [versionEntry] }), + ); + fetchSpy.mockResolvedValueOnce( + mockFetchResponse({ activity: [versionEntry] }), + ); + fetchSpy.mockResolvedValueOnce( + mockFetchResponse({ activity: [versionEntry] }), + ); const snapshots = await endpoints.list(); @@ -373,9 +392,15 @@ describe("createYHubVersioningEndpoints", () => { }); it("falls back to the attribution name when the store has no entry", async () => { - fetchSpy.mockResolvedValueOnce(mockFetchResponse([VERSION_ENTRY_1])); - fetchSpy.mockResolvedValueOnce(mockFetchResponse([VERSION_ENTRY_1])); - fetchSpy.mockResolvedValueOnce(mockFetchResponse([VERSION_ENTRY_1])); + fetchSpy.mockResolvedValueOnce( + mockFetchResponse({ activity: [VERSION_ENTRY_1] }), + ); + fetchSpy.mockResolvedValueOnce( + mockFetchResponse({ activity: [VERSION_ENTRY_1] }), + ); + fetchSpy.mockResolvedValueOnce( + mockFetchResponse({ activity: [VERSION_ENTRY_1] }), + ); const endpoints = makeEndpoints(); const snapshots = await endpoints.list(); @@ -395,10 +420,14 @@ describe("createYHubVersioningEndpoints", () => { // version marker (VERSION_ENTRY_2). Since latestEdit.to > VERSION_ENTRY_2.to // a synthetic current-version row is prepended. fetchSpy.mockResolvedValueOnce( - mockFetchResponse([VERSION_ENTRY_2, VERSION_ENTRY_1]), + mockFetchResponse({ activity: [VERSION_ENTRY_2, VERSION_ENTRY_1] }), + ); + fetchSpy.mockResolvedValueOnce( + mockFetchResponse({ activity: [latestEdit] }), + ); + fetchSpy.mockResolvedValueOnce( + mockFetchResponse({ activity: [VERSION_ENTRY_2] }), ); - fetchSpy.mockResolvedValueOnce(mockFetchResponse([latestEdit])); - fetchSpy.mockResolvedValueOnce(mockFetchResponse([VERSION_ENTRY_2])); const endpoints = makeEndpoints(); const snapshots = await endpoints.list(); @@ -427,12 +456,18 @@ describe("createYHubVersioningEndpoints", () => { }; // 1: activity list — newest entry is the history edit, not a marker. fetchSpy.mockResolvedValueOnce( - mockFetchResponse([historyEdit, VERSION_ENTRY_2, VERSION_ENTRY_1]), + mockFetchResponse({ + activity: [historyEdit, VERSION_ENTRY_2, VERSION_ENTRY_1], + }), ); // 2: latest edit of any kind → the history edit. - fetchSpy.mockResolvedValueOnce(mockFetchResponse([historyEdit])); + fetchSpy.mockResolvedValueOnce( + mockFetchResponse({ activity: [historyEdit] }), + ); // 3: latest version marker → VERSION_ENTRY_2 (older than the edit). - fetchSpy.mockResolvedValueOnce(mockFetchResponse([VERSION_ENTRY_2])); + fetchSpy.mockResolvedValueOnce( + mockFetchResponse({ activity: [VERSION_ENTRY_2] }), + ); const endpoints = makeEndpoints(); const snapshots = await endpoints.list(); @@ -443,9 +478,9 @@ describe("createYHubVersioningEndpoints", () => { }); it("forwards the mergeUsers param when configured", async () => { - fetchSpy.mockResolvedValueOnce(mockFetchResponse([])); - fetchSpy.mockResolvedValueOnce(mockFetchResponse([])); - fetchSpy.mockResolvedValueOnce(mockFetchResponse([])); + fetchSpy.mockResolvedValueOnce(mockFetchResponse({ activity: [] })); + fetchSpy.mockResolvedValueOnce(mockFetchResponse({ activity: [] })); + fetchSpy.mockResolvedValueOnce(mockFetchResponse({ activity: [] })); const endpoints = createYHubVersioningEndpoints({ baseUrl: BASE_URL, @@ -475,7 +510,7 @@ describe("createYHubVersioningEndpoints", () => { // Only one fetch call (PATCH) — no activity fetch expect(fetchSpy).toHaveBeenCalledOnce(); const [patchUrl, patchInit] = fetchSpy.mock.calls[0]; - expect(patchUrl).toBe(`${BASE_URL}/ydoc/${ORG}/${DOC_ID}`); + expect(patchUrl).toBe(`${BASE_URL}/ydoc/v1/${ORG}/${DOC_ID}`); expect(patchInit.method).toBe("PATCH"); expect(patchInit.body).toBeInstanceOf(Uint8Array); @@ -510,9 +545,9 @@ describe("createYHubVersioningEndpoints", () => { }); expect(snapshot.by).toBe("user-1"); - // The PATCH is attributed to the same user. + // The PATCH is attributed to the same user via the body, not query params. const patchUrl = new URL(fetchSpy.mock.calls[0][0] as string); - expect(patchUrl.searchParams.get("userid")).toBe("user-1"); + expect(patchUrl.searchParams.has("userid")).toBe(false); }); it("creates a version without a name", async () => { @@ -587,17 +622,15 @@ describe("createYHubVersioningEndpoints", () => { // changeset reconstructed by timestamp, NOT by custom attribution const url = new URL(fetchSpy.mock.calls[0][0] as string); - expect(url.pathname).toBe(`/changeset/${ORG}/${DOC_ID}`); + expect(url.pathname).toBe(`/api/changeset/v1/${ORG}/${DOC_ID}`); expect(url.searchParams.get("ydoc")).toBe("true"); expect(url.searchParams.get("to")).toBe(String(SNAPSHOT_1.createdAt)); expect(url.searchParams.has("from")).toBe(false); expect(url.searchParams.has("withCustomAttributions")).toBe(false); }); - it("throws when changeset has no nextDoc", async () => { - fetchSpy.mockResolvedValueOnce( - mockFetchResponse({ prevDoc: new Uint8Array() }), - ); + it("throws when changeset has no ydoc", async () => { + fetchSpy.mockResolvedValueOnce(mockFetchResponse({})); const endpoints = makeEndpoints(); await expect(endpoints.getContent(SNAPSHOT_1)).rejects.toThrow( @@ -653,7 +686,7 @@ describe("createYHubVersioningEndpoints", () => { // changeset without attributions fetchSpy.mockResolvedValueOnce( - mockFetchResponse({ nextDoc: new Uint8Array() }), + mockFetchResponse({ ydoc: new Uint8Array() }), ); await expect(endpoints.getAttributions!(SNAPSHOT_1)).rejects.toThrow( @@ -682,12 +715,12 @@ describe("createYHubVersioningEndpoints", () => { // 1st call: GET changeset by timestamp const csUrl = new URL(fetchSpy.mock.calls[0][0] as string); - expect(csUrl.pathname).toBe(`/changeset/${ORG}/${DOC_ID}`); + expect(csUrl.pathname).toBe(`/api/changeset/v1/${ORG}/${DOC_ID}`); expect(csUrl.searchParams.get("to")).toBe(String(SNAPSHOT_1.createdAt)); // 2nd call: POST rollback const [rollbackUrl, rollbackInit] = fetchSpy.mock.calls[1]; - expect(rollbackUrl).toContain(`/rollback/${ORG}/${DOC_ID}`); + expect(rollbackUrl).toBe(`${BASE_URL}/rollback/v1/${ORG}/${DOC_ID}`); expect(rollbackInit.method).toBe("POST"); expect(content).toBeInstanceOf(Uint8Array); diff --git a/packages/core/src/y/versioning/yhub.ts b/packages/core/src/y/versioning/yhub.ts index d5cf07c019..56a93d71eb 100644 --- a/packages/core/src/y/versioning/yhub.ts +++ b/packages/core/src/y/versioning/yhub.ts @@ -25,7 +25,8 @@ const VERSION_NAMES_MAP = "__bn_version_names"; */ export interface YHubVersioningOptions { /** - * Base URL of the YHub API (e.g. `"https://yhub.example.com"`). + * Base URL of the YHub API, including the API prefix + * (e.g. `"https://yhub.example.com/api"`). * Must **not** include a trailing slash. */ baseUrl: string; @@ -79,7 +80,7 @@ export interface YHubVersioningOptions { /** * Shape of a single activity entry returned by the YHub - * `GET /activity/{org}/{docId}` endpoint (after `decodeAny`). + * `GET /api/activity/v1/{org}/{docId}` endpoint (after `decodeAny`). */ interface YHubActivityEntry { /** Start of the change window (unix-ms timestamp). */ @@ -93,14 +94,12 @@ interface YHubActivityEntry { } /** - * Shape returned by the YHub `GET /changeset/{org}/{docId}` endpoint (after - * `decodeAny`). + * Shape returned by the YHub `GET /api/changeset/v1/{org}/{docId}` endpoint + * (after `decodeAny`). */ interface YHubChangeset { - /** Full Y.Doc state **before** the changeset window. */ - prevDoc?: Uint8Array; - /** Full Y.Doc state **after** the changeset window. */ - nextDoc?: Uint8Array; + /** Full Y.Doc state at the `to` timestamp. */ + ydoc?: Uint8Array; /** * Encoded {@link Y.ContentMap} describing who authored each change in the * window and when. Present when the changeset is requested with @@ -109,6 +108,11 @@ interface YHubChangeset { attributions?: Uint8Array; } +/** Shape returned by the YHub activity endpoint. */ +interface YHubActivityResponse { + activity: YHubActivityEntry[]; +} + /** * Whether an activity entry is a version marker (created with a `type:version` * custom attribution) as opposed to a plain edit. @@ -228,7 +232,7 @@ async function yhubFetch( * user: { name: "Alice", color: "#ff0" }, * provider, * versioningEndpoints: createYHubVersioningEndpoints({ - * baseUrl: "https://yhub.example.com", + * baseUrl: "https://yhub.example.com/api", * org: "my-org", * docId: "my-doc", * }), @@ -249,9 +253,10 @@ export function createYHubVersioningEndpoints( group, } = options; - const activityUrl = `${baseUrl}/activity/${org}/${docId}`; - const changesetUrl = `${baseUrl}/changeset/${org}/${docId}`; - const rollbackUrl = `${baseUrl}/rollback/${org}/${docId}`; + const activityUrl = `${baseUrl}/activity/v1/${org}/${docId}`; + const changesetUrl = `${baseUrl}/changeset/v1/${org}/${docId}`; + const rollbackUrl = `${baseUrl}/rollback/v1/${org}/${docId}`; + const ydocUrl = `${baseUrl}/ydoc/v1/${org}/${docId}`; return (editor) => { /** @@ -309,11 +314,11 @@ export function createYHubVersioningEndpoints( yhubFetch(`${activityUrl}?${latestVersionParams}`, headers), ]); const latestEdit = ( - decodeAny(new Uint8Array(latestBuf)) as YHubActivityEntry[] - )[0]; + decodeAny(new Uint8Array(latestBuf)) as YHubActivityResponse + ).activity[0]; const latestVersion = ( - decodeAny(new Uint8Array(latestVersionBuf)) as YHubActivityEntry[] - )[0]; + decodeAny(new Uint8Array(latestVersionBuf)) as YHubActivityResponse + ).activity[0]; if (!latestEdit || latestEdit.to <= (latestVersion?.to ?? 0)) { return undefined; @@ -370,15 +375,14 @@ export function createYHubVersioningEndpoints( const update = Y.encodeStateAsUpdate(markerDoc); const body: Record = { update, customAttributions }; + if (by) { + body.by = by; + } - await yhubFetch( - `${baseUrl}/ydoc/${org}/${docId}${by ? `?userid=${by}` : ""}`, - headers, - { - method: "PATCH", - body: encodeAny(body) as BufferSource, - }, - ); + await yhubFetch(ydocUrl, headers, { + method: "PATCH", + body: encodeAny(body) as BufferSource, + }); }; /** @@ -422,7 +426,7 @@ export function createYHubVersioningEndpoints( /** * Reconstruct the full document state as it was at a given `to` timestamp. * - * The changeset endpoint builds `nextDoc` purely from the `to` timestamp + * The changeset endpoint builds `ydoc` purely from the `to` timestamp * range — it ignores `withCustomAttributions` for doc reconstruction (that * filter only scopes the attribution overlay). So historical document state * can only be retrieved by timestamp, never by the version's `id`. @@ -436,11 +440,11 @@ export function createYHubVersioningEndpoints( const buf = await yhubFetch(`${changesetUrl}?${params}`, headers); const changeset = decodeAny(new Uint8Array(buf)) as YHubChangeset; - if (!changeset.nextDoc) { + if (!changeset.ydoc) { throw new Error(`YHub returned no document state at timestamp ${to}.`); } - return Y.convertUpdateFormatV1ToV2(changeset.nextDoc); + return Y.convertUpdateFormatV1ToV2(changeset.ydoc); }; /** @@ -504,7 +508,7 @@ export function createYHubVersioningEndpoints( const to = snapshot.createdAt; const snapshotContent = await getContentAt(to); - await yhubFetch(`${rollbackUrl}?from=${to}`, headers, { + await yhubFetch(rollbackUrl, headers, { method: "POST", body: encodeAny({ from: to }) as BufferSource, }); @@ -587,7 +591,9 @@ export function createYHubVersioningEndpoints( } const buf = await yhubFetch(`${activityUrl}?${params}`, headers); - const entries = decodeAny(new Uint8Array(buf)) as YHubActivityEntry[]; + const { activity: entries } = decodeAny( + new Uint8Array(buf), + ) as YHubActivityResponse; const snapshots = sortSnapshotsNewestFirst( entries