Merge pull request #35293 from overleaf/mj-zod-history-ot

[real-time] Allow history-ot operations in real-time

GitOrigin-RevId: 4b1b9e4c1a214edf49c5eface46a4d309ba41b0d
This commit is contained in:
Mathias Jakobsen
2026-09-17 11:31:49 +00:00
committed by Copybot
parent 9d57689112
commit d8f167dec2
12 changed files with 330 additions and 64 deletions
@@ -0,0 +1,89 @@
// @ts-check
'use strict'
const { z, zz } = require('@overleaf/validation-tools')
const rawTrackingProps = z.strictObject({
type: z.enum(['insert', 'delete']),
userId: zz.objectId(),
ts: z.iso.datetime(),
})
const rawClearTrackingProps = z.strictObject({
type: z.literal('none'),
})
const rawInsertOp = z.union([
z.strictObject({
i: z.string(),
commentIds: z.array(zz.objectId()).optional(),
tracking: rawTrackingProps.optional(),
}),
z.string(),
])
const rawRemoveOp = z.number().int().max(-1)
const rawRetainOp = z.union([
z.strictObject({
r: z.number().int().min(1),
commentIds: z.array(zz.objectId()).optional(),
tracking: rawTrackingProps.or(rawClearTrackingProps).optional(),
}),
z.number().int().min(1),
])
const rawScanOp = z.union([rawInsertOp, rawRemoveOp, rawRetainOp])
const rawTextOperation = z.strictObject({
textOperation: z.array(rawScanOp).min(1),
contentHash: z.string().optional(),
})
const rawRange = z.strictObject({
pos: z.number().int().min(0),
length: z.number().int().min(0),
})
const rawAddCommentOperation = z.strictObject({
commentId: zz.objectId(),
ranges: z.array(rawRange).min(1),
resolved: z.boolean().optional(),
})
const rawSetCommentStateOperation = z.strictObject({
commentId: zz.objectId(),
resolved: z.boolean(),
})
const rawDeleteCommentOperation = z.strictObject({
deleteComment: zz.objectId(),
})
const rawEditNoOperation = z.strictObject({
noOp: z.literal(true),
})
const rawEditOperation = z.union([
rawTextOperation,
rawAddCommentOperation,
rawDeleteCommentOperation,
rawSetCommentStateOperation,
rawEditNoOperation,
])
module.exports = {
rawTrackingProps,
rawClearTrackingProps,
rawInsertOp,
rawRemoveOp,
rawRetainOp,
rawScanOp,
rawTextOperation,
rawRange,
rawAddCommentOperation,
rawSetCommentStateOperation,
rawDeleteCommentOperation,
rawEditNoOperation,
rawEditOperation,
}
+26 -54
View File
@@ -1,6 +1,8 @@
import Blob from './blob' import type Blob from './blob'
import TrackingProps from './file_data/tracking_props' import type TrackingProps from './file_data/tracking_props'
import ClearTrackingProps from './file_data/clear_tracking_props' import type ClearTrackingProps from './file_data/clear_tracking_props'
import type { z } from '@overleaf/validation-tools'
import type * as schemas from './schemas'
export type BlobStore = { export type BlobStore = {
getBlob(hash: string): Promise<Blob | null> getBlob(hash: string): Promise<Blob | null>
@@ -17,10 +19,7 @@ export type RangesBlob = {
trackedChanges: TrackedChangeRawData[] trackedChanges: TrackedChangeRawData[]
} }
export type RawRange = { export type RawRange = z.infer<typeof schemas.rawRange>
pos: number
length: number
}
export type CommentRawData = { export type CommentRawData = {
id: string id: string
@@ -33,15 +32,11 @@ export type TrackedChangeRawData = {
tracking: TrackingPropsRawData tracking: TrackingPropsRawData
} }
export type TrackingPropsRawData = { export type TrackingPropsRawData = z.infer<typeof schemas.rawTrackingProps>
type: 'insert' | 'delete'
userId: string
ts: string
}
export type ClearTrackingPropsRawData = { export type ClearTrackingPropsRawData = z.infer<
type: 'none' typeof schemas.rawClearTrackingProps
} >
export type TrackingDirective = TrackingProps | ClearTrackingProps export type TrackingDirective = TrackingProps | ClearTrackingProps
@@ -112,55 +107,32 @@ export type RawHollowStringFileData = { stringLength: number }
export type RawV2DocVersions = Record<string, { pathname: string; v: number }> export type RawV2DocVersions = Record<string, { pathname: string; v: number }>
export type RawInsertOp = export type RawInsertOp = z.infer<typeof schemas.rawInsertOp>
| {
i: string
commentIds?: string[]
tracking?: TrackingPropsRawData
}
| string
export type RawRemoveOp = number export type RawRemoveOp = z.infer<typeof schemas.rawRemoveOp>
export type RawRetainOp = export type RawRetainOp = z.infer<typeof schemas.rawRetainOp>
| {
r: number
commentIds?: string[]
tracking?: TrackingPropsRawData | ClearTrackingPropsRawData
}
| number
export type RawScanOp = RawInsertOp | RawRemoveOp | RawRetainOp export type RawScanOp = z.infer<typeof schemas.rawScanOp>
export type RawTextOperation = { export type RawTextOperation = z.infer<typeof schemas.rawTextOperation>
textOperation: RawScanOp[]
contentHash?: string
}
export type RawAddCommentOperation = { export type RawAddCommentOperation = z.infer<
commentId: string typeof schemas.rawAddCommentOperation
ranges: RawRange[] >
resolved?: boolean
}
export type RawDeleteCommentOperation = { deleteComment: string } export type RawDeleteCommentOperation = z.infer<
typeof schemas.rawDeleteCommentOperation
>
export type RawSetCommentStateOperation = { export type RawSetCommentStateOperation = z.infer<
commentId: string typeof schemas.rawSetCommentStateOperation
resolved: boolean >
}
export type RawEditNoOperation = { export type RawEditNoOperation = z.infer<typeof schemas.rawEditNoOperation>
noOp: true
}
export type RawEditFileOperation = RawEditOperation & { pathname: string } export type RawEditFileOperation = RawEditOperation & { pathname: string }
export type RawEditOperation = export type RawEditOperation = z.infer<typeof schemas.rawEditOperation>
| RawTextOperation
| RawAddCommentOperation
| RawDeleteCommentOperation
| RawSetCommentStateOperation
| RawEditNoOperation
export type LinkedFileData = { export type LinkedFileData = {
importedAt: string importedAt: string
@@ -26,6 +26,7 @@
}, },
"dependencies": { "dependencies": {
"@overleaf/o-error": "workspace:*", "@overleaf/o-error": "workspace:*",
"@overleaf/validation-tools": "workspace:*",
"check-types": "^5.1.0", "check-types": "^5.1.0",
"lodash": "^4.18.1", "lodash": "^4.18.1",
"p-map": "^4.0.0", "p-map": "^4.0.0",
+2
View File
@@ -39,6 +39,8 @@ const operationSchema = z
hash: z.string(), hash: z.string(),
}) })
.optional(), .optional(),
// TODO: Use rawEditOperation from overleaf-editor-core/lib/schemas.js
// instead
textOperation: z.array(z.any()).optional(), textOperation: z.array(z.any()).optional(),
file: fileSchema.optional(), file: fileSchema.optional(),
contentHash: z.string().optional(), contentHash: z.string().optional(),
+3 -1
View File
@@ -11,6 +11,7 @@ import Errors from './Errors.js'
import { z, zz } from '@overleaf/validation-tools' import { z, zz } from '@overleaf/validation-tools'
import { isZodErrorLike } from 'zod-validation-error' import { isZodErrorLike } from 'zod-validation-error'
import os from 'node:os' import os from 'node:os'
import schemas from 'overleaf-editor-core/lib/schemas.js'
const { UnexpectedArgumentsError } = Errors const { UnexpectedArgumentsError } = Errors
@@ -60,7 +61,8 @@ const applyOtUpdateSchema = z.object({
}) })
) )
) )
.min(1), .min(1)
.or(z.array(schemas.rawEditOperation).min(1)),
v: z.number().int().min(0), v: z.number().int().min(0),
}), }),
}) })
@@ -8,6 +8,7 @@ import ConnectedUsersManager from './ConnectedUsersManager.js'
import WebsocketLoadBalancer from './WebsocketLoadBalancer.js' import WebsocketLoadBalancer from './WebsocketLoadBalancer.js'
import RoomManager from './RoomManager.js' import RoomManager from './RoomManager.js'
import Errors from './Errors.js' import Errors from './Errors.js'
import { EditOperationBuilder } from 'overleaf-editor-core'
const { const {
CodedError, CodedError,
@@ -658,14 +659,42 @@ export default WebsocketController = {
) )
}, },
_isHistoryOTUpdate(update) {
for (const op of update.op) {
if (!EditOperationBuilder.isValid(op)) {
return false
}
}
return true
},
_assertClientCanApplyUpdate(client, docId, update, callback) { _assertClientCanApplyUpdate(client, docId, update, callback) {
if (WebsocketController._isCommentUpdate(update)) { if (WebsocketController._isHistoryOTUpdate(update)) {
return WebsocketController._assertClientCanApplyHistoryUpdate(
client,
docId,
update,
callback
)
} else {
return WebsocketController._assertClientCanApplyShareJsUpdate(
client,
docId,
update,
callback
)
}
},
_assertClientCanApplyShareJsUpdate(client, docId, update, callback) {
if (WebsocketController._isShareJsCommentUpdate(update)) {
return AuthorizationManager.assertClientCanViewProjectAndDoc( return AuthorizationManager.assertClientCanViewProjectAndDoc(
client, client,
docId, docId,
callback callback
) )
} else if (update.meta?.tc) { } else if (WebsocketController._isShareJsTrackedChangesUpdate(update)) {
return AuthorizationManager.assertClientCanReviewProjectAndDoc( return AuthorizationManager.assertClientCanReviewProjectAndDoc(
client, client,
docId, docId,
@@ -680,10 +709,7 @@ export default WebsocketController = {
} }
}, },
_isCommentUpdate(update) { _isShareJsCommentUpdate(update) {
if (!(update && update.op instanceof Array)) {
return false
}
for (const op of update.op) { for (const op of update.op) {
if (!op.c) { if (!op.c) {
return false return false
@@ -691,4 +717,73 @@ export default WebsocketController = {
} }
return true return true
}, },
_isShareJsTrackedChangesUpdate(update) {
return Boolean(update.meta?.tc)
},
_assertClientCanApplyHistoryUpdate(client, docId, update, callback) {
if (WebsocketController._isHistoryOTCommentUpdate(update)) {
return AuthorizationManager.assertClientCanViewProjectAndDoc(
client,
docId,
callback
)
} else if (WebsocketController._isHistoryOTTrackedChangesUpdate(update)) {
return AuthorizationManager.assertClientCanReviewProjectAndDoc(
client,
docId,
callback
)
} else {
return AuthorizationManager.assertClientCanEditProjectAndDoc(
client,
docId,
callback
)
}
},
_isHistoryOTCommentUpdate(update) {
for (const op of update.op) {
if (!WebsocketController._isHistoryOTAddCommentOperation(op)) {
return false
}
}
return true
},
_isHistoryOTAddCommentOperation(op) {
return typeof op === 'object' && 'commentId' in op && 'ranges' in op
},
_isHistoryOTTrackedChangesUpdate(update) {
for (const op of update.op) {
if (!('textOperation' in op)) {
return false
}
for (const scanOp of op.textOperation) {
// Plain retains
if (typeof scanOp === 'number' && scanOp > 0) {
continue
}
// Plain insertions
if (typeof scanOp !== 'object') {
return false
}
// Tracked insertions
if ('i' in scanOp && 'tracking' in scanOp) {
continue
}
// Tracked ranges (we don't accepting by clearing tracking)
if ('r' in scanOp && scanOp.tracking?.type !== 'none') {
continue
}
return false
}
}
return true
},
} }
+1
View File
@@ -32,6 +32,7 @@
"express": "4.22.1", "express": "4.22.1",
"express-session": "^1.17.1", "express-session": "^1.17.1",
"lodash": "^4.18.1", "lodash": "^4.18.1",
"overleaf-editor-core": "workspace:*",
"proxy-addr": "^2.0.7", "proxy-addr": "^2.0.7",
"socket.io": "github:overleaf/socket.io#0.9.19-overleaf-12", "socket.io": "github:overleaf/socket.io#0.9.19-overleaf-12",
"socket.io-client": "https://github.com/overleaf/socket.io-client/archive/refs/tags/0.9.17-overleaf-6.tar.gz", "socket.io-client": "https://github.com/overleaf/socket.io-client/archive/refs/tags/0.9.17-overleaf-6.tar.gz",
@@ -1788,5 +1788,107 @@ describe('WebsocketController', function () {
}) })
}) })
}) })
describe('with a history-OT client', function () {
const tracking = {
insert: { type: 'insert', userId: 'user-id-123', ts: '2026-07-15' },
delete: { type: 'delete', userId: 'user-id-123', ts: '2026-07-15' },
none: { type: 'none' },
}
function expectRoutedTo(ctx, target, update) {
return new Promise(resolve => {
for (const name of [
'assertClientCanViewProjectAndDoc',
'assertClientCanReviewProjectAndDoc',
'assertClientCanEditProjectAndDoc',
]) {
ctx.AuthorizationManager[name].yields(new Error('not authorized'))
}
ctx.AuthorizationManager[target].yields(null)
ctx.WebsocketController._assertClientCanApplyUpdate(
ctx.client,
ctx.doc_id,
update,
error => {
expect(error).to.be.null
expect(ctx.AuthorizationManager[target].calledOnce).to.be.true
resolve()
}
)
})
}
it('should route an add-comment op to view access', async function (ctx) {
await expectRoutedTo(ctx, 'assertClientCanViewProjectAndDoc', {
op: [{ commentId: 'comment-id', ranges: [{ pos: 0, length: 3 }] }],
})
})
it('should route a tracked insertion to review access', async function (ctx) {
await expectRoutedTo(ctx, 'assertClientCanReviewProjectAndDoc', {
op: [{ textOperation: [5, { i: 'x', tracking: tracking.insert }] }],
})
})
it('should route a tracked deletion to review access', async function (ctx) {
await expectRoutedTo(ctx, 'assertClientCanReviewProjectAndDoc', {
op: [{ textOperation: [{ r: 3, tracking: tracking.delete }] }],
})
})
it('should route accepting a tracked change (clearing tracking) to edit access', async function (ctx) {
await expectRoutedTo(ctx, 'assertClientCanEditProjectAndDoc', {
op: [{ textOperation: [{ r: 3, tracking: tracking.none }] }],
})
})
it('should route an untracked insertion to edit access', async function (ctx) {
await expectRoutedTo(ctx, 'assertClientCanEditProjectAndDoc', {
op: [{ textOperation: ['plain'] }],
})
})
it('should route an untracked deletion to edit access', async function (ctx) {
await expectRoutedTo(ctx, 'assertClientCanEditProjectAndDoc', {
op: [{ textOperation: [-3] }],
})
})
it('should route a mix of tracked and untracked ops to edit access', async function (ctx) {
await expectRoutedTo(ctx, 'assertClientCanEditProjectAndDoc', {
op: [
{ textOperation: [{ i: 'x', tracking: tracking.insert }] },
{ textOperation: ['plain'] },
],
})
})
it('should route a resolve-comment op to edit access', async function (ctx) {
// Only add-comment ops (commentId + ranges) are recognised as comment
// updates; resolving a comment currently requires edit access.
await expectRoutedTo(ctx, 'assertClientCanEditProjectAndDoc', {
op: [{ commentId: 'comment-id', resolved: true }],
})
})
it('should route a permanent history-OT insertion carrying meta.tc to edit access', async function (ctx) {
// meta.tc is a legacy (sharejs) tracked-changes id seed and is
// meaningless for history-OT, where tracking is carried by the op.
// A review-only client must not be able to make a permanent edit by
// attaching it.
await expectRoutedTo(ctx, 'assertClientCanEditProjectAndDoc', {
op: [{ textOperation: ['permanent untracked text'] }],
meta: { tc: 'tracked-change-id' },
})
})
it('should route a permanent history-OT deletion carrying meta.tc to edit access', async function (ctx) {
await expectRoutedTo(ctx, 'assertClientCanEditProjectAndDoc', {
op: [{ textOperation: [-3] }],
meta: { tc: 'tracked-change-id' },
})
})
})
}) })
}) })
@@ -24,7 +24,7 @@ import {
EditOperationBuilder, EditOperationBuilder,
CommentList, CommentList,
} from 'overleaf-editor-core' } from 'overleaf-editor-core'
import { import type {
StringFileRawData, StringFileRawData,
RawEditOperation, RawEditOperation,
} from 'overleaf-editor-core/lib/types' } from 'overleaf-editor-core/lib/types'
@@ -1,6 +1,6 @@
import { StringFileData } from 'overleaf-editor-core' import { StringFileData } from 'overleaf-editor-core'
import { AnyOperation } from '../../../../../../types/change' import { AnyOperation } from '../../../../../../types/change'
import { RawEditOperation } from 'overleaf-editor-core/lib/types' import type { RawEditOperation } from 'overleaf-editor-core/lib/types'
export type Version = number export type Version = number
@@ -1,6 +1,6 @@
import pLimit from 'p-limit' import pLimit from 'p-limit'
import { Change, Chunk, Snapshot, File } from 'overleaf-editor-core' import { Change, Chunk, Snapshot, File } from 'overleaf-editor-core'
import { RawChange, RawChunk } from 'overleaf-editor-core/lib/types' import type { RawChange, RawChunk } from 'overleaf-editor-core/lib/types'
import { FetchError, getJSON, postJSON } from '@/infrastructure/fetch-json' import { FetchError, getJSON, postJSON } from '@/infrastructure/fetch-json'
import path from 'path-browserify' import path from 'path-browserify'
+2
View File
@@ -7187,6 +7187,7 @@ __metadata:
mocha: "npm:^11.1.0" mocha: "npm:^11.1.0"
mocha-junit-reporter: "npm:^2.2.1" mocha-junit-reporter: "npm:^2.2.1"
mocha-multi-reporters: "npm:^1.5.1" mocha-multi-reporters: "npm:^1.5.1"
overleaf-editor-core: "workspace:*"
proxy-addr: "npm:^2.0.7" proxy-addr: "npm:^2.0.7"
sinon: "npm:^9.2.4" sinon: "npm:^9.2.4"
sinon-chai: "npm:3.7.0" sinon-chai: "npm:3.7.0"
@@ -25255,6 +25256,7 @@ __metadata:
resolution: "overleaf-editor-core@workspace:libraries/overleaf-editor-core" resolution: "overleaf-editor-core@workspace:libraries/overleaf-editor-core"
dependencies: dependencies:
"@overleaf/o-error": "workspace:*" "@overleaf/o-error": "workspace:*"
"@overleaf/validation-tools": "workspace:*"
"@types/check-types": "npm:^7.3.7" "@types/check-types": "npm:^7.3.7"
"@types/path-browserify": "npm:^1.0.3" "@types/path-browserify": "npm:^1.0.3"
chai: "npm:^3.3.0" chai: "npm:^3.3.0"