[ranges-tracker] add legacy orderedRejections field in tracked-changes (#36565)

* [docstore] use more realistic test stubs for ranges

* [monorepo] define ranges schemas in @overleaf/ranges-tracker

* [ranges-tracker] add legacy orderedRejections field in tracked-changes

* [monorepo] resolve @overleaf/ranges-tracker subpath imports in typescript

* [real-time] validate comment thread ids and tracked change id seeds

* [ranges-tracker] require ObjectId ids and tracked change metadata in ranges

* [monorepo] require ObjectId tracked change ids on the accept routes

* [web] use ObjectId comment thread ids in the DocumentController tests

GitOrigin-RevId: 58b20dec311d85b917ef63f9efbe745a265a2de6
This commit is contained in:
Jakob Ackermann
2026-09-17 12:01:19 +00:00
committed by Copybot
parent 833e44d142
commit 4e80d65efc
23 changed files with 501 additions and 383 deletions
+4
View File
@@ -4,6 +4,7 @@
"main": "index.cjs", "main": "index.cjs",
"files": [ "files": [
"index.cjs", "index.cjs",
"schemas.js",
"types" "types"
], ],
"author": "Overleaf (https://www.overleaf.com)", "author": "Overleaf (https://www.overleaf.com)",
@@ -16,6 +17,9 @@
"test:unit": "mocha --exit test/**/*.{js,cjs}", "test:unit": "mocha --exit test/**/*.{js,cjs}",
"types:check": "tsc --noEmit" "types:check": "tsc --noEmit"
}, },
"dependencies": {
"@overleaf/validation-tools": "workspace:*"
},
"devDependencies": { "devDependencies": {
"@types/node": "^24.5.2", "@types/node": "^24.5.2",
"chai": "^4.3.6", "chai": "^4.3.6",
+88
View File
@@ -0,0 +1,88 @@
// @ts-check
'use strict'
const { z, zz } = require('@overleaf/validation-tools')
// Zod schemas for the ranges data (RangesTracker format) accepted by
// updateDoc.
//
// Ranges reach docstore via web from several writers and are stored (and
// later round-tripped, e.g. by project clone) without normalization, so the
// schemas model wire reality rather than the ideal shape:
// - document-updater flushes RangesTracker data: a comment's id is its thread
// id (docstore's RangeManager keeps the two in sync) and a tracked change's
// id is a RangesTracker id, i.e. an 18 character seed plus a 6 character
// increment, see libraries/ranges-tracker/index.cjs
// - history restores send id-less changes and comment ops with a `resolved`
// flag (stripped again by RangeManager), and detached comments (a zero
// length comment range at position 0), see
// overleaf-editor-core/lib/doc_updater_compatible_ranges.js
// - old tracked changes created while the fixedRemoveChange flag existed
// (removed in https://github.com/overleaf/internal/pull/23993) still carry
// it in their op until accepted/rejected
// - old tracked changes created while the orderedRejections flag existed
// (added in https://github.com/overleaf/internal/pull/22650) still carry
// it in their op until accepted/rejected.
const insertOp = z.strictObject({
i: z.string(),
p: z.number().int().min(0),
u: z.boolean().optional(),
fixedRemoveChange: z.boolean().optional(),
orderedRejections: z.boolean().optional(),
})
const deleteOp = z.strictObject({
d: z.string(),
p: z.number().int().min(0),
u: z.boolean().optional(),
fixedRemoveChange: z.boolean().optional(),
orderedRejections: z.boolean().optional(),
})
const commentOp = z.strictObject({
c: z.string(),
p: z.number().int().min(0),
t: zz.objectId(),
u: z.boolean().optional(),
// sent by history restores; removed again by RangeManager
resolved: z.boolean().optional(),
})
const commentMetadata = z.strictObject({
user_id: z.string().optional(),
ts: z.string().optional(),
})
// tracked changes always carry the author and the time of the edit: both
// RangesTracker and history restores set them when creating the change
const trackedChangeMetadata = z.strictObject({
user_id: z.string(),
ts: z.string(),
})
const comment = z.strictObject({
id: zz.objectId().optional(),
op: commentOp,
metadata: commentMetadata.optional(),
})
const trackedChange = z.strictObject({
id: zz.objectId().optional(),
op: insertOp.or(deleteOp),
metadata: trackedChangeMetadata,
})
const ranges = z.strictObject({
comments: z.array(comment).optional(),
changes: z.array(trackedChange).optional(),
})
module.exports = {
insertOp,
deleteOp,
commentOp,
comment,
trackedChange,
ranges,
}
@@ -0,0 +1,129 @@
const { expect } = require('chai')
const { ranges } = require('../../schemas')
const metadata = Object.freeze({
user_id: '65a4f2c3b1e4d5a6f7089123',
ts: '2026-08-14T10:47:58.000Z',
})
const threadId = '65a4f2c3b1e4d5a6f7089124'
// an 18 character RangesTracker id seed plus its 6 character increment
const changeId = '65a4f2c3b1e4d5a6f7' + '000001'
describe('schemas', function () {
describe('ranges', function () {
it('accepts a legacy fixedRemoveChange flag on an insert op', function () {
const result = ranges.safeParse({
changes: [
{ op: { i: 'foo', p: 0, fixedRemoveChange: true }, metadata },
],
})
expect(result.success).to.equal(true)
})
it('accepts a legacy fixedRemoveChange flag on a delete op', function () {
const result = ranges.safeParse({
changes: [
{ op: { d: 'foo', p: 0, fixedRemoveChange: true }, metadata },
],
})
expect(result.success).to.equal(true)
})
it('accepts a legacy orderedRejections flag on an insert op', function () {
const result = ranges.safeParse({
changes: [
{ op: { i: 'foo', p: 0, orderedRejections: true }, metadata },
],
})
expect(result.success).to.equal(true)
})
it('accepts a legacy orderedRejections flag on a delete op', function () {
const result = ranges.safeParse({
changes: [
{ op: { d: 'foo', p: 0, orderedRejections: true }, metadata },
],
})
expect(result.success).to.equal(true)
})
it('rejects an unrecognized key on an insert op', function () {
const result = ranges.safeParse({
changes: [{ op: { i: 'foo', p: 0, somethingElse: true }, metadata }],
})
expect(result.success).to.equal(false)
})
it('rejects a tracked change without metadata', function () {
const result = ranges.safeParse({
changes: [{ op: { i: 'foo', p: 0 } }],
})
expect(result.success).to.equal(false)
})
it('rejects a tracked change without a user_id', function () {
const result = ranges.safeParse({
changes: [{ op: { i: 'foo', p: 0 }, metadata: { ts: metadata.ts } }],
})
expect(result.success).to.equal(false)
})
it('accepts a tracked change with a RangesTracker id', function () {
const result = ranges.safeParse({
changes: [{ id: changeId, op: { i: 'foo', p: 0 }, metadata }],
})
expect(result.success).to.equal(true)
})
it('rejects a tracked change with a non-ObjectId id', function () {
const result = ranges.safeParse({
changes: [{ id: 'tc_1000001', op: { i: 'foo', p: 0 }, metadata }],
})
expect(result.success).to.equal(false)
})
it('rejects a tracked change without a ts', function () {
const result = ranges.safeParse({
changes: [
{ op: { i: 'foo', p: 0 }, metadata: { user_id: metadata.user_id } },
],
})
expect(result.success).to.equal(false)
})
it('accepts a comment op with an ObjectId thread id', function () {
const result = ranges.safeParse({
comments: [{ op: { c: 'foo', p: 0, t: threadId } }],
})
expect(result.success).to.equal(true)
})
it('rejects a comment op with a non-ObjectId thread id', function () {
const result = ranges.safeParse({
comments: [{ op: { c: 'foo', p: 0, t: 'thread-id-1' } }],
})
expect(result.success).to.equal(false)
})
it('rejects a comment op without a thread id', function () {
const result = ranges.safeParse({
comments: [{ op: { c: 'foo', p: 0 } }],
})
expect(result.success).to.equal(false)
})
it('accepts a comment with an ObjectId id', function () {
const result = ranges.safeParse({
comments: [{ id: threadId, op: { c: 'foo', p: 0, t: threadId } }],
})
expect(result.success).to.equal(true)
})
it('rejects a comment with a non-ObjectId id', function () {
const result = ranges.safeParse({
comments: [{ id: 'thread-id-1', op: { c: 'foo', p: 0, t: threadId } }],
})
expect(result.success).to.equal(false)
})
})
})
+2 -2
View File
@@ -6,7 +6,7 @@ import Errors from './Errors.js'
import Settings from '@overleaf/settings' import Settings from '@overleaf/settings'
import { expressify } from '@overleaf/promise-utils' import { expressify } from '@overleaf/promise-utils'
import { parseReq, z, zz } from '@overleaf/validation-tools' import { parseReq, z, zz } from '@overleaf/validation-tools'
import * as schemas from './schemas.js' import rangesSchemas from '@overleaf/ranges-tracker/schemas.js'
const projectParamsSchema = z.object({ const projectParamsSchema = z.object({
params: z.strictObject({ params: z.strictObject({
@@ -73,7 +73,7 @@ const updateDocSchema = z.object({
body: z.strictObject({ body: z.strictObject({
lines: z.array(z.string()), lines: z.array(z.string()),
version: z.number(), version: z.number(),
ranges: schemas.ranges, ranges: rangesSchemas.ranges,
}), }),
}) })
-66
View File
@@ -1,66 +0,0 @@
// @ts-check
import { z } from '@overleaf/validation-tools'
// Zod schemas for the ranges data (RangesTracker format) accepted by
// updateDoc.
//
// Ranges reach docstore via web from several writers and are stored (and
// later round-tripped, e.g. by project clone) without normalization, so the
// schemas model wire reality rather than the ideal shape:
// - document-updater flushes RangesTracker data: change/comment ids are
// RangesTracker ids (seed + increment) or thread ids, i.e. strings that are
// not always ObjectIds (and old fixtures/documents carry arbitrary string
// thread ids), see libraries/ranges-tracker/index.cjs
// - history restores send id-less changes and comment ops with a `resolved`
// flag (stripped again by RangeManager), and detached comments without an
// id, see overleaf-editor-core/lib/doc_updater_compatible_ranges.js
// - old tracked changes created while the fixedRemoveChange flag existed
// (removed in https://github.com/overleaf/internal/pull/23993) still carry
// it in their op until accepted/rejected
const insertOp = z.strictObject({
i: z.string(),
p: z.number().int().min(0),
u: z.boolean().optional(),
fixedRemoveChange: z.boolean().optional(),
})
const deleteOp = z.strictObject({
d: z.string(),
p: z.number().int().min(0),
u: z.boolean().optional(),
fixedRemoveChange: z.boolean().optional(),
})
const commentOp = z.strictObject({
c: z.string().optional(),
p: z.number().int().min(0).optional(),
// comment thread ids are usually ObjectIds, but legacy documents carry
// arbitrary strings
t: z.string().optional(),
u: z.boolean().optional(),
// sent by history restores; removed again by RangeManager
resolved: z.boolean().optional(),
})
const rangeMetadata = z.strictObject({
user_id: z.string().optional(),
ts: z.string().optional(),
})
const comment = z.strictObject({
id: z.string().optional(),
op: commentOp,
metadata: rangeMetadata.optional(),
})
const trackedChange = z.strictObject({
id: z.string().optional(),
op: insertOp.or(deleteOp).optional(),
metadata: rangeMetadata.optional(),
})
export const ranges = z.strictObject({
comments: z.array(comment).optional(),
changes: z.array(trackedChange).optional(),
})
+1
View File
@@ -23,6 +23,7 @@
"@overleaf/o-error": "workspace:*", "@overleaf/o-error": "workspace:*",
"@overleaf/object-persistor": "workspace:*", "@overleaf/object-persistor": "workspace:*",
"@overleaf/promise-utils": "workspace:*", "@overleaf/promise-utils": "workspace:*",
"@overleaf/ranges-tracker": "workspace:*",
"@overleaf/settings": "workspace:*", "@overleaf/settings": "workspace:*",
"@overleaf/stream-utils": "^0.1.0", "@overleaf/stream-utils": "^0.1.0",
"@overleaf/validation-tools": "workspace:*", "@overleaf/validation-tools": "workspace:*",
@@ -17,12 +17,23 @@ describe('Getting all docs', function () {
lines: ['one', 'two', 'three'], lines: ['one', 'two', 'three'],
ranges: { ranges: {
comments: [ comments: [
{ id: new ObjectId().toString(), op: { t: this.threadId1 } }, {
id: new ObjectId().toString(),
op: {
c: 'c',
p: 0,
t: this.threadId1,
},
},
], ],
changes: [ changes: [
{ {
id: new ObjectId().toString(), id: new ObjectId().toString(),
metadata: { user_id: 'user-id-1' }, op: { d: 'd', p: 1 },
metadata: {
user_id: 'user-id-1',
ts: new Date().toJSON(),
},
}, },
], ],
}, },
@@ -35,7 +46,11 @@ describe('Getting all docs', function () {
changes: [ changes: [
{ {
id: new ObjectId().toString(), id: new ObjectId().toString(),
metadata: { user_id: 'user-id-2' }, op: { i: 'd', p: 1 },
metadata: {
user_id: 'user-id-2',
ts: new Date().toJSON(),
},
}, },
], ],
}, },
@@ -46,12 +61,19 @@ describe('Getting all docs', function () {
lines: ['111', '222', '333'], lines: ['111', '222', '333'],
ranges: { ranges: {
comments: [ comments: [
{ id: new ObjectId().toString(), op: { t: this.threadId2 } }, {
id: new ObjectId().toString(),
op: { c: 'c', p: 1, t: this.threadId2 },
},
], ],
changes: [ changes: [
{ {
id: new ObjectId().toString(), id: new ObjectId().toString(),
metadata: { user_id: 'anonymous-user' }, op: { d: 'x', p: 1 },
metadata: {
user_id: 'anonymous-user',
ts: new Date().toJSON(),
},
}, },
], ],
}, },
@@ -71,9 +93,21 @@ describe('Getting all docs', function () {
_id: new ObjectId(), _id: new ObjectId(),
lines: ['deleted'], lines: ['deleted'],
ranges: { ranges: {
comments: [{ id: new ObjectId().toString(), op: { t: 'thread-id-3' } }], comments: [
{
id: new ObjectId().toString(),
op: { c: 'c', p: 1, t: new ObjectId().toString() },
},
],
changes: [ changes: [
{ id: new ObjectId().toString(), metadata: { user_id: 'user-id-3' } }, {
id: new ObjectId().toString(),
op: { d: 'd', p: 0 },
metadata: {
user_id: 'user-id-3',
ts: new Date().toJSON(),
},
},
], ],
}, },
rev: 8, rev: 8,
@@ -1,27 +0,0 @@
import { describe, it, expect } from 'vitest'
import { ranges } from '../../../app/js/schemas.js'
describe('schemas', function () {
describe('ranges', function () {
it('accepts a legacy fixedRemoveChange flag on an insert op', function () {
const result = ranges.safeParse({
changes: [{ op: { i: 'foo', p: 0, fixedRemoveChange: true } }],
})
expect(result.success).to.equal(true)
})
it('accepts a legacy fixedRemoveChange flag on a delete op', function () {
const result = ranges.safeParse({
changes: [{ op: { d: 'foo', p: 0, fixedRemoveChange: true } }],
})
expect(result.success).to.equal(true)
})
it('rejects an unrecognized key on an insert op', function () {
const result = ranges.safeParse({
changes: [{ op: { i: 'foo', p: 0, somethingElse: true } }],
})
expect(result.success).to.equal(false)
})
})
})
@@ -1,7 +1,7 @@
const { expressify } = require('@overleaf/promise-utils') const { expressify } = require('@overleaf/promise-utils')
const { parseReq, z, zz } = require('@overleaf/validation-tools') const { parseReq, z, zz } = require('@overleaf/validation-tools')
const editorCoreSchemas = require('overleaf-editor-core/lib/schemas') const editorCoreSchemas = require('overleaf-editor-core/lib/schemas')
const schemas = require('./schemas') const rangesSchemas = require('@overleaf/ranges-tracker/schemas')
const DocumentManager = require('./DocumentManager') const DocumentManager = require('./DocumentManager')
const HistoryManager = require('./HistoryManager') const HistoryManager = require('./HistoryManager')
const ProjectManager = require('./ProjectManager') const ProjectManager = require('./ProjectManager')
@@ -487,15 +487,14 @@ async function deleteMultipleProjects(req, res) {
res.sendStatus(204) // No Content res.sendStatus(204) // No Content
} }
// tracked-change ids are RangesTracker ids (seed + increment), not ObjectIds
const acceptChangesSchema = z.object({ const acceptChangesSchema = z.object({
params: z.strictObject({ params: z.strictObject({
project_id: zz.objectId(), project_id: zz.objectId(),
doc_id: zz.objectId(), doc_id: zz.objectId(),
change_id: z.string().min(1).optional(), change_id: zz.objectId().optional(),
}), }),
body: z.strictObject({ body: z.strictObject({
change_ids: z.array(z.string().min(1)).optional(), change_ids: z.array(zz.objectId()).optional(),
}), }),
}) })
@@ -610,7 +609,7 @@ const addUpdateSchema = z.strictObject({
id: zz.objectId(), id: zz.objectId(),
pathname: zz.safePath(), pathname: zz.safePath(),
docLines: z.string().optional(), docLines: z.string().optional(),
ranges: schemas.ranges.optional(), ranges: rangesSchemas.ranges.optional(),
historyRangesSupport: z.boolean().optional(), historyRangesSupport: z.boolean().optional(),
// legacy filestore url for files without a created blob // legacy filestore url for files without a created blob
url: z.string().nullish(), url: z.string().nullish(),
@@ -1,72 +0,0 @@
// @ts-check
'use strict'
const { z, zz } = require('@overleaf/validation-tools')
// Zod schemas for the sharejs-text-ot ranges data (RangesTracker format).
// The types in ./types.ts are inferred from these schemas.
//
// Ranges data is persisted round-trip (docstore <-> web <-> document-updater)
// without normalization, so legacy shapes survive on old documents and the
// schemas model them explicitly: comments/changes preloaded from web may lack `id`.
// Tightening these is tracked as part of moving the shared editor payload
// schemas into overleaf-editor-core.
//
// Old tracked changes created while the fixedRemoveChange flag existed
// (removed in https://github.com/overleaf/internal/pull/23993) still carry
// it in their op until accepted/rejected.
const insertOp = z.strictObject({
i: z.string(),
p: z.number().int().min(0),
u: z.boolean().optional(),
fixedRemoveChange: z.boolean().optional(),
})
const deleteOp = z.strictObject({
d: z.string(),
p: z.number().int().min(0),
u: z.boolean().optional(),
fixedRemoveChange: z.boolean().optional(),
})
const commentOp = z.strictObject({
c: z.string(),
p: z.number().int().min(0),
t: zz.objectId(),
u: z.boolean().optional(),
// Used by project-history when restoring CommentSnapshots
resolved: z.boolean().optional(),
})
const rangeMetadata = z.strictObject({
user_id: z.string(),
ts: z.string(),
})
const comment = z.strictObject({
id: zz.objectId().optional(),
op: commentOp,
metadata: rangeMetadata.optional(),
})
// tracked-change ids are RangesTracker ids (seed + increment), not ObjectIds
const trackedChange = z.strictObject({
id: z.string().optional(),
op: insertOp.or(deleteOp),
metadata: rangeMetadata,
})
const ranges = z.strictObject({
comments: z.array(comment).optional(),
changes: z.array(trackedChange).optional(),
})
module.exports = {
insertOp,
deleteOp,
commentOp,
comment,
trackedChange,
ranges,
}
+7 -7
View File
@@ -4,7 +4,7 @@ import {
RawEditOperation, RawEditOperation,
} from 'overleaf-editor-core/lib/types' } from 'overleaf-editor-core/lib/types'
import type { z } from '@overleaf/validation-tools' import type { z } from '@overleaf/validation-tools'
import type * as schemas from './schemas' import type rangesSchemas from '@overleaf/ranges-tracker/schemas'
export type OTType = 'sharejs-text-ot' | 'history-ot' export type OTType = 'sharejs-text-ot' | 'history-ot'
@@ -32,25 +32,25 @@ export type HistoryOTEditOperationUpdate = Omit<Update, 'op'> & {
export type Op = InsertOp | DeleteOp | CommentOp | RetainOp export type Op = InsertOp | DeleteOp | CommentOp | RetainOp
export type InsertOp = z.infer<typeof schemas.insertOp> export type InsertOp = z.infer<typeof rangesSchemas.insertOp>
export type RetainOp = { export type RetainOp = {
r: string r: string
p: number p: number
} }
export type DeleteOp = z.infer<typeof schemas.deleteOp> export type DeleteOp = z.infer<typeof rangesSchemas.deleteOp>
export type CommentOp = z.infer<typeof schemas.commentOp> export type CommentOp = z.infer<typeof rangesSchemas.commentOp>
/** /**
* Ranges record on a document * Ranges record on a document
*/ */
export type Ranges = z.infer<typeof schemas.ranges> export type Ranges = z.infer<typeof rangesSchemas.ranges>
export type Comment = z.infer<typeof schemas.comment> export type Comment = z.infer<typeof rangesSchemas.comment>
export type TrackedChange = z.infer<typeof schemas.trackedChange> export type TrackedChange = z.infer<typeof rangesSchemas.trackedChange>
/** /**
* Updates sent to project-history * Updates sent to project-history
@@ -307,9 +307,9 @@ describe('Ranges', function () {
historyRangesSupport: true, historyRangesSupport: true,
}) })
await DocUpdaterClient.preloadDoc(this.project_id, this.doc.id) await DocUpdaterClient.preloadDoc(this.project_id, this.doc.id)
this.id_seed_1 = 'tc_1' this.id_seed_1 = '587357bd35e64f6151'
this.id_seed_2 = 'tc_2' this.id_seed_2 = '587357bd35e64f6152'
this.id_seed_3 = 'tc_3' this.id_seed_3 = '587357bd35e64f6153'
this.updates = [ this.updates = [
{ {
@@ -718,7 +718,7 @@ describe('Ranges', function () {
before(async function () { before(async function () {
this.project_id = DocUpdaterClient.randomId() this.project_id = DocUpdaterClient.randomId()
this.user_id = DocUpdaterClient.randomId() this.user_id = DocUpdaterClient.randomId()
this.id_seed = DocUpdaterClient.randomId() this.id_seed = '587357bd35e64f6157'
this.doc = { this.doc = {
id: DocUpdaterClient.randomId(), id: DocUpdaterClient.randomId(),
lines: ['aaa'], lines: ['aaa'],
@@ -11,7 +11,7 @@ const {
zz, zz,
} = require('@overleaf/validation-tools') } = require('@overleaf/validation-tools')
const { rawStringFileData } = require('overleaf-editor-core/lib/schemas') const { rawStringFileData } = require('overleaf-editor-core/lib/schemas')
const schemas = require('../../../../app/js/schemas') const schemas = require('@overleaf/ranges-tracker/schemas')
const app = express() const app = express()
const MAX_REQUEST_SIZE = 2 * (2 * 1024 * 1024 + 64 * 1024) const MAX_REQUEST_SIZE = 2 * (2 * 1024 * 1024 + 64 * 1024)
@@ -1,36 +0,0 @@
const { expect } = require('chai')
const { insertOp, deleteOp } = require('../../../app/js/schemas')
describe('schemas', function () {
describe('insertOp', function () {
it('accepts a legacy fixedRemoveChange flag', function () {
const result = insertOp.safeParse({
i: 'foo',
p: 0,
fixedRemoveChange: true,
})
expect(result.success).to.equal(true)
})
it('rejects an unrecognized key', function () {
const result = insertOp.safeParse({ i: 'foo', p: 0, somethingElse: true })
expect(result.success).to.equal(false)
})
})
describe('deleteOp', function () {
it('accepts a legacy fixedRemoveChange flag', function () {
const result = deleteOp.safeParse({
d: 'foo',
p: 0,
fixedRemoveChange: true,
})
expect(result.success).to.equal(true)
})
it('rejects an unrecognized key', function () {
const result = deleteOp.safeParse({ d: 'foo', p: 0, somethingElse: true })
expect(result.success).to.equal(false)
})
})
})
+5 -5
View File
@@ -40,7 +40,10 @@ const applyOtUpdateSchema = z.strictObject({
lastV: z.number().optional(), lastV: z.number().optional(),
meta: z meta: z
.strictObject({ .strictObject({
tc: z.string().optional(), // the seed RangesTracker builds tracked-change ids from: the first 18
// characters of an ObjectId, leaving 6 for the increment part, see
// RangesTracker.generateIdSeed
tc: zz.hex().length(18).optional(),
}) })
.optional(), .optional(),
op: z op: z
@@ -62,10 +65,7 @@ const applyOtUpdateSchema = z.strictObject({
z.strictObject({ z.strictObject({
c: z.string(), c: z.string(),
p: z.number().int().min(0), p: z.number().int().min(0),
// comment thread ids are usually ObjectIds, but legacy t: zz.objectId(),
// documents carry arbitrary strings (see docstore's ranges
// schema for the same caveat) -- kept as a plain string
t: z.string(),
u: z.boolean().optional(), u: z.boolean().optional(),
}) })
) )
@@ -340,7 +340,7 @@ describe('applyOtUpdate', function () {
describe('when authorized to read-only with a comment update', function () { describe('when authorized to read-only with a comment update', function () {
before(function (done) { before(function (done) {
this.comment_update = { this.comment_update = {
op: [{ c: 'foo', p: 42, t: '1234' }], op: [{ c: 'foo', p: 42, t: FixturesManager.getRandomId() }],
v: 42, v: 42,
} }
return async.series( return async.series(
@@ -930,6 +930,201 @@ describe('applyOtUpdate', function () {
}) })
}) })
describe('when authorized with a tracked change id seed', function () {
before(function (done) {
// RangesTracker seeds are the first 18 characters of an ObjectId
this.id_seed = FixturesManager.getRandomId().slice(0, 18)
this.update = {
op: [{ i: 'foo', p: 42 }],
v: 42,
meta: { tc: this.id_seed },
}
return async.series(
[
cb => {
return FixturesManager.setUpProject(
{
privilegeLevel: 'readAndWrite',
},
(e, { project_id: projectId, user_id: userId }) => {
this.project_id = projectId
this.user_id = userId
return cb(e)
}
)
},
cb => {
return FixturesManager.setUpDoc(
this.project_id,
{ lines: this.lines, version: this.version, ops: this.ops },
(e, { doc_id: docId }) => {
this.doc_id = docId
return cb(e)
}
)
},
cb => {
this.client = RealTimeClient.connect(this.project_id, cb)
},
cb => {
return this.client.emit('joinDoc', this.doc_id, cb)
},
cb => {
return this.client.emit(
'applyOtUpdate',
this.doc_id,
this.update,
cb
)
},
],
done
)
})
it('should push the project into the pending updates list', function (done) {
getPendingUpdatesList((error, ...rest) => {
if (error) return done(error)
const [projectId] = Array.from(rest[0])
projectId.should.equal(this.project_id)
return done()
})
return null
})
it('should push the update into redis with the id seed', function (done) {
rclient.lrange(
redisSettings.documentupdater.key_schema.pendingProjectUpdates({
project_id: this.project_id,
}),
0,
-1,
(error, ...rest) => {
if (error) return done(error)
let [update] = Array.from(rest[0])
update = JSON.parse(update)
update.op.should.deep.equal(this.update.op)
update.meta.should.include({
source: this.client.publicId,
user_id: this.user_id,
tc: this.id_seed,
})
return done()
}
)
return null
})
return after(function (done) {
return async.series(
[
cb => clearPendingUpdatesList(cb),
cb =>
rclient.del(
'DocsWithPendingUpdates',
`${this.project_id}:${this.doc_id}`,
cb
),
cb =>
rclient.del(
redisSettings.documentupdater.key_schema.pendingProjectUpdates({
project_id: this.project_id,
}),
cb
),
],
done
)
})
})
describe('when authorized with a malformed tracked change id seed', function () {
before(function (done) {
this.update = {
op: [{ i: 'foo', p: 42 }],
v: 42,
meta: { tc: 'not-an-id-seed' },
}
return async.series(
[
cb => {
return FixturesManager.setUpProject(
{
privilegeLevel: 'readAndWrite',
},
(e, { project_id: projectId, user_id: userId }) => {
this.project_id = projectId
this.user_id = userId
return cb(e)
}
)
},
cb => {
return FixturesManager.setUpDoc(
this.project_id,
{ lines: this.lines, version: this.version, ops: this.ops },
(e, { doc_id: docId }) => {
this.doc_id = docId
return cb(e)
}
)
},
cb => {
this.client = RealTimeClient.connect(this.project_id, cb)
},
cb => {
return this.client.emit('joinDoc', this.doc_id, cb)
},
cb => {
return this.client.emit(
'applyOtUpdate',
this.doc_id,
this.update,
error => {
this.error = error
return cb()
}
)
},
],
done
)
})
it('should return an error', function () {
return expect(this.error).to.exist
})
it('should disconnect the client', function (done) {
return setTimeout(() => {
this.client.socket.connected.should.equal(false)
return done()
}, 300)
})
return it('should not put the update in redis', function (done) {
rclient.llen(
redisSettings.documentupdater.key_schema.pendingProjectUpdates({
project_id: this.project_id,
}),
(error, len) => {
if (error) return done(error)
len.should.equal(0)
return done()
}
)
return null
})
})
describe('when authorized with unknown fields', function () { describe('when authorized with unknown fields', function () {
before(function (done) { before(function (done) {
this.update = { this.update = {
@@ -9,64 +9,7 @@ import { plainTextResponse } from '../../infrastructure/Response.mjs'
import { expressify } from '@overleaf/promise-utils' import { expressify } from '@overleaf/promise-utils'
import Modules from '../../infrastructure/Modules.mjs' import Modules from '../../infrastructure/Modules.mjs'
import { z, zz, parseReq } from '../../infrastructure/Validation.mjs' import { z, zz, parseReq } from '../../infrastructure/Validation.mjs'
import rangesSchemas from '@overleaf/ranges-tracker/schemas.js'
// Zod schemas for the sharejs-text-ot ranges data (RangesTracker format)
// that document-updater flushes to this endpoint -- NOT the unrelated
// overleaf-editor-core StringFileData rawComment/rawTrackedChange shape
// (that's the canonical history-ot representation, a different shape used
// elsewhere for linked-file/history payloads).
//
// This mirrors document-updater's own app/js/schemas.js (the sender) and
// docstore's app/js/schemas.js (the next hop, which this data is forwarded
// to untouched via DocstoreManager) -- see those files' comments for why the
// fields are this permissive: ids aren't always ObjectIds (RangesTracker ids
// are seed+increment strings; legacy documents carry arbitrary string thread
// ids), and history restores send id-less changes/detached comments plus a
// `resolved` flag.
const insertOp = z.strictObject({
i: z.string(),
p: z.number().int().min(0),
u: z.boolean().optional(),
fixedRemoveChange: z.boolean().optional(),
})
const deleteOp = z.strictObject({
d: z.string(),
p: z.number().int().min(0),
u: z.boolean().optional(),
fixedRemoveChange: z.boolean().optional(),
})
const commentOp = z.strictObject({
c: z.string().optional(),
p: z.number().int().min(0).optional(),
t: z.string().optional(),
u: z.boolean().optional(),
// sent by history restores; removed again by RangesManager
resolved: z.boolean().optional(),
})
const rangeMetadata = z.strictObject({
user_id: z.string().optional(),
ts: z.string().optional(),
})
const comment = z.strictObject({
id: z.string().optional(),
op: commentOp,
metadata: rangeMetadata.optional(),
})
const trackedChange = z.strictObject({
id: z.string().optional(),
op: insertOp.or(deleteOp).optional(),
metadata: rangeMetadata.optional(),
})
const rangesSchema = z.strictObject({
comments: z.array(comment).optional(),
changes: z.array(trackedChange).optional(),
})
const getDocumentSchema = z.object({ const getDocumentSchema = z.object({
params: z.strictObject({ params: z.strictObject({
@@ -177,7 +120,7 @@ const setDocumentSchema = z.object({
// history-ot docs) -- never the raw StringFileData shape. // history-ot docs) -- never the raw StringFileData shape.
lines: z.array(z.string()), lines: z.array(z.string()),
version: z.number().int(), version: z.number().int(),
ranges: rangesSchema, ranges: rangesSchemas.ranges,
lastUpdatedAt: z.coerce.number().int().positive().nullish(), lastUpdatedAt: z.coerce.number().int().positive().nullish(),
lastUpdatedBy: zz.objectId().nullish(), lastUpdatedBy: zz.objectId().nullish(),
}), }),
+1
View File
@@ -100,6 +100,7 @@
"@overleaf/o-error": "workspace:*", "@overleaf/o-error": "workspace:*",
"@overleaf/object-persistor": "workspace:*", "@overleaf/object-persistor": "workspace:*",
"@overleaf/promise-utils": "workspace:*", "@overleaf/promise-utils": "workspace:*",
"@overleaf/ranges-tracker": "workspace:*",
"@overleaf/redis-wrapper": "workspace:*", "@overleaf/redis-wrapper": "workspace:*",
"@overleaf/settings": "workspace:*", "@overleaf/settings": "workspace:*",
"@overleaf/stream-utils": "workspace:*", "@overleaf/stream-utils": "workspace:*",
@@ -1,6 +1,7 @@
import AbstractMockApi from './AbstractMockApi.mjs' import AbstractMockApi from './AbstractMockApi.mjs'
import { parseReq, z, zz } from '@overleaf/validation-tools' import { parseReq, z, zz } from '@overleaf/validation-tools'
import editorCoreSchemas from 'overleaf-editor-core/lib/schemas.js' import editorCoreSchemas from 'overleaf-editor-core/lib/schemas.js'
import rangesSchemas from '@overleaf/ranges-tracker/schemas.js'
const docParamsSchema = z.strictObject({ const docParamsSchema = z.strictObject({
projectId: zz.objectId(), projectId: zz.objectId(),
@@ -26,48 +27,6 @@ const setDocSchema = z.object({
}), }),
}) })
// Ranges data as document-updater persists it (RangesTracker format) --
// mirrors services/document-updater/app/js/schemas.js's `ranges` export
// (this mock stands in for document-updater's own API in web's acceptance
// tests).
const insertOp = z.strictObject({
i: z.string(),
p: z.number().int().min(0),
u: z.boolean().optional(),
fixedRemoveChange: z.boolean().optional(),
})
const deleteOp = z.strictObject({
d: z.string(),
p: z.number().int().min(0),
u: z.boolean().optional(),
fixedRemoveChange: z.boolean().optional(),
})
const commentOp = z.strictObject({
c: z.string(),
p: z.number().int().min(0),
t: zz.objectId().optional(),
u: z.boolean().optional(),
resolved: z.boolean().optional(),
})
const rangeMetadata = z.strictObject({
user_id: z.string(),
ts: z.string(),
})
const comment = z.strictObject({
id: zz.objectId().optional(),
op: commentOp,
metadata: rangeMetadata.optional(),
})
const trackedChange = z.strictObject({
id: z.string().optional(),
op: insertOp.or(deleteOp),
metadata: rangeMetadata,
})
const rangesSchema = z.strictObject({
comments: z.array(comment).optional(),
changes: z.array(trackedChange).optional(),
})
// Mirrors services/document-updater/app/js/HttpController.js's // Mirrors services/document-updater/app/js/HttpController.js's
// updateProjectSchema (renameUpdateSchema/addUpdateSchema) -- this mock's // updateProjectSchema (renameUpdateSchema/addUpdateSchema) -- this mock's
// own routes use :projectId/:docId casing rather than document-updater's // own routes use :projectId/:docId casing rather than document-updater's
@@ -85,7 +44,7 @@ const addUpdateSchema = z.strictObject({
id: zz.objectId(), id: zz.objectId(),
pathname: zz.safePath(), pathname: zz.safePath(),
docLines: z.string().optional(), docLines: z.string().optional(),
ranges: rangesSchema.optional(), ranges: rangesSchemas.ranges.optional(),
historyRangesSupport: z.boolean().optional(), historyRangesSupport: z.boolean().optional(),
// legacy filestore url for files without a created blob // legacy filestore url for files without a created blob
url: z.string().nullish(), url: z.string().nullish(),
@@ -1,46 +1,7 @@
import { db, ObjectId } from '../../../../app/src/infrastructure/mongodb.mjs' import { db, ObjectId } from '../../../../app/src/infrastructure/mongodb.mjs'
import AbstractMockApi from './AbstractMockApi.mjs' import AbstractMockApi from './AbstractMockApi.mjs'
import { parseReq, z, zz } from '@overleaf/validation-tools' import { parseReq, z, zz } from '@overleaf/validation-tools'
import rangesSchemas from '@overleaf/ranges-tracker/schemas.js'
// Mirrors services/docstore/app/js/schemas.js (this mock stands in for
// docstore's own API in web's acceptance tests).
const insertOp = z.strictObject({
i: z.string(),
p: z.number().int().min(0),
u: z.boolean().optional(),
fixedRemoveChange: z.boolean().optional(),
})
const deleteOp = z.strictObject({
d: z.string(),
p: z.number().int().min(0),
u: z.boolean().optional(),
fixedRemoveChange: z.boolean().optional(),
})
const commentOp = z.strictObject({
c: z.string().optional(),
p: z.number().int().min(0).optional(),
t: z.string().optional(),
u: z.boolean().optional(),
resolved: z.boolean().optional(),
})
const rangeMetadata = z.strictObject({
user_id: z.string().optional(),
ts: z.string().optional(),
})
const comment = z.strictObject({
id: z.string().optional(),
op: commentOp,
metadata: rangeMetadata.optional(),
})
const trackedChange = z.strictObject({
id: z.string().optional(),
op: insertOp.or(deleteOp).optional(),
metadata: rangeMetadata.optional(),
})
const rangesSchema = z.strictObject({
comments: z.array(comment).optional(),
changes: z.array(trackedChange).optional(),
})
const docParamsSchema = z.strictObject({ const docParamsSchema = z.strictObject({
projectId: zz.objectId(), projectId: zz.objectId(),
@@ -52,7 +13,7 @@ const updateDocSchema = z.object({
body: z.strictObject({ body: z.strictObject({
lines: z.array(z.string()), lines: z.array(z.string()),
version: z.number(), version: z.number(),
ranges: rangesSchema, ranges: rangesSchemas.ranges,
}), }),
}) })
@@ -15,22 +15,24 @@ describe('DocumentController', function () {
ctx.doc = { _id: '5c9a5d5b0000000000000002' } ctx.doc = { _id: '5c9a5d5b0000000000000002' }
ctx.doc_lines = ['one', 'two', 'three'] ctx.doc_lines = ['one', 'two', 'three']
ctx.version = 42 ctx.version = 42
ctx.threadId1 = '5c9a5d5b0000000000000011'
ctx.threadId2 = '5c9a5d5b0000000000000012'
ctx.ranges = { ctx.ranges = {
comments: [ comments: [
{ {
id: 'comment1', id: ctx.threadId1,
op: { op: {
c: 'foo', c: 'foo',
p: 123, p: 123,
t: 'comment1', t: ctx.threadId1,
}, },
}, },
{ {
id: 'comment2', id: ctx.threadId2,
op: { op: {
c: 'bar', c: 'bar',
p: 456, p: 456,
t: 'comment2', t: ctx.threadId2,
}, },
}, },
], ],
@@ -53,8 +55,8 @@ describe('DocumentController', function () {
}, },
} }
ctx.resolvedThreadIds = [ ctx.resolvedThreadIds = [
'comment2', ctx.threadId2,
'comment4', // Comment in project but not in doc '5c9a5d5b0000000000000014', // Comment in project but not in doc
] ]
ctx.ProjectGetter = { ctx.ProjectGetter = {
@@ -159,7 +161,7 @@ describe('DocumentController', function () {
pathname: ctx.pathname, pathname: ctx.pathname,
projectHistoryId: ctx.project.overleaf.history.id, projectHistoryId: ctx.project.overleaf.history.id,
projectHistoryType: 'project-history', projectHistoryType: 'project-history',
resolvedCommentIds: ['comment2'], resolvedCommentIds: [ctx.threadId2],
historyRangesSupport: false, historyRangesSupport: false,
otMigrationStage: 0, otMigrationStage: 0,
}) })
+1
View File
@@ -22,6 +22,7 @@
"@overleaf/piece-table": ["./libraries/piece-table"], "@overleaf/piece-table": ["./libraries/piece-table"],
"@overleaf/promise-utils": ["./libraries/promise-utils"], "@overleaf/promise-utils": ["./libraries/promise-utils"],
"@overleaf/ranges-tracker": ["./libraries/ranges-tracker"], "@overleaf/ranges-tracker": ["./libraries/ranges-tracker"],
"@overleaf/ranges-tracker/*": ["./libraries/ranges-tracker/*"],
"@overleaf/redis-wrapper": ["./libraries/redis-wrapper"], "@overleaf/redis-wrapper": ["./libraries/redis-wrapper"],
"@overleaf/settings": ["./libraries/settings"], "@overleaf/settings": ["./libraries/settings"],
"@overleaf/stream-utils": ["./libraries/stream-utils"], "@overleaf/stream-utils": ["./libraries/stream-utils"],
+2
View File
@@ -7158,6 +7158,7 @@ __metadata:
"@overleaf/o-error": "workspace:*" "@overleaf/o-error": "workspace:*"
"@overleaf/object-persistor": "workspace:*" "@overleaf/object-persistor": "workspace:*"
"@overleaf/promise-utils": "workspace:*" "@overleaf/promise-utils": "workspace:*"
"@overleaf/ranges-tracker": "workspace:*"
"@overleaf/settings": "workspace:*" "@overleaf/settings": "workspace:*"
"@overleaf/stream-utils": "npm:^0.1.0" "@overleaf/stream-utils": "npm:^0.1.0"
"@overleaf/validation-tools": "workspace:*" "@overleaf/validation-tools": "workspace:*"
@@ -7619,6 +7620,7 @@ __metadata:
version: 0.0.0-use.local version: 0.0.0-use.local
resolution: "@overleaf/ranges-tracker@workspace:libraries/ranges-tracker" resolution: "@overleaf/ranges-tracker@workspace:libraries/ranges-tracker"
dependencies: dependencies:
"@overleaf/validation-tools": "workspace:*"
"@types/node": "npm:^24.5.2" "@types/node": "npm:^24.5.2"
chai: "npm:^4.3.6" chai: "npm:^4.3.6"
core-js: "npm:~3.46.0" core-js: "npm:~3.46.0"