-
Notifications
You must be signed in to change notification settings - Fork 65
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
54 changed files
with
1,747 additions
and
448 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
151 changes: 151 additions & 0 deletions
151
apps/backend/collaboration/src/extensions/version-history.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 }; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 }; |
Oops, something went wrong.