diff --git a/CHANGELOG.md b/CHANGELOG.md index 2aeff74d..d4667380 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,8 @@ x.x.x Release notes (yyyy-MM-dd) ============================================================= ### Enhancements -* None. +* Added `Realm.subscriptions()` to query active query-based sync subscriptions. This method is in beta and might change in future releases. ([#2052](https://github.com/realm/realm-js/issues/2052)) +* Added `Realm.unsubscribe()` to unsubscribe by name an active query-based sync subscription. This method is in beta and might change in future releases. ([#2052](https://github.com/realm/realm-js/issues/2052)) ### Fixed * ([#????](https://github.com/realm/realm-js/issues/????), since v?.?.?) @@ -15,7 +16,7 @@ x.x.x Release notes (yyyy-MM-dd) ### Internal * None. -2.19.0 Release notes (2018-10-9) +2.19.0-rc.1 Release notes (2018-10-9) ============================================================= ### Enhancements * Added `SyncConfig.customQueryBasedSyncIdentifier` to allow customizing the identifier appended to the realm path when opening a query based Realm. This identifier is used to distinguish between query based Realms opened on different devices and by default Realm builds it as a combination of a user's id and a random string, allowing the same user to subscribe to different queries on different devices. In very rare cases, you may want to share query based Realms between devices and specifying the `customQueryBasedSyncIdentifier` allows you to do that. diff --git a/docs/realm.js b/docs/realm.js index f2b8612b..af920f2a 100644 --- a/docs/realm.js +++ b/docs/realm.js @@ -331,6 +331,22 @@ class Realm { * @throws {Error} If anything in the provided `config` is invalid. */ static deleteFile(config) {} + + /** + * Get a list of subscriptions. THIS METHOD IS IN BETA AND MAY CHANGE IN FUTURE VERSIONS. + * @param {string} name - Optional parameter to query for either a specific name or pattern (using + * cards `?` and `*`). + * @throws {Error} If `name` is not a string. + * @returns an array of objects of (`name`, `objectType`, `query`). + */ + subscriptions(name) {} + + /** + * Unsubscribe a named subscription. THIS METHOD IS IN BETA AND MAY CHANGE IN FUTURE VERSIONS. + * @param {string} name - The name of the subscription. + * @throws {Error} If `name` is not a string or an empty string. + */ + unsubscribe(name) {} } /** * This describes the different options used to create a {@link Realm} instance. diff --git a/lib/browser/subscription.js b/lib/browser/subscription.js index bfc70508..55085ead 100644 --- a/lib/browser/subscription.js +++ b/lib/browser/subscription.js @@ -27,10 +27,11 @@ export default class Subscription { Object.defineProperties(Subscription.prototype, { error: { get: getterForProperty('error') }, - state: { get: getterForProperty('state') } + state: { get: getterForProperty('state') }, + name: { get: getterForProperty('name') } }); -// // Non-mutating methods: +// Non-mutating methods: createMethods(Subscription.prototype, objectTypes.SUBSCRIPTION, [ 'unsubscribe', 'addListener', diff --git a/lib/extensions.js b/lib/extensions.js index 410d00c0..dbbb7f31 100644 --- a/lib/extensions.js +++ b/lib/extensions.js @@ -54,7 +54,7 @@ function waitForDownloadConfig(config) { /** * Finds the permissions associated with a given Role or create them as needed. - * + * * @param {RealmObject} Container RealmObject holding the permission list. * @param {List} list of permissions. * @param {string} name of the role to find or create permissions for. @@ -363,6 +363,30 @@ module.exports = function(realmConstructor) { }); } + const ResultSets = function() {}; + ResultSets.schema = Object.freeze({ + name: '__ResultSets', + properties: { + name: { type: 'string', indexed: true }, + query: 'string', + matches_property: 'string', + status: 'int', + error_message: 'string', + query_parse_counter: 'int' + } + }); + + const subscriptionSchema = { + 'ResultSets': ResultSets + }; + + if (!realmConstructor.Subscription) { + Object.defineProperty(realmConstructor, 'Subscription', { + value: subscriptionSchema, + configurable: false, + }); + } + // Add instance methods to the Realm object that are only applied if Sync is Object.defineProperties(realmConstructor.prototype, getOwnPropertyDescriptors({ permissions(arg) { @@ -379,6 +403,55 @@ module.exports = function(realmConstructor) { return classPermissions[0]; } }, + + subscriptions(name) { + let all_subscriptions = this.objects('__ResultSets'); + if (name) { + if (typeof(name) !== 'string') { + throw new Error(`string expected - got ${typeof(name)}.`); + } + if (name.includes('*') || name.includes('?')) { + all_subscriptions = all_subscriptions.filtered(`name LIKE '${name}'`); + } else { + all_subscriptions = all_subscriptions.filtered(`name == '${name}'`); + } + } + let listOfSubscriptions = []; + for (var subscription of all_subscriptions) { + let matches_property = subscription['matches_property']; + let sub = { + name: subscription['name'], + objectType: matches_property.substr(0, matches_property.length-8), // remove _matches + query: subscription['query'], + } + listOfSubscriptions.push(sub); + } + return listOfSubscriptions; + }, + + unsubscribe(name) { + if (typeof(name) === 'string') { + if (name !== '') { + let named_subscriptions = this.objects('__ResultSets').filtered(`name == '${name}'`); + if (named_subscriptions.length === 0) { + return; + } + let doCommit = false; + if (!this.isInTransaction) { + this.beginTransaction(); + doCommit = true; + } + this.delete(named_subscriptions); + if (doCommit) { + this.commitTransaction(); + } + } else { + throw new Error('Non-empty string expected.'); + } + } else { + throw new Error(`string expected - got ${typeof(name)}.`); + } + } })); } @@ -391,8 +464,8 @@ module.exports = function(realmConstructor) { * but still need to modify any input config. */ _constructor(config) { - // Even though this runs code only available for Sync it requires some serious misconfiguration - // for this to happen + // Even though this runs code only available for Sync it requires some serious misconfiguration + // for this to happen if (config && config.sync) { if (!Realm.Sync) { throw new Error("Realm is not compiled with Sync, but the configuration contains sync features."); @@ -408,6 +481,7 @@ module.exports = function(realmConstructor) { addSchemaIfNeeded(config.schema, realmConstructor.Permissions.Realm); addSchemaIfNeeded(config.schema, realmConstructor.Permissions.Role); addSchemaIfNeeded(config.schema, realmConstructor.Permissions.User); + addSchemaIfNeeded(config.schema, realmConstructor.Subscription.ResultSets); } } return config; diff --git a/lib/index.d.ts b/lib/index.d.ts index ef6178e7..8a7b04ab 100644 --- a/lib/index.d.ts +++ b/lib/index.d.ts @@ -649,6 +649,12 @@ interface ProgressPromise extends Promise { progress(callback: Realm.Sync.ProgressNotificationCallback): Promise } +interface NamedSubscription { + name: string, + objectType: string, + query: string +} + declare class Realm { static defaultPath: string; @@ -819,6 +825,8 @@ declare class Realm { permissions() : Realm.Permissions.Realm; permissions(objectType: string | Realm.ObjectSchema | Function) : Realm.Permissions.Class; + subscriptions(name?: string): NamedSubscription[]; + unsubscribe(name: string): void; } declare module 'realm' { diff --git a/src/js_results.hpp b/src/js_results.hpp index f2ab1772..057293ec 100644 --- a/src/js_results.hpp +++ b/src/js_results.hpp @@ -308,7 +308,7 @@ void ResultsClass::subscribe(ContextType ctx, ObjectType this_object, Argumen } auto subscription = partial_sync::subscribe(*results, subscription_name); - return_value.set(SubscriptionClass::create_instance(ctx, std::move(subscription))); + return_value.set(SubscriptionClass::create_instance(ctx, std::move(subscription), subscription_name)); } #endif diff --git a/src/js_sync.hpp b/src/js_sync.hpp index 5b0fbea9..bdef3467 100644 --- a/src/js_sync.hpp +++ b/src/js_sync.hpp @@ -688,9 +688,10 @@ void SessionClass::override_server(ContextType ctx, ObjectType this_object, A template class Subscription : public partial_sync::Subscription { public: - Subscription(partial_sync::Subscription s) : partial_sync::Subscription(std::move(s)) {} + Subscription(partial_sync::Subscription s, util::Optional name) : partial_sync::Subscription(std::move(s)), m_name(name) {} Subscription(Subscription &&) = default; + util::Optional m_name; std::vector, partial_sync::SubscriptionNotificationToken>> m_notification_tokens; }; @@ -712,10 +713,11 @@ public: std::string const name = "Subscription"; static FunctionType create_constructor(ContextType); - static ObjectType create_instance(ContextType, partial_sync::Subscription); + static ObjectType create_instance(ContextType, partial_sync::Subscription, util::Optional); static void get_state(ContextType, ObjectType, ReturnValue &); static void get_error(ContextType, ObjectType, ReturnValue &); + static void get_name(ContextType, ObjectType, ReturnValue &); static void unsubscribe(ContextType, ObjectType, Arguments, ReturnValue &); static void add_listener(ContextType, ObjectType, Arguments, ReturnValue &); @@ -724,7 +726,8 @@ public: PropertyMap const properties = { {"state", {wrap, nullptr}}, - {"error", {wrap, nullptr}} + {"error", {wrap, nullptr}}, + {"name", {wrap, nullptr}}, }; MethodMap const methods = { @@ -736,8 +739,8 @@ public: }; template -typename T::Object SubscriptionClass::create_instance(ContextType ctx, partial_sync::Subscription subscription) { - return create_object>(ctx, new Subscription(std::move(subscription))); +typename T::Object SubscriptionClass::create_instance(ContextType ctx, partial_sync::Subscription subscription, util::Optional name) { + return create_object>(ctx, new Subscription(std::move(subscription), name)); } template @@ -762,6 +765,18 @@ void SubscriptionClass::get_error(ContextType ctx, ObjectType object, ReturnV } } +template +void SubscriptionClass::get_name(ContextType ctx, ObjectType object, ReturnValue &return_value) { + auto subscription = get_internal>(object); + if (subscription->m_name == util::none) { + // FIXME: should we reconstruct the name to match the one stored in __ResultSets? + return_value.set_undefined(); + } + else { + return_value.set(subscription->m_name); + } +} + template void SubscriptionClass::unsubscribe(ContextType ctx, ObjectType this_object, Arguments args, ReturnValue &return_value) { args.validate_maximum(0); diff --git a/src/object-store b/src/object-store index b0fc2814..85cc4f3f 160000 --- a/src/object-store +++ b/src/object-store @@ -1 +1 @@ -Subproject commit b0fc2814d9e6061ce5ba1da887aab6cfba4755ca +Subproject commit 85cc4f3fc78fb905060489d6e51c2503d770393b diff --git a/tests/js/permission-tests.js b/tests/js/permission-tests.js index 0d65a87d..4987d877 100644 --- a/tests/js/permission-tests.js +++ b/tests/js/permission-tests.js @@ -289,7 +289,7 @@ module.exports = { let realm = new Realm(config); TestCase.assertTrue(realm.empty); - TestCase.assertEqual(realm.schema.length, 5); + TestCase.assertEqual(realm.schema.length, 5 + 1); // 5 = see below, 1 = __ResultSets TestCase.assertEqual(realm.schema.filter(schema => schema.name === '__Class').length, 1); TestCase.assertEqual(realm.schema.filter(schema => schema.name === '__Permission').length, 1); TestCase.assertEqual(realm.schema.filter(schema => schema.name === '__Realm').length, 1); @@ -354,7 +354,7 @@ module.exports = { Realm.deleteFile(config); // connecting with an empty schema should be possible, permission is added implicitly Realm.open(user.createConfiguration()).then((realm) => { - return waitForUpload(realm); + return waitForUpload(realm); }).then((realm) => { return waitForDownload(realm); }).then((realm) => { @@ -381,7 +381,7 @@ module.exports = { return getPartialRealm().then(realm => { return new Promise((resolve, reject) => { let realmPermissions = realm.permissions(); - TestCase.assertEqual(2, realm.objects('__Role').length); // [ "everyone", "__User:" ] + TestCase.assertEqual(2, realm.objects('__Role').length); // [ "everyone", "__User:" ] realm.write(() => { let permissions = realmPermissions.findOrCreate("foo"); TestCase.assertEqual("foo", permissions.role.name); @@ -393,7 +393,7 @@ module.exports = { TestCase.assertFalse(permissions.canQuery); TestCase.assertFalse(permissions.canModifySchema); TestCase.assertFalse(permissions.canSetPermissions); - TestCase.assertEqual(3, realm.objects('__Role').length); // [ "everyone", "__User:", "foo" ] + TestCase.assertEqual(3, realm.objects('__Role').length); // [ "everyone", "__User:", "foo" ] }); resolve(); }); @@ -406,7 +406,7 @@ module.exports = { realm.write(() => { realm.create('__Role', {'name':'foo'}); }); - TestCase.assertEqual(3, realm.objects('__Role').length); // [ "everyone", "__User:xxx", "foo" ] + TestCase.assertEqual(3, realm.objects('__Role').length); // [ "everyone", "__User:xxx", "foo" ] let realmPermissions = realm.permissions(); realm.write(() => { @@ -419,7 +419,7 @@ module.exports = { TestCase.assertFalse(permissions.canQuery); TestCase.assertFalse(permissions.canModifySchema); TestCase.assertFalse(permissions.canSetPermissions); - TestCase.assertEqual(3, realm.objects('__Role').length); // [ "everyone", "__User:xxx", "foo" ] + TestCase.assertEqual(3, realm.objects('__Role').length); // [ "everyone", "__User:xxx", "foo" ] }); resolve(); }); @@ -430,7 +430,7 @@ module.exports = { return getPartialRealm().then(realm => { return new Promise((resolve, reject) => { let classPermissions = realm.permissions('__Class'); - TestCase.assertEqual(2, realm.objects('__Role').length); // [ "everyone", "__User:xxx" ] + TestCase.assertEqual(2, realm.objects('__Role').length); // [ "everyone", "__User:xxx" ] realm.write(() => { let permissions = classPermissions.findOrCreate("foo"); TestCase.assertEqual("foo", permissions.role.name); @@ -442,7 +442,7 @@ module.exports = { TestCase.assertFalse(permissions.canQuery); TestCase.assertFalse(permissions.canModifySchema); TestCase.assertFalse(permissions.canSetPermissions); - TestCase.assertEqual(3, realm.objects('__Role').length); // [ "everyone", "__User:xxx", "foo" ] + TestCase.assertEqual(3, realm.objects('__Role').length); // [ "everyone", "__User:xxx", "foo" ] }); resolve(); }); diff --git a/tests/js/realm-tests.js b/tests/js/realm-tests.js index 406c2445..747e5618 100644 --- a/tests/js/realm-tests.js +++ b/tests/js/realm-tests.js @@ -1264,6 +1264,7 @@ module.exports = { // FIXME: We need to test adding a property also calls the listener testSchemaUpdatesNewClass: function() { return new Promise((resolve, reject) => { + let called = false; let realm1 = new Realm({ _cache: false }); TestCase.assertTrue(realm1.empty); TestCase.assertEqual(realm1.schema.length, 0); // empty schema @@ -1274,6 +1275,7 @@ module.exports = { TestCase.assertEqual(schema[0].name, 'TestObject'); TestCase.assertEqual(realm1.schema.length, 1); TestCase.assertEqual(realm.schema[0].name, 'TestObject'); + called = true; }); const schema = [{ @@ -1291,7 +1293,11 @@ module.exports = { // in real world, a Realm will not be closed just after its // schema has been updated setTimeout(() => { - resolve(); + if (called) { + resolve(); + } else { + reject(); + } }, 1000); }); }, diff --git a/tests/js/session-tests.js b/tests/js/session-tests.js index c370dfbd..7161146f 100644 --- a/tests/js/session-tests.js +++ b/tests/js/session-tests.js @@ -870,6 +870,12 @@ module.exports = { Realm.deleteFile(config); realm = new Realm(config); + + let listener_called = false; + let schema_listener = function (realm, msg, newSchema) { + listener_called = true; + }; + realm.addListener('schema', schema_listener); const session = realm.syncSession; TestCase.assertInstanceOf(session, Realm.Sync.Session); TestCase.assertEqual(session.user.identity, user.identity); @@ -881,7 +887,7 @@ module.exports = { var subscription1 = results1.subscribe(); TestCase.assertEqual(subscription1.state, Realm.Sync.SubscriptionState.Creating); - var subscription2 = results2.subscribe(); + var subscription2 = results2.subscribe('foobar'); TestCase.assertEqual(subscription2.state, Realm.Sync.SubscriptionState.Creating); let called1 = false; @@ -893,15 +899,11 @@ module.exports = { TestCase.assertEqual(collection.length, 1); TestCase.assertTrue(collection[0].name === 'Lassy 1', "The object is not synced correctly"); results1.removeAllListeners(); - subscription1.unsubscribe(); + TestCase.assertUndefined(subscription1.name); called1 = true; }); } else if (state === Realm.Sync.SubscriptionState.Invalidated) { subscription1.removeAllListeners(); - if (called1 && called2) { - realm.close(); - resolve('Done'); - } } }); @@ -911,20 +913,50 @@ module.exports = { TestCase.assertEqual(collection.length, 1); TestCase.assertTrue(collection[0].name === 'Lassy 2', "The object is not synced correctly"); results2.removeAllListeners(); - subscription2.unsubscribe(); + TestCase.assertEqual(subscription2.name, 'foobar'); called2 = true; }); } else if (state === Realm.Sync.SubscriptionState.Invalidated) { subscription2.removeAllListeners(); - if (called1 && called2) { - realm.close(); - resolve('Done'); - } } }); setTimeout(() => { - reject("listeners never called"); + if (called1 && called2 && listener_called) { + let listOfSubscriptions = realm.subscriptions(); + TestCase.assertArrayLength(listOfSubscriptions, 2 + 5); // 2 = the two subscriptions, 5 = the permissions classes + TestCase.assertEqual(listOfSubscriptions[0]['name'], '[Dog] name == "Lassy 1" '); // the query is the default name; notice the trailing whitespace! + TestCase.assertEqual(listOfSubscriptions[0]['query'], 'name == "Lassy 1" '); // notice the trailing whitespace! + TestCase.assertEqual(listOfSubscriptions[0]['objectType'], 'Dog'); + TestCase.assertEqual(listOfSubscriptions[1]['name'], 'foobar'); + TestCase.assertEqual(listOfSubscriptions[1]['query'], 'name == "Lassy 2" '); // notice the trailing whitespace! + TestCase.assertEqual(listOfSubscriptions[1]['objectType'], 'Dog'); + + listOfSubscriptions = realm.subscriptions('foobar'); + TestCase.assertArrayLength(listOfSubscriptions, 1); + + listOfSubscriptions = realm.subscriptions('*bar'); + TestCase.assertArrayLength(listOfSubscriptions, 1); + + listOfSubscriptions = realm.subscriptions('RABOOF'); + TestCase.assertArrayLength(listOfSubscriptions, 0); + + subscription1.unsubscribe(); + realm.unsubscribe('foobar'); + realm.removeAllListeners(); + + // check if subscriptions have been removed + // subscription1.unsubscribe() requires a server round-trip so it might take a while + setTimeout(() => { + listOfSubscriptions = realm.subscriptions(); + TestCase.assertArrayLength(listOfSubscriptions, 5); // the 5 permissions classes + + realm.close(); + resolve(); + }, 10000); + } else { + reject("listeners never called"); + } }, 15000); }); });