automerge/javascript/src/proxies.ts
Alex Good 8e131922e7
Move wrappers/javascript -> javascript
Continuing our theme of treating all languages equally, move
wrappers/javascript to javascrpit. Automerge libraries for new languages
should be built at this top level if possible.
2022-10-16 19:55:54 +01:00

711 lines
21 KiB
TypeScript
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { Automerge, Heads, ObjID } from "@automerge/automerge-wasm"
import { Prop } from "@automerge/automerge-wasm"
import { AutomergeValue, ScalarValue, MapValue, ListValue, TextValue } from "./types"
import { Counter, getWriteableCounter } from "./counter"
import { Text } from "./text"
import { STATE, HEADS, TRACE, FROZEN, OBJECT_ID, READ_ONLY, COUNTER, INT, UINT, F64, TEXT } from "./constants"
function parseListIndex(key) {
if (typeof key === 'string' && /^[0-9]+$/.test(key)) key = parseInt(key, 10)
if (typeof key !== 'number') {
// throw new TypeError('A list index must be a number, but you passed ' + JSON.stringify(key))
return key
}
if (key < 0 || isNaN(key) || key === Infinity || key === -Infinity) {
throw new RangeError('A list index must be positive, but you passed ' + key)
}
return key
}
function valueAt(target, prop: Prop) : AutomergeValue | undefined {
const { context, objectId, path, readonly, heads} = target
const value = context.getWithType(objectId, prop, heads)
if (value === null) {
return
}
const datatype = value[0]
const val = value[1]
switch (datatype) {
case undefined: return;
case "map": return mapProxy(context, val, [ ... path, prop ], readonly, heads);
case "list": return listProxy(context, val, [ ... path, prop ], readonly, heads);
case "text": return textProxy(context, val, [ ... path, prop ], readonly, heads);
//case "table":
//case "cursor":
case "str": return val;
case "uint": return val;
case "int": return val;
case "f64": return val;
case "boolean": return val;
case "null": return null;
case "bytes": return val;
case "timestamp": return val;
case "counter": {
if (readonly) {
return new Counter(val);
} else {
return getWriteableCounter(val, context, path, objectId, prop)
}
}
default:
throw RangeError(`datatype ${datatype} unimplemented`)
}
}
function import_value(value) {
switch (typeof value) {
case 'object':
if (value == null) {
return [ null, "null"]
} else if (value[UINT]) {
return [ value.value, "uint" ]
} else if (value[INT]) {
return [ value.value, "int" ]
} else if (value[F64]) {
return [ value.value, "f64" ]
} else if (value[COUNTER]) {
return [ value.value, "counter" ]
} else if (value[TEXT]) {
return [ value, "text" ]
} else if (value instanceof Date) {
return [ value.getTime(), "timestamp" ]
} else if (value instanceof Uint8Array) {
return [ value, "bytes" ]
} else if (value instanceof Array) {
return [ value, "list" ]
} else if (Object.getPrototypeOf(value) === Object.getPrototypeOf({})) {
return [ value, "map" ]
} else if (value[OBJECT_ID]) {
throw new RangeError('Cannot create a reference to an existing document object')
} else {
throw new RangeError(`Cannot assign unknown object: ${value}`)
}
break;
case 'boolean':
return [ value, "boolean" ]
case 'number':
if (Number.isInteger(value)) {
return [ value, "int" ]
} else {
return [ value, "f64" ]
}
break;
case 'string':
return [ value ]
break;
default:
throw new RangeError(`Unsupported type of value: ${typeof value}`)
}
}
const MapHandler = {
get (target, key) : AutomergeValue {
const { context, objectId, readonly, frozen, heads, cache } = target
if (key === Symbol.toStringTag) { return target[Symbol.toStringTag] }
if (key === OBJECT_ID) return objectId
if (key === READ_ONLY) return readonly
if (key === FROZEN) return frozen
if (key === HEADS) return heads
if (key === TRACE) return target.trace
if (key === STATE) return context;
if (!cache[key]) {
cache[key] = valueAt(target, key)
}
return cache[key]
},
set (target, key, val) {
const { context, objectId, path, readonly, frozen} = target
target.cache = {} // reset cache on set
if (val && val[OBJECT_ID]) {
throw new RangeError('Cannot create a reference to an existing document object')
}
if (key === FROZEN) {
target.frozen = val
return true
}
if (key === HEADS) {
target.heads = val
return true
}
if (key === TRACE) {
target.trace = val
return true
}
const [ value, datatype ] = import_value(val)
if (frozen) {
throw new RangeError("Attempting to use an outdated Automerge document")
}
if (readonly) {
throw new RangeError(`Object property "${key}" cannot be modified`)
}
switch (datatype) {
case "list": {
const list = context.putObject(objectId, key, [])
const proxyList = listProxy(context, list, [ ... path, key ], readonly );
for (let i = 0; i < value.length; i++) {
proxyList[i] = value[i]
}
break
}
case "text": {
const text = context.putObject(objectId, key, "", "text")
const proxyText = textProxy(context, text, [ ... path, key ], readonly );
for (let i = 0; i < value.length; i++) {
proxyText[i] = value.get(i)
}
break
}
case "map": {
const map = context.putObject(objectId, key, {})
const proxyMap = mapProxy(context, map, [ ... path, key ], readonly );
for (const key in value) {
proxyMap[key] = value[key]
}
break;
}
default:
context.put(objectId, key, value, datatype)
}
return true
},
deleteProperty (target, key) {
const { context, objectId, readonly } = target
target.cache = {} // reset cache on delete
if (readonly) {
throw new RangeError(`Object property "${key}" cannot be modified`)
}
context.delete(objectId, key)
return true
},
has (target, key) {
const value = this.get(target, key)
return value !== undefined
},
getOwnPropertyDescriptor (target, key) {
// const { context, objectId } = target
const value = this.get(target, key)
if (typeof value !== 'undefined') {
return {
configurable: true, enumerable: true, value
}
}
},
ownKeys (target) {
const { context, objectId, heads} = target
// FIXME - this is a tmp workaround until fix the dupe key bug in keys()
const keys = context.keys(objectId, heads)
return [...new Set<string>(keys)]
},
}
const ListHandler = {
get (target, index) {
const {context, objectId, readonly, frozen, heads } = target
index = parseListIndex(index)
if (index === Symbol.hasInstance) { return (instance) => { return Array.isArray(instance) } }
if (index === Symbol.toStringTag) { return target[Symbol.toStringTag] }
if (index === OBJECT_ID) return objectId
if (index === READ_ONLY) return readonly
if (index === FROZEN) return frozen
if (index === HEADS) return heads
if (index === TRACE) return target.trace
if (index === STATE) return context;
if (index === 'length') return context.length(objectId, heads);
if (typeof index === 'number') {
return valueAt(target, index)
} else {
return listMethods(target)[index]
}
},
set (target, index, val) {
const {context, objectId, path, readonly, frozen } = target
index = parseListIndex(index)
if (val && val[OBJECT_ID]) {
throw new RangeError('Cannot create a reference to an existing document object')
}
if (index === FROZEN) {
target.frozen = val
return true
}
if (index === HEADS) {
target.heads = val
return true
}
if (index === TRACE) {
target.trace = val
return true
}
if (typeof index == "string") {
throw new RangeError('list index must be a number')
}
const [ value, datatype] = import_value(val)
if (frozen) {
throw new RangeError("Attempting to use an outdated Automerge document")
}
if (readonly) {
throw new RangeError(`Object property "${index}" cannot be modified`)
}
switch (datatype) {
case "list": {
let list
if (index >= context.length(objectId)) {
list = context.insertObject(objectId, index, [])
} else {
list = context.putObject(objectId, index, [])
}
const proxyList = listProxy(context, list, [ ... path, index ], readonly);
proxyList.splice(0,0,...value)
break;
}
case "text": {
let text
if (index >= context.length(objectId)) {
text = context.insertObject(objectId, index, "", "text")
} else {
text = context.putObject(objectId, index, "", "text")
}
const proxyText = textProxy(context, text, [ ... path, index ], readonly);
proxyText.splice(0,0,...value)
break;
}
case "map": {
let map
if (index >= context.length(objectId)) {
map = context.insertObject(objectId, index, {})
} else {
map = context.putObject(objectId, index, {})
}
const proxyMap = mapProxy(context, map, [ ... path, index ], readonly);
for (const key in value) {
proxyMap[key] = value[key]
}
break;
}
default:
if (index >= context.length(objectId)) {
context.insert(objectId, index, value, datatype)
} else {
context.put(objectId, index, value, datatype)
}
}
return true
},
deleteProperty (target, index) {
const {context, objectId} = target
index = parseListIndex(index)
if (context.get(objectId, index)[0] == "counter") {
throw new TypeError('Unsupported operation: deleting a counter from a list')
}
context.delete(objectId, index)
return true
},
has (target, index) {
const {context, objectId, heads} = target
index = parseListIndex(index)
if (typeof index === 'number') {
return index < context.length(objectId, heads)
}
return index === 'length'
},
getOwnPropertyDescriptor (target, index) {
const {context, objectId, heads} = target
if (index === 'length') return {writable: true, value: context.length(objectId, heads) }
if (index === OBJECT_ID) return {configurable: false, enumerable: false, value: objectId}
index = parseListIndex(index)
const value = valueAt(target, index)
return { configurable: true, enumerable: true, value }
},
getPrototypeOf(target) { return Object.getPrototypeOf(target) },
ownKeys (/*target*/) : string[] {
const keys : string[] = []
// uncommenting this causes assert.deepEqual() to fail when comparing to a pojo array
// but not uncommenting it causes for (i in list) {} to not enumerate values properly
//const {context, objectId, heads } = target
//for (let i = 0; i < target.context.length(objectId, heads); i++) { keys.push(i.toString()) }
keys.push("length");
return keys
}
}
const TextHandler = Object.assign({}, ListHandler, {
get (target, index) {
// FIXME this is a one line change from ListHandler.get()
const {context, objectId, readonly, frozen, heads } = target
index = parseListIndex(index)
if (index === Symbol.toStringTag) { return target[Symbol.toStringTag] }
if (index === Symbol.hasInstance) { return (instance) => { return Array.isArray(instance) } }
if (index === OBJECT_ID) return objectId
if (index === READ_ONLY) return readonly
if (index === FROZEN) return frozen
if (index === HEADS) return heads
if (index === TRACE) return target.trace
if (index === STATE) return context;
if (index === 'length') return context.length(objectId, heads);
if (typeof index === 'number') {
return valueAt(target, index)
} else {
return textMethods(target)[index] || listMethods(target)[index]
}
},
getPrototypeOf(/*target*/) {
return Object.getPrototypeOf(new Text())
},
})
export function mapProxy(context: Automerge, objectId: ObjID, path?: Prop[], readonly?: boolean, heads?: Heads) : MapValue {
return new Proxy({context, objectId, path, readonly: !!readonly, frozen: false, heads, cache: {}}, MapHandler)
}
export function listProxy(context: Automerge, objectId: ObjID, path?: Prop[], readonly?: boolean, heads?: Heads) : ListValue {
const target = []
Object.assign(target, {context, objectId, path, readonly: !!readonly, frozen: false, heads, cache: {}})
return new Proxy(target, ListHandler)
}
export function textProxy(context: Automerge, objectId: ObjID, path?: Prop[], readonly?: boolean, heads?: Heads) : TextValue {
const target = []
Object.assign(target, {context, objectId, path, readonly: !!readonly, frozen: false, heads, cache: {}})
return new Proxy(target, TextHandler)
}
export function rootProxy<T>(context: Automerge, readonly?: boolean) : T {
/* eslint-disable-next-line */
return <any>mapProxy(context, "_root", [], !!readonly)
}
function listMethods(target) {
const {context, objectId, path, readonly, frozen, heads} = target
const methods = {
deleteAt(index, numDelete) {
if (typeof numDelete === 'number') {
context.splice(objectId, index, numDelete)
} else {
context.delete(objectId, index)
}
return this
},
fill(val: ScalarValue, start: number, end: number) {
const [value, datatype] = import_value(val)
const length = context.length(objectId)
start = parseListIndex(start || 0)
end = parseListIndex(end || length)
for (let i = start; i < Math.min(end, length); i++) {
context.put(objectId, i, value, datatype)
}
return this
},
indexOf(o, start = 0) {
const length = context.length(objectId)
for (let i = start; i < length; i++) {
const value = context.getWithType(objectId, i, heads)
if (value && value[1] === o[OBJECT_ID] || value[1] === o) {
return i
}
}
return -1
},
insertAt(index, ...values) {
this.splice(index, 0, ...values)
return this
},
pop() {
const length = context.length(objectId)
if (length == 0) {
return undefined
}
const last = valueAt(target, length - 1)
context.delete(objectId, length - 1)
return last
},
push(...values) {
const len = context.length(objectId)
this.splice(len, 0, ...values)
return context.length(objectId)
},
shift() {
if (context.length(objectId) == 0) return
const first = valueAt(target, 0)
context.delete(objectId, 0)
return first
},
splice(index, del, ...vals) {
index = parseListIndex(index)
del = parseListIndex(del)
for (const val of vals) {
if (val && val[OBJECT_ID]) {
throw new RangeError('Cannot create a reference to an existing document object')
}
}
if (frozen) {
throw new RangeError("Attempting to use an outdated Automerge document")
}
if (readonly) {
throw new RangeError("Sequence object cannot be modified outside of a change block")
}
const result : AutomergeValue[] = []
for (let i = 0; i < del; i++) {
const value = valueAt(target, index)
if (value !== undefined) {
result.push(value)
}
context.delete(objectId, index)
}
const values = vals.map((val) => import_value(val))
for (const [value,datatype] of values) {
switch (datatype) {
case "list": {
const list = context.insertObject(objectId, index, [])
const proxyList = listProxy(context, list, [ ... path, index ], readonly);
proxyList.splice(0,0,...value)
break;
}
case "text": {
const text = context.insertObject(objectId, index, "", "text")
const proxyText = textProxy(context, text, [ ... path, index ], readonly);
proxyText.splice(0,0,...value)
break;
}
case "map": {
const map = context.insertObject(objectId, index, {})
const proxyMap = mapProxy(context, map, [ ... path, index ], readonly);
for (const key in value) {
proxyMap[key] = value[key]
}
break;
}
default:
context.insert(objectId, index, value, datatype)
}
index += 1
}
return result
},
unshift(...values) {
this.splice(0, 0, ...values)
return context.length(objectId)
},
entries() {
const i = 0;
const iterator = {
next: () => {
const value = valueAt(target, i)
if (value === undefined) {
return { value: undefined, done: true }
} else {
return { value: [ i, value ], done: false }
}
}
}
return iterator
},
keys() {
let i = 0;
const len = context.length(objectId, heads)
const iterator = {
next: () => {
let value : undefined | number = undefined
if (i < len) { value = i; i++ }
return { value, done: true }
}
}
return iterator
},
values() {
const i = 0;
const iterator = {
next: () => {
const value = valueAt(target, i)
if (value === undefined) {
return { value: undefined, done: true }
} else {
return { value, done: false }
}
}
}
return iterator
},
toArray() : AutomergeValue[] {
const list : AutomergeValue = []
let value
do {
value = valueAt(target, list.length)
if (value !== undefined) {
list.push(value)
}
} while (value !== undefined)
return list
},
map<T>(f: (AutomergeValue, number) => T) : T[] {
return this.toArray().map(f)
},
toString() : string {
return this.toArray().toString()
},
toLocaleString() : string {
return this.toArray().toLocaleString()
},
forEach(f: (AutomergeValue, number) => undefined ) {
return this.toArray().forEach(f)
},
// todo: real concat function is different
concat(other: AutomergeValue[]) : AutomergeValue[] {
return this.toArray().concat(other)
},
every(f: (AutomergeValue, number) => boolean) : boolean {
return this.toArray().every(f)
},
filter(f: (AutomergeValue, number) => boolean) : AutomergeValue[] {
return this.toArray().filter(f)
},
find(f: (AutomergeValue, number) => boolean) : AutomergeValue | undefined {
let index = 0
for (let v of this) {
if (f(v, index)) {
return v
}
index += 1
}
},
findIndex(f: (AutomergeValue, number) => boolean) : number {
let index = 0
for (let v of this) {
if (f(v, index)) {
return index
}
index += 1
}
return -1
},
includes(elem: AutomergeValue) : boolean {
return this.find((e) => e === elem) !== undefined
},
join(sep?: string) : string {
return this.toArray().join(sep)
},
// todo: remove the any
reduce<T>(f: (any, AutomergeValue) => T, initalValue?: T) : T | undefined {
return this.toArray().reduce(f,initalValue)
},
// todo: remove the any
reduceRight<T>(f: (any, AutomergeValue) => T, initalValue?: T) : T | undefined{
return this.toArray().reduceRight(f,initalValue)
},
lastIndexOf(search: AutomergeValue, fromIndex = +Infinity) : number {
// this can be faster
return this.toArray().lastIndexOf(search,fromIndex)
},
slice(index?: number, num?: number) : AutomergeValue[] {
return this.toArray().slice(index,num)
},
some(f: (AutomergeValue, number) => boolean) : boolean {
let index = 0;
for (let v of this) {
if (f(v,index)) {
return true
}
index += 1
}
return false
},
[Symbol.iterator]: function *() {
let i = 0;
let value = valueAt(target, i)
while (value !== undefined) {
yield value
i += 1
value = valueAt(target, i)
}
}
}
return methods
}
function textMethods(target) {
const {context, objectId, heads } = target
const methods = {
set (index: number, value) {
return this[index] = value
},
get (index: number) : AutomergeValue {
return this[index]
},
toString () : string {
return context.text(objectId, heads).replace(//g,'')
},
toSpans () : AutomergeValue[] {
const spans : AutomergeValue[] = []
let chars = ''
const length = context.length(objectId)
for (let i = 0; i < length; i++) {
const value = this[i]
if (typeof value === 'string') {
chars += value
} else {
if (chars.length > 0) {
spans.push(chars)
chars = ''
}
spans.push(value)
}
}
if (chars.length > 0) {
spans.push(chars)
}
return spans
},
toJSON () : string {
return this.toString()
},
indexOf(o, start = 0) {
const text = context.text(objectId)
return text.indexOf(o,start)
}
}
return methods
}