@y/y
Version:
Shared Editing Library
1,753 lines • 52.9 kB
JavaScript
import * as math from 'lib0/math'
import * as traits from 'lib0/traits'
import * as encoding from 'lib0/encoding'
import * as decoding from 'lib0/decoding'
import * as buf from 'lib0/buffer'
import * as rabin from 'lib0/hash/rabin'
import * as array from 'lib0/array'
import * as map from 'lib0/map'
import * as s from 'lib0/schema'
import { iterateStructs, findIndexSS, iterateStructsWithoutSplits, tryGc } from './transaction-helpers.js'
import { UpdateEncoderV2, IdSetEncoderV2 } from './UpdateEncoder.js'
import { IdSetDecoderV2 } from './UpdateDecoder.js'
/**
* @typedef {{ inserts: IdSet, deletes: IdSet }} ContentIds
*/
/**
* @typedef {{ inserts: IdMap<any>, deletes: IdMap<any> }} ContentMap
*/
export class IdRange {
/**
* @param {number} clock
* @param {number} len
*/
constructor (clock, len) {
/**
* @type {number}
*/
this.clock = clock
/**
* @type {number}
*/
this.len = len
}
/**
* @param {number} clock
* @param {number} len
*/
copyWith (clock, len) {
return new IdRange(clock, len)
}
/**
* Helper method making this compatible with IdMap.
*
* @return {Array<ContentAttribute<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)
export class IdRanges {
/**
* @param {Array<IdRange>} ids
*/
constructor (ids) {
this.sorted = false
/**
* A typical use-case for IdSet is to append data. We heavily optimize this case by allowing the
* last item to be mutated if it isn't used currently.
* This flag is true if the last item was exposed to the outside.
*/
this._lastIsUsed = false
/**
* @private
*/
this._ids = ids
}
copy () {
const cpy = new IdRanges(this._ids.slice())
cpy.sorted = this.sorted
this._lastIsUsed = true
return cpy
}
/**
* @param {number} clock
* @param {number} length
*/
add (clock, length) {
const last = this._ids[this._ids.length - 1]
if (last != null && last.clock + last.len === clock) {
if (this._lastIsUsed) {
this._ids[this._ids.length - 1] = new IdRange(last.clock, last.len + length)
this._lastIsUsed = false
} else {
this._ids[this._ids.length - 1].len += length
}
} else {
this.sorted = false
this._ids.push(new IdRange(clock, length))
}
}
/**
* Return the list of immutable id ranges, sorted and merged.
*/
getIds () {
const ids = this._ids
this._lastIsUsed = true
if (!this.sorted) {
this.sorted = true
ids.sort((a, b) => a.clock - b.clock)
// merge items without filtering or splicing the array
// i is the current pointer
// j refers to the current insert position for the pointed item
// try to merge dels[i] into dels[j-1] or set dels[j]=dels[i]
let i, j
for (i = 1, j = 1; i < ids.length; i++) {
const left = ids[j - 1]
const right = ids[i]
if (left.clock + left.len >= right.clock) {
const r = right.clock + right.len - left.clock
if (left.len < r) {
ids[j - 1] = new IdRange(left.clock, r)
}
} else if (left.len === 0) {
ids[j - 1] = right
} else {
if (j < i) {
ids[j] = right
}
j++
}
}
ids.length = ids[j - 1].len === 0 ? j - 1 : j
}
return ids
}
/**
* Test a predicate against every attribute held by these ranges. Does not mutate - see
* {@link AttrRanges#everyAttr}.
*
* This exists because an `IdMap` may hold `IdRanges`: {@link insertIntoIdMap}'s `src` may be an
* `IdSet`, and `_insertIntoIdSet` picks the container class from whichever `src` reaches a client
* first. For a pure `IdRanges` the loop is vacuous ({@link IdRange#attrs} is always `[]`), but it
* must *not* shortcut to `true`: the append branch of `_insertIntoIdSet` copies elements without
* checking their class, so an `IdRanges` reached by an `IdSet` first and an `IdMap` second holds
* real `AttrRange`s - which {@link $idMap} must still validate.
*
* @param {(attr:ContentAttribute<any>) => boolean} f
* @return {boolean}
*/
everyAttr (f) {
const ids = this._ids
for (let i = 0; i < ids.length; i++) {
const attrs = ids[i].attrs
if (attrs == null) continue
for (let j = 0; j < attrs.length; j++) {
if (!f(attrs[j])) return false
}
}
return true
}
}
/**
* @implements {traits.EqualityTrait}
*/
export class IdSet {
constructor () {
/**
* @type {Map<number,IdRanges>}
*/
this.clients = new Map()
}
isEmpty () {
return this.clients.size === 0
}
/**
* @param {(idrange:IdRange, client:number) => void} f
*/
forEach (f) {
this.clients.forEach((ranges, client) => {
ranges.getIds().forEach((range) => {
f(range, client)
})
})
}
/**
* @param {ID} id
* @return {boolean}
*/
hasId (id) {
return this.has(id.client, id.clock)
}
/**
* @param {number} client
* @param {number} clock
*/
has (client, clock) {
const dr = this.clients.get(client)
if (dr) {
return findIndexInIdRanges(dr.getIds(), clock) !== null
}
return false
}
/**
* Whether any id in the range `[clock, clock+len)` of `client` is contained in this set.
* Allocation-free (binary search).
*
* @param {number} client
* @param {number} clock
* @param {number} len
* @return {boolean}
*/
intersects (client, clock, len) {
const dr = this.clients.get(client)
if (dr) {
const ranges = dr.getIds()
const index = findRangeStartInIdRanges(ranges, clock)
return index !== null && ranges[index].clock < clock + len
}
return false
}
/**
* Whether the entire range `[clock, clock+len)` of `client` is contained in this set.
* Allocation-free (binary search). Because `getIds()` returns maximal, merged ranges, full
* coverage of a range implies it lies within a single range.
*
* @param {number} client
* @param {number} clock
* @param {number} len
* @return {boolean}
*/
covers (client, clock, len) {
const dr = this.clients.get(client)
if (dr) {
const ranges = dr.getIds()
const index = findIndexInIdRanges(ranges, clock)
if (index !== null) {
const r = ranges[index]
return clock + len <= r.clock + r.len
}
}
return false
}
/**
* Total number of ids in `[clock, clock+len)` of `client` contained in this set.
* Allocation-free (binary search + linear scan of the overlapping ranges). Equivalent to
* `slice(client, clock, len).reduce((s, r) => r.exists ? s + r.len : s, 0)` without allocating.
*
* @param {number} client
* @param {number} clock
* @param {number} len
* @return {number}
*/
coveredLength (client, clock, len) {
const dr = this.clients.get(client)
let covered = 0
if (dr) {
const ranges = dr.getIds()
const end = clock + len
let index = findRangeStartInIdRanges(ranges, clock)
if (index !== null) {
for (; index < ranges.length; index++) {
const r = ranges[index]
if (r.clock >= end) break
const from = r.clock < clock ? clock : r.clock
const rEnd = r.clock + r.len
const to = rEnd > end ? end : rEnd
covered += to - from
}
}
}
return covered
}
/**
* 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
* @param {number} len
*/
add (client, clock, len) {
if (len === 0) return
const idRanges = this.clients.get(client)
if (idRanges) {
idRanges.add(clock, len)
} else {
this.clients.set(client, new IdRanges([new IdRange(clock, len)]))
}
}
/**
* @param {number} client
* @param {number} clock
* @param {number} len
*/
delete (client, clock, len) {
_deleteRangeFromIdSet(this, client, clock, len)
}
/**
* @param {any} other
*/
[traits.EqualityTraitSymbol] (other) {
return equalIdSets(this, other)
}
}
/**
* @param {IdSet} ds1
* @param {IdSet} ds2
*/
export const equalIdSets = (ds1, ds2) => {
if (ds1.clients.size !== ds2.clients.size) return false
for (const [client, _deleteItems1] of ds1.clients.entries()) {
const deleteItems1 = _deleteItems1.getIds()
const deleteItems2 = ds2.clients.get(client)?.getIds()
if (deleteItems2 === undefined || deleteItems1.length !== deleteItems2.length) return false
for (let i = 0; i < deleteItems1.length; i++) {
const di1 = deleteItems1[i]
const di2 = deleteItems2[i]
if (di1.clock !== di2.clock || di1.len !== di2.len) {
return false
}
}
}
return true
}
/**
* @param {IdSet | IdMap<any>} set
* @param {number} client
* @param {number} clock
* @param {number} len
*/
export const _deleteRangeFromIdSet = (set, client, clock, len) => {
const dr = set.clients.get(client)
if (dr && len > 0) {
const ids = dr.getIds()
let index = findRangeStartInIdRanges(ids, clock)
if (index != null) {
for (let r = ids[index]; index < ids.length && r.clock < clock + len; r = ids[++index]) {
if (r.clock < clock) {
ids[index] = r.copyWith(r.clock, clock - r.clock)
if (clock + len < r.clock + r.len) {
ids.splice(index + 1, 0, r.copyWith(clock + len, r.clock + r.len - clock - len))
}
} else if (clock + len < r.clock + r.len) {
// need to retain end
ids[index] = r.copyWith(clock + len, r.clock + r.len - clock - len)
} else if (ids.length === 1) {
set.clients.delete(client)
return
} else {
ids.splice(index--, 1)
}
}
}
}
}
/**
* Iterate over all structs that are mentioned by the IdSet.
*
* @param {Transaction} transaction
* @param {IdSet} ds
* @param {function(GC|Item):void} f
*
* @function
*/
export const iterateStructsByIdSet = (transaction, ds, f) =>
ds.clients.forEach((idRanges, clientid) => {
const ranges = idRanges.getIds()
const structs = /** @type {Array<GC|Item>} */ (transaction.doc.store.clients.get(clientid))
if (structs != null) {
for (let i = 0; i < ranges.length; i++) {
const del = ranges[i]
iterateStructs(transaction, structs, del.clock, del.len, f)
}
}
})
/**
* Garbage-collect the deleted content referenced by `ids` on `doc`.
*
* This is useful to retroactively reclaim memory on a document created with `gc: false`
* (e.g. once a snapshot, or an UndoManager StackItem, that referenced this content is no
* longer needed) - without enabling gc for the whole document. Ids that reference live
* (non-deleted) content, already-collected structs, items flagged with `keep`, or items
* rejected by `gcFilter` are skipped.
*
* @param {Doc} doc
* @param {IdSet} ids
* @param {function(Item):boolean} [gcFilter]
*/
export const gcIdSet = (doc, ids, gcFilter = doc.gcFilter) =>
doc.transact(tr => {
// Split structs exactly at the IdSet boundaries so that only the referenced content is
// collected - an arbitrary IdSet need not align with struct boundaries.
iterateStructsByIdSet(tr, ids, () => {})
tryGc(tr, ids, gcFilter)
})
/**
* Iterate over all structs that are mentioned by the IdSet, without spliting the items.
*
* @param {StructStore} store
* @param {IdSet} ds
* @param {(struct: GC|Item|Skip, offset:number, len:number)=>void} f
*/
export const iterateStructsByIdSetWithoutSplits = (store, ds, f) =>
ds.clients.forEach((idRanges, clientid) => {
const ranges = idRanges.getIds()
const structs = /** @type {Array<GC|Item>} */ (store.clients.get(clientid))
if (structs != null) {
const lastStruct = structs[structs.length - 1]
const nextClock = lastStruct.id.clock + lastStruct.length
for (let i = 0; i < ranges.length; i++) {
const del = ranges[i]
if (del.clock < nextClock) {
iterateStructsWithoutSplits(structs, del.clock, del.len, f)
}
}
}
})
/**
* @param {Array<IdRange>} dis
* @param {number} clock
* @return {number|null}
*
* @private
* @function
*/
export const findIndexInIdRanges = (dis, clock) => {
let left = 0
let right = dis.length - 1
while (left <= right) {
const midindex = math.floor((left + right) / 2)
const mid = dis[midindex]
const midclock = mid.clock
if (midclock <= clock) {
if (clock < midclock + mid.len) {
return midindex
}
left = midindex + 1
} else {
right = midindex - 1
}
}
return null
}
/**
* Find the first range that contains clock or comes after clock.
*
* @param {Array<IdRange>} dis
* @param {number} clock
* @return {number|null}
*
* @private
* @function
*/
export const findRangeStartInIdRanges = (dis, clock) => {
let left = 0
let right = dis.length - 1
while (left <= right) {
const midindex = math.floor((left + right) / 2)
const mid = dis[midindex]
const midclock = mid.clock
if (midclock <= clock) {
if (clock < midclock + mid.len) {
return midindex
}
left = midindex + 1
} else {
right = midindex - 1
}
}
return left < dis.length ? left : null
}
/**
* @param {Array<IdSet>} idSets
* @return {IdSet} A fresh IdSet
*/
export const mergeIdSets = idSets => {
const merged = new IdSet()
for (let dssI = 0; dssI < idSets.length; dssI++) {
idSets[dssI].clients.forEach((rangesLeft, client) => {
if (!merged.clients.has(client)) {
// Write all missing keys from current ds and all following.
// If merged already contains `client` current ds has already been added.
const ids = rangesLeft.getIds().slice()
for (let i = dssI + 1; i < idSets.length; i++) {
const nextIds = idSets[i].clients.get(client)
if (nextIds) {
array.appendTo(ids, nextIds.getIds())
}
}
merged.clients.set(client, new IdRanges(ids))
}
})
}
return merged
}
/**
* @template {IdSet | IdMap<any>} S
* @param {S} dest
* @param {S} src
*/
const _insertIntoIdSet = (dest, src) => {
src.clients.forEach((srcRanges, client) => {
const targetRanges = dest.clients.get(client)
if (targetRanges) {
array.appendTo(targetRanges.getIds(), srcRanges.getIds())
targetRanges.sorted = false
} else {
// order first
srcRanges.getIds()
dest.clients.set(client, /** @type {any} */ (srcRanges.copy()))
}
})
}
/**
* @param {IdSet} dest
* @param {IdSet} src
*/
export const insertIntoIdSet = _insertIntoIdSet
/**
* Note that `src` may be an `IdSet`, in which case `dest` ends up holding `IdRanges` - which carry
* no attributes ({@link IdRange#attrs} is `[]`). This is by design; {@link $idMap} tolerates it.
*
* @type {(dest: IdMap<any>, src: IdMap<any>|IdSet) => void}
*/
export const insertIntoIdMap = _insertIntoIdSet
/**
* @todo rename to excludeIdSet | excludeIdMap
*
* @todo dispatch on the schema rather than `instanceof IdSet`: `const isMap = set.$type ===
* $idMapAny`. Mind the polarity - it inverts. `instanceof IdSet` selects the "is a set" branch,
* `$type === $idMapAny` selects the "is a map" branch, so both ternaries below flip. Compare
* against `$idMapAny` (the interned tag), never `$idMap(attrs)` - that builds a fresh `s.$custom`
* per call, so `===` against it is always false.
*
* The reason is soundness, not speed: `instanceof` walks the prototype chain and is per-install, so
* an `IdSet` from a duplicate yjs install falls through to the IdMap branch and we silently build an
* IdMap holding IdRanges. `$type` is a single inline-cached load and stays sound across installs -
* the same reason `$renderer` uses `$type` over `$instanceOf`. Not a perf win either way: this runs
* twice per call, outside both loops.
*
* Applies equally to `_intersectSets`.
*
* Remove all ranges from `exclude` from `ds`. The result is a fresh IdSet containing all ranges from `idSet` that are not
* in `exclude`.
*
* @template {IdSet | IdMap<any>} Set
* @param {Set} set
* @param {IdSet | IdMap<any>} exclude
* @return {Set}
*/
export const _diffSet = (set, exclude) => {
/**
* @type {Set}
*/
const res = /** @type {any } */ (set instanceof IdSet ? new IdSet() : new IdMap())
const Ranges = set instanceof IdSet ? IdRanges : AttrRanges
set.clients.forEach((_setRanges, client) => {
/**
* @type {Array<IdRange>}
*/
let resRanges = []
const _excludedRanges = exclude.clients.get(client)
const setRanges = _setRanges.getIds()
if (_excludedRanges == null) {
resRanges = setRanges.slice()
} else {
const excludedRanges = _excludedRanges.getIds()
let i = 0; let j = 0
let currRange = setRanges[0]
while (i < setRanges.length && j < excludedRanges.length) {
const e = excludedRanges[j]
if (currRange.clock + currRange.len <= e.clock) { // no overlapping, use next range item
if (currRange.len > 0) resRanges.push(currRange)
currRange = setRanges[++i]
} else if (e.clock + e.len <= currRange.clock) { // no overlapping, use next excluded item
j++
} else if (e.clock <= currRange.clock) { // exclude laps into range (we already know that the ranges somehow collide)
const newClock = e.clock + e.len
const newLen = currRange.clock + currRange.len - newClock
if (newLen > 0) {
currRange = currRange.copyWith(newClock, newLen)
j++
} else {
// this item is completely overwritten. len=0. We can jump to the next range
currRange = setRanges[++i]
}
} else { // currRange.clock < e.clock -- range laps into exclude => adjust len
// beginning can't be empty, add it to the result
const nextLen = e.clock - currRange.clock
resRanges.push(currRange.copyWith(currRange.clock, nextLen))
// retain the remaining length after exclude in currRange
currRange = currRange.copyWith(currRange.clock + e.len + nextLen, math.max(currRange.len - e.len - nextLen, 0))
if (currRange.len === 0) currRange = setRanges[++i]
else j++
}
}
if (currRange != null) {
resRanges.push(currRange)
}
i++
while (i < setRanges.length) {
resRanges.push(setRanges[i++])
}
}
// @ts-ignore
if (resRanges.length > 0) res.clients.set(client, /** @type {any} */ (new Ranges(resRanges)))
})
return res
}
/**
* Remove all ranges from `exclude` from `idSet`. The result is a fresh IdSet containing all ranges from `idSet` that are not
* in `exclude`.
*
* @type {(idSet: IdSet, exclude: IdSet|IdMap<any>) => IdSet}
*/
export const diffIdSet = _diffSet
/**
* @todo dispatch on `setA.$type === $idMapAny` rather than `setA instanceof IdSet` - see the note
* on `_diffSet`. The polarity inverts.
*
* @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<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
export const createIdSet = () => new IdSet()
/**
* @param {StructStore} ss
* @return {IdSet}
*
* @private
* @function
*/
export const createDeleteSetFromStructStore = ss => {
const ds = createIdSet()
ss.clients.forEach((structs, client) => {
/**
* @type {Array<IdRange>}
*/
const dsitems = []
for (let i = 0; i < structs.length; i++) {
const struct = structs[i]
if (struct.deleted) {
const clock = struct.id.clock
let len = struct.length
if (i + 1 < structs.length) {
for (let next = structs[i + 1]; i + 1 < structs.length && next.deleted; next = structs[++i + 1]) {
len += next.length
}
}
dsitems.push(new IdRange(clock, len))
}
}
if (dsitems.length > 0) {
ds.clients.set(client, new IdRanges(dsitems))
}
})
return ds
}
/**
* @param {Array<GC | Item | Skip>} structs
* @param {boolean} filterDeleted
*
*/
export const _createInsertSliceFromStructs = (structs, filterDeleted) => {
/**
* @type {Array<IdRange>}
*/
const iditems = []
for (let i = 0; i < structs.length; i++) {
const struct = structs[i]
if (!(filterDeleted && struct.deleted)) {
const clock = struct.id.clock
let len = struct.length
if (i + 1 < structs.length) {
// eslint-disable-next-line
for (let next = structs[i + 1]; i + 1 < structs.length && !(filterDeleted && next.deleted); next = structs[++i + 1]) {
len += next.length
}
}
iditems.push(new IdRange(clock, len))
}
}
return iditems
}
/**
* @param {StructStore} ss
* @param {boolean} filterDeleted
*/
export const createInsertSetFromStructStore = (ss, filterDeleted) => {
const idset = createIdSet()
ss.clients.forEach((structs, client) => {
const iditems = _createInsertSliceFromStructs(structs, filterDeleted)
if (iditems.length !== 0) {
idset.clients.set(client, new IdRanges(iditems))
}
})
return idset
}
/**
* @param {IdSetEncoderV1 | IdSetEncoderV2} encoder
* @param {IdSet} idSet
*
* @private
* @function
*/
export const writeIdSet = (encoder, idSet) => {
encoding.writeVarUint(encoder.restEncoder, idSet.clients.size)
// Ensure that the delete set is written in a deterministic order
array.from(idSet.clients.entries())
.sort((a, b) => b[0] - a[0])
.forEach(([client, _idRanges]) => {
const idRanges = _idRanges.getIds()
encoder.resetIdSetCurVal()
encoding.writeVarUint(encoder.restEncoder, client)
const len = idRanges.length
encoding.writeVarUint(encoder.restEncoder, len)
for (let i = 0; i < len; i++) {
const item = idRanges[i]
encoder.writeIdSetClock(item.clock)
encoder.writeIdSetLen(item.len)
}
})
}
/**
* @param {IdSetDecoderV1 | IdSetDecoderV2} decoder
* @return {IdSet}
*
* @private
* @function
*/
export const readIdSet = decoder => {
const ds = new IdSet()
const numClients = decoding.readVarUint(decoder.restDecoder)
for (let i = 0; i < numClients; i++) {
decoder.resetDsCurVal()
const client = decoding.readVarUint(decoder.restDecoder)
const numberOfDeletes = decoding.readVarUint(decoder.restDecoder)
if (numberOfDeletes > 0) {
/**
* @type {Array<IdRange>}
*/
const dsRanges = []
for (let i = 0; i < numberOfDeletes; i++) {
dsRanges.push(new IdRange(decoder.readDsClock(), decoder.readDsLen()))
}
ds.clients.set(client, new IdRanges(dsRanges))
}
}
return ds
}
/**
* @param {IdSet} idSet
* @return {Uint8Array<ArrayBuffer>}
*/
export const encodeIdSet = idSet => {
const encoder = new IdSetEncoderV2()
writeIdSet(encoder, idSet)
return encoder.toUint8Array()
}
/**
* @param {Uint8Array} data
* @return {IdSet}
*/
export const decodeIdSet = data => readIdSet(new IdSetDecoderV2(decoding.createDecoder(data)))
/**
* @todo YDecoder also contains references to String and other Decoders. Would make sense to exchange YDecoder.toUint8Array for YDecoder.DsToUint8Array()..
*/
/**
* @param {IdSetDecoderV1 | IdSetDecoderV2} decoder
* @param {Transaction} transaction
* @param {StructStore} store
* @return {Uint8Array<ArrayBuffer>|null} Returns a v2 update containing all deletes that couldn't be applied yet; or null if all deletes were applied successfully.
*
* @private
* @function
*/
export const readAndApplyDeleteSet = (decoder, transaction, store) => {
const unappliedDS = new IdSet()
const numClients = decoding.readVarUint(decoder.restDecoder)
for (let i = 0; i < numClients; i++) {
decoder.resetDsCurVal()
const client = decoding.readVarUint(decoder.restDecoder)
const numberOfDeletes = decoding.readVarUint(decoder.restDecoder)
const structs = store.clients.get(client) || []
const state = store.getClock(client)
for (let i = 0; i < numberOfDeletes; i++) {
const clock = decoder.readDsClock()
const clockEnd = clock + decoder.readDsLen()
if (clock < state) {
if (state < clockEnd) {
unappliedDS.add(client, state, clockEnd - state)
}
let index = findIndexSS(structs, clock)
/**
* We can ignore the case of GC and Delete structs, because we are going to skip them
* @type {Item | GC | Skip}
*/
let struct = structs[index]
// split the first item if necessary
if (!struct.deleted && struct.id.clock < clock && struct.isItem) {
// increment index, we now want to use the next struct
structs.splice(++index, 0, /** @type {Item} */ (struct).split(transaction, clock - struct.id.clock))
}
while (index < structs.length) {
// @ts-ignore
struct = structs[index++]
if (struct.id.clock < clockEnd) {
if (!struct.deleted) {
if (struct.isItem) {
if (clockEnd < struct.id.clock + struct.length) {
structs.splice(index, 0, /** @type {Item} */ (struct).split(transaction, clockEnd - struct.id.clock))
}
struct.delete(transaction)
} else { // is a Skip - add range to unappliedDS
const c = math.max(struct.id.clock, clock)
unappliedDS.add(client, c, math.min(struct.length, clockEnd - c))
}
}
} else {
break
}
}
} else {
unappliedDS.add(client, clock, clockEnd - clock)
}
}
}
if (unappliedDS.clients.size > 0) {
const ds = new UpdateEncoderV2()
encoding.writeVarUint(ds.restEncoder, 0) // encode 0 structs
writeIdSet(ds, unappliedDS)
return ds.toUint8Array()
}
return null
}
/**
* @template Attrs
*/
export class AttrRange {
/**
* @param {number} clock
* @param {number} len
* @param {Array<ContentAttribute<Attrs>>} attrs
*/
constructor (clock, len, attrs) {
/**
* @readonly
*/
this.clock = clock
/**
* @readonly
*/
this.len = len
/**
* @readonly
*/
this.attrs = attrs
}
/**
* @param {number} clock
* @param {number} len
*/
copyWith (clock, len) {
return new AttrRange(clock, len, this.attrs)
}
}
/**
* @todo rename this to `Attribute`
* @template V
*/
export class ContentAttribute {
/**
* @param {string} name
* @param {V} val
*/
constructor (name, val) {
this.name = name
this.val = val
}
hash () {
const encoder = encoding.createEncoder()
encoding.writeVarString(encoder, this.name)
encoding.writeAny(encoder, /** @type {any} */ (this.val))
return buf.toBase64(rabin.fingerprint(rabin.StandardIrreducible128, encoding.toUint8Array(encoder)))
}
}
/**
* @template V
* @param {string} name
* @param {V} val
* @return {ContentAttribute<V>}
*/
export const createContentAttribute = (name, val) => new ContentAttribute(name, val)
/**
* @template Attrs
* @typedef {{ clock: number, len: number, attrs: Array<ContentAttribute<Attrs>>? }} MaybeAttrRange
*/
/**
* @template Attrs
*
* @param {number} clock
* @param {number} len
* @param {Array<ContentAttribute<Attrs>>?} attrs
* @return {MaybeAttrRange<Attrs>}
*/
export const createMaybeAttrRange = (clock, len, attrs) => new AttrRange(clock, len, /** @type {any} */ (attrs))
/**
* @template T
* @param {Array<T>} a
* @param {Array<T>} b
*/
const idmapAttrRangeJoin = (a, b) => a.concat(b.filter(attr => !idmapAttrsHas(a, attr)))
/**
* Whenever this is instantiated, it must receive a fresh array of ops, not something copied.
*
* @template Attrs
*/
export class AttrRanges {
/**
* @param {Array<AttrRange<Attrs>>} ids
*/
constructor (ids) {
this.sorted = false
/**
* @private
*/
this._ids = ids
}
copy () {
const cpy = new AttrRanges(this._ids.slice())
cpy.sorted = this.sorted
return cpy
}
/**
* @param {number} clock
* @param {number} length
* @param {Array<ContentAttribute<Attrs>>} attrs
*/
add (clock, length, attrs) {
if (length === 0) return
this.sorted = false
this._ids.push(new AttrRange(clock, length, attrs))
}
/**
* Return the list of id ranges, sorted and merged.
*/
getIds () {
const ids = this._ids
if (!this.sorted) {
this.sorted = true
ids.sort((a, b) => a.clock - b.clock)
/**
* algorithm thoughts:
* - sort (by clock AND by length), bigger length is to the right (or not, we can't make
* assumptions abouth length after long length has been split)
* -- maybe better: sort by clock+length. Then split items from right to left. This way, items are always
* in the right order. But I also need to swap if left items is smaller after split
* --- thought: there is no way to go around swapping. Unless, for each item from left to
* right, when I have to split because one of the look-ahead items is overlapping, i split
* it and merge the attributes into the following ones (that I also need to split). Best is
* probably left to right with lookahead.
* - left to right, split overlapping items so that we can make the assumption that either an
* item is overlapping with the next 1-on-1 or it is not overlapping at all (when splitting,
* we can already incorporate the attributes)
* -- better: for each item, go left to right and add own attributes to overlapping items.
* Split them if necessary. After split, i must insert the retainer at a valid position.
* - merge items if neighbor has same attributes
*/
for (let i = 0; i < ids.length - 1;) {
const range = ids[i]
const nextRange = ids[i + 1]
// find out how to split range. it must match with next range.
// 1) we have space. Split if necessary.
// 2) concat attributes in range to the next range. Split range and splice the remainder at
// the correct position.
if (range.clock < nextRange.clock) { // might need to split range
if (range.clock + range.len > nextRange.clock) {
// is overlapping
const diff = nextRange.clock - range.clock
ids[i] = new AttrRange(range.clock, diff, range.attrs)
ids.splice(i + 1, 0, new AttrRange(nextRange.clock, range.len - diff, range.attrs))
}
i++
continue
}
// now we know that range.clock === nextRange.clock
// merge range with nextRange
const largerRange = range.len > nextRange.len ? range : nextRange
const smallerLen = range.len < nextRange.len ? range.len : nextRange.len
ids[i] = new AttrRange(range.clock, smallerLen, idmapAttrRangeJoin(range.attrs, nextRange.attrs))
if (range.len === nextRange.len) {
ids.splice(i + 1, 1)
} else {
ids[i + 1] = new AttrRange(range.clock + smallerLen, largerRange.len - smallerLen, largerRange.attrs)
array.bubblesortItem(ids, i + 1, (a, b) => a.clock - b.clock)
}
if (smallerLen === 0) i++
}
while (ids.length > 0 && ids[0].len === 0) {
ids.splice(0, 1)
}
// merge items without filtering or splicing the array.
// i is the current pointer
// j refers to the current insert position for the pointed item
// try to merge dels[i] into dels[j-1] or set dels[j]=dels[i]
let i, j
for (i = 1, j = 1; i < ids.length; i++) {
const left = ids[j - 1]
const right = ids[i]
if (left.clock + left.len === right.clock && idmapAttrsEqual(left.attrs, right.attrs)) {
ids[j - 1] = new AttrRange(left.clock, left.len + right.len, left.attrs)
} else if (right.len !== 0) {
if (j < i) {
ids[j] = right
}
j++
}
}
ids.length = ids.length === 0 ? 0 : (ids[j - 1].len === 0 ? j - 1 : j)
}
return ids
}
/**
* Test a predicate against every attribute held by these ranges.
*
* Unlike {@link AttrRanges#getIds} this does **not** sort/merge - i.e. it does not mutate its
* receiver, which matters because it backs {@link $idMap}`.check`. The *set* of attributes is
* invariant under sorting/merging (merging only ever concatenates existing attr arrays), so this
* observes exactly the attributes that `getIds()` would expose.
*
* @param {(attr:ContentAttribute<Attrs>) => boolean} f
* @return {boolean}
*/
everyAttr (f) {
const ids = this._ids
for (let i = 0; i < ids.length; i++) {
const attrs = ids[i].attrs
// only `createMaybeAttrRange` yields null attrs, never `clients` - defensive
if (attrs == null) continue
for (let j = 0; j < attrs.length; j++) {
if (!f(attrs[j])) return false
}
}
return true
}
}
/**
* @template Attrs
*/
export class IdMap {
constructor () {
/**
* @type {Map<number,AttrRanges<Attrs>>}
*/
this.clients = new Map()
/**
* @type {Map<string, ContentAttribute<Attrs>>}
*/
this.attrsH = new Map()
/**
* @type {Set<ContentAttribute<Attrs>>}
*/
this.attrs = new Set()
}
/**
* @param {(attrRange:AttrRange<Attrs>, client:number) => void} f
*/
forEach (f) {
this.clients.forEach((ranges, client) => {
ranges.getIds().forEach((range) => {
f(range, client)
})
})
}
isEmpty () {
return this.clients.size === 0
}
/**
* @param {ID} id
* @return {boolean}
*/
hasId (id) {
return this.has(id.client, id.clock)
}
/**
* @param {number} client
* @param {number} clock
* @return {boolean}
*/
has (client, clock) {
const dr = this.clients.get(client)
if (dr) {
return findIndexInIdRanges(dr.getIds(), clock) !== null
}
return false
}
/**
* Total number of ids in `[clock, clock+len)` of `client` contained in this map (i.e. carrying
* an attribution, incl. an empty `[]`). Allocation-free (binary search + linear scan).
* Equivalent to `slice(client, clock, len).reduce((s, r) => r.attrs != null ? s + r.len : s, 0)`
* without allocating.
*
* @param {number} client
* @param {number} clock
* @param {number} len
* @return {number}
*/
coveredLength (client, clock, len) {
const dr = this.clients.get(client)
let covered = 0
if (dr) {
const ranges = dr.getIds()
const end = clock + len
let index = findRangeStartInIdRanges(ranges, clock)
if (index !== null) {
for (; index < ranges.length; index++) {
const r = ranges[index]
if (r.clock >= end) break
const from = r.clock < clock ? clock : r.clock
const rEnd = r.clock + r.len
const to = rEnd > end ? end : rEnd
covered += to - from
}
}
}
return covered
}
/**
* Return attributions for a slice of ids.
*
* @param {ID} id
* @param {number} len
* @return {Array<MaybeAttrRange<Attrs>>}
*/
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>>}
*/
const res = []
if (dr) {
/**
* @type {Array<AttrRange<Attrs>>}
*/
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 AttrRange(clock, r.len - (clock - 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 : clock
if (prevEnd < r.clock) {
res.push(createMaybeAttrRange(prevEnd, r.clock - prevEnd, null))
}
prev = r
res.push(r)
index++
}
}
}
if (res.length > 0) {
const last = res[res.length - 1]
const end = last.clock + last.len
if (end < clock + len) {
res.push(createMaybeAttrRange(end, clock + len - end, null))
}
} else {
res.push(createMaybeAttrRange(clock, len, null))
}
return res
}
/**
* @param {number} client
* @param {number} clock
* @param {number} len
* @param {Array<ContentAttribute<Attrs>>} attrs
*/
add (client, clock, len, attrs) {
if (len === 0) return
attrs = _ensureAttrs(this, attrs)
const ranges = this.clients.get(client)
if (ranges == null) {
this.clients.set(client, new AttrRanges([new AttrRange(clock, len, attrs)]))
} else {
ranges.add(clock, len, attrs)
}
}
/**
* @param {number} client
* @param {number} clock
* @param {number} len
*/
delete (client, clock, len) {
_deleteRangeFromIdSet(this, client, clock, len)
}
}
/**
* @template T
* @param {Array<T>} attrs
* @param {T} attr
*
*/
const idmapAttrsHas = (attrs, attr) => attrs.find(a => a === attr)
/**
* @template T
* @param {Array<T>} a
* @param {Array<T>} b
*/
export const idmapAttrsEqual = (a, b) => a.length === b.length && a.every(v => idmapAttrsHas(b, v))
/**
* Merge multiple idmaps. Ensures that there are no redundant attribution definitions (two
* Attributions that describe the same thing).
*
* @template T
* @param {Array<IdMap<T>>} ams
* @return {IdMap<T>} A fresh IdSet
*/
export const mergeIdMaps = ams => {
/**
* Maps attribution to the attribution of the merged idmap.
*
* @type {Map<ContentAttribute<any>,ContentAttribute<any>>}
*/
const attrMapper = new Map()
const merged = createIdMap()
for (let amsI = 0; amsI < ams.length; amsI++) {
ams[amsI].clients.forEach((rangesLeft, client) => {
if (!merged.clients.has(client)) {
// Write all missing keys from current set and all following.
// If merged already contains `client` current ds has already been added.
let ids = rangesLeft.getIds().slice()
for (let i = amsI + 1; i < ams.length; i++) {
const nextIds = ams[i].clients.get(client)
if (nextIds) {
array.appendTo(ids, nextIds.getIds())
}
}
ids = ids.map(id => new AttrRange(id.clock, id.len, id.attrs.map(attr =>
map.setIfUndefined(attrMapper, attr, () =>
_ensureAttrs(merged, [attr])[0]
)
)))
merged.clients.set(client, new AttrRanges(ids))
}
})
}
return merged
}
/**
* @param {IdSet} idset
* @param {Array<ContentAttribute<any>>} attrs
*/
export const createIdMapFromIdSet = (idset, attrs) => {
const idmap = createIdMap()
// map attrs to idmap
attrs = _ensureAttrs(idmap, attrs)
// filter out duplicates
/**
* @type {Array<ContentAttribute<any>>}
*/
const checkedAttrs = []
attrs.forEach(attr => {
if (!idmapAttrsHas(checkedAttrs, attr)) {
checkedAttrs.push(attr)
}
})
idset.clients.forEach((ranges, client) => {
const attrRanges = new AttrRanges(ranges.getIds().map(range => new AttrRange(range.clock, range.len, checkedAttrs)))
attrRanges.sorted = true // is sorted because idset is sorted
idmap.clients.set(client, attrRanges)
})
return idmap
}
/**
* Create an IdSet from an IdMap by stripping the attributes.
*
* @param {IdMap<any>} idmap
* @return {IdSet}
*/
export const createIdSetFromIdMap = idmap => {
const idset = createIdSet()
idmap.clients.forEach((ranges, client) => {
const idRanges = new IdRanges([])
ranges.getIds().forEach(range => idRanges.add(range.clock, range.len))
idset.clients.set(client, idRanges)
})
return idset
}
/**
* Efficiently encodes IdMap to a binary form. Ensures that information is de-duplicated when
* written. Attribute.names are referenced by id. Attributes themselfs are also referenced by id.
*
* @template Attr
* @param {IdSetEncoderV1 | IdSetEncoderV2} encoder
* @param {IdMap<Attr>} idmap
*
* @private
* @function
*/
export const writeIdMap = (encoder, idmap) => {
encoding.writeVarUint(encoder.restEncoder, idmap.clients.size)
let lastWrittenClientId = 0
/**
* @type {Map<ContentAttribute<Attr>, number>}
*/
const visitedAttributions = map.create()
/**
* @type {Map<string, number>}
*/
const visitedAttrNames = map.create()
// Ensure that the ids are written in a deterministic order (smaller clientids first)
array.from(idmap.clients.entries())
.sort((a, b) => a[0] - b[0])
.forEach(([client, _idRanges]) => {
const attrRanges = _idRanges.getIds()
encoder.resetIdSetCurVal()
const diff = client - lastWrittenClientId
encoding.writeVarUint(encoder.restEncoder, diff)
lastWrittenClientId = client
const len = attrRanges.length
encoding.writeVarUint(encoder.restEncoder, len)
for (let i = 0; i < len; i++) {
const item = attrRanges[i]
const attrs = item.attrs
const attrLen = attrs.length
encoder.writeIdSetClock(item.clock)
encoder.writeIdSetLen(item.len)
encoding.writeVarUint(encoder.restEncoder, attrLen)
for (let j = 0; j < attrLen; j++) {
const attr = attrs[j]
const attrId = visitedAttributions.get(attr)
if (attrId != null) {
encoding.writeVarUint(encoder.restEncoder, attrId)
} else {
const newAttrId = visitedAttributions.size
visitedAttributions.set(attr, newAttrId)
encoding.writeVarUint(encoder.restEncoder, newAttrId)
const attrNameId = visitedAttrNames.get(attr.name)
// write attr.name
if (attrNameId != null) {
encoding.writeVarUint(encoder.restEncoder, attrNameId)
} else {
const newAttrNameId = visitedAttrNames.size
encoding.writeVarUint(encoder.restEncoder, newAttrNameId)
encoding.writeVarString(encoder.restEncoder, attr.name)
visitedAttrNames.set(attr.name, newAttrNameId)
}
encoding.writeAny(encoder.restEncoder, /** @type {any} */ (attr.val))
}
}
}
})
}
/**
* @param {IdMap<any>} idmap
*/
export const encodeIdMap = idmap => {
const encoder = new IdSetEncoderV2()
writeIdMap(encoder, idmap)
return encoder.toUint8Array()
}
/**
* @param {IdSetDecoderV1 | IdSetDecoderV2} decoder
* @return {IdMap<any>}
*
* @private
* @function
*/
export const readIdMap = decoder => {
const idmap = new IdMap()
const numClients = decoding.readVarUint(decoder.restDecoder)
/**
* @type {Array<ContentAttribute<any>>}
*/
const visitedAttributions = []
/**
* @type {Array<string>}
*/
const visitedAttrNames = []
let lastClientId = 0
for (let i = 0; i < numClients; i++) {
decoder.resetDsCurVal()
const client = lastClientId + decoding.readVarUint(decoder.restDecoder)
lastClientId = client
const numberOfDeletes = decoding.readVarUint(decoder.restDecoder)
/**
* @type {Array<AttrRange<any>>}
*/
const attrRanges = []
for (let i = 0; i < numberOfDeletes; i++) {
const rangeClock = decoder.readDsClock()
const rangeLen = decoder.readDsLen()
/**
* @type {Array<ContentAttribute<any>>}
*/
const attrs = []
const attrsLen = decoding.readVarUint(decoder.restDecoder)
for (let j = 0; j < attrsLen; j++) {
const attrId = decoding.readVarUint(decoder.restDecoder)
if (attrId >= visitedAttributions.length) {
// attrId not known yet
const attrNameId = decoding.readVarUint(decoder.restDecoder)
if (attrNameId >= visitedAttrNames.length) {
visitedAttrNames.push(decoding.readVarString(decoder.restDecoder))
}
visitedAttributions.push(new ContentAttribute(visitedAttrNames[attrNameId], decoding.readAny(decoder.restDecoder)))
}
attrs.push(visitedAttributions[attrId])
}
attrRanges.push(new AttrRange(rangeClock, rangeLen, attrs))
}
idmap.clients.set(client, new AttrRanges(attrRanges))
}
visitedAttributions.forEach(attr => {
idmap.attrs.add(attr)
idmap.attrsH.set(attr.hash(), attr)
})
return idmap
}
/**
* @param {Uint8Array} data
* @return {IdMap<any>}
*/
export const decodeIdMap = data => readIdMap(new IdSetDecoderV2(decoding.createDecoder(data)))
/**
* @template Attrs
* @param {IdMap<Attrs>} idmap
* @param {Array<ContentAttribute<Attrs>>} attrs
* @return {Array<ContentAttribute<Attrs>>}
*/
const _ensureAttrs = (idmap, attrs) => attrs.map(attr =>
idmap.attrs.has(attr)
? attr
: map.setIfUndefined(idmap.attrsH, attr.hash(), () => {
idmap.attrs.add(attr)
return attr
}))
export const createIdMap = () => new IdMap()
/**
* Remove all ranges from `exclude` from `ds`. The result is a fresh IdMap containing all ranges from `idSet` that are not
* in `exclude`.
*
* @todo this should be called "excludeIdMap"
*
* @todo the `instanceof IdSet` dispatch this relies on lives in `_diffSet` - see the `$idMapAny`
* note there. Separately, the `attrs`/`attrsH` aliasing below leaves a *stale* index on an emptied
* result. {@link $idMap} is unaffected: it reads the ranges, which are authoritative.
*
* @template {IdMap<any>} ISet
* @param {ISet} set
* @param {IdSet | IdMap<any>} exclude
* @return {ISet}
*/
export const diffIdMap = (set, exclude) => {
const diffed = _diffSet(set, exclude)
diffed.attrs = set.attrs
diffed.attrsH = set.attrsH
return diffed
}
export const intersectMaps = _intersectSets
/**
* Filter attributes in an IdMap based on a predicate function.
* Returns a new IdMap containing idranges that match the predicate.
*
* @template Attrs
* @param {IdMap<Attrs>} idmap
* @param {(attr: Array<ContentAttribute<Attrs>>) => boolean} predicate
* @return {IdMap<Attrs>}
*/
export const filterIdMap = (idmap, predicate) => {
const filtered = createIdMap()
idmap.clients.forEach((ranges, client) => {
/**
* @type {Array<AttrRange<Attrs>>}
*/
const attrRanges = []
ranges.getIds().forEach((range) => {
if (predicate(range.attrs)) {
const rangeCpy = range.copyWith(range.clock, range.len)
attrRanges.push(rangeCpy)
rangeCpy.attrs.forEach(attr => {
filtered.attrs.add(attr)
filtered.attrsH.set(attr.hash(), attr)
})
}
})
if (attrRanges.length > 0) {
filtered.clients.set(client, new AttrRanges(attrRanges))
}
})
return filtered
}
/**
* Schema of an {@link IdSet}.
*
* Nominal: `check` is a single identity compare against the globally interned `y:idSet` tag, so it
* stays sound across duplicate yjs installations (unlike `instanceof`) - the same reason
* {@link $renderer} uses `$type`.
*/
export const $idSet = IdSet.prototype.$type = s.$type('y:idSet', IdSet)
/**
* Schema of an {@link IdMap} with any mapped-value type. This is where the nominal `y:idMap` tag
* lives - {@link $idMap} builds on it. Mirrors lib0's `$deltaAny` / `$delta` split.
*/
export const $idMapAny = /** @type {s.Schema<IdMap<any>>} */ (IdMap.prototype.$type = s.$type('y:idMap', IdMap))
/**
* Schema of an {@link IdMap} whose mapped values ({@link ContentAttribute#val}) all match `$attrs`.
*
* True iff the value carries the nominal `y:idMap` tag **and** every attribute reachable through
* `clients` satisfies `$attrs`. Those ranges are the authoritative content - exactly what
* {@link writeIdMap} serializes. An empty map holds no values, so it satisfies every `$attrs`.
*
* This deliberately ignores `attrs` / `attrsH`: that pair is a hash-interning cache for `===`
* comparison (see `_ensureAttrs`), and it drifts from the ranges in *both* directions today -
* {@link diffIdMap} aliases a stale index onto an emptied result, while {@link intersectMaps} and
* {@link insertIntoIdMap} never rebuild it, so most real maps carry an empty index while holding
* values. An index-based check would therefore validate nothing for the common case.
*
* Like every container schema this is a *snapshot*: an `IdMap` is mutable, so a later `add()` can
* invalidate a previously-passing check.
*
* Allocates a fresh schema per call - hoist it rather than calling it in a hot loop. For a cheap
* nominal gate use {@link $idMapAny} (one identity compare).
*
* @template Attrs
* @param {s.Schema<Attrs>} $attrs - schema of the mapped values
* @return {s.Schema<IdMap<Attrs>>}
*/
export const $idMap = $attrs => s.$custom(o => {
if (!$idMapAny.check(o)) return false
for (const ranges of o.clients.values()) {
if (!ranges.everyAttr(attr => $attrs.check(attr.val))) return false
}
return true
})