Skip to content

Commit

Permalink
feat: Version history
Browse files Browse the repository at this point in the history
  • Loading branch information
areknawo committed Jun 19, 2024
1 parent cbf39c5 commit 592cd34
Show file tree
Hide file tree
Showing 54 changed files with 1,747 additions and 448 deletions.
8 changes: 7 additions & 1 deletion apps/backend/collaboration/src/extensions/git-sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,13 @@ class GitSync implements Extension {
context,
document
}: Pick<onChangePayload, "documentName" | "document" | "context">): void {
if (documentName.startsWith("workspace:") || documentName.startsWith("snippet:")) return;
if (
documentName.startsWith("workspace:") ||
documentName.startsWith("snippet:") ||
documentName.startsWith("version:")
) {
return;
}

const [contentPieceId, variantId = null] = documentName.split(":");
const update = (): void => {
Expand Down
8 changes: 7 additions & 1 deletion apps/backend/collaboration/src/extensions/search-indexing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,13 @@ class SearchIndexing implements Extension {
document,
context
}: Pick<onChangePayload, "documentName" | "document" | "context">): void {
if (documentName.startsWith("workspace:") || documentName.startsWith("snippet:")) return;
if (
documentName.startsWith("workspace:") ||
documentName.startsWith("snippet:") ||
documentName.startsWith("version:")
) {
return;
}

const [contentPieceId, variantId] = documentName.split(":");
const state = docToBuffer(document);
Expand Down
151 changes: 151 additions & 0 deletions apps/backend/collaboration/src/extensions/version-history.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
import { Extension, onChangePayload } from "@hocuspocus/server";
import {
docToJSON,
getContentVersionsCollection,
getVersionsCollection,
jsonToBuffer,
publishVersionEvent,
fetchEntryMembers
} from "@vrite/backend";
import { FastifyInstance } from "fastify";
import { Binary, ObjectId } from "mongodb";

interface Configuration {
debounce: number | false | null;
}

class VersionHistory implements Extension {
private configuration: Configuration = {
debounce: 45000
};

private fastify: FastifyInstance;

private versionsCollection: ReturnType<typeof getVersionsCollection>;

private contentVersionsCollection: ReturnType<typeof getContentVersionsCollection>;

private debounced: Map<string, { timeout: NodeJS.Timeout; start: number; members: string[] }> =
new Map();

public constructor(fastify: FastifyInstance, configuration?: Partial<Configuration>) {
this.fastify = fastify;
this.configuration = {
...this.configuration,
...configuration
};
this.versionsCollection = getVersionsCollection(fastify.mongo.db!);
this.contentVersionsCollection = getContentVersionsCollection(fastify.mongo.db!);
}

public async onChange({
documentName,
document,
context,
update,
...x
}: onChangePayload): Promise<void> {
return this.debounceUpdate({ documentName, document, context });
}

private debounceUpdate({
documentName,
context,
document
}: Pick<onChangePayload, "documentName" | "document" | "context">): void {
if (
documentName.startsWith("workspace:") ||
documentName.startsWith("snippet:") ||
documentName.startsWith("version:")
) {
return;
}

const [contentPieceId, variantId = null] = documentName.split(":");
const update = (): void => {
const debouncedData = this.debounced.get(documentName);

this.createVersion(contentPieceId, variantId, debouncedData?.members || [], {
context,
document
});
};

this.debounce(
documentName,
update,
[...document.awareness.getStates().values()]
.map((state) => state.user.membershipId)
.filter(Boolean)
);
}

private async createVersion(
contentPieceId: string,
variantId: string | null,
members: string[],
details: Pick<onChangePayload, "context" | "document">
): Promise<void> {
if (variantId) return;

const ctx = {
db: this.fastify.mongo.db!,
auth: {
workspaceId: new ObjectId(`${details.context.workspaceId}`),
userId: new ObjectId(`${details.context.userId}`)
}
};
const json = docToJSON(details.document);
const buffer = jsonToBuffer(json);
const versionId = new ObjectId();
const date = new Date();
const version = {
_id: versionId,
date,
contentPieceId: new ObjectId(contentPieceId),
...(variantId ? { variantId: new ObjectId(variantId) } : {}),
members: members.map((id) => new ObjectId(id)),
workspaceId: ctx.auth.workspaceId
};

await this.versionsCollection.insertOne(version);
await this.contentVersionsCollection.insertOne({
_id: new ObjectId(),
contentPieceId: new ObjectId(contentPieceId),
versionId,
...(variantId ? { variantId: new ObjectId(variantId) } : {}),
content: new Binary(buffer)
});
publishVersionEvent({ fastify: this.fastify }, `${details.context.workspaceId}`, {
action: "create",
userId: `${details.context.userId}`,
data: {
id: `${versionId}`,
date: date.toISOString(),
contentPieceId: `${contentPieceId}`,
variantId: variantId ? `${variantId}` : null,
members: await fetchEntryMembers(ctx.db, version),
workspaceId: `${ctx.auth.workspaceId}`
}
});
}

private debounce(id: string, func: Function, members: string[]): void {
const old = this.debounced.get(id);
const start = old?.start || Date.now();
const run = (): void => {
func();
this.debounced.delete(id);
};

if (old?.timeout) clearTimeout(old.timeout);

this.debounced.set(id, {
start,
timeout: setTimeout(run, this.configuration.debounce as number),
members: [...new Set([...(old?.members || []), ...members])]
});
}
}

export { VersionHistory };
22 changes: 19 additions & 3 deletions apps/backend/collaboration/src/writing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,20 @@ import {
getContentVariantsCollection,
errors,
SessionData,
getSnippetContentsCollection
getSnippetContentsCollection,
getContentVersionsCollection
} from "@vrite/backend";
import { Server } from "@hocuspocus/server";
import { Database } from "@hocuspocus/extension-database";
import { Redis } from "@hocuspocus/extension-redis";
import { ObjectId, Binary } from "mongodb";
import { SearchIndexing } from "#extensions/search-indexing";
import { GitSync } from "#extensions/git-sync";
import { VersionHistory } from "#extensions/version-history";

const writingPlugin = createPlugin(async (fastify) => {
const snippetContentsCollection = getSnippetContentsCollection(fastify.mongo.db!);
const contentVersionsCollection = getContentVersionsCollection(fastify.mongo.db!);
const contentsCollection = getContentsCollection(fastify.mongo.db!);
const contentVariantsCollection = getContentVariantsCollection(fastify.mongo.db!);
const server = Server.configure({
Expand Down Expand Up @@ -55,6 +58,18 @@ const writingPlugin = createPlugin(async (fastify) => {
return null;
}

if (documentName.startsWith("version:")) {
const contentVersion = await contentVersionsCollection.findOne({
versionId: new ObjectId(documentName.split(":")[1])
});

if (contentVersion && contentVersion.content) {
return new Uint8Array(contentVersion.content.buffer);
}

return null;
}

if (documentName.startsWith("snippet:")) {
const snippetContent = await snippetContentsCollection.findOne({
snippetId: new ObjectId(documentName.split(":")[1])
Expand Down Expand Up @@ -91,7 +106,7 @@ const writingPlugin = createPlugin(async (fastify) => {
return null;
},
async store({ documentName, state, ...details }) {
if (documentName.startsWith("workspace:")) {
if (documentName.startsWith("workspace:") || documentName.startsWith("version:")) {
return;
}

Expand Down Expand Up @@ -140,7 +155,8 @@ const writingPlugin = createPlugin(async (fastify) => {
}
}),
new SearchIndexing(fastify),
new GitSync(fastify)
new GitSync(fastify),
new VersionHistory(fastify)
]
});

Expand Down
6 changes: 6 additions & 0 deletions apps/web/src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,11 @@ const SnippetEditorView = lazy(async () => {

return { default: SnippetEditorView };
});
const VersionEditorView = lazy(async () => {
const { VersionEditorView } = await import("#views/editor");

return { default: VersionEditorView };
});
const DashboardView = lazy(async () => {
const { DashboardView } = await import("#views/dashboard");

Expand Down Expand Up @@ -69,6 +74,7 @@ const App: Component = () => {
<Route path={["/", "/*contentPieceId"]} component={DashboardView} />
<Route path="/editor/*contentPieceId" component={ContentPieceEditorView} />
<Route path="/snippet/*snippetId" component={SnippetEditorView} />
<Route path="/version/:contentPieceId/*versionId" component={VersionEditorView} />
<Show when={hostConfig.githubApp}>
<Route path="/conflict" component={ConflictView} />
</Show>
Expand Down
134 changes: 134 additions & 0 deletions apps/web/src/context/history.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
import {
Accessor,
ParentComponent,
Setter,
createContext,
createEffect,
createMemo,
createSignal,
on,
onCleanup,
useContext
} from "solid-js";
import { createStore, reconcile } from "solid-js/store";
import { useLocation, useParams } from "@solidjs/router";
import { App, useClient, useContentData } from "#context";

interface HistoryActions {
updateVersion(update: Pick<App.Version, "id" | "label">): void;
}
interface HistoryDataContextData {
versions: Record<string, App.VersionWithAdditionalData>;
entryIds: Accessor<string[]>;
loading: Accessor<boolean>;
setLoading: Setter<boolean>;
moreToLoad: Accessor<boolean>;
historyActions: HistoryActions;
loadMore(): Promise<void>;
activeVersionId(): string;
}

const HistoryDataContext = createContext<HistoryDataContextData>();
const HistoryDataProvider: ParentComponent = (props) => {
const client = useClient();
const params = useParams();
const location = useLocation();
const { activeContentPieceId, activeVariantId } = useContentData();
const [versions, setVersions] = createStore<Record<string, App.VersionWithAdditionalData>>({});
const [entryIds, setEntryIds] = createSignal<string[]>([]);
const [loading, setLoading] = createSignal(false);
const [moreToLoad, setMoreToLoad] = createSignal(true);
const loadMore = async (): Promise<void> => {
const lastId = entryIds().at(-1);

if (loading() || !moreToLoad()) return;

setLoading(true);

const data = await client.versions.list.query({
contentPieceId: activeContentPieceId()!,
...(activeVariantId() && { variantId: activeVariantId()! }),
perPage: 100,
lastId
});

setEntryIds((ids) => [...ids, ...data.map((entry) => entry.id)]);
data.forEach((entry) => {
setVersions(entry.id, entry);
});
setLoading(false);
setMoreToLoad(data.length === 100);
};
const activeVersionId = createMemo((): string => {
if (!location.pathname.startsWith("/version")) return "";

return params.versionId;
});
const historyActions: HistoryActions = {
updateVersion: (update) => {
if (versions[update.id]) {
setVersions(update.id, { ...versions[update.id], label: update.label });
}
}
};
const versionsSubscription = client.versions.changes.subscribe(undefined, {
onData({ action, data }) {
if (action === "update") {
historyActions.updateVersion(data);
} else if (action === "create") {
setEntryIds((entries) => [data.id, ...entries]);
setVersions(data.id, data);
}
}
});

createEffect(
on(activeContentPieceId, (activeContentPieceId) => {
setEntryIds([]);
setMoreToLoad(true);
setLoading(false);
setVersions(reconcile({}));

if (activeContentPieceId) {
loadMore();
}
})
);
createEffect(
on(activeVersionId, (activeVersionId) => {
if (activeVersionId && !versions[activeVersionId]) {
client.versions.get
.query({ id: activeVersionId })
.then((version) => {
setVersions(version.id, version);
})
.catch(() => {});
}
})
);
onCleanup(() => {
versionsSubscription.unsubscribe();
});

return (
<HistoryDataContext.Provider
value={{
entryIds,
versions,
loading,
setLoading,
moreToLoad,
loadMore,
activeVersionId,
historyActions
}}
>
{props.children}
</HistoryDataContext.Provider>
);
};
const useHistoryData = (): HistoryDataContextData => {
return useContext(HistoryDataContext)!;
};

export { HistoryDataProvider, useHistoryData };
Loading

0 comments on commit 592cd34

Please sign in to comment.