diff --git a/.github/workflows/install-test-react-native.yml b/.github/workflows/install-test-react-native.yml index cedd73547a..d45d4d81a5 100644 --- a/.github/workflows/install-test-react-native.yml +++ b/.github/workflows/install-test-react-native.yml @@ -71,7 +71,7 @@ jobs: - name: Initialize app # Using "--skip-bundle-install" to let the setup-ruby action install the bundle # Using "--skip-pod-install" to ensure it happens after setup-ruby has executed - run: npm run init -- --skip-bundle-install --skip-pod-install --realm-version ${{ matrix.realm-version }} --react-native-version ${{ matrix.react-native-version }} --engine ${{ matrix.engine }} + run: npm run init -- --skip-bundle-install --skip-pod-install --realm-version ${{ matrix.realm-version }} --react-native-version ${{ matrix.react-native-version }} --engine ${{ matrix.engine }} --new-architecture ${{ matrix.new-architecture }} - uses: ruby/setup-ruby@v1 if: ${{ matrix.platform == 'ios' }} diff --git a/.github/workflows/package-unit-tests.yml b/.github/workflows/package-unit-tests.yml index b2b8058e3d..3c71535aa1 100644 --- a/.github/workflows/package-unit-tests.yml +++ b/.github/workflows/package-unit-tests.yml @@ -34,6 +34,11 @@ jobs: - uses: actions/setup-node@v3 with: node-version: 18 + # ninja-build is used by default if available and results in faster build times + - name: Install ninja + run: sudo apt-get install ninja-build + - name: ccache + uses: hendrikmuhs/ccache-action@v1 # Install the root package to get dev-dependencies # (--ignore-scripts to avoid downloading or building the native module) - run: npm ci --ignore-scripts diff --git a/CHANGELOG.md b/CHANGELOG.md index a36fe51e71..c561523a06 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,23 @@ * Access token refresh for websockets was not updating the location metadata ([realm/realm-core#6630](https://github.com/realm/realm-core/issues/6630), since v11.9.0) * Fix several UBSan failures which did not appear to result in functional bugs ([realm/realm-core#6649](https://github.com/realm/realm-core/pull/6649)). * Using both synchronous and asynchronous transactions on the same thread or scheduler could hit the assertion failure "!realm.is_in_transaction()" if one of the callbacks for an asynchronous transaction happened to be scheduled during a synchronous transaction ([realm/realm-core#6659](https://github.com/realm/realm-core/pull/6649), since v10.12.0) +* Added APIs to facilitate adding and removing subscriptions. ([#5772](https://github.com/realm/realm-js/pull/5772)) + * Experimental APIs: Enabled subscribing and unsubscribing directly to and from a `Results` instance via `Results.subscribe()` (asynchronous) and `Results.unsubscribe()` (synchronous). + * Added a `WaitForSync` enum specifying whether to wait or not wait for subscribed objects to be downloaded before resolving the promise returned from `Results.subscribe()`. + * Extended `SubscriptionOptions` to take a `WaitForSync` behavior and a maximum waiting timeout before returning from `Results.subscribe()`. + * Added the instance method `MutableSubscriptionSet.removeUnnamed()` for removing only unnamed subscriptions. + ```javascript + const peopleOver20 = await realm + .objects("Person") + .filtered("age > 20") + .subscribe({ + name: "peopleOver20", + behavior: WaitForSync.FirstTime, // Default + timeout: 2000, + }); + // ... + peopleOver20.unsubscribe(); + ``` ### Fixed * Fix a stack overflow crash when using the query parser with long chains of AND/OR conditions. ([realm/realm-core#6428](https://github.com/realm/realm-core/pull/6428), since v10.11.0) @@ -30,6 +47,7 @@ * Fixed an error where performing a query like "{1, 2, 3, ...} IN list" where the array is longer than 8 and all elements are smaller than some values in list, the program would crash. ([realm/realm-core#6545](https://github.com/realm/realm-core/pull/6545), since v10.20.0) * Performing a large number of queries without ever performing a write resulted in steadily increasing memory usage, some of which was never fully freed due to an unbounded cache. ([realm/realm-core#6530](https://github.com/realm/realm-core/pull/6530), since v10.19.0) * Partition-Based to Flexible Sync Migration for migrating a client app that uses partition based sync to use flexible sync under the hood if the server has been migrated to flexible sync is officially supported with this release. Any clients using an older version of Realm will receive a "switch to flexible sync" error message when trying to sync with the app. ([realm/realm-core#6554](https://github.com/realm/realm-core/issues/6554), since v11.9.0) +* Fix deprecated namespace method warning when building for Android ([#5646](https://github.com/realm/realm-js/issues/5646)) ### Compatibility * React Native >= v0.71.4 diff --git a/integration-tests/environments/react-native/android/app/src/main/AndroidManifest.xml b/integration-tests/environments/react-native/android/app/src/main/AndroidManifest.xml index 4122f36a59..07d6364c96 100644 --- a/integration-tests/environments/react-native/android/app/src/main/AndroidManifest.xml +++ b/integration-tests/environments/react-native/android/app/src/main/AndroidManifest.xml @@ -8,6 +8,7 @@ android:icon="@mipmap/ic_launcher" android:roundIcon="@mipmap/ic_launcher_round" android:allowBackup="false" + android:usesCleartextTraffic="true" android:theme="@style/AppTheme"> 10"); }); + + it("returns true and removes a subscription with an empty name", async function (this: RealmContext) { + addSubscription(this.realm, this.realm.objects(FlexiblePersonSchema.name).filtered("age > 10")); + const { subs } = addSubscriptionForPerson(this.realm, { name: "" }); + expect(subs).to.have.length(2); + + await subs.update((mutableSubs) => { + expect(mutableSubs.removeByName("")).to.be.true; + }); + + expect(subs).to.have.length(1); + expect([...subs][0].queryString).to.equal("age > 10"); + }); }); describe("#remove", function () { @@ -1532,6 +1547,44 @@ describe.skipIf(environment.missingServer, "Flexible sync", function () { }); }); + describe("#removeUnnamed", function () { + it("removes all unnamed subscriptions and returns the number of subscriptions removed", async function (this: RealmContext) { + // Add 1 named and 2 unnamed subscriptions. + addSubscriptionForPerson(this.realm, { name: "test" }); + addSubscription(this.realm, this.realm.objects(FlexiblePersonSchema.name).filtered("age < 5")); + await addSubscriptionAndSync( + this.realm, + this.realm.objects(FlexiblePersonSchema.name).filtered("age > 10"), + ); + expect(this.realm.subscriptions).to.have.length(3); + + let numRemoved = 0; + await this.realm.subscriptions.update((mutableSubs) => { + numRemoved = mutableSubs.removeUnnamed(); + }); + + expect(numRemoved).to.equal(2); + expect(this.realm.subscriptions).to.have.length(1); + }); + + it("does not remove subscription with empty name", async function (this: RealmContext) { + await addSubscriptionAndSync( + this.realm, + this.realm.objects(FlexiblePersonSchema.name).filtered("age > 10"), + { name: "" }, + ); + expect(this.realm.subscriptions).to.have.length(1); + + let numRemoved = 0; + await this.realm.subscriptions.update((mutableSubs) => { + numRemoved = mutableSubs.removeUnnamed(); + }); + + expect(numRemoved).to.equal(0); + expect(this.realm.subscriptions).to.have.length(1); + }); + }); + describe("#removeByObjectType", function () { it("returns 0 if no subscriptions for the object type exist", async function (this: RealmContext) { const { subs } = addSubscriptionForPerson(this.realm); @@ -1571,6 +1624,200 @@ describe.skipIf(environment.missingServer, "Flexible sync", function () { }); }); + describe("Results#subscribe", function () { + it("waits for objects to sync the first time only", async function (this: RealmContext) { + expect(this.realm.subscriptions).to.have.length(0); + + const peopleOver10 = this.realm.objects(FlexiblePersonSchema.name).filtered("age > 10"); + + // Subscribing the first time should wait for synchronization. + await peopleOver10.subscribe({ behavior: WaitForSync.FirstTime }); + expect(this.realm.subscriptions.state).to.equal(SubscriptionSetState.Complete); + + // Subscribing the second time should return without waiting. + await peopleOver10.subscribe({ behavior: WaitForSync.FirstTime }); + expect(this.realm.subscriptions.state).to.equal(SubscriptionSetState.Pending); + + expect(this.realm.subscriptions).to.have.length(1); + }); + + it("waits for objects to sync the first time only by default", async function (this: RealmContext) { + expect(this.realm.subscriptions).to.have.length(0); + + const peopleOver10 = this.realm.objects(FlexiblePersonSchema.name).filtered("age > 10"); + + await peopleOver10.subscribe(); + expect(this.realm.subscriptions.state).to.equal(SubscriptionSetState.Complete); + + await peopleOver10.subscribe(); + expect(this.realm.subscriptions.state).to.equal(SubscriptionSetState.Pending); + + expect(this.realm.subscriptions).to.have.length(1); + }); + + it("waits for objects to sync the first time only for separate 'Results' instances w/ same query", async function (this: RealmContext) { + expect(this.realm.subscriptions).to.have.length(0); + + // Subscribe to a query on 'Results' instance 1. + let peopleOver10 = this.realm.objects(FlexiblePersonSchema.name).filtered("age > 10"); + await peopleOver10.subscribe({ behavior: WaitForSync.FirstTime }); + expect(this.realm.subscriptions.state).to.equal(SubscriptionSetState.Complete); + + // Subscribe to the same query on 'Results' instance 2 (overwrite previous 'peopleOver10' value). + peopleOver10 = this.realm.objects(FlexiblePersonSchema.name).filtered("age > 10"); + await peopleOver10.subscribe({ behavior: WaitForSync.FirstTime }); + expect(this.realm.subscriptions.state).to.equal(SubscriptionSetState.Pending); + + expect(this.realm.subscriptions).to.have.length(1); + }); + + it("always waits for objects to sync", async function (this: RealmContext) { + expect(this.realm.subscriptions).to.have.length(0); + + const peopleOver10 = this.realm.objects(FlexiblePersonSchema.name).filtered("age > 10"); + + await peopleOver10.subscribe({ behavior: WaitForSync.Always }); + expect(this.realm.subscriptions.state).to.equal(SubscriptionSetState.Complete); + + await peopleOver10.subscribe({ behavior: WaitForSync.Always }); + expect(this.realm.subscriptions.state).to.equal(SubscriptionSetState.Complete); + + expect(this.realm.subscriptions).to.have.length(1); + }); + + it("never waits for objects to sync", async function (this: RealmContext) { + expect(this.realm.subscriptions).to.have.length(0); + + const peopleOver10 = this.realm.objects(FlexiblePersonSchema.name).filtered("age > 10"); + + await peopleOver10.subscribe({ behavior: WaitForSync.Never }); + expect(this.realm.subscriptions.state).to.equal(SubscriptionSetState.Pending); + + await peopleOver10.subscribe({ behavior: WaitForSync.Never }); + expect(this.realm.subscriptions.state).to.equal(SubscriptionSetState.Pending); + + expect(this.realm.subscriptions).to.have.length(1); + }); + + it("waits for objects to sync when timeout is longer", async function (this: RealmContext) { + expect(this.realm.subscriptions).to.have.length(0); + + const peopleOver10 = this.realm.objects(FlexiblePersonSchema.name).filtered("age > 10"); + // `this.timeout()` is used so that it doesn't exceed the timeout that our tests + // are configured to use (which could vary). + await peopleOver10.subscribe({ behavior: WaitForSync.Always, timeout: this.timeout() }); + expect(this.realm.subscriptions.state).to.equal(SubscriptionSetState.Complete); + + expect(this.realm.subscriptions).to.have.length(1); + }); + + it("does not wait for objects to sync when timeout is shorter", async function (this: RealmContext) { + expect(this.realm.subscriptions).to.have.length(0); + + const peopleOver10 = this.realm.objects(FlexiblePersonSchema.name).filtered("age > 10"); + await peopleOver10.subscribe({ behavior: WaitForSync.Always, timeout: 0 }); + expect(this.realm.subscriptions.state).to.equal(SubscriptionSetState.Pending); + + expect(this.realm.subscriptions).to.have.length(1); + }); + + it("returns the same 'Results' instance", async function (this: RealmContext) { + const beforeSubscribe = this.realm.objects(FlexiblePersonSchema.name).filtered("age > 10"); + const afterSubscribe = await beforeSubscribe.subscribe(); + expect(beforeSubscribe).to.equal(afterSubscribe); + }); + }); + + describe("Results#unsubscribe", function () { + it("unsubscribes from existing subscription", async function (this: RealmContext) { + const peopleOver10 = await this.realm.objects(FlexiblePersonSchema.name).filtered("age > 10").subscribe(); + expect(this.realm.subscriptions).to.have.length(1); + + peopleOver10.unsubscribe(); + expect(this.realm.subscriptions).to.have.length(0); + }); + + it("does not throw or unsubscribe when there is no matching subscription", function (this: RealmContext) { + const peopleUnder10 = this.realm.objects(FlexiblePersonSchema.name).filtered("age < 10").subscribe(); + const peopleOver10 = this.realm.objects(FlexiblePersonSchema.name).filtered("age > 10"); + expect(this.realm.subscriptions).to.have.length(1); + + // Unsubscribe to the Results without a subscription. + peopleOver10.unsubscribe(); + expect(this.realm.subscriptions).to.have.length(1); + }); + + it("does not unsubscribe multiple times", async function (this: RealmContext) { + await this.realm.objects(FlexiblePersonSchema.name).filtered("age < 10").subscribe(); + const peopleOver10 = await this.realm.objects(FlexiblePersonSchema.name).filtered("age > 10").subscribe(); + expect(this.realm.subscriptions).to.have.length(2); + + peopleOver10.unsubscribe(); + peopleOver10.unsubscribe(); + expect(this.realm.subscriptions).to.have.length(1); + }); + + it("unsubscribes from subscription with matching name", async function (this: RealmContext) { + // Create 3 subscriptions with the same query: 2 named, 1 unnamed. + const queryString = "age > 10"; + await this.realm.objects(FlexiblePersonSchema.name).filtered(queryString).subscribe({ name: "name1" }); + await this.realm.objects(FlexiblePersonSchema.name).filtered(queryString).subscribe(); + const peopleOver10 = await this.realm + .objects(FlexiblePersonSchema.name) + .filtered(queryString) + .subscribe({ name: "name2" }); + expect(this.realm.subscriptions).to.have.length(3); + + // Expect only the "name2" subscription to be gone. + peopleOver10.unsubscribe(); + + const subs = [...this.realm.subscriptions]; + expect(subs).to.have.length(2); + expect(subs[0].queryString).to.equal(queryString); + expect(subs[0].name).to.equal("name1"); + expect(subs[1].queryString).to.equal(queryString); + expect(subs[1].name).to.be.null; + }); + + it("unsubscribes from subscription with matching name when subscribing via `update()`", async function (this: RealmContext) { + // Save a reference to a Results that is not yet subscribed to. + const queryString = "age > 10"; + const peopleOver10 = await this.realm.objects(FlexiblePersonSchema.name).filtered(queryString); + expect(this.realm.subscriptions).to.have.length(0); + + // Create 3 subscriptions via `update()` with the same query: 2 named, 1 unnamed. + await this.realm.subscriptions.update((mutableSubs) => { + mutableSubs.add(this.realm.objects(FlexiblePersonSchema.name).filtered(queryString), { name: "name1" }); + mutableSubs.add(this.realm.objects(FlexiblePersonSchema.name).filtered(queryString)); + // Pass the Results reference to subscribe to. + mutableSubs.add(peopleOver10, { name: "name2" }); + }); + expect(this.realm.subscriptions).to.have.length(3); + + // Expect only the "name2" subscription to be gone. + peopleOver10.unsubscribe(); + + const subs = [...this.realm.subscriptions]; + expect(subs).to.have.length(2); + expect(subs[0].queryString).to.equal(queryString); + expect(subs[0].name).to.equal("name1"); + expect(subs[1].queryString).to.equal(queryString); + expect(subs[1].name).to.be.null; + }); + + it("unsubscribes from subscription with matching query", async function (this: RealmContext) { + const queryString = "age > 10"; + const results1 = await this.realm.objects(FlexiblePersonSchema.name).filtered(queryString).subscribe(); + const results2 = await this.realm.objects(FlexiblePersonSchema.name).filtered(queryString); + expect(this.realm.subscriptions).to.have.length(1); + + // Even though `subscribe()` was called on `results1`, `unsubscribe()` removes unnamed + // subscriptions by query, thus removing the one `results1` subscribed to. + results2.unsubscribe(); + expect(this.realm.subscriptions).to.have.length(0); + }); + }); + // TODO Right now there is no is_valid method we can use to verify if the subs // are in a valid state... maybe need a different solution as this will crash xdescribe("when realm is closed", function () { diff --git a/packages/realm/react-native/android/build.gradle b/packages/realm/react-native/android/build.gradle index 5bd196b38e..1cf1291f0b 100644 --- a/packages/realm/react-native/android/build.gradle +++ b/packages/realm/react-native/android/build.gradle @@ -22,6 +22,7 @@ allprojects { apply plugin: 'com.android.library' android { + namespace 'io.realm.react' compileSdkVersion rootProject.hasProperty('compileSdkVersion') ? rootProject.compileSdkVersion : 28 buildToolsVersion rootProject.hasProperty('buildToolsVersion') ? rootProject.buildToolsVersion : '28.0.3' diff --git a/packages/realm/react-native/android/src/main/AndroidManifest.xml b/packages/realm/react-native/android/src/main/AndroidManifest.xml index dbd8486dd6..0fd6dadeb8 100644 --- a/packages/realm/react-native/android/src/main/AndroidManifest.xml +++ b/packages/realm/react-native/android/src/main/AndroidManifest.xml @@ -1,3 +1,3 @@ - + diff --git a/packages/realm/src/ProgressRealmPromise.ts b/packages/realm/src/ProgressRealmPromise.ts index 7196f4c08e..4b7501b744 100644 --- a/packages/realm/src/ProgressRealmPromise.ts +++ b/packages/realm/src/ProgressRealmPromise.ts @@ -171,8 +171,10 @@ export class ProgressRealmPromise implements Promise { if (typeof timeOut === "number") { this.timeoutPromise = new TimeoutPromise( this.handle.promise, // Ensures the timeout gets cancelled when the realm opens - timeOut, - `Realm could not be downloaded in the allocated time: ${timeOut} ms.`, + { + ms: timeOut, + message: `Realm could not be downloaded in the allocated time: ${timeOut} ms.`, + }, ); if (timeOutBehavior === OpenRealmTimeOutBehavior.ThrowException) { // Make failing the timeout, reject the promise diff --git a/packages/realm/src/Results.ts b/packages/realm/src/Results.ts index c8c14af0b7..304a9b63c4 100644 --- a/packages/realm/src/Results.ts +++ b/packages/realm/src/Results.ts @@ -22,6 +22,9 @@ import { OrderedCollectionHelpers, Realm, RealmInsertionModel, + SubscriptionOptions, + TimeoutPromise, + WaitForSync, assert, binding, } from "./internal"; @@ -40,6 +43,8 @@ export class Results extends OrderedCollection { * @internal */ public declare internal: binding.Results; + /** @internal */ + public subscriptionName?: string; /** * Create a `Results` wrapping a set of query `Results` from the binding. @@ -65,6 +70,11 @@ export class Results extends OrderedCollection { writable: false, value: realm, }); + Object.defineProperty(this, "subscriptionName", { + enumerable: false, + configurable: false, + writable: true, + }); } get length(): number { @@ -104,6 +114,53 @@ export class Results extends OrderedCollection { } } + /** + * Add this query result to the set of active subscriptions. The query will be joined + * via an `OR` operator with any existing queries for the same type. + * + * @param options Options to use when adding this subscription (e.g. a name or wait behavior). + * @returns A promise that resolves to this {@link Results} instance. + * @experimental This API is experimental and may change or be removed. + */ + async subscribe(options: SubscriptionOptions = { behavior: WaitForSync.FirstTime }): Promise { + const subs = this.realm.subscriptions; + const shouldWait = + options.behavior === WaitForSync.Always || (options.behavior === WaitForSync.FirstTime && !subs.exists(this)); + if (shouldWait) { + if (typeof options.timeout === "number") { + await new TimeoutPromise( + subs.update((mutableSubs) => mutableSubs.add(this, options)), + { ms: options.timeout, rejectOnTimeout: false }, + ); + } else { + await subs.update((mutableSubs) => mutableSubs.add(this, options)); + } + } else { + subs.updateNoWait((mutableSubs) => mutableSubs.add(this, options)); + } + + return this; + } + + /** + * Unsubscribe from this query result. It returns immediately without waiting + * for synchronization. + * + * If the subscription is unnamed, the subscription matching the query will + * be removed. + * + * @experimental This API is experimental and may change or be removed. + */ + unsubscribe(): void { + this.realm.subscriptions.updateNoWait((mutableSubs) => { + if (this.subscriptionName) { + mutableSubs.removeByName(this.subscriptionName); + } else { + mutableSubs.remove(this); + } + }); + } + isValid(): boolean { return this.internal.isValid; } diff --git a/packages/realm/src/TimeoutPromise.ts b/packages/realm/src/TimeoutPromise.ts index 56a01c6d61..f576f3d2d1 100644 --- a/packages/realm/src/TimeoutPromise.ts +++ b/packages/realm/src/TimeoutPromise.ts @@ -20,19 +20,26 @@ import { TimeoutError } from "./errors"; import { PromiseHandle } from "./PromiseHandle"; export type TimeoutPromiseOptions = { - ms: number; + ms?: number; message?: string; + rejectOnTimeout?: boolean; }; -export class TimeoutPromise implements Promise { +export class TimeoutPromise implements Promise { private timer: Timer | undefined; - private handle = new PromiseHandle(); + private handle = new PromiseHandle(); - constructor(inner: Promise, ms?: number, message = `Waited ${ms}ms`) { + constructor( + inner: Promise, + { ms, message = `Waited ${ms}ms`, rejectOnTimeout = true }: TimeoutPromiseOptions = {}, + ) { if (typeof ms === "number") { this.timer = setTimeout(() => { - const err = new TimeoutError(message); - this.handle.reject(err); + if (rejectOnTimeout) { + this.handle.reject(new TimeoutError(message)); + } else { + this.handle.resolve(); + } }, ms); } inner.then(this.handle.resolve, this.handle.reject).finally(() => { diff --git a/packages/realm/src/app-services/BaseSubscriptionSet.ts b/packages/realm/src/app-services/BaseSubscriptionSet.ts index a8137d27cb..9d344805fe 100644 --- a/packages/realm/src/app-services/BaseSubscriptionSet.ts +++ b/packages/realm/src/app-services/BaseSubscriptionSet.ts @@ -196,6 +196,14 @@ export abstract class BaseSubscriptionSet { return subscription ? (new Subscription(subscription) as Subscription) : null; // TODO: Remove the type assertion into Subscription } + /** @internal */ + exists(query: Results): boolean { + if (query.subscriptionName === undefined) { + return !!this.internal.findByQuery(query.internal.query); + } + return !!this.internal.findByName(query.subscriptionName); + } + /** * Makes the subscription set iterable. * diff --git a/packages/realm/src/app-services/MutableSubscriptionSet.ts b/packages/realm/src/app-services/MutableSubscriptionSet.ts index db0daf16fc..c3c8665bf4 100644 --- a/packages/realm/src/app-services/MutableSubscriptionSet.ts +++ b/packages/realm/src/app-services/MutableSubscriptionSet.ts @@ -18,16 +18,36 @@ import { BaseSubscriptionSet, Realm, Results, Subscription, SubscriptionSet, assert, binding } from "../internal"; +/** + * Behavior when waiting for subscribed objects to be synchronized/downloaded. + */ +export enum WaitForSync { + /** + * Waits until the objects have been downloaded from the server + * the first time the subscription is created. If the subscription + * already exists, the `Results` is returned immediately. + */ + FirstTime = "first-time", + /** + * Always waits until the objects have been downloaded from the server. + */ + Always = "always", + /** + * Never waits for the download to complete, but keeps downloading the + * objects in the background. + */ + Never = "never", +} + /** * Options for {@link MutableSubscriptionSet.add}. */ -export interface SubscriptionOptions { +export type SubscriptionOptions = { /** * Sets the name of the subscription being added. This allows you to later refer * to the subscription by name, e.g. when calling {@link MutableSubscriptionSet.removeByName}. */ name?: string; - /** * By default, adding a subscription with the same name as an existing one * but a different query will update the existing subscription with the new @@ -36,7 +56,17 @@ export interface SubscriptionOptions { * Adding a subscription with the same name and query is always a no-op. */ throwOnUpdate?: boolean; -} + /** + * Specifies how to wait or not wait for subscribed objects to be downloaded. + */ + behavior?: WaitForSync; + /** + * The maximum time (in milliseconds) to wait for objects to be downloaded. + * If the time exceeds this limit, the `Results` is returned and the download + * continues in the background. + */ + timeout?: number; +}; /** * The mutable version of a given SubscriptionSet. The {@link MutableSubscriptionSet} @@ -71,14 +101,14 @@ export class MutableSubscriptionSet extends BaseSubscriptionSet { add(query: Results, options?: SubscriptionOptions): Subscription { assert.instanceOf(query, Results, "query"); if (options) { - assertIsSubscriptionOptions(options); + validateSubscriptionOptions(options); } const subscriptions = this.internal; const results = query.internal; const queryInternal = results.query; - if (options?.throwOnUpdate && options.name) { + if (options?.throwOnUpdate && options.name !== undefined) { const existingSubscription = subscriptions.findByName(options.name); if (existingSubscription) { const isSameQuery = @@ -91,9 +121,13 @@ export class MutableSubscriptionSet extends BaseSubscriptionSet { } } - const [subscription] = options?.name - ? subscriptions.insertOrAssignByName(options.name, queryInternal) - : subscriptions.insertOrAssignByQuery(queryInternal); + const [subscription] = + // Check for `undefined` rather than falsy since we treat empty names as named. + options?.name === undefined + ? subscriptions.insertOrAssignByQuery(queryInternal) + : subscriptions.insertOrAssignByName(options.name, queryInternal); + + query.subscriptionName = subscription.name; return new Subscription(subscription); } @@ -141,17 +175,43 @@ export class MutableSubscriptionSet extends BaseSubscriptionSet { * @returns The number of subscriptions removed. */ removeByObjectType(objectType: string): number { + assert.string(objectType, "objectType"); + + return this.removeByPredicate((subscription) => subscription.objectClassName === objectType); + } + + /** + * Remove all subscriptions from the SubscriptionSet. + * + * @returns The number of subscriptions removed. + */ + removeAll(): number { + const numRemoved = this.internal.size; + this.internal.clear(); + + return numRemoved; + } + + /** + * Remove all unnamed/anonymous subscriptions from the SubscriptionSet. + * + * @returns The number of subscriptions removed. + */ + removeUnnamed(): number { + return this.removeByPredicate((subscription) => subscription.name === undefined); + } + + /** @internal */ + private removeByPredicate(predicate: (subscription: binding.SyncSubscription) => boolean): number { // TODO: This is currently O(n^2) because each erase call is O(n). Once Core has // fixed https://github.com/realm/realm-core/issues/6241, we can update this. - assert.string(objectType, "objectType"); - // Removing the subscription (calling `eraseSubscription()`) invalidates all current // iterators, so it would be illegal to continue iterating. Instead, we push it to an // array to remove later. const subscriptionsToRemove: binding.SyncSubscription[] = []; for (const subscription of this.internal) { - if (subscription.objectClassName === objectType) { + if (predicate(subscription)) { subscriptionsToRemove.push(subscription); } } @@ -165,21 +225,9 @@ export class MutableSubscriptionSet extends BaseSubscriptionSet { return numRemoved; } - - /** - * Remove all subscriptions from the SubscriptionSet. - * - * @returns The number of subscriptions removed. - */ - removeAll(): number { - const numSubscriptions = this.internal.size; - this.internal.clear(); - - return numSubscriptions; - } } -function assertIsSubscriptionOptions(input: unknown): asserts input is SubscriptionOptions { +function validateSubscriptionOptions(input: unknown): asserts input is SubscriptionOptions { assert.object(input, "options", { allowArrays: false }); if (input.name !== undefined) { assert.string(input.name, "'name' on 'SubscriptionOptions'"); diff --git a/packages/realm/src/app-services/SyncSession.ts b/packages/realm/src/app-services/SyncSession.ts index ce32d0fe36..0121bca195 100644 --- a/packages/realm/src/app-services/SyncSession.ts +++ b/packages/realm/src/app-services/SyncSession.ts @@ -364,22 +364,20 @@ export class SyncSession { downloadAllServerChanges(timeoutMs?: number): Promise { return this.withInternal( (internal) => - new TimeoutPromise( - internal.waitForDownloadCompletion(), - timeoutMs, - `Downloading changes did not complete in ${timeoutMs} ms.`, - ), + new TimeoutPromise(internal.waitForDownloadCompletion(), { + ms: timeoutMs, + message: `Downloading changes did not complete in ${timeoutMs} ms.`, + }), ); } uploadAllLocalChanges(timeoutMs?: number): Promise { return this.withInternal( (internal) => - new TimeoutPromise( - internal.waitForUploadCompletion(), - timeoutMs, - `Uploading changes did not complete in ${timeoutMs} ms.`, - ), + new TimeoutPromise(internal.waitForUploadCompletion(), { + ms: timeoutMs, + message: `Uploading changes did not complete in ${timeoutMs} ms.`, + }), ); } diff --git a/packages/realm/src/app-services/User.ts b/packages/realm/src/app-services/User.ts index 4d8c352127..343e2b2541 100644 --- a/packages/realm/src/app-services/User.ts +++ b/packages/realm/src/app-services/User.ts @@ -258,12 +258,13 @@ export class User< * Call a remote Atlas App Services Function by its name. * Note: Consider using `functions[name]()` instead of calling this method. * - * @param name Name of the function. - * @param args Arguments passed to the function. + * @param name Name of the App Services Function. + * @param args Arguments passed to the Function. + * @returns A promise that resolves to the value returned by the Function. * * @example * // These are all equivalent: - * await user.callFunction("doThing", [a1, a2, a3]); + * await user.callFunction("doThing", a1, a2, a3); * await user.functions.doThing(a1, a2, a3); * await user.functions["doThing"](a1, a2, a3); * @example diff --git a/packages/realm/src/index.ts b/packages/realm/src/index.ts index a45f93bda1..0c7bfcdddd 100644 --- a/packages/realm/src/index.ts +++ b/packages/realm/src/index.ts @@ -167,6 +167,7 @@ export { UserChangeCallback, UserState, UserTypeName, + WaitForSync, } from "./internal"; import { Realm, RealmObjectConstructor } from "./internal";