[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
@@ -1,7 +1,7 @@
const { expressify } = require('@overleaf/promise-utils')
const { parseReq, z, zz } = require('@overleaf/validation-tools')
const editorCoreSchemas = require('overleaf-editor-core/lib/schemas')
const schemas = require('./schemas')
const rangesSchemas = require('@overleaf/ranges-tracker/schemas')
const DocumentManager = require('./DocumentManager')
const HistoryManager = require('./HistoryManager')
const ProjectManager = require('./ProjectManager')
@@ -487,15 +487,14 @@ async function deleteMultipleProjects(req, res) {
res.sendStatus(204) // No Content
}
// tracked-change ids are RangesTracker ids (seed + increment), not ObjectIds
const acceptChangesSchema = z.object({
params: z.strictObject({
project_id: zz.objectId(),
doc_id: zz.objectId(),
change_id: z.string().min(1).optional(),
change_id: zz.objectId().optional(),
}),
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(),
pathname: zz.safePath(),
docLines: z.string().optional(),
ranges: schemas.ranges.optional(),
ranges: rangesSchemas.ranges.optional(),
historyRangesSupport: z.boolean().optional(),
// legacy filestore url for files without a created blob
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,
} from 'overleaf-editor-core/lib/types'
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'
@@ -32,25 +32,25 @@ export type HistoryOTEditOperationUpdate = Omit<Update, 'op'> & {
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 = {
r: string
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
*/
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
@@ -307,9 +307,9 @@ describe('Ranges', function () {
historyRangesSupport: true,
})
await DocUpdaterClient.preloadDoc(this.project_id, this.doc.id)
this.id_seed_1 = 'tc_1'
this.id_seed_2 = 'tc_2'
this.id_seed_3 = 'tc_3'
this.id_seed_1 = '587357bd35e64f6151'
this.id_seed_2 = '587357bd35e64f6152'
this.id_seed_3 = '587357bd35e64f6153'
this.updates = [
{
@@ -718,7 +718,7 @@ describe('Ranges', function () {
before(async function () {
this.project_id = DocUpdaterClient.randomId()
this.user_id = DocUpdaterClient.randomId()
this.id_seed = DocUpdaterClient.randomId()
this.id_seed = '587357bd35e64f6157'
this.doc = {
id: DocUpdaterClient.randomId(),
lines: ['aaa'],
@@ -11,7 +11,7 @@ const {
zz,
} = require('@overleaf/validation-tools')
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 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)
})
})
})