diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ae72bf791..f628ccc946 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,10 +1,10 @@ ## vNext (TBD) - ### Enhancements * Add support for using functions as default property values, in order to allow dynamic defaults [#5001](https://github.com/realm/realm-js/pull/5001), [#2393](https://github.com/realm/realm-js/issues/2393) * All fields of a `Realm.Object` treated as optional by TypeScript when constructing a new class-based model, unless specified in the second type parameter [#5000](https://github.com/realm/realm-js/pull/5000) * Improve performance of client reset with automatic recovery and converting top-level tables into embedded tables. ([realm/realm-core#5897](https://github.com/realm/realm-core/pull/5897)) * If a sync client sends a message larger than 16 MB, the sync server will request a client reset. ([realm/realm-core#5209](https://github.com/realm/realm-core/issues/5209)) +* Add two new modes to client reset: `RecoverUnsyncedChanges` and `RecoverOrDiscardUnsyncedChanges`. The two modes will recover local/unsynced changes with changes from the server if possible. If not possible, `RecoverOrDiscardUnsyncedChanges` will remove the local Realm file and download a fresh file from the server. The mode `DiscardLocal` is duplicated as `DiscardUnsyncedChanges`, and `DiscardLocal` is be removed in a future version. ([#4135](https://github.com/realm/realm-js/issues/4135)) ### Fixed * Fixed a use-after-free if the last external reference to an encrypted Realm was closed between when a client reset error was received and when the download of the new Realm began. ([realm/realm-core#5949](https://github.com/realm/realm-core/pull/5949), since v10.20.0) diff --git a/docs/sync.js b/docs/sync.js index 4043b8376f..1cfbcd6254 100644 --- a/docs/sync.js +++ b/docs/sync.js @@ -37,9 +37,11 @@ /** * This describes the options to configure client reset. * @typedef {Object} Realm.App.Sync~ClientResetConfiguration - * @property {string} mode - Either "manual" (see also `Realm.App.Sync.initiateClientReset()`) or "discardLocal" (download a fresh copy from the server). Default is "discardLocal". - * @property {callback(realm)|null} [onBefore] - called before sync initiates a client reset. - * @property {callback(beforeRealm, afterRealm)|null} [onAfter] - called after client reset has been executed; `beforeRealm` and `afterRealm` are instances of the Realm before and after the client reset. + * @property {string} mode - Either "manual" (deprecated, see also `Realm.App.Sync.initiateClientReset()`), "discardUnsyncedChanges" (download a fresh copy from the server), "recoverUnsyncedChanges" (merged remote and local, unsynced changes), or "recoverOrDiscardUnsyncedChanges" (download a fresh copy from the server if recovery of unsynced changes is not possible) + * @property {callback(realm)|null} [onBefore] - called before sync initiates a client reset (only for "discardUnsyncedChanges", "recoverUnsyncedChanges" or "recoverOrDiscardUnsyncedChanges" modes). + * @property {callback(beforeRealm, afterRealm)|null} [onAfter] - called after client reset has been executed; `beforeRealm` and `afterRealm` are instances of the Realm before and after the client reset (only for "discardUnsyncedChanges", "recoverUnsyncedChanges" or "recoverOrDiscardUnsyncedChanges" modes). + * @property {callback(session, path)|null} [onFallback] - called if recovery or discard fail (only for "recoverUnsyncedChanges" or "recoverOrDiscardUnsyncedChanges" modes). + * @property {callback(session, path)|null} [onManual] - perform manual client reset - see also `Realm.App.Sync.initiateClientReset()` (only "manual" mode). * @since {10.11.0} */ diff --git a/integration-tests/realm-apps/with-db-flx/functions/triggerClientReset/config.json b/integration-tests/realm-apps/with-db-flx/functions/triggerClientReset/config.json new file mode 100644 index 0000000000..715f702585 --- /dev/null +++ b/integration-tests/realm-apps/with-db-flx/functions/triggerClientReset/config.json @@ -0,0 +1,5 @@ +{ + "name": "triggerClientReset", + "private": false, + "run_as_system": true +} \ No newline at end of file diff --git a/integration-tests/realm-apps/with-db-flx/functions/triggerClientReset/source.js b/integration-tests/realm-apps/with-db-flx/functions/triggerClientReset/source.js new file mode 100644 index 0000000000..29e68befc6 --- /dev/null +++ b/integration-tests/realm-apps/with-db-flx/functions/triggerClientReset/source.js @@ -0,0 +1,29 @@ +//////////////////////////////////////////////////////////////////////////// +// +// Copyright 2022 Realm Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +//////////////////////////////////////////////////////////////////////////// + +/* eslint-env node */ +/* global context */ + +exports = async function (appId, userId) { + return (await deleteClientFile(`__realm_sync_${appId}`, userId)) || (await deleteClientFile(`__realm_sync`, userId)); +}; + +async function deleteClientFile(db, userId) { + const mongodb = context.services.get("mongodb"); + return (await mongodb.db(db).collection("clientfiles").deleteMany({ ownerId: userId })).deletedCount > 0; +} diff --git a/integration-tests/realm-apps/with-db/functions/triggerClientReset/config.json b/integration-tests/realm-apps/with-db/functions/triggerClientReset/config.json new file mode 100644 index 0000000000..715f702585 --- /dev/null +++ b/integration-tests/realm-apps/with-db/functions/triggerClientReset/config.json @@ -0,0 +1,5 @@ +{ + "name": "triggerClientReset", + "private": false, + "run_as_system": true +} \ No newline at end of file diff --git a/integration-tests/realm-apps/with-db/functions/triggerClientReset/source.js b/integration-tests/realm-apps/with-db/functions/triggerClientReset/source.js new file mode 100644 index 0000000000..29e68befc6 --- /dev/null +++ b/integration-tests/realm-apps/with-db/functions/triggerClientReset/source.js @@ -0,0 +1,29 @@ +//////////////////////////////////////////////////////////////////////////// +// +// Copyright 2022 Realm Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +//////////////////////////////////////////////////////////////////////////// + +/* eslint-env node */ +/* global context */ + +exports = async function (appId, userId) { + return (await deleteClientFile(`__realm_sync_${appId}`, userId)) || (await deleteClientFile(`__realm_sync`, userId)); +}; + +async function deleteClientFile(db, userId) { + const mongodb = context.services.get("mongodb"); + return (await mongodb.db(db).collection("clientfiles").deleteMany({ ownerId: userId })).deletedCount > 0; +} diff --git a/integration-tests/tests/src/tests/index.ts b/integration-tests/tests/src/tests/index.ts index 55110f842a..9bccb9ce7d 100644 --- a/integration-tests/tests/src/tests/index.ts +++ b/integration-tests/tests/src/tests/index.ts @@ -38,3 +38,4 @@ import "./sync/sync-as-local"; import "./transaction"; import "./schema"; import "./types"; +import "./sync/client-reset"; diff --git a/integration-tests/tests/src/tests/sync/asymmetric.ts b/integration-tests/tests/src/tests/sync/asymmetric.ts index b95bb53e7d..e314e6b128 100644 --- a/integration-tests/tests/src/tests/sync/asymmetric.ts +++ b/integration-tests/tests/src/tests/sync/asymmetric.ts @@ -23,6 +23,7 @@ import { authenticateUserBefore, importAppBefore, openRealmBeforeEach } from ".. describe.skipIf(environment.missingServer, "Asymmetric sync", function () { describe("Configuration and schema", function () { + this.timeout(20 * 1000); const PersonSchema: Realm.ObjectSchema = { name: "Person", asymmetric: true, diff --git a/integration-tests/tests/src/tests/sync/client-reset.ts b/integration-tests/tests/src/tests/sync/client-reset.ts new file mode 100644 index 0000000000..72851fae63 --- /dev/null +++ b/integration-tests/tests/src/tests/sync/client-reset.ts @@ -0,0 +1,542 @@ +//////////////////////////////////////////////////////////////////////////// +// +// Copyright 2021 Realm Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +//////////////////////////////////////////////////////////////////////////// + +import { ObjectId, UUID } from "bson"; +import { expect } from "chai"; +import Realm, { ClientResetMode, SessionStopPolicy } from "realm"; +import { authenticateUserBefore, importAppBefore } from "../../hooks"; +import { DogSchema, IPerson, PersonSchema } from "../../schemas/person-and-dog-with-object-ids"; +import { expectClientResetError } from "../../utils/expect-sync-error"; +import { createPromiseHandle } from "../../utils/promise-handle"; + +const FlexiblePersonSchema = { ...PersonSchema, properties: { ...PersonSchema.properties, nonQueryable: "string?" } }; +const FlexibleDogSchema = { ...DogSchema, properties: { ...DogSchema.properties, nonQueryable: "string?" } }; + +/** + * Adds required subscriptions + * + * @param realm Realm instance + */ +function addSubscriptions(realm: Realm): void { + const subs = realm.subscriptions; + subs.update((mutableSubs) => { + mutableSubs.add(realm.objects(FlexiblePersonSchema.name)); + mutableSubs.add(realm.objects(FlexibleDogSchema.name)); + }); +} + +function getPartitionValue() { + return new UUID().toHexString(); +} + +async function triggerClientReset(app: Realm.App, user: Realm.User): Promise { + await user.functions.triggerClientReset(app.id, user.id); +} + +async function waitServerSideClientResetDiscardUnsyncedChangesCallbacks( + useFlexibleSync: boolean, + schema: Realm.ObjectSchema[], + app: Realm.App, + user: Realm.User, + actionBefore: (realm: Realm) => void, + actionAfter: (beforeRealm: Realm, afterRealm: Realm) => void, +): Promise { + const resetHandle = createPromiseHandle(); + let afterCalled = false; + let beforeCalled = false; + + const realm = new Realm({ + schema, + sync: { + user, + _sessionStopPolicy: SessionStopPolicy.Immediately, + ...(useFlexibleSync ? { flexible: true } : { partitionValue: getPartitionValue() }), + clientReset: { + mode: ClientResetMode.DiscardUnsyncedChanges, + onAfter: (before: Realm, after: Realm) => { + afterCalled = true; + actionAfter(before, after); + if (beforeCalled) { + resetHandle.resolve(); + } + }, + onBefore: (realm: Realm) => { + beforeCalled = true; + actionBefore(realm); + if (afterCalled) { + resetHandle.resolve(); + } + }, + }, + }, + }); + if (useFlexibleSync) { + addSubscriptions(realm); + } + realm.write(() => { + realm.create(DogSchema.name, { _id: new ObjectId(), name: "Rex", age: 2 }); + }); + + await realm.syncSession?.uploadAllLocalChanges(); + await triggerClientReset(app, user); + await resetHandle.promise; +} + +async function waitServerSideClientResetRecoveryCallbacks( + useFlexibleSync: boolean, + schema: Realm.ObjectSchema[], + app: Realm.App, + user: Realm.User, + actionBefore: (realm: Realm) => void, + actionAfter: (beforeRealm: Realm, afterRealm: Realm) => void, +): Promise { + const resetHandle = createPromiseHandle(); + let afterCalled = false; + let beforeCalled = false; + + const realm = new Realm({ + schema, + sync: { + user, + _sessionStopPolicy: SessionStopPolicy.Immediately, + ...(useFlexibleSync ? { flexible: true } : { partitionValue: getPartitionValue() }), + clientReset: { + mode: ClientResetMode.RecoverUnsyncedChanges, + onAfter: (before: Realm, after: Realm) => { + afterCalled = true; + actionAfter(before, after); + if (beforeCalled) { + resetHandle.resolve(); + } + }, + onBefore: (realm: Realm) => { + beforeCalled = true; + actionBefore(realm); + if (afterCalled) { + resetHandle.resolve(); + } + }, + }, + }, + }); + if (useFlexibleSync) { + addSubscriptions(realm); + } + realm.write(() => { + realm.create(DogSchema.name, { _id: new ObjectId(), name: "Rex", age: 2 }); + }); + + await realm.syncSession?.uploadAllLocalChanges(); + await triggerClientReset(app, user); + await resetHandle.promise; +} + +async function waitSimulatedClientResetDiscardUnsyncedChangesCallbacks( + useFlexibleSync: boolean, + schema: Realm.ObjectSchema[], + user: Realm.User, + actionBefore: (realm: Realm) => void, + actionAfter: (beforeRealm: Realm, afterRealm: Realm) => void, +): Promise { + return new Promise((resolve) => { + let afterCalled = false; + let beforeCalled = false; + + const modifiedConfig: Realm.Configuration = { + schema, + sync: { + user, + _sessionStopPolicy: SessionStopPolicy.Immediately, + ...(useFlexibleSync ? { flexible: true } : { partitionValue: getPartitionValue() }), + clientReset: { + mode: ClientResetMode.DiscardUnsyncedChanges, + onAfter: (before: Realm, after: Realm) => { + afterCalled = true; + actionAfter(before, after); + if (beforeCalled) { + resolve(); + } + }, + onBefore: (realm: Realm) => { + beforeCalled = true; + actionBefore(realm); + if (afterCalled) { + resolve(); + } + }, + }, + }, + }; + + const realm = new Realm(modifiedConfig); + if (useFlexibleSync) { + addSubscriptions(realm); + } + realm.write(() => { + realm.create(DogSchema.name, { _id: new ObjectId(), name: "Rex", age: 2 }); + }); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const session = realm.syncSession; + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore calling undocumented method _simulateError + session._simulateError(211, "Simulate Client Reset", "realm::sync::ProtocolError", false); // 211 -> diverging histories + }); +} + +async function waitSimulatedClientResetRecoverCallbacks( + useFlexibleSync: boolean, + schema: Realm.ObjectSchema[], + user: Realm.User, + actionBefore: (realm: Realm) => void, + actionAfter: (beforeRealm: Realm, afterRealm: Realm) => void, +): Promise { + return new Promise((resolve) => { + let afterCalled = false; + let beforeCalled = false; + + const modifiedConfig: Realm.Configuration = { + schema, + sync: { + user, + _sessionStopPolicy: SessionStopPolicy.Immediately, + ...(useFlexibleSync ? { flexible: true } : { partitionValue: getPartitionValue() }), + clientReset: { + mode: ClientResetMode.RecoverUnsyncedChanges, + onAfter: (before: Realm, after: Realm) => { + afterCalled = true; + actionAfter(before, after); + if (beforeCalled) { + resolve(); + } + }, + onBefore: (realm: Realm) => { + beforeCalled = true; + actionBefore(realm); + if (afterCalled) { + resolve(); + } + }, + }, + }, + }; + + const realm = new Realm(modifiedConfig); + if (useFlexibleSync) { + addSubscriptions(realm); + } + realm.write(() => { + realm.create(DogSchema.name, { _id: new ObjectId(), name: "Rex", age: 2 }); + }); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const session = realm.syncSession!; + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore calling undocumented method _simulateError + session._simulateError(211, "Simulate Client Reset", "realm::sync::ProtocolError", false); // 211 -> diverging histories; 217 -> synchronization no longer possible for client-side file + }); +} + +/** + * Returns a string representation of the type of sync + * @param useFlexibleSync + * @returns a string representation of flexible or partition-based sync + */ +function getPartialTestTitle(useFlexibleSync: boolean) { + if (useFlexibleSync) { + return "flexible"; + } else { + return "partition-based"; + } +} + +/** + * Returns the object schemas depending on sync type + * @param useFlexibleSync + * @returns a schema matching either flexible or partition-based sync + */ +function getSchema(useFlexibleSync: boolean) { + if (useFlexibleSync) { + return [FlexiblePersonSchema, DogSchema]; + } else { + return [PersonSchema, DogSchema]; + } +} + +// FIXME: testing flexible sync is currently disabled as it is timing out +[false /*, true*/].forEach((useFlexibleSync) => { + describe.skipIf( + environment.missingServer, + `client reset handling (${getPartialTestTitle(useFlexibleSync)} sync)`, + function () { + this.timeout(100 * 1000); // client reset with flexible sync can take quite some time + importAppBefore(useFlexibleSync ? "with-db-flx" : "with-db"); + authenticateUserBefore(); + + it(`manual client reset requires either error handler, client reset callback or both (${getPartialTestTitle( + useFlexibleSync, + )} sync)`, async function (this: RealmContext) { + const config: Realm.Configuration = { + schema: getSchema(useFlexibleSync), + sync: { + _sessionStopPolicy: SessionStopPolicy.Immediately, + ...(useFlexibleSync ? { flexible: true } : { partitionValue: getPartitionValue() }), + user: this.user, + clientReset: { + mode: ClientResetMode.Manual, + }, + }, + }; + + expect(() => new Realm(config)).throws(); + }); + + it(`handles manual simulated client resets with ${getPartialTestTitle( + useFlexibleSync, + )} sync enabled`, async function (this: RealmContext) { + await expectClientResetError( + { + schema: getSchema(useFlexibleSync), + sync: { + _sessionStopPolicy: SessionStopPolicy.Immediately, + ...(useFlexibleSync ? { flexible: true } : { partitionValue: getPartitionValue() }), + user: this.user, + clientReset: { + mode: ClientResetMode.Manual, + }, + }, + }, + this.user, + (realm) => { + if (useFlexibleSync) { + addSubscriptions(realm); + } + const session = realm.syncSession; + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore calling undocumented method _simulateError + session._simulateError(211, "Simulate Client Reset", "realm::sync::ProtocolError", false); // 211 -> diverging histories + }, + (error) => { + expect(error.name).to.equal("ClientReset"); + expect(error.message).to.equal("Simulate Client Reset"); + expect(error.code).to.equal(211); + }, + ); + }); + + it(`handles manual simulated client resets by callback with ${getPartialTestTitle( + useFlexibleSync, + )} sync enabled`, async function (this: RealmContext) { + return new Promise((resolve, _) => { + const config: Realm.Configuration = { + schema: getSchema(useFlexibleSync), + sync: { + _sessionStopPolicy: SessionStopPolicy.Immediately, + ...(useFlexibleSync ? { flexible: true } : { partitionValue: getPartitionValue() }), + user: this.user, + clientReset: { + mode: ClientResetMode.Manual, + onManual: (session, path) => { + expect(session).to.be.not.null; + expect(path).to.not.empty; + resolve(); + }, + }, + }, + }; + + const realm = new Realm(config); + if (useFlexibleSync) { + addSubscriptions(realm); + } + const session = realm.syncSession; + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore calling undocumented method _simulateError + session._simulateError(211, "Simulate Client Reset", "realm::sync::ProtocolError", true); // 211 -> diverging histories + }); + }); + + it(`handles manual simulated client resets by callback from error handler with ${getPartialTestTitle( + useFlexibleSync, + )} sync enabled`, async function (this: RealmContext) { + return new Promise((resolve, reject) => { + const config: Realm.Configuration = { + schema: getSchema(useFlexibleSync), + sync: { + _sessionStopPolicy: SessionStopPolicy.Immediately, + ...(useFlexibleSync ? { flexible: true } : { partitionValue: getPartitionValue() }), + user: this.user, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + error: (_) => { + reject(); + }, + clientReset: { + mode: ClientResetMode.Manual, + onManual: (session, path) => { + expect(session).to.be.not.null; + expect(path).to.not.empty; + resolve(); + }, + }, + }, + }; + + const realm = new Realm(config); + const session = realm.syncSession; + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore calling undocumented method _simulateError + session._simulateError(211, "Simulate Client Reset", "realm::sync::ProtocolError", true); // 211 -> diverging histories + }); + }); + + it(`client reset fails, the error handler is called (${getPartialTestTitle( + useFlexibleSync, + )})`, async function (this: RealmContext) { + // if client reset fails, the error handler is called + // and the two before/after handlers are not called + // we simulate the failure by error code 132") + + return new Promise((resolve, reject) => { + const config: Realm.Configuration = { + schema: getSchema(useFlexibleSync), + sync: { + user: this.user, + ...(useFlexibleSync ? { flexible: true } : { partitionValue: getPartitionValue() }), + error: () => { + resolve(); + }, + clientReset: { + mode: ClientResetMode.DiscardUnsyncedChanges, + onBefore: () => { + reject(); + }, + onAfter: () => { + reject(); + }, + }, + }, + }; + + const realm = new Realm(config); + if (useFlexibleSync) { + addSubscriptions(realm); + } + const session = realm.syncSession; + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore calling undocumented method _simulateError + session._simulateError(132, "Simulate Client Reset", "realm::sync::ProtocolError", true); // 132 -> automatic client reset failed + }); + }); + + it(`handles discard local simulated client reset with ${getPartialTestTitle( + useFlexibleSync, + )} sync enabled`, async function (this: RealmContext) { + // (i) using a client reset in "DiscardUnsyncedChanges" mode, a fresh copy + // of the Realm will be downloaded (resync) + // (ii) two callback will be called, while the sync error handler is not + // (iii) after the reset, the Realm can be used as before + + const clientResetBefore = (realm: Realm) => { + expect(realm.objects(DogSchema.name).length).to.equal(1); + }; + const clientResetAfter = (beforeRealm: Realm, afterRealm: Realm) => { + expect(beforeRealm.objects(DogSchema.name).length).to.equal(1); + expect(afterRealm.objects(DogSchema.name).length).to.equal(1); + }; + + await waitSimulatedClientResetDiscardUnsyncedChangesCallbacks( + useFlexibleSync, + getSchema(useFlexibleSync), + this.user, + clientResetBefore, + clientResetAfter, + ); + }); + + it(`handles simulated client reset with recovery with ${getPartialTestTitle( + useFlexibleSync, + )} sync enabled`, async function (this: RealmContext) { + const clientResetBefore = (realm: Realm): void => { + expect(realm.objects(DogSchema.name).length).to.equal(1); + }; + const clientResetAfter = (beforeRealm: Realm, afterRealm: Realm) => { + expect(beforeRealm.objects(DogSchema.name).length).to.equal(1); + expect(afterRealm.objects(DogSchema.name).length).to.equal(1); + }; + + await waitSimulatedClientResetRecoverCallbacks( + useFlexibleSync, + getSchema(useFlexibleSync), + this.user, + clientResetBefore, + clientResetAfter, + ); + }); + + it.skip(`handles discard local client reset with ${getPartialTestTitle( + useFlexibleSync, + )} sync enabled`, async function (this: RealmContext) { + // (i) using a client reset in "DiscardUnsyncedChanges" mode, a fresh copy + // of the Realm will be downloaded (resync) + // (ii) two callback will be called, while the sync error handler is not + // (iii) after the reset, the Realm can be used as before + + const clientResetBefore = (realm: Realm) => { + expect(realm.objects(DogSchema.name).length).to.equal(1); + }; + const clientResetAfter = (beforeRealm: Realm, afterRealm: Realm) => { + expect(beforeRealm.objects(DogSchema.name).length).to.equal(1); + expect(afterRealm.objects(DogSchema.name).length).to.equal(1); + }; + + await waitServerSideClientResetDiscardUnsyncedChangesCallbacks( + useFlexibleSync, + getSchema(useFlexibleSync), + this.app, + this.user, + clientResetBefore, + clientResetAfter, + ); + }); + + it.skip(`handles recovery client reset with ${getPartialTestTitle( + useFlexibleSync, + )} sync enabled`, async function (this: RealmContext) { + // (i) using a client reset in "Recovery" mode, a fresh copy + // of the Realm will be downloaded (resync) + // (ii) two callback will be called, while the sync error handler is not + // (iii) after the reset, the Realm can be used as before + this.timeout(900 * 1000); + const clientResetBefore = (realm: Realm) => { + expect(realm.objects(DogSchema.name).length).to.equal(1); + }; + const clientResetAfter = (beforeRealm: Realm, afterRealm: Realm) => { + expect(beforeRealm.objects(DogSchema.name).length).to.equal(1); + expect(afterRealm.objects(DogSchema.name).length).to.equal(1); + }; + + await waitServerSideClientResetRecoveryCallbacks( + useFlexibleSync, + getSchema(useFlexibleSync), + this.app, + this.user, + clientResetBefore, + clientResetAfter, + ); + }); + }, + ); +}); diff --git a/integration-tests/tests/src/tests/sync/flexible.ts b/integration-tests/tests/src/tests/sync/flexible.ts index 03dec532b0..534103b72c 100644 --- a/integration-tests/tests/src/tests/sync/flexible.ts +++ b/integration-tests/tests/src/tests/sync/flexible.ts @@ -1630,7 +1630,7 @@ describe.skipIf(environment.missingServer, "Flexible sync", function () { }); }); - describe("client reset handling", function () { + describe("client reset handling for flexible sync", function () { it("handles manual client resets with flexible sync enabled", async function (this: RealmContext) { await expectClientResetError( { diff --git a/integration-tests/tests/src/tests/sync/mixed.ts b/integration-tests/tests/src/tests/sync/mixed.ts index 84451c9c31..9989becf31 100644 --- a/integration-tests/tests/src/tests/sync/mixed.ts +++ b/integration-tests/tests/src/tests/sync/mixed.ts @@ -229,12 +229,14 @@ function describeTypes(flexibleSync: boolean) { } describe.skipIf(environment.missingServer, "mixed", () => { - describe("parition-based sync roundtrip", function () { + describe("partition-based sync roundtrip", function () { + this.timeout(25 * 1000); importAppBefore("with-db"); describeTypes(false); }); describe.skipIf(environment.skipFlexibleSync, "flexible sync roundtrip", function () { + this.timeout(25 * 1000); importAppBefore("with-db-flx"); describeTypes(true); }); diff --git a/integration-tests/tests/src/utils/promise-handle.ts b/integration-tests/tests/src/utils/promise-handle.ts new file mode 100644 index 0000000000..185798dabc --- /dev/null +++ b/integration-tests/tests/src/utils/promise-handle.ts @@ -0,0 +1,38 @@ +//////////////////////////////////////////////////////////////////////////// +// +// Copyright 2022 Realm Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +//////////////////////////////////////////////////////////////////////////// + +type ResolveType = (value: T | PromiseLike) => void; +type RejectType = (reason?: any) => void; +type PromiseHandle = { + promise: Promise; + resolve: ResolveType; + reject: RejectType; +}; + +export function createPromiseHandle(): PromiseHandle { + let resolve: ResolveType | null = null; + let reject: RejectType | null = null; + const promise = new Promise((arg0, arg1) => { + resolve = arg0; + reject = arg1; + }); + if (!resolve || !reject) { + throw new Error("Expected promise executor to be called synchroniously"); + } + return { promise, resolve, reject }; +} diff --git a/lib/extensions.js b/lib/extensions.js index 9486bd610b..e581822b16 100644 --- a/lib/extensions.js +++ b/lib/extensions.js @@ -488,7 +488,9 @@ module.exports = function (realmConstructor) { realmConstructor.ClientResetMode = { Manual: "manual", - DiscardLocal: "discardLocal", + DiscardUnsyncedChanges: "discardUnsyncedChanges", + RecoverUnsyncedChanges: "recoverUnsyncedChanges", + RecoverOrDiscardUnsyncedChanges: "recoverOrDiscardUnsyncedChanges", }; realmConstructor.App.Sync.openLocalRealmBehavior = { diff --git a/src/js_sync.hpp b/src/js_sync.hpp index 0c45c5c482..dfd7ba29d2 100644 --- a/src/js_sync.hpp +++ b/src/js_sync.hpp @@ -20,11 +20,13 @@ #include #include +#include #include #include "js_class.hpp" #include "js_collection.hpp" #include "js_app.hpp" +#include "js_types.hpp" #include "js_user.hpp" #include "js_subscriptions.hpp" #include "logger.hpp" @@ -185,6 +187,11 @@ class ClientResetAfterFunctor { : m_ctx(Context::get_global_context(ctx)) , m_func(ctx, after_func) { +#if defined(REALM_PLATFORM_NODE) + // Suppressing destruct prevents a crash when closing an Electron app with a + // custom client reset handler: https://github.com/realm/realm-js/issues/4150 + m_func.SuppressDestruct(); +#endif } typename T::Function func() const @@ -192,24 +199,72 @@ class ClientResetAfterFunctor { return m_func; } - void operator()(SharedRealm before_realm, ThreadSafeReference after_realm_ref, bool) + void operator()(SharedRealm before_realm, ThreadSafeReference after_realm_ref, bool did_recover) { - HANDLESCOPE(m_ctx) + HANDLESCOPE(m_ctx); + typename T::Value arguments[2]; SharedRealm after_realm = Realm::get_shared_realm(std::move(after_realm_ref), util::Scheduler::make_default()); - - typename T::Value arguments[] = { - create_object>(m_ctx, new SharedRealm(before_realm)), - create_object>(m_ctx, new SharedRealm(after_realm)), - }; + arguments[0] = create_object>(m_ctx, new SharedRealm(before_realm)); + arguments[1] = create_object>(m_ctx, new SharedRealm(after_realm)); Function::callback(m_ctx, m_func, 2, arguments); } private: const Protected m_ctx; - const Protected m_func; + Protected m_func; +}; + + +template +class ClientResetAfterRecoveryOrDiscardFunctor { +public: + ClientResetAfterRecoveryOrDiscardFunctor(typename T::Context ctx, typename T::Function after_func, + typename T::Function discard_func) + : m_ctx(Context::get_global_context(ctx)) + , m_func(ctx, after_func) + , m_discard_func(ctx, discard_func) + { +#if defined(REALM_PLATFORM_NODE) + // Suppressing destruct prevents a crash when closing an Electron app with a + // custom client reset handlers: https://github.com/realm/realm-js/issues/4150 + m_func.SuppressDestruct(); + m_discard_func.SuppressDestruct(); +#endif + } + + typename T::Function func() const + { + return m_func; + } + + void operator()(SharedRealm before_realm, ThreadSafeReference after_realm_ref, bool did_recover) + { + HANDLESCOPE(m_ctx); + typename T::Value arguments[2]; + + SharedRealm after_realm = + Realm::get_shared_realm(std::move(after_realm_ref), util::Scheduler::make_default()); + + + if (did_recover) { + arguments[0] = create_object>(m_ctx, new SharedRealm(before_realm)); + arguments[1] = create_object>(m_ctx, new SharedRealm(after_realm)); + Function::callback(m_ctx, m_func, 2, arguments); + } + else { + arguments[0] = create_object>(m_ctx, new WeakSession(before_realm->sync_session())); + arguments[1] = Value::from_string(m_ctx, before_realm->config().path); + Function::callback(m_ctx, m_discard_func, 2, arguments); + } + } + +private: + const Protected m_ctx; + Protected m_func; + Protected m_discard_func; }; template @@ -219,6 +274,11 @@ class ClientResetBeforeFunctor { : m_ctx(Context::get_global_context(ctx)) , m_func(ctx, before_func) { +#if defined(REALM_PLATFORM_NODE) + // Suppressing destruct prevents a crash when closing an Electron app with a + // custom client reset handler: https://github.com/realm/realm-js/issues/4150 + m_func.SuppressDestruct(); +#endif } typename T::Function func() const @@ -228,7 +288,7 @@ class ClientResetBeforeFunctor { void operator()(SharedRealm local_realm) { - HANDLESCOPE(m_ctx) + HANDLESCOPE(m_ctx); typename T::Value arguments[] = { create_object>(m_ctx, new SharedRealm(local_realm)), @@ -239,11 +299,125 @@ class ClientResetBeforeFunctor { private: const Protected m_ctx; - const Protected m_func; + Protected m_func; }; template -class SyncSessionErrorHandlerFunctor { +class SyncSessionErrorBase { +public: + virtual typename T::Function func() + { + return typename T::Function(); + }; + virtual void operator()(std::shared_ptr, SyncError){}; +}; + +template +class SyncSessionClientResetManualFunctor : public SyncSessionErrorBase { +public: + SyncSessionClientResetManualFunctor(typename T::Context ctx, typename T::Function client_reset_func) + : m_ctx(Context::get_global_context(ctx)) + , m_client_reset_func(ctx, client_reset_func) + { +#if defined(REALM_PLATFORM_NODE) + // Suppressing destruct prevents a crash when closing an Electron app with a + // custom client reset handler handler: https://github.com/realm/realm-js/issues/4150 + m_client_reset_func.SuppressDestruct(); +#endif + } + + typename T::Function func() const + { + return m_client_reset_func; + } + + void operator()(std::shared_ptr session, SyncError error) + { + HANDLESCOPE(m_ctx); + + if (error.is_client_reset_requested()) { + typename T::Value arguments[] = { + create_object>(m_ctx, new WeakSession(session)), + Value::from_string(m_ctx, error.user_info[SyncError::c_recovery_file_path_key]), + }; + Function::callback(m_ctx, m_client_reset_func, 2, arguments); + } + } + +private: + const Protected m_ctx; + Protected m_client_reset_func; +}; + +template +class SyncSessionErrorAndClientResetManualFunctor : SyncSessionErrorBase { +public: + SyncSessionErrorAndClientResetManualFunctor(typename T::Context ctx, typename T::Function error_func, + typename T::Function client_reset_func) + : m_ctx(Context::get_global_context(ctx)) + , m_func(ctx, error_func) + , m_client_reset_func(ctx, client_reset_func) + { +#if defined(REALM_PLATFORM_NODE) + // Suppressing destruct prevents a crash when closing an Electron app with a + // custom sync error handler: https://github.com/realm/realm-js/issues/4150 + m_func.SuppressDestruct(); + m_client_reset_func.SuppressDestruct(); +#endif + } + + typename T::Function func() const + { + return m_func; + } + + void operator()(std::shared_ptr session, SyncError error) + { + HANDLESCOPE(m_ctx); + + if (error.is_client_reset_requested()) { + typename T::Value arguments[] = { + create_object>(m_ctx, new WeakSession(session)), + Value::from_string(m_ctx, error.user_info[SyncError::c_recovery_file_path_key]), + }; + Function::callback(m_ctx, m_client_reset_func, 2, arguments); + } + else { + std::string name = "Error"; + auto error_object = Object::create_empty(m_ctx); + + Object::set_property(m_ctx, error_object, "name", Value::from_string(m_ctx, name)); + Object::set_property(m_ctx, error_object, "message", Value::from_string(m_ctx, error.message)); + Object::set_property(m_ctx, error_object, "isFatal", Value::from_boolean(m_ctx, error.is_fatal)); + Object::set_property(m_ctx, error_object, "category", + Value::from_string(m_ctx, error.error_code.category().name())); + Object::set_property(m_ctx, error_object, "code", + Value::from_number(m_ctx, error.error_code.value())); + + auto user_info = Object::create_empty(m_ctx); + for (auto& kvp : error.user_info) { + Object::set_property(m_ctx, user_info, kvp.first, Value::from_string(m_ctx, kvp.second)); + } + Object::set_property(m_ctx, error_object, "userInfo", user_info); + + typename T::Value arguments[] = { + create_object>(m_ctx, new WeakSession(session)), + error_object, + }; + + Function::callback(m_ctx, m_func, 2, arguments); + } + } + +private: + const Protected m_ctx; + Protected m_func; + Protected m_client_reset_func; +}; + + +template +class SyncSessionErrorHandlerFunctor : SyncSessionErrorBase { public: SyncSessionErrorHandlerFunctor(typename T::Context ctx, typename T::Function error_func) : m_ctx(Context::get_global_context(ctx)) @@ -263,7 +437,7 @@ class SyncSessionErrorHandlerFunctor { void operator()(std::shared_ptr session, SyncError error) { - HANDLESCOPE(m_ctx) + HANDLESCOPE(m_ctx); std::string name = "Error"; auto error_object = Object::create_empty(m_ctx); @@ -301,7 +475,7 @@ class SyncSessionErrorHandlerFunctor { private: const Protected m_ctx; - Protected m_func; + Protected m_func; // general error callback }; @@ -355,7 +529,7 @@ class SSLVerifyCallbackSyncThreadFunctor { const std::string& pem_certificate, int preverify_ok, int depth) { const Protected& ctx = this_object->m_ctx; - HANDLESCOPE(ctx) + HANDLESCOPE(ctx); typename T::Object ssl_certificate_object = Object::create_empty(ctx); @@ -438,13 +612,6 @@ void SessionClass::get_config(ContextType ctx, ObjectType object, ReturnValue Value::from_string(ctx, String::from_bson(partition_value_bson))); } - auto conf = session->config(); - if (auto dispatcher = - conf.error_handler.template target>()) { - if (auto handler = dispatcher->func().template target>()) { - Object::set_property(ctx, config, "onError", handler->func()); - } - } if (!session->config().custom_http_headers.empty()) { ObjectType custom_http_headers_object = Object::create_empty(ctx); for (auto it = session->config().custom_http_headers.begin(); @@ -957,12 +1124,14 @@ void SyncClass::populate_sync_config(ContextType ctx, ObjectType realm_constr else if (!Value::is_undefined(ctx, sync_config_value)) { auto sync_config_object = Value::validated_to_object(ctx, sync_config_value); - std::function error_handler; - ValueType error_func = Object::get_property(ctx, sync_config_object, "onError"); - if (!Value::is_undefined(ctx, error_func)) { - error_handler = util::EventLoopDispatcher( - SyncSessionErrorHandlerFunctor(ctx, Value::validated_to_function(ctx, error_func))); - } + // how the error handler will actually look like depends on the client reset mode + // see the parsing of the client reset subconfiguration below + // if the mode is "manual" + // a) the error handler will be initialized with the callback if it exists + // b) if the error handler is not specified, the callback will be wrap as an error handler + // c) if no callback and no error handler, an exception is thrown + // otherwise, the error handler is used as it is + ValueType error_func = Object::get_property(ctx, sync_config_object, "error"); ObjectType user_object = Object::validated_get_object(ctx, sync_config_object, "user"); if (!(Object::template is_instance>(ctx, user_object))) { @@ -988,8 +1157,6 @@ void SyncClass::populate_sync_config(ContextType ctx, ObjectType realm_constr config.sync_config = std::make_shared(user->m_user, std::move(partition_value)); } - config.sync_config->error_handler = std::move(error_handler); - SyncSessionStopPolicy session_stop_policy = SyncSessionStopPolicy::AfterChangesUploaded; ValueType session_stop_policy_value = Object::get_property(ctx, sync_config_object, "_sessionStopPolicy"); if (!Value::is_undefined(ctx, session_stop_policy_value)) { @@ -1012,50 +1179,193 @@ void SyncClass::populate_sync_config(ContextType ctx, ObjectType realm_constr // Client reset // - // i) manual: the error handler is called with the proper error code and a client reset is initiated - // ii) discardLocal: the sync client handles it but notifications are send before and after + // i) manual: + // a) if a callback is registered, no error handler registred, the callback will wrapped and will be + // called b) if no callback is registered, the error handler is called with the proper error code and a + // client reset is initiated (old behavior) c) if callback and error handler error handler are + // registered, the callback will be called + // ii) discardUnsyncedChanges: the sync client handles it but notifications are send before and after + // iii) recoverUnsyncedChanges: as above + // iv) recoverOrDiscardUnsyncedChanges: as above // - // The default setting is discardLocal + // The default setting is recoverOrDiscardUnsyncedChanges - const std::string client_reset_manual = "manual"; - const std::string client_reset_discard_local = "discardLocal"; - config.sync_config->client_resync_mode = realm::ClientResyncMode::DiscardLocal; + config.sync_config->client_resync_mode = realm::ClientResyncMode::RecoverOrDiscard; ValueType client_reset_value = Object::get_property(ctx, sync_config_object, "clientReset"); if (!Value::is_undefined(ctx, client_reset_value)) { auto client_reset_object = Value::validated_to_object(ctx, client_reset_value); ValueType client_reset_mode_value = Object::get_property(ctx, client_reset_object, "mode"); if (!Value::is_undefined(ctx, client_reset_mode_value)) { std::string client_reset_mode = Value::validated_to_string(ctx, client_reset_mode_value, "mode"); - if (client_reset_mode == client_reset_manual) { - config.sync_config->client_resync_mode = realm::ClientResyncMode::Manual; + static std::unordered_map const client_reset_mode_map = { + {"manual", realm::ClientResyncMode::Manual}, + {"discardLocal", realm::ClientResyncMode::DiscardLocal}, // for backward compatibility + {"discardUnsyncedChanges", realm::ClientResyncMode::DiscardLocal}, + {"recoverUnsyncedChanges", realm::ClientResyncMode::Recover}, + {"recoverOrDiscardUnsyncedChanges", realm::ClientResyncMode::RecoverOrDiscard}}; + auto it = client_reset_mode_map.find(client_reset_mode); + if (it == client_reset_mode_map.end()) { + throw std::invalid_argument( + util::format("Unknown argument '%1' for clientReset.mode. Expected " + "'manual', 'discardUnsyncedChanges', 'recoverUnsyncedChanges', or " + "'recoverOrDiscardUnsyncedChanges'", + client_reset_mode)); } - else if (client_reset_mode == client_reset_discard_local) { - config.sync_config->client_resync_mode = realm::ClientResyncMode::DiscardLocal; + config.sync_config->client_resync_mode = it->second; + } + + switch (config.sync_config->client_resync_mode) { + case realm::ClientResyncMode::Manual: { + ValueType client_reset_after_value = Object::get_property(ctx, client_reset_object, "onManual"); + if (!Value::is_undefined(ctx, client_reset_after_value)) { + auto client_reset_after_callback = + Value::validated_to_function(ctx, client_reset_after_value); + if (!Value::is_undefined(ctx, error_func)) { + auto error_handler = util::EventLoopDispatcher( + SyncSessionErrorAndClientResetManualFunctor( + ctx, Value::validated_to_function(ctx, error_func), client_reset_after_callback)); + config.sync_config->error_handler = std::move(error_handler); + } + else { + auto error_handler = util::EventLoopDispatcher( + SyncSessionClientResetManualFunctor(ctx, client_reset_after_callback)); + config.sync_config->error_handler = std::move(error_handler); + } + } + else { + if (!Value::is_undefined(ctx, error_func)) { + auto error_handler = + util::EventLoopDispatcher(SyncSessionErrorHandlerFunctor( + ctx, Value::validated_to_function(ctx, error_func))); + config.sync_config->error_handler = std::move(error_handler); + } + else { + throw std::invalid_argument("For clientReset: 'manual', it is require to set either " + "'error', 'clientReset.onManual' or both"); + } + } + break; } - else { - throw std::invalid_argument(util::format( - "Unknown argument '%1' for clientReset.mode. Expected 'manual' or 'discardLocal'.", - client_reset_mode)); + + case realm::ClientResyncMode::DiscardLocal: { + ValueType client_reset_after_value = Object::get_property(ctx, client_reset_object, "onAfter"); + if (!Value::is_undefined(ctx, client_reset_after_value)) { + auto client_reset_after_callback = + Value::validated_to_function(ctx, client_reset_after_value); + auto client_reset_after_handler = + util::EventLoopDispatcher( + ClientResetAfterFunctor(ctx, client_reset_after_callback)); + config.sync_config->notify_after_client_reset = std::move(client_reset_after_handler); + } + + ValueType client_reset_before_value = Object::get_property(ctx, client_reset_object, "onBefore"); + if (!Value::is_undefined(ctx, client_reset_before_value)) { + auto client_reset_before_callback = + Value::validated_to_function(ctx, client_reset_before_value); + auto client_reset_before_handler = util::EventLoopDispatcher( + ClientResetBeforeFunctor(ctx, client_reset_before_callback)); + config.sync_config->notify_before_client_reset = std::move(client_reset_before_handler); + } + + if (!Value::is_undefined(ctx, error_func)) { + auto error_handler = util::EventLoopDispatcher( + SyncSessionErrorHandlerFunctor(ctx, Value::validated_to_function(ctx, error_func))); + config.sync_config->error_handler = std::move(error_handler); + } + break; } - std::function client_reset_before_handler; - ValueType client_reset_before_value = Object::get_property(ctx, client_reset_object, "onBefore"); - if (!Value::is_undefined(ctx, client_reset_before_value)) { - client_reset_before_handler = - util::EventLoopDispatcher(ClientResetBeforeFunctor( - ctx, Value::validated_to_function(ctx, client_reset_before_value))); + case realm::ClientResyncMode::Recover: { + ValueType client_reset_after_value = Object::get_property(ctx, client_reset_object, "onAfter"); + if (!Value::is_undefined(ctx, client_reset_after_value)) { + auto client_reset_after_callback = + Value::validated_to_function(ctx, client_reset_after_value); + auto client_reset_after_handler = + util::EventLoopDispatcher( + ClientResetAfterFunctor(ctx, client_reset_after_callback)); + config.sync_config->notify_after_client_reset = std::move(client_reset_after_handler); + } + + ValueType client_reset_before_value = Object::get_property(ctx, client_reset_object, "onBefore"); + if (!Value::is_undefined(ctx, client_reset_before_value)) { + auto client_reset_before_handler = + util::EventLoopDispatcher(ClientResetBeforeFunctor( + ctx, Value::validated_to_function(ctx, client_reset_before_value))); + config.sync_config->notify_before_client_reset = std::move(client_reset_before_handler); + } + + if (!Value::is_undefined(ctx, error_func)) { + auto error_handler = util::EventLoopDispatcher( + SyncSessionErrorHandlerFunctor(ctx, Value::validated_to_function(ctx, error_func))); + config.sync_config->error_handler = std::move(error_handler); + } + break; } - config.sync_config->notify_before_client_reset = std::move(client_reset_before_handler); - std::function client_reset_after_handler; - ValueType client_reset_after_value = Object::get_property(ctx, client_reset_object, "onAfter"); - if (!Value::is_undefined(ctx, client_reset_after_value)) { - client_reset_after_handler = + case realm::ClientResyncMode::RecoverOrDiscard: { + FunctionType client_reset_recovery_callback; + FunctionType client_reset_discard_callback; + + ValueType client_reset_after_value = Object::get_property(ctx, client_reset_object, "onDiscard"); + if (!Value::is_undefined(ctx, client_reset_after_value)) { + client_reset_discard_callback = Value::validated_to_function(ctx, client_reset_after_value); + } + else { + throw std::invalid_argument("'onDiscard' is required"); + } + + ValueType client_reset_recovery_value = + Object::get_property(ctx, client_reset_object, "onRecovery"); + if (!Value::is_undefined(ctx, client_reset_after_value)) { + client_reset_recovery_callback = + Value::validated_to_function(ctx, client_reset_recovery_value); + } + else { + throw std::invalid_argument("'onRecovery' is required"); + } + + auto client_reset_after_handler = util::EventLoopDispatcher( - ClientResetAfterFunctor(ctx, - Value::validated_to_function(ctx, client_reset_after_value))); + ClientResetAfterRecoveryOrDiscardFunctor(ctx, client_reset_recovery_callback, + client_reset_discard_callback)); + config.sync_config->notify_after_client_reset = std::move(client_reset_after_handler); + + ValueType client_reset_before_value = Object::get_property(ctx, client_reset_object, "onBefore"); + if (!Value::is_undefined(ctx, client_reset_before_value)) { + auto client_reset_before_handler = + util::EventLoopDispatcher(ClientResetBeforeFunctor( + ctx, Value::validated_to_function(ctx, client_reset_before_value))); + config.sync_config->notify_before_client_reset = std::move(client_reset_before_handler); + } + + ValueType client_reset_fallback_value = + Object::get_property(ctx, client_reset_object, "onFallback"); + if (!Value::is_undefined(ctx, client_reset_fallback_value)) { + auto client_reset_fallback_callback = + Value::validated_to_function(ctx, client_reset_fallback_value); + if (!Value::is_undefined(ctx, error_func)) { + auto error_handler = util::EventLoopDispatcher( + SyncSessionErrorAndClientResetManualFunctor( + ctx, Value::validated_to_function(ctx, error_func), + client_reset_fallback_callback)); + config.sync_config->error_handler = std::move(error_handler); + } + else { + auto error_handler = util::EventLoopDispatcher( + SyncSessionClientResetManualFunctor(ctx, client_reset_fallback_callback)); + config.sync_config->error_handler = std::move(error_handler); + } + } + break; } - config.sync_config->notify_after_client_reset = std::move(client_reset_after_handler); + } + } + else { + // if sync.clientReset is not defined, use the error function + if (!Value::is_undefined(ctx, error_func)) { + auto error_handler = util::EventLoopDispatcher( + SyncSessionErrorHandlerFunctor(ctx, Value::validated_to_function(ctx, error_func))); + config.sync_config->error_handler = std::move(error_handler); } } diff --git a/tests/js/session-tests.js b/tests/js/session-tests.js index e18cdb9810..7235e889a5 100644 --- a/tests/js/session-tests.js +++ b/tests/js/session-tests.js @@ -371,10 +371,11 @@ module.exports = { _reject(e); } }; + config.sync.clientReset = { + mode: "manual", + }; const realm = new Realm(config); const session = realm.syncSession; - - TestCase.assertEqual(session.config.onError, config.sync.onError); session._simulateError(123, "simulated error", "realm::sync::ProtocolError", false); }); }); @@ -556,133 +557,6 @@ module.exports = { realm.close(); }, - testClientReset() { - // FIXME: try to enable for React Native - if (!platformSupported) { - return; - } - - const partition = Utils.genPartition(); - let creds = Realm.Credentials.anonymous(); - let app = new Realm.App(appConfig); - return app.logIn(creds).then((user) => { - return new Promise((resolve, _reject) => { - let realm; - const config = getSyncConfiguration(user, partition); - config.sync.onError = (sender, error) => { - try { - console.log(JSON.stringify(error)); - TestCase.assertEqual(error.name, "ClientReset"); - TestCase.assertEqual(error.message, "Simulate Client Reset"); - TestCase.assertEqual(error.code, 211); // 211 -> diverging histories - const path = realm.path; - realm.close(); - Realm.App.Sync.initiateClientReset(app, path); - // open Realm with error.config, and copy required objects a Realm at `path` - resolve(); - } catch (e) { - _reject(e); - } - }; - config.sync.clientReset = { - mode: "manual", - }; - realm = new Realm(config); - const session = realm.syncSession; - - TestCase.assertEqual(session.config.onError, config.sync.onError); - session._simulateError(211, "Simulate Client Reset", "realm::sync::ProtocolError", false); // 211 -> diverging histories - }); - }); - }, - - testClientResetDiscardLocalFailed() { - if (!platformSupported) { - return; - } - - // if client reset fails, the error handler is called - // and the two before/after handlers are not called - // we simulate the failure by error code 132 - const partition = Utils.genPartition(); - let creds = Realm.Credentials.anonymous(); - let app = new Realm.App(appConfig); - return new Promise((resolve, reject) => { - return app.logIn(creds).then((user) => { - const config = getSyncConfiguration(user, partition); - config.sync.clientReset = { - mode: "discardLocal", - onBefore: (realm) => { - console.log("XXXXXXXXXXXXXXXx this happen?"); - reject("clientResetBefore"); - }, - onAfter: (beforeRealm, afterRealm) => { - console.log("YYYYYYYYYYYYYYy or this?"); - reject("clientResetAfter"); - }, - }; - config.sync.onError = (sender, error) => { - resolve(); - }; - - Realm.open(config).then((r) => { - r.syncSession._simulateError(132, "Simulate Client Reset", "realm::sync::ProtocolError", true); // 132 -> automatic client reset failed - }); - }); - }); - }, - - testClientResetDiscardLocal() { - if (!platformSupported) { - return; - } - - // (i) using a client reset in "DiscardLocal" mode, a fresh copy - // of the Realm will be downloaded (resync) - // (ii) two callback will be called, while the sync error handler is not - // (iii) after the reset, the Realm can be used as before - - let beforeCalled = false; - let afterCalled = false; - - const partition = Utils.genPartition(); - let creds = Realm.Credentials.anonymous(); - let app = new Realm.App(appConfig); - return new Promise((resolve, reject) => { - return app.logIn(creds).then((user) => { - const config = getSyncConfiguration(user, partition); - config.sync.clientReset = { - mode: "discardLocal", - onBefore: (realm) => { - beforeCalled = true; - TestCase.assertEqual(realm.objects("Dog").length, 1, "local"); - }, - onAfter: (beforeRealm, afterRealm) => { - afterCalled = true; - TestCase.assertEqual(beforeRealm.objects("Dog").length, 1, "local"); - TestCase.assertEqual(afterRealm.objects("Dog").length, 1, "after"); - }, - }; - config.sync.onError = (sender, error) => { - reject(`error: ${JSON.stringify(error)}`); - }; - - Realm.open(config).then((r) => { - r.write(() => { - r.create("Dog", { _id: new ObjectId(), name: "Lassy" }); - }); - r.syncSession._simulateError(211, "Simulate Client Reset", "realm::sync::ProtocolError", false); - setTimeout(() => { - TestCase.assertTrue(beforeCalled, "before"); - TestCase.assertTrue(afterCalled, "after"); - TestCase.assertEqual(r.objects("Dog").length, 1); - resolve(); - }, 1000); - }); - }); - }); - }, - testAddConnectionNotification() { const partition = Utils.genPartition(); let app = new Realm.App(appConfig); @@ -1308,6 +1182,7 @@ module.exports = { _sessionStopPolicy: "immediately", // Make it safe to delete files after realm.close() clientReset: { mode: "manual", + onManual: () => console.log("error"), }, }, schema: [schemas.PersonForSync, schemas.DogForSync], @@ -1356,7 +1231,7 @@ module.exports = { 13) synced, unencrypted Realm -> synced, unencrypted Realm 14) synced, unencrypted Realm -> synced, encrypted Realm - 15) synced, encrypted Realm -> synced, unencrypted Realm + 15) synced, encrypted Realm -> synced, unencrypted Relam 16) synced, encrypted Realm -> synced, encrypted Realm */ diff --git a/types/index.d.ts b/types/index.d.ts index de2176b336..1a60d6a4fb 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -146,17 +146,41 @@ declare namespace Realm { } enum ClientResetMode { - Manual = "manual", - DiscardLocal = "discardLocal", + Manual = 'manual', + DiscardLocal = 'discardLocal', // for backward compatibility + DiscardUnsyncedChanges = 'discardUnsyncedChanges', + RecoverUnsyncedChanges = 'recoverUnsyncedChanges', + RecoverOrDiscardUnsyncedChanges = 'recoverOrDiscardUnsyncedChanges' } + type ClientResetFallbackCallback = (session: Realm.App.Sync.Session, path: string) => void; type ClientResetBeforeCallback = (localRealm: Realm) => void; type ClientResetAfterCallback = (localRealm: Realm, remoteRealm: Realm) => void; - interface ClientResetConfiguration { - mode: ClientResetModeT; - onBefore?: ClientResetBeforeCallback; - onAfter?: ClientResetAfterCallback; + interface ClientResetManualConfiguration { + mode: ClientResetMode.Manual; + onManual?: ClientResetFallbackCallback; } + interface ClientResetDiscardUnsyncedChangesConfiguration { + mode: ClientResetMode.DiscardLocal | ClientResetMode.DiscardUnsyncedChanges; + onBefore?: ClientResetBeforeCallback; + onAfter?: ClientResetAfterCallback; + } + + interface ClientResetRecoveryConfiguration { + mode: ClientResetMode.RecoverUnsyncedChanges; + onBefore?: ClientResetBeforeCallback; + onAfter?: ClientResetAfterCallback; + onFallback?: ClientResetFallbackCallback; + } + interface ClientResetRecoveryOrDiscardConfiguration { + mode: ClientResetMode.RecoverOrDiscardUnsyncedChanges; + onBefore?: ClientResetBeforeCallback; + onRecovery: ClientResetAfterCallback; + onDiscard: ClientResetAfterCallback; + onFallback?: ClientResetFallbackCallback; + } + + type ClientResetConfiguration = ClientResetManualConfiguration | ClientResetDiscardUnsyncedChangesConfiguration | ClientResetRecoveryConfiguration | ClientResetRecoveryOrDiscardConfiguration; interface BaseSyncConfiguration{ user: User; @@ -166,6 +190,7 @@ declare namespace Realm { newRealmFileBehavior?: OpenRealmBehaviorConfiguration; existingRealmFileBehavior?: OpenRealmBehaviorConfiguration; onError?: ErrorCallback; + clientReset?: ClientResetConfiguration; } // We only allow `flexible` to be `true` or `undefined` - `{ flexible: false }` @@ -175,7 +200,6 @@ declare namespace Realm { interface FlexibleSyncConfiguration extends BaseSyncConfiguration { flexible: true; partitionValue?: never; - clientReset?: ClientResetConfiguration; /** * Optional object to configure the setup of an initial set of flexible * sync subscriptions to be used when opening the Realm. If this is specified, @@ -222,7 +246,6 @@ declare namespace Realm { interface PartitionSyncConfiguration extends BaseSyncConfiguration { flexible?: never; partitionValue: Realm.App.Sync.PartitionValue; - clientReset?: ClientResetConfiguration; } type SyncConfiguration = FlexibleSyncConfiguration | PartitionSyncConfiguration;