From f469d6fb3575dd24aa845f3954fcc613a2e5706a Mon Sep 17 00:00:00 2001 From: Camila Bojman Date: Tue, 3 Aug 2021 18:46:19 +0100 Subject: [PATCH] add Free Proxy API --- README.md | 6 + frontend/index.js | 28 +- frontend/proxies.js | 26 +- frontend/proxy_polyfill.js | 214 +++++++++++++++ proxy_free.md | 105 ++++++++ src/automerge.js | 2 +- test/encoding_test.js | 2 +- test/frontend_test.js | 24 ++ test/proxies_polyfill_test.js | 474 ++++++++++++++++++++++++++++++++++ test/test.js | 23 ++ 10 files changed, 894 insertions(+), 10 deletions(-) create mode 100644 frontend/proxy_polyfill.js create mode 100644 proxy_free.md create mode 100644 test/proxies_polyfill_test.js diff --git a/README.md b/README.md index 959c06b62..db20f545a 100644 --- a/README.md +++ b/README.md @@ -660,3 +660,9 @@ MIT license (see `LICENSE`). Created by [Martin Kleppmann](https://martin.kleppmann.com/) and [many great contributors](https://github.com/automerge/automerge/graphs/contributors). + + +# Proxy Free API +Automerge uses JS Proxy extensively for its front-end API. However, to be able to support multiple JS runtime which does not support `Proxy` you can use the **Proxy Free API**. + +To use the Proxy Free API, you will only need to change a flag by calling `Automerge.useProxyFreeAPI()`. Read more documentation on this API on [`proxy_free.md`]https://github.com/automerge/automerge/blob/main/proxy_free.md). diff --git a/frontend/index.js b/frontend/index.js index 7a8bbf363..e558c1057 100644 --- a/frontend/index.js +++ b/frontend/index.js @@ -2,7 +2,7 @@ const { OPTIONS, CACHE, STATE, OBJECT_ID, CONFLICTS, CHANGE, ELEM_IDS } = requir const { isObject, copyObject } = require('../src/common') const uuid = require('../src/uuid') const { interpretPatch, cloneRootObject } = require('./apply_patch') -const { rootObjectProxy } = require('./proxies') +const { rootObjectProxy, setProxyFree } = require('./proxies') const { Context } = require('./context') const { Text } = require('./text') const { Table } = require('./table') @@ -160,6 +160,13 @@ function applyPatchToDoc(doc, patch, state, fromBackend) { return updateRootObject(doc, updated, state) } +/** + * This function will set syntax defined by `ListProxyPolyfill`/`MapProxyPolyfill` as frontend interface + */ +function useProxyFreeAPI() { + setProxyFree(true) +} + /** * Creates an empty document object with no changes. */ @@ -325,12 +332,21 @@ function applyPatch(doc, patch, backendState = undefined) { return updateRootObject(doc, {}, state) } } +/** + * Returns the Automerge value associated with `key` of the given object. + */ +function get(object, key) { + if (typeof object.get === 'function') { + return object.get(key) + } + return object[key] +} /** * Returns the Automerge object ID of the given object. */ function getObjectId(object) { - return object[OBJECT_ID] + return get(object, OBJECT_ID) } /** @@ -343,17 +359,17 @@ function getObjectById(doc, objectId) { // However, that requires knowing the path from the root to the current // object, which we don't have if we jumped straight to the object by its ID. // If we maintained an index from object ID to parent ID we could work out the path. - if (doc[CHANGE]) { + if (get(doc, CHANGE)) { throw new TypeError('Cannot use getObjectById in a change callback') } - return doc[CACHE][objectId] + return get(get(doc, CACHE), objectId) } /** * Returns the Automerge actor ID of the given document. */ function getActorId(doc) { - return doc[STATE].actorId || doc[OPTIONS].actorId + return get(doc, STATE).actorId || get(doc, OPTIONS).actorId } /** @@ -409,7 +425,7 @@ function getElementIds(list) { } module.exports = { - init, from, change, emptyChange, applyPatch, + useProxyFreeAPI, init, from, change, emptyChange, applyPatch, getObjectId, getObjectById, getActorId, setActorId, getConflicts, getLastLocalChange, getBackendState, getElementIds, Text, Table, Counter, Observable, Float64, Int, Uint diff --git a/frontend/proxies.js b/frontend/proxies.js index fac0a4d24..4bb658883 100644 --- a/frontend/proxies.js +++ b/frontend/proxies.js @@ -2,6 +2,19 @@ const { OBJECT_ID, CHANGE, STATE } = require('./constants') const { createArrayOfNulls } = require('../src/common') const { Text } = require('./text') const { Table } = require('./table') +const { ListProxyPolyfill, MapProxyPolyfill } = require('./proxy_polyfill') + +/** + * This variable express if interface will be defined by `ListProxyPolyfill`/`MapProxyPolyfill` (if `true`) or native `Proxy` (if `false`) + */ +let ProxyFree = false + +/** + * This function will set global varible `ProxyFree` which will express if interface will be defined by `ListProxyPolyfill`/`MapProxyPolyfill` (if `true`) or native `Proxy` (if `false`) + */ +function setProxyFree(value) { + ProxyFree = value +} function parseListIndex(key) { if (typeof key === 'string' && /^[0-9]+$/.test(key)) key = parseInt(key, 10) @@ -30,7 +43,10 @@ function listMethods(context, listId, path) { }, indexOf(o, start = 0) { - const id = o[OBJECT_ID] + let id = o[OBJECT_ID] + if (typeof o.get === 'function') { + id = o.get(OBJECT_ID) + } if (id) { const list = context.getObject(listId) for (let index = start; index < list.length; index++) { @@ -231,10 +247,16 @@ const ListHandler = { } function mapProxy(context, objectId, path, readonly) { + if (ProxyFree) { + return new MapProxyPolyfill({context, objectId, path, readonly}, MapHandler) + } return new Proxy({context, objectId, path, readonly}, MapHandler) } function listProxy(context, objectId, path) { + if (ProxyFree) { + return new ListProxyPolyfill([context, objectId, path], ListHandler, listMethods) + } return new Proxy([context, objectId, path], ListHandler) } @@ -260,4 +282,4 @@ function rootObjectProxy(context) { return mapProxy(context, '_root', []) } -module.exports = { rootObjectProxy } +module.exports = { rootObjectProxy, setProxyFree } diff --git a/frontend/proxy_polyfill.js b/frontend/proxy_polyfill.js new file mode 100644 index 000000000..7594b18b4 --- /dev/null +++ b/frontend/proxy_polyfill.js @@ -0,0 +1,214 @@ +/** + * ProxyPolyfill is a dump wrapper for `handler` + * where `target` is a map and is always passed as parameter. + */ +class MapProxyPolyfill { + /** + * Creates ProxyPolyfill and defines methos dynamically. + * All methods are a dump wrapper to `handler` methods with `target` as first parameter. + */ + constructor(target, handler) { + this.target = target + for (const item in handler) { + if (Object.prototype.hasOwnProperty.call(handler, item)) { + this[item] = (...args) => handler[item](this.target, ...args) + } + } + + + // Implements `getOwnPropertyNames` method for wrapped class. + // This is needed because it is not possible to override `Object.getOwnPropertyNames()` without a `Proxy`. + // + // This method is a dump wrapper of `ownKey()` so it must be created only if the handle has `ownKey()` method. + // (https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Proxy/Proxy/ownKeys for more info) + if (typeof handler.ownKeys === 'function') { + this.getOwnPropertyNames = () => handler.ownKeys(this.target) + } + + // Implements `assign` method for wrapped class. + // This is needed because it is not possible to override `Object.assign()` without a `Proxy`. + if (typeof handler.set === 'function') { + this.assign = (object) => { + Object.keys(object).forEach(function(key) { + handler.set(target, key, object[key]) + }) + } + } + } + + iterator () { + // NOTE: this method used to be a generator; it has been converted to a regular + // method (that mimics the interface of a generator) to avoid having to include + // generator polyfills in the distribution build. + // eslint-disable-next-line consistent-this + const doc = this + let keys = doc.ownKeys() + let index = 0 + return { + next () { + let key = keys[index] + if (!key) return { value: undefined, done: true } + index = index + 1 + return {value: [key, doc.get(key)], done: false} + }, + [Symbol.iterator]: () => this.iterator(), + } + } + + /** + * Defines iterator. Iterates the map's key and values + */ + [Symbol.iterator] () { + return this.iterator() + } + + /** + * To be used by JSON.stringify() function. + * It returns the wrapped instance. + * (more info https://javascript.info/json#custom-tojson) + */ + toJSON () { + const { context, objectId } = this.target + let object = context.getObject(objectId) + return object + } + + /** + * Implements isArray method for wrapped class. + * This is needed because it is not possible to override Array.isArray() without a Proxy. + */ + isArray () { + return false + } +} + +/** + * ListProxyPolyfill is a dump wrapper for `handler` + * where `target` is an array and is always passed as parameter. + */ +class ListProxyPolyfill { + /** + * Creates ListProxyPolyfill and defines methos dynamically. + * All methods are a dump wrapper to `handler` methods with `target` as first parameter. + */ + constructor(target, handler, listMethods) { + this.target = target + for (const item in handler) { + if (Object.prototype.hasOwnProperty.call(handler, item)) { + this[item] = (...args) => handler[item](this.target, ...args) + } + } + + // Casts `key` to string before calling `handler`s `get` method. + // This is needed because Proxy does so and the handler is prepared for that. + this.get = (key) => { + if (typeof key == 'number') { + key = key.toString() + } + return handler.get(this.target, key) + } + + // Casts `key` to string before calling `handler`s `get` method. + // This is needed because Proxy does so and the handler is prepared for that. + this.has = (key) => { + if (typeof key == 'number') { + key = key.toString() + } + return handler.has(this.target, key) + } + + + // Implements `objectKeys` method for wrapped class. + // This is needed because it is not possible to override `Object.keys()` without a `Proxy`. + // + // This method returns only enumerable property names. + // (more info https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/keys) + if (typeof handler.ownKeys === 'function' && typeof handler.getOwnPropertyDescriptor === 'function') { + this.objectKeys = () => { + let keys = [] + for (let key of handler.ownKeys(this.target)) { + let description = handler.getOwnPropertyDescriptor(this.target, key) + if (description.enumerable) { + keys.push(key) + } + } + return keys + } + } + + // Implements `getOwnPropertyNames` method for wrapped class. + // This is needed because it is not possible to override `Object.getOwnPropertyNames()` without a `Proxy`. + // + // This method is a dump wrapper of `ownKey()` so it must be created only if the handle has `ownKey()` method. + // (https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Proxy/Proxy/ownKeys for more info) + if (typeof handler.ownKeys === 'function') { + this.getOwnPropertyNames = () => handler.ownKeys(this.target) + } + + // Defines same methods as listMethods + // All methods are a dump wrapper to the ones defined on listMethods. + const [context, objectId, path] = target + const _listMethods = listMethods(context, objectId, path) + for (const methodName in _listMethods) { + if (Object.prototype.hasOwnProperty.call(_listMethods, methodName)) { + this[methodName] = (...args) => _listMethods[methodName](...args) + } + } + } + + iterator () { + // NOTE: this method used to be a generator; it has been converted to a regular + // method (that mimics the interface of a generator) to avoid having to include + // generator polyfills in the distribution build. + // eslint-disable-next-line consistent-this + let doc = this + let keysIterator = doc.keys() + return { + next () { + let nextKey = keysIterator.next() + if (nextKey.done) return nextKey + return {value: doc.get(nextKey.value), done: false} + }, + [Symbol.iterator]: () => this.iterator(), + } + } + + /** + * Defines iterator. Iterates the array's values + */ + [Symbol.iterator] () { + return this.iterator() + } + + /** + * Implements isArray method for wrapped class. + * This is needed because it is not possible to override Array.isArray() without a Proxy. + */ + isArray () { + return true + } + + /** + * Implements length method for wrapped class. + * This is needed because it is not possible to override .length without a Proxy. + */ + length () { + const [context, objectId, /* path */] = this.target + const object = context.getObject(objectId) + return object.length + } + + /** + * To be used by JSON.stringify() function. + * It returns the wrapped instance. + * (more info https://javascript.info/json#custom-tojson) + */ + toJSON () { + const [ context, objectId ] = this.target + let object = context.getObject(objectId) + return object + } +} + + +module.exports = { ListProxyPolyfill, MapProxyPolyfill } diff --git a/proxy_free.md b/proxy_free.md new file mode 100644 index 000000000..8eff9792a --- /dev/null +++ b/proxy_free.md @@ -0,0 +1,105 @@ +# Proxy Free API +Automerge uses JS Proxy extensively for its front-end API. However, to be able to support multiple JS runtime which does not support `Proxy` you can use the **Proxy Free API**. + +This API does not modify the way automerge handles conflict or nested objects. It is only modifies the `Object` and `Array` APIs. + + +## Getting starting +To use the Proxy Free API, you will only need to change a flag by calling `Automerge.useProxyFreeAPI()`. + + +## Getters and Setters +The main difference between the current API and the Proxy Free API is that the latest one doesn't have property accessors. So, it is not possible to access to an automerge proxy free object's properties by using the bracket notation `value = doc[key]` and `doc[key] = value`. Instead, you can use the `set` and `get` methods. For example, `doc.set(key, value)` and `value = doc.get(key)`. + + Proxy API: + ``` js + Automerge.change(Automerge.init(), doc => { + doc.key1 = 'value1' + assert.strictEqual(doc.key1, 'value1') + }) + ``` + + Free Proxy API: + ```js + Automerge.change(Automerge.init(), doc => { + doc.set('key1', 'value1') + assert.strictEqual(doc.get('key1'), 'value1') + }) + ``` + + +## `Object` static methods +It is not possible to use `Object` static methods. +### Changes: + ```js + Object.getOwnPropertyNames(doc) -> doc.getOwnPropertyNames() + Object.ownKeys(doc) -> doc.ownKeys() + Object.assign(doc) -> doc.assign() + Object.getOwnPropertyNames(doc) -> doc.getOwnPropertyNames() + ``` + +## `Array` static methods +As with `Object` static methods it is not possible to use `Array` static methods. +### Changes: + ```js + // doc: { list: [1, 2, 3] } + Array.isArray(doc.lis) -> doc.get('list').isArray() + ``` + +## Standard Array Read-Only Operations +Standard array read-only operations works the same as the current API. + ```js + .concat() + .entries() + .every() + .filter() + .find() + .findIndex() + .forEach() + .includes() + .indexOf() + .indexOf() + .join() + .keys() + .lastIndexOf() + .map() + .reduce() + .reduceRight() + .slice() + .some() + .toString() + .values() + .pop() + .push() + .shift() + .splice() + .unshift() + ``` + +The array proxy also allows mutation of objects returned from readonly list methods: +``` js +root = Automerge.change(Automerge.init({freeze: true}), doc => { + doc.set('objects', [{id: 1, value: 'one'}, {id: 2, value: 'two'}]) +}) +root = Automerge.change(root, doc => { + doc.get('objects').find(obj => obj.get('id') === 1).set('value', 'ONE!') +}) +// root: {objects: [{id: 1, value: 'ONE!'}, {id: 2, value: 'two'}]} +``` +and supports standard mutation methods: +```js +// doc: { list: [1, 2, 3] } +root = Automerge.change(root, doc => doc.get('list').fill('a')) +// doc: { list: ['a', 'a', 'a'] } +``` + +## Minor changes on Array Proxy +# `len` property +```js +doc.list.length -> doc.get('list').length() +``` + +# `in` operator +```js +0 in doc.list.length -> doc.get('list').has(0) +``` diff --git a/src/automerge.js b/src/automerge.js index 4db9ee39e..bf6317695 100644 --- a/src/automerge.js +++ b/src/automerge.js @@ -160,7 +160,7 @@ module.exports = { } for (let name of ['getObjectId', 'getObjectById', 'getActorId', - 'setActorId', 'getConflicts', 'getLastLocalChange', + 'setActorId', 'getConflicts', 'getLastLocalChange', 'useProxyFreeAPI', 'Text', 'Table', 'Counter', 'Observable', 'Int', 'Uint', 'Float64']) { module.exports[name] = Frontend[name] } diff --git a/test/encoding_test.js b/test/encoding_test.js index bfb11a643..dd8bb02d0 100644 --- a/test/encoding_test.js +++ b/test/encoding_test.js @@ -2,7 +2,7 @@ const assert = require('assert') const { checkEncoded } = require('./helpers') const { Encoder, Decoder, RLEEncoder, RLEDecoder, DeltaEncoder, DeltaDecoder, BooleanEncoder, BooleanDecoder } = require('../backend/encoding') -describe('Binary encoding', () => { +describe('Binary encoding modern web browsers', () => { describe('Encoder and Decoder', () => { describe('32-bit LEB128 encoding', () => { it('should encode unsigned integers', () => { diff --git a/test/frontend_test.js b/test/frontend_test.js index 1201fd2ef..82191d975 100644 --- a/test/frontend_test.js +++ b/test/frontend_test.js @@ -5,6 +5,7 @@ const { Backend } = require('../src/automerge') const uuid = require('../src/uuid') const { STATE } = require('../frontend/constants') const UUID_PATTERN = /^[0-9a-f]{32}$/ +const { setProxyFree } = require('../frontend/proxies') describe('Automerge.Frontend', () => { describe('initializing', () => { @@ -40,6 +41,29 @@ describe('Automerge.Frontend', () => { }) }) + describe('initializing with facebook syntax', () => { + after(() => { + setProxyFree(false) + }) + + it('should be an empty object by default', () => { + Frontend.useProxyFreeAPI() + const doc = Frontend.init() + assert.deepStrictEqual(doc, {}) + assert(UUID_PATTERN.test(Frontend.getActorId(doc).toString())) + }) + + it('should support .get and .set', () => { + Frontend.useProxyFreeAPI() + const doc = Frontend.init() + Frontend.change(doc, doc => { + assert.deepStrictEqual(doc.get('key'), undefined) + doc.set('key', 'value') + assert.deepStrictEqual(doc.get('key'), 'value') + }) + }) + }) + describe('performing changes', () => { it('should return the unmodified document if nothing changed', () => { const doc0 = Frontend.init() diff --git a/test/proxies_polyfill_test.js b/test/proxies_polyfill_test.js new file mode 100644 index 000000000..136fe725a --- /dev/null +++ b/test/proxies_polyfill_test.js @@ -0,0 +1,474 @@ +const assert = require('assert') +const Automerge = process.env.TEST_DIST === '1' ? require('../dist/automerge') : require('../src/automerge') +const { assertEqualsOneOf } = require('./helpers') +const UUID_PATTERN = /^[0-9a-f]{32}$/ +const { setProxyFree } = require('../frontend/proxies') + +describe('Automerge polyfill proxy API', () => { + + before(() => { + Automerge.useProxyFreeAPI() + }) + + after(() => { + setProxyFree(false) + }) + + describe('root object', () => { + it('should have a fixed object ID', () => { + Automerge.change(Automerge.init(), doc => { + assert.strictEqual(Automerge.getObjectId(doc), '_root') + }) + }) + + it('should know its actor ID', () => { + Automerge.change(Automerge.init(), doc => { + assert(UUID_PATTERN.test(Automerge.getActorId(doc).toString())) + assert.notEqual(Automerge.getActorId(doc), '_root') + assert.strictEqual(Automerge.getActorId(Automerge.init('01234567')), '01234567') + }) + }) + + it('should expose keys as object properties', () => { + Automerge.change(Automerge.init(), doc => { + doc.set('key1', 'value1') + assert.strictEqual(doc.get('key1'), 'value1') + }) + }) + + it('should return undefined for unknown properties', () => { + Automerge.change(Automerge.init(), doc => { + assert.strictEqual(doc.get('someProperty'), undefined) + }) + }) + + it('should support ownKeys()', () => { + Automerge.change(Automerge.init(), doc => { + assert.deepStrictEqual(doc.ownKeys(), []) + doc.set('key1', 'value1') + assert.deepStrictEqual(doc.ownKeys(), ['key1']) + doc.set('key2', 'value2') + assertEqualsOneOf(doc.ownKeys(), ['key1', 'key2'], ['key2', 'key1']) + }) + }) + + it('should support .getOwnPropertyNames()', () => { + Automerge.change(Automerge.init(), doc => { + assert.deepStrictEqual(doc.getOwnPropertyNames(), []) + doc.set('key1', 'value1') + assert.deepStrictEqual(doc.getOwnPropertyNames(), ['key1']) + doc.set('key2', 'value2') + assertEqualsOneOf(doc.getOwnPropertyNames(), ['key1', 'key2'], ['key2', 'key1']) + }) + }) + + it('should support bulk assignment with assign()', () => { + Automerge.change(Automerge.init(), doc => { + doc.assign({key1: 'value1', key2: 'value2'}) + assert.strictEqual(doc.get('key1'), 'value1') + assert.strictEqual(doc.get('key2'), 'value2') + }) + }) + + it('should support JSON.stringify()', () => { + Automerge.change(Automerge.init(), doc => { + assert.deepStrictEqual(JSON.stringify(doc), '{}') + doc.set('key1', 'value1') + assert.deepStrictEqual(JSON.stringify(doc), '{"key1":"value1"}') + doc.set('key2', 'value2') + assert.deepStrictEqual(JSON.parse(JSON.stringify(doc)), { + key1: 'value1', key2: 'value2' + }) + }) + }) + + it('should allow access to an object by id', () => { + const doc = Automerge.change(Automerge.init(), doc => { + doc.set('deepObj', {}) + let a = doc.get('deepObj') + a.set('deepList', {}) + + const listId = Automerge.getObjectId(doc.get('deepObj').get('deepList')) + assert.throws(() => { Automerge.getObjectById(doc, listId) }, /Cannot use getObjectById in a change callback/) + }) + + const objId = Automerge.getObjectId(doc.deepObj) + assert.strictEqual(Automerge.getObjectById(doc, objId), doc.deepObj) + const listId = Automerge.getObjectId(doc.deepObj.deepList) + assert.strictEqual(Automerge.getObjectById(doc, listId), doc.deepObj.deepList) + }) + + it('should support iteration', () => { + Automerge.change(Automerge.init(), doc => { + doc.set('key1', 'value1') + doc.set('key2', 'value2') + doc.set('key3', 'value3') + let copy = {} + for (const [key, value] of doc) copy[key] = value + assert.deepStrictEqual(copy, {key1: 'value1', key2: 'value2', key3: 'value3'}) + + // spread operator also uses iteration protocol + assert.deepStrictEqual([['key0', 'value0'], ...doc, ['key4', 'value4']], [['key0', 'value0'], ['key1', 'value1'], ['key2', 'value2'], ['key3', 'value3'], ['key4', 'value4']]) + }) + }) + }) + + describe('list object', () => { + let root + beforeEach(() => { + root = Automerge.change(Automerge.init(), doc => { + doc.set('list', [1, 2, 3]) + doc.set('empty', []) + doc.set('listObjects', [ {id: "first"}, {id: "second"} ]) + }) + }) + + it('should look like a JavaScript array', () => { + Automerge.change(root, doc => { + assert.strictEqual((doc.get('list').isArray()), true) + assert.strictEqual(typeof doc.get('list'), 'object') + }) + }) + + it('should have a length property', () => { + Automerge.change(root, doc => { + assert.strictEqual(doc.get('empty').length(), 0) + assert.strictEqual(doc.get('list').length(), 3) + }) + }) + + it('should allow entries to be fetched by index', () => { + Automerge.change(root, doc => { + assert.strictEqual(doc.get('list').get(0), 1) + assert.strictEqual(doc.get('list').get('0'), 1) + assert.strictEqual(doc.get('list').get(1), 2) + assert.strictEqual(doc.get('list').get('1'), 2) + assert.strictEqual(doc.get('list').get(2), 3) + assert.strictEqual(doc.get('list').get('2'), 3) + assert.strictEqual(doc.get('list').get(3), undefined) + assert.strictEqual(doc.get('list').get('3'), undefined) + assert.strictEqual(doc.get('list').get(-1), undefined) + assert.strictEqual(doc.get('list').get('someProperty'), undefined) + }) + }) + + it('should support .has()', () => { + Automerge.change(root, doc => { + assert.strictEqual(doc.get('list').has(0), true) + assert.strictEqual(doc.get('list').has('0'), true) + assert.strictEqual(doc.get('list').has(3), false) + assert.strictEqual(doc.get('list').has('3'), false) + assert.strictEqual(doc.get('list').has('length'), true) + assert.strictEqual(doc.get('list').has('someProperty'), false) + }) + }) + + it('should support .objectKeys()', () => { + Automerge.change(root, doc => { + assert.deepStrictEqual(doc.get('list').objectKeys(), ['0', '1', '2']) + }) + }) + + it('should support .getOwnPropertyNames()', () => { + Automerge.change(root, doc => { + assert.deepStrictEqual(doc.get('list').getOwnPropertyNames(), ['length', '0', '1', '2']) + }) + }) + + it('should support JSON.stringify()', () => { + Automerge.change(root, doc => { + assert.deepStrictEqual(JSON.parse(JSON.stringify(doc)), { + list: [1, 2, 3], empty: [], listObjects: [ {id: "first"}, {id: "second"} ] + }) + assert.deepStrictEqual(JSON.stringify(doc.get('list')), '[1,2,3]') + }) + }) + + it('should support iteration', () => { + Automerge.change(root, doc => { + let copy = [] + for (let x of doc.get('list')) copy.push(x) + assert.deepStrictEqual(copy, [1, 2, 3]) + + // spread operator also uses iteration protocol + assert.deepStrictEqual([0, ...doc.get('list'), 4], [0, 1, 2, 3, 4]) + }) + }) + + describe('should support standard array read-only operations', () => { + it('concat()', () => { + Automerge.change(root, doc => { + assert.deepStrictEqual(doc.get('list').concat([4, 5, 6]), [1, 2, 3, 4, 5, 6]) + assert.deepStrictEqual(doc.get('list').concat([4], [5, [6]]), [1, 2, 3, 4, 5, [6]]) + }) + }) + + it('entries()', () => { + Automerge.change(root, doc => { + let copy = [] + for (let x of doc.get('list').entries()) copy.push(x) + assert.deepStrictEqual(copy, [[0, 1], [1, 2], [2, 3]]) + assert.deepStrictEqual([...doc.get('list').entries()], [[0, 1], [1, 2], [2, 3]]) + }) + }) + + it('every()', () => { + Automerge.change(root, doc => { + assert.strictEqual(doc.get('empty').every(() => false), true) + assert.strictEqual(doc.get('list').every(val => val > 0), true) + assert.strictEqual(doc.get('list').every(val => val > 2), false) + assert.strictEqual(doc.get('list').every((val, index) => index < 3), true) + // check that in the callback, 'this' is set to the second argument of 'every' + doc.get('list').every(function () { assert.strictEqual(this.hello, 'world'); return true }, {hello: 'world'}) + }) + }) + + it('filter()', () => { + Automerge.change(root, doc => { + assert.deepStrictEqual(doc.get('empty').filter(() => false), []) + assert.deepStrictEqual(doc.get('list').filter(num => num % 2 === 1), [1, 3]) + assert.deepStrictEqual(doc.get('list').filter(() => true), [1, 2, 3]) + doc.get('list').filter(function () { assert.strictEqual(this.hello, 'world'); return true }, {hello: 'world'}) + }) + }) + + it('find()', () => { + Automerge.change(root, doc => { + assert.strictEqual(doc.get('empty').find(() => true), undefined) + assert.strictEqual(doc.get('list').find(num => num >= 2), 2) + assert.strictEqual(doc.get('list').find(num => num >= 4), undefined) + doc.get('list').find(function () { assert.strictEqual(this.hello, 'world'); return true }, {hello: 'world'}) + }) + }) + + it('findIndex()', () => { + Automerge.change(root, doc => { + assert.strictEqual(doc.get('empty').findIndex(() => true), -1) + assert.strictEqual(doc.get('list').findIndex(num => num >= 2), 1) + assert.strictEqual(doc.get('list').findIndex(num => num >= 4), -1) + doc.get('list').findIndex(function () { assert.strictEqual(this.hello, 'world'); return true }, {hello: 'world'}) + }) + }) + + it('forEach()', () => { + Automerge.change(root, doc => { + doc.get('empty').forEach(() => { assert.fail('was called', 'not called', 'callback error') }) + let binary = [] + doc.get('list').forEach(num => binary.push(num.toString(2))) + assert.deepStrictEqual(binary, ['1', '10', '11']) + doc.get('list').forEach(function () { assert.strictEqual(this.hello, 'world'); return true }, {hello: 'world'}) + }) + }) + + it('includes()', () => { + Automerge.change(root, doc => { + assert.strictEqual(doc.get('empty').includes(3), false) + assert.strictEqual(doc.get('list').includes(3), true) + assert.strictEqual(doc.get('list').includes(1, 1), false) + assert.strictEqual(doc.get('list').includes(2, -2), true) + assert.strictEqual(doc.get('list').includes(0), false) + }) + }) + + it('indexOf()', () => { + Automerge.change(root, doc => { + assert.strictEqual(doc.get('empty').indexOf(3), -1) + assert.strictEqual(doc.get('list').indexOf(3), 2) + assert.strictEqual(doc.get('list').indexOf(1, 1), -1) + assert.strictEqual(doc.get('list').indexOf(2, -2), 1) + assert.strictEqual(doc.get('list').indexOf(0), -1) + }) + }) + + it('indexOf() with objects', () => { + Automerge.change(root, doc => { + assert.strictEqual(doc.get('listObjects').indexOf(doc.get('listObjects').get(0)), 0) + assert.strictEqual(doc.get('listObjects').indexOf(doc.get('listObjects').get(1)), 1) + + assert.strictEqual(doc.get('listObjects').indexOf(doc.get('listObjects').get(0), 0), 0) + assert.strictEqual(doc.get('listObjects').indexOf(doc.get('listObjects').get(0), 1), -1) + assert.strictEqual(doc.get('listObjects').indexOf(doc.get('listObjects').get(1), 0), 1) + assert.strictEqual(doc.get('listObjects').indexOf(doc.get('listObjects').get(1), 1), 1) + }) + }) + + it('join()', () => { + Automerge.change(root, doc => { + assert.strictEqual(doc.get('empty').join(', '), '') + assert.strictEqual(doc.get('list').join(), '1,2,3') + assert.strictEqual(doc.get('list').join(''), '123') + assert.strictEqual(doc.get('list').join(', '), '1, 2, 3') + }) + }) + + it('keys()', () => { + Automerge.change(root, doc => { + let keys = [] + for (let x of doc.get('list').keys()) keys.push(x) + assert.deepStrictEqual(keys, [0, 1, 2]) + assert.deepStrictEqual([...doc.get('list').keys()], [0, 1, 2]) + }) + }) + + it('lastIndexOf()', () => { + Automerge.change(root, doc => { + assert.strictEqual(doc.get('empty').lastIndexOf(3), -1) + assert.strictEqual(doc.get('list').lastIndexOf(3), 2) + assert.strictEqual(doc.get('list').lastIndexOf(3, 1), -1) + assert.strictEqual(doc.get('list').lastIndexOf(3, -1), 2) + assert.strictEqual(doc.get('list').lastIndexOf(0), -1) + }) + }) + + it('map()', () => { + Automerge.change(root, doc => { + assert.deepStrictEqual(doc.get('empty').map(num => num * 2), []) + assert.deepStrictEqual(doc.get('list').map(num => num * 2), [2, 4, 6]) + assert.deepStrictEqual(doc.get('list').map((num, index) => index + '->' + num), ['0->1', '1->2', '2->3']) + doc.get('list').map(function () { assert.strictEqual(this.hello, 'world'); return true }, {hello: 'world'}) + }) + }) + + it('reduce()', () => { + Automerge.change(root, doc => { + assert.strictEqual(doc.get('empty').reduce((sum, val) => sum + val, 0), 0) + assert.strictEqual(doc.get('list').reduce((sum, val) => sum + val, 0), 6) + assert.strictEqual(doc.get('list').reduce((sum, val) => sum + val, ''), '123') + assert.strictEqual(doc.get('list').reduce((sum, val) => sum + val), 6) + assert.strictEqual(doc.get('list').reduce((sum, val, index) => ((index % 2 === 0) ? (sum + val) : sum), 0), 4) + }) + }) + + it('reduceRight()', () => { + Automerge.change(root, doc => { + assert.strictEqual(doc.get('empty').reduceRight((sum, val) => sum + val, 0), 0) + assert.strictEqual(doc.get('list').reduceRight((sum, val) => sum + val, 0), 6) + assert.strictEqual(doc.get('list').reduceRight((sum, val) => sum + val, ''), '321') + assert.strictEqual(doc.get('list').reduceRight((sum, val) => sum + val), 6) + assert.strictEqual(doc.get('list').reduceRight((sum, val, index) => ((index % 2 === 0) ? (sum + val) : sum), 0), 4) + }) + }) + + it('slice()', () => { + Automerge.change(root, doc => { + assert.deepStrictEqual(doc.get('empty').slice(), []) + assert.deepStrictEqual(doc.get('list').slice(2), [3]) + assert.deepStrictEqual(doc.get('list').slice(-2), [2, 3]) + assert.deepStrictEqual(doc.get('list').slice(0, 0), []) + assert.deepStrictEqual(doc.get('list').slice(0, 1), [1]) + assert.deepStrictEqual(doc.get('list').slice(0, -1), [1, 2]) + }) + }) + + it('some()', () => { + Automerge.change(root, doc => { + assert.strictEqual(doc.get('empty').some(() => true), false) + assert.strictEqual(doc.get('list').some(val => val > 2), true) + assert.strictEqual(doc.get('list').some(val => val > 4), false) + assert.strictEqual(doc.get('list').some((val, index) => index > 2), false) + doc.get('list').some(function () { assert.strictEqual(this.hello, 'world'); return true }, {hello: 'world'}) + }) + }) + + it('toString()', () => { + Automerge.change(root, doc => { + assert.strictEqual(doc.get('empty').toString(), '') + assert.strictEqual(doc.get('list').toString(), '1,2,3') + }) + }) + + it('values()', () => { + Automerge.change(root, doc => { + let values = [] + for (let x of doc.get('list').values()) values.push(x) + assert.deepStrictEqual(values, [1, 2, 3]) + assert.deepStrictEqual([...doc.get('list').values()], [1, 2, 3]) + }) + }) + + it('should allow mutation of objects returned from built in list iteration', () => { + root = Automerge.change(Automerge.init({freeze: true}), doc => { + doc.set('objects', [{id: 1, value: 'one'}, {id: 2, value: 'two'}]) + }) + root = Automerge.change(root, doc => { + for (let obj of doc.get('objects')) if (obj.get('id') === 1) obj.set('value', 'ONE!') + }) + assert.deepStrictEqual(root, {objects: [{id: 1, value: 'ONE!'}, {id: 2, value: 'two'}]}) + }) + + it('should allow mutation of objects returned from readonly list methods', () => { + root = Automerge.change(Automerge.init({freeze: true}), doc => { + doc.set('objects', [{id: 1, value: 'one'}, {id: 2, value: 'two'}]) + }) + root = Automerge.change(root, doc => { + doc.get('objects').find(obj => obj.get('id') === 1).set('value', 'ONE!') + }) + assert.deepStrictEqual(root, {objects: [{id: 1, value: 'ONE!'}, {id: 2, value: 'two'}]}) + }) + }) + + describe('should support standard mutation methods', () => { + it('fill()', () => { + root = Automerge.change(root, doc => doc.get('list').fill('a')) + assert.deepStrictEqual(root.list, ['a', 'a', 'a']) + root = Automerge.change(root, doc => doc.get('list').fill('c', 1).fill('b', 1, 2)) + assert.deepStrictEqual(root.list, ['a', 'b', 'c']) + }) + + it('pop()', () => { + root = Automerge.change(root, doc => assert.strictEqual(doc.get('list').pop(), 3)) + assert.deepStrictEqual(root.list, [1, 2]) + root = Automerge.change(root, doc => assert.strictEqual(doc.get('list').pop(), 2)) + assert.deepStrictEqual(root.list, [1]) + root = Automerge.change(root, doc => assert.strictEqual(doc.get('list').pop(), 1)) + assert.deepStrictEqual(root.list, []) + root = Automerge.change(root, doc => assert.strictEqual(doc.get('list').pop(), undefined)) + assert.deepStrictEqual(root.list, []) + }) + + it('push()', () => { + root = Automerge.change(root, doc => doc.set('noodles', [])) + root = Automerge.change(root, doc => doc.get('noodles').push('udon', 'soba')) + root = Automerge.change(root, doc => doc.get('noodles').push('ramen')) + assert.deepStrictEqual(root.noodles, ['udon', 'soba', 'ramen']) + assert.strictEqual(root.noodles[0], 'udon') + assert.strictEqual(root.noodles[1], 'soba') + assert.strictEqual(root.noodles[2], 'ramen') + assert.strictEqual(root.noodles.length, 3) + }) + + it('shift()', () => { + root = Automerge.change(root, doc => assert.strictEqual(doc.get('list').shift(), 1)) + assert.deepStrictEqual(root.list, [2, 3]) + root = Automerge.change(root, doc => assert.strictEqual(doc.get('list').shift(), 2)) + assert.deepStrictEqual(root.list, [3]) + root = Automerge.change(root, doc => assert.strictEqual(doc.get('list').shift(), 3)) + assert.deepStrictEqual(root.list, []) + root = Automerge.change(root, doc => assert.strictEqual(doc.get('list').shift(), undefined)) + assert.deepStrictEqual(root.list, []) + }) + + it('splice()', () => { + root = Automerge.change(root, doc => assert.deepStrictEqual(doc.get('list').splice(1), [2, 3])) + assert.deepStrictEqual(root.list, [1]) + root = Automerge.change(root, doc => assert.deepStrictEqual(doc.get('list').splice(0, 0, 'a', 'b', 'c'), [])) + assert.deepStrictEqual(root.list, ['a', 'b', 'c', 1]) + root = Automerge.change(root, doc => assert.deepStrictEqual(doc.get('list').splice(1, 2, '-->'), ['b', 'c'])) + assert.deepStrictEqual(root.list, ['a', '-->', 1]) + root = Automerge.change(root, doc => assert.deepStrictEqual(doc.get('list').splice(2, 200, 2), [1])) + assert.deepStrictEqual(root.list, ['a', '-->', 2]) + }) + + it('unshift()', () => { + root = Automerge.change(root, doc => doc.set('noodles', [])) + root = Automerge.change(root, doc => doc.get('noodles').unshift('soba', 'udon')) + root = Automerge.change(root, doc => doc.get('noodles').unshift('ramen')) + assert.deepStrictEqual(root.noodles, ['ramen', 'soba', 'udon']) + assert.strictEqual(root.noodles[0], 'ramen') + assert.strictEqual(root.noodles[1], 'soba') + assert.strictEqual(root.noodles[2], 'udon') + assert.strictEqual(root.noodles.length, 3) + }) + }) + }) +}) diff --git a/test/test.js b/test/test.js index 201443155..c8eeeed18 100644 --- a/test/test.js +++ b/test/test.js @@ -4,6 +4,7 @@ const { assertEqualsOneOf } = require('./helpers') const { decodeChange } = require('../backend/columnar') const UUID_PATTERN = /^[0-9a-f]{32}$/ const OPID_PATTERN = /^[0-9]+@[0-9a-f]{32}$/ +const { setProxyFree } = require('../frontend/proxies') describe('Automerge', () => { @@ -59,6 +60,28 @@ describe('Automerge', () => { }) }) + describe('initialization with facebook syntax', () => { + after(() => { + setProxyFree(false) + }) + + it('should be an empty object by default', () => { + Automerge.useProxyFreeAPI() + const doc = Automerge.init() + assert.deepStrictEqual(doc, {}) + }) + + it('should support .get and .set', () => { + Automerge.useProxyFreeAPI() + const doc = Automerge.init() + Automerge.change(doc, doc => { + assert.deepStrictEqual(doc.get('key'), undefined) + doc.set('key', 'value') + assert.deepStrictEqual(doc.get('key'), 'value') + }) + }) + }) + describe('sequential use', () => { let s1, s2 beforeEach(() => {