mirror of
https://github.com/yu-i-i/overleaf-cep.git
synced 2026-11-12 13:13:45 +01:00
[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:
@@ -4,6 +4,7 @@
|
||||
"main": "index.cjs",
|
||||
"files": [
|
||||
"index.cjs",
|
||||
"schemas.js",
|
||||
"types"
|
||||
],
|
||||
"author": "Overleaf (https://www.overleaf.com)",
|
||||
@@ -16,6 +17,9 @@
|
||||
"test:unit": "mocha --exit test/**/*.{js,cjs}",
|
||||
"types:check": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@overleaf/validation-tools": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.5.2",
|
||||
"chai": "^4.3.6",
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user