Skip to content

Conversation

@Horusiath
Copy link
Contributor

@Horusiath Horusiath commented Nov 19, 2025

Summary by Sourcery

Implement full view history feature by pairing new HTTP endpoints and IndexedDB storage to manage collaboration version snapshots. Extend the sync layer and protobuf definitions to carry version metadata, enable version-specific document persistence in openCollabDB, and add UI hooks to fetch and revert document versions.

New Features:

  • Add HTTP API methods to fetch, create, delete, and revert collaborative document versions
  • Expose versioned persistence in openCollabDB and integrate version metadata into AFClientService and hooks
  • Implement view history operations in useSync and useViewOperations including revertCollabVersion callback
  • Add history.ts to manage collab version caching in IndexedDB and compute editorsBetween snapshots

Enhancements:

  • Propagate version field through SyncContext and protobuf messages (SyncRequest, Update) and reset Y.Doc on version change
  • Extend protobuf definitions with HttpRealtimeMessage and CollabDocStateParams and clean up obsolete decode parameters
  • Enhance database schema with collab_versions table and handle version expiry and invalidation

Tests:

  • Add unit tests for history helpers (history.test.ts)

@sourcery-ai
Copy link

sourcery-ai bot commented Nov 19, 2025

Reviewer's Guide

Implements versioned collaboration history by extending protobuf definitions, propagating document version through the sync protocol, persisting and fetching version snapshots via HTTP and IndexedDB, and integrating rollback capabilities in the client.

ER diagram for new collab_versions table in IndexedDB

