import * as random from 'lib0/random' import * as Y from 'yjs' import type { AstId, Owned, SyncTokenId } from '.' import { Token, asOwned, isTokenId, newExternalId } from '.' import { assert } from '../util/assert' import type { ExternalId } from '../yjsModel' import type { AstFields, FixedMap, Mutable } from './tree' import { Ast, Invalid, MutableAst, MutableInvalid, Wildcard, invalidFields, materializeMutable, setAll, } from './tree' export interface Module { edit(): MutableModule root(): Ast | undefined get(id: AstId): Ast | undefined get(id: AstId | undefined): Ast | undefined ///////////////////////////////// checkedGet(id: AstId): Ast checkedGet(id: AstId | undefined): Ast | undefined getToken(token: SyncTokenId): Token getToken(token: SyncTokenId | undefined): Token | undefined getAny(node: AstId | SyncTokenId): Ast | Token has(id: AstId): boolean } export interface ModuleUpdate { nodesAdded: AstId[] nodesDeleted: AstId[] fieldsUpdated: { id: AstId; fields: (readonly [string, unknown])[] }[] metadataUpdated: { id: AstId; changes: Map }[] } type YNode = FixedMap type YNodes = Y.Map export class MutableModule implements Module { private readonly nodes: YNodes get ydoc() { const ydoc = this.nodes.doc assert(ydoc != null) return ydoc } /** Return this module's copy of `ast`, if this module was created by cloning `ast`'s module. */ getVersion(ast: T): Mutable { const instance = this.checkedGet(ast.id) return instance as Mutable } edit(): MutableModule { const doc = new Y.Doc() Y.applyUpdateV2(doc, Y.encodeStateAsUpdateV2(this.ydoc)) return new MutableModule(doc) } root(): MutableAst | undefined { return this.rootPointer()?.expression } replaceRoot(newRoot: Owned | undefined): Owned | undefined { if (newRoot) { const rootPointer = this.rootPointer() if (rootPointer) { return rootPointer.expression.replace(newRoot) } else { invalidFields(this, this.baseObject('Invalid', undefined, ROOT_ID), { whitespace: '', node: newRoot, }) return undefined } } else { const oldRoot = this.root() if (!oldRoot) return this.nodes.delete(ROOT_ID) oldRoot.fields.set('parent', undefined) return asOwned(oldRoot) } } syncRoot(root: Owned) { this.replaceRoot(root) this.gc() } private gc() { const live = new Set() const active = new Array() let next: Ast | undefined = this.root() while (next) { for (const child of next.children()) { if (child instanceof Ast) active.push(child) } live.add(next.id) next = active.pop() } const all = Array.from(this.nodes.keys()) for (const id of all) { if (id === ROOT_ID) continue assert(isAstId(id)) if (!live.has(id)) this.nodes.delete(id) } } /** Copy the given node into the module. */ copy(ast: T): Owned> { const id = newAstId(ast.typeName()) const fields = ast.fields.clone() this.nodes.set(id, fields as any) fields.set('id', id) fields.set('parent', undefined) const ast_ = materializeMutable(this, fields) ast_.importReferences(ast.module) return ast_ as Owned> } static Transient() { return new this(new Y.Doc()) } observe(observer: (update: ModuleUpdate) => void) { const handle = (events: Y.YEvent[]) => observer(this.observeEvents(events)) // Attach the observer first, so that if an update hook causes changes in reaction to the initial state update, we // won't miss them. this.nodes.observeDeep(handle) observer(this.getStateAsUpdate()) return handle } unobserve(handle: ReturnType) { this.nodes.unobserveDeep(handle) } getStateAsUpdate(): ModuleUpdate { const updateBuilder = new UpdateBuilder(this.nodes) for (const id of this.nodes.keys()) updateBuilder.addNode(id as AstId) return updateBuilder } applyUpdate(update: Uint8Array, origin?: string): ModuleUpdate | undefined { let summary: ModuleUpdate | undefined const observer = (events: Y.YEvent[]) => { summary = this.observeEvents(events) } this.nodes.observeDeep(observer) Y.applyUpdate(this.ydoc, update, origin) this.nodes.unobserveDeep(observer) return summary } private observeEvents(events: Y.YEvent[]): ModuleUpdate { const updateBuilder = new UpdateBuilder(this.nodes) for (const event of events) { if (event.target === this.nodes) { // Updates to the node map. for (const [key, change] of event.changes.keys) { const id = key as AstId switch (change.action) { case 'add': updateBuilder.addNode(id) break case 'update': updateBuilder.updateAllFields(id) break case 'delete': updateBuilder.deleteNode(id) break } } } else if (event.target.parent === this.nodes) { // Updates to a node's fields. assert(event.target instanceof Y.Map) const id = event.target.get('id') as AstId const node = this.nodes.get(id) if (!node) continue const changes: (readonly [string, unknown])[] = Array.from(event.changes.keys, ([key]) => [ key, node.get(key as any), ]) updateBuilder.updateFields(id, changes) } else if (event.target.parent.parent === this.nodes) { // Updates to fields of a metadata object within a node. const id = event.target.parent.get('id') as AstId const node = this.nodes.get(id) if (!node) continue const metadata = node.get('metadata') as unknown as Map const changes: (readonly [string, unknown])[] = Array.from(event.changes.keys, ([key]) => [ key, metadata.get(key as any), ]) updateBuilder.updateMetadata(id, changes) } } return updateBuilder } clear() { this.nodes.clear() } checkedGet(id: AstId): Mutable checkedGet(id: AstId | undefined): Mutable | undefined checkedGet(id: AstId | undefined): Mutable | undefined { if (!id) return undefined const ast = this.get(id) assert(ast !== undefined, 'id in module') return ast } get(id: AstId): Mutable | undefined get(id: AstId | undefined): Mutable | undefined get(id: AstId | undefined): Mutable | undefined { if (!id) return undefined const nodeData = this.nodes.get(id) if (!nodeData) return undefined const fields = nodeData as any return materializeMutable(this, fields) } replace(id: AstId, value: Owned): Owned | undefined { return this.get(id)?.replace(value) } replaceValue(id: AstId, value: Owned): Owned | undefined { return this.get(id)?.replaceValue(value) } take(id: AstId): Owned { return this.replace(id, Wildcard.new(this)) || asOwned(this.checkedGet(id)) } updateValue(id: AstId, f: (x: Owned) => Owned): T | undefined { return this.get(id)?.updateValue(f) } ///////////////////////////////////////////// constructor(doc: Y.Doc) { this.nodes = doc.getMap('nodes') } private rootPointer(): MutableRootPointer | undefined { const rootPointer = this.get(ROOT_ID) if (rootPointer) return rootPointer as MutableRootPointer } /** @internal */ baseObject(type: string, externalId?: ExternalId, overrideId?: AstId): FixedMap { const map = new Y.Map() const map_ = map as unknown as FixedMap<{}> const id = overrideId ?? newAstId(type) const metadata = new Y.Map() as unknown as FixedMap<{}> const metadataFields = setAll(metadata, { externalId: externalId ?? newExternalId(), }) const fields = setAll(map_, { id, type: type, parent: undefined, metadata: metadataFields, }) this.nodes.set(id, fields) return fields } /** @internal */ getToken(token: SyncTokenId): Token getToken(token: SyncTokenId | undefined): Token | undefined getToken(token: SyncTokenId | undefined): Token | undefined { if (!token) return token if (token instanceof Token) return token return Token.withId(token.code_, token.tokenType_, token.id) } getAny(node: AstId | SyncTokenId): MutableAst | Token { return isTokenId(node) ? this.getToken(node) : this.checkedGet(node) } /** @internal Copy a node into the module, if it is bound to a different module. */ copyIfForeign(ast: Owned): Owned copyIfForeign(ast: Owned | undefined): Owned | undefined { if (!ast) return ast if (ast.module === this) return ast return this.copy(ast) as any } /** @internal */ delete(id: AstId) { this.nodes.delete(id) } /** @internal */ has(id: AstId) { return this.nodes.has(id) } } type MutableRootPointer = MutableInvalid & { get expression(): MutableAst | undefined } /** @internal */ export interface RootPointer extends Invalid {} function newAstId(type: string): AstId { return `ast:${type}#${random.uint53()}` as AstId } /** Checks whether the input looks like an AstId. */ export function isAstId(value: string): value is AstId { return /ast:[A-Za-z]*#[0-9]*/.test(value) } export const ROOT_ID = `Root` as AstId class UpdateBuilder implements ModuleUpdate { readonly nodesAdded: AstId[] = [] readonly nodesDeleted: AstId[] = [] readonly fieldsUpdated: { id: AstId; fields: (readonly [string, unknown])[] }[] = [] readonly metadataUpdated: { id: AstId; changes: Map }[] = [] private readonly nodes: YNodes constructor(nodes: YNodes) { this.nodes = nodes } addNode(id: AstId) { this.nodesAdded.push(id) this.updateAllFields(id) } updateAllFields(id: AstId) { this.updateFields(id, this.nodes.get(id)!.entries()) } updateFields(id: AstId, changes: Iterable) { const fields = new Array() let metadataChanges = undefined for (const entry of changes) { const [key, value] = entry if (key === 'metadata') { assert(value instanceof Y.Map) metadataChanges = new Map(value.entries()) } else { assert(!(value instanceof Y.AbstractType)) fields.push(entry) } } if (fields.length !== 0) this.fieldsUpdated.push({ id, fields }) if (metadataChanges) this.metadataUpdated.push({ id, changes: metadataChanges }) } updateMetadata(id: AstId, changes: Iterable) { const changeMap = new Map() for (const [key, value] of changes) changeMap.set(key, value) this.metadataUpdated.push({ id, changes: changeMap }) } deleteNode(id: AstId) { this.nodesDeleted.push(id) } }