be able to intersect idmaps and idsets

This commit is contained in:
Kevin Jahns
2025-05-09 20:34:18 +02:00
parent 62422544bc
commit b646654df1
7 changed files with 252 additions and 27 deletions

View File

@@ -518,10 +518,14 @@ export const typeListGetContent = (type, am) => {
}
for (let i = 0; i < cs.length; i++) {
const { content, deleted, attrs } = cs[i]
const attribution = createAttributionFromAttributionItems(attrs, deleted)
const { attribution, retainOnly } = createAttributionFromAttributionItems(attrs, deleted)
if (retainOnly) {
d.retain(content.getLength())
} else if (content.isCountable()) {
d.insert(content.getContent(), null, attribution)
}
}
}
return d
}
@@ -1008,7 +1012,7 @@ export const typeMapGetContent = (parent, am) => {
am.readContent(cs, item, false)
const { deleted, attrs, content } = cs[cs.length - 1]
const c = array.last(content.getContent())
const attribution = createAttributionFromAttributionItems(attrs, deleted)
const { attribution } = createAttributionFromAttributionItems(attrs, deleted)
if (deleted) {
mapcontent[key] = { prevValue: c, value: undefined, attribution }
} else {

View File

@@ -24,7 +24,7 @@ import {
updateMarkerChanges,
ContentType,
warnPrematureAccess,
noAttributionsManager, AbstractAttributionManager, ArraySearchMarker, UpdateDecoderV1, UpdateDecoderV2, UpdateEncoderV1, UpdateEncoderV2, Doc, Item, Transaction, // eslint-disable-line
IdSet, noAttributionsManager, AbstractAttributionManager, ArraySearchMarker, UpdateDecoderV1, UpdateDecoderV2, UpdateEncoderV1, UpdateEncoderV2, Doc, Item, Transaction, // eslint-disable-line
createAttributionFromAttributionItems
} from '../internals.js'
@@ -678,7 +678,7 @@ export class YTextEvent extends YEvent {
am.readContent(cs, item, freshDelete) // do item.right after calling this
for (let i = 0; i < cs.length; i++) {
const c = cs[i]
const attribution = createAttributionFromAttributionItems(c.attrs, c.deleted)
const { attribution } = createAttributionFromAttributionItems(c.attrs, c.deleted)
switch (c.content.constructor) {
case ContentType:
case ContentEmbed:
@@ -983,15 +983,23 @@ export class YText extends AbstractType {
}
for (let i = 0; i < cs.length; i++) {
const { content, deleted, attrs } = cs[i]
const attribution = createAttributionFromAttributionItems(attrs, deleted)
const { attribution, retainOnly } = createAttributionFromAttributionItems(attrs, deleted)
switch (content.constructor) {
case ContentString: {
if (retainOnly) {
d.retain(content.getLength(), null, attribution)
} else {
d.insert(/** @type {ContentString} */ (content).str, null, attribution)
}
break
}
case ContentType:
case ContentEmbed: {
if (retainOnly) {
d.retain(content.getLength(), null, attribution)
} else {
d.insert(/** @type {ContentEmbed | ContentType} */ (content).getContent()[0], null, attribution)
}
break
}
case ContentFormat: {

View File

@@ -31,13 +31,14 @@ import * as error from 'lib0/error'
/**
* @param {Array<import('./IdMap.js').AttributionItem<any>>?} attrs
* @param {boolean} deleted - whether the attributed item is deleted
* @return {Attribution?}
* @return {{ attribution: Attribution?, retainOnly: boolean }}
*/
export const createAttributionFromAttributionItems = (attrs, deleted) => {
/**
* @type {Attribution?}
*/
let attribution = null
let retainOnly = false
if (attrs != null) {
attribution = {}
if (deleted) {
@@ -47,6 +48,9 @@ export const createAttributionFromAttributionItems = (attrs, deleted) => {
}
attrs.forEach(attr => {
switch (attr.name) {
case 'retain':
retainOnly = true
break
case 'insert':
case 'delete':
case 'suggest': {
@@ -63,7 +67,7 @@ export const createAttributionFromAttributionItems = (attrs, deleted) => {
}
})
}
return attribution
return { attribution, retainOnly }
}
/**
@@ -120,7 +124,7 @@ export class TwosetAttributionManager {
*/
readContent (contents, item, forceRead) {
const deleted = item.deleted
const slice = (deleted ? this.deletes : this.inserts).slice(item.id, item.length)
const slice = (deleted ? this.deletes : this.inserts).sliceId(item.id, item.length)
let content = slice.length === 1 ? item.content : item.content.copy()
slice.forEach(s => {
const c = content
@@ -221,7 +225,7 @@ export class DiffAttributionManager {
*/
readContent (contents, item, forceRead) {
const deleted = item.deleted || /** @type {any} */ (item.parent).doc !== this._nextDoc
const slice = (deleted ? this.deletes : this.inserts).slice(item.id, item.length)
const slice = (deleted ? this.deletes : this.inserts).sliceId(item.id, item.length)
let content = slice.length === 1 ? item.content : item.content.copy()
if (content instanceof ContentDeleted && slice[0].attrs != null && !this.inserts.hasId(item.id)) {
// Retrieved item is never more fragmented than the newer item.
@@ -290,7 +294,7 @@ export class SnapshotAttributionManager {
*/
readContent (contents, item, forceRead) {
if ((this.nextSnapshot.sv.get(item.id.client) ?? 0) <= item.id.clock) return // future item that should not be displayed
const slice = this.attrs.slice(item.id, item.length)
const slice = this.attrs.sliceId(item.id, item.length)
let content = slice.length === 1 ? item.content : item.content.copy()
slice.forEach(s => {
const deleted = this.nextSnapshot.ds.has(item.id.client, s.clock)

View File

@@ -4,7 +4,8 @@ import {
findRangeStartInIdRanges,
_deleteRangeFromIdSet,
DSDecoderV1, DSDecoderV2, IdSetEncoderV1, IdSetEncoderV2, IdSet, ID, // eslint-disable-line
_insertIntoIdSet
_insertIntoIdSet,
_intersectSets
} from '../internals.js'
import * as array from 'lib0/array'
@@ -360,8 +361,20 @@ export class IdMap {
* @param {number} len
* @return {Array<MaybeAttrRange<Attrs>>}
*/
slice (id, len) {
const dr = this.clients.get(id.client)
sliceId (id, len) {
return this.slice(id.client, id.clock, len)
}
/**
* Return attributions for a slice of ids.
*
* @param {number} client
* @param {number} clock
* @param {number} len
* @return {Array<MaybeAttrRange<Attrs>>}
*/
slice (client, clock, len) {
const dr = this.clients.get(client)
/**
* @type {Array<MaybeAttrRange<Attrs>>}
*/
@@ -371,19 +384,19 @@ export class IdMap {
* @type {Array<AttrRange<Attrs>>}
*/
const ranges = dr.getIds()
let index = findRangeStartInIdRanges(ranges, id.clock)
let index = findRangeStartInIdRanges(ranges, clock)
if (index !== null) {
let prev = null
while (index < ranges.length) {
let r = ranges[index]
if (r.clock < id.clock) {
r = new AttrRange(id.clock, r.len - (id.clock - r.clock), r.attrs)
if (r.clock < clock) {
r = new AttrRange(clock, r.len - (clock - r.clock), r.attrs)
}
if (r.clock + r.len > id.clock + len) {
r = new AttrRange(r.clock, id.clock + len - r.clock, r.attrs)
if (r.clock + r.len > clock + len) {
r = new AttrRange(r.clock, clock + len - r.clock, r.attrs)
}
if (r.len <= 0) break
const prevEnd = prev != null ? prev.clock + prev.len : id.clock
const prevEnd = prev != null ? prev.clock + prev.len : clock
if (prevEnd < r.clock) {
res.push(createMaybeAttrRange(prevEnd, r.clock - prevEnd, null))
}
@@ -396,11 +409,11 @@ export class IdMap {
if (res.length > 0) {
const last = res[res.length - 1]
const end = last.clock + last.len
if (end < id.clock + len) {
res.push(createMaybeAttrRange(end, id.clock + len - end, null))
if (end < clock + len) {
res.push(createMaybeAttrRange(end, clock + len - end, null))
}
} else {
res.push(createMaybeAttrRange(id.clock, len, null))
res.push(createMaybeAttrRange(clock, len, null))
}
return res
}
@@ -610,3 +623,5 @@ export const diffIdMap = (set, exclude) => {
diffed.attrsH = set.attrsH
return diffed
}
export const intersectMaps = _intersectSets

View File

@@ -6,7 +6,8 @@ import {
UpdateEncoderV2,
IdMap,
AttrRanges,
AbstractStruct, DSDecoderV1, IdSetEncoderV1, DSDecoderV2, IdSetEncoderV2, Item, GC, StructStore, Transaction, ID // eslint-disable-line
AttrRange,
AbstractStruct, DSDecoderV1, IdSetEncoderV1, DSDecoderV2, IdSetEncoderV2, Item, GC, StructStore, Transaction, ID, AttributionItem, // eslint-disable-line
} from '../internals.js'
import * as array from 'lib0/array'
@@ -37,7 +38,46 @@ export class IdRange {
copyWith (clock, len) {
return new IdRange(clock, len)
}
/**
* Helper method making this compatible with IdMap.
*
* @return {Array<import('./IdMap.js').AttributionItem<any>>}
*/
get attrs () {
return []
}
}
export class MaybeIdRange {
/**
* @param {number} clock
* @param {number} len
* @param {boolean} exists
*/
constructor (clock, len, exists) {
/**
* @type {number}
*/
this.clock = clock
/**
* @type {number}
*/
this.len = len
/**
* @type {boolean}
*/
this.exists = exists
}
}
/**
* @param {number} clock
* @param {number} len
* @param {boolean} exists
* @return {MaybeIdRange}
*/
export const createMaybeIdRange = (clock, len, exists) => new MaybeIdRange(clock, len, exists)
class IdRanges {
/**
@@ -144,6 +184,59 @@ export class IdSet {
return false
}
/**
* Return slices of ids that exist in this idset.
*
* @param {number} client
* @param {number} clock
* @param {number} len
* @return {Array<MaybeIdRange>}
*/
slice (client, clock, len) {
const dr = this.clients.get(client)
/**
* @type {Array<MaybeIdRange>}
*/
const res = []
if (dr) {
/**
* @type {Array<IdRange>}
*/
const ranges = dr.getIds()
let index = findRangeStartInIdRanges(ranges, clock)
if (index !== null) {
let prev = null
while (index < ranges.length) {
let r = ranges[index]
if (r.clock < clock) {
r = new IdRange(clock, r.len - (clock - r.clock))
}
if (r.clock + r.len > clock + len) {
r = new IdRange(r.clock, clock + len - r.clock)
}
if (r.len <= 0) break
const prevEnd = prev != null ? prev.clock + prev.len : clock
if (prevEnd < r.clock) {
res.push(createMaybeIdRange(prevEnd, r.clock - prevEnd, false))
}
prev = r
res.push(createMaybeIdRange(r.clock, r.len, true))
index++
}
}
}
if (res.length > 0) {
const last = res[res.length - 1]
const end = last.clock + last.len
if (end < clock + len) {
res.push(createMaybeIdRange(end, clock + len - end, false))
}
} else {
res.push(createMaybeIdRange(clock, len, false))
}
return res
}
/**
* @param {number} client
* @param {number} clock
@@ -399,6 +492,55 @@ export const _diffSet = (set, exclude) => {
*/
export const diffIdSet = _diffSet
/**
* @template {IdSet | IdMap<any>} SetA
* @template {IdSet | IdMap<any>} SetB
* @param {SetA} setA
* @param {SetB} setB
* @return {SetA extends IdMap<infer A> ? (SetB extends IdMap<infer B> ? IdMap<A | B> : IdMap<A>) : IdSet}
*/
export const _intersectSets = (setA, setB) => {
/**
* @type {IdMap<any> | IdSet}
*/
const res = /** @type {any } */ (setA instanceof IdSet ? new IdSet() : new IdMap())
const Ranges = setA instanceof IdSet ? IdRanges : AttrRanges
setA.clients.forEach((_aRanges, client) => {
/**
* @type {Array<IdRange>}
*/
const resRanges = []
const _bRanges = setB.clients.get(client)
const aRanges = _aRanges.getIds()
if (_bRanges != null) {
const bRanges = _bRanges.getIds()
for (let a = 0, b = 0; a < aRanges.length && b < bRanges.length;) {
const aRange = aRanges[a]
const bRange = bRanges[b]
// construct overlap
const clock = math.max(aRange.clock, bRange.clock)
const len = math.min(aRange.len - (clock - aRange.clock), bRange.len - (clock - bRange.clock))
if (len > 0) {
resRanges.push(aRange instanceof AttrRange
? new AttrRange(clock, len, /** @type {Array<AttributionItem<any>>} */ (aRange.attrs).concat(bRange.attrs))
: new IdRange(clock, len)
)
}
if (aRange.clock + aRange.len < bRange.clock + bRange.len) {
a++
} else {
b++
}
}
}
// @ts-ignore
if (resRanges.length > 0) res.clients.set(client, /** @type {any} */ (new Ranges(resRanges)))
})
return /** @type {any} */ (res)
}
export const intersectSets = _intersectSets
/**
* @param {IdSet} idSet
* @param {number} client

View File

@@ -94,7 +94,7 @@ export const testRepeatMergingMultipleIdMaps = tc => {
const mergedHas = merged.hasId(new ID(iclient, iclock))
const oneHas = sets.some(ids => ids.hasId(new ID(iclient, iclock)))
t.assert(mergedHas === oneHas)
const mergedAttrs = merged.slice(new ID(iclient, iclock), 1)
const mergedAttrs = merged.sliceId(new ID(iclient, iclock), 1)
mergedAttrs.forEach(a => {
if (a.attrs != null) {
composed.add(iclient, a.clock, a.len, a.attrs)
@@ -161,3 +161,36 @@ export const testRepeatRandomDeletes = tc => {
}
compareIdMaps(idset, diffed)
}
/**
* @param {t.TestCase} tc
*/
export const testrepeatRandomIntersects = tc => {
const clients = 4
const clockRange = 100
const ids1 = createRandomIdMap(tc.prng, clients, clockRange, [1])
const ids2 = createRandomIdMap(tc.prng, clients, clockRange, ['two'])
const intersected = idmap.intersectMaps(ids1, ids2)
for (let client = 0; client < clients; client++) {
for (let clock = 0; clock < clockRange; clock++) {
t.assert((ids1.has(client, clock) && ids2.has(client, clock)) === intersected.has(client, clock))
/**
* @type {Array<any>?}
*/
const slice1 = ids1.slice(client, clock, 1)[0].attrs
/**
* @type {Array<any>?}
*/
const slice2 = ids2.slice(client, clock, 1)[0].attrs
/**
* @type {Array<any>?}
*/
const expectedAttrs = (slice1 != null && slice2 != null) ? slice1.concat(slice2) : null
const attrs = intersected.slice(client, clock, 1)[0].attrs
t.assert(attrs?.length === expectedAttrs?.length)
}
}
const diffed1 = idmap.diffIdMap(ids1, ids2)
const altDiffed1 = idmap.diffIdMap(ids1, intersected)
compareIdMaps(diffed1, altDiffed1)
}

View File

@@ -221,3 +221,22 @@ export const testRepeatRandomDiffing2 = tc => {
const excludedMerged = d.mergeIdSets([e1, e2])
compareIdSets(mergedExcluded, excludedMerged)
}
/**
* @param {t.TestCase} tc
*/
export const testrepeatRandomIntersects = tc => {
const clients = 4
const clockRange = 100
const ids1 = createRandomIdSet(tc.prng, clients, clockRange)
const ids2 = createRandomIdSet(tc.prng, clients, clockRange)
const intersected = d.intersectSets(ids1, ids2)
for (let client = 0; client < clients; client++) {
for (let clock = 0; clock < clockRange; clock++) {
t.assert((ids1.has(client, clock) && ids2.has(client, clock)) === intersected.has(client, clock))
}
}
const diffed1 = d.diffIdSet(ids1, ids2)
const altDiffed1 = d.diffIdSet(ids1, intersected)
compareIdSets(diffed1, altDiffed1)
}