erDiagram
  collab_versions {
    string viewId
    string versionId
    string parentId
    string name
    date createdAt
    string[] uids
    Uint8Array snapshot
  }

  collab_versions ||--o{ collab_versions : parentId
Loading

Class diagram for new and updated collaboration types

classDiagram
  class messages.HttpRealtimeMessage {
    +string deviceId
    +Uint8Array payload
    +static create(properties)
    +static encode(message, writer)
    +static encodeDelimited(message, writer)
    +static decode(reader, length)
    +static decodeDelimited(reader)
    +static verify(message)
    +static fromObject(object)
    +static toObject(message, options)
    +toJSON()
    +static getTypeUrl(typeUrlPrefix)
  }

  class collab.SyncRequest {
    +collab.IRid lastMessageId
    +Uint8Array stateVector
    +string version
  }

  class collab.Update {
    +collab.IRid messageId
    +number flags
    +Uint8Array payload
    +string version
  }

  class collab.CollabDocStateParams {
    +string objectId
    +number collabType
    +PayloadCompressionType compression
    +Uint8Array sv
    +Uint8Array docState
    +static create(properties)
    +static encode(message, writer)
    +static encodeDelimited(message, writer)
    +static decode(reader, length)
    +static decodeDelimited(reader)
    +static verify(message)
    +static fromObject(object)
    +static toObject(message, options)
    +toJSON()
    +static getTypeUrl(typeUrlPrefix)
  }

  class collab.PayloadCompressionType {
    +NONE
    +ZSTD
  }

  messages.HttpRealtimeMessage --> collab.SyncRequest
  collab.SyncRequest --> collab.Update
  collab.CollabDocStateParams --> collab.PayloadCompressionType
Loading

Class diagram for VersionedDoc and related types

classDiagram
  class VersionedDoc {
    +Y.Doc doc
    +string version
  }

  class CollabVersionRecord {
    +string viewId
    +string versionId
    +string parentId
    +string name
    +Date createdAt
    +Uint8Array snapshot
  }

  class EncodedCollab {
    +Uint8Array stateVector
    +Uint8Array docState
    +string version
  }

  VersionedDoc --> CollabVersionRecord
  VersionedDoc --> EncodedCollab
Loading

Class diagram for CollabHistoryService interface

classDiagram
  class CollabHistoryService {
    +getCollabHistory(workspaceId, viewId, since): Promise<CollabVersionRecord[]>
    +createCollabVersion(workspaceId, viewId, name, snapshot): Promise<string>
    +deleteCollabVersion(workspaceId, viewId, versionId): Promise<void>
    +revertCollabVersion(workspaceId, viewId, collabType, versionId): Promise<EncodedCollab>
  }

  CollabHistoryService --> CollabVersionRecord
  CollabHistoryService --> EncodedCollab
Loading

File-Level Changes

Change Details Files
Extend protobuf definitions to support new messages and version fields
  • Removed obsolete "error" parameter and break logic from decode functions
  • Appended HttpRealtimeMessage definition with full encode/decode/verify/toObject support
  • Added "version" field to SyncRequest and Update messages in JS and TS definitions
  • Introduced PayloadCompressionType enum and CollabDocStateParams message for state snapshots
src/proto/messages.js
src/proto/messages.d.ts
Propagate document version in sync protocol
  • Added "version" property to SyncContext interface
  • Included ctx.version in outgoing syncRequest and update messages
  • Cleared pending updates on document destroy event to prevent stale messages
  • Introduced userMappings field in SyncContext for Yjs PermanentUserData
src/application/services/js-services/sync-protocol.ts
Enhance useSync hook with version change detection and rollback
  • Implemented versionChanged helper to compare incoming message versions
  • On version mismatch: destroy context, delete outdated DB, reopen with expectedVersion and reinitialize
  • Exposed revertCollabVersion callback to manually revert to a given version
  • Initialized PermanentUserData mapping when registering new sync contexts
src/components/ws/useSync.ts
Add HTTP API and service methods for collab history
  • Implemented getCollabVersions, createCollabVersion, deleteCollabVersion, revertCollabVersion in http_api
  • Exposed corresponding methods in AFClientService and added CollabHistoryService interface
  • Updated service type definitions to include collab history operations
src/application/services/js-services/http/http_api.ts
src/application/services/js-services/index.ts
src/application/services/services.type.ts
Persist and manage version snapshots in IndexedDB
  • Introduced versionSchema and VersionsTable for collab_versions store
  • Extended openCollabDB to accept expectedVersion, clear outdated data, persist version, and return VersionedDoc
  • Updated Dexie initialization and callers to handle returned version alongside Y.Doc
src/application/db/index.ts
src/application/types.ts
src/application/db/tables/versions.ts
Update application layers to propagate version info
  • Refactored getPageDoc and cache modules to return VersionedDoc
  • Adjusted useViewOperations and AppSyncLayer to destructure version and pass to registerSyncContext
  • Extended SyncInternalContext to include revertCollabVersion
src/components/app/hooks/useViewOperations.ts
src/components/app/layers/AppSyncLayer.tsx
src/application/services/js-services/cache/index.ts
Introduce history module for computing and caching versions
  • Added history.ts with range utilities (subtractRange, intersectRange) and editorsBetween
  • Implemented collabVersions to merge remote and cached versions, compute contributors, and prune expired entries
src/application/services/js-services/history.ts
Add unit tests for history utilities
  • Created tests/history.test.ts to validate range subtraction and intersection logic
src/application/services/js-services/__tests__/history.test.ts

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

Copy link

@sourcery-ai sourcery-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey there - I've reviewed your changes - here's some feedback:

  • Consider extracting the version-reset and document reinitialization logic in useSync’s effect into a separate helper or custom hook to reduce complexity and improve readability.
  • The WebSocket and BroadcastChannel message handlers are nearly identical—consolidate them or extract a shared handler to avoid duplication and potential drift.
  • The massive protobuf codegen changes are drowning the manual logic—please separate autogenerated file updates into their own commit or ignore them in reviews so the core feature diff is clearer.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- Consider extracting the version-reset and document reinitialization logic in useSync’s effect into a separate helper or custom hook to reduce complexity and improve readability.
- The WebSocket and BroadcastChannel message handlers are nearly identical—consolidate them or extract a shared handler to avoid duplication and potential drift.
- The massive protobuf codegen changes are drowning the manual logic—please separate autogenerated file updates into their own commit or ignore them in reviews so the core feature diff is clearer.

## Individual Comments

### Comment 1
<location> `src/application/db/tables/versions.ts:15-16` </location>
<code_context>
+  }>;
+};
+
+export const versionSchema = {
+  collab_versions: 'version',
+};
\ No newline at end of file
</code_context>

<issue_to_address>
**issue (bug_risk):** The primary key for collab_versions should match the actual key used in the table.

The schema defines 'version' as the primary key, while the table uses 'versionId'. Please update the schema to use 'versionId' for consistency and to prevent potential runtime errors.
</issue_to_address>

### Comment 2
<location> `src/application/services/js-services/history.ts:200` </location>
<code_context>
+  // Merge both arrays, using versionId as unique identifier
+  // Track records that need database updates
+  const versionMap = new Map<string, CollabVersion>();
+  const versions = await db.collab_versions.filter(v => v.viewId !== viewId).toArray();
+
+  let lastUpdate: Date|undefined;
</code_context>

<issue_to_address>
**issue (bug_risk):** The filter for collab_versions excludes the current viewId, which may be unintended.

The filter currently excludes all records matching the current viewId. If you intend to retrieve versions for this viewId, update the filter to 'v.viewId === viewId'.
</issue_to_address>

### Comment 3
<location> `src/application/services/js-services/history.ts:259-260` </location>
<code_context>
+            const toSnapshot = Y.decodeSnapshot(version.snapshot);
+
+            // get first non-deleted parent
+            let parent = null;
+            let current = versionMap.get(version.versionId);
+
+            while (current && current.parentId !== null) {
</code_context>

<issue_to_address>
**issue (bug_risk):** The parent lookup logic may not traverse the version chain correctly.

The loop should use 'current.parentId' to traverse the parent chain, rather than always referencing 'version.versionId'. This will ensure correct parent lookup.
</issue_to_address>

### Comment 4
<location> `src/application/services/js-services/history.ts:171` </location>
<code_context>
+const VERSION_EXPIRY_DAYS = 7;
+
+const cleanupExpiredVersions = async (versions: Map<string, CollabVersion>) => {
+  const expirationDate = Date.now() - (VERSION_EXPIRY_DAYS * 1000 * 60 * 60 * 24);
+  const toDelete = [];
+
</code_context>

<issue_to_address>
**nitpick:** VERSION_EXPIRY_DAYS is documented as 30 but set to 7.

Please ensure the comment and value for VERSION_EXPIRY_DAYS are consistent to avoid confusion.
</issue_to_address>

### Comment 5
<location> `src/application/services/js-services/history.ts:196` </location>
<code_context>
+ *              it will let collab versions fill the information about which users made changes between specific version
+ *              and its predecessor.
+ */
+export const collabVersions = async (workspaceId: string, viewId: string, users: Y.PermanentUserData|null) => {
+  // Merge both arrays, using versionId as unique identifier
+  // Track records that need database updates
</code_context>

<issue_to_address>
**suggestion (bug_risk):** Consider adding concurrency control for collabVersions cache updates.

Concurrent updates to the cache for the same viewId may cause race conditions. Implement a locking mechanism or queue to ensure updates are serialized.

Suggested implementation:

```typescript
const collabVersionsLocks: Map<string, Promise<void>> = new Map();

export const collabVersions = async (workspaceId: string, viewId: string, users: Y.PermanentUserData|null) => {
  // Acquire lock for this viewId to serialize cache updates
  let releaseLock: (() => void) | null = null;
  const lockPromise = new Promise<void>(resolve => { releaseLock = resolve; });
  while (collabVersionsLocks.has(viewId)) {
    // Wait for previous lock to release
    await collabVersionsLocks.get(viewId);
  }
  collabVersionsLocks.set(viewId, lockPromise);

  try {
    // Merge both arrays, using versionId as unique identifier
    // Track records that need database updates
    const versionMap = new Map<string, CollabVersion>();
    const versions = await db.collab_versions.filter(v => v.viewId !== viewId).toArray();

    let lastUpdate: Date|undefined;

    for (const version of versions) {
      versionMap.set(version.versionId, version);
      if ((lastUpdate?.getTime() || 0) < version.createdAt.getTime()) {
        lastUpdate = version.createdAt;
      }
    }
    // ... rest of your function logic ...
  } finally {
    // Release lock
    collabVersionsLocks.delete(viewId);
    releaseLock && releaseLock();
  }

```

If collabVersions is called from multiple places, ensure all calls use the same locking logic. 
If you use a worker/threaded environment, consider a more robust cross-process locking solution.
</issue_to_address>

### Comment 6
<location> `src/application/services/js-services/history.ts:250-259` </location>
<code_context>
+    // Update database if needed
</code_context>

<issue_to_address>
**suggestion (bug_risk):** BulkPut may overwrite existing records without merging user IDs.

If user IDs are added over time, merge new IDs with existing ones in the uids array to prevent data loss.
</issue_to_address>

### Comment 7
<location> `src/components/ws/useSync.ts:62` </location>
<code_context>
+  }
+}
+
 export const useSync = (ws: AppflowyWebSocketType, bc: BroadcastChannelType, eventEmitter?: EventEmitter): SyncContextType => {
   const { sendMessage, lastMessage } = ws;
   const { postMessage, lastBroadcastMessage } = bc;
</code_context>

<issue_to_address>
**issue (complexity):** Consider extracting the complex in-hook logic into dedicated helper utilities and hooks to isolate concerns and simplify the main hook.

Consider pulling all of the in-`useEffect` logic out into small, focused helpers. For example, you can:

1. Extract your “apply message + version reset” flow into a single async util.  
2. Extract your broadcast listener into its own hook.  
3. Extract your revert flow into its own hook.

Here’s a sketch of what that might look like:

```ts
// src/utils/collab.ts
import { handleMessage } from '@/application/services/js-services/sync-protocol';
import { deleteDB, openCollabDB } from '@/application/db';
import * as awarenessProtocol from 'y-protocols/awareness';
import { versionChanged } from '@/hooks/useSync'; // keep your version check here

export async function applyCollabMessage(
  message: ICollabMessage,
  contexts: Map<string, SyncContext>,
  register: (c: RegisterSyncContext) => SyncContext,
  currentUser?: User
): Promise<UpdateCollabInfo|void> {
  const objectId = message.objectId!;
  let ctx = contexts.get(objectId);
  if (!ctx) return;

  if (versionChanged(ctx, message)) {
    const newVersion = message.update?.version || message.syncRequest?.version || null;
    ctx.doc.emit('reset', [ctx, newVersion]);
    ctx.doc.destroy();

    // remove and re-open IndexedDB
    await deleteDB(objectId);
    const { doc } = await openCollabDB(objectId, {
      expectedVersion: newVersion,
      currentUser: currentUser?.uid
    });
    const awareness = new awarenessProtocol.Awareness(doc);
    ctx = register({ doc: awareness.doc, awareness, collabType: ctx.collabType, version: newVersion });
  }

  handleMessage(ctx, message);
  const ts = message.update?.messageId?.timestamp;
  return {
    objectId,
    publishedAt: ts ? new Date(ts) : undefined,
    collabType: message.collabType as Types
  };
}
```

```ts
// src/hooks/useSync.ts (excerpt)
import { applyCollabMessage } from '@/utils/collab';

useEffect(() => {
  const msg = lastMessage?.collabMessage;
  if (!msg) return;

  let alive = true;
  applyCollabMessage(msg, registeredContexts.current, registerSyncContext, currentUser)
    .then(info => {
      if (alive && info) setLastUpdatedCollab(info);
    })
    .catch(console.error);

  return () => { alive = false; };
}, [lastMessage, currentUser, registerSyncContext]);
```

```ts
// src/hooks/useBroadcastCollab.ts
import { handleMessage } from '@/application/services/js-services/sync-protocol';

export function useBroadcastCollab(
  lastBroadcast: ICollabMessage|undefined,
  contexts: Ref<Map<string,SyncContext>>,
  onUpdate: (info: UpdateCollabInfo) => void
) {
  useEffect(() => {
    const msg = lastBroadcast;
    if (!msg) return;
    const ctx = contexts.current.get(msg.objectId!);
    if (!ctx) return;

    handleMessage(ctx, msg);
    onUpdate({
      objectId: msg.objectId!,
      publishedAt: msg.update?.messageId?.timestamp
        ? new Date(msg.update.messageId.timestamp)
        : undefined,
      collabType: msg.collabType as Types
    });
  }, [lastBroadcast, contexts, onUpdate]);
}
```

```ts
// src/hooks/useRevertCollabVersion.ts
import { deleteDB } from 'lib0/indexeddb';
import * as http from '@/application/services/js-services/http/http_api';
import { openCollabDB } from '@/application/db';
import * as awarenessProtocol from 'y-protocols/awareness';

export function useRevertCollabVersion(
  contexts: Ref<Map<string,SyncContext>>,
  register: (c: RegisterSyncContext) => SyncContext,
  currentUser?: User
) {
  return useCallback(async (viewId: string, version: string) => {
    if (!currentUser) return;
    await deleteDB(viewId);

    const ctx = contexts.current.get(viewId);
    if (!ctx) return;
    const { docState } = await http.revertCollabVersion(
      currentUser.latestWorkspaceId,
      viewId,
      ctx.collabType,
      version
    );

    ctx.doc.emit('reset', [ctx, version]);
    ctx.doc.destroy();

    const { doc } = await openCollabDB(viewId, {
      expectedVersion: version,
      currentUser: currentUser.uid
    });
    Y.applyUpdate(doc, docState);
    const awareness = new awarenessProtocol.Awareness(doc);
    register({ doc: awareness.doc, awareness, collabType: ctx.collabType, version });
  }, [contexts, register, currentUser]);
}
```

These changes shrink the big `useSync` hook down to three simple effects/callbacks and keep each concern isolated.
</issue_to_address>

### Comment 8
<location> `src/proto/messages.js:299-304` </location>
<code_context>

</code_context>

<issue_to_address>
**issue (code-quality):** Avoid function declarations, favouring function assignment expressions, inside blocks. ([`avoid-function-declarations-in-blocks`](https://docs.sourcery.ai/Reference/Rules-and-In-Line-Suggestions/JavaScript/Default-Rules/avoid-function-declarations-in-blocks))

<details><summary>Explanation</summary>Function declarations may be hoisted in Javascript, but the behaviour is inconsistent between browsers.
Hoisting is generally confusing and should be avoided. Rather than using function declarations inside blocks, you
should use function expressions, which create functions in-scope.
</details>
</issue_to_address>

### Comment 9
<location> `src/proto/messages.js:2210-2215` </location>
<code_context>

</code_context>

<issue_to_address>
**issue (code-quality):** Avoid function declarations, favouring function assignment expressions, inside blocks. ([`avoid-function-declarations-in-blocks`](https://docs.sourcery.ai/Reference/Rules-and-In-Line-Suggestions/JavaScript/Default-Rules/avoid-function-declarations-in-blocks))

<details><summary>Explanation</summary>Function declarations may be hoisted in Javascript, but the behaviour is inconsistent between browsers.
Hoisting is generally confusing and should be avoided. Rather than using function declarations inside blocks, you
should use function expressions, which create functions in-scope.
</details>
</issue_to_address>

### Comment 10
<location> `src/application/services/js-services/http/http_api.ts:827` </location>
<code_context>
    const data = response.data;

</code_context>

<issue_to_address>
**suggestion (code-quality):** Prefer object destructuring when accessing and using properties. ([`use-object-destructuring`](https://docs.sourcery.ai/Reference/Rules-and-In-Line-Suggestions/TypeScript/Default-Rules/use-object-destructuring))

```suggestion
    const {data} = response;
```

<br/><details><summary>Explanation</summary>Object destructuring can often remove an unnecessary temporary reference, as well as making your code more succinct.

From the [Airbnb Javascript Style Guide](https://airbnb.io/javascript/#destructuring--object)
</details>
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment on lines +15 to +16
export const versionSchema = {
collab_versions: 'version',
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (bug_risk): The primary key for collab_versions should match the actual key used in the table.

The schema defines 'version' as the primary key, while the table uses 'versionId'. Please update the schema to use 'versionId' for consistency and to prevent potential runtime errors.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants