@casual-simulation/aux-common
Version:
Common library for AUX projects
599 lines • 26.5 kB
JavaScript
import { isTagEdit, preserve, del, insert, edit, asyncResult, asyncError, getInstStateFromUpdates, } from '../bots';
import { breakIntoIndividualEvents, botAdded, createBot, botRemoved, hasValue, botUpdated, convertToString, } from '../bots';
import { Text, Map, applyUpdate, YMapEvent, createAbsolutePositionFromRelativePosition, YTextEvent, encodeStateAsUpdate, } from 'yjs';
import { MemoryPartitionImpl } from './MemoryPartition';
import { createRelativePositionFromStateVector, getClock, getStateVector, } from '../yjs/YjsHelpers';
import { ensureTagIsSerializable, getStateFromUpdates, supportsRemoteEvent, } from './PartitionUtils';
import { fromByteArray, toByteArray } from 'base64-js';
import { YjsSharedDocument } from '../documents/YjsSharedDocument';
import { v4 as uuid } from 'uuid';
const APPLY_UPDATES_TO_INST_TRANSACTION_ORIGIN = '__apply_updates_to_inst';
/**
* Attempts to create a YjsPartition from the given config.
* @param config The config.
*/
export function createYjsPartition(config) {
if (config.type === 'yjs') {
return new YjsPartitionImpl(config);
}
return undefined;
}
export class YjsPartitionImpl extends YjsSharedDocument {
get onBotsAdded() {
return this._internalPartition.onBotsAdded;
}
get onBotsRemoved() {
return this._internalPartition.onBotsRemoved;
}
get onBotsUpdated() {
return this._internalPartition.onBotsUpdated;
}
get onStateUpdated() {
return this._internalPartition.onStateUpdated;
}
unsubscribe() {
return this._sub.unsubscribe();
}
// get doc() {
// return this._doc;
// }
get state() {
return this._internalPartition.state;
}
get space() {
return this._internalPartition.space;
}
set space(value) {
this._internalPartition.space = value;
}
get realtimeStrategy() {
return 'immediate';
}
constructor(config) {
super(config);
this._hasRegisteredSubs = false;
this.type = 'yjs';
this.private = config.private || false;
this._remoteEvents = config.remoteEvents;
this._connectionId = config.connectionId;
this._bots = this._doc.getMap('bots');
this._masks = this._doc.getMap('masks');
this._doc.on('afterTransaction', (transaction) => {
this._processTransaction(transaction);
});
this._internalPartition = new MemoryPartitionImpl({
type: 'memory',
initialState: {},
localSiteId: this._currentSite,
remoteSiteId: this._remoteSite,
});
this._internalPartition.getCurrentVersion = () => {
return this._currentVersion;
};
this._internalPartition.getNextVersion = (textEdit) => {
var _a;
const version = getStateVector(this._doc);
if (this._isRemoteUpdate) {
const { [this._currentSite]: currentVersion, ...otherVersions } = version;
return {
currentSite: this._currentSite,
remoteSite: this._remoteSite,
vector: otherVersions,
};
}
else {
const site = textEdit.isRemote
? this._remoteSite
: this._currentSite;
return {
currentSite: this._currentSite,
remoteSite: this._remoteSite,
vector: {
[site]: (_a = version[this._doc.clientID]) !== null && _a !== void 0 ? _a : 0,
},
};
}
};
}
async sendRemoteEvents(events) {
if (!this._remoteEvents) {
return;
}
for (let event of events) {
if (!supportsRemoteEvent(this._remoteEvents, event)) {
continue;
}
if (event.type === 'remote') {
if (event.event.type === 'get_remote_count') {
this._onEvents.next([asyncResult(event.taskId, 1)]);
}
else if (event.event.type === 'get_remotes') {
if (this._connectionId) {
this._onEvents.next([
asyncResult(event.taskId, [this._connectionId]),
]);
}
else {
this._onEvents.next([asyncResult(event.taskId, [])]);
}
}
else if (event.event.type === 'list_inst_updates') {
try {
const updates = [
{
id: 0,
timestamp: Date.now(),
update: fromByteArray(encodeStateAsUpdate(this._doc)),
},
];
this._onEvents.next([
asyncResult(event.taskId, updates, false),
]);
}
catch (err) {
this._onEvents.next([asyncError(event.taskId, err)]);
}
}
else if (event.event.type === 'get_inst_state_from_updates') {
const action = event.event;
try {
let partition = new YjsPartitionImpl({
type: 'yjs',
});
for (let { update } of action.updates) {
const updateBytes = toByteArray(update);
applyUpdate(partition.doc, updateBytes);
}
this._onEvents.next([
asyncResult(event.taskId, partition.state, false),
]);
}
catch (err) {
this._onEvents.next([asyncError(event.taskId, err)]);
}
}
else if (event.event.type === 'create_initialization_update') {
const action = (event.event);
try {
let partition = new YjsPartitionImpl({
type: 'yjs',
});
partition.doc.on('update', (update) => {
let instUpdate = {
id: 0,
timestamp: Date.now(),
update: fromByteArray(update),
};
this._onEvents.next([
asyncResult(event.taskId, instUpdate, false),
]);
});
await partition.applyEvents(action.bots.map((b) => botAdded(createBot(b.id, b.tags))));
}
catch (err) {
this._onEvents.next([asyncError(event.taskId, err)]);
}
}
else if (event.event.type === 'apply_updates_to_inst') {
const action = event.event;
try {
this._applyUpdates(action.updates.map((u) => u.update), APPLY_UPDATES_TO_INST_TRANSACTION_ORIGIN);
this._onEvents.next([
asyncResult(event.taskId, null, false),
]);
}
catch (err) {
this._onEvents.next([asyncError(event.taskId, err)]);
}
}
else if (event.event.type === 'get_current_inst_update') {
const action = event.event;
try {
const update = {
id: 0,
timestamp: Date.now(),
update: fromByteArray(encodeStateAsUpdate(this._doc)),
};
this._onEvents.next([
asyncResult(event.taskId, update, false),
]);
}
catch (err) {
this._onEvents.next([asyncError(event.taskId, err)]);
}
}
else if (event.event.type === 'install_aux_file') {
const action = event.event;
try {
if (action.mode === 'copy') {
let bots;
if (action.aux.version === 2) {
const state = getStateFromUpdates(getInstStateFromUpdates(action.aux.updates));
bots = Object.values(state);
}
else {
bots = Object.values(action.aux.state);
}
if (bots && bots.length > 0) {
this._applyEvents(bots.map((b) => botAdded(createBot(uuid(), b.tags))));
}
}
else {
if (action.aux.version === 2) {
this.applyStateUpdates(action.aux.updates);
}
else if (action.aux.version === 1) {
this._applyEvents(Object.values(action.aux.state).map((b) => botAdded(createBot(b.id, b.tags))));
}
}
this._onEvents.next([
asyncResult(event.taskId, null, false),
]);
}
catch (err) {
this._onEvents.next([asyncError(event.taskId, err)]);
}
}
}
}
}
async applyEvents(events) {
const finalEvents = events.flatMap((e) => {
if (e.type === 'apply_state') {
return breakIntoIndividualEvents(this.state, e);
}
else if (e.type === 'add_bot' ||
e.type === 'remove_bot' ||
e.type === 'update_bot') {
return [e];
}
else {
return [];
}
});
this._applyEvents(finalEvents);
return [];
}
_applyEvents(events) {
this._isLocalTransaction = true;
this._doc.transact((t) => {
for (let event of events) {
if (event.type === 'add_bot') {
const map = new Map();
for (let tag in event.bot.tags) {
const val = ensureTagIsSerializable(event.bot.tags[tag]);
if (hasValue(val)) {
const yVal = typeof val === 'string' ? new Text(val) : val;
map.set(tag, yVal);
}
}
if (this.space && event.bot.masks) {
const tags = event.bot.masks[this.space];
if (tags) {
for (let tag of Object.keys(tags)) {
const maskId = tagMaskId(event.id, tag);
const val = ensureTagIsSerializable(tags[tag]);
if (hasValue(val)) {
const yVal = typeof val === 'string'
? new Text(val)
: val;
this._masks.set(maskId, yVal);
}
}
}
}
this._bots.set(event.id, map);
}
else if (event.type === 'remove_bot') {
this._bots.delete(event.id);
}
else if (event.type === 'update_bot') {
const currentBot = this.state[event.id];
const currentMap = this._bots.get(event.id);
if (event.update.tags && currentBot && currentMap) {
for (let tag of Object.keys(event.update.tags)) {
let newVal = ensureTagIsSerializable(event.update.tags[tag]);
const oldVal = currentBot.tags[tag];
if (newVal === oldVal) {
if (Array.isArray(newVal)) {
newVal = newVal.slice();
}
else {
continue;
}
}
this._updateValueInMap(this._doc, currentMap, tag, newVal);
}
}
if (this.space && event.update.masks) {
const tags = event.update.masks[this.space];
if (tags) {
for (let tag of Object.keys(tags)) {
const maskId = tagMaskId(event.id, tag);
const value = ensureTagIsSerializable(tags[tag]);
this._updateValueInMap(this._doc, this._masks, maskId, value);
}
}
}
}
}
});
}
async _processTransaction(transaction) {
let memoryEvents = [];
const version = getStateVector(this._doc);
for (let [type, events] of transaction.changedParentTypes) {
if (type === this._bots) {
for (let event of events) {
// Update the current target so that the event
// path is calculated from the bots map.
// see https://github.com/yjs/yjs/blob/5244755879daaa7b5a1ca64e6af617cdbb110462/src/utils/YEvent.js#L63
event.currentTarget = this._bots;
const target = event.target;
if (target === type) {
// Bot was added or removed
if (event instanceof YMapEvent) {
for (let [key, change] of event.changes.keys) {
if (change.action === 'add') {
// bot added
const value = this._bots.get(key);
const bot = this._mapToBot(key, value);
memoryEvents.push(botAdded(bot));
}
else if (change.action === 'delete') {
// bot deleted
memoryEvents.push(botRemoved(key));
}
else if (change.action === 'update') {
// bot updated in a way that we can't track
// in this scenario, we just remove the bot and re-add it
memoryEvents.push(botRemoved(key));
const value = this._bots.get(key);
const bot = this._mapToBot(key, value);
memoryEvents.push(botAdded(bot));
}
}
}
}
else {
this._handleValueUpdates(target, event, memoryEvents, version, (event) => event.path[event.path.length - 1], (event, key) => {
throw new Error('should not happen');
}, (event) => ({
id: event.path[event.path.length - 2],
tag: event.path[event.path.length - 1],
}), (id, tags) => botUpdated(id, {
tags,
}), (id, tag, val) => botUpdated(id, {
tags: {
[tag]: val,
},
}));
}
}
}
else if (type === this._masks) {
for (let event of events) {
// Update the current target so that the event
// path is calculated from the bots map.
// see https://github.com/yjs/yjs/blob/5244755879daaa7b5a1ca64e6af617cdbb110462/src/utils/YEvent.js#L63
event.currentTarget = this._masks;
const target = event.target;
this._handleValueUpdates(target, event, memoryEvents, version, (event) => event.path[event.path.length - 1], (event, key) => parseTagMaskId(key), (event) => parseTagMaskId(event.path[event.path.length - 1]), (id, tags) => botUpdated(id, {
masks: {
[this.space]: tags,
},
}), (id, tag, val) => botUpdated(id, {
masks: {
[this.space]: {
[tag]: val,
},
},
}));
}
}
}
this._currentVersion = {
currentSite: this._currentSite,
remoteSite: this._remoteSite,
vector: version,
};
await this._internalPartition.applyEvents(memoryEvents);
this._onVersionUpdated.next(this._currentVersion);
}
_mapToBot(id, map) {
let tags = {};
for (let [key, value] of map.entries()) {
const val = map.get(key);
let finalVal;
if (hasValue(val)) {
if (val instanceof Text) {
finalVal = val.toString();
}
else {
finalVal = val;
}
tags[key] = finalVal;
}
}
return createBot(id, tags);
}
/**
* Updates the given value in the given document and map.
* This is a generalization of the update logic for tags and tag masks.
* @param doc The document.
* @param map The map that contains the values.
* @param valueId The ID that the value should be looked up by. For tags, this is the tag name. For tag masks, this is the tag mask ID.
* @param newVal The new value.
*/
_updateValueInMap(doc, map, valueId, newVal) {
doc.clientID = this._localId;
if (hasValue(newVal)) {
if (isTagEdit(newVal)) {
if (newVal.isRemote) {
doc.clientID = this._remoteId;
this._isLocalTransaction = false;
}
const val = map.get(valueId);
let text;
if (val instanceof Text) {
text = val;
}
else {
text = new Text(convertToString(val));
map.set(valueId, text);
}
const version = {
...newVal.version,
[this._currentSite]: getClock(doc, this._localId),
};
if (text instanceof Text) {
for (let ops of newVal.operations) {
let index = 0;
for (let op of ops) {
if (op.type === 'preserve') {
index += op.count;
}
else if (op.type === 'insert') {
if (op.text.length <= 0) {
continue;
}
const relativePos = createRelativePositionFromStateVector(text, version, index);
const finalPosition = createAbsolutePositionFromRelativePosition(relativePos, doc);
text.insert(finalPosition.index, op.text);
index += op.text.length;
}
else if (op.type === 'delete') {
if (op.count <= 0) {
continue;
}
const relativePos = createRelativePositionFromStateVector(text, version, index);
const finalPosition = createAbsolutePositionFromRelativePosition(relativePos, doc);
text.delete(finalPosition.index, op.count);
}
}
version[this._currentSite] = getClock(doc, this._localId);
}
}
}
else {
const yVal = typeof newVal === 'string' ? new Text(newVal) : newVal;
map.set(valueId, yVal);
}
}
else {
map.delete(valueId);
}
}
_handleValueUpdates(target, event, events, version, getMapBotId, getTagMaskBotValues, getTextBotValues, createBotUpdate, createTextUpdate) {
// child event - this could mean that a bot tag was updated
if (target instanceof Map) {
if (event instanceof YMapEvent) {
if (target === this._masks) {
let botToMasks = new globalThis.Map();
for (let [key, change] of event.changes.keys) {
const { id, tag } = getTagMaskBotValues(event, key);
let tags = botToMasks.get(id);
if (!tags) {
tags = {};
botToMasks.set(id, tags);
}
if (change.action === 'add' ||
change.action === 'update') {
// tag added
const value = target.get(key);
const auxValue = value instanceof Text
? value.toString()
: value;
tags[tag] = auxValue;
}
else if (change.action === 'delete') {
tags[tag] = null;
}
}
for (let [id, tags] of botToMasks) {
events.push(createBotUpdate(id, tags));
}
}
else {
// Update the current target so that the event
// path is calculated from the bots map.
// see https://github.com/yjs/yjs/blob/5244755879daaa7b5a1ca64e6af617cdbb110462/src/utils/YEvent.js#L63
event.currentTarget = this._bots;
// Maps are only used for bots and tags
// so a map that is not the bots map must be for a tag
const id = getMapBotId(event);
let tags = {};
for (let [key, change] of event.changes.keys) {
if (change.action === 'add' ||
change.action === 'update') {
// tag added
const value = target.get(key);
const auxValue = value instanceof Text
? value.toString()
: value;
tags[key] = auxValue;
}
else if (change.action === 'delete') {
tags[key] = null;
}
}
events.push(createBotUpdate(id, tags));
}
}
}
else if (target instanceof Text) {
// text is used for string tag values
const { id, tag } = getTextBotValues(event);
if (event instanceof YTextEvent) {
let operations = [];
for (let delta of event.delta) {
if (hasValue(delta.retain)) {
operations.push(preserve(delta.retain));
}
else if (hasValue(delta.insert)) {
if (typeof delta.insert === 'string') {
operations.push(insert(delta.insert));
}
else if (Array.isArray(delta.insert)) {
for (let str of delta.insert) {
operations.push(insert(str));
}
}
}
else if (hasValue(delta.delete)) {
operations.push(del(delta.delete));
}
}
const siteId = this._isLocalTransaction
? this._currentSite
: this._remoteSite;
const e = edit({ [siteId]: version[siteId] }, ...operations);
e.isRemote = !this._isLocalTransaction;
events.push(createTextUpdate(id, tag, e));
}
}
}
}
/**
* Creates the tag mask ID for the given bot ID and tag.
* @param botId The ID of the bot.
* @param tag the tag.
*/
function tagMaskId(botId, tag) {
return `${botId}:${tag}`;
}
/**
* Creates the tag mask ID for the given bot ID and tag.
* @param botId The ID of the bot.
* @param tag the tag.
*/
function parseTagMaskId(val) {
const index = val.indexOf(':');
if (index < 0) {
throw new Error('Invalid tag mask ID');
}
const id = val.substring(0, index);
const tag = val.substring(index + 1);
return { id, tag };
}
//# sourceMappingURL=YjsPartition.js.map