diff --git a/packages/core/__tests__/notes.test.js b/packages/core/__tests__/notes.test.js index 9438dcf67..5ca5a787a 100644 --- a/packages/core/__tests__/notes.test.js +++ b/packages/core/__tests__/notes.test.js @@ -37,7 +37,7 @@ test("get delta of note", () => test("delete note", () => noteTest().then(async ({ db, id }) => { - let { id: notebookId } = await db.notebooks.add(TEST_NOTEBOOK); + let notebookId = await db.notebooks.add(TEST_NOTEBOOK); let topics = db.notebooks.notebook(notebookId).topics; let topic = topics.topic("General"); await topic.add(id); @@ -160,7 +160,7 @@ test("favorite note", () => test("add note to topic", () => noteTest().then(async ({ db, id }) => { - let { id: notebookId } = await db.notebooks.add({ title: "Hello" }); + let notebookId = await db.notebooks.add({ title: "Hello" }); let topics = db.notebooks.notebook(notebookId).topics; await topics.add("Home"); let topic = topics.topic("Home"); @@ -175,7 +175,7 @@ test("add note to topic", () => test("duplicate note to topic should not be added", () => noteTest().then(async ({ db, id }) => { - let { id: notebookId } = await db.notebooks.add({ title: "Hello" }); + let notebookId = await db.notebooks.add({ title: "Hello" }); let topics = db.notebooks.notebook(notebookId).topics; await topics.add("Home"); let topic = topics.topic("Home"); @@ -186,14 +186,14 @@ test("duplicate note to topic should not be added", () => test("move note", (done) => noteTest().then(async ({ db, id }) => { - let { id: notebookId } = await db.notebooks.add({ title: "Hello" }); + let notebookId = await db.notebooks.add({ title: "Hello" }); let topics = db.notebooks.notebook(notebookId).topics; await topics.add("Home"); let topic = topics.topic("Home"); await topic.add(id); setTimeout(async () => { try { - let { id: notebookId2 } = await db.notebooks.add({ title: "Hello2" }); + let notebookId2 = await db.notebooks.add({ title: "Hello2" }); await db.notebooks.notebook(notebookId2).topics.add("Home2"); await db.notes.move({ id: notebookId2, topic: "Home2" }, id); let note = db.notes.note(id); @@ -207,7 +207,7 @@ test("move note", (done) => test("moving note to same notebook and topic should do nothing", () => noteTest().then(async ({ db, id }) => { - const { id: notebookId } = await db.notebooks.add({ title: "Hello" }); + const notebookId = await db.notebooks.add({ title: "Hello" }); let topics = db.notebooks.notebook(notebookId).topics; await topics.add("Home"); let topic = topics.topic("Home"); diff --git a/packages/core/__tests__/trash.test.js b/packages/core/__tests__/trash.test.js index 7c2763072..86942d706 100644 --- a/packages/core/__tests__/trash.test.js +++ b/packages/core/__tests__/trash.test.js @@ -3,7 +3,7 @@ import { noteTest, notebookTest, TEST_NOTE, - TEST_NOTEBOOK + TEST_NOTEBOOK, } from "./utils"; beforeEach(() => StorageInterface.clear()); @@ -22,11 +22,8 @@ test("permanently delete a note", () => test("restore a deleted note", () => noteTest().then(async ({ db, id }) => { - let { id: nbId } = await db.notebooks.add(TEST_NOTEBOOK); - await db.notebooks - .notebook(nbId) - .topics.topic("General") - .add(id); + let nbId = await db.notebooks.add(TEST_NOTEBOOK); + await db.notebooks.notebook(nbId).topics.topic("General").add(id); await db.notes.delete(id); await db.trash.restore(db.trash.all[0].id); expect(db.trash.all.length).toBe(0); @@ -34,12 +31,9 @@ test("restore a deleted note", () => expect(note).toBeDefined(); expect(await note.text()).toBe(TEST_NOTE.content.text); expect(await note.delta()).toStrictEqual(TEST_NOTE.content.delta); - expect( - db.notebooks - .notebook(nbId) - .topics.topic("General") - .has(id) - ).toBe(true); + expect(db.notebooks.notebook(nbId).topics.topic("General").has(id)).toBe( + true + ); expect(db.notes.note(id).notebook.id).toBe(nbId); expect(db.notes.note(id).notebook.topic).toBe("General"); })); @@ -70,15 +64,12 @@ test("restore a deleted locked note", () => test("restore a deleted note that's in a deleted notebook", () => noteTest().then(async ({ db, id }) => { - let { id: nbId } = await db.notebooks.add(TEST_NOTEBOOK); - await db.notebooks - .notebook(nbId) - .topics.topic("General") - .add(id); + let nbId = await db.notebooks.add(TEST_NOTEBOOK); + await db.notebooks.notebook(nbId).topics.topic("General").add(id); await db.notes.delete(id); await db.notebooks.delete(nbId); const deletedNote = db.trash.all.find( - v => v.itemId.includes(id) && v.type === "note" + (v) => v.itemId.includes(id) && v.type === "note" ); await db.trash.restore(deletedNote.id); let note = db.notes.note(id); @@ -89,10 +80,7 @@ test("restore a deleted note that's in a deleted notebook", () => test("delete a notebook", () => notebookTest().then(async ({ db, id }) => { let noteId = await db.notes.add(TEST_NOTE); - await db.notebooks - .notebook(id) - .topics.topic("General") - .add(noteId); + await db.notebooks.notebook(id).topics.topic("General").add(noteId); await db.notebooks.delete(id); expect(db.notebooks.notebook(id).data.deleted).toBe(true); expect(db.notes.note(noteId).notebook).toStrictEqual({}); @@ -101,10 +89,7 @@ test("delete a notebook", () => test("restore a deleted notebook", () => notebookTest().then(async ({ db, id }) => { let noteId = await db.notes.add(TEST_NOTE); - await db.notebooks - .notebook(id) - .topics.topic("General") - .add(noteId); + await db.notebooks.notebook(id).topics.topic("General").add(noteId); await db.notebooks.delete(id); await db.trash.restore(db.trash.all[0].id); let notebook = db.notebooks.notebook(id); @@ -116,14 +101,11 @@ test("restore a deleted notebook", () => test("restore a notebook that has deleted notes", () => notebookTest().then(async ({ db, id }) => { let noteId = await db.notes.add(TEST_NOTE); - await db.notebooks - .notebook(id) - .topics.topic("General") - .add(noteId); + await db.notebooks.notebook(id).topics.topic("General").add(noteId); await db.notebooks.delete(id); await db.notes.delete(noteId); const deletedNotebook = db.trash.all.find( - v => v.itemId.includes(id) && v.type === "notebook" + (v) => v.itemId.includes(id) && v.type === "notebook" ); await db.trash.restore(deletedNotebook.id); let notebook = db.notebooks.notebook(id); diff --git a/packages/core/__tests__/utils/index.js b/packages/core/__tests__/utils/index.js index 8cf843694..5f91d05e3 100644 --- a/packages/core/__tests__/utils/index.js +++ b/packages/core/__tests__/utils/index.js @@ -5,13 +5,13 @@ import { getLastWeekTimestamp } from "../../utils/date"; const TEST_NOTEBOOK = { title: "Test Notebook", description: "Test Description", - topics: ["hello", "hello", " "] + topics: ["hello", "hello", " "], }; const TEST_NOTEBOOK2 = { title: "Test Notebook 2", description: "Test Description 2", - topics: ["Home2"] + topics: ["Home2"], }; function databaseTest() { @@ -20,23 +20,23 @@ function databaseTest() { } const notebookTest = (notebook = TEST_NOTEBOOK) => - databaseTest().then(async db => { - let nb = await db.notebooks.add(notebook); - return { db, id: nb ? nb.id : undefined }; + databaseTest().then(async (db) => { + let id = await db.notebooks.add(notebook); + return { db, id }; }); var TEST_NOTE = { content: { delta: { ops: [{ type: "insert", text: "I am a delta" }] }, - text: "I am a text" - } + text: "I am a text", + }, }; const LONG_TEXT = "Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries, but also the leap into electronic typesetting, remaining essentially unchanged. It was popularised in the 1960s with the release of Letraset sheets containing Lorem Ipsum passages, and more recently with desktop publishing software like Aldus PageMaker including versions of Lorem Ipsum."; const noteTest = (note = TEST_NOTE) => - databaseTest().then(async db => { + databaseTest().then(async (db) => { let id = await db.notes.add(note); return { db, id }; }); @@ -47,12 +47,12 @@ const groupedTest = (type, special = false) => await db.notes.add({ ...TEST_NOTE, title: "Some title", - dateCreated: getLastWeekTimestamp() - 604800000 + dateCreated: getLastWeekTimestamp() - 604800000, }); await db.notes.add({ ...TEST_NOTE, title: "Some title and title title", - dateCreated: getLastWeekTimestamp() - 604800000 * 2 + dateCreated: getLastWeekTimestamp() - 604800000 * 2, }); let grouped = db.notes.group(type, special); if (special) { @@ -77,5 +77,5 @@ export { TEST_NOTEBOOK, TEST_NOTEBOOK2, TEST_NOTE, - LONG_TEXT + LONG_TEXT, }; diff --git a/packages/core/api/sync.js b/packages/core/api/sync.js deleted file mode 100644 index 2d1417c2d..000000000 --- a/packages/core/api/sync.js +++ /dev/null @@ -1,252 +0,0 @@ -/** - * GENERAL PROCESS: - * make a get request to server with current lastSyncedTimestamp - * parse the response. the response should contain everything that user has on the server - * decrypt the response - * merge everything into the database and look for conflicts - * send the conflicts (if any) to the end-user for resolution - * once the conflicts have been resolved, send the updated data back to the server - */ - -/** - * MERGING: - * Locally, get everything that was editted/added after the lastSyncedTimestamp - * Run forEach loop on the server response. - * Add items that do not exist in the local collections - * Remove items (without asking) that need to be removed - * Update items that were editted before the lastSyncedTimestamp - * Try to merge items that were edited after the lastSyncedTimestamp - * Items in which the content has changed, send them for conflict resolution - * Otherwise, keep the most recently updated copy. - */ - -/** - * CONFLICTS: - * Syncing should pause until all the conflicts have been resolved - * And then it should continue. - */ -import Database from "./index"; -import { HOST, HEADERS } from "../utils/constants"; -var tfun = require("transfun/transfun.js").tfun; -if (!tfun) { - tfun = global.tfun; -} - -export default class Sync { - /** - * - * @param {Database} db - */ - constructor(db) { - this.db = db; - } - - async _fetch(lastSyncedTimestamp) { - let token = await this.db.user.token(); - if (!token) throw new Error("You are not logged in"); - let response = await fetch(`${HOST}sync?lst=${lastSyncedTimestamp}`, { - headers: { ...HEADERS, Authorization: `Bearer ${token}` }, - }); - //TODO decrypt the response. - return await response.json(); - } - - async throwOnConflicts() { - let hasConflicts = await this.db.context.read("hasConflicts"); - if (hasConflicts) { - const mergeConflictError = new Error( - "Merge conflicts detected. Please resolve all conflicts to continue syncing." - ); - mergeConflictError.code = "MERGE_CONFLICT"; - throw mergeConflictError; - } - } - - async start() { - let user = await this.db.user.get(); - if (!user) throw new Error("You need to login to sync."); - - await this.db.conflicts.recalculate(); - await this.throwOnConflicts(); - - let lastSyncedTimestamp = user.lastSynced || 0; - let serverResponse = await this._fetch(lastSyncedTimestamp); - - // we prepare local data before merging so we always have correct data - const prepare = new Prepare(this.db, user); - const data = await prepare.get(lastSyncedTimestamp); - - // merge the server response - const merger = new Merger(this.db, lastSyncedTimestamp); - const mergeResult = await merger.merge(serverResponse); - await this.throwOnConflicts(); - // send the data back to server - await this._send(data); - - // update our lastSynced time - if (mergeResult || !areAllEmpty(data)) - await this.db.user.set({ lastSynced: data.lastSynced }); - } - - async _send(data) { - //TODO encrypt the payload - let token = await this.db.user.token(); - if (!token) return; - let response = await fetch(`${HOST}sync`, { - method: "POST", - headers: { ...HEADERS, Authorization: `Bearer ${token}` }, - body: JSON.stringify(data), - }); - return response.ok; - } -} - -class Merger { - /** - * - * @param {Database} db - */ - constructor(db, lastSynced) { - this._db = db; - this._lastSynced = lastSynced; - } - - async _mergeItem(remoteItem, get, add) { - let localItem = await get(remoteItem.id); - remoteItem = { ...JSON.parse(remoteItem.data), remote: true }; - if (!localItem || remoteItem.dateEdited > localItem.dateEdited) { - await add(remoteItem); - } - } - - async _mergeArray(array, get, set) { - return Promise.all( - array.map(async (item) => await this._mergeItem(item, get, set)) - ); - } - - async _mergeItemWithConflicts(remoteItem, get, add, resolve) { - let localItem = await get(remoteItem.id); - if (!localItem) { - await add({ ...JSON.parse(remoteItem.data), remote: true }); - } else if (localItem.resolved) { - await add({ ...localItem, resolved: false }); - } else if (localItem.dateEdited > this._lastSynced) { - // we have a conflict - await resolve(localItem, JSON.parse(remoteItem.data)); - } - } - - async _mergeArrayWithConflicts(array, get, set, resolve) { - return Promise.all( - array.map( - async (item) => - await this._mergeItemWithConflicts(item, get, set, resolve) - ) - ); - } - - async merge(serverResponse) { - const { - notes, - synced, - notebooks, - delta, - text, - tags, - colors, - trash, - } = serverResponse; - - if (synced || areAllEmpty(serverResponse)) return false; - - await this._mergeArray( - notes, - (id) => this._db.notes.note(id), - (item) => this._db.notes.add(item) - ); - await this._mergeArray( - notebooks, - (id) => this._db.notebooks.notebook(id), - (item) => this._db.notebooks.add(item) - ); - - await this._mergeArrayWithConflicts( - delta, - (id) => this._db.delta.raw(id), - (item) => this._db.delta.add(item), - async (local, remote) => { - await this._db.delta.add({ ...local, conflicted: remote }); - await this._db.notes.add({ id: local.noteId, conflicted: true }); - await this._db.context.write("hasConflicts", true); - } - ); - - await this._mergeArray( - text, - (id) => this._db.text.raw(id), - (item) => this._db.text.add(item) - ); - - await this._mergeArray( - tags, - (id) => this._db.tags.tag(id), - (item) => this._db.tags.merge(item) - ); - - await this._mergeArray( - colors, - (id) => this._db.colors.tag(id), - (item) => this._db.colors.merge(item) - ); - - await this._mergeArray( - trash, - () => undefined, - (item) => this._db.trash.add(item) - ); - - return true; - } -} - -class Prepare { - /** - * - * @param {Database} db - * @param {Object} user - * @param {Number} lastSyncedTimestamp - */ - constructor(db, user) { - this._db = db; - this._user = user; - } - - async get(lastSyncedTimestamp) { - this._lastSyncedTimestamp = lastSyncedTimestamp; - return { - notes: this._prepareForServer(this._db.notes.raw), - notebooks: this._prepareForServer(this._db.notebooks.raw), - delta: this._prepareForServer(await this._db.delta.all()), - text: this._prepareForServer(await this._db.text.all()), - tags: this._prepareForServer(this._db.tags.raw), - colors: this._prepareForServer(this._db.colors.raw), - trash: this._prepareForServer(this._db.trash.raw), - lastSynced: Date.now(), - }; - } - - _prepareForServer(array) { - return tfun - .filter((item) => item.dateEdited > this._lastSyncedTimestamp) - .map((item) => ({ - id: item.id, - data: JSON.stringify(item), - }))(array); - } -} - -function areAllEmpty(obj) { - const arrays = Object.values(obj).filter((v) => v.length !== undefined); - return arrays.every((array) => array.length === 0); -} diff --git a/packages/core/api/sync/__tests__/merger.test.js b/packages/core/api/sync/__tests__/merger.test.js new file mode 100644 index 000000000..5536eb214 --- /dev/null +++ b/packages/core/api/sync/__tests__/merger.test.js @@ -0,0 +1,123 @@ +import Merger from "../merger"; +import { + StorageInterface, + databaseTest, + noteTest, + TEST_NOTE, + TEST_NOTEBOOK, +} from "../../../__tests__/utils"; +import { tagsCollectionParams, mainCollectionParams } from "./utils"; + +const emptyServerResponse = { + notes: [], + notebooks: [], + delta: [], + text: [], + tags: [], + colors: [], + trash: [], +}; + +const testItem = { id: "someId", dateEdited: 2 }; + +beforeEach(() => { + StorageInterface.clear(); +}); + +test("server response with all arrays empty should cause early return", async () => { + const merger = new Merger(); + const result = await merger.merge(emptyServerResponse); + expect(result).toBe(false); +}); + +test("null server response should return false", async () => { + const merger = new Merger(); + const result = await merger.merge(); + expect(result).toBe(false); +}); + +const tests = [ + mainCollectionParams("notes", "note", TEST_NOTE), + mainCollectionParams("notebooks", "notebook", TEST_NOTEBOOK), + tagsCollectionParams("tags", "someTag"), + tagsCollectionParams("colors", "red"), +]; + +describe.each(tests)( + "general %s syncing tests", + (collection, add, edit, get) => { + test(`merge ${collection} into empty database`, () => + databaseTest().then(async (db) => { + const merger = new Merger(db, 0); + const result = await merger.merge({ + [collection]: [{ id: testItem.id, data: JSON.stringify(testItem) }], + synced: false, + }); + expect(result).toBe(true); + expect(db[collection].all[0].id).toStrictEqual(testItem.id); + expect(db[collection].all[0].dateEdited).toStrictEqual( + testItem.dateEdited + ); + })); + + test(`merge local and remote ${collection}`, () => + databaseTest().then(async (db) => { + const merger = new Merger(db, 0); + const item = await add(db); + item.title = "Google"; + const result = await merger.merge({ + [collection]: [{ id: item.id, data: JSON.stringify(item) }], + synced: false, + }); + expect(result).toBe(true); + expect(db[collection].all.length).toBe(1); + expect(db[collection].all[0]).toStrictEqual(item); + })); + + test(`local ${collection} are more updated than remote ones`, () => + databaseTest().then(async (db) => { + const merger = new Merger(db, 0); + const item = await add(db); + await edit(db, item); + item.title = "Google"; + const result = await merger.merge({ + [collection]: [{ id: item.id, data: JSON.stringify(item) }], + synced: false, + }); + expect(result).toBe(true); + expect(db[collection].all.length).toBe(1); + expect(db[collection].all[0]).toStrictEqual(get(db, item)); + })); + } +); + +test("local delta updated after lastSyncedTimestamp should cause merge conflict", () => { + return noteTest().then(async ({ db, id }) => { + const content = { + text: "my name is abdullah", + delta: { ops: [{ insert: "my name is abdullah" }] }, + }; + const deltaId = db.notes.note(id).data.content.delta; + const merger = new Merger(db, 200); + const result = await merger.merge({ + delta: [ + { + id: deltaId, + data: JSON.stringify({ + id: deltaId, + noteId: id, + data: JSON.stringify(content.delta), + dateEdited: 2919, + conflicted: false, + resolved: false, + }), + }, + ], + }); + const localDelta = await db.delta.raw(deltaId); + expect(localDelta.conflicted.id).toBe(deltaId); + expect(localDelta.conflicted.noteId).toBe(id); + expect(result).toBe(true); + expect(await db.context.read("hasConflicts")).toBe(true); + }); +}); diff --git a/packages/core/api/sync/__tests__/prepare.test.js b/packages/core/api/sync/__tests__/prepare.test.js new file mode 100644 index 000000000..264dc2ee5 --- /dev/null +++ b/packages/core/api/sync/__tests__/prepare.test.js @@ -0,0 +1,91 @@ +import StorageInterface from "../../../__mocks__/storage.mock"; +import Prepare from "../prepare"; +import { + noteTest, + TEST_NOTE, + TEST_NOTEBOOK, + databaseTest, +} from "../../../__tests__/utils"; + +function getMainCollectionParams(name, testItem) { + return [ + name, + (db, collection) => db[collection].add(testItem), + (db, collection) => + db[collection].add({ + ...testItem, + id: Math.random().toString(), + remote: true, + dateEdited: 1, + }), + ]; +} + +function getTagsCollectionParams(name, testItem) { + return [ + name, + (db, collection) => db[collection].add(testItem + Math.random(), 2), + (db, collection) => + db[collection]._collection.addItem({ + title: testItem + MAX_ITEMS + 1, + noteIds: [2], + deletedIds: [], + id: Math.random().toString(), + remote: true, + dateEdited: 1, + }), + ]; +} + +const MAX_ITEMS = 5; + +const tests = [ + getMainCollectionParams("notes", TEST_NOTE), + getMainCollectionParams("notebooks", TEST_NOTEBOOK), + getTagsCollectionParams("tags", "someTag"), + getTagsCollectionParams("colors", "red"), + getMainCollectionParams("trash", { + id: 2141, + type: "note", + title: "someTitle", + }), + getMainCollectionParams("delta", { ops: [{ insert: "true" }] }), + getMainCollectionParams("text", "true"), +]; + +describe.each(tests)("%s preparation", (collection, add, addExtra) => { + beforeEach(() => { + StorageInterface.clear(); + }); + + test(`prepare ${collection} when user has never synced before`, () => { + return databaseTest().then(async (db) => { + await Promise.all( + Array(MAX_ITEMS) + .fill(0) + .map(() => add(db, collection)) + ); + const prepare = new Prepare(db); + const data = await prepare.get(0); + expect(data[collection].length).toBe(MAX_ITEMS); + expect(data[collection].every((item) => !!item.data)).toBeTruthy(); + expect(data.lastSynced).toBeGreaterThan(0); + }); + }); + + test(`prepare ${collection} when user has synced before`, () => { + return databaseTest().then(async (db) => { + await Promise.all( + Array(MAX_ITEMS) + .fill(0) + .map(() => add(db, collection)) + ); + await addExtra(db, collection); + const prepare = new Prepare(db); + const data = await prepare.get(10); + expect(data[collection].length).toBe(MAX_ITEMS); + expect(data[collection].every((item) => !!item.data)).toBeTruthy(); + expect(data.lastSynced).toBeGreaterThan(0); + }); + }); +}); diff --git a/packages/core/api/sync/__tests__/sync.test.js b/packages/core/api/sync/__tests__/sync.test.js new file mode 100644 index 000000000..01dea6d9a --- /dev/null +++ b/packages/core/api/sync/__tests__/sync.test.js @@ -0,0 +1,102 @@ +//import User from "../../models/user"; +import { enableFetchMocks, disableFetchMocks } from "jest-fetch-mock"; +import StorageInterface from "../../../__mocks__/storage.mock"; +//import Sync from "../sync"; +//import Prepare from "../prepare"; +import { databaseTest, TEST_NOTE } from "../../../__tests__/utils"; + +const SUCCESS_LOGIN_RESPONSE = { + access_token: "access_token", + refresh_token: "refresh_token", + payload: { + username: "thecodrr", + email: process.env.EMAIL, + lastSynced: 0, + }, + scopes: "sync", + expiry: 36000, +}; + +beforeAll(() => { + enableFetchMocks(); +}); + +beforeEach(() => { + fetch.resetMocks(); + StorageInterface.clear(); +}); + +test("syncing when user is not logged in should throw", () => + databaseTest().then((db) => { + expect(db.sync()).rejects.toThrow("You need to login to sync."); + })); + +test("sync without merge conflicts, cause merge conflicts, resolve them and then resync", () => { + return databaseTest().then(async (db) => { + // 1. login + fetchMock.mockResponseOnce(JSON.stringify(SUCCESS_LOGIN_RESPONSE)); + await db.user.login("username", "password"); + + // 2. create local note + const noteId = await db.notes.add(TEST_NOTE); + + // 3. start sync + fetchMock + .once(JSON.stringify({ notes: [], synced: false })) + .once(JSON.stringify({}), { status: 200 }); + await db.sync(); + + const user = await db.user.get(); + expect(user.lastSynced).toBeGreaterThan(0); + + /////// CAUSE MERGE CONFLICT! /////// + // 4. edit the note's content + await db.notes.add({ + id: noteId, + content: { text: "i am a text", delta: { ops: [{ insert: "text" }] } }, + }); + + // 5. sync again and expect conflicts + const deltaId = db.notes.note(noteId).data.content.delta; + const delta = { + id: deltaId, + data: JSON.stringify({ + id: deltaId, + dateEdited: Date.now(), + conflicted: false, + data: { ops: [{ insert: "text" }] }, + }), + }; + + fetchMock + .once(JSON.stringify({ notes: [], delta: [delta], synced: false })) + .once(JSON.stringify({}), { status: 200 }); + + await expect(db.sync()).rejects.toThrow( + "Merge conflicts detected. Please resolve all conflicts to continue syncing." + ); + + let rawDelta = await db.delta.raw(deltaId); + expect(rawDelta.conflicted.id).toBe(deltaId); + expect(rawDelta.conflicted.data).toBeTruthy(); + + // 6. Resolve conflicts + await db.notes.add({ + id: noteId, + conflicted: false, + content: { + text: "i am a text", + delta: { data: { ops: [{ insert: "text" }] }, resolved: true }, + }, + }); + rawDelta = await db.delta.raw(deltaId); + expect(rawDelta.conflicted).toBe(false); + //expect(rawDelta.resolved).toBe(true); + + // 7. Resync (no conflicts should appear) + fetchMock + .once(JSON.stringify({ notes: [], delta: [delta], synced: false })) + .once(JSON.stringify({}), { status: 200 }); + await expect(db.sync()).resolves.toBeUndefined(); + }); +}); diff --git a/packages/core/api/sync/__tests__/utils.js b/packages/core/api/sync/__tests__/utils.js new file mode 100644 index 000000000..ecbdaf40a --- /dev/null +++ b/packages/core/api/sync/__tests__/utils.js @@ -0,0 +1,35 @@ +function mainCollectionParams(collection, itemKey, item) { + async function addItem(db) { + const id = await db[collection].add(item); + return db[collection][itemKey](id).data; + } + + async function editItem(db, item) { + await db[collection].add({ ...item, title: "dobido" }); + } + + function getItem(db, item) { + return db[collection][itemKey](item.id).data; + } + + return [collection, addItem, editItem, getItem]; +} + +function tagsCollectionParams(collection, item) { + async function addItem(db) { + const id = await db[collection].add(item, 20); + return db[collection].tag(id); + } + + async function editItem(db) { + await db[collection].add(item, 240); + } + + function getItem(db, item) { + return db[collection].tag(item.id); + } + + return [collection, addItem, editItem, getItem]; +} + +export { tagsCollectionParams, mainCollectionParams }; diff --git a/packages/core/api/sync/__tests__/utils.test.js b/packages/core/api/sync/__tests__/utils.test.js new file mode 100644 index 000000000..ec436447e --- /dev/null +++ b/packages/core/api/sync/__tests__/utils.test.js @@ -0,0 +1,11 @@ +import { areAllEmpty } from "../utils"; + +test("return true if all array items in object are empty", () => { + const result = areAllEmpty({ a: [], b: [], c: true, f: 214 }); + expect(result).toBe(true); +}); + +test("return false if any array item in object is not empty", () => { + const result = areAllEmpty({ a: [2, 3, 4], b: [], c: true, f: 214 }); + expect(result).toBe(false); +}); diff --git a/packages/core/api/sync/index.js b/packages/core/api/sync/index.js new file mode 100644 index 000000000..b99a3fcf8 --- /dev/null +++ b/packages/core/api/sync/index.js @@ -0,0 +1,109 @@ +/** + * GENERAL PROCESS: + * make a get request to server with current lastSyncedTimestamp + * parse the response. the response should contain everything that user has on the server + * decrypt the response + * merge everything into the database and look for conflicts + * send the conflicts (if any) to the end-user for resolution + * once the conflicts have been resolved, send the updated data back to the server + */ + +/** + * MERGING: + * Locally, get everything that was editted/added after the lastSyncedTimestamp + * Run forEach loop on the server response. + * Add items that do not exist in the local collections + * Remove items (without asking) that need to be removed + * Update items that were editted before the lastSyncedTimestamp + * Try to merge items that were edited after the lastSyncedTimestamp + * Items in which the content has changed, send them for conflict resolution + * Otherwise, keep the most recently updated copy. + */ + +/** + * CONFLICTS: + * Syncing should pause until all the conflicts have been resolved + * And then it should continue. + */ +import Database from "../index"; +import { HOST, HEADERS } from "../../utils/constants"; +import Prepare from "./prepare"; +import Merger from "./merger"; +import { areAllEmpty } from "./utils"; +var tfun = require("transfun/transfun.js").tfun; +if (!tfun) { + tfun = global.tfun; +} + +export default class Sync { + /** + * + * @param {Database} db + */ + constructor(db) { + this.db = db; + } + + async _fetch(lastSyncedTimestamp) { + let token = await this.db.user.token(); + if (!token) throw new Error("You are not logged in"); + let response = await fetch(`${HOST}sync?lst=${lastSyncedTimestamp}`, { + headers: { ...HEADERS, Authorization: `Bearer ${token}` }, + }); + //TODO decrypt the response. + return await response.json(); + } + + async throwOnConflicts(lastSynced) { + let hasConflicts = await this.db.context.read("hasConflicts"); + if (hasConflicts) { + if (lastSynced) { + await this.db.user.set({ lastSynced }); + } + const mergeConflictError = new Error( + "Merge conflicts detected. Please resolve all conflicts to continue syncing." + ); + mergeConflictError.code = "MERGE_CONFLICT"; + throw mergeConflictError; + } + } + + async start() { + let user = await this.db.user.get(); + if (!user) throw new Error("You need to login to sync."); + + await this.db.conflicts.recalculate(); + await this.throwOnConflicts(); + + let lastSyncedTimestamp = user.lastSynced || 0; + let serverResponse = await this._fetch(lastSyncedTimestamp); + + // we prepare local data before merging so we always have correct data + const prepare = new Prepare(this.db, user); + const data = await prepare.get(lastSyncedTimestamp); + + // merge the server response + const merger = new Merger(this.db, lastSyncedTimestamp); + const mergeResult = await merger.merge(serverResponse); + await this.throwOnConflicts(data.lastSynced); + + // send the data back to server + await this._send(data); + + // update our lastSynced time + if (mergeResult || !areAllEmpty(data)) + await this.db.user.set({ lastSynced: data.lastSynced }); + } + + async _send(data) { + //TODO encrypt the payload + let token = await this.db.user.token(); + if (!token) return; + let response = await fetch(`${HOST}sync`, { + method: "POST", + headers: { ...HEADERS, Authorization: `Bearer ${token}` }, + body: JSON.stringify(data), + }); + return response.ok; + } +} diff --git a/packages/core/api/sync/merger.js b/packages/core/api/sync/merger.js new file mode 100644 index 000000000..70063e523 --- /dev/null +++ b/packages/core/api/sync/merger.js @@ -0,0 +1,120 @@ +import Database from "../index"; +import { areAllEmpty } from "./utils"; + +class Merger { + /** + * + * @param {Database} db + */ + constructor(db, lastSynced) { + this._db = db; + this._lastSynced = lastSynced; + } + + async _mergeItem(remoteItem, get, add) { + let localItem = await get(remoteItem.id); + remoteItem = { ...JSON.parse(remoteItem.data), remote: true }; + if (!localItem || remoteItem.dateEdited > localItem.dateEdited) { + await add(remoteItem); + } + } + + async _mergeArray(array, get, set) { + if (!array) return; + return Promise.all( + array.map(async (item) => await this._mergeItem(item, get, set)) + ); + } + + async _mergeItemWithConflicts(remoteItem, get, add, resolve) { + let localItem = await get(remoteItem.id); + + remoteItem = { ...JSON.parse(remoteItem.data), remote: true }; + + if (!localItem || remoteItem.dateEdited > localItem.dateEdited) { + await add(remoteItem); + } /* else if (localItem.resolved) { + await add({ ...localItem, resolved: false }); + } */ else if ( + localItem.dateEdited > this._lastSynced + ) { + // we have a conflict + await resolve(localItem, remoteItem); + } + } + + async _mergeArrayWithConflicts(array, get, set, resolve) { + if (!array) return; + return Promise.all( + array.map( + async (item) => + await this._mergeItemWithConflicts(item, get, set, resolve) + ) + ); + } + + async merge(serverResponse) { + if (!serverResponse) return false; + const { + notes, + synced, + notebooks, + delta, + text, + tags, + colors, + trash, + } = serverResponse; + + if (synced || areAllEmpty(serverResponse)) return false; + + await this._mergeArray( + notes, + (id) => this._db.notes.note(id), + (item) => this._db.notes.add(item) + ); + await this._mergeArray( + notebooks, + (id) => this._db.notebooks.notebook(id), + (item) => this._db.notebooks.add(item) + ); + + await this._mergeArrayWithConflicts( + delta, + (id) => this._db.delta.raw(id), + (item) => this._db.delta.add(item), + async (local, remote) => { + await this._db.delta.add({ ...local, conflicted: remote }); + await this._db.notes.add({ id: local.noteId, conflicted: true }); + await this._db.context.write("hasConflicts", true); + } + ); + + await this._mergeArray( + text, + (id) => this._db.text.raw(id), + (item) => this._db.text.add(item) + ); + + await this._mergeArray( + tags, + (id) => this._db.tags.tag(id), + (item) => this._db.tags.merge(item) + ); + + await this._mergeArray( + colors, + (id) => this._db.colors.tag(id), + (item) => this._db.colors.merge(item) + ); + + await this._mergeArray( + trash, + () => undefined, + (item) => this._db.trash.add(item) + ); + + return true; + } +} +export default Merger; diff --git a/packages/core/api/sync/prepare.js b/packages/core/api/sync/prepare.js new file mode 100644 index 000000000..381283775 --- /dev/null +++ b/packages/core/api/sync/prepare.js @@ -0,0 +1,35 @@ +import Database from "../index"; + +class Prepare { + /** + * + * @param {Database} db + */ + constructor(db) { + this._db = db; + } + + async get(lastSyncedTimestamp) { + this._lastSyncedTimestamp = lastSyncedTimestamp; + return { + notes: this._prepareForServer(this._db.notes.raw), + notebooks: this._prepareForServer(this._db.notebooks.raw), + delta: this._prepareForServer(await this._db.delta.all()), + text: this._prepareForServer(await this._db.text.all()), + tags: this._prepareForServer(this._db.tags.raw), + colors: this._prepareForServer(this._db.colors.raw), + trash: this._prepareForServer(this._db.trash.raw), + lastSynced: Date.now(), + }; + } + + _prepareForServer(array) { + return tfun + .filter((item) => item.dateEdited > this._lastSyncedTimestamp) + .map((item) => ({ + id: item.id, + data: JSON.stringify(item), + }))(array); + } +} +export default Prepare; diff --git a/packages/core/api/sync/utils.js b/packages/core/api/sync/utils.js new file mode 100644 index 000000000..deab1087e --- /dev/null +++ b/packages/core/api/sync/utils.js @@ -0,0 +1,6 @@ +function areAllEmpty(obj) { + const arrays = Object.values(obj).filter((v) => v.length !== undefined); + return arrays.every((array) => array.length === 0); +} + +export { areAllEmpty }; diff --git a/packages/core/collections/content.js b/packages/core/collections/content.js index 2f27abc06..0ada51c9d 100644 --- a/packages/core/collections/content.js +++ b/packages/core/collections/content.js @@ -18,7 +18,7 @@ export default class Content { id, data: content.data || content, conflicted: content.conflicted || false, - resolved: !!content.resolved, + //resolved: !!content.resolved, dateEdited: content.dateEdited, dateCreated: content.dateCreated, remote: content.remote, diff --git a/packages/core/collections/notebooks.js b/packages/core/collections/notebooks.js index 4b56e1f02..8062c9f3b 100644 --- a/packages/core/collections/notebooks.js +++ b/packages/core/collections/notebooks.js @@ -28,6 +28,11 @@ export default class Notebooks { async add(notebookArg) { if (!notebookArg) throw new Error("Notebook cannot be undefined or null."); + + if (notebookArg.remote) { + return await this._collection.addItem(notebookArg); + } + //TODO reliably and efficiently check for duplicates. const id = notebookArg.id || getId(); let oldNotebook = this._collection.getItem(id); @@ -37,7 +42,7 @@ export default class Notebooks { let notebook = { ...oldNotebook, - ...notebookArg + ...notebookArg, }; notebook = { @@ -49,7 +54,7 @@ export default class Notebooks { pinned: !!notebook.pinned, favorite: !!notebook.favorite, topics: notebook.topics || [], - totalNotes: 0 + totalNotes: 0, }; if (!oldNotebook) { notebook.topics.splice(0, 0, "General"); @@ -60,7 +65,7 @@ export default class Notebooks { if (!oldNotebook) { await this.notebook(notebook).topics.add(...notebook.topics); } - return notebook; + return id; } get raw() { @@ -68,7 +73,7 @@ export default class Notebooks { } get all() { - return sort(this._collection.getAllItems()).desc(t => t.pinned); + return sort(this._collection.getAllItems()).desc((t) => t.pinned); } get pinned() { @@ -100,7 +105,7 @@ export default class Notebooks { filter(query) { if (!query) return []; - let queryFn = v => fuzzysearch(query, v.title + " " + v.description); + let queryFn = (v) => fuzzysearch(query, v.title + " " + v.description); if (query instanceof Function) queryFn = query; return tfun.filter(queryFn)(this.all); } diff --git a/packages/core/collections/tags.js b/packages/core/collections/tags.js index 23ae3d650..7bc5763eb 100644 --- a/packages/core/collections/tags.js +++ b/packages/core/collections/tags.js @@ -13,13 +13,13 @@ export default class Tags { } notes(tag) { - const tagItem = this.all.find(t => t.title === tag); + const tagItem = this.all.find((t) => t.title === tag); if (!tagItem) return []; return tagItem.noteIds; } tag(id) { - const tagItem = this.all.find(t => t.id === id); + const tagItem = this.all.find((t) => t.id === id); if (!tagItem) return; return tagItem; } @@ -30,7 +30,7 @@ export default class Tags { await this._collection.addItem(tag); return; } - const oldTag = this.all.find(t => t.id === tag.id); + const oldTag = this.all.find((t) => t.id === tag.id); if (!oldTag) return await this._collection.addItem(tag); const deletedIds = set.union(oldTag.deletedIds, tag.deletedIds); @@ -45,14 +45,14 @@ export default class Tags { ...oldTag, noteIds, dateEdited, - deletedIds + deletedIds, }; await this._collection.addItem(tag); } async add(tagTitle, noteId) { if (!tagTitle || !noteId) return; - const oldTag = this.all.find(t => t.title === tagTitle) || {}; + const oldTag = this.all.find((t) => t.title === tagTitle) || {}; let tag = { ...oldTag, title: tagTitle }; let id = tag.id || getId(); @@ -62,10 +62,11 @@ export default class Tags { id, title: tag.title, noteIds: [...notes, noteId], - deletedIds + deletedIds, }; await this._collection.addItem(tag); + return id; } get raw() { @@ -78,7 +79,7 @@ export default class Tags { async remove(tagTitle, noteId) { if (!tagTitle || !noteId) return; - let tag = this.all.find(t => t.title === tagTitle); + let tag = this.all.find((t) => t.title === tagTitle); if (!tag) return; tag = qclone(tag); const noteIndex = tag.noteIds.indexOf(noteId); diff --git a/packages/core/package.json b/packages/core/package.json index 29980a6ff..163572e6f 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -7,6 +7,7 @@ "@babel/plugin-transform-runtime": "^7.7.4", "@babel/preset-env": "^7.7.4", "@babel/runtime": "^7.7.4", + "@types/jest": "^25.2.1", "babel-jest": "^24.9.0", "babel-polyfill": "^6.26.0", "babel-preset-env": "^1.7.0", diff --git a/packages/core/yarn.lock b/packages/core/yarn.lock index f3a8d68df..790b1d7c8 100644 --- a/packages/core/yarn.lock +++ b/packages/core/yarn.lock @@ -823,6 +823,16 @@ "@types/istanbul-reports" "^1.1.1" "@types/yargs" "^13.0.0" +"@jest/types@^25.2.6": + version "25.2.6" + resolved "https://registry.yarnpkg.com/@jest/types/-/types-25.2.6.tgz#c12f44af9bed444438091e4b59e7ed05f8659cb6" + integrity sha512-myJTTV37bxK7+3NgKc4Y/DlQ5q92/NOwZsZ+Uch7OXdElxOg61QYc72fPYNAjlvbnJ2YvbXLamIsa9tj48BmyQ== + dependencies: + "@types/istanbul-lib-coverage" "^2.0.0" + "@types/istanbul-reports" "^1.1.1" + "@types/yargs" "^15.0.0" + chalk "^3.0.0" + "@types/babel__core@^7.1.0": version "7.1.3" resolved "https://registry.yarnpkg.com/@types/babel__core/-/babel__core-7.1.3.tgz#e441ea7df63cd080dfcd02ab199e6d16a735fc30" @@ -856,6 +866,11 @@ dependencies: "@babel/types" "^7.3.0" +"@types/color-name@^1.1.1": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@types/color-name/-/color-name-1.1.1.tgz#1c1261bbeaa10a8055bbc5d8ab84b7b2afc846a0" + integrity sha512-rr+OQyAjxze7GgWrSaJwydHStIhHq2lvY3BOC2Mj7KnzI7XK0Uw1TOOdI9lDoajEbSWLiYgoo4f1R51erQfhPQ== + "@types/istanbul-lib-coverage@*", "@types/istanbul-lib-coverage@^2.0.0": version "2.0.1" resolved "https://registry.yarnpkg.com/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.1.tgz#42995b446db9a48a11a07ec083499a860e9138ff" @@ -876,6 +891,14 @@ "@types/istanbul-lib-coverage" "*" "@types/istanbul-lib-report" "*" +"@types/jest@^25.2.1": + version "25.2.1" + resolved "https://registry.yarnpkg.com/@types/jest/-/jest-25.2.1.tgz#9544cd438607955381c1bdbdb97767a249297db5" + integrity sha512-msra1bCaAeEdkSyA0CZ6gW1ukMIvZ5YoJkdXw/qhQdsuuDlFTcEUrUw8CLCPt2rVRUfXlClVvK2gvPs9IokZaA== + dependencies: + jest-diff "^25.2.1" + pretty-format "^25.2.1" + "@types/stack-utils@^1.0.1": version "1.0.1" resolved "https://registry.yarnpkg.com/@types/stack-utils/-/stack-utils-1.0.1.tgz#0a851d3bd96498fa25c33ab7278ed3bd65f06c3e" @@ -893,6 +916,13 @@ dependencies: "@types/yargs-parser" "*" +"@types/yargs@^15.0.0": + version "15.0.4" + resolved "https://registry.yarnpkg.com/@types/yargs/-/yargs-15.0.4.tgz#7e5d0f8ca25e9d5849f2ea443cf7c402decd8299" + integrity sha512-9T1auFmbPZoxHz0enUFlUuKRy3it01R+hlggyVUMtnCTQRunsQYifnSGb8hET4Xo8yiC0o0r1paW3ud5+rbURg== + dependencies: + "@types/yargs-parser" "*" + abab@^2.0.0: version "2.0.3" resolved "https://registry.yarnpkg.com/abab/-/abab-2.0.3.tgz#623e2075e02eb2d3f2475e49f99c91846467907a" @@ -956,6 +986,11 @@ ansi-regex@^4.0.0, ansi-regex@^4.1.0: resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-4.1.0.tgz#8b9f8f08cf1acb843756a839ca8c7e3168c51997" integrity sha512-1apePfXM1UOSqw0o9IiFAovVz9M5S1Dg+4TrDwfMewQ6p/rmMueb7tWZjQ1rx4Loy1ArBggoqGpfqqdI4rondg== +ansi-regex@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-5.0.0.tgz#388539f55179bf39339c81af30a654d69f87cb75" + integrity sha512-bY6fj56OUQ0hU1KjFNDQuJFezqKdrAyFdIevADiqrWHwSlbmBNMHp5ak2f40Pm8JTFyM2mqxkG6ngkHO11f/lg== + ansi-styles@^2.2.1: version "2.2.1" resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-2.2.1.tgz#b432dd3358b634cf75e1e4664368240533c1ddbe" @@ -968,6 +1003,14 @@ ansi-styles@^3.2.0, ansi-styles@^3.2.1: dependencies: color-convert "^1.9.0" +ansi-styles@^4.0.0, ansi-styles@^4.1.0: + version "4.2.1" + resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-4.2.1.tgz#90ae75c424d008d2624c5bf29ead3177ebfcf359" + integrity sha512-9VGjrMsG1vePxcSweQsN20KY/c4zN0h9fLjqAbwbPfahM3t+NL+M9HC8xeXG2I8pX5NoamTGNuomEUFI7fcUjA== + dependencies: + "@types/color-name" "^1.1.1" + color-convert "^2.0.1" + anymatch@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-2.0.0.tgz#bcb24b4f37934d9aa7ac17b4adaf89e7c76ef2eb" @@ -1718,6 +1761,14 @@ chalk@^2.0.0, chalk@^2.0.1, chalk@^2.4.2: escape-string-regexp "^1.0.5" supports-color "^5.3.0" +chalk@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/chalk/-/chalk-3.0.0.tgz#3f73c2bf526591f574cc492c51e2456349f844e4" + integrity sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg== + dependencies: + ansi-styles "^4.1.0" + supports-color "^7.1.0" + chownr@^1.1.1: version "1.1.3" resolved "https://registry.yarnpkg.com/chownr/-/chownr-1.1.3.tgz#42d837d5239688d55f303003a508230fa6727142" @@ -1772,11 +1823,23 @@ color-convert@^1.9.0: dependencies: color-name "1.1.3" +color-convert@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-2.0.1.tgz#72d3a68d598c9bdb3af2ad1e84f21d896abd4de3" + integrity sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ== + dependencies: + color-name "~1.1.4" + color-name@1.1.3: version "1.1.3" resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.3.tgz#a7d0558bd89c42f795dd42328f740831ca53bc25" integrity sha1-p9BVi9icQveV3UIyj3QIMcpTvCU= +color-name@~1.1.4: + version "1.1.4" + resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2" + integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA== + combined-stream@^1.0.6, combined-stream@~1.0.6: version "1.0.8" resolved "https://registry.yarnpkg.com/combined-stream/-/combined-stream-1.0.8.tgz#c3d45a8b34fd730631a110a8a2520682b31d5a7f" @@ -1976,6 +2039,11 @@ diff-sequences@^24.9.0: resolved "https://registry.yarnpkg.com/diff-sequences/-/diff-sequences-24.9.0.tgz#5715d6244e2aa65f48bba0bc972db0b0b11e95b5" integrity sha512-Dj6Wk3tWyTE+Fo1rW8v0Xhwk80um6yFYKbuAxc9c3EZxIHFDYwbi34Uk42u1CdnIiVorvt4RmlSDjIPyzGC2ew== +diff-sequences@^25.2.6: + version "25.2.6" + resolved "https://registry.yarnpkg.com/diff-sequences/-/diff-sequences-25.2.6.tgz#5f467c00edd35352b7bca46d7927d60e687a76dd" + integrity sha512-Hq8o7+6GaZeoFjtpgvRBUknSXNeJiCx7V9Fr94ZMljNiCr9n9L8H8aJqgWOQiDDGdyn29fRNcDdRVJ5fdyihfg== + domexception@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/domexception/-/domexception-1.0.1.tgz#937442644ca6a31261ef36e3ec677fe805582c90" @@ -2370,6 +2438,11 @@ has-flag@^3.0.0: resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-3.0.0.tgz#b5d454dc2199ae225699f3467e5a07f3b955bafd" integrity sha1-tdRU3CGZriJWmfNGfloH87lVuv0= +has-flag@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-4.0.0.tgz#944771fd9c81c81265c4d6941860da06bb59479b" + integrity sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ== + has-symbols@^1.0.0, has-symbols@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.0.1.tgz#9f5214758a44196c406d9bd76cebf81ec2dd31e8" @@ -2774,6 +2847,16 @@ jest-diff@^24.9.0: jest-get-type "^24.9.0" pretty-format "^24.9.0" +jest-diff@^25.2.1: + version "25.2.6" + resolved "https://registry.yarnpkg.com/jest-diff/-/jest-diff-25.2.6.tgz#a6d70a9ab74507715ea1092ac513d1ab81c1b5e7" + integrity sha512-KuadXImtRghTFga+/adnNrv9s61HudRMR7gVSbP35UKZdn4IK2/0N0PpGZIqtmllK9aUyye54I3nu28OYSnqOg== + dependencies: + chalk "^3.0.0" + diff-sequences "^25.2.6" + jest-get-type "^25.2.6" + pretty-format "^25.2.6" + jest-docblock@^24.3.0: version "24.9.0" resolved "https://registry.yarnpkg.com/jest-docblock/-/jest-docblock-24.9.0.tgz#7970201802ba560e1c4092cc25cbedf5af5a8ce2" @@ -2828,6 +2911,11 @@ jest-get-type@^24.9.0: resolved "https://registry.yarnpkg.com/jest-get-type/-/jest-get-type-24.9.0.tgz#1684a0c8a50f2e4901b6644ae861f579eed2ef0e" integrity sha512-lUseMzAley4LhIcpSP9Jf+fTrQ4a1yHQwLNeeVa2cEmbCGeoZAtYPOIv8JaxLD/sUpKxetKGP+gsHl8f8TSj8Q== +jest-get-type@^25.2.6: + version "25.2.6" + resolved "https://registry.yarnpkg.com/jest-get-type/-/jest-get-type-25.2.6.tgz#0b0a32fab8908b44d508be81681487dbabb8d877" + integrity sha512-DxjtyzOHjObRM+sM1knti6or+eOgcGU4xVSb2HNP1TqO4ahsT+rqZg+nyqHWJSvWgKC5cG3QjGFBqxLghiF/Ig== + jest-haste-map@^24.9.0: version "24.9.0" resolved "https://registry.yarnpkg.com/jest-haste-map/-/jest-haste-map-24.9.0.tgz#b38a5d64274934e21fa417ae9a9fbeb77ceaac7d" @@ -3794,6 +3882,16 @@ pretty-format@^24.9.0: ansi-styles "^3.2.0" react-is "^16.8.4" +pretty-format@^25.2.1, pretty-format@^25.2.6: + version "25.2.6" + resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-25.2.6.tgz#542a1c418d019bbf1cca2e3620443bc1323cb8d7" + integrity sha512-DEiWxLBaCHneffrIT4B+TpMvkV9RNvvJrd3lY9ew1CEQobDzEXmYT1mg0hJhljZty7kCc10z13ohOFAE8jrUDg== + dependencies: + "@jest/types" "^25.2.6" + ansi-regex "^5.0.0" + ansi-styles "^4.0.0" + react-is "^16.12.0" + private@^0.1.6: version "0.1.8" resolved "https://registry.yarnpkg.com/private/-/private-0.1.8.tgz#2381edb3689f7a53d653190060fcf822d2f368ff" @@ -3860,6 +3958,11 @@ rc@^1.2.7: minimist "^1.2.0" strip-json-comments "~2.0.1" +react-is@^16.12.0: + version "16.13.1" + resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4" + integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ== + react-is@^16.8.4: version "16.12.0" resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.12.0.tgz#2cc0fe0fba742d97fd527c42a13bec4eeb06241c" @@ -4452,6 +4555,13 @@ supports-color@^6.1.0: dependencies: has-flag "^3.0.0" +supports-color@^7.1.0: + version "7.1.0" + resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-7.1.0.tgz#68e32591df73e25ad1c4b49108a2ec507962bfd1" + integrity sha512-oRSIpR8pxT1Wr2FquTNnGet79b3BWljqOuoW/h4oBhxJ/HUbX5nX6JSruTkvXDCFMwDPvsaTTbvMLKZWSy0R5g== + dependencies: + has-flag "^4.0.0" + symbol-tree@^3.2.2: version "3.2.4" resolved "https://registry.yarnpkg.com/symbol-tree/-/symbol-tree-3.2.4.tgz#430637d248ba77e078883951fb9aa0eed7c63fa2"