Skip to content

Commit 1949208

Browse files
committed
feat: add YHub versioning endpoints
1 parent 7c35493 commit 1949208

2 files changed

Lines changed: 299 additions & 0 deletions

File tree

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from "./yhub.js";
Lines changed: 298 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,298 @@
1+
import * as Y from "@y/y";
2+
import { decodeAny, encodeAny } from "lib0/buffer";
3+
4+
import {
5+
sortSnapshotsNewestFirst,
6+
type CreateSnapshotOptions,
7+
type VersioningEndpoints,
8+
type VersionSnapshot,
9+
} from "../../extensions/Versioning/index.js";
10+
11+
// ---------------------------------------------------------------------------
12+
// Types
13+
// ---------------------------------------------------------------------------
14+
15+
/**
16+
* Options for creating a YHub versioning endpoints instance.
17+
*/
18+
export interface YHubVersioningOptions {
19+
/**
20+
* Base URL of the YHub API (e.g. `"https://yhub.example.com"`).
21+
* Must **not** include a trailing slash.
22+
*/
23+
baseUrl: string;
24+
25+
/** YHub organisation identifier. */
26+
org: string;
27+
28+
/** Document identifier within the organisation. */
29+
docId: string;
30+
31+
/**
32+
* Optional headers to include in every request (e.g. authentication tokens).
33+
*/
34+
headers?: Record<string, string>;
35+
36+
/**
37+
* Maximum number of activity entries to fetch when listing versions.
38+
* @default 50
39+
*/
40+
activityLimit?: number;
41+
}
42+
43+
/**
44+
* Shape of a single activity entry returned by the YHub
45+
* `GET /activity/{org}/{docId}` endpoint (after `decodeAny`).
46+
*/
47+
interface YHubActivityEntry {
48+
/** Start of the change window (unix-ms timestamp). */
49+
from: number;
50+
/** End of the change window (unix-ms timestamp). */
51+
to: number;
52+
/** User who authored the change (when `customAttributions` is enabled). */
53+
by?: string;
54+
}
55+
56+
/**
57+
* Shape returned by the YHub `GET /changeset/{org}/{docId}` endpoint (after
58+
* `decodeAny`).
59+
*/
60+
interface YHubChangeset {
61+
/** Full Y.Doc state **before** the changeset window. */
62+
prevDoc?: Uint8Array;
63+
/** Full Y.Doc state **after** the changeset window. */
64+
nextDoc?: Uint8Array;
65+
}
66+
67+
// ---------------------------------------------------------------------------
68+
// Helpers
69+
// ---------------------------------------------------------------------------
70+
71+
/** Snapshot-metadata store (names & options that aren't tracked by YHub). */
72+
interface SnapshotMeta {
73+
name?: string;
74+
restoredFromSnapshotId?: string;
75+
}
76+
77+
/**
78+
* Convert a YHub activity entry into a {@link VersionSnapshot}.
79+
*/
80+
function activityToSnapshot(
81+
entry: YHubActivityEntry,
82+
meta?: SnapshotMeta,
83+
): VersionSnapshot {
84+
return {
85+
id: String(entry.to),
86+
name: meta?.name,
87+
createdAt: entry.from,
88+
updatedAt: entry.to,
89+
secondaryLabel: entry.by,
90+
restoredFromSnapshotId: meta?.restoredFromSnapshotId,
91+
};
92+
}
93+
94+
async function yhubFetch(
95+
url: string,
96+
headers: Record<string, string>,
97+
init?: RequestInit,
98+
): Promise<ArrayBuffer> {
99+
const res = await fetch(url, {
100+
...init,
101+
headers: { ...headers, ...init?.headers },
102+
});
103+
if (!res.ok) {
104+
throw new Error(
105+
`YHub request failed: ${res.status} ${res.statusText} (${url})`,
106+
);
107+
}
108+
return res.arrayBuffer();
109+
}
110+
111+
// ---------------------------------------------------------------------------
112+
// Factory
113+
// ---------------------------------------------------------------------------
114+
115+
/**
116+
* Create a {@link VersioningEndpoints} implementation backed by the
117+
* [YHub](https://github.com/yjs/yhub) HTTP API.
118+
*
119+
* YHub stores continuous edit history rather than discrete snapshots. This
120+
* adapter maps YHub's *activity* entries to {@link VersionSnapshot}s so they
121+
* can be listed, previewed, and restored through BlockNote's versioning UI.
122+
*
123+
* @example
124+
* ```ts
125+
* import { withCollaboration } from "@blocknote/core/y";
126+
* import { createYHubVersioningEndpoints } from "@blocknote/core/y";
127+
*
128+
* const editor = BlockNoteEditor.create(
129+
* withCollaboration({
130+
* collaboration: {
131+
* fragment,
132+
* user: { name: "Alice", color: "#ff0" },
133+
* provider,
134+
* versioningEndpoints: createYHubVersioningEndpoints({
135+
* baseUrl: "https://yhub.example.com",
136+
* org: "my-org",
137+
* docId: "my-doc",
138+
* }),
139+
* },
140+
* }),
141+
* );
142+
* ```
143+
*/
144+
export function createYHubVersioningEndpoints(
145+
options: YHubVersioningOptions,
146+
): VersioningEndpoints<Y.Type, Uint8Array> {
147+
const { baseUrl, org, docId, headers = {}, activityLimit = 50 } = options;
148+
149+
const activityUrl = `${baseUrl}/activity/${org}/${docId}`;
150+
const changesetUrl = `${baseUrl}/changeset/${org}/${docId}`;
151+
const rollbackUrl = `${baseUrl}/rollback/${org}/${docId}`;
152+
153+
// Client-side metadata (names, restored-from links) keyed by snapshot id.
154+
const metaMap = new Map<string, SnapshotMeta>();
155+
156+
// ------------------------------------------------------------------
157+
// list
158+
// ------------------------------------------------------------------
159+
const list: VersioningEndpoints<Y.Type, Uint8Array>["list"] = async () => {
160+
const params = new URLSearchParams({
161+
order: "desc",
162+
limit: String(activityLimit),
163+
group: "true",
164+
customAttributions: "true",
165+
});
166+
167+
const buf = await yhubFetch(`${activityUrl}?${params}`, headers);
168+
const entries = decodeAny(new Uint8Array(buf)) as YHubActivityEntry[];
169+
170+
return sortSnapshotsNewestFirst(
171+
entries.map((e) => activityToSnapshot(e, metaMap.get(String(e.to)))),
172+
);
173+
};
174+
175+
// ------------------------------------------------------------------
176+
// create
177+
// ------------------------------------------------------------------
178+
const create: VersioningEndpoints<Y.Type, Uint8Array>["create"] = async (
179+
fragment: Y.Type,
180+
opts?: CreateSnapshotOptions,
181+
) => {
182+
const doc = fragment.doc;
183+
if (!doc) {
184+
throw new Error(
185+
"Cannot create snapshot: the Y.Type is not attached to a Y.Doc.",
186+
);
187+
}
188+
189+
// Encode the current document state.
190+
const update = Y.encodeStateAsUpdateV2(doc);
191+
192+
// Persist via PATCH /ydoc to make sure the current state is stored.
193+
await yhubFetch(`${baseUrl}/ydoc/${org}/${docId}`, headers, {
194+
method: "PATCH",
195+
body: encodeAny({ update }) as Blob | BufferSource,
196+
});
197+
198+
const now = Date.now();
199+
const id = String(now);
200+
201+
const meta: SnapshotMeta = {
202+
name: opts?.name,
203+
restoredFromSnapshotId: opts?.restoredFromSnapshotId,
204+
};
205+
metaMap.set(id, meta);
206+
207+
return {
208+
id,
209+
name: opts?.name,
210+
createdAt: now,
211+
updatedAt: now,
212+
restoredFromSnapshotId: opts?.restoredFromSnapshotId,
213+
};
214+
};
215+
216+
// ------------------------------------------------------------------
217+
// getContent
218+
// ------------------------------------------------------------------
219+
const getContent: VersioningEndpoints<
220+
Y.Type,
221+
Uint8Array
222+
>["getContent"] = async (id: string) => {
223+
const to = Number(id);
224+
const params = new URLSearchParams({
225+
from: "0",
226+
to: String(to),
227+
ydoc: "true",
228+
});
229+
230+
const buf = await yhubFetch(`${changesetUrl}?${params}`, headers);
231+
const changeset = decodeAny(new Uint8Array(buf)) as YHubChangeset;
232+
233+
if (!changeset.nextDoc) {
234+
throw new Error(`YHub returned no document state for snapshot ${id}.`);
235+
}
236+
237+
return changeset.nextDoc;
238+
};
239+
240+
// ------------------------------------------------------------------
241+
// restore
242+
// ------------------------------------------------------------------
243+
const restore: VersioningEndpoints<Y.Type, Uint8Array>["restore"] = async (
244+
fragment: Y.Type,
245+
id: string,
246+
) => {
247+
// 1. Create a backup snapshot of the current state.
248+
await create(fragment, { name: "Backup" });
249+
250+
// 2. Get the content of the target snapshot.
251+
const snapshotContent = await getContent(id);
252+
253+
// 3. Issue a rollback via YHub. Rolling back everything after the target
254+
// timestamp effectively restores the document to that point.
255+
const to = Number(id);
256+
await yhubFetch(`${rollbackUrl}?from=${to}`, headers, {
257+
method: "POST",
258+
body: encodeAny({ from: to, customAttributions: true }) as
259+
| Blob
260+
| BufferSource,
261+
});
262+
263+
// 4. Record metadata for the restored snapshot.
264+
const restoredSnapshot = await create(fragment, {
265+
name: "Restored Snapshot",
266+
restoredFromSnapshotId: id,
267+
});
268+
269+
metaMap.set(restoredSnapshot.id, {
270+
name: "Restored Snapshot",
271+
restoredFromSnapshotId: id,
272+
});
273+
274+
return snapshotContent;
275+
};
276+
277+
// ------------------------------------------------------------------
278+
// updateSnapshotName
279+
// ------------------------------------------------------------------
280+
const updateSnapshotName: VersioningEndpoints<
281+
Y.Type,
282+
Uint8Array
283+
>["updateSnapshotName"] = async (id: string, name?: string) => {
284+
const existing = metaMap.get(id) ?? {};
285+
metaMap.set(id, { ...existing, name });
286+
};
287+
288+
// ------------------------------------------------------------------
289+
// Return
290+
// ------------------------------------------------------------------
291+
return {
292+
list,
293+
create,
294+
getContent,
295+
restore,
296+
updateSnapshotName,
297+
};
298+
}

0 commit comments

Comments
 (0